Spring Boot 3 핵심 정리 시리즈 17편 — 시리즈 완결. Spring Professional 자격증의 핵심 지식 압축 정리, Spring Boot 3.4의 구조화된 로깅 신기능, Spec-First API 설계와 OpenAPI Validation, 보안·성능·테스트 모범 사례, 그리고 시리즈 17편을 마무리하는 따뜻한 글.
이 글은 Spring Boot 3 핵심 정리 시리즈의 17편이자 마지막 편입니다. 1편의 @SpringBootApplication부터 시작해 16편의 마이크로서비스·Kafka까지 — 여기까지 와줘서 정말 고마워요. 시리즈 안내 한 줄로 글을 시작했던 게 엊그제 같은데, 같이 17편을 마무리하는 자리에 와 있다는 게 새삼 뿌듯합니다.
이번 17편에서는 두 가지를 함께 풀어 갑니다 — Spring Professional 자격증 시험에 자주 나오는 핵심 지식의 압축 정리, 그리고 실무 베스트 프랙티스예요. 자격증과 베스트 프랙티스가 한 글에 묶인 이유는 단순합니다. 둘 다 "지금까지 배운 걸 한 차원 위에서 다시 정리하는" 영역이기 때문이에요.
왜 자격증·베스트 프랙티스 단원이 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, 이미 알고 있는 것 같은데 막상 시험 문제를 보면 막혀요. DI·Bean Scope·@Transactional 같은 개념을 머리로는 안다고 생각했는데, "Propagation.REQUIRES_NEW와 NESTED의 차이를 한 줄로 말해 보세요" 같은 질문에 막힙니다. 익숙한 것과 정확하게 아는 것은 다른 차원이에요.
둘째, 베스트 프랙티스가 추상적으로 느껴져요. "Spec-First가 좋다", "테스트 피라미드를 지켜라", "민감 정보는 환경 변수로" — 이 조언들이 옳다는 건 알지만, 어디서부터 어떻게 적용해야 할지 손에 잡히지 않습니다.
셋째, Spring Boot 3.4의 신기능이 너무 빨리 추가돼요. 구조화된 로깅, Graceful Shutdown 개선, AOT 컴파일 — 따라가기만으로도 벅찹니다. 어디까지가 핵심이고 어디까지가 "알면 좋은" 영역인지 안 보이죠.
해결법은 한 가지예요. 자격증은 "1편부터 16편까지의 핵심을 한 번에 다시 훑는 시험" 으로 보고, 베스트 프랙티스는 "각 편의 함정 회피 패턴들의 모음" 으로 보면 갑자기 가까워집니다. 이 글은 그 두 시각으로 풀어 가요.
Spring Professional 자격증이란 — 공인 자격증 한 줄 정리
Spring Professional Certification은 Broadcom(VMware의 모회사)에서 제공하는 공식 스프링 자격증이에요. 60개 문항으로 구성된 시험을 통해 Spring Framework 전반에 대한 이해도를 검증합니다. Broadcom Education 포털을 통해 응시할 수 있어요. 자세한 내용은 Spring Professional 자격증 페이지에서 확인할 수 있습니다.
자격증 취득 가치는 위치에 따라 달라요. 경력 초기 개발자에게는 기술력을 객관적으로 입증하는 수단이 되고, 취업 면접에서 Spring 전문성을 어필할 때 유리합니다. 다만 이미 충분한 실무 경력이 있는 시니어 개발자에게는 상대적으로 가치가 낮아요.
여기서 정말 중요한 시험 함정 — 자격증 시험은 이 시리즈에서 다루지 않은 주제도 포함합니다. 시험 준비를 하면서 "내가 더 공부해야 할 영역"을 파악하는 게 어쩌면 시험 자체보다 더 중요한 목표일 수 있어요.
Spring Container & DI — 자격증 시험 단골
1편에서 다뤘던 IoC·DI가 자격증 시험에 가장 많이 나오는 주제예요. 한 번 더 압축해서 정리합니다.
IoC Container 핵심
스프링의 IoC(Inversion of Control) 컨테이너는 객체의 생성·관리·의존성 주입을 담당해요.
// 빈 등록 방법 1 — @Component 계열 어노테이션
@Component // 일반 컴포넌트
@Service // 서비스 레이어 (의미적 구분)
@Repository // 데이터 액세스 레이어 (예외 변환 기능 포함)
@Controller // MVC 컨트롤러
@RestController // @Controller + @ResponseBody
// 빈 등록 방법 2 — @Configuration + @Bean
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// @Bean 메서드 이름이 빈 이름이 됨 (기본값)
@Bean(name = "customEncoder")
public MessageDigestPasswordEncoder sha256Encoder() {
return new MessageDigestPasswordEncoder("SHA-256");
}
}
여기서 시험 함정이 하나 있어요. @Repository만 추가 기능(SQL 예외 변환)이 있고, 다른 스테레오타입은 의미적 구분만 합니다. 이걸 모르고 "다 똑같다"고 답하면 틀려요.
DI 방식 비교 — 생성자 주입이 정답
// 1. 생성자 주입 (권장)
@Service
public class ProductService {
private final ProductRepository productRepository;
private final ProductMapper productMapper;
// @Autowired 생략 가능 (생성자가 하나일 때, Spring 4.3+)
public ProductService(ProductRepository productRepository, ProductMapper productMapper) {
this.productRepository = productRepository;
this.productMapper = productMapper;
}
}
// 2. 필드 주입 (테스트 어려움 — 비권장)
@Service
public class ProductService {
@Autowired // 리플렉션으로 주입 — 불변성 보장 안 됨
private ProductRepository productRepository;
}
// 3. Setter 주입 (선택적 의존성에 사용)
@Service
public class ProductService {
private ProductRepository productRepository;
@Autowired(required = false)
public void setProductRepository(ProductRepository productRepository) {
this.productRepository = productRepository;
}
}
판단 기준 — 생성자 주입이 정답, 필드 주입은 단위 테스트에 리플렉션이 필요하고 final이 안 돼서 비권장이에요.
Bean Scope 5종
| Scope | 인스턴스 생성 시점 | 사용 사례 |
|---|---|---|
| singleton (기본) | 컨테이너 초기화 시 | 대부분의 서비스, 리포지토리 |
| prototype | getBean() 호출마다 | 상태를 가진 빈 |
| request | HTTP 요청마다 | 요청별 상태 관리 |
| session | HTTP 세션마다 | 사용자별 상태 관리 |
| application | ServletContext 1개 | 앱 전역 상태 |
여기서 정말 중요한 시험 함정 — Singleton 빈에 인스턴스 변수로 상태를 저장하면 스레드 안전이 깨져요. 모든 요청이 같은 인스턴스를 공유하니 데이터가 섞입니다.
Spring AOP — 횡단 관심사의 분리
AOP(Aspect-Oriented Programming)는 핵심 비즈니스 로직과 횡단 관심사(로깅·트랜잭션·보안 등)를 분리하는 프로그래밍 패러다임이에요. Spring Cache·@Transactional·@Async — 우리가 시리즈에서 만난 이 어노테이션들이 모두 AOP 위에서 동작합니다.
@Aspect
@Component
@Slf4j
public class PerformanceLoggingAspect {
/**
* @Around — 메서드 전후 모두 개입
* execution — 어떤 메서드에 적용할지 지정 (포인트컷 표현식)
*/
@Around("execution(* com.example.service.*.*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
try {
Object result = joinPoint.proceed(); // 실제 메서드 실행
long duration = System.currentTimeMillis() - startTime;
log.info("Method {} executed in {}ms", methodName, duration);
return result;
} catch (Exception e) {
log.error("Method {} threw exception: {}", methodName, e.getMessage());
throw e;
}
}
@Before("@annotation(com.example.annotation.Auditable)")
public void auditBeforeExecution(JoinPoint joinPoint) {
log.info("Auditing method: {}", joinPoint.getSignature().getName());
}
@AfterReturning(pointcut = "execution(* com.example.service.ProductService.saveProduct(..))",
returning = "result")
public void afterProductSaved(Object result) {
log.info("Product saved: {}", result);
}
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
throwing = "exception")
public void afterMethodException(Exception exception) {
log.error("Exception in service: {}", exception.getMessage());
}
}
자격증 시험에서는 5가지 Advice 종류(@Before·@After·@Around·@AfterReturning·@AfterThrowing)와 포인트컷 표현식이 자주 나와요.
Spring Transaction — Propagation 7종
@Transactional의 Propagation 속성은 시험 함정이 가장 많은 영역 중 하나예요. 7가지 모두 이름과 동작을 정확히 매칭해 두면 안전합니다.
@Service
public class OrderService {
@Transactional(
propagation = Propagation.REQUIRED, // 기본값
isolation = Isolation.READ_COMMITTED, // 커밋된 데이터만 읽기
readOnly = false,
rollbackFor = {RuntimeException.class},
timeout = 30
)
public OrderDTO createOrder(OrderDTO orderDTO) {
// ...
}
@Transactional(readOnly = true) // 읽기 전용 — 더티 체킹 비활성화로 성능↑
public OrderDTO getOrder(UUID orderId) {
// ...
}
}
| Propagation | 동작 |
|---|---|
| REQUIRED | 트랜잭션 없으면 새로 생성, 있으면 참여 (기본값) |
| REQUIRES_NEW | 기존 트랜잭션 일시 중단, 항상 새 트랜잭션 시작 |
| SUPPORTS | 트랜잭션 있으면 참여, 없으면 비트랜잭션 |
| MANDATORY | 트랜잭션 없으면 예외 발생 |
| NOT_SUPPORTED | 기존 트랜잭션 중단, 비트랜잭션 |
| NEVER | 트랜잭션 있으면 예외 발생 |
| NESTED | 중첩 트랜잭션 생성 (Savepoint 기반) |
여기서 정말 중요한 시험 함정 — @Transactional도 AOP 프록시 기반이라 14편에서 다룬 @Cacheable과 똑같이 같은 클래스 내부 호출 시 적용 안 됩니다.
// 잘못된 예 — 같은 클래스 내부 호출은 프록시 미통과
@Service
public class OrderService {
public void processOrder() {
this.saveOrder(); // @Transactional 적용 안 됨!
}
@Transactional
public void saveOrder() { ... }
}
// 해결 — 별도 클래스로 분리
@Async와 @Transactional을 함께 쓸 때도 주의해야 해요. 비동기 메서드는 별도 스레드에서 새로운 트랜잭션을 시작하니, 발행자의 트랜잭션과 별개로 동작합니다.
Spring Security 핵심
7편에서 깊이 다뤘던 Spring Security를 자격증 관점에서 다시 한 번 압축해 봅시다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").hasRole("USER")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable()) // REST API는 보통 비활성화
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Spring Security 6은 람다 DSL이 필수예요. Spring Boot 2.x에서 쓰던 chained method 스타일은 이제 deprecated 됐습니다.
Spring Data JPA — Repository 계층 구조
3편·6편에서 깊이 다뤘던 JPA의 핵심을 한 장으로 압축합니다.
// Repository 계층 구조
Repository (최상위 인터페이스, 마커 인터페이스)
└── CrudRepository (기본 CRUD 메서드)
└── PagingAndSortingRepository (페이징, 정렬)
└── JpaRepository (JPA 특화 — flush, batch 등)
public interface ProductRepository extends JpaRepository<Product, UUID> {
// 메서드 이름으로 자동 쿼리 생성
List<Product> findByProductName(String productName);
Page<Product> findByProductNameContainingIgnoreCase(String name, Pageable pageable);
Optional<Product> findFirstByOrderByCreatedDateDesc();
// @Query로 JPQL 직접 작성
@Query("SELECT p FROM Product p WHERE p.productName = :name AND p.category = :category")
List<Product> findByNameAndCategory(@Param("name") String name,
@Param("category") ProductCategory category);
// 네이티브 SQL
@Query(value = "SELECT * FROM product WHERE product_name = :name", nativeQuery = true)
List<Product> findByNameNative(@Param("name") String name);
// Specification으로 동적 쿼리
@Override
List<Product> findAll(Specification<Product> spec);
}
Spring Boot 3.4 — 새로운 핵심 기능
Spring Boot가 빠르게 진화하고 있어요. 3.4에서 추가된 가장 주목할 신기능을 정리합니다. Spring Boot 3.4의 자세한 사양은 Spring Boot 공식 문서에서 확인할 수 있어요.
구조화된 로깅 (Structured Logging)
Spring Boot 3.4의 가장 주목할 만한 신기능이에요. 전통적인 플레인 텍스트 로그 대신 JSON 같은 기계가 읽을 수 있는 형식으로 로그를 출력합니다. 별도 의존성 추가 없이 즉시 사용할 수 있어요.
기계 가독성 로그가 필요한 이유 — Elasticsearch·Grafana Loki·Graylog 같은 로그 집계 시스템과 쉽게 통합되고, 특정 필드로 빠른 검색·필터링이 가능하며, 마이크로서비스 환경에서 여러 서비스 로그를 일관된 형식으로 집계할 수 있습니다.
# application.properties
# 콘솔 출력 포맷 — ecs / logstash / gelf 중 선택
logging.structured.format.console=ecs # Elastic Common Schema
# 또는
logging.structured.format.console=logstash # Logstash JSON Format
# 또는
logging.structured.format.console=gelf # Graylog Extended Log Format
# 파일 출력 포맷도 별도 설정 가능
logging.structured.format.file=ecs
logging.file.name=application.log
ECS 포맷 출력 예시
{
"@timestamp": "2026-04-15T10:30:45.123Z",
"log.level": "INFO",
"message": "Product saved with id: 123e4567-e89b-12d3-a456-426614174000",
"service.name": "product-service",
"process.pid": 12345,
"log.logger": "com.example.service.ProductService",
"thread.name": "main"
}
기본 제공 포맷이 요구사항에 맞지 않으면 StructuredLoggingJsonMembersCustomizer를 구현해 커스터마이징할 수 있어요.
public class CustomJsonLogger implements StructuredLoggingJsonMembersCustomizer<ILoggingEvent> {
@Override
public void customize(JsonWriter<ILoggingEvent> writer) {
writer.add("timestamp", (event) ->
Instant.ofEpochMilli(event.getTimeStamp()).toString());
writer.add("level", (event) -> event.getLevel().toString());
writer.add("message", ILoggingEvent::getFormattedMessage);
writer.add("logger", (event) -> event.getLoggerName());
writer.add("thread", (event) -> event.getThreadName());
// 커스텀 필드 추가
writer.add("app_version", (event) -> "1.0.0");
writer.add("environment", (event) -> System.getenv("APP_ENV"));
}
}
# 커스텀 로거 클래스 경로 설정 (Spring Bean 등록 불필요)
logging.structured.format.console=com.example.logging.CustomJsonLogger
개발 환경에서 구조화 로그 비활성화
JSON 형식은 프로덕션에선 유용하지만 개발 시엔 가독성이 낮아요. 프로파일로 분리하는 게 정석입니다.
# application-dev.properties (개발)
logging.structured.format.console= # 비어있으면 일반 텍스트
# application-prod.properties (프로덕션)
logging.structured.format.console=ecs
Java 17+ 활용 — Record · Sealed · Pattern Matching
// Record를 DTO로 활용 (Java 16+)
public record ProductDTO(
UUID id,
@NotNull String productName,
ProductCategory category,
@NotNull @Positive BigDecimal price,
Integer quantityOnHand
) {}
// Sealed Classes 활용 (Java 17+)
public sealed interface OrderEvent
permits OrderCreatedEvent, OrderShippedEvent, OrderCancelledEvent {}
public record OrderCreatedEvent(UUID orderId, LocalDateTime createdAt)
implements OrderEvent {}
// Pattern Matching (Java 16+)
public String describeEvent(OrderEvent event) {
return switch (event) {
case OrderCreatedEvent e -> "Created: " + e.orderId();
case OrderShippedEvent e -> "Shipped: " + e.orderId();
case OrderCancelledEvent e -> "Cancelled: " + e.orderId() + " - " + e.reason();
};
}
Graceful Shutdown 개선
server.shutdown=graceful # graceful shutdown 활성화
spring.lifecycle.timeout-per-shutdown-phase=30s # 각 단계 최대 30초
베스트 프랙티스 1 — Spec-First API 설계
코드를 먼저 짜고 나중에 API 명세를 만드는 Code-First 방식보다, 명세를 먼저 작성하고 코드가 명세를 따르는 Spec-First 방식이 권장돼요.
Code-First의 문제점 3가지:
- 개발자가 의도치 않게 API 호환성을 깨는 변경을 만들 수 있음
- API 문서가 항상 코드와 일치한다는 보장이 없음
- 프론트엔드와 백엔드가 병렬 개발하기 어려움
Spec-First의 장점 3가지:
- OpenAPI 명세가 확정되면 프론트엔드·백엔드 팀이 동시에 개발 시작 가능
- CI/CD 파이프라인에서 명세 유효성 자동 검증
- API 변경 시 명세 먼저 수정하므로 Breaking Change 방지
# openapi.yaml — API 명세를 먼저 정의
openapi: 3.0.3
info:
title: Product Service API
version: 1.0.0
paths:
/api/v1/products:
get:
summary: List products
operationId: listProducts
parameters:
- name: productName
in: query
schema:
type: string
- name: pageNumber
in: query
schema:
type: integer
default: 0
responses:
'200':
description: List of products
content:
application/json:
schema:
$ref: '#/components/schemas/ProductPagedList'
/api/v1/products/{productId}:
get:
summary: Get product by ID
operationId: getProductById
parameters:
- name: productId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Product found
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'404':
description: Product not found
Redocly CLI 활용
# Redocly CLI 설치
npm install -g @redocly/cli
# OpenAPI 명세 유효성 검사
redocly lint openapi.yaml
# 분리된 파일들을 하나로 번들링
redocly bundle openapi.yaml -o dist/openapi.yaml
# 개발 중 실시간 문서 미리보기
redocly preview-docs openapi.yaml
OpenAPI Validation — 계약 기반 테스트
RestAssured와 OpenAPI 명세를 결합해 실제 응답이 명세와 일치하는지 자동 검증하는 계약 기반 테스트를 만들 수 있어요.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductControllerOpenApiTest {
@LocalServerPort
private int port;
@Test
void shouldConformToOpenApiSpec() {
OpenApiValidationFilter validationFilter = new OpenApiValidationFilter(
"classpath:openapi.yaml"
);
given()
.filter(validationFilter)
.port(port)
.when()
.get("/api/v1/products")
.then()
.statusCode(200)
.body("content", not(empty()));
}
}
베스트 프랙티스 2 — 코드 품질 관리
의미 있는 예외 처리 계층 + 전역 핸들러
public class NotFoundException extends RuntimeException {
public NotFoundException() {
super("Resource Not Found");
}
public NotFoundException(String message) {
super(message);
}
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unexpected error: {}", ex.getMessage(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(500, "Internal Server Error"));
}
}
유효성 검사 계층화
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {
private UUID id;
@NotBlank(message = "Product name is required")
@Size(min = 2, max = 50, message = "Product name must be between 2 and 50 characters")
private String productName;
@NotNull(message = "Category is required")
private ProductCategory category;
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
@DecimalMin(value = "0.01", message = "Price must be at least 0.01")
private BigDecimal price;
@Min(value = 0, message = "Quantity cannot be negative")
private Integer quantityOnHand;
@Pattern(regexp = "^\\d{12}$", message = "UPC must be 12 digits")
private String upc;
}
// 컨트롤러에서 @Valid로 자동 검증 트리거
@PostMapping
public ResponseEntity<ProductDTO> createProduct(@Valid @RequestBody ProductDTO productDTO) {
ProductDTO savedProduct = productService.saveNewProduct(productDTO);
return ResponseEntity.created(
UriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedProduct.getId())
.toUri()
).body(savedProduct);
}
베스트 프랙티스 3 — 보안
민감 정보 보호
# 절대 하드코딩 금지 — 환경 변수 또는 Spring Cloud Config 활용
spring.datasource.password=${DB_PASSWORD}
spring.security.oauth2.client.registration.my-client.client-secret=${OAUTH_CLIENT_SECRET}
Actuator 엔드포인트 보안 — 17편에서 한 번 더 강조
12편에서 다뤘던 Actuator를 시리즈 마지막에 한 번 더 강조할게요. 프로덕션에서 Actuator 엔드포인트가 무방비로 노출되면 시스템 내부 정보가 그대로 새어나갑니다.
@Configuration
public class ActuatorSecurityConfig {
@Bean
@Order(1) // 우선순위 높게
public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN")
);
return http.build();
}
}
# 프로덕션에선 필요한 엔드포인트만 노출
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when-authorized
management.endpoints.web.base-path=/management # 기본 경로 변경으로 보안 강화
여기서 정말 중요한 시험 함정 — **management.endpoints.web.exposure.include=* 는 절대 금지**입니다. 모든 엔드포인트를 노출하는 설정인데, 운영 정보·환경 변수·Bean 목록까지 다 새어나가요.
SQL Injection 방지
// JPA·Hibernate는 기본적으로 PreparedStatement 사용 — 안전
productRepository.findByProductName(name);
// @Query 사용 시 파라미터 바인딩
@Query("SELECT p FROM Product p WHERE p.productName = :name")
List<Product> findByName(@Param("name") String name); // 안전
// 절대 금지 — 직접 쿼리 문자열 연결
@Query("SELECT p FROM Product p WHERE p.productName = '" + name + "'") // SQL Injection 취약
베스트 프랙티스 4 — 성능
N+1 문제 해결
// 문제 — N+1 쿼리
@OneToMany(mappedBy = "order")
private List<OrderLine> orderLines; // LAZY 로딩 기본값
// 해결책 1 — Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.orderLines WHERE o.id = :id")
Optional<Order> findByIdWithLines(@Param("id") UUID id);
// 해결책 2 — EntityGraph
@EntityGraph(attributePaths = {"orderLines"})
Optional<Order> findById(UUID id);
// 해결책 3 — 배치 사이즈 설정
@OneToMany(mappedBy = "order")
@BatchSize(size = 50)
private List<OrderLine> orderLines;
# Hibernate 배치 사이즈 전역 설정
spring.jpa.properties.hibernate.default_batch_fetch_size=50
캐싱 전략 — 14편 복습
// 자주 읽히고 거의 변경되지 않는 데이터에 캐싱
@Cacheable(value = "categories", key = "'all'")
public List<ProductCategory> getAllCategories() {
return Arrays.asList(ProductCategory.values());
}
// 쓰기 작업 후 관련 캐시 무효화
@CacheEvict(value = {"productCache", "productListCache"}, allEntries = true)
public void clearProductCache() {
log.info("Product cache cleared");
}
동기 이벤트 리스너에서 느린 작업 금지
// 잘못된 예 — 응답 시간 5초 지연
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
sendEmailWithAttachment(event); // 5초 걸리는 작업
}
// 올바른 예 — @Async로 비동기 처리
@Async
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
sendEmailWithAttachment(event);
}
베스트 프랙티스 5 — 테스트 피라미드
[E2E 테스트] ← 소수, 느림, 비용 큼
[통합 테스트] ← 중간
[단위 테스트] ← 다수, 빠름, 비용 낮음
// 단위 테스트 — 외부 의존성 없이 빠른 검증
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@Mock
private ProductMapper productMapper;
@InjectMocks
private ProductServiceImpl productService;
@Test
void getProductById_shouldReturnProduct() {
UUID productId = UUID.randomUUID();
Product product = Product.builder().id(productId).productName("Sample Product").build();
ProductDTO productDTO = ProductDTO.builder().id(productId).productName("Sample Product").build();
when(productRepository.findById(productId)).thenReturn(Optional.of(product));
when(productMapper.productToDto(product)).thenReturn(productDTO);
ProductDTO result = productService.getProductById(productId);
assertThat(result.getProductName()).isEqualTo("Sample Product");
verify(productRepository, times(1)).findById(productId);
}
}
// 통합 테스트 — 실제 스프링 컨텍스트와 DB 사용
@SpringBootTest
@Transactional
class ProductControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ProductRepository productRepository;
@Test
@WithMockUser(roles = "ADMIN")
void createProduct_shouldReturnCreated() throws Exception {
ProductDTO newProduct = ProductDTO.builder()
.productName("New Sample Product")
.category(ProductCategory.GENERAL)
.price(new BigDecimal("9.99"))
.build();
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newProduct)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.productName").value("New Sample Product"));
}
}
| 어노테이션 | 로딩 범위 | 속도 | 용도 |
|---|---|---|---|
@SpringBootTest | 전체 컨텍스트 | 느림 | 완전한 통합 테스트 |
@WebMvcTest | MVC 레이어만 | 빠름 | 컨트롤러 테스트 |
@DataJpaTest | JPA 레이어만 | 중간 | 레포지토리 테스트 |
@MockitoExtension | 없음 | 매우 빠름 | 순수 단위 테스트 |
@TestContainers | 전체 컨텍스트 | 느림 | 실제 DB 통합 테스트 |
Spring Boot 2.x → 3.x 변경 정리
| 기능 | Spring Boot 2.x | Spring Boot 3.x |
|---|---|---|
| Java 최소 버전 | Java 8 | Java 17 |
| 네임스페이스 | javax.* | **jakarta.*** |
| 구조화된 로깅 | 외부 라이브러리 필요 | 3.4.0부터 내장 |
| AOT 컴파일 | 없음 | 지원 (GraalVM Native) |
| Micrometer 관측성 | 기본 | 강화된 통합 |
| Spring Security | 5.x | 6.x (람다 DSL 필수) |
// Spring Boot 2.x (javax)
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;
import javax.servlet.http.HttpServletRequest;
// Spring Boot 3.x (jakarta) — 반드시 수정
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;
import jakarta.servlet.http.HttpServletRequest;
자주 만나는 함정 7가지 — 시리즈 전체 압축
1. @Transactional과 프록시 한계
같은 클래스 내부 호출 시 프록시를 거치지 않아 트랜잭션 적용 안 됨. 별도 클래스로 분리하거나 self-injection.
2. @Async와 @Transactional 혼동
비동기 메서드는 별도 스레드에서 새 트랜잭션 시작. 발행자의 트랜잭션과 별개로 동작합니다.
3. 에러 응답에 민감 정보 노출
// 잘못된 예 — 스택 트레이스가 그대로 노출
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleError(Exception ex) {
return ResponseEntity.status(500).body(ex.toString()); // 위험!
}
// 올바른 예 — 일반적 메시지만 반환
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleError(Exception ex) {
log.error("Internal error", ex); // 서버 로그에만 기록
return ResponseEntity.status(500)
.body(new ErrorResponse(500, "An internal error occurred"));
}
4. 모든 Actuator 엔드포인트 노출
management.endpoints.web.exposure.include=* 절대 금지. 필요한 엔드포인트만 명시.
5. 불필요한 전체 로딩
// 잘못된 예 — 전체 로딩 후 일부 필드만 사용
List<Product> all = productRepository.findAll();
List<String> names = all.stream().map(Product::getProductName).toList();
// 올바른 예 — 필요한 데이터만 조회
@Query("SELECT p.productName FROM Product p")
List<String> findAllProductNames();
6. 동기 리스너에서 느린 작업
@Async로 비동기 처리하지 않으면 응답 시간이 직접 지연됩니다.
7. 강의·실무에서 가장 자주 묻는 — JPA 엔티티에 @Data
1편에서도 다뤘던 — JPA 엔티티에 @Data를 무분별하게 쓰면 @EqualsAndHashCode로 양방향 관계가 무한 루프(StackOverflowError)에 빠집니다. @Getter·@Setter·@NoArgsConstructor만 쓰고 관계 필드는 @ToString.Exclude로 빼는 게 정석.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음 (시리즈 종합)
여기까지가 17편의 핵심이자 시리즈 17편 전체의 마지막 정리예요. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- DI 방식 — 생성자 주입 권장(불변성+테스트 용이), 필드 주입 비권장
- Bean Scope — singleton(기본)·prototype·request·session·application
@Repository만 예외 변환 기능 — 다른 스테레오타입은 의미적 구분만@Transactional은 AOP 프록시 기반 — 같은 클래스 내부 호출 시 미동작- Propagation 7종 — REQUIRED(기본)·REQUIRES_NEW(새 트랜잭션)·NESTED(중첩) 위주로 외우기
- AOP Advice 5종 —
@Before·@After·@Around·@AfterReturning·@AfterThrowing - Spring Security 6 = 람다 DSL 필수 — chained method 스타일은 deprecated
- Spring Data Repository 계층 — Repository → CrudRepository → PagingAndSortingRepository → JpaRepository
- @Query — JPQL 또는
nativeQuery=true로 네이티브 SQL - Spring Boot 3 = Java 17 + Jakarta EE 10 —
javax.→jakarta.마이그레이션 - Spring Boot 3.4 신기능 = 구조화된 로깅 —
logging.structured.format.console=ecs한 줄 - 구조화된 로깅 포맷 — ECS(Elastic) / Logstash / GELF(Graylog)
- 개발 환경에선 구조화된 로깅 비활성화(가독성), 프로덕션에서만 활성화
- Java 17+ 활용 — Record(DTO), Sealed Classes, Pattern Matching
- Graceful Shutdown —
server.shutdown=graceful+spring.lifecycle.timeout-per-shutdown-phase=30s - Spec-First API — OpenAPI 명세 먼저, 코드가 명세를 따름
- Redocly CLI —
redocly lint·redocly bundle·redocly preview-docs - 계약 기반 테스트 — RestAssured + OpenApiValidationFilter
- 전역 예외 핸들러 —
@RestControllerAdvice+ 도메인별@ExceptionHandler - 검증 — DTO 레벨
@Valid+@NotBlank·@Size·@Positive등 - 민감 정보 — 환경 변수 또는 Spring Cloud Config, 절대 하드코딩 X
- Actuator 엔드포인트 노출 최소화 —
include=*금지, 필요한 것만 명시 - SQL Injection 방지 —
@Param파라미터 바인딩 사용, 문자열 연결 금지 - N+1 문제 — Fetch Join · EntityGraph ·
@BatchSize셋 중 하나 - 테스트 피라미드 — 단위 多·통합 中·E2E 少
- 테스트 어노테이션 —
@SpringBootTest(전체)·@WebMvcTest(MVC)·@DataJpaTest(JPA) - 에러 응답에 스택 트레이스 노출 X — 일반 메시지만, 스택은 서버 로그에만
- JPA 엔티티에
@Data금지 — 양방향 관계 무한 루프,@Getter+@Setter+@NoArgsConstructor만 - 시리즈 함정 — Spring Boot에서 가장 많이 틀리는 패턴은 결국 AOP 프록시 한계(캐시·트랜잭션·비동기)
자격증 시험 준비의 자세한 정보는 Spring Professional 자격증 페이지에서, Spring Boot 3.4의 모든 신기능은 Spring Boot 공식 문서에서 확인할 수 있어요.
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC
- 3편 — Spring Data JPA · 검증
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient
- 9편 — Reactive Programming · WebFlux 입문
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완) (현재 글, 완결)
이 시리즈를 마치며
여기까지 와줘서 정말 고마워요. 1편의 @SpringBootApplication부터 17편의 베스트 프랙티스까지, 17개의 글을 통해 우리는 Spring Boot 3 + Spring Framework 6의 큰 그림을 한 번 함께 그려 봤습니다.
처음 1편에서 회사 자동 세팅 도구 비유로 시작했던 게 떠올라요. Spring Boot는 복잡한 회사 시스템을 자동으로 세팅해 주는 도구, IoC 컨테이너는 회사 직원 관리 부서. 그 두 비유에서 출발해 — REST API, JPA, Security, WebFlux, Spring AI, Actuator, 캐싱·이벤트, 컨테이너, 마이크로서비스까지 — 한 단계씩 비유를 늘려 가며 풀어 왔습니다.
처음에는 어노테이션 50개가 머리를 어지럽혔을 거예요. 하지만 이 시리즈를 따라온 지금쯤이면, @Cacheable을 보면 "책상 옆 사물함이 떠오르고", @KafkaListener를 보면 "사내 게시판을 구독하는 부서가 떠오르고", Pod를 보면 "한 사무실에 함께 들어간 동료가 떠오르는" 단계에 와 있을 거라 생각해요. 그게 이 시리즈가 노린 가장 중요한 변화입니다 — 추상 개념이 일상 비유로 자연스럽게 떠오르는 단계.
Spring Boot는 살아 있는 프레임워크예요. 3.4가 나오고, 3.5가 나오고, 4.0이 곧 다가올 거예요. 새 기능이 추가될 때마다 모든 걸 다시 외우려 하지 마세요. 이 시리즈에서 잡은 비유들이 변하지 않을 뼈대가 되어, 새 기능이 나올 때마다 그 위에 한 줄씩만 얹으면 됩니다. AOP 프록시의 동작 원리, IoC 컨테이너의 책임 분리, 메시지 기반 통신의 결합도 분리 — 이 핵심 원칙들은 Spring이 5.0이 되든 6.0이 되든 그대로 남아 있을 거예요.
자격증을 준비하는 분이라면 이 시리즈가 시험 직전 마지막 복습 자료가 됐으면 좋겠고, 실무에 적용하는 분이라면 매일 만지는 코드 한 줄 한 줄에 비유들이 떠올라 작업이 즐거워졌으면 좋겠어요. 그게 이 시리즈를 쓴 가장 큰 보람이 될 거예요.
긴 시리즈를 함께 끝까지 따라와 주셔서 다시 한 번 진심으로 감사합니다. 좋은 코드, 즐거운 개발, 그리고 든든한 Spring Boot와 함께하는 하루하루가 되시길 바랄게요.