스프링 개발 회고 (10.01)
2025. 10. 1. 23:11

오늘은 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() {
        // 메뉴 목록 조회
    }
}

주요 어노테이션

  1. @PreAuthorize("hasRole('ROLE_NAME')")
    • 단일 권한 체크
    @PreAuthorize("hasRole('ADMIN')")
    public void adminOnlyMethod() { }
    
  2. @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();
    }
}

왜 좋을까?

  1. 가독성: 메서드 위에 어노테이션만 붙이면 되니 직관적
  2. 재사용성: 같은 권한 로직을 여러 곳에서 쉽게 적용
  3. 보안: 컨트롤러 레벨보다 세밀한 제어 가능

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 표준에 따르면, 리소스를 생성했을 때 응답에는:

  1. 201 Created 상태 코드
  2. 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());

메서드 분리의 장점

  1. 가독성 향상: 메서드 이름만 봐도 뭘 하는지 알 수 있음
  2. 재사용성: 같은 로직을 여러 곳에서 사용 가능
  3. 테스트 용이: 각 메서드를 독립적으로 테스트 가능
  4. 유지보수: 특정 기능만 수정할 때 해당 메서드만 찾으면 됨

오늘의 핵심 정리

  1. @PreAuthorize: 메서드 레벨에서 권한을 간편하게 제어할 수 있다
  2. ResponseEntity.created(): RESTful하게 리소스 생성 응답을 만들자 (Location 헤더 포함!)
  3. 메서드 분리: 복잡한 로직은 의미 있는 이름의 메서드로 나눠서 가독성을 높이자