본문 바로가기
1. 개발

[Spring] 모노레포 환경에서 MessageSource를 모듈별로 독립시키는 방법(feat. BeanPostProcessor)

by su8y 2026. 1. 14.

최근 로또 프로젝트를 모노레포(Monorepo) 구조로 전환하면서 흥미로운 기술적 난관에 부딪혔습니다.


현재 프로젝트는 모듈별 관심사 분리를 위해 Common 모듈의 AbstractCommonException과 ErrorCode를 확장하여 각 모듈(User, Lotto, Resilience 등)이 독자적인 에러 처리를 담당하고 있습니다. 이때 ErrorCode를 구현한 Enum은 MessageSource를 통해 국제화(i18n)된 에러 메시지를 반환하게 됩니다.

public interface ErrorCode {
	String code();

	HttpStatus status();

	String messageKey();
}


public abstract class AbstractErrorCodeException extends RuntimeException {

	private final ErrorCode errorCode;
	private final Object[] args;
    
    // ...
}

문제 상황: 중앙 집중식 설정의 한계

이상적인 구조라면 각 모듈이 저마다의 messages.properties를 독립적으로 관리해야 합니다. 하지만 이를 중앙(메인 애플리케이션)에서 통합하려다 보면, 불필요한 결합이 생기거나 설정 충돌이 발생하기 쉽습니다.

# Bad Case: 메인 모듈이 모든 서브 모듈을 알고 있어야 함
spring.messages.basename=common,user,auth,resilience,lotto

오늘은 이 문제를 해결하기 위해 시도했던 CompositeMessageSource 접근법, Spring 메인테이너의 답변, 그리고 최종적으로 선택한 BeanPostProcessor를 활용한 우아한 해결책을 정리합니다.

해결책 1. Composite 패턴을 써볼까?

가장 먼저 떠오른 생각은 여러 MessageSource를 묶어서 관리해 주는 CompositeMessageSource 같은 구현체를 찾는 것이었습니다. "스프링이라면 당연히 있겠지?"라고 생각했죠.

하지만 놀랍게도 공식 문서나 코드베이스 어디에도 이런 구현체는 없었습니다. 검색 끝에 저와 똑같은 고민을 했던 개발자가 올린 Github Issue (SPR-16614)를 발견했습니다.

이슈 제기자(Dmytro Nosan)의 호소

  • 여러 MessageSource를 사용할 때 NoSuchMessageException 처리가 반복된다.
  • useCodeAsDefaultMessage 옵션 사용 시, 첫 번째 소스에서 찾지 못하면 바로 코드를 반환해 버려 다음 소스로 넘어가지 못한다.
  • "프레임워크 차원에서 Composite 기능을 제공해 달라!"

Spring 메인테이너(Juergen Hoeller) Juergen Hoeller의 답변은 단호했습니다.

"We have a 'parent' arrangement for MessageSource providers... Since we have such a delegation model in the core framework already, we won't add another variant out of the box."

"우리는 이미 부모 위임(Delegation) 모델을 가지고 있습니다. 코어 프레임워크에 이미 이런 모델이 있으므로, 별도의 Composite 변형을 추가할 계획이 없습니다."

결국 이 이슈는 "계층 구조(HierarchicalMessageSource)를 쓰는 것이 더 자연스럽다"는 이유로 닫혔습니다.

결론: 스프링은 A -> B -> C와 같은 수직적 부모-자식 관계를 권장합니다. 하지만 서로 대등한 관계인 모듈들(User, Lotto)을 수직적으로 엮는 것은 아키텍처상 어색했고, 관리 복잡도만 높이는 결과를 낳았습니다.

해결책 2. BeanPostProcessor (BPP)

새로운 구현체를 만드는 대신, 기존 구현체의 설정을 확장하는 방식으로 접근했습니다.

Spring의 ReloadableResourceBundleMessageSource는 변경 감지를 지원하며, addBasenames(String) 메소드를 통해 런타임에 basename을 추가할 수 있습니다. 그렇다면 Spring Boot가 생성한 빈에 우리 모듈의 경로만 슬쩍 끼워 넣으면 되지 않을까요?

이때 사용할 수 있는 것이 BeanPostProcessor입니다.
BeanPostProcessor는 컨테이너의 모든 빈이 초기화(Initialization)되기 전후에 개입할 수 있는 권한을 가집니다. 즉, 우리는 빈 생성 직후 다음과 같이 요청할 수 있습니다.

"방금 생성된 messageSource 빈 잠깐만요. 내 모듈 설정 파일 경로 하나만 추가하고 돌려드릴게요."

우아한 해결책: 침투적(Invasive) 설정에서 반응형(Reactive) 설정으로

구현은 간단합니다. 각 모듈에 BeanPostProcessor를 구현한 설정 클래스를 하나씩 심어두기만 하면 됩니다.

구현 코드 (Resilience 모듈 예시)


@Component
public class ResilienceMessagePostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if ("messageSource".equals(beanName) && bean instanceof AbstractResourceBasedMessageSource) {

            AbstractResourceBasedMessageSource messageSource = (AbstractResourceBasedMessageSource) bean;

            messageSource.addBasenames("resilience-messages");

            log.info("Resilience 모듈의 메시지 설정이 성공적으로 주입되었습니다.");
        }
        return bean;
    }
}
  1. Spring Boot가 구동되며 기본 messageSource 빈을 생성합니다.
  2. 빈 초기화 직전, 등록된 BeanPostProcessor들이 순회합니다.
  3. 각 모듈의 Processor가 messageSource를 발견하면 자신의 경로를 addBasenames()로 리스트 끝에 추가합니다.
  4. 결과적으로 하나의 MessageSource 빈 안에 모든 모듈의 설정이 누적(Accumulate)됩니다.

마치며: 완벽한 정답은 없다 (Trade-offs)

처음에는 Composite 구현체가 없다는 사실에 당황했지만, 메인테이너들의 토론을 통해 스프링의 철학(위임 모델)을 이해할 수 있었습니다. 저는 그 철학을 존중하면서도 모듈 간 수평적 결합이라는 요구사항을 해결하기 위해 BeanPostProcessor를 선택했습니다.

이 방식은 단순 설정 파일 수정보다 명확한 장점이 있습니다.

  • 완벽한 모듈 자율성: 모듈을 추가하면 설정이 자동으로 따라오고, 제거하면 깔끔하게 사라집니다.
  • 안전한 확장: 기존 빈을 재정의(@Bean)하여 충돌을 일으키는 대신, 빈을 후처리하여 안전하게 확장했습니다.

남은 과제와 한계

물론 은탄환은 아닙니다.

  • 메시지 키 충돌 위험: 전역 네임스페이스가 오염될 수 있으므로, 엄격한 네이밍 컨벤션으로 충돌을 원천 차단해야 합니다.
  • 구체 클래스에 대한 강한 결합: 위 코드는 ReloadableResourceBundleMessageSource 같은 특정 구현체에 강하게 결합되어 있습니다. 만약 프레임워크가 내부 구현체를 변경하거나 다른 커스텀 구현체로 교체된다면, 이 설정은 조용히 실패(Silent Failure)할 수 있습니다.

유연함을 위해 도입한 코드가 역설적으로 특정 구현체에 대한 의존성을 만들어낸 셈입니다. 하지만 현재 상황에서 스프링 생태계의 편의성과 모듈의 자율성을 동시에 확보하는 방법을 선택하게 되었습니다.