DynamoDB에서 PostgreSQL로 (1) - DynamoDB의 한계점

메디스트림·2026년 8월 20일

왜 데이터베이스를 바꾸게 되었는가

안녕하세요, 메디스트림 PM 스쿼드의 백엔드 개발자 김현수입니다. PM 스쿼드는 한의사 밀착관리 서비스 린다이어트를 개발하고 있습니다.

린다이어트는 환자용 모바일 앱과 한의사용 차트(웹), 그리고 그 뒤의 백엔드 서비스 여러개로 구성되어 있는데, 이 백엔드 전체가 DynamoDB 단일 테이블 위에서 돌아가고 있었습니다. 그리고 저희는 이 백엔드 서비스에서 바라보는 DB를 전부 DynamoDB에서 PostgreSQL로 옮겼습니다. 50여 개 도메인, 100개가 넘는 테이블, 160여 개의 마이그레이션 SQL 분량의 작업이었습니다.

앞으로 저는 데이터베이스를 옮기면서 있었던 일들을 정리해서 시리즈로 발행하려 합니다. 이번 첫 글에서는 "왜 옮겼는가"를 다룹니다. DynamoDB는 적절한 설계와 함께한다면 뛰어난 성능을 보장하는 데이터베이스입니다. 다만 저희는 제품이 성장하며 데이터 규모와 요구사항의 복잡도가 임계점을 넘었고, DynamoDB를 사용하기 적절하지 않은 시점에 도달했습니다. 이 글에서는 그 사례들을 정리해 보여드리겠습니다.

DynamoDB는 무엇을 약속하는가

저희가 처음 DynamoDB를 사용하기로 결정했던 이유는 아래와 같았습니다.

  • 완전 관리형 서비스라 운영 부담이 거의 없습니다. RDB를 운영할 때 따라오는 스케일링, 커넥션 풀 관리 등 복잡한 인프라 관리에서 벗어날 수 있습니다. 초기 팀에게 큰 장점이었습니다.
  • 성능이 예측 가능합니다. 적절한 키 설계로 뒷받침하면 데이터의 양과 관계없이 빠른 속도를 보장합니다.
  • 쓰는 만큼만 과금됩니다. 트래픽이 거의 없던 초기에 비용 관련 걱정을 덜 수 있었습니다.
  • Lambda와의 궁합이 좋습니다. 린다이어트 백엔드는 서버리스 스택을 적극적으로 사용했습니다. Lambda와 RDB는 커넥션 관리에 많은 신경을 써야하지만, DynamoDB는 HTTP API 기반이라 그런 고민이 없습니다.

하지만 DynamoDB를 사용하기 위해서 반드시 전제가 되어야 할 조건이 있습니다.

DynamoDB는 접근 패턴(access pattern)을 먼저 설계해야 합니다.

"이 데이터를 어떤 키로, 어떤 순서로, 어떤 조건으로 읽을 것인가"를 테이블 설계 시점에 확정할 수 있다면 DynamoDB는 최고의 선택입니다. 반대로 말하면, 기존에 고려하지 못했던 새로운 요구사항을 대응할 때마다 개발자들은 항상 고민해야 한다는 뜻입니다. 특히 DynamoDB가 취약한 통계 기능을 추가해야 한다든가, 검색 기능을 추가해야 한다든가 하는 것들이죠. 모두 처음부터 고려하지 않았다면 NoSQL과 DynamoDB의 한계를 마주치게 되는 기능들입니다.

DynamoDB의 한계를 마주했던 4가지 장면

이관 직전의 코드베이스에는 제약 안에서 요구사항을 구현해내기 위해 고민한 흔적이 곳곳에 남아 있었습니다. 각각은 DynamoDB 커뮤니티와 AI 등을 활용하며 선택한 최선이었지만, 성능이나 관리 면에서 기술 부채로 인식되었습니다.

이관을 결정한 기준은 단순했습니다. 예측 가능하고 해법이 알려진 비용은 감당하되, 기능이 늘수록 함께 불어나는 비용은 끊어낸다. 저희에게 DynamoDB의 한계를 우회하는 비용은 후자였습니다. 그 비용이 실제로 어떻게 불어나고 있었는지, 4가지 장면으로 보여드리겠습니다.

장면 1) 검색 기능

한의사용 차트의 환자 검색 요구사항은 "환자의 특정 컬럼 여러개 중 아무거나, 부분일치하면 해당"이었습니다. 그런데 DynamoDB에는 ILIKE도, OR 조건 검색도 없습니다. 여러개의 컬럼 중 하나라도 부분일치하면 검색 결과에 포함되어야 했기에 GSI 등을 활용한 begin_with 쿼리 역시 사용하기 어려웠습니다.

저는 해당할 가능성이 있는 데이터를 1차로 모두 불러온 뒤, 필터링을 하는 선택을 했습니다. 물론 pk, sk 키 조건으로 데이터를 줄이는데 많은 고민을 했고, 최대한 효율적으로 조회했습니다. 다만 이 방법은 환자의 수가 늘어날 수록 속도와 메모리 부담이 커지게 됩니다. 데이터 수가 늘어나도 빠른 속도를 보장하는 DynamoDB의 장점과는 멀어지는 선택이었죠. RDB의 인덱스 기반 조건 검색에 비하면 표현력의 한계가 분명했습니다.

PostgreSQL로 옮긴 뒤 같은 요구사항은 개념적으로 아래와 같은 쿼리 하나로 끝납니다.

-- 이관 후: 여러 컬럼을 OR로 묶은 부분일치 검색 (예시용으로 단순화한 쿼리)
SELECT *
FROM patients
WHERE column_a ILIKE '%키워드%'
   OR column_b ILIKE '%키워드%'
   OR CAST(column_c AS TEXT) ILIKE '%키워드%';

실제 운영 쿼리에는 한의원별 권한 처리 같은 조건까지 더해져 이보다 훨씬 복잡하고, WHERE 절에 붙는 조건도 많습니다. 인덱스를 최대한 활용해 성능 면에서 이득을 보기 위해 쿼리 튜닝을 열심히 했습니다.

장면 2) 통계 기능

DynamoDB에는 COUNT, SUM, GROUP BY가 없습니다. 통계가 필요할 때 선택지는 두 가지였습니다.

첫 번째는 DynamoDB의 정석대로 집계 아이템(aggregate item)을 미리 만들어두는 것입니다. 예를 들어 월별 신규 환자 수라면, 환자가 등록될 때마다 해당 월의 카운트 아이템을 함께 갱신하는 방식입니다. 실제로 저희도 이런 집계 아이템을 많이 만들어 운영했습니다. 조회는 빨랐지만 대신 관리 부담이 따라왔습니다. 원본 데이터와 집계 아이템의 정합성을 항상 신경 써야 했고, 운영 중 데이터를 보정할 일이 생기면 원본과 집계 아이템 여러 곳을 빠짐없이 함께 맞춰야 했습니다. 신규 입사자나 프론트엔드 개발자가 코드를 볼 때 "이 숫자는 어느 아이템을 봐야 하는 거지?"를 파악하기 어렵다는 문제도 있었습니다.

두 번째는 다 읽어와서 애플리케이션에서 계산하는 것입니다. 집계 아이템으로 미리 대비하지 못한 경우나, 마이그레이션 부담이 큰 경우 선택했던 방법입니다. 저희는 이를 위한 전용 헬퍼 메서드를 만들기도 했습니다.

// LastEvaluatedKey가 없어질 때까지 재귀로 전 페이지를 긁어 메모리에 모으는 함수.
const loopQuery =
  (tableName) =>
  async ({ params, items = [], count = 0, lastEvaluatedKey = null }) => {
    if (count > 0 && !lastEvaluatedKey) return items;
    if (lastEvaluatedKey) params.exclusiveStartKey = lastEvaluatedKey;
    const { Items = [], LastEvaluatedKey = null } =
      await queryItem(tableName)(params);
    return loopQuery(tableName)({
      params,
      items: [...items, ...Items],
      count: count + 1,
      lastEvaluatedKey: LastEvaluatedKey,
    });
  };

이렇게 가져온 데이터 위에서 집계는 JavaScript로 이뤄졌습니다. 대상 데이터를 가져와 map / filter/reduce로 계산하는 방식이었죠.

"내 주변 한의원" 기능도 마찬가지였습니다. 전체 한의원을 로드해서 JS로 Haversine 거리 계산 → 정렬 → slice를 하는 구조였는데, DynamoDB에서는 이것이 최선이었지만 데이터가 늘수록 Lambda 메모리 사용량과 응답 시간이 같이 늘어날 수밖에 없었습니다. 이관 후에는 지리 연산 기능 등을 활용해 효율적으로 구현할 수 있었습니다. 이 외에도, 복잡했던 로직의 경우 200줄 가까이 줄이기도 했습니다.

장면 3) GSI

단일 테이블 설계에서는 모든 엔티티가 엔티티타입#식별자 형태의 pk/sk 복합키 컨벤션으로 한 테이블에 들어갑니다. 이 설계에서 "다른 방향으로 조회하고 싶다"는 요구가 생길 때마다 GSI(Global Secondary Index)를 추가해야 합니다.

이관 직전 저희 테이블에는 GSI가 여러 개 있었습니다. DynamoDB의 GSI는 조회할 때 IndexName으로 인덱스를 직접 지정해줘야 합니다. 그래서 어떤 GSI가 어느 접근 패턴을 위해 존재하는지 모르면 코드를 읽기 어렵고, 새 기능을 붙일 때마다 기존 GSI를 활용할지, 새 GSI를 만들지 검토해야 합니다. 쓰기 쪽에도 부담이 있습니다. 아이템을 생성하거나 업데이트할 때 GSI를 조회하기 위한 값을 항상 함께 갱신해줘야 하고, GSI로 사용하고 있는 속성은 null로 설정할 수 없어 값 관리에도 신경 써야 합니다.

RDB에서는 새로운 쿼리 패턴이 나왔다면 인덱스를 추가하면 되기 때문에 상대적으로 조회에 대한 고민이 적었습니다.

장면 4) JOIN

가장 DynamoDB의 한계를 체감했던 부분입니다. DynamoDB는 JOIN을 지원하지 않기 때문에, 자주 함께 조회되는 데이터는 비정규화 설계를 권장하고 있습니다. 린다이어트 백엔드도 그에 따라 비정규화를 택한 부분이 많았습니다. 장면 2에서 소개한 집계 아이템과 같은 계열의 설계였죠. 자연스럽게 집계 아이템이 갖는 정합성 문제도 똑같이 갖게 됩니다.

운영 중 데이터를 보정할 때는 원래의 엔티티와 비정규화된 아이템들, 여러 곳의 정합성을 함께 맞춰야 하고, 그러려면 갱신해야 할 곳이 어디인지 전부 알고 있어야 합니다. 만들 때도 마찬가지입니다. 엔티티를 생성하거나 수정하는 코드에는 복제해 둔 아이템을 함께 갱신하는 코드가 항상 따라붙어야 했고, 하나라도 빠뜨리면 사용자는 일치하지 않는 데이터를 보게 됩니다.

이관 후에는 데이터를 복제해 두는 대신 읽는 시점에 JOIN으로 조회하기 때문에, 이런 갱신 관리 자체가 사라졌습니다.

RDB가 갖고 있는 단점들

그렇다고 RDB가 만능인 건 아니겠죠. RDB로 이관하고 난 뒤 저희가 새롭게 갖게 된 부담들도 있습니다.

  • 커넥션 관리. DynamoDB와는 달리, RDB 환경에서는 커넥션 관리에 항상 신경써야 합니다. 이관하면서 프로젝트 별로 커넥션은 얼마나 할당할지, 커넥션 관련 세팅 값은 어떻게 설정할지 많은 논의를 거쳤습니다. DynamoDB 시절에는 존재하지 않던 고민입니다.
  • 용량과 성능을 다시 생각해야 합니다. DynamoDB는 데이터가 얼마나 커지든 키 조회 성능이 같지만, RDB는 인덱스 설계, 실행 계획, VACUUM 등을 항상 고려해야 합니다.
  • 비용 구조의 변화. 요청당 과금에서 인스턴스 비용으로 바뀝니다. 트래픽이 아주 적거나, 특정 시간대에 몰린다면 여전히 DynamoDB가 유리합니다.

반면, 흔히 RDB의 비용으로 꼽히는 스키마 관리는 저희에게 오히려 장점이었습니다. DynamoDB는 스키마리스라서 유연해 보이지만, 실제로는 신규 요구사항으로 프로퍼티가 추가될 때마다 기존 아이템 전체를 갱신하는 마이그레이션을 돌리거나 애플리케이션 단에서 기본값을 처리해야 했습니다. DynamoDB 역시 ERD 등으로 스키마를 정리한 뒤 설계하는 게 정석이기도 하고요. RDB로 옮긴 뒤엔 ORM 등의 도구를 활용해 스키마 문서화가 편해졌습니다.

그럼에도 이관을 후회하지 않는 이유는 앞서 세운 기준 그대로입니다. 위의 부담들은 예측 가능하고 해법이 잘 알려진, 감당하기로 한 비용이기 때문입니다.

정리: 언제 무엇을 쓸 것인가

DynamoDBRDB (PostgreSQL)
접근 패턴설계 시점에 고정 — 벗어나면 GSI 추가 또는 Scan유연 — 새 질문은 새 쿼리(느리면 인덱스)
검색키 조건 중심ILIKE, full-text, 복합 조건 자유
집계/통계없음 — 집계 아이템을 미리 만들거나 전량 로드 후 계산GROUP BY, COUNT, window function
조인없음 — 비정규화 또는 앱 레벨 병합JOIN
스케일링자동, 사실상 무한수직 확장 중심
운영 부담거의 없음커넥션·인덱스·백업 등 상시 관리
비용요청당 과금상시 비용

이 글의 핵심을 요약하면 다음과 같습니다.

  1. DynamoDB는 접근 패턴을 미리 설계할 수 있는 환경에 최적화된 데이터베이스입니다.
  2. 하지만 검색, 통계, 관계 조회처럼 복잡한 요구사항이 계속 늘어나는 제품에서는 그 제약을 애플리케이션 코드가 대신 짊어지게 되고, 그 비용은 기능이 늘수록 불어납니다.
  3. 저희는 제품이 성장하며 데이터 규모와 요구사항의 복잡도가 임계점을 넘었다고 판단한 시점에 이관을 결정했습니다.
  4. RDB로 옮기며 커넥션 관리 같은 새 비용을 떠안았지만, 이것은 예측 가능하고 많은 사람들이 겪은 비용이었기 때문에 감당할 수 있다고 판단했습니다.

다음 편에서는 실제 이관 과정에서 AI를 적극적으로 활용했던 사례를 다루겠습니다. 100개가 넘는 테이블을 손으로 하나하나 설계하는 대신 ERD와 DDL 생성을 Claude Code 스킬(generate-erd, generate-ddl)로 자동화했는데, 정규화 규칙을 어떻게 스킬로 코드화했는지 공유하겠습니다. 감사합니다.


💬 린다이어트는 기술부채 해결에 진심이에요

이번 글에 담은 데이터베이스 이관은 저희 팀이 기술부채를 다루는 방식을 보여주는 하나의 사례일 뿐이에요. 미룰수록 불어나는 문제라면, 백엔드 전체의 데이터베이스를 옮기는 큰 결정도 망설이지 않습니다.

이렇게 부채를 덜어낸 만큼 저희는 본질에 가까워집니다. 의료진에게는 진료에만 집중할 수 있는 환경을 만들고, 환자에게는 쉽고 빠르게 다이어트할 수 있는 경험을 만드는 일이죠.

물론 앞으로 풀어야 할 문제도 많이 남아 있습니다. 그래서 저희는 지금, 이 문제들을 함께 풀어갈 분들을 찾고 있어요. 저희가 어떤 문제를 풀고 있는지 궁금하시다면 아래 채용 공고에서 확인해보세요!

👉 린다이어트 팀 채용 공고 보러 가기

profile
메디스트림 기술 블로그

0개의 댓글