나르지지

8만 건 데이터 DB 마이그레이션 자동화 구축

changha. 2025. 8. 6. 12:26

들어가며

나르지지 서비스는 Riot에서 제공하는 대회 데이터 기반으로 분석 서비스를 제공하는 것을 목표로 합니다. 

서비스 모식도

위와 같이 서버 단에서 데이터 수집 및 정제 후 DB에 저장하여 가공한 서비스를 제공하는 것이 목표입니다. 

 

이를 위해 주기적으로 업데이트되는 Google Drive 파일을 우리 서비스에서도 똑같이 업데이트해야됩니다. 

 

진행 사항 

스케줄링 

Google Drive 파일이 6시간 간격으로 업데이트 됩니다.

@Scheduled(cron = "0 30 4,10,16,22 * * ?", zone = "Asia/Seoul")

 

 이에 맞춰 저희 서비스에서도 6시간 간격으로 스케줄링을 설정하였습니다.

 

Data Ingestion 

Data Ingestion 시퀸스 다이어그램

DataIngestionFacade

배치처리에 있어서 가장 중심이 되는 Class입니다. 

청크 단위를 조절할 수 있고 청크별 트랜잭션을 적용하도록 하였습니다. 

뒤에나올 GameProcessor, EntityResolver를 활용하여 청크별로 DB에 저장 할 엔티티들을 한번에 insert하는 역할을 합니다. 

 

EntityResolver

Game 데이터에 필요한 연관 엔티티(Player, Champion 등)의 중복을 방지하고, DB 영속성을 보장하는 역할입니다.

  • Player 이름이나 Champion Key 같은 식별자로 DB에 해당 엔티티가 이미 존재하는지 확인합니다.
  • 존재하지 않는다면, 새로운 엔티티를 생성하여 DB에 저장하고 반환합니다.
  • 이미 존재한다면, DB에서 조회한 엔티티를 반환합니다.

GameProcessor 

개별 게임 데이터(DTO)를 받아 완전한 Game 엔티티로 조립하고 완성합니다. 

  • 게임 DTO에 포함된 Player, Champion, Ban 등의 정보를 EntityResolver에 보내 영속화된 엔티티를 받아옵니다.
  • 전달받은 영속 엔티티들과 게임 결과 데이터를 조합하여 최종 Game 엔티티와 하위 GameParticipant 엔티티 등을 생성합니다.

+) 연관 엔티티는 이 과정에서 자동으로 함께 완성 후 저장됩니다. 

 

문제 상황 / 해결

N+1 문제 

League N+1 문제

 

LeagueTeam 추가 시 N+1문제

명시적 N+1 문제 

이전 문제 : EntityResolver가 League를 찾기 위해 forEach 루프 안에서 findBy... 쿼리를 N번 실행했습니다.

현재 해결책 : resolveEntitiesByName이라는 제네릭 메서드를 통해 이 문제를 해결했습니다

  1. 필요한 정보 미리 수집하기 : 필요한 모든 리그의 이름과 년도를 각각 Set 자료구조를 활용하여 미리 수집합니다. 
  2. 'IN' 절로 '한 번에' 조회하기 : 수집한 leagueNamesToFind와 yearsToFind를 가지고 Repository에 단 한 번 요청합니다.

 

암묵적 N+1문제 

이전 문제 : GameProcessor의 for 루프 안에서 league.addLeagueTeam()을 호출하여 LAZY 상태인 leagueTeams 컬렉션에 접근했고, 이로 인해 추가 쿼리가 발생했습니다.

현재 해결책 : 이 문제는 JOIN FETCH와 역할/책임의 이동을 통해 해결했습니다.

  • JOIN FETCH로 동시 로딩 : LeagueRepository의 findLeaguesWithTeams...와 같은 메서드는 내부적으로 JOIN FETCH를 사용하도록 구현되었습니다. 이 덕분에 League를 조회할 때, 연관된 LeagueTeam 데이터까지 한 번의 쿼리로 미리 가져옵니다.

 

처리 시간 단축

@GeneratedValue(strategy = GenerationType.IDENTITY)로 모든 테이블이 적용되어 있는 상황입니다. 

따라서 JDBC 배치 INSERT 기능을 사용할 수 없었습니다.

현재 8만건 데이터를 처리하는데 약 6분의 처리시간이 걸렸습니다. 이를 더 줄여보고자 하였습니다. 

생각한 방안들

  1. SEQUENCE 전략 + PreLoading 방식 
  2. 순차적 UUID + 기존 방식 

 

1. SEQUENCE 전략 + PreLoading 방식 

현재 방식 : 각 청크마다 필요한 팀, 선수 정보를 확인하기 위해 DB에 계속해서 SELECT 쿼리를 실행합니다. 

개선 방식

  • 메모리에 PreLoading한 후 이후 불필요한 DB 조회를 제거하는 방식입니다.
  • SEQUENCE 전략을 통해 배치처리가 가능합니다. 

개선 방식 적용
기존 방식 - 개선 방식과 비슷한 그래프

 

로컬환경에서는 1분 30초가 걸리며 약 5분이 단축되며 획기적으로 개선되었습니다. 

하지만 배포환경(vCPU : 2, Memory : 1GB)인 환경에서는 7분정도로 기존 방식과 처리시간이 크게 차이나지 않았습니다. 

위 사진처럼 모니터링 환경에서도 거의 비슷하게 동작하였습니다. 

 

2. 순차적 UUID + 기존 방식

순차적 UUID를 적용하여 배치처리를 가능하게 하였습니다. 

하지만 로컬환경에서도 처리시간에 큰 변화가 없었습니다.

 

  기존 방식 프리로딩 + 배치(50) 기존 + 배치(50) 기존+배치(200) 기존+배치(1000)
로컬 환경 약 6분 1분 30초 약 6분 약 5분 약 6분
배포 환경 약 8분 약 7분 - - -

 

제한된 리소스 환경에서는 I/O 기반 작업의 성능을 코드 최적화만으로 단축하는 데 근본적인 한계가 있음을 확인했습니다.

 

전략적 선택

처음 로딩을 제외하고는 주기적으로 발생하는 동기화 작업만 빠르게 해내는 것이 비즈니스 요구사항입니다. 

따라서 전체 데이터를 매번 처리하는 비효율을 없애고 변경된 데이터만 처리하는 '델타 로드(Delta Load)' 방식을 도입하였습니다. 

 

구현 방식

  • 마지막으로 처리된 gameId를 별도의 테이블(sync_status)에 기록
  • 다음 동기화 시점에는, 이 ID 이후의 데이터만 필터링하여 처리

최종 성능

  • 초기 데이터 적재 (1회성 작업): 약 8만 7천 건, 약 8분 소요.
  • 주기적 업데이트 (6시간 간격): 평균 10~20게임 (120~240건), 수 초(seconds) 이내 완료.

 

챔피언 이름 불일치 문제 

Riot 측 제공 챔피언 이름과 저희가 가진 CSV파일의 챔피언 이름의 차이점이 있었습니다. 

  • 특수문자 및 공백 : Vel'Koz vs. Velkoz, Kai'Sa vs. Kaisa
  • 별칭 또는 다른 이름 : Wukong vs. Monkey King, Nunu & Willump vs. Nunu

위와 같이 챔피언 이름이 DB(Riot 공식 데이터 기반)에 있는 것과 다를 경우 해당 Game을 스킵한다는 문제점이 있었습니다. 

 

해결책 : NameNormalizer를 이용한 데이터 표준화

→  항상 동일한 하나의 표준 데이터로 DB와 CSV 파일의 챔피언 이름을 통일하였습니다. 

  1. 불필요한 요소 제거 : replaceAll()을 사용해 이름에 포함된 공백, 특수문자(', ., - 등)를 모두 제거
  2. 예외 케이스 처리 : switch 문을 사용해 '오공'처럼 이름이 완전히 다른 경우나, '누누와 윌럼프'처럼 이름이 합쳐져야 하는 특수한 케이스를 직접 처리 (다행히 3개의 챔피언만 해당)
  3. 최종 표준화 : 모든 처리 후, 단어의 첫 글자는 대문자로, 나머지는 소문자로 통일하여 최종 표준 형식을 완성

 

향 후 계획 

더 안정적인 서비스를 위한 비동기 아키텍처 구축 

아래와 같은 문제에 대응하기 위해 메시지 큐(MQ)를 활용한 비동기 처리 구조를 도입하고자 합니다.

 

현재 잠재적 문제점

위험 1 : 외부 API 응답 지연 문제 (I/O Blocking)

  • 만약 Google Drive API 응답이 늦어지거나, 네트워크 문제로 1분 동안 응답이 없다면, 스케줄러 스레드는 1분 동안 아무것도 못 하고 대기해야 합니다.

위험 2 : 대용량 데이터 처리 문제 (긴 처리 시간)

  • 갑작스러운 서비스 중단으로 인해 대량의 데이터가 쌓일 수 있고 그로 인해 델타 로드 시간이 오래 걸릴 수 있습니다. 서버의 CPU와 메모리를 장시간 점유하여 전체적인 API 응답성을 저하시킬 수 있습니다.