
Claude Code와 함께한 대규모 레거시 이전 기록
요약
Luup의 백엔드 엔지니어가 Claude Code를 활용하여 대규모 레거시 코드를 DDD Modules 구조로 이전한 경험을 공유합니다. 약 38,000행의 코드를 추가하며 동작 차이 제로를 목표로 진행한 1개월간의 기록과 AI 에이전트 활용 팁을 담고 있습니다.
핵심 포인트
- Claude Code를 활용해 232개 파일, 대규모 코드 구조 이전 완수
- 동작 차이 제로(Zero Unapproved Diff)를 원칙으로 안정성 확보
- AI 리뷰를 통해 사람이 놓칠 수 있는 동작 차이 버그 1건 검출
- AI 에이전트의 불필요한 변경을 막기 위한 명확한 원칙 수립 필요성
안녕하세요, Luup의 User Product Group Backend Team 백엔드 엔지니어, Jang(장)입니다.
"성공해도 아무 일도 일어나지 않는 것"이 올바른 결과가 되는 업무라는 것이 있습니다. 추가 38,000행·삭제 13,000행에 달하는 레거시 이전을, 외부에서 관측 가능한 동작에 미승인 차분(unapproved diff)을 단 하나도 만들지 않고 완수하는 것. 이번에 Claude Code와 함께 수행한 것은 바로 그런 프로젝트였습니다. 이 기사는 그 1개월간의 기록입니다. 잘된 점뿐만 아니라, AI 에이전트에게 발목을 잡힐 뻔했던 경험도 포함하여 작성합니다.
TL;DR
과제: 특정 도메인의 코드가 DDD Modules 구조 외부에 남아 있어, 기능 추가 시마다 영향 범위 파악이 어려워지고 있었습니다.
수행 내용: "main 브랜치와 동작 차이 제로(미승인 차분 제로)"를 절대 원칙으로 내걸고, Claude Code와 함께 레거시 코드를 DDD Modules로 이전하는 작업을 추가 약 38,000행 추가·약 13,000행 삭제(232개 파일) 규모로 진행했습니다. 7개의 Stacked PR로 분할하여 1개월, 59세션 이상에 걸쳐 진행했습니다.
효과: 이전 과정 중 AI 리뷰가 사용자 영향으로 이어질 수 있는 동작 차이 버그를 실제로 1건 검출했습니다. 사람이 직접 했을 때는 놓쳤을 가능성이 있습니다.
솔직한 이야기: AI는 "친절함" 때문에 불필요한 변경을 섞어 넣습니다. 원칙을 명문화하여 에이전트의 메모리(memory)에 새겨두지 않으면 이를 가려낼 수 없습니다.
왜 이 이전이 필요했는가
이전 대상이 된 것은 사용자 혜택(할인 등)과 관련된 특정 도메인입니다. 결제 금액에 직접적인 영향을 미치는 로직이지만, 코드의 상당 부분이 DDD Modules 구조(Domain / Application / Infrastructure / Presentation) 외부에, Controller나 공통 계층에 흩어져 있었습니다.
이는 수년에 걸쳐 기능을 쌓아온 결과이며, 누군가 특정 인물의 실수는 아닙니다. 다만, 이 상태가 지속되면 다음과 같은 문제가 발생합니다.
- 영향 범위를 코드를 읽지 않으면 알 수 없음: 새로운 시책을 추가할 때마다 관련 로직이 어디에 있는지 찾는 비용이 발생합니다.
- 테스트를 작성하기 어려움: Controller에 비즈니스 로직이 섞여 있으면 도메인 로직(domain logic)만을 단체 테스트(unit test)하기 어렵습니다.
- 종속화(Siloing): "이 계산, 왜 이렇게 되어 있었지?"를 추적하는 데 매번 시간이 걸립니다.
이 세 가지 문제를 안은 채 방치하지 않은 이유는, 이 도메인이 "앞으로도 계속 만지게 될 코드"이기 때문입니다. 새로운 시책이나 캠페인이 정기적으로 추가되는 영역이기에, 구조의 나쁨이 매번 개발 비용으로서 복리로 작용합니다. 반대로 말하면, 이제 더 이상 만질 일이 없는 레거시였다면 이전하지 않고 방치하는 것이 정답이었을 것입니다. 이전 비용은 일시불이고, 구조의 나쁨은 시책마다 계속 지불해야 합니다. 이 비교가 이번 투자 판단의 근거였습니다.
이 도메인을 DDD Modules로 이전하는 프로젝트가 수립되었습니다. 규모는 최종적으로 232개 파일, 약 +38,000 / -13,000행이 되었습니다.
여기서 가장 무서운 점은 "돈과 직결되는 기능"이라는 점입니다. 이전 작업에서 버그를 단 하나라도 심으면, 사용자에게 혜택이 과도하게 적용되거나 반대로 적용되어야 할 혜택이 사라지게 됩니다. 서두에 쓴 "성공해도 아무 일도 일어나지 않는 것이 올바른 결과"라는 말은 바로 이 뜻이며, 눈에 보이는 어떤 변화가 나타난 시점에서 그것은 버그입니다.
제1원칙: "main과 동작 차이 제로"
가장 먼저 정한 것은 이 이전 작업 내내 지켜야 할 규칙이었습니다.
Firestore의 영속화 형식·API 입출력·계산 결과를 포함하여, 외부에서 관측 가능한 기능적 동작에 있어
미승인 차분을 만들지 않는다. 차분을 만드는 경우에는 반드시 명시적으로 리스트업하여 승인을 받는다.
"1비트도 바꾸지 않겠다"라고 단언하고 싶지만, 정확하게는 다음과 같습니다. 후술하겠지만 완전히 동일하게 만들 수 없거나 만들어서는 안 되는 부분이 현실적으로 존재하기 때문에, 원칙의 본체는 "차분 제로"가 아니라 "미승인 차분 제로"입니다. 또한, 이 원칙이 커버하는 것은 기능적 동작이며, 레이턴시(latency)나 콜드 스타트(cold start)와 같은 비기능적 특성은 별도로 모니터링합니다(실제로 여기서 문제를 겪은 이야기도 나중에 쓰겠습니다).
당연하게 들릴지 모르지만, 이를 1개월, 59세션 이상에 걸쳐 계속 지켜나가는 것은 생각보다 어려운 일이었습니다. Claude Code와의 세션은 장시간 유지할 수 없기 때문에, 세션이 바뀔 때마다 이 원칙을 다시 설명해야 합니다. 몇 번인가 "어라, 저번에 정한 걸 벌써 잊어버렸네" 하는 상황에 직면했습니다.
그래서 저는 이 원칙을 에이전트의 메모리 파일(memory file)에 명문화하여 저장했습니다. 실제로 작성한 문구는 대략 다음과 같은 형태입니다.
이 리팩터링(refactoring) 전체는 main과 동작의 차이가 절대 없음을 최우선으로 한다.
- 각 단계에서 characterization test(특성 테스트: 기한 계산, 직렬화 등)를 green 상태로 유지한다.
- 영속화 형식(persistence format)을 변경할 의심이 있는 변경(코덱(codec) 도입, 객체의 스프레드(spread) 구문 교체 등)은, 교체 전후의 동등성을 test로 먼저 고정한 후 실시한다.
- 망설여질 때는 "main의 실제 동작"을 1차 정보(git, grep, test, 실제 데이터)로 확인한 후 움직인다.
이렇게 작성해 둔 덕분에, 새로운 세션을 시작하더라도 에이전트가 동일한 판단 기준으로 움직일 수 있게 되었습니다. 뒤집어 말하면, 이렇게 써두지 않았다면 매번 제가 그때마다 다시 설명해야 했을 것입니다. 한 달간 지속되는 태스크를 AI 에이전트와 진행하려면, 원칙의 영속화(persistence)는 거의 필수입니다.
원칙의 메모리화에 더해, 세션 간의 인수인계에는 두 종류의 파일을 운용했습니다. 하나는 hand-off 문서로, 세션을 마칠 때마다 "현재 어디까지 진행되었는지, 다음 액션은 무엇인지, 건드려서는 안 되는 것은 무엇인지"를 에이전트 스스로 작성하게 합니다. 다른 하나는 결정 사항 파일로, "한번 논의하여 결정한 사항(예: 굳이 검증을 추가하지 않는다 등)"을 이유와 함께 축적합니다. 새로운 세션은 반드시 이 두 가지를 읽는 것부터 시작하기 때문에, "이전 세션에서 기각한 설계를 다음 세션이 좋은 의도로 다시 제안해 오는" 무한 루프를 피할 수 있었습니다.
다만 솔직히 말하자면, 이 파일군을 유지보수하는 것 자체는 에이전트에게만 맡길 수 없었습니다. 작성은 에이전트가 하지만, "무엇이 인수인계할 가치가 있는 정보인가"를 취사선택하는 것은 결국 인간의 몫이었기에, 세션이 끝날 때마다 몇 분간의 손질을 매번 계속해야 했습니다. 원칙의 영속화는 거저 얻어지는 것이 아니라는 점을 실감했습니다.
7개의 Stacked PR로 분할하기
이전은 레이어별로 7개의 PR로 나누었습니다.
7개로 분할한 이유는 리뷰 가능한 단위로 수용하기 위해서와, 안전한 상태를 단계적으로 유지하기 위해서입니다. 이는 Strangler Fig 패턴(신구 시스템을 병존시키고, 소비 측을 조금씩 신규 구현으로 교체한 뒤 마지막에 레거시를 제거하는 기법) 그 자체입니다. 한꺼번에 전부를 전환하는 Big Bang 이전 방식에 비해, 각 PR의 차이(diff)를 추적하기 쉽고, 도중에 이상을 발견하더라도 피해 범위를 1개 페이즈 분량으로 한정할 수 있습니다.
안전성의 내역을 정확히 적어두겠습니다. pr1~pr5 사이에서 신규 Modules의 코드는 추가될 뿐 아무도 호출하지 않습니다. 소비 측은 계속해서 레거시 경로를 통하므로, 운영 동작에 미치는 영향은 제로입니다. 진정한 의미의 신구 공존이 시작되는 것은 pr6부터로, 신규 코드가 트래픽을 처리하기 시작하지만 레거시 코드는 아직 남아 있으므로, 문제가 발생하면 pr6를 revert하는 것만으로 즉시 레거시 경로로 되돌릴 수 있습니다. 공존이 끝나는 것은 pr7(레거시 삭제)이며, 이곳을 넘어서면 롤백(rollback) 비용이 급격히 상승합니다. 그래서 pr7은 pr6가 운영 환경에서 문제없이 동작하는 것을 확인한 뒤 머지(merge)한다는 순서를 지켰습니다.
간과하기 쉽지만, 이 pr6의 revert가 안전하게 성립하는 것은 "main과 동작 차이 제로" 원칙 덕분입니다. 신규 코드가 Firestore의 영속화 형식(스키마, 직렬화 형태)을 바꾸지 않았기 때문에, 신규 코드가 작성한 데이터를 레거시 코드가 그대로 읽을 수 있습니다. 영속화되는 값에 영향을 줄 수 있는 차이점은 후술할 "의도적인 차이점 리스트"를 통해 모두 파악하고 있었으므로, revert 시 확인해야 할 영향 범위도 그 리스트 안에 한정됩니다. 만약 이전하는 김에 영속화 형식을 "개선"해 버렸다면, revert하는 순간 레거시 코드에서는 읽을 수 없는 데이터가 남게 되어 롤백이 불가능해졌을 것입니다. 미승인 차이점 제로는 품질 보증의 원칙인 동시에, 롤백 안전성의 보증이기도 했던 셈입니다.
도중에 pr8로서 "판정을 동반하지 않는 조회를 ReadModel을 통해 직행시키는" PR도 만들었으나, 이는 스코프(scope) 외라고 판단하여 클로즈(close)했습니다. AI 에이전트는 "만들 수 있는 것은 만들어 버리는" 경향이 있기 때문에, 선을 긋는 기준을 명문화하여 운용했습니다.
- 관측 가능한 동작을 바꾸는 변경 → 즉시 revert (후술할
undefined→null...
(이것이 해당 사례임) - 동작을 바꾸지 않으면서 사내 설계 규약 위반을 해소하는 변경 → 이행(Migration) 범위 내 포함 (규약에 준거한 구조로 정비하는 것 자체가 「이행 완료」의 정의에 포함되기 때문. 후술할 ReadModel 재설계가 이에 해당함)
- 동작도 바꾸지 않고, 규약 위반 해소도 아닌 새로운 개선 → 별도의 PR로 분리 (pr8이 이에 해당함)
이 3가지 분류가 없으면, 「어디까지 해야 이행이 끝나는 것인가」에 대해 에이전트는 물론 인간 팀과도 합의할 수 없습니다.
실무 측면에서는 worktree를 사용하여 여러 세션을 병행하며, 각 PR을 동시에 진행했습니다. 각 세션의 끝에는 타입 체크(Type Check) 빌드와 해당 레이어에 집중한 jest 테스트 모두를 green 상태로 만든 후에만 커밋한다는 게이트(Gate)를 철저히 지켰습니다. 테스트만 green이고 빌드가 깨져 있는 상태를 몇 번 발견했기에, 이 2단계 검증은 생략할 수 없었습니다.
한편, main 브랜치의 업데이트를 이 7단계의 스택 전체에 전파시키는 작업은 생각보다 번거로웠습니다. 「이 브랜치에 main을 머지하고, 컨플릭트(Conflict)는 이렇게 해결해」라는 지시를 7번 반복하는 것은, 솔직히 코드를 작성하게 하는 것보다 더 힘들었습니다. 최종적으로 이 이행 작업만으로 50건 가까운 커밋이 쌓였습니다.
동작 차이를 검증하는 3가지 방법
「main과 동작 차이 제로」라고 말하기는 쉽지만, 그것을 어떻게 검증하느냐가 본론입니다. 실제로 수행한 방법은 3가지입니다.
1. 특성 테스트 (Characterization Test)를 먼저 작성하기
기한 계산, 직렬화 (Serialization) 등 레거시의 동작을 먼저 테스트로 고정한 후, 구현을 교체했습니다. 테스트가 먼저 있으면, 교체한 후의 구현이 레거시와 동일한 결과를 반환하는지 기계적으로 확인할 수 있습니다.
이때 중요한 점은, 테스트가 검증하는 것이 「사양으로서 올바른 동작」이 아니라 「레거시의 실제 동작」이라는 점입니다. 예를 들어 기한 계산에 타임존(Timezone) 기인으로 직관에 반하는 동작이 있더라도, 그것을 그대로 고정합니다. 수정하고 싶어지겠지만, 수정은 이행이 끝난 다음에 해야 합니다. 이행과 수정을 동시에 진행하면, 테스트가 실패했을 때 「이행의 실수인지, 의도한 수정인지」를 구분할 수 없게 됩니다.
특히 효과적이었던 것은 Firestore로의 쓰기 형식 (영속 형태) 고정이었습니다. 엔티티(Entity)를 스프레드 구문 (Spread Syntax)으로 전개하여 저장하던 부분을 코덱 (Codec) 경유 방식으로 교체할 때, 교체 전후로 쓰여지는 객체가 완전히 동일함을 테스트로 먼저 고정한 뒤 구현을 교체했습니다.
2. 운영 데이터 실측을 통한 설계 판단
이행 중 고민스러운 판단이 하나 있었습니다. 「마스터 데이터의 Firestore 문서를 읽을 때, 잘못된 값을 검증하여 걸러내야 하는가」라는 논점이었습니다.
DDD (도메인 주도 설계)의 교과서적인 발상으로는, Domain 객체 생성 시점에 유효성 검사 (Validation)를 통과하는 것이 자연스럽습니다. 하지만 이번 이행의 제1원칙은 「main과 동작 차이 제로」이지, 「DDD적으로 올바른 상태로 만들기」가 아닙니다. 만약 레거시가 검증을 건너뛰고 통과시키던 레코드가 있다면, 새 코드에서 검증을 추가하는 순간 그 레코드만 읽을 수 없게 되어 동작 차이가 발생합니다.
「검증을 추가해도 되는가」는 추측이 아니라 실제 운영 중인 Firestore 데이터로 결정하기로 했습니다. 알고 싶은 것은 단 하나입니다. 현재의 생성 규칙이 거부할 만한 잘못된 값을 가진 문서가 운영 환경에 실제로 존재하는가. 실재하지 않는다면 검증을 추가해도 아무도 곤란해하지 않지만, 단 한 건이라도 실재한다면 검증 추가는 해당 데이터를 읽지 못하게 만드는 동작 차이가 됩니다. 다만 전체를 애플리케이션에서 읽으면 읽기 비용을 무시할 수 없으므로, 해당 값을 가진 문서의 건수만을 count() 집계로 계산했습니다.
// 문서 본체는 읽지 않고, count() aggregation만으로 실제 데이터의 분포를 파악함
const snapshot = await db
.collection("targetCollection")
...
count() aggregation은 매칭된 index entry 1,000건당 1 read로 과금되므로, 수십만 건 규모의 컬렉션에서도 매우 적은 비용으로 실태를 파악할 수 있습니다.
결과적으로 마스터 데이터 측은 총 건수 54만 건 이상에 대해 잘못된 값을 가진 레코드는 0건이었습니다. 반면 사용자가 보유한 이력 데이터 측은, 원래라면 collectionGroup 쿼리로 동일하게 집계하고 싶었으나, 필터링에 필요한 인덱스(Index)가 prod 환경에 존재하지 않아 FAILED_PRECONDITION
반려되었습니다. 스키마 변경이 필요한 인덱스(Index) 신설은 이번 이전의 범위(Scope) 외였기에 단념하고, 대신 "해당하는 값을 가진 실제 사용자의 이력이 존재한다"라는 사실을 관계자 확인을 통해 확정했습니다. 이 실측 결과에 기반하여, 사용자 측의 이력 데이터 읽기는 검증을 스킵하고 통과시키는 구현(레거시와 동일)을 유지한다는 판단을 내렸습니다.
이는 추상론만으로는 결정할 수 없는 판단이었습니다. 다만 정리해 두자면, 실측을 사용할 수 있었던 것은 마스터 데이터 측(0건이라는 결과를 근거로 삼을 수 있었음)뿐입니다. 판단의 핵심이었던 이력 데이터 측은 실측에 실패하였고, "실측할 수 없었다"라는 사실을 명시한 뒤 관계자 확인을 근거로 삼았습니다. 여기서 얻은 교훈은 "실측하면 안전하다"가 아니라, 추측인 채로 진행하지 않는 것입니다. 측정할 수 있는 것은 측정하고, 측정할 수 없는 것은 측정할 수 없었다고 기록하여 다른 근거를 찾으러 가는 것입니다. AI 에이전트는 내버려 두면 추측으로 격차(Gap)를 메워버리기 때문에, 이러한 규율은 인간 측에서 가져올 필요가 있었습니다.
3. 의도적인 차분을 PR 본문에 전부 작성하기
"완전히 동일함"이 기술적으로 불가능하거나 적절하지 않은 부분도 일부 있었습니다 (예: 잔여 수량 차감을 스킵하는 조건 분기 등). 그러한 부분은 숨기지 않고, PR 본문에 의도적인 차분(Difference)으로서 리스트업했습니다. 리뷰어가 "이것은 의도된 변경인가, 아니면 몰래 섞여 들어온 변경인가"를 구분할 수 있도록 하기 위해서입니다.
AI의 "친절함"이 버그가 되는 순간
검증의 그물을 펼치며 실제로 발견한 문제 중 하나가, Claude Code 스스로가 만들어낸 스코프 크립(Scope creep, 본래 범위를 벗어난 변경)이었습니다.
타입을 더 엄격하게 만들려다가, 특정 필드의 타입을 undefined에서 null로 바꾸는 커밋이 섞여 있었던 적이 있었습니다.
- interface RecordSnapshot {
- note?: string;
- }
...
악의는 전혀 없었고, 오히려 "좋은 의도로" 행한 개선이었습니다. 하지만 이 또한 엄연한 동작 차이입니다. Firestore의 ignoreUndefinedProperties: true 설정 하에서는 undefined 필드는 쓰기 시점에 문서(Document)에서 제거되지만, null은 명시적인 값으로 쓰여집니다. 똑같이 "값이 없는" 상태라고 생각할지라도, 실제로 영속화(Persistence)되는 문서의 형태가 달라지게 됩니다.
이를 찾아낼 수 있었던 것은 "main과 동작 차이 제로"라는 원칙이 있었기 때문입니다. 원칙이 있었기에 "이것은 개선인가, 아니면 동작 차이인가"라는 판단을 개인의 감각이 아닌 기계적으로 내릴 수 있었습니다. 순수한 리팩터링(Refactoring)을 AI 에이전트와 진행할 때는, 이러한 "친절한 개선 욕구"를 어떻게 제어하느냐가 은근히 중요하다는 것을 느꼈습니다.
여러 관점에서 리뷰시키기
코드 리뷰는 관점별로 별도의 AI 에이전트를 병렬로 실행하는 체제로 구축했습니다. 동작 차이 유무, DDD 규약 준수, 단순함(KISS), 성능 저하 유무, 그리고 우리가 쌓아온 PR 체크리스트라는 5가지 관점입니다. 중요도가 높은 지적에 대해서는 상위 모델로 재검증하는 단계도 거쳤습니다.
재검증 단계를 거치는 이유는 AI 리뷰의 지적에는 일정 비율로 오탐(False Positive)이 섞여 있기 때문입니다. 지적을 그대로 믿고 수정하면, 이번에는 "불필요한 수정"이라는 또 다른 노이즈가 차분에 포함됩니다. 지적된 코드를 실제로 동작시켜 확인한 뒤 수정하는 것을 철저히 했습니다.
이 체제에서의 검출 내역은 수정 필수(P1) 1건, 수정 권장 10건, 실증 결과 오탐으로 판단하여 기각한 것이 3건이었습니다. 수정 필수인 1건은 페일 클로즈(Fail-close) 동작 차이였습니다.
- 레거시 구현: 정의 맵에 없는 코드는 DB로 폴백(Fallback) 조회하고, 그래도 발견되지 않으면 해당 코드만 스킵하여 로그를 남긴 뒤 나머지 처리를 계속함
- 신규 구현 (이전 직후): 정의 맵에 없는 코드가 들어오면 그 자리에서 처리 전체를 중단함 (DB 폴백도 사라져 있었음)
이는 라이드 종료 시의 보상 부여 경로와 관련된 부분으로, 만약 놓쳤다면 새로운 식별 코드의 추가 및 배포 타이밍에 따라 혜택이 전체적으로 미부여될 가능성이 있었습니다.
공정성을 위해 적어두자면, 이 회귀(Regression)를 만들어낸 것은 이전 작업 그 자체, 즉 저와 Claude Code입니다. AI가 만든 버그를 AI 리뷰가 검출했다는, 마치 자작극 같은 구도이지만 그렇기에 검증망은 생략할 수 없습니다. 만드는 쪽의 속도가 올라간 만큼, 버그를 만들어내는 속도도 올라갈 수 있습니다. 검증 체계는 그러한 전제하에 설계해야 합니다. 복구 방침은 맵(Map) 상에서 해결할 수 없으면 DB 폴백(Fallback)하고, 그래도 찾을 수 없다면 해당 코드만 스킵(Skip)하고 처리를 계속하는, 레거시와 동일한 동작을 애플리케이션(Application) 계층에서 완결시키는 형태로 했습니다.
// 정의된 코드는 메모리상의 정의로부터 즉시 해결
// 정의에 없는 코드는 DB로 폴백하며, 그래도 찾을 수 없으면 null을 반환
async function resolveByCode(code: string) {
...
호출 측에서는 null이 반환된 경우에는 해당 코드만 로그를 남기고 스킵하며, 찾은 만큼만 처리를 계속합니다. 연결 에러는 상위로 전파시켜 레거시의 동작과 동일하게 만들었습니다. 도메인(Domain) 계층은 변경하지 않고, 애플리케이션(Application) 계층에 리포지토리(Repository)를 하나 주입하는 것만으로 해결할 수 있었기에, 레이어 책임(Layer Responsibility) 관점에서도 자연스러운 수정 방식이었습니다.
한편, 솔직히 말하자면 5가지 리뷰 관점 모두가 놓쳤던 규약 위반도 1건 있었습니다 (자세한 내용은 후술합니다). 여러 관점으로 리뷰를 시켜도 놓치는 것을 제로(0)로 만들 수는 없다는 것을 뼈저리게 느꼈습니다. 사람에 의한 리뷰(이번에는 17건의 지적 사항 대응이 있었습니다)와 조합한다는 전제는 앞으로도 유지할 생각입니다.
참고로, 이 놓친 부분을 최종 검증에서 잡아낸 것은 이전 작업 도중에 출시된 Claude Code의 신규 모델, Fable 선생님이었습니다. 1개월 프로젝트 도중에 툴 측의 능력이 세대교체되어, 동일한 코드를 다시 리뷰했더니 이전에는 보이지 않았던 문제가 발견되는 것. 이는 인간 팀에서는 일어나지 않는, AI 에이전트와의 협업에서만 가능한 순풍이었습니다. 장기 프로젝트일수록 도중에 모델을 교체하여 과거의 결과물을 재검증할 가치가 있다는 점은 이번 발견 중 하나입니다.
퍼포먼스와 복잡도에서도 실제로 문제가 발견됨
전제로, 이 백엔드는 Cloud Functions 위에서 동작하고 있으며, 한동안 요청이 없었던 인스턴스가 기동될 때는 콜드 스타트(Cold Start)가 발생합니다. 퍼포먼스 관련하여 가장 컸던 문제는 이전 작업 중에 실제로 겪었던, 이 콜드 스타트 시의 크래시(Crash)였습니다.
DI 컨테이너(DI Container)에 Logger를 바인딩(Bind)하는 것을 요청 처리 입구(Wrapper 실행 시)까지 지연시켰던 것이 원인이었습니다. 그런데 일부 코드는 모듈(Module)의 최상위 레벨에서 Logger를 컨테이너로부터 해결(Resolve)하고 있었기 때문에, 콜드 스타트 직후에 Wrapper가 아직 실행되지 않은 상태에서 해당 라인이 실행되면 "해당하는 바인딩을 찾을 수 없음"으로 인해 떨어집니다.
// before: 바인딩을 요청 처리 입구까지 지연
export function registerGlobalServices(environment: Environment) {
if (!container.isBound(TYPES.Logger)) {
...
// after: 바인딩 자체는 모듈 로드 시에 완료시키고, 환경 이름만 나중에 반영한다
let envName: string | undefined;
container
...
toDynamicValue는 get이 호출될 때마다 팩토리(Factory)를 재실행하므로, 바인딩 타이밍과 값이 확정되는 타이밍을 분리할 수 있습니다. 이를 통해 "모듈 로드 시에 안전하게 바인딩을 마치는 동시에, 실행 시점의 값을 반영하는 것"을 양립할 수 있었습니다. 또한, Logger를 일부러 매번 get할 때마다 생성하도록 한 것은 실행 시점의 환경 이름을 반영하기 위한 의도적인 선택이며, 생성이 가벼운 Logger이기 때문에 가능한 판단입니다 (의존 그래프(Dependency Graph) 전체를 다시 해결하는 비용과는 규모가 다릅니다).
이 사건을 계기로 컨테이너에서 get()을 호출하는 위치에 대한 지침도 필요하다는 것을 깨달았습니다. 기본적으로는 모듈 레벨에서 한 번만 해결해 두는 방침입니다. 웜 인스턴스(Warm Instance)가 동일한 핸들러를 반복해서 처리할 때마다 의존 그래프를 다시 해결하는 것은 낭비되는 비용이기 때문입니다. 사실 이 수정 이전의 코드베이스에는, 바인딩이 요청 입구까지 지연된다는 전제하에 "콜드 스타트 크래시를 피하기 위해 의도적으로 핸들러 내에서 get()
하는"이라는 회피 패턴이 존재했습니다. bind를 모듈 (module) 로드 시점에 앞당김으로써 이 회피의 존재 이유는 사라졌지만, 코드상에는 남아있습니다. 성능 관점의 AI 리뷰가 찾아낸 것은, 바로 이 신구(新舊) 두 가지 이유가 동일한 파일 내에 혼재되어 일관성이 깨진 부분이었습니다. "왜 여기만 핸들러 (handler) 내부인가"에 대한 이유가 명문화되어 있지 않으면, AI 에이전트(AI agent)도 인간 리뷰어(reviewer)도 이것이 의도인지 방치인지 판단할 수 없습니다 (이 회피 패턴의 정리는 후속 과제로 남겨두었습니다).
복잡도 측면에서도, 미미하지만 효과적인 지적들이 몇 가지 있었습니다.
- 동일한 구조의 타입 (type)이 3곳에서 중복 정의됨 → 하나의 공통 타입으로 집약
- 동일한 형태의 에러 객체 (error object)를 구성하는 로직이 5곳에 흩어져 있음 → 하나의 헬퍼 함수 (helper function)로 집약
- 두 모듈 간에 순환 임포트 (circular import)가 발생함 (한쪽의 getter를 통해 증상만 회피되어 있었으며, 근본적인 의존 관계는 그대로 남아있음)
순환 임포트는 이번 범위(scope) 내에서 근본적으로 해결하지 못하고 다음 과제로 남겨두었습니다. 성능과 달리 복잡도는 "동작하지 않는" 형태로 표면화되지 않기 때문에, 원칙적으로 모니터링 대상에 넣어두지 않으면 간과하기 쉽습니다.
이 이전을 통해 보인 DDD와 현실의 거리
"운영 데이터의 실측을 통해 설계 판단을 한다"는 부분에서 언급한 검증 스킵(validation skip) 이야기는, DDD의 원칙론과 현실의 데이터가 충돌한 한 사례였습니다. 이 충돌은 임시방편으로 해결하는 것이 아니라, 최종적으로 엔티티 (Entity)의 생성 경로를 두 가지로 나누는 설계 규칙으로 정립했습니다.
create: 신규 생성 경로. 도메인의 불변 조건 (invariant)을 완전히 검증하며, 유효하지 않은 값은 거부함reconstruct: 영속화(persistence)된 데이터로부터의 복원 경로. 검증하지 않고, 저장되어 있던 값을 그대로 신뢰함
언뜻 보기에 reconstruct는 요령을 피우는 것처럼 보일 수 있지만, 논리가 있습니다. 불변 조건은 "현재의 비즈니스 규칙"이며, 영속화된 데이터는 "과거 규칙의 산물"입니다. 스키마리스 (schemaless)한 Firestore에서는 과거에 정당하게 기록된 데이터가 현재의 규칙에 부합한다는 보장이 어디에도 없습니다. 복원 경로에서 현재의 규칙을 강제하는 것은 과거의 데이터를 현재의 기준으로 검열하는 것이 되며, 이는 바로 검증 스킵 이야기에서 보았던 "실재하는 사용자 이력을 읽을 수 없게 되는" 문제를 일으킵니다. 새로 만드는 것에는 현재의 규칙을 엄격하게, 이미 존재하는 것에는 관대하게. 이 비대칭성은 레거시 (legacy)와 공존하는 DDD에서는 하나의 원칙이라고 생각합니다.
그 외에도 이번 이전에서는 다음과 같은 DDD적인 정리를 수행했습니다.
- Value Object (VO) 정리: VO마다 무검증 복원 메서드를 갖는 설계를 중단하고, 구분 값(discriminator)만을 VO로 남기는 형태로 축소. 무검증 복원의 입구는 위의 엔티티 복원 경로 한 곳으로 집약했습니다 (VO마다 백도어(backdoor)가 늘어나면, "VO의 생성 경로를 통하면 불변 조건을 만족한다"라는 타입 레벨(type level)의 보장이 형해화되기 때문입니다).
- CQRS적인 Read Model 정리: 엔티티로부터의 매핑 타입 (Mapped Type)으로 만들어졌던 Read Model을, Firestore의 문서 스키마 (document schema)로부터 직접 도출하는 플레인 DTO (plain DTO)로 교체하고, 리포지토리 (repository) 명칭도
XxxReadModelRepository에서XxxQueryService로 변경
두 번째 사례를 좀 더 구체적으로 쓰겠습니다. 이전 전의 Read Model은 엔티티의 타입으로부터 매핑 타입으로 기계적으로 만들어져 있었습니다.
// before: 엔티티의 타입에 의존한 매핑 타입 (Mapped Type)
type ItemReadModel = Pick<Item, "id" | "code" | "expiresAt">";
매핑 타입은 편리한 면이 있는 반면, 엔티티 측의 타입 변경 (프로퍼티의 VO화 등)이 그대로 Read Model의 타입에 파급되어 의도치 않게 변해버리거나, Firestore의 실제 문서 스키마와 괴리될 수 있다는 문제를 안고 있었습니다. 이전 후에는 문서 스키마로부터 직접 도출하는 플레인 DTO로 교체했습니다.
// after: Firestore 문서 스키마로부터 직접 도출한 플레인 DTO (plain DTO)
interface ItemReadModel {
id: string;
...
동시에, ItemReadModelRepository라는 명칭도 ItemQueryService로 변경했습니다. "Repository"는 본래 엔티티의 영속화를 담당하는 용어이며, 판정을 동반하지 않는 단순 조회에는 의미가 너무 강하다는 것이 그 이유입니다.
앞서 언급한 5가지 리뷰 관점 모두가 놓쳤던 규약 위반이 바로 이것입니다. 어떤 Domain Service가 최상위 함수 (top-level function)로 구현되었을 뿐만 아니라, Infrastructure 계층의 로거 (logger)를 직접 import 하고 있었습니다.
// before: 최상위 함수 + Domain 계층에서 Infrastructure 계층로의 직접 import
import { logger } from "infrastructure/logger";
export function resolve(code: string, items: Item[]) {
...
Domain Service는 클래스 (class)로 통일해야 한다는 점과, Domain은 인프라 (infrastructure)를 import 하지 않아야 한다는 두 가지 규약을 동시에 위반하고 있었습니다. 수정은 클래스화와 로그 책임 (log responsibility)의 이동을 동시에 수행하는 방식으로 진행했습니다.
// after: 클래스로 통일하고, 로그 출력은 Domain에서 분리
export class ItemResolver {
constructor(private readonly repo: ItemRepository) {}
...
호출 측인 Application 계층이 Domain의 반환 값을 받아 로그를 남기는 형태로 변경하여, Domain 계층에서 로그 출력 자체를 분리했습니다. 또한, 이와 함께 "사전에 취득한 목록을 인자로 받는" 형태에서 "리포지토리 (repository)를 통해 취득하는" 형태로도 정리했습니다 (리포지토리의 인터페이스 (interface)는 Domain 측이 소유하고 있으므로, Domain Service에서의 참조는 의존성 역전 (Dependency Inversion) 범위 내에 있습니다). 5가지 관점으로 리뷰를 진행하더라도, 이처럼 "여러 규약을 동시에 위반하고 있는" 부분은 놓치기 쉽다는 것을 실감한 사례였습니다.
이 수정 과정에서는 "애초에 이 로직이 Domain Service로 적절한가, 아니면 Application Service로 올려야 하는가"에 대한 논의도 이루어졌습니다. 판단에 사용한 기준은 두 가지입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기