2024. 10. 27. 22:24ㆍ카테고리 없음
본 문서는 회사의 기존 RDB 에서 ElasticSearch 로의 데이터 마이그레이션 전략을 다룬 문서입니다!
개발 환경
JDK : 17
Spring Boot : 3.0.0
MySQL : 8.0.36
ElasticSearch : 7.15.1
Spring Data ElasticSearch : 5.0.12
배경
ElasticSearch 설치 이후, 남은 작업은 국내 숙박 목적지 검색에 사용되었던 호텔, 지역 정보 데이터들을
ElasticSearch 로 옮겨야하는 데이터 마이그레이션 작업입니다.
그래서 이관에 앞서 숙박 목적지 검색에 사용되던 데이터들 총 마이그레이션 데이터의 양을 계산 해야 합니다.
기존 목적지 검색 API 의 응답 형태는 다음과 같습니다.
{
"regions" : [...],
"hotels" : [...],
...
}
즉 검색시 Regions, Hotels 를 RS 에 담아 보내야 하고, 이때 2개의 테이블을 조회하게 됩니다. (REGION, HOTEL)
그리고 각 테이블의 총 row 의 갯수는 다음과 같습니다.
select count(*)
from BS_REGION_CODE; // 784,024
select count(*)
from HO_HOTEL_MASTER; // 866,929
=> 1,650,953 건
방법 1: Limit 1000, Offset += 1000
Spring 서버에서 MySQL 로 데이터를 1,000 개씩 가져와
ElasticSearch 에 적재하는 방식을 먼저 사용해봤습니다.
사용한 쿼리는 다음과 같습니다.
public void migrateMySQLDataUsingOffset() {
int offset = 0;
List<HotelDestinationDto> hotels;
List<HotelDestinationDocument> hotelDocuments;
do {
hotels = hotelDestinationMapper.getHotelDestinationsByOffset(offset, BATCH_SIZE);
hotelDocuments = HotelDestinationDocument.fromDtoList(hotels);
elasticHotelDestinationRepository.saveAll(hotelDocuments);
offset += BATCH_SIZE;
} while (hotels.size() == BATCH_SIZE);
}
<select id="getHotelDestinationsByOffset" resultType="HotelDestinationDto">
SELECT
HOTEL.HOTEL_ID,
//...HOTEL 테이블에서 10개 필드
REGION.CODE AS CITY_NAME_EN
//...REGION 테이블에서 2개 필드
COUNTRY.COUNTRY_ID
//...COUNTRY 테이블에서 3개 필드
FROM HOTEL
JOIN REGION
ON HOTEL.REGION_ID = REGION.REGION_ID
JOIN COUNTRY
ON COUNTRY.COUNTRY_ID = REGION.COUNTRY_ID
<where>
HOTEL.STATUS_CODE = 'ACTIVE'
AND HOTEL.REGION_ID IS NOT NULL
</where>
LIMIT #{limit} OFFSET #{offset}
</select>
위 코드들로 실행을 하고 결과는 다음과 같…. 다고 말하고 싶었는데
로그의 offset 값을 보면 절반을 하는데 1 시간이 넘게 걸려 이 방법은 사용할 수가 없다고 판단.
바로 버리고 다른 방법을 찾아봤습니다.
나중에 알고 보니, 이때 설정을 잘못해 ElasticSearch에 저장도 안되고 있었습니다.
즉, 단순히 MySQL 에서 1,000 건씩 조회하는 속도였습니다!
미친듯이 느린 이유?
위 방법의 처참한 속도의 원인은 다음과 같습니다.
SELECT * FROM salaries ORDER BY salary LIMIT 0,10;
-- 10 rows in set (0.00 sec)
SELECT * FROM salaries ORDER BY salary LIMIT 2000000,10;
-- 10 rows in set (1.57 sec)
위 쿼리를 보면 offset 값 차이에 따라 명백한 성능 차이를 보이고 있습니다.
이는 MySQL 이 offset 명령어를 사용하게 되면, offset+limit 만큼의 데이터를 읽고 난 뒤,
offset 만큼의 데이터를
public void migrateMySQLDataUsingOffset() {
int offset = 0;
List<HotelDestinationDto> hotels;
List<HotelDestinationDocument> hotelDocuments;
do {
hotels = hotelDestinationMapper.getHotelDestinationsByOffset(offset, BATCH_SIZE);
hotelDocuments = HotelDestinationDocument.fromDtoList(hotels);
elasticHotelDestinationRepository.saveAll(hotelDocuments);
offset += BATCH_SIZE;
} while (hotels.size() == BATCH_SIZE);
}
이 코드처럼 계속해서 커지는 offset 값 때문에
매 쿼리마다 점점 읽어야 하는 데이터의 수가 많아지게 됩니다.
방법#1 처럼 1,000개씩 읽는 경우, 실제로 읽는 총 데이터 양을 계산해보면 다음과 같습니다.
- 1번째 요청: 1,000개 읽음 (OFFSET 0)
- 2번째 요청: 2,000개 읽음 (OFFSET 1,000)
- 3번째 요청: 3,000개 읽음 (OFFSET 2,000)
- ...
- 마지막 요청: 800,000개 읽음 (OFFSET 799,000)
이를 실제로 계산하게 되면
2800(1,000+800,000) / 2 = 2800×801,000/2=320,400,000 (건)
총 약 320,400,000 (약 3억 2천) 개의 데이터를 읽는 셈이 됩니다.
즉, offset 방법은 버려야한다! 라는 결론에 다다릅니다.
방법 3: id(PK) 활용한 cursor 방식
MySQL의 프라이머리 인덱스(PK)는 고유하며, 정렬된 상태로 유지됩니다. 따라서 PK를 활용하면 효율적인 데이터 접근이 가능해집니다. MySQL에서 프라이머리 인덱스를 기반으로 데이터를 조회할 때는 인덱스의 순서가 보장되기 때문에, 순차적으로 데이터를 가져오는 것이 매우 빠릅니다.
기존의 OFFSET 방식과 달리, 커서 방식은 데이터를 조회할 때 읽고 넘기는 과정이 없습니다. 이는 OFFSET 방식이 데이터가 많아질수록 쿼리 성능이 급격히 저하되는 문제를 해결할 수 있습니다. OFFSET은 지정된 위치까지 모든 데이터를 일일이 확인한 후에야 필요한 데이터만 반환하기 때문에 데이터의 양이 많을수록 비효율적입니다. 반면, PK를 기준으로 데이터를 읽으면 이미 정렬된 인덱스를 활용하여, 마지막으로 읽은 PK 값 이후부터 바로 데이터를 가져올 수 있습니다.
예를 들어, PK를 활용한 커서 방식을 사용할 경우 첫 번째 쿼리에서 PK가 1부터 시작하는 1,000개의 데이터를 읽었다면, 다음 쿼리는 PK가 1,000보다 큰 값부터 시작하여 다시 1,000개의 데이터를 읽습니다. 이러한 방식으로 마지막 PK 값을 기억하고 그 이후부터 데이터를 가져오는 방식은 매번 읽어야 할 데이터의 양이 일정하게 유지되기 때문에, 대용량 데이터에서도 성능 저하 없이 안정적으로 데이터를 가져올 수 있습니다.
이를 코드로 구현하면 다음과 같은 형태가 됩니다:
public void migrateMySQLDataUsingCursor() {
Long lastHotelCode = 0L;
List<HotelDestinationDto> hotels;
List<HotelDestinationDocument> hotelDocuments;
do {
hotels = hotelDestinationMapper.getHotelDestinationsByLastId(lastHotelCode, BATCH_SIZE);
hotelDocuments = HotelDestinationDocument.fromDtoList(hotels);
elasticHotelDestinationRepository.saveAll(hotelDocuments);
if (!hotels.isEmpty()) {
lastHotelCode = hotels.get(hotels.size() - 1).getHotelId();
}
} while (!hotels.isEmpty());
}
이 방식은 OFFSET 방식과 비교했을 때, 매번 읽어야 하는 데이터의 양이 줄어들기 때문에 속도 면에서 큰 이점을 가집니다. 특히 데이터의 양이 수백만 건에 달하는 경우에도 일관된 성능을 보장할 수 있습니다. 이는 마이그레이션 작업의 효율성을 높이고, 시간 소모를 최소화하는 데 크게 기여합니다.
또한, 이 방식은 MySQL뿐만 아니라 대부분의 RDBMS에서 유사하게 적용할 수 있으며, 대량의 데이터를 다루는 모든 상황에서 효과적입니다. 커서 방식의 가장 큰 장점은 조회 성능을 유지하면서도 데이터의 순차적인 처리를 보장한다는 점입니다. 데이터 마이그레이션 시 속도와 안정성 모두를 확보하고자 할 때 최적의 선택이라고 할 수 있습니다.
결론
이번 데이터 마이그레이션 작업에서는 기존 OFFSET 방식을 사용하는 접근 방식이 대량 데이터 처리에 있어 비효율적임을 확인했습니다. 데이터의 양이 많아질수록 매번 읽어야 하는 데이터 양이 증가하여 성능이 크게 저하되는 문제가 있었습니다. 이를 해결하기 위해, PK 기반의 커서 방식을 채택함으로써 데이터의 효율적인 접근과 안정적인 성능을 확보할 수 있었습니다.
PK 기반 커서 방식은 매번 일정한 양의 데이터만을 읽어오기 때문에 쿼리 성능이 일정하게 유지됩니다. 이러한 특성 덕분에 대규모 데이터 환경에서도 마이그레이션 작업을 신속하게 진행할 수 있었으며, Elasticsearch에 데이터를 적재하는 과정 또한 더욱 안정적으로 수행할 수 있었습니다.
따라서 대용량 데이터 마이그레이션 시에는 OFFSET 대신 PK 기반의 커서 방식을 사용하는 것이 훨씬 효율적이며, 특히 MySQL과 같이 프라이머리 인덱스를 효율적으로 활용할 수 있는 DB에서는 더 큰 성능 향상을 기대할 수 있습니다. 앞으로도 데이터 마이그레이션 시 안정성과 속도를 모두 확보하기 위해 이러한 방식을 적극 활용할 계획입니다.
참고