개발
home
🌳

Strangler Pattern으로 레거시 API 안전하게 이관하기

Created
2026/08/06
Tags
Design Pattern
Facade Pattern
Proxy Pattern
Kotlin
SpringBoot
2026-08-06

개요

레거시 시스템을 새로 짓는 일은 대부분 "이번엔 제대로 만들자"로 시작해서 "일단 이번 분기까지만 버티자"로 끝납니다. 한 번에 갈아엎는 빅뱅 재작성(Big Bang Rewrite) 은 개발 기간 동안에도 레거시가 계속 변하기 때문에, 목표 지점이 계속 도망가는 구조적 문제를 안고 있어요.
스트랭글러 패턴(정식 명칭은 Strangler Fig Pattern)은 이 문제를 시간축으로 분산시키는 마이그레이션 패턴입니다. 기존 시스템 앞에 라우팅 계층을 두고, 기능을 하나씩 새 시스템으로 옮기면서, 최종적으로 기존 시스템을 걷어내요. 이름은 Martin Fowler가 호주 퀸즐랜드 열대우림의 교살 무화과나무(Strangler Fig) 에서 따왔습니다. 이 나무는 숙주 나무의 틈에서 발아해 숙주를 감고 자라다가, 결국 숙주를 완전히 대체하고 홀로 서게 돼요.
이 글에서는 특히 API 이관 관점에서 이 패턴을 어떻게 실무에 적용하는지 정리해요. 라우팅 전략, Shadow 검증, 데이터 동기화, 그리고 이 패턴이 실패하는 방식까지 다룹니다.

본문

왜 빅뱅 재작성은 실패하는가

빅뱅 재작성의 실패는 개발자의 실력 문제가 아니라 산술 문제입니다.
목표가 움직인다: 재작성에 1년이 걸리면 그 1년 동안 레거시에도 기능이 계속 추가돼요. 완성 시점의 스펙은 착수 시점의 스펙이 아닙니다.
가치가 늦게 나온다: 전환 D-Day까지 사용자에게 전달되는 가치가 0입니다. 그동안 조직의 인내심은 계속 줄어들어요.
리스크가 한 점에 몰린다: 전환 시점에 모든 위험이 응축됩니다. 롤백은 "전부 되돌리기" 외에 선택지가 없어요.
불필요한 동작까지 복제한다: Fowler는 레거시 동작의 상당 부분이 사실 아무도 원하지 않는 동작이라고 지적해요. 그걸 충실히 재현하는 건 순수한 낭비입니다.
스트랭글러 패턴은 이 네 가지를 모두 뒤집습니다. 각 단계가 작고, 매 단계마다 가치가 나오고, 롤백 단위가 기능 단위로 쪼개지며, 옮길 가치가 없는 기능은 옮기지 않고 버릴 수 있어요.

동작 방식: 감싸고, 옮기고, 걷어낸다

패턴 자체는 네 단계로 아주 단순합니다.
1.
Facade(Proxy) 삽입: 클라이언트와 레거시 사이에 라우팅 계층을 둡니다. 클라이언트는 이 계층만 바라봐요. 초기에는 100% 레거시로 흘려보냅니다.
2.
기능 단위 이전: 특정 엔드포인트를 새 시스템에 구현하고, Facade에서 그 엔드포인트만 새 시스템으로 라우팅해요.
3.
점진 확장: 이전 대상을 늘려가며 레거시의 책임 범위를 줄입니다.
4.
레거시 제거 → Facade 제거: 모든 트래픽이 새 시스템으로 향하면 레거시를 내리고, 마지막에 Facade까지 걷어내 클라이언트가 새 시스템과 직접 통신하게 해요.
중요한 건 4단계입니다. Facade는 transitional architecture, 즉 한시적으로만 존재해야 할 구조물입니다. 이걸 영구 구조물로 만드는 순간 패턴이 안티패턴으로 바뀌어요.

Facade를 어디에 둘 것인가

API 이관에서 가장 먼저 결정해야 할 건 "라우팅을 어느 레이어에서 할 것인가"입니다. 선택지는 대체로 세 가지예요.
위치
장점
단점
인프라 레이어 (Nginx / ALB / API Gateway)
애플리케이션 코드 변경 없음, 성능 오버헤드 최소
라우팅 조건이 경로와 헤더 수준으로 제한, 비즈니스 로직 기반 분기 어려움
애플리케이션 레이어 (Gateway 서비스 / BFF / Filter)
사용자 코호트, 피처 플래그 등 정교한 분기 가능, 로깅과 비교 로직 삽입 용이
홉이 하나 늘어남, 그 자체가 SPOF 후보
클라이언트 (앱/웹에서 직접 분기)
서버 인프라 변경 불필요
배포 주기가 앱 심사에 묶임, 롤백이 즉시 불가 → 비추천
클라이언트 분기는 실질적으로 롤백 불가에 가깝습니다. 앱 강제 업데이트 없이는 잘못된 라우팅을 되돌릴 수 없어요. 반드시 서버 측에서 제어권을 쥐어야 합니다.
실무에서는 인프라 레이어에서 경로 단위 대분류를 하고, 애플리케이션 레이어에서 코호트와 비율 단위 세분류를 하는 2단 구성이 무난해요.

라우팅 전략: 무엇을 기준으로 나눌 것인가

라우팅 기준은 이관의 리스크 프로파일을 결정해요. 대표적인 네 가지입니다.
경로 기반: /v2/orders처럼 신규 경로를 파는 방식. 가장 단순하지만 클라이언트 수정이 필요해요.
헤더 기반: X-Api-Version: v2 같은 헤더로 분기. URL을 건드리지 않아 클라이언트 영향이 적습니다.
코호트 기반: 사내 직원 → 베타 옵트인 → 특정 등급 → 전체 순으로 확대. 문제가 터졌을 때 영향 범위가 예측 가능해요.
비율 기반: 사용자 ID 해시로 1% → 5% → ... → 100%. 코호트를 다 소진한 뒤 마지막 단계에서 사용합니다.
비율 기반 라우팅에서 반드시 지켜야 할 건 결정론적 분기예요. 요청마다 랜덤으로 나누면 같은 사용자가 어떤 요청은 신규, 어떤 요청은 레거시로 가서 세션과 캐시 정합성이 깨집니다. 사용자 ID 해시로 고정해야 해요.
@Component class MigrationRouter( private val featureFlagClient: FeatureFlagClient, ) { fun resolveTarget( endpoint: String, userId: Long, ): MigrationTarget { if (!featureFlagClient.isEnabled("migration.$endpoint.enabled")) { return MigrationTarget.LEGACY } val cohort = featureFlagClient.getCohort(userId) if (cohort == Cohort.INTERNAL || cohort == Cohort.BETA) { return MigrationTarget.NEW } // 사용자 단위 고정 분기: 같은 사용자는 항상 같은 시스템으로 val bucket = Math.floorMod(userId.hashCode(), 100) val rolloutPercentage = featureFlagClient.getInt("migration.$endpoint.percentage") return if (bucket < rolloutPercentage) { MigrationTarget.NEW } else { MigrationTarget.LEGACY } } }
Kotlin
복사
라우팅 비율은 반드시 런타임에 바꿀 수 있어야 합니다. 설정값을 코드에 박아두면 롤백에 배포 시간이 그대로 붙어요. 피처 플래그(Growthbook, LaunchDarkly 등)나 최소한 동적 설정 서버를 붙이세요.

Shadow Traffic: 사용자에게 노출하기 전에 검증하기

신규 API를 만들었다고 바로 트래픽을 태우는 건 도박입니다. Shadow Traffic(Dark Launch) 은 프로덕션 요청의 복사본을 신규 시스템에도 흘려보내되, 응답은 버리고 레거시 응답만 사용자에게 반환하는 기법이에요.
사용자는 100% 레거시 응답을 받으므로 리스크 0
실제 프로덕션 트래픽 패턴으로 검증: 합성 테스트가 절대 못 잡는 엣지 케이스가 잡혀요
두 응답을 비교(diff)해서 불일치율을 측정할 수 있습니다
호출 주체를 먼저 정리하고 넘어갈게요. 앞의 MigrationRouter는 목적지를 판단만 하지 직접 호출하지는 않습니다. 라우터가 Shadow까지 부르면 조회 함수가 네트워크 부수 효과를 갖게 되고, 라우팅 로직만 테스트하려 해도 Shadow 클라이언트를 목킹해야 합니다. 호출과 미러링은 그 위 Facade의 몫입니다.
요청 → Facade → 레거시 호출 ──────────→ 응답 (사용자) └→ (비동기) 신규 호출 → 비교 → 메트릭 / diff 적재
Kotlin
복사
Shadow 응답은 사용자에게 돌아가지 않아요. 어디까지나 비교용이라 쓰고 버립니다. 그래서 이 시기에는 라우터가 100% 레거시를 가리키고 있고, 비율을 올리는 건 Shadow를 졸업한 다음 단계예요.
@Component class OrderFacade( private val migrationRouter: MigrationRouter, private val legacyOrderClient: LegacyOrderClient, private val newOrderClient: NewOrderClient, private val shadowComparator: ShadowComparator, ) { fun getOrder( request: OrderRequest, userId: Long, ): OrderResponse { val target = migrationRouter.resolveTarget( endpoint = "orders", userId = userId, ) if (target == MigrationTarget.NEW) { return newOrderClient.getOrder(request) } val legacyResponse = legacyOrderClient.getOrder(request) if (migrationRouter.isShadowEnabled("orders")) { // 반환값을 쓰지 않는다. 비교하고 버린다 shadowComparator.compareAsync( request = request, legacyResponse = legacyResponse, traceId = MDC.get("traceId"), ) } return legacyResponse } }
Kotlin
복사
이미 신규로 라우팅된 요청(NEW)에는 Shadow를 걸지 않아요. 거기까지 갔다는 건 이미 불일치율 기준을 통과했다는 뜻이고, 그 뒤에도 레거시를 계속 부르면 걷어내려는 시스템에 부하를 그대로 유지시키는 셈입니다. 전환 단계부터는 diff 대신 에러율, 레이턴시, 비즈니스 지표가 그 자리를 대신해요.
비교 자체는 Shadow를 호출한 그 자리에서 바로 해요. 양쪽이 서명만 저장소에 남기고 배치로 조인해 대조하는 구성도 있지만, 저장소에 조인 배치에 한쪽만 도착한 건 처리까지 붙는 것치고 얻는 결과는 같습니다. 해시 두 개 비교라 인라인으로 해도 부담이 없어요.
핵심은 비동기 fire-and-forget입니다. Shadow 호출이 실패하거나 느려도 사용자 요청에 절대 영향을 주면 안 돼요. 신규 호출을 Facade가 아니라 이 안에 둔 이유도 그것입니다. Facade에서 부르면 그 호출이 동기라 사용자 요청 지연에 그대로 물려요.
@Component class ShadowComparator( private val newApiClient: NewApiClient, private val diffRecorder: DiffRecorder, private val meterRegistry: MeterRegistry, ) { @Async("shadowExecutor") fun compareAsync( request: OrderRequest, legacyResponse: OrderResponse, traceId: String, ) { runCatching { // 신규 호출도 이 안에서. 그래야 지연이 사용자 요청에 물리지 않는다 val newResponse = newApiClient.getOrder(request) if (signatureOf(legacyResponse) == signatureOf(newResponse)) { // 일치: 카운터만 올리고 페이로드는 버린다 meterRegistry.counter("shadow.compare", "result", "match").increment() } else { meterRegistry.counter("shadow.compare", "result", "mismatch").increment() // 불일치: 원본을 통째로 남겨야 나중에 원인을 볼 수 있다 diffRecorder.record( traceId = traceId, endpoint = "/orders", legacyResponse = legacyResponse, newResponse = newResponse, ) } }.onFailure { e -> // Shadow 실패는 절대 사용자 요청에 전파하지 않는다 log.warn("shadow comparison failed. traceId={}", traceId, e) } } // 타임스탬프, 요청 ID 등 매 요청 달라지는 필드는 제외하고 해싱 private fun signatureOf(response: OrderResponse): String { return response.copy( requestId = null, respondedAt = null, ).let { objectMapper.writeValueAsString(it) } .let { DigestUtils.sha256Hex(it) } } }
Kotlin
복사
응답 서명(signature)을 만들 때 매 요청 달라지는 필드는 반드시 제외해야 합니다. 이걸 빼지 않으면 불일치율이 100%로 나와서 아무 정보도 얻지 못해요.
일치한 건과 어긋난 건을 다르게 적재하는 것도 중요해요. 일치한 것까지 페이로드를 쌓으면 프로덕션 트래픽을 두 벌씩 저장하는 셈이라 감당이 안 되고, 반대로 불일치를 해시만 남겨두면 "0.3% 틀렸다"는 사실만 알 뿐 무엇이 틀렸는지는 알 수 없습니다.
알럿은 불일치율만 걸지 말고 비교 카운터 자체가 0이 되는 경우도 같이 잡아두세요. fire-and-forget으로 격리해둔 탓에 Shadow 스레드풀이 포화돼도 곉으로는 아무 증상이 없어서, 검증하고 있다고 믿는 동안 실제로는 아무것도 안 돌아가는 상황이 생겨요.
Shadow를 졸업하는 기준은 대략 다음과 같이 잡습니다.
불일치율 0.1% 미만 (결제, 정산 등 크리티컬 도메인은 0.01% 미만)
불일치가 체계적 패턴 없이 산발적일 것. 특정 조건에서만 틀리는 건 명백한 버그입니다
신규 시스템 P99 레이턴시가 레거시의 120% 이내
커넥션 고갈, 메모리 누수 등 리소스 이상 없음
Shadow는 부수 효과가 없는 조회에만 적용합니다. 쓰기 API에 그대로 쓰면 주문이 두 번 생성되고 결제가 두 번 일어나요. 쓰기 쪽 정합성은 Shadow가 아니라 뒤에 나올 Dual Write와 정합성 대조 배치가 담당하는 영역입니다.

점진적 전환과 자동 롤백 기준

Shadow를 통과했으면 실제 트래픽을 태워요. 여기서 중요한 건 각 단계의 게이트 조건을 사전에 문서로 박아두는 것입니다. 장애 상황에서 "이 정도면 괜찮은 것 같은데?"라는 판단은 대부분 틀립니다.
단계
비율
다음 단계 진입 조건
1
1%
수동 검증 완료, 24시간 무사고
2
5%
24h 유지, 에러율 증가 0.1%p 미만
3
10%
48h 유지, 레이턴시 레거시 대비 110% 이내
4
25%
48h 유지, 인시던트 0건
5
50%
48h 유지, 인시던트 0건
6
100%
96h 유지 후 레거시 read-only 전환
자동 롤백 트리거도 함께 걸어둬요.
지표
임계값
관찰 윈도우
에러율
절대값 1% 초과 또는 베이스라인 대비 +0.5%p
2분 지속
P99 레이턴시
레거시 베이스라인의 2배 초과
5분 지속
가용성
성공 응답 99.5% 미만
즉시
롤백 후에는 30분 쿨다운을 두세요. 없으면 임계값 근처에서 트래픽이 신규레거시로 진동(flapping)하면서 오히려 상황이 악화됩니다.

진짜 어려운 건 데이터다

라우팅은 사실 쉬운 부분이에요. 스트랭글러 마이그레이션이 실제로 무너지는 지점은 두 시스템이 공존하는 기간의 데이터 정합성입니다.
가장 흔한 접근인 Dual Write는 겉보기엔 간단하지만 함정이 많습니다.
레거시 쓰기는 성공했는데 신규 쓰기가 실패하면? → 두 시스템이 조용히 어긋납니다(drift)
두 쓰기를 하나의 트랜잭션으로 묶을 수 없어요 (서로 다른 DB)
재시도 로직이 없으면 유실, 있으면 중복. 멱등성 설계가 필수입니다
그래서 Dual Write를 쓴다면 최소한 이 규칙을 지켜야 해요.
필드 단위로 source of truth를 문서에 명시: "이 컬럼의 정답은 어느 시스템인가"를 코드 짜기 전에 정합니다
레거시(=현재 SoT)에 먼저 쓰고, 신규 쓰기는 비동기 + 지수 백오프 재시도 큐
신규 시스템 쓰기 실패가 사용자 요청을 절대 실패시키지 않도록 격리
주기적 정합성 대조 배치로 drift를 감지 (없으면 어긋난 걸 인지조차 못 합니다)
더 견고한 대안은 CDC(Change Data Capture) 입니다. 애플리케이션이 두 번 쓰는 대신, 레거시 DB의 변경 로그(binlog, oplog)를 구독해 신규 DB로 흘려보내요. 애플리케이션 코드에 동기화 책임이 없어지고, 순서 보장과 재처리가 인프라 레벨에서 해결됩니다.
DB 이관 자체도 스트랭글러로 진행할 수 있어요.
1.
신규 서비스가 도메인 요청을 처리하되, 데이터는 여전히 레거시 DB를 읽고 씁니다
2.
도메인 전용 DB를 만들고 ETL로 초기 적재 + CDC로 동기화. 이 기간 동안 양쪽 정합성을 검증해요
3.
검증 후 신규 DB를 SoT로 승격. 레거시 DB에서 해당 도메인 테이블을 제거합니다
3단계(레거시 테이블 제거)를 넘어가면 롤백 비용이 급격히 올라가요. 테이블과 프로시저 복원 + 변경분 재생(replay)이 필요하기 때문입니다. 레거시 객체 삭제는 검증이 끝난 뒤 의도적으로 수행하는 최종 단계로 취급하세요.

Anti-Corruption Layer: 레거시 언어가 새 시스템을 오염시키지 않도록

공존 기간에는 양방향 호출이 반드시 생겨요. 신규 시스템이 아직 안 옮긴 레거시 기능을 호출해야 하고, 레거시도 이미 옮겨간 기능을 호출해야 합니다.
이때 신규 시스템이 레거시의 데이터 모델과 명명 규칙을 그대로 받아들이면, 새로 만든 시스템이 처음부터 레거시의 부채를 상속받게 돼요. Anti-Corruption Layer(ACL) 는 두 시스템 사이에서 요청과 응답을 번역하는 어댑터로, 이 오염을 차단합니다.
// 레거시 응답의 기괴한 스키마를 신규 도메인 모델로 번역 @Component class LegacyOrderAdapter( private val legacyClient: LegacyOrderClient, ) : OrderOutputPort { override fun findById(orderId: OrderId): Order? { val legacyDto = legacyClient.getOrderInfo(orderId.value) ?: return null return Order( id = OrderId(legacyDto.ORD_NO), status = mapStatus(legacyDto.STAT_CD), // "01", "02" → enum amount = Money(legacyDto.AMT_WON), // 원 단위 Long → 값 객체 ) } private fun mapStatus(code: String): OrderStatus { return when (code) { "01" -> OrderStatus.PENDING "02" -> OrderStatus.PAID "09" -> OrderStatus.CANCELED else -> throw IllegalLegacyStatusException("unknown legacy status code: $code") } } }
Kotlin
복사
ACL이 없으면 STAT_CD = "01" 같은 레거시 표현이 신규 시스템의 도메인 로직 깊숙한 곳까지 퍼집니다. 그러면 레거시를 걷어내도 레거시의 흔적은 영원히 남아요.

이 패턴이 실패하는 방식

스트랭글러 패턴의 가장 흔한 실패는 기술적 실패가 아니라 완주 실패입니다.
영원한 공존: 쉬운 기능만 옮기고 어려운 것들이 레거시에 남아요. 시스템이 두 개가 되었을 뿐, 하나도 줄지 않았습니다. 기술부채는 오히려 늘어납니다.
임시 구조물의 영구화: "일시적"으로 만든 Dual Write 로직과 동기화 배치가 어느새 아무도 건드릴 수 없는 핵심 인프라가 돼요.
아무도 청소하지 않음: 어댑터, 호환 레이어, 피처 플래그가 삭제되지 않고 남아 코드베이스를 절반쯤 마이그레이션된 상태로 고정시킵니다.
Facade가 병목/SPOF로 전락: 라우팅 계층이 모든 트래픽을 통과시키는데 이중화와 모니터링이 부실하면, 이관을 위해 만든 것이 장애 원인이 됩니다.
이를 막는 방법은 결국 프로세스예요.
이관 대상 엔드포인트 목록을 만들고 잔여 개수를 대시보드로 노출: 진척률이 보이지 않으면 멈춰도 아무도 모릅니다
각 피처 플래그에 제거 기한(expiry date) 을 적어둬요
레거시 제거를 별도 티켓으로 만들고 이관 티켓의 완료 조건에 포함시킵니다
Fowler가 강조했듯, 기술 전환과 함께 조직과 개발 프로세스도 함께 바뀌어야 합니다. Conway's Law는 여기서도 작동해요

언제 쓰지 말아야 하는가

좋은 패턴이지만 만능은 아니에요. 다음 경우엔 다른 방법을 찾는 게 낫습니다.
요청을 가로챌 수 없는 구조: 라우팅 계층을 끼워넣을 지점이 없으면 패턴 자체가 성립하지 않아요
레거시 소스 코드에 접근할 수 없을 때: 이미 옮긴 기능을 레거시에서 비활성화하고 내부 호출을 리다이렉트하려면 레거시를 수정할 수 있어야 합니다
시스템이 충분히 작을 때: 2주면 통째로 다시 쓸 수 있는 걸 3개월짜리 점진 이관으로 만드는 건 낭비예요
레거시를 빠르게 완전히 종료해야 할 때: 라이선스 만료, 서버 계약 종료 등 데드라인이 촉박하면 공존 기간을 확보할 수 없습니다

정리

스트랭글러 패턴을 한 문장으로 줄이면 이렇습니다.
감싸고(Facade) → 나눠 보내고(Route) → 대조하고(Shadow) → 걷어낸다(Retire). 빅뱅의 리스크를 시간에 분산시키는 대신, 공존 기간의 복잡도와 완주 의지를 대가로 지불하는 패턴.
실무에서 기억할 것은 세 가지예요.
롤백 가능성이 전부다: 런타임에 라우팅 비율을 되돌릴 수 없다면 그건 스트랭글러가 아니라 그냥 위험한 배포입니다
어려운 건 라우팅이 아니라 데이터다: Dual Write를 쓴다면 필드 단위 source of truth를 코드보다 먼저 문서에 적으세요
마지막 단계까지 가야 이득이 난다: 레거시를 걷어내지 못한 스트랭글러는 시스템 개수만 두 배로 늘린 것입니다

참고 링크