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 진입
// 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에서 퍼블릭 경로를 열어주기 전까지 정상이다.
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
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();
}
}
모든 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;
}
}
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_FOUND | 401 |
| 회원 정보 조회 API에서 없음 | MEMBER_NOT_FOUND | 404 |
| 로그인 이메일/비밀번호 틀림 | INVALID_CREDENTIALS | 401 |
| OAuth 로그인 실패 | OAUTH2_LOGIN_FAILED | 401 |
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는 구현체가 불필요하다.
경로별 접근 제어 규칙과 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();
}
}
/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