결제 플랫폼 빈 이름 충돌로 서버 부팅 실패 해결
목차
이커머스 결제 플랫폼 백엔드를 올리는데 갑자기 부팅 실패가 났다. 로그가 끝까지 내려가지도 않고 컨텍스트 초기화 단계에서 프로세스가 죽어버림. 처음엔 단순 의존성 누락인 줄 알았는데, 에러 메시지를 천천히 읽어보니 원인이 달랐다.
The bean 'businessValidationController' could not be registered.
A bean with that name has already been defined in file
[...LegacyPartnerController.class] and overriding is disabled.
빈 이름 충돌. 결제 도메인 안에 컨트롤러가 두 개였는데, 둘 다 프레임워크 디폴트 네이밍 규칙을 그대로 쓰고 있었다.
원인을 찾는 과정
Spring(혹은 대부분의 DI 컨테이너)의 디폴트 빈 네이밍은 단순하다. 클래스 단순명을 camelCase로 변환한 것 전부. 패키지 경로는 무관하다. 즉 com.payment.partner.BusinessValidationController와 com.payment.verification.BusinessValidationController가 동시에 등록을 요청하면 둘 다 businessValidationController라는 키를 두고 충돌한다. 컨테이너는 어느 쪽을 살려야 할지 알 방법이 없으니 설정에 따라 예외를 던지거나, 나중에 등록된 쪽으로 조용히 덮어쓴다.
우리 쪽은 overriding을 비활성화한 상태였기 때문에 예외로 튀어나왔다. 문제가 숨겨지지 않고 즉시 드러난 건 오히려 다행이다. overriding이 허용된 환경이었다면 어느 컨트롤러가 실제로 살아있는지도 모른 채 한동안 이상한 동작을 했을 것.
스택을 위로 거슬러 올라가며 확인한 상황:
- 같은 결제 도메인 아래에 역할이 다른 컨트롤러 두 개가 동일한 디폴트 이름으로 등록 시도
- 한쪽은 사업자 진위 검증 신규 기능(최근 머지된 PR에서 추가), 다른 쪽은 레거시 파트너 사업자 정보 조회 모듈
- 클래스 이름이 충분히 유사해서 camelCase 변환 결과가 같은 키로 나옴
| 기존 빈 | 신규 빈 | |
|---|---|---|
| 역할 | 파트너 사업자 정보 조회 | 사업자 진위 검증 호출 |
| 패키지 | com.payment.legacy | com.payment.verification |
| 빈 이름(디폴트) | businessValidationController | businessValidationController |
| 충돌 결과 | 컨텍스트 부팅 실패 | 컨텍스트 부팅 실패 |
요점은 두 컨트롤러가 같은 키를 두고 부딪혔다는 것. 도메인 분리가 패키지 레벨에서는 돼 있었어도, 빈 등록 시점에는 그 구분이 전혀 반영되지 않는다는 게 이번에 다시 확인한 부분이다.
해결 - 빠른 수정과 구조적 정리
당장 서버를 살리는 가장 빠른 방법은 신규 컨트롤러에 명시적 이름을 부여하는 것. 한 줄이면 된다.
// 수정 전: 디폴트 이름 사용 → businessValidationController로 충돌
@RestController
@RequestMapping("/api/verification/business")
public class BusinessValidationController {
// ...
}
// 수정 후: 명시적 이름으로 충돌 회피
@RestController("verificationBusinessController")
@RequestMapping("/api/verification/business")
public class BusinessValidationController {
// ...
}
이름 명시 한 줄 추가하니 서버는 즉시 정상 부팅됐다. 하지만 이건 응급처치다. 이름만 바꿔서 충돌을 피하면 나중에 다른 사람이 이 컨트롤러를 참조할 때 "왜 이름이 이렇게 됐지?"라는 의문이 생기고, 빈 이름이 코드베이스 어딘가에 하드코딩돼 있다면 추적도 번거롭다. 충돌의 근원인 도메인 구조 문제는 그대로 남아 있다.
그래서 다음 PR에서 같이 가져가기로 한 것이 도메인별 네임스페이스 정리다. 레거시 파트너 검증과 신규 사업자 검증은 책임 경계가 다른 영역이기 때문에, 컨트롤러 이름뿐 아니라 패키지 경로 자체를 명확히 갈라놓는 쪽이 맞다. 그렇게 하면 클래스 이름이 비슷하게 지어지더라도 빈 이름에 도메인 컨텍스트를 자연스럽게 반영할 수 있고, 팀 전체가 컨벤션을 공유하면 충돌 자체가 발생하기 어려운 구조가 된다.
빠른 수정과 구조 정리, 둘 다 필요한 이유가 있다. 빠른 수정만 하면 기술 부채가 쌓이고, 구조 정리만 하려다가 서비스를 오래 내리면 안 되는 상황에선 선택지가 없다. 이번처럼 1번으로 막고 2번을 별도 PR로 가져가는 게 현실적이다.
회고 포인트
이번 건을 정리하며 느낀 것 세 가지.
디폴트 네이밍은 대규모 모놀리식에서 시한폭탄이다. 소규모 프로젝트에서는 편하다. 클래스 이름이 곧 빈 이름이니 따로 관리할 게 없다. 하지만 팀이 여럿이고 도메인이 십수 개를 넘어가면 완전히 다른 얘기다. Validation, Handler, Processor, Manager 같은 단어는 도메인을 가로질러 반복되는 게 자연스럽고, 그 순간 이름 충돌은 필연이다. 이 정도 규모에서는 빈 이름에 도메인 접두사를 강제하거나, 네임스페이스 컨벤션을 팀 레벨에서 문서화해두는 게 낫다. 구두로 공유하면 잊혀진다.
부팅 실패 류 이슈는 단위 테스트로 못 잡는다. 단위 테스트는 빈을 개별로 올리거나 테스트 컨텍스트를 쪼개서 사용하기 때문에, 전체 컨테이너를 초기화하는 과정에서 나는 충돌을 감지하지 못한다. 이런 류는 전체 애플리케이션 컨텍스트를 실제로 로딩하는 통합 레벨 스모크 테스트가 PR 머지 전에 돌아야 잡힌다. 아래처럼 컨텍스트 로딩만 검증하는 테스트 하나면 충분하다.
@SpringBootTest
class ApplicationContextSmokeTest {
@Test
void contextLoads() {
// 컨텍스트가 정상 로딩되면 통과
// 빈 충돌, 주입 실패, 설정 오류 있으면 여기서 실패
}
}
CI 파이프라인에 이 테스트 하나만 달아도 "로컬에서는 됐는데 배포하니 서버가 안 뜬다"류 사고를 미리 걸러낼 수 있다. 지금까지 없었던 게 신기할 정도.
신규 컨트롤러 작성 전 검색 한 번. IDE에서 클래스 이름을 확정하기 전에 프로젝트 전체에서 동일하거나 유사한 이름이 있는지 확인하는 습관. 5초짜리 검색이 나중에 몇 시간짜리 트러블슈팅을 막는다. 특히 여러 팀원이 동시에 다른 피처를 개발 중인 환경에서는 같은 단어로 클래스를 만들 확률이 생각보다 높다. 리뷰 단계에서 잡히면 그나마 낫고, 이번처럼 배포 직전에 터지면 불필요한 인시던트가 된다.
다음 스텝으로는 도메인별 컨트롤러 네이밍 컨벤션을 팀 위키에 정리하고, CI에 컨텍스트 로딩 스모크를 달고, 레거시 파트너 검증 모듈은 신규 사업자 검증 쪽으로 단계적으로 흡수할 계획이다. 급하게 이름 하나 바꿔서 막은 상태니까 구조 정리는 미루면 안 된다. 같은 실수 두 번은 안 하면 된다.
댓글 0
첫 댓글 달아줘.