Spring Boot — 설정 지옥을 자동화하다
순수 Spring은 강력하지만 설정이 많았다. XML을 쓰고, 빈을 일일이 정의하고, 서버를 따로 셋업해야 했다. Spring Boot는 여기에 "합리적 기본값 + 자동 설정 + 내장 서버"라는 답을 내놓아, 손대지 않아도 바로 도는 앱을 만든다. 그런데 이 "자동"이라는 게 마법처럼 보여서, 정작 왜 이 빈이 생겼는지, 어떻게 끄는지, 안 될 때 어떻게 디버깅하는지를 모르면 답답해진다. 그래서 자동 설정의 내부 동작까지 파고들며 그 답들을 같이 챙긴다.
왜 Boot인가 — 보일러플레이트의 제거
시작은 @SpringBootApplication 하나와 main이면 충분하다.
@SpringBootApplication
public class App {
public static void main(String[] args) { SpringApplication.run(App.class, args); }
}
이 @SpringBootApplication은 사실 세 가지를 합쳐 놓은 것이다 — 설정 클래스임을 뜻하는 @SpringBootConfiguration, 빈을 훑는 @ComponentScan, 그리고 자동 설정의 핵심인 @EnableAutoConfiguration. Boot의 철학은 한마디로 convention over configuration이다. Spring은 무엇이든 설정할 수 있어서 오히려 설정이 많았는데, Boot는 "대부분의 앱이 원하는 기본값"을 자동으로 깔아 주되 내가 직접 정의하면 조용히 물러난다. 즉 공통적인 건 자동으로, 특수한 것만 내가 손대면 되는 것이다 — 이 "내가 정의하면 물러난다"가 자동 설정 전체를 관통하는 약속이라, 잠시 뒤 그 내부 구현을 보면 모든 게 맞아떨어진다.
스타터 — 의존성을 묶음으로
설정만 자동인 게 아니라 의존성도 묶음으로 온다. spring-boot-starter-web 하나를 넣으면 Spring MVC와 내장 Tomcat, Jackson, 검증이 서로 호환되는 버전으로 한꺼번에 딸려 온다. 이런 스타터(-data-jpa·-security·-actuator…)는 함께 쓰이는 의존성을 묶어 둔 것이고, 버전은 Boot 부모 POM의 **BOM(Bill of Materials)**이 일괄로 맞춰 줘서(dependency-management), 라이브러리 버전이 어긋나 충돌하는 의존성 지옥을 크게 줄인다. 그래서 우리는 보통 버전을 적지 않고 스타터 이름만 적는다 — 버전은 Boot가 책임진다.
자동 설정의 내부 — 두 단계로 도는 조건 평가
이제 핵심이다. 자동 설정을 한 문장으로 줄이면 *"클래스패스에 무엇이 있는지 살펴보고, 내가 직접 만들지 않았으면 기본 빈을 대신 만들어 준다"*이다. 그런데 그 순서가 중요하다.
자동 설정을 끌어오는 AutoConfigurationImportSelector는 **DeferredImportSelector**라는 점이 결정적이다 — 이름 그대로 "미뤄서" 동작한다. 그래서 내 @Configuration·@Component가 먼저 처리된 뒤에야 자동 설정 후보들이 평가된다. 이 "사용자 먼저, 자동 나중" 순서가 바로 내 정의가 우선하는 토대다. 후보 목록은 META-INF/spring/...AutoConfiguration.imports(Boot 3 기준; 2.7 이전의 spring.factories를 대체) 파일에 적혀 있고, Boot는 애너테이션 프로세서가 미리 만들어 둔 spring-autoconfigure-metadata.properties로 명백히 탈락할 후보를 먼저 걸러 기동을 빠르게 한다. (출처: AutoConfigurationImportSelector, Creating Your Own Auto-configuration.)
살아남은 후보는 저마다 @Conditional 가드를 달고 있다. @ConditionalOnClass(그 라이브러리가 클래스패스에 있을 때), @ConditionalOnProperty(특정 프로퍼티가 켜졌을 때), @ConditionalOnWebApplication(웹 앱일 때) 등 종류가 다양한데, 그중 핵심이 @ConditionalOnMissingBean — "내가 그 빈을 직접 정의하지 않았을 때만" 등록하라는 조건이다.
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean // ← 내가 DataSource를 안 만들었을 때만
DataSource dataSource() { ... }
}
바로 이 @ConditionalOnMissingBean 하나가 앞에서 말한 그 약속, "기본은 깔되 내가 정의하면 양보한다"를 구현한다. 그래서 spring-boot-starter-data-jpa를 넣는 것만으로 DataSource·EntityManagerFactory·TransactionManager가 자동으로 생기고, 내가 DataSource 빈을 직접 만들면 자동으로 깔리려던 것은 물러난다. 자동 설정 사이에 순서가 필요하면(예: A 설정이 B 설정 뒤에 와야 하면) @AutoConfigureBefore·@AutoConfigureAfter·@AutoConfigureOrder로 조정하는데, 이 순서는 같은 selector가 가져온 자동 설정들 사이에서만 적용된다는 한계가 있다.
설정값은 코드 밖으로 — 외부화와 우선순위
포트, DB URL, 기능 토글 같은 설정값은 코드에 박지 않고 application.yml이나 환경 변수로 외부화한다.
spring:
datasource:
url: ${DB_URL:jdbc:h2:mem:test} # 환경변수 우선, 없으면 기본값
server:
port: 8080
이때 @ConfigurationProperties로 설정을 타입 안전한 객체에 묶으면(@Value를 산발적으로 쓰는 것보다 구조적이고 검증·메타데이터에 유리하다), @Profile과 application-prod.yml 같은 프로파일로 dev·prod 환경별 설정과 빈을 분리하면, 같은 jar를 설정만 바꿔 모든 환경에 배포할 수 있다(12-factor). 여러 프로파일을 묶고 싶으면 spring.profiles.group을, 외부 파일·Vault를 끌어오려면 spring.config.import를 쓴다.
설정 소스가 충돌하면 정해진 우선순위로 병합되는데, 바깥일수록 우선이다. 높은 쪽부터 대략 — 명령행 인자(--server.port=9000) > SPRING_APPLICATION_JSON > OS 환경 변수·시스템 프로퍼티 > 프로파일별 yml > 기본 application.yml > @PropertySource 순이고, spring.config.import로 여러 곳을 가져오면 나중에 적은 것이 우선한다. (출처: Externalized Configuration.) 그래서 운영에서 yml 값을 환경 변수로 오버라이드하는 게 자연스럽다. 한 가지 편의가 더 있는데, relaxed binding — my.service.pool-size라는 한 속성을 my.service.poolSize·MY_SERVICE_POOLSIZE(환경 변수) 같은 여러 표기로 받아 준다(특히 환경 변수 매핑에 요긴하다).
혼자 도는 앱 — 내장 서버와 실행 가능 jar
Boot 앱은 서버까지 품고 있다. SpringApplication.run(...)이 하는 일을 풀어 보면 — 환경을 준비하고, ApplicationContext를 만들고, 컨텍스트를 refresh하는 그 과정에서 내장 웹 서버(Tomcat 기본·Jetty·Undertow)를 생성해 띄운다. 그래서 별도 WAS 없이 java -jar app.jar로 단독 실행된다. 이 jar는 일반 jar 안에 의존성 jar들을 또 담은 특수 구조(nested jar)라, Boot의 JarLauncher가 중첩 jar를 읽을 수 있는 클래스로더로 부팅한다 — 컨테이너·클라우드 배포에 잘 맞는 형태다. 여기에 운영용 창구인 Actuator가 /actuator/health·/metrics·/prometheus·/loggers 같은 엔드포인트를 제공해, 쿠버네티스의 liveness/readiness 프로브나 모니터링 연동의 표준 통로가 된다(관측성 편에서 자세히 다룬다).
안 될 땐 — 조건 리포트와 실무
이제 처음의 질문, *"왜 이 빈이 생겼지, 혹은 왜 안 생겼지?"*로 돌아오자. 답은 --debug(또는 /actuator/conditions)로 보는 *조건 평가 리포트(Conditions Evaluation Report)*에 있다 — *어떤 @Conditional이 통과하고(Positive matches) 어떤 게 탈락했는지(Negative matches)*가 그대로 찍힌다. 기동이 아예 실패하면 FailureAnalyzer가 "포트가 이미 점유됨" 같은 사람이 읽을 진단과 조치를 알려 준다. 그리고 특정 자동 설정이 거슬리면 spring.autoconfigure.exclude나 @SpringBootApplication(exclude = ...)로 꺼 버리면 된다.
실무에서 더 챙길 것도 있다. 빈이 수천 개로 늘면 기동 시 모두 생성·조건 평가하는 비용이 커져 부팅이 느려지는데, spring.main.lazy-initialization=true로 첫 사용 시점까지 생성을 미뤄 기동을 앞당길 수 있다(대신 첫 요청이 느려지고 설정 오류가 늦게 드러나는 트레이드오프). 콜드 스타트가 치명적인 서버리스·함수 환경이라면 GraalVM 네이티브 이미지로 빌드해 기동을 밀리초대로 줄이기도 한다(대신 빌드 시간과 리플렉션 힌트의 부담이 따른다). 그리고 같은 이미지를 환경마다 다르게 띄우려면 비밀(secret)은 절대 코드·yml에 박지 말고 환경 변수나 시크릿 매니저로 외부화한다.
정리하면, Spring Boot는 합리적 기본값을 자동으로 깔되 내가 정의하면 물러나는(convention over configuration) 방식으로 설정 부담을 덜어 준다. 그 자동 설정은 AutoConfigurationImportSelector가 사용자 설정 다음에(deferred) 후보를 읽어 @Conditional로 판단하며, 특히 @ConditionalOnMissingBean이 "내 정의에 양보한다"를 구현한다. 의존성은 스타터+BOM이 버전까지 맞춰 묶어 주고, 설정값은 외부화·프로파일로 환경마다 갈아 끼우며(바깥일수록 우선·relaxed binding), 앱은 내장 서버를 띄우는 실행 가능 jar로 혼자 돌고 Actuator로 운영을 들여다본다 — 안 될 땐 조건 리포트와 FailureAnalyzer가 답을 알려 준다.