4000줄의 레거시 쿼리, 안전하게 걷어내기

2026-10-06 · 햄햄 (hyemin916)

#레거시#장바구니#Strangler Fig#테스트#리팩터링#Feature Flag#Dual Write#도메인 설계#성능 개선

케이타운포유에 재직하면서 내가 맡은 가장 큰 프로젝트라 하면 장바구니와 주문 개선이 가장 먼저 떠오른다. 이커머스의 핵심 도메인에 깊게 관여할 수 있었고, 뛰어난 개발자들과 함께 복잡한 레거시를 해체하면서 많은 것을 배울 수 있었기 때문이다.

발목잡는 레거시

비즈니스 상황

케이타운포유는 해외 배송 비중이 큰 케이팝 이커머스 플랫폼으로, 배송 정책이 사업의 중요한 부분을 차지한다. 그리고 케이팝 굿즈 특성상 입고 자체가 매우 늦을 때가 있다. 어떤 사용자가 상품 A, B를 샀는데 상품 A는 재고가 있지만 상품B는 한달 후에 입고되어 한달이 지난 뒤에나 배송을 받는 상황이 종종 있었다. 이런 상황에서 사용자들의 만족감을 높이고 이윤을 높이기 위해서 합배송, 분할배송 도입이 중요했으며 물류 시스템을 개선하기 위한 상품 구조의 개선도 필요했다. 또한 주문 워크플로우 자체가 10년이 넘어 사용자 경험 개선 또한 필요한 상태였다.

시스템 상황

그런데 레거시 장바구니 쿼리가 장애물이 되었다. 레거시 장바구니 쿼리는 4,000줄로 이루어져 있으며 그 안에 장바구니, 상품, 주문, 배송 로직이 뒤섞여 있었다. 가격 계산, 상품 구조, 판매 상태 결정 등 비즈니스 로직이 SQL에 섞여 있었으며, 장바구니 뿐 아니라 주문서에서도 이 쿼리를 쓰고 있었다. 오랜 세월동안 누덕누덕 기워진 쿼리는 당연히 변경하기 어려웠고, 자동화된 테스트 또한 거의 불가능했다. 성능 역시 매우 느렸는데, 장바구니 조회 API의 레이턴시는 P95 기준 2.83초였다. 상품 개수가 많을수록 급격히 느려져 장바구니를 비워달라는 CS도 종종 들어왔었다. 상품 주문에 대해 매끄러운 사용자 경험을 제공하고, 다양한 상품 유형과 효율적인 물류로 수익을 극대화하고자 하는 케타포의 미션을 달성하기 위해서 이 쿼리는 무조건 제거되어야 했다.

그래서 어떻게 제거할 것인가?

레거시 쿼리는 거대한 만큼 다양한 유즈케이스에 사용되고 있어 쉽게 제거할 수는 없었다. 한번에 레거시 쿼리를 걷어내는 것을 프로젝트 완료 기준으로 삼으면, 장바구니와 주문을 한꺼번에 개선해야 했다. 그런데 우리 팀은 한번에 모든 것을 바꾸는 개선보다는 점진적으로 새로운 시스템으로 바꿔가는 전략을 선호했다. 비즈니스 가치를 더 빨리 전달할 수 있고, 리스크를 최소화하며, 피드백도 더 빨리 받을 수 있기 때문이다. 따라서 Strangler Fig 방식으로 레거시 쿼리를 제거하고자 했다.

Strangler Fig 방식의 점진적 시스템 전환

Strangler Fig는 마틴 파울러가 사용하기 시작한 용어로 신규 서비스와 레거시 서비스를 공존시키면서 신규 서비스가 레거시 서비스를 점차 대체해나가는 전략을 말한다. 이와 같은 점진적은 전환을 위해서는 독립적으로 이전이 가능한 경계를 만드는 것이 무엇보다 중요했다. 우리는 우리가 개선해야 할 시스템을 분해하여 경계를 정의하고 이를 기준으로 마일스톤을 세웠다. 프로젝트의 마일스톤은 다음과 같았다.

  1. 신규 장바구니 개발 및 레거시 장바구니 Dual Write
  2. 주문서 개선 및 레거시 장바구니 삭제
  3. 주문 Dual Write
  4. 신규 주문 출시

점진적이고 안전한 장바구니 전환

첫번째 과제는 신규 장바구니를 개발하고 레거시 장바구니에서 신규 장바구니로 안전하게 이전하는 것이었다. 이를 위해 우리는 Feature Flag와 Canary Release를 활용했다. 먼저 클라이언트와 서버 사이에 파사드 레이어를 두고, 파사드 레이어에서 일정 비율의 사용자는 신규 장바구니를, 그 외의 사용자는 레거시 장바구니를 사용하도록 만든다. 이 또한 Strangler Fig에서 아이디어를 얻은 전략이다. 케타포는 국가별로 다른 상점을 운영했는데, 초기 버전의 영향 범위를 최소화하기 위해 사용자가 가장 적은 상점에 먼저 배포했다. 그리고 처음에는 1%의 작은 비율로 사용자들이 신규 장바구니를 사용하도록 만든다. 그리고 신규 장바구니 API를 호출할 때마다 신규 장바구니와 레거시 장바구니를 Dual Write하고 두 장바구니의 데이터를 비교해서, 불일치가 발생하면 해당 유저의 Feature Flag를 끈다. Feature Flag가 꺼진 유저는 다시 레거시 장바구니 페이지로 리다이렉트되어 기존 장바구니를 사용하게 된다. 로직에 대한 플로우 차트를 아래 그림에서 확인할 수 있다.

Feature Flag와 Dual Write를 활용한 장바구니 전환 흐름

Feature Flag 구현에는 생각보다 복잡한 로직이 많이 필요했다. 먼저, 사용자의 Feature Flag를 켜는 기준을 세우는 것이 까다로웠다. 특정 확률로 사용자를 선별해서 Feature Flag를 켜되, 장바구니 불일치로 인해 Feature Flag가 꺼진 사용자도 다시 신규 장바구니를 사용할 수 있어야 했다. 따라서 레거시 장바구니가 비워져 있는 모든 사용자를 후보로 허용했다. 우리 서비스는 일주일 단위로 장바구니가 비워졌기 때문에, 시스템에 의해 Feature Flag가 강제적으로 꺼진 사용자도 일주일이 지나 레거시 장바구니가 비워지면 다시 신규 장바구니를 이용할 수 있었다. 또한 불일치의 원인을 추적하기 위해 불일치가 발생할 경우 이를 로그로 출력도 했다.

또 다른 어려움은 신규 장바구니와 레거시 장바구니의 행위의 불일치를 제거하는 것이었다. 일단, 시스템적 한계로 인해 어떤 불일치는 사실상 일치하는 것으로 여겨져야 했다. 예를 들면 레거시 장바구니에서는 가격 계산에 Double 타입을 쓰고 신규 장바구니에서는 BigDecimal 타입을 썼기 때문에 가격이 환전되면 불일치가 생길 수 밖에 없었다. 따라서 가격은 0.05의 차이까지는 허용해야 했다. 그런데 이와 같이 일정 범위 오차는 일치하는 것으로 여겨도, 신규 시스템과 기존 시스템의 동작을 100% 일치시키기는 불가능했다. 최대한 신규 시스템이 레거시 시스템과 동일하게 동작하도록 수정했지만, 기존 시스템 자체가 가진 버그도 있었기 때문이다. 결국 불일치 케이스가 줄어들수록 남은 케이스를 해결하기는 점점 어려워졌다. 아래 지수함수 그래프처럼, 마지막 5%의 불일치를 없애는 데 막대한 시간이 들 것이었다.

마지막 불일치를 제거하는 데 필요한 시간의 증가

따라서 정량적인 완료 기준(completion threshold) 를 세웠다. 불일치 비율을 측정하는 쿼리를 작성하여, 불일치 비율이 5% 이하이면 장바구니 이관이 완료된 것으로 여겼다. 이 덕분에 엣지케이스를 처리하는 데에 너무 많은 시간을 쏟지 않을 수 있었다. 또한 불일치 케이스를 문서화하고 이에 대한 이유를 작성하여 PM과 공유하여, 팀원 모두가 엣지케이스에 대해 인지할 수 있도록 하였다.

이 전략은 많은 트레이드 오프를 수반했다. 먼저 Feature Flag 로직 자체가 복잡했기 때문에 이에 대한 추가적인 운영비용이 들었다. 그리고 신규 API를 사용하더라도 레거시 장바구니를 Dual Write 했기 때문에 레이턴시는 오히려 나빠졌다. 그럼에도 불구하고 우리는 이 전략이 충분한 가치가 있다고 판단했다. 신규 시스템에 대한 버그가 있더라도 레거시 시스템으로의 폴백이 동작하기 때문에 사용자는 문제없이 서비스를 이용할 수 있었기 때문이다. 덕분에 신규 장바구니를 안전하게, 마음 편히 출시할 수 있었다. 결과적으로 장애없이 장바구니 시스템을 현대화할 수 있었다. 또한 레거시 서비스를 감싼 REST API를 만듦으로서 기존에 모놀리딕으로 개발되어있던 장바구니 페이지를 FE와 BE로 나눌 수 있었고, 따라서 UI/UX 개선이 가능하게 되었다. 또한 JSP, Java, SQL에 흩어져 있었던 비즈니스 로직을 백엔드 코드에 일원화시켜 이전보다 훨씬 변경과 유지보수가 쉬운 시스템으로 만들 수 있었다.

도메인 객체를 수호하기

장바구니 시스템을 이관하면서, 가장 중요한 점 중 하나는 이전의 문제를 반복하지 않는 것이었다. 이전에는 비즈니스 로직이 프론트엔드, 백엔드, 인프라스트럭처(SQL)에 전부 흩어져 있어서 변경하기가 매우 까다롭고 자동화된 테스트가 아주 어려웠다. 따라서 새로운 장바구니 프로젝트는 인프라스트럭처와 도메인을 잘 분리하여 테스트 용이한 코드를 작성하는 데에 초점을 맞췄다.

이전 프로젝트에서는 JPA Entity를 도메인 객체로 활용했었다. 그런데 장점보다는 단점을 더 많이 느꼈다. 먼저, 암시적으로 내포된 Hibernate만의 기능이 너무 많았다. 도메인 객체를 빠르고 안전하게 수정하기 위해서는 Hibernate의 동작을 완벽히 파악하고 있어야 했다. Spring 개발자라면 JPA와 Hibernate 정도는 당연히 잘 알아야 한다고 말할수도 있겠지만, 도메인 로직을 수정하기 위해 프레임워크를 잘 알아야한다는 사실 자체가 우발적 복잡성과 본질적 복잡성이 분리되지 않았다는 사실을 방증한다. 또한 도메인 객체를 불변으로 만들 수 없는 것도 문제였다. 불변을 통한 스레드 안정성, 예측 가능성, 참조 투명성과 같은 이득을 얻을 수 없다. 그래서 우리는 신규 장바구니를 개발할 때 JPA Entity는 인프라스트럭처에 배치하고, 도메인 객체는 POJO로 만들기로 했다.

도메인 객체를 POJO로 만들고 JPA Entity를 인프라스트럭처에 배치한다면 다음과 같은 단점이 있다. 먼저 매핑(mapping)이 어렵고, 매핑 로직에 비즈니스 로직이 섞일 가능성이 있으며, 장바구니의 프로퍼티가 변경될 때마다 매핑 로직을 수정해야한다는 것이다. AI를 활용한다면 매핑 로직을 작성하는 것은 이전에 비해 훨씬 쉬워졌다. 매핑 로직과 비즈니스 로직이 섞이지 않도록 하는 것에는 개발자로서의 통찰력이 필요하다고 생각한다. 또한 우리 팀은 주기적인 코드 리뷰를 통해 비즈니스 로직의 유출을 감시했다. 도메인 객체에 프로퍼티가 추가될 땐 어쩔 수 없이 수정해야 할 코드가 많았는데, 장바구니 도메인 객체가 SRP를 준수할 수 있도록 프로퍼티를 추가하는 데에 신중을 기했다. 사전에 장바구니의 역할과 책임을 고려하여 객체를 설계하고, 만약에 장바구니에 새로운 필드 추가가 필요하다면 팀원 전원의 합의를 얻도록 했다. 이는 신규 장바구니가 레거시에 대한 의존이 없고, 팀원이 총 4명인 작은 규모의 팀이어서 누릴 수 있는 이득이었다.

Single Source of Truth인 문서, 테스트 코드

우발적 복잡도와 본질적 복잡도를 분리하니 도메인 객체에 대한 테스트가 매우 쉬워졌다. 테스트를 작성하기 쉬워졌으므로, 다양한 테스트 전략을 사용해볼 수 있었다. Test Data Builder, Protocol Driver, Approval Tests 등을 적극적으로 활용해보았다. 결과적으로 가장 유용하다고 느낀 패턴은 Test Data Builder였다. 구현이 쉽고 직관적이었기 때문이었다. 여기서 주의해야할 점은 Test Data Builder를 구현할 때 값을 단순한 setter에 전달하는 것처럼 작성하지 않고, 테스트 코드가 비즈니스 언어처럼 읽히도록 하는 것이다.

단순한 Setter로서의 Test Data Builder

anOrder()
		.withShop(ShopCode.GLOBAL)
        .withDeliveryCode(Country.USA)
        .build();

비즈니스 문서로서의 Test Data Builder

anOrder()
		.globalShop()
        .deliveredToUsa()
        .build();

위와 같이 테스트 코드를 비즈니스 언어와 가깝게 작성한다면 테스트 코드 그 자체가 Single Source of Truth인 비즈니스 문서가 된다. AI 시대의 개발에서는 '신뢰(trust)'가 병목이라고들 한다. 그런데 테스트 코드를 문서처럼 작성한다면 우리는 신뢰 가능한 스펙을 얻게 된다. 물론 모든 유즈케이스에 이 전략이 유효하다고 보지는 않는다. 코드 생산 속도가 기하급수적으로 빨라진 지금, 테스트하기 좋은 도메인 객체를 설계하는 일은 그 자체로 상대적으로 큰 비용이기 때문이다. 하지만 핵심 비즈니스를 담당하는 중요한 도메인 객체라면 이야기가 다르다. 그런 객체에는 테스트 코드를 SSOT 스펙으로 활용해 볼 만하다.

장바구니 쿼리가 제거된 이후

레거시 쿼리의 완전한 제거는 주문(checkout) 페이지까지 이관한 이후에 가능했다. 레거시 쿼리가 제거된 이후, 장바구니 레이턴시가 P95 기준 2.83s에서 203ms로 줄었다. 덕분에 "장바구니가 조회되지 않는다"는 사용자 불만도 사라졌다. 또한 주문 UX/UI를 개선해, 사용자가 주문을 완료하기까지 걸리는 시간이 2분 43초에서 1분 21초로 약 50% 줄었다. 또한 테스트와 변경이 용이한 구조로 코드를 작성한 덕분에 빠른 기능 변경이 가능해져 비즈니스 손실을 막을 수 있었다. 미국향 배송에 관세가 추가되어 실시간으로 손실을 보고 있는 상황에서 영업일 7일만에 관세 기능을 빠르게 추가할 수 있었다. 그 외에도 4000줄의 SQL 쿼리에 강결합 되어있던 상품, 배송 도메인에 대한 제어권이 유연한 구조의 어플리케이션 로직으로 넘어오면서 빠르게 변경하고 실험할 수 있었다. 개인적으로는 난이도 높고 기간이 긴 프로젝트에 참여함으로서 레거시 코드를 생성하는 것에 대한 비용과 이를 안전하게 제거하는 것에 대한 중요성을 알게 되어 뜻깊었다.


원문: 햄햄 · 4000줄의 레거시 쿼리, 안전하게 걷어내기