우당탕탕 개발일지

[방치RPG 서버 제작기] 6. 전송 암호화, DB테이블 구조 분리하기 본문

Server/방치RPG 서버

[방치RPG 서버 제작기] 6. 전송 암호화, DB테이블 구조 분리하기

devchop 2025. 4. 25. 17:41

클라에서 암호화 > 서버에서 복호화하기

중요한 데이터는 암호화해서 전송해야한다. backup API에서 암호/복호화를 진행해보자

 

RSAUtils 작성하기 : 암호/복호화를 하기위한 util 클래스이다. 시작할때 키를 생성하고 암호화/복호화가 있다 ( 우선 여기서는 복호화만 사용할 예정이다). 그리고 클라이언트에게 전달할 publicKey를 리턴하는 함수도 있다.

 

package com.esaAdventure.dante_server.common.utils;

import java.security.*;
import java.util.Base64;
import javax.crypto.Cipher;

public class RSAUtil {

    private static KeyPair keyPair;

    static {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(2048);
            keyPair = generator.generateKeyPair();
        } catch (Exception e) {
            throw new RuntimeException("RSA 키 생성 실패", e);
        }
    }

    public static String decrypt(String encryptedData) throws Exception {
        byte[] bytes = Base64.getDecoder().decode(encryptedData);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
        byte[] decrypted = cipher.doFinal(bytes);
        return new String(decrypted);
    }

    public static String encrypt(String rawData) throws Exception {
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic());
        byte[] encrypted = cipher.doFinal(rawData.getBytes());
        return Base64.getEncoder().encodeToString(encrypted);
    }

    // ✅ 공개키를 Base64 문자열로 리턴
    public static String getPublicKey() {
        return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
    }
}

 

로그인 응답인 LoginResponseDto 에서 rsaPublicKey도 같이 반환한다. 참고로, 이전단계에서 LoginResponseDto 에 accessToken과 refreshToken을 담아서 전달했었는데, 헤더에 두 토큰을 넣는것으로 수정했다. 

@Data
public class LoginResponseDto {

    private String userData; // JSON string
    private String rsaPublicKey;

    public LoginResponseDto(String rsaPublicKey, String userData) {

        this.rsaPublicKey = rsaPublicKey;
        this.userData = userData;
    }
}

 

UserController 의 guest login API 를 다음과 같이 수정했다.

  1. rsaUtil 에서 publicKey를 받는다.
  2. accessToken 과 refreshToken을 받아서 응답 헤더에 넣어준다.
 @PostMapping("/auth/guest")
    public ResponseEntity<LoginResponseDto> guestLogin(@RequestBody GuestLoginRequestDto request) {
        User user = userService.guestLogin(request.getDeviceId());
        Long userId = user.getId();

        HttpStatus status = user.isNewlyCreated() ? HttpStatus.CREATED : HttpStatus.OK;

        if (user.isNewlyCreated()) {// 신규 유저라면 기본 데이터 생성
            backupService.provideDefaultData(userId);
        }

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

        UserBackupDto backupDto = backupService.exportBackup(userId);
        String json = JsonUtil.toJson(backupDto);

        // 📦 서버 DB에 refreshToken 저장 (예: 유저ID별로)
        authService.saveRefreshToken(userId, refreshToken);

        LoginResponseDto response = new LoginResponseDto(RSAUtil.getPublicKey(),json);
        return ResponseEntity.status(status)
                .header("Authorization", "Bearer " + accessToken)
                .header("X-Refresh-Token", refreshToken)
                .body(response); // 또는 body 없이 헤더 기반 처리

    }

 

 


 

Processor 를 이용해 테이블별로 분리하기

이제 backup 시에 entity > json 으로 변환하거나 혹은 json> dto 로 변경하기, 수정된 데이터 저장하기 등의 역할을 하는 BackupService를 구현한다. 그런데 테이블이 많아질수록 BackupService가 무거워지기 때문에, 테이블별로 분리하는 작업을 했다

 

먼저, 테이블 별 작업을 담당하는 Processor 의 인터페이스를 제작한다. 이름은 BackupProcessor.java 이다.

  • process : db값과 비교하여 내용이 변경되었을 경우 db에 수정내용을 적용한다.
  • extract : db값을 dto형식으로 변환하여 dtoOut 변수에 넣어준다 
  • provideDefault : 처음 가입 시 기본으로 제공되는 재화를 넣어준다.
public interface BackupProcessor {
    void process(Long userId, UserBackupDto backupDto);
    void extract(Long userId, UserBackupDto dtoOut);
    void provideDefault(Long userId);
}

 

UserBackupDto 는 유저가 보유하고있는 전체 데이터들을 모아놓은 클래스이다. 리스트 속 요소는 Equipment 가 아니라 EquipmentDto 이다. Equipment는 db에 직접적으로 저장되기 때문에, 반환형으로 직접 가져다 쓰는것은 굉장히 위험하다. 따라서 dto 형식으로 따로 만들어서 전달하는것이 안전하다. Equipment는 userId 과 고유id들까지 있지만 EquipmentDto 는 전달에 필요한 정보 (itemId, level, cnt) 만 저장된다.

@Data
public class UserBackupDto {
    private List<EquipmentDto> equipments;
    private List<SkillDto> skills;

}

 

 

 

이제 BackupProcessor 를 상속받은 EquipmentBackupProcessor 를 보자. 만약 equipement 테이블 내용이 변경된다면, 이 파일만 수정하면 된다.

package com.esaAdventure.dante_server.backup.processor;

import com.esaAdventure.dante_server.backup.dto.EquipmentDto;
import com.esaAdventure.dante_server.backup.dto.UserBackupDto;
import com.esaAdventure.dante_server.backup.entitiy.Equipment;
import com.esaAdventure.dante_server.backup.repository.EquipmentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class EquipmentBackupProcessor implements BackupProcessor {

    private final EquipmentRepository equipmentRepository;

    @Override
    public void process(Long userId, UserBackupDto dto) {
        if(dto.getEquipments() == null) return;

        for (EquipmentDto e : dto.getEquipments()) {
            equipmentRepository.findByUserIdAndItemId(userId, e.getId())
                    .ifPresentOrElse(
                            entity -> {
                                if (entity.updateFromDto(e)) {
                                    equipmentRepository.save(entity);
                                }
                            },
                            () -> {
                                equipmentRepository.save(EquipmentMapper.toEntity(userId,e));
                            }
                    );
        }
    }

    @Override
    public void extract(Long userId, UserBackupDto dtoOut) {
        List<Equipment> entities = equipmentRepository.findAllByUserId(userId);

        List<EquipmentDto> dtoList = entities.stream()
                .map(EquipmentMapper::toDto)
                .collect(Collectors.toList());
        dtoOut.setEquipments(dtoList);
    }

    @Override
    public void provideDefault(Long userId) {
        Equipment defaultWeapon = new Equipment(userId, 1, 1, 1);
        equipmentRepository.save(defaultWeapon);
    }


    public static class EquipmentMapper {
        public static EquipmentDto toDto(Equipment e) {
            EquipmentDto d = new EquipmentDto();
            d.setId(e.getItemId());
            d.setLevel(e.getLevel());
            d.setCount(e.getCount());
            return d;
        }

        public static Equipment toEntity(Long userId, EquipmentDto d) {
            return new Equipment(userId, d.getId(), d.getLevel(), d.getCount());
        }
    }
}

 

 

BackupService에서는 모든 BackupProcessor 을 보유하고있다가, 각 processor 에게 extract 혹은 process 등의 함수를 일괄 호출하는 역할을 담당한다. 명령만 내리기 때문에, 굉장히 간결하다

package com.esaAdventure.dante_server.backup.service;

import com.esaAdventure.dante_server.backup.dto.UserBackupDto;
import com.esaAdventure.dante_server.backup.processor.BackupProcessor;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class BackupService {


    //스프링이 @Component로 등록된 모든 BackupProcessor 구현체를 리스트로 주입해줌!
    private final List<BackupProcessor> processors;

    @Transactional
    public void saveBackup(Long userId, UserBackupDto dto) {
        for (BackupProcessor processor : processors) {
            processor.process(userId, dto);
        }
    }
    @Transactional(readOnly = true)
    public UserBackupDto exportBackup(Long userId) {
        UserBackupDto dto = new UserBackupDto();
        for (BackupProcessor processor : processors) {
            processor.extract(userId, dto); // 각 Processor가 dto를 채워줌
        }
        return dto;
    }

    @Transactional
    public void provideDefaultData(Long userId) {
        for (BackupProcessor processor : processors) {
            processor.provideDefault(userId);
        }
    }

}

 

실제 API 호출부인 BackupController를 보자. 5.저장처리에서 명령어 한줄로, 변경된 내용만 알아서 각 processor로 전달된다. 호출부는 간결하니 좋다!


    @PostMapping
    public ResponseEntity<?> backupData(@RequestBody BackupRequest request,
                                        @RequestHeader("Authorization") String token) {
        try {
            // 1. 복호화
            String decryptedJson = RSAUtil.decrypt(request.getData());

            // 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()));
            }

            // 4. 유저 식별
            Long userId = jwtProvider.getUserIdFromToken(token);

            // 5. 저장 처리
            backupService.saveBackup(userId, backupDto);

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

        } catch (Exception e) {
            e.printStackTrace(); // 실운영에서는 로거로 기록
            return ResponseEntity.internalServerError().body("백업 실패: " + e.getMessage());
        }
    }

 

 

내일은 Validator 를 구현해보자.. 지금 게임 데이터가 다 유니티 안에 있어서, 이걸 서버로 어떻게 옮겨야 할지 걱정이 많다. 또 고민해보면 어떻게 되겠지~