우당탕탕 개발일지

[방치RPG 서버 제작기] 11. 점검모드 & 화이트리스트 관리 본문

Server/방치RPG 서버

[방치RPG 서버 제작기] 11. 점검모드 & 화이트리스트 관리

devchop 2025. 5. 24. 11:51

 

오늘은 점검모드 on/off 하고, deviceId를 기반으로 화이트리스트 유저를 관리해보자. 

점검시 일반유저는 접근할 수 없고, 화이트리스트에 속한 유저만 접근할 수 있다.


1. 점검모드를 위한 필터링 제작

config/ServerConfig.java

점검인지 아닌지를 저장하는 boolean값과, 화이트리스트 deviceId를 저장한 Set<String> 으로 이루어져있다. save, load, 그리고 데이터를 가져오는 get, set 함수들로 이루어져있다. 

@Component
public class ServerConfig {
    private boolean maintenanceMode = false;

    private final Set<String> allowedUsers = ConcurrentHashMap.newKeySet();

    private final Path configPath = Paths.get("config", "server-config.json");

    public ServerConfig() {
        load();
    }

    public void setMaintenanceMode(boolean mode) {
        this.maintenanceMode = mode;
        save();
    }

    public boolean isMaintenanceMode() {
        return maintenanceMode;
    }

    public boolean isAllowed(String deviceId) {
        if (deviceId == null) return false;
        return allowedUsers.contains(deviceId);
    }

    public void addAllowedUser(String deviceId) {
        allowedUsers.add(deviceId);
        save(); // 저장도 함께!
    }

    public Set<String> getAllowedUsers() {
        return Collections.unmodifiableSet(allowedUsers); // 읽기 전용으로 반환
    }

    public boolean removeAllowedUser(String deviceId) {
        if (deviceId == null || deviceId.isBlank()) return false;

        boolean removed = allowedUsers.remove(deviceId);
        if (removed) save(); // 제거됐을 때만 저장
        return removed;
    }

    private void save() {
        try {
            Files.createDirectories(configPath.getParent());
            Map<String, Object> data = new HashMap<>();
            data.put("maintenanceMode", maintenanceMode);
            data.put("allowedUsers", allowedUsers); // ✅ 이거 꼭 추가!

            String json = new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(data);
            Files.writeString(configPath, json);
        } catch (IOException e) {
            System.out.println("❌ server-config.json 저장 실패: " + e.getMessage());
        }
    }

    private void load() {
        if (!Files.exists(configPath)) return;
        try {
            String json = Files.readString(configPath);
            if (json.isBlank()) {
                System.out.println("⚠️ server-config.json is empty. Using default config.");
                return;
            }

            JsonNode node = new ObjectMapper().readTree(json);
            this.maintenanceMode = node.get("maintenanceMode").asBoolean(false);

            // optional: allowedUsers도 읽기
        } catch (Exception e) {
            System.out.println("server-config.json 로딩 실패: " + e.getMessage());
        }
    }
}

 

config/MaintenacneFilter.java

API 호출이 들어왔을때 점검인지 아닌지를 체크하여 , 만약 점검이고 화이트유저가 아닌 경우 실패를 반환하는 필터링역할을 수행한다.

주의할 점이 몇가지 있다.

  1. API요청 시 accessToken 안에 들어있는 deviceId를 찾는다.  login API 의 경우 아직 accessToken이 없으므로 deviceId == null이 나온다. 이를 위해, 로그인 API의 경우 예외적으로 헤더에 deviceId를 넣도록 수정해야 한다.
  2. /api/admin 의 경우 점검설정/해제 , 화이트리스트 추가/삭제를 하는 API들이다. 이는 필터링이 작동하지 않아야 하므로, 예외처리 했다.
  3.  
@Component
@Order(1)  // 우선순위 높게 설정
@RequiredArgsConstructor
public class MaintenanceFilter extends OncePerRequestFilter {

    private final ServerConfig config;
    private final JwtProvider jwtProvider;
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String path = request.getRequestURI();

        // admin API는 점검 예외
        if (path.startsWith("/api/admin")) {
            filterChain.doFilter(request, response);
            return;
        }

        if (config.isMaintenanceMode()) {
            String deviceId = null;

            // 로그인 요청이라면 헤더에서 deviceId 허용
            if (path.startsWith("/api/user/auth")) {
                deviceId = request.getHeader("X-Device-Id");

                System.out.println("로그인 API. path:"+path+", deviceId:"+deviceId);
            } else {
                // 그 외 요청은 JWT에서 추출
                String token = jwtProvider.resolveToken(request);
                if (token != null) deviceId = jwtProvider.getDeviceIdFromToken(token);

                System.out.println("일반 API. deviceId:"+deviceId);
            }

            // 공통 차단 로직
            if (deviceId == null || !config.isAllowed(deviceId)) {
                response.setStatus(HttpStatus.SERVICE_UNAVAILABLE.value());
                response.setContentType("application/json");
                response.setCharacterEncoding("UTF-8");
                response.getWriter().write("""
                {
                    "code": "MAINTENANCE",
                    "message": "점검 중입니다"
                }
            """);
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

}

 

 

admin/controller/AdminController.java

API 구현부. 점검 설정/해제, 화이트리스트 추가/삭제, 점검상태 가져오기 등으로 이루어져 있다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin")
public class AdminController {

    private final ServerConfig config;
    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    @PostMapping("/set-maintenance")
    public ResponseEntity<?> setMaintenance(@RequestParam boolean mode) {
        config.setMaintenanceMode(mode);

        if (mode) {
            messagingTemplate.convertAndSend("/topic/maintenance", Map.of(
                    "message", "점검 중입니다",
                    "code", "MAINTENANCE"
            ));
        }

        return ResponseEntity.ok("Maintenance mode: " + mode);
    }

    @GetMapping("/maintenance-status")
    public ResponseEntity<?> getMaintenanceStatus() {
        return ResponseEntity.ok(Map.of(
                "maintenance", config.isMaintenanceMode()
        ));
    }

    @GetMapping("/allowed-users")
    public ResponseEntity<?> getAllowedUsers() {
        return ResponseEntity.ok(Map.of(
                "allowedUsers", config.getAllowedUsers()
        ));
    }

    @PostMapping("/allow-user")
    public ResponseEntity<?> addAllowedUser(@RequestParam String deviceId) {
        if (deviceId == null || deviceId.isBlank()) {
            return ResponseEntity.badRequest().body("deviceId가 유효하지 않습니다.");
        }

        config.addAllowedUser(deviceId);
        return ResponseEntity.ok("허용된 유저 추가됨: " + deviceId);
    }

    @PostMapping("/allowed-user/delete")
    public ResponseEntity<?> removeAllowedUser(@RequestParam String deviceId) {
        if (config.removeAllowedUser(deviceId)) {
            return ResponseEntity.ok("허용된 유저 제거됨: " + deviceId);
        } else {
            return ResponseEntity.ok("허용되지 않은 유저 ");
        }
    }
}

 

 

@RestController
public class StatusController {

    @GetMapping("/health")
    public String health() {
        return "OK";
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestHeader("X-Device-Id") String deviceId) {
        if (MaintenanceConfig.isMaintenanceMode() && !WhitelistManager.isAllowed(deviceId)) {
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                    .body("점검 중입니다.");
        }
        // 로그인 로직 진행
        return ResponseEntity.ok("Login Success");
    }
}

2. server-config.json 마운트

점검설정파일인 server-config.json 파일은 서버가 종료되더라도 정보를 유지하기 위해 꼭 필요하다. 그런데 docker를 사용할 경우, 로컬파일과 마운트하지않으면 재배포할때마다 server-config.json 파일이 삭제된다.

이를 위해서, 서버에 server-config.json 파일을 만들고,이를 docker 내에서 마운트하여, 데이터가 손실되지 않도록 해야한다.

 

docker-compose.yml 파일

app: 하위에 아래와 같이 볼륨을 마운트한다.

 

참고로 server-config.json의 경로는 .jar 위치 기준으로 ./config/server-config.json 이다. 

volumes:
  - ./config/server-config.json:/app/config/server-config.json

 

 

 

3. accessToken에 deviceId 정보 추가하기

대부분의 API 요청에서, accessToken 안에 있는 deviceId를 추출하여 화이트유저 검사를 진행한다. 그러려면 accestoken에 deviceId 정보를 넣어야 한다. 기존에 있던 jwtProvider에서 수정가능하다.

 

acessToken에 deviceId 추가하기

public String createToken(Long userId, String deviceId) {
        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))
                .claim("deviceId",deviceId)
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

 

accessToken에서 deviceId 뽑기

public String getDeviceIdFromToken(String token){
        if (token == null || token.isBlank()) {
            System.out.println("토큰이 비어있습니다.");
            return "";
        }
        // "Bearer " 접두어가 있으면 제거
        if (token.startsWith("Bearer ")) {
            token = token.substring(7).trim(); // "Bearer " 제거 후 공백 제거
        }
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();

        return claims.get("deviceId",String.class);
    }

 

4. [클라이언트] 로그인 API에서 deviceId 헤더 추가하기

앞에서 말했듯이, 로그인을 시도하는 유저는 accessToken이 없기 때문에 deviceId를 뽑아올 수 없다. 이를 위해 로그인 API만 예외적으로 헤더에서 deviceId를 뽑게 되어있다. 클라이언트쪽에서는 아래처럼 RequestHeader에 "X-Device-Id" 값을 추가한다.

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("X-Device-Id", deviceId); // ✅ 로그인 API에만 헤더추가
            request.SetRequestHeader("Content-Type", "application/json");
            request.SetRequestHeader("Accept", "application/json"); 

            yield return request.SendWebRequest();

            if (request.result == UnityWebRequest.Result.Success)
            {
               //..

            }
            else
            {
               //..

            }
        }

 

 

아직 문제가 많다.

1. Amin API를 누구나 접근이 가능하다. 관리자만 요청할 수 있게끔 제약이 필요하다.

2. 점검상태로 변환되었을 때 WebSocket을 이용해 클라이언트쪽에 이벤트를 호출해야한다. 실시간게임이 아니라 방치RPG이기 때문에, 점검상태로 바뀌어도 게임을 계속 플레이하는 현상을 막아야한다.

 

이 두개는 조금 나중에 해보기로 하자.. WebSocket 연결에서 계속 실패해서..흑흑