파트너 API 경로 분리로 결제·정산 연동 구조 정비
목차
파트너 전용 API 경로를 새로 따고, 연동 문서를 거기 맞춰 갱신했다. 단순히 prefix 문자열 하나 바꾸는 작업처럼 보이는데 실제로는 이커머스/결제 플랫폼 쪽에서 권한·로깅·외부 계약 라인을 한꺼번에 다시 그리는 일이라 시간이 꽤 걸렸음.
왜 분리가 필요했나
기존엔 내부 운영용 호출이랑 파트너 호출이 같은 prefix 아래 섞여 있었다. 초기에는 인증 로직에서 분기하는 방식으로 처리해왔는데, 이게 누적되면서 문제가 생기기 시작했음.
가장 귀찮은 건 권한 분기가 핸들러 단에 박혀있는 구조였다. 라우팅 레이어에서 내부/외부 구분이 안 되니까, rate limit 정책이나 감사 로그 범위를 바꾸려면 매번 핸들러를 일일이 찾아서 손봐야 했다. AOP나 필터로 횡단 관심사를 처리하려면 어떻게든 경로 기준으로 묶어야 하는데, prefix 자체가 뒤섞인 상태에선 그 묶음이 불가능하다.
두 번째는 계약 라인 문제. 파트너와 연동할 때는 "이 API는 6월까지 이 스펙으로 유지한다"는 암묵적 약속이 생긴다. 근데 내부 운영 경로랑 같은 코드베이스에 붙어있으면, 내부 편의를 위해 필드 하나 바꿨을 때 그게 외부 계약 위반인지 모르고 지나치기 쉽다. prefix가 다르면 적어도 "아, 파트너 경로 건드는 거구나"하는 시각적 마찰이 생김.
경로 분리 기준과 라우팅 변경
분리 기준은 단순하게 잡았다.
- 내부 운영/대시보드용:
/internal/** - 파트너(외부 연동)용:
/partner/v1/** - 정적/뷰 리소스: 기존 그대로
/partner/v1/** 쪽에 버전 세그먼트를 처음부터 박아뒀다. 아직 v2가 없어도 나중에 breaking change가 생겼을 때 신규 버전 경로를 추가하면서 구버전을 유지하는 패턴을 쓸 수 있게. 버전 없이 시작했다가 나중에 끼워넣으면 기존 파트너들이 갑자기 경로를 바꿔야 하는 상황이 생긴다. 처음 설계할 때 한 줄 더 쓰는 것 치고 나중에 돌아오는 보상이 크다.
MVC 설정 쪽에서 핸들 등록 순서랑 prefix 매핑을 같이 정리했음. 실제 엔드포인트 기준으로는 이렇게 됐다:
/partner/v1/charges → 충전 요청
/partner/v1/charges/{id} → 단건 조회
/partner/v1/settlements → 정산 내역
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 경로 prefix | 혼재 | /partner/v1 고정 |
| 인증 방식 | 세션+API 키 혼용 | API 키 단일 |
| 응답 포맷 | 화면용 객체 일부 노출 | 외부 전용 DTO |
인증도 이번에 정리했는데, 파트너 prefix로 들어오는 요청은 세션 쿠키가 아닌 API 키만 허용하도록 필터를 단순화했다. 혼용 상태에선 세션이 없으면 API 키로 폴백하는 로직이 있었는데, 테스트할 때 실수로 세션 인증으로 통과하고 API 키 검증은 빠뜨리는 경우가 생기곤 했음. 경로 기준으로 인증 방식을 고정하면 그 가능성이 없어진다. 인증 필터 자체의 분리는 범위가 커서 별도 PR로 나눴다. 한 PR에 같이 넣으면 리뷰 컨텍스트가 섞이는 게 싫었음.
외부 DTO 분리가 핵심이었다
이번 작업에서 가장 공을 들인 건 응답 DTO를 아예 따로 만든 부분이다. 화면에서 쓰던 객체를 그대로 외부에 던지는 패턴을 제일 많이 보는데, 단기적으로 편하고 장기적으로 가장 비싸다.
이유가 명확하다. 내부 뷰 객체는 화면 요구사항에 따라 계속 바뀐다. 필드가 추가되고, 타입이 바뀌고, 이름이 리팩터링되고. 파트너 연동 쪽은 그 변화를 모른다. 파트너가 자기 시스템에 필드를 박아뒀는데 응답에서 사라지거나 타입이 바뀌면, 통합 테스트 통과 후 프로덕션 배포했다가 자정에 정산이 안 된다는 알람이 오는 식의 사고가 난다.
외부 전용 DTO를 강제하면 컨버터 레이어가 생기고 코드가 늘어나는 건 맞다. 트레이드오프라면 트레이드오프인데, 이 레이어가 "내부 변경이 외부 계약에 튀는 것"을 막는 방화벽 역할을 한다. 내부 객체 필드 이름을 바꾸더라도 DTO 컨버터에서 기존 이름 유지해주면 파트너 쪽은 모른다. 처음엔 귀찮아 보여도 호환성 사고 한 번 터지면 본전 뽑힘.
패턴 자체는 단순하다:
// 내부 도메인 객체 → 외부 응답 DTO 변환
public PartnerChargeResponse toPartnerResponse(ChargeEntity entity) {
return PartnerChargeResponse.builder()
.chargeId(entity.getId())
.amount(entity.getAmount())
.status(entity.getStatus().toPartnerCode()) // 내부 enum → 외부 코드 매핑
.createdAt(entity.getCreatedAt())
.build();
}
내부 Status enum 값이 바뀌어도 toPartnerCode()에서 외부 계약 코드로 매핑하는 레이어가 있으니 파트너 쪽엔 영향이 없다. 이런 변환 로직이 한 군데 모여 있으면 계약 변경이 필요할 때도 어디를 봐야 하는지 명확하고, 리뷰어도 "이 변경이 외부에 어떻게 나가는지"를 컨버터 하나만 보면 파악할 수 있다.
문서 갱신과 deprecated 처리
코드만 바꾸고 끝낼 수 없는 게 파트너 연동 작업의 특징이다. 문서랑 코드 prefix가 한 글자라도 어긋나면 파트너가 통합 테스트에서 바로 막힌다. 예전에 슬래시 하나 빠뜨려서 하루 통째로 날린 적이 있어서, 이번엔 코드 → 문서 → 샘플 요청이 동일한 소스에서 추출되도록 끼워뒀음. 수동으로 두 곳 동기화하는 불안함이 없어진다.
이번 문서 갱신 포인트:
- 엔드포인트 표 새 prefix로 일괄 치환
- 기존 경로는 한동안 살려두되 "deprecated, 6월까지 유지" 명시
- 파트너 쪽에 보낼 변경 노트 초안까지 같이 작성
구버전 경로를 당장 죽이지 않는 건 파트너 마이그레이션 일정 때문이다. 연동 시스템이 있는 쪽은 우리가 문서 올리는 날 바로 반영 못 한다. 릴리즈 사이클이 있고, QA가 있고, 배포 승인이 있다. deprecated 기간을 명시해두면 파트너 쪽에서 스케줄을 잡기도 편하고, 우리도 "6월 이후 구버전 트래픽이 0이면 내린다"는 기준이 생긴다. 다음 작업으로 구버전 prefix 호출 비중 모니터링을 붙이는 게 남아있는데, 메트릭 없이는 누가 아직 구경로 쓰는지 판단을 못 하니까 그게 붙고 나서 파트너 마이그레이션 스케줄을 컨펌하는 순서다.
이 구조가 자리를 잡으면 다음 파트너 API를 추가할 때 prefix 아래 핸들러 하나 붙이면 인증/로깅/rate limit이 자동으로 따라온다. 그걸 할 시점이 오면 "이거 해두길 잘했다" 싶게 되는 종류의 작업이었다.
댓글 0
첫 댓글 달아줘.