reltype 0.1.1 → 0.1.2

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 (3) hide show
  1. package/README.ko.md +954 -0
  2. package/README.md +227 -225
  3. package/package.json +2 -1
package/README.ko.md ADDED
@@ -0,0 +1,954 @@
1
+ # reltype
2
+
3
+ [![npm version](https://img.shields.io/npm/v/reltype.svg)](https://www.npmjs.com/package/reltype)
4
+ [![npm downloads](https://img.shields.io/npm/dm/reltype.svg)](https://www.npmjs.com/package/reltype)
5
+ [![license](https://img.shields.io/npm/l/reltype.svg)](https://github.com/psh-suhyun/reltype/blob/main/LICENSE)
6
+ [![node](https://img.shields.io/node/v/reltype.svg)](https://www.npmjs.com/package/reltype)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
8
+
9
+ **TypeScript를 위한 Type-first PostgreSQL 관계형 모델링 라이브러리.**
10
+
11
+ PostgreSQL 테이블을 TypeScript 코드로 정의하면, 모든 쿼리의 반환 타입이 자동으로 추론됩니다.
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 연동
19
+
20
+ > English documentation is available in [README.md](./README.md).
21
+
22
+ ---
23
+
24
+ ## 설치
25
+
26
+ ```bash
27
+ # reltype 설치
28
+ npm install reltype
29
+
30
+ # pg는 peerDependency — 직접 설치해야 합니다
31
+ npm install pg
32
+ npm install --save-dev @types/pg
33
+ ```
34
+
35
+ > `pg` 버전 8.0.0 이상이 필요합니다.
36
+
37
+ ---
38
+
39
+ ## 환경 변수
40
+
41
+ `.env` 파일을 프로젝트 루트에 생성합니다.
42
+
43
+ ```env
44
+ # ── 필수 (CONNECTION_STRING 또는 DB_NAME 중 하나는 반드시 설정) ──────────────
45
+
46
+ # 방법 1: Connection String (우선 적용)
47
+ DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
48
+
49
+ # 방법 2: 개별 설정
50
+ DB_HOST=127.0.0.1
51
+ DB_PORT=5432
52
+ DB_NAME=mydb
53
+ DB_USER=postgres
54
+ 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)
73
+ ```
74
+
75
+ ---
76
+
77
+ ## 빠른 시작
78
+
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
+ });
92
+ ```
93
+
94
+ ### 2. 타입 자동 추론
95
+
96
+ ```ts
97
+ import { InferRow, InferInsert, InferUpdate } from 'reltype';
98
+
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 }
117
+ ```
118
+
119
+ ### 3. 앱 진입점에서 dotenv 로드
120
+
121
+ `reltype`은 `process.env`를 읽기만 합니다. `.env` 파일 로딩은 **애플리케이션 진입점**에서 직접 하세요.
122
+
123
+ ```ts
124
+ // 애플리케이션 진입점 (index.ts / server.ts / app.ts)
125
+ import 'dotenv/config'; // 반드시 다른 import 전에 위치
126
+
127
+ // 이후 reltype import
128
+ import { getDatabaseConfig, getPool } from 'reltype';
129
+ ```
130
+
131
+ ### 4. Repository 생성
132
+
133
+ ```ts
134
+ import { createRepo } from 'reltype';
135
+ import { usersTable } from './schema';
136
+
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
+
161
+ ---
162
+
163
+ ## create
164
+
165
+ 단건 INSERT. 자동으로 생성되는 컬럼(serial, default, nullable)은 입력을 생략할 수 있습니다.
166
+
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
174
+ ```
175
+
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
+ ```
194
+
195
+ ---
196
+
197
+ ## delete
198
+
199
+ PK를 기준으로 삭제합니다. 삭제된 row가 있으면 `true`, 없으면 `false`를 반환합니다.
200
+
201
+ ```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
213
+
214
+ 충돌 컬럼 기준으로 INSERT 또는 UPDATE합니다.
215
+
216
+ ```ts
217
+ // PK(id) 기준 (기본값)
218
+ const user = await userRepo.upsert({
219
+ id: 1,
220
+ firstName: 'John',
221
+ email: 'john@example.com',
222
+ });
223
+
224
+ // 다른 unique 컬럼 기준 (snake_case)
225
+ const user = await userRepo.upsert(
226
+ { firstName: 'John', email: 'john@example.com' },
227
+ 'email',
228
+ );
229
+ // → User
230
+ ```
231
+
232
+ ---
233
+
234
+ ## bulkCreate
235
+
236
+ 여러 row를 단일 `INSERT` 쿼리로 삽입합니다.
237
+
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
+ ```
245
+
246
+ ---
247
+
248
+ ## select — 플루언트 쿼리 빌더
249
+
250
+ `repo.select(where?)`는 `QueryBuilder`를 반환합니다.
251
+ 메서드를 체인으로 조합한 후 `await` 하거나 `.exec()` 로 실행합니다.
252
+
253
+ ### 기본 조회
254
+
255
+ ```ts
256
+ // 전체 조회 (await 직접 사용 가능)
257
+ const users = await userRepo.select();
258
+
259
+ // 초기 WHERE 조건
260
+ const users = await userRepo.select({ isActive: true });
261
+ ```
262
+
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
278
+ 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 조건들)` 형태로 생성됩니다.
292
+
293
+ ```ts
294
+ // firstName ILIKE '%john%' OR email ILIKE '%john%'
295
+ const users = await userRepo.select({ isActive: true })
296
+ .or({ firstName: { operator: 'ILIKE', value: '%john%' } })
297
+ .or({ email: { operator: 'ILIKE', value: '%john%' } });
298
+ // → WHERE (is_active = true) OR (first_name ILIKE '%john%') OR (email ILIKE '%john%')
299
+ ```
300
+
301
+ ### ORDER BY
302
+
303
+ ```ts
304
+ const users = await userRepo.select()
305
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }]);
306
+
307
+ // 다중 정렬
308
+ const users = await userRepo.select()
309
+ .orderBy([
310
+ { column: 'isActive', direction: 'DESC' },
311
+ { 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' }])
320
+ .limit(20)
321
+ .offset(40);
322
+ // 3페이지 (0-indexed offset)
323
+ ```
324
+
325
+ ### GROUP BY
326
+
327
+ ```ts
328
+ const result = await userRepo.select()
329
+ .groupBy(['isActive'])
330
+ .calculate([
331
+ { fn: 'COUNT', alias: 'count' },
332
+ ]);
333
+ // → { count: '42' }
334
+ ```
335
+
336
+ ### JOIN
337
+
338
+ ```ts
339
+ // LEFT JOIN
340
+ const result = await userRepo.select({ isActive: true })
341
+ .join({ table: 'orders', on: 'users.id = orders.user_id', type: 'LEFT' })
342
+ .columns(['users.id', 'users.email'])
343
+ .groupBy(['users.id', 'users.email'])
344
+ .orderBy([{ column: 'id', direction: 'ASC' }])
345
+ .exec();
346
+ ```
347
+
348
+ JOIN 타입: `INNER` `LEFT` `RIGHT` `FULL`
349
+
350
+ ### 컬럼 지정 (columns)
351
+
352
+ ```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);
382
+
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' }
391
+ ```
392
+
393
+ ---
394
+
395
+ ## paginate — OFFSET 페이지네이션
396
+
397
+ COUNT 쿼리와 DATA 쿼리를 병렬로 실행합니다.
398
+
399
+ ```ts
400
+ const result = await userRepo.select({ isActive: true })
401
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
402
+ .paginate({ page: 1, pageSize: 20 });
403
+
404
+ // result 구조
405
+ // {
406
+ // data: User[], // 현재 페이지 데이터
407
+ // count: 150, // 전체 row 수 (필터 적용)
408
+ // page: 1,
409
+ // pageSize: 20,
410
+ // nextAction: true, // 다음 페이지 존재 여부
411
+ // previousAction: false, // 이전 페이지 존재 여부
412
+ // }
413
+ ```
414
+
415
+ > 수백만 건 이상의 테이블에서는 `cursorPaginate()` 를 사용하세요.
416
+
417
+ ---
418
+
419
+ ## cursorPaginate — 커서 기반 페이지네이션 (대용량)
420
+
421
+ OFFSET 스캔 없이 `WHERE id > last_id` 방식으로 동작합니다.
422
+ 인덱스가 있는 컬럼을 `cursorColumn`으로 지정하면 수천만 건에서도 일정한 속도를 유지합니다.
423
+
424
+ ```ts
425
+ // 첫 페이지
426
+ const p1 = await userRepo.select({ isActive: true })
427
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
428
+
429
+ // p1 = { data: [...], nextCursor: 'xxx', pageSize: 20, hasNext: true }
430
+
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()
439
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'createdAt', direction: 'desc' });
440
+ ```
441
+
442
+ | `paginate` | `cursorPaginate` |
443
+ |---|---|
444
+ | 전체 count 제공 | count 미제공 |
445
+ | page 번호로 이동 | 이전/다음만 가능 |
446
+ | 대용량에서 느려짐 | 항상 일정한 속도 |
447
+
448
+ ---
449
+
450
+ ## forEach — 배치 처리
451
+
452
+ 전체 데이터를 메모리에 올리지 않고 청크 단위로 처리합니다.
453
+ 대용량 ETL, 이메일 일괄 발송, 데이터 마이그레이션에 적합합니다.
454
+
455
+ ```ts
456
+ await userRepo.select({ isActive: true })
457
+ .orderBy([{ column: 'id', direction: 'ASC' }])
458
+ .forEach(async (batch) => {
459
+ // batch: User[] (기본 500개씩)
460
+ await sendEmailBatch(batch);
461
+ }, { batchSize: 200 });
462
+ ```
463
+
464
+ ---
465
+
466
+ ## stream — 스트리밍 (AsyncGenerator)
467
+
468
+ `for await...of` 루프로 row를 하나씩 순회합니다.
469
+ 내부적으로 배치 단위로 DB를 조회하여 메모리 효율을 유지합니다.
470
+
471
+ ```ts
472
+ // for await...of 직접 사용 (Symbol.asyncIterator 지원)
473
+ for await (const user of userRepo.select({ isActive: true })) {
474
+ await processRow(user);
475
+ }
476
+
477
+ // 배치 크기 지정
478
+ for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
479
+ await processRow(user);
480
+ }
481
+ ```
482
+
483
+ ---
484
+
485
+ ## raw — Raw SQL 직접 실행
486
+
487
+ 복잡한 쿼리가 필요할 때 SQL을 직접 작성합니다.
488
+ 결과 컬럼명은 `snake_case → camelCase`로 자동 변환됩니다.
489
+
490
+ ```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
+ );
496
+
497
+ // QueryBuilder.raw() — 레포지토리 없이 독립적으로 사용
498
+ import { QueryBuilder } from 'reltype';
499
+
500
+ const rows = await QueryBuilder.raw(
501
+ `SELECT u.id, u.email, COUNT(o.id) AS order_count
502
+ FROM users u
503
+ LEFT JOIN orders o ON u.id = o.user_id
504
+ WHERE u.is_active = $1
505
+ GROUP BY u.id, u.email`,
506
+ [true],
507
+ );
508
+ ```
509
+
510
+ ---
511
+
512
+ ## explain — 쿼리 플랜 분석
513
+
514
+ 인덱스 사용 여부 및 성능 병목을 확인합니다.
515
+
516
+ ```ts
517
+ // EXPLAIN
518
+ const plan = await userRepo.select({ isActive: true }).explain();
519
+ console.log(plan);
520
+
521
+ // EXPLAIN ANALYZE (실제 실행 통계 포함)
522
+ const plan = await userRepo.select({ isActive: true })
523
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
524
+ .explain(true);
525
+ ```
526
+
527
+ ---
528
+
529
+ ## toSQL — SQL 미리 확인 (디버깅)
530
+
531
+ 실제 실행 없이 생성될 SQL과 params를 반환합니다.
532
+
533
+ ```ts
534
+ const { sql, params } = userRepo.select({ isActive: true })
535
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
536
+ .limit(20)
537
+ .toSQL();
538
+
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 ]
543
+ ```
544
+
545
+ ---
546
+
547
+ ## hooks — 쿼리 라이프사이클 훅
548
+
549
+ ### 쿼리별 훅
550
+
551
+ ```ts
552
+ const users = await userRepo.select({ isActive: true })
553
+ .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 }),
557
+ })
558
+ .paginate({ page: 1, pageSize: 20 });
559
+ ```
560
+
561
+ ### 레포지토리 전역 훅
562
+
563
+ 레포지토리의 모든 `select()` 빌더에 자동 적용됩니다.
564
+
565
+ ```ts
566
+ userRepo.useHooks({
567
+ beforeExec: ({ sql }) => logger.debug('SQL:', sql),
568
+ afterExec: ({ elapsed }) => metrics.histogram('db.latency', elapsed),
569
+ onError: ({ err }) => logger.error('DB 오류', { kind: err.message }),
570
+ });
571
+
572
+ // 이후 모든 select()에 훅이 적용됨
573
+ const users = await userRepo.select({ isActive: true }).exec();
574
+ ```
575
+
576
+ ---
577
+
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
+ });
593
+
594
+ // PK로 단건 조회
595
+ const user = await userRepo.findById(1); // User | null
596
+
597
+ // 조건으로 단건 조회 (단순 등호만 지원)
598
+ const user = await userRepo.findOne({ email: 'john@example.com' }); // User | null
599
+ ```
600
+
601
+ > 연산자(LIKE, IN, OR 등)가 필요한 경우 `repo.select()` 를 사용하세요.
602
+
603
+ ---
604
+
605
+ ## 컬럼 타입
606
+
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`) |
621
+
622
+ ### 컬럼 수식어
623
+
624
+ ```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
630
+ ```
631
+
632
+ ---
633
+
634
+ ## 트랜잭션
635
+
636
+ ```ts
637
+ import { runInTx } from 'reltype';
638
+
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';
643
+ });
644
+ // 하나라도 실패 시 자동 ROLLBACK
645
+ ```
646
+
647
+ ---
648
+
649
+ ## 커넥션 풀
650
+
651
+ ```ts
652
+ import { getPool, withClient, closePool } from 'reltype';
653
+
654
+ // Pool 직접 접근
655
+ const pool = getPool();
656
+
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 없이 직접 쿼리를 빌드할 수 있습니다.
672
+
673
+ ```ts
674
+ import { buildSelect, buildInsert, buildUpdate, buildDelete, buildUpsert, buildBulkInsert, withClient } from 'reltype';
675
+
676
+ // SELECT
677
+ const { sql, params } = buildSelect('users', {
678
+ where: { isActive: true },
679
+ orderBy: [{ col: 'createdAt', dir: 'DESC' }],
680
+ limit: 5,
681
+ });
682
+
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
+ ]);
700
+
701
+ // 실행
702
+ await withClient(async (client) => {
703
+ const result = await client.query(sql, params);
704
+ return result.rows;
705
+ });
706
+ ```
707
+
708
+ > 모든 쿼리 빌더는 camelCase key → snake_case 컬럼명으로 자동 변환합니다.
709
+
710
+ ---
711
+
712
+ ## 케이스 변환 유틸리티
713
+
714
+ ```ts
715
+ import { toCamel, toSnake, keysToCamel, keysToSnake } from 'reltype';
716
+
717
+ toCamel('first_name') // 'firstName'
718
+ toSnake('firstName') // 'first_name'
719
+
720
+ keysToCamel({ first_name: 'John', created_at: new Date() })
721
+ // { firstName: 'John', createdAt: Date }
722
+
723
+ keysToSnake({ firstName: 'John', createdAt: new Date() })
724
+ // { first_name: 'John', created_at: Date }
725
+ ```
726
+
727
+ ---
728
+
729
+ ## 로거
730
+
731
+ ```ts
732
+ import { Logger } from 'reltype';
733
+
734
+ const logger = Logger.fromEnv(process.env as Record<string, string | undefined>, {
735
+ prefix: '[MyApp]',
736
+ level: 'info',
737
+ });
738
+
739
+ logger.debug('debug message');
740
+ logger.info('info message');
741
+ logger.warn('warn message');
742
+ logger.error('error message', new Error('oops'));
743
+ ```
744
+
745
+ 환경 변수 `LOGGER=true`, `LOG_LEVEL=debug` 으로 활성화합니다.
746
+
747
+ ---
748
+
749
+ ## BaseRepo 확장
750
+
751
+ 커스텀 메서드를 추가하려면 `BaseRepo`를 상속합니다.
752
+
753
+ ```ts
754
+ import { BaseRepo, InferRow } from 'reltype';
755
+ import { usersTable } from './schema';
756
+
757
+ class UserRepo extends BaseRepo<typeof usersTable> {
758
+ async findActiveUsers(): Promise<InferRow<typeof usersTable>[]> {
759
+ return this.findAll({ where: { isActive: true } });
760
+ }
761
+
762
+ async findByEmail(email: string): Promise<InferRow<typeof usersTable> | null> {
763
+ return this.findOne({ email });
764
+ }
765
+ }
766
+
767
+ export const userRepo = new UserRepo(usersTable);
768
+ ```
769
+
770
+ ---
771
+
772
+ ## 에러 처리
773
+
774
+ ### DbError — PostgreSQL 에러 분류
775
+
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
+ }
800
+ ```
801
+
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
+ });
821
+ ```
822
+
823
+ ### DbErrorKind 전체 목록
824
+
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 |
840
+
841
+ ---
842
+
843
+ ## 풀 모니터링
844
+
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 방지
863
+
864
+ `.env`에서 Pool 크기와 타임아웃을 반드시 설정하세요.
865
+
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
871
+ ```
872
+
873
+ > `DB_CONNECTION_TIMEOUT`이 설정되지 않으면 Pool 소진 시 요청이 무한 대기합니다.
874
+ > 반드시 설정하세요.
875
+
876
+ ---
877
+
878
+ ## 로그 시스템
879
+
880
+ ### 포맷 설정
881
+
882
+ ```env
883
+ LOGGER=true # 로거 활성화
884
+ LOG_LEVEL=debug # debug / info / log / warn / error
885
+ LOG_FORMAT=json # text(기본) / json(프로덕션 권장)
886
+ ```
887
+
888
+ ### text 포맷 (개발 환경)
889
+
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
+ ```
895
+
896
+ ### json 포맷 (프로덕션 / 로그 수집기)
897
+
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] | 롤백 실패 |
917
+
918
+ ---
919
+
920
+ ## 아키텍처
921
+
922
+ ```
923
+ src/
924
+ ├── index.ts ← 공개 API 진입점
925
+ ├── configs/env.ts ← DB 설정 파싱
926
+ ├── utils/
927
+ │ ├── logger.ts ← Logger 클래스
928
+ │ └── reader.ts ← env 파서, PostgresConfig
929
+ └── 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
935
+ ```
936
+
937
+ ---
938
+
939
+ ## 기여
940
+
941
+ 버그 리포트, 기능 제안, PR 모두 환영합니다.
942
+ → [Issues](https://github.com/psh-suhyun/reltype/issues) · [Pull Requests](https://github.com/psh-suhyun/reltype/pulls)
943
+
944
+ ---
945
+
946
+ ## 변경 이력
947
+
948
+ 전체 변경 이력은 [CHANGELOG.md](./CHANGELOG.md) 를 참고하세요.
949
+
950
+ ---
951
+
952
+ ## 라이선스
953
+
954
+ MIT