
내일배움캠프 개인과제 중 기존 프로젝트를 분석하고 개선 가능성이 있는 문제를 선정하고 해결하는 내용이 있었다. 과제를 진행하면서 정리한 내용을 작성해본다.
userRole을 포함하고 있음userRole 사용하여 인증 수행ADMIN인 A 유저가 로그인USER로 변경아래에서 기존에 로그인을 해서 토큰을 가지고 있지만 중간에 권한이 박탈된 유저를 "유저 A"라고 칭하겠다.
userRole 값이 ADMIN인 유저가 Admin API 사용 시 DB에서 userRole 재차 검증여러 해결방안 중에서 3번 방법을 적용하기로 했다. 그 이유는 다음과 같다.
/admin으로 시작하는 URL로 요청이 들어온 경우, JWT 토큰의 userRole 값을 검사하여 ADMIN인지 1차 검사 수행 (JwtFilter.java에 이미 구현되어 있음)/admin/**)로 접근하는 요청들에 대해서 인터셉터를 등록해서 2차 검사AdminApiInterceptor.java
@Component
public class AdminApiInterceptor implements HandlerInterceptor {
private final UserRepository userRepository;
public AdminApiInterceptor(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Long userId = (Long) request.getAttribute("userId");
// 요청한 사용자가 관리자 권한을 가지고 있는지 체크
if (!userRepository.existsByIdAndRole(userId)) {
throw new UnauthorizedAdminAccessException();
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
WebConfig.java
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AdminApiInterceptor adminApiInterceptor;
...
// Interceptor 등록
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminApiInterceptor)
.addPathPatterns("/admin/**");
}
}
UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT COUNT(u) > 0 FROM User u WHERE u.id = :userId AND u.userRole = 'ADMIN'")
boolean existsByIdAndRole(Long userId);
}
UnauthorizedAdminAccessException.java
/**
* JwtFilter에서 토큰의 userRole을 기반으로 1차 관리자 권한을 검사하지만,
* 토큰 발급 이후 권한이 변경되었을 가능성에 대비해 DB에서 userRole을 다시 확인한다.
* DB 기준으로 관리자 권한이 없는 경우 이 예외를 발생시킨다.
*/
public class UnauthorizedAdminAccessException extends RuntimeException {
public UnauthorizedAdminAccessException() {
super("관리자 권한이 필요합니다. 로그인 후 다시 시도해주세요.");
}
}
GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
...
@ExceptionHandler(UnauthorizedAdminAccessException.class)
public ResponseEntity<Map<String, Object>> handleUnauthorizedAdminAccessException(UnauthorizedAdminAccessException ex) {
HttpStatus status = HttpStatus.FORBIDDEN;
return getErrorResponse(status, ex.getMessage());
}
public ResponseEntity<Map<String, Object>> getErrorResponse(HttpStatus status, String message) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", status.name());
errorResponse.put("code", status.value());
errorResponse.put("message", message);
return new ResponseEntity<>(errorResponse, status);
}
}
AdminApiInterceptorTest.java
@WebMvcTest(CommentAdminController.class)
@Import({AdminApiInterceptor.class, WebConfig.class})
class AdminApiInterceptorTest {
@Autowired MockMvc mockMvc;
@MockBean UserRepository userRepository;
@MockBean CommentAdminService commentAdminService;
@DisplayName("JWT 토큰 userRole=ADMIN 이지만 실제 DB userRole=USER 인 경우")
@Test
void userRole이_USER인_유저가_Admin_API에_접근하면_Forbidden_예외발생() throws Exception {
// Given
long commentId = 1L;
long userId = 1L;
given(userRepository.existsByIdAndRole(userId)).willReturn(false);
// When & Then
mockMvc.perform(delete("/admin/comments/{commentId}", commentId)
.requestAttr("userId", userId))
.andExpect(status().isForbidden())
.andExpect(result ->
assertThat(result.getResolvedException() instanceof UnauthorizedAdminAccessException).isTrue());
}
@DisplayName("JWT 토큰과 DB 모두 userRole=ADMIN 인 경우")
@Test
void userRole이_ADMIN인_유저는_Admin_API에_정상_접근할_수_있다() throws Exception {
// Given
long commentId = 1L;
long userId = 1L;
given(userRepository.existsByIdAndRole(userId)).willReturn(true);
// When & Then
mockMvc.perform(delete("/admin/comments/{commentId}", commentId)
.requestAttr("userId", userId))
.andExpect(status().isOk());
verify(commentAdminService).deleteComment(eq(commentId));
}
}
JWT 기반 인증은 빠르고 stateless하다는 장점이 있지만, 발급 후 사용자 상태 변경(특히 권한 변경)이 반영되지 않는 구조적 한계가 있다. 이로 인해 토큰 발급 이후 권한이 변경된 유저가 여전히 관리자 API에 접근할 수 있다는 보안상 허점이 발생할 수 있었다.
이를 해결하기 위해 요청마다 DB를 통해 권한을 재검증하는 방식을 도입했으며, 이는 성능 저하라는 트레이드 오프를 감수한 선택이었다. 하지만 관리자 권한은 보안상 민감한 영역이기 때문에, 성능보다 안정성을 우선시하는 방향이 더 타당하다고 판단했다.
또한, 관리자 전용 API 전반에 중복 없이 권한 검증을 적용하기 위해 HandlerInterceptor를 활용했으며, 이를 통해 코드의 일관성과 유지보수성 또한 함께 확보할 수 있었다.
| 항목 | 적용 전 | 적용 후 | 트레이드 오프 |
|---|---|---|---|
| 권한 검증 방식 | 토큰 기반 1차 검증만 수행 | 토큰 + DB 기반 2중 검증 | 보안성 ↑, 성능 ↓ |
| 권한 변경 반영 시점 | 다음 로그인 시 반영 | 실시간 반영 가능 | 응답 속도 ↓ |
| 예외 처리 | 미정의 (보안 허점 존재) | UnauthorizedAdminAccessException + 403 Forbidden | - |