4.Node.js/Prisma(ORM)

[Prisma7] 5편. Prisma 관계 조회 심화: include / select와 중첩 관계 탐색

쿼드큐브 2026. 1. 19. 14:22
반응형
반응형

 

5편. Prisma 관계 조회 심화: include / select와 중첩 관계 탐색

 

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

 

Prisma 관계 조회 삽화 이미지
Prisma 관계 조회 삽화 이미지

 

📂 [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 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형