Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
Tags
- table status
- VI
- 인프런
- 맥
- 스터디2기
- 분할정복 방법
- CS스터디
- mycli
- 알고리즘
- oh-my-zsh
- mysql 표
- 오일러 경로
- cs
- MySQL
- 오블완
- 데이크스트라
- 동적 프로그래밍 방법
- 이진탐색
- 인프런워밍업클럽
- 욕심쟁이 방법
- Pager
- spring boot
- zsh
- 순차탐색
- 티스토리챌린지
- zsh theme
- Less
- 네트워킹데이
- 터미널
- mysql 표 출력
Archives
- Today
- Total
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이 여기 들어옵니다.
- @PreAuthorize("isAuthenticated()")
@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()으로 가짜 구현을 만들고, 서비스 로직이 올바르게 예외를 발생시키는지 확인합니다.
- Mockito를 사용한 이유
더보기
@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 사용자의 권한을 체크합니다.
- @WithMockUser의 역할
더보기
@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를 호출하면 어떻게 되지?" 같은 엣지 케이스를 놓치지 않을 수 있었다.

'백엔드 > 게시판 만들기' 카테고리의 다른 글
| [Spring Boot] 게시판 - 19. 비동기 처리로 사용자 응답 속도 개선하기 (feat. K6 성능 테스트) (0) | 2025.12.30 |
|---|---|
| [Spring Boot] 게시판 - 18. 이메일 인증 구현 (0) | 2025.12.19 |
| [Spring Boot] 게시판 - 16. JWT 적용 + 스테이징 배포 트러블슈팅 기록 (1) | 2025.12.17 |
| [Spring Boot] 게시판 - 15. JWT 인증 구현하기 (Token 기반으로 전환) (0) | 2025.12.16 |
| [Spring Boot] 게시판 - 14. Spring Security 로그인 구현 (0) | 2025.12.14 |