본문으로 건너뛰기

· 약 10분
제이
박스터

안녕하세요~ 우테코 카페인 팀의 제이입니다.

오늘은 카페인 팀의 프로젝트를 진행하면서 '박스터'와 함께 어떤 문제를 겪고 해결했는지 적어보도록 하겠습니다.

  • 배우는 단계이다 보니 틀린 부분이 있을 수 있는데, 피드백 부탁드립니다 :)

먼저 글을 쓰기 전에 문제 상황에 대해 간단하게 말씀드리겠습니다.

문제 상황

카페인 팀에서는 전기차 충전소 공공 API를 활용하여 충전소의 혼잡도 제공 및 여러 서비스를 제공합니다.

이런 서비스를 사용자들에게 제공하기 위해서 다음과 같은 작업들이 필요합니다.

  1. 첫 실행시 공공 API 데이터를 모두 불러서 데이터베이스에 삽입합니다.
  2. 혼잡도를 제공하기 위해서 주기적인 시간 (아직 정하진 않았지만 ex.12시간) 단위로 충전소와 충전기의 상태를 업데이트 하기 위해서 다시 데이터를 요청을 합니다.
  3. 새롭게 추가된 충전소와 충전기는 모두 Insert해주고, 기존에 있던 충전소 혹은 충전기가 업데이트 됐다면 변경된 데이터로 업데이트 해줍니다.

저랑 박스터는 2~3번 과정을 진행하는 역할을 맡았습니다.

테이블의 관계는 다음과 같습니다.

charge_station <---1------N---> charger
charger <---1------1---> charger_status

저희는 이 문제를 어떻게 해결 했는지 보겠습니다.

문제 해결 과정

전제조건

  • 첫 실행 모든 테이블은 초기화 상태이다.
  • 데이터는 9999건을 기준으로 한다.
  • 메서드 첫 시행에서는 모든 데이터가 새롭게 insert 되고
  • 그 다음 메서드 시행에서는 일부 데이터는 추가되고, 일부는 업데이트 된다.

Ver1. findAll() 조회 후 각각 save() 해주기 (약14초)

저희가 처음에 생각한 방법입니다. 알아서 바뀐 것들은 업데이트 해주고, 새로운 건 저장해주기 때문에 간단한 방법으로 생각했습니다.

실제로 해본 결과, 삽입의 경우는 SELECT 쿼리문 실행 후 INSERT 쿼리문을 발생 시켰고, 업데이트 시에도 SELECT 후 UPDATE 혹은 INSERT를 발생 시켰습니다. (변경 사항 없으면 SELECT만)

이는 식별자에 따른 JPA 작동 방식 때문인데요. 이 방법의 결과는 약 14초가 나왔습니다.

저희는 이렇게 불필요한 SELECT 작업을 막아보고자 다른 방법을 구상해봤습니다.

기본적으로 Jdbc를 이용해서 Batch Insert와 Batch Update를 사용하기로 했고, 이 작업을 위해서 변경 혹은 삽입될 데이터들을 직접 찾는 과정이 중요했습니다.

Ver2. 변경 감지를 직접 해주고, 자료구조로 배치 데이터 모으기 : O(n^2) (약 11초)

두 번째로 저희가 생각한 방법입니다. 먼저 데이터 추가 및 변경 감지 부분입니다.

기존 업데이트 시에 SELECT와 UPDATE(or INSERT) 두번의 쿼리가 나가는 것이 맘에 들지 않아서 변경 감지를 직접 해주려고 메서드를 만들었습니다.

저희가 생각한 변경 감지는 생각보다 간단한데요. 도메인에 메서드를 만들어서 필드를 if문으로 하나씩 비교해줬습니다.

충전소의 데이터 특징상 데이터가 자주 바뀌는 데이터는 비교적 초반에 비교하도록 구현하고, 자주 바뀌지 않는 데이터는 후에 비교하도록 만들었습니다.

그리고 데이터 저장 및 업데이트 부분입니다. 먼저 findAll()로 충전소와 충전기 등 관련된 모든 데이터를 Map에 넣었습니다. Map<stationId, Station>의 구조로 기존에 테이블에 저장된 모든 데이터를 자료구조에 넣었습니다.

그리고 공공 API를 불러와서, 똑같이 Map<updatedStationId, Station>의 구조로 만들었습니다. (Station 안에는 List<Charger>가 존재)

저희는 충전소와 충전소에 해당하는 모든 충전기들을 비교하면서 변경 감지를 해줘야하기 때문에 각각의 Map.values()인 List<Station> : 기존 충전소List<Station> : 업데이트된 충전소를 비교해줬습니다.

비교를 하면서 새로 삽입된 충전소와 업데이트 된 충전소를 각각 처리해주었습니다.

충전소의 변경 감지를 위해서 충전기들은 충전소 안에 List로 속해있기 때문에 O(n^2)의 시간 복잡도를 가지고 전체 데이터들은 약 23만 건이므로, 전체 데이터를 대상으로 한다면 약 530억번의 연산이 이뤄졌겠네요.

Ver1에 비해서는 크지는 않지만 평균적으로 약 2초 정도 줄었습니다.

Ver3. 변경 감지를 직접 해주고, 자료구조로 배치 데이터 모으기 : O(1) (약 10초)

Ver2와 거의 유사한 방법입니다.

차이점은 Map 자료 구조 사용방법을 변경했습니다. 기존 2중 for문에서, 1중 for문을 돌면서 키 값을 통해서 신규 데이터와 업데이트 될 데이터들을 분류하고, 이들을 각각 List에 넣어주었습니다. 이를 통해서 Ver2에 비해서 1초정도 줄었습니다.

Ver4. 이전 방식 + Fetch Join 사용하기 (약 6초)

마지막 방법은 조회 과정의 시간 단축입니다.

처음에 Stations를 findAll()하는 쿼리를 확인해보니 N+1 문제가 발생하고 있었습니다. 그 이유는 Station에서 Chargers를 지연로딩으로 설정 했는데, 이를 그대로 get 메서드를 통해 조회해서 해당 문제가 발생했습니다.

List<ChargeStation> findAll(); // 기존

@Query("SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers"); // Fetch Join 적용
List<ChargeStation> findAll();

따라서 위에 코드와 같이 Fetch Join을 이용해서 처음에 데이터를 가져왔습니다. 이렇게 효율적인 조회로 변경하면서 시간을 많이 줄일 수 있었습니다.

지금까지의 방법을 정리를 하자면

Ver1 과 같은 방식에서는 업데이트 과정에서 JPA의 식별자에 따른 처리 방식으로 인해 [SELECT + UPDATE] or [SELECT + INSERT] 와 같이 쿼리가 두 번씩 나갔습니다.

그래서 Ver3까지 개선을 하기 위해서 저장과 업데이트를 한 번에 JDBC를 이용해서 Batch로 처리해주는 방식을 선택했고,

변경 감지 + 배치 데이터를 모으기 위해서 자료구조를 이용해서 시간을 조금씩 단축 했습니다.

마지막으로 Ver4에서는 findAll()에서 발생하는 N+1의 문제를 해결하면서 시간을 단축했습니다.

이런 과정을 통해서 동일 작업을 14초에서 6초 정도로 줄일 수 있었습니다!