우당탕탕 개발일지

[방치RPG 서버 제작기] 7. backup API 구현하기 본문

Server/방치RPG 서버

[방치RPG 서버 제작기] 7. backup API 구현하기

devchop 2025. 4. 26. 11:31

 

액세스 토큰 관리하기(클라이언트)

로그인은 액세스토큰이 없어도 되지만, 로그인 후에 수행하는 대부분의 api는 액세스 토큰이 필요하다. 이를 위해, 클라이언트에서 액세스토큰을 관리해야한다. 서버에서 헤더로 액세스토큰과 리프레시 토큰을 발급하기 때문에, 헤더에서 뽑아낸 후 LocalTokenStore에 저장한다. 

 

    private IEnumerator SendGuestLogin(string deviceId, Action<ResponseCode> handler)
    {
        GuestLoginRequestDto payload = new GuestLoginRequestDto { deviceId = deviceId };
        string json = JsonUtility.ToJson(payload);

        using (UnityWebRequest request = new UnityWebRequest(guestLoginUrl, "POST"))
        {
            byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(json);
            request.uploadHandler = new UploadHandlerRaw(bodyRaw);
            request.downloadHandler = new DownloadHandlerBuffer();

            request.SetRequestHeader("Content-Type", "application/json");
            request.SetRequestHeader("Accept", "application/json"); // ✅ 이거 중요!

            yield return request.SendWebRequest();

            if (request.result == UnityWebRequest.Result.Success)
            {
                // ✅ 헤더에서 토큰 추출
                string accessTokenHeader = request.GetResponseHeader("Authorization");
                string refreshTokenHeader = request.GetResponseHeader("X-Refresh-Token");

                // 액세스 토큰에서 "Bearer " 제거 (있다면)
                string accessToken = accessTokenHeader?.StartsWith("Bearer ") == true
                    ? accessTokenHeader.Substring(7)
                    : accessTokenHeader;

                string refreshToken = refreshTokenHeader;

                // ✅ 응답 바디에서 rsaPublicKey, userData 추출
                string responseText = request.downloadHandler.text;
                LoginResponseDto data = JsonUtility.FromJson<LoginResponseDto>(responseText);

                DebugX.Log($"[Token] Access: {accessToken}");
                DebugX.Log($"[Token] Refresh: {refreshToken}");
                DebugX.Log($"[RSA] PublicKey: {data.rsaPublicKey}");
                DebugX.Log($"[UserData] Raw JSON: {data.userData}");

                LocalTokenStore.AccessToken = accessToken;
                LocalTokenStore.RefreshToken = refreshToken;
                LocalTokenStore.RsaPublicKey = data.rsaPublicKey;

            }
            else
            {

                Debug.LogError($"Login Failed. url:{guestLoginUrl}, reason : {request.error} ( code {request.responseCode} )");
            }
        }
    }

 


 

유저 백업 데이터 전송하기

  • 유저들의 데이터를 json화 한다. 
  • 결과값을 AES 로 암호화한다.
  • 서버에서 받아서 AES로 복호화한다
  • 데이터를 각 processor 에게 보내 저장한다.

위 순서대로 진행한다. AES 암호/복호화에 사용하는 key는 클라와 서버에서 동일한값을 가지고있다. 

public static string encrypt(string plainText)
        {

            using var aes = Aes.Create();
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;
            aes.Key = Encoding.UTF8.GetBytes(key);
            aes.IV = new byte[16];

            using var encryptor = aes.CreateEncryptor();
            byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
            byte[] encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);

            return Convert.ToBase64String(encryptedBytes);
        }

 

 

 

이제 서버에서 데이터를 받을 경우 , 아래 함수를 이용해서 복호화를 진행한다. AESUtil 이라는 클래스를 만들어서 복호화를 진행한다. 

주의할점은, 클라이언트의 암호화 방식과 서버의 복호화 방식을 정확하게 맞춰줘야 한다. PaddingMode 와 Mode 를 똑.같.이. 맞춰준다

private static final IvParameterSpec IV = new IvParameterSpec(new byte[16]); // 16바이트 0 초기화

public static String decrypt(String base64Encrypted) throws Exception {
    byte[] encrypted = Base64.getDecoder().decode(base64Encrypted);

    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), "AES");

    cipher.init(Cipher.DECRYPT_MODE, keySpec, IV);
    byte[] decrypted = cipher.doFinal(encrypted);

    return new String(decrypted, StandardCharsets.UTF_8);
}

 

[Error] io.jsonwebtoken.io.DecodingException: Illegal base64url character: ' '

유저 백업 API 호출 시 이런 에러가 발생했다. 처음엔 AES 암호/복호화에서 발생하는 문제인 줄 알았으나, 알고보니 액세스토큰에서 userId를 뽑아오는 부분에서 에러가 발생한거였다....

 

아래는 BackupController 의 backup 함수이다.

 @PostMapping("/backup")
    public ResponseEntity<?> backupData(@RequestBody BackupRequestDto request,
                                        @RequestHeader("Authorization") String token) {
        try {
            String decryptedJson = AESUtil.decrypt(request.getData());
            Long userId = jwtProvider.getUserIdFromToken(token);

            // 2. JSON → DTO 매핑
            UserBackupDto backupDto = objectMapper.readValue(decryptedJson, UserBackupDto.class);

            // 3. 유효성 검사
            ValidationResult result = BackupDataValidator.validate(backupDto);
            if (!result.isValid()) {
                return ResponseEntity.badRequest().body("검증 실패: " + String.join(", ", result.getErrorMessages()));
            }
            
            backupService.saveBackup(userId, backupDto);

            return ResponseEntity.ok(new BackupResponseDto("백업 성공"));

        } catch (Exception e) {
            e.printStackTrace(); // 실운영에서는 로거로 기록
            return ResponseEntity.internalServerError().body(new BackupResponseDto("백업 성공"));
        }
    }

 

 

 

 

jwtProvider에서 토큰을 이용해 userId를 가져오는 부분이 있는데, 들어가보면, Bearer 라는 문자열이 토큰에 포함되어있을 경우, 이부분을 제거한 다음 parsing 하는 부분이 빠져있어서 발생한 오류였다.  JwtFilter에서는 Bearer 를 잘 제거했는데 , 여기에서 에러가 날 줄은...흑흑 삽질만 2시간 ㅠㅠ

public Long getUserIdFromToken(String token) {
        if (token == null || token.isBlank()) {
            throw new IllegalArgumentException("토큰이 비어있습니다.");
        }

        // "Bearer " 접두어가 있으면 제거
        if (token.startsWith("Bearer ")) {
            token = token.substring(7).trim(); // "Bearer " 제거 후 공백 제거
        }

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

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

 

[Error] 백업 실패: JSON parse error: Invalid value. 

서버에서는 db저장이 정상적으로 되는데, 클라이언트에 에러문이 반환되는 현상이 발생했다. 액세스토큰을 검증해야하는 API의 경우, BackendManager에서 공통적으로 SendRequest<T> 함수를 이용해 통신을 진행한다. 여기서 T는 ResponseDto 객체를 넣어준다. 즉, 서버에서는 무조건 객체형식으로 반환을 해줘야 한다는 의미이다.

 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, ""); // 초기화용, 나중에 method 오버라이드
        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();

        // UnityWebRequestAsyncOperation은 Task가 아니므로 수동 대기
        var operation = request.SendWebRequest();
        while (!operation.isDone)
            await Task.Yield();

        // AccessToken 만료 시 → refresh 시도
        if (request.responseCode == 401)
        {
            bool refreshSuccess = await BackendTokenHandler.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.responseCode}]: {request.error}");
            throw new Exception(request.error);
        }
    }

 

기존에 서버쪽에서는 "백업성공" 과 같이 문자열로 반환을 했는데, 이 반환형이 맞지 않아서 에러가 발생한 것이었다. 따라서 BackupAPI 쪽 반환형을 객체로변경하면 해결된다. 

 return ResponseEntity.ok(new BackupResponseDto("백업 성공"));//성공시
 return ResponseEntity.internalServerError().body(new BackupResponseDto("백업 실패."+e.getMessage())); //실패시