reltype 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +83 -3
  2. package/README.ko.md +517 -623
  3. package/README.md +511 -623
  4. package/package.json +1 -1
package/README.ko.md CHANGED
@@ -6,629 +6,589 @@
6
6
  [![node](https://img.shields.io/node/v/reltype.svg)](https://www.npmjs.com/package/reltype)
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
8
8
 
9
- **TypeScript를 위한 Type-first PostgreSQL 관계형 모델링 라이브러리.**
9
+ > English documentation [README.md](./README.md)
10
10
 
11
- PostgreSQL 테이블을 TypeScript 코드로 정의하면, 모든 쿼리의 반환 타입이 자동으로 추론됩니다.
11
+ **방해받지 않는 PostgreSQL 쿼리 라이브러리.**
12
12
 
13
- - **타입 안전** 스키마 정의에서 INSERT / SELECT / UPDATE 타입을 자동 추론
14
- - **camelCase snake_case** DB 컬럼과 TypeScript 변수명을 자동 변환
15
- - **플루언트 쿼리 빌더** — `WHERE`, `OR`, `JOIN`, `GROUP BY`, `LIMIT`, `paginate`, `calculate`, `stream` 체인 지원
16
- - **대용량 최적화** — 커서 페이지네이션, 배치 처리, AsyncGenerator 스트리밍
17
- - **에러 분류** — `DbError`로 PostgreSQL 에러를 13가지 종류로 자동 분류
18
- - **훅 시스템** — 쿼리 전/후 라이프사이클 훅으로 모니터링·APM 연동
13
+ Prisma 스키마 파일 없음. 데코레이터 없음. 코드 생성 없음. 마이그레이션 CLI 없음.
14
+ TypeScript만 있으면 됩니다테이블을 정의하면 완전히 타입이 지정된 쿼리를 즉시 사용할 수 있습니다.
19
15
 
20
- > English documentation is available in [README.md](./README.md).
16
+ ```ts
17
+ // 한 번 정의하면
18
+ const usersTable = defineTable('users', {
19
+ id: col.serial().primaryKey(),
20
+ firstName: col.varchar(255).notNull(),
21
+ email: col.text().notNull(),
22
+ isActive: col.boolean().default(),
23
+ createdAt: col.timestamptz().defaultNow(),
24
+ });
25
+
26
+ // 어디서든 완전한 타입으로 사용
27
+ const page = await userRepo
28
+ .select({ isActive: true })
29
+ .where({ email: { operator: 'ILIKE', value: '%@gmail.com' } })
30
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
31
+ .paginate({ page: 1, pageSize: 20 });
32
+ // → { data: User[], count: 150, page: 1, pageSize: 20, nextAction: true, previousAction: false }
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 왜 reltype인가?
38
+
39
+ ### 기존 도구들의 문제점
40
+
41
+ | | Prisma | TypeORM | Drizzle | **reltype** |
42
+ |---|---|---|---|---|
43
+ | 스키마 정의 | `schema.prisma` 파일 | 클래스 데코레이터 | TS 스키마 | **TS 스키마** |
44
+ | 코드 생성 필요 | ✅ 필요 | ❌ 불필요 | ❌ 불필요 | **❌ 불필요** |
45
+ | 마이그레이션 CLI 필요 | ✅ 필요 | 선택 | 선택 | **❌ 영원히 불필요** |
46
+ | camelCase ↔ snake_case | 수동 설정 | 수동 설정 | 수동 설정 | **자동** |
47
+ | Raw SQL 지원 | 제한적 | 있음 | 있음 | **있음** |
48
+ | 번들 크기 | 무거움 | 무거움 | 가벼움 | **최소** |
49
+ | 대용량 스트리밍 | 플러그인 필요 | 직접 구현 | 직접 구현 | **내장** |
50
+
51
+ ### reltype이 다른 이유
52
+
53
+ **1. 한 번 정의하면 타입이 자동으로 생성됩니다**
54
+ TypeScript로 스키마를 작성하세요. `INSERT`, `SELECT`, `UPDATE` 타입이 자동으로 추론됩니다.
55
+ 중복된 인터페이스, `@Entity`, `model User {}`가 필요 없습니다.
56
+
57
+ **2. camelCase ↔ snake_case 변환이 완전 자동입니다**
58
+ DB에는 `first_name`, `created_at`, `is_active`가 있고 TypeScript에는 `firstName`, `createdAt`, `isActive`가 있습니다.
59
+ reltype이 양방향 매핑을 항상, 무료로 처리합니다.
60
+
61
+ **3. 빌드 과정, CLI, 마이그레이션 파일이 없습니다**
62
+ `npm install reltype` 하고 쿼리를 작성하기 시작하면 됩니다. 그게 전부입니다.
63
+
64
+ **4. 대규모 프로덕션에서도 바로 사용 가능합니다**
65
+ 커서 기반 페이지네이션, AsyncGenerator 스트리밍, 배치 처리, 커넥션 풀 모니터링, 구조화된 에러 분류, 라이프사이클 훅 — 모두 내장되어 있습니다.
21
66
 
22
67
  ---
23
68
 
24
69
  ## 설치
25
70
 
26
71
  ```bash
27
- # reltype 설치
28
- npm install reltype
29
-
30
- # pg는 peerDependency — 직접 설치해야 합니다
31
- npm install pg
72
+ npm install reltype pg
32
73
  npm install --save-dev @types/pg
33
74
  ```
34
75
 
35
- > `pg` 버전 8.0.0 이상이 필요합니다.
76
+ > `pg` (node-postgres)는 peerDependency입니다. 8.0.0 이상 필요합니다.
36
77
 
37
78
  ---
38
79
 
39
- ## 환경 변수
80
+ ## 2분 빠른 시작
40
81
 
41
- `.env` 파일을 프로젝트 루트에 생성합니다.
82
+ ### 1단계 환경 변수 설정
42
83
 
43
84
  ```env
44
- # ── 필수 (CONNECTION_STRING 또는 DB_NAME 중 하나는 반드시 설정) ──────────────
45
-
46
- # 방법 1: Connection String (우선 적용)
47
- DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
48
-
49
- # 방법 2: 개별 설정
85
+ # .env
50
86
  DB_HOST=127.0.0.1
51
87
  DB_PORT=5432
52
88
  DB_NAME=mydb
53
89
  DB_USER=postgres
54
90
  DB_PASSWORD=postgres
55
-
56
- # ── 선택 ────────────────────────────────────────────────────────────────────
57
-
58
- DB_SSL=false # SSL 활성화 여부
59
- DB_MAX=10 # 최대 연결 풀 수
60
- DB_IDLE_TIMEOUT=30000 # idle 연결 해제 대기시간 (ms)
61
- DB_CONNECTION_TIMEOUT=2000 # 연결 타임아웃 (ms)
62
- DB_ALLOW_EXIT_ON_IDLE=false # idle 상태에서 프로세스 종료 허용
63
- DB_STATEMENT_TIMEOUT=0 # SQL 문 실행 타임아웃 (ms, 0=무제한)
64
- DB_QUERY_TIMEOUT=0 # 쿼리 타임아웃 (ms, 0=무제한)
65
- DB_APPLICATION_NAME=my-app # pg_stat_activity에 표시될 앱 이름
66
- DB_KEEP_ALIVE=true # TCP keep-alive 활성화
67
- DB_KEEP_ALIVE_INITIAL_DELAY=10000 # keep-alive 최초 지연 (ms)
68
-
69
- # ── 로깅 ────────────────────────────────────────────────────────────────────
70
-
71
- LOGGER=true # 로거 활성화 (true / false)
72
- LOG_LEVEL=info # 로그 레벨 (debug / info / log / warn / error)
91
+ DB_MAX=10
92
+ DB_CONNECTION_TIMEOUT=3000
73
93
  ```
74
94
 
75
- ---
76
-
77
- ## 빠른 시작
95
+ 또는 연결 문자열 사용:
78
96
 
79
- ### 1. 테이블 스키마 정의
80
-
81
- ```ts
82
- import { defineTable, col } from 'reltype';
83
-
84
- export const usersTable = defineTable('users', {
85
- id: col.serial().primaryKey(), // SERIAL PRIMARY KEY (INSERT 시 optional)
86
- firstName: col.varchar(255).notNull(), // VARCHAR(255) NOT NULL
87
- lastName: col.varchar(255).nullable(), // VARCHAR(255) NULL (INSERT 시 optional)
88
- email: col.text().notNull(), // TEXT NOT NULL
89
- isActive: col.boolean().default(), // BOOLEAN DEFAULT ... (INSERT 시 optional)
90
- createdAt: col.timestamptz().defaultNow(), // TIMESTAMPTZ DEFAULT NOW() (INSERT 시 optional)
91
- });
97
+ ```env
98
+ DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
92
99
  ```
93
100
 
94
- ### 2. 타입 자동 추론
101
+ ### 2단계 진입점에서 dotenv 로드
95
102
 
96
103
  ```ts
97
- import { InferRow, InferInsert, InferUpdate } from 'reltype';
104
+ // index.ts 가장 번째 줄이어야 합니다
105
+ import 'dotenv/config';
98
106
 
99
- // SELECT 결과 타입
100
- type User = InferRow<typeof usersTable>;
101
- // {
102
- // id: number;
103
- // firstName: string;
104
- // lastName: string | null;
105
- // email: string;
106
- // isActive: boolean;
107
- // createdAt: Date;
108
- // }
109
-
110
- // INSERT 입력 타입 (optional 컬럼 자동 제외)
111
- type CreateUser = InferInsert<typeof usersTable>;
112
- // { firstName: string; email: string; lastName?: string | null; isActive?: boolean; createdAt?: Date }
113
-
114
- // UPDATE 입력 타입 (PK 제외, 전체 optional)
115
- type UpdateUser = InferUpdate<typeof usersTable>;
116
- // { firstName?: string; lastName?: string | null; email?: string; isActive?: boolean; createdAt?: Date }
107
+ import { getPool } from 'reltype';
117
108
  ```
118
109
 
119
- ### 3. 진입점에서 dotenv 로드
120
-
121
- `reltype`은 `process.env`를 읽기만 합니다. `.env` 파일 로딩은 **애플리케이션 진입점**에서 직접 하세요.
110
+ ### 3단계 테이블 스키마 정의
122
111
 
123
112
  ```ts
124
- // 애플리케이션 진입점 (index.ts / server.ts / app.ts)
125
- import 'dotenv/config'; // 반드시 다른 import 전에 위치
113
+ // schema/usersTable.ts
114
+ import { defineTable, col } from 'reltype';
115
+
116
+ export const usersTable = defineTable('users', {
117
+ id: col.serial().primaryKey(),
118
+ firstName: col.varchar(255).notNull(),
119
+ lastName: col.varchar(255).nullable(),
120
+ email: col.text().notNull(),
121
+ isActive: col.boolean().default(),
122
+ createdAt: col.timestamptz().defaultNow(),
123
+ });
126
124
 
127
- // 이후 reltype import
128
- import { getDatabaseConfig, getPool } from 'reltype';
125
+ // 타입은 자동으로 사용 가능 — 추가 코드 불필요
126
+ // InferRow<typeof usersTable> → SELECT 결과 타입
127
+ // InferInsert<typeof usersTable> → INSERT 입력 타입 (수정자에 따라 필수/선택)
128
+ // InferUpdate<typeof usersTable> → UPDATE 입력 타입 (PK 제외, 모두 선택)
129
129
  ```
130
130
 
131
- ### 4. Repository 생성
131
+ ### 4단계 레포지토리 생성 및 쿼리
132
132
 
133
133
  ```ts
134
134
  import { createRepo } from 'reltype';
135
- import { usersTable } from './schema';
135
+ import { usersTable } from './schema/usersTable';
136
136
 
137
137
  export const userRepo = createRepo(usersTable);
138
- ```
139
-
140
- ---
141
-
142
- ## Repository API
143
-
144
- ### 전체 메서드 요약
145
-
146
- | 메서드 | 반환 타입 | 설명 |
147
- |---|---|---|
148
- | `create(data)` | `Promise<T>` | 단건 INSERT |
149
- | `update(id, data)` | `Promise<T \| null>` | PK 기준 UPDATE |
150
- | `delete(id)` | `Promise<boolean>` | PK 기준 DELETE |
151
- | `upsert(data, col?)` | `Promise<T>` | INSERT or UPDATE |
152
- | `bulkCreate(rows)` | `Promise<T[]>` | 다건 INSERT |
153
- | `select(where?)` | `QueryBuilder<T>` | 플루언트 빌더 시작점 |
154
- | `selectOne(where)` | `Promise<T \| null>` | 단건 조회 |
155
- | `raw(sql, params?)` | `Promise<R[]>` | Raw SQL 실행 |
156
- | `findAll(opts?)` | `Promise<T[]>` | 정적 전체 조회 |
157
- | `findById(id)` | `Promise<T \| null>` | PK 단건 조회 |
158
- | `findOne(where)` | `Promise<T \| null>` | 조건 단건 조회 |
159
- | `useHooks(h)` | `this` | 글로벌 훅 등록 |
160
138
 
161
- ---
139
+ // SELECT
140
+ const users = await userRepo.select({ isActive: true })
141
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
142
+ .limit(10);
162
143
 
163
- ## create
144
+ // INSERT
145
+ const user = await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
164
146
 
165
- 단건 INSERT. 자동으로 생성되는 컬럼(serial, default, nullable)은 입력을 생략할 수 있습니다.
147
+ // UPDATE
148
+ const updated = await userRepo.update(user.id, { isActive: false });
166
149
 
167
- ```ts
168
- const user = await userRepo.create({
169
- firstName: 'John',
170
- email: 'john@example.com',
171
- // lastName, isActive, createdAt → optional (DB default 또는 nullable)
172
- });
173
- // → User
150
+ // DELETE
151
+ const deleted = await userRepo.delete(user.id);
174
152
  ```
175
153
 
176
- ---
177
-
178
- ## update
179
-
180
- PK를 기준으로 지정한 컬럼만 UPDATE합니다. 존재하지 않으면 `null`을 반환합니다.
181
-
182
- ```ts
183
- // 부분 업데이트
184
- const updated = await userRepo.update(1, {
185
- firstName: 'Jane',
186
- isActive: false,
187
- });
188
- // → User | null
189
-
190
- if (!updated) {
191
- throw new Error('사용자를 찾을 수 없습니다.');
192
- }
193
- ```
154
+ 완료. 이제 완전히 타입이 지정된 프로덕션 수준의 데이터 레이어가 생겼습니다.
194
155
 
195
156
  ---
196
157
 
197
- ## delete
158
+ ## 타입 추론 — 핵심 기능
198
159
 
199
- PK를 기준으로 삭제합니다. 삭제된 row가 있으면 `true`, 없으면 `false`를 반환합니다.
160
+ 스키마를 정의하면 reltype이 모든 타입을 자동으로 추론합니다:
200
161
 
201
162
  ```ts
202
- const deleted = await userRepo.delete(1);
203
- // → boolean
204
-
205
- if (!deleted) {
206
- throw new Error('사용자를 찾을 수 없습니다.');
207
- }
208
- ```
209
-
210
- ---
211
-
212
- ## upsert
163
+ import { InferRow, InferInsert, InferUpdate } from 'reltype';
213
164
 
214
- 충돌 컬럼 기준으로 INSERT 또는 UPDATE합니다.
165
+ type User = InferRow<typeof usersTable>;
166
+ // {
167
+ // id: number;
168
+ // firstName: string;
169
+ // lastName: string | null;
170
+ // email: string;
171
+ // isActive: boolean;
172
+ // createdAt: Date;
173
+ // }
215
174
 
216
- ```ts
217
- // PK(id) 기준 (기본값)
218
- const user = await userRepo.upsert({
219
- id: 1,
220
- firstName: 'John',
221
- email: 'john@example.com',
222
- });
175
+ type CreateUser = InferInsert<typeof usersTable>;
176
+ // {
177
+ // firstName: string; ← 필수 (notNull, 기본값 없음)
178
+ // email: string; ← 필수
179
+ // lastName?: string | null; 선택 (nullable)
180
+ // isActive?: boolean; ← 선택 (DB 기본값 있음)
181
+ // createdAt?: Date; ← 선택 (defaultNow)
182
+ // }
183
+ // id 제외 — serial이 자동 생성
223
184
 
224
- // 다른 unique 컬럼 기준 (snake_case)
225
- const user = await userRepo.upsert(
226
- { firstName: 'John', email: 'john@example.com' },
227
- 'email',
228
- );
229
- // → User
185
+ type UpdateUser = InferUpdate<typeof usersTable>;
186
+ // {
187
+ // firstName?: string;
188
+ // lastName?: string | null;
189
+ // email?: string;
190
+ // isActive?: boolean;
191
+ // createdAt?: Date;
192
+ // }
193
+ // id 제외 — 조회 키로만 사용
230
194
  ```
231
195
 
232
- ---
196
+ 스키마에서 컬럼을 변경하면 TypeScript가 즉시 잘못된 모든 호출 위치를 감지합니다.
197
+ **스키마가 유일한 진실의 원천입니다.**
233
198
 
234
- ## bulkCreate
199
+ ---
235
200
 
236
- 여러 row를 단일 `INSERT` 쿼리로 삽입합니다.
201
+ ## 레포지토리 API
237
202
 
238
- ```ts
239
- const created = await userRepo.bulkCreate([
240
- { firstName: 'Alice', email: 'alice@example.com' },
241
- { firstName: 'Bob', email: 'bob@example.com' },
242
- ]);
243
- // User[]
244
- ```
203
+ | 메서드 | 반환 타입 | 설명 |
204
+ |---|---|---|
205
+ | `create(data)` | `Promise<T>` | 단일 행 INSERT |
206
+ | `update(id, data)` | `Promise<T \| null>` | primary key로 UPDATE |
207
+ | `delete(id)` | `Promise<boolean>` | primary key로 DELETE |
208
+ | `upsert(data, col?)` | `Promise<T>` | 충돌 시 INSERT 또는 UPDATE |
209
+ | `bulkCreate(rows)` | `Promise<T[]>` | 단일 쿼리로 여러 행 INSERT |
210
+ | `select(where?)` | `QueryBuilder<T>` | 플루언트 쿼리 시작 |
211
+ | `selectOne(where)` | `Promise<T \| null>` | 단일 행 조회 |
212
+ | `raw(sql, params?)` | `Promise<R[]>` | Raw SQL 실행 |
213
+ | `findAll(opts?)` | `Promise<T[]>` | 필터/정렬/페이징을 포함한 간단한 쿼리 |
214
+ | `findById(id)` | `Promise<T \| null>` | primary key로 단일 행 조회 |
215
+ | `findOne(where)` | `Promise<T \| null>` | 조건으로 단일 행 조회 |
216
+ | `useHooks(h)` | `this` | 전역 라이프사이클 훅 등록 |
245
217
 
246
218
  ---
247
219
 
248
- ## select — 플루언트 쿼리 빌더
220
+ ## 플루언트 쿼리 빌더
249
221
 
250
222
  `repo.select(where?)`는 `QueryBuilder`를 반환합니다.
251
- 메서드를 체인으로 조합한 후 `await` 하거나 `.exec()` 실행합니다.
223
+ 메서드를 자유롭게 체이닝한 후 `await`하거나 `.exec()`를 호출하면 실행됩니다.
252
224
 
253
- ### 기본 조회
225
+ ### 필터링 (WHERE / OR)
254
226
 
255
227
  ```ts
256
- // 전체 조회 (await 직접 사용 가능)
257
- const users = await userRepo.select();
258
-
259
- // 초기 WHERE 조건
228
+ // 단순 동등 비교
260
229
  const users = await userRepo.select({ isActive: true });
261
- ```
262
230
 
263
- ### WHERE AND 조건
264
-
265
- ```ts
266
- // 단순 등호
267
- const users = await userRepo.select().where({ isActive: true });
268
-
269
- // 비교 연산자
270
- const users = await userRepo.select()
271
- .where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } });
272
-
273
- // IN
274
- const users = await userRepo.select()
275
- .where({ id: { operator: 'IN', value: [1, 2, 3] } });
276
-
277
- // IS NULL
231
+ // 연산자: =, !=, >, <, >=, <=, LIKE, ILIKE, IN, NOT IN, IS NULL, IS NOT NULL
278
232
  const users = await userRepo.select()
279
- .where({ deletedAt: { operator: 'IS NULL' } });
280
-
281
- // LIKE / ILIKE (대소문자 무시)
282
- const users = await userRepo.select()
283
- .where({ email: { operator: 'ILIKE', value: '%@gmail.com' } });
284
- ```
285
-
286
- 지원하는 연산자: `=` `!=` `>` `<` `>=` `<=` `LIKE` `ILIKE` `IN` `NOT IN` `IS NULL` `IS NOT NULL`
287
-
288
- ### OR — OR 조건
289
-
290
- `.or()`를 여러 번 호출하면 각각 OR로 연결됩니다.
291
- AND 조건이 있을 경우 `WHERE (AND 조건들) OR (OR 조건들)` 형태로 생성됩니다.
233
+ .where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } })
234
+ .where({ id: { operator: 'IN', value: [1, 2, 3] } });
292
235
 
293
- ```ts
294
- // firstName ILIKE '%john%' OR email ILIKE '%john%'
236
+ // OR 조건
295
237
  const users = await userRepo.select({ isActive: true })
296
238
  .or({ firstName: { operator: 'ILIKE', value: '%john%' } })
297
239
  .or({ email: { operator: 'ILIKE', value: '%john%' } });
298
240
  // → WHERE (is_active = true) OR (first_name ILIKE '%john%') OR (email ILIKE '%john%')
241
+
242
+ // NULL 확인
243
+ const unverified = await userRepo.select()
244
+ .where({ verifiedAt: { operator: 'IS NULL' } });
299
245
  ```
300
246
 
301
- ### ORDER BY
247
+ ### 정렬, 페이징, 그룹화
302
248
 
303
249
  ```ts
304
- const users = await userRepo.select()
305
- .orderBy([{ column: 'createdAt', direction: 'DESC' }]);
306
-
307
- // 다중 정렬
308
250
  const users = await userRepo.select()
309
251
  .orderBy([
310
252
  { column: 'isActive', direction: 'DESC' },
311
253
  { column: 'createdAt', direction: 'ASC' },
312
- ]);
313
- ```
314
-
315
- ### LIMIT / OFFSET
316
-
317
- ```ts
318
- const users = await userRepo.select()
319
- .orderBy([{ column: 'id', direction: 'ASC' }])
254
+ ])
320
255
  .limit(20)
321
- .offset(40);
322
- // 3페이지 (0-indexed offset)
323
- ```
324
-
325
- ### GROUP BY
256
+ .offset(40); // 3페이지
326
257
 
327
- ```ts
328
- const result = await userRepo.select()
258
+ // GROUP BY + 집계
259
+ const stats = await userRepo.select()
329
260
  .groupBy(['isActive'])
330
- .calculate([
331
- { fn: 'COUNT', alias: 'count' },
332
- ]);
333
- // → { count: '42' }
261
+ .calculate([{ fn: 'COUNT', alias: 'count' }]);
334
262
  ```
335
263
 
336
264
  ### JOIN
337
265
 
338
266
  ```ts
339
- // LEFT JOIN
340
267
  const result = await userRepo.select({ isActive: true })
341
268
  .join({ table: 'orders', on: 'users.id = orders.user_id', type: 'LEFT' })
342
- .columns(['users.id', 'users.email'])
269
+ .columns(['users.id', 'users.email', 'COUNT(orders.id) AS orderCount'])
343
270
  .groupBy(['users.id', 'users.email'])
344
- .orderBy([{ column: 'id', direction: 'ASC' }])
345
271
  .exec();
346
272
  ```
347
273
 
348
- JOIN 타입: `INNER` `LEFT` `RIGHT` `FULL`
274
+ > JOIN 타입: `INNER` · `LEFT` · `RIGHT` · `FULL`
349
275
 
350
- ### 컬럼 지정 (columns)
276
+ ### 디버깅 실행 전 SQL 미리 보기
351
277
 
352
278
  ```ts
353
- const users = await userRepo.select()
354
- .columns(['id', 'email', 'firstName'])
355
- .exec();
356
- ```
357
-
358
- ---
359
-
360
- ## selectOne
361
-
362
- `select(where).one()` 의 단축형입니다. 조건에 맞는 첫 번째 row를 반환합니다.
363
-
364
- ```ts
365
- const user = await userRepo.selectOne({ email: 'john@example.com' });
366
- // → User | null
367
-
368
- const user = await userRepo.selectOne({ id: 1 });
369
- if (!user) throw new Error('not found');
370
- ```
371
-
372
- ---
373
-
374
- ## calculate — 집계 함수
375
-
376
- `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`를 실행합니다.
377
-
378
- ```ts
379
- // 전체 카운트
380
- const result = await userRepo.select().calculate([{ fn: 'COUNT', alias: 'count' }]);
381
- const total = parseInt(String(result.count), 10);
279
+ const { sql, params } = userRepo.select({ isActive: true })
280
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
281
+ .limit(20)
282
+ .toSQL();
382
283
 
383
- // 다중 집계
384
- const stats = await userRepo.select({ isActive: true })
385
- .calculate([
386
- { fn: 'COUNT', alias: 'count' },
387
- { fn: 'AVG', column: 'score', alias: 'avgScore' },
388
- { fn: 'MAX', column: 'score', alias: 'maxScore' },
389
- ]);
390
- // → { count: '42', avgScore: '87.5', maxScore: '100' }
284
+ console.log(sql);
285
+ // SELECT * FROM users WHERE is_active = $1 ORDER BY created_at DESC LIMIT $2
286
+ console.log(params);
287
+ // [ true, 20 ]
391
288
  ```
392
289
 
393
290
  ---
394
291
 
395
- ## paginate — OFFSET 페이지네이션
292
+ ## 페이지네이션
396
293
 
397
- COUNT 쿼리와 DATA 쿼리를 병렬로 실행합니다.
294
+ ### OFFSET 페이지네이션 일반적인 목록 화면
398
295
 
399
296
  ```ts
400
297
  const result = await userRepo.select({ isActive: true })
401
298
  .orderBy([{ column: 'createdAt', direction: 'DESC' }])
402
299
  .paginate({ page: 1, pageSize: 20 });
403
300
 
404
- // result 구조
405
301
  // {
406
- // data: User[], // 현재 페이지 데이터
407
- // count: 150, // 전체 row 수 (필터 적용)
302
+ // data: User[],
303
+ // count: 150, 전체 매칭 수 (COUNT 쿼리 자동 실행)
408
304
  // page: 1,
409
305
  // pageSize: 20,
410
- // nextAction: true, // 다음 페이지 존재 여부
411
- // previousAction: false, // 이전 페이지 존재 여부
306
+ // nextAction: true, 다음 페이지 존재
307
+ // previousAction: false, 이전 페이지 없음
412
308
  // }
413
309
  ```
414
310
 
415
- > 수백만 이상의 테이블에서는 `cursorPaginate()` 를 사용하세요.
311
+ ### 커서 페이지네이션 대용량 테이블
416
312
 
417
- ---
418
-
419
- ## cursorPaginate — 커서 기반 페이지네이션 (대용량)
420
-
421
- OFFSET 스캔 없이 `WHERE id > last_id` 방식으로 동작합니다.
422
- 인덱스가 있는 컬럼을 `cursorColumn`으로 지정하면 수천만 건에서도 일정한 속도를 유지합니다.
313
+ OFFSET은 페이지가 깊어질수록 느려집니다. 커서 페이지네이션은 그렇지 않습니다.
314
+ `WHERE id > last_id` 방식은 아무리 깊은 페이지도 동일한 속도를 보장합니다.
423
315
 
424
316
  ```ts
425
- // 페이지
317
+ // 1페이지
426
318
  const p1 = await userRepo.select({ isActive: true })
427
319
  .cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
320
+ // → { data: [...], nextCursor: 'eyJpZCI6MjB9', pageSize: 20, hasNext: true }
428
321
 
429
- // p1 = { data: [...], nextCursor: 'xxx', pageSize: 20, hasNext: true }
322
+ // 2페이지 커서를 전달
323
+ const p2 = await userRepo.select({ isActive: true })
324
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
430
325
 
431
- // 다음 페이지
432
- if (p1.hasNext) {
433
- const p2 = await userRepo.select({ isActive: true })
434
- .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
435
- }
436
-
437
- // 내림차순 커서 (createdAt DESC)
438
- const result = await userRepo.select()
326
+ // 내림차순 (최신순)
327
+ const latest = await userRepo.select()
439
328
  .cursorPaginate({ pageSize: 20, cursorColumn: 'createdAt', direction: 'desc' });
440
329
  ```
441
330
 
442
- | `paginate` | `cursorPaginate` |
443
- |---|---|
444
- | 전체 count 제공 | count 미제공 |
445
- | page 번호로 이동 | 이전/다음만 가능 |
446
- | 대용량에서 느려짐 | 항상 일정한 속도 |
331
+ | | `paginate` | `cursorPaginate` |
332
+ |---|---|---|
333
+ | 전체 개수 제공 | 있음 | ❌ 없음 |
334
+ | 페이지 번호 이동 | 있음 | ❌ 다음/이전만 |
335
+ | 100만 번째 행에서 성능 | 느림 | ✅ 일정한 속도 |
336
+ | 적합한 상황 | 관리자 테이블, 일반 목록 | 피드, 로그, 대용량 내보내기 |
447
337
 
448
338
  ---
449
339
 
450
- ## forEach 배치 처리
340
+ ## 대용량 데이터 처리
451
341
 
452
- 전체 데이터를 메모리에 올리지 않고 청크 단위로 처리합니다.
453
- 대용량 ETL, 이메일 일괄 발송, 데이터 마이그레이션에 적합합니다.
342
+ ### 배치 처리 (forEach)
343
+
344
+ 서버를 다운시키지 않고 1,000만 건을 처리합니다. 청크 단위로 처리하며 전체를 메모리에 올리지 않습니다.
454
345
 
455
346
  ```ts
347
+ // 모든 활성 사용자에게 이메일 전송 — 전체 사용자를 한 번에 로드하지 않음
456
348
  await userRepo.select({ isActive: true })
457
349
  .orderBy([{ column: 'id', direction: 'ASC' }])
458
350
  .forEach(async (batch) => {
459
- // batch: User[] (기본 500개씩)
460
- await sendEmailBatch(batch);
351
+ await sendEmailBatch(batch); // batch: User[] ( 번에 200행)
461
352
  }, { batchSize: 200 });
462
353
  ```
463
354
 
464
- ---
465
-
466
- ## stream — 스트리밍 (AsyncGenerator)
355
+ ### 스트리밍 (AsyncGenerator)
467
356
 
468
- `for await...of` 루프로 row를 하나씩 순회합니다.
469
- 내부적으로 배치 단위로 DB를 조회하여 메모리 효율을 유지합니다.
357
+ `for await...of`로 행을 하나씩 처리합니다. 실시간 파이프라인에 완벽합니다.
470
358
 
471
359
  ```ts
472
- // for await...of 직접 사용 (Symbol.asyncIterator 지원)
473
360
  for await (const user of userRepo.select({ isActive: true })) {
474
- await processRow(user);
361
+ await processRow(user); // 한 번에 한 행, 낮은 메모리 사용
475
362
  }
476
363
 
477
- // 배치 크기 지정
364
+ // 내부 페칭의 배치 크기 커스텀
478
365
  for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
479
- await processRow(user);
366
+ await writeToFile(user);
480
367
  }
481
368
  ```
482
369
 
483
- ---
370
+ ### EXPLAIN — 쿼리 플랜 분석
484
371
 
485
- ## raw — Raw SQL 직접 실행
372
+ ```ts
373
+ // 인덱스가 사용되는지 확인
374
+ const plan = await userRepo.select({ isActive: true })
375
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
376
+ .explain(true); // true = EXPLAIN ANALYZE (실제로 실행)
486
377
 
487
- 복잡한 쿼리가 필요할 때 SQL을 직접 작성합니다.
488
- 결과 컬럼명은 `snake_case camelCase`로 자동 변환됩니다.
378
+ console.log(plan);
379
+ // Index Scan using users_created_at_idx on users ...
380
+ ```
381
+
382
+ ---
383
+
384
+ ## 집계 함수
489
385
 
490
386
  ```ts
491
- // repo.raw() 사용
492
- const users = await userRepo.raw<UserRow>(
493
- 'SELECT * FROM users WHERE first_name ILIKE $1 ORDER BY created_at DESC',
494
- ['%john%'],
495
- );
387
+ // 단일 집계
388
+ const result = await userRepo.select().calculate([{ fn: 'COUNT', alias: 'count' }]);
389
+ const total = parseInt(String(result.count), 10); // 1042
496
390
 
497
- // QueryBuilder.raw() 레포지토리 없이 독립적으로 사용
498
- import { QueryBuilder } from 'reltype';
391
+ // 필터를 포함한 복수 집계
392
+ const stats = await userRepo.select({ isActive: true })
393
+ .calculate([
394
+ { fn: 'COUNT', alias: 'total' },
395
+ { fn: 'AVG', column: 'score', alias: 'avgScore' },
396
+ { fn: 'MAX', column: 'score', alias: 'maxScore' },
397
+ ]);
398
+ // → { total: '850', avgScore: '72.4', maxScore: '100' }
399
+ ```
499
400
 
500
- const rows = await QueryBuilder.raw(
501
- `SELECT u.id, u.email, COUNT(o.id) AS order_count
401
+ ---
402
+
403
+ ## Raw SQL
404
+
405
+ 쿼리 빌더로 부족할 때 Raw SQL을 직접 사용하세요. camelCase 변환은 그대로 적용됩니다.
406
+
407
+ ```ts
408
+ // 레포지토리를 통해
409
+ const users = await userRepo.raw<{ id: number; orderCount: number }>(
410
+ `SELECT u.id, COUNT(o.id) AS order_count
502
411
  FROM users u
503
412
  LEFT JOIN orders o ON u.id = o.user_id
504
413
  WHERE u.is_active = $1
505
- GROUP BY u.id, u.email`,
414
+ GROUP BY u.id`,
506
415
  [true],
507
416
  );
417
+ // → [{ id: 1, orderCount: 5 }, ...] ← order_count → orderCount 자동 변환
418
+
419
+ // 독립 실행 (레포지토리 불필요)
420
+ import { QueryBuilder } from 'reltype';
421
+
422
+ const rows = await QueryBuilder.raw(
423
+ 'SELECT * FROM users WHERE first_name ILIKE $1',
424
+ ['%john%'],
425
+ );
508
426
  ```
509
427
 
510
428
  ---
511
429
 
512
- ## explain — 쿼리 플랜 분석
430
+ ## CRUD 메서드
513
431
 
514
- 인덱스 사용 여부 및 성능 병목을 확인합니다.
432
+ ### create
515
433
 
516
434
  ```ts
517
- // EXPLAIN
518
- const plan = await userRepo.select({ isActive: true }).explain();
519
- console.log(plan);
435
+ const user = await userRepo.create({
436
+ firstName: 'Alice',
437
+ email: 'alice@example.com',
438
+ // isActive, createdAt → 선택 (DB가 기본값 처리)
439
+ });
440
+ // → User (RETURNING *로 전체 행 반환)
441
+ ```
520
442
 
521
- // EXPLAIN ANALYZE (실제 실행 통계 포함)
522
- const plan = await userRepo.select({ isActive: true })
523
- .orderBy([{ column: 'createdAt', direction: 'DESC' }])
524
- .explain(true);
443
+ ### update
444
+
445
+ ```ts
446
+ // 전달한 필드만 업데이트
447
+ const updated = await userRepo.update(1, {
448
+ firstName: 'Alicia',
449
+ isActive: true,
450
+ });
451
+ // → User | null (ID 없으면 null)
525
452
  ```
526
453
 
527
- ---
454
+ ### delete
528
455
 
529
- ## toSQL — SQL 미리 확인 (디버깅)
456
+ ```ts
457
+ const ok = await userRepo.delete(1);
458
+ // → 삭제되면 true, 없으면 false
459
+ ```
530
460
 
531
- 실제 실행 없이 생성될 SQL과 params를 반환합니다.
461
+ ### upsert
532
462
 
533
463
  ```ts
534
- const { sql, params } = userRepo.select({ isActive: true })
535
- .orderBy([{ column: 'createdAt', direction: 'DESC' }])
536
- .limit(20)
537
- .toSQL();
464
+ // primary key 충돌 (기본)
465
+ await userRepo.upsert({ id: 1, firstName: 'Bob', email: 'bob@example.com' });
538
466
 
539
- console.log(sql);
540
- // SELECT * FROM users WHERE is_active = $1 ORDER BY created_at DESC LIMIT $2
541
- console.log(params);
542
- // [ true, 20 ]
467
+ // 다른 unique 컬럼 충돌
468
+ await userRepo.upsert(
469
+ { firstName: 'Bob', email: 'bob@example.com' },
470
+ 'email', // snake_case 컬럼명
471
+ );
472
+ ```
473
+
474
+ ### bulkCreate
475
+
476
+ ```ts
477
+ const users = await userRepo.bulkCreate([
478
+ { firstName: 'Alice', email: 'alice@example.com' },
479
+ { firstName: 'Bob', email: 'bob@example.com' },
480
+ { firstName: 'Carol', email: 'carol@example.com' },
481
+ ]);
482
+ // → User[] (단일 INSERT 쿼리, RETURNING *)
543
483
  ```
544
484
 
545
485
  ---
546
486
 
547
- ## hooks — 쿼리 라이프사이클 훅
487
+ ## 라이프사이클 훅
488
+
489
+ 비즈니스 로직을 건드리지 않고 모든 쿼리를 모니터링하거나 APM을 통합하거나 느린 쿼리를 로깅할 수 있습니다.
548
490
 
549
491
  ### 쿼리별 훅
550
492
 
551
493
  ```ts
552
494
  const users = await userRepo.select({ isActive: true })
553
495
  .hooks({
554
- beforeExec: ({ sql, params }) => logger.debug('SQL 실행 예정:', sql),
555
- afterExec: ({ rows, elapsed }) => metrics.record('db.query.duration', elapsed),
556
- onError: ({ err, sql }) => alerting.send({ err, sql }),
496
+ beforeExec: ({ sql, params }) => {
497
+ console.log('[SQL]', sql);
498
+ },
499
+ afterExec: ({ rows, elapsed }) => {
500
+ if (elapsed > 500) console.warn('느린 쿼리:', elapsed, 'ms');
501
+ metrics.record('db.query.duration', elapsed);
502
+ },
503
+ onError: ({ err, sql }) => {
504
+ alerting.send({ message: err.message, sql });
505
+ },
557
506
  })
558
507
  .paginate({ page: 1, pageSize: 20 });
559
508
  ```
560
509
 
561
510
  ### 레포지토리 전역 훅
562
511
 
563
- 레포지토리의 모든 `select()` 빌더에 자동 적용됩니다.
512
+ 한 번 설정하면 이 레포지토리의 모든 `select()`에 자동으로 적용됩니다.
564
513
 
565
514
  ```ts
566
515
  userRepo.useHooks({
567
516
  beforeExec: ({ sql }) => logger.debug('SQL:', sql),
568
517
  afterExec: ({ elapsed }) => metrics.histogram('db.latency', elapsed),
569
- onError: ({ err }) => logger.error('DB 오류', { kind: err.message }),
518
+ onError: ({ err }) => logger.error('DB 오류', { kind: err.kind }),
570
519
  });
571
-
572
- // 이후 모든 select()에 훅이 적용됨
573
- const users = await userRepo.select({ isActive: true }).exec();
574
520
  ```
575
521
 
576
522
  ---
577
523
 
578
- ## 정적 CRUD (findAll / findById / findOne)
579
-
580
- 단순한 조회에는 정적 메서드를 사용할 수 있습니다.
581
-
582
- ```ts
583
- // 전체 조회
584
- const users = await userRepo.findAll();
585
-
586
- // 조건 + 정렬 + 페이지네이션
587
- const users = await userRepo.findAll({
588
- where: { isActive: true },
589
- orderBy: [{ col: 'createdAt', dir: 'DESC' }],
590
- limit: 10,
591
- offset: 0,
592
- });
524
+ ## 에러 처리
593
525
 
594
- // PK로 단건 조회
595
- const user = await userRepo.findById(1); // User | null
526
+ ### DbError 구조화된 PostgreSQL 에러 분류
596
527
 
597
- // 조건으로 단건 조회 (단순 등호만 지원)
598
- const user = await userRepo.findOne({ email: 'john@example.com' }); // User | null
599
- ```
528
+ 모든 DB 에러는 자동으로 `DbError`로 래핑됩니다.
529
+ 사용자에게 안전하게 보여줄 정보와 로그에만 남길 내부 정보를 분리합니다.
600
530
 
601
- > 연산자(LIKE, IN, OR 등)가 필요한 경우 `repo.select()` 를 사용하세요.
531
+ ```ts
532
+ import { DbError } from 'reltype';
602
533
 
603
- ---
534
+ try {
535
+ await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
536
+ } catch (err) {
537
+ if (err instanceof DbError) {
538
+ // ✅ 클라이언트에 안전하게 전달 가능
539
+ res.status(409).json(err.toUserPayload());
540
+ // → { error: '이미 존재하는 값입니다.', kind: 'uniqueViolation', isRetryable: false }
604
541
 
605
- ## 컬럼 타입
542
+ // 🔒 내부 상세 정보 — 절대 외부에 노출하지 마세요
543
+ logger.error('db 오류', err.toLogContext());
544
+ // → { pgCode: '23505', table: 'users', constraint: 'users_email_key', detail: '...' }
606
545
 
607
- | 메서드 | PostgreSQL 타입 | TypeScript 타입 |
608
- |---|---|---|
609
- | `col.serial()` | `SERIAL` | `number` |
610
- | `col.integer()` | `INTEGER` | `number` |
611
- | `col.bigint()` | `BIGINT` | `bigint` |
612
- | `col.numeric()` | `NUMERIC` | `number` |
613
- | `col.varchar(n?)` | `VARCHAR(n)` | `string` |
614
- | `col.text()` | `TEXT` | `string` |
615
- | `col.boolean()` | `BOOLEAN` | `boolean` |
616
- | `col.timestamp()` | `TIMESTAMP` | `Date` |
617
- | `col.timestamptz()` | `TIMESTAMPTZ` | `Date` |
618
- | `col.date()` | `DATE` | `Date` |
619
- | `col.uuid()` | `UUID` | `string` |
620
- | `col.jsonb<T>()` | `JSONB` | `T` (기본 `unknown`) |
546
+ // 일시적 오류 재시도
547
+ if (err.isRetryable) await retry(operation);
548
+ }
549
+ }
550
+ ```
621
551
 
622
- ### 컬럼 수식어
552
+ ### Express 통합 예제
623
553
 
624
554
  ```ts
625
- col.text().notNull() // NOT NULL (기본 상태)
626
- col.text().nullable() // NULL 허용, INSERT 시 optional
627
- col.integer().primaryKey() // PRIMARY KEY, INSERT 시 optional
628
- col.boolean().default() // DB DEFAULT, INSERT 시 optional
629
- col.timestamptz().defaultNow() // DEFAULT NOW(), INSERT 시 optional
555
+ app.post('/users', async (req, res) => {
556
+ try {
557
+ const user = await userRepo.create(req.body);
558
+ res.status(201).json(user);
559
+ } catch (err) {
560
+ if (err instanceof DbError) {
561
+ const status =
562
+ err.kind === 'uniqueViolation' ? 409 :
563
+ err.kind === 'notNullViolation' ? 400 :
564
+ err.kind === 'foreignKeyViolation'? 422 :
565
+ err.isRetryable ? 503 : 500;
566
+ res.status(status).json(err.toUserPayload());
567
+ } else {
568
+ res.status(500).json({ error: '예기치 못한 오류가 발생했습니다.' });
569
+ }
570
+ }
571
+ });
630
572
  ```
631
573
 
574
+ ### 에러 종류 참조
575
+
576
+ | Kind | PostgreSQL 코드 | 설명 | isRetryable |
577
+ |---|---|---|---|
578
+ | `uniqueViolation` | 23505 | UNIQUE 제약 위반 | false |
579
+ | `foreignKeyViolation` | 23503 | FK 제약 위반 | false |
580
+ | `notNullViolation` | 23502 | NOT NULL 제약 위반 | false |
581
+ | `checkViolation` | 23514 | CHECK 제약 위반 | false |
582
+ | `deadlock` | 40P01 | 교착 상태 | **true** |
583
+ | `serializationFailure` | 40001 | 직렬화 실패 | **true** |
584
+ | `connectionFailed` | 08xxx | 연결 실패 | **true** |
585
+ | `tooManyConnections` | 53300 | 풀 소진 | **true** |
586
+ | `queryTimeout` | 57014 | 쿼리 시간 초과 | false |
587
+ | `undefinedTable` | 42P01 | 테이블 없음 | false |
588
+ | `undefinedColumn` | 42703 | 컬럼 없음 | false |
589
+ | `invalidInput` | 22xxx | 잘못된 데이터 형식 | false |
590
+ | `unknown` | 기타 | 분류되지 않은 오류 | false |
591
+
632
592
  ---
633
593
 
634
594
  ## 트랜잭션
@@ -636,12 +596,13 @@ col.timestamptz().defaultNow() // DEFAULT NOW(), INSERT 시 optional
636
596
  ```ts
637
597
  import { runInTx } from 'reltype';
638
598
 
639
- const result = await runInTx(async (client) => {
640
- await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
641
- await userRepo.create({ firstName: 'Bob', email: 'bob@example.com' });
642
- return 'done';
599
+ await runInTx(async (client) => {
600
+ // 작업이 같은 트랜잭션에서 실행됨
601
+ const user = await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
602
+ const order = await orderRepo.create({ userId: user.id, total: 9900 });
603
+ return { user, order };
643
604
  });
644
- // 하나라도 실패 자동 ROLLBACK
605
+ // 어떤 작업이든 실패하면 자동으로 ROLLBACK
645
606
  ```
646
607
 
647
608
  ---
@@ -649,119 +610,110 @@ const result = await runInTx(async (client) => {
649
610
  ## 커넥션 풀
650
611
 
651
612
  ```ts
652
- import { getPool, withClient, closePool } from 'reltype';
653
-
654
- // Pool 직접 접근
655
- const pool = getPool();
613
+ import { getPool, getPoolStatus, checkPoolHealth, closePool } from 'reltype';
656
614
 
657
- // Client 빌려서 Raw 쿼리 실행
658
- const rows = await withClient(async (client) => {
659
- const result = await client.query('SELECT NOW()');
660
- return result.rows;
661
- });
662
-
663
- // 애플리케이션 종료
664
- await closePool();
665
- ```
666
-
667
- ---
668
-
669
- ## Raw 쿼리 빌더
670
-
671
- Repository 없이 직접 쿼리를 빌드할 수 있습니다.
615
+ // 실시간 지표
616
+ const status = getPoolStatus();
617
+ // {
618
+ // isInitialized: true,
619
+ // totalCount: 8, ← 총 오픈된 연결 수
620
+ // idleCount: 3, ← 사용 가능한 연결 수
621
+ // waitingCount: 0, ← 대기 중인 요청 (0 = 정상)
622
+ // isHealthy: true
623
+ // }
672
624
 
673
- ```ts
674
- import { buildSelect, buildInsert, buildUpdate, buildDelete, buildUpsert, buildBulkInsert, withClient } from 'reltype';
625
+ // DB 서버 핑 (SELECT 1)
626
+ const alive = await checkPoolHealth(); // boolean
675
627
 
676
- // SELECT
677
- const { sql, params } = buildSelect('users', {
678
- where: { isActive: true },
679
- orderBy: [{ col: 'createdAt', dir: 'DESC' }],
680
- limit: 5,
628
+ // 안전한 종료
629
+ process.on('SIGTERM', async () => {
630
+ await closePool();
631
+ process.exit(0);
681
632
  });
633
+ ```
682
634
 
683
- // INSERT
684
- const built = buildInsert('users', { firstName: 'John', email: 'john@example.com' });
685
-
686
- // UPDATE
687
- const built = buildUpdate('users', { firstName: 'Jane' }, { id: 1 });
688
-
689
- // DELETE
690
- const built = buildDelete('users', { id: 1 });
691
-
692
- // UPSERT
693
- const built = buildUpsert('users', { id: 1, firstName: 'John', email: 'john@example.com' }, 'id');
694
-
695
- // BULK INSERT
696
- const built = buildBulkInsert('users', [
697
- { firstName: 'Alice', email: 'alice@example.com' },
698
- { firstName: 'Bob', email: 'bob@example.com' },
699
- ]);
635
+ ### 권장 풀 설정
700
636
 
701
- // 실행
702
- await withClient(async (client) => {
703
- const result = await client.query(sql, params);
704
- return result.rows;
705
- });
637
+ ```env
638
+ DB_MAX=10 # 최대 연결 수 (Postgres max_connections에 맞게 설정)
639
+ DB_CONNECTION_TIMEOUT=3000 # ⚠️ 필수 설정 없으면 소진 시 무한 대기
640
+ DB_IDLE_TIMEOUT=30000 # 30초 후 유휴 연결 해제
641
+ DB_STATEMENT_TIMEOUT=10000 # 10초 후 폭주 쿼리 강제 종료
706
642
  ```
707
643
 
708
- > 모든 쿼리 빌더는 camelCase key snake_case 컬럼명으로 자동 변환합니다.
644
+ > `DB_CONNECTION_TIMEOUT`을 설정하지 않으면 reltype이 시작 경고를 출력합니다.
645
+ > 이 값 없이 풀이 소진되면 요청이 무한정 대기하게 됩니다.
709
646
 
710
647
  ---
711
648
 
712
- ## 케이스 변환 유틸리티
649
+ ## PostgreSQL 스키마 지원
713
650
 
714
651
  ```ts
715
- import { toCamel, toSnake, keysToCamel, keysToSnake } from 'reltype';
716
-
717
- toCamel('first_name') // 'firstName'
718
- toSnake('firstName') // 'first_name'
652
+ // 표기법
653
+ const logsTable = defineTable('audit.activity_logs', { ... });
719
654
 
720
- keysToCamel({ first_name: 'John', created_at: new Date() })
721
- // { firstName: 'John', createdAt: Date }
655
+ // 명시적 옵션
656
+ const usersTable = defineTable('users', { ... }, { schema: 'auth' });
722
657
 
723
- keysToSnake({ firstName: 'John', createdAt: new Date() })
724
- // { first_name: 'John', created_at: Date }
658
+ // → SQL: INSERT INTO "auth"."users" ...
659
+ // 예약어 충돌을 피하기 위해 식별자는 항상 인용됩니다
725
660
  ```
726
661
 
727
662
  ---
728
663
 
729
- ## 로거
664
+ ## 컬럼 타입
730
665
 
731
- ```ts
732
- import { Logger } from 'reltype';
666
+ | 메서드 | PostgreSQL 타입 | TypeScript 타입 |
667
+ |---|---|---|
668
+ | `col.serial()` | `SERIAL` | `number` |
669
+ | `col.integer()` | `INTEGER` | `number` |
670
+ | `col.bigint()` | `BIGINT` | `bigint` |
671
+ | `col.numeric()` | `NUMERIC` | `number` |
672
+ | `col.varchar(n?)` | `VARCHAR(n)` | `string` |
673
+ | `col.text()` | `TEXT` | `string` |
674
+ | `col.boolean()` | `BOOLEAN` | `boolean` |
675
+ | `col.timestamp()` | `TIMESTAMP` | `Date` |
676
+ | `col.timestamptz()` | `TIMESTAMPTZ` | `Date` |
677
+ | `col.date()` | `DATE` | `Date` |
678
+ | `col.uuid()` | `UUID` | `string` |
679
+ | `col.jsonb<T>()` | `JSONB` | `T` (기본 `unknown`) |
733
680
 
734
- const logger = Logger.fromEnv(process.env as Record<string, string | undefined>, {
735
- prefix: '[MyApp]',
736
- level: 'info',
737
- });
681
+ ### 수정자
738
682
 
739
- logger.debug('debug message');
740
- logger.info('info message');
741
- logger.warn('warn message');
742
- logger.error('error message', new Error('oops'));
683
+ ```ts
684
+ col.text().notNull() // INSERT 필수
685
+ col.text().nullable() // INSERT 선택, NULL 허용
686
+ col.integer().primaryKey() // INSERT 선택, serial/자동
687
+ col.boolean().default() // INSERT 선택 (DB에 DEFAULT 있음)
688
+ col.timestamptz().defaultNow() // INSERT 선택 (DEFAULT NOW())
743
689
  ```
744
690
 
745
- 환경 변수 `LOGGER=true`, `LOG_LEVEL=debug` 으로 활성화합니다.
746
-
747
691
  ---
748
692
 
749
693
  ## BaseRepo 확장
750
694
 
751
- 커스텀 메서드를 추가하려면 `BaseRepo`를 상속합니다.
695
+ 레포지토리에 도메인 전용 메서드를 추가하세요:
752
696
 
753
697
  ```ts
754
698
  import { BaseRepo, InferRow } from 'reltype';
755
699
  import { usersTable } from './schema';
756
700
 
757
701
  class UserRepo extends BaseRepo<typeof usersTable> {
758
- async findActiveUsers(): Promise<InferRow<typeof usersTable>[]> {
702
+ findActive(): Promise<InferRow<typeof usersTable>[]> {
759
703
  return this.findAll({ where: { isActive: true } });
760
704
  }
761
705
 
762
- async findByEmail(email: string): Promise<InferRow<typeof usersTable> | null> {
706
+ findByEmail(email: string): Promise<InferRow<typeof usersTable> | null> {
763
707
  return this.findOne({ email });
764
708
  }
709
+
710
+ async search(query: string, page: number) {
711
+ return this.select()
712
+ .or({ firstName: { operator: 'ILIKE', value: `%${query}%` } })
713
+ .or({ email: { operator: 'ILIKE', value: `%${query}%` } })
714
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
715
+ .paginate({ page, pageSize: 20 });
716
+ }
765
717
  }
766
718
 
767
719
  export const userRepo = new UserRepo(usersTable);
@@ -769,186 +721,128 @@ export const userRepo = new UserRepo(usersTable);
769
721
 
770
722
  ---
771
723
 
772
- ## 에러 처리
773
-
774
- ### DbError — PostgreSQL 에러 분류
724
+ ## 로깅
775
725
 
776
- 모든 DB 에러는 자동으로 `DbError`로 변환됩니다.
777
- `DbError`는 내부 로그용 상세 정보와 사용자 노출용 메시지를 분리합니다.
778
-
779
- ```ts
780
- import { DbError } from 'reltype';
781
-
782
- try {
783
- await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
784
- } catch (err) {
785
- if (err instanceof DbError) {
786
- // 사용자에게 안전하게 노출
787
- console.log(err.toUserPayload());
788
- // { error: '이미 존재하는 값입니다.', kind: 'uniqueViolation', isRetryable: false }
789
-
790
- // 내부 로깅용 상세 정보
791
- console.log(err.toLogContext());
792
- // { pgCode: '23505', kind: 'uniqueViolation', table: 'users', constraint: '...', ... }
793
-
794
- // 재시도 가능 여부 확인
795
- if (err.isRetryable) {
796
- // 재시도 로직
797
- }
798
- }
799
- }
726
+ ```env
727
+ LOGGER=true # 로깅 활성화
728
+ LOG_LEVEL=debug # debug | info | log | warn | error
729
+ LOG_FORMAT=json # text (개발, 색상) | json (프로덕션, 로그 수집기)
800
730
  ```
801
731
 
802
- ### Express에서 사용하는 예시
803
-
804
- ```ts
805
- app.post('/users', async (req, res) => {
806
- try {
807
- const user = await userRepo.create(req.body);
808
- res.status(201).json(user);
809
- } catch (err) {
810
- if (err instanceof DbError) {
811
- const status = err.kind === 'uniqueViolation' ? 409
812
- : err.kind === 'notNullViolation' ? 400
813
- : err.isRetryable ? 503
814
- : 500;
815
- res.status(status).json(err.toUserPayload());
816
- } else {
817
- res.status(500).json({ error: '알 수 없는 오류가 발생했습니다.' });
818
- }
819
- }
820
- });
732
+ **개발 환경 출력 (`text` 포맷):**
733
+ ```
734
+ 2026-01-01T00:00:00.000Z [Pool] INFO 풀 생성 완료 { max: 10, connectionTimeoutMillis: 3000 }
735
+ 2026-01-01T00:00:00.000Z [Repo] DEBUG SQL: SELECT * FROM users WHERE is_active = $1 [ true ]
736
+ 2026-01-01T00:00:00.000Z [Repo] DEBUG 완료 (8ms) rowCount=42
821
737
  ```
822
738
 
823
- ### DbErrorKind 전체 목록
739
+ **프로덕션 출력 (`json` 포맷, Datadog / CloudWatch / Grafana Loki용):**
740
+ ```json
741
+ {"ts":"2026-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"풀 생성 완료","meta":[{"max":10}]}
742
+ {"ts":"2026-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"쿼리 실패 [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
743
+ ```
824
744
 
825
- | kind | PostgreSQL SQLSTATE | 설명 | isRetryable |
826
- |---|---|---|---|
827
- | `uniqueViolation` | 23505 | UNIQUE 제약 위반 | false |
828
- | `foreignKeyViolation` | 23503 | FK 제약 위반 | false |
829
- | `notNullViolation` | 23502 | NOT NULL 위반 | false |
830
- | `checkViolation` | 23514 | CHECK 제약 위반 | false |
831
- | `deadlock` | 40P01 | 교착 상태 | **true** |
832
- | `serializationFailure` | 40001 | 직렬화 실패 | **true** |
833
- | `connectionFailed` | 08xxx | 연결 실패 | **true** |
834
- | `tooManyConnections` | 53300 | 연결 초과 | **true** |
835
- | `queryTimeout` | 57014 | 쿼리 타임아웃 | false |
836
- | `undefinedTable` | 42P01 | 테이블 없음 | false |
837
- | `undefinedColumn` | 42703 | 컬럼 없음 | false |
838
- | `invalidInput` | 22xxx | 잘못된 입력 형식 | false |
839
- | `unknown` | 기타 | 분류 불가 | false |
745
+ | 레벨 | 접두사 | 이벤트 |
746
+ |---|---|---|
747
+ | INFO | [Pool] | 생성 / 종료 |
748
+ | WARN | [Pool] | connectionTimeoutMillis 미설정 / 최대 연결 수 도달 |
749
+ | ERROR | [Pool] | 유휴 클라이언트 오류 / 연결 획득 실패 |
750
+ | DEBUG | [Repo] | 모든 SQL + 경과 시간 |
751
+ | ERROR | [Repo] | 쿼리 실패 (pgCode, kind, elapsed 포함) |
752
+ | DEBUG | [Tx] | 트랜잭션 시작 / 커밋 |
753
+ | WARN | [Tx] | 롤백 |
754
+ | ERROR | [Tx] | 롤백 실패 |
840
755
 
841
756
  ---
842
757
 
843
- ## 모니터링
758
+ ## 전체 환경 변수
844
759
 
845
- ```ts
846
- import { getPoolStatus, checkPoolHealth } from 'reltype';
847
-
848
- // 현재 Pool 상태 조회
849
- const status = getPoolStatus();
850
- console.log(status);
851
- // {
852
- // totalCount: 5, // 총 생성된 연결 수
853
- // idleCount: 3, // 유휴 연결 수
854
- // waitingCount: 0, // 연결 대기 중인 요청 수
855
- // isHealthy: true // 정상 여부
856
- // }
857
-
858
- // DB 서버와의 연결 헬스체크 (SELECT 1)
859
- const isAlive = await checkPoolHealth();
860
- ```
861
-
862
- ### Too Many Connections 방지
760
+ ```env
761
+ # ── 연결 ──────────────────────────────────────────────────────────────────────
762
+ DB_CONNECTION_STRING= # postgresql://user:pass@host:5432/db (우선)
763
+ DB_HOST=127.0.0.1
764
+ DB_PORT=5432
765
+ DB_NAME=mydb
766
+ DB_USER=postgres
767
+ DB_PASSWORD=postgres
863
768
 
864
- `.env`에서 Pool 크기와 타임아웃을 반드시 설정하세요.
769
+ # ── ────────────────────────────────────────────────────────────────────────
770
+ DB_MAX=10 # 최대 풀 크기
771
+ DB_IDLE_TIMEOUT=30000 # 유휴 연결 해제 (ms)
772
+ DB_CONNECTION_TIMEOUT=3000 # 연결 획득 최대 대기 (ms) — 반드시 설정하세요
773
+ DB_ALLOW_EXIT_ON_IDLE=false # 풀이 비었을 때 프로세스 종료 허용
774
+ DB_STATEMENT_TIMEOUT=0 # 최대 구문 실행 시간 (ms, 0 = 무제한)
775
+ DB_QUERY_TIMEOUT=0 # 최대 쿼리 시간 (ms, 0 = 무제한)
776
+ DB_SSL=false # SSL 활성화
777
+ DB_KEEP_ALIVE=true # TCP 연결 유지
778
+ DB_KEEP_ALIVE_INITIAL_DELAY=10000 # 연결 유지 초기 지연 (ms)
779
+ DB_APPLICATION_NAME=my-app # pg_stat_activity에 표시될 앱 이름
865
780
 
866
- ```env
867
- DB_MAX=10 # 최대 연결 풀 크기 (기본값: 10)
868
- DB_CONNECTION_TIMEOUT=3000 # 연결 획득 타임아웃 ms (미설정 무한 대기 경고)
869
- DB_IDLE_TIMEOUT=30000 # 유휴 연결 해제 시간 ms
870
- DB_STATEMENT_TIMEOUT=10000 # SQL 문 최대 실행 시간 ms
781
+ # ── 로깅 ──────────────────────────────────────────────────────────────────────
782
+ LOGGER=true
783
+ LOG_LEVEL=info # debug | info | log | warn | error
784
+ LOG_FORMAT=text # text | json
871
785
  ```
872
786
 
873
- > `DB_CONNECTION_TIMEOUT`이 설정되지 않으면 Pool 소진 시 요청이 무한 대기합니다.
874
- > 반드시 설정하세요.
875
-
876
787
  ---
877
788
 
878
- ## 로그 시스템
789
+ ## 자주 묻는 질문 (FAQ)
879
790
 
880
- ### 포맷 설정
791
+ **Q. 마이그레이션을 실행해야 하나요?**
792
+ 아니요. reltype은 데이터베이스 스키마를 관리하지 않습니다. 선호하는 마이그레이션 도구(Flyway, Liquibase, `psql` 등)를 사용하세요. reltype은 SQL 쿼리만 생성하고 실행합니다.
881
793
 
882
- ```env
883
- LOGGER=true # 로거 활성화
884
- LOG_LEVEL=debug # debug / info / log / warn / error
885
- LOG_FORMAT=json # text(기본) / json(프로덕션 권장)
886
- ```
794
+ **Q. 기존 데이터베이스와 함께 사용할 수 있나요?**
795
+ 네. 기존 컬럼과 일치하도록 `defineTable(...)`을 정의하기만 하면 됩니다. reltype은 Postgres에 있는 데이터를 읽기만 합니다.
887
796
 
888
- ### text 포맷 (개발 환경)
797
+ **Q. 매우 복잡한 쿼리는 어떻게 하나요?**
798
+ `repo.raw(sql, params)` 또는 `QueryBuilder.raw(sql, params)`를 사용하여 완전한 SQL 제어권을 가지세요. 결과에 camelCase 변환은 여전히 적용됩니다.
889
799
 
890
- ```
891
- 2024-01-01T00:00:00.000Z [Pool] INFO Pool 생성 완료 { max: 10, ... }
892
- 2024-01-01T00:00:00.000Z [Repo] DEBUG SQL: SELECT * FROM users WHERE id = $1 [ 1 ]
893
- 2024-01-01T00:00:00.000Z [Repo] DEBUG 완료 (12ms) rowCount=1
894
- ```
800
+ **Q. NestJS / Fastify / Koa와 함께 사용할 수 있나요?**
801
+ 네. reltype은 프레임워크에 종속되지 않습니다. `pg`에만 의존합니다.
895
802
 
896
- ### json 포맷 (프로덕션 / 로그 수집기)
803
+ **Q. SQL 인젝션에 안전한가요?**
804
+ `where`, `create`, `update` 등의 모든 값은 파라미터화된 쿼리(`$1`, `$2`, ...)로 전달됩니다. 문자열 보간을 사용하지 않습니다. 주의해야 할 유일한 부분은 `.join()`의 `on` 절입니다 — 코드에서 정적 문자열로 구성하세요.
897
805
 
898
- ```json
899
- {"ts":"2024-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"Pool 생성 완료","meta":[{"max":10}]}
900
- {"ts":"2024-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"쿼리 실패 [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
901
- ```
902
-
903
- ### 로그 이벤트 목록
904
-
905
- | 레벨 | prefix | 이벤트 |
906
- |---|---|---|
907
- | INFO | [Pool] | Pool 생성 완료 / Pool 종료 |
908
- | WARN | [Pool] | connectionTimeoutMillis 미설정 경고 |
909
- | WARN | [Pool] | 최대 연결 수 도달 |
910
- | DEBUG | [Pool] | 새 연결 생성 / 연결 제거 |
911
- | ERROR | [Pool] | 유휴 클라이언트 오류 / 클라이언트 획득 실패 |
912
- | DEBUG | [Repo] | SQL 실행 + 소요시간 |
913
- | ERROR | [Repo] | 쿼리 실패 (pgCode, kind, 소요시간 포함) |
914
- | DEBUG | [Tx] | 트랜잭션 시작 / 커밋 |
915
- | WARN | [Tx] | 트랜잭션 롤백 |
916
- | ERROR | [Tx] | 롤백 실패 |
806
+ **Q. Drizzle ORM과 어떻게 다른가요?**
807
+ 둘 다 TypeScript 우선이며 가볍습니다. reltype의 주요 장점은 자동 camelCase↔snake_case 변환(Drizzle은 수동 컬럼 이름 지정 필요), 커서 페이지네이션, 스트리밍, 배치 처리의 내장 지원, 그리고 사용자 안전 메시지를 포함한 구조화된 `DbError` 시스템입니다.
917
808
 
918
809
  ---
919
810
 
920
811
  ## 아키텍처
921
812
 
922
813
  ```
923
- src/
924
- ├── index.ts ← 공개 API 진입점
925
- ├── configs/env.ts ← DB 설정 파싱
814
+ reltype/
815
+ ├── index.ts ← 공개 API
816
+ ├── configs/env.ts ← DB 설정 헬퍼
926
817
  ├── utils/
927
- │ ├── logger.ts ← Logger 클래스
928
- └── reader.ts env 파서, PostgresConfig
818
+ │ ├── logger.ts ← Logger (text/json 포맷)
819
+ ├── dbError.ts DbError 분류
820
+ │ └── reader.ts ← 환경 파서, PostgresConfig
929
821
  └── features/
930
- ├── schema/ ← defineTable, col, InferRow/Insert/Update
931
- ├── transform/ ← camelCase ↔ snake_case 변환
932
- ├── connection/ ← Pool 관리, Transaction
933
- ├── query/ SQL 쿼리 빌더 (select/insert/update/delete/upsert/bulkInsert)
934
- └── repository/ ← BaseRepo, createRepo, IRepo
822
+ ├── schema/ ← defineTable, col, InferRow/Insert/Update
823
+ ├── transform/ ← camelCase ↔ snake_case
824
+ ├── connection/ ← Pool, withClient, runInTx
825
+ ├── query/ QueryBuilder, build* 함수들
826
+ └── repository/ ← BaseRepo, createRepo
935
827
  ```
936
828
 
937
829
  ---
938
830
 
939
- ## 기여
831
+ ## 기여하기
832
+
833
+ 버그 리포트, 기능 제안, PR을 모두 환영합니다.
940
834
 
941
- 버그 리포트, 기능 제안, PR 모두 환영합니다.
942
- → [Issues](https://github.com/psh-suhyun/reltype/issues) · [Pull Requests](https://github.com/psh-suhyun/reltype/pulls)
835
+ [이슈 열기](https://github.com/psh-suhyun/reltype/issues)
836
+ → [PR 제출](https://github.com/psh-suhyun/reltype/pulls)
943
837
 
944
838
  ---
945
839
 
946
840
  ## 변경 이력
947
841
 
948
- 전체 변경 이력은 [CHANGELOG.md](./CHANGELOG.md) 참고하세요.
842
+ 전체 버전 히스토리는 [CHANGELOG.md](./CHANGELOG.md)를 참조하세요.
949
843
 
950
844
  ---
951
845
 
952
846
  ## 라이선스
953
847
 
954
- MIT
848
+ MIT © [psh-suhyun](https://github.com/psh-suhyun)