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.
- package/CHANGELOG.md +83 -3
- package/README.ko.md +517 -623
- package/README.md +511 -623
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -6,629 +6,589 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/reltype)
|
|
7
7
|
[](https://www.typescriptlang.org/)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
> English documentation → [README.md](./README.md)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**방해받지 않는 PostgreSQL 쿼리 라이브러리.**
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
76
|
+
> `pg` (node-postgres)는 peerDependency입니다. 8.0.0 이상 필요합니다.
|
|
36
77
|
|
|
37
78
|
---
|
|
38
79
|
|
|
39
|
-
##
|
|
80
|
+
## 2분 빠른 시작
|
|
40
81
|
|
|
41
|
-
|
|
82
|
+
### 1단계 — 환경 변수 설정
|
|
42
83
|
|
|
43
84
|
```env
|
|
44
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
// index.ts — 가장 첫 번째 줄이어야 합니다
|
|
105
|
+
import 'dotenv/config';
|
|
98
106
|
|
|
99
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
`reltype`은 `process.env`를 읽기만 합니다. `.env` 파일 로딩은 **애플리케이션 진입점**에서 직접 하세요.
|
|
110
|
+
### 3단계 — 테이블 스키마 정의
|
|
122
111
|
|
|
123
112
|
```ts
|
|
124
|
-
//
|
|
125
|
-
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
|
-
//
|
|
128
|
-
|
|
125
|
+
// 타입은 자동으로 사용 가능 — 추가 코드 불필요
|
|
126
|
+
// InferRow<typeof usersTable> → SELECT 결과 타입
|
|
127
|
+
// InferInsert<typeof usersTable> → INSERT 입력 타입 (수정자에 따라 필수/선택)
|
|
128
|
+
// InferUpdate<typeof usersTable> → UPDATE 입력 타입 (PK 제외, 모두 선택)
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
### 4
|
|
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
|
-
|
|
144
|
+
// INSERT
|
|
145
|
+
const user = await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
|
|
164
146
|
|
|
165
|
-
|
|
147
|
+
// UPDATE
|
|
148
|
+
const updated = await userRepo.update(user.id, { isActive: false });
|
|
166
149
|
|
|
167
|
-
|
|
168
|
-
const
|
|
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
|
-
##
|
|
158
|
+
## 타입 추론 — 핵심 기능
|
|
198
159
|
|
|
199
|
-
|
|
160
|
+
스키마를 한 번 정의하면 reltype이 모든 타입을 자동으로 추론합니다:
|
|
200
161
|
|
|
201
162
|
```ts
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
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
|
-
|
|
199
|
+
---
|
|
235
200
|
|
|
236
|
-
|
|
201
|
+
## 레포지토리 API
|
|
237
202
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
##
|
|
220
|
+
## 플루언트 쿼리 빌더
|
|
249
221
|
|
|
250
222
|
`repo.select(where?)`는 `QueryBuilder`를 반환합니다.
|
|
251
|
-
메서드를
|
|
223
|
+
메서드를 자유롭게 체이닝한 후 `await`하거나 `.exec()`를 호출하면 실행됩니다.
|
|
252
224
|
|
|
253
|
-
###
|
|
225
|
+
### 필터링 (WHERE / OR)
|
|
254
226
|
|
|
255
227
|
```ts
|
|
256
|
-
//
|
|
257
|
-
const users = await userRepo.select();
|
|
258
|
-
|
|
259
|
-
// 초기 WHERE 조건
|
|
228
|
+
// 단순 동등 비교
|
|
260
229
|
const users = await userRepo.select({ isActive: true });
|
|
261
|
-
```
|
|
262
230
|
|
|
263
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
328
|
-
const
|
|
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
|
-
###
|
|
276
|
+
### 디버깅 — 실행 전 SQL 미리 보기
|
|
351
277
|
|
|
352
278
|
```ts
|
|
353
|
-
const
|
|
354
|
-
.
|
|
355
|
-
.
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
##
|
|
292
|
+
## 페이지네이션
|
|
396
293
|
|
|
397
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
322
|
+
// 2페이지 — 커서를 전달
|
|
323
|
+
const p2 = await userRepo.select({ isActive: true })
|
|
324
|
+
.cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
|
|
430
325
|
|
|
431
|
-
//
|
|
432
|
-
|
|
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
|
-
| 전체
|
|
445
|
-
|
|
|
446
|
-
|
|
|
331
|
+
| | `paginate` | `cursorPaginate` |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| 전체 개수 제공 | ✅ 있음 | ❌ 없음 |
|
|
334
|
+
| 페이지 번호 이동 | ✅ 있음 | ❌ 다음/이전만 |
|
|
335
|
+
| 100만 번째 행에서 성능 | ❌ 느림 | ✅ 일정한 속도 |
|
|
336
|
+
| 적합한 상황 | 관리자 테이블, 일반 목록 | 피드, 로그, 대용량 내보내기 |
|
|
447
337
|
|
|
448
338
|
---
|
|
449
339
|
|
|
450
|
-
##
|
|
340
|
+
## 대용량 데이터 처리
|
|
451
341
|
|
|
452
|
-
|
|
453
|
-
|
|
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[] (
|
|
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
|
|
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
|
|
366
|
+
await writeToFile(user);
|
|
480
367
|
}
|
|
481
368
|
```
|
|
482
369
|
|
|
483
|
-
|
|
370
|
+
### EXPLAIN — 쿼리 플랜 분석
|
|
484
371
|
|
|
485
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
//
|
|
492
|
-
const
|
|
493
|
-
|
|
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
|
-
//
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
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
|
-
##
|
|
430
|
+
## CRUD 메서드
|
|
513
431
|
|
|
514
|
-
|
|
432
|
+
### create
|
|
515
433
|
|
|
516
434
|
```ts
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
456
|
+
```ts
|
|
457
|
+
const ok = await userRepo.delete(1);
|
|
458
|
+
// → 삭제되면 true, 없으면 false
|
|
459
|
+
```
|
|
530
460
|
|
|
531
|
-
|
|
461
|
+
### upsert
|
|
532
462
|
|
|
533
463
|
```ts
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
.limit(20)
|
|
537
|
-
.toSQL();
|
|
464
|
+
// primary key 충돌 (기본)
|
|
465
|
+
await userRepo.upsert({ id: 1, firstName: 'Bob', email: 'bob@example.com' });
|
|
538
466
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
//
|
|
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
|
-
##
|
|
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 }) =>
|
|
555
|
-
|
|
556
|
-
|
|
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 })
|
|
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
|
-
##
|
|
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
|
-
|
|
595
|
-
const user = await userRepo.findById(1); // User | null
|
|
526
|
+
### DbError — 구조화된 PostgreSQL 에러 분류
|
|
596
527
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
```
|
|
528
|
+
모든 DB 에러는 자동으로 `DbError`로 래핑됩니다.
|
|
529
|
+
사용자에게 안전하게 보여줄 정보와 로그에만 남길 내부 정보를 분리합니다.
|
|
600
530
|
|
|
601
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
await userRepo.create({ firstName: '
|
|
642
|
-
|
|
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
|
-
//
|
|
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,
|
|
653
|
-
|
|
654
|
-
// Pool 직접 접근
|
|
655
|
-
const pool = getPool();
|
|
613
|
+
import { getPool, getPoolStatus, checkPoolHealth, closePool } from 'reltype';
|
|
656
614
|
|
|
657
|
-
//
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
//
|
|
664
|
-
|
|
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
|
-
|
|
674
|
-
|
|
625
|
+
// DB 서버 핑 (SELECT 1)
|
|
626
|
+
const alive = await checkPoolHealth(); // → boolean
|
|
675
627
|
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
limit: 5,
|
|
628
|
+
// 안전한 종료
|
|
629
|
+
process.on('SIGTERM', async () => {
|
|
630
|
+
await closePool();
|
|
631
|
+
process.exit(0);
|
|
681
632
|
});
|
|
633
|
+
```
|
|
682
634
|
|
|
683
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
>
|
|
644
|
+
> `DB_CONNECTION_TIMEOUT`을 설정하지 않으면 reltype이 시작 시 경고를 출력합니다.
|
|
645
|
+
> 이 값 없이 풀이 소진되면 요청이 무한정 대기하게 됩니다.
|
|
709
646
|
|
|
710
647
|
---
|
|
711
648
|
|
|
712
|
-
##
|
|
649
|
+
## PostgreSQL 스키마 지원
|
|
713
650
|
|
|
714
651
|
```ts
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
toCamel('first_name') // 'firstName'
|
|
718
|
-
toSnake('firstName') // 'first_name'
|
|
652
|
+
// 점 표기법
|
|
653
|
+
const logsTable = defineTable('audit.activity_logs', { ... });
|
|
719
654
|
|
|
720
|
-
|
|
721
|
-
|
|
655
|
+
// 명시적 옵션
|
|
656
|
+
const usersTable = defineTable('users', { ... }, { schema: 'auth' });
|
|
722
657
|
|
|
723
|
-
|
|
724
|
-
//
|
|
658
|
+
// → SQL: INSERT INTO "auth"."users" ...
|
|
659
|
+
// 예약어 충돌을 피하기 위해 식별자는 항상 인용됩니다
|
|
725
660
|
```
|
|
726
661
|
|
|
727
662
|
---
|
|
728
663
|
|
|
729
|
-
##
|
|
664
|
+
## 컬럼 타입
|
|
730
665
|
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
735
|
-
prefix: '[MyApp]',
|
|
736
|
-
level: 'info',
|
|
737
|
-
});
|
|
681
|
+
### 수정자
|
|
738
682
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
+
findActive(): Promise<InferRow<typeof usersTable>[]> {
|
|
759
703
|
return this.findAll({ where: { isActive: true } });
|
|
760
704
|
}
|
|
761
705
|
|
|
762
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
826
|
-
|
|
827
|
-
|
|
|
828
|
-
|
|
|
829
|
-
|
|
|
830
|
-
|
|
|
831
|
-
|
|
|
832
|
-
|
|
|
833
|
-
|
|
|
834
|
-
|
|
|
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
|
-
```
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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
|
-
|
|
797
|
+
**Q. 매우 복잡한 쿼리는 어떻게 하나요?**
|
|
798
|
+
`repo.raw(sql, params)` 또는 `QueryBuilder.raw(sql, params)`를 사용하여 완전한 SQL 제어권을 가지세요. 결과에 camelCase 변환은 여전히 적용됩니다.
|
|
889
799
|
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
803
|
+
**Q. SQL 인젝션에 안전한가요?**
|
|
804
|
+
`where`, `create`, `update` 등의 모든 값은 파라미터화된 쿼리(`$1`, `$2`, ...)로 전달됩니다. 문자열 보간을 사용하지 않습니다. 주의해야 할 유일한 부분은 `.join()`의 `on` 절입니다 — 코드에서 정적 문자열로 구성하세요.
|
|
897
805
|
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
924
|
-
├── index.ts
|
|
925
|
-
├── configs/env.ts
|
|
814
|
+
reltype/
|
|
815
|
+
├── index.ts ← 공개 API
|
|
816
|
+
├── configs/env.ts ← DB 설정 헬퍼
|
|
926
817
|
├── utils/
|
|
927
|
-
│ ├── logger.ts
|
|
928
|
-
│
|
|
818
|
+
│ ├── logger.ts ← Logger (text/json 포맷)
|
|
819
|
+
│ ├── dbError.ts ← DbError 분류
|
|
820
|
+
│ └── reader.ts ← 환경 파서, PostgresConfig
|
|
929
821
|
└── features/
|
|
930
|
-
├── schema/
|
|
931
|
-
├── transform/
|
|
932
|
-
├── connection/
|
|
933
|
-
├── query/
|
|
934
|
-
└── repository/
|
|
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
|
-
|
|
942
|
-
→ [
|
|
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
|
-
전체
|
|
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)
|