[Let's Git It] Spring Security + JWT 인증 기반 세팅

dobby·2026년 5월 2일

Let's git it BE

목록 보기
8/20

Let's Git It — WebSocket 기반 실시간 Git 명령어 학습 게임 프로젝트의 백엔드 인증 구현 시리즈입니다.
이번 글은 로컬 로그인 구현의 첫 번째 단계, Spring Security + JWT 기반 세팅을 다룹니다.


왜 이 작업이 필요한가

초기 프로젝트에는 Spring Security가 아예 없었다. 즉, /api/v1/auth/logout이나 게임 API 전부를 누구든 호출할 수 있는 상태였다.

Spring Security + JWT 조합으로 이 문제를 해결한다. 각자의 역할은 다음과 같다.

[Spring Security]    → "이 요청이 인증된 사용자인가?" 를 필터링
[JWT]                → "이 사람이 누구인지"를 증명하는 토큰 형식
[JwtProvider]        → JWT를 만들고 검증하는 유틸 클래스
[JwtAuthenticationFilter] → 모든 요청 앞에서 JWT를 꺼내 검증하는 관문
[SecurityConfig]     → "어느 API는 허용, 어느 건 차단" 규칙 설정
[CustomUserDetailsService] → "이 이메일 가진 유저가 DB에 존재하냐?" 조회 담당

요청 흐름

클라이언트 요청
    ↓
JwtAuthenticationFilter (토큰 꺼내서 검증)
    ↓ 유효하면
SecurityContextHolder에 인증 정보 저장
    ↓
Controller 진입

Step 1. 의존성 추가 (build.gradle)

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// JWT (jjwt)
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

// 이메일 발송 (이메일 인증 단계에서 사용)
implementation 'org.springframework.boot:spring-boot-starter-mail'

⚠️ Security 의존성을 추가하는 순간 모든 API가 401로 막힌다. SecurityConfig에서 퍼블릭 경로를 열어주기 전까지 정상이다.


Step 2. application.yml JWT 설정 추가

jwt:
  secret: ${JWT_SECRET}          # 서명용 비밀키 (256비트 이상, .env에서 주입)
  access-expiration: 1800000     # Access Token 만료: 30분 (밀리초)
  refresh-expiration: 604800000  # Refresh Token 만료: 7일 (밀리초)

비밀키는 .env에서 환경변수로 관리한다.

JWT_SECRET=letsgitit-secret-key-must-be-at-least-256bits-long-for-hs256

Step 3. JwtProvider

JWT를 생성·검증·파싱하는 유틸 클래스다. 여러 곳에서 필요한 로직을 한 곳에 모아 중복을 제거한다.

@Component
public class JwtProvider {

    @Value("${jwt.secret}")
    private String secretString;

    @Value("${jwt.access-expiration}")
    private long accessExpiration;

    @Value("${jwt.refresh-expiration}")
    private long refreshExpiration;

    private SecretKey secretKey;

    // 의존성 주입 완료 후 1회 실행 — 문자열 → SecretKey 변환
    @PostConstruct
    public void init() {
        this.secretKey = Keys.hmacShaKeyFor(secretString.getBytes());
    }

    // Access Token 생성
    // subject: 토큰 주인(이메일), type claim으로 access/refresh 구분
    public String createAccessToken(String email) {
        Date now = new Date();
        return Jwts.builder()
            .subject(email)
            .claim("type", "access")
            .issuedAt(now)
            .expiration(new Date(now.getTime() + accessExpiration))
            .signWith(secretKey)
            .compact();
    }

    // Refresh Token 생성 — 구조는 동일하지만 만료 시간이 훨씬 길다
    public String createRefreshToken(String email) {
        Date now = new Date();
        return Jwts.builder()
            .subject(email)
            .claim("type", "refresh")
            .issuedAt(now)
            .expiration(new Date(now.getTime() + refreshExpiration))
            .signWith(secretKey)
            .compact();
    }

    // 토큰에서 이메일(subject) 추출
    public String getEmail(String token) {
        return parseClaims(token).getSubject();
    }

    // 토큰 유효성 검증 (서명 위변조, 만료, 형식 오류 체크)
    public boolean validateToken(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (ExpiredJwtException e) {
            throw e; // 필터에서 만료 여부를 따로 처리하기 위해 던진다
        } catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            return false;
        }
    }

    // 토큰 남은 만료 시간(ms) — 로그아웃 시 블랙리스트 TTL에 사용
    public long getExpiration(String token) {
        Date expiration = parseClaims(token).getExpiration();
        return expiration.getTime() - System.currentTimeMillis();
    }

    private Claims parseClaims(String token) {
        return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

Step 4. JwtAuthenticationFilter

모든 HTTP 요청이 Controller에 도달하기 전에 토큰을 검사하는 관문이다. OncePerRequestFilter를 상속해 같은 요청에서 두 번 실행되지 않도록 한다.

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {

        String token = resolveToken(request); // 1. 헤더에서 토큰 추출

        if (token != null) {
            try {
                if (jwtProvider.validateToken(token)) {         // 2. 유효성 검증
                    String email = jwtProvider.getEmail(token); // 3. 이메일 추출
                    UserDetails userDetails = userDetailsService.loadUserByUsername(email); // 4. DB 조회

                    // 5. SecurityContext에 인증 정보 등록
                    UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (ExpiredJwtException e) {
                // 만료 토큰은 통과시킴 — SecurityContext 비어있으므로 Security가 401 처리
                log.debug("만료된 Access Token: {}", request.getRequestURI());
            }
        }

        filterChain.doFilter(request, response); // 6. 다음 필터로 전달
    }

    // "Bearer eyJhbGci..." → 순수 토큰만 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Step 5. CustomUserDetailsService

Spring Security가 인증할 때 "이 이메일 가진 유저가 DB에 있냐?"를 물어보면 대답해주는 클래스다.

팀 컨벤션인 interface + Impl 구조로 분리했다.

// 인터페이스
public interface CustomUserDetailsService extends UserDetailsService {
}
// 구현체
@Service
@RequiredArgsConstructor
public class CustomUserDetailsServiceImpl implements CustomUserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email)
            .orElseThrow(() -> new BusinessException(ErrorCode.AUTH_MEMBER_NOT_FOUND));

        return User.builder()
            .username(member.getEmail())
            .password(member.getPassword() != null ? member.getPassword() : "")
            .authorities(Collections.emptyList())
            .build();
    }
}

에러코드 설계 결정

UsernameNotFoundException 대신 프로젝트 공통 예외인 BusinessException으로 던져 GlobalExceptionHandler가 일관된 형식으로 응답하게 했다.

또한 유저 조회 실패 에러코드를 맥락에 따라 분리했다.

상황에러코드HTTP
JWT 필터 / 인증 과정에서 이메일 없음AUTH_MEMBER_NOT_FOUND401
회원 정보 조회 API에서 없음MEMBER_NOT_FOUND404
로그인 이메일/비밀번호 틀림INVALID_CREDENTIALS401
OAuth 로그인 실패OAUTH2_LOGIN_FAILED401

Step 6. MemberRepository

Spring Data JPA는 인터페이스만 작성하면 런타임에 구현체를 자동 생성해준다. findByEmail, existsByEmail 모두 메서드 이름 규칙으로 쿼리가 자동 생성되므로 Impl이 필요 없다.

public interface MemberRepository extends JpaRepository<Member, UUID> {

    Optional<Member> findByEmail(String email);

    boolean existsByEmail(String email);
}

Redis 직접 조작이 필요한 SingleRankingRedisRepository와 달리 JPA Repository는 구현체가 불필요하다.


Step 7. SecurityConfig

경로별 접근 제어 규칙과 JWT 필터 등록을 담당한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;
    private final CustomUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)        // JWT는 세션 미사용 → CSRF 불필요
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stateless
            .formLogin(AbstractHttpConfigurer::disable)   // JSON API 방식 → 폼 로그인 불필요
            .httpBasic(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/v1/auth/email/send",
                    "/api/v1/auth/email/verify",
                    "/api/v1/auth/register",
                    "/api/v1/auth/login",
                    "/api/v1/auth/token",
                    "/api/v1/auth/password/reset",
                    "/api/v1/auth/reissue",
                    "/oauth2/**",
                    "/swagger-ui/**",
                    "/api-docs/**",
                    "/actuator/**",
                    "/ws/**"         // WebSocket은 STOMP 레벨에서 별도 인증 처리 예정
                ).permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(
                new JwtAuthenticationFilter(jwtProvider, userDetailsService),
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }

    // BCrypt: 단방향 해시 + 솔트 자동 추가 → 같은 비밀번호도 매번 다른 해시값
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 로그인 API에서 이메일 + 비밀번호 검증 시 직접 호출하는 객체
    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration authenticationConfiguration
    ) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

WebSocket 인증은?

/ws/**permitAll()로 열어둔 이유가 있다.

WebSocket은 HTTP 핸드셰이크 후 프로토콜이 전환되기 때문에, Security의 HTTP 필터는 STOMP 메시지 레벨을 검사하지 못한다.

HTTP 핸드셰이크 (/ws)   ← JwtFilter 검사 가능
        ↓ 프로토콜 업그레이드
STOMP CONNECT 프레임    ← JwtFilter 검사 불가
STOMP SEND 프레임

WebSocket 인증은 ChannelInterceptor를 구현해 STOMP CONNECT 프레임에서 토큰을 검증하는 방식으로 처리한다. 게임 로직 구현 단계에서 추가할 예정이다.


전체 파일 구조

global/
├── config/
│   └── SecurityConfig.java
├── jwt/
│   ├── JwtProvider.java
│   └── JwtAuthenticationFilter.java
└── exception/
    └── ErrorCode.java          (AUTH_MEMBER_NOT_FOUND 추가)

domain/member/
├── repository/
│   └── MemberRepository.java
└── service/
    ├── CustomUserDetailsService.java
    └── CustomUserDetailsServiceImpl.java

다음 단계

  • 2단계: DTO 구체화 (AuthRequest / AuthResponse)
  • 3단계: 이메일 인증 발송 / 검증 (SMTP + Redis TTL)
  • 4단계: 회원가입
  • 5단계: 로컬 로그인 (AuthenticationManager + JWT 발급)
profile
느리게 한걸음

0개의 댓글