NoSQL(Firestore)에서 RDBMS(MySQL)로 데이터베이스 동기화
배경
최근 진행한 프로젝트의 MVP(Minimum Viable Product) 개발 과정에서 Firestore를 주요 데이터 저장소로 활용해왔습니다.
초기에는 Firestore의 저렴한 비용과 NoSQL의 유연성이 큰 장점이었지만, Firestore가 검색 기능을 제공하고는 있지만 프로젝트가 고도화되면서 복잡한 쿼리와 검색 기능에 한계를 느꼈습니다.
(대안으로 SaaS 검색 엔진인 Algolia를 적용해서 사용해봤지만 필요한 조회 요구사항을 전부 충족하지는 못했답니다 🥲)
제가 재직 중인 회사의 도메인은 에듀테크로 주로 교육 및 학습 정보를 가공하고, 이를 바탕으로 통계 및 리포트를 제공하는 업무를 주로 수행하고 있습니다. 이번 프로젝트에서도 학습 데이터를 기반으로 학습 현황 분석, AI 기반 개인화 피드백 등의 데이터를 제공해야 했고, Firestore만으로는 이러한 요구사항을 만족하기는 어려웠습니다. 이에 Firestore의 데이터를 MySQL로 주기적으로 동기화하는 방안을 적용하게 되었습니다.
동기화 프로세스 구현
ETL(Extract, Transform, Load) 프로세스를 기반으로 동기화 프로세스를 설계되었습니다.
- 추출(Extract): Firebase에서 데이터를 추출합니다.
- 변환(Transform): 데이터를 MySQL 테이블에 맞게 변환합니다.
- 로드(Load): 변환된 데이터를 MySQL에 적재합니다.
각 단계별 주요 구현 내용은 다음과 같습니다.
// 1. Firestore에서 동기화에 필요한 컬렉션을 조회합니다.
Collection collection = reader.getCollection()
// 2. 조회한 Firestore 도큐먼트(비정형 데이터)들을 MySQL 로우(정형 데이터)로 변환합니다.
List<Entity> entities = collection.convertToEntities()
// 3. 변환된 데이터를 MySQL 테이블에 적재합니다.
persistence.saveAll(entities)
구현하는 과정에서 도메인 모델을 최대한 활용하여 데이터의 구조화와 관리 효율성을 높이고자 하였습니다.
이러한 원칙에 따라, 기본적으로 하나의 Firestore 컬렉션은 하나의 도메인 컬렉션 객체와 연결되도록 설계하고, 필요에 따라 중첩 구조(배열, 맵)인 경우에 추가적인 도메인 객체를 만들어 관리하고 있습니다.
public class Documents {
private final List<Document> documents;
private Documents(final List<Document> documents) {
validate(documents)
extract(documents)
convert(documents)
this.documents = documents;
}
public static Documents create(final List<Document> documents) {
return new Documents(documents);
}
// convert
// extract
// calculate
// ...
}
또한, 데이터베이스 동기화 과정에서 반복적으로 수행되는 컬렉션 조회 및 변경 작업을 효율적으로 관리하기 위해, 제네릭 메서드로 공통 기능으로 사용될 수 있도록 구현했습니다.
public <T> List<T> getDocumentsBy(
final LocalDateTime startAt,
final LocalDateTime endAt,
final FirestoreCollection collection,
final Class<T> resultType
) {
log.info("[SELECT ALL] TARGET COLLECTION = {}", collection);
final ApiFuture<QuerySnapshot> future = firestoreClient
.collection(collection.getCollectionKey())
.whereGreaterThan("updateDt", startAt) // 마지막 배치 실행 시간
.whereLessThan("updateDt", endAt) // 현재 배치 시작 시간
.get();
try {
List<QueryDocumentSnapshot> documents = future.get().getDocuments();
return documents.stream()
.map(document -> document.toObject(resultType))
.toList();
} catch (InterruptedException | ExecutionException e) {...}
}
그 결과, 11개의 Firebase 컬렉션을 14개의 MySQL 테이블로 마이그레이션 할 수 있었습니다.
배운점
이번 프로젝트를 통해 작은 규모지만 데이터 웨어하우스를 직접 구현해보는 경험을 하였습니다. 이 과정에서 가장 큰 깨달음은 동기화 작업을 한 번에 모두 처리하려고 하기보다는 중요도와 복잡도에 따라 단계적으로 접근하는 것이 비용과 리스크 관리 측면에서 용이하다는 것입니다.
개발 과정에서 데이터 타입 변경이나 구조 불일치로 인한 동기화 실패를 빈번하게 경험했는데, 동기화 과정에서 실패를 최소화하는 로직을 설계하고 장애로 인해 일시적인 데이터 불일치가 발생하더라도 서비스 운영에 문제가 없도록 하는 데 중점을 두었습니다. 만약을 대비해 웹훅을 통한 즉시 동기화 메커니즘(전체 데이터 삭제 후 재동기화)을 구현하고, 어떠한 이유에서든 동기화에 실패하면 슬랙 알림을 보내어 빠른 시간내에 대응할 수 있도록 하였습니다.
향후 다른 형태의 배치 프로세스를 설계한다면 재시도 메커니즘과 멱등성 같은 안정성 관련 요소들을 좀 더 깊게 고민해볼 것 같습니다.
해결되지 않은 점
일반적으로는 RDBMS에서 NoSQL로의 마이그레이션이 더 흔하지만, 우리는 반대 방향으로 진행해야 했습니다. 운영 배포까지 두 달밖에 남지 않은 상황에서 커맨드 성격의 기능들까지 마이그레이션하기에는 시간이 부족했고, 결과적으로 현재의 배치 기반 동기화 방식을 선택했습니다.
그렇기 때문에 이 방식은 실시간성이 다소 부족하다는 단점을 가지고 있습니다. 일정 간격으로 동기화가 이루어지기 때문에 커맨드 작업의 변경사항이 즉시 반영되지는 않기 때문이죠. 다행히 통계나 리포트 데이터가 완벽히 실시간 조회가 제공되지 않아도 되어서 결과물과 구현 관점에서는 문제가 없지만, 사용자 경험 측면에서는 실시간에 가까울수록 더 나은 사용성을 제공할 수 있게 됩니다.
이 이유뿐만 아니라 다른 여타 이유들로 데이터 구조를 관계형 데이터베이스에서 새로 설계하고 커맨드 기능들도 점진적으로 이전하는 방향으로 진행되고 있습니다.
결론
이번 데이터베이스 동기화 프로젝트를 통해 많은 것을 배웠지만, 동시에 아쉬운 점도 있었습니다. 다른 오픈소스 툴이나 특정 언어에서 제공하는 라이브러리를 활용했다면 더 효율적인 구현이 가능했을지도 모른다는 생각이 듭니다.
그럼에도 불구하고, 이 경험을 통해 데이터 동기화가 단순한 데이터 이관 작업이 아니라는 것을 깨달았고, 데이터의 일관성, 무결성, 실시간성을 보장하며 안정성을 얻기 위해서는 고려해야 하는 것들이 많다는 것을 느꼈습니다.