reltype 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -8,93 +8,95 @@
8
8
 
9
9
  **Type-first relational modeling for PostgreSQL in TypeScript.**
10
10
 
11
- PostgreSQL 테이블을 TypeScript 코드로 정의하면, 모든 쿼리의 반환 타입이 자동으로 추론됩니다.
11
+ Define your PostgreSQL tables in TypeScript code and get fully-typed query results automatically.
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
+ - **Type-safe** — INSERT / SELECT / UPDATE types are automatically inferred from your schema
14
+ - **camelCase ↔ snake_case** — Automatic conversion between DB column names and TypeScript variables
15
+ - **Fluent query builder**Chain `WHERE`, `OR`, `JOIN`, `GROUP BY`, `LIMIT`, `paginate`, `calculate`, `stream` and more
16
+ - **Large data optimization** Cursor pagination, batch processing, AsyncGenerator streaming
17
+ - **Error classification** — `DbError` automatically classifies PostgreSQL errors into 13 distinct kinds
18
+ - **Hook system**Before/after query lifecycle hooks for monitoring and APM integration
19
+
20
+ > 한국어 문서는 [README.ko.md](./README.ko.md) 를 참고하세요.
19
21
 
20
22
  ---
21
23
 
22
24
  ## Installation
23
25
 
24
26
  ```bash
25
- # reltype 설치
27
+ # Install reltype
26
28
  npm install reltype
27
29
 
28
- # pg peerDependency — 직접 설치해야 합니다
30
+ # pg is a peerDependency — install it separately
29
31
  npm install pg
30
32
  npm install --save-dev @types/pg
31
33
  ```
32
34
 
33
- > `pg` 버전 8.0.0 이상이 필요합니다.
35
+ > Requires `pg` version 8.0.0 or higher.
34
36
 
35
37
  ---
36
38
 
37
39
  ## Environment Variables
38
40
 
39
- `.env` 파일을 프로젝트 루트에 생성합니다.
41
+ Create a `.env` file in your project root.
40
42
 
41
43
  ```env
42
- # ── 필수 (CONNECTION_STRING 또는 DB_NAME 하나는 반드시 설정) ──────────────
44
+ # ── Required (either CONNECTION_STRING or DB_NAME must be set) ───────────────
43
45
 
44
- # 방법 1: Connection String (우선 적용)
46
+ # Option 1: Connection String (takes priority)
45
47
  DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
46
48
 
47
- # 방법 2: 개별 설정
49
+ # Option 2: Individual settings
48
50
  DB_HOST=127.0.0.1
49
51
  DB_PORT=5432
50
52
  DB_NAME=mydb
51
53
  DB_USER=postgres
52
54
  DB_PASSWORD=postgres
53
55
 
54
- # ── 선택 ────────────────────────────────────────────────────────────────────
56
+ # ── Optional ─────────────────────────────────────────────────────────────────
55
57
 
56
- DB_SSL=false # SSL 활성화 여부
57
- DB_MAX=10 # 최대 연결
58
- DB_IDLE_TIMEOUT=30000 # idle 연결 해제 대기시간 (ms)
59
- DB_CONNECTION_TIMEOUT=2000 # 연결 타임아웃 (ms)
60
- DB_ALLOW_EXIT_ON_IDLE=false # idle 상태에서 프로세스 종료 허용
61
- DB_STATEMENT_TIMEOUT=0 # SQL 실행 타임아웃 (ms, 0=무제한)
62
- DB_QUERY_TIMEOUT=0 # 쿼리 타임아웃 (ms, 0=무제한)
63
- DB_APPLICATION_NAME=my-app # pg_stat_activity에 표시될 이름
64
- DB_KEEP_ALIVE=true # TCP keep-alive 활성화
65
- DB_KEEP_ALIVE_INITIAL_DELAY=10000 # keep-alive 최초 지연 (ms)
58
+ DB_SSL=false # Enable SSL
59
+ DB_MAX=10 # Max connection pool size
60
+ DB_IDLE_TIMEOUT=30000 # Idle connection timeout (ms)
61
+ DB_CONNECTION_TIMEOUT=2000 # Connection acquisition timeout (ms)
62
+ DB_ALLOW_EXIT_ON_IDLE=false # Allow process exit when idle
63
+ DB_STATEMENT_TIMEOUT=0 # SQL statement timeout (ms, 0 = unlimited)
64
+ DB_QUERY_TIMEOUT=0 # Query timeout (ms, 0 = unlimited)
65
+ DB_APPLICATION_NAME=my-app # App name shown in pg_stat_activity
66
+ DB_KEEP_ALIVE=true # Enable TCP keep-alive
67
+ DB_KEEP_ALIVE_INITIAL_DELAY=10000 # Initial keep-alive delay (ms)
66
68
 
67
- # ── 로깅 ────────────────────────────────────────────────────────────────────
69
+ # ── Logging ───────────────────────────────────────────────────────────────────
68
70
 
69
- LOGGER=true # 로거 활성화 (true / false)
70
- LOG_LEVEL=info # 로그 레벨 (debug / info / log / warn / error)
71
+ LOGGER=true # Enable logger (true / false)
72
+ LOG_LEVEL=info # Log level (debug / info / log / warn / error)
71
73
  ```
72
74
 
73
75
  ---
74
76
 
75
77
  ## Quick Start
76
78
 
77
- ### 1. 테이블 스키마 정의
79
+ ### 1. Define a Table Schema
78
80
 
79
81
  ```ts
80
82
  import { defineTable, col } from 'reltype';
81
83
 
82
84
  export const usersTable = defineTable('users', {
83
- id: col.serial().primaryKey(), // SERIAL PRIMARY KEY (INSERT optional)
85
+ id: col.serial().primaryKey(), // SERIAL PRIMARY KEY (optional on INSERT)
84
86
  firstName: col.varchar(255).notNull(), // VARCHAR(255) NOT NULL
85
- lastName: col.varchar(255).nullable(), // VARCHAR(255) NULL (INSERT optional)
87
+ lastName: col.varchar(255).nullable(), // VARCHAR(255) NULL (optional on INSERT)
86
88
  email: col.text().notNull(), // TEXT NOT NULL
87
- isActive: col.boolean().default(), // BOOLEAN DEFAULT ... (INSERT optional)
88
- createdAt: col.timestamptz().defaultNow(), // TIMESTAMPTZ DEFAULT NOW() (INSERT optional)
89
+ isActive: col.boolean().default(), // BOOLEAN DEFAULT ... (optional on INSERT)
90
+ createdAt: col.timestamptz().defaultNow(), // TIMESTAMPTZ DEFAULT NOW() (optional on INSERT)
89
91
  });
90
92
  ```
91
93
 
92
- ### 2. 타입 자동 추론
94
+ ### 2. Automatic Type Inference
93
95
 
94
96
  ```ts
95
97
  import { InferRow, InferInsert, InferUpdate } from 'reltype';
96
98
 
97
- // SELECT 결과 타입
99
+ // SELECT result type
98
100
  type User = InferRow<typeof usersTable>;
99
101
  // {
100
102
  // id: number;
@@ -105,28 +107,28 @@ type User = InferRow<typeof usersTable>;
105
107
  // createdAt: Date;
106
108
  // }
107
109
 
108
- // INSERT 입력 타입 (optional 컬럼 자동 제외)
110
+ // INSERT input type (optional columns automatically excluded)
109
111
  type CreateUser = InferInsert<typeof usersTable>;
110
112
  // { firstName: string; email: string; lastName?: string | null; isActive?: boolean; createdAt?: Date }
111
113
 
112
- // UPDATE 입력 타입 (PK 제외, 전체 optional)
114
+ // UPDATE input type (PK excluded, all fields optional)
113
115
  type UpdateUser = InferUpdate<typeof usersTable>;
114
116
  // { firstName?: string; lastName?: string | null; email?: string; isActive?: boolean; createdAt?: Date }
115
117
  ```
116
118
 
117
- ### 3. 진입점에서 dotenv 로드
119
+ ### 3. Load dotenv at Application Entry Point
118
120
 
119
- `reltype`은 `process.env`를 읽기만 합니다. `.env` 파일 로딩은 **애플리케이션 진입점**에서 직접 하세요.
121
+ `reltype` only reads `process.env`. Load your `.env` file **at the application entry point**.
120
122
 
121
123
  ```ts
122
- // 애플리케이션 진입점 (index.ts / server.ts / app.ts)
123
- import 'dotenv/config'; // 반드시 다른 import 전에 위치
124
+ // Application entry point (index.ts / server.ts / app.ts)
125
+ import 'dotenv/config'; // Must be placed before other imports
124
126
 
125
- // 이후 reltype import
127
+ // Then import reltype
126
128
  import { getDatabaseConfig, getPool } from 'reltype';
127
129
  ```
128
130
 
129
- ### 4. Repository 생성
131
+ ### 4. Create a Repository
130
132
 
131
133
  ```ts
132
134
  import { createRepo } from 'reltype';
@@ -139,34 +141,34 @@ export const userRepo = createRepo(usersTable);
139
141
 
140
142
  ## Repository API
141
143
 
142
- ### 전체 메서드 요약
144
+ ### Method Summary
143
145
 
144
- | 메서드 | 반환 타입 | 설명 |
146
+ | Method | Return Type | Description |
145
147
  |---|---|---|
146
- | `create(data)` | `Promise<T>` | 단건 INSERT |
147
- | `update(id, data)` | `Promise<T \| null>` | PK 기준 UPDATE |
148
- | `delete(id)` | `Promise<boolean>` | PK 기준 DELETE |
148
+ | `create(data)` | `Promise<T>` | INSERT a single row |
149
+ | `update(id, data)` | `Promise<T \| null>` | UPDATE by primary key |
150
+ | `delete(id)` | `Promise<boolean>` | DELETE by primary key |
149
151
  | `upsert(data, col?)` | `Promise<T>` | INSERT or UPDATE |
150
- | `bulkCreate(rows)` | `Promise<T[]>` | 다건 INSERT |
151
- | `select(where?)` | `QueryBuilder<T>` | 플루언트 빌더 시작점 |
152
- | `selectOne(where)` | `Promise<T \| null>` | 단건 조회 |
153
- | `raw(sql, params?)` | `Promise<R[]>` | Raw SQL 실행 |
154
- | `findAll(opts?)` | `Promise<T[]>` | 정적 전체 조회 |
155
- | `findById(id)` | `Promise<T \| null>` | PK 단건 조회 |
156
- | `findOne(where)` | `Promise<T \| null>` | 조건 단건 조회 |
157
- | `useHooks(h)` | `this` | 글로벌 등록 |
152
+ | `bulkCreate(rows)` | `Promise<T[]>` | INSERT multiple rows |
153
+ | `select(where?)` | `QueryBuilder<T>` | Start a fluent query builder |
154
+ | `selectOne(where)` | `Promise<T \| null>` | Fetch a single row |
155
+ | `raw(sql, params?)` | `Promise<R[]>` | Execute raw SQL |
156
+ | `findAll(opts?)` | `Promise<T[]>` | Static full query |
157
+ | `findById(id)` | `Promise<T \| null>` | Fetch single row by PK |
158
+ | `findOne(where)` | `Promise<T \| null>` | Fetch single row by condition |
159
+ | `useHooks(h)` | `this` | Register global hooks |
158
160
 
159
161
  ---
160
162
 
161
163
  ## create
162
164
 
163
- 단건 INSERT. 자동으로 생성되는 컬럼(serial, default, nullable)은 입력을 생략할 수 있습니다.
165
+ INSERT a single row. Columns with `serial`, `default`, or `nullable` modifiers are optional.
164
166
 
165
167
  ```ts
166
168
  const user = await userRepo.create({
167
169
  firstName: 'John',
168
170
  email: 'john@example.com',
169
- // lastName, isActive, createdAt → optional (DB default 또는 nullable)
171
+ // lastName, isActive, createdAt → optional (DB default or nullable)
170
172
  });
171
173
  // → User
172
174
  ```
@@ -175,10 +177,10 @@ const user = await userRepo.create({
175
177
 
176
178
  ## update
177
179
 
178
- PK를 기준으로 지정한 컬럼만 UPDATE합니다. 존재하지 않으면 `null`을 반환합니다.
180
+ UPDATE only the specified columns by primary key. Returns `null` if the row does not exist.
179
181
 
180
182
  ```ts
181
- // 부분 업데이트
183
+ // Partial update
182
184
  const updated = await userRepo.update(1, {
183
185
  firstName: 'Jane',
184
186
  isActive: false,
@@ -186,7 +188,7 @@ const updated = await userRepo.update(1, {
186
188
  // → User | null
187
189
 
188
190
  if (!updated) {
189
- throw new Error('사용자를 찾을 수 없습니다.');
191
+ throw new Error('User not found.');
190
192
  }
191
193
  ```
192
194
 
@@ -194,14 +196,14 @@ if (!updated) {
194
196
 
195
197
  ## delete
196
198
 
197
- PK를 기준으로 삭제합니다. 삭제된 row가 있으면 `true`, 없으면 `false`를 반환합니다.
199
+ DELETE by primary key. Returns `true` if a row was deleted, `false` if not found.
198
200
 
199
201
  ```ts
200
202
  const deleted = await userRepo.delete(1);
201
203
  // → boolean
202
204
 
203
205
  if (!deleted) {
204
- throw new Error('사용자를 찾을 수 없습니다.');
206
+ throw new Error('User not found.');
205
207
  }
206
208
  ```
207
209
 
@@ -209,17 +211,17 @@ if (!deleted) {
209
211
 
210
212
  ## upsert
211
213
 
212
- 충돌 컬럼 기준으로 INSERT 또는 UPDATE합니다.
214
+ INSERT or UPDATE based on a conflict column.
213
215
 
214
216
  ```ts
215
- // PK(id) 기준 (기본값)
217
+ // By PK (id) default
216
218
  const user = await userRepo.upsert({
217
219
  id: 1,
218
220
  firstName: 'John',
219
221
  email: 'john@example.com',
220
222
  });
221
223
 
222
- // 다른 unique 컬럼 기준 (snake_case)
224
+ // By another unique column (snake_case)
223
225
  const user = await userRepo.upsert(
224
226
  { firstName: 'John', email: 'john@example.com' },
225
227
  'email',
@@ -231,7 +233,7 @@ const user = await userRepo.upsert(
231
233
 
232
234
  ## bulkCreate
233
235
 
234
- 여러 row를 단일 `INSERT` 쿼리로 삽입합니다.
236
+ Insert multiple rows with a single `INSERT` query.
235
237
 
236
238
  ```ts
237
239
  const created = await userRepo.bulkCreate([
@@ -243,28 +245,28 @@ const created = await userRepo.bulkCreate([
243
245
 
244
246
  ---
245
247
 
246
- ## select — 플루언트 쿼리 빌더
248
+ ## select — Fluent Query Builder
247
249
 
248
- `repo.select(where?)`는 `QueryBuilder`를 반환합니다.
249
- 메서드를 체인으로 조합한 `await` 하거나 `.exec()` 실행합니다.
250
+ `repo.select(where?)` returns a `QueryBuilder`.
251
+ Chain methods and then `await` or call `.exec()` to execute.
250
252
 
251
- ### 기본 조회
253
+ ### Basic Query
252
254
 
253
255
  ```ts
254
- // 전체 조회 (await 직접 사용 가능)
256
+ // Fetch all (await directly thenable)
255
257
  const users = await userRepo.select();
256
258
 
257
- // 초기 WHERE 조건
259
+ // With initial WHERE condition
258
260
  const users = await userRepo.select({ isActive: true });
259
261
  ```
260
262
 
261
- ### WHERE — AND 조건
263
+ ### WHERE — AND Conditions
262
264
 
263
265
  ```ts
264
- // 단순 등호
266
+ // Simple equality
265
267
  const users = await userRepo.select().where({ isActive: true });
266
268
 
267
- // 비교 연산자
269
+ // Comparison operator
268
270
  const users = await userRepo.select()
269
271
  .where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } });
270
272
 
@@ -276,17 +278,17 @@ const users = await userRepo.select()
276
278
  const users = await userRepo.select()
277
279
  .where({ deletedAt: { operator: 'IS NULL' } });
278
280
 
279
- // LIKE / ILIKE (대소문자 무시)
281
+ // LIKE / ILIKE (case-insensitive)
280
282
  const users = await userRepo.select()
281
283
  .where({ email: { operator: 'ILIKE', value: '%@gmail.com' } });
282
284
  ```
283
285
 
284
- 지원하는 연산자: `=` `!=` `>` `<` `>=` `<=` `LIKE` `ILIKE` `IN` `NOT IN` `IS NULL` `IS NOT NULL`
286
+ Supported operators: `=` `!=` `>` `<` `>=` `<=` `LIKE` `ILIKE` `IN` `NOT IN` `IS NULL` `IS NOT NULL`
285
287
 
286
- ### OR — OR 조건
288
+ ### OR — OR Conditions
287
289
 
288
- `.or()`를 여러 호출하면 각각 OR 연결됩니다.
289
- AND 조건이 있을 경우 `WHERE (AND 조건들) OR (OR 조건들)` 형태로 생성됩니다.
290
+ Each `.or()` call adds an OR clause.
291
+ When AND conditions are present, the result is `WHERE (AND conditions) OR (OR conditions)`.
290
292
 
291
293
  ```ts
292
294
  // firstName ILIKE '%john%' OR email ILIKE '%john%'
@@ -302,7 +304,7 @@ const users = await userRepo.select({ isActive: true })
302
304
  const users = await userRepo.select()
303
305
  .orderBy([{ column: 'createdAt', direction: 'DESC' }]);
304
306
 
305
- // 다중 정렬
307
+ // Multiple sort columns
306
308
  const users = await userRepo.select()
307
309
  .orderBy([
308
310
  { column: 'isActive', direction: 'DESC' },
@@ -317,7 +319,7 @@ const users = await userRepo.select()
317
319
  .orderBy([{ column: 'id', direction: 'ASC' }])
318
320
  .limit(20)
319
321
  .offset(40);
320
- // 3페이지 (0-indexed offset)
322
+ // Page 3 (0-indexed offset)
321
323
  ```
322
324
 
323
325
  ### GROUP BY
@@ -343,9 +345,9 @@ const result = await userRepo.select({ isActive: true })
343
345
  .exec();
344
346
  ```
345
347
 
346
- JOIN 타입: `INNER` `LEFT` `RIGHT` `FULL`
348
+ JOIN types: `INNER` `LEFT` `RIGHT` `FULL`
347
349
 
348
- ### 컬럼 지정 (columns)
350
+ ### Column Selection (columns)
349
351
 
350
352
  ```ts
351
353
  const users = await userRepo.select()
@@ -357,7 +359,7 @@ const users = await userRepo.select()
357
359
 
358
360
  ## selectOne
359
361
 
360
- `select(where).one()` 단축형입니다. 조건에 맞는 첫 번째 row를 반환합니다.
362
+ Shorthand for `select(where).one()`. Returns the first matching row.
361
363
 
362
364
  ```ts
363
365
  const user = await userRepo.selectOne({ email: 'john@example.com' });
@@ -369,16 +371,16 @@ if (!user) throw new Error('not found');
369
371
 
370
372
  ---
371
373
 
372
- ## calculate — 집계 함수
374
+ ## calculate — Aggregate Functions
373
375
 
374
- `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`를 실행합니다.
376
+ Runs `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` aggregations.
375
377
 
376
378
  ```ts
377
- // 전체 카운트
379
+ // Total count
378
380
  const result = await userRepo.select().calculate([{ fn: 'COUNT', alias: 'count' }]);
379
381
  const total = parseInt(String(result.count), 10);
380
382
 
381
- // 다중 집계
383
+ // Multiple aggregations
382
384
  const stats = await userRepo.select({ isActive: true })
383
385
  .calculate([
384
386
  { fn: 'COUNT', alias: 'count' },
@@ -390,89 +392,89 @@ const stats = await userRepo.select({ isActive: true })
390
392
 
391
393
  ---
392
394
 
393
- ## paginate — OFFSET 페이지네이션
395
+ ## paginate — OFFSET Pagination
394
396
 
395
- COUNT 쿼리와 DATA 쿼리를 병렬로 실행합니다.
397
+ Runs COUNT and DATA queries in parallel.
396
398
 
397
399
  ```ts
398
400
  const result = await userRepo.select({ isActive: true })
399
401
  .orderBy([{ column: 'createdAt', direction: 'DESC' }])
400
402
  .paginate({ page: 1, pageSize: 20 });
401
403
 
402
- // result 구조
404
+ // result shape
403
405
  // {
404
- // data: User[], // 현재 페이지 데이터
405
- // count: 150, // 전체 row 수 (필터 적용)
406
+ // data: User[], // Current page data
407
+ // count: 150, // Total matching rows
406
408
  // page: 1,
407
409
  // pageSize: 20,
408
- // nextAction: true, // 다음 페이지 존재 여부
409
- // previousAction: false, // 이전 페이지 존재 여부
410
+ // nextAction: true, // Next page exists
411
+ // previousAction: false, // Previous page exists
410
412
  // }
411
413
  ```
412
414
 
413
- > 수백만 이상의 테이블에서는 `cursorPaginate()` 를 사용하세요.
415
+ > For tables with millions of rows, use `cursorPaginate()` instead.
414
416
 
415
417
  ---
416
418
 
417
- ## cursorPaginate — 커서 기반 페이지네이션 (대용량)
419
+ ## cursorPaginate — Cursor-based Pagination (Large Data)
418
420
 
419
- OFFSET 스캔 없이 `WHERE id > last_id` 방식으로 동작합니다.
420
- 인덱스가 있는 컬럼을 `cursorColumn`으로 지정하면 수천만 건에서도 일정한 속도를 유지합니다.
421
+ Uses `WHERE id > last_id` instead of OFFSET scanning.
422
+ Assigning an indexed column as `cursorColumn` ensures consistent speed even with tens of millions of rows.
421
423
 
422
424
  ```ts
423
- // 페이지
425
+ // First page
424
426
  const p1 = await userRepo.select({ isActive: true })
425
427
  .cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
426
428
 
427
429
  // p1 = { data: [...], nextCursor: 'xxx', pageSize: 20, hasNext: true }
428
430
 
429
- // 다음 페이지
431
+ // Next page
430
432
  if (p1.hasNext) {
431
433
  const p2 = await userRepo.select({ isActive: true })
432
434
  .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
433
435
  }
434
436
 
435
- // 내림차순 커서 (createdAt DESC)
437
+ // Descending cursor (createdAt DESC)
436
438
  const result = await userRepo.select()
437
439
  .cursorPaginate({ pageSize: 20, cursorColumn: 'createdAt', direction: 'desc' });
438
440
  ```
439
441
 
440
442
  | `paginate` | `cursorPaginate` |
441
443
  |---|---|
442
- | 전체 count 제공 | count 미제공 |
443
- | page 번호로 이동 | 이전/다음만 가능 |
444
- | 대용량에서 느려짐 | 항상 일정한 속도 |
444
+ | Provides total count | No total count |
445
+ | Navigate by page number | Next / previous only |
446
+ | Slows down on large tables | Consistent speed always |
445
447
 
446
448
  ---
447
449
 
448
- ## forEach — 배치 처리
450
+ ## forEach — Batch Processing
449
451
 
450
- 전체 데이터를 메모리에 올리지 않고 청크 단위로 처리합니다.
451
- 대용량 ETL, 이메일 일괄 발송, 데이터 마이그레이션에 적합합니다.
452
+ Processes data in chunks without loading everything into memory.
453
+ Ideal for large-scale ETL, bulk email sending, and data migration.
452
454
 
453
455
  ```ts
454
456
  await userRepo.select({ isActive: true })
455
457
  .orderBy([{ column: 'id', direction: 'ASC' }])
456
458
  .forEach(async (batch) => {
457
- // batch: User[] (기본 500개씩)
459
+ // batch: User[] (default 500 rows per chunk)
458
460
  await sendEmailBatch(batch);
459
461
  }, { batchSize: 200 });
460
462
  ```
461
463
 
462
464
  ---
463
465
 
464
- ## stream — 스트리밍 (AsyncGenerator)
466
+ ## stream — Streaming (AsyncGenerator)
465
467
 
466
- `for await...of` 루프로 row를 하나씩 순회합니다.
467
- 내부적으로 배치 단위로 DB를 조회하여 메모리 효율을 유지합니다.
468
+ Iterates rows one by one with `for await...of`.
469
+ Internally fetches in batches to keep memory usage low.
468
470
 
469
471
  ```ts
470
- // for await...of 직접 사용 (Symbol.asyncIterator 지원)
472
+ // Direct for await...of (Symbol.asyncIterator supported)
471
473
  for await (const user of userRepo.select({ isActive: true })) {
472
474
  await processRow(user);
473
475
  }
474
476
 
475
- // 배치 크기 지정
477
+ // With custom batch size
476
478
  for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
477
479
  await processRow(user);
478
480
  }
@@ -480,19 +482,19 @@ for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
480
482
 
481
483
  ---
482
484
 
483
- ## raw — Raw SQL 직접 실행
485
+ ## raw — Raw SQL Execution
484
486
 
485
- 복잡한 쿼리가 필요할 SQL을 직접 작성합니다.
486
- 결과 컬럼명은 `snake_case camelCase`로 자동 변환됩니다.
487
+ Write SQL directly when complex queries are needed.
488
+ Result column names are automatically converted from `snake_case` to `camelCase`.
487
489
 
488
490
  ```ts
489
- // repo.raw() 사용
491
+ // repo.raw()
490
492
  const users = await userRepo.raw<UserRow>(
491
493
  'SELECT * FROM users WHERE first_name ILIKE $1 ORDER BY created_at DESC',
492
494
  ['%john%'],
493
495
  );
494
496
 
495
- // QueryBuilder.raw() — 레포지토리 없이 독립적으로 사용
497
+ // QueryBuilder.raw() — standalone, no repository needed
496
498
  import { QueryBuilder } from 'reltype';
497
499
 
498
500
  const rows = await QueryBuilder.raw(
@@ -507,16 +509,16 @@ const rows = await QueryBuilder.raw(
507
509
 
508
510
  ---
509
511
 
510
- ## explain — 쿼리 플랜 분석
512
+ ## explain — Query Plan Analysis
511
513
 
512
- 인덱스 사용 여부 성능 병목을 확인합니다.
514
+ Inspect index usage and identify performance bottlenecks.
513
515
 
514
516
  ```ts
515
517
  // EXPLAIN
516
518
  const plan = await userRepo.select({ isActive: true }).explain();
517
519
  console.log(plan);
518
520
 
519
- // EXPLAIN ANALYZE (실제 실행 통계 포함)
521
+ // EXPLAIN ANALYZE (includes actual execution statistics)
520
522
  const plan = await userRepo.select({ isActive: true })
521
523
  .orderBy([{ column: 'createdAt', direction: 'DESC' }])
522
524
  .explain(true);
@@ -524,9 +526,9 @@ const plan = await userRepo.select({ isActive: true })
524
526
 
525
527
  ---
526
528
 
527
- ## toSQL — SQL 미리 확인 (디버깅)
529
+ ## toSQL — Preview SQL (Debugging)
528
530
 
529
- 실제 실행 없이 생성될 SQL params 반환합니다.
531
+ Returns the generated SQL and params without executing the query.
530
532
 
531
533
  ```ts
532
534
  const { sql, params } = userRepo.select({ isActive: true })
@@ -542,46 +544,46 @@ console.log(params);
542
544
 
543
545
  ---
544
546
 
545
- ## hooks — 쿼리 라이프사이클
547
+ ## hooks — Query Lifecycle Hooks
546
548
 
547
- ### 쿼리별
549
+ ### Per-query Hooks
548
550
 
549
551
  ```ts
550
552
  const users = await userRepo.select({ isActive: true })
551
553
  .hooks({
552
- beforeExec: ({ sql, params }) => logger.debug('SQL 실행 예정:', sql),
554
+ beforeExec: ({ sql, params }) => logger.debug('About to run SQL:', sql),
553
555
  afterExec: ({ rows, elapsed }) => metrics.record('db.query.duration', elapsed),
554
556
  onError: ({ err, sql }) => alerting.send({ err, sql }),
555
557
  })
556
558
  .paginate({ page: 1, pageSize: 20 });
557
559
  ```
558
560
 
559
- ### 레포지토리 전역
561
+ ### Repository-level Global Hooks
560
562
 
561
- 레포지토리의 모든 `select()` 빌더에 자동 적용됩니다.
563
+ Automatically applied to all `select()` builders on this repository.
562
564
 
563
565
  ```ts
564
566
  userRepo.useHooks({
565
567
  beforeExec: ({ sql }) => logger.debug('SQL:', sql),
566
568
  afterExec: ({ elapsed }) => metrics.histogram('db.latency', elapsed),
567
- onError: ({ err }) => logger.error('DB 오류', { kind: err.message }),
569
+ onError: ({ err }) => logger.error('DB error', { message: err.message }),
568
570
  });
569
571
 
570
- // 이후 모든 select() 훅이 적용됨
572
+ // All subsequent select() calls will use the hooks
571
573
  const users = await userRepo.select({ isActive: true }).exec();
572
574
  ```
573
575
 
574
576
  ---
575
577
 
576
- ## 정적 CRUD (findAll / findById / findOne)
578
+ ## Static CRUD (findAll / findById / findOne)
577
579
 
578
- 단순한 조회에는 정적 메서드를 사용할 수 있습니다.
580
+ Use static methods for simple queries.
579
581
 
580
582
  ```ts
581
- // 전체 조회
583
+ // Fetch all
582
584
  const users = await userRepo.findAll();
583
585
 
584
- // 조건 + 정렬 + 페이지네이션
586
+ // With filter, sort, and pagination
585
587
  const users = await userRepo.findAll({
586
588
  where: { isActive: true },
587
589
  orderBy: [{ col: 'createdAt', dir: 'DESC' }],
@@ -589,20 +591,20 @@ const users = await userRepo.findAll({
589
591
  offset: 0,
590
592
  });
591
593
 
592
- // PK로 단건 조회
594
+ // Single row by PK
593
595
  const user = await userRepo.findById(1); // User | null
594
596
 
595
- // 조건으로 단건 조회 (단순 등호만 지원)
597
+ // Single row by condition (equality only)
596
598
  const user = await userRepo.findOne({ email: 'john@example.com' }); // User | null
597
599
  ```
598
600
 
599
- > 연산자(LIKE, IN, OR 등)가 필요한 경우 `repo.select()` 를 사용하세요.
601
+ > For operators like `LIKE`, `IN`, or `OR`, use `repo.select()` instead.
600
602
 
601
603
  ---
602
604
 
603
605
  ## Column Types
604
606
 
605
- | 메서드 | PostgreSQL 타입 | TypeScript 타입 |
607
+ | Method | PostgreSQL Type | TypeScript Type |
606
608
  |---|---|---|
607
609
  | `col.serial()` | `SERIAL` | `number` |
608
610
  | `col.integer()` | `INTEGER` | `number` |
@@ -615,16 +617,16 @@ const user = await userRepo.findOne({ email: 'john@example.com' }); // User | nu
615
617
  | `col.timestamptz()` | `TIMESTAMPTZ` | `Date` |
616
618
  | `col.date()` | `DATE` | `Date` |
617
619
  | `col.uuid()` | `UUID` | `string` |
618
- | `col.jsonb<T>()` | `JSONB` | `T` (기본 `unknown`) |
620
+ | `col.jsonb<T>()` | `JSONB` | `T` (default `unknown`) |
619
621
 
620
622
  ### Column Modifiers
621
623
 
622
624
  ```ts
623
- col.text().notNull() // NOT NULL (기본 상태)
624
- col.text().nullable() // NULL 허용, INSERT 시 optional
625
- col.integer().primaryKey() // PRIMARY KEY, INSERT optional
626
- col.boolean().default() // DB DEFAULT, INSERT optional
627
- col.timestamptz().defaultNow() // DEFAULT NOW(), INSERT optional
625
+ col.text().notNull() // NOT NULL (default state)
626
+ col.text().nullable() // Allow NULL, optional on INSERT
627
+ col.integer().primaryKey() // PRIMARY KEY, optional on INSERT
628
+ col.boolean().default() // DB DEFAULT, optional on INSERT
629
+ col.timestamptz().defaultNow() // DEFAULT NOW(), optional on INSERT
628
630
  ```
629
631
 
630
632
  ---
@@ -639,7 +641,7 @@ const result = await runInTx(async (client) => {
639
641
  await userRepo.create({ firstName: 'Bob', email: 'bob@example.com' });
640
642
  return 'done';
641
643
  });
642
- // 하나라도 실패 자동 ROLLBACK
644
+ // Automatically rolls back if any operation fails
643
645
  ```
644
646
 
645
647
  ---
@@ -649,16 +651,16 @@ const result = await runInTx(async (client) => {
649
651
  ```ts
650
652
  import { getPool, withClient, closePool } from 'reltype';
651
653
 
652
- // Pool 직접 접근
654
+ // Direct pool access
653
655
  const pool = getPool();
654
656
 
655
- // Client 빌려서 Raw 쿼리 실행
657
+ // Borrow a client and run a raw query
656
658
  const rows = await withClient(async (client) => {
657
659
  const result = await client.query('SELECT NOW()');
658
660
  return result.rows;
659
661
  });
660
662
 
661
- // 애플리케이션 종료
663
+ // On application shutdown
662
664
  await closePool();
663
665
  ```
664
666
 
@@ -666,7 +668,7 @@ await closePool();
666
668
 
667
669
  ## Raw Query Builders
668
670
 
669
- Repository 없이 직접 쿼리를 빌드할 수 있습니다.
671
+ Build queries directly without a repository.
670
672
 
671
673
  ```ts
672
674
  import { buildSelect, buildInsert, buildUpdate, buildDelete, buildUpsert, buildBulkInsert, withClient } from 'reltype';
@@ -696,14 +698,14 @@ const built = buildBulkInsert('users', [
696
698
  { firstName: 'Bob', email: 'bob@example.com' },
697
699
  ]);
698
700
 
699
- // 실행
701
+ // Execute
700
702
  await withClient(async (client) => {
701
703
  const result = await client.query(sql, params);
702
704
  return result.rows;
703
705
  });
704
706
  ```
705
707
 
706
- > 모든 쿼리 빌더는 camelCase key snake_case 컬럼명으로 자동 변환합니다.
708
+ > All query builders automatically convert camelCase keys to snake_case column names.
707
709
 
708
710
  ---
709
711
 
@@ -740,13 +742,13 @@ logger.warn('warn message');
740
742
  logger.error('error message', new Error('oops'));
741
743
  ```
742
744
 
743
- 환경 변수 `LOGGER=true`, `LOG_LEVEL=debug` 으로 활성화합니다.
745
+ Enable with environment variables: `LOGGER=true`, `LOG_LEVEL=debug`.
744
746
 
745
747
  ---
746
748
 
747
749
  ## Extending BaseRepo
748
750
 
749
- 커스텀 메서드를 추가하려면 `BaseRepo`를 상속합니다.
751
+ Extend `BaseRepo` to add custom methods.
750
752
 
751
753
  ```ts
752
754
  import { BaseRepo, InferRow } from 'reltype';
@@ -769,10 +771,10 @@ export const userRepo = new UserRepo(usersTable);
769
771
 
770
772
  ## Error Handling
771
773
 
772
- ### DbError — PostgreSQL 에러 분류
774
+ ### DbError — PostgreSQL Error Classification
773
775
 
774
- 모든 DB 에러는 자동으로 `DbError`로 변환됩니다.
775
- `DbError`는 내부 로그용 상세 정보와 사용자 노출용 메시지를 분리합니다.
776
+ All DB errors are automatically converted to `DbError`.
777
+ `DbError` separates internal log details from user-facing messages.
776
778
 
777
779
  ```ts
778
780
  import { DbError } from 'reltype';
@@ -781,23 +783,23 @@ try {
781
783
  await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
782
784
  } catch (err) {
783
785
  if (err instanceof DbError) {
784
- // 사용자에게 안전하게 노출
786
+ // Safe to expose to users
785
787
  console.log(err.toUserPayload());
786
- // { error: '이미 존재하는 값입니다.', kind: 'uniqueViolation', isRetryable: false }
788
+ // { error: 'A duplicate value already exists.', kind: 'uniqueViolation', isRetryable: false }
787
789
 
788
- // 내부 로깅용 상세 정보
790
+ // Internal logging details
789
791
  console.log(err.toLogContext());
790
792
  // { pgCode: '23505', kind: 'uniqueViolation', table: 'users', constraint: '...', ... }
791
793
 
792
- // 재시도 가능 여부 확인
794
+ // Check if retryable
793
795
  if (err.isRetryable) {
794
- // 재시도 로직
796
+ // retry logic
795
797
  }
796
798
  }
797
799
  }
798
800
  ```
799
801
 
800
- ### Express에서 사용하는 예시
802
+ ### Example with Express
801
803
 
802
804
  ```ts
803
805
  app.post('/users', async (req, res) => {
@@ -812,29 +814,29 @@ app.post('/users', async (req, res) => {
812
814
  : 500;
813
815
  res.status(status).json(err.toUserPayload());
814
816
  } else {
815
- res.status(500).json({ error: ' 없는 오류가 발생했습니다.' });
817
+ res.status(500).json({ error: 'An unexpected error occurred.' });
816
818
  }
817
819
  }
818
820
  });
819
821
  ```
820
822
 
821
- ### DbErrorKind 전체 목록
823
+ ### DbErrorKind Reference
822
824
 
823
- | kind | PostgreSQL SQLSTATE | 설명 | isRetryable |
825
+ | kind | PostgreSQL SQLSTATE | Description | isRetryable |
824
826
  |---|---|---|---|
825
- | `uniqueViolation` | 23505 | UNIQUE 제약 위반 | false |
826
- | `foreignKeyViolation` | 23503 | FK 제약 위반 | false |
827
- | `notNullViolation` | 23502 | NOT NULL 위반 | false |
828
- | `checkViolation` | 23514 | CHECK 제약 위반 | false |
829
- | `deadlock` | 40P01 | 교착 상태 | **true** |
830
- | `serializationFailure` | 40001 | 직렬화 실패 | **true** |
831
- | `connectionFailed` | 08xxx | 연결 실패 | **true** |
832
- | `tooManyConnections` | 53300 | 연결 초과 | **true** |
833
- | `queryTimeout` | 57014 | 쿼리 타임아웃 | false |
834
- | `undefinedTable` | 42P01 | 테이블 없음 | false |
835
- | `undefinedColumn` | 42703 | 컬럼 없음 | false |
836
- | `invalidInput` | 22xxx | 잘못된 입력 형식 | false |
837
- | `unknown` | 기타 | 분류 불가 | false |
827
+ | `uniqueViolation` | 23505 | UNIQUE constraint violation | false |
828
+ | `foreignKeyViolation` | 23503 | Foreign key constraint violation | false |
829
+ | `notNullViolation` | 23502 | NOT NULL constraint violation | false |
830
+ | `checkViolation` | 23514 | CHECK constraint violation | false |
831
+ | `deadlock` | 40P01 | Deadlock detected | **true** |
832
+ | `serializationFailure` | 40001 | Serialization failure | **true** |
833
+ | `connectionFailed` | 08xxx | Connection failure | **true** |
834
+ | `tooManyConnections` | 53300 | Too many connections | **true** |
835
+ | `queryTimeout` | 57014 | Query timeout | false |
836
+ | `undefinedTable` | 42P01 | Table does not exist | false |
837
+ | `undefinedColumn` | 42703 | Column does not exist | false |
838
+ | `invalidInput` | 22xxx | Invalid input format | false |
839
+ | `unknown` | other | Unclassified error | false |
838
840
 
839
841
  ---
840
842
 
@@ -843,75 +845,75 @@ app.post('/users', async (req, res) => {
843
845
  ```ts
844
846
  import { getPoolStatus, checkPoolHealth } from 'reltype';
845
847
 
846
- // 현재 Pool 상태 조회
848
+ // Get current pool status
847
849
  const status = getPoolStatus();
848
850
  console.log(status);
849
851
  // {
850
- // totalCount: 5, // 생성된 연결 수
851
- // idleCount: 3, // 유휴 연결 수
852
- // waitingCount: 0, // 연결 대기 중인 요청
853
- // isHealthy: true // 정상 여부
852
+ // totalCount: 5, // Total connections created
853
+ // idleCount: 3, // Idle connections
854
+ // waitingCount: 0, // Requests waiting for a connection
855
+ // isHealthy: true // Pool is healthy
854
856
  // }
855
857
 
856
- // DB 서버와의 연결 헬스체크 (SELECT 1)
858
+ // Health check against the DB server (SELECT 1)
857
859
  const isAlive = await checkPoolHealth();
858
860
  ```
859
861
 
860
- ### Too Many Connections 방지
862
+ ### Preventing Too Many Connections
861
863
 
862
- `.env`에서 Pool 크기와 타임아웃을 반드시 설정하세요.
864
+ Always configure pool size and timeouts in your `.env`.
863
865
 
864
866
  ```env
865
- DB_MAX=10 # 최대 연결 크기 (기본값: 10)
866
- DB_CONNECTION_TIMEOUT=3000 # 연결 획득 타임아웃 ms (미설정 무한 대기 경고)
867
- DB_IDLE_TIMEOUT=30000 # 유휴 연결 해제 시간 ms
868
- DB_STATEMENT_TIMEOUT=10000 # SQL 최대 실행 시간 ms
867
+ DB_MAX=10 # Max pool size (default: 10)
868
+ DB_CONNECTION_TIMEOUT=3000 # Connection acquisition timeout in ms (infinite wait if not set — warning)
869
+ DB_IDLE_TIMEOUT=30000 # Idle connection release time in ms
870
+ DB_STATEMENT_TIMEOUT=10000 # Max SQL statement execution time in ms
869
871
  ```
870
872
 
871
- > `DB_CONNECTION_TIMEOUT`이 설정되지 않으면 Pool 소진 요청이 무한 대기합니다.
872
- > 반드시 설정하세요.
873
+ > If `DB_CONNECTION_TIMEOUT` is not set, requests will wait indefinitely when the pool is exhausted.
874
+ > Always configure this value.
873
875
 
874
876
  ---
875
877
 
876
878
  ## Log System
877
879
 
878
- ### 포맷 설정
880
+ ### Format Configuration
879
881
 
880
882
  ```env
881
- LOGGER=true # 로거 활성화
883
+ LOGGER=true # Enable logger
882
884
  LOG_LEVEL=debug # debug / info / log / warn / error
883
- LOG_FORMAT=json # text(기본) / json(프로덕션 권장)
885
+ LOG_FORMAT=json # text (default) / json (recommended for production)
884
886
  ```
885
887
 
886
- ### text 포맷 (개발 환경)
888
+ ### text format (development)
887
889
 
888
890
  ```
889
- 2024-01-01T00:00:00.000Z [Pool] INFO Pool 생성 완료 { max: 10, ... }
891
+ 2024-01-01T00:00:00.000Z [Pool] INFO Pool created { max: 10, ... }
890
892
  2024-01-01T00:00:00.000Z [Repo] DEBUG SQL: SELECT * FROM users WHERE id = $1 [ 1 ]
891
- 2024-01-01T00:00:00.000Z [Repo] DEBUG 완료 (12ms) rowCount=1
893
+ 2024-01-01T00:00:00.000Z [Repo] DEBUG Done (12ms) rowCount=1
892
894
  ```
893
895
 
894
- ### json 포맷 (프로덕션 / 로그 수집기)
896
+ ### json format (production / log aggregators)
895
897
 
896
898
  ```json
897
- {"ts":"2024-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"Pool 생성 완료","meta":[{"max":10}]}
898
- {"ts":"2024-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"쿼리 실패 [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
899
+ {"ts":"2024-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"Pool created","meta":[{"max":10}]}
900
+ {"ts":"2024-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"Query failed [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
899
901
  ```
900
902
 
901
- ### 로그 이벤트 목록
903
+ ### Log Event Reference
902
904
 
903
- | 레벨 | prefix | 이벤트 |
905
+ | Level | Prefix | Event |
904
906
  |---|---|---|
905
- | INFO | [Pool] | Pool 생성 완료 / Pool 종료 |
906
- | WARN | [Pool] | connectionTimeoutMillis 미설정 경고 |
907
- | WARN | [Pool] | 최대 연결 도달 |
908
- | DEBUG | [Pool] | 연결 생성 / 연결 제거 |
909
- | ERROR | [Pool] | 유휴 클라이언트 오류 / 클라이언트 획득 실패 |
910
- | DEBUG | [Repo] | SQL 실행 + 소요시간 |
911
- | ERROR | [Repo] | 쿼리 실패 (pgCode, kind, 소요시간 포함) |
912
- | DEBUG | [Tx] | 트랜잭션 시작 / 커밋 |
913
- | WARN | [Tx] | 트랜잭션 롤백 |
914
- | ERROR | [Tx] | 롤백 실패 |
907
+ | INFO | [Pool] | Pool created / Pool closed |
908
+ | WARN | [Pool] | connectionTimeoutMillis not configured |
909
+ | WARN | [Pool] | Max connections reached |
910
+ | DEBUG | [Pool] | New connection / Connection removed |
911
+ | ERROR | [Pool] | Idle client error / Client acquisition failed |
912
+ | DEBUG | [Repo] | SQL executed + elapsed time |
913
+ | ERROR | [Repo] | Query failed (pgCode, kind, elapsed included) |
914
+ | DEBUG | [Tx] | Transaction started / committed |
915
+ | WARN | [Tx] | Transaction rolled back |
916
+ | ERROR | [Tx] | Rollback failed |
915
917
 
916
918
  ---
917
919
 
@@ -919,16 +921,16 @@ LOG_FORMAT=json # text(기본) / json(프로덕션 권장)
919
921
 
920
922
  ```
921
923
  src/
922
- ├── index.ts ← 공개 API 진입점
923
- ├── configs/env.ts ← DB 설정 파싱
924
+ ├── index.ts ← Public API entry point
925
+ ├── configs/env.ts ← DB config parsing
924
926
  ├── utils/
925
- │ ├── logger.ts ← Logger 클래스
926
- │ └── reader.ts ← env 파서, PostgresConfig
927
+ │ ├── logger.ts ← Logger class
928
+ │ └── reader.ts ← Env parser, PostgresConfig
927
929
  └── features/
928
930
  ├── schema/ ← defineTable, col, InferRow/Insert/Update
929
- ├── transform/ ← camelCase ↔ snake_case 변환
930
- ├── connection/ ← Pool 관리, Transaction
931
- ├── query/ ← SQL 쿼리 빌더 (select/insert/update/delete/upsert/bulkInsert)
931
+ ├── transform/ ← camelCase ↔ snake_case conversion
932
+ ├── connection/ ← Pool management, Transaction
933
+ ├── query/ ← SQL query builders (select/insert/update/delete/upsert/bulkInsert)
932
934
  └── repository/ ← BaseRepo, createRepo, IRepo
933
935
  ```
934
936
 
@@ -936,14 +938,14 @@ src/
936
938
 
937
939
  ## Contributing
938
940
 
939
- 버그 리포트, 기능 제안, PR 모두 환영합니다.
941
+ Bug reports, feature suggestions, and pull requests are all welcome.
940
942
  → [Issues](https://github.com/psh-suhyun/reltype/issues) · [Pull Requests](https://github.com/psh-suhyun/reltype/pulls)
941
943
 
942
944
  ---
943
945
 
944
946
  ## Changelog
945
947
 
946
- 전체 변경 이력은 [CHANGELOG.md](./CHANGELOG.md) 참고하세요.
948
+ See [CHANGELOG.md](./CHANGELOG.md) for the full version history.
947
949
 
948
950
  ---
949
951