노트

[SolveGO #6] Crush 잊어버리지마 - JWT 토큰 발급, 검증 구현 본문

Project/SolveGO

[SolveGO #6] Crush 잊어버리지마 - JWT 토큰 발급, 검증 구현

forwarder 2026. 6. 23. 14:19

 

 

 

 

 

 

 

 

 

가수 크러쉬의 명곡 "잊어버리지마"는 

사랑하는 사람과 함께하는 순간을 가슴 속에 영원히 담아두고 싶다는 소망을 이야기한다.

 

정말 명곡 중의 명곡이다.

 

이 노래에는 아래와 같은 가사가 있다.

 

너와 나 언젠가 남이 되어도
영영 닿을 수 없는 사이 되어도
잊어버리지마 (잊어버리지마)
잃어버리지마

 

 

 

"잊어버리지 않는 것"

 

그것은 

 

 

영영 닿을 수 없는 사이가 되었어도, 언제든 그 사람을 본다면
그 사람이 누구였는지 함께 했던 추억들을 떠올릴 수 있게 기억해두는 것이 아닐까.

 

 

 

 

 

우리가 사랑하는 사람을 기억하는 것도 중요한 만큼,

서버도 로그인한 사람이 누구였는지 기억하는 것이 중요하다.

 

서버와 클라이언트도 한때 사랑하는 사이였기 때문이다.

 

그러나 이들이 이야기하는 매체가 되는 언어(HTTP)는 직전의 한 대사만을 기억할 수 있기 때문에,

이들은 대사마다 서로만의 언어로(서버만이 검증할 수 있는 비밀키로 서명된) 자신이 누구인지 말해준다.

 

그리고 서버는 클라이언트와 영영 닿을 수 없는 사이 되어도 (HTTP 연결이 종료되어도)

그 클라이언트가 누구였는지 함께 했던 추억(로그인 정보)들을 떠올릴 수 있게 저장해둔다.

 

바로 JWT가 그것이다.

 

 

정리하자면,

서버는 클라이언트가 보내온 JWT를 검증하여 클라이언트가 누구였는지 함께 했던 추억들을 떠올린다.

(다만 서버는 쿨하기 때문에 클라이언트를 영원히 기억하지는 않고, JWT 토큰 만료 시간까지만 기억한다)

 

이제 서버도 사랑하는 사람을 기억할 수 있게 JWT 기능을 추가하자.

 

 

JWT 기능을 추가한다는 것은 아래와 같을 것이다.

1. 로그인한 사용자에게 JWT를 발급해 준다.   -  JWT 발급

2. 이후 JWT를 포함한 요청이 온다면, 그 JWT를 검증하여 요청을 통과시킬지 결정한다 - JWT 검증

 

즉, JWT 기능은 JWT를 발급해주는 문제와, JWT를 검증하는 문제로 나눌 수 있다.

그러면 JWT를 발급해주는 문제부터 해결하자.

 

 


 

 

문제 정의 1 : JWT 발급

 

클라이언트가 로그인 API로 유효한 계정 정보를 보내면,

서버는 아이디와 비밀번호를 검증한 뒤, 로그인 성공을 의미하는 응답을 반환해야 한다.

 

POST /api/auth/login
{
"username": "username",
"password": "1234"
}

 

 

JWT를 적용하기 전에는 단순 userId를 반환할 수 있었다.

{
  "userId": 1,
  "message": "Login successful"
}

 

 

하지만 이후 요청에서 사용자를 식별하려면, 클라이언트가 인증 정보를 계속 들고 있어야 한다.

따라서 로그인 성공 시 서버는 JWT를 발급하고, 클라이언트에게 accessToken을 반환하도록 변경한다.

 

{
  "accessToken": "eyJ..."
}

 

 

 

즉, 기존의 로그인 시 userId를 반환하는 것을 JWT를 반환하도록 수정하는 문제는

 

다시 다음과 같이 나눌 수 있다.

1. 로그인이 JWT를 이용하도록 비즈니스 로직을 수정한다.

2. API 응답 포맷을 수정한다.

 

 

이때 SRP 관점에서 보면, 각 변경은 자신이 책임지는 객체에서 일어나야 한다.

JWT 생성과 로그인 성공 결과를 만드는 책임은 AuthService가 가진다.
또한 클라이언트에게 반환되는 응답 형태를 표현하는 책임은 LoginResponse DTO가 가진다.

따라서 Controller는 수정할 필요가 없다.
Controller의 책임은 여전히 HTTP 요청을 받고 AuthService.login()을 호출한 뒤, 그 결과를 반환하는 것이기 때문이다.

 

 

 

 

시퀀스 다이어그램은 위와 같고, 의존성을 추가한 뒤 바텀업 방식으로 JWT 토큰을 생성하는 JwtTokenProvider부터 생성하자.

 


 

 

JWT 관련 기능을 이용하기 위해 build.gradle에 의존성을 추가해준다.

 

implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

 

 

그리고 JWT 생성 시 비밀키의 역할을 하는 환경변수와 토큰 만료 시간을 설정해주자.

여기서 expiration은 밀리초 단위이고 600000은 10분을 의미한다.

 

실제 서비스에서는 보통 Access Token과 Refresh Token을 함께 사용한다.

Access Token은 짧은 만료 시간을 가지고 API 요청 시 사용되며,

Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용된다.

 

다만 현재 SolveGO의 MVP 단계에서는 Refresh Token은 구현하지 않고 Access Token만 사용하도록 하였다.

 

jwt:
  secret: ${JWT_SECRET}
  expiration: 600000

 

 

 

그 후 JwtTokenProvider를 정의해주어 AuthService가 createToken을 호출할 수 있도록 해주자.

package com.kdh.solvego.domain.auth;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.expiration}") long expiration
    ){
        this.secretKey= Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration=expiration;
    }

    public String createToken(Long userId) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .subject(String.valueOf(userId))
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(secretKey)
                .compact();
    }
}

 

 

createToken()은 userId를 subject claim으로 넣고, 발급 시간과 만료 시간을 설정한 뒤, secretKey로 서명하여 JWT를 생성한다.

 

 

그리고 API 응답 포맷을 변경하기 위해 응답 DTO도 토큰만을 반환하도록 변경하자.

package com.kdh.solvego.domain.auth;

public class LoginResponse {

   private final String accessToken;

   public LoginResponse(String accessToken) {
       this.accessToken = accessToken;
   }

   public String getAccessToken() {
       return accessToken;
   }

}

 

 

그리고 로그인 비즈니스 로직도 변경해주기 위해 AuthService를 아래와 같이 수정하자.

package com.kdh.solvego.domain.auth;

import com.kdh.solvego.domain.user.User;
import com.kdh.solvego.domain.user.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class AuthService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public LoginResponse login(LoginRequest request) {
        User user = userRepository.findByUsername(request.getUsername())
                .orElseThrow(InvalidLoginException::new);

        if (!user.matchesPassword(
                request.getPassword(),
                passwordEncoder
        )) {
            throw new InvalidLoginException();
        }

        String accessToken= jwtTokenProvider.createToken(user.getId());
        return new LoginResponse(accessToken);
    }

}

 

AuthService는 앞서 정의한 JwtTokenProvider를 주입받아, 로그인 성공 시 accessToken을 생성하고 이를 LoginResponse DTO에 담아 반환하도록 수정하였다.

 

 

이후 서버를 실행하여 Postman으로 실행해보자.

유효한 계정 정보로 JWT 발급

 

유효하지 않는 계정 정보일 경우 401 에러

 

테스트도 모두 통과했다.

 

 

 

 

 

 

과연 그럴까?

JWT는 흔히 암호화된 토큰으로 오해받지만, 실제로는 암호화가 아니라 서명된 토큰이다.

(암호화 : 내용을 숨김, 서명 : 내용이 안바뀜을 증명)
따라서 JWT를 가진 사람은 누구나 Header와 Payload를 확인할 수 있으며, 이를 복호화가 아닌 디코딩이라고 부른다.


실제로 앞서 발급한 JWT를 디코딩해보면 다음과 같다.

 

JWT 디코딩

 

 

 

디코딩 결과를 보면 sub 값이 1인 것을 확인할 수 있다.
이는 앞서 로그인에 성공한 사용자의 id가 JWT 내부에 저장되었음을 의미한다.
또한 iat와 exp를 통해 토큰의 발급 시각과 만료 시각도 확인할 수 있다.

참고로 JWT의 Signature는 서버의 secretKey를 이용해 생성된다.

따라서 secretKey를 모르는 사용자는 JWT의 Header나 Payload 내용을 임의로 수정하더라도 올바른 Signature를 만들 수 없다.

 

서버는 JWT 검증을 통해 이 Signature가 올바른지 확인하고, 이를 통해 토큰이 위조되지 않았음을 판단한다.

 

 

그렇다면 서버는 이후 요청에서 JWT를 어떻게 검증하고, 이를 통해 사용자가 누구인지 다시 알아낼 수 있을까?

이제 JWT 검증 문제를 해결해보자.

 


 

 

문제 정의 2 : JWT 검증

 

 

서버가 이전에 secretKey로 JWT에 서명했으므로,
클라이언트가 보낸 JWT의 Header와 Payload를 읽고, 

자신의 secretKey를 이용해 다시 계산한 Signature가 JWT에 포함된 Signature와 일치하는지 비교하여 위조 여부를 검사한다.

 

이후 위조되지 않은 JWT임을 증명하면,

userId를 JWT에서 추출한다.

 

 

서버는 JWT가 위조되지 않았음을 검증한 뒤 userId를 추출할 수 있다.

현재 SolveGO MVP에서는 JWT 검증에 성공하면 해당 userId를 신뢰하도록 구현하였다.

다만 실제 서비스에서는 회원 탈퇴, 계정 정지, 권한 변경 등의 상황이 발생할 수 있으므로, 필요에 따라 DB 조회를 통해 사용자의 상태를 추가로 검증하기도 한다.

 

 

 

시퀀스 다이어그램은 다음과 같고, JWT 검증이 필요한 임시 API를 하나 만들어서 테스트 하자.

 

구현은 바텀업 방식으로 진행하자. (위 시퀀스 다이어그램에서 오른쪽 -> 왼쪽)
가장 먼저 JwtTokenProvider에 토큰 검증 기능과 userId 추출 기능을 추가한다. (현재는 토큰 생성 기능만 있음)
그 후 JwtAuthenticationFilter를 만들어 요청마다 Authorization 헤더를 확인하고,
유효한 JWT가 들어온 경우 SecurityContext에 인증 정보를 저장하도록 한다.
마지막으로 SecurityConfig에 JwtAuthenticationFilter를 등록하고,
JWT 인증이 필요한 임시 API를 만들어 실제로 401과 200 응답이 정상적으로 나오는지 테스트한다.

 

 

먼저 토큰 생성, 검증, 내부 정보 추출 기능을 책임지는 JwtTokenProvider를 정의하자.

 

package com.kdh.solvego.domain.auth;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.expiration}") long expiration
    ){
        this.secretKey= Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration=expiration;
    }

    public String createToken(Long userId) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .subject(String.valueOf(userId))
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(secretKey)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (Exception e){
            return false;
        }
    }

    public Long getUserId(String token) {
        String subject=Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();

        return Long.parseLong(subject);
    }


}

 

 

token을 검증하는 validateToken()과 토큰에서 userId를 추출하는 getUserId()를 추가했다.

 

여기서 JWT를 검증하는 원리를 시각적으로 나타내자면 아래와 같다.

 

JWT의 구조 = Header.Payload.Signature

서버:
JWT (Header + Payload ) + 내부 보관 secretKey
        ↓
   Signature 재계산
        ↓
요청을 받은 JWT의 Signature와 비교
        ↓
일치 → 유효한 토큰
불일치 → 위조된 토큰
 

 

 

이제 요청에서 토큰을 걸러서(필터링) jwtProvider에게 전달하는 책임을 지닌 JwtAuthenticationFilter를 정의하자.

 

package com.kdh.solvego.domain.auth;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException{
        String authorizationHeader = request.getHeader("Authorization");

        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authorizationHeader.substring("Bearer ".length());

        if (jwtTokenProvider.validateToken(token)) {
            Long userId = jwtTokenProvider.getUserId(token);

            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userId,
                    null,
                    List.of()
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

 

하위 컴포넌트인 JwtTokenProvider는 생성자 DI로 받았고,

OncePerRequestFilter를 상속받아 doFilterInternal로 필터 내부에서 해야할 동작을 나타냈다.

 

UsernamePasswordAuthenticationToken은 Spring Security의 Authentication 구현체이다.
여기서는 principal에 userId를 넣고, JWT 인증 방식에서는 비밀번호를 사용하지 않으므로 credentials는 null로 두었다.
권한 정보는 아직 사용하지 않기 때문에 빈 리스트를 전달하였다.

이렇게 만든 Authentication 객체를 SecurityContextHolder에 저장하면, 

이후 Security Filter Chain에서 해당 요청을 인증된 요청으로 판단할 수 있다.

 

 

참고로 doFilter()는 아래 그림과 같이 Spring Security가 여러 필터를 Chain으로 관리하기 때문에, 현재 요청과 응답을 다음 필터로 넘기라는 의미이다.
마지막 필터까지 통과하면 요청은 DispatcherServlet으로 전달되고, 이후 Controller까지 도달한다.
반대로 Controller에서 응답이 만들어지면, 응답은 필터 체인을 거꾸로 지나 클라이언트에게 반환된다.

 

Spring Security Filter Architecture

 

 

 

 

그리고 이렇게 만든 JwtAuthenticationFilter를 FilterChain에 등록해주자.

 

 

package com.kdh.solvego.global;

import com.kdh.solvego.domain.auth.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtAuthenticationFilter
    ) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .httpBasic(httpBasic -> httpBasic.disable())
                .formLogin(formLogin -> formLogin.disable())
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(
                            new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)
                ))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/users").permitAll()
                        .requestMatchers("/api/auth/login").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(
                        jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class
                );

        return http.build();
    }


}

 

여기서 SecurityFilterChain에 앞서 만든 JwtAuthenticationFilter를 등록하여,
회원가입과 로그인을 제외한 모든 요청에 대해 JWT 인증이 수행되도록 설정했다. (테스트용 TestController에 대한 부분은 생략한다)

 

 

그리고 Postman으로 테스트해보자.

 

유효하지 않은 토큰 : 401 에러

 

유효한 토큰 : 200

 

이렇게 유효한 토큰을 가지고 해당 테스트 API에 성공적으로 접근할 수 있음을 확인할 수 있다.

 

 

 

 

이렇게 만든 JWT 인증은 SolveGO의 문제 등록, 풀이 제출, 오답노트 조회 기능에서 반드시 갖추어야 할 기능이다.

 

 

 

이제 인증 파트의 큰 문을 넘었다.

다음 글부터는 실제로 사용자가 문제를 등록하고 조회할 수 있는 기능을 만들어보자!