Spring Boot의 자동 설정과 Web MVC의 요청 처리 흐름을 학습합니다.
Spring Boot는 "Convention over Configuration" 원칙을 따릅니다. 복잡한 설정 없이도 합리적인 기본값으로 애플리케이션을 시작할 수 있습니다.
@SpringBootApplication // 이 하나의 어노테이션이 모든 것을 시작
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
// @SpringBootApplication은 다음 3개의 조합
@SpringBootConfiguration // @Configuration과 동일
@EnableAutoConfiguration // 자동 설정 활성화
@ComponentScan // 컴포넌트 스캔 활성화
public @interface SpringBootApplication { }// 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 // 웹 애플리케이션일 때# 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: 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 { }// @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 변환되어 응답
}
}@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();
}
}@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;
}@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);
}
}// 상품 생성 요청
@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);
}
}// 상품 응답
@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;
}
}| 어노테이션 | 설명 | 예시 |
|---|---|---|
| @NotNull | null 불가 | @NotNull Long id |
| @NotBlank | null, 빈 문자열, 공백만 불가 | @NotBlank String name |
| @NotEmpty | null, 빈 컬렉션 불가 | @NotEmpty List items |
| @Size | 크기 제한 | @Size(min=2, max=100) |
| @Min/@Max | 최소/최대값 | @Min(0) @Max(100) |
| 이메일 형식 | @Email String email | |
| @Pattern | 정규식 패턴 | @Pattern(regexp="^[0-9]+$") |
| @Past/@Future | 과거/미래 날짜 | @Past LocalDate birthDate |
// 커스텀 어노테이션 정의
@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;
}// 검증 그룹 인터페이스
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 그룹의 검증만 실행
}
}// 기본 비즈니스 예외
@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));
}
}// 에러 응답 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();
}
}@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);
}
}// 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"
}| 구분 | Filter | Interceptor |
|---|---|---|
| 관리 주체 | Servlet Container | Spring Container |
| 실행 시점 | DispatcherServlet 전/후 | Controller 전/후 |
| Spring Bean | 주입 가능 (Spring Boot) | 주입 가능 |
| 사용 예 | 인코딩, 보안, 로깅 | 인증, 권한, 로깅 |
// 요청 로깅 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);
}
}// 인증 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);
}
}@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);
}
}
}Controller 메서드의 파라미터를 원하는 방식으로 바인딩할 수 있습니다. 인증된 사용자 정보, 페이징 정보 등을 깔끔하게 주입받을 수 있습니다.
// 커스텀 어노테이션
@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());
}
}// 커스텀 페이징 어노테이션
@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));
}// 요청 컨텍스트 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());
// ...
}@SpringBootApplication으로 자동 설정 활성화. @ConditionalOn... 어노테이션으로 조건부 Bean 등록. 개발자 설정이 자동 설정보다 우선.
HTTP 메서드별 매핑 (@GetMapping, @PostMapping 등). @PathVariable, @RequestParam, @RequestBody로 파라미터 바인딩. ResponseEntity로 상태 코드와 헤더 제어.
요청/응답 DTO 분리, Entity 직접 노출 금지. Bean Validation으로 입력값 검증. 커스텀 Validator로 복잡한 검증 로직 구현.
비즈니스 예외는 RuntimeException 상속. ErrorCode로 예외 유형 표준화. @ControllerAdvice로 전역 예외 처리.
Filter는 Servlet 레벨, Interceptor는 Spring MVC 레벨. 인증/권한 체크는 Interceptor로 구현. 실행 순서: Filter - DispatcherServlet - Interceptor - Controller.
Controller 파라미터 커스텀 바인딩. @LoginUser 같은 도메인 특화 어노테이션 생성. 반복 코드 제거, Controller 코드 간결화.
이번 세션에서 배운 Web MVC는 클라이언트와의 인터페이스입니다. 다음 세션에서 배울 JPA는 데이터베이스와의 인터페이스입니다. 두 계층 사이에서 Service가 비즈니스 로직을 처리합니다. 각 계층의 역할을 명확히 이해하고, DTO로 계층 간 데이터를 전달하세요.
// 전역 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) {
// ...
}// 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@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@SpringBootApplication으로 자동 설정 활성화. @ConditionalOn... 어노테이션으로 조건부 Bean 등록. 개발자 설정이 자동 설정보다 우선.
HTTP 메서드별 매핑 (@GetMapping, @PostMapping 등). @PathVariable, @RequestParam, @RequestBody로 파라미터 바인딩. ResponseEntity로 상태 코드와 헤더 제어.
요청/응답 DTO 분리, Entity 직접 노출 금지. Bean Validation으로 입력값 검증. 커스텀 Validator로 복잡한 검증 로직 구현.
비즈니스 예외는 RuntimeException 상속. ErrorCode로 예외 유형 표준화. @ControllerAdvice로 전역 예외 처리.
Filter는 Servlet 레벨, Interceptor는 Spring MVC 레벨. 인증/권한 체크는 Interceptor로 구현. ArgumentResolver로 커스텀 파라미터 바인딩.
Web MVC는 클라이언트와의 인터페이스입니다. 다음 세션에서 배울 JPA는 데이터베이스와의 인터페이스입니다. 각 계층의 역할을 명확히 이해하고, DTO로 계층 간 데이터를 전달하세요.