Spring 애플리케이션에 ObjectMapper 빈이 두 개 있었다. 공용 JSON 처리용과 Kafka 전송용이었다. 새 컴포넌트가 사용할 mapper를 명시하지 않으면서 서버 기동이 실패했고, 주입 대상을 정한 뒤에는 JSON 복원 과정에서 다른 오류가 드러났다.
첫 오류는 NoUniqueBeanDefinitionException이었다. 어느 빈을 주입할지 결정하지 못한 문제였다. 두 번째는 저장해 둔 JSON을 객체로 읽지 못하는 역직렬화 오류였다. 타입이 같은 빈을 고르는 일과 그 빈에 필요한 Jackson 모듈을 연결하는 일은 따로 확인해야 했다.
이 글은 Spring이 주입할 빈을 선택하는 문제와 Jackson이 JSON을 객체로 복원하는 문제를 나누어 정리한다. 첫 번째는 Qualifier로 주입 대상을 정했고, 두 번째는 선택된 mapper의 생성 경로와 모듈 구성을 확인해 해결했다.

주입 충돌의 원인: 테스트에 빠져 있던 빈 조합
사례의 환경은 Java 17, Spring Boot 3.5, Spring Security 6.5이며, 기존 JWT REST 서비스에서 패스키 기능을 켰을 때 문제가 드러났다. 기능 설정에 따라 새 컴포넌트가 생성되는 구조였지만, 여기서 다루는 원인은 ObjectMapper 빈 선택과 모듈 구성이다.
기존 애플리케이션에는 공용 JSON mapper와 Kafka 전송용 mapper가 함께 있었다. 둘 다 ObjectMapper 타입이지만 쓰임새는 달랐다. 새 컴포넌트가 어느 쪽을 사용할지 명시하지 않은 채 mapper를 요청하면서 주입이 실패했다.
초기 HTTP 테스트는 인증에 필요한 부분을 모은 서버였다. PostgreSQL과 Redis는 실제로 연결했지만, 제품에서 함께 올라오는 두 mapper 설정은 빠져 있었다. 기능을 켜고 제품의 빈 조합을 함께 생성하는 조건이 빠져 있었던 셈이다.
재현할 때는 HTTP 테스트 서버에 실제 공용 설정과 Kafka producer 설정을 함께 올렸다. 전체 앱 기동 테스트에도 해당 컴포넌트가 생성되는 활성화 설정을 넣었다. 수정 전 코드에서 두 테스트 모두 같은 주입 오류가 발생했다.
주입 충돌 해결: Qualifier로 공용 mapper 선택
새 컴포넌트가 사용할 대상은 공용 mapper였다. 관련된 네 컴포넌트의 생성자 인자에 @Qualifier("objectMapper")를 붙였다.
Qualifier는 타입으로 찾은 후보를 추가 조건으로 좁히는 데 사용한다. 이 경우에는 공용 mapper의 빈 이름을 지정했다. 같은 타입의 빈이 여러 개라고 언제나 실패하는 것은 아니다. Primary나 이름 매칭 등 다른 선택 조건도 있지만, 이번 구성에서는 주입할 대상을 결정하지 못했다. 공식 문서
공용 mapper에 전역 우선순위를 주는 방식은 선택하지 않았다. 다른 주입 지점의 선택까지 바꾸기보다 새 컴포넌트가 무엇을 사용할지 명시하는 쪽으로 수정 범위를 잡았다. 기존 Primary 정책과 Kafka mapper는 유지했다.
이후 전체 앱 기동 테스트는 통과했다. 다만 HTTP 테스트는 29개 중 23개가 실패했다. 이제 서버는 시작됐고, 실패는 실제 요청을 처리하는 과정으로 옮겨갔다.
두 번째 오류: Redis JSON 역직렬화 실패
다음 오류에는 PublicKeyCredentialCreationOptions를 생성할 수 없다는 내용이 있었다. 이 사례에서 저장하고 복원하던 WebAuthn 등록 옵션 타입이다. 기본 생성자 같은 Creator가 없다는 역직렬화 오류였고, 서버 기동 후 JSON을 읽는 경로에서 발생했다.
이 경로에서는 WebAuthn 옵션 객체를 Redis에 JSON으로 저장한 뒤 다시 같은 타입의 객체로 읽었다. JSON 쓰기에 성공하는 것만으로는 충분하지 않았다. 같은 mapper로 저장한 값을 원래 WebAuthn 옵션 타입으로 복원할 수 있어야 했다.
공용 mapper를 선택하는 데는 성공했지만 그 mapper에 필요한 모듈이 없었다. 실제 공용 mapper는 설정 코드에서 직접 생성하고 있었고, 테스트에서 사용하던 mapper와 구성이 달랐다.
Spring Boot 3.5는 Module 빈을 자동 구성된 Jackson2ObjectMapperBuilder에 등록하고, 그 builder가 만든 mapper에 적용한다. 직접 만든 mapper가 그 생성 경로를 거치지 않는다면 모듈이 빈으로 있다는 사실만으로는 충분하지 않다. 실제 주입된 인스턴스에 어떤 모듈이 연결됐는지를 봐야 했다. 공식 문서
WebAuthn 모듈과 애플리케이션 타입 모듈을 연결한 중간 수정 뒤에도 실패가 남았다. 이 경로에는 ParameterNamesModule도 필요했다. 오류 문구에 생성자가 나온다고 해서 DTO에 기본 생성자를 추가하는 문제로 한정할 수는 없었다. 복원할 타입과 그 타입을 읽는 mapper 설정을 함께 확인했다.
JSON 복원 해결: 선택한 mapper에 모듈 연결
공용 mapper의 생성 방식을 모두 바꾸는 것은 이번 수정 범위를 벗어났다. 여러 기능이 이미 사용 중이었고, Kafka 전용 mapper까지 같은 설정을 적용할 이유도 없었다.
최종적으로 해당 기능의 활성화 조건을 유지한 static @Bean 메서드로 BeanPostProcessor를 등록했다. 이름이 objectMapper인 빈에 ParameterNames, WebAuthn, 애플리케이션 사용자 타입 모듈을 최초 사용 전에 연결했다. Kafka 전용 mapper는 대상에서 제외했다.
BeanPostProcessor는 빈 생성 과정에 추가 처리를 붙일 수 있는 지점이다. 여기서는 기존 공용 mapper 생성 코드와 Primary 정책을 유지하면서 필요한 모듈을 연결하는 데 사용했다. 공식 문서
다만 주입할 곳을 네 컴포넌트로 제한했다고 해서 모듈의 영향도 그 네 곳에만 갇히는 것은 아니다. 변경 대상은 여러 기능이 공유하는 mapper였다. 그래서 오류가 발생한 요청뿐 아니라 기존 모듈 테스트 결과도 함께 확인했다.
이 방식은 당시 공통 설정을 유지해야 한다는 제약 아래 선택한 것이다. mapper 생성 경로를 정리할 수 있는 프로젝트라면 builder를 통한 공통 구성부터 검토할 수 있다. 모듈 누락이 생길 때마다 BeanPostProcessor를 추가하는 방식을 일반적인 해결책으로 삼지는 않았다.
검증: 서버 기동과 JSON 복원을 따로 확인
수정 결과는 세 단계로 남겼다. 실제 두 mapper를 포함한 수정 전 구성에서는 주입이 실패했다. Qualifier만 적용한 뒤에는 앱이 시작됐지만 HTTP 테스트 29개 중 23개가 실패했다. 필요한 모듈까지 연결한 뒤에는 기동 테스트 1개와 HTTP 테스트 29개가 통과했다.
각 테스트의 환경도 달랐다. 전체 앱 기동 테스트의 DB는 H2였다. HTTP 테스트는 실제 공용·Kafka mapper 설정을 포함한 인증 테스트 서버에 PostgreSQL과 Redis를 연결했다. 외부 Kafka 감사 전송은 mock이었으므로, 이 결과를 Kafka 송신까지 확인한 것으로 적지는 않았다.
이번 수정에서 빈 선택과 JSON 복원은 별도로 확인해야 했다. Qualifier는 사용할 mapper를 정했지만, 그 인스턴스에 필요한 모듈까지 구성해 주지는 않았다. 서버 기동을 확인한 뒤 실제 저장 데이터를 같은 타입으로 다시 읽는 요청까지 실행하면서 두 번째 오류를 잡았다.
답글 남기기