Spring Professional 자격증과 베스트 프랙티스 — 시리즈 완결

2026-05-02AWS SAA-C03 스터디

Spring Boot 3 핵심 정리 시리즈 17편 — 시리즈 완결. Spring Professional 자격증의 핵심 지식 압축 정리, Spring Boot 3.4의 구조화된 로깅 신기능, Spec-First API 설계와 OpenAPI Validation, 보안·성능·테스트 모범 사례, 그리고 시리즈 17편을 마무리하는 따뜻한 글.

📚 Spring Boot 3 핵심 정리 · 17편 / 14편 — 시리즈 완결

이 글은 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 (기본)컨테이너 초기화 시대부분의 서비스, 리포지토리
prototypegetBean() 호출마다상태를 가진 빈
requestHTTP 요청마다요청별 상태 관리
sessionHTTP 세션마다사용자별 상태 관리
applicationServletContext 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전체 컨텍스트느림완전한 통합 테스트
@WebMvcTestMVC 레이어만빠름컨트롤러 테스트
@DataJpaTestJPA 레이어만중간레포지토리 테스트
@MockitoExtension없음매우 빠름순수 단위 테스트
@TestContainers전체 컨텍스트느림실제 DB 통합 테스트

Spring Boot 2.x → 3.x 변경 정리

기능Spring Boot 2.xSpring Boot 3.x
Java 최소 버전Java 8Java 17
네임스페이스javax.***jakarta.***
구조화된 로깅외부 라이브러리 필요3.4.0부터 내장
AOT 컴파일없음지원 (GraalVM Native)
Micrometer 관측성기본강화된 통합
Spring Security5.x6.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 10javax.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 CLIredocly 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편의 @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와 함께하는 하루하루가 되시길 바랄게요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!