파트너 포털 계좌 조회 API 라우팅 중복 제거로 환경별 응답 불일치 해소
목차
파트너 포털 컨트롤러를 리팩토링하다가 같은 경로에 핸들러가 두 개 달려있는 걸 발견했다. GET /accounts/{id} 하나에 신규 응답 스펙 메서드, 레거시 단건 계좌 조회 메서드가 나란히 걸려있었음.
처음엔 "이게 어떻게 지금까지 돌아가고 있었지?" 싶었는데, 운영은 멀쩡히 돌고 있었으니 아무도 인식을 못 하고 있었던 거다. 이걸 발견하게 된 건 로컬이었음. 팀원 한 명이 "계좌 조회 응답에 필드가 빠져있다"고 들고 왔고, 재현해보니 레거시 메서드가 먼저 매칭돼서 옛날 포맷을 그대로 뱉고 있었다.
운영에서는 신규 메서드가 먼저 잡혀서 아무 증상이 없었고, 그 팀원 로컬에서만 레거시가 먼저 잡혔음. 같은 코드, 다른 응답.
라우팅 충돌이 환경별로 달라지는 이유
Spring MVC 기준으로, 동일 경로에 핸들러가 두 개 등록돼있으면 어느 쪽이 먼저 매칭되는지는 빌드 순서, 클래스 로딩 순서, 컴포넌트 스캔 순서에 따라 달라진다. 프레임워크가 핸들러를 등록하는 방식 자체가 결정론적이지 않기 때문임. 동일 패키지 내 클래스를 스캔할 때 파일시스템 inode 순서, jar 내부 엔트리 순서처럼 환경마다 다른 요소들이 끼어든다.
이게 까다로운 이유는 실패가 아니라는 점이다. 예외가 터지지 않고, 로그에 경고도 안 남긴다. 그냥 둘 중 하나를 골라서 처리함. 응답 필드가 다른 게 버그인데, 알아채려면 기대 스펙을 알고 있어야 한다. 모르면 그냥 흘러가고, 흘러가면 쌓인다.
가장 무서운 시나리오는 JVM 버전 업이나 프레임워크 마이너 버전 업에서 조용히 순서가 뒤집히는 경우임. 아무도 라우팅 코드를 건드리지 않았는데 배포 이후 특정 API 응답이 달라지는 증상이 나온다. 에러도 없고, 스택 트레이스도 없다. 그냥 필드가 다르다. 이런 버그는 원인을 찾는 데 시간이 오래 걸린다. "배포 전후로 뭐가 바뀌었지?" 하고 코드 변경분을 아무리 뒤져봐도 로직은 그대로니까.
이번 케이스는 몇 분기 전 라우팅 우선순위가 바뀐 시점에 잠재 버그가 심어졌을 가능성이 높았음. 운영에서 먼저 터지지 않은 건 운이었다.
왜 레거시가 살아남아 있었나, 그리고 어떻게 지웠나
신규 응답 스펙을 만들 때 레거시 메서드에 @Deprecated를 붙이고 내부 호출처는 신규로 전환했음. 여기까지는 맞는 흐름이다. 근데 메서드 자체를 안 지웠다. "외부 시스템이 이 경로를 직접 치고 있을 수도 있으니까"라는 이유였음.
이 판단 자체가 나쁜 건 아니다. 결제대행사 같은 외부 연동은 API 변경 공지를 해도 예전 경로를 그대로 쓰는 경우가 있고, 그걸 서버 측에서 완전히 통제하기 어렵다. 문제는 그 조심이 검증을 대체했다는 거임. 호출이 실제로 있는지 없는지를 확인하지 않고 그냥 뒀고, 그게 몇 분기 쌓였음.
deprecated 마킹은 코드 단에서 "이거 쓰지 마"라는 신호지, 코드를 죽이는 게 아니다. 컴파일러 경고를 무시하면 그냥 계속 동작한다. 시간이 지날수록 아무도 손을 안 대게 되고, 건드리면 뭔가 터질 것 같다는 막연한 두려움이 생김. 그 두려움 때문에 오히려 더 오래 살아남는다는 게 아이러니다.
정리 순서는 이렇게 했다.
| 단계 | 한 일 |
|---|---|
| 1 | 레거시 메서드 호출 흔적 전수 검색 (전사 grep + 결제대행사 콜백 로그 확인) |
| 2 | 최근 90일 내 레거시 경로 호출 0건 확인 |
| 3 | 메서드 통째 삭제, 매핑 1개로 단일화 |
| 4 | 동일 URL이 여러 메서드에 물려있는 케이스 전수조사 |
변경 전: GET /accounts/{id} → 핸들러 2개 (신규/레거시)
변경 후: GET /accounts/{id} → 핸들러 1개
콜백 로그까지 들여다본 이유가 있음. 결제대행사 같은 외부 시스템은 자체 로그를 거의 남기지 않아서, 서버 측 액세스 로그 없이는 실제 호출 여부를 알 수가 없다. 코드 검색만으로 "쓰는 곳 없음"을 결론 내리면 위험하다. 내부 코드에서 안 쓰는 것과 외부에서 안 쓰는 건 다른 얘기임. 90일치 0건이 확인되고 나서야 삭제했다. 그전까지는 손을 못 댔음. 다른 컨트롤러 전수조사까지 돌렸는데 이번 한 건으로 끝났다. 운 좋은 결과였다.
이번 일을 겪으면서 deprecated 처리 프로세스에 뭔가 빠져있다는 게 분명해졌음. deprecated를 붙이는 시점에 삭제 조건을 같이 기록하는 습관이 있었다면 이게 몇 분기 넘게 살아남지 않았을 거다. 이슈나 PR 코멘트에 이런 식으로 남겨두면 된다.
# 삭제 예정 기록
- 대상: AccountController#getLegacyAccount
- deprecated 처리: YYYY-MM
- 삭제 조건: /accounts/{id} 레거시 경로 90일간 호출 0건
- 확인 방법: 서버 액세스 로그 + 결제대행사 콜백 로그
"나중에 지워야지"는 거의 안 지운다. 삭제 조건이 명시돼있지 않으면 언제 지워도 된다는 기준이 없고, 결국 아무도 안 지운다.
라우팅 충돌은 빌드 단계에서도 잡을 수 있다. 동일 경로에 핸들러가 두 개 이상 등록되는 케이스를 감지하는 검사를 통합 테스트나 린트 단계에 넣으면 이런 버그가 운영까지 못 가게 막을 수 있음. 아직 적용은 못 했고, 다음 분기 정리 항목에 올려뒀다.
"내 환경에서는 잘 된다"는 말이 나오는 순간 라우팅 충돌을 먼저 의심해볼 것. 에러나 스택 트레이스 없이 응답 필드만 다르게 나오는 증상은 대부분 이 패턴임. 로직 버그는 일관되게 틀리는데, 환경 의존성 버그는 환경마다 다르게 틀린다. 이 차이를 기억해두면 디버깅 시간을 꽤 줄일 수 있다.
환경별로 다르게 동작하는 코드는 발견 즉시 단일화하는 게 맞다. 지금 운영이 잘 돌아가고 있다고 미뤄선 안 됨. 이번엔 로컬에서 발견됐는데, 다음엔 운영 배포 이후에 터질 수도 있다.
댓글 0
첫 댓글 달아줘.