2025년 11월부터 2026년 1월까지 약 3개월간 데이터 마이그레이션에 대부분의 힘을 쏟아부었다.
사용자의 데이터 (1000만개) 를 구별하기 위한 키 값을 전환하는 것이 작업의 궁극적인 목표였다.
그 와중에 데이터 증식(?)이 일어나는 버그도 발견해서 소스 코드 긴급 수정 및 데이터 클렌징 작업도 하고... 참 다사다난 했다.
그래서 그 경험을 정리해보려고 한다!
참 서러운 부제이다.
SI회사의 특성상 위에서 내려오는 방침대로 우리 조직은 움직일 수 밖에 없다. 하지만 요구사항이 너무나도 추상적이었다. 키 값을 바꾸라는 것은 알겠는데, 무슨 키값을 사용할 것인지 정해져 있지도 않고, 키 값을 관리하고 있는 서비스의 API는 개발도 안되어있고... 모든 것을 내가 직접 수소문해가며 알아가야만 하고, 요청해야만 했다.
처음에는 스트레스였다.
한가지 사항의 논의가 마무리되면 다른 곳에서 엣지케이스가 또 튀어나오고... 고난의 연속이었다.
근데 또 다른 사람들과 소통하며 사양을 하나하나 정해가는 재미도 있긴 했다. 원래라면 알지 못했던 도메인의 전반적인 그림을 그릴 수 있게 되었고, 도메인 지식도 많이 늘었다. 해당 도메인의 전문가가 된 기분이었다.
아무쪼록, 데이터 마이그를 위한 고려사항이 얼추 정리가 되면서 데이터 마이그 전략을 수립했다. 고려사항과 계획은 다음과 같다.
<고려 사항>
키 값 전환을 위해 호출할 API 서버의 영향도를 최소한으로 해야하며,
1월 이내의 모든 데이터를 마이그레이션해야 한다.<계획>
1. 어플리케이션 소스 코드에서 키 값 전환 로직 선 반영
2. 배치를 통해 전환되지 않은 사용자 데이터 전체 전환
처음에는 API서버의 응답값으로 키 값을 전환하는 것이 참 비효율적이라고 생각했다. 그래서 키 값 DB를 덤프떠서 공유해줄 수 있지 않을까 부탁드렸는데 거절당했다...ㅠ
만약 1000만건의 데이터를 10TPS의 HTTP요청으로 전환하려면 대략 12일동안 풀타임으로 배치를 돌려야 한다. 새벽시간에만 작업한다고 하면 36일이 걸린다.
이는 불가능하다.
그렇다면 최선은 미리 어플리케이션에서 전환하는 로직을 추가하는 것이다.
일반적으로 사용자의 데이터 업데이트는 조회 → 수정 흐름으로 이루어진다.
이를 활용해 다음과 같은 로직을 추가했다.
(물론 Index는 미리 걸고 작업했다!)
조회 시 특정 key 값이 없으면 → API 호출 후 key 값 채워넣기
이러한 로직을 반영해놓고 보니 엣지 케이스가 추가로 더 생겼다.
치명적이지 않은 데이터이긴 하지만, 기존 키값을 기반으로 신규 키값이 없는 경우가 존재했다. 차주가 바뀐 경우가 그러했는데, 이 부분은 비즈니스 중요성/민감도가 아주 낮은 데이터에서만 발생하는 현상이라 Known Issue로 아직 해결방안을 논의중이다.
12월에 어플리케이션에서 키값 전환 로직을 배포하고, 1월에 진행할 데이터 배치 작업을 계획했다.
신규 key 값이 없는 데이터를 Fetching 해오고, API Call 을 통해 Upsert
그런데 데이터 양을 산정하다가, 특정 데이터가 비이상적으로 많다는 사실을 알게 되었다.
처음에는 히스토리를 잘몰라서, 그냥 뭐 데이터가 많이 쌓였나 보지? 라고 생각했었는데 알고보니 가입자 수만큼 데이터가 존재해야 하는 도메인이었다.
우리나라 국민이 5000만명인데 2억 5천만명이 가입할 일은 없는 노릇이었다..
데이터 증식의 원인을 찾아보니 범인은 소스코드였다.
서버로 인입되는 요청중에 사용자 키값이 없는 경우가 존재해서 이를 채워넣기 위해 타 MSA 서비스 응답값을 활용하는데 응답값에서 필드값을 가져오는 것이 아니라, 또다시 Request Body의 키값으로 upsert하는 로직으로 인해 끊임없이 가비지 데이터가 양산되는 구조였다.
바로 핫픽스..나갔다.. 이걸 이제야 알았다니!
처음에는 2억여 건의 데이터를 신규 key값으로 전환하기 위한 배치 계획을 세웠었다.
2억여건중 500만개의 데이터만 실제 데이터였고 (거의 2%),로컬 DB에서 실행계획/성능검증도 진행해본 결과 인덱스 효과를 톡톡히 볼 수 있었다.
upsert하는 다큐먼트 수도 2%의 데이터라서 upsert로 인한 인덱스 쓰기 비용이 성능을 크게 뒤흔들 것 같진 않았다.
하지만, 찜찜하기도 하고 기술 부채를 남기고 싶지도 않았다. 아무리 새벽에 작업을 한다고 하지만 혹여나 서비스의 영향도가 있지 않을까 하는 두려움, 그리고 내가 안하면 언젠가 누군가 해야하는 일이었다. (그리고 그게 내가 될것 같기도 했다ㅋ) 그래서 그냥 내가 하기로 했다!
데이터 클렌징 방법에는 두가지가 있다. (DB는 mongoDB다)
아무래도 99%의 데이터가 삭제대상이라서 1번을 하기 많이 부담이 되었다.
그렇다면 2번은 빠를까?
직접 해보면 되지!
해봤더니 1억건중 3백만건을 신규 컬렉션으로 이관하는게 2분30초안에 끝났다.
데이터 삭제를 위한 Prmiary 쓰기 비용도 없고 Read에는 문제가 없는 이 방법으로 진행하기로 했다!
근데 한가지 고려해야할 부분은 있었다. 전환 과정에서 기존 컬렉션 다큐먼트에 Update 요청이 인입되면 그 데이터는 신규 컬렉션에 존재하지 않을 수 있으니 유실될 수도 있다.
<데이터가 유실되면 안되는 경우>
- 어플리케이션 소스 코드 반영
(1) 둘다 쓰기
신규 컬렉션 생성 이후 update 요청들에 대해 기존 컬렉션과 신규 컬렉션의 둘다 upsert & write
(2) 데이터 이관
이후 기존 컬렉션에서 신규 컬렉션으로 aggregation 이관<데이터가 유실되도 괜찮은 경우>
그냥 이관.
다만 Collection renaming을 할것인지 말지 선택이 필요
- Renaming 한다
Renaming 도중 컬렉션에 전체 Lock 발생.
만일 쿼리는 날아갔는데, 도중에 컬렉션 Lock으로 인해 요청이 대기한다면 어플리케이션 커넥션풀 고갈로 장애 발생 가능성 증가- Renaming 안한다
이관 이후 어플리케이션 소스코드에서 신규 컬렉션을 바라보도록 배포 딸깍
클렌징 대상 데이터의 성격상, 유실이 있다고 하더라도 사용자들이 불편함을 모른다 (그만큼 중요치 않고, VOC가 여태껏 없었다). 또한 클렌징 작업은 새벽에 작업하기도 하고, update 요청도 굉장히 드물다.
그래서 데이터 유실을 감안하고 작업하기로 했다.
다만, Read요청은 초당 10TPS정도로 꽤나 많은 요청이 인입된다. MongoDB 커넥션 풀의 개수가 Default 100개로 설정되어 있는데, 만일 Renaming에 10초이상 소요된다면 장애가 발생할 여지가 존재했다.
그래서 Renaming 없이 어플리케이션에서 바라보는 컬렉션을 변경하는 배포를 진행하기로 했다.
지금 글을 작성하는 오늘 새벽 이관을 진행했는데 많은일이 존재했다..
그래서 작업시간이 거의 5시간이 걸렸다..
이번 작업을 하면서 한가지 알아간 사실은 $out 방식으로 기존 컬렉션 dump로 이관하는 방식은 컬렉션을 생성하는 마지막 단계에서 오래걸릴 수도 있다. 기존 컬렉션 덤프 내용을 메모리든 디스크에 올려놓고 그대로 복사하는 작업이라 금방 끝날줄 알았는데 그게 아니었다. (다른 브랜드에서 정상적인 flow로 작업했을 때에는 한 15분정도 걸린것 같다 - 이때에는 약 250만개의 데이터? 약 8기가정도였던 것 같았다)
위 6번에서 문제가 되었던 부분이.. 아마 중간에 내가 컬렉션을 drop했기 때문이 아닐까 싶다. 정확히 원인은 알 수가 없지만..
이제 남은 것은 배치이다.
Chunk 단위로, TPS조절이 가능하며, 엣지케이스를 고려한 쿼리, 인덱스도 다 마련해 놓은 상태이다.
성능 검증도 어느정도 된 것 같다.
엣지 케이스를 고려할때 Partial Index를 도입했다.
엣지 케이스가 앞에서 말한 신규 key값이 존재하지 않는 경우인데, 이 경우에는 신규 key값을 upsert칠 수 없게된다.
만일 API call 대상의 다큐먼트를 찾기 위한 쿼리가 다음과 같다면
find(newKey : ${exist:false})
엣지케이스의 경우에는 이미 한번 요청이 되어서 실패했지만 newKey가 없으므로 다시 Http 요청 대상에 포함되어질 것이다.
이를 방지하기 위해서 한번 요청이 끝났지만 newKey가 없는 대상을 후보지에서 제외 하기 위해 flag key 값을 upsert할 것이다.
그 이후, find쿼리는 다음처럼 작성한다.
find(newKey : ${exist:false}, flag : {$exist: false})
partialIndex는 newKey가 존재하지 않는 대상으로 filter하게끔 생성하면 된다.
(참고)
find(flag : {$exist: false})
이렇게 해도 partial index를 타긴 하더라!
pod를 local로 port-forwarding해서 local 에서 내부 API endpoint로 요청하여 받아온 응답값을 토대로 DB에 직접 접근하여 upsert하는 방식으로 진행할 예정이다.
하지만.. 카펜터 때문인지 pod가 node로 부터 evict되는 drain 현상이 있어서 pod와의 연결이 끊길 수 있었다.
찾아보니 service name으로 포트포워딩하면 알아서 pod랑 붙을 수 있다는 사실을 알게되었다.
그런데도 불구하고, 포트포워딩 대상의 pod는 항상 고정되므로.. 문제는 여전했다.
이는 .bat파일로 스크립트를 만들어서 port forwarding 연결이 끊기면 자동으로 다시 service name 기준으로 port forwarding 될 수 있도록 스크립트를 실행하여 해결할 수 있었다. fail over도 성공적이었다!
이젠 실행만 남았다.
사실 배치라는 마지막 관문이 남았긴 한데..
3달간의 긴 여정을 마무리하는 느낌이라 설레발치며 글을 작성해봤다.
뭐 잘 되겠지~!