던플 개발기 (16): 백엔드 아키텍처 감사 — 무엇을 고치고, 무엇을 일부러 안 고쳤나

Dunplay

지난 편까지 프론트 경계를 branded type과 DTO→Model seam으로 다졌다면, 이번엔 백엔드 전체를 스스로 감사한 이야기입니다. 앱 6개에 라이브러리 수십 개, 약 23,000 LOC(테스트 제외)까지 커진 뒤라, "이쯤에서 한 번 전면적으로 갈아엎어야 하지 않나" 하는 유혹이 들었습니다.

그래서 감사를 돌렸습니다. 그런데 감사의 결론은 정반대였습니다. 구조는 건강하니 전면 리팩터링은 하지 마라. 대신 "설계는 맞는데 구현이 아직 안 닫힌" 곳 4건만 HIGH로 뽑고, 나머지는 지금 건드리지 말 것으로 판정했습니다. 이 글은 그 판정의 기록입니다. 제 결론은 하나로 요약됩니다 — 좋은 감사는 "다 갈아엎자"가 아니라 "무엇을 지금 안 건드려도 되는지"를 판정하는 일입니다.

한눈에 보면

  • 백엔드가 커지자 "전면 리팩터링" 충동이 들었지만, 감사의 목표는 그 충동을 증거로 반박하는 것이었습니다.
  • 경계별로 7개 정밀 감사 + 교차 검증을 돌렸고, 모든 발견은 file:line 증거에 묶었습니다. 추측은 항목으로 올리지 않았습니다.
  • 결과: CRITICAL 0건, HIGH 4건, MEDIUM 5건, LOW 11건. command/query/event/job/presentation 경계가 책임대로 분리돼 있어 전면 리팩터링은 불필요하다고 판정했습니다.
  • HIGH 4건은 (1) command consumer 미배선, (2) collect-due 에러 분류 누락, (3) raw SQL 미검증, (4) item→market Neople 의존 — 대부분 "구조 결함"이 아니라 "완성도 공백"입니다.
  • 감사와 별개로 잡은 실제 버그 하나: 분 단위 집계가 DEFAULT 파티션에 갇혀 롤업이 영구 정지했고, 그 탓에 일 집계와 홈 총액 차트가 빈 채로 남아 있었습니다.
  • 부채에 우선순위를 매기는 기준은 심각도가 아니라 correctness → boundary 순서, 그리고 각 항목을 "독립 배포 가능한 단일 경계 작업"으로 쪼갤 수 있느냐였습니다.

왜 스스로 감사했나

리팩터링을 하고 싶어질 때 가장 위험한 건, "느낌"으로 시작하는 것입니다. 파일이 길어졌고, 앱이 많아졌고, 메시징이 RabbitMQ에 Kafka에 outbox까지 얽혀 있으니 "복잡하다 = 부실하다"로 넘겨짚기 쉽습니다. 그 상태로 대공사를 시작하면, 멀쩡한 경계까지 같이 무너뜨리고 몇 주를 태웁니다.

그래서 리팩터링 대신 감사를 먼저 했습니다. 원칙은 세 가지였습니다.

  • 증거 기반. 모든 발견은 file:line으로 지목한다. "이 부분이 좀 애매하다" 같은 인상은 항목이 아니다.
  • 경계별로 나눠서 본다. 전체를 한 덩어리로 보지 않고 command / query / event / job / presentation 경계를 따로 감사한 뒤 교차 검증한다.
  • 판정에는 "안 고쳐도 되는 것"도 포함한다. 정상인 곳을 정상이라고 명시적으로 적는다. 그래야 나중에 그곳을 괜히 다시 안 건드린다.

이렇게 나온 감사 결과의 심각도 분포는 이렇습니다.

심각도 건수 성격
CRITICAL 0
HIGH 4 command 백본 consumer 공백, 수집 워커 에러 분류 누락, raw SQL 미검증, bounded-context 의존
MEDIUM 5 의존 방향·태깅 일관성, Phase-1 event fallback 미구현
LOW 11 presentation 다듬기, 운영 hardening

CRITICAL이 0건이라는 게 핵심입니다. HIGH 4건조차 "구조가 틀렸다"가 아니라 대부분 "설계는 맞으나 구현이 아직 안 닫힌" 완성도·안전성 공백이었습니다.

전면 리팩터링이 불필요한 이유

감사에서 가장 먼저 확인한 건 "경계가 실제로 지켜지고 있는가"였습니다. 던플 백엔드는 헥사고날 command 계층, read model query 계층, transactional outbox event 계층, RabbitMQ job 계층, BFF presentation 계층으로 나뉘어 있는데 — 이 분리가 문서상의 그림이 아니라 코드에서 강제되고 있는지가 관건이었습니다.

flowchart TB
  subgraph pres["presentation · runtime:bff/gateway"]
    BFF["market-bff<br/>DB/Redis/MQ/Neople 직접접근 0"]
    GW["realtime-gateway<br/>DB write 0, 순수 fan-out"]
  end
  subgraph asm["command/query 조립 · runtime:server"]
    MAPI["market-api (gRPC)"]
    IAPI["item-api"]
  end
  subgraph job["job side"]
    CRON["market-cron<br/>dispatch·collect-due·relay"]
  end
  subgraph app["application · hexagonal"]
    APP["market-application<br/>금지 infra import 0"]
  end
  DOM["market-domain<br/>외부 import 0, 결정론"]
  DA["market-data-access<br/>CQRS read / mapper"]
 
  BFF -->|gRPC only| MAPI
  BFF -->|gRPC only| IAPI
  MAPI --> APP
  CRON --> APP
  APP -->|호출만| DOM
  APP -->|port| DA
  GW -.Kafka event.-> CRON

검증해 보니 이 그림이 코드에서 실제로 성립했습니다. @nx/enforce-module-boundaries로 import 경계가 강제되고 있었고, 특히 두 지점이 모범적이었습니다.

  • market-domain외부 import 0건의 순수 도메인이었습니다. 시간·난수까지 port로 주입받아 결정론적이었고, scoring/tier/freshness/reconcile/movement 같은 도메인 수학이 전부 여기 모여 있었습니다.
  • market-application@prisma/@nestjs/kafkajs/amqplib/data-access를 import 0건으로 지키는 헥사고날 use case 계층이었습니다. 도메인 규칙은 호출만 하고, 명령은 lease·dedup·state machine으로 멱등했습니다.

경계별 건강도를 표로 정리하면 이렇습니다.

경계 패턴 상태 비고
market-domain 도메인 순수성 우수 외부 import 0, 시간/난수 port 주입, 결정론
market-application hexagonal ports 우수 금지 import 0, 도메인 규칙 위임, 명령 멱등
market-data-access CQRS read / mapper 양호 아웃박스 정확, mapper 전면 / raw SQL row 미검증
market-api gRPC 조립 우수 컨트롤러 얇음, DTO 분리, 로직 누출 없음
market-cron job/worker 부분 dispatch 정확 / collect-due 에러 분류·consumer 미배선
market-bff presentation 우수 DB/Redis/MQ/Neople 직접 접근 0, gRPC 조합만
realtime-gateway consume+fanout 우수 DB write 없음, 순수 fan-out
event side outbox/messaging 양호 아웃박스/버전/역할분리 정확 / Kafka 외 fallback 미구현
item context hexagonal 양호 소유권 leak 0, 키 분리 정확 / market-neople-client 의존

그리고 감사에서 가장 중요한 산출물은 사실 이 목록입니다 — 리팩터링하지 말 것. 이미 올바른 구현이라 손대면 손해인 곳들입니다.

  • 트랜잭셔널 아웃박스. 상태변경 write와 outboxEvent.createMany(...)가 동일 $transaction에 커밋되고, relay가 별도로 미발행 row를 드레인합니다. dual-write가 없습니다.
  • CQRS read 경계. 무거운 SQL은 data-access에 있고 use case는 read port만 호출합니다. 대시보드를 가짜 aggregate로 감싸지 않았습니다.
  • BFF/gateway. BFF는 DB/Redis/MQ/Kafka/Neople 직접 접근이 0건이고(boundary 테스트로 강제), gateway는 DB write 없이 구독자 수만 gRPC로 보고합니다.
  • provider 회복탄력성. dispatcher가 점검 시 job 생성을 멈추고, jitter로 복구하고, 점검 실패는 DLX로 보내지 않습니다. purge-after-rollup watermark, collection_gaps로 장애 구간 제외까지 (2)편에서 설계한 대로 살아 있었습니다.
  • Neople 키 분리. catalog는 NEOPLE_ITEM_API_KEY, auction은 NEOPLE_MARKET_API_KEY. 단일 변수 fallback을 의도적으로 두지 않아 quota 교차를 차단합니다.

이 목록이 있었기에, "커졌으니 다 다시 짜자"는 충동을 증거로 눌렀습니다. 구조가 건강하다는 판정은 곧 "여기부터 저기까지는 이번에 열지 않는다"는 선언이기도 합니다.

HIGH 4건

그렇다면 HIGH 4건은 무엇이었을까요. 넷 다 공통점이 있습니다 — 설계 문서와 port는 이미 맞는데, 그 절반이 실제 런타임에 배선되지 않았거나 규칙이 한 파일에서 새고 있다. 그래서 CRITICAL이 아니라 HIGH입니다. 각 항목마다 "왜 CRITICAL이 아닌가"를 명시적으로 적어, 급하게 대응하다 멀쩡한 경계를 건드리지 않게 했습니다.

# 항목 경계 왜 문제인가 왜 CRITICAL이 아닌가 Phase
HIGH-1 command consumer 미배선 job dispatcher가 RabbitMQ에 발행만, consume하는 앱이 없음 consumer 인프라·테스트는 존재, due 수집은 collect-due가 대신 수행 3
HIGH-2 collect-due 에러 분류 누락 collection credential/maintenance 에러를 swallow하고 계속 수집 dispatcher 경로의 점검·복구는 정확, 위험은 단일 파일에 한정 1
HIGH-3 item→market Neople 의존 dependency item 트래픽이 형제 컨텍스트 infra를 통과 의존이 app 조립점에만 한정, adapter가 경계에서 재매핑 5
HIGH-4 raw SQL 미검증 data-access $queryRaw<T[]>가 사실상 blind 캐스트, 검증 없이 산술 진입 같은 lib에 올바른 검증 패턴 이미 존재(outbox/tracked-item mapper) 2

HIGH-1 · command 백본의 소비측이 비어 있다

dispatcher는 market.collect-item.requested를 RabbitMQ에 발행합니다. 그런데 그 큐를 consume해서 CollectAuctionUseCase를 실행하는 앱이 어느 런타임에도 없습니다. consumer 코드 자체는 libs/shared/messaging-rabbitmq에 있고 ack/nack, DLX parking, retry-TTL까지 단위 테스트가 돼 있는데, 실제 .consume( 호출자는 Kafka 쪽 realtime-gateway뿐이었습니다.

dispatcher → RabbitMQ → [consumer 없음] → CollectAuctionUseCase. 백본의 절반이 비어 있습니다. 운영에서 dispatcher를 켜면 command가 소비되지 않고 쌓입니다. CRITICAL이 아닌 이유는 지금 실제로 due 아이템을 수집하는 경로가 큐를 우회하는 collect-due이기 때문입니다("local stand-in for the EventBridge tick + SQS worker"). 목표 구조인 market-worker 앱이 아직 없을 뿐, 데이터가 안 모이는 상황은 아니었습니다.

HIGH-2 · collect-due가 credential/maintenance 에러를 삼킨다

이게 넷 중 correctness 위험이 가장 컸습니다. collect-due 워커는 provider 상태를 배치 상단에서 딱 한 번 확인하고, 이후 아이템별 수집은 모든 에러를 catch+log한 뒤 결과와 무관하게 다음 아이템으로 넘어갑니다.

// apps/market-cron/src/app/collect-due/collect-due.module.ts
} catch (error: unknown) {
  this.logger.error(`collect failed ${item.itemId}: ...`);
}
// Reschedule by tier regardless of collect outcome ...
const next = nextCollectAtForTier(item.collectionTier, now);

문제는 명확합니다. CREDENTIAL 에러여도 나머지 99개 아이템을 잘못된 키로 계속 호출하고, 배치 도중 점검이 시작돼도 감지하지 못한 채 수집을 이어갑니다. (2)편에서 그렇게 공들여 만든 classifyCollectionError 분류기를 이 경로에서는 호출하지 않고 있었던 것입니다. CRITICAL이 아닌 이유는 dispatcher 경로의 점검·복구 처리는 정확하고(점검 시 job 생성 중단, jitter 복구, MAINTENANCE→PAUSE/CREDENTIAL→DISABLE), 위험이 이 단일 파일에만 한정되기 때문입니다. 그래서 수정 범위도 파일 하나로 좁습니다.

HIGH-3 · item-api가 market의 Neople client에 의존한다

item-neople-client가 아직 없어서, item catalog/spec 트래픽이 @dunplay/market-neople-client(scope:market, layer:infra)를 통과합니다. item 컨텍스트의 가용성이 형제 컨텍스트 인프라에 결합되는 실제 기술 부채입니다. CRITICAL이 아닌 이유는 (a) 의존이 app 조립점에만 한정되고(libs/item 하위 어떤 lib도 market client를 import하지 않음), (b) NeopleItemCatalogAdapter가 경계에서 item 도메인 모델로 재매핑해 market 타입 leak이 없으며, (c) port가 이미 올바라 adapter 하나만 교체하면 되기 때문입니다. eslint가 scope:item → scope:market를 한시적으로 허용("until item-neople-client is extracted")하는 것으로 부채를 명시해 뒀습니다.

HIGH-4 · raw SQL 결과가 검증 없이 산술에 들어간다

data-access 계층의 CLAUDE.md는 "raw SQL과 $queryRaw 결과는 이 계층에서 명시적으로 파싱한다"고 규정합니다. 그런데 분석 쿼리들은 이 규칙을 어기고 있었습니다.

// prisma-market-dashboard.repository.ts — 제네릭은 Prisma가 강제하지 않는 blind 캐스트
const rows = await this.prisma.$queryRaw<MoverRow[]>`...`;
// 검증 없이 곧장 산술로:
const spread = row.listingMax - row.listingMin;

$queryRaw<T[]> 제네릭은 사실상 as T[] 캐스트라 런타임 보장이 없습니다. 지금은 ::double precision 캐스트 덕에 동작하지만, 향후 쿼리 수정으로 그 캐스트가 빠지면 Postgres가 BigInt/string/Decimal을 반환해 BigInt - number throw나 문자열 결합으로 조용히 시세가 오염됩니다. CRITICAL이 아닌 이유는 같은 lib에 올바른 패턴이 이미 있기 때문입니다.

// outbox-event.mapper.ts — 이건 제대로 파싱한다
const env = eventEnvelopeSchema.parse(row.payload);
// tracked-item.mapper.ts — dispatcher claim row의 tier enum도 검증

즉 계층 전체가 틀린 게 아니라, 분석 쿼리에만 검증이 빠진 겁니다. 수정 방향은 단일 raw-row 파싱 헬퍼를 만들어 모든 $queryRaw 호출부에 element 단위 .parse()를 적용하는 것입니다.

DEFAULT 파티션에 걸린 롤업 버그

감사 항목과 별개로, 감사 직전에 잡은 진짜 버그가 하나 있었습니다. 이게 이번 편에서 가장 실감 나는 이야기입니다. 일 집계와 홈 총액 차트가 계속 비어 있었는데, 원인은 집계 로직이 아니라 PostgreSQL 파티션의 순서 문제였습니다.

배경부터 보겠습니다. item_market_minute_aggregates는 하루 단위 PARTITION BY RANGE 테이블이고, 아직 파티션이 없는 날의 row는 DEFAULT 파티션으로 떨어지도록 설계돼 있습니다. 그리고 롤업은 분 → 시간 → 일 → 전체 순으로 올라갑니다.

flowchart LR
  MIN["minute aggregates<br/>(PARTITION BY RANGE, DEFAULT 있음)"] --> HOUR["hourly"]
  HOUR --> DAY["daily"]
  DAY --> LIFE["lifetime"]
  DAY --> CHART["홈 총액 차트"]
  MIN -. "여기서 정지" .-> X((("PG 23514")))
  X -. "hourly 이후 전부 빈 채로" .-> HOUR

문제의 순서는 이렇습니다. collect-due그날의 파티션이 생기기 전에 분 단위 집계를 write합니다. 그래서 그 row들이 DEFAULT 파티션에 쌓입니다. 그다음 rollupMinuteToHourlyensureMinutePartitions를 호출해 그날의 named 파티션을 만들려는데 — 원래 코드는 이렇게 단순한 CREATE였습니다.

// Before (버그): DEFAULT에 그날 row가 이미 있으면 실패한다
await this.prisma.$executeRawUnsafe(
  `CREATE TABLE IF NOT EXISTS "${partName}"
     PARTITION OF item_market_minute_aggregates
     FOR VALUES FROM ('${fromTs}') TO ('${toTs}')`
);

PostgreSQL은 DEFAULT 파티션이 그 범위의 row를 이미 들고 있는 동안에는 CREATE TABLE ... PARTITION OF를 거부합니다(error 23514, "updated partition constraint for default partition ... would be violated by some row"). 그 결과 그날의 롤업이 영구적으로 막히고, hourly/daily 집계와 그걸 읽는 대시보드·홈 총액 차트가 빈 채로 남았습니다. 집계 SQL은 멀쩡했는데 그 앞 단계에서 막혀 아무것도 못 올라갔던 겁니다.

처음엔 통합 테스트에서 이 문제를 손으로 우회하고 있었습니다. 그게 오히려 버그를 가리는 냄새였습니다.

// Before: 테스트가 write 전에 파티션을 미리 만들어 문제를 숨기고 있었다
await rollupRepo.ensureMinutePartitions(
  new Date('2026-06-20T08:00:00Z'),
  new Date('2026-06-20T09:00:00Z')
);
// ↑ 프로덕션의 collect-due는 이렇게 미리 안 만든다 → 테스트가 실상황과 달랐다

고친 방향은 "DEFAULT에 row가 있으면 그날을 도려내서 named 파티션으로 옮긴다"입니다. 파티션이 없고 DEFAULT에 그날 row가 있을 때만, DETACH → CREATE → migrate → 재-ATTACH를 한 트랜잭션으로 처리합니다. 흔한 미래-날짜 경우는 fast path로 그냥 CREATE만 합니다.

// After (fix): fast path와 self-heal path를 분기
const existsRows = await this.prisma.$queryRawUnsafe<{ exists: boolean }[]>(
  `SELECT to_regclass('${partName}') IS NOT NULL AS exists`
);
if (!existsRows[0]?.exists) {
  const orphanRows = await this.prisma.$queryRawUnsafe<{ has_rows: boolean }[]>(
    `SELECT EXISTS (
       SELECT 1 FROM item_market_minute_aggregates_default
       WHERE bucket_start >= '${fromTs}' AND bucket_start < '${toTs}'
     ) AS has_rows`
  );
  const defaultHasRows = orphanRows[0]?.has_rows ?? false;
  // defaultHasRows === false → 그냥 CREATE (미래 날짜 흔한 경우)
  // defaultHasRows === true  → 아래 self-heal 트랜잭션
}

self-heal 경로가 핵심입니다.

// After (fix): DEFAULT를 떼고, 파티션을 만들고, 갇힌 row를 옮기고, 다시 붙인다 — 한 트랜잭션
await this.prisma.$transaction(async (tx) => {
  await tx.$executeRawUnsafe(
    `ALTER TABLE item_market_minute_aggregates
       DETACH PARTITION item_market_minute_aggregates_default`
  );
  await tx.$executeRawUnsafe(
    `CREATE TABLE "${partName}"
       PARTITION OF item_market_minute_aggregates
       FOR VALUES FROM ('${fromTs}') TO ('${toTs}')`
  );
  await tx.$executeRawUnsafe(
    `WITH moved AS (
       DELETE FROM item_market_minute_aggregates_default
       WHERE bucket_start >= '${fromTs}' AND bucket_start < '${toTs}'
       RETURNING *
     )
     INSERT INTO item_market_minute_aggregates SELECT * FROM moved`
  );
  await tx.$executeRawUnsafe(
    `ALTER TABLE item_market_minute_aggregates
       ATTACH PARTITION item_market_minute_aggregates_default DEFAULT`
  );
});

그리고 통합 테스트도 "실상황을 그대로 재현"하도록 바꿨습니다. 파티션을 미리 만들지 않고, 프로덕션의 collect-due처럼 파티션 없이 write해서 row를 DEFAULT에 떨어뜨린 뒤, 롤업이 throw 없이 self-heal하는지를 회귀 테스트로 못박았습니다.

// After: 회귀 테스트 — row가 정말 DEFAULT에 갇혔는지 확인 후, 롤업이 도려내는지 검증
it('carves DEFAULT-partition rows into a named partition during rollup (regression: PG 23514)', async () => {
  // 파티션 없이 write → DEFAULT로 떨어짐 (프로덕션 순서 재현)
  await prisma.itemMarketMinuteAggregate.create({
    /* bucket 2026-06-17T05:10Z */
  });
 
  // 롤업이 throw하지 않고 hourly bucket을 만들어야 한다
  await expect(rollupRepo.rollupMinuteToHourly(/* 05:00 ~ 06:00 */)).resolves.toBeGreaterThan(0);
 
  // row가 DEFAULT에서 named 일 파티션으로 도려내졌는지 확인
  // → item_market_minute_aggregates_2026_06_17 존재, DEFAULT에는 0건
});

여기서 얻은 교훈이 감사 관점과 이어집니다. 이 버그는 "집계 로직이 틀려서"가 아니라 테스트가 실상황과 다른 순서로 돌고 있어서 오래 숨어 있었습니다. 감사가 "무엇이 정상인가"를 명시적으로 적게 만드는 것처럼, 테스트도 "프로덕션과 같은 순서로 실패해 봐야" 진짜 문제를 드러냅니다.

부채에 우선순위를 매기는 법

HIGH 4건과 MEDIUM/LOW를 어떤 순서로 갚을지가 마지막 문제였습니다. 여기서 심각도(HIGH/MEDIUM/LOW)를 그대로 처리 순서로 쓰지 않았습니다. 심각도는 "얼마나 위험한가"이지 "먼저 손대야 하는가"가 아니기 때문입니다.

대신 두 축으로 정렬했습니다.

flowchart TD
  START["부채 항목"] --> Q1{"correctness에<br/>직접 영향?"}
  Q1 -->|예| P1["최우선<br/>(HIGH-2 에러분류,<br/>HIGH-4 raw SQL)"]
  Q1 -->|아니오| Q2{"단일 경계로<br/>독립 배포 가능?"}
  Q2 -->|예| P2["경계별 순차<br/>(HIGH-1 consumer,<br/>HIGH-3 의존 차단)"]
  Q2 -->|아니오| SPLIT["먼저 쪼갠다<br/>→ 쪼갤 수 없으면 미룬다"]
  P1 --> DONE["Phase로 배치"]
  P2 --> DONE
  • 첫째 축 — correctness가 먼저. 데이터를 조용히 오염시킬 수 있는 것부터입니다. 그래서 collect-due 에러 분류(HIGH-2)와 raw SQL 검증(HIGH-4)이 Phase 1·2로 앞섰습니다. consumer 미배선(HIGH-1)은 심각도는 HIGH지만 지금 collect-due가 그 일을 대신하고 있어 데이터 손실이 없으므로 Phase 3으로 뒤로 뒀습니다.
  • 둘째 축 — 단일 경계로 독립 배포 가능한가. 각 Phase는 하나의 경계에만 손대고, 좁은 검증 명령 하나로 닫히게 설계했습니다. 폴더 대이동이나 새 추상화 신설은 경계 문제가 입증된 곳(HIGH-3의 item-neople-client 추출)에만 허용했습니다.

이렇게 나온 8개 Phase의 뼈대는 이렇습니다.

flowchart LR
  P1["Phase 1<br/>collect-due 에러분류<br/>(HIGH-2)"] --> P2["Phase 2<br/>raw SQL 검증<br/>(HIGH-4)"]
  P2 --> P3["Phase 3<br/>consumer 완성<br/>(HIGH-1)"]
  P3 --> P4["Phase 4<br/>app/command 경계"]
  P4 --> P5["Phase 5<br/>Neople 의존 차단<br/>(HIGH-3)"]
  P5 --> P6["Phase 6<br/>태깅 일관성"]
  P6 --> P7["Phase 7<br/>event fallback"]
  P7 --> P8["Phase 8<br/>presentation 다듬기"]

각 Phase가 pnpm nx run <project>:test 하나로 닫히도록 범위를 좁힌 게 포인트입니다. "리팩터링 대공사"를 8개의 작고 되돌리기 쉬운 커밋으로 쪼갠 셈입니다.

정리하면

이번 편의 결론은 제목 그대로입니다 — 무엇을 고칠지보다, 무엇을 일부러 안 고칠지를 판정하는 게 좋은 감사였습니다.

  • 백엔드가 커졌다고 곧장 갈아엎지 않고, 경계별 증거 기반 감사로 충동을 반박했습니다. 결과는 CRITICAL 0건, 전면 리팩터링 불필요.
  • HIGH 4건은 "구조 결함"이 아니라 "설계는 맞는데 절반이 배선 안 된" 완성도 공백이었고, 각 항목마다 "왜 CRITICAL이 아닌가"를 적어 과잉 대응을 막았습니다.
  • 감사와 별개로, 분 단위 집계가 DEFAULT 파티션에 갇혀 롤업이 영구 정지한 버그를 잡았습니다. 원인은 집계 로직이 아니라 write와 파티션 생성의 순서, 그리고 그걸 가리던 테스트였습니다.
  • 부채는 심각도가 아니라 correctness → 독립 배포 가능성 순으로 정렬해, 대공사를 8개의 작은 Phase로 쪼갰습니다.

"정상인 곳을 정상이라고 명시적으로 적는다"는 게 이번 감사의 가장 큰 수확이었습니다. 그래야 다음에 또 "다 갈아엎고 싶은" 충동이 왔을 때, 열지 않아도 되는 문 앞에서 멈출 수 있습니다.

다음 편에서는 다시 화면 쪽으로 넘어가, 수십억 골드를 그대로 찍으면 아무도 못 읽는 문제 — 조·억·만 한국식 단위 절삭과 숫자 포매팅을 다루겠습니다.

같이 보면 좋은 글