Post

로그를 활용한 사용자 차단과 관리시스템 개발

부적절한 요청을 보낸 유저를 차단하고, 올바른 데이터로 복구하기위해 로그를 어떻게 활용하였는지 설명합니다.

개발 배경

현재 운영하고 있는 메이플 주문서 시뮬레이터메이플스토리라는 게임에 있는 아이템을 원하는 대로 강화할 수 있는 서비스이다. 실제로 인 게임내에서 강화를 하기 위해서는 많은 돈이 필요하기 때문에 돈을 들이지 않고 강화를 사전에 경험해 보고싶은 사람들을 위해서 만들었다.

게임 내에는 최고 기록 이라는 기능이 존재한다. 강화를 가장 잘 해낸 사람이 이름과 함께 기록을 띄워주면, 강화 페이지 옆에 누가, 몇번의 시도로 얼마나 강화에 성공했는지 보여준다.

image

그리고 홈페이지에 전체 아이템 강화 랭킹을 보여준다.

문제는 API 스펙만 알고 있으면 누구든지 minified 된 코드를 조작해서 원하는대로 요청을 보낼 수 있다는 것이다. 아래는 한사람이 1위 부터 10위까지 기록을 조작한 상태를 보여준다.

image

강화중인 사용자의 상태를 DB에서 관리하고 강화 성공,실패 여부를 백엔드에서 관리하면 문제를 해결할 수 있다. 하지만 배포에 사용하고 있는 컴퓨터의 사양이 좋지 않아 기각되었다. 백엔드에서 관리하면 사용자가 강화를 시도하는 키보드 단축키를 하나 누를 때마다 서버로 요청이 날아오기 때문에 100명의 사용자만 동시접속해도 성능이 느려지기 때문이다.

그래서 데이터의 정합성 문제를 먼저 개선하기로 하였다.

  • 기록에 도전한 사용자의 이름에 부적절한 단어가 포함되어 있는지 확인한다.
  • 강화에 성공한 횟수가 특정 범위를 초과하지 않았는지 점검한다.
  • 강화를 시도한 주문서가 해당 아이템에 실제로 사용할 수 있는지 검토한다.
  • 해당 주문서로 강화를 여러 번 성공했을 때 상승하는 능력치가 올바른지 확인한다.

하지만 데이터 정합성에 맞도록 데이터를 조작하여 요청하는 것까지는 막아주지 못하는 문제가 있었다.

기록의 복구 측면에서도 문제가 있었다. 부적절한 요청으로 기록을 등록했을 때 데이터베이스에 쿼리를 날려 직접 기록을 삭제해 줄 수 있다. 하지만 이전에 그 아이템이 기록으로 등록되어 왔던 로그가 없었기 때문에 이전의 값으로 기록을 복구하는 것이 아니라 삭제할 수 밖에 없었다. 조작 이전에 정당한 요청으로 기록을 등록했던 유저의 기록은 그냥 사라져버리고 마는 것이다.

그래서 요구사항은 다음 두가지이다.

  1. 기록을 조작한 유저가 서비스를 이용할 수 없도록 차단할 것.
  2. 부적절한 요청으로 만들어진 기록을 삭제하고 이전의 기록으로 복구할 것.

이 두가지 요구사항을 만족하기 위해서는 특정 기록을 누가 등록했는지, 그리고 아이템이 강화되어 온 과정이 필요했고 그래서 아이템이 강화할 때마다 로그를 기록하기로 했다.

개발

기록을 조작한 유저를 차단하기

기록을 조작한 유저를 찾았다면 그 유저가 누군지 알아야한다. 기록에 대한 로그 정보를 저장하는 테이블에 IP 속성도 포함하였다. 아래의 과정은 기록을 등록할 때 클라이언트 IP정보도 함께 등록된다는 것을 전제로 한다.

사용자의 IP 주소를 어떻게 알아낼 것인가?

처음에 생각했던 방법은 WAS에서 컨트롤러로 받아온 HttpServletRequestgetRemoteAddr()메서드를 사용하는 것이다. 그러나 이 방법은 이전에 요청을 보내온 곳의 주소를 반환하기 때문에 로드밸런서나 프록시 서버를 거치는 경우 클라이언트의 실제 IP 주소가 아닌 로드밸런서, 프록시 서버의 주소를 가져오게 된다.

따라서 사용자의 실제 IP주소를 HTTP 요청 헤더에 담아 WAS 로 전달하는 방법을 선택하였다. HTTP표준에서는 X-Forwarded-For 헤더가 그 역할을 한다. 헤더의 역할에 대한 내용은 여기서 자세히 다루지 않겠다.

어디서 사용자의 IP 주소를 담아야하는가?

클라이언트에서 헤더를 직접 담는 것은 절대 하면 안된다. 악의적인 의도를 가진 사람이 X-Forwarded-For 헤더로 IP주소가 전달되는 것을 보고 얼마든지 헤더를 조작할 수 있기 때문이다.

로드밸런서나 프록시 서버에서 클라이언트 요청을 처리할 때 X-Forwarded-For, X-Real-IP 헤더도 추가하도록 해야한다. 아래는 내가 작성한 nginx 설정파일이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
server {
  #...

	location /api {
        proxy_pass http://gongnomok.site:8080;
        # Add X-Forwarded-For and other necessary headers
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
    }

    #...
}

/api 경로의 요청은 WAS로 전달하면서 X-Real-IPX-Forwarded-For 헤더를 설정하고 있다. $remove_addr 는 이전에 요청을 보낸 클라이언트의 IP 주소를 의미하고, $proxy_add_x_forwarded_for 는 이전 클라이언트의 IP주소를 ,로 구분해서 제일 앞서 작성해준다.

사용자의 IP 주소는 로드밸런서나 프록시 서버에서 설정된 X-Real-IP, X-Forwarded-For 헤더를 통해 알아낸다.

서버 IP 차단

백엔드 서버는 스프링 부트 기반으로 구현되었다. 차단된 유저가 모든 서비스에 접근하지 못하도록 하기 위해, 스프링 인터셉터를 사용하여 차단 기능을 구현했다. 웹 관련 공통 관심사 기능을 구현하는 데에는 인터셉터가 가장 적합하다고 판단했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
@RequiredArgsConstructor
@Slf4j
public class IpAccessInterceptor implements HandlerInterceptor {

    public static Set<String> blackList = new HashSet<>(List.of(<<임시 차단 IP 목록>>));
    
    @Override
    public boolean preHandle(
        final HttpServletRequest request,
        final HttpServletResponse response, 
        final Object handler
    ) throws Exception {

        String xForwardedFor = request.getHeader("X-Forwarded-For");
        String realIPHeader = request.getHeader("X-Real-IP");

        if (blackList.contains(realIPHeader)) {
            log.warn("블랙리스트 사용자 접근 - IP Address: {}", realIPHeader);
            response.sendError(403);
            return false;
        }

        return true;
    }
}

X-Real-IP 헤더로 클라이언트의 IP 정보를 가져와 블랙리스트에 존재하는 IP라면 403응답과 함께 false를 반환하여 더이상 요청이 진행될 수 없도록 한다. 지금은 IP 블랙리스트를 임시로 정적변수로 선언했지만 수정될 예정이다. 차단된 IP 목록을 저장한 테이블을 만들고 주기적으로 접근해 차단정보를 갱신하는 방식으로 구현할 계획이다.

만든 인터셉터를 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final IpAccessInterceptor ipAccessInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(ipAccessInterceptor)
            .addPathPatterns("/**");
    }
}

차단된 유저는 모든 서비스를 사용할 수 없도록 해야하므로 모든 경로(/**)에 인터셉터가 적용되도록 설정하였다.

이전 기록 복구

이전에는 최고기록만을 테이블에 저장해두었다. 새로운 기록이 발생하였을 때 기존 튜플을 업데이트하는 방식이였기 때문에 이전의 기록을 조회할 수 있는 방법이 없었다. 따라서 부적절한 요청으로 만들어진 기록 이전의 것으로 복구가 불가능한 문제가 있었다.

로그 기록

새로운 기록이 갱신될 때마다 로그가 기록되도록 설정하였다. 로그테이블에는 아이템이 얼마나 강화되었는지 정보가 포함되어야하기 때문에 다음과 같은 정보들이 포함되었다.

  • 어떠한 능력치가 얼마나 올라갔는지
  • 강화를 성공한 사람의 닉네임, IP는 무엇인지
  • 10%, 60%, 100% 주문서를 몇번씩 성공했는지

등등 여러가지 정보를 담을 수 있도록 테이블을 설계했다. 이제 사용자가 기록갱신에 성공할 때마다 새로운 로그 가 테이블에 insert 된다.

기록등록과 로그의 트랜잭션

여기서 한가지 고민점이 생겼다. 기록강화에 성공해 새로운 기록을 등록하는 것과 로그를 남기는 것이 하나의 트랜잭션에서 수행되어야할까?

만약 로그가 단순히 참고용이고 로그가 남는 것이 중요하지 않다면 별도의 트랜잭션으로 처리했을 것이다. 하지만 기록관리 차원에서 로그가 없으면 기록의 복구가 불가능하기 때문에 로그를 남기는 것은 아주 중요한 작업이다. 따라서 두 작업을 하나의 트랜잭션으로 묶어 처리하는 것이 좋다고 생각했다. 두가지가 모두 성공하거나, 모두 실패해야한다.

복구 매커니즘

부적절한 기록을 삭제했다면 어떻게 새로운 기록을 복구할 수 있을까? 아이템의 고유식별자(item_id), 그리고 기록의 점수(score) 정보를 가지고 수행된다.

  1. 부적절한 기록을 삭제한다.
  2. 부적절한 기록이 등록된 아이템과 동일한 아이템 식별자(item_id)를 가지면서 점수가 가장 높은것을 새로운 기록으로 등록한다.
  3. 해당 아이템에 대한 로그가 없다면 아무런 동작도 하지 않는다.

간단한 흐름으로 수행된다.

This post is licensed under CC BY 4.0 by the author.