- Published on
Spring Boot 3.x 완전 가이드 2025: Virtual Threads, GraalVM Native, Spring AI
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. Spring Boot 3.x 빅 픽처
- 2. Java 21 Virtual Threads 실전 활용
- 3. GraalVM Native Image
- 4. Spring AI — LLM 통합
- 5. Spring Security 6
- 6. Spring Data JPA + R2DBC
- 7. Testcontainers + @ServiceConnection
- 8. Observability (Micrometer + OTLP)
- 9. Docker + Kubernetes 배포
- 10. 프로덕션 체크리스트 15
- 11. 인터뷰 질문 15선
- 12. 퀴즈
- 참고 자료
들어가며
Spring Boot 3.x는 Java 생태계에서 가장 강력한 프레임워크로 자리잡았습니다. Jakarta EE 10으로의 전환, Java 21 Virtual Threads 네이티브 지원, GraalVM Native Image 정식 지원, 그리고 Spring AI까지 — 2025년 현재 Spring Boot는 단순한 웹 프레임워크를 넘어 엔터프라이즈 AI 애플리케이션의 기반이 되고 있습니다.
이 가이드에서는 Spring Boot 3.x의 핵심 기능을 모두 다룹니다. Virtual Threads로 동시성을 극대화하는 방법부터 GraalVM으로 0.1초 기동을 달성하는 실전 설정, Spring AI로 LLM을 통합하는 파이프라인, 그리고 프로덕션 운영에 필요한 Observability, Security, 배포 전략까지 900줄 이상의 방대한 내용을 체계적으로 정리합니다.
1. Spring Boot 3.x 빅 픽처
1.1 Jakarta EE 마이그레이션
Spring Boot 3.0의 가장 큰 변화는 javax.* 에서 jakarta.* 네임스페이스로의 전환입니다.
// Before (Spring Boot 2.x)
import javax.persistence.Entity;
import javax.servlet.http.HttpServletRequest;
// After (Spring Boot 3.x)
import jakarta.persistence.Entity;
import jakarta.servlet.http.HttpServletRequest;
마이그레이션 체크리스트:
- 모든
javax.persistence를jakarta.persistence로 변경 javax.servlet를jakarta.servlet로 변경javax.validation를jakarta.validation로 변경- 서드파티 라이브러리의 Jakarta EE 호환 버전 확인
- OpenRewrite 자동 마이그레이션 레시피 활용
// build.gradle — OpenRewrite 자동 마이그레이션
plugins {
id 'org.openrewrite.rewrite' version '6.16.0'
}
rewrite {
activeRecipe(
'org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3'
)
}
dependencies {
rewrite platform('org.openrewrite.recipe:rewrite-recipe-bom:2.13.0')
rewrite 'org.openrewrite.recipe:rewrite-spring'
}
1.2 Java 21 베이스라인
Spring Boot 3.2 이상은 Java 17이 최소 요구사항이며, 3.3부터는 Java 21을 적극 활용합니다.
| 기능 | Java 17 | Java 21 |
|---|---|---|
| Record 패턴 | 기본 | 향상된 패턴 매칭 |
| Sealed 클래스 | 지원 | 완전 지원 |
| Virtual Threads | 미지원 | 정식 지원 |
| Structured Concurrency | 미지원 | Preview |
| String Templates | 미지원 | Preview |
1.3 모듈 구조 권장 패턴
my-app/
app-api/ # REST 컨트롤러, DTO
app-domain/ # 엔티티, 서비스, 리포지토리 인터페이스
app-infra/ # DB, 외부 API 구현체
app-common/ # 공통 유틸, 예외
app-batch/ # 배치 잡
// settings.gradle
rootProject.name = 'my-app'
include 'app-api', 'app-domain', 'app-infra', 'app-common', 'app-batch'
2. Java 21 Virtual Threads 실전 활용
2.1 Virtual Threads vs OS Threads
기존 Java의 스레드 모델은 OS 커널 스레드와 1:1 매핑됩니다. 하나의 OS 스레드가 약 1MB의 스택 메모리를 사용하므로, 4GB 힙에서 최대 4,000개 정도의 동시 요청만 처리 가능했습니다.
Virtual Threads는 JVM이 관리하는 경량 스레드로, 수 KB의 메모리만 사용합니다.
// 전통적 스레드 풀 (Spring Boot 2.x 기본)
// 최대 200개 스레드 = 200개 동시 요청
@Configuration
public class ThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandler() {
return handler -> handler.setMaxThreads(200);
}
}
// Virtual Threads (Spring Boot 3.2+)
// 수만 개의 동시 요청 처리 가능
// application.yml 한 줄이면 됨
# application.yml — Virtual Threads 활성화
spring:
threads:
virtual:
enabled: true
이것 하나로 Tomcat의 모든 요청 처리가 Virtual Thread에서 수행됩니다.
2.2 벤치마크: 전통 스레드 vs Virtual Threads
@RestController
@RequestMapping("/api/benchmark")
public class BenchmarkController {
@GetMapping("/blocking")
public String blockingCall() throws InterruptedException {
// DB 호출 시뮬레이션 (100ms 블로킹)
Thread.sleep(100);
return "response from " + Thread.currentThread();
}
}
부하 테스트 결과 (k6, 1000 동시 사용자):
| 메트릭 | 전통 스레드 (200) | Virtual Threads |
|---|---|---|
| RPS | 1,850 | 9,200 |
| P95 레이턴시 | 520ms | 108ms |
| P99 레이턴시 | 1,200ms | 115ms |
| 메모리 사용 | 800MB | 350MB |
| 에러율 | 3.2% | 0% |
2.3 Virtual Threads vs WebFlux
// WebFlux (리액티브 — 학습 곡선 높음)
@GetMapping("/reactive")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id)
.flatMap(user -> enrichmentService.enrich(user))
.onErrorResume(e -> Mono.error(new UserNotFoundException(id)));
}
// Virtual Threads (동기 코드 그대로 — 학습 곡선 낮음)
@GetMapping("/virtual")
public User getUser(@PathVariable Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return enrichmentService.enrich(user);
}
Virtual Threads를 선택해야 하는 경우:
- I/O 바운드 작업이 대부분 (DB, HTTP, 파일)
- 기존 Spring MVC 코드베이스
- 팀의 리액티브 경험이 부족한 경우
WebFlux를 선택해야 하는 경우:
- 스트리밍 데이터 처리 (SSE, WebSocket)
- 논블로킹 백프레셔가 필요한 경우
- 이미 리액티브 스택에 익숙한 팀
2.4 Virtual Threads 사용 시 주의사항
// 주의 1: synchronized 블록에서 carrier thread pinning 발생
// BAD — carrier thread가 고정되어 성능 저하
public synchronized void updateCache() {
// 이 안에서 블로킹 I/O 하면 안 됨
httpClient.send(request, bodyHandler);
}
// GOOD — ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();
public void updateCache() {
lock.lock();
try {
httpClient.send(request, bodyHandler);
} finally {
lock.unlock();
}
}
// 주의 2: ThreadLocal 과도 사용 금지
// Virtual Thread는 수만 개 생성되므로 ThreadLocal 메모리 누수 위험
// ScopedValue(Preview) 사용 권장
// 주의 3: CPU 바운드 작업에는 부적합
// 피보나치 계산 같은 CPU 작업은 Virtual Thread가 이점 없음
# Virtual Thread 모니터링 JFR 이벤트 활성화
# application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,threaddump
3. GraalVM Native Image
3.1 왜 Native Image인가?
기존 JVM 애플리케이션은 기동에 2-5초, 메모리 200MB 이상이 필요합니다. GraalVM Native Image는 AOT(Ahead-of-Time) 컴파일을 통해 네이티브 바이너리를 생성합니다.
| 메트릭 | JVM | Native Image |
|---|---|---|
| 기동 시간 | 2.5초 | 0.08초 |
| 메모리 | 250MB | 80MB |
| 패키지 크기 | 18MB (JAR) | 65MB (바이너리) |
| Peak 처리량 | 높음 | JVM 대비 80-90% |
| 빌드 시간 | 5초 | 3-5분 |
3.2 Native Image 빌드 설정
// build.gradle
plugins {
id 'org.graalvm.buildtools.native' version '0.10.1'
id 'org.springframework.boot' version '3.3.0'
}
graalvmNative {
binaries {
main {
buildArgs.addAll(
'--initialize-at-build-time=org.slf4j',
'-H:+ReportExceptionStackTraces',
'--verbose'
)
jvmArgs.add('-Xmx8g') // 빌드 시 메모리
}
}
}
# Native Image 빌드
./gradlew nativeCompile
# 실행 (0.08초 기동!)
./build/native/nativeCompile/my-app
# Docker로 빌드
./gradlew bootBuildImage
3.3 Reflection Hints 설정
GraalVM은 AOT 컴파일 시 리플렉션을 정적으로 분석합니다. 동적 리플렉션은 힌트를 제공해야 합니다.
// RuntimeHintsRegistrar로 힌트 등록
@ImportRuntimeHints(MyRuntimeHints.class)
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
public class MyRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// 리플렉션 힌트
hints.reflection()
.registerType(UserDto.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS);
// 리소스 힌트
hints.resources()
.registerPattern("templates/*.html")
.registerPattern("static/**");
// 직렬화 힌트
hints.serialization()
.registerType(UserDto.class);
}
}
3.4 Native Image 제한사항과 해결책
주요 제한사항:
- 동적 프록시:
@RegisterReflectionForBinding사용 - CGLIB 프록시: Spring AOT가 자동 처리
- 리소스 번들:
@ResourceHint로 등록 - JNI: 수동 힌트 필요
// DTO에 @RegisterReflectionForBinding 사용
@RegisterReflectionForBinding(UserDto.class)
@RestController
public class UserController {
// UserDto가 Native Image에서 직렬화/역직렬화 가능
}
4. Spring AI — LLM 통합
4.1 Spring AI 개요
Spring AI는 Spring 생태계에서 AI/LLM을 통합하는 공식 프레임워크입니다. OpenAI, Anthropic, Ollama, Azure OpenAI 등 다양한 모델을 지원합니다.
// build.gradle
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0'
// 또는 Anthropic
// implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter:1.0.0'
}
# application.yml
spring:
ai:
openai:
api-key: your-api-key-here
chat:
options:
model: gpt-4o
temperature: 0.7
4.2 ChatClient 기본 사용
@Service
public class AiService {
private final ChatClient chatClient;
public AiService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("당신은 친절한 Java 개발 도우미입니다.")
.build();
}
// 간단한 텍스트 생성
public String chat(String userMessage) {
return chatClient.prompt()
.user(userMessage)
.call()
.content();
}
// 구조화된 응답 (JSON -> Java 객체)
public CodeReview reviewCode(String code) {
return chatClient.prompt()
.user(u -> u.text("다음 코드를 리뷰해주세요: {code}")
.param("code", code))
.call()
.entity(CodeReview.class);
}
// 스트리밍 응답
public Flux<String> streamChat(String userMessage) {
return chatClient.prompt()
.user(userMessage)
.stream()
.content();
}
}
public record CodeReview(
int score,
List<String> issues,
List<String> suggestions,
String summary
) {}
4.3 RAG 파이프라인 구축
@Configuration
public class RagConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return new PgVectorStore(
jdbcTemplate, embeddingModel,
PgVectorStore.PgVectorStoreConfig.builder()
.withDimensions(1536)
.withIndexType(PgVectorStore.PgIndexType.HNSW)
.build()
);
}
}
@Service
public class RagService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public RagService(ChatClient.Builder builder, VectorStore vectorStore) {
this.vectorStore = vectorStore;
this.chatClient = builder
.defaultAdvisors(
new QuestionAnswerAdvisor(vectorStore,
SearchRequest.defaults().withTopK(5))
)
.build();
}
// 문서 인덱싱
public void indexDocuments(List<Document> documents) {
// 텍스트 분할
TokenTextSplitter splitter = new TokenTextSplitter(800, 200, 5, 10000, true);
List<Document> chunks = splitter.apply(documents);
// 벡터 저장소에 저장
vectorStore.add(chunks);
}
// RAG 기반 질의
public String queryWithContext(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
4.4 Tool Calling (Function Calling)
@Service
public class WeatherService {
@Tool("현재 날씨를 조회합니다")
public WeatherInfo getCurrentWeather(
@ToolParam("도시 이름") String city
) {
// 실제 날씨 API 호출
return weatherApiClient.getWeather(city);
}
}
@RestController
public class AiController {
private final ChatClient chatClient;
private final WeatherService weatherService;
@GetMapping("/ai/chat")
public String chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.tools(weatherService)
.call()
.content();
}
}
// "서울 날씨 어때?" -> AI가 자동으로 WeatherService.getCurrentWeather("서울") 호출
5. Spring Security 6
5.1 SecurityFilterChain 설정
Spring Security 6에서는 WebSecurityConfigurerAdapter 가 완전히 제거되었습니다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.cors(cors -> cors.configurationSource(corsConfigSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter()))
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new Http403ForbiddenEntryPoint())
)
.build();
}
@Bean
public CorsConfigurationSource corsConfigSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://myapp.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
5.2 OAuth2 Resource Server + JWT
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/my-realm
# 또는 jwk-set-uri
// JWT 클레임 -> Spring Security Authority 매핑
@Bean
public JwtAuthenticationConverter jwtConverter() {
JwtGrantedAuthoritiesConverter grantedAuthorities =
new JwtGrantedAuthoritiesConverter();
grantedAuthorities.setAuthoritiesClaimName("roles");
grantedAuthorities.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthorities);
return converter;
}
// Method Security
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public User getUser(String userId) {
return userRepository.findById(userId).orElseThrow();
}
@PostAuthorize("returnObject.email == authentication.name")
public UserProfile getProfile(String userId) {
return profileRepository.findByUserId(userId);
}
}
5.3 CORS, CSRF 전략
REST API의 경우 일반적으로 CSRF를 비활성화하고 CORS를 명시적으로 설정합니다.
// SPA + REST API 환경에서의 보안 설정
@Bean
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(AbstractHttpConfigurer::disable) // JWT 사용 시 CSRF 불필요
.cors(cors -> cors.configurationSource(corsConfigSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
6. Spring Data JPA + R2DBC
6.1 Hibernate 6 + Spring Data 2025
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(unique = true, nullable = false)
private String email;
@Enumerated(EnumType.STRING)
private UserStatus status;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
// Spring Data JPA Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 메서드 이름 기반 쿼리
Optional<User> findByEmail(String email);
// JPQL
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt > :since")
List<User> findActiveUsersSince(
@Param("status") UserStatus status,
@Param("since") LocalDateTime since
);
// Native Query
@Query(value = "SELECT * FROM users WHERE email LIKE %:domain", nativeQuery = true)
List<User> findByEmailDomain(@Param("domain") String domain);
// Projection
@Query("SELECT u.name as name, u.email as email FROM User u WHERE u.id = :id")
Optional<UserSummary> findSummaryById(@Param("id") Long id);
}
interface UserSummary {
String getName();
String getEmail();
}
6.2 QueryDSL 통합
// build.gradle
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0'
}
@Repository
@RequiredArgsConstructor
public class UserQueryRepository {
private final JPAQueryFactory queryFactory;
public Page<User> searchUsers(UserSearchCond cond, Pageable pageable) {
QUser user = QUser.user;
BooleanBuilder where = new BooleanBuilder();
if (cond.getName() != null) {
where.and(user.name.containsIgnoreCase(cond.getName()));
}
if (cond.getStatus() != null) {
where.and(user.status.eq(cond.getStatus()));
}
if (cond.getFromDate() != null) {
where.and(user.createdAt.goe(cond.getFromDate()));
}
List<User> content = queryFactory
.selectFrom(user)
.where(where)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(user.createdAt.desc())
.fetch();
long total = queryFactory
.selectFrom(user)
.where(where)
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
}
6.3 R2DBC (리액티브 데이터 접근)
// R2DBC Repository (WebFlux 사용 시)
public interface ReactiveUserRepository extends ReactiveCrudRepository<User, Long> {
Flux<User> findByStatus(UserStatus status);
Mono<User> findByEmail(String email);
}
@Service
@RequiredArgsConstructor
public class ReactiveUserService {
private final ReactiveUserRepository userRepository;
public Flux<User> getActiveUsers() {
return userRepository.findByStatus(UserStatus.ACTIVE)
.delayElements(Duration.ofMillis(10)) // 백프레셔 시뮬레이션
.onErrorResume(e -> Flux.empty());
}
}
7. Testcontainers + @ServiceConnection
7.1 Testcontainers 기본 설정
// build.gradle
dependencies {
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:kafka'
}
7.2 @ServiceConnection 자동 설정
Spring Boot 3.1부터 @ServiceConnection으로 Testcontainers를 자동 설정할 수 있습니다.
@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUser() {
// Given
CreateUserRequest request = new CreateUserRequest("John", "john@test.com");
// When
User created = userService.createUser(request);
// Then
assertThat(created.getId()).isNotNull();
assertThat(created.getName()).isEqualTo("John");
User found = userRepository.findById(created.getId()).orElseThrow();
assertThat(found.getEmail()).isEqualTo("john@test.com");
}
}
7.3 테스트 격리와 CI/CD 통합
// 공통 테스트 설정 — 모든 통합 테스트에서 재사용
@TestConfiguration(proxyBeanMethods = false)
public class TestContainersConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // 컨테이너 재사용으로 테스트 속도 향상
}
@Bean
@ServiceConnection
public GenericContainer<?> redisContainer() {
return new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);
}
}
// CI/CD에서의 Testcontainers (GitHub Actions)
# .github/workflows/test.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Run tests
run: ./gradlew test
env:
TESTCONTAINERS_RYUK_DISABLED: false
8. Observability (Micrometer + OTLP)
8.1 Micrometer + OpenTelemetry 통합
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-otlp'
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
}
# application.yml
management:
otlp:
metrics:
export:
url: http://otel-collector:4318/v1/metrics
step: 30s
tracing:
endpoint: http://otel-collector:4318/v1/traces
tracing:
sampling:
probability: 1.0 # 프로덕션에서는 0.1 등으로 조절
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
metrics:
tags:
application: my-app
environment: production
8.2 커스텀 메트릭
@Service
@RequiredArgsConstructor
public class OrderService {
private final MeterRegistry meterRegistry;
private final Counter orderCounter;
private final Timer orderProcessingTimer;
public OrderService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.orderCounter = Counter.builder("orders.created")
.tag("type", "standard")
.description("Number of orders created")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.time")
.description("Order processing time")
.register(meterRegistry);
}
public Order createOrder(CreateOrderRequest request) {
return orderProcessingTimer.record(() -> {
Order order = processOrder(request);
orderCounter.increment();
// 게이지 — 현재 상태
meterRegistry.gauge("orders.pending",
orderRepository.countByStatus(OrderStatus.PENDING));
return order;
});
}
}
8.3 분산 추적 (Distributed Tracing)
@Service
@RequiredArgsConstructor
public class PaymentService {
private final RestClient restClient;
private final ObservationRegistry observationRegistry;
// Spring Boot 3.x에서는 RestClient/WebClient가 자동으로 trace 전파
public PaymentResult processPayment(PaymentRequest request) {
Observation observation = Observation.createNotStarted(
"payment.process", observationRegistry
);
return observation.observe(() -> {
// 이 호출의 trace-id가 외부 서비스까지 전파됨
return restClient.post()
.uri("https://payment-gateway.com/api/charge")
.body(request)
.retrieve()
.body(PaymentResult.class);
});
}
}
8.4 Grafana 대시보드 설정
# docker-compose.yml (Observability Stack)
version: '3.8'
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.96.0
ports:
- "4317:4317" # gRPC
- "4318:4318" # HTTP
volumes:
- ./otel-config.yaml:/etc/otelcol/config.yaml
prometheus:
image: prom/prometheus:v2.51.0
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:10.4.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686" # UI
- "14250:14250" # gRPC
9. Docker + Kubernetes 배포
9.1 멀티 스테이지 Dockerfile
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle settings.gradle ./
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
COPY /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseZGC", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar"]
9.2 Cloud Native Buildpacks
# Buildpacks로 이미지 빌드 (Dockerfile 불필요)
./gradlew bootBuildImage --imageName=myapp:latest
# Native Image로 빌드
./gradlew bootBuildImage --imageName=myapp:native \
-Pnative=true
9.3 Kubernetes 매니페스트
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
labels:
app: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: myapp:latest
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "production"
- name: JAVA_TOOL_OPTIONS
value: "-XX:+UseZGC -XX:MaxRAMPercentage=75.0"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
startupProbe:
httpGet:
path: /actuator/health
port: 8080
failureThreshold: 30
periodSeconds: 2
terminationGracePeriodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: my-app-service
spec:
selector:
app: my-app
ports:
- port: 80
targetPort: 8080
type: ClusterIP
9.4 Graceful Shutdown
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
// 커스텀 Graceful Shutdown 로직
@Component
public class GracefulShutdownHandler implements DisposableBean {
private final ExecutorService executorService;
@Override
public void destroy() throws Exception {
log.info("Graceful shutdown initiated...");
executorService.shutdown();
if (!executorService.awaitTermination(25, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
log.info("Graceful shutdown completed");
}
}
10. 프로덕션 체크리스트 15
보안
- Security Headers —
X-Content-Type-Options,X-Frame-Options,Content-Security-Policy설정 - Actuator 보안 —
/actuator엔드포인트 인증 필수, 민감 정보 비활성화 - Secrets 관리 — 환경변수 또는 Vault로 시크릿 주입, application.yml에 하드코딩 금지
- HTTPS 강제 —
server.ssl.enabled=true또는 로드밸런서 TLS 종단 - 의존성 취약점 스캔 — Dependabot, Snyk, OWASP Dependency Check
성능
- Connection Pool — HikariCP 설정 최적화 (max-pool-size, connection-timeout)
- 캐시 전략 — Spring Cache + Redis/Caffeine 2레벨 캐시
- JVM 튜닝 — ZGC/G1GC 선택, MaxRAMPercentage 설정
안정성
- Health Check — Readiness/Liveness Probe 분리
- Circuit Breaker — Resilience4j로 외부 서비스 장애 격리
- Rate Limiting — Bucket4j 또는 API Gateway에서 처리
운영
- Profile 관리 — local, dev, staging, production 환경 분리
- 로그 구조화 — JSON 로그 포맷, MDC로 요청 추적
- DB 마이그레이션 — Flyway/Liquibase 필수
- Graceful Shutdown — 무중단 배포를 위한 설정
# 프로덕션 application-production.yml 예시
server:
shutdown: graceful
port: 8080
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
cache:
type: redis
redis:
time-to-live: 600000
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
logging:
pattern:
console: '{"timestamp":"%d","level":"%p","logger":"%c","message":"%m","trace":"%X{traceId}","span":"%X{spanId}"}%n'
11. 인터뷰 질문 15선
기본 (1-5)
Q1. Spring Boot 3.x에서 가장 큰 변화는 무엇인가요?
Jakarta EE 10 마이그레이션(javax에서 jakarta 네임스페이스), Java 17+ 필수, Spring Security 6 통합, Observability 개선(Micrometer Tracing), GraalVM Native Image 정식 지원이 핵심 변화입니다.
Q2. Virtual Threads와 기존 스레드 풀의 차이를 설명하세요.
기존 스레드는 OS 커널 스레드와 1:1 매핑되어 약 1MB의 스택 메모리를 사용합니다. Virtual Threads는 JVM이 관리하는 경량 스레드로 수 KB만 사용하며, M:N 스케줄링으로 수만 개의 동시 요청을 처리합니다. Spring Boot 3.2에서 spring.threads.virtual.enabled=true로 활성화합니다.
Q3. @SpringBootApplication은 어떤 애노테이션의 조합인가요?
@SpringBootConfiguration (설정 클래스), @EnableAutoConfiguration (자동 설정), @ComponentScan (컴포넌트 스캔)의 조합입니다.
Q4. Spring Boot의 Auto-Configuration이 동작하는 원리를 설명하세요.
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일에 등록된 설정 클래스들이 조건부(@ConditionalOnClass, @ConditionalOnMissingBean 등)로 평가되어 필요한 빈만 자동 등록됩니다.
Q5. Spring Boot 3.x에서 기존 javax를 jakarta로 마이그레이션하는 이유는?
Oracle이 Java EE를 Eclipse Foundation에 이관하면서 javax 네임스페이스 사용권을 이전하지 않았기 때문입니다. Jakarta EE는 독립적인 진화를 위해 새 네임스페이스를 채택했습니다.
심화 (6-10)
Q6. GraalVM Native Image의 장단점과 적합한 사용 사례는?
장점: 0.1초 기동, 50% 메모리 절약, 서버리스/CLI에 적합. 단점: 빌드 시간 3-5분, 리플렉션 제한, Peak 처리량 감소, 디버깅 어려움. 서버리스(AWS Lambda, Cloud Run), CLI 도구, 마이크로서비스에 적합합니다.
Q7. Spring Security 6에서 WebSecurityConfigurerAdapter 대신 어떤 방식을 사용하나요?
SecurityFilterChain 빈을 직접 등록합니다. HttpSecurity를 주입받아 Lambda DSL로 설정하고 http.build()로 반환합니다. @EnableMethodSecurity로 메서드 레벨 보안도 설정합니다.
Q8. Testcontainers에서 @ServiceConnection의 역할은?
Spring Boot 3.1에서 도입된 기능으로, Testcontainers의 컨테이너 정보(호스트, 포트, 인증)를 자동으로 application.properties에 매핑합니다. @DynamicPropertySource를 수동으로 설정할 필요가 없습니다.
Q9. Virtual Threads에서 synchronized 블록이 문제가 되는 이유는?
Virtual Thread가 synchronized 블록 안에서 블로킹 I/O를 수행하면 carrier thread(OS 스레드)가 고정(pinning)됩니다. 이로 인해 다른 Virtual Thread의 스케줄링이 차단되어 성능이 저하됩니다. ReentrantLock을 사용하면 해결됩니다.
Q10. Spring AI에서 RAG 파이프라인의 구성 요소를 설명하세요.
Document Loader(문서 로딩), Text Splitter(청크 분할), Embedding Model(벡터화), Vector Store(벡터 저장소), Retriever(유사도 검색), ChatClient + Advisor(컨텍스트 주입 후 LLM 질의)로 구성됩니다.
실전 (11-15)
Q11. Spring Boot에서 Graceful Shutdown을 구현하는 방법은?
server.shutdown=graceful과 spring.lifecycle.timeout-per-shutdown-phase=30s를 설정합니다. SIGTERM 수신 시 새 요청을 거부하고 진행 중인 요청이 완료될 때까지 대기합니다. K8s에서는 terminationGracePeriodSeconds와 맞춰야 합니다.
Q12. Micrometer와 OpenTelemetry의 관계를 설명하세요.
Micrometer는 메트릭 수집의 추상화 레이어(SLF4J의 메트릭 버전)이고, OpenTelemetry는 메트릭+트레이싱+로깅의 벤더 중립적 표준입니다. Spring Boot 3.x에서는 Micrometer Tracing Bridge를 통해 OpenTelemetry 프로토콜(OTLP)로 데이터를 내보냅니다.
Q13. HikariCP 커넥션 풀 튜닝 시 주요 파라미터와 권장값은?
maximum-pool-size는 CPU 코어 수의 2배(보통 10-20), minimum-idle은 maximum-pool-size와 같게(풀 예열), connection-timeout은 3초(빠른 실패), max-lifetime은 30분(DB 커넥션 타임아웃보다 짧게), idle-timeout은 10분으로 설정합니다.
Q14. Spring Boot 프로파일을 효과적으로 관리하는 전략은?
application.yml(공통), application-local.yml, application-dev.yml, application-prod.yml로 분리합니다. 민감 정보는 환경변수나 Vault로 주입하고, spring.profiles.active는 환경변수로 설정합니다. Spring Cloud Config Server로 중앙 집중 관리도 가능합니다.
Q15. Circuit Breaker 패턴을 Spring Boot에서 구현하는 방법은?
Resilience4j를 사용합니다. @CircuitBreaker(name="myService", fallbackMethod="fallback")로 선언적으로 설정하고, application.yml에서 실패율 임계값, 대기 시간, 링 버퍼 크기를 설정합니다. Half-Open 상태에서 점진적으로 요청을 허용하여 서비스 회복을 감지합니다.
12. 퀴즈
Q1. Spring Boot 3.2에서 Virtual Threads를 활성화하는 설정은?
spring.threads.virtual.enabled=true 를 application.yml에 추가합니다. 이것으로 Tomcat의 모든 요청 처리가 Virtual Thread에서 수행됩니다.
Q2. GraalVM Native Image에서 리플렉션을 사용하려면 어떻게 해야 하나요?
RuntimeHintsRegistrar 를 구현하여 @ImportRuntimeHints 로 등록하거나, @RegisterReflectionForBinding 애노테이션을 사용합니다. Spring AOT가 빌드 시 자동으로 대부분의 힌트를 생성합니다.
Q3. Spring Security 6에서 CSRF를 비활성화하는 Lambda DSL 코드는?
http.csrf(AbstractHttpConfigurer::disable) 를 사용합니다. JWT 기반 Stateless API에서는 CSRF 보호가 불필요하므로 비활성화하는 것이 일반적입니다.
Q4. Testcontainers에서 @ServiceConnection이 @DynamicPropertySource보다 좋은 이유는?
@ServiceConnection 은 컨테이너 타입을 자동 인식하여 Spring Boot의 auto-configuration에 필요한 프로퍼티를 자동 매핑합니다. @DynamicPropertySource 는 수동으로 프로퍼티 이름과 값을 지정해야 합니다.
Q5. Spring AI에서 Tool Calling의 동작 원리는?
LLM이 사용자 질문을 분석하여 등록된 Tool 중 적합한 것을 선택하고, 파라미터를 추출합니다. Spring AI가 해당 Java 메서드를 실행하고 결과를 LLM에 전달하면, LLM이 최종 응답을 생성합니다. @Tool 애노테이션으로 메서드를 등록합니다.
참고 자료
- Spring Boot 공식 문서
- Spring AI 공식 문서
- Java 21 Virtual Threads JEP 444
- GraalVM Native Image 가이드
- Spring Security 6 마이그레이션 가이드
- Testcontainers Spring Boot
- Micrometer OpenTelemetry
- Spring Boot Docker 가이드
- Resilience4j Spring Boot
- HikariCP 설정 가이드
- Spring Data JPA 공식 문서
- QueryDSL JPA 가이드
- Kubernetes Spring Boot 배포
- OpenRewrite Spring Boot 3 마이그레이션
- Spring Boot Actuator 가이드
- Flyway DB 마이그레이션
- Spring Cloud Config
- Baeldung Spring Boot 3 가이드