TL;DR

커스텀 @CurrentUser 만들려다가 Spring Security에 이미 @AuthenticationPrincipal이 있는 걸 발견. 2시간 삽질할 뻔했는데 30분만에 끝났다.


문제: 보일러플레이트 코드의 반복

모든 Controller 메서드에서 이런 코드를 반복하고 있었다:

@PostMapping("/assign")
public ResponseEntity<...> assignStudyTime(@RequestBody Request request) {
    // 매번 이 8줄을 반복...
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null || !(authentication.getPrincipal() instanceof CustomUserPrincipal)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(ResponseBase.error("인증이 필요합니다."));
    }

    CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
    Long teacherId = principal.getId();
    // ... 실제 비즈니스 로직
}

3개 Controller 메서드에서 총 29줄의 중복 코드. 더 많은 엔드포인트가 추가될수록 이 보일러플레이트는 계속 늘어난다.


첫 번째 시도: 커스텀 @CurrentUser 만들기

처음엔 커스텀 어노테이션과 ArgumentResolver를 만들 계획이었다:

// 1. 커스텀 어노테이션
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}

// 2. ArgumentResolver 구현
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(...) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !(auth.getPrincipal() instanceof CustomUserPrincipal)) {
            throw new UnauthorizedException("인증이 필요합니다.");
        }
        return auth.getPrincipal();
    }
}

// 3. WebMvcConfigurer에 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentUserArgumentResolver());
    }
}

예상 작업 시간: 2-3시간 (구현 + 테스트)


발견: Spring Security가 이미 제공하고 있었다

코드베이스를 조사하던 중 발견한 사실:

# 기존 코드에서 이미 사용 중이었다!
grep -r "@AuthenticationPrincipal" src/
// CampusEventController.java
@PostMapping
public ResponseEntity<...> createEvent(
    @RequestBody EventRequest request,
    @AuthenticationPrincipal CustomUserPrincipal principal) {  // 이미 있네?
    // ...
}

Spring Security가 이미 @AuthenticationPrincipal을 제공하고 있었고, 프로젝트 일부에서는 이미 사용 중이었다.


해결: @AuthenticationPrincipal 적용

커스텀 구현 대신 Spring 표준을 사용:

// Before: 17줄
@GetMapping("/me")
public ResponseEntity<...> getCurrentUser() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    log.debug("Authentication: {}", authentication != null ? authentication.getClass().getSimpleName() : "null");

    if (authentication == null || !(authentication.getPrincipal() instanceof CustomUserPrincipal)) {
        log.warn("Authentication failed - authentication: {}, principal type: {}",
                authentication != null ? "present" : "null",
                authentication != null ? authentication.getPrincipal().getClass().getSimpleName() : "null");
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(ResponseBase.error("인증되지 않은 사용자입니다."));
    }

    CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal();
    log.debug("User principal: ID={}, Username={}", userPrincipal.getId(), userPrincipal.getUsername());
    // ...
}

// After: 6줄
@GetMapping("/me")
public ResponseEntity<...> getCurrentUser(
        @AuthenticationPrincipal CustomUserPrincipal userPrincipal) {

    log.debug("User principal: ID={}, Username={}", userPrincipal.getId(), userPrincipal.getUsername());
    // ...
}

결과

코드 감소

  • StudyTimeController: 8줄 감소 (53%)
  • UserController.getCurrentUser(): 11줄 감소 (65%)
  • UserController.updateProfile(): 10줄 감소 (59%)
  • 총 29줄 제거

실제 작업 시간

  • 예상: 2-3시간 (커스텀 구현)
  • 실제: 30분 (기존 기능 활용)
  • 절약: 1.5-2.5시간

추가 이점

  • Spring Security가 자동으로 401 처리
  • 테스트 시 Mock 주입 용이
  • 유지보수할 커스텀 코드 없음

Spring Security의 다른 유용한 어노테이션들

이번에 알게 된 것들:

// Principal 객체 전체
@GetMapping("/profile")
public String profile(@AuthenticationPrincipal CustomUserPrincipal principal) {
    return principal.getUsername();
}

// SpEL로 특정 필드만
@GetMapping("/username")
public String username(@AuthenticationPrincipal(expression = "username") String username) {
    return username;
}

// SecurityContext 전체
@GetMapping("/context")
public String context(@CurrentSecurityContext SecurityContext context) {
    return context.getAuthentication().getName();
}

배운 점

1. 코드베이스 먼저 검색하기

새 기능을 만들기 전에 항상:

  • 기존 코드에서 비슷한 패턴 검색
  • Spring/프레임워크가 제공하는 기능 확인
  • 팀이 이미 사용 중인 방법 확인

2. 표준 > 커스텀

  • Spring 표준: 문서화 잘 되어있음, 커뮤니티 지원, 버그 수정
  • 커스텀 구현: 유지보수 부담, 테스트 필요, 온보딩 비용

3. Quick Win 찾기

복잡한 해결책보다 간단한 표준 기능이 더 나을 때가 많다.


다음 단계

프로젝트 전체에 적용:

# 아직 수동 인증 체크가 남아있는 곳 찾기
grep -r "SecurityContextHolder.getContext().getAuthentication()" src/

발견되는 모든 곳을 @AuthenticationPrincipal로 교체 예정.


교훈: 바퀴를 재발명하기 전에, 이미 있는 바퀴를 찾아보자. 🚲