개발 slecs

은행 연동 코드를 어댑터 패턴으로 신규 프로젝트에 이전

목차

기존 이커머스 플랫폼에 여기저기 박혀 있던 은행 연동 코드를 신규 프로젝트로 옮기는 작업을 시작했다. 단순 복붙이면 반나절에 끝날 것 같았는데, 막상 파보니 의존성부터 꼬여 있었음.

문제의 시작은 기존 코드가 캐시 유틸을 직접 import 하고 있다는 점이었다. 신규 프로젝트엔 그 유틸 자체가 없었고, 같은 이름으로 새로 만들자니 내부 구현까지 맞춰야 해서 작업 범위가 커졌다. 인터페이스만 맞추자니 어디까지 맞춰야 하는지가 애매했고. 결국 신규 쪽에 얇게 다시 깎는 쪽으로 결정했다. 메서드 시그니처만 동일하게, 내부 구현은 신규 인프라 방식으로. 기존 키 네이밍 규칙은 그대로 승계했다. 캐시 키가 달라지면 기존 데이터가 전부 미스 나면서 순간적으로 DB에 부하가 몰리는 상황이 생기기 때문이다.

어댑터 한 겹을 두는 이유

호출부가 100군데를 넘는다는 걸 확인한 시점에서 전수 수정은 선택지에서 바로 빠졌다. 오버로드가 여러 개인 메서드도 있고, 응답 객체를 받아서 필드 단위로 접근하는 코드도 섞여 있었다. 시그니처 하나 바꾸면 연쇄적으로 터지는 구조였음.

어댑터 패턴은 이 상황에서 꽤 잘 맞는다. 기존 인터페이스를 구현하는 어댑터 클래스를 하나 두고, 내부에서 신규 로직으로 위임하는 식. 호출부는 아무것도 안 바꿔도 되고, 신규 코드는 신규 방식대로만 작성하면 된다. 어댑터가 그 사이 변환을 전담한다.

class BankHandlerAdapter:
    """기존 BankHandler 인터페이스를 신규 큐 배치 방식으로 위임"""

    def __init__(self, transfer_queue: TransferQueue):
        self._queue = transfer_queue

    def process_transfer(
        self, account_id: str, amount: int, memo: str
    ) -> LegacyTransferResult:
        # 신규 큐에 투입
        job_id = self._queue.enqueue(
            TransferJob(account_id=account_id, amount=amount, memo=memo)
        )
        # 호출부 입장엔 동기처럼 보이게 짧게 폴링
        result = self._queue.wait_for_result(job_id, timeout_ms=3000)
        # 응답을 기존 포맷으로 변환
        return LegacyTransferResult(
            success=result.status == "COMPLETED",
            transfer_id=result.job_id,
            message=result.description,
        )

이렇게 짜고 나서 호출부 코드 수정이 실제로 0줄이었다. 어댑터 안에서 변환 비용이 조금 생기지만, 마이그레이션 단계에선 충분히 감수할 만하다. 나중에 호출부를 신규 시그니처로 직접 전환할 때 어댑터만 걷어내면 된다.

송금 큐 배치 이전 - 트랜잭션 경계 문제

이게 제일 골치 아팠음. 기존엔 큐를 읽어서 건건이 동기 처리했고, 신규 쪽은 비동기 배치 처리를 전제로 설계되어 있었다. 처리 모델 자체가 달라서 트랜잭션 경계도 근본적으로 달랐다.

항목 기존 신규
처리 방식 동기 비동기
트랜잭션 단건 배치 단위
실패 재시도 즉시 재호출 큐 재투입
결과 반환 시점 처리 완료 후 즉시 폴링 또는 콜백

재시도 정책이 특히 문제였다. 기존엔 실패하면 즉시 재호출하는 방식이었는데, 신규는 실패하면 큐에 다시 집어넣고 나중에 처리한다. 호출부는 "지금 실패했으니까 지금 다시 해줘"를 기대하는데, 신규 방식은 "언젠가 다시 처리될 거야"라고 응답하는 구조다. 이 간극을 그냥 두면 호출부 쪽 에러 처리가 전부 틀어진다.

어댑터 안에서 이걸 흡수했다. 큐에 투입한 다음 짧은 시간 동안 폴링해서 결과를 받아오고, 그 안에 완료가 안 되면 타임아웃 에러로 올린다. 내부적으론 비동기지만 외부에서 보면 동기처럼 동작한다. 성능은 살짝 손해지만, 동작 보존이 1순위인 마이그레이션 단계에선 맞는 판단이었다고 본다.

DDL 업데이트와 마이그레이션 위생

테이블 스키마 변경분을 DDL 문서에 반영했다. 운영DB 반영은 사용자 승인 후로 미뤘고, 개발DB와 파트너 DB 두 곳에만 먼저 적용했다. 이 순서가 중요한 게, 개발 환경에서 스키마를 먼저 검증하지 않고 운영에 올리면 롤백 절차가 훨씬 복잡해진다.

이번 작업에서 지킨 원칙 몇 가지:

  • DDL 변경은 문서와 코드를 동시에 반영한다. 한쪽만 바꾸면 다음 검수에서 충돌 나는 게 거의 확실하다.
  • 운영DB 적용 전에 개발DB에서 마이그레이션 스크립트를 실제로 한 번 돌려본다. DDL이 예상대로 동작하는지, 기존 데이터와 충돌하는 컬럼이 없는지 확인한다.
  • .gitignore에 로컬 빌드 산출물과 IDE 설정 파일 추가. 이런 파일이 커밋에 섞이면 리뷰할 때 노이즈가 심해진다.

DDL은 코드보다 롤백이 어렵다. 컬럼 삭제나 타입 변경이 포함된 경우는 데이터 유실 위험도 있어서, 변경을 작게 쪼개고 각 단계마다 문서로 흔적을 남겨두는 게 나중에 추적할 때 편하다. 이번에도 변경 의도를 DDL 주석에 같이 남겼다.


마이그레이션은 한 번에 다 옮기려 들면 중간에 꼭 문제가 생긴다. 기존 코드가 운영 트래픽을 받고 있는 상황에서 전면 전환하는 건 리스크가 너무 크고, 뭔가 터졌을 때 어디서 터진 건지도 구분이 안 된다. 어댑터 한 겹 두고 호출부는 그대로, 내부만 갈아끼우는 방식이 이번엔 맞았음.

큐 배치처럼 처리 모델 자체가 달라지는 부분은 동작 보존을 우선으로 뒀다. 폴링으로 동기처럼 흉내내는 게 성능상 좋은 선택은 아니지만, 호출부 안정성을 먼저 확보한 다음 성능을 다듬는 게 순서가 맞다. 어댑터가 살아있는 동안은 기존 동작을 얼마나 잘 흉내내느냐가 기준이다.

다음 단계는 어댑터를 걷어내고 호출부를 신규 시그니처로 직접 전환하는 작업이다. 이 시점이 되면 폴링 로직이 사라지고 비동기 처리가 호출부까지 올라오면서 전체 성능 프로파일도 달라질 거다. 그게 이번 마이그레이션의 진짜 끝이다.

댓글 0

첫 댓글 달아줘.