noSQL의 역정규화

ds-k.dev·2025년 1월 12일
0

고민의 시작

우선 현 프로젝트의 firestore 내부 post페이지의 post data를 작업하다가 고민이 시작되었다. 프로젝트 막바지지만, 그래도 짚고 나갈 것은 짚고 나가야 하기 때문에..

  • drawee에서 사용하는 post data 구조
  • 랜더링한 화면

문제 발생

주목해야할 데이터는 다음과 같다.

userId, subjectId

userId를 통해, users 컬렉션 내부를 조회해서 이름을 받아오고,
subjectId를 통해, subjects 컬렉션 내부를 조회해서 주제 이름을 받아오는 것으로 구현하고자 했다.

역정규화

이는 noSQL의 역정규화를 전혀 사용하지 않고, RDB적으로 설계를 한 것이라는 것을 프로젝트 진행 중에도 알고 있었지만, 이대로 진행한 이유는 다음과 같다.

  1. user의 데이터를 변화할 일이 생긴다. (프로필 수정)
  2. subject별 post데이터를 따로 또 저장하고 있다.
  3. post를 수정할 경우 post, subject를 모두 조회해서 수정해야 한다.

그래서 id를 통해 서로 엮어서 데이터를 저장하는 것이 유리할 것이라고 생각했다.

그러나,

다시 위 화면으로 돌아가서

위 화면의 userId와 subjectId를 viewmodel 내부에서 요청을 날려 이름과 주제를 가져오는 것을 구현하려고 할때 심각한 의문점이 생겼다.

  1. 만약 post 데이터가 10만개라고 가정을 하자.
  2. 각각의 post 만큼 반복문을 돌면서
    2-1. userId로 userName을 가져오는 쿼리를 보내야 한다.
    2-2. subjectId로 subjectName을 가져오는 쿼리를 보내야 한다.
  3. 카테고리를 변경할 때마다 해당 과정을 계속 반복한다.

즉, posts 조회 쿼리 하나당, 2 * post 개수 만큼의 쿼리를 추가로 보내 가져와야 하는 상황이 생긴 것이다. (찾아보니 collectionGroup이라는 게 있어서 좀 더 간소화되게 보낼 수 있는 방법은 있지만, 그게 요청횟수를 줄일 수 있을거 같아 보이지는 않았다.)
아무리 생각해도 이는 너무 비효율적이서 다시 찾아보았다.

해결책

크게 3가지 정도의 해결책이 있다.

  1. 역정규화
    이 글의 주제처럼, noSQL의 설계 원칙에 가장 부합하는 방법이다. userId와 userName, subjectId와 subjectName을 동시에 저장하는 것이다. 그렇게 하면 많은 요청을 줄일 수가 있다.
  2. 클라이언트 사이드 join
    위에 이미 말한 그 방법이다. 그냥 쿼리를 양껏 보내 데이터를 받아 클라이언트 사이드에서 trimming하는 방법이다.
  3. Cloud Functions를 활용한 서버 측 조인
    Firebase Cloud Functions를 사용하여 서버 측에서 JOIN 작업을 수행한 후 결과를 클라이언트에 반환한다. 그러면 클라이언트 측 부하를 줄일 수가 있지만, functions에서 db에 쿼리를 보내는 양은 똑같다.

역정규화 선택

DB 쪽에서 join을 지원하지 않는 이 상황에서 읽기 성능을 최적화하고, 요청을 줄일 수 있는 방법은 역정규화밖에 없다는 생각이 들었다. 즉, userName, subjectName, comments 내부 userName 모두 추가해주는 방식이다.

그럼 수정, 삭제는?

이렇게 되면 원점이다. 수정, 삭제의 효율성을 따져 id를 활용한 join 설계를 했는데, 이제와서 역정규화로 name들을 중복 저장한다면, 어떻게 수정 삭제를 할수 있을 것인가라는 생각이 들어 실 사례를 찾아보았다.

읽기 성능이 더 중요한 서비스에서 역정규화를 사용하고 있었다. user 데이터 변화가 빈번한 소셜 미디어에서 사용하고 있는 것이 신기했지만, 수많은 게시물들마다 매번 테이블간 조인을 하는 것은 엄청난 부하가 걸릴 테니 당연하다는 생각도 들었다.
밑에서 설명하겠지만, 그들은 수정 삭제를 여러가지 전략을 통해 최적화하고 있었다.

  1. Facebook:
    • 게시물에 사용자 이름이 역정규화되어 저장되지만, 이름 변경 시 대규모 데이터 업데이트는 비동기로 처리.
    • 일관성보다는 읽기 성능을 우선시.
  2. Twitter:
    • 트윗 데이터를 역정규화하지만, 이름 변경은 API 요청 시 최신 정보를 반영하도록 설계.
    • 오래된 트윗에서 이전 이름이 보일 수 있음.
  3. Slack:
    • 메시지 기록은 사용자 ID를 사용하며, 이름이나 프로필은 별도로 실시간 조회.
    • 캐싱을 적극적으로 사용해 성능 최적화.

결론

읽기 성능을 높이기 위해 역정규화를 한다면, 쓰기에 부하가 걸린다.
쓰기 성능을 높이기 위해 RDB 방식으로 한다면, 읽기에 부하가 걸린다.

읽기 성능을 높이기 위한 목적으로 설계된 noSQL을 사용한다면(테이블 내 join 기능이 없다면), 역정규화를 사용한 뒤에, 쓰기, 삭제 등의 데이터 업데이트는 캐싱 혹인 비동기 전략을 구성해서 작업해야 한다는 어느정도의 결론을 내릴 수 있겠다.

이미 설계된 프로젝트고 마감이 얼마 안남았지만, 그래도 효율적으로 컨트롤할수 있는 방법을 고민해서 최대한 적용해보겠다. 기회가 되면 변경된 설계를 소개해보도록 하겠다.

추가...!(반전 있음)

역정규화를 하지 않기로 했다...

  1. 이미 해당 데이터(역정규화되지 않은 데이터)로 너무 많은 코드를 작성해두어서 수정을 하기가 부담스러운 상황
  2. userId : [username, profileURL], subjectId : [subjectName] 등으로 캐쉬 데이터를 만들어서 미리 캐시를 조회한 뒤에 없다면 데이터를 불러오는 방식으로 설계하면 요청을 줄일 수 있겠다는 판단

그런데, firestore가 자체 캐시를 지원한다고 한다.

오프라인 데이터 사용 설정 - Firebase

persitenceEnabled 옵션을 통해 백엔드에서 수신된 모든 문서를 오프라인 액세스용으로 캐시를 하고, 자동으로 동기화를 해준다고 한다. 이로 인해 한번 받은 데이터는 캐시를 통해 가져올 수 있다.

db
    .collection("cities")
    .where("state", isEqualTo: "CA")
    .snapshots(includeMetadataChanges: true)
    .listen((querySnapshot) {
  for (var change in querySnapshot.docChanges) {
    if (change.type == DocumentChangeType.added) {
      final source =
          (querySnapshot.metadata.isFromCache) ? "local cache" : "server";

      print("Data fetched from $source}");
    }
  }

이 샘플 코드를 통해 해당 데이터가 어디서 받아오는지를 확인할 수 있다고 하니, 이 기능을 몰랐던(나같은) 분들은 한번 확인해보기를 바란다.

0개의 댓글

관련 채용 정보