이론 학습으로 돌아가기
Spring FrameworkPart 1: Spring 기초

Spring 04: Spring Boot와 Web MVC

Spring Boot의 자동 설정과 Web MVC의 요청 처리 흐름을 학습합니다.

약 60분9개 섹션

학습 목표

  • Spring Boot Auto Configuration의 동작 원리
  • @RestController와 REST API 설계
  • 요청/응답 DTO와 Bean Validation
  • @ControllerAdvice를 활용한 전역 예외 처리
  • Filter, Interceptor, ArgumentResolver 활용

1. Spring Boot 자동 설정

Spring Boot는 "Convention over Configuration" 원칙을 따릅니다. 복잡한 설정 없이도 합리적인 기본값으로 애플리케이션을 시작할 수 있습니다.

1.1 @SpringBootApplication의 비밀

@SpringBootApplication  // 이 하나의 어노테이션이 모든 것을 시작
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

// @SpringBootApplication은 다음 3개의 조합
@SpringBootConfiguration  // @Configuration과 동일
@EnableAutoConfiguration  // 자동 설정 활성화
@ComponentScan            // 컴포넌트 스캔 활성화
public @interface SpringBootApplication { }

1.2 Auto Configuration 동작 원리

// spring-boot-autoconfigure의 META-INF/spring/
// org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일에 정의

// 예: DataSourceAutoConfiguration
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean
    public DataSource dataSource(DataSourceProperties properties) {
        // 개발자가 DataSource Bean을 등록하지 않았을 때만 생성
        return properties.initializeDataSourceBuilder().build();
    }
}

// 조건부 어노테이션들
@ConditionalOnClass        // 특정 클래스가 클래스패스에 있을 때
@ConditionalOnMissingClass // 특정 클래스가 없을 때
@ConditionalOnBean         // 특정 Bean이 있을 때
@ConditionalOnMissingBean  // 특정 Bean이 없을 때
@ConditionalOnProperty     // 특정 프로퍼티 값이 있을 때
@ConditionalOnWebApplication  // 웹 애플리케이션일 때

1.3 자동 설정 확인하기

# application.yml - 자동 설정 리포트 활성화
debug: true

# 실행 시 출력되는 리포트
============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:  # 적용된 자동 설정
-----------------
DataSourceAutoConfiguration matched:
  - @ConditionalOnClass found required classes 'javax.sql.DataSource'

Negative matches:  # 적용되지 않은 자동 설정
-----------------
RedisAutoConfiguration:
  Did not match:
    - @ConditionalOnClass did not find required class 'org.springframework.data.redis.core.RedisOperations'

1.4 자동 설정 커스터마이징

// 방법 1: application.yml로 설정 변경
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/shop
    hikari:
      maximum-pool-size: 20

// 방법 2: 직접 Bean 등록 (자동 설정 대체)
@Configuration
public class DataSourceConfig {
    
    @Bean  // 이 Bean이 있으면 자동 설정의 DataSource는 생성되지 않음
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://localhost:3306/shop");
        ds.setMaximumPoolSize(30);
        return ds;
    }
}

// 방법 3: 특정 자동 설정 제외
@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,
    SecurityAutoConfiguration.class
})
public class ShopApplication { }
자동 설정의 장점:
  • - 의존성만 추가하면 관련 설정이 자동으로 적용
  • - 합리적인 기본값으로 빠른 개발 시작
  • - 필요시 쉽게 커스터마이징 가능
  • - 설정 충돌 시 개발자 설정이 우선

2. @RestController와 REST API

2.1 @Controller vs @RestController

// @Controller - View를 반환 (Thymeleaf, JSP 등)
@Controller
public class PageController {
    
    @GetMapping("/products")
    public String productList(Model model) {
        model.addAttribute("products", productService.findAll());
        return "products/list";  // templates/products/list.html
    }
}

// @RestController - JSON/XML 데이터 반환
// @Controller + @ResponseBody
@RestController
@RequestMapping("/api/products")
public class ProductApiController {
    
    @GetMapping
    public List<ProductResponse> getProducts() {
        return productService.findAll().stream()
            .map(ProductResponse::from)
            .toList();
        // 자동으로 JSON 변환되어 응답
    }
}

2.2 HTTP 메서드 매핑

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
    
    private final ProductService productService;
    
    // GET /api/v1/products - 목록 조회
    @GetMapping
    public ResponseEntity<List<ProductResponse>> getProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        
        Page<Product> products = productService.findAll(PageRequest.of(page, size));
        List<ProductResponse> response = products.stream()
            .map(ProductResponse::from)
            .toList();
        
        return ResponseEntity.ok(response);
    }
    
    // GET /api/v1/products/{id} - 단건 조회
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
        return productService.findById(id)
            .map(ProductResponse::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // POST /api/v1/products - 생성
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @Valid @RequestBody CreateProductRequest request) {
        
        Product product = productService.create(request.toCommand());
        ProductResponse response = ProductResponse.from(product);
        
        URI location = URI.create("/api/v1/products/" + product.getId());
        return ResponseEntity.created(location).body(response);
    }
    
    // PUT /api/v1/products/{id} - 전체 수정
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody UpdateProductRequest request) {
        
        Product product = productService.update(id, request.toCommand());
        return ResponseEntity.ok(ProductResponse.from(product));
    }
    
    // PATCH /api/v1/products/{id} - 부분 수정
    @PatchMapping("/{id}")
    public ResponseEntity<ProductResponse> patchProduct(
            @PathVariable Long id,
            @RequestBody PatchProductRequest request) {
        
        Product product = productService.patch(id, request.toCommand());
        return ResponseEntity.ok(ProductResponse.from(product));
    }
    
    // DELETE /api/v1/products/{id} - 삭제
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

2.3 요청 파라미터 바인딩

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    // @PathVariable - URL 경로 변수
    // GET /api/products/123
    @GetMapping("/{productId}")
    public Product getProduct(@PathVariable Long productId) {
        return productService.findById(productId);
    }
    
    // @RequestParam - 쿼리 파라미터
    // GET /api/products?category=electronics&minPrice=1000
    @GetMapping
    public List<Product> searchProducts(
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "0") int minPrice,
            @RequestParam(defaultValue = "100") int size) {
        return productService.search(category, minPrice, size);
    }
    
    // @RequestBody - JSON 요청 본문
    // POST /api/products
    @PostMapping
    public Product createProduct(@RequestBody CreateProductRequest request) {
        return productService.create(request);
    }
    
    // @RequestHeader - HTTP 헤더
    @GetMapping("/with-header")
    public Product getWithHeader(
            @RequestHeader("X-User-Id") String userId,
            @RequestHeader(value = "X-Trace-Id", required = false) String traceId) {
        return productService.findByUser(userId);
    }
    
    // @CookieValue - 쿠키 값
    @GetMapping("/with-cookie")
    public Product getWithCookie(@CookieValue("sessionId") String sessionId) {
        return productService.findBySession(sessionId);
    }
    
    // @ModelAttribute - 폼 데이터 또는 쿼리 파라미터를 객체로
    // GET /api/products/search?keyword=phone&category=electronics&minPrice=100
    @GetMapping("/search")
    public List<Product> search(@ModelAttribute ProductSearchCondition condition) {
        return productService.search(condition);
    }
}

// 검색 조건 객체
@Getter @Setter
public class ProductSearchCondition {
    private String keyword;
    private String category;
    private Integer minPrice;
    private Integer maxPrice;
}

2.4 ResponseEntity 활용

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    // 상태 코드와 헤더 제어
    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @Valid @RequestBody CreateProductRequest request) {
        
        Product product = productService.create(request);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)  // 201
            .header("X-Product-Id", product.getId().toString())
            .body(ProductResponse.from(product));
    }
    
    // 조건부 응답
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
        return productService.findById(id)
            .map(product -> ResponseEntity.ok(ProductResponse.from(product)))
            .orElseGet(() -> ResponseEntity.notFound().build());
    }
    
    // 다양한 응답 상태
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (!productService.exists(id)) {
            return ResponseEntity.notFound().build();  // 404
        }
        
        productService.delete(id);
        return ResponseEntity.noContent().build();  // 204
    }
    
    // 파일 다운로드
    @GetMapping("/{id}/image")
    public ResponseEntity<Resource> downloadImage(@PathVariable Long id) {
        Resource resource = productService.getImage(id);
        
        return ResponseEntity.ok()
            .contentType(MediaType.IMAGE_PNG)
            .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename="product-" + id + ".png"")
            .body(resource);
    }
}
REST API 설계 원칙:
  • - URI는 리소스를 나타내고, HTTP 메서드로 행위 표현
  • - 복수형 명사 사용: /products, /orders
  • - 계층 관계: /orders/{orderId}/items
  • - 적절한 HTTP 상태 코드 반환

3. 요청/응답 DTO와 Validation

3.1 DTO 설계 원칙

Entity를 직접 반환하면 안 되는 이유:
  • - Entity 변경이 API 스펙에 영향
  • - 순환 참조로 인한 무한 루프
  • - 불필요한 데이터 노출 (비밀번호 등)
  • - N+1 문제 발생 가능

요청 DTO

// 상품 생성 요청
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CreateProductRequest {
    
    @NotBlank(message = "상품명은 필수입니다")
    @Size(min = 2, max = 100, message = "상품명은 2~100자입니다")
    private String name;
    
    @NotNull(message = "가격은 필수입니다")
    @Min(value = 0, message = "가격은 0 이상이어야 합니다")
    private Integer price;
    
    @NotNull(message = "카테고리는 필수입니다")
    private Long categoryId;
    
    @Size(max = 1000, message = "설명은 1000자 이내입니다")
    private String description;
    
    @Min(value = 0, message = "재고는 0 이상이어야 합니다")
    private Integer stockQuantity = 0;
    
    // Command 객체로 변환
    public CreateProductCommand toCommand() {
        return CreateProductCommand.builder()
            .name(name)
            .price(Money.of(price))
            .categoryId(categoryId)
            .description(description)
            .stockQuantity(stockQuantity)
            .build();
    }
}

// 상품 수정 요청
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UpdateProductRequest {
    
    @NotBlank(message = "상품명은 필수입니다")
    private String name;
    
    @NotNull(message = "가격은 필수입니다")
    @Min(0)
    private Integer price;
    
    private String description;
    
    public UpdateProductCommand toCommand() {
        return new UpdateProductCommand(name, Money.of(price), description);
    }
}

응답 DTO

// 상품 응답
@Getter
@Builder
public class ProductResponse {
    private Long id;
    private String name;
    private int price;
    private String categoryName;
    private String description;
    private int stockQuantity;
    private ProductStatus status;
    private LocalDateTime createdAt;
    
    // Entity -> DTO 변환 (정적 팩토리 메서드)
    public static ProductResponse from(Product product) {
        return ProductResponse.builder()
            .id(product.getId())
            .name(product.getName())
            .price(product.getPrice().getValue())
            .categoryName(product.getCategory().getName())
            .description(product.getDescription())
            .stockQuantity(product.getStockQuantity())
            .status(product.getStatus())
            .createdAt(product.getCreatedAt())
            .build();
    }
}

// 상품 목록 응답 (페이징)
@Getter
@Builder
public class ProductListResponse {
    private List<ProductResponse> products;
    private int page;
    private int size;
    private long totalElements;
    private int totalPages;
    private boolean hasNext;
    
    public static ProductListResponse from(Page<Product> page) {
        return ProductListResponse.builder()
            .products(page.getContent().stream()
                .map(ProductResponse::from)
                .toList())
            .page(page.getNumber())
            .size(page.getSize())
            .totalElements(page.getTotalElements())
            .totalPages(page.getTotalPages())
            .hasNext(page.hasNext())
            .build();
    }
}

// 상품 상세 응답 (연관 데이터 포함)
@Getter
@Builder
public class ProductDetailResponse {
    private Long id;
    private String name;
    private int price;
    private CategoryDto category;
    private String description;
    private List<ProductImageDto> images;
    private List<ReviewSummaryDto> recentReviews;
    private double averageRating;
    private int reviewCount;
    
    @Getter @Builder
    public static class CategoryDto {
        private Long id;
        private String name;
        private String path;
    }
    
    @Getter @Builder
    public static class ProductImageDto {
        private Long id;
        private String url;
        private int displayOrder;
    }
}

3.2 Bean Validation

주요 검증 어노테이션

어노테이션설명예시
@NotNullnull 불가@NotNull Long id
@NotBlanknull, 빈 문자열, 공백만 불가@NotBlank String name
@NotEmptynull, 빈 컬렉션 불가@NotEmpty List items
@Size크기 제한@Size(min=2, max=100)
@Min/@Max최소/최대값@Min(0) @Max(100)
@Email이메일 형식@Email String email
@Pattern정규식 패턴@Pattern(regexp="^[0-9]+$")
@Past/@Future과거/미래 날짜@Past LocalDate birthDate

커스텀 Validator

// 커스텀 어노테이션 정의
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface PhoneNumber {
    String message() default "올바른 전화번호 형식이 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// Validator 구현
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
    
    private static final Pattern PHONE_PATTERN = 
        Pattern.compile("^01[016789]-?\d{3,4}-?\d{4}$");
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return true;  // @NotBlank와 조합해서 사용
        }
        return PHONE_PATTERN.matcher(value).matches();
    }
}

// 사용
public class CreateMemberRequest {
    @NotBlank
    @PhoneNumber
    private String phoneNumber;
}

3.3 그룹 검증

// 검증 그룹 인터페이스
public interface OnCreate {}
public interface OnUpdate {}

// DTO에서 그룹별 검증 적용
public class ProductRequest {
    
    @Null(groups = OnCreate.class, message = "생성 시 ID는 null이어야 합니다")
    @NotNull(groups = OnUpdate.class, message = "수정 시 ID는 필수입니다")
    private Long id;
    
    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    private String name;
    
    @NotNull(groups = OnCreate.class)
    private Integer price;
}

// Controller에서 그룹 지정
@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @Validated(OnCreate.class) @RequestBody ProductRequest request) {
        // OnCreate 그룹의 검증만 실행
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> update(
            @PathVariable Long id,
            @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
        // OnUpdate 그룹의 검증만 실행
    }
}
DTO 설계 가이드:
  • - 요청/응답 DTO 분리 (같은 필드라도 검증 조건이 다름)
  • - Entity 변환 로직은 DTO에 정적 팩토리 메서드로
  • - 불변 객체로 설계 (@Getter만, @Setter 없이)
  • - 검증 메시지는 사용자 친화적으로

4. 예외 처리

4.1 비즈니스 예외 정의

// 기본 비즈니스 예외
@Getter
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;
    
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
    
    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

// 에러 코드 정의
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // Common
    INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다"),
    INTERNAL_SERVER_ERROR(500, "C002", "서버 오류가 발생했습니다"),
    
    // Product
    PRODUCT_NOT_FOUND(404, "P001", "상품을 찾을 수 없습니다"),
    PRODUCT_OUT_OF_STOCK(400, "P002", "상품 재고가 부족합니다"),
    PRODUCT_ALREADY_EXISTS(409, "P003", "이미 존재하는 상품입니다"),
    
    // Order
    ORDER_NOT_FOUND(404, "O001", "주문을 찾을 수 없습니다"),
    ORDER_CANNOT_CANCEL(400, "O002", "취소할 수 없는 주문입니다"),
    ORDER_ALREADY_PAID(400, "O003", "이미 결제된 주문입니다"),
    
    // Member
    MEMBER_NOT_FOUND(404, "M001", "회원을 찾을 수 없습니다"),
    DUPLICATE_EMAIL(409, "M002", "이미 사용 중인 이메일입니다"),
    INVALID_PASSWORD(401, "M003", "비밀번호가 일치하지 않습니다");
    
    private final int status;
    private final String code;
    private final String message;
}

// 구체적인 예외 클래스
public class ProductNotFoundException extends BusinessException {
    public ProductNotFoundException(Long productId) {
        super(ErrorCode.PRODUCT_NOT_FOUND, 
              "상품을 찾을 수 없습니다. ID: " + productId);
    }
}

public class InsufficientStockException extends BusinessException {
    public InsufficientStockException(Long productId, int requested, int available) {
        super(ErrorCode.PRODUCT_OUT_OF_STOCK,
              String.format("재고 부족 - 상품: %d, 요청: %d, 가용: %d", 
                           productId, requested, available));
    }
}

4.2 @ExceptionHandler

// 에러 응답 DTO
@Getter
@Builder
public class ErrorResponse {
    private String code;
    private String message;
    private List<FieldError> errors;
    private LocalDateTime timestamp;
    
    @Getter @Builder
    public static class FieldError {
        private String field;
        private String value;
        private String reason;
    }
    
    public static ErrorResponse of(ErrorCode errorCode) {
        return ErrorResponse.builder()
            .code(errorCode.getCode())
            .message(errorCode.getMessage())
            .errors(List.of())
            .timestamp(LocalDateTime.now())
            .build();
    }
    
    public static ErrorResponse of(ErrorCode errorCode, String message) {
        return ErrorResponse.builder()
            .code(errorCode.getCode())
            .message(message)
            .errors(List.of())
            .timestamp(LocalDateTime.now())
            .build();
    }
    
    public static ErrorResponse of(ErrorCode errorCode, 
                                   BindingResult bindingResult) {
        List<FieldError> fieldErrors = bindingResult.getFieldErrors().stream()
            .map(error -> FieldError.builder()
                .field(error.getField())
                .value(error.getRejectedValue() != null ? 
                       error.getRejectedValue().toString() : "")
                .reason(error.getDefaultMessage())
                .build())
            .toList();
        
        return ErrorResponse.builder()
            .code(errorCode.getCode())
            .message(errorCode.getMessage())
            .errors(fieldErrors)
            .timestamp(LocalDateTime.now())
            .build();
    }
}

4.3 @ControllerAdvice

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    // 비즈니스 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException e) {
        
        log.warn("Business exception: {}", e.getMessage());
        
        ErrorCode errorCode = e.getErrorCode();
        ErrorResponse response = ErrorResponse.of(errorCode, e.getMessage());
        
        return ResponseEntity
            .status(errorCode.getStatus())
            .body(response);
    }
    
    // Validation 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException e) {
        
        log.warn("Validation exception: {}", e.getMessage());
        
        ErrorResponse response = ErrorResponse.of(
            ErrorCode.INVALID_INPUT_VALUE, 
            e.getBindingResult()
        );
        
        return ResponseEntity.badRequest().body(response);
    }
    
    // @RequestParam 검증 실패
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException e) {
        
        log.warn("Constraint violation: {}", e.getMessage());
        
        ErrorResponse response = ErrorResponse.of(
            ErrorCode.INVALID_INPUT_VALUE, 
            e.getMessage()
        );
        
        return ResponseEntity.badRequest().body(response);
    }
    
    // 타입 변환 실패 (예: String -> Long)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e) {
        
        String message = String.format("'%s' 값이 올바르지 않습니다", e.getValue());
        ErrorResponse response = ErrorResponse.of(
            ErrorCode.INVALID_INPUT_VALUE, 
            message
        );
        
        return ResponseEntity.badRequest().body(response);
    }
    
    // HTTP 메서드 불일치
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotSupported(
            HttpRequestMethodNotSupportedException e) {
        
        ErrorResponse response = ErrorResponse.of(
            ErrorCode.INVALID_INPUT_VALUE,
            "지원하지 않는 HTTP 메서드입니다: " + e.getMethod()
        );
        
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(response);
    }
    
    // 그 외 모든 예외
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unexpected exception", e);
        
        ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
        
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(response);
    }
}

4.4 예외 처리 실전 예제

// Service에서 예외 발생
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final MemberRepository memberRepository;
    
    @Transactional
    public OrderResult createOrder(Long memberId, CreateOrderCommand command) {
        // 회원 조회 - 없으면 예외
        Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new MemberNotFoundException(memberId));
        
        // 상품 조회 및 재고 확인
        List<OrderLine> orderLines = command.getItems().stream()
            .map(item -> {
                Product product = productRepository.findById(item.getProductId())
                    .orElseThrow(() -> new ProductNotFoundException(item.getProductId()));
                
                // 재고 확인
                if (product.getStockQuantity() < item.getQuantity()) {
                    throw new InsufficientStockException(
                        product.getId(),
                        item.getQuantity(),
                        product.getStockQuantity()
                    );
                }
                
                return OrderLine.create(product, item.getQuantity());
            })
            .toList();
        
        Order order = Order.create(member, orderLines);
        orderRepository.save(order);
        
        return OrderResult.from(order);
    }
}

// API 응답 예시
// 성공 시: 201 Created
{
    "orderId": 123,
    "totalAmount": 50000,
    "status": "CREATED"
}

// 상품 없음: 404 Not Found
{
    "code": "P001",
    "message": "상품을 찾을 수 없습니다. ID: 999",
    "errors": [],
    "timestamp": "2024-01-15T10:30:00"
}

// 재고 부족: 400 Bad Request
{
    "code": "P002",
    "message": "재고 부족 - 상품: 1, 요청: 10, 가용: 5",
    "errors": [],
    "timestamp": "2024-01-15T10:30:00"
}

// Validation 실패: 400 Bad Request
{
    "code": "C001",
    "message": "잘못된 입력값입니다",
    "errors": [
        {
            "field": "name",
            "value": "",
            "reason": "상품명은 필수입니다"
        },
        {
            "field": "price",
            "value": "-100",
            "reason": "가격은 0 이상이어야 합니다"
        }
    ],
    "timestamp": "2024-01-15T10:30:00"
}
예외 처리 Best Practices:
  • - 비즈니스 예외는 RuntimeException 상속
  • - ErrorCode로 예외 유형 표준화
  • - @ControllerAdvice로 전역 예외 처리
  • - 클라이언트에게 일관된 에러 응답 형식 제공
  • - 내부 예외 정보는 로그로만, 클라이언트에게는 안전한 메시지만

5. Interceptor와 Filter

5.1 Filter vs Interceptor

구분FilterInterceptor
관리 주체Servlet ContainerSpring Container
실행 시점DispatcherServlet 전/후Controller 전/후
Spring Bean주입 가능 (Spring Boot)주입 가능
사용 예인코딩, 보안, 로깅인증, 권한, 로깅

5.2 Filter 구현

// 요청 로깅 Filter
@Component
@Order(1)  // 필터 순서 지정
@Slf4j
public class RequestLoggingFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        
        // MDC에 요청 ID 저장 (로그에 포함)
        MDC.put("requestId", requestId);
        
        long startTime = System.currentTimeMillis();
        
        log.info(">>> [{}] {} {}", 
                 requestId, 
                 httpRequest.getMethod(), 
                 httpRequest.getRequestURI());
        
        try {
            chain.doFilter(request, response);  // 다음 필터 또는 서블릿 호출
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            
            log.info("<<< [{}] {} {} - {}ms", 
                     requestId,
                     httpResponse.getStatus(),
                     httpRequest.getRequestURI(),
                     elapsed);
            
            MDC.clear();
        }
    }
}

// 특정 URL에만 적용하는 Filter
@Configuration
public class FilterConfig {
    
    @Bean
    public FilterRegistrationBean<ApiKeyFilter> apiKeyFilter() {
        FilterRegistrationBean<ApiKeyFilter> registration = 
            new FilterRegistrationBean<>();
        
        registration.setFilter(new ApiKeyFilter());
        registration.addUrlPatterns("/api/external/*");  // 특정 URL만
        registration.setOrder(2);
        
        return registration;
    }
}

public class ApiKeyFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String apiKey = httpRequest.getHeader("X-API-Key");
        
        if (apiKey == null || !isValidApiKey(apiKey)) {
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.getWriter().write("Invalid API Key");
            return;
        }
        
        chain.doFilter(request, response);
    }
    
    private boolean isValidApiKey(String apiKey) {
        // API 키 검증 로직
        return "valid-key".equals(apiKey);
    }
}

5.3 Interceptor 구현

// 인증 Interceptor
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {
    
    private final TokenProvider tokenProvider;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response,
                            Object handler) throws Exception {
        
        // 핸들러가 컨트롤러 메서드가 아니면 통과
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        
        // @NoAuth 어노테이션이 있으면 인증 스킵
        if (handlerMethod.hasMethodAnnotation(NoAuth.class)) {
            return true;
        }
        
        // 토큰 검증
        String token = extractToken(request);
        if (token == null || !tokenProvider.validateToken(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"message\": \"인증이 필요합니다\"}");
            return false;
        }
        
        // 사용자 정보를 request에 저장
        Long userId = tokenProvider.getUserId(token);
        request.setAttribute("userId", userId);
        
        return true;
    }
    
    @Override
    public void postHandle(HttpServletRequest request,
                          HttpServletResponse response,
                          Object handler,
                          ModelAndView modelAndView) throws Exception {
        // Controller 실행 후, View 렌더링 전
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler,
                               Exception ex) throws Exception {
        // 요청 완료 후 (View 렌더링 후)
        if (ex != null) {
            log.error("Request failed", ex);
        }
    }
    
    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoAuth {
}

// Interceptor 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    
    private final AuthenticationInterceptor authInterceptor;
    private final LoggingInterceptor loggingInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loggingInterceptor)
            .addPathPatterns("/**")
            .order(1);
        
        registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/**")
            .excludePathPatterns(
                "/api/auth/login",
                "/api/auth/signup",
                "/api/products/**"  // 상품 조회는 인증 불필요
            )
            .order(2);
    }
}

5.4 실행 시간 측정 Interceptor

@Component
@Slf4j
public class PerformanceInterceptor implements HandlerInterceptor {
    
    private static final String START_TIME = "startTime";
    
    @Override
    public boolean preHandle(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler) {
        request.setAttribute(START_TIME, System.currentTimeMillis());
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler,
                               Exception ex) {
        
        Long startTime = (Long) request.getAttribute(START_TIME);
        if (startTime == null) return;
        
        long elapsed = System.currentTimeMillis() - startTime;
        String uri = request.getRequestURI();
        String method = request.getMethod();
        int status = response.getStatus();
        
        if (elapsed > 1000) {
            log.warn("Slow request: {} {} - {}ms (status: {})", 
                     method, uri, elapsed, status);
        } else {
            log.info("{} {} - {}ms (status: {})", 
                     method, uri, elapsed, status);
        }
    }
}
Filter vs Interceptor 선택 가이드:
  • - Filter: 모든 요청에 적용, Spring 외부 처리 (인코딩, CORS)
  • - Interceptor: Controller 관련 처리 (인증, 권한, 로깅)
  • - Spring Bean 주입이 필요하면 Interceptor 권장
  • - 실행 순서: Filter - DispatcherServlet - Interceptor - Controller

6. ArgumentResolver와 커스텀 어노테이션

6.1 HandlerMethodArgumentResolver

Controller 메서드의 파라미터를 원하는 방식으로 바인딩할 수 있습니다. 인증된 사용자 정보, 페이징 정보 등을 깔끔하게 주입받을 수 있습니다.

@LoginUser 구현

// 커스텀 어노테이션
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

// 로그인 사용자 정보 DTO
@Getter
@Builder
public class LoginUserInfo {
    private Long id;
    private String email;
    private String name;
    private UserRole role;
}

// ArgumentResolver 구현
@Component
@RequiredArgsConstructor
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
    
    private final TokenProvider tokenProvider;
    private final MemberRepository memberRepository;
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // @LoginUser 어노테이션이 있고, 타입이 LoginUserInfo인 경우
        return parameter.hasParameterAnnotation(LoginUser.class)
            && parameter.getParameterType().equals(LoginUserInfo.class);
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) {
        
        HttpServletRequest request = 
            (HttpServletRequest) webRequest.getNativeRequest();
        
        String token = extractToken(request);
        if (token == null) {
            return null;  // 또는 예외 발생
        }
        
        Long userId = tokenProvider.getUserId(token);
        Member member = memberRepository.findById(userId)
            .orElseThrow(() -> new MemberNotFoundException(userId));
        
        return LoginUserInfo.builder()
            .id(member.getId())
            .email(member.getEmail())
            .name(member.getName())
            .role(member.getRole())
            .build();
    }
    
    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

// WebMvcConfigurer에 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    
    private final LoginUserArgumentResolver loginUserResolver;
    
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserResolver);
    }
}

// Controller에서 사용
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @LoginUser LoginUserInfo user,  // 자동으로 주입됨
            @Valid @RequestBody CreateOrderRequest request) {
        
        OrderResult result = orderService.createOrder(user.getId(), request);
        return ResponseEntity.created(...).body(OrderResponse.from(result));
    }
    
    @GetMapping("/my")
    public ResponseEntity<List<OrderResponse>> getMyOrders(
            @LoginUser LoginUserInfo user) {
        
        List<Order> orders = orderService.findByMemberId(user.getId());
        return ResponseEntity.ok(orders.stream()
            .map(OrderResponse::from)
            .toList());
    }
}

6.2 페이징 파라미터 커스터마이징

// 커스텀 페이징 어노테이션
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomPageable {
    int defaultSize() default 20;
    int maxSize() default 100;
    String defaultSort() default "createdAt";
    String defaultDirection() default "DESC";
}

// ArgumentResolver
@Component
public class CustomPageableArgumentResolver implements HandlerMethodArgumentResolver {
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CustomPageable.class)
            && parameter.getParameterType().equals(Pageable.class);
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) {
        
        CustomPageable annotation = parameter.getParameterAnnotation(CustomPageable.class);
        
        // 요청 파라미터에서 값 추출
        String pageStr = webRequest.getParameter("page");
        String sizeStr = webRequest.getParameter("size");
        String sort = webRequest.getParameter("sort");
        
        int page = pageStr != null ? Integer.parseInt(pageStr) : 0;
        int size = sizeStr != null ? Integer.parseInt(sizeStr) : annotation.defaultSize();
        
        // 최대 크기 제한
        size = Math.min(size, annotation.maxSize());
        
        // 정렬 설정
        Sort sortObj;
        if (sort != null && !sort.isBlank()) {
            String[] parts = sort.split(",");
            String property = parts[0];
            Sort.Direction direction = parts.length > 1 
                ? Sort.Direction.fromString(parts[1])
                : Sort.Direction.fromString(annotation.defaultDirection());
            sortObj = Sort.by(direction, property);
        } else {
            sortObj = Sort.by(
                Sort.Direction.fromString(annotation.defaultDirection()),
                annotation.defaultSort()
            );
        }
        
        return PageRequest.of(page, size, sortObj);
    }
}

// Controller에서 사용
@GetMapping
public ResponseEntity<Page<ProductResponse>> getProducts(
        @CustomPageable(defaultSize = 10, maxSize = 50) Pageable pageable) {
    
    return ResponseEntity.ok(productService.findAll(pageable)
        .map(ProductResponse::from));
}

6.3 요청 컨텍스트 주입

// 요청 컨텍스트 DTO
@Getter
@Builder
public class RequestContext {
    private String requestId;
    private String clientIp;
    private String userAgent;
    private LocalDateTime requestTime;
}

// 어노테이션
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentRequest {
}

// ArgumentResolver
@Component
public class RequestContextArgumentResolver implements HandlerMethodArgumentResolver {
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentRequest.class)
            && parameter.getParameterType().equals(RequestContext.class);
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) {
        
        HttpServletRequest request = 
            (HttpServletRequest) webRequest.getNativeRequest();
        
        return RequestContext.builder()
            .requestId(getRequestId(request))
            .clientIp(getClientIp(request))
            .userAgent(request.getHeader("User-Agent"))
            .requestTime(LocalDateTime.now())
            .build();
    }
    
    private String getRequestId(HttpServletRequest request) {
        String requestId = request.getHeader("X-Request-Id");
        return requestId != null ? requestId : UUID.randomUUID().toString();
    }
    
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty()) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

// 사용
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
        @LoginUser LoginUserInfo user,
        @CurrentRequest RequestContext context,
        @Valid @RequestBody CreateOrderRequest request) {
    
    log.info("[{}] 주문 생성 요청 - user: {}, ip: {}", 
             context.getRequestId(), user.getId(), context.getClientIp());
    
    // ...
}
ArgumentResolver 활용:
  • - 반복되는 파라미터 추출 로직을 한 곳에서 관리
  • - Controller 코드가 깔끔해짐
  • - 테스트 시 Mock 주입 용이
  • - @LoginUser, @CurrentRequest 등 도메인 특화 어노테이션 생성

7. 정리 및 다음 단계

7.1 핵심 개념 정리

Spring Boot 자동 설정

@SpringBootApplication으로 자동 설정 활성화. @ConditionalOn... 어노테이션으로 조건부 Bean 등록. 개발자 설정이 자동 설정보다 우선.

@RestController와 REST API

HTTP 메서드별 매핑 (@GetMapping, @PostMapping 등). @PathVariable, @RequestParam, @RequestBody로 파라미터 바인딩. ResponseEntity로 상태 코드와 헤더 제어.

DTO와 Validation

요청/응답 DTO 분리, Entity 직접 노출 금지. Bean Validation으로 입력값 검증. 커스텀 Validator로 복잡한 검증 로직 구현.

예외 처리

비즈니스 예외는 RuntimeException 상속. ErrorCode로 예외 유형 표준화. @ControllerAdvice로 전역 예외 처리.

Filter와 Interceptor

Filter는 Servlet 레벨, Interceptor는 Spring MVC 레벨. 인증/권한 체크는 Interceptor로 구현. 실행 순서: Filter - DispatcherServlet - Interceptor - Controller.

ArgumentResolver

Controller 파라미터 커스텀 바인딩. @LoginUser 같은 도메인 특화 어노테이션 생성. 반복 코드 제거, Controller 코드 간결화.

7.2 실무 체크리스트

API 설계

  • VRESTful URI 설계 (복수형 명사)
  • V적절한 HTTP 메서드 사용
  • V일관된 응답 형식
  • V버전 관리 (/api/v1/...)

DTO

  • V요청/응답 DTO 분리
  • VEntity 직접 반환 금지
  • VValidation 어노테이션 적용
  • V정적 팩토리 메서드로 변환

예외 처리

  • VErrorCode 표준화
  • V@ControllerAdvice 전역 처리
  • V일관된 에러 응답 형식

보안

  • V인증 Interceptor 구현
  • V민감 정보 응답에서 제외
  • V입력값 검증 철저히

7.3 다음 학습 내용

Spring 05: Spring Data JPA

  • - JpaRepository와 쿼리 메서드
  • - @Query와 JPQL/Native Query
  • - 페이징과 정렬
  • - Auditing (생성일, 수정일 자동 관리)

Spring 06: Entity 설계

  • - Entity 기본 설계 원칙
  • - 연관관계 매핑 전략
  • - 상속 매핑
  • - 복합키와 식별 관계
학습 포인트:

이번 세션에서 배운 Web MVC는 클라이언트와의 인터페이스입니다. 다음 세션에서 배울 JPA는 데이터베이스와의 인터페이스입니다. 두 계층 사이에서 Service가 비즈니스 로직을 처리합니다. 각 계층의 역할을 명확히 이해하고, DTO로 계층 간 데이터를 전달하세요.

8. CORS와 보안 설정

8.1 CORS 설정

// 전역 CORS 설정
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000", "https://shop.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);  // preflight 캐시 시간
    }
}

// Controller 레벨 CORS
@RestController
@RequestMapping("/api/products")
@CrossOrigin(origins = "http://localhost:3000")
public class ProductController {
    // ...
}

// 메서드 레벨 CORS
@GetMapping("/{id}")
@CrossOrigin(origins = "*", maxAge = 3600)
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
    // ...
}

8.2 Content Negotiation

// JSON과 XML 모두 지원
@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    // Accept 헤더에 따라 응답 형식 결정
    // Accept: application/json -> JSON
    // Accept: application/xml -> XML
    @GetMapping(value = "/{id}", 
                produces = {MediaType.APPLICATION_JSON_VALUE, 
                           MediaType.APPLICATION_XML_VALUE})
    public ProductResponse getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }
}

// application.yml 설정
spring:
  mvc:
    contentnegotiation:
      favor-parameter: true  # ?format=json 파라미터 지원
      parameter-name: format

8.3 파일 업로드

@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
    
    private final FileStorageService fileStorageService;
    
    // 단일 파일 업로드
    @PostMapping("/upload")
    public ResponseEntity<FileUploadResponse> uploadFile(
            @RequestParam("file") MultipartFile file) {
        
        String fileName = fileStorageService.store(file);
        String fileUrl = "/api/files/" + fileName;
        
        return ResponseEntity.ok(new FileUploadResponse(fileName, fileUrl));
    }
    
    // 다중 파일 업로드
    @PostMapping("/upload-multiple")
    public ResponseEntity<List<FileUploadResponse>> uploadMultiple(
            @RequestParam("files") List<MultipartFile> files) {
        
        List<FileUploadResponse> responses = files.stream()
            .map(file -> {
                String fileName = fileStorageService.store(file);
                return new FileUploadResponse(fileName, "/api/files/" + fileName);
            })
            .toList();
        
        return ResponseEntity.ok(responses);
    }
    
    // 파일 다운로드
    @GetMapping("/{fileName}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String fileName) {
        Resource resource = fileStorageService.loadAsResource(fileName);
        
        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename="" + resource.getFilename() + """)
            .body(resource);
    }
}

// application.yml
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 50MB
Web MVC 추가 기능:
  • - CORS는 프론트엔드 도메인만 허용
  • - 파일 업로드 크기 제한 설정 필수
  • - 파일명 검증으로 보안 강화

9. 정리 및 다음 단계

9.1 핵심 개념 정리

Spring Boot 자동 설정

@SpringBootApplication으로 자동 설정 활성화. @ConditionalOn... 어노테이션으로 조건부 Bean 등록. 개발자 설정이 자동 설정보다 우선.

@RestController와 REST API

HTTP 메서드별 매핑 (@GetMapping, @PostMapping 등). @PathVariable, @RequestParam, @RequestBody로 파라미터 바인딩. ResponseEntity로 상태 코드와 헤더 제어.

DTO와 Validation

요청/응답 DTO 분리, Entity 직접 노출 금지. Bean Validation으로 입력값 검증. 커스텀 Validator로 복잡한 검증 로직 구현.

예외 처리

비즈니스 예외는 RuntimeException 상속. ErrorCode로 예외 유형 표준화. @ControllerAdvice로 전역 예외 처리.

Filter, Interceptor, ArgumentResolver

Filter는 Servlet 레벨, Interceptor는 Spring MVC 레벨. 인증/권한 체크는 Interceptor로 구현. ArgumentResolver로 커스텀 파라미터 바인딩.

9.2 실무 체크리스트

API 설계

  • VRESTful URI 설계
  • V적절한 HTTP 메서드
  • V일관된 응답 형식
  • VAPI 버전 관리

보안

  • VCORS 설정
  • V인증 Interceptor
  • V입력값 검증
  • V민감 정보 제외

9.3 다음 학습 내용

Spring 05: Spring Data JPA

  • - JpaRepository와 쿼리 메서드
  • - @Query와 JPQL
  • - 페이징과 정렬
  • - Auditing
학습 포인트:

Web MVC는 클라이언트와의 인터페이스입니다. 다음 세션에서 배울 JPA는 데이터베이스와의 인터페이스입니다. 각 계층의 역할을 명확히 이해하고, DTO로 계층 간 데이터를 전달하세요.