Spring Security 탈출기: JWT 인증 직접 구현으로 독립성 찾기

Jayson·2025년 7월 15일
0
post-thumbnail

4단계로 완성하는 커스텀 JWT 인증

안녕하세요! Terning 팀의 장순입니다. 오늘은 저희 팀이 약 1년간 사용해온 Spring Security와 작별하고, 자체 JWT 인증/인가 메커니즘을 구축한 여정을 공유하려고 합니다.

왜 Spring Security를 도입했고, 왜 떠나보내게 되었는가?

모든 것의 시작은 "3일"이라는 아주 짧은 개발 기간이었습니다. 프로젝트 초기, 저는 처음 구현해보는 소셜 로그인을 빠르게 완성해서 다른 팀원들이 API 개발에 바로 착수할 수 있도록 해야 한다는 압박이 컸습니다. 그 당시 제 머릿속에는 오직 한 가지 생각뿐이었습니다.

"가장 빠르고 확실한 방법은 검증된 프레임워크를 도입하는 것이다!"

그렇게 저는 Spring Security를 선택했습니다. 프레임워크를 도입함으로써 오는 설정의 복잡성이나 수많은 어노테이션의 학습 비용을 모르는 바는 아니었지만, '일단 동작하는 코드'를 만드는 것이 최우선이었기에 가장 합리적인 결정이라고 생각하였습니다.

그렇게 1년이 흘렀습니다. Spring Security는 든든하게 제 역할을 해주었지만, 서서히 불편함이 느껴지기 시작했습니다.

과유불급(過猶不及) 프레임워크: 저희 서비스의 인증 관련 요구사항은 1년간 거의 변하지 않았습니다. 하지만 Spring Security는 저희에게 필요한 기능에 비해 너무 많은 것을 담고 있는, 말 그대로 '과도한 프레임워크'였습니다. 간단한 수정을 위해서도 복잡한 내부 구조를 파악해야 하는 유지보수의 어려움이 커졌습니다.

과도한 추상화의 덫: 비단 프레임워크만의 문제는 아니었습니다. 초기 서비스 로직을 설계할 때, 미래의 '확장성'을 고려하여 인터페이스 기반의 추상화를 많이 도입했습니다. 하지만 이 또한 요구사항이 변하지 않는 상태에서는 코드의 흐름을 파악하기 어렵게 만드는 복잡성의 원인이 되었습니다.

결국 저는 '필요 이상으로 무거운 프레임워크''지나치게 복잡한 내부 구조'라는 두 가지 기술 부채를 청산하기로 결정했습니다.

이 블로그에서는 저희가 어떻게 이 문제들을 해결하고, 총 4단계에 걸쳐 Spring Security 의존성을 점진적으로 제거하며 완전한 독립을 이뤄냈는지 그 과정을 자세히 소개해 드리겠습니다.


1단계: 기반 다지기 - JwtProvider로 JWT 로직 통합

가장 먼저 한 일은 프로젝트 곳곳에 흩어져 있던 JWT 관련 로직을 한데 모으는 것이었습니다. 기존에는 JWT 생성, 검증, 파싱 코드가 common.security 패키지 내 여러 클래스에 분산되어 응집도가 떨어지고 코드 추적이 어려웠습니다.

PR #279: 커스텀 JWT Provider 구현
https://github.com/teamterning/Terning-Server/pull/279

이 문제를 해결하기 위해 인증(auth) 도메인에 특화된 JwtProvider를 구현하여 모든 JWT 처리 책임을 위임했습니다.

주요 변경 사항:

  • JwtProvider 클래스 신설: JWT 토큰 생성, 유효성 검증, 페이로드에서 사용자 ID 추출 로직을 모두 이 클래스로 통합했습니다.
  • Token DTO 추가: Access Token과 Refresh Token을 함께 반환하기 위한 record 타입의 DTO를 추가하여 코드의 불변성과 간결함을 높였습니다.
  • 예외 클래스 패키지 이동: JwtException, JwtErrorCode 등 JWT 관련 예외 클래스들을 common.security에서 auth.jwt 패키지로 이동시켜 auth 도메인의 응집도를 강화했습니다.

2단계: 핵심 부품 제작 - Interceptor와 ArgumentResolver 구현

JWT 로직을 중앙화한 후, Spring Security의 필터 체인을 대체할 새로운 인증 메커니즘을 구현해야 했습니다. 저희는 Spring MVC의 HandlerInterceptorHandlerMethodArgumentResolver를 사용하여 그 핵심 부품을 만들었습니다.

PR #281: Interceptor 및 ArgumentResolver 기반 인증 매커니즘 구현
https://github.com/teamterning/Terning-Server/pull/281

이 단계의 목표는 Spring Security의 @AuthenticationPrincipal을 대체하여, 컨트롤러에서 인증된 사용자 정보를 편리하게 주입받는 커스텀 어노테이션을 만드는 것이었습니다.

주요 변경 사항:

  • @Login 어노테이션 추가: 인증된 사용자의 ID를 컨트롤러 메서드 파라미터로 주입받기 위한 커스텀 어노테이션입니다.
  • LoginCheckInterceptor 구현: HTTP 요청 헤더의 JWT 유효성을 검증하고, 성공 시 요청(request) 객체에 사용자 ID를 attribute로 담아줍니다.
  • LoginUserArgumentResolver 구현: 컨트롤러 파라미터에 @Login 어노테이션이 붙어있으면, 인터셉터가 담아둔 사용자 ID를 해당 파라미터에 주입합니다.

한 가지 중요한 점은, 이 단계에서는 새로운 기능들을 구현만 하고 실제 WebConfig에 등록하여 활성화하지는 않았다는 것입니다. 이는 대규모 변경을 점진적으로 안전하게 적용하기 위한 전략이었습니다.


3단계: 구조 개선 - 과감한 AuthService 리팩토링

핵심 부품 제작 후, 실제 전환에 앞서 내부 코드 구조를 정리하는 시간을 가졌습니다. 특히 여러 서비스로 분리되어 있던 인증 관련 로직을 통합하는 대규모 리팩토링을 진행했습니다.

PR #283: AuthService 로직 통합 및 리팩토링
https://github.com/teamterning/Terning-Server/pull/283

초기에는 확장성을 고려하여 AuthSignInService, AuthSignUpService처럼 기능별로 인터페이스와 구현체를 분리했습니다. 하지만 약 1년간 서비스를 운영하며 이러한 구조가 실제로는 거의 확장되지 않았고, 오히려 코드의 흐름을 파악하기 어렵게 만들어 유지보수성을 해친다고 판단했습니다.

그래서 이론적인 유연성보다 실제 개발 생산성과 유지보수의 명확성을 선택하기로 결정했습니다.

주요 변경 사항:

  • AuthService로 통합: 회원가입, 로그인, 토큰 재발급 등 여러 서비스에 흩어져 있던 인증 로직을 단일 AuthService로 통합했습니다.
  • 불필요한 인터페이스 제거: 과감하게 인터페이스-구현체 구조를 제거하여 구조를 단순화하고 코드 추적을 용이하게 만들었습니다.
  • DTO 구조 개선: record와 팩토리 메서드를 적극적으로 사용하여 DTO를 간결하고 명확하게 개선했습니다.

4단계: 대망의 전환 - Spring Security 의존성 완전 제거

드디어 마지막 단계입니다. 앞서 만든 부품과 잘 정돈된 로직을 바탕으로 Spring Security를 완전히 걷어내고 커스텀 인증 메커니즘으로 전면 전환했습니다.

PR #285: 스프링 시큐리티 의존성 제거 및 커스텀 인증 전면 적용
https://github.com/teamterning/Terning-Server/pull/285

이 PR을 통해 저희 프로젝트는 Spring Security로부터 완벽하게 독립했습니다.

최종 작업 내역:

  • 의존성 제거: build.gradle에서 spring-boot-starter-security 의존성을 삭제했습니다.
  • 관련 설정 완전 삭제: SecurityConfig.java를 포함하여 더 이상 필요 없어진 모든 Spring Security 관련 클래스와 설정을 제거했습니다.
  • 커스텀 인증 활성화: WebConfigLoginCheckInterceptorLoginUserArgumentResolver를 정식으로 등록하고, 인증이 필요 없는 API 경로(AUTH_WHITELIST)를 설정했습니다.
  • @AuthenticationPrincipal -> @Login 전환: 프로젝트 전체의 컨트롤러에서 사용되던 @AuthenticationPrincipal을 저희가 만든 @Login 어노테이션으로 모두 교체했습니다.
  • 최종 안정화: 인터셉터에서 JWT 파싱 예외를 try-catch로 처리하고, 토큰의 "Bearer " 접두사가 이중으로 처리되던 미세한 버그를 수정하여 안정성을 높였습니다.

마무리하며

총 4단계의 여정을 통해 저희는 Spring Security에 대한 강한 의존성을 제거하고, 우리 서비스에 꼭 맞는 가볍고 명확한 자체 인증 시스템을 구축할 수 있었습니다.

  1. 기반 다지기 (Integrate): 흩어진 JWT 로직을 JwtProvider로 통합했습니다.
  2. 부품 제작 (Build): InterceptorArgumentResolver로 인증의 핵심을 만들었습니다.
  3. 구조 개선 (Refactor): 실용적인 관점에서 AuthService를 리팩토링하여 복잡성을 낮췄습니다.
  4. 전면 전환 (Activate): 마침내 Spring Security를 걷어내고 커스텀 인증을 전면 적용했습니다.

이 과정은 단순히 라이브러리 하나를 걷어내는 작업이 아니었습니다. 우리 팀의 현주소를 진단하고 실용적인 아키텍처를 선택하며, 점진적인 개발을 통해 안정적으로 대규모 변경을 완료한 성공적인 경험이었습니다.

profile
Small Big Cycle

0개의 댓글