5편. Prisma 관계 조회 심화: include / select와 중첩 관계 탐색
📚 목차
1. include vs select 차이와 실무 선택 기준
2. 중첩 관계 트리 조회 (Nested Query)
3. 관계 로드 전략 (Relation Load Strategy)

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /Prisma
1. include vs select 차이와 실무 선택 기준
Prisma는 SQL처럼 JOIN을 직접 작성하지 않습니다.
대신, “어떤 구조의 결과 데이터가 필요한가”를 선언하면 Prisma가 내부적으로 적절한 쿼리를 생성합니다.
🔷 관계 탐색(Relation Traversal)의 실무 적용
Prisma에서의 조회는 테이블을 합치는 과정이 아니라, 루트(Root) 모델에서 시작해 연결된 노드를 찾아가는 탐색입니다.
const users = await prisma.user.findMany({
include: {
posts: {
where: { published: true },
take: 5,
},
},
});
▸ 의미: User를 기점으로, 각 사용자가 가진 posts 관계를 탐색하여 결과 객체에 포함합니다.
▸ 실무 포인트: Prisma 7에서는 이 탐색 과정에서 필터링(where)이나 페이지네이션(take, skip)을 매우 직관적으로 작성할 수 있습니다. 이는 SQL JOIN 문에서 서브쿼리를 복잡하게 작성해야 했던 수고를 크게 덜어줍니다.
🔷 include : "빠른 개발과 도메인 로직 중심"
실무에서 include는 주로 비즈니스 로직 내부에서 엔티티의 전체 상태가 필요할 때 사용합니다.
▸ 장점: 모델의 모든 필드가 보장되므로, 서비스 레이어의 함수에 인자로 넘길 때 타입 호환성이 좋습니다.
▸ 단점: password나 secretKey 같은 민감한 필드가 실수로 API 응답에 포함될 위험이 있습니다.
// 유저 정보와 게시글을 가져와서 비즈니스 로직 처리
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true }, // 유저의 모든 정보가 필요할 때
});
반환되는 구조는 Prisma 모델 정의를 그대로 따릅니다.
{
id: number;
email: string;
posts: Post[];
}
🔷 select : "성능 최적화와 보안 중심"
트래픽이 많은 서비스나 모바일 앱용 API에서는 select가 표준입니다.
▸ DB 부하 감소: SQL 차원에서 SELECT *가 아닌 필요한 컬럼만 조회하므로 DB I/O를 줄입니다.
▸ 보안: 노출되면 안 되는 필드를 원천 차단합니다.
▸ 중첩 조회: 관계 데이터의 일부 필드만 가져올 때 필수입니다.
// API 응답용 데이터 추출 (필요한 것만 딱!)
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
_count: { // Prisma의 유용한 기능: 관계 개수만 파악
select: { posts: true }
},
posts: {
where: { published: true }, // 관계 데이터 필터링 가능
take: 5, // 개수 제한
select: {
title: true,
}
}
}
});
🔷 include vs select 비교 요약
| 구분 | include | select |
| 핵심 철학 | 확장(Extension): 기본 모델에 관계 데이터를 붙이는 방식 | 투영(Projection): 필요한 필드만 명시적으로 추출 |
| 반환 타입 | 기본 모델의 모든 필드 + 관계 모델 | 선택한 필드만 포함하는 Sparse Object |
| 데이터 양 | 상대적으로 많음(불필요한 컬럼 포함 가능) | 최소화(네트워크·메모리 효율적) |
| 유연성 | 관계 데이터를 통째로 가져올 때 편리 | 필드 단위의 세밀한 제어 가능 |
Prisma 사고 관점에서 보면,
▸ include는 객체 그래프 확장에 가깝고
▸ select는 쿼리 결과 형태를 설계하는 접근에 가깝습니다.
🔷 실무 선택 기준
| 구분 | include | select |
| 주요 사용 사례 | 관리자 페이지내부 도구 | 외부 API 응답 |
| 트래픽 특성 | 상대적으로 트래픽이 적음 | 트래픽이 많은 엔드포인트 |
| 개발 단계 | 빠른 프로토타이핑에 적합 | 운영·배포 단계에 적합 |
| 데이터 관리 방식 | 모델 전체 구조를 빠르게 파악 | DTO 구조를 엄격히 통제 |
| 실무 활용 패턴 | 관계 구조 탐색 및 초기 구현 | 성능·보안·응답 크기 최적화 |
실무에서는 “include로 전체 구조를 파악한 뒤, select로 필요한 필드만 정제한다”는 단계적 접근이 가장 일반적이며 안정적인 전략입니다.
2. 중첩 관계 트리 조회 (Nested Query)
Prisma의 강력함은 관계 트리를 한 번에 선언할 수 있다는 점입니다.
// 스키마 기준 관계 기준
User
├─ posts (1:N)
│ ├─ comments (1:N)
│ │ └─ author (N:1 → User)
└─ profile (1:1)
// 실습할 핵심 트리
User
└─ posts
└─ comments
└─ author (User)
🔷 include를 활용한 전체 트리 조회
프로토타이핑 단계나 내부 관리자 툴에서는 관계 전체를 빠르게 훑는 것이 중요합니다.
const usersWithAllData = await prisma.user.findMany({
include: {
posts: {
include: {
comments: {
include: {
author: true, // 댓글 작성자의 전체 프로필까지 포함
},
},
},
},
},
});
//결과 구조 예시
[
{
"id": 1,
"name": "강하늘",
"email": "sky@example.com",
// ... User 모델의 모든 필드
"posts": [
{
"id": 101,
"title": "Prisma 중첩 조회 가이드",
"content": "이것은 본문 내용입니다...",
"authorId": 1,
"comments": [
{
"id": 501,
"content": "정말 도움이 되는 글이네요!",
"postId": 101,
"authorId": 2,
"author": {
"id": 2,
"name": "김철수",
"email": "chulsoo@example.com"
// ... 댓글 작성자의 모든 필드 포함
}
}
]
}
]
}
]
🔷 select를 활용한 정밀한 트리 제어
실제 서비스 API를 만들 때는 "필요한 것만, 안전하게" 가져오는 것이 핵심입니다. select 안에 다시 select를 중첩하여 구조를 설계합니다.
const fastUsers = await prisma.user.findMany({
select: {
id: true,
name: true, // 이메일 등은 제외하고 이름만 노출
posts: {
where: { published: true }, // 게시글 중 '발행'된 것만 필터링 가능!
take: 3, // 최근 게시글 3개만 가져오기 (성능 최적화)
select: {
title: true,
createdAt: true,
_count: { // 댓글 전체 리스트 대신 개수만 먼저 파악
select: { comments: true }
},
comments: {
take: 5,
select: {
content: true,
author: { // 다시 유저 모델로 들어가서 이름만 추출
select: { name: true }
}
}
}
}
}
}
});
🔷 include + select 조합
사용자의 핵심 프로필만 선택적으로 조회하면서, 연결된 게시글 전체와 각 댓글의 작성자 이름까지 한 번의 쿼리로 정밀하게 가져오는 구조입니다
const result = await prisma.user.findMany({
// 1. 최상위 사용자는 '일부'만 선택 (id, name)
select: {
id: true,
name: true,
// 2. 게시글(posts)은 '전체' 필드를 가져오기 위해 include 사용
posts: {
include: {
// 3. 댓글(comments)은 다시 '일부' 필드만 선택
comments: {
select: {
id: true,
content: true,
// 4. 핵심: 댓글 작성자(author)의 '이름'만 포함하도록 개선 🎯
author: {
select: {
name: true,
},
},
},
},
},
},
},
});
🔷 soft delete 조건 적용
실습 스키마에는 모든 주요 테이블에 deletedAt 컬럼이 존재합니다.
관계 조회 시 soft delete 조건을 각 관계 단계마다 where: { deletedAt: null }을 명시적으로 적용해야 합니다.
const users = await prisma.user.findMany({
where: {
deletedAt: null,
},
select: {
id: true,
email: true,
posts: {
where: {
deletedAt: null,
},
select: {
id: true,
title: true,
},
},
},
});
🔷 중첩 조회의 핵심 장점
1. N+1 문제 해결:
여러 번의 쿼리를 날리지 않고 DB 수준에서 Join 혹은 최적화된 Batch Query로 한 번에 처리합니다.
2. 페이로드 최적화:
게시글의 무거운 content 필드를 제외하고 title만 가져옴으로써 모바일 환경 등에서 네트워크 속도를 개선합니다.
3. 유연한 필터링:
중첩된 posts 안에서도 where, orderBy, take 등을 사용하여 원하는 데이터만 골라낼 수 있습니다.
3. 관계 로드 전략 (Relation Load Strategy)
Prisma 7.x에서 관계 데이터를 조회할 때, 내부적으로 어떤 방식으로 데이터를 가져올지 결정하는 relationLoadStrategy 옵션을 사용할 수 있습니다.
| 전략 | 설명 |
| join | 데이터베이스 수준의 JOIN을 활용해 한 번의 조회로 관계 데이터를 함께 가져옵니다. 단일 SQL 쿼리(조인 포함)로 필요한 데이터를 조회한 뒤, 결과를 관계 구조로 구성합니다. |
| query | 관계별로 필요한 데이터를 나눠 조회한 후 애플리케이션에서 결합합니다. 기준 엔티티를 먼저 조회하고, 이후 관계마다 별도의 SELECT를 실행한 뒤 애플리케이션 레벨에서 매핑/결합합니다. |
▸ 기본값: 명시하지 않으면 Prisma 엔진이 쿼리 구조를 분석하여 가장 효율적인 전략을 자동으로 선택합니다.
✔️ 이 기능은 현재 Preview Feature 단계로 제공됩니다.
1. 데이터베이스 간 기능 격차 (SQLite 미지원)
가장 큰 이유는 모든 지원 데이터베이스에서 동일하게 작동하지 않기 때문입니다.
현재 PostgreSQL, MySQL 등에서는 잘 작동하지만, SQLite와 같은 가벼운 DB에서는 아직 완벽한 조인 전략을 지원하지 못하고 있습니다
2. '카테시안 곱(Cartesian Product)'에 대한 안전장치 부족
join 전략은 강력하지만, 1:N 관계가 중첩될 경우 데이터가 기하급수적으로 중복 전송되는 위험이 있습니다.
3. 성능 예측의 불확실성
JOIN이 항상 Separate Query보다 빠른 것은 아닙니다. 인덱스 상태, 데이터의 양, 네트워크 레이턴시에 따라 성능 결과가 천차만별입니다.
🔷 환경 설정 (필수)
Prisma 7.x에서도 이 기능을 활성화하려면 schema.prisma 파일에 직접 명시해야 합니다.
설정 후에는 반드시 npx prisma generate를 실행해야 합니다.
generator client {
provider = "prisma-client"
output = "../generated"
// Prisma 7.x 기준 여전히 Preview 기능이므로 설정이 필수입니다.
previewFeatures = ["relationJoins"]
}
datasource db {
provider = "postgresql" // MySQL, SQL Server, CockroachDB 지원
}
🔷 join 전략 (Database-level JOIN)
단 한 번의 데이터베이스 호출로 모든 관계 데이터를 가져옵니다.
▸ 네트워크 지연(Latency) 이 커서 쿼리 횟수를 최소화해야 할 때.
▸ 1:1 관계를 조회할 때.
▸ 전체 데이터 결과 셋이 크지 않아 카테시안 곱(Cartesian Product) 부하가 적을 때.
const users = await prisma.user.findMany({
relationLoadStrategy: 'join', // 전략 명시
include: {
posts: {
include: {
comments: true,
},
},
},
});
✔️ 생성되는 SELECT 문 예시
Prisma의 join 전략 SQL이 복잡한 이유는 중복 행을 반환하지 않고, 관계 데이터를 JSON 트리로 한 번에 조립하기 위해서이며, 이를 위해 LATERAL JOIN + JSONB_AGG/BUILD_OBJECT를 적극적으로 사용하기 때문이다.
SELECT
"t0"."id",
...
"t0"."email",
"t0"."display_name" AS "displayName",
"User_posts"."__prisma_data__" AS "posts"
FROM "study"."users" AS "t0"
LEFT JOIN LATERAL (
SELECT
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__"
FROM (
SELECT
"t3"."__prisma_data__"
FROM (
SELECT
JSONB_BUILD_OBJECT(
'id', "t2"."id",
...
'authorId', "t2"."author_id",
'comments', "Post_comments"."__prisma_data__"
) AS "__prisma_data__",
"t2"."id"
FROM (
SELECT
"t1".*
FROM "study"."posts" AS "t1"
WHERE
"t0"."id" = "t1"."author_id"
/* root select */
) AS "t2"
LEFT JOIN LATERAL (
SELECT
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__"
FROM (
SELECT
"t7"."__prisma_data__"
FROM (
SELECT
JSONB_BUILD_OBJECT(
'id', "t6"."id",
...
'authorId', "t6"."author_id"
) AS "__prisma_data__"
FROM (
SELECT
"t5".*
FROM "study"."comments" AS "t5"
WHERE
"t2"."id" = "t5"."post_id"
/* root select */
) AS "t6"
/* inner select */
) AS "t7"
/* middle select */
) AS "t8"
/* outer select */
) AS "Post_comments"
ON true
/* inner select */
) AS "t3"
/* middle select */
) AS "t4"
/* outer select */
) AS "User_posts"
ON true;
-- Params: []
🔷 query 전략 (Application-level Join)
Prisma의 전통적인 방식으로, 각 테이블에 대해 별도의 쿼리를 실행합니다.
▸ 관계 트리가 깊거나 1:N 관계가 많아 JOIN 시 중복 데이터가 너무 많이 생성될 때.
▸ 관계 모델에 take, skip 같은 페이지네이션이나 복잡한 필터링이 포함될 때.
▸ 데이터베이스 엔진이 복잡한 JOIN을 처리하는 데 과부하가 걸릴 때.
const users = await prisma.user.findMany({
relationLoadStrategy: 'query', // 개별 쿼리로 분할 실행
include: {
posts: {
include: {
comments: true,
},
},
},
});
✔️ 생성되는 SELECT 문 예시
// Step 1: 대상 유저 조회
// 가장 먼저 메인 테이블인 User 정보를 가져옵니다.
SELECT
"study"."users"."id",
...
"study"."users"."email",
"study"."users"."display_name"
FROM "study"."users"
WHERE 1 = 1
OFFSET $1;
// Step 2: 조회된 유저들에 속한 포스트 조회
SELECT
"study"."posts"."id",
...
"study"."posts"."author_id"
FROM "study"."posts"
WHERE "study"."posts"."author_id" IN (...)
OFFSET $28;
// Step 3: 조회된 포스트들에 속한 댓글 조회
SELECT
"study"."comments"."id",
...
"study"."comments"."author_id"
FROM "study"."comments"
WHERE "study"."comments"."post_id" IN (...)
OFFSET $55;
✔️ query 전략의 핵심 메커니즘
1. Prisma 엔진이 각 단계의 결과를 메모리에서 결합(Join)합니다.
2. join 전략과 달리 중복 데이터(카테시안 곱) 전송이 없습니다. 각 레코드는 네트워크를 통해 단 한 번만 전송됩니다.
3. 아주 복잡한 조인 연산 대신 단순한 인덱스 기반의 SELECT 쿼리를 여러 번 실행하므로, 데이터베이스 엔진의 CPU 부담을 덜 수 있는 경우가 많습니다.
🔷 실무 가이드
1. 자동 판단 신뢰:
대부분의 경우 Prisma의 기본 자동 선택 기능이 우수합니다. 처음부터 모든 쿼리에 전략을 강제할 필요는 없습니다.
2. 병목 구간 최적화:
특정 API의 성능이 느리다면 log: ['query']를 통해 생성된 SQL을 확인하고 전략을 변경해 보며 벤치마킹하세요.
3. 카테시안 곱 주의:
join은 결과 레코드가 중복되어 전송되므로, 너무 많은 1:N 관계를 한 번에 join으로 묶으면 메모리 부족(OOM)이 발생할 수 있음을 명심해야 합니다.
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > Prisma(ORM)' 카테고리의 다른 글
| [Prisma7] 7편. Prisma 트랜잭션과 데이터 정합성: 실무 설계와 구현 (0) | 2026.01.21 |
|---|---|
| [Prisma7] 6편. Prisma로 해결되지 않는 쿼리 다루기: Raw SQL 실전 활용 (0) | 2026.01.20 |
| [Prisma7] 4편. 조회 쿼리 고급 옵션(where)과 관계 데이터 생성(create) 패턴 이해 (0) | 2026.01.16 |
| [Prisma7] 3편. Prisma Client Query 구조 와 CRUD API 이해하기 (0) | 2026.01.14 |
| [Prisma7] 2편. schema.prisma 설계 규칙: 필드,제약,인덱스,Relation (0) | 2026.01.13 |