Spring 7기 프로젝트/일정관리 프로젝트

Spring ArgumentResolver를 활용하여 컨트롤러 코드 줄여보기

JuNo_12 2025. 5. 22. 16:34

오늘은 스프링 부트(Spring Boot) 애플리케이션에서 반복적인 컨트롤러 코드를 줄이고, 

가독성을 높이는 데 아주 유용한 HandlerMethodArgumentResolver(이하 ArgumentResolver)에 대해 이야기해보려고 합니다. 

특히 로그인한 사용자 정보를 컨트롤러 메소드 파라미터로 깔끔하게 받아오는 @LoginUser 어노테이션을 직접 만들어 적용한 과정을 공유해보겠습니다.

 

우리가 마주했던 문제점: 컨트롤러의 반복적인 코드

API를 개발하다 보면, 많은 엔드포인트에서 현재 로그인한 사용자의 정보를 필요로 합니다. 

예를 들어, 특정 사용자의 게시글을 작성하거나, 프로필 정보를 수정하는 경우죠. 

기존에는 주로 다음과 같이 HttpServletRequest에서 세션을 직접 가져와 사용자 정보를 꺼내는 방식을 사용했습니다.

// 기존 컨트롤러 방식 (예시)
@RestController
@RequiredArgsConstructor
public class ScheduleController {

    private final ScheduleService scheduleService;

    @PostMapping("/schedules")
    public ResponseEntity<ScheduleCreationResponseDto> createSchedule(
            HttpServletRequest request, // HttpServletRequest 직접 사용
            @Valid @RequestBody ScheduleCreationRequestDto requestDto
    ) {
        HttpSession session = request.getSession(false); // 세션 가져오기
        if (session == null || session.getAttribute("userId") == null) { // 인증 체크 반복
            // 인증되지 않은 사용자 처리 (예: 예외 발생 또는 에러 응답)
            throw new UnauthorizedException("로그인이 필요합니다.");
        }
        Long userId = (Long) session.getAttribute("userId"); // 타입 캐스팅

        ScheduleCreationResponseDto response = scheduleService.createSchedule(userId, requestDto.getTitle(), requestDto.getContents());
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    // ... 다른 메소드에서도 유사한 코드 반복 ...
}

 

이런 방식은 몇 가지 아쉬운 점이 있습니다:

1.코드 중복: 인증된 사용자 ID를 가져오는 로직이 여러 컨트롤러와 메소드에 걸쳐 반복됩니다.

2.가독성 저하: 컨트롤러 메소드의 주된 관심사(비즈니스 로직 호출) 외에 부가적인 코드가 많아집니다.

이런 문제점을 해결하기 위해 스프링 MVC가 제공하는 강력한 확장 포인트인 ArgumentResolver를 사용해보기로 했습니다.

 

HandlerMethodArgumentResolver란?

HandlerMethodArgumentResolver는 특정 조건을 만족하는 컨트롤러 메소드의 파라미터에 대해, 

개발자가 원하는 방식으로 값을 해석하고 주입해주는 인터페이스입니다. 

즉, 우리가 직접 "이런 파라미터는 이렇게 값을 만들어 넣어줘!"라고 정의할 수 있는 것이죠.

// ArgumentResolver 적용 후 컨트롤러 (예시)
@RestController
@RequiredArgsConstructor
public class ScheduleController {

    private final ScheduleService scheduleService;

    @PostMapping("/schedules")
    public ResponseEntity<ScheduleCreationResponseDto> createSchedule(
            @LoginUser Long userId, 
            @Valid @RequestBody ScheduleCreationRequestDto requestDto
    ) {
        // HttpServletRequest나 세션 관련 코드 없이 바로 userId 사용!
        ScheduleCreationResponseDto response = scheduleService.createSchedule(userId, requestDto.getTitle(), requestDto.getContents());
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

 

 

나만의 @LoginUser ArgumentResolver 만들기

이제 실제로 로그인한 사용자의 ID를 주입해주는 @LoginUser 어노테이션과 이를 처리하는 ArgumentResolver를 만들어 보겠습니다.

 

1단계:

커스텀 어노테이션 @LoginUser 정의하기먼저, 어떤 파라미터에 사용자 ID를 주입할지 표시하기 위한 어노테이션을 만듭니다.

@Target(ElementType.PARAMETER) // 파라미터에만 사용 가능하도록 지정
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션 정보 유지
public @interface LoginUser {
}

 

  • @Target(ElementType.PARAMETER): 이 어노테이션은 메소드의 파라미터에만 붙일 수 있다는 의미입니다.
  • @Retention(RetentionPolicy.RUNTIME): 이 어노테이션 정보가 런타임 시점까지 유지되어 리플렉션을 통해 조회할 수 있도록 합니다.

2단계: 

LoginUserArgumentResolver 구현하기이제 @LoginUser 어노테이션이 붙은 파라미터를 만나면 세션에서 사용자 ID를 가져와 주입하는 ArgumentResolver를 구현합니다.

@Component // 스프링 빈으로 등록
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    // 세션에 저장된 사용자 ID 속성의 이름 (AuthController와 일치해야 함!)
    public static final String USER_ID_SESSION_ATTRIBUTE_NAME = "userId";

    /**
     * 이 ArgumentResolver가 특정 파라미터를 지원하는지 여부를 반환합니다.
     * @param parameter 검사할 메소드 파라미터
     * @return 지원하면 true, 그렇지 않으면 false
     */
    @Override
    public boolean supportsParameter(@NonNull MethodParameter parameter) {
        // 파라미터에 @LoginUser 어노테이션이 붙어 있고,
        // 파라미터의 타입이 Long 클래스(또는 Long 기본 타입)인지 확인
        return parameter.hasParameterAnnotation(LoginUser.class) &&
               Long.class.isAssignableFrom(parameter.getParameterType());
    }

    /**
     * 실제 파라미터 값을 해석(resolve)하여 반환합니다.
     * supportsParameter가 true를 반환한 파라미터에 대해서만 호출됩니다.
     * @return 해석된 파라미터 값 (여기서는 Long 타입의 userId)
     */
    @Override
    public Object resolveArgument(
            @NonNull MethodParameter parameter,
            ModelAndViewContainer mavContainer, // 현재 예제에서는 사용 안 함
            @NonNull NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory) throws Exception { // 현재 예제에서는 사용 안 함

        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false); // 현재 세션이 없으면 null 반환

        if (session == null) {
            // 세션 자체가 없는 경우 (로그인하지 않았거나 세션 만료)
            // GlobalExceptionHandler에서 처리할 수 있도록 적절한 예외를 던집니다.
            throw new UnauthorizedException("User not authenticated. Session not found.");
        }

        Object userIdAttribute = session.getAttribute(USER_ID_SESSION_ATTRIBUTE_NAME);

        if (userIdAttribute == null) {
            // 세션은 있지만, 사용자 ID 속성이 없는 경우
            throw new UnauthorizedException("User not authenticated. User ID not found in session.");
        }


        return userIdAttribute; // 사용자 ID 반환
    }
}

 

3단계: 

WebMvcConfigurer에 ArgumentResolver 등록하기마지막으로, 우리가 만든 LoginUserArgumentResolver를 스프링 MVC가 인식하고 사용할 수 있도록 설정 파일에 등록해야 합니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver; // 주입 받음

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver); // 리스트에 커스텀 리졸버 추가
    }
}
  • WebMvcConfigurer 인터페이스를 구현하고 addArgumentResolvers 메소드를 오버라이드합니다.
  • 인자로 받은 resolvers 리스트에 우리가 만든 loginUserArgumentResolver 빈을 추가해주면 끝!

 

컨트롤러에서 @LoginUser 사용하기

이제 모든 준비가 끝났습니다! 컨트롤러에서 다음과 같이 @LoginUser 어노테이션을 사용하여 로그인한 사용자의 ID를 간편하게 받아올 수 있습니다.

// ScheduleController.java (일정 생성 예시)
@PostMapping
public ResponseEntity<ScheduleCreationResponseDto> createSchedule(
        @LoginUser Long userId, // 세션에서 가져온 사용자 ID가 여기에 주입됩니다!
        @Valid @RequestBody ScheduleCreationRequestDto requestDto
) {
    ScheduleCreationResponseDto response = scheduleService.createSchedule(
            userId,
            requestDto.getTitle(),
            requestDto.getContents()
    );
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

// CommentController.java (댓글 생성 예시)
@PostMapping
public ResponseEntity<CommentCreationResponseDto> createComment(
        @PathVariable Long scheduleId,
        @Valid @RequestBody CommentCreationRequestDto requestDto,
        @LoginUser Long userId // 마찬가지로 사용자 ID 주입!
) {
    CommentCreationResponseDto response = commentService.createComment(
            userId,
            scheduleId,
            requestDto.getContent()
    );
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

 

 

추가 팁 및 고려 사항

1.필터(Filter)와의 역할 분담: 

ArgumentResolver는 이미 인증된 사용자의 정보를 '편리하게 가져오는' 데 초점을 맞춥니다. 

실제 요청에 대한 **인증 체크(로그인 여부 확인)**는 여전히 서블릿 필터(예: LoginCheckFilter)의 역할입니다. 

필터가 먼저 요청을 가로채서 인증되지 않은 접근을 차단하고,

 필터를 통과한 요청에 대해서만 ArgumentResolver가 동작하여 사용자 정보를 주입하는 흐름이 일반적입니다. 

제 프로젝트의 LoginCheckFilter.java에서는 화이트리스트에 없는 경로에 대해 

세션 유무 및 userId 존재 여부를 확인하여, 없으면 401 응답을 보내고 있습니다.

 

2.세션 속성 이름의 일관성: 

다시 한번 강조하지만, LoginUserArgumentResolver의 USER_ID_SESSION_ATTRIBUTE_NAME과 

실제 로그인 처리 시 세션에 setAttribute하는 키 값은 반드시 일치해야 합니다. (제 경우 "userId"로 통일했습니다.)

 

3.예외 처리의 중요성: 

resolveArgument 메소드 내에서 세션이 없거나, 세션에 기대하는 속성이 없는 경우, 

명확한 예외(예: UnauthorizedException)를 발생시키는 것이 좋습니다. 

이 예외는 @ControllerAdvice와 @ExceptionHandler를 사용한 GlobalExceptionHandler에서 

공통으로 처리하여 클라이언트에게 일관된 오류 응답(예: JSON 형태의 401 에러)을 제공할 수 있습니다.

 

4.테스트 용이성 향상: 

컨트롤러 단위 테스트 시, 더 이상 HttpServletRequest나 HttpSession 객체를 모킹할 필요 없이, 

@LoginUser 파라미터에 직접 테스트용 userId 값을 전달하여 테스트를 수행할 수 있게 됩니다.

 

 

마무리

HandlerMethodArgumentResolver는 스프링 MVC의 유연성을 보여주는 좋은 예시입니다.

이를 활용하면 반복적인 코드를 효과적으로 줄이고, 컨트롤러를 더 깔끔하게 유지하며,

애플리케이션의 유지보수성을 높일 수 있습니다. 다들 프로젝트에 적용해보시는 것도 좋을거같습니다!