
세션저장소 기반 로그인을 구현하면서 이후에 만들 API에 공통적으로 사용할 '인증(Authentication) 및 인가(Authorization)' 처리에 대해 고민하고 구현한 내용을 작성해 보려고 한다. ‘바로’에서 JWT가 아닌 세션저장소 방식을 선택한 이유에는 아래에서 확인할 수 있다
https://chobo-backend.tistory.com/50
[바로] JWT는 정말 괜찮은 방법일까? (Ft.세션 선택의 이유)
클라우드 네이티브와 마이크로서비스 아키텍처(MSA) 시대에 'Stateless(무상태)'하다는 것은 꽤 중요한 특징이 되었다. 그리고 인증 과정에서 무상태를 위한 방법으로 JWT(JSON Web Token)가 자리 잡고 있
chobo-backend.tistory.com
Part1. 문제 상황 : 반복되는 검증 코드
대부분의 API 엔드포인트는 "누가" 요청했는지 알아야 하고, 때로는 "그럴 권한이 있는지" 검사해야 한다. 가장 원시적인 코드는 아마 이런 모습일 거다
@RestController
class MyController(private val myService: MyService) {
@PostMapping("/api/v1/articles")
fun createArticle(request: HttpServletRequest, @RequestBody createRequest: ArticleCreateRequest): ResponseEntity<Void> {
// 1. 반복되는 세션 검증
val session = request.getSession(false)
if (session == null) {
throw UnauthorizedException("로그인이 필요합니다.")
}
val userId = session.getAttribute("USER_ID") as? Long
?: throw UnauthorizedException("세션 정보가 올바르지 않습니다.")
// 2. 권한 검사까지 추가 (관심사의 혼합)
val userRole = session.getAttribute("USER_ROLE") as? UserRole
?: throw UnauthorizedException("세션 정보가 올바르지 않습니다.")
if (userRole != UserRole.ADMIN) {
throw ForbiddenException("관리자만 글을 작성할 수 있습니다.")
}
// 3. 비즈니스 로직 호출
myService.createArticleFor(userId, createRequest)
return ResponseEntity.status(HttpStatus.CREATED).build()
}
}
이 코드의 문제점은 다음과 같다
- 엄청난 중복 (Non-DRY) : 모든 컨트롤러 메서드마다 세션을 확인하고, ID를 꺼내고, 예외를 던지는 코드가 반복된다
- 관심사의 분리(SoC) 원칙 위배 : 컨트롤러는 HTTP 요청을 받아 비즈니스 로직으로 연결하는 역할에 집중해야 한다. 인증/인가라는 횡단 관심사(Cross-cutting Concern)가 비즈니스 로직의 흐름을 어지럽힌다
- 테스트의 어려움 : 컨트롤러를 단위 테스트하려면
HttpServletRequest와HttpSession을 Mocking 해야 하는 번거로움이 생긴다
이런 코드는 애플리케이션이 작을 땐 괜찮을지 몰라도, 기능이 수십, 수백 개로 늘어나는 순간 유지보수의 지옥을 열게 될 것이다..
Part2. 여러 방법들 : 필터, 인터셉터, 그리고 AOP
스프링 MVC는 이런 횡단 관심사를 처리할 수 있는 여러 방식을 제공한다. 다음의 4가지 방식을 비교하며 최선의 방법이 뭔지 고민해보았다
1) 서블릿 필터 (Servlet Filter)
동작 시점 : DispatcherServlet에 도달하기 전, 서블릿 컨테이너 레벨에서 요청을 가장 먼저 가로챈다
장점 : 프레임워크에 독립적이며, 모든 요청에 대해 일괄 처리가 가능하다
단점
- 정보 전달의 한계 : 인증된 사용자 정보를 컨트롤러에 전달하려면
request.setAttribute("USER_ID", userId)와 같이 원시적인 방법을 써야 한다. 컨트롤러에서는 이 attribute를 다시 꺼내 캐스팅해야 하므로, 타입 안전성과 보일러플레이트 문제는 전혀 해결되지 않는다 - 스프링 컨텍스트 외부 : 필터에서 발생한 예외는 기본적으로
@ControllerAdvice같은 스프링의 예외 처리 메커니즘에 잡히지 않는다. 별도의 예외 처리 필터를 두는 등 처리가 복잡해진다
결론 : 글로벌 로깅이나 인코딩 설정 등, 스프링과 무관한 저수준 처리에 적합하지만 인증 정보를 비즈니스 레이어로 전달하는 역할로는 부적합하다
2) 스프링 인터셉터 (HandlerInterceptor)
동작 시점 : DispatcherServlet이 핸들러(컨트롤러 메서드)를 찾아 실행하기 직전(preHandle)과 직후(postHandle)에 개입한다
장점 : 스프링 컨텍스트 내에서 동작하므로 스프링 빈을 주입받을 수 있고, @ControllerAdvice 예외 처리도 연동된다. 어떤 핸들러가 호출되는지에 대한 정보(HandlerMethod)에도 접근할 수 있다
단점
- 파라미터 주입 불가 : 인터셉터는 컨트롤러 메서드의 '파라미터'를 직접 만들어 넣어줄 수 없다. 필터와 마찬가지로
request.setAttribute를 쓰거나ThreadLocal을 이용해야 한다. 다만ThreadLocal의 경우에는 코루틴 같은 비동기를 도입한다면 정상적으로 처리되기 힘들다
결론 : 특정 URL 패턴에 대한 전처리나 후처리에 좋지만, '컨트롤러 파라미터'를 동적으로 채우는 데에는 한계가 있다
3) AOP (@Aspect)
동작 시점 : Pointcut으로 지정된 메서드(e.g., 서비스, 컨트롤러 메서드)의 실행 전/후/주변(@Around)에 개입한다
장점 : @CheckAuth 같은 커스텀 애노테이션을 만들어 선언적으로 인증/인가 로직을 적용할 수 있다
단점
- 파라미터 주입 문제 : 인터셉터와 마찬가지로, AOP 어드바이스가 컨트롤러 메서드의 파라미터(
@CurrentUser userId: Long)를 직접 주입해 줄 표준적인 방법은 없다 - 트랜잭션 문제 : 만약 AOP 어드바이스 내부에서 DB 조회가 필요하다면(e.g., 권한 정보를 DB에서 조회), 트랜잭션 경계 문제가 발생한다
- 예시 : AOP가
@Transactional이 없는 상태에서@Transactional이 걸린 서비스 메서드를 호출하면, 서비스 메서드 호출 시점에만 트랜잭션이 열리고 닫힌다. AOP 어드바이스로 돌아온 엔티티는 준영속(detached) 상태가 되어 Lazy Loading 등이 불가능해진다 - 해결책? : AOP 어드바이스 자체에
@Transactional을 걸면 되지만, 이렇게 되면 트랜잭션의 범위가 AOP -> 비즈니스 로직 전체로 넓어져 '트랜잭션 체류 시간 증가', '예외 처리 경계 모호' 등 또 다른 문제를 발생시킨다
- 예시 : AOP가
결론 : 선언적 '검사' 로직에는 탁월하지만, '데이터 주입'과 '트랜잭션 관리' 측면에서 신중한 설계가 필요하다
4) HandlerMethodArgumentResolver
동작 시점 : HandlerAdapter가 컨트롤러 메서드를 호출하기 직전, 각 파라미터의 값을 결정하기 위해 사용된다
장점
- 타입-세이프 파라미터 주입 :
@CurrentUser userId: Long처럼 특정 애노테이션과 타입을 가진 파라미터를 만나면, 개발자가 정의한 로직에 따라 값을 생성하여 '직접' 주입해 준다 - 컨트롤러의 완벽한 분리 : 컨트롤러는 그저 선언된 파라미터를 받기만 하면 되며, 세션 접근 코드가 완벽히 사라진다
- 재사용성 및 가독성:
@CurrentUser애노테이션 하나로 인증된 사용자 ID를 얻는다는 의도가 명확히 드러난다
단점
- 오직 파라미터 해결에만 집중 : 이름 그대로 'Argument Resolver'이므로, 권한 검사처럼 메서드 실행 자체를 막는 로직을 넣기엔 부적합하다.
resolveArgument()에서 예외를 던져 흐름을 중단시킬 수는 있지만, 이는 본래 목적과 약간 다르며 책임 범위를 넘는 로직이 된다
Part3. 최종 선택 : AOP와 Argument Resolver 조합
1. 인증 정보 '주입'은 HandlerMethodArgumentResolver에게
- 컨트롤러가 "현재 로그인한 사용자가 누구인가?"를 알아야 할 때,
@CurrentUser애노테이션을 사용한다 CurrentUserArgumentResolver는 이 애노테이션을 감지하여 세션에서 사용자 ID를 꺼내Long타입으로 안전하게 주입한다. 컨트롤러는 더 이상HttpServletRequest에 의존하지 않는다
2. ‘인증’, '인가' 로직은 AOP에게
- "이 사용자가 이 작업을 수행할 권한이 있는가?"를 검사해야 할 때,
@CheckAuth(UserRole.BUYER)애노테이션을 사용한다 CheckAuthAspect는 이 애노테이션을 감지하여 메서드 실행 전에 세션이 있는지 체크하고, 권한을 검사하여 권한이 없으면ForbiddenException을 던져 실행을 중단시킨다
Appendix : DB 조회 vs 세션 Trade-Off
권한 검사를 위해 매번 DB에서 사용자 정보를 조회하는 것은 확실한 방법이지만, 모든 요청마다 DB I/O가 발생하는 것은 부하를 발생한다고 생각했다. 그래서 User 객체 자체를 넘겨줄까도 생각했지만 모든 API에 User의 모든 정보를 넘겨주는 것은 어색하다고 느꼈고, 비즈니스 로직에서 User를 사용하는 경우 영속성 관리를 위해 @Transactional 범위를 늘리거나 한번 더 조회해야한다. 따라서 UserRole을 세션에 담아 DB 조회를 줄이는 방식과 DB 조회 방식 중에 고민했다
- DB 조회 : 데이터 정합성은 100% 보장되지만 성능 저하가 우려된다
- 세션 활용 : 성능은 극대화되지만, DB에서 사용자 역할이 변경되어도 세션이 만료되기 전까지는 이전 역할로 검사되는 데이터 불일치 문제가 발생할 수 있다. 또한 세션저장소(Redis)의 메모리 사용량을 늘린다
결과적으로는 세션을 선택했다. 그 이유로는 기획상 '바로' 서비스에서 사용자 역할은 변경은 거의 없다고 판단했고, 역할 변경 시 해당 사용자의 세션을 강제 만료시키는 로직을 추가하면 정합성 문제도 해결할 수 있기 때문이다. 메모리 사용량 관련해서는 일단 세션은 TTL 설정을 통해 영구적으로 축적지 않는다. 또한 권한 같은(ADMIN, USER, BUYER) 문자열의 데이터 타입 크기는 몇 바이트에 불과하며 이러한 경우 Redis는 내부적으로 ziplist 과 같은 자료구조를 통해 메모리를 압축관리하기에 크게 부담되지 않는다
Part4. 구현하기(Kotlin)
1. 애노테이션 : @CheckAuth & @CurrentUser
가장 먼저, 두 개의 커스텀 애노테이션을 정의한다
@CheckAuth
/**
* 메서드 또는 클래스 레벨에 적용하여 인증 및 인가(권한) 검사를 수행하도록 지시한다
* @property roles 필요한 역할(들). 비어 있으면 인증 여부만 확인한다
*/
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckAuth(
vararg val roles: UserRole = []
)
@Target: 이 애노테이션을 클래스와 함수에 모두 붙일 수 있도록 허용한다. 컨트롤러 클래스 전체에 공통 권한을 적용하거나, 특정 메서드에만 다른 권한을 적용할 수 있는 유연성을 제공한다@Retention(AnnotationRetention.RUNTIME): 이 애노테이션 정보가 런타임까지 살아있도록 한다. AOP가 런타임에 리플렉션을 통해 이 애노테이션을 찾을 수 있다roles프로퍼티 :[ADMIN, MANAGER]와 같이 필요한 권한 목록을 지정할 수 있다. 기본값은 빈 배열로, 이 경우 '로그인 여부'만 확인하게 된다
@CurrentUser
/**
* 컨트롤러 메서드의 파라미터에 적용하여, 현재 인증된 사용자의 ID를 주입하도록 한다
*/
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class CurrentUser
@Target(AnnotationTarget.VALUE_PARAMETER): 이 애노테이션은 오직 메서드의 파라미터에만 붙일 수 있다
2. 세션 관리자 : SessionManager
@Component
class SessionManager(
private val request: HttpServletRequest
) {
fun getCurrentUserId(): Long =
request.getSession(false)
?.getAttribute(SessionKeys.USER_ID) as? Long
?: throw UnauthorizedException(ErrorMessage.UNAUTHORIZED.message)
fun getCurrentUserRole(): UserRole =
request.getSession(false)
?.getAttribute(SessionKeys.USER_ROLE) as? UserRole
?: throw UnauthorizedException(ErrorMessage.UNAUTHORIZED.message)
}
1. request.getSession(false) : true를 인자로 넘기면 세션이 없을 때 새로운 세션을 생성해 버린다. 현재 '존재하는 세션을 확인'하는 것이 목적이므로, 세션이 없으면 null을 반환하는 false 옵션을 사용해야 한다
2. 안전한 캐스팅 as?와 엘비스 연산자 ?: : getAttribute()는 Any? 타입을 반환한다. as? Long은 캐스팅에 실패하면 ClassCastException을 던지는 대신 null을 반환한다. 이어서 엘비스 연산자 ?:를 사용해, 결과가 null일 경우(세션이 없거나, attribute가 없거나, 타입이 맞지 않는 모든 경우) UnauthorizedException을 던진다
3. 파라미터 주입 : CurrentUserArgumentResolver
@Component
class CurrentUserArgumentResolver(
private val sessionManager: SessionManager
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean =
parameter.getParameterAnnotation(CurrentUser::class.java) != null
&& parameter.parameterType == Long::class.java
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any {
return sessionManager.getCurrentMemberId()
}
}
Spring MVC는 HTTP 요청을 처리할 때, RequestMappingHandlerAdapter를 통해 컨트롤러 메서드를 호출한다. 이때 HandlerAdapter는 메서드 호출에 필요한 인자(argument)들을 준비하기 위해 자신이 가진 HandlerMethodArgumentResolver 목록을 순회한다
1. supportsParameter(parameter) : HandlerAdapter는 컨트롤러 메서드의 각 파라미터 정보를 MethodParameter 객체에 담아 모든 Argument Resolver의 supportsParameter를 호출한다. 이 메서드가 true를 반환하는 첫 번째 리졸버를 선택한다
parameter.getParameterAnnotation(CurrentUser::class.java) != null: 파라미터에@CurrentUser애노테이션이 붙어있는지 확인한다::class.java의 존재 이유 : 코틀린의KClass(e.g.,Long::class)와 자바의Class(e.g.,Long.class)는 다른 타입이다. 스프링의 리플렉션 API는 자바Class객체를 인자로 받기 때문에, 코틀린KClass객체를.java프로퍼티를 통해 자바Class객체로 변환해줘야 한다
2. resolveArgument(...) : supportsParameter가 true를 반환했을 때만 호출된다. 이 메서드의 반환 값이 컨트롤러 파라미터에 최종적으로 주입된다. 여기서 sessionManager를 통해 사용자 ID를 가져와 반환한다. sessionManager 내부에서 인증 실패 시 예외가 발생하므로, 이 메서드에 도달했다면 ID는 항상 유효하다고 신뢰할 수 있다
4. 인증, 인가 : AuthenticationAspect
@Aspect
@Component
class AuthenticationAspect(
private val sessionManager: SessionManager
) {
@Around("@within(auth)")
fun checkOnClass(pjp: ProceedingJoinPoint, auth: CheckAuth) = doCheck(pjp, auth)
@Around("@annotation(auth)")
fun checkOnMethod(pjp: ProceedingJoinPoint, auth: CheckAuth) = doCheck(pjp, auth)
private fun doCheck(pjp: ProceedingJoinPoint, auth: CheckAuth): Any? {
// 1. 인증 검사 (로그인 여부)
sessionManager.getCurrentUserId()
// 2. 인가 검사 (권한 확인)
if (auth.roles.isNotEmpty()) {
val role = sessionManager.getCurrentUserRole()
if (role != UserRole.ADMIN && role !in auth.roles) {
throw ForbiddenException(ErrorMessage.FORBIDDEN.message)
}
}
// 3. 모든 검사 통과 시, 원본 메서드 실행
return pjp.proceed()
}
}
1. @Around("@within(auth)") & @Around("@annotation(auth)") : Pointcut 표현식이다
@within(auth):@CheckAuth애노테이션이 붙은 클래스 내의 모든 public 메서드를 대상으로 한다@annotation(auth):@CheckAuth애노테이션이 직접 붙은 메서드를 대상으로 한다auth: CheckAuth: Pointcut에 매칭된 애노테이션 인스턴스를auth파라미터로 바인딩합니다. 이를 통해@CheckAuth(roles = ...)에 지정된roles배열 값에 접근할 수 있다
2. doCheck 로직
- 먼저
sessionManager.getCurrentMemberId()를 호출한다. 이 메서드는 로그인되어 있지 않으면 예외를 던지므로, 그 자체로 '인증' 검사의 역할을 수행한다 auth.roles가 비어있지 않으면, 즉 권한 검사가 필요하면sessionManager에서 현재 사용자의 역할을 가져와auth.roles에 포함되어 있는지 확인한다. 없으면ForbiddenException을 던진다- 모든 검사를 통과해야만
joinPoint.proceed()를 통해 원래의 컨트롤러(또는 서비스) 메서드가 실행된다
3. 반환 타입 Any?: @Around 어드바이스는 원본 메서드의 실행을 가로채므로, 원본 메서드가 어떤 타입을 반환하든(객체, Unit, null) 그대로 반환해 주어야 한다. 코틀린의 Any?는 자바의 Object와 같으며 null을 허용하는 최상위 타입이므로, 모든 경우를 안전하게 처리할 수 있다
Part 5. 결과적으로 깔끔해진다
@RestController
@RequestMapping("/api/v1/articles")
class ArticleController(private val articleService: ArticleService) {
@GetMapping("/{articleId}")
@CheckAuth(UserRole.ADMIN)// 로그인한 사용자만 접근 가능
fun getArticle(
@PathVariable articleId: Long,
@CurrentUser userId: Long // ArgumentResolver가 세션에서 ID를 자동으로 주입
): ArticleResponse {
// 인증/인가 코드는 단 한 줄도 없다. 오직 비즈니스 로직에만 집중.
return articleService.findArticleBy(articleId, userId)
}
}
- 완벽한 관심사 분리 : 컨트롤러는 HTTP 요청과 응답, 그리고 비즈니스 로직 호출에만 집중한다. 인증/인가는 애노테이션으로 선언될 뿐, 구현은 완전히 분리되었다
- 높은 가독성과 재사용성 :
@CurrentUser와@CheckAuth애노테이션만 봐도 해당 엔드포인트의 보안 요구사항을 즉시 파악할 수 있다 - 유지보수 및 확장 용이성 : 인증 방식이 세션에서 JWT로 바뀌더라도
SessionManager의 내부 구현만 수정하면 된다. AOP, Argument Resolver, 컨트롤러 코드는 전혀 변경할 필요가 없다 - 테스트 용이성 : 컨트롤러 단위 테스트 시, 더 이상
HttpServletRequest를 Mocking 할 필요 없이,userId파라미터에 원하는 값을 직접 넘겨주기만 하면 된다
P.S 좋은 설계에는 추상화와 깊은 이해가 필요하다
반복되는 코드, 내부 설계(여러 추상화 수준이 혼재)등등 읽는데 방해가 되는 코드가 있는 경우 이게 '더러운 코드'라는 인식을 가질 수 있는 인사이트를 가지고 있어야 흔히 말하는 클린코드를 짤 수 있을 것이다. 인식을 하지 못한다면 개선 의지 조차 가지지 못할 것이고, 이는 추후에 코드를 이해하는데 오랜 시간을 걸리게 하고 유지 보수하는데에도 큰 어려움을 줄 것이다. 이번에 AOP를 적용해서 문제를 해결했지만 단순히 이걸로 끝이 아니라, 내부적으로 어떤 동작에 의해서 적용이 되는지 이해하고 있어야 갑작스럽게 만나는 버그들을 처리할 수 있다.
예를들어 Spring AOP와 AspectJ의 차이를 알아야 하고, Spring AOP는 스프링 빈과 메서드에만 적용 가능했던 이유가 동적 프록시(JDK 동적 프록시 + CGLIB)를 기반으로 런타임 위빙을 사용했기 때문이라는 것을 알아야한다. 그래야 더 넓은 범위에 AOP를 적용하고 싶을 때 AspectJ 방식을 여러 후보 중 하나로 추가할 수 있을 것이다.
최근에는 나날이 발전하는 AI로 인해 누구나 이러한 지식들에 접근할 수 있지만, 누구나 전문가처럼 퀄리티 있는 코드를 짤 수 있지는 않다. AI는 아무것도 모르는 사용자에게는 그 수준에 맞는 답만 내어줄 뿐, 사용자가 아는만큼 더 퀄리티 있는 답을 준다. AI가 점점 더 발전하면서 도태되지 않고 살아남기 위해서는 AI가 없던 시대보다 더 많이 배워야 할 것이다.
'프로젝트' 카테고리의 다른 글
| [바로] DeadLock 범인 찾기 (Ft. 위험한 FK?) (3) | 2025.08.25 |
|---|---|
| [바로] 단일 주문 성능 개선 삽질기 (Ft. JPA save, FK) (0) | 2025.08.20 |
| [바로] 분산 시스템에서 ID가 유일하려면?(Ft. Snowflake VS TSID 성능테스트) (3) | 2025.07.28 |
| [바로] JWT는 정말 괜찮은 방법일까? (Ft. 세션저장소 선택 이유) (2) | 2025.07.22 |
| [바로] 스와이프로 찾는 내 스타일, ‘바로’를 기획하며..(Ft. 기술적 목표) (3) | 2025.07.22 |