오늘은 Spring Security를 활용한 권한 관리와 RESTful API의 올바른 응답 방식, 그리고 코드 가독성에 대해 배웠다.
1. @PreAuthorize로 메서드 레벨 권한 제어하기
Spring Security의 권한 관리
프로젝트를 진행하다 보니 특정 API는 특정 권한을 가진 사용자만 접근할 수 있어야 했다. 예를 들어 메뉴 등록은 가게 주인(OWNER)이나 관리자(MANAGER, MASTER)만 할 수 있어야 한다.
@PreAuthorize 사용법
@RestController
@RequestMapping("/api/menus")
public class MenuController {
@PostMapping
@PreAuthorize("hasAnyRole('OWNER', 'MANAGER', 'MASTER')")
public ResponseEntity<?> createMenu(@RequestBody MenuCreateDto dto) {
// 메뉴 생성 로직
}
@DeleteMapping("/{menuId}")
@PreAuthorize("hasRole('MASTER')")
public ResponseEntity<?> deleteMenu(@PathVariable Long menuId) {
// 메뉴 삭제 로직 (MASTER만 가능)
}
@GetMapping
// 권한 제한 없음 - 누구나 조회 가능
public ResponseEntity<?> getMenuList() {
// 메뉴 목록 조회
}
}
주요 어노테이션
- @PreAuthorize("hasRole('ROLE_NAME')")
- 단일 권한 체크
@PreAuthorize("hasRole('ADMIN')") public void adminOnlyMethod() { } - @PreAuthorize("hasAnyRole('ROLE1', 'ROLE2')")
- 여러 권한 중 하나라도 있으면 접근 허용
@PreAuthorize("hasAnyRole('OWNER', 'MANAGER', 'MASTER')") public void managerLevelMethod() { }
설정 필요사항
@PreAuthorize를 사용하려면 Spring Security 설정에서 활성화해야 한다:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 이거 필수!
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
왜 좋을까?
- 가독성: 메서드 위에 어노테이션만 붙이면 되니 직관적
- 재사용성: 같은 권한 로직을 여러 곳에서 쉽게 적용
- 보안: 컨트롤러 레벨보다 세밀한 제어 가능
2. RESTful한 201 Created 응답 만들기
기존 방식
처음에는 이렇게 작성했다:
@PostMapping
public ResponseEntity<?> createReview(@RequestBody ReviewCreateDto dto) {
ReviewResponseDto response = reviewService.createReview(dto);
return ResponseEntity
.status(HttpStatus.CREATED) // 201 상태 코드
.body(response);
}
동작은 하지만 !! 튜터님이 갑자기 지적했다.
왜냐하면 아래에는 ReponseEntity.ok 여서 이거 아냐고 물어봤는데 아는데.... ~하고 뒷말 생략
RESTful API의 Location 헤더
REST 표준에 따르면, 리소스를 생성했을 때 응답에는:
- 201 Created 상태 코드
- Location 헤더: 생성된 리소스의 URI
이 두 가지가 포함되어야 한다.
추천하는 방식
@PostMapping
public ResponseEntity<?> createReview(@RequestBody ReviewCreateDto dto) {
ReviewResponseDto response = reviewService.createReview(dto);
// 생성된 리소스의 URI 생성
URI location = URI.create("/api/reviews/" + response.reviewId());
return ResponseEntity
.created(location) // 201 + Location 헤더 자동 설정!
.body(response);
}
실제 HTTP 응답 예시
HTTP/1.1 201 Created
Location: http://localhost:8080/api/reviews/123
Content-Type: application/json
{
"reviewId": 123,
"content": "너무 맛있어요!",
"rating": 5,
"createdAt": "2025-10-01T10:30:00"
}
클라이언트는 Location 헤더를 보고 생성된 리소스에 바로 접근할 수 있다!
비교 정리
방식 상태 코드 Location 헤더 RESTful
| .status(CREATED) | ✅ 201 | ❌ 없음 | ⚠️ 부분적 |
| .created(location) | ✅ 201 | ✅ 있음 | ✅ 완벽 |
3. 복잡한 로직은 메서드로 분리하자
문제 상황
코드를 작성하다 보면 이런 복잡한 로직을 만나게 된다:
@Service
public class OrderService {
public OrderResponseDto createOrder(OrderCreateDto dto) {
// 주문 생성
Order order = orderRepository.save(Order.from(dto));
// 주문 항목들 생성 및 재고 확인
List<OrderItem> orderItems = dto.items().stream()
.map(itemDto -> {
Menu menu = menuRepository.findById(itemDto.menuId())
.orElseThrow(() -> new EntityNotFoundException("메뉴를 찾을 수 없습니다"));
// 재고 확인 및 차감
if (menu.getStock() < itemDto.quantity()) {
throw new InsufficientStockException("재고가 부족합니다");
}
menu.decreaseStock(itemDto.quantity());
return OrderItem.builder()
.order(order)
.menu(menu)
.quantity(itemDto.quantity())
.price(menu.getPrice() * itemDto.quantity())
.build();
})
.collect(Collectors.toList());
orderItemRepository.saveAll(orderItems);
// 총액 계산 및 설정
int totalPrice = orderItems.stream()
.mapToInt(OrderItem::getPrice)
.sum();
order.setTotalPrice(totalPrice);
return OrderResponseDto.from(order);
}
}
😵 뭐가 뭔지 한눈에 들어오지 않는다!
개선: 의미 있는 메서드로 분리
@Service
public class OrderService {
public OrderResponseDto createOrder(OrderCreateDto dto) {
Order order = orderRepository.save(Order.from(dto));
List<OrderItem> orderItems = createOrderItems(order, dto.items());
orderItemRepository.saveAll(orderItems);
int totalPrice = calculateTotalPrice(orderItems);
order.setTotalPrice(totalPrice);
return OrderResponseDto.from(order);
}
// 주문 항목 생성 로직 분리
private List<OrderItem> createOrderItems(Order order, List<OrderItemDto> itemDtos) {
return itemDtos.stream()
.map(itemDto -> createOrderItem(order, itemDto))
.collect(Collectors.toList());
}
// 개별 주문 항목 생성 (재고 확인 포함)
private OrderItem createOrderItem(Order order, OrderItemDto itemDto) {
Menu menu = findMenuById(itemDto.menuId());
validateAndDecreaseStock(menu, itemDto.quantity());
return OrderItem.builder()
.order(order)
.menu(menu)
.quantity(itemDto.quantity())
.price(menu.getPrice() * itemDto.quantity())
.build();
}
// 메뉴 조회
private Menu findMenuById(Long menuId) {
return menuRepository.findById(menuId)
.orElseThrow(() -> new EntityNotFoundException("메뉴를 찾을 수 없습니다: " + menuId));
}
// 재고 확인 및 차감
private void validateAndDecreaseStock(Menu menu, int quantity) {
if (menu.getStock() < quantity) {
throw new InsufficientStockException(
String.format("재고가 부족합니다. 요청: %d, 재고: %d", quantity, menu.getStock())
);
}
menu.decreaseStock(quantity);
}
// 총액 계산
private int calculateTotalPrice(List<OrderItem> orderItems) {
return orderItems.stream()
.mapToInt(OrderItem::getPrice)
.sum();
}
}
개선 효과
Before:
// 이게 뭐하는 코드지? 🤔
menu.decreaseStock(itemDto.quantity());
After:
// 아! 재고 확인하고 차감하는구나! 💡
validateAndDecreaseStock(menu, itemDto.quantity());
메서드 분리의 장점
- 가독성 향상: 메서드 이름만 봐도 뭘 하는지 알 수 있음
- 재사용성: 같은 로직을 여러 곳에서 사용 가능
- 테스트 용이: 각 메서드를 독립적으로 테스트 가능
- 유지보수: 특정 기능만 수정할 때 해당 메서드만 찾으면 됨
오늘의 핵심 정리
- @PreAuthorize: 메서드 레벨에서 권한을 간편하게 제어할 수 있다
- ResponseEntity.created(): RESTful하게 리소스 생성 응답을 만들자 (Location 헤더 포함!)
- 메서드 분리: 복잡한 로직은 의미 있는 이름의 메서드로 나눠서 가독성을 높이자
'👍코드 회고' 카테고리의 다른 글
| 장바구니 코드 속 store_id는 어디에 둘까? (10.15) (0) | 2025.10.15 |
|---|---|
| 서비스단 깔끔하게 하기 (10.02) (0) | 2025.10.03 |
| 스프링 개발 회고 (09.30) (0) | 2025.10.01 |
| 프로젝트 설계 회고 (09.29) (0) | 2025.09.29 |
| ERD 설계 회고 (09.27) (0) | 2025.09.27 |