4.Node.js/Prisma(ORM)

[Prisma7] 4편. 조회 쿼리 고급 옵션(where)과 관계 데이터 생성(create) 패턴 이해

쿼드큐브 2026. 1. 16. 08:33
반응형
반응형

 

4편. 조회 쿼리 고급 옵션(where)과 관계 데이터 생성(create) 패턴 이해

 

📚 목차
1. 조회 쿼리 고급 옵션 : where / select / include / pagination
2. 관계 데이터 생성 패턴 : connect / create / connectOrCreate

 

삽화 이미지
삽화 이미지

 

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /Prisma

 

1. 조회 쿼리 고급 옵션 : where / select / include / pagination

🔷 where 조건: 정밀한 필터링

1) 기본 스칼라 필터 (Boolean / Number / Date)

- 예시코드: /ch03/3-1-1.기본스칼라필터.ts

where: {
  // published 컬럼이 true(발행됨)인 글만 조회
  published: true,
  // viewCount(조회수)가 100 "초과"인 글만 조회  (>)
  viewCount: { gt: 100 },
  // viewCount가 100 "이상"인 글만 조회 (>=)
  viewCount: { gte: 100 },
  // viewCount가 100 "미만"인 글만 조회 (<)
  viewCount: { lt: 100 },
  // viewCount가 100 "이하"인 글만 조회 (<=)
  viewCount: { lte: 100 },
  // createdAt(작성일)이 2025-01-01 00:00:00 "이후(포함)"인 글만 조회 (>=)
  createdAt: { gte: new Date('2025-01-01') },
  // createdAt이 "현재 시각 이전"인 글만 조회 (<)
  // 일반적으로 미래 예약 데이터 제외 등에 사용
  createdAt: { lt: new Date() },
}

 

2) 문자열 필터 (String)

- 예시코드: /ch03/3-1-2.문자열필터.ts

where: {
  // title이 정확히 'Prisma' 와 "완전 일치"하는 글만 조회 (=)
  title: { equals: 'Prisma' },
  // title에 'Prisma' 문자열이 "포함"된 글만 조회 (LIKE %Prisma%)
  title: { contains: 'Prisma' },
  // contains + mode:'insensitive' => 대소문자 구분 없이 포함 검색
  // 예: 'prisma', 'PRISMA' 모두 매칭
  title: { contains: 'Prisma', mode: 'insensitive' },
  // title이 'Pri' 로 "시작"하는 글만 조회 (LIKE Pri%)
  title: { startsWith: 'Pri' },
  // title이 'ORM' 으로 "끝"나는 글만 조회 (LIKE %ORM)
  title: { endsWith: 'ORM' },
  // title이 배열 안 값 중 "하나"와 일치하면 조회 (IN (...))
  title: { in: ['Prisma', 'ORM'] },
  // title이 배열 안 값들은 "제외"하고 조회 (NOT IN (...))
  title: { notIn: ['Draft', 'Temp'] },
  // title에 'deprecated'가 포함된 것은 "제외" (NOT LIKE %deprecated%)
  title: { not: { contains: 'deprecated', mode: 'insensitive' } },
}

 

3) NULL 필터 (Nullable 필드)

- 예시코드: /ch03/3-1-3.Null필터.ts

where: {
  // content가 NULL인 글만 조회
  // (예: 내용이 없는 임시 데이터)
  content: null,
  // content가 NULL이 "아닌" 글만 조회
  // (예: 정상적으로 내용이 있는 데이터만)
  content: { not: null },
}

 

4) 논리 조합 (AND / OR / NOT)

- 예시코드: /ch03/3-1-4.논리조합.ts

where: {
  // AND: 아래 조건들을 "모두 만족"해야 조회됨
  AND: [
    { published: true },            // 발행된 글
    { viewCount: { gt: 100 } },     // 조회수 100 초과
  ],
  // OR: 아래 조건들 중 "하나라도 만족"하면 조회됨
  OR: [
    { title: { contains: 'Prisma', mode: 'insensitive' } }, // title에 Prisma 포함
    { title: { contains: 'ORM', mode: 'insensitive' } },    // 또는 title에 ORM 포함
  ],
  // NOT: 아래 조건을 만족하는 데이터는 "제외"
  NOT: [
    { title: { contains: 'deprecated', mode: 'insensitive' } }, // deprecated 포함 글 제외
  ],
}

 

5) 관계 필터 (Relation Filtering) : 1:1 / N:1 관계 (author 같은 단일 관계) - is, isNot

- 예시코드: /ch03/3-1-5.관계필터-1.ts

▸ is: "관계가 반드시 존재해야 함" + "그 대상이 조건을 만족해야 함"
▸ isNot: "관계가 존재하지 않거나" OR "관계는 있지만 조건을 만족하지 않음"
▸ is: null: 관계가 맺어지지 않은 상태 (Foreign Key가 null인 경우 포함).
▸ isNot: null: 관계가 반드시 맺어져 있는 상태.

where: {
  author: {
    // 1) 작성자(author)가 존재하는 글만
    //    author 관계가 "optional(Nullable)"인 스키마에서만 의미가 있음.
    isNot: null,
    // 2) author(User)에 대한 추가 조건을 걸고 싶을 때 사용
    is: {
      // 예: 작성자 role이 ADMIN인 글만
      role: 'ADMIN',
      // email: { endsWith: '@company.com' }, // 작성자 이메일 도메인 조건도 가능
    
    // 3) author의 profile(1:1) 조건
    //    NOT( profile이 존재하고 AND (bio가 '01'을 포함) )
    // 참고) "profile은 반드시 존재"하면서 "bio에 '01' 미포함"을 원한다면
    //       profile: { is: { bio: { not: { contains: '01' } } } }
       
      profile: {
      // profile이 아예 없는 작성자(profile = null) : 포함
      // profile은 있지만 bio가 '01'을 포함하지 않는 작성자: 포함
        isNot: {
          bio: {
            contains: '01',
          },
        },
      },
    },
  },
}

 

6) 1:N 관계 (comments 같은 리스트 관계) - some / every / none

- 예시코드: /ch03/3-1-6.관계필터-2.ts, /ch03/3-1-7.관계필터-3.ts

where: {
  comments: {
    // some: 댓글 중 "하나라도" 조건을 만족하면 해당 Post를 조회
    // 예: 댓글에 helpful 포함된 글만
    some: {
      content: { contains: 'helpful', mode: 'insensitive' },
    },
    // every: 댓글이 "전부" 조건을 만족해야 조회 (현실적으로는 사용이 까다로운 편)
    // every: { content: { contains: 'helpful' } },
    
    // none: 댓글 중 조건을 만족하는 것이 "하나도 없어야" 조회
    // none: { content: { contains: 'spam' } },
  },
}

 

7) 중첩 조건 (깊게 타고 들어가기)

where: {
  author: {
    is: {
      profile: {
        // profile이 nullable이라면 is / isNot로 존재 여부와 조건을 함께 줄 수 있음
        is: {
          // author.profile.bio에 'backend'가 포함된 작성자의 글만 조회
          bio: { contains: 'backend', mode: 'insensitive' },
        },
      },
    },
  },
}

 

8) 배열 필드 필터 (String[] 등) - 스키마에 배열 필드가 있을 때

where: {
  // tags 배열에 'prisma' 값이 "포함"된 글만 조회
  tags: { has: 'prisma' },
  // tags 배열에 ['prisma', 'node'] 중 "하나라도" 포함되면 조회
  tags: { hasSome: ['prisma', 'node'] },
  // tags 배열에 ['prisma', 'node']가 "모두" 포함되어야 조회
  tags: { hasEvery: ['prisma', 'node'] },
  // tags 배열이 비어있는지 여부
  // isEmpty: false => 비어있지 않은 글만
  tags: { isEmpty: false },
}

 

🔷 select vs include

기준 select include
주요 목적 응답 데이터의 Shape(구조) 결정 관계된 전체 객체 로드
데이터 양 최소화 (필요한 필드만 조회 → 네트워크 비용 저렴) 많음 (관계 모델의 전체 컬럼 로드)
추천 상황 클라이언트 전달용 API, 대량 목록 조회 비즈니스 로직 연산, 복잡한 데이터 가공
관계 처리 하위 select를 통해 관계 필드도 선택적 추출 가능 관계 테이블의 모든 필드를 기본으로 가져옴

조회 쿼리를 설계할 때 가장 중요한 질문은 "이 API가 필요로 하는 최소한의 데이터는 무엇인가?"입니다

 

1) select : 필드 선택 (권장 기본값)

select는 SQL의 SELECT column1, column2와 정확히 대응됩니다.

Prisma에서 select를 사용한다는 것은 "내가 명시하지 않은 필드는 절대 가져오지 마라"고 선언하는 것과 같습니다.

보안: User 모델에 비밀번호나 내부 시스템용 필드가 있을 경우, select를 쓰지 않으면 실수로 외부에 노출될 수 있습니다.

성능: DB에서 애플리케이션 서버로 데이터를 전송할 때, 데이터의 크기가 작을수록 응답 속도가 빨라집니다.
타입 추론: TypeScript 환경에서 select로 지정한 필드만 타입으로 잡히기 때문에, 존재하지 않는 데이터에 접근하려는 런타임 에러를 방지합니다.

// 예시: 공개용 프로필 카드 데이터 조회
const userCard = await prisma.user.findUnique({
  where: { id: 135 },
  select: {
    id: true,
    displayName: true,
    // Profile 전체가 아닌 bio만 콕 집어서 가져오기 (관계의 select)
    profile: {
      select: { bio: true }
    }
  }
});

//로그 sql 문 예시
SELECT
  "study"."users"."id",
  "study"."users"."display_name"
FROM "study"."users"
WHERE "study"."users"."id" = 135;

SELECT
  "study"."profiles"."id",
  "study"."profiles"."bio",
  "study"."profiles"."user_id"
FROM "study"."profiles"
WHERE "study"."profiles"."user_id" = 135

Prisma는 기본적으로 User를 먼저 조회하고, 그 결과로 나온 user.id들을 모아 관계 테이블(Profile)을 별도 쿼리로 한 번 더 조회해 애플리케이션 레벨에서 합칩니다.
장점: 조인 결과가 “행이 늘어나는 문제(카테시안/중복)”를 피하고, 관계가 많거나 복잡해도 결과 Shape를 안정적으로 만들기 쉬움
단점: 단일 레코드여도 SQL이 2번 나갈 수 있음(지금 케이스)

 

2) include : 관계 데이터 포함 (모든 필드 + 관계)

include는 객체 지향적인 관점에서 접근합니다.

특정 모델을 조회할 때 그와 연결된 연관 관계(Relation)를 통째로 객체 그래프에 포함하고 싶을 때 사용합니다.

▸ 비즈니스 로직 처리: 게시글을 수정할 때 작성자 정보가 전부 필요하거나, 댓글의 모든 속성을 기반으로 서버 측 연산을 해야 할 때 유용합니다.
▸ 백오피스/어드민: 데이터의 전체 모습을 확인해야 하는 관리자 페이지에서 편리하게 사용됩니다.

async function exam1() {
  const userCard = await prisma.user.findUnique({
  // - 최상위에 include를 쓰면 User의 스칼라 컬럼을 "필드 제한 없이" 전부 로드하는 경향이 있다.
  //   (그래서 SQL에 created_at, updated_at, deleted_at, email 등도 함께 선택됨)
  where: { id: 135 },
    include: {
      profile: {
        select: { bio: true },
      },
    },
  });

  console.log(userCard);
}

// 로그 SQL 문 예시
SELECT
  "study"."users"."id",
  "study"."users"."created_at",
  "study"."users"."updated_at",
  "study"."users"."deleted_at",
  "study"."users"."email",
  "study"."users"."display_name"
FROM "study"."users"
WHERE "study"."users"."id" = 135

SELECT
  "study"."profiles"."id",
  "study"."profiles"."bio",
  "study"."profiles"."user_id"
FROM "study"."profiles"
WHERE "study"."profiles"."user_id" = 135

 

3) select와 include는 동시에 사용 불가

⚠️ Prisma에서는 select와 include를 동시에 사용할 수 없습니다.

select
▸ “어떤 필드를 반환할 것인가”에 대한 선언
▸ 반환 데이터의 Shape를 필드 단위(field-level)로 정의
▸ API 응답 스키마를 명확히 통제하는 데 초점

include
▸ “어떤 관계를 함께 로드할 것인가”에 대한 선언
▸ 반환 데이터의 Shape를 엔티티 그래프(entity graph) 단위로 정의
▸ 관계형 데이터의 탐색과 로딩에 초점

즉, select와 include는 반환 Shape를 정의하는 서로 다른 두 철학을 기반으로 하며,

이로 인해 Prisma는 두 옵션의 동시 사용을 허용하지 않습니다.

 

4) 중첩 include의 이해와 Depth 관리

Prisma의 가장 큰 장점 중 하나는 중첩된 관계를 조회할 때 N+1 문제(연관된 데이터를 가져오기 위해 쿼리가 반복 실행되는 현상)를 내부적으로 최적화한다는 점입니다.

 

📌 N+1 문제와 Prisma의 최적화
전통적인 ORM은 10명의 유저를 조회할 때 각 유저의 게시글을 찾기 위해 쿼리를 10번 더 날리는 N+1 문제를 일으킵니다.

Prisma는 이를 내부적으로 IN 연산자를 사용하여 단 2번의 쿼리(유저 전체 조회 + 해당 유저들의 게시글 통합 조회)로 해결합니다.

 

 

(1) 중첩 include의 편리함 (생산성)
Prisma는 여러 테이블에 흩어진 데이터를 단 한 번의 호출로 계층 구조(JSON)로 만들어 반환합니다.

// 3단계 중첩: 유저 -> 게시글 -> 댓글 -> 댓글 작성자
const userFeed = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: {
      include: {
        comments: {
          include: { author: true }
        }
      }
    }
  }
});

▸ 장점: 복잡한 JOIN 쿼리를 직접 작성할 필요가 없으며, 반환된 데이터의 타입이 자동으로 추론되어 개발 생산성이 극대화됩니다.

 

(2) 중첩 include의 위험성 (성능 및 보안)

▸ 데이터 폭발 (Over-fetching): 위의 예시에서 author: true를 호출하면, 댓글 작성자의 이메일, 가입일, 심지어 비밀번호 해시(모델에 포함된 경우)까지 모두 가져오게 됩니다. 이는 보안 취약점이 될 수 있습니다.

 

▸ 메모리 압박: DB에서 데이터를 효율적으로 가져오더라도(N+1 최적화), 서버(Node.js)는 이 거대한 JSON 덩어리를 메모리에 생성하고 조립해야 합니다. 중첩이 깊고 데이터가 많을수록 서버의 RAM 사용량이 급증합니다.

 

▸ 불필요한 부하: 사용자는 화면에서 게시글 제목만 보고 싶어 할 수도 있는데, 보이지도 않는 3~4단계 아래의 댓글 작성자 정보까지 가져오는 것은 명백한 자원 낭비입니다.

 

 

(3) 개선 방안: 중첩 include를 중첩 select로 전환

중첩 구조가 반드시 필요하다면, 하위 레벨에서도 select를 사용하여 필요한 필드만 필터링하는 것이 실무 표준입니다.

// 개선된 중첩 구조: 깊은 관계에서도 필요한 데이터만 최소화
const optimizedFeed = await prisma.user.findUnique({
  where: { id: 1 },
  select: {
    displayName: true,
    posts: {
      take: 5, // 개수 제한
      select: {
        title: true,
        comments: {
          where: { deletedAt: null }, // 논리 삭제 필터링
          select: {
            content: true,
            author: { select: { displayName: true } } // 필요한 필드만 콕 집어서!
          }
        }
      }
    }
  }
});

 

 

🔷 페이지네이션(Pagination) : Offset-based Pagination / Cursor-based Pagination

✔️Offset 기반 페이지네이션 vs Cursor 기반 페이지네이션

실시간 데이터가 많이 추가/삭제되는 서비스라면 반드시 Cursor 기반 페이지네이션을 사용하세요.

성능과 데이터 일관성에서 압도적인 이점을 제공합니다.

구분 Offset 기반 Cursor 기반
적합한 UI 전통적인 게시판 (페이지 번호 클릭) 무한 스크롤, SNS 타임라인
성능 뒤로 갈수록 느려짐 (O(N)) 항상 빠름 (O(1))
중복 / 누락 현상 발생 가능성 높음 거의 발생하지 않음
전체 개수 파악 쉬움 (count 쿼리) 어려움 (별도 count 필요)
임의 페이지 이동 가능 불가능
데이터 일관성 낮음 높음
Prisma 권장도 소규모 데이터 대용량 / 실시간 서비스 (강력 권장)
인덱스 요구사항 정렬 필드에 인덱스 커서 필드에 고유 인덱스

▸ 소규모 프로젝트 (< 10,000건): Offset 방식도 충분히 사용 가능
▸ 대규모 프로젝트 (> 100,000건): Cursor 방식 필수
▸ 관리자 도구: Offset 방식이 편리 (페이지 번호 필요)
▸ 사용자 피드: Cursor 방식 권장 (무한 스크롤)
▸ 하이브리드: 초반 N페이지는 Offset, 이후 Cursor 전환

 

✔️ Offset 기반 페이지네이션 (Offset-based Pagination)

데이터베이스에서 특정 개수만큼의 레코드를 건너뛰고(skip), 필요한 개수만큼 가져오는(take) 방식입니다.

// 3페이지를 조회 (페이지는 1부터 시작한다고 가정)
const page = 3;

// 한 페이지당 10개씩 가져오기
const limit = 10;

const posts = await prisma.post.findMany({
  // 앞에서부터 (page-1)*limit 개를 건너뜀
  // 3페이지면 (3-1)*10 = 20개 skip → 21번째부터 조회
  skip: (page - 1) * limit,

  // 이번 페이지에서 가져올 개수
  take: limit,

  // 최신 글이 먼저 오도록 생성일 내림차순 정렬
  orderBy: {
    createdAt: 'desc',
  },
});

장점: 특정 페이지로 바로 이동하는(예: 1, 2, 3... 10 버튼) UI 구현이 매우 쉽습니다.
단점 (성능 저하): 데이터베이스는 skip한 만큼의 데이터를 모두 읽어서 버린 뒤 다음 데이터를 찾습니다.

          만약 skip: 1000000이라면 성능이 급격히 떨어집니다.
단점 (데이터 중복/누락): 사용자가 1페이지를 보는 동안 새로운 글이 올라오면, 2페이지로 넘어갔을 때 1페이지에서 봤던 글이 중복되어 보일 수 있습니다.

 

✔️ Cursor 기반 페이지네이션 (Cursor-based Pagination)

마지막으로 조회한 데이터의 고유한 값(Cursor)을 기준으로 그 다음 데이터를 가져오는 방식입니다. Prisma에서는 이 방식을 강력히 권장합니다.

커서는 “다음 목록을 어디서부터 이어서 가져올지”를 알려주는 책갈피(bookmark)입니다.
ex) “마지막으로 내가 본 게시글이 id=100이었어” → “그다음 10개 가져와”
즉, 커서는 페이지 번호가 아니라 ‘마지막으로 본 기준점의 고유 값’입니다.

 

// 이전 페이지(또는 이전 요청)에서 "마지막으로 받은 게시글"의 id
// 다음 페이지는 이 id '이후'의 데이터를 가져오면 됨
const lastId = 100;

const posts = await prisma.post.findMany({
  take: 10, // 다음 게시글 10개만 조회

  // cursor: 기준점(시작 위치) 지정
  // id=100 레코드를 기준으로 페이지를 이어서 조회
  cursor: { id: lastId },

  // cursor로 지정한 레코드(id=100) 자체도 결과에 포함될 수 있어서
  // 그 1개를 제외하고 "그 다음부터" 가져오도록 skip: 1
  skip: 1,

  // cursor 기반 페이징은 "정렬 기준"이 매우 중요
  // 여기서는 id 오름차순으로 정렬해서 id=100 다음(101, 102, ...)을 가져옴
  orderBy: { id: 'asc' },
});

장점 (고성능): 인덱싱 된 커서 값을 기준으로 즉시 데이터 위치를 찾으므로, 데이터가 수억 건이라도 속도가 일정합니다.
장점 (안정성): 데이터가 실시간으로 추가되거나 삭제되어도 결과가 밀리거나 중복되지 않아 무한 스크롤이나 타임라인에 최적입니다.
단점: "500페이지로 바로 이동"과 같은 기능 구현이 어렵습니다. (이전 페이지의 마지막 커서 값을 알아야 하기 때문)

 

✔️ 복합 정렬 커서(Composite Cursor) 이해하기

실무 API에서는 단순히 id 순서가 아닌 createdAt(최신순), viewCount(조회수순) 등 다양한 기준으로 데이터를 정렬합니다.

하지만 정렬 기준 필드에 중복된 값(예: 동일한 작성 시간)이 존재할 경우, 단순 커서로는 데이터 누락이나 중복이 발생합니다.

이를 해결하기 위해 정렬 기준 필드와 고유 식별자(ID)를 결합한 것이 복합 커서입니다.

 

1) 필수 전제 조건: 스키마 설정

Prisma에서 복합 커서를 사용하려면 데이터베이스 레벨에서 해당 필드 조합이 고유하다는 것을 보장해야 합니다.

schema.prisma에 @@unique 설정을 반드시 추가해야 합니다.

model Post {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  title     String

  // 복합 커서 사용을 위한 필수 설정
  // Prisma는 이를 통해 'createdAt_id'라는 커서 이름을 생성합니다.
  @@unique([createdAt, id])
  //
  @@index([authorId, createdAt])
  @@index([published, createdAt])
  @@index([deletedAt])
  @@map("posts")
}

 

2) 예시 코드 (Prisma 7)

const posts = await prisma.post.findMany({
  take: 10,
  cursor: {
    // @@unique로 정의된 필드 조합을 키로 사용
    createdAt_id: {
      createdAt: new Date('2025-12-24T00:00:00Z'), // 마지막으로 본 데이터의 시간
      id: 105,                                   // 마지막으로 본 데이터의 ID
    },
  },
  orderBy: [
    { createdAt: 'desc' }, // 1차 정렬 기준
    { id: 'desc' }          // 2차 정렬 기준 (동일 시간 데이터 처리용)
  ],
  skip: 1, // 커서 본인(105번)은 이미 봤으므로 제외하고 다음 데이터부터 조회
});

▸ createdAt: 전체적인 정렬 흐름(최신순)을 잡기 위해 필요합니다.
▸ id: 만약 createdAt이 1초의 오차도 없이 동일한 데이터가 여러 개 있다면, ID를 비교하여 정확히 어느 지점까지 읽었는지 식별하기 위해 필요합니다.

▸ 정렬 일치: cursor에 사용된 필드들과 orderBy에 사용된 필드/순서는 반드시 일치해야 최적의 성능을 냅니다.

▸ Skip 사용: 커서 기반 조회 시 현재 지점을 제외하려면 skip: 1이 거의 항상 동반됩니다.

 

3) 데이터 흐름 (Workflow)

복합 커서 페이지네이션은 클라이언트와 서버가 커서 데이터를 주고받으며 완성됩니다.

▸ 첫 요청: 클라이언트가 커서 없이 요청 → 서버는 최신 데이터 10개와 마지막 데이터의 커서 정보(createdAt, id)를 응답.

▸ 다음 요청 (무한 스크롤 등): 클라이언트는 받은 커서 정보를 쿼리 스트링에 담아 다시 요청.

▸ 서버 처리: 서버는 전달받은 커서를 prisma.findMany의 cursor 옵션에 넣어 정확히 그 지점 다음부터 데이터를 조회.

커서기반 페이지네이션 결과 예시
커서기반 페이지네이션 결과 예시

반응형

 

2. 관계 데이터 생성 패턴 : connect / create / connectOrCreate

Prisma에서 관계 데이터를 생성하는 것은 단순히 외래 키 값을 채우는 행위가 아니라, "데이터 간의 유효성을 검증하고 관계의 의도를 선언"하는 과정입니다.

관계 생성이란?
관계 데이터 생성은 단순히 "외래 키에 숫자 넣기"가 아닙니다.
"이 데이터가 실제로 존재하는지 확인하고, 안전하게 연결하는 과정"입니다.

 

✔️ 관계 생성 패턴 선택 가이드

상황 추천 패턴 이유
기존 데이터 확실 connect 안전하고 명확한 에러 처리
항상 함께 생성 create 트랜잭션으로 일관성 보장
존재 여부 불확실 connectOrCreate 존재 여부 자동 처리
성능이 매우 중요 authorId:1 DB 검증만 사용 (주의 필요)
N:M 관계 명시적 중간 테이블 추가 정보 저장 가능

 

🔷 connect - 기존 데이터와의 명시적 연결

실무에서 가장 많이 혼동하는 부분은 외래 키 ID를 직접 넣는 것과 connect를 사용하는 것의 차이입니다.

 

1) 외래 키 직접 할당 (authorId: 1)

Prisma는 별도의 검증 없이 DB에 INSERT 쿼리를 보냅니다.
제약조건 체크는 데이터베이스(DB)가 담당합니다. FK 제약 조건이 없다면 잘못된 ID도 저장될 위험이 있습니다.

미세하게 빠를 수 있으나, 에러 발생 시 DB 네이티브 에러가 발생하여 처리가 까다로울 수 있습니다.

await prisma.post.create({
  data: {
    title: '게시글',
    authorId: 1  // 숫자만 넣음
  }
})

 

2) 관계 연결(author: { connect: { id: 1 } }) : 권장

Prisma가 해당 레코드의 존재 여부를 확인하며 관계를 맺습니다.

대상이 없으면 Prisma 전용 에러(P2025)를 던져 애플리케이션 레벨에서 명확한 대응이 가능합니다.

데이터 정합성을 Prisma 수준에서 한 번 더 보장하며, '관계' 중심의 코드를 작성하게 해 줍니다.

반드시 @id 또는 @unique 필드로만 연결 가능합니다.

// 로그인한 사용자가 게시글 작성
const userId = session.user.id; // 세션을 통해 확보된 신뢰할 수 있는 사용자 ID

await prisma.post.create({
  data: {
    title: 'Prisma 완벽 가이드',
    content: '관계 생성 패턴을...',
    // [관계 생성 패턴: connect]
    // 1. authorId: userId를 직접 할당하는 대신 author: { connect: ... }를 사용.
    // 2. Prisma 엔진이 DB에 데이터를 넣기 전, 해당 User(ID: 1)가 실제 존재하는지 사전 검증함.
    // 3. 만약 사용자가 없다면 DB 에러 대신 Prisma 유효성 에러(P2025)를 던져 안전한 핸들링이 가능함.
    author: {
      connect: { id: userId }
    }
  }
});

 

🔷 create - 관계 레코드 동시 생성

부모와 자식 레코드를 한 번에 생성합니다. Prisma는 이를 내부적으로 단일 트랜잭션으로 처리합니다.

하나라도 실패하면 전체 작업이 롤백되어 데이터 고아 현상(Orphan Record)을 방지합니다.

ex) 회원가입 시 프로필 생성, 게시글 생성 시 초기 설정 저장 등 "반드시 함께 존재해야 하는 데이터"에 적합합니다.

▸ User 생성 성공 → Profile 생성 성공 → 둘 다 저장
▸ User 생성 성공 → Profile 생성 실패 → 전체 롤백

▸ "User만 있고 Profile은 없는" 고아 데이터 방지!

 

예시) 회원가입 시 프로필 자동 생성(1:1 관계)

await prisma.user.create({
  data: {
    email: 'newuser@example.com',
    displayName: '홍길동',
    // 1:1 관계인 Profile 모델을 동시에 생성 (Nested Write)
    profile: {
      /**
       * [create 패턴]
       * - 부모(User)를 생성함과 동시에 자식(Profile) 레코드를 생성합니다.
       * - Prisma가 내부적으로 단일 트랜잭션을 보장합니다.
       * - User 생성이 성공해도 Profile 생성에서 에러가 나면 전체가 롤백됩니다.
       * - 별도로 userId를 입력하지 않아도 Prisma가 생성된 User의 ID를 자동으로 연결합니다.
       */
      create: {
        bio: '안녕하세요!',
      },
    },
  },
});

 

예시) 사용자 생성시 여러 개의 게시글을 동시에 작성(1:N 관계)

await prisma.user.create({
  data: {
    email: 'author@prisma.io',
    displayName: '작성자A',
    // 1:N 관계인 Post 모델들을 동시에 생성
    posts: {
      /**
       * [1:N create 패턴]
       * - User 레코드를 생성하면서 동시에 여러 개의 Post를 생성합니다.
       * - posts: { create: [...] } 배열 형태로 전달하여 여러 레코드를 한 번에 넣을 수 있습니다.
       */
      create: [
        { 
          title: '첫 번째 게시글', 
          content: '반갑습니다.',
          published: true 
        },
        { 
          title: '두 번째 게시글', 
          content: 'Prisma 7 공부 중입니다.' 
        },
      ],
    },
  },
});

 

예시) Post 생성하면서 사용자 생성

await prisma.post.create({
  data: {
    title: '게시글',
    author: {
      create: {  // ✅ 작성자를 새로 생성
        email: 'newauthor@example.com',
        displayName: 'New Author'
      }
    }
  }
})

 

🔷 connectOrCreate - 조건부 생성 전략

대상이 있으면 연결(connect)하고, 없으면 생성(create)합니다.

이 기능은 동시성 환경에서 반드시 unique index를 전제로 합니다.

where 절에 유니크한 조건이 들어가야 하며, 중복 생성을 방지하기 위해 DB의 Unique 인덱스에 의존합니다.

ex) 외부 시스템 연동(소셜 로그인), 태그 시스템 등 데이터 존재 여부를 미리 알기 어려운 경우에 사용합니다.

await prisma.post.create({
  data: {
    // 1) 생성할 게시글의 제목
    title: '연동 게시글',

    // 2) 이 게시글의 작성자(author)를 설정하는 부분
    author: {
      // connectOrCreate:
      // - User가 이미 존재하면 connect
      // - 존재하지 않으면 create 후 connect
      connectOrCreate: {
        // 3) "기존 User를 찾기 위한 조건"
        //    반드시 @unique 또는 @id 필드로만 찾을 수 있습니다.
        //    (여기서는 User.email이 @unique이므로 적합)
        where: { email: 'guest@external.com' },

        // 4) where 조건에 해당하는 User가 없을 때 생성할 데이터
        //    이 데이터로 User 레코드를 만들고,
        //    생성된 User를 Post.author로 연결합니다.
        create: {
          email: 'guest@external.com',
          displayName: '게스트',
        },
      },
    },
  },
})

 

🔷 N:M 관계 - PostLike (명시적 중간 테이블)

Prisma에서는 N:M 관계를 명시적인 중간 테이블로 모델링할 수 있습니다.

model PostLike {
  // Composite key (explicit M:N)
  userId Int @map("user_id")
  postId Int @map("post_id")

  // Common-ish columns
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@index([postId, createdAt])
  @@index([deletedAt])
  @@map("post_likes")
}


예제: 게시글 좋아요 추가

await prisma.postLike.create({
  data: {
    /**
     * [관계 기반 connect 패턴]
     * * 1. 안전성 (Validation): 
     * id: 1인 User와 id: 10인 Post가 실제로 DB에 존재하는지 Prisma가 먼저 확인합니다.
     * 만약 어느 한쪽이라도 없다면 P2025 에러를 발생시켜 데이터 무결성을 지킵니다.
     * * 2. 추상화: 
     * 단순히 숫자(외래키)를 넣는 것이 아니라 '사용자'와 '게시글'이라는 
     * 객체 간의 관계를 맺는다는 의도를 명확히 합니다.
     * * 3. 확장성:
     * 나중에 @unique 설정이 된 다른 필드(예: email)로 연결 대상을 
     * 변경하더라도 코드 구조를 동일하게 유지할 수 있습니다.
     */
    user: { 
      connect: { id: 1 } 
    },
    post: { 
      connect: { id: 10 } 
    },
  },
})

 

예제: 좋아요 취소 (Soft Delete)

await prisma.postLike.update({
  // 1. 복합 ID 찾기: @id([userId, postId])로 정의된 복합 키를 참조
  where: {
    userId_postId: { 
      userId, 
      postId 
    }
  },
  // 2. Soft Delete: 실제 레코드를 삭제하는 대신 삭제 시간만 기록
  data: {
    deletedAt: new Date()
  }
})

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형