자바 아키텍처·Spring 학습 노트 목차

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를 산발적으로 쓰는 것보다 구조적이고 검증·메타데이터에 유리하다), @Profileapplication-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 bindingmy.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 프로브나 모니터링 연동의 표준 통로가 된다(관측성 편에서 자세히 다룬다).

버전은 어떻게 흘러가나 — 그리고 Spring Boot 4

Boot를 오래 쓰다 보면 반드시 부딪히는 사실이 하나 있다 — Spring Boot에는 LTS가 없다. 마이너 버전이 6개월마다 나오고, 각 마이너의 오픈소스 지원은 대략 12~13개월이다. "LTS를 잡고 몇 년 버틴다"는 전략이 애초에 존재하지 않는 릴리스 정책이라, 올라가지 않는 것 자체가 곧 기술 부채가 된다.

버전출시OSS 지원 종료
4.12026-062027-07
4.02025-112026-12
3.52025-052026-06 (종료)

Spring Boot 4는 Spring Framework 7 + Jakarta EE 11 위에 선다. 다행히 2.7 → 3.0 때의 javaxjakarta 같은 전면 개명은 없고, 베이스라인은 여전히 Java 17이다(Java 25를 1급으로 지원). 대신 성격이 다른 변화가 온다.

① 모듈화 — 업그레이드에서 실제로 부딪히는 지점. 코드베이스가 잘게 쪼개지면서, 예전에 다른 스타터에 묻어 들어오던 자동 설정이 더는 안 딸려온다. 그래서 3.x에서 올라오면 분명히 되던 것이 안 되는 경험을 하게 되는데, 대개 원인은 버그가 아니라 의존성을 명시적으로 선언해야 하는 것이다(spring-boot-starter-flyway 등). 앞서 본 조건 평가 리포트가 이럴 때 정확히 답을 준다 — 자동 설정이 탈락한 이유가 @ConditionalOnClass 실패로 찍힌다.

② API 버저닝이 프레임워크 기능이 됐다. 그동안 /v1/...·/v2/...로 경로를 갈라 컨트롤러를 복사하던 걸, version 속성으로 한 컨트롤러 안에서 처리한다.

@GetMapping(value = "/products/{id}", version = "1")
ProductV1 getV1(@PathVariable Long id) { ... }

@GetMapping(value = "/products/{id}", version = "2")   // 같은 경로, 다른 버전
ProductV2 getV2(@PathVariable Long id) { ... }

// 버전을 어디서 읽을지는 설정으로 — 헤더·쿼리 파라미터·미디어타입·경로 중 선택
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
    configurer.useRequestHeader("X-API-Version");
}

version = "1.2+"처럼 기준선 표기도 되고, deprecation 헤더도 프레임워크가 붙여 준다.

③ HTTP Service Client — 선언형 REST 클라이언트. 인터페이스만 정의하면 구현을 프레임워크가 만들어 준다. 외부 라이브러리 없이 Boot가 직접 제공한다.

public interface PaymentClient {
    @PostExchange("/charges")
    ChargeResult charge(@RequestBody ChargeRequest req);
}

@SpringBootApplication
@ImportHttpServices(group = "payment", basePackages = "com.example.client")
class App { }
spring:
  http:
    serviceclient:
      payment:
        base-url: "https://api.payments.example.com"
        connect-timeout: 2s
        read-timeout: 2s

④ 널 안전성이 컴파일 시점으로. 패키지에 @NullMarked를 걸면 그 안의 파라미터·반환값이 기본 non-null이 되고, 널이 될 수 있는 것만 @Nullable로 표시한다. 표준 규격인 JSpecify를 채택해서 IDE·정적 분석이 함께 읽는다 — Spring 포트폴리오 전체가 이 표기로 옮겨갔다.

⑤ 회복탄력성이 기본 탑재. @Retryable(지수 백오프·jitter 포함)과 @ConcurrencyLimit이 별도 라이브러리 없이 들어왔다(회복탄력성 편의 패턴들이 프레임워크 기본 기능이 된 셈이다).

⑥ Spring Data AOT. 리포지토리 쿼리 메서드의 준비 작업을 기동 시점이 아니라 빌드 시점으로 옮긴다 — 쿼리 메서드를 실제 소스 코드로 생성해 두므로, 기동할 때 리플렉션으로 쿼리를 파싱하는 과정이 사라진다. 기동 시간과 메모리가 함께 준다. 단 프리뷰 기능이고 기본으로 켜지지 않는다.

spring.aot.repositories.enabled=true      # 현재 JPA(Hibernate)·MongoDB 지원

4.1에서 더해진 것 중 실무에 바로 와닿는 건 lazy JDBC connection(spring.datasource.connection-fetch=lazyLazyConnectionDataSourceProxy로 감싸 첫 JDBC 문장 시점에 커넥션을 빌린다 → 커넥션 점유 시간 단축), gRPC 자동 설정, @RedisListener, 그리고 사용자가 준 URL로 내부망을 찌르는 **SSRF를 막는 InetAddressFilter**다.

실무에서는 — 업그레이드 순서. 3.x → 4.x는 한 번에 가지 말고 ① 3.5 최신 패치까지 올려 deprecation 경고를 0으로 만들고 ② Java 17 이상인지 확인한 뒤 ③ 4.0으로 올리면서 사라진 암묵적 의존성을 조건 리포트로 하나씩 채우는 순서가 안전하다. 그리고 4.0은 이미 지원 종료가 가까우니 신규는 4.1부터 시작한다.

(출처: Spring Boot 4.0.0 available now · API Versioning in Spring · Spring Boot — REST Clients · endoflife.date — Spring Boot.)

안 될 땐 — 조건 리포트와 실무

이제 처음의 질문, *"왜 이 빈이 생겼지, 혹은 왜 안 생겼지?"*로 돌아오자. 답은 --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가 답을 알려 준다. 그리고 Boot에는 LTS가 없어 6개월 주기·12개월 지원으로 계속 흘러가므로, 올라가는 것 자체가 유지보수의 일부다 — 현재 줄기는 Spring Framework 7 기반의 Boot 4이고, 모듈화로 사라진 암묵적 의존성·API 버저닝·HTTP Service Client·JSpecify 널 안전성이 그 변화의 핵심이다.

Spring 핵심 — IoC·DI·AOP애너테이션 지도 — 계층별로 무엇이 무엇을 하나