Post

LLM(Gemini)의 코딩 스타일 한계와 리팩터링: '동작하는 모놀리스'에서 '휴먼 리더블한 파이프라인'으로

LLM(Gemini)의 코딩 스타일 한계와 리팩터링: '동작하는 모놀리스'에서 '휴먼 리더블한 파이프라인'으로

최근 LLM(대형 언어 모델)의 코딩 능력은 복잡한 금융 트레이딩 시스템의 수학적 공식이나 비동기 락, 상태 머신을 한 번에 구현해 낼 정도로 발전했다. 프롬프트에 자본 안전 규칙, 정밀도 제약조건, API 스펙을 던져주면 놀랍도록 빠른 속도로 “요구사항을 만족하여 컴파일되고 테스트를 통과하는 코드”를 토해낸다.

하지만 실제 운영 코드베이스에서 AI와 페어 프로그래밍을 진행하다 보면 필연적으로 마주하는 거대한 벽이 있다. 바로 “휴먼 리더블(Human-readable)한 코드, 유지보수하기 쉬운 구조, 직관적으로 이해되는 설계”에 대한 심각한 결여다.

AI는 주어진 수많은 제약조건을 어떻게든 만족시키기 위해 “단 하나의 거대한 클래스 안에 모든 책임을 다 우겨넣는 모놀리식 God Class”를 만들어내는 강력한 본능적 경향을 보인다. 기계 입장에서는 한 파일 안에서 모든 변수를 참조하고 분기치는 것이 가장 효율적일지 몰라도, 사람이 읽고 운영하기에는 끔찍한 인지 부하와 결합도를 유발한다.

최근 금융 트레이딩 시스템에 신규 숏 청산 후 재진입(Liquidation Reentry) 전략을 구현하면서, AI(Gemini)가 처음에 작성했던 복잡하고 위험한 코드와 이를 인간 엔지니어의 날카로운 지적을 통해 정석적인 레이어드 아키텍처로 깎아낸 리팩터링 과정을 기록으로 정리한다.


1. Before: Gemini가 처음에 짰던 코드의 전형적인 안티패턴들

처음 AI가 작성한 LiquidationReentryService는 파일 하나가 무려 350라인이 넘는 전형적인 God Class였다. 이 코드에는 LLM 코딩에서 흔히 나타나는 5가지 핵심 설계 악취(Code Smell)가 고스란히 담겨 있었다.

1) 두 개의 이질적인 자본 실행을 하나로 뭉갠 executeNextJob

전략에는 두 번의 주문이 존재한다:

  1. 청산 직후 남은 잔여 숏을 청산가에 닫는 주문 (DETECTED -> CLOSE_SUBMITTING)
  2. 고점 반등 후 꺾임이 확인되었을 때 원래 수량으로 신규 숏을 재진입하는 주문 (ENTRY_READY -> ENTRY_SUBMITTING)

이 둘은 시점도, 목적도, 다루는 상태도 완전히 다른 독립적인 실행이다. 하지만 AI는 이를 하나의 private 메서드 안에 몰아넣었다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 🔴 Before: 두 가지 자본 실행을 하나에 몰아넣고 firstOrNull로 처리
private suspend fun executeNextJob(state: LiquidationReentryState, notifications: MutableList<String>) {
    val job = state.jobs.firstOrNull {
        it.phase == LiquidationReentryPhase.DETECTED || it.phase == LiquidationReentryPhase.ENTRY_READY
    } ?: return
    if (!tryAcquireTradingLock("LiquidationReentry")) return
    try {
        if (job.phase == LiquidationReentryPhase.DETECTED) {
            // 잔여 숏 닫기 발주 처리...
        } else if (job.phase == LiquidationReentryPhase.ENTRY_READY) {
            // 신규 숏 재진입 발주 처리...
        }
    } finally {
        releaseTradingLock()
    }
}

이 구조는 치명적인 크로스코인 블로킹(Cross-coin Blocking)을 유발했다. 만약 A 코인이 청산되어 DETECTED 상태로 들어오면, 이미 시그널을 충족하고 대기 중이던 B 코인의 ENTRY_READY는 실행 순서가 뒤로 밀려 10초 뒤 다음 사이클까지 발주가 지연되는 문제가 발생했다.

2) 가변 리스트 짐꾼 파라미터 (notifications: MutableList<String>)

AI는 “자본 실행 락(tradingLock)을 쥐고 있는 동안에는 외부 통신(텔레그램 발송)을 피해야 한다”는 제약조건을 문자 그대로만 해석했다.

그 결과, cycle() 메서드 시작 부분에서 val notifications = mutableListOf<String>()를 생성한 뒤, 서비스 내부의 거의 모든 private 메서드(registerLiquidations, advanceObservedOrders, executeNextJob, executeEntry, submitOnce, saveState…)에 이 가변 리스트를 파라미터로 줄줄이 짐꾼처럼 끌고 다녔다.

1
2
3
4
// 🔴 Before: 모든 컴포넌트 시그니처가 가변 리스트에 오염됨
fun ingest(state: LiquidationReentryState, snapshot: ..., notifications: MutableList<String>): LiquidationReentryState
fun execute(state: LiquidationReentryState, notifications: MutableList<String>): LiquidationReentryState
fun track(state: LiquidationReentryState, obs: ..., notifications: MutableList<String>): LiquidationReentryState

실제 인프라의 알림 포트는 네트워크 I/O를 직접 때리는 블로킹 호출이 아니라 가상 스레드 기반 인메모리 큐(LinkedBlockingQueue.offer)로 동작하는 non-blocking 호출이었음에도, AI는 이를 맹목적으로 모아서 finally에서 날리겠다고 모든 도메인/비즈니스 메서드의 시그니처를 오염시켰다.

3) 저수준 저장소 라이프사이클의 오케스트레이터 침투

비즈니스 흐름을 조율해야 할 오케스트레이터 메서드 최상단에 저수준 DB 조회 및 null 체크, 초기 베이스라인 생성 로직이 날것 그대로 노출되어 있었다:

1
2
3
4
5
6
7
8
// 🔴 Before: cycle() 초반에 저수준 DB 분기가 그대로 노출
var state = repository.findByIdOrNull("BINANCE_FUTURE")
if (state == null) {
    if (snapshot != null) {
        repository.save(LiquidationReentryState(seenEventKeys = snapshot.events.mapTo(linkedSetOf()) { it.eventKey }))
    }
    return
}

4) 테스트 편의를 위한 생성자 디폴트 파라미터 냄새

단위 테스트 픽스처에서 한 줄로 LiquidationReentryService(props, source, repo, exchange)를 쉽게 인스턴스화하겠다는 안이한 생각으로, @Service 스프링 컴포넌트 생성자에 디폴트 파라미터(= ClassName(...), = null)를 덕지덕지 달아놓았다:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 🔴 Before: 테스트 편의를 위해 스프링 컴포넌트에 직접 new 기본값을 바인딩
@Service
class LiquidationReentryService(
    private val properties: LiquidationReentryProperties,
    private val events: FutureLiquidationEventSnapshotSource,
    private val repository: LiquidationReentryStateRepository,
    private val exchange: LiquidationReentryExchange,
    private val clock: Clock = Clock.systemUTC(),
    private val notificationPort: StrategyNotificationPort = DefaultStrategyNotificationPort,
    private val stateWriter: LiquidationReentryStateWriter = LiquidationReentryStateWriter(repository, notificationPort),
    private val eventIngester: LiquidationEventIngester = LiquidationEventIngester(stateWriter),
    ...
)

이로 인해 서비스는 자기가 직접 사용하지도 않는 repository, clock, notificationPort를 오직 “디폴트 파라미터에서 하위 객체를 new 하기 위해” 생성자 파라미터로 떠안는 의존성 누수가 발생했다.

5) 레이어링 위반 중복 레이턴시 측정

Runner 레이어의 스케줄러(scheduleMeasured)가 이미 나노초 단위로 전체 실행 시간을 정밀하게 측정하여 메트릭 버킷(p50, p95, p99, max)에 기록하고 있었음에도, Application 서비스의 cycle() 내부에서 또다시 System.nanoTime()을 재고 try/finally로 감싸 slow-cycle WARN 로그를 찍고 있었다.


2. After: 인간 엔지니어의 피드백을 통해 정돈된 최종 아키텍처

인간 엔지니어(형님)의 집요하고 날카로운 질문들을 통해 위의 모든 군더더기가 하나씩 깎여나갔다:

  • “왜 notifications를 각자 바로 fire 안 하고 굳이 리스트에 담아서 마지막에 finally에서 날려? 걷어내.”
  • “초반에 repository에서 state 가져와서 초기화하는 부분 더 간결하게 못 해? StateWriter로 넘겨.”
  • “왜 생성자에서 디폴트 파라미터로 객체를 만들어? 디폴트 명시 안 해도 스프링이 DI 해주잖아?”
  • “cycle 메서드에 millis 측정하는 거 runner 레이어의 scheduleMeasured랑 중복 아니야? 왜 있는 거야?”

이 피드백들을 거쳐 최종적으로 완성된 cycle() 메서드는 다음과 같다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 🟢 After: 군더더기가 완전히 제거된 순수한 6단계 상태 전이 파이프라인
@Service
class LiquidationReentryService(
    private val properties: LiquidationReentryProperties,
    private val events: FutureLiquidationEventSnapshotSource,
    private val exchange: LiquidationReentryExchange,
    private val stateWriter: LiquidationReentryStateWriter,
    private val eventIngester: LiquidationEventIngester,
    private val residualCloseExecutor: ResidualShortCloseExecutor,
    private val residualCloseTracker: ResidualCloseOrderTracker,
    private val signalEvaluator: ReentrySignalEvaluator,
    private val reentryExecutor: ReentryOrderExecutor,
    private val reentryTracker: ReentryOrderTracker,
) {
    suspend fun cycle(now: Instant = Instant.now()) {
        if (!properties.enabled) return

        val snapshot = events
            .currentLiquidationEventSnapshot()
            ?.takeUnless { it.observedAt.isBefore(now.minusSeconds(30)) }
        var state = stateWriter.loadOrInitialize(snapshot) ?: return
        val observations = exchange.currentOrders()

        // [1단계: 잔여 숏 정리 파이프라인]
        if (snapshot != null) state = eventIngester.ingest(state, snapshot, now)
        state = residualCloseExecutor.execute(state)
        state = residualCloseTracker.track(state, observations, now)

        // [2단계: 신규 숏 재진입 파이프라인]
        state = signalEvaluator.evaluate(state, now)
        state = reentryExecutor.execute(state)
        reentryTracker.track(state, observations, now)
    }
}

무엇이 달라졌는가?

  1. 완벽한 단방향 6단계 파이프라인:
    • 감지 -> 닫기 발주 -> 닫기 체결 (1단계: 잔여 숏 정리)
    • 시그널 평가 -> 재진입 발주 -> 재진입 체결 (2단계: 신규 숏 재진입)
    • 6개의 독립된 전담 컴포넌트가 각자의 책임을 수행하며, 이전 상태를 받아 다음 상태를 반환하는 불변 데이터 파이프라인이 완성되었다.
  2. notifications 가변 리스트 완전 소멸:
    • 모든 비즈니스 컴포넌트 시그니처에서 notifications 파라미터가 사라졌다. 알림은 상태 전이가 DB에 기록되는 시점에 StateWriter가 직접 디스패치한다.
  3. 저수준 저장소 책임 격리:
    • stateWriter.loadOrInitialize(snapshot) ?: return 한 줄로 상태 조회와 베이스라인 생성이 깔끔하게 은닉되었다.
  4. 순수한 스프링 생성자 주입 (Constructor Injection):
    • 생성자 디폴트 파라미터와 불필요한 의존성(repository, clock, notificationPort)을 완전히 버리고, 스프링 표준 DI에 온전히 맡겼다. 테스트용 객체 조립 책임은 테스트 픽스처 내부로 격리했다.
  5. 레이어링 원칙 준수:
    • 실행 시간 측정과 텔레메트리는 런타임 스케줄러(Runner)의 scheduleMeasured가 전담하고, Application 서비스는 순수한 비즈니스 유스케이스 조율에만 집중한다.

3. 구조 비교 요약

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Before: 모놀리식 God Class]
LiquidationReentryService (350+ lines)
 ├── 72h 캔들 페치 & 주도봉 판정
 ├── 잔여 숏 닫기 발주 + 재진입 발주 (executeNextJob 짬뽕)
 ├── 주문 관측 체결 판정 (닫기 체결 + 재진입 체결 짬뽕)
 ├── DB 직접 조회 및 저장 (repository.findByIdOrNull)
 ├── 알림 짐꾼 파라미터 관리 (notifications: MutableList)
 └── 실행 시간 측정 로깅 (System.nanoTime)

                    ▼ 리팩터링 후

[After: 레이어드 아키텍처 & 단방향 파이프라인]
LiquidationReentryService (85 lines - Pure Orchestrator)
 │
 ├── [1. Ingest]   LiquidationEventIngester     : NONE -> DETECTED
 ├── [2. Close]    ResidualShortCloseExecutor   : DETECTED -> CLOSE_SUBMITTING
 ├── [3. Track]    ResidualCloseOrderTracker    : CLOSE_SUBMITTING -> WAITING_SIGNAL
 │
 ├── [4. Signal]   ReentrySignalEvaluator       : WAITING_SIGNAL -> ENTRY_READY
 ├── [5. Reentry]  ReentryOrderExecutor         : ENTRY_READY -> ENTRY_SUBMITTING
 ├── [6. Track]    ReentryOrderTracker          : ENTRY_SUBMITTING -> COMPLETED
 │
 └── [State]       LiquidationReentryStateWriter: DB 영속화, 전이 로깅, 알림 즉시 발송

4. 보완책: 어떻게 하면 LLM이 처음부터 마음에 드는 구조로 코드를 짜오게 만들까?

이번 경험을 통해 “그렇다면 다음에 비슷한 전략이나 복잡한 기능을 AI에게 시킬 때, 처음부터 이 최종 버전 같은 스타일로 짜오게 만들려면 어떻게 해야 할까?”라는 고민이 남는다. 몇 가지 접근법을 검토해 보면 현실적인 답이 나온다.

1) “구현 전에 아예 완벽하게 설계해서 지시하기” $\rightarrow$ 비현실적

  • 이상적으로는 인간 개발자가 사전에 컴포넌트 구조와 인터페이스를 완벽하게 설계해서 AI에게 던져주면 된다.
  • 하지만 현실에서는 “일단 코드를 짜서 중간 산출물을 눈으로 봐야 그 다음에 비로소 보이는 것들”이 너무나 많다.
  • 도메인의 디테일, 엣지 케이스, 거래소 API의 제약, 동시성 이슈 등은 실제로 코드를 굴려보면서 구체화된다. 처음부터 모든 클래스 분할과 의존성을 완벽하게 머릿속에 그리고 지시를 넣는 것은 개발자에게 너무 큰 피로를 준다.

2) “장문의 상세 체크리스트 프롬프트 제공” $\rightarrow$ 관리 불가 & 구멍 발생

  • “클래스는 100줄 이하로”, “가변 리스트 넘기지 말 것”, “생성자에 디폴트 금지” 같은 체크리스트를 수십 개 나열하는 방식이다.
  • 하지만 체크리스트가 길어질수록 LLM의 주의력 희석(Attention Dilution)으로 인해 규칙을 누락하기 쉽고, 체크리스트에 미처 정의하지 못한 새로운 설계 악취(Hole)는 언제나 발생한다.

3) [가장 실용적인 팁] 코드베이스 내 “골든 레퍼런스(Golden Reference)” 지정하기

  • 가장 비용이 적게 들면서도 효과가 압도적인 방법은 “이미 잘 깎아둔 기존 코드를 레퍼런스로 찍어주는 것”이다.
  • LLM은 추상적인 객체지향 원칙(“SRP를 지키고 결합도를 낮춰라”)은 엉망으로 해석하지만, “구체적인 기존 코드를 보고 그 구조와 패턴을 그대로 흉내 내라(Mimic)”는 지시는 기가 막히게 잘 따른다.

실제 프롬프트에서 다음과 같이 지시하는 것이다:

“새로운 [OO] 전략을 구현해 줘.
단, 비즈니스 파이프라인의 구조와 클래스 분리 스타일은 우리 코드베이스의 dev.umbum.application.liquidationreentry 패키지를 골든 레퍼런스로 참고해서 짜와.
1) 상태 저장과 알림을 전담하는 StateWriter,
2) 단계별 전담 컴포넌트(Ingester, Executor, Tracker, Evaluator),
3) 오직 파이프라인만 지휘하는 100줄 이하의 순수 오케스트레이터 서비스 구조를 그대로 따라서 분리해.”

이렇게 하면 개발자가 사전에 완벽한 설계를 다 적어줄 필요도 없고, 수십 개의 체크리스트를 관리할 필요도 없다. AI는 코드베이스에 이미 존재하는 검증된 클래스 구조, 네이밍 규칙, 책임 분리 방식을 그대로 복제하여 새 기능의 뼈대를 세우게 된다.


5. 결론: AI 코딩 시대에 인간 엔지니어의 역할

LLM(Gemini)은 똑똑하다. 주어진 복잡한 제약조건을 우회하지 않고 어떻게든 통과하는 알고리즘과 테스트 코드를 순식간에 작성한다.

하지만 AI에게 온전히 설계를 맡겨두면 필연적으로 “기계에게만 편하고 인간은 읽을 수 없는 거대한 모놀리스”가 탄생한다. AI는 다음과 같은 엔지니어링의 본질을 스스로 먼저 고민하지 못한다:

  • “이 클래스가 변경되어야 하는 이유가 몇 개인가? (단일 책임 원칙)”
  • “이 가변 리스트 파라미터가 왜 6개 메서드를 줄줄이 관통하고 있는가? (부수효과의 적절한 위치)”
  • “이 코드가 Presentation, Application, Domain, Persistence 중 어느 레이어의 관심사인가? (관심사의 분리)”
  • “나중에 다른 동료(또는 미래의 나)가 이 코드를 읽었을 때 파이프라인 흐름이 직관적으로 보이는가? (휴먼 리더빌리티)”

AI와의 페어 프로그래밍에서 최고의 생산성과 코드 품질을 얻는 길은 명확하다. 복잡한 수치 계산, 스펙 구현, 보일러플레이트 작성은 AI의 엄청난 속도를 활용하되, 아키텍처의 경계를 세우고, 모범 레퍼런스를 지정하며, 코드를 인간의 언어로 정돈하는 키(Key)는 언제나 인간 엔지니어가 쥐고 있어야 한다.

This post is licensed under CC BY 4.0 by the author.