
안녕하세요, 메디스트림 PM 스쿼드의 백엔드 개발자 김현수입니다. PM 스쿼드는 한의사 밀착관리 서비스 린다이어트를 개발하고 있습니다.
린다이어트는 환자용 모바일 앱과 한의사용 차트(웹), 그리고 그 뒤의 백엔드 서비스 여러개로 구성되어 있는데, 이 백엔드 전체가 DynamoDB 단일 테이블 위에서 돌아가고 있었습니다. 그리고 저희는 이 백엔드 서비스에서 바라보는 DB를 전부 DynamoDB에서 PostgreSQL로 옮겼습니다. 50여 개 도메인, 100개가 넘는 테이블, 160여 개의 마이그레이션 SQL 분량의 작업이었습니다.
앞으로 저는 데이터베이스를 옮기면서 있었던 일들을 정리해서 시리즈로 발행하려 합니다. 이번 첫 글에서는 "왜 옮겼는가"를 다룹니다. DynamoDB는 적절한 설계와 함께한다면 뛰어난 성능을 보장하는 데이터베이스입니다. 다만 저희는 제품이 성장하며 데이터 규모와 요구사항의 복잡도가 임계점을 넘었고, DynamoDB를 사용하기 적절하지 않은 시점에 도달했습니다. 이 글에서는 그 사례들을 정리해 보여드리겠습니다.
저희가 처음 DynamoDB를 사용하기로 결정했던 이유는 아래와 같았습니다.
하지만 DynamoDB를 사용하기 위해서 반드시 전제가 되어야 할 조건이 있습니다.
DynamoDB는 접근 패턴(access pattern)을 먼저 설계해야 합니다.
"이 데이터를 어떤 키로, 어떤 순서로, 어떤 조건으로 읽을 것인가"를 테이블 설계 시점에 확정할 수 있다면 DynamoDB는 최고의 선택입니다. 반대로 말하면, 기존에 고려하지 못했던 새로운 요구사항을 대응할 때마다 개발자들은 항상 고민해야 한다는 뜻입니다. 특히 DynamoDB가 취약한 통계 기능을 추가해야 한다든가, 검색 기능을 추가해야 한다든가 하는 것들이죠. 모두 처음부터 고려하지 않았다면 NoSQL과 DynamoDB의 한계를 마주치게 되는 기능들입니다.
이관 직전의 코드베이스에는 제약 안에서 요구사항을 구현해내기 위해 고민한 흔적이 곳곳에 남아 있었습니다. 각각은 DynamoDB 커뮤니티와 AI 등을 활용하며 선택한 최선이었지만, 성능이나 관리 면에서 기술 부채로 인식되었습니다.
이관을 결정한 기준은 단순했습니다. 예측 가능하고 해법이 알려진 비용은 감당하되, 기능이 늘수록 함께 불어나는 비용은 끊어낸다. 저희에게 DynamoDB의 한계를 우회하는 비용은 후자였습니다. 그 비용이 실제로 어떻게 불어나고 있었는지, 4가지 장면으로 보여드리겠습니다.
한의사용 차트의 환자 검색 요구사항은 "환자의 특정 컬럼 여러개 중 아무거나, 부분일치하면 해당"이었습니다. 그런데 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 절에 붙는 조건도 많습니다. 인덱스를 최대한 활용해 성능 면에서 이득을 보기 위해 쿼리 튜닝을 열심히 했습니다.
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줄 가까이 줄이기도 했습니다.
단일 테이블 설계에서는 모든 엔티티가 엔티티타입#식별자 형태의 pk/sk 복합키 컨벤션으로 한 테이블에 들어갑니다. 이 설계에서 "다른 방향으로 조회하고 싶다"는 요구가 생길 때마다 GSI(Global Secondary Index)를 추가해야 합니다.
이관 직전 저희 테이블에는 GSI가 여러 개 있었습니다. DynamoDB의 GSI는 조회할 때 IndexName으로 인덱스를 직접 지정해줘야 합니다. 그래서 어떤 GSI가 어느 접근 패턴을 위해 존재하는지 모르면 코드를 읽기 어렵고, 새 기능을 붙일 때마다 기존 GSI를 활용할지, 새 GSI를 만들지 검토해야 합니다. 쓰기 쪽에도 부담이 있습니다. 아이템을 생성하거나 업데이트할 때 GSI를 조회하기 위한 값을 항상 함께 갱신해줘야 하고, GSI로 사용하고 있는 속성은 null로 설정할 수 없어 값 관리에도 신경 써야 합니다.
RDB에서는 새로운 쿼리 패턴이 나왔다면 인덱스를 추가하면 되기 때문에 상대적으로 조회에 대한 고민이 적었습니다.
가장 DynamoDB의 한계를 체감했던 부분입니다. DynamoDB는 JOIN을 지원하지 않기 때문에, 자주 함께 조회되는 데이터는 비정규화 설계를 권장하고 있습니다. 린다이어트 백엔드도 그에 따라 비정규화를 택한 부분이 많았습니다. 장면 2에서 소개한 집계 아이템과 같은 계열의 설계였죠. 자연스럽게 집계 아이템이 갖는 정합성 문제도 똑같이 갖게 됩니다.
운영 중 데이터를 보정할 때는 원래의 엔티티와 비정규화된 아이템들, 여러 곳의 정합성을 함께 맞춰야 하고, 그러려면 갱신해야 할 곳이 어디인지 전부 알고 있어야 합니다. 만들 때도 마찬가지입니다. 엔티티를 생성하거나 수정하는 코드에는 복제해 둔 아이템을 함께 갱신하는 코드가 항상 따라붙어야 했고, 하나라도 빠뜨리면 사용자는 일치하지 않는 데이터를 보게 됩니다.
이관 후에는 데이터를 복제해 두는 대신 읽는 시점에 JOIN으로 조회하기 때문에, 이런 갱신 관리 자체가 사라졌습니다.
그렇다고 RDB가 만능인 건 아니겠죠. RDB로 이관하고 난 뒤 저희가 새롭게 갖게 된 부담들도 있습니다.
반면, 흔히 RDB의 비용으로 꼽히는 스키마 관리는 저희에게 오히려 장점이었습니다. DynamoDB는 스키마리스라서 유연해 보이지만, 실제로는 신규 요구사항으로 프로퍼티가 추가될 때마다 기존 아이템 전체를 갱신하는 마이그레이션을 돌리거나 애플리케이션 단에서 기본값을 처리해야 했습니다. DynamoDB 역시 ERD 등으로 스키마를 정리한 뒤 설계하는 게 정석이기도 하고요. RDB로 옮긴 뒤엔 ORM 등의 도구를 활용해 스키마 문서화가 편해졌습니다.
그럼에도 이관을 후회하지 않는 이유는 앞서 세운 기준 그대로입니다. 위의 부담들은 예측 가능하고 해법이 잘 알려진, 감당하기로 한 비용이기 때문입니다.
| DynamoDB | RDB (PostgreSQL) | |
|---|---|---|
| 접근 패턴 | 설계 시점에 고정 — 벗어나면 GSI 추가 또는 Scan | 유연 — 새 질문은 새 쿼리(느리면 인덱스) |
| 검색 | 키 조건 중심 | ILIKE, full-text, 복합 조건 자유 |
| 집계/통계 | 없음 — 집계 아이템을 미리 만들거나 전량 로드 후 계산 | GROUP BY, COUNT, window function |
| 조인 | 없음 — 비정규화 또는 앱 레벨 병합 | JOIN |
| 스케일링 | 자동, 사실상 무한 | 수직 확장 중심 |
| 운영 부담 | 거의 없음 | 커넥션·인덱스·백업 등 상시 관리 |
| 비용 | 요청당 과금 | 상시 비용 |
이 글의 핵심을 요약하면 다음과 같습니다.
다음 편에서는 실제 이관 과정에서 AI를 적극적으로 활용했던 사례를 다루겠습니다. 100개가 넘는 테이블을 손으로 하나하나 설계하는 대신 ERD와 DDL 생성을 Claude Code 스킬(generate-erd, generate-ddl)로 자동화했는데, 정규화 규칙을 어떻게 스킬로 코드화했는지 공유하겠습니다. 감사합니다.
💬 린다이어트는 기술부채 해결에 진심이에요
이번 글에 담은 데이터베이스 이관은 저희 팀이 기술부채를 다루는 방식을 보여주는 하나의 사례일 뿐이에요. 미룰수록 불어나는 문제라면, 백엔드 전체의 데이터베이스를 옮기는 큰 결정도 망설이지 않습니다.
이렇게 부채를 덜어낸 만큼 저희는 본질에 가까워집니다. 의료진에게는 진료에만 집중할 수 있는 환경을 만들고, 환자에게는 쉽고 빠르게 다이어트할 수 있는 경험을 만드는 일이죠.
물론 앞으로 풀어야 할 문제도 많이 남아 있습니다. 그래서 저희는 지금, 이 문제들을 함께 풀어갈 분들을 찾고 있어요. 저희가 어떤 문제를 풀고 있는지 궁금하시다면 아래 채용 공고에서 확인해보세요!