본문 바로가기
개발 일지/카카오VX

실시간 유저 재화를 위한 kafka 도입

by StelthPark 2024. 8. 16.

1.  작업 배경

앱 내에 기존에 유저 테이블에서 유저들의 보유한 재화를 포함한 정보들을 저장하고 있었다. 실시간으로 update 될 만한 기능이 존재하지 않아서 API 요청에 따른 RDB데이터를 업데이트하곤 했었는데 이번 신규 기능 업데이트로 인해 유저가 보유한 재화를 실시간으로 지급, 소모시켜야하는 경우가 생겼다. 현재 구조로는 실시간으로 update를 할 시, DB 부하가 심할 것으로 예상되어 다른 구조로 개선 작업이 필요 했다.

 

2.  작업 방향

유저가 보유한 재화를 redis에 저장하고 비동기로 메세지 큐(MQ)를 사용하여 RDB에 최종적으로 히스토리를 저장하는 형태로 구조를 변경하기로 했다. 빠르게 데이터를 저장하고 삭제, 수정할 수 있는 한 휘발성 데이터이기에 재화에 관련된 데이터가 날아간다면 복구를 위한 백업파일이 있어야 한다. 그 역할을 히스토리처럼 남기기 위해 RDB에 재화 업데이트 기록을 남겨두게 됐다. 히스토리는 사실상 빠르게 적재 될 필요는 없으므로 MQ로 비동기적으로 잘 저장만 되면 됐다.

 

시스템 구조는 아래와 같다.

 

각 서비스 별로 기존에는 User entity를 사용해 DB를 업데이트했지만 해당 기능을 유저 재화 별도로 처리하는 모듈을 만들었고 모든 서비스별로 해당 모듈을 추가하여 재화를 처리하는 방식을 변경하였다.

 

해당 모듈 내 서비스는 유저 재화는 redis에 저장 또는 조회하고 MQ에 publish를 한다. 이 후 재화 관련 행위가 있을 때 마다 로그를 적재했었던 것처럼 로그 적재 요청을 하게 된다. consumer는 message poll를 통해 RDB 히스토리 테이블에 재화 변경 이력을 insert 하게 된다.

 

3.  개발 간 발생한 문제점

개발을 완료하고 테스트를 진행 하던 중 RDB 히스토리에 예상치 못한 결과가 발생했다. 앱 내 기능중 채팅에 참가중인 유저들에게 실시간으로 재화를 증가시키는 기능이 있었는데 500명의 유저들에게 일괄적으로 재화를 업데이트하고 그 히스토리를 남기게 되었다. redis에 적재된 최종 재화량은 일치했지만 히스토리가 이상하게 쌓였다.

 

몇 명의 유저들에게서 히스토리가 누락되는 문제가 발생했는데 첫번째 원인은 MQ로 사용중인 카프카에 publish를 할 때면 고유한 메세지 값을 주었었는데 이때 메세지 값을 타임스탬프와 유저 고유번호로 만들었었다. 만약 같은 messageId가 컨슈머에서 받는다면 하나만 처리하게 로직이 짜져있었는데 타임스탬프까지 동일한 어떤 요청이 2개 들어가다보니 하나만 처리하게 되어 다른 하나의 재화 처리 히스토리는 적재되지 못한 것이였다. 이때, 보상을 발생시키는 완전 고유한 seq도 함께 메세지에 달아서 보내주면서 해결은 됐다.

 

또한 재화 처리 방식의 redis 클라이언트 코드를 보다 보니 이상한 점을 발견했다. 재화를 업데이트하기 위해 이전에 재화를 GET 한 다음 새로운 재화를 계산하여 SET을 하고 있었는데, 빠르게 재화가 처리되어야하는 상황이 아니라면 문제가 없겠지만 이전 처럼 거의 동시간대에 2번의 재화 상승 요청이 들어온다면 첫번째 재화 상승요청이 끝나기전에 두번째 재화 상승의 요청 get에서 이전 값의 업데이트 된 값을 불러오지 못할 것이다. 결론은 재화가 0에서 시작했다면 10, 10 을 상승시켜 20이되어야하지만 둘다 0에서 10을 상승시켜 결과는 10이 되될 것이다.

 

기존에 이런문제가 생기지 않았다. user 컬럼을 업데이트 할 때 get-set이 아닌 increment방식을 썼기 때문이다.  아래와 같이 get-set이 아니였다.

  "\
  UPDATE TB_USER \
  SET TRAINING = TRAINING + ?, \
      BDST = BDST + ?\
  WHERE SEQ_NO = ?; \
";

 

그래서 redis 에서도 get-set이 아닌 increment 메소드를 사용하는 방식으로 수정하였다. 레디스의 연산은 기본적으로 Atomic이 보장된다. 하지만 get-set의 경우 여러서버에서 동시에 요청을 하게 된다면 잘못된 값을 보여주게 된다. 이럴때는 increment 같은 메소드를 써야 이 여러 서버가 동시에 명령을 날려도 synchronized 키워드를 쓴 것처럼 명령어가 묶여서 실행되기에 Atomic이 보장된다.

 

redis에서 이러한 함수는 hincrbyfloat 이 있다 소수까지 계산하는 increment 메소드로 앱 에서는 소수점 둘째짜리 까지 재화를 계산하기에 해당 함수를 사용하였고 아래아 같이 redis 클라이언트 기능 함수를 수정하였다.

  async updateAllUserAsset(userSeq, addedUserBdp, addedUserTraining) {
    const updateUserBdp = this._cache.hincrbyfloat(
      `user.asset.${userSeq}`,
      constants.USER_ASSET_TYPE.BDP,
      addedUserBdp
    );
    const updateUserTraining = this._cache.hincrbyfloat(
      `user.asset.${userSeq}`,
      constants.USER_ASSET_TYPE.TRAINING,
      addedUserTraining
    );
    return await Promise.all([updateUserBdp, updateUserTraining]);
  }

 

두개의 재화는 promise.all를 통해 병렬적으로 처리하도록 하여 처리속도를 보장하였다. 사실 예전에 동시에 요청을 들어오는 것을 막기 위해("따딱 이슈")를 막고자 get-set에 대해 학습한 적이 있어서 이번의 경우는 조금 더 빠르게 원인을 파악할 수 있었던 것 같다.

 

이후 다시 한번 500명의 재화를 0으로 수정 한 뒤 채팅 시스템을 열고 재화 히스토리의 적재를 확인 했고 쌓인 데이터를 계산해보니 정확하게 일치하는 것을 확인 할 수 있었다.

댓글