SQL 조인(Joins)

SQL에서 조인(Join)은 두 개 이상의 테이블을 관련된 컬럼을 기준으로 결합할 때 사용합니다. Drizzle ORM의 조인 구문은 SQL과 유사하면서도 타입 안전성을 고려한 균형을 이루고 있습니다.

조인 타입

Drizzle ORM은 INNER JOIN, FULL JOIN, LEFT JOIN, RIGHT JOIN을 지원합니다. 아래 테이블 스키마를 기반으로 예제를 살펴보겠습니다.

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const pets = pgTable('pets', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  ownerId: integer('owner_id').notNull().references(() => users.id),
});

Left Join (왼쪽 조인)

const result = await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId));
select ... from "users" left join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
    user: {
        id: number;
        name: string;
    };
    pets: {
        id: number;
        name: string;
        ownerId: number;
    } | null;
}[];

위 코드는 users 테이블과 pets 테이블을 왼쪽 조인(left join)하는 예제입니다. users 테이블의 idpets 테이블의 owner_id를 기준으로 조인합니다. 결과는 userpets 정보를 포함하며, petsnull일 수 있습니다. 이는 users 테이블에 해당하는 pets 데이터가 없을 경우를 의미합니다.

Right Join (오른쪽 조인)

const result = await db.select().from(users).rightJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" right join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
    user: {
        id: number;
        name: string;
    } | null;
    pets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

오른쪽 조인(Right Join)은 두 테이블을 결합할 때, 오른쪽 테이블의 모든 행을 포함하고 왼쪽 테이블의 일치하는 행을 결합합니다. 만약 왼쪽 테이블에 일치하는 행이 없다면, 해당 필드는 null로 채워집니다.

위의 예제에서는 users 테이블과 pets 테이블을 오른쪽 조인으로 결합합니다. users 테이블의 idpets 테이블의 ownerId가 일치하는 경우에만 결합됩니다. 결과 타입은 user 객체가 null일 수 있으며, pets 객체는 항상 존재합니다.

내부 조인(Inner Join)

const result = await db.select().from(users).innerJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" inner join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
    user: {
        id: number;
        name: string;
    };
    pets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

풀 조인(Full Join)

const result = await db.select().from(users).fullJoin(pets, eq(users.id, pets.ownerId));
select ... from "users" full join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
    user: {
        id: number;
        name: string;
    } | null;
    pets: {
        id: number;
        name: string;
        ownerId: number;
    } | null;
}[];

풀 조인은 두 테이블의 모든 레코드를 결합합니다. 왼쪽 테이블(users)과 오른쪽 테이블(pets)의 모든 데이터를 포함하며, 일치하지 않는 경우 null 값을 반환합니다. 위 예제에서는 users 테이블과 pets 테이블을 users.idpets.ownerId를 기준으로 조인합니다. 결과는 userpets 객체를 포함하는 배열로 반환되며, 일치하지 않는 경우 해당 필드는 null이 됩니다.

부분 선택(Partial Select)

특정 필드의 부분 집합을 선택하거나 평평한(flat) 응답 타입이 필요하다면, Drizzle ORM은 부분 선택과 함께 조인을 지원하며 .select({ ... }) 구조를 기반으로 자동으로 반환 타입을 추론합니다.

await db.select({
  userId: users.id,
  petId: pets.id,
}).from(user).leftJoin(pets, eq(users.id, pets.ownerId))
select "users"."id", "pets"."id" from "users" left join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
  userId: number;
  petId: number | null;
}[];

여기서 petIdnull이 될 수 있다는 것을 눈치챘을 겁니다. 이는 왼쪽 조인(left join)을 사용했기 때문에, 애완동물이 없는 사용자가 있을 수 있기 때문입니다.

부분 선택 필드와 필요한 경우 집계(aggregation)를 위해 sql 연산자를 사용할 때, 올바른 결과 타입 추론을 위해 sql<type | null>을 사용해야 한다는 점을 꼭 기억해야 합니다. 이 부분은 여러분의 책임입니다!

const result = await db.select({
  userId: users.id,
  petId: pets.id,
  petName1: sql`upper(${pets.name})`,
  petName2: sql<string | null>`upper(${pets.name})`,
  //˄왼쪽 조인을 사용하므로 이 필드에 대해 'string | null' 타입을 명시적으로 지정해야 함
}).from(user).leftJoin(pets, eq(users.id, pets.ownerId))
select "users"."id", "pets"."id", upper("pets"."name")... from "users" left join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
  userId: number;
  petId: number | null;
  petName1: unknown;
  petName2: string | null;
}[];

많은 컬럼을 가진 테이블을 조인할 때, 수많은 null 가능 필드를 피하기 위해 중첩된 선택 객체 구문을 활용할 수 있습니다. 이 경우, 스마트 타입 추론이 모든 테이블 필드를 null 가능으로 만드는 대신, 전체 객체를 null 가능으로 만듭니다.

await db.select({
  userId: users.id,
  userName: users.name,
  pet: {
    id: pets.id,
    name: pets.name,
    upperName: sql`upper(${pets.name})`
  }
}).from(user).fullJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" full join "pets" on "users"."id" = "pets"."owner_id"
// 결과 타입
const result: {
    userId: number | null;
    userName: string | null;
    pet: {
        id: number;
        name: string;
        upperName: string;
    } | null;
}[];

별칭(Aliases)과 자기 조인(Self Joins)

Drizzle ORM은 테이블 별칭을 지원합니다. 이 기능은 자기 조인을 할 때 매우 유용합니다.

예를 들어, 사용자와 그들의 부모 정보를 함께 가져오려고 한다고 가정해 봅시다:

index.ts
schema.ts
import { user } from "./schema";

const parent = aliasedTable(user, "parent")
const result = db
  .select()
  .from(user)
  .leftJoin(parent, eq(parent.id, user.parentId));
select ... from "user" left join "user" "parent" on "parent"."id" = "user"."parent_id"
// 결과 타입
const result: {
    user: {
        id: number;
        name: string;
        parentId: number;
    };
    parent: {
        id: number;
        name: string;
        parentId: number;
    } | null;
}[];

위 코드에서 aliasedTable 함수를 사용하여 user 테이블에 parent라는 별칭을 붙였습니다. 이를 통해 user 테이블을 자기 자신과 조인할 수 있습니다. 결과적으로 사용자와 그들의 부모 정보를 함께 가져올 수 있습니다.

결과 집계하기

Drizzle ORM은 드라이버에서 받은 이름이 매핑된 결과를 구조를 변경하지 않고 그대로 전달합니다. 여러분은 원하는 방식으로 결과를 다룰 수 있습니다. 아래는 다대일 관계 데이터를 매핑하는 예제입니다:

type User = typeof users.$inferSelect;
type Pet = typeof pets.$inferSelect;

const rows = db.select({
    user: users,
    pet: pets,
  }).from(users).leftJoin(pets, eq(users.id, pets.ownerId)).all();

const result = rows.reduce(
  (acc, row) => {
    const user = row.user;
    const pet = row.pet;

    if (!acc[user.id]) {
      acc[user.id] = { user, pets: [] };
    }

    if (pet) {
      acc[user.id].pets.push(pet);
    }

    return acc;
  },
  {}
);

// 결과 타입
const result: Record<number, {
    user: User;
    pets: Pet[];
}>;

이 코드는 사용자와 그들의 펫 데이터를 조인한 후, 사용자 ID를 기준으로 결과를 집계합니다. 각 사용자 객체는 해당 사용자의 펫 목록을 포함하게 됩니다.

다대일 관계 예제

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { drizzle } from 'drizzle-orm/better-sqlite3';

// 도시 테이블 정의
const cities = sqliteTable('cities', {
  id: integer('id').primaryKey(),  // 기본 키
  name: text('name'),              // 도시 이름
});

// 사용자 테이블 정의
const users = sqliteTable('users', {
  id: integer('id').primaryKey(),  // 기본 키
  name: text('name'),              // 사용자 이름
  cityId: integer('city_id').references(() => cities.id)  // 도시 테이블 참조
});

// 데이터베이스 연결
const db = drizzle();

// 도시와 사용자 테이블을 조인하여 데이터 조회
const result = db.select().from(cities).leftJoin(users, eq(cities.id, users.cityId)).all();

이 예제에서는 도시와 사용자 간의 다대일 관계를 정의하고, 이를 통해 데이터를 조회하는 방법을 보여줍니다. cities 테이블은 도시 정보를 저장하고, users 테이블은 사용자 정보를 저장하며, cityId를 통해 도시 테이블을 참조합니다. 마지막으로 leftJoin을 사용하여 도시와 사용자 테이블을 조인하고 데이터를 조회합니다.

다대다 관계 예제

const users = sqliteTable('users', {
  id: integer('id').primaryKey(),  // 사용자 ID (기본 키)
  name: text('name'),              // 사용자 이름
});

const chatGroups = sqliteTable('chat_groups', {
  id: integer('id').primaryKey(),  // 채팅 그룹 ID (기본 키)
  name: text('name'),              // 채팅 그룹 이름
});

const usersToChatGroups = sqliteTable('usersToChatGroups', {
  userId: integer('user_id').notNull().references(() => users.id),  // 사용자 ID (외래 키)
  groupId: integer('group_id').notNull().references(() => chatGroups.id),  // 채팅 그룹 ID (외래 키)
});

// ID가 1인 채팅 그룹과 해당 그룹의 모든 참여자(사용자) 조회
db.select()
  .from(usersToChatGroups)
  .leftJoin(users, eq(usersToChatGroups.userId, users.id))  // 사용자 테이블과 조인
  .leftJoin(chatGroups, eq(usersToChatGroups.groupId, chatGroups.id))  // 채팅 그룹 테이블과 조인
  .where(eq(chatGroups.id, 1))  // 채팅 그룹 ID가 1인 조건
  .all();

이 예제는 SQLite 데이터베이스에서 다대다 관계를 구현하는 방법을 보여줍니다. users 테이블과 chatGroups 테이블 사이의 관계를 usersToChatGroups 테이블을 통해 정의하고, 이를 활용해 특정 채팅 그룹에 속한 모든 사용자를 조회합니다.