DynamoDB JOIN: 테이블을 조인하는 방법(그리고 왜 대개 못 하는가)
DynamoDB에는 JOIN이 없습니다. API에는 조인 연산자가 없고, 데이터 모델에는 외래 키가 없으며 —
대부분의 사람을 놀라게 하는 부분으로 — SQL 풍의 쿼리 계층인 PartiQL도 하나를 추가하지 않습니다.
PartiQL SELECT는 정확히 하나의 테이블만 읽습니다.
관계형 데이터베이스에서 왔다면, 이것이 첫 번째로 부딪히는 벽입니다. 이 가이드는 그 벽이 거기 있는 이유, 개발자들이 대신 하는 네 가지, 진짜 조인이 정말로 필요한 한 가지 경우 — 그리고 하나를 실행하는 방법을 다룹니다.
DynamoDB에서 조인을 할 수 있나요?
아니요. DynamoDB는 테이블을 조인할 수 없습니다 — 저수준 API(GetItem / Query / Scan /
BatchGetItem)를 통해서도, PartiQL을 통해서도, 어떤 내장 쿼리 플래너를 통해서도 안 됩니다. 쿼리
플래너 자체가 없기 때문입니다. 모든 읽기는 단일 테이블이나 그 인덱스 중 하나로 매핑됩니다. 일치하는
키로 두 테이블을 결합하는 것은 DynamoDB가 항목을 돌려준 후에 앱에서 하는 일이지, 결코 그 안에서가
아닙니다.
- DynamoDB에는
JOIN연산자가 없습니다. 한 번도 없었습니다. - PartiQL의
SELECT는 단일 테이블 전용입니다 — 문법은 문자 그대로SELECT … FROM {{table}}[.{{index}}]이며, 두 테이블을 겨누면ValidationException: Only Select from a Single Table or index supported를 반환합니다. - AWS가 권장하는 해결책은 조인이 필요 없게 하는 것입니다: 비정규화하거나, 단일 테이블 설계를 사용해 관련 항목이 단일 요청으로 가져오는 한 파티션에 살게 하세요.
- 진짜 교차 테이블 / 즉석 경우에는, DynamoDB 밖에서 조인합니다 — 앱에서, 또는 대신 해주는 도구로.
DynamoDB에 조인이 없는 이유
SQL JOIN은 데이터베이스에 여러 테이블을 읽고 쿼리 시점에 조립하도록 요청합니다. AWS 자체의
관계형 데이터 모델링 가이드는
그 비용을 명확히 합니다. 다음과 같은 쿼리는
SELECT * FROM Orders
INNER JOIN Order_Items ON Orders.Order_ID = Order_Items.Order_ID
INNER JOIN Products ON Products.Product_ID = Order_Items.Product_ID
ORDER BY Quantity_on_Hand DESC유연하지만, "쿼리의 각 조인은 쿼리의 런타임 복잡도를 증가시킵니다. 각 테이블의 데이터를 준비한 다음 조립해야 하기 때문입니다." 그 작업은 무한합니다 — 비용이 쿼리가 아니라 데이터에 달려 있습니다 — 이는 정확히 DynamoDB가 갖기를 거부하는 속성입니다.
그래서 AWS는 그 제약을 설계에 넣었습니다. DynamoDB는 그들의 말로, "JOIN을 제거하고(그리고
데이터 비정규화를 장려하고) 애플리케이션 쿼리를 항목에 대한 단일 요청으로 완전히 답하도록 데이터베이스
아키텍처를 최적화함으로써 [CPU와 네트워크] 제약을 모두 최소화하도록 만들어졌습니다." 그것이 어떤
규모에서든 한 자릿수 밀리초 지연을 사는 속성입니다: DynamoDB 읽기의 런타임 비용은 테이블 크기와
관계없이 일정합니다. 설계상 계획을 세울 조인 엔진도, 외래 키 개념도 없습니다.
"그래도 PartiQL은 SQL이잖아, 조인하겠지?"
아니요. PartiQL은 DynamoDB에 대한 SELECT / INSERT / UPDATE / DELETE 구문을 주지만,
SQL이 아니라 SQL-호환입니다.
공식 SELECT 문법은:
SELECT {{expression}} [, ...]
FROM {{table}}[.{{index}}]
[ WHERE {{condition}} ]
[ ORDER BY {{key}} [DESC|ASC], ... ]FROM은 하나의 테이블(선택적으로 그 인덱스 중 하나)을 받습니다. 두 번째 FROM 테이블도,
JOIN도, 서브쿼리도, CTE도 없습니다. PartiQL을 두 테이블에 겨누면 DynamoDB가 거부합니다
(AWS re:Post에 보고됨):
ValidationException: Only Select from a Single Table or index supportedPartiQL이 SQL처럼 보이지만 SQL처럼 행동할 수 없는 전체 근거를 원한다면, PartiQL 대 SQL을 보세요.
개발자들이 실제로 사용하는 4가지 우회 방법
1. 비정규화(데이터를 복사해 넣기)
조인할 필드를 항목에 직접 저장합니다. Order가 나중에 해석할 customerId 대신 customerName과
shippingAddress의 스냅샷을 담습니다. 읽기 한 번, 조인 없음.
비용은 쓰기 시점 팬아웃입니다: 소스가 변하면 모든 복사본을 갱신합니다(보통 DynamoDB Streams 핸들러를 통해). 읽기 복잡도를 쓰기 복잡도와 맞바꾸는 것이며 — 읽기가 많은 앱에는 보통 좋은 거래입니다.
2. 단일 테이블 설계(파티션에서 미리 조인)
관련 엔티티를 공유 파티션 키 아래 하나의 테이블에 두어 항목 컬렉션 자체가 조인된 결과가 되게
합니다. 고객과 그들의 모든 주문이 PK = "CUSTOMER#42"를 공유합니다. 하나의 Query가 고객 항목과
모든 주문 항목을 반환합니다 — "조인"이 이미 쓰기 시점에 일어난 것입니다.
Query PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE (고객)
→ CUSTOMER#42 / ORDER#1001 (주문)
→ CUSTOMER#42 / ORDER#1002 (주문)
이것이 일대다 관계에 대한 정석적인 DynamoDB 답입니다. 전체 설명은 단일 테이블 설계에서.
3. 애플리케이션 측 조인(읽기 두 번, 코드에서 꿰매기)
테이블 A에서 읽고, 받은 키를 가지고 테이블 B에서 읽은 다음, 애플리케이션에서 두 결과 집합을 병합합니다. 관계형 조인 로직 — 다만 데이터베이스가 아니라 여러분의 코드에서 돌아가는 것입니다:
// "각 주문을 그 고객 이름과 함께 가져오기" — 수동 조인.
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});
const customers = await Promise.all(
orders.map((o) => ddb.getItem({TableName: 'Customers', Key: {id: o.customerId}}))
);
const joined = orders.map((o, i) => ({
...o,
customerName: customers[i].Item?.name
}));작은 팬아웃에는 괜찮습니다. 주문이 많으면 N+1 문제가 됩니다 — 주문 목록에 읽기 한 번, 그다음 주문마다
읽기 한 번 — 느리고 읽기 용량을 태웁니다. BatchGetItem(다음)은 그 두 번째 물결을 한 번의
왕복으로 접습니다.
4. BatchGetItem(한 번의 왕복, 여러 테이블)
BatchGetItem은
API가 "두 테이블을 한 번에 건드리는" 것에 가장 가까이 가는 방법입니다: 하나의 요청이 "하나 이상의
테이블에서 하나 이상의 항목의 속성"을 반환하며, 호출당 100개 항목 또는 16 MB 중 먼저
도달하는 것까지입니다. 앱 측 조인의 왕복을 줄여주지만 — 조인은 아닙니다. "요청된 항목을 기본
키로 식별"하며, ON 조건도 관계형 매칭도 없습니다. 여전히 키를 미리 알고 응답을 직접 꿰매야 합니다.
진짜 JOIN을 피할 수 없을 때
네 가지 우회 방법은 프로덕션 읽기 경로를 잘 커버합니다. 무너지는 곳은 즉석의, 탐색적, 분석적 쿼리입니다 — 모델링하지 않은 그것입니다:
Orders테이블과Customers테이블에 걸쳐 "지난달 $500 넘게 주문한 EU 고객은 누구인가?"- 두 엔티티 유형을 조인하는 일회성 데이터 품질 점검.
- 리포팅과 집계(
GROUP BY,SUM,COUNT) — DynamoDB에 아예 연산자가 없는 것.
이들은 정확히 파티션에 미리 구울 수 없는 쿼리입니다. 정의상 묻게 될 줄 몰랐기 때문입니다. 관계형 본능 —
JOIN을 쓰는 것 — 이 여기서는 옳습니다. DynamoDB가 그저 네이티브로 제공할 수 없고, PartiQL도
못합니다.
흔한 무거운 답은 S3로 내보내 Athena로 쿼리하거나 웨어하우스로 파이프하는 것입니다. 규모 있는 진짜 분석에는 옳지만, 지금 라이브 테이블에 대해 답하고 싶은 질문에는 많은 배관 작업입니다.
DynoTable의 SQL Workbench로 진짜 JOIN 실행하기
DynoTable은 SQL Workbench가 실제 SQL — JOIN, GROUP BY, 집계 함수
포함 — 을 DynamoDB 테이블에 대해 실행하는 데스크톱 DynamoDB 클라이언트입니다. 일반 DynamoDB API를
통해 항목을 읽은 다음, 쿼리의 관계형 부분을 클라이언트에서 실행합니다. 그래서 다음을 쓸 수 있습니다:
SELECT c.name, SUM(o.total) AS spend
FROM Customers c
JOIN Orders o ON o.customerId = c.id
WHERE c.region = 'EU'
GROUP BY c.name
HAVING SUM(o.total) > 500— 그리고 관계가 정의되지 않은 테이블과 JOIN 키워드가 없는 쿼리 엔진에 대해 결과 집합을 얻습니다.
정직한 주의 — "DynamoDB의 액세스 패턴 규칙 내": Workbench는 여전히 DynamoDB를 통해 읽으므로,
무한한 조인은 무한한 읽기입니다. 가장 빠른 쿼리는 WHERE 절(또는 조인의 ON 속성)이 최소 한쪽의
파티션 키나 GSI에 닿아, DynamoDB가 전체 테이블
스캔이 아니라 조인 실행 전에 Query를 돌리는 경우입니다. Workbench는
이 가이드의 제약을 철회하지 않습니다 — 그저 손수 꿰매는 대신 SQL 질문을 던지게 해주고, 그 아래에서
무엇을 하는지 알려줍니다.
이것은 실제로 참인 유일한 "예, 조인할 수 있습니다"입니다: PartiQL과 AWS 자체의
NoSQL Workbench
— 그 작업 빌더는 단일 테이블 데이터 플레인 작업(Query / Scan / GetItem)으로 제한됩니다 —
둘 다 단일 테이블 벽에서 멈추며, 대부분의 다른 GUI 클라이언트도 그렇습니다. DynoTable이
DynamoDB GUI로서 어떻게 견주는지 보세요.
FAQ
PartiQL이 JOIN을 지원하나요?
아니요. PartiQL의 SELECT는 단일 테이블(또는 그 인덱스 중 하나)을 읽습니다. 다중 테이블 쿼리는
ValidationException: Only Select from a Single Table or index supported를 반환합니다.
나머지 API와 같은 벽입니다.
한 쿼리에서 두 DynamoDB 테이블을 조인할 수 있나요?
네이티브로는 안 됩니다. DynamoDB API에는 두 테이블을 읽고 키로 매칭하는 문장이 없습니다.
BatchGetItem은 한 요청에서 여러 테이블의 항목을 읽을 수 있지만 ON 조건이 없습니다 — 기본 키로
지정한 항목을 반환하고 매칭은 여러분에게 맡깁니다. 진짜 JOIN … ON …은 DynamoDB 밖에서만
일어납니다: 앱에서, 또는 DynoTable의 SQL Workbench에서.
테이블을 그 GSI에 조인할 수 있나요?
아니요 — 글로벌 보조 인덱스는 조인할 별도 테이블이 아니라, 같은 항목의 대체
키 뷰입니다. 주어진 SELECT에서 테이블 또는 인덱스 중 하나를 Query하지, 둘을 함께 조인하지
않습니다. GSI는 항목을 다른 키로 도달하게 해주며, 이는 종종 애초에 조인의 필요를 없앱니다.
두 AWS 계정에 걸쳐(또는 다른 계정의 두 테이블을) 조인할 수 있나요?
네이티브로도, BatchGetItem으로도 안 됩니다 — 단일 요청은 자격 증명을 넘나들 수 없고, 계정 간 조인
프리미티브가 없습니다. 각 테이블을 그 계정의 자격 증명으로 읽은 다음, 애플리케이션이나 DynoTable의
Workbench 같은 도구에서 결과를 조인하게 됩니다.
비정규화가 정말 조인보다 나은가요? DynamoDB의 대상 워크로드 — 예측 가능한 대용량 읽기 — 에는 그렇습니다. 평탄하게 확장되는 단일 요청 읽기를 얻는 대가로 비용을 쓰기 시점으로 옮기고(약간의 데이터 중복을 감수합니다). 단일 테이블 설계 가이드가 트레이드오프를 다룹니다.
이런 읽기를 위한 키와 조건을 손으로 만드는 것은 번거롭습니다 —
expression builder가
KeyConditionExpression / FilterExpression 구문을 대신 생성하고,
DynoTable은 우회 방법이 통하지 않을 때 진짜 SQL을 실행합니다.