> ## Documentation Index
> Fetch the complete documentation index at: https://sonamu.cartanova.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 조인

> Puri로 테이블 조인하기

Puri는 여러 테이블을 조인하여 관련 데이터를 함께 조회할 수 있습니다. 이 문서는 INNER JOIN, LEFT JOIN, 그리고 복잡한 조인 패턴을 설명합니다.

## 조인 개요

<CardGroup cols={2}>
  <Card title="INNER JOIN" icon="link">
    양쪽 테이블에 모두 존재하는 데이터 join()
  </Card>

  <Card title="LEFT JOIN" icon="arrow-left">
    왼쪽 테이블 기준, 오른쪽은 Optional leftJoin()
  </Card>

  <Card title="Self JOIN" icon="repeat">
    같은 테이블을 조인 alias 사용
  </Card>

  <Card title="M:N JOIN" icon="diagram-project">
    다대다 관계 조인 중간 테이블 활용
  </Card>
</CardGroup>

## INNER JOIN

양쪽 테이블에 **모두 존재하는** 데이터만 조회합니다.

### 기본 JOIN

```typescript theme={null}
const results = await db.table("employees").join("users", "employees.user_id", "users.id").select({
  employeeId: "employees.id",
  employeeNumber: "employees.employee_number",
  userName: "users.username",
  userEmail: "users.email",
});

// SQL:
// SELECT ...
// FROM employees
// INNER JOIN users ON employees.user_id = users.id
```

<Info>
  **INNER JOIN 사용 시기**: - 관계가 **필수**인 경우 (NOT NULL 외래 키) - 양쪽에 데이터가 있어야
  하는 경우 - 예: 직원 → 사용자 (모든 직원은 반드시 사용자 계정이 있음)
</Info>

### 여러 테이블 JOIN

```typescript theme={null}
const results = await db
  .table("employees")
  .join("users", "employees.user_id", "users.id")
  .join("departments", "employees.department_id", "departments.id")
  .join("companies", "departments.company_id", "companies.id")
  .select({
    employeeName: "users.username",
    departmentName: "departments.name",
    companyName: "companies.name",
  });

// employees → users (필수)
// employees → departments (필수)
// departments → companies (필수)
```

## LEFT JOIN

왼쪽 테이블의 모든 데이터를 포함하고, 오른쪽 테이블은 **있으면 포함**합니다.

### 기본 LEFT JOIN

```typescript theme={null}
const results = await db
  .table("employees")
  .leftJoin("departments", "employees.department_id", "departments.id")
  .select({
    employeeId: "employees.id",
    employeeNumber: "employees.employee_number",
    departmentName: "departments.name", // null 가능
  });

// SQL:
// SELECT ...
// FROM employees
// LEFT JOIN departments ON employees.department_id = departments.id
```

<Info>
  **LEFT JOIN 사용 시기**: - 관계가 **선택적**인 경우 (NULLABLE 외래 키) - 왼쪽 테이블의 모든
  데이터가 필요한 경우 - 예: 직원 → 부서 (일부 직원은 부서가 없을 수 있음)
</Info>

### INNER JOIN vs LEFT JOIN 비교

<CodeGroup>
  ```typescript title="INNER JOIN - 양쪽 모두 있는 것만" theme={null}
  const results = await db
    .table("employees")
    .join("departments", "employees.department_id", "departments.id")
    .select({
      employeeId: "employees.id",
      departmentName: "departments.name",
    });

  // 부서가 배정된 직원만 조회 (부서 없는 직원 제외)

  ```

  ```typescript title="LEFT JOIN - 왼쪽 전체 + 오른쪽 있으면" theme={null}
  const results = await db
    .table("employees")
    .leftJoin("departments", "employees.department_id", "departments.id")
    .select({
      employeeId: "employees.id",
      departmentName: "departments.name", // null 가능
    });

  // 모든 직원 조회 (부서 없는 직원도 포함, department_name은 null)
  ```
</CodeGroup>

### 복합 LEFT JOIN

```typescript theme={null}
const results = await db
  .table("employees")
  .leftJoin("departments", "employees.department_id", "departments.id")
  .leftJoin("companies", "departments.company_id", "companies.id")
  .select({
    employeeId: "employees.id",
    employeeNumber: "employees.employee_number",
    departmentName: "departments.name", // null 가능
    companyName: "companies.name", // null 가능
  });

// employees (필수)
// → departments (선택)
// → companies (선택)
```

## INNER + LEFT JOIN 혼합

필수 관계는 INNER JOIN, 선택 관계는 LEFT JOIN을 사용합니다.

```typescript theme={null}
const results = await db
  .table("employees")
  .join("users", "employees.user_id", "users.id") // 필수: 모든 직원은 사용자 있음
  .leftJoin("departments", "employees.department_id", "departments.id") // 선택: 부서 없을 수 있음
  .select({
    employeeName: "users.username", // 항상 존재
    departmentName: "departments.name", // null 가능
  });
```

<Tip>
  **조인 타입 선택 가이드**: 1. 데이터베이스 스키마 확인 (NOT NULL vs NULLABLE) 2. NOT NULL 외래 키
  → INNER JOIN 3. NULLABLE 외래 키 → LEFT JOIN 4. 비즈니스 요구사항 고려
</Tip>

## Self JOIN - 자기 참조

같은 테이블을 조인할 때는 \*\*별칭(alias)\*\*을 사용합니다.

### 부서 계층구조 예제

```typescript theme={null}
const results = await db
  .table({ child: "departments" })
  .leftJoin({ parent: "departments" }, "child.parent_id", "parent.id")
  .select({
    childId: "child.id",
    childName: "child.name",
    parentId: "parent.id",
    parentName: "parent.name", // null 가능 (최상위 부서)
  });

// departments (자식) LEFT JOIN departments (부모)
// child.parent_id = parent.id
```

**결과 예시**:

| childId | childName | parentId | parentName |
| ------- | --------- | -------- | ---------- |
| 1       | 본부        | null     | null       |
| 2       | 개발팀       | 1        | 본부         |
| 3       | 디자인팀      | 1        | 본부         |
| 4       | 백엔드팀      | 2        | 개발팀        |

### 사용자 추천인 예제

```typescript theme={null}
const results = await db
  .table({ user: "users" })
  .leftJoin({ referrer: "users" }, "user.referrer_id", "referrer.id")
  .select({
    userId: "user.id",
    userName: "user.username",
    referrerId: "referrer.id",
    referrerName: "referrer.username", // null 가능
  })
  .where("user.role", "normal");

// 일반 사용자 + 추천인 정보
```

## Many-to-Many 조인

다대다(M:N) 관계는 **중간 테이블**을 통해 조인합니다.

### 프로젝트 ↔ 직원 예제

```typescript theme={null}
const results = await db
  .table("projects")
  .leftJoin("projects__employees", "projects.id", "projects__employees.project_id")
  .leftJoin("employees", "projects__employees.employee_id", "employees.id")
  .leftJoin("users", "employees.user_id", "users.id")
  .select({
    projectId: "projects.id",
    projectName: "projects.name",
    projectStatus: "projects.status",
    employeeId: "employees.id",
    employeeName: "users.username",
  })
  .where("projects.status", "in_progress");

// projects ↔ projects__employees ↔ employees ↔ users
```

**결과 특징**:

* 프로젝트 1개에 직원 N명 → N개 행
* 직원이 없는 프로젝트 → 1개 행 (employee 필드는 null)

<Warning>
  **M:N 조인 주의사항**: 1. 결과가 중복됨 (프로젝트당 직원 수만큼 행 생성) 2. 집계 시 주의
  (`COUNT(DISTINCT ...)` 사용) 3. 페이지네이션 복잡해짐
</Warning>

### M:N 직원별로 그룹화

```typescript theme={null}
const results = await db
  .table("employees")
  .leftJoin("projects__employees", "employees.id", "projects__employees.employee_id")
  .leftJoin("projects", "projects__employees.project_id", "projects.id")
  .leftJoin("users", "employees.user_id", "users.id")
  .select({
    employeeId: "employees.id",
    employeeName: "users.username",
    projectCount: Puri.count("projects.id"),
  })
  .groupBy("employees.id", "users.username")
  .having("projectCount", ">", 0);

// 직원별 참여 프로젝트 수
```

## 서브쿼리 조인

서브쿼리를 테이블처럼 조인할 수 있습니다.

### 부서별 통계 조인

```typescript theme={null}
// 부서별 직원 수 서브쿼리
const empStats = db
  .table("employees")
  .select({
    department_id: "department_id",
    emp_count: Puri.count("id"),
    avg_salary: Puri.avg("salary"),
  })
  .groupBy("department_id");

// 부서 + 통계 조인
const results = await db
  .table("departments")
  .leftJoin({ stats: empStats }, "departments.id", "stats.department_id")
  .select({
    deptId: "departments.id",
    deptName: "departments.name",
    empCount: "stats.emp_count",
    avgSalary: "stats.avg_salary",
  });
```

### 최신 로그인 조인

```typescript theme={null}
// 사용자별 최신 로그인 서브쿼리
const latestLogins = db
  .table("login_logs")
  .select({
    user_id: "user_id",
    latest_login: Puri.max("logged_in_at"),
  })
  .groupBy("user_id");

// 사용자 + 최신 로그인 조인
const results = await db
  .table("users")
  .leftJoin({ latest: latestLogins }, "users.id", "latest.user_id")
  .select({
    userId: "users.id",
    userName: "users.username",
    latestLogin: "latest.latest_login",
  });
```

## 조인 + WHERE 조건

### 조인 전 필터링

```typescript theme={null}
const results = await db
  .table("employees")
  .join("users", "employees.user_id", "users.id")
  .leftJoin("departments", "employees.department_id", "departments.id")
  .select({
    employeeId: "employees.id",
    userName: "users.username",
    deptName: "departments.name",
  })
  .where("users.is_active", true) // users 필터
  .where("employees.salary", ">=", 50000) // employees 필터
  .where("departments.name", "like", "%개발%"); // departments 필터
```

### 조인 후 집계

```typescript theme={null}
const results = await db
  .table("departments")
  .leftJoin("employees", "departments.id", "employees.department_id")
  .select({
    deptId: "departments.id",
    deptName: "departments.name",
    empCount: Puri.count("employees.id"),
  })
  .groupBy("departments.id", "departments.name")
  .having("empCount", ">", 5)
  .orderBy("empCount", "desc");
```

## 실전 예제

### 사용자 프로필 조회

```typescript theme={null}
async getUserProfile(userId: number) {
  const profile = await this.getPuri("r")
    .table("users")
    .join("employees", "users.id", "employees.user_id")
    .leftJoin("departments", "employees.department_id", "departments.id")
    .leftJoin("companies", "departments.company_id", "companies.id")
    .select({
      userId: "users.id",
      username: "users.username",
      email: "users.email",
      employeeNumber: "employees.employee_number",
      departmentName: "departments.name",
      companyName: "companies.name",
      salary: "employees.salary",
    })
    .where("users.id", userId)
    .first();

  if (!profile) {
    throw new Error("User not found");
  }

  return profile;
}
```

### 프로젝트 상세 + 참여자 목록

```typescript theme={null}
async getProjectDetails(projectId: number) {
  const project = await this.getPuri("r")
    .table("projects")
    .select({
      id: "projects.id",
      name: "projects.name",
      status: "projects.status",
      description: "projects.description",
    })
    .where("projects.id", projectId)
    .first();

  if (!project) {
    throw new Error("Project not found");
  }

  // 참여자 목록
  const members = await this.getPuri("r")
    .table("projects__employees")
    .join("employees", "projects__employees.employee_id", "employees.id")
    .join("users", "employees.user_id", "users.id")
    .leftJoin("departments", "employees.department_id", "departments.id")
    .select({
      employeeId: "employees.id",
      employeeName: "users.username",
      employeeNumber: "employees.employee_number",
      departmentName: "departments.name",
      role: "projects__employees.role",
    })
    .where("projects__employees.project_id", projectId);

  return {
    ...project,
    members,
  };
}
```

### 부서 계층구조 조회

```typescript theme={null}
async getDepartmentHierarchy(companyId: number) {
  const departments = await this.getPuri("r")
    .table({ dept: "departments" })
    .leftJoin(
      { parent: "departments" },
      "dept.parent_id",
      "parent.id"
    )
    .leftJoin("companies", "dept.company_id", "companies.id")
    .select({
      id: "dept.id",
      name: "dept.name",
      parentId: "parent.id",
      parentName: "parent.name",
      companyName: "companies.name",
      level: Puri.rawNumber(`
        CASE
          WHEN dept.parent_id IS NULL THEN 0
          WHEN parent.parent_id IS NULL THEN 1
          ELSE 2
        END
      `),
    })
    .where("dept.company_id", companyId)
    .orderBy("level", "asc")
    .orderBy("dept.name", "asc");

  return departments;
}
```

## 타입 안전성

Puri의 조인은 타입 안전합니다.

```typescript theme={null}
const results = await db
  .table("employees")
  .join("users", "employees.user_id", "users.id")
  .leftJoin("departments", "employees.department_id", "departments.id")
  .select({
    employeeId: "employees.id", // number
    userName: "users.username", // string
    departmentName: "departments.name", // string | null (LEFT JOIN)
  });

// 타입이 자동으로 추론됨
const first = results[0];
first.employeeId; // number
first.userName; // string
first.departmentName; // string | null
```

<Info>
  **LEFT JOIN과 타입**: - LEFT JOIN된 테이블의 컬럼은 `T | null` 타입 - INNER JOIN된 테이블의 컬럼은
  `T` 타입 - 타입스크립트가 자동으로 null 체크 강제
</Info>

## 성능 최적화

### 인덱스 활용

```sql theme={null}
-- 조인 키에 인덱스 생성
CREATE INDEX idx_employees_user_id ON employees(user_id);
CREATE INDEX idx_employees_department_id ON employees(department_id);
```

### 필요한 컬럼만 선택

```typescript theme={null}
// ❌ 나쁨: 모든 컬럼 조회
await db.table("employees").join("users", "employees.user_id", "users.id").selectAll();

// ✅ 좋음: 필요한 컬럼만 선택
await db.table("employees").join("users", "employees.user_id", "users.id").select({
  id: "employees.id",
  name: "users.username",
});
```

### JOIN 순서 최적화

```typescript theme={null}
// 작은 테이블 → 큰 테이블 순서
await db
  .table("departments")        // 10개
  .join("employees", ...)      // 100개
  .join("projects__employees", ...); // 1000개
```

## 다음 단계

<CardGroup cols={2}>
  <Card title="Aggregations" icon="calculator" href="/ko/database/puri/aggregations">
    집계 함수로 데이터 분석하기
  </Card>

  <Card title="Advanced Patterns" icon="wand-magic-sparkles" href="/ko/database/puri/advanced-patterns">
    서브쿼리와 복잡한 패턴
  </Card>

  <Card title="Type Safety" icon="shield" href="/ko/database/puri/type-safety">
    조인 시 타입 안전성
  </Card>

  <Card title="Basic Queries" icon="magnifying-glass" href="/ko/database/puri/basic-queries">
    기본 쿼리로 돌아가기
  </Card>
</CardGroup>
