- 클라이언트(프론트엔드)의 요청을 가장 먼저 맞이하는 대문 역할을 하는 컨트롤러입니다. 여기서는 단순히 요청을 서비스로 넘기는 것을 넘어, 보안 인가, 파라미터 검증, 그리고 응답의 표준화를 어떻게 처리했는지 살펴보겠습니다.
💻 실제 코드 (AddressControllerV1.java)
@Tag(name = "Address API", description = "사용자 배송지 관리 API")
@RestController
@RequestMapping("/api/v1/addresses")
@RequiredArgsConstructor
@Validated
public class AddressControllerV1 {
private final AddressServiceV1 addressService;
@Operation(summary = "배송지 생성", description = "[CUSTOMER] 로그인한 사용자의 새로운 배송지를 생성합니다.")
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER')") // (1) 역할 기반 접근 제어
public ResponseEntity<ResCreateAddressDtoV1> createAddress(
@Valid @RequestBody ReqCreateAddressDtoV1 request, // (2) 유효성 검증
@AuthenticationPrincipal AuthUser authUser) { // (3) 인증 주체 추출
return ResponseEntity.ok(addressService.createAddress(request, authUser.userId(), authUser.role()));
}
@Operation(summary = "본인 배송지 목록 조회", description = "[CUSTOMER] 로그인한 사용자의 배송지 목록을 조회합니다. 별칭으로 검색이 가능합니다.")
@PageableAsQueryParam
@GetMapping
@PreAuthorize("hasAnyRole('CUSTOMER')")
public ResponseEntity<PageResponse<ResGetAddressDtoV1>> getMyAddresses(
@RequestParam(name = "alias", required = false) String alias,
@AuthenticationPrincipal AuthUser authUser,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { // (4)
기본 페이징 설정
// 악의적인 페이징 크기 요청 방어 (예: size=10000 차단)
Pageable validatedPageable = PageUtil.validatePageSize(pageable);
// (5) 공통 페이징 응답 객체로 감싸서 반환
Page<ResGetAddressDtoV1> addresses = addressService.getMyAddresses(authUser.userId(), authUser.role(), alias, validatedPageable);
return ResponseEntity.ok(new PageResponse<>(addresses));
}
@Operation(summary = "배송지 수정", description = "[CUSTOMER] 특정 배송지의 정보를 수정합니다. 본인의 배송지만 수정 가능합니다.")
@PutMapping("/{addressId}")
@PreAuthorize("hasAnyRole('CUSTOMER')")
public ResponseEntity<ResGetAddressDtoV1> updateAddress(
@PathVariable("addressId") UUID addressId,
@Valid @RequestBody ReqUpdateAddressDtoV1 request,
@AuthenticationPrincipal AuthUser authUser) {
return ResponseEntity.ok(addressService.updateAddress(addressId, request, authUser.userId(), authUser.role()));
}
@Operation(summary = "배송지 삭제", description = "[CUSTOMER] 특정 배송지를 삭제(Soft Delete) 처리합니다. 본인의 배송지만 삭제 가능합니다.")
@DeleteMapping("/{addressId}")
@PreAuthorize("hasAnyRole('CUSTOMER')")
public ResponseEntity<Void> deleteAddress(
@PathVariable("addressId") UUID addressId,
@AuthenticationPrincipal AuthUser authUser) {
addressService.deleteAddress(addressId, authUser.userId(), authUser.role());
return ResponseEntity.noContent().build(); // 204 No Content 반환
}
}
🤔 왜 이렇게 짰는가? (설계 의도와 이유)
Controller 레벨의 철통 방어 (@PreAuthorize & @Valid)
- 서비스 로직이 실행되기 전에, 컨트롤러 단에서 의미 없는 요청들을 미리 잘라내어 서버 자원을 아꼈습니다.
- @PreAuthorize("hasAnyRole('CUSTOMER')"): 스프링 시큐리티 어노테이션을 사용하여, 토큰에 CUSTOMER 권한이 없는 유저(예: OWNER)나 비로그인 유저가 접근하면 컨트롤러 집입조차 못하게 403/401 에러로 막았습니다.
- @Valid: 앞서 정의했던 DTO에 @NotBlank 같은 제약 조건들을 활성화합니다. 주소가 비어있으면 곧바로 400 에러를 뱉어냅니다.
내 정보는 내가 증명한다 (@AuthenticationPrincipal)
- 클라이언트가 요청을 보낼 때 Body나 파라미터로 {"userId": "내 아이디"}를 보내도록 설계하면 해킹의 위험(다른 사람 ID를 넣어서 보냄)이 있습니다.
- 그래서 파라미터로 ID를 받지 않고, 보안 필터를 통과한 JWT 토큰에서 직접 파싱한 안전한 유저 정보(@AuthenticationPrincipal AuthUser authUser)를 꺼내어 Service 계층으로 넘겼습니다.
악의적인 요청 방어와 응답 표준화 (PageUtil & PageResponse)
- 목록을 조회할 때 악성 유저가 URL 파라미터로 ?size=10000을 보내서 DB 전체를 긁어오려고 시도하면 DB가 뻗을 수 있습니다.
- 이를 막기 위해 PageUtil.validatePageSize()라는 커스텀 유틸을 통해 10, 30, 50 단위로만 조회가 가능하도록 강제 방어선을 쳤습니다.
- 또한, JPA가 반환하는 Page 객체를 날것 그대로 보내지 않고, PageResponse<>라는 껍데기로 한 번 더 감싸서 클라이언트가 항상 일관된 포맷의 JSON(예: 통일된 메타 데이터 포맷)을 받을 수 있도록 표준화했습니다.
💡 정리
- 어노테이션 기반의 보안/검증 설계: AOP(관점 지향 프로그래밍, 공통 기능을 핵심 로직과 분리하여 처리하는 방식)와 프록시(실제 메서드 실행 전에 요청을 가로채 추가 로직을 수행하는 객체) 기반으로 동작하는 @PreAuthorize, @Valid, @AuthenticationPrincipal을 활용하여, 컨트롤러 코드에 인증/인가 및 검증 로직을 직접 작성하지 않고도 깔끔하게 분리했습니다.
- 대용량 트래픽 방어 페이징: PageableDefault로 기본 페이징 기준을 잡고, 악의적인 대량 데이터 조회 공격을 막기 위해 자체 구현한 PageUtil.validatePageSize로 페이지 사이즈를 강제하여 DB 성능 저하를 방어했습니다.
- Swagger 자동화 문서화: @Tag와 @Operation 어노테이션을 꼼꼼히 작성하여 프론트엔드 개발자와의 협업을 위한 API 명세서를 자동으로 생성하고 관리했습니다.