빌드노트
@Value 필드는 채워졌는데 생성자에서 만든 값은 왜 null일까
@Value 필드 주입은 생성자 실행 뒤에 일어난다. 생성자에서 만든 문자열이 초기값을 유지하는 관찰을 바탕으로, 생성자 매개변수 주입과 @PostConstruct 계산을 선택하는 기준을 설명한다.
Spring 빈의 @Value 필드는 정상인데, 생성자에서 그 필드를 읽어 만든 문자열에 null이 남을 수 있다. 필드 주입은 생성자가 끝난 다음에 일어난다. 나중에 원본 필드가 채워져도 생성자에서 이미 계산한 값까지 다시 만들어 주지는 않는다. 생성할 때 필요한 설정값이라면 @Value를 생성자 매개변수로 옮긴다.
먼저 문제가 난 빈의 생성자에서 @Value 필드를 읽는지 확인한다. 다음은 비교에 사용한 빈에서 원인을 드러내는 선언만 남긴 것이다. name과 label은 같은 객체의 필드다.
@Value("${catalog.name}")
private String name;
private final String label;
public FieldSnapshot() {
label = "catalog=" + name;
}
catalog.name=blue가 준비되어 있어도 이 생성자 안의 name은 아직 null이다. 문자열 연결 결과는 "catalog=null"이고, 그 문자열이 label에 저장된다. 이후 name에 "blue"가 주입되는 것은 별도 작업이다. label에서 final을 빼는 것만으로 재계산이 일어나지는 않는다.
Spring 6.2.19의 AutowiredAnnotationBeanPostProcessor API (새 탭에서 열림)는 이 처리기가 @Autowired와 @Value를 다루며, 필드 주입은 객체 생성 직후 수행한다고 설명한다. 위 코드에서는 그 주입보다 문자열 계산이 먼저 실행된 것이다.
원본 필드와 파생 값을 같은 시점에 비교한다
기존 빈을 진단할 때는 생성자 마지막, @PostConstruct 메서드 안, 컨텍스트에서 빈을 얻은 뒤의 세 지점에서 name과 label을 함께 본다. 디버거를 쓴다면 두 필드가 같은 인스턴스의 값인지도 확인한다. 조회 때 name은 정상인데 label만 잘못되어 있다면, 설정 파일을 다시 읽히기 전에 계산 위치부터 살펴볼 근거가 된다.
이를 확인하려고 Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19에서 별도 최소 AnnotationConfigApplicationContext를 실행했다. 실제 앱 설정 대신 catalog.name=blue 한 쌍을 메모리 프로퍼티로 넣었다. 각 비교는 새 컨텍스트에서 시작했고, 첫 번째 빈의 초기화 메서드는 값을 관찰만 했다. 다음은 그 실행의 출력이다.
field constructor name=null label=catalog=null
field postconstruct name=blue label=catalog=null
field lookup name=blue label=catalog=null
lookup은 컨텍스트 초기화를 마치고 getBean으로 받은 객체를 읽은 시점이다. 초기화 때 이미 name=blue였지만 label은 그대로였다. 이 비교에서 주입 자체는 성공했다. 잘못된 값이 남은 이유는 생성자에서 너무 일찍 문자열을 만들었기 때문이다.
생성자에서 필요한 값은 매개변수로 받는다
설정값으로 객체의 상태를 완성해야 한다면 필드의 @Value를 제거하고 생성자 매개변수에 붙인다. 다음은 수정할 필드와 생성자 부분이다. 클래스가 Spring 빈으로 등록되어 있고 생성자가 하나인 경우를 전제로 한다.
private final String name;
private final String label;
public ConstructorSnapshot(@Value("${catalog.name}") String name) {
this.name = name;
label = "catalog=" + name;
}
이번에는 Spring이 설정값을 생성자 인자로 전달하므로, 본문에서 사용하는 name이 이미 "blue"다. 별도 비교 실행에서도 생성자·초기화·조회 세 지점 모두 name=blue label=catalog=blue였다. green을 넣어 다시 실행했을 때도 세 지점 모두 catalog=green으로 바뀌었다.
공식 @Value 사용 문서 (새 탭에서 열림)도 생성자 매개변수에 프로퍼티를 주입하는 형태를 제시한다. 생성자가 하나인 빈은 생성자에 @Autowired를 추가하지 않아도 된다는 조건은 앞의 API 문서에서 확인할 수 있다. 필드 초기화식으로 계산 위치만 옮기면 여전히 필드 주입보다 먼저 실행되므로 이 문제를 피하지 못한다.
필드 주입을 유지한다면 계산을 초기화 단계로 옮긴다
기존 구조상 필드 주입을 유지해야 한다면, 생성자에서 하던 계산을 지우고 @PostConstruct 메서드로 옮길 수 있다. 아래 선언은 Spring 6 계열의 jakarta.annotation.PostConstruct를 사용한다.
@Value("${catalog.name}")
private String name;
private String label;
@PostConstruct
void init() {
label = "catalog=" + name;
}
이 방식의 비교 빈은 생성자에서 name=null label=null, 초기화와 조회 때는 모두 name=blue label=catalog=blue였다. 생성자에서 완성된 상태가 필요한 경우에는 앞의 매개변수 주입이 맞고, 필드 주입 뒤에 수행할 계산이라면 이 초기화 지점을 사용할 수 있다. 이 코드에서는 label을 생성자 밖에서 대입하므로 final로 선언하지 않았다.
Spring의 초기화 콜백 설명 (새 탭에서 열림)은 필요한 프로퍼티를 설정한 뒤 초기화 작업을 수행한다고 설명한다. @PostConstruct 문서 (새 탭에서 열림)에 따르면 해당 콜백을 처리하는 CommonAnnotationBeanPostProcessor가 컨텍스트에 등록되어 있어야 한다. 이번 실험에서는 애너테이션 기반 컨텍스트와 jakarta.annotation-api 2.1.1을 사용했다. JDK 17 자체에 이 애너테이션이 들어 있다고 가정하면 안 된다.
초기화 이후에도 원본 필드가 null이라면 이 글에서 관찰한 패턴과 다르다. 우선 검사한 객체를 Spring에서 받았는지, 직접 new로 만든 것은 아닌지 확인한다. 별도 대조에서 new FieldSnapshot()만 호출한 객체는 필드가 계속 null이었고 @PostConstruct도 자동 호출되지 않았다. 콜백을 생성자에서 직접 부르면 다시 주입 전으로 돌아가므로 해결책이 되지 않는다.
여기서 확인한 범위는 일반 JVM에서 Spring이 생성·초기화한 빈의 설정 주입과 문자열 계산 시점이다. 설정값 누락·우선순위 충돌, 설정 변경 후 재주입, 다른 Spring 버전이나 네이티브 이미지의 동작은 이 실험으로 검증하지 않았다.