우당탕탕 개발일지

[방치RPG 서버 제작기] 5. 액세스 토큰 + 리프레시 토큰 관리 및 갱신 본문

Server/방치RPG 서버

[방치RPG 서버 제작기] 5. 액세스 토큰 + 리프레시 토큰 관리 및 갱신

devchop 2025. 4. 23. 18:41

1. Jwt 인증을 위한 스크립트 3개 정리 (서버)

1) JwtProvider

JWT 토큰 생성, 검증, 사용자 정보 추출을 담당한다. 

  • createToken(userId) : accessToken 생성
  • createRefreshToken(userId) : refreshToken 생성 (주의: subject에 userId 꼭 넣기!)
  • isTokenValid(token) : 만료 여부 확인
  • getUserIdFromToken(token) : 토큰에서 userId 추출
package com.esaAdventure.dante_server.common.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;

@Component
public class JwtProvider {

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

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String createToken(Long userId) {
        Date now = new Date();
        // 2시간
        long EXPIRATION_TIME = 1000L * 60 * 60 * 2;
        Date expiry = new Date(now.getTime() + EXPIRATION_TIME);

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public Long getUserIdFromToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();

        return Long.parseLong(claims.getSubject());
    }

    public boolean isTokenValid(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(getSigningKey())
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
    public UsernamePasswordAuthenticationToken getAuthentication(String token) {
        Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));

        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        String userId = claims.getSubject(); // 또는 deviceId 등

        UserDetails userDetails = new org.springframework.security.core.userdetails.User(userId, "", List.of());
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String createRefreshToken(Long userId) {
        long refreshExp = 1000L * 60 * 60 * 24 * 14; // 14일짜리
        Date now = new Date();
        Date expiry = new Date(now.getTime() + refreshExp);

        return Jwts.builder()
                .setSubject(String.valueOf((userId)))
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }
}

2) SecurityConfig

Spring Security 설정 클래스. 

  • .requestMatchers("/api/user/auth/**").permitAll() 로 로그인/회원가입 API 허용
  • .anyRequest().authenticated() 로 나머지는 인증 필요
  • .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
package com.esaAdventure.dante_server.config;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    public SecurityConfig(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/user/auth/guest").permitAll() // 로그인/회원가입은 허용
                        .requestMatchers("/api/user/auth/**").permitAll()
                        .anyRequest().authenticated()           // 나머지는 인증 필요
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

3) JwtAuthenticationFilter

요청마다 JWT accessToken 검증하는 필터

  • Authorization 헤더에서 Bearer 토큰 추출
  • 검증 성공 시 SecurityContext 에 인증 정보 저장

 

package com.esaAdventure.dante_server.common.security;

import java.io.IOException;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        String token = resolveToken(request);
        if (token != null && jwtProvider.isTokenValid(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

 

2.  [Error] permitAll 설정했는데도 UnAuth 뜨는 문제

@EnableWebSecurity
public class SecurityConfig {

로그인 API에서 permitAll() 설정을 해놨는데도 UnAuth 가 리턴되며 로그인 되지 않는 현상이 발생했다. 원인은 SecurityConfig에서  @Configuration 어노테이션을 붙이지 않아서 필터 자체가 등록이 안되었던 것이었다.Spring이 Security 설정 클래스를 인식하지 못해서 아무리 permitAll()을 해도 무시된다. 

 

해결법:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

이렇게 @Configuration 꼭 붙여줘야 한다.

 

이제 서버에서 액세스토큰, 리프레시토큰을 생성및 검증하는 코드는완료되었다. 이제 로그인 시 액세스토큰과 리프레시를 같이 반환하고, 액세스토큰이 만료되었을 경우 갱신하는 방법을 알아보자!


3. AccessToken과 RefreshToken의 개념

  • Access Token
    • 유저 인증용, 짧은 수명 (보통 15분 ~ 2시간)
    • 모든 API 요청에 사용됨
  • Refresh Token
    • accessToken이 만료됐을 때 새로 발급받기 위한 인증 수단
    • 보통 7일 ~ 30일 유지

토큰은 클라이언트가 보관하고, 서버는 refreshToken만 DB나 Redis 등에 저장해서 관리한다.

 

 

4. 토큰 갱신을 위한 AuthController, TokenService 생성하기

 

AuthController.java

package com.esaAdventure.dante_server.controller.auth;


@RestController
@RequestMapping("/api/user/auth")
public class AuthController {

    private final JwtProvider jwtProvider;
    private final TokenService tokenService;

    public AuthController(JwtProvider jwtProvider, TokenService tokenService) {
        this.jwtProvider = jwtProvider;
        this.tokenService = tokenService;
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refreshAccessToken(HttpServletRequest request) {
        String refreshToken = resolveToken(request);
        if (refreshToken == null) {
            return new ResponseEntity<>("토큰 없음", HttpStatus.UNAUTHORIZED);
        }

        if (!jwtProvider.isTokenValid(refreshToken)) {
            return new ResponseEntity<>("토큰 만료됨", HttpStatus.UNAUTHORIZED);
        }

        Long userId = jwtProvider.getUserIdFromToken(refreshToken);

        // ✅ 저장된 refreshToken과 비교
        if (!tokenService.isRefreshTokenValid(userId, refreshToken)) {
            return new ResponseEntity<>("리프레시 토큰 불일치", HttpStatus.UNAUTHORIZED);
        }

        String newAccessToken = jwtProvider.createToken(userId);

        return new ResponseEntity<>(new AccessTokenResponse(newAccessToken), HttpStatus.OK);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

 

TokenService.java

package com.esaAdventure.dante_server.service.auth;

import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class TokenService {
    private final Map<Long, String> refreshTokenStorage = new ConcurrentHashMap<>();

    public void saveRefreshToken(Long userId, String token) {
        refreshTokenStorage.put(userId, token);
    }

    public boolean isRefreshTokenValid(Long userId, String token) {
        return token.equals(refreshTokenStorage.get(userId));
    }
}

 

TokenResponse.java (dto)

package com.esaAdventure.dante_server.dto.auth;

public record TokenResponse(String accessToken, String refreshToken) {}

 

AccessTokenRefresh.java. (dto)

package com.esaAdventure.dante_server.dto.auth;

public record AccessTokenResponse(String accessToken) {}

 


5. 로그인 시 두 토큰을 함께 반환하기(UserController.java)

@PostMapping("/auth/guest")
    public ResponseEntity<UserResponse> guestLogin(@RequestBody GuestLoginRequest request) {
        User user = userService.guestLogin(request.getDeviceId());

        HttpStatus status = user.isNewlyCreated() ? HttpStatus.CREATED : HttpStatus.OK;
        Long userId = user.getId();
        if (user.isNewlyCreated()) {// 신규 유저라면 기본 데이터 생성
            userDataService.createInitialData(userId);
        }

        String accessToken = jwtProvider.createToken(userId);
        String refreshToken = jwtProvider.createRefreshToken(userId);

        String gameDataJson = userDataService.getAllUserDataAsJson(user.getId());
        // 📦 서버 DB에 refreshToken 저장 (예: 유저ID별로)
        tokenService.saveRefreshToken(userId, refreshToken);

        //return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
        // UserResponse에 통합해서 응답
        UserResponse response = new UserResponse(accessToken,refreshToken, gameDataJson);
        return new ResponseEntity<>(response, status);
    }

UserResponse에 accessToken, refreshToken, userData 모두 포함시켜서 한번에 전달한다.


6.  유니티에서 액세스토큰 관리 구조 설계 (클라이언트)

  • BackendManager : 실제 통신 담당, accessToken 자동 붙이기 + 만료 시 자동 갱신
  • TokenHandler : refreshToken으로 accessToken 갱신 처리
  • LocalTokenStore : accessToken / refreshToken 저장, 불러오기

모든 요청은 BackendManager.SendRequest<T>() 로 통일해서 사용하면 유지보수 편함.

accessToken 만료되면 → refresh → 다시 원래 요청 재시도 → 실패 시 로그인 유도까지 자동화.

 

 

BackendManager.java

public static class BackendManager
{
    private const string BASE_URL = "http://localhost:8080";

    public static async Task<T> SendRequest<T>(string path, string method, object body = null)
    {
        string url = $"{BASE_URL}{path}";
        string accessToken = LocalTokenStore.AccessToken;

        var request = UnityWebRequest.Put(url, "");
        request.method = method;
        request.SetRequestHeader("Content-Type", "application/json");
        if (!string.IsNullOrEmpty(accessToken))
            request.SetRequestHeader("Authorization", $"Bearer {accessToken}");

        if (body != null)
        {
            string json = JsonUtility.ToJson(body);
            byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
            request.uploadHandler = new UploadHandlerRaw(jsonBytes);
        }

        request.downloadHandler = new DownloadHandlerBuffer();

        await request.SendWebRequest();

        // 만료된 accessToken일 경우 -> refresh 시도
        if (request.responseCode == 401)
        {
            bool refreshSuccess = await TokenHandler.TryRefreshToken();
            if (refreshSuccess)
            {
                // 재시도
                return await SendRequest<T>(path, method, body);
            }
            else
            {
                // 로그인 화면으로 유도
                UIManager.ShowLoginScreen();
                throw new Exception("AccessToken 만료 및 refresh 실패");
            }
        }

        // 정상 응답 처리
        if (request.result == UnityWebRequest.Result.Success)
        {
            return JsonUtility.FromJson<T>(request.downloadHandler.text);
        }
        else
        {
            Debug.LogError("API 요청 실패: " + request.error);
            throw new Exception(request.error);
        }
    }
}

TokenHandler.java

public static class TokenHandler
{
    public static async Task<bool> TryRefreshToken()
    {
        string refreshToken = LocalTokenStore.RefreshToken;
        if (string.IsNullOrEmpty(refreshToken)) return false;

        var request = new UnityWebRequest("http://localhost:8080/api/user/auth/refresh", "POST");
        request.SetRequestHeader("Authorization", $"Bearer {refreshToken}");
        request.downloadHandler = new DownloadHandlerBuffer();

        await request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            var tokenResponse = JsonUtility.FromJson<AccessTokenResponse>(request.downloadHandler.text);
            LocalTokenStore.AccessToken = tokenResponse.accessToken;
            return true;
        }

        return false;
    }

    [Serializable]
    public class AccessTokenResponse
    {
        public string accessToken;
    }
}

 

 

LocalTokenStore.java

public static class LocalTokenStore
{
    private const string ACCESS_KEY = "access_token";
    private const string REFRESH_KEY = "refresh_token";

    public static string AccessToken
    {
        get => PlayerPrefs.GetString(ACCESS_KEY, null);
        set => PlayerPrefs.SetString(ACCESS_KEY, value);
    }

    public static string RefreshToken
    {
        get => PlayerPrefs.GetString(REFRESH_KEY, null);
        set => PlayerPrefs.SetString(REFRESH_KEY, value);
    }

    public static void Clear()
    {
        PlayerPrefs.DeleteKey(ACCESS_KEY);
        PlayerPrefs.DeleteKey(REFRESH_KEY);
    }
}

 

사용할땐 다음과 같이 사용하면 된다.

await ApiManager.SendRequest<object>("/api/backup", "POST", new { data = myData });

7. Unity의 BackendManager 분리 설계

API 많아지면 하나의 클래스에서 관리하기엔 헬이 되므로, 기능별로 분리하는것이 좋다.

권장 구조:

  • BackendManager : 통신 + 토큰 갱신 공통 처리
  • UserService, InventoryService, GachaService 등 → API 의미 단위별로 나눔

이렇게 분리하면:

  • 각 API 역할이 명확해지고
  • 테스트도 쉬워지고
  • 추후 리팩토링도 간편해진다