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
테이블의 id
와 pets
테이블의 owner_id
를 기준으로 조인합니다. 결과는 user
와 pets
정보를 포함하며, pets
는 null
일 수 있습니다. 이는 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
테이블의 id
와 pets
테이블의 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.id
와 pets.ownerId
를 기준으로 조인합니다. 결과는 user
와 pets
객체를 포함하는 배열로 반환되며, 일치하지 않는 경우 해당 필드는 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;
}[];
여기서 petId
가 null
이 될 수 있다는 것을 눈치챘을 겁니다. 이는 왼쪽 조인(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은 테이블 별칭을 지원합니다. 이 기능은 자기 조인을 할 때 매우 유용합니다.
예를 들어, 사용자와 그들의 부모 정보를 함께 가져오려고 한다고 가정해 봅시다:
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
테이블을 통해 정의하고, 이를 활용해 특정 채팅 그룹에 속한 모든 사용자를 조회합니다.