우리 백엔드는 외부 벤더 시스템 4개에서 데이터를 받아 멀티테넌트 DB로 동기화한다. 하나는 SSH 터널 너머 DB를 직접 읽는 레거시 온프레미스 시스템이고, 나머지 셋은 HTTP API인데 각자 성격이 다르다. 하나는 터널 뒤에서 EUC-KR로 응답하고, 하나는 토큰 로그인을 쓰고, 하나는 정적 토큰을 쓰는 원내 어댑터다.
각 연동은 NestJS 모노레포 안에서 독립 모듈로 산다. 그리고 새 연동은 전부 직전 연동 모듈을 복사해서 시작됐다. 두 번째 벤더까지는 이게 합리적인 출하 방식이다. 네 번째 벤더쯤 되니, 체감은 되는데 측정은 안 해본 문제가 하나 생겨 있었다.
그래서 손대기 전에 먼저 측정했다.
감사(audit)부터
네 모듈의 파일을 전부 훑으면서 하나씩 분류했다. 이 파일은 벤더 지식인가(그쪽 API 형태, 인증, 이상한 상태 코드), 아니면 어느 벤더든 똑같이 생겼을 오케스트레이션인가.
| 모듈 | 프로덕션 LOC | boilerplate 추정 |
|---|---|---|
| 벤더 A (레거시 DB) | ~10,400 | 20~30% |
| 벤더 B (HTTP + 터널) | ~4,300 | ~50% |
| 벤더 C (REST + 토큰) | ~3,000 | 60~70% |
| 벤더 D (원내 어댑터) | ~4,300 | ~45% |
흥미로운 건 총량이 아니라 중복이 어디에 사는가였다. 나는 HTTP 클라이언트와 transformer가 문제일 거라 예상했다. 틀렸다. transformer는 anti-corruption 경계라서 벤더마다 진짜로 다른 스키마를 매핑한다. 그 코드는 제 몫을 하는 코드다. 진짜 중복은 그 주변에 있었다.
- 벤더 쪽에서 사라진 레코드를 감지해 soft-delete하는 "reconcile" 컴포넌트가 두 모듈에 거의 글자 단위로 복사돼 있었다. 두 번째 사본의 주석에는 대놓고 "다른 벤더와 같은 패턴"이라고 적혀 있었다.
- 스케줄러 서비스 두 개가 주석까지 동일했다. 다른 건 벤더 enum과 로그 문자열뿐.
- 계정 연동에 쓰는 직원 조회 서비스가 세 벌, 각각 50줄 안팎, 구조는 사실상 100% 동일.
-
getToday,addDays,formatDate,chunk같은 잔챙이 헬퍼가 14개 파일에 private 메서드로 복붙돼 있었다.
한 가지가 눈에 들어왔다. 우리는 벤더를 빠뜨리는 실수에 대해서는 이미 컴파일 타임 방어가 있었다. 라우팅 지점이 전부 Record<VendorType, Port> 맵이라 enum에 값을 추가하고 어댑터 등록을 빼먹으면 빌드가 깨진다. 벤더별 계약 불변식을 강제하는 테스트 게이트도 있다. 이 장치들은 잘 작동한다. 다만 이들이 강제하는 건 존재이지 유일성이 아니다. 파일 20개를 복붙해서 계약을 충족하는 것은 아무도 막지 않는다.
함정 1: "동일한" 헬퍼가 동일하지 않았다
당연한 첫수는 날짜 헬퍼를 공용 util로 올리는 것이다. 쉬워 보인다.
거의 쉬웠다. 14개 사본을 나란히 놓고 읽어보니 getToday에 두 가지 변형이 있었다. 대부분은 "지금 시각을 비즈니스 타임존으로 시프트한 값"을 반환했는데, 두 사본은 거기에 setHours(0,0,0,0)으로 자정 절삭까지 하고 있었다. 이름도 같고 주석도 같은데 값이 다르다.
이걸 함수 하나로 합쳤다면 어떤 스케줄 윈도우는 조용히 어긋나고, 어떤 타임스탬프 비교는 조용히 바뀌었을 것이다. 그래서 공용 util은 차이를 실토하는 이름으로 둘 다 export한다.
export function getKstNow(): Date { /* 타임존 시프트된 now */ }
export function getKstTodayStart(): Date { /* 시프트 + 자정 절삭 */ }
formatDate도 같은 이야기다. 세 모듈은 YYYYMMDD를, 한 모듈은 YYYY-MM-DD를 만들고 있었다. 함수 두 개로 두었다. 변형 두 개를 하나로 "정리"하는 순간 그것은 더 이상 리팩토링이 아니다. 리팩토링의 탈을 쓴 동작 변경이다.
같은 커밋에서 나온 작은 함정 하나 더. 언젠가 10분을 아껴줄지도 모르니 적어둔다. this.chunk(...)를 import 함수로 바꾸다가 for (const chunk of chunk(codes, SIZE)) 형태의 호출부가 깨졌다. 루프 변수가 자기 초기화식 안에서 import를 가리는 temporal dead zone 에러다. 루프 변수 이름을 바꾸고 넘어갔다.
함정 2: "동일한 스케줄러 3개" 중 하나는 아니었다
감사 노트에는 HTTP 벤더 스케줄러 3개가 전부 복붙이라고 적혀 있었다. 둘은 정말 그랬다. 같은 크론 패턴, 같은 테넌트 루프, 같은 실행 추적 전이, 주석까지. 그런데 세 번째는 멀리서 보면 비슷한데 구조가 달랐다. 동기화를 인라인으로 실행하는 게 아니라 cascade 잡을 enqueue하고 나머지는 큐 워커에게 맡긴다.
일치하는 둘만 추상 베이스로 뽑았다.
export abstract class SyncSchedulerBase {
// 소유: 테넌트 루프, 실행 추적, master -> records 실행, 에러 삼킴
protected async runScheduled(trigger: string): Promise<void> { /* ... */ }
}
@Injectable()
export class VendorBScheduler extends SyncSchedulerBase {
// 소유: 크론 선언 (분산 락 이름이 벤더별로 유니크해야 함)
@DistributedCron("0 6 * * *", { name: "vendor-b-morning" })
async morning() { await this.runScheduled("morning"); }
}
크론 데코레이터는 일부러 서브클래스에 남겼다. 우리 분산 크론 래퍼는 name을 Redis 락 키로 쓰기 때문에 벤더별로 유니크해야 하고, 데코레이터 메타데이터는 어차피 상속이 깔끔하지 않다.
세 번째 스케줄러는? 그대로 두었다. 베이스에는 왜 제외했는지 주석으로 남겼다. 억지로 베이스에 넣으려면 "인라인 vs enqueue"를 가르는 훅이 필요해지는데, 그 순간부터 추상화 비용이 중복 비용을 넘어선다. 사전에 만든 감사 문서에 "하지 말 것" 목록이 있었는데, 이 목록이 정말 필요해지는 시점이 바로 리팩토링 도중이다. 완결주의 충동은 꼭 그때 온다.
재시도 코어: 베이스 클래스가 아니라 함수
두 HTTP 클라이언트의 재시도 뼈대는 같았다. 3회 시도, [1000, 2000, 4000] backoff, private sleep. 상수도 루프도 동일하다. 하지만 루프를 둘러싼 에러 처리는 진짜로 달랐다.
- 벤더 B는 터널 레벨 연결 실패에 즉시 fail-fast한다. 죽은 터널에 재시도해봐야 호출당 9초를 태우고 상위 RPC 예산만 날리기 때문에, 즉시 경보를 보내고 도메인 에러를 던진다.
- 벤더 C는 404를 "데이터 없음"(벤더 관례상 정상적인 빈 결과)으로 변환하고, 401에는 토큰 캐시를 폐기하며, 네트워크 에러·401·5xx만 재시도한다.
여기서 유혹은 AbstractHttpClient다. 안 했다. 한쪽은 array buffer에서 EUC-KR을 디코드하고 다른 쪽은 토큰 로그인을 관리한다. 공용 베이스를 만들면 template method 훅만 가득하고 실제로 공유되는 알맹이는 없다. 대신 공용층에는 함수 하나만 두었다.
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options?: {
maxAttempts?: number;
delaysMs?: readonly number[];
// false 반환: 원본 에러로 즉시 실패.
// throw: *당신의* 에러로 즉시 실패 (터널 fail-fast 용).
isRetryable?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise<void>;
},
): Promise<T>
두 클라이언트를 모두 수용하게 해준 계약은 isRetryable이 throw할 수 있다는 것이었다. 벤더 B의 터널 체크는 훅 안에서 경보를 보내고 도메인 에러를 던진다. 그리고 코어는 최종 실패를 로깅하지도, 에러를 래핑하지도 않는다. 원본 에러를 그대로 다시 던지고, 각 호출자가 기존의 "최종 실패" 로그 라인과 에러 메시지 형식을 유지하게 했다. 덕분에 diff가 읽기 좋았다. 루프는 이동했지만 동작은 안 변했다.
결과
커밋 다섯 개로: 공용 util 층(날짜, 본문 정제, chunk, retry), reconcile 컴포넌트 두 벌 → 한 벌, 스케줄러 복사본 두 벌 → 베이스 하나, 직원 조회 세 벌 → 제네릭 베이스 하나, 손으로 만 재시도 루프 두 벌 → 코어 하나.
코드베이스 순 효과는 약 380줄 감소, 테스트 수는 오히려 증가다(공용 헬퍼마다 단위 테스트를 새로 붙였다). 호출자 변경은 0건. 클래스명, DI 토큰, public 메서드가 전부 제자리라서 리뷰도 조용히 지나갔다.
내가 진짜 신경 쓰는 숫자는 온보딩 비용이다. 전에는 HTTP 벤더 하나를 추가하려면 가장 비슷한 기존 모듈에서 파일 20개쯤을 복사해야 했다. 이제 그 복붙 층은 대부분 사라졌고, 남은 작성 대상은 진짜 벤더 고유물이다. transformer, HTTP 클라이언트의 transport 특이점, 그리고 배선.
배운 것
- 컴파일 타임 레지스트리와 계약 테스트 게이트는 훌륭하지만, 이들이 막는 건 누락이지 중복이 아니다. 두 번째 문제에는 다른 도구가 필요하고, 그 도구는 감사와 의도적인 추출이다.
- 추상화 전에 측정하라. "동일한 스케줄러 3개" 중 둘만 동일했다. 셋째는 우연히 비슷하게 들리는 다른 설계였다.
- 헬퍼에 변형이 있으면 변형을 유지하고 이름으로 실토하라. 합치는 것은 정리가 아니라 동작 변경이다.
- 추상화 경계는 지식 경계에 두어라. 벤더 지식(인증, 인코딩, 상태 코드 관례)은 훅과 서브클래스로, 오케스트레이션(루프, 실행 추적, backoff)은 공용층으로.
- HTTP 재시도에는 훅 두 개짜리 얇은 함수가 베이스 클래스를 이겼다. transport가 이만큼 다르면 상속이 공유하는 것은 로직이 아니라 격식이다.
Top comments (0)