Project/Boilerplate

@PreAuthorize를 활용한 댓글 수정/삭제 권한 관리 및 코드 개선

조용우 2025. 3. 10. 22:24

기존 문제점: 관리자(Admin) 권한이 있어도 댓글 수정/삭제 불가

기존 코드 분석

현재 @AuthenticationPrincipal을 이용하여 컨트롤러에서 userId를 추출하고, 이를 서비스 계층에서 권한 검증하고 있다.

@PutMapping("/{commentId}")
public ResponseEntity<CommentResponse> updateComment(
    @AuthenticationPrincipal JwtUserDetails userPrincipal,
    @PathVariable Long commentId,
    @Valid @RequestBody CommentRequest request
) {
    return ResponseEntity.ok(
        commentService.update(
            userPrincipal.getId(),
            commentId,
            request.getContent()
        )
    );
}

@DeleteMapping("/{commentId}")
public ResponseEntity<Void> deleteComment(
    @AuthenticationPrincipal JwtUserDetails userPrincipal,
    @PathVariable Long commentId
) {
    commentService.delete(userPrincipal.getId(), commentId);
    return ResponseEntity.ok().build();
}

문제점

  • Admin이 댓글을 수정/삭제할 권한이 없다고 판정됨
  • 컨트롤러에서 userId를 추출하고 서비스에서 권한을 검증하는 방식이 번거로움
  • 컨트롤러에서 인증을 하고 서비스에서 인가를 하는 방식 → 역할이 모호함

해결 방법: @PreAuthorize를 활용하여 서비스 계층에서 권한 관리

개선 방향

  • 컨트롤러에서는 @PreAuthorize를 통해 서비스 계층에서 직접 권한을 확인하도록 변경
  • Admin도 댓글 수정/삭제가 가능하도록 hasRole('ADMIN') 추가
  • 컨트롤러에서 불필요한 인증 로직을 제거하여 코드 가독성을 높임

📌 개선된 코드

1. 댓글 수정/삭제 서비스 코드

@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public CommentResponse update(Long userId, Long commentId, String newContent) {
    Comment comment = commentRepository.findByIdWithUser(commentId)
        .orElseThrow(() -> new EntityNotFoundException(PostError.COMMENT_NOT_EXIST.getMessage() + commentId));
    comment.updateContent(newContent);
    return CommentResponse.from(comment);
}

@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public void delete(Long userId, Long commentId) {
    Comment comment = commentRepository.findByIdWithUser(commentId)
        .orElseThrow(() -> new EntityNotFoundException(PostError.COMMENT_NOT_EXIST.getMessage() + commentId));
    Post post = comment.getPost();
    post.decreaseCommentCounts();
    commentRepository.deleteById(commentId);
}

변경된 사항

  • @PreAuthorize를 적용하여 권한 검사를 서비스 계층에서 수행
  • Admin 또는 댓글 작성자만 수정/삭제 가능하도록 hasRole('ADMIN') 추가
  • 컨트롤러에서 불필요한 userId 전달 제거

2. 컨트롤러 코드 (불필요한 인증 로직 제거)

@PutMapping("/{commentId}")
public ResponseEntity<CommentResponse> updateComment(
    @PathVariable Long commentId,
    @Valid @RequestBody CommentRequest request
) {
    return ResponseEntity.ok(
        commentService.update(
            SecurityUtil.getCurrentUserId(),
            commentId,
            request.getContent()
        )
    );
}

@DeleteMapping("/{commentId}")
public ResponseEntity<Void> deleteComment(
    @PathVariable Long commentId
) {
    commentService.delete(SecurityUtil.getCurrentUserId(), commentId);
    return ResponseEntity.ok().build();
}

변경된 사항

  • 컨트롤러에서 @AuthenticationPrincipal을 사용하지 않고 SecurityUtil을 활용하여 현재 로그인한 사용자 ID 추출
  • 컨트롤러는 단순한 API 요청 처리에 집중하고, 권한 검사는 서비스에서 처리

3. SecurityUtil을 활용하여 현재 로그인한 사용자 ID 가져오기

public class SecurityUtil {

    public static Long getCurrentUserId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new IllegalStateException(AuthenticationError.AUTHENTICATION_FAILURE.getMessage());
        }

        JwtUserDetails userDetails = (JwtUserDetails) authentication.getPrincipal();
        return userDetails.getId();
    }
}
  • 컨트롤러 코드에서 불필요한 인증 로직 제거
  • 현재 로그인한 사용자의 ID를 손쉽게 가져올 수 있음

4. 추가적으로 SecurityFilter 정리

기존 문제: SecurityFilter의 requestMatchers()가 계속 늘어나고 있음

//경로별 인가 작업
http
    .authorizeHttpRequests((auth) -> auth
        .requestMatchers("/", "/api/login", "/api/join", "/api/token/**", "/api/posts/list", "/api/posts/grid")
        .permitAll()
        .requestMatchers(HttpMethod.GET, "/api/posts/{code:^[0-9]*$}")
        .permitAll()
        .requestMatchers("/admin").hasRole("ADMIN")
        .anyRequest().authenticated());

 

문제점

  • 매번 새로운 엔드포인트가 추가될 때마다 requestMatchers()를 수정해야 함
  • 불필요하게 복잡한 보안 설정

✅ 해결: 메소드 단위에서 인증 및 인가 수행

기본적인 설정만 유지하고, 세부적인 권한 검사는 @PreAuthorize를 활용하여 서비스 계층에서 수행

http
    .authorizeHttpRequests((auth) -> auth
        .requestMatchers("/", "/api/login", "/api/join", "/api/token/**").permitAll() // 기본 공개 API
        .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
        .requestMatchers("/admin").hasRole("ADMIN")
        .anyRequest().authenticated());

5. 테스트 코드 개선

권한이 필요한 기능은 통합 테스트로 분리

@PreAuthorize가 적용된 서비스 메서드는 단순한 단위 테스트가 아닌 통합 테스트(@SpringBootTest)로 수행해야 한다.

권한 관련 테스트는 이후 controller 테스트에 함께 포함할 예정

 

 


추가

@PreAuthorize를 서비스 메소드에 적용한 이유

 

  • 재사용성:
    • 같은 서비스 메서드를 여러 컨트롤러에서 호출할 수 있음.
    • 컨트롤러에서 권한 검사를 하지 않아도 다른 서비스에서 호출할 때도 보안이 유지됨.
  • 비즈니스 로직과 보안 로직을 분리:
    • 컨트롤러는 HTTP 요청 처리에 집중, 서비스 계층은 비즈니스 로직을 담당.

 

References:

https://velog.io/@joon6093/SecuredPreAuthorize-PostAuthorize

https://velog.io/@shon5544/Spring-Security-4.-%EA%B6%8C%ED%95%9C-%EC%B2%98%EB%A6%AC