Develop

[Spring Boot] 게시판 - 17. JWT 인증에 권한 관리 더하기 - @PreAuthorize로 메서드 레벨 보안 구현 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 17. JWT 인증에 권한 관리 더하기 - @PreAuthorize로 메서드 레벨 보안 구현

230801 2025. 12. 18. 04:27

안녕하세요 ~ .ᐟ

어제 만든 JWT 인증은 사용자가 누구인지 신원만 확인합니다.

오늘은 무엇을 할 수 있는지 제어하는 역할 기반 권한 관리(Authorization)와 리소스 소유권 검증을 추가해보겠습니다.

  • Spring Security의 @PreAuthorize를 사용해 메서드 레벨에서 권한을 검증
  • 게시글 작성자 본인만 수정/삭제할 수 있도록 소유권 검증 로직 추가

 

먼저 헷갈리기 쉬운 두 개념을 먼저 정리하고 가겠습니다.

  • 인증(Authentication) : 누구인지 → JWT 토큰으로 신원 확인
  • 인가(Authorization) : 뭘 할 수 있는지 → 역할(Role)과 소유권으로 권한 확인

 


1. Role Enum 개선

  • 기존에도 Role Enum 이 있었지만, 이번에는 각 역할을 지정해주었습니다.
    • Spring Security는 내부적으로 권한을 "ROLE_" prefix와 함께 관리합니다.
    • hasRole("USER")를 호출하면 실제로는 "ROLE_USER"를 체크합니다.
    • 이걸 미리 Enum에 담아두면 실수를 방지할 수 있습니다.
public enum Role {
    USER("ROLE_USER"),
    ADMIN("ROLE_ADMIN");

    private final String key;

    Role(String key) {
        this.key = key;
    }

    public String getKey() {
        return key;
    }
}

 

  • getKey()는 언제쓰나요?
    • CustomUserDetails 에서 사용
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return Collections.singletonList(
        new SimpleGrantedAuthority(user.getRole().getKey()) // 여기서 사용!
    );
}

 

 


 

2. @EnableMethodSecurity로 메서드 레벨 보안 활성화

  • SecurityConfig에 단 한 줄만 추가하면 됩니다.
  • 이제 컨트롤러나 서비스 메서드에 @PreAuthorize 어노테이션을 붙일 수 있습니다.
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    // 기존 설정...
}

 

 


 

3. PostController에 권한 검증 추가

  • 인증된 사용자만 게시글 작성/수정/삭제
    • @PreAuthorize("isAuthenticated()")
      : 메서드 실행 전에 인증 여부를 체크하고 응답을 반환합니다.
      • 401: JWT 토큰 없음/만료됨 (인증 실패)
      • 403: 토큰은 있지만 권한 부족 (예: USER가 ADMIN API 호출)
    • @AuthenticationPrincipal UserDetails
      : Spring Security의 SecurityContext에서 현재 로그인한 사용자 정보를 자동으로 주입받습니다. JWT 필터에서 설정한 Authentication 객체의 Principal이 여기 들어옵니다.
@PostMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiResponse<PostDetailResponse>> createPost(
    @Valid @RequestBody PostCreateRequest request,
    @AuthenticationPrincipal UserDetails userDetails
) {
    Post post = postService.create(userDetails.getUsername(), request);
    return ResponseEntity.ok(ApiResponse.success(PostDetailResponse.from(post)));
}

 

 

  • userId → email 기반 인증으로 전환
    • 기존 방법
      • 클라이언트가 userId를 직접 보내는 방식으로, 사용자가 다른 사람의 ID를 보낼 수 있어 보안상 문제가 있습니다.
    • 개선 방법
      • JWT 토큰은 서버가 서명한 것이므로 변조가 불가능하기때문에 토큰에서 추출한 email은 신뢰할 수 있습니다.
// Before: 클라이언트가 userId 전송 (보안 취약)
postService.create(request, userId);

// After: JWT에서 email 추출 (안전)
String email = userDetails.getUsername(); // JWT의 subject가 email
postService.create(email, request);


// CustomUserDetails에서 username을 email로 사용하기로 설정했기 때문
@Override
public String getUsername() {
    return user.getEmail();
}

 

 


 

4. 게시글 소유권 검증

  • 역할(Role)만으로는 부족합니다.
  • 같은 USER 역할이어도 본인이 작성한 게시글만 수정/삭제할 수 있어야 합니다.
public void update(Long postId, String email, PostUpdateRequest request) {
    Post post = postRepository.findById(postId)
        .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.POST_NOT_FOUND));
    
    // 작성자 본인 확인
    if (!post.getAuthor().getEmail().equals(email)) {
        throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_POST_ACCESS);
    }
    
    post.update(request.getTitle(), request.getContent());
}

 

 

  • 게시글 소유권을 서비스에서 검증하는 이유
    • 트랜잭션 경계 내에서 검증 + 수정을 원자적으로 처리
    • 다른 곳에서 Service를 재사용할 때도 권한 체크가 보장됨
    • 단일 책임 원칙 (Controller는 요청 매핑만, Service는 비즈니스 로직)

 

 

 

 

 


 

5. Admin 전용 API 추가

  • 관리자 전용 기능을 테스트하기 위해 간단한 엔드포인트를 만들었습니다.
@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @GetMapping("/dashboard")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ApiResponse<String>> getDashboard() {
        return ResponseEntity.ok(ApiResponse.success("관리자 전용 대시보드"));
    }
}

 

일반 USER가 이 API를 호출하면 403 Forbidden이 반환됩니다.

 

 


 

6. 테스트 작성

  • 단위 테스트 (PostServiceTest)
    • Mockito를 사용한 이유
      • 실제 DB 없이 빠르게 비즈니스 로직만 검증할 수 있습니다.
      • Repository의 동작을 given().willReturn()으로 가짜 구현을 만들고, 서비스 로직이 올바르게 예외를 발생시키는지 확인합니다.
더보기
@ExtendWith(MockitoExtension.class)
class PostServiceTest {

    @Mock
    private PostRepository postRepository;
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private PostService postService;

    @Test
    void 다른_사용자가_게시글_수정_시도하면_예외발생() {
        // given
        User author = User.builder().email("author@test.com").build();
        Post post = Post.builder().author(author).build();
        
        given(postRepository.findById(1L)).willReturn(Optional.of(post));
        
        // when & then
        assertThatThrownBy(() -> 
            postService.update(1L, "other@test.com", new PostUpdateRequest())
        ).isInstanceOf(UnauthorizedException.class);
    }
}

 

 

  • 통합 테스트 (PostControllerTest)
    • @WithMockUser의 역할
      • 실제 JWT 토큰을 만들지 않고도 인증된 사용자를 흉내낼 수 있습니다.
      • @PreAuthorize가 이 Mock 사용자의 권한을 체크합니다.
더보기
@WebMvcTest(PostController.class)
@Import(SecurityConfig.class)
class PostControllerTest {

    @MockitoBean
    private PostService postService;
    
    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "test@test.com", roles = "USER")
    void 게시글_생성_성공() throws Exception {
        // given
        Post mockPost = Post.builder()
            .id(1L)
            .title("제목")
            .content("내용")
            .build();
            
        given(postService.create(eq("test@test.com"), any()))
            .willReturn(mockPost);
        
        // when & then
        mockMvc.perform(post("/api/posts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"제목\",\"content\":\"내용\"}"))
            .andExpect(status().isOk());
    }

    @Test
    @WithAnonymousUser
    void 비인증_사용자는_게시글_작성_불가() throws Exception {
        mockMvc.perform(post("/api/posts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
            .andExpect(status().isUnauthorized());
    }
}

 


 

트러블슈팅: 테스트에서 SecurityConfig 로드하기

 

처음에는 @WebMvcTest만 사용했더니 @PreAuthorize가 작동하지 않았습니다.

 

  • 원인: @WebMvcTest는 기본적으로 컨트롤러 레이어만 로드합니다. Security 설정은 포함되지 않습니다.
  • 해결: java@Import(SecurityConfig.class) // Security 설정 수동 로드 이제 테스트에서도 @PreAuthorize가 정상 작동합니다.

 

  • 환경별 설정 분리
    • Docker 컨테이너 내부에서는 localhost가 아니라 서비스 이름(mysql)으로 접근해야 하므로,
    • 로컬 개발과 Docker 환경에서 다른 DB를 사용하도록 설정을 분리했습니다.
# application-local.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/board_db

# application-docker.yml
spring:
  datasource:
    url: jdbc:mysql://mysql:3306/board_db

 

 

 

 


 

느낀 점

  • 처음에는 "어디서 권한을 체크해야 하지?"가 헷갈렸다. 하지만 오늘 구현하면서 각 레이어의 역할이 명확해졌다.
  • 이 구조 덕분에 "누가 접근했는가"와 "무엇을 할 수 있는가"를 명확히 분리할 수 있었다.  
    • Filter 레이어 : JWT 토큰 검증 (인증)
    • Controller 레이어 : `@PreAuthorize`로 역할 기반 접근 제어
    • Service 레이어 : 비즈니스 규칙에 따른 소유권 검증
  • 기존에는 클라이언트가 보낸 `userId`를 그대로 사용했다. Postman에서 `userId: 999`로 바꿔서 보내면 다른 사람의 게시글을 수정할 수 있는점이 너무 위험했다.
    • JWT에서 추출한 `email`은 서버가 서명했기 때문에 신뢰할 수 있다.
    • "클라이언트 입력은 모두 검증해야 한다"는 원칙을 다시 한번 체감했다.
  • 테스트가 있어야 리팩토링이 무섭지 않다
    • userId → email로 전환하면서 거의 모든 API가 바뀌었다.
    • 하지만 단위 테스트와 통합 테스트가 있었기 때문에 자신 있게 변경할 수 있었다.
    • 특히 `@WithMockUser`로 권한별 테스트를 만들어두니 "일반 유저가 관리자 API를 호출하면 어떻게 되지?" 같은 엣지 케이스를 놓치지 않을 수 있었다.