소감 & 회고록

[회고] 1.4TB로 커져 버린 DB 테이블 교체하기

왈왈디 2024. 7. 21. 16:43
728x90

0. Intro

재직 중인 회사에서 담당한 서비스의 숙원사업이었던
포인트 테이블 개선 작업을 18주 동안 진행하여 RDS 사용량을 크게 줄인 후 남기는 회고입니다.

1. 문제의 시작 - 포인트 로그 테이블

담당 서비스 DB에 포인트 획득/사용의 기반이 되는 테이블이 있었습니다. 일명 포인트 디테일 로그 테이블은 모든 포인특 획득/사용 기록을 건당 저장합니다. 현재 유저가 보유한 포인트 계산은 포인트 디테일 로그 테이블을 기반으로 획득 내역은 더하고, 사용 내역은 감하여 계산됩니다.

포인트 디테일 로그   테이블

서비스 초기에는 이 포인트 기록, 조회 방식이 문제가 되지 않았습니다. 그러나 시간이 지날수록 쌓이는 데이터가 너무 많아졌고, 현재 포인트 디테일 로그 테이블의 크기는 1.4TB로 성장했으며, 레코드 개수는 96억개를 넘었습니다.

(과거 id를 int로 설정하여 테이블을 생성했다가, 21억을 넘는 순간 에러가 발생해 제 2의 포인트 디테일 로그 테이블을 새로 생성하게 되었습니다.)

 

가장 큰 문제는 유저들이 자신의 보유 포인트 내역을 조회하는 데 너무 긴 시간이 걸린다는 것이었습니다.

20만개 이상의 레코드를 보유한 유저도 있어, 레코드가 많은 유저들은 보유 포인트를 조회하기 위해 30초 이상 걸렸고,

timeout 이슈로 결과를 확인할 수 없는 경우도 발생했습니다.

 

DB에서도 가장 큰 부하를 일으키는 주범이었습니다. 전체 로드의 70% 이상을 차지하여 비용적으로도 문제가 있었습니다.

2. 해결 방안 도출 - 신규 월별 포인트 테이블 도입

  • [기존 포인트 디테일 로그 테이블 스키마]
model 포인트 디테일 로그 {
  id                 BigInt @id @default(autoincrement())
  금액                Int
  획득/사용 여부		Boolean
  포인트 사용/획득처 id  Int
  유저 id             Int
}

 

근본적으로 문제의 고리를 끊어내기 위해 신규 포인트 테이블을 도입하기로 했습니다.

포인트 로그가 많이 쌓이더라도
더 빠르게 조회가 가능하도록 월별로 포인트 내역을 묶어 저장하는 테이블을 설계했습니다.

  • [월별 포인트 테이블 스키마]
model 월별 포인트 {
  유저 id          Int           
  년도+월           Int           
  각 포인트 타입     Int    @default(0) @db.UnsignedInt
  월별 잔액         Int    @default(0)
}

월별 포인트 테이블에서는 유저 id년도+월(KST)을 복합 Primary key로 하여 매월 포인트를 합산합니다.

 

포인트 디테일 로그 테이블에서는 건당 포인트의 타입 id를 기록했으니,

월별 포인트 테이블에서는 이 부분을 어떻게 살릴지 고민했습니다.

 

결국 타입별로, 획득/사용 여부에 따라 각각 2개씩 필드를 생성하여 타입&획득/사용 여부 별로 합산되게 하고,

모든 필드의 총 합산을 계산하는 월별 잔액 필드를 추가했습니다.

3. 테이블 도입 과정

테이블을 교체하는 작업을 크게 7단계로 나누어 보면 아래와 같습니다.

  1. 포인트 획득/사용 시 월별 포인트 테이블 업데이트
    • 신규 월별 포인트 테이블에 포인트 획득/사용마다 데이터를 저장하는 코드를 배포한다.
    • 조회 기능들은 기존 테이블 데이터에 의존하고 있기에, 삭제하지 않는다.
    • 기존 포인트 로직에 어떤 영향을 주지 않도록, 트랜잭션을 걸지 않는다.
  2. 트랜잭션 추가
    • 동작에 이상이 없다면 포인트 획득/사용에 대해 월별 포인트 테이블과 포인트 디테일 로그 테이블 싱크가 어긋나지 않도록 (1) 작업을 트랜잭션 처리한다.
  3. 배치 스크립트 실행
    • (1) 작업으로 데이터가 쌓이기 시작하면, 신규 월별 테이블에 역대 포인트 디테일 로그 테이블의 데이터를 모두 반영하는 배치 스크립트를 실행한다.
    • 배치 로그 테이블을 사용하여, 유저별로 배치 스크립트 완료 여부와, 배치 결과의 정합성 여부를 기록한다.
  4. 포인트 조회 로직을 월별 포인트 테이블 기반으로 변경
    • 배치 로그 테이블을 사용하여, 전체 유저에 대해 배치가 완료되지 않더라도, 완료된 유저만 분기처리 하여 포인트 디테일 로그 테이블 테이블을 기반으로 조회 하던 로직을 모두 월별 포인트 테이블로 교체한다.
  5. 배치 스크립트 실행 완료
  6. 예외 케이스 처리
    • 기획팀과 논의하여 포인트에 이상이 있는 예외 케이스들을 처리한다. (1% 의 예외 케이스가 존재했음)
  7. 월별 포인트 테이블 로직만 남기고, 기존 로직 제거
    • 모든 유저에 대해 스크립트 실행이 완료되면, 분기 처리 했던 기존 로직을 제거한다.

4. 이슈 해결 - Prisma 커넥션 관리 & 병렬 작업 효율화

작업을 진행하며 여러 기술적 어려움이 있었으나, 

그 중 가장 긴 시간이 소요된 건 500만 유저의 96억개 데이터를 
새로운 테이블로 마이그레이션 하는 작업이었습니다.

 

많은 양의 데이터였기에 최대한 빠르고 효율적으로 배치 스크립트를 실행할 방법을 강구해야 했습니다.

작성한 배치 스크립트의 구조는

  • 유저별로 포인트 디테일 로그 테이블에서 신규 월별 포인트 테이블로 모든 데이터 이전
  • 이번달 기준 데이터 합산이 서로 동일한지 확인
  • 배치 로그 테이블에 해당 유저의 스크립트 완료 여부 기록

이었고, 이 유저별 실행 로직을 몇 명씩 병렬로 실행할 지가 관건이었습니다.

 

병렬 실행 인원이 많으면 많을 수록 속도가 빨라질 수 있으나,

동시에 DB에 연결되는 커넥션 수가 많아집니다.

 

Prisma를 사용하고 있었는데,

Prisma에서는 워커 당 DB 커넥션 수의 최대치(커넥션 limit)를 제한할 수 있습니다.

 

커넥션 limit을 늘리고 병렬 실행 인원을 늘릴 수록 속도는 빨라지는 대신
DB connection이 많아져 DB에 많은 부하를 주게 됩니다.

 

DB는 유저들이 사용하는 상용 서버에도 연결되어 있으므로,

배치 스크립트 작업으로 인해 DB에 연결된 커넥션 수가 최대 커넥션 수에 도달하여

DB가 정상 동작하지 않고

이로 인해 상용 서버가 DB에 정상 접근 하지 못한다면 큰 문제가 되기 때문에

마냥 속도를 빠르게 하는 것이 해답은 아니었습니다.

병렬 실행 개수에 따른 실행 시간 테스트

 

또 스크립트가 실행되며 한 유저 당 하나의 커넥션만을 사용하는 것이 아니라,

유저 내에서도 각 로직별로 새로운 커넥션을 사용하기 때문에

병렬 실행 개수는 전체 커넥션 limit에 훨씬 못 미치는 수로 설정해야만

커넥션 대기 시간 timeout이 발생하지 않고

스크립트 작업이 중단 없이 실행될 수 있었습니다.

 

여러 옵션을 테스트하여 워커 당 Prisma 커넥션 limit을 300으로 설정하고

80개씩 병렬 실행하는 것으로 결정했습니다.

 

이전에는 Prisma connection pool이 어떤 식으로 동작하는지 잘 이해하지 못했으나,

이번 기회에 살펴보게 되었습니다.

5. 성과

DB Load와 CPU 사용량 지표가 약 50% 이상 눈에 띄게 감소했습니다.

RDS writer, reader 인스턴스 모두 절반으로 줄여 비용도 크게 절감했습니다.

6. 장기 작업을 완수할 수 있었던 이유

작업 기간이 길어질 수록

작업이 무산되거나, 중단 후 잊혀지는 경우가 많습니다.

 

18주라는 기간 동안 무산되지 않고 진행될 수 있었던 이유에 대해
아래 2가지를 꼽아 보았습니다.

 

1. 작업의 중요성 공유

장기 작업들이 완수되지 못하고

뒤로 밀려나는 이유는 높은 우선순위를 유지하지 못했기 때문이라고 생각합니다.

 

장기 작업이들은 마감 기한이 명확하지 않은 경우가 많고,

그렇기에 당장 해결되어야 할 것 같은 일들에 우선 순위가 밀리고 밀려

위로 올라오지 못하고 계속해서 방치되는 상황이 발생합니다.

 

본 작업은 백엔드팀 내에서 비용을 크게 줄일 수 있고

유저의 서비스 질을 개선하는

중요한 작업으로 판단되었고,

이를 PM, 기획팀, 프론트 개발팀 등 팀내 다른 조직에도 공유하여

우선 순위를 낮출 수 없음을 설득했습니다.

 

덕분에 다른 업무가 들어오더라도

병행하거나, 이 일을 우선하여 작업할 수 있었습니다.

 

2. 뚜렷한 성과 지표

또 다른 이유는

완수 시 성과가 가시적으로 드러나는 업무였기 때문입니다.

 

포인트 테이블 이슈가 DB에 부하를 크게 주는 상황이었고,

해결하면 바로 DB 부하가 줄어들고,

RDS 비용이 줄어드는 상황이었기에 목표가 뚜렷했습니다.

 

포인트 테이블 용량 때문에

포인트 조회 API의 처리 시간이 30초가 넘어 timeout되는 케이스가 많이 발생했는데,

작업이 진행되면서 처리시간이 1초 이하로 줄어들고,

포인트를 조회할 수 없었던 유저들이 실시간으로 조회가 가능해졌다는 피드백을 받을 수 있었습니다.

 

뚜렷한 성과 지표 덕분에,

작업자 개인의 성취감도 크고,

완수 시 드러날 성과를 기대하며

끝까지 동기 부여를 받으며 작업할 수 있었습니다.

 

비용 및 성능 면에서 정량적으로 성과가 드러난다는 점에 의해

팀 내에서 본 작업의 중요성에 대해 공감을 얻기도 수월했습니다.

7. 느낀점

처음에는 팀내에서 워낙 오랫동안 묵혀온 문제이기도 하고,

잘못 되면 서비스에 큰 영향을 미치기에 엄두가 나지 않았는데,

18주 동안 업무를 작은 단위로 쪼개고 쪼개어 하나씩 해나가다 보니 어느새 완수할 수 있었습니다.

 

개인적으로 배치 작업도 처음 해보고,

이만큼 범위가 큰 작업은 경험해보지 못해서 많은 것을 배우고 성장하는 기회가 되었습니다.

 

수익성면에서(비용을 줄였으므로) 가시적인 성과를 내는 경험도 처음이라 매우 보람있고 의미있는 작업이었습니다.

 

진행하면서 어려운 과정도 정말 많았는데, 포기하지 않고 숲을 보려고 하는 태도가 중요한 것 같습니다.

대부분의 문제들은 NestJS, MySQL, Linux, CS에 대한 저의 지식이 부족해 발생했다는 생각이 들어,

평소에도 꾸준히 공부하며

문제가 발생했을 때 신속하고 정확하게 원인을 파악하여 대처할 수 있는 능력을 길러야겠다는 생각이 들었습니다.

728x90