글 목록

필드 하나 바꾸는데 파일 열 개가 바뀌던 시절

두 서비스가 협력 대신 서로를 번역하느라 DTO가 폭증하던 백엔드를 한 프로세스로 합치고 MyBatis에서 JPA로 옮겼다. 그 앞에 외주사 A사가 왜 실패했는지도 같이 적었다.

주재범12분 읽기

POV: 배차 서비스에 필드 하나 추가하는 PR을 열었다. 바뀐 파일을 세어 보니 그쪽 컨트롤러와 매퍼, DTO에 이어 본체 쪽 전송용 DTO 변형이 넷, 정적 유틸 메서드가 하나 더 늘었다. 필드 하나에 파일 열 개. 서비스를 둘로 나누는 결정은 아키텍처 회의에서 한 번 내려지지만, 그 대가는 이렇게 PR마다 조금씩 청구된다. 경계를 넘는 호출마다 요청 객체와 응답 객체가 한 벌씩 생기고, 상대 서비스의 식별자가 내 도메인에 스며들고, 같은 개념이 양쪽에서 조금 다른 이름으로 자란다. 어느 시점부터 두 서비스는 협력하는 데보다 서로를 번역하는 데 더 많은 코드를 쓴다. 우리 솔루션의 백엔드가 그랬다. 그 둘을 한 프로세스로 되돌리고 영속성 계층을 MyBatis에서 JPA로 옮기면서 무엇을 따졌는지, 그리고 그 앞에 있었던 외주사 A사의 시도가 왜 실패했는지를 적어 둔다.

한 줄 요약: 두 서비스 사이의 HTTP 경계와 그 위에 쌓인 전송용 DTO, 식별자 번역 계층을 걷어내고 한 프로세스로 합쳤다. 문자열 SQL과 메타 테이블에 흩어져 있던 도메인 규칙은 JPA 엔티티와 아키텍처 테스트로 옮겼다. 대가로 배포 단위가 하나가 됐고, ORM의 함정을 팀 규율로 계속 눌러야 하는 비용이 남았다.

배경

우리 솔루션은 주문을 받아 기사와 차량에 배차하고, 운행 결과를 정리해 운임을 계산하는 B2B 물류 플랫폼이다. 도메인은 얇지 않다. 주문 유형과 반복 일정, 배차 계획과 작업 단위, 운행 결과와 검수, 외부 기관 연동까지 규칙이 켜켜이 쌓여 있다.

백엔드는 두 서비스다. 본체는 계정과 회사, 구독, 결제, 외부 기관 연동, 그리고 화면이 쓰는 API를 맡는다. 배차 서비스는 배차 프로세스만 따로 떼어 낸 것으로, 기사와 차량, 장소, 작업 계획, 작업 단위를 들고 있다. 이 분리는 실수가 아니라 의도였다. 배차 프로세스를 공통화해 두면 우리 솔루션 말고 다른 서비스가 와도 그 앞에 붙일 수 있다는 계산이었다. 그 다른 서비스는 없었다. 올 예정이었는데 안 왔다. 재구축을 시작할 때까지 배차 서비스 앞에 붙은 것은 우리 솔루션 하나였고, 두 서비스는 각자 데이터베이스와 배포 단위를 따로 가진 채 본체가 배차 서비스를 HTTP로 호출하고 있었다. 둘 다 MyBatis 위에 XML 매퍼로 SQL을 썼고, 같은 구조의 국가별 사본까지 있어서 같은 도메인이 사실 네 벌 있었다.

이 시스템을 새로 만들자는 결정은 재구축보다 앞서 있었다. 처음에는 해외 외주 개발사 A사에 새 화면 구축을 맡겼고, 그 시도는 반년 만에 접었다. A사 이야기는 뒤에서 다시 나온다. 두 번째 시도의 순서를 정할 때 그 실패가 교과서였기 때문이다. 그 뒤 우리 팀이 구조 설계부터 데이터 이관까지 직접 맡았다. 여기서 재구축은 그 두 번째 시도를 가리킨다.

문제를 느낀 계기

  • 경계가 번역기가 되어 있었다. 본체 안에는 배차 서비스를 부르기 위한 패키지가 따로 있었다. 자원마다 기본형과 조회용, 생성용, 수정용 변형이 따로 있어서 전송용 DTO만 54개. 이걸 감싼 정적 유틸 클래스가 11개, 그걸 부르는 곳이 26개 파일에 207곳. 응답은 대부분 타입 없는 JSON 봉투로 받아 다시 꺼냈는데, 그 봉투 타입을 참조하는 곳만 112개 파일에 1,100번이 넘었다. 그렇다. 첫 문단의 그 PR이 특별한 게 아니었다.
  • 상대 서비스의 식별자가 내 도메인에 스며들었다. 배차 서비스는 공통화를 위해 자기만의 회사 개념을 가졌고, 그래서 같은 회사가 두 시스템에서 다른 ID를 가졌다. 배차 서비스의 회사 ID가 본체 코드 169개 파일에 800번 넘게 등장했고, 이 값이 빠져서 데이터가 어긋나는 버그가 잊을 만하면 돌아왔다. 경계는 만들었는데 결합은 그대로였고, 결합을 유지하는 비용만 추가된 셈이다.
  • 규칙이 SQL 문자열과 메타 테이블에 흩어져 있었다. 두 서비스의 XML 매퍼는 105개. 소프트 삭제 조건, 테넌트 조건, 감사 컬럼을 매퍼마다 손으로 반복했다. 날짜는 yyyymmdd 문자열로 저장해서 94개 파일에서 문자열 포맷으로 비교했다. 정형화되지 않은 속성은 키-값 메타 테이블과 JSON 컬럼에 들어갔고, 운임 같은 핵심 숫자가 JSON 안의 문자열로 살았다. 컴파일러가 잡아 줄 수 있는 게 거의 없었다.
  • A사의 시도가 같은 함정에 더 깊이 빠졌다. A사는 위 데이터 구조를 그대로 두고 그 위에 새 화면을 올리는 방식으로 일했다. 결과는 예상대로다. JSON 속성과 두 종류의 회사 ID, 문자열 날짜가 화면 코드까지 그대로 전파됐다. 여기에 시차와 언어가 다른 팀과의 소통이 겹쳐서 도메인 규칙 하나 확인하는 데 며칠이 걸렸다. 반년 뒤 남은 것은 낡은 모델을 그대로 비추는 화면 한 벌과, 손대지 않은 데이터 구조였다.

제약

  • 운영 중인 시스템이다. 두 서비스는 매일 배차와 정산을 처리하고 있고 멈출 수 없다.
  • 팀이 작다. 재구축 전체를 처음부터 끝까지 파악하는 사람이 소수여야 하고, 그래서 구조가 한 사람의 머리에 들어와야 한다.
  • 데이터 이관은 피할 수 없다. 수년치 주문, 작업, 정산 데이터를 새 구조로 옮기되, 옮긴 뒤 검증할 수 있어야 한다.
  • 새 프론트엔드는 이미 만들어지고 있다. 백엔드 API 계약을 무한정 흔들 수 없다.
  • 경로 최적화처럼 계산이 무거운 부분은 별도 서비스로 두는 게 맞다. 전부를 하나로 만들 생각은 없다.

선택지

1. 마이크로서비스를 제대로 한다. 서비스 디스커버리와 게이트웨이를 세우고 도메인별로 서비스를 다시 나눈다. 장점: 경계가 명확하고, 팀이 커질 때 병렬 개발이 쉽다. 단점: 우리를 괴롭힌 것은 경계 위의 번역 비용이었고, 서비스를 더 나누면 그 비용은 늘어난다. 실제로 3주 동안 이 길을 가 봤다. 계획은 완벽했다. 서비스 디스커버리와 게이트웨이를 올리고 Kotlin으로 플랫폼 모듈을 다시 썼다. 인증, 권한, 조직 도메인이 테스트와 함께 완성됐을 때 배차 도메인은 아직 한 줄도 시작하지 못했고, 그 시점에 계산이 섰다. 새 스키마, 새 언어, 빅뱅 전환이라는 세 가지 위험을 한꺼번에 지는 계획이었다. 3주치 코드는 손절했다. 생각보다 덜 아팠다.

2. 레거시를 유지하며 점진 개선한다. MyBatis와 두 서비스 구조는 두고 문제 되는 곳만 고친다. 장점: 위험이 가장 적다. 단점: 문제는 구조에 있었다. 메타 테이블과 문자열 날짜는 매퍼 105개에 스며 있어서, 고칠수록 두 벌씩 고쳐야 했다.

3. 한 프로세스로 합치고, 영속성 계층과 스키마를 제자리에서 다시 설계한다. 장점: 번역 계층이 사라지고, 도메인 규칙을 엔티티 한 곳에 모을 수 있다. 돌아가는 리포 안에서 도메인 단위로 교체하니까 중간 상태를 항상 배포할 수 있다. 단점: 배포 단위가 하나가 된다. ORM 전환은 팀 전체의 쿼리 작성 습관을 바꾸는 일이고, 그 함정은 운영에서 드러난다.

결정

3을 택했다. 따진 기준은 되돌리기 비용, 장애 반경, 그리고 팀이 지킬 수 있는가였다.

왜 하나의 프로세스인가

경계를 없앤 게 아니다. 경계를 네트워크에서 코드 안으로 옮겼다. 도메인은 플랫폼, 운송, 운영자, 인프라, 공통, 배치, 앱의 일곱 모듈로 나뉘고, 모듈 사이의 규칙은 아키텍처 테스트가 빌드에서 집행한다. 다른 도메인의 저장소를 직접 쓰지 못하고 그 도메인의 서비스를 거쳐야 한다는 규칙이 대표적이다. 기존 위반은 동결하고 새 위반만 빌드를 깨뜨리는 방식이라, 큰 코드베이스에서도 도입 첫날부터 켤 수 있었다.

// 모듈 경계는 빌드가 지킨다. 기존 위반은 동결하고, 새로 생기는 의존만 실패시킨다.
@ArchTest
static final ArchRule 다른_도메인_저장소_직접_의존_금지 = FreezingArchRule.freeze(
        classes().that(inDomainPackage())
                .should(notDependOnForeignDomainRepository()));

이렇게 하면 마이크로서비스에서 우리에게 필요했던 것, 즉 도메인 사이 의존 방향의 통제는 남는다. 필요하지 않았던 직렬화와 재시도, 식별자 번역, 두 벌의 배포는 사라진다. 두 시스템의 회사 ID는 하나의 그룹 키로 통합했고, 그 키를 모든 엔티티의 공통 부모가 들고 있어서 테넌트 조건을 잊을 수 없게 했다.

경계를 네트워크로 되돌릴 조건도 그때 적어 두었다. 인증을 별도 서비스로 떼어내는 설계 문서를 미리 써 두었는데, 테이블 소유권 분리부터 외래키 해체, 보상 트랜잭션, 요청 컨텍스트 전파까지 다섯 단계였다. 인증의 배포 주기나 장애 반경이 본체와 달라져서 그 비용을 감당할 이유가 생기면 실행한다. 아직 그런 날은 오지 않았고 문서는 그대로 있다.

배차 프로세스는 안으로 들였지만, 그 안에서 호출하는 경로 최적화 엔진은 처음부터 별도 서비스로 두었다. 경로 계산은 본체와 자원 특성과 배포 주기가 다르고, 본체가 결과를 소비하는 방향의 호출만 있어서 번역 비용이 한 방향에 갇힌다. 알림 발송과 문서 인식 파이프라인도 같은 이유로 바깥에 있다. 옛 배차 서비스와의 차이가 여기 있다. 배차 서비스는 본체의 회사와 장소와 주문을 전부 알아야 했고, 그래서 양방향으로 번역이 필요했다. 경계를 넘는 대화가 한 방향이고 상대의 식별자가 내 도메인에 들어오지 않을 때만 네트워크 경계를 허용한다는 것이 우리가 정한 기준이다.

왜 JPA인가

MyBatis 자체에 불만이 있었던 것은 아니다. 문제는 우리 도메인에서 반복되는 것이 SQL보다 규칙이었다는 점이다. 모든 테이블에 소프트 삭제와 테넌트 키, 감사 컬럼이 있고, 주문은 계획으로, 계획은 작업 단위로, 작업 단위는 결과와 검수와 정산으로 이어지는 생명주기를 가진다. 이런 규칙이 매퍼 105개에 복사되는 대신 엔티티 계층 한 곳에 있어야 했다.

  • 소프트 삭제는 엔티티 어노테이션 한 줄로 모든 조회에 적용되고, 빠뜨리면 아키텍처 테스트가 잡는다.
  • 테넌트 키와 감사 컬럼은 공통 부모 엔티티가 채운다.
  • 상태 전이와 생성 불변식은 엔티티의 도메인 메서드와 정적 팩토리에 둔다. 태어날 때의 상태가 서비스 코드에 흩어져 복사되는 것을 막는다.
  • 날짜는 InstantLocalDate로, 금액은 소수 둘째 자리 고정 BigDecimal로 타입을 확정했다. 문자열 비교 94곳이 사라진 자리에 컴파일러가 들어왔다.
  • 메타 테이블과 JSON 컬럼의 속성은 이관 스크립트에서 꺼내 정규 컬럼으로 옮겼다. 운임이 JSON 속 문자열에서 숫자 컬럼이 된 것이 대표 사례다.

QueryDSL은 JPA의 약점을 메우는 짝이다. 목록과 페이징 조회는 엔티티를 거치지 않고 프로젝션으로 DTO를 직접 만들어 N+1을 구조적으로 피하고, 단건 조회는 fetch join 헬퍼를 통해서만 한다. 네, 그 N+1 맞다. 이 두 규칙은 운영에서 여러 번 맞은 뒤에 가이드와 아키텍처 테스트로 굳어졌다.

A사가 실패한 이유에서 가져온 규칙

A사의 실패를 사람 탓으로 돌리면 배울 게 없다. 구조로 보면 세 가지였다.

데이터 구조를 계약 조건으로 고정했다. 기존 API와 스키마를 그대로 쓴다는 조건은 일정을 지키기 위한 것이었다. 하지만 도메인의 복잡함이 데이터 구조에 녹아 있지 않은 상태에서 그 구조를 고정하면, 복잡함은 화면마다 다시 구현된다. 새 화면은 낡은 모델을 더 넓게 퍼뜨리는 확성기가 됐다.

도메인 지식이 코드 밖에 있었다. 규칙은 운영자의 머리와 SQL 문자열 속에 있었고, 그것을 시차와 언어가 다른 팀에 전달하는 대역폭은 좁았다. 질문 하나가 왕복하는 데 하루가 걸리면 하루에 확인할 수 있는 규칙은 하나다. 도메인이 두꺼운 시스템에서 이 속도로는 끝까지 갈 수 없다.

동작하는 것과 맞는 것을 구분할 기준이 없었다. 검수 기준은 사람의 기억에 있었다. 잘못 이해된 규칙은 화면이 그럴싸하게 동작하는 동안 발견되지 않았다.

그래서 두 번째 시도는 순서를 뒤집었다. 먼저 도메인 규칙을 엔티티와 불변식, 아키텍처 테스트로 코드 안에 박고, 그 위에서만 사람을 늘렸다. 프론트엔드가 소비하는 API 계약은 이 모델에서 도출했고, 새로 합류한 동료는 규칙을 사람에게 묻는 대신 테스트에서 읽었다.

전환 순서

돌아가는 시스템을 안에서 바꾸는 일이라 순서가 설계의 절반이었다.

  1. 배차 서비스의 코드를 본체 리포로 먼저 흡수했다. 이 시점에는 여전히 MyBatis였다. 한 번에 하나만 바꾸기로 했다.
  2. 빌드 체계를 정리하고 JPA와 QueryDSL을 MyBatis 옆에 나란히 올렸다. 도메인 하나를 골라 엔티티와 저장소, 서비스를 새로 쓰고 그 도메인의 매퍼를 지웠다. 이걸 도메인 수만큼 반복했다.
  3. 스키마 이관은 번호 붙은 스크립트 60본으로 만들었다. 불필요 테이블 정리, 회사 키 생성, 메타 테이블 정규화, 작업 단위의 상하차 통합, 레거시 테이블 삭제 순서다. 개발 데이터베이스를 이관 결과물로 바꿔 두 달간 그 위에서 개발하며 스크립트를 다듬었다.
  4. 운영 전환과 함께 스키마를 베이스라인으로 고정하고, 이후의 변경은 Flyway 버전 마이그레이션으로만 한다. 재구축은 여기서 끝나지 않는다. 다만 이제부터의 변경은 안전하다.

결과와 한계

  • 얻은 것: 두 서비스와 국가별 사본으로 흩어져 있던 도메인이 한 코드베이스, 일곱 모듈, 엔티티 150여 개로 수렴했다. 경계 위의 전송용 DTO 54개와 정적 유틸 11개, JSON 봉투 참조 1,100여 곳, 회사 ID 번역 800여 곳이 사라졌다. 소프트 삭제와 테넌트, 감사 규칙은 한 곳에 있고, 빠뜨리면 빌드가 실패한다. 이관은 번호 붙은 스크립트 60본으로 남았고, 이후의 스키마 변경은 버전으로 남는다.
  • 잃은 것: 배포 단위가 하나다. 한 도메인의 배포가 전체를 재시작시키고, 한 도메인의 메모리 누수가 전체의 장애다. ORM의 함정은 운영에서 계속 드러났다. N+1은 규칙을 세운 뒤에도 여러 번 다시 잡았고, 배차의 병렬 처리는 커넥션 풀과 락 설계를 따로 요구했다. 규칙을 지키는 비용은 계속 드는 비용이라서, 사람 대신 빌드가 지키게 했다.
  • 다시 계산할 조건: 어느 도메인의 배포 주기나 자원 특성이 본체와 뚜렷하게 갈라지면 그 도메인만 네트워크 경계 밖으로 낸다. 인증 분리 문서가 그 첫 후보의 설계도다. 팀이 도메인 모듈 수보다 많아져서 배포 대기가 병목이 되는 날도 같은 신호다.

다른 시스템에 적용한다면, 서비스를 나눌지 합칠지 정하기 전에 경계를 넘는 대화에 번역이 얼마나 끼어 있는지부터 세어 보면 좋겠다. 전송용 DTO의 개수, 상대 식별자가 내 코드에 등장하는 횟수, 타입 없는 응답 봉투의 참조 수. 이 셋이 계속 자라고 있다면 그 경계는 이미 경계 노릇을 못 하고 있다.

팀에 남긴 규칙은 이렇다. 경계는 빌드가 지키고, 도메인 규칙은 엔티티가 기억한다. 어느 모듈의 배포 주기가 나머지와 다른 리듬으로 뛰기 시작하면 이 결정을 다시 꺼내 본다.

이 글 공유
LinkedInThreadsX