0. 들어가며

최근 SSAFY 자율 프로젝트를 하다가 남은 GMS(Gateway Management Service) 토큰을 활용하기 위해서 WebClient 비동기 방식으로 직접 Open AI를 연결하여 AI 기능을 수행하는 Client를 구현한 경험이 있습니다. 클라이언트를 직접 구현하던 중, 분명 설정 파일에 값은 잘 들어가 있는데 WebClient의 "GMS_BASE_URL"이 null로 설정되는 이상한 문제를 겪었습니다. 오류 내용은 다음과 같았습니다.
2025-12-05T19:37:33.398+09:00 ERROR 80129 --- [spots] [nio-8080-exec-6] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
[Request processing failed: java.lang.IllegalArgumentException: URI with undefined scheme]
with root cause java.lang.IllegalArgumentException: URI with undefined scheme at java.net.http/jdk.internal.net.http.common.Utils.newIAE(Utils.java:326) ~[java.net.http:na] Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: Error has been observed at the following site(s): *__checkpoint ⇢ Request to POST /v1/chat/completions [DefaultWebClient] Original Stack Trace:
오류 내용을 구분을 해놓았듯이, 가장 핵심적인 오류는 "URI with undefined scheme"입니다. 오류를 확인하고 나서, 해당 URI로 Postman 요청을 보내보았고, 문제없이 결과를 잘 받는 것을 확인했습니다. 왜 애플리케이션 단에서는 위와 같이 URI 뼈대가 정의되어있지 않다고 하는 것일까요? 이 글에서는 @Value 어노테이션에 대한 설명과 이 문제를 해결하는 과정에 대해서 작성해 보도록 하겠습니다 :>
1. @Value 어노테이션
1.1 사용하는 이유
@Value 어노테이션은 Spring에서 외부 설정 값(application.yml, properties, 환경 변수 등)을 빈 필드나 생성자 파라미터에 주입하기 위해 사용하는 어노테이션입니다. 예를 들어, API 키와 같이 비용과 관련된 변수, 노출되어선 안되는 특정 엔드포인트 등 외부에 노출되면 안 되는 값들을 내부적으로 주입받아 사용하기 위해서 많이 사용합니다.
- 환경별(dev, prod) 설정 분리
- 민감 정보(API Key 등) 코드 외부 관리
- 설정 변경 시 코드 수정 최소화
즉, "하드코딩을 제거하고 설정을 중심으로 개발을 가능하게 해주는 핵심 도구"인 셈이죠. 그런데, 왜 해당 코드에서 위와 같은 문제가 발생하는 것일까요? 해당 코드를 다시 살펴보도록 하겠습니다.
@Component
public class GmsOpenAiClient {
private final WebClient webClient;
@Value("${gms.base-url}")
private String GMS_BASE_URL;
public GmsOpenAiClient(@Value("${gms.api-key}") String key) {
this.webClient = WebClient.builder()
.baseUrl(GMS_BASE_URL)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key)
.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.build();
}
}
처음에는 단순히 환경 변수 설정에 문제라고 생각했지만, 결과적으로 원인은 "Spring Bean 생성 시점과 @Value 어노테이션 값 주입 시점"의 차이로 인해 발생한 오류였습니다. 자주 사용하는 어노테이션이지만, 이론적인 이해의 부족으로 발생한 오류인 것이죠...
1.2 주입 시점
그러면 @Value 어노테이션은 설정 값들을 변수에 어느 시점에 주입해주는 걸까요? 공식 문서에는 다음과 같이 적혀있습니다.

정리해 보면,
@Value 어노테이션의 실제 처리는 "BeanPostProcessor"에 의해 수행되므로 BeanPostProcessor 또는 BeanFactoryProcessor 유형 내에서는 @Value를 사용할 수 없다.
@Value의 실제 처리를 맡는 "BeanPostProcessor"는 @Bean이나 @Component를 사용하여 빈 등록을 하면 Spring은 대상 객체를 생성하고, Spring Container 내부의 빈 저장소에 해당 객체들을 저장합니다.

위 그림처럼 Spring에서 빈(Bean) 객체를 생성하고, 빈 저장소에 넘기는 과정에서 중간에 위치하는 빈 후처리기(BeanPostProcessor)를 통해 @Value 어노테이션에 대한 값을 객체에 넣어준 뒤에 빈 저장소에 저장하는 것입니다. 이 과정에서 만약 객체를 다른 객체로 바꿔치기한다면, 다른 객체가 빈 저장소에 등록되게 됩니다.

공식문서에서도 해당 내용을 살펴볼 수 있는데요. 핵심 내용은 다음과 같습니다.
1. Spring 컨테이너는 먼저 빈 객체를 생성하고, 그다음에 BeanPostProcessor가 그 객체를 처리한다.
2. BeanPostProcessor는 이미 만들어진 객체에만 작동한다.

또한, BeanPostProcessor의 여러 구현체 중에서도 "AutowiredAnnotationBeanPostProcessor" 구현체 클래스가 이를 맡아서 @Autowired, @Value, @Inject 어노테이션의 기능을 수행합니다.
1.3 전체 과정에 대한 이해 ❗️
사실 이 과정이 조금 복잡하고, 전체 흐름을 이해해야 해서 개인적으로 어려웠습니다. 그럼, 전체 과정에 대해서 이해하는 시간을 가져보도록 하겠습니다.

1. 애플리케이션 시작
SpringApplication.run() 실행 이후, ApplicationContext 생성합니다.
2. 컴포넌트 스캔 및 설정 클래스 파싱 (BeanDefinition 등록)
이 단계에서 Spring은 객체를 만드는 것이 아니라, 설계도를 수집합니다.
- 스캔 대상: @Component, @Controller, @Service, @Repository
- 스캔 대상: @Configuration, @Bean
이 내용들은 BeanDefinitionRegistry(내부 저장소)에 위 내용들이 등록됩니다.
3. BeanFactoryPostProcessor 실행 (설계도 수정 및 보강)
BeanFactoryPostProcessor는 BeanDefinition을 읽고, 수정할 수 있습니다.
- PropertySourcesPlaceholderConfigurer : ${...} placeholder 해석 준비
- ConfigurationPropertiesBindingPostProcessor : @ConfigurationProperties 바인딩 기반 마련
4. BeanPostProcessor 등록 (빈 후처리기 준비)
이 단계에서 Spring은 "나중에 객체 만들어지면 처리할 후처리기들"을 먼저 준비합니다.
- AutowiredAnnotationBeanPostProcessor (field, setter, method 주입, @Value 처리, @Inject)
- AOP 관련 BeanPostProcessor (프록시 생성)
아직 객체 생성 전이지만, 후 처리기 자체는 미리 준비합니다.
5. BeanFactory가 BeanDefinition을 기반으로 빈(Bean) 인스턴스 생성
BeanFactory가 각 BeanDefinition을 보고 다음과 같은 행동을 합니다.
- 어떤 생성 전략을 쓸지 결정합니다.
- 생성자 파라미터가 필요하면 의존성을 먼저 해결합니다.
- 생성자 파라미터에 @Value가 있으면 Environment에서 먼저 해석해서 전달합니다.
여기서 생성자가 호출됩니다.
6. Property 주입 단계에서 @Value(필드) 처리
객체가 만들어진 후, "필드에 값 채우기" 단계가 옵니다.
- 필드 @Value, @Autowired 등이 처리됩니다.
- 이 과정은 주로 AutowiredAnnotationBeanPostProcessor가 수행합니다.
이 단계에서 필드의 @Value 어노테이션 처리를 하기 때문에 생성자 호출단계에서 null 값이 들어가 URI 오류가 발생한 것입니다.
7. 초기화 콜백
- @PostConstruct 수행
- InitializingBean.afterPropertiesSet() 수행
8.. BeanPostProcessor before/afterInitialization (프록시 가능)
- postProcessBeforeInitialization 수행
- postProcessAfterInitialization 수행
여기서 AOP 프록시가 만들어지면 “바꿔치기(B 객체 등록)”가 발생할 수 있습니다.
9. 싱글톤 빈 저장소에 최종 등록
- 최종 객체(원본 or 프록시)가 Singleton Registry에 저장됩니다.
- 이제부터 getBean()으로 꺼내 쓰는 “완성된 빈”이 됩니다.
2. 문제 코드와 원인 분석
문제의 원인이 되었던 코드를 다시 보면서, @Value 어노테이션이 언제 주입되는지, 그리고 이를 어떻게 개선할 수 있는지 그 방법에 대해서 살펴보도록 합시다.
@Component
public class GmsOpenAiClient {
private final WebClient webClient;
@Value("${gms.base-url}")
private String GMS_BASE_URL;
public GmsOpenAiClient(@Value("${gms.api-key}") String key) {
this.webClient = WebClient.builder()
.baseUrl(GMS_BASE_URL)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key)
.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.build();
}
}
개념을 공부하고 나서, 다시 보니까 문제점이 바로 한눈에 보였습니다.
2.1 원인 분석
BeanFactory가 BeanDefinition을 기반으로 빈(Bean) 객체를 생성하기에 앞서, 생성자를 호출합니다. 생성자를 호출하게 되면 생성자 파라미터에 적힌 @Value를 처리하기 위해서 Environment를 통해 값을 채웁니다. "GMS_BASE_URL" 변수의 경우 아직 값이 채워지지 않았기 때문에 null 값으로 처리됩니다. 때문에 "URI with undefined scheme" 오류가 발생했던 것입니다.
즉, 아직 주입되지 않은 값을 사용해서 WebClient를 초기화하고 있었던 것입니다. 결과적으로 baseUrl(null) 상태의 WebClient가 만들어졌고, 이후 모든 요청이 정상 동작하지 않았던 것이죠.
이 사실을 모르고 코드를 작성해서, 의도치 않은 버그를 만나게 되었네요... 그런데, 알아가다 보니 상당히 복잡하면서도 재밌었습니다 :>
3. 해결 과정
이 오류를 해결하기 위한 방법은 생각보다 간단했는데요. 2가지 해결 방법을 소개드리겠습니다.
✅ 해결 방법 1: 생성자 주입으로 @Value 모두 처리하는 방법
@Component
public class GmsOpenAiClient {
private final WebClient webClient;
public GmsOpenAiClient(
@Value("${gms.api-key}") String key,
@Value("${gms.base-url}") String GMS_BASE_URL
) {
this.webClient = WebClient.builder()
.baseUrl(GMS_BASE_URL)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key)
.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.build();
}
}
가장 깔끔한 방법으로 필드 주입을 제거하고 생성자 주입으로 통일하는 것입니다.
이 방식의 장점은 다음과 같습니다.
- 생성 시점에 모든 값이 보장됩니다.
- 불변 객체(final) 유지가 가능합니다.
- 테스트 코드 작성이 쉽습니다.
✅ 해결 방법 2: @PostConstruct 사용하는 방법
@Component
public class GmsOpenAiClient {
private WebClient webClient;
@Value("${gms.base-url}")
private String GMS_BASE_URL;
@Value("${gms.api-key}")
private String key;
@PostConstruct
public void init() {
this.webClient = WebClient.builder()
.baseUrl(GMS_BASE_URL)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key)
.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.build();
}
}
이 방식 또한, 코드가 되게 간단하고, 전체 흐름을 충분히 이해하고 있어야 가능한 해결 방법입니다. Bean 객체가 만들어지고 나서, 최종적으로 빈으로 등록하기 전에 필드에 존재하는 @Value, @Autowired 등 어노테이션이 모두 처리된 값들을 활용하기 때문에 해당 문제를 해결할 수 있는 것입니다.
이 방법은 해결 방법의 하나이지만, 의존성 주입이 모두 끝난 직후에 일어난다는 점에서 다음과 같은 부분을 고려해야 합니다.
- 객체 생성과 초기화가 분리됩니다.
- final 사용이 불가합니다.
- 테스트/유지보수 측면에서 불리합니다.
4. 정리하며
이번 트러블 슈팅을 통해 정말 많은 것을 느낄 수 있었습니다. 자주 사용하던 어노테이션이 동작하는 과정을 모르니까, 오류가 나도 그 원인을 바로 파악하지 못했다는 점이 매우 인상 깊었습니다.
생성형 AI를 비롯하여 공식 문서, 여러 블로그들을 구글링하며 원인을 찾아냈지만, 그 원인이 되는 포인트가 정말 복잡한 과정들이 얽혀있고, Spring DI 개념, Bean 개념 등 Spring의 핵심 내부 기능들이 정말 중요하게 작용하고 있다는 것을 깨달았습니다. 아직 해당 부분들에 대해서 더 공부해야 할 부분들, 이해해야 할 부분들 많이 남아있습니다. 오늘 경험을 토대로 보다 더 내부 원리들을 파악하고자 마음먹은 계기가 된 것 같습니다.
앞으로는 같은 오류를 반복하지 않도록 전체적인 원리와 그 흐름을 이해하기 위해 노력하겠습니다. 더불어, 설정 변수들, WebClient, DataSource와 같이 초기 설정이 중요한 변수 및 객체를 만들 때는 주입 시점을 정확히 이해하고 코드를 작성할 수 있을 것 같아 기분이 매우 행복합니다 :>
빈 생성 시점과 DI 전체 과정 및 순서를 제대로 이해하자 !!
5. Reference
'SpringBoot' 카테고리의 다른 글
| [SpringBoot] CORS 개념과 그 해결법에 대해서 (1) | 2025.12.05 |
|---|
