Contributor Onboarding Guide
이 문서의 목적: Stingerloom ORM의 내부 아키텍처를 이해하고, 코드를 수정하거나 새 기능을 추가할 수 있도록 돕는 문서예요. API 사용법이 필요하면 Getting Started를 먼저 읽어주세요.
대상 독자: 프로젝트에 새로 합류하는 개발자 또는 외부 컨트리뷰터
1. 로컬 개발 환경 설정
필수 도구
| Tool | Minimum Version | Notes |
|---|---|---|
| Node.js | >=16 | package.json의 engines 필드 참고 |
| pnpm | >=8 | 프로젝트 패키지 매니저 |
| TypeScript | >=5.6 | devDependencies에 포함돼 있어요 |
| Docker | Latest stable | 통합 테스트용 MySQL/PostgreSQL |
Clone & Build
git clone https://github.com/biud436/stingerloom-orm.git
cd stingerloom-orm
pnpm install
pnpm build # Generate dist/ — required before running examplespnpm build는 rimraf dist && tsc를 실행해요. ORM이 아직 npm에 배포되지 않았기 때문에, examples/ 프로젝트는 로컬 빌드 결과물(dist/)을 참조해요.
Docker로 데이터베이스 설정
통합 테스트에는 MySQL과 PostgreSQL이 필요해요.
# MySQL
docker run -d --name stingerloom-mysql \
-e MYSQL_ROOT_PASSWORD=test \
-e MYSQL_DATABASE=test \
-p 3306:3306 \
mysql:8
# PostgreSQL
docker run -d --name stingerloom-postgres \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=test \
-p 5432:5432 \
postgres:16테스트 실행
# Unit tests (no DB connection required)
pnpm test
# Run a specific test file only
pnpm test -- --testPathPattern="schema-diff"
# Integration tests (MySQL/PostgreSQL must be running)
INTEGRATION_TEST=true pnpm test -- --testPathPattern="integration"유닛 테스트는 72개 이상의 파일에 1400개 이상의 케이스로 구성돼 있어요. 통합 테스트는 INTEGRATION_TEST=true 환경변수가 없으면 자동으로 건너뛰어요.
예제 실행
pnpm build # Must build first
cd examples/nestjs-cats && pnpm install && pnpm start
# or
cd examples/nestjs-blog && pnpm install && pnpm start2. 아키텍처 개요
디렉토리 구조
src/
├── core/ <- Core logic (EntityManager, Repository, QueryBuilder, etc.)
├── decorators/ <- Decorator definitions (@Entity, @Column, etc.)
├── dialects/ <- Per-DB driver implementations (MySQL, PostgreSQL, SQLite)
├── metadata/ <- Layered metadata system (multi-tenancy)
├── scanner/ <- Decorator metadata collectors (EntityScanner, ColumnScanner, etc.)
├── migration/ <- Migration system
├── types/ <- Common type definitions
├── utils/ <- Helpers (Logger, ReflectManager)
└── errors/ <- Error class hierarchy왜 이렇게 나눠져 있나요?
core/와dialects/를 분리해서 DB 독립적인 로직과 DB 종속 코드를 격리해요.scanner/와decorators/를 분리해서 "메타데이터 수집"과 "메타데이터 정의"의 관심사를 구분해요.metadata/는 독립 모듈이라 멀티테넌시 레이어 시스템이 다른 코드에 영향을 주지 않아요.
데이터 흐름
엔티티 클래스 정의부터 DB 저장까지의 전체 흐름이에요.
┌─────────────────────────────────────────────────────────────────────┐
│ Decorator Registration (at app load) │
│ │
│ @Column() -> ColumnScanner.set() ─┐ │
│ @ManyToOne() -> ManyToOneScanner.set() ├-> MetadataLayerRegistry │
│ @Entity() -> EntityScanner.set() ─┘ (written to public │
│ layer) │
└─────────────────────────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────────┐
│ EntityManager.register() call │
│ │
│ DatabaseClient.connect() -> Connector created -> Driver created│
│ (singleton) (MySQL/PG/...) (ISqlDriver │
│ impl) │
│ │
│ registerEntities() │
│ Pass 1: createTable() — generate table DDL │
│ Pass 2: registerForeignKeys/Index — add FK/indexes │
│ Pass 3: ManyToMany join table creation │
└─────────────────────────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────────┐
│ CRUD Execution (runtime) │
│ │
│ em.save(User, data) │
│ -> Query metadata (MetadataLayerRegistry) │
│ -> Execute BeforeInsert hook │
│ -> TransactionSessionManager.connect() │
│ -> START TRANSACTION / BEGIN │
│ -> Build SQL (sql-template-tag parameter binding) │
│ -> Execute via Driver │
│ -> Deserialize (Deserializer) │
│ -> COMMIT (or ROLLBACK on error) │
│ -> Execute AfterInsert hook │
└─────────────────────────────────────────────────────────────────────┘Layered Metadata System
Docker OverlayFS와 같은 개념으로 동작해요.
┌──────────────────────────────┐
│ Tenant Layer (upper) │ <- Per-tenant modifications (Copy-on-Write)
│ e.g. "tenant_1", "tenant_2"│
├──────────────────────────────┤
│ Public Layer (lower) │ <- Base schema (all entity metadata)
│ Read-only (after decorator │
│ registration) │
└──────────────────────────────┘동작 방식:
- 앱 로드 시,
@Entity나@Column같은 데코레이터가 public layer에 메타데이터를 기록해요. - 멀티테넌트 환경에서
MetadataContext.run("tenant_1", callback)을 호출하면, 해당 콜백 내에서 tenant_1 레이어가 활성화돼요. - 메타데이터 조회 시 tenant layer를 먼저 확인하고, 없으면 public layer로 fallback해요 (OverlayFS 방식).
- 쓰기는 항상 tenant layer에서만 발생해요 (Copy-on-Write).
주요 파일:
| File | Role |
|---|---|
src/metadata/MetadataContext.ts | AsyncLocalStorage 기반 request-scoped context |
src/metadata/LayeredMetadataStore.ts | Layer 기반 metadata store |
src/metadata/MetadataLayer.ts | 개별 layer (key-value Map) |
src/scanner/MetadataScanner.ts | MetadataLayerRegistry를 사용하는 Scanner 기반 클래스 |
Driver Abstraction
모든 DB 드라이버는 ISqlDriver 인터페이스(src/dialects/SqlDriver.ts)를 구현해요.
| Driver | Identifier Wrapping | Auto PK Generation | Schema Support | File Path |
|---|---|---|---|---|
| MySQL | Backtick (`) | AUTO_INCREMENT | Not supported | src/dialects/mysql/MySqlDriver.ts |
| PostgreSQL | Double quote (") | SERIAL / RETURNING | Supported (schema.table) | src/dialects/postgres/PostgresDriver.ts |
| SQLite | Double quote (") | INTEGER PRIMARY KEY | Not supported | src/dialects/sqlite/SqliteDriver.ts |
각 드라이버의 연결 레이어 구조는 이래요:
DatabaseClient (singleton)
└-> Connector (IConnector implementation — manages actual DB connections)
└-> Driver (ISqlDriver implementation — DDL/DML abstraction)
└-> DataSource (IDataSource implementation — transaction/query execution)3. 주요 코드 흐름
Connection Flow
EntityManager.register(options)
-> EntityManager.connect(options)
-> DatabaseClient.getInstance().connect(options)
-> createConnector(type) // MySqlConnector | PostgresConnector | ...
-> connector.connect(options)
-> Driver creation (MySqlDriver | PostgresDriver | ...)
-> DataSource creation (for transaction management)
-> EntityManager.registerEntities()관련 파일:
src/core/EntityManager.ts:121—register()methodsrc/core/EntityManager.ts:146—connect()methodsrc/DatabaseClient.ts:48—connect()method
Entity Registration Flow
데코레이터는 클래스 정의 시점(모듈 로드)에 실행돼요. 실행 순서가 중요해요.
1. @Column() executes (property decorator — in class body order)
-> inferColumnDefaults() infers design:type
-> ColumnScanner.set() temporarily stores metadata
2. @ManyToOne() executes (property decorator)
-> ManyToOneScanner.set() temporarily stores relation metadata
3. @Entity() executes (class decorator — last)
-> ColumnScanner.allMetadata() retrieves collected columns
-> ManyToOneScanner.allMetadata() retrieves collected relations
-> EntityScanner.set() snapshots entity metadata
-> ColumnScanner.clear() resets temporary buffer핵심 포인트: @Column()이 @Entity()보다 먼저 실행돼요. @Entity()는 그 시점까지 수집된 컬럼 메타데이터의 스냅샷을 찍고 임시 버퍼를 초기화해요.
관련 파일:
src/decorators/Column.ts:153—Column()decoratorsrc/decorators/Entity.ts:24—Entity()decoratorsrc/scanner/MetadataScanner.ts:179— Scanner base class
CRUD Flow (save 예시)
em.save(User, { name: "John Doe", email: "john@example.com" })
1. resolveEntityMetadata(User)
-> EntityScanner.scan(User) or Reflect.getMetadata(ENTITY_TOKEN, User)
2. EntityValidator.validate(entity)
-> @Validation decorator checks
3. Hook execution: BeforeInsert
4. EntitySubscriber.beforeInsert() event
5. TransactionSessionManager creation -> connect() -> startTransaction()
6. SQL build: INSERT INTO "users" ("name", "email") VALUES ($1, $2)
-> Parameter binding via sql-template-tag
7. Execute query -> return result rows
8. commit()
9. Deserializer converts result -> User instance
10. Hook execution: AfterInsert
11. EntitySubscriber.afterInsert() event관련 파일:
src/core/EntityManager.ts:2146—save()methodsrc/dialects/TransactionSessionManager.ts— Transaction managementsrc/core/EntityValidator.ts— Validation
Relation Loading
Eager Loading (기본값):
em.find(Post, { relations: ["author"] })
-> LEFT JOIN으로 관련 엔티티를 한 번에 조회
-> ResultTransformer가 flat row를 중첩 객체로 변환Lazy Loading:
em.find(Post, { relations: [] }) // Relations configured as lazy
-> Object.defineProperty()로 getter proxy 주입
-> post.author 접근 시 -> 별도 SELECT 쿼리 실행
-> 이후 접근 시 캐시된 값 반환관련 파일:
src/core/LazyLoader.ts—injectLazyProxy(),createLazyProxy()
4. 새 기능 추가 가이드
새 데코레이터 추가
예시: @CreatedAt() 데코레이터를 추가한다고 가정할게요.
Step 1: Symbol token 정의 + 데코레이터 함수 작성
// src/decorators/CreatedAt.ts
export const CREATED_AT_TOKEN = Symbol.for("STG_CREATED_AT");
export function CreatedAt(): PropertyDecorator {
return (target, propertyKey) => {
Reflect.defineMetadata(CREATED_AT_TOKEN, propertyKey, target.constructor);
};
}Step 2: 데코레이터 export
// Add to src/decorators/index.ts
export * from "./CreatedAt";Step 3: EntityManager에서 token 사용
save/update 메서드에서 Reflect.getMetadata(CREATED_AT_TOKEN, entity)를 조회해서 날짜를 자동으로 설정해요.
Step 4: 테스트 작성
__tests__/unit/created-at.test.ts를 만들고 데코레이터 동작을 검증해요.
네이밍 규칙:
- Token symbol:
STG_prefix (예:Symbol.for("STG_CREATED_AT")) - 데코레이터 파일: PascalCase (예:
CreatedAt.ts) - Token 상수: SCREAMING_SNAKE_CASE +
_TOKENsuffix (예:CREATED_AT_TOKEN)
새 드라이버 추가
새 데이터베이스(예: CockroachDB)를 지원하려면:
Step 1: 디렉토리 생성
src/dialects/cockroach/
├── CockroachDriver.ts <- ISqlDriver implementation
├── CockroachConnector.ts <- IConnector implementation
└── CockroachDataSource.ts <- IDataSource implementationStep 2: ISqlDriver 완전 구현
src/dialects/SqlDriver.ts의 ISqlDriver 인터페이스에 정의된 모든 메서드를 구현해요. 주요 메서드:
createTable()/hasTable()— DDLaddForeignKey()/addIndex()— ConstraintscastType()— ColumnType -> DB native type 변환buildUpsertSql()— UPSERT 구문setQueryTimeout()— 쿼리 타임아웃
Step 3: 등록 코드 추가
세 곳에 분기를 추가해요:
src/DatabaseClient.ts:72—createConnector()methodsrc/core/EntityManager.ts:158—connect()method의 switch문src/dialects/TransactionSessionManager.ts:48—connect()method의 if-else문
Step 4: 테스트
__tests__/unit/cockroach-driver.test.ts를 생성해요.
EntityManager에 새 메서드 추가
EntityManager에 public API를 추가하는 절차예요:
Step 1: src/core/BaseEntityManager.ts 인터페이스에 메서드 시그니처 추가
Step 2: src/core/EntityManager.ts에서 구현
Step 3: src/core/BaseRepository.ts에 wrapper 메서드 추가 (Repository pattern 지원용)
Step 4: 필요하면 src/index.ts -> src/core/index.ts에서 export 확인
새 Error 클래스 추가
Step 1: src/errors/OrmErrorCode.ts에 새 에러 코드 추가
export enum OrmErrorCode {
// ... existing codes
MY_NEW_ERROR = "ORM_MY_NEW_ERROR",
}Step 2: src/errors/ 디렉토리에 에러 클래스 생성
// src/errors/MyNewError.ts
import { OrmError } from "./OrmError";
import { OrmErrorCode } from "./OrmErrorCode";
export class MyNewError extends OrmError {
constructor() {
super(OrmErrorCode.MY_NEW_ERROR, "Description message");
this.name = "MyNewError";
}
}Step 3: src/errors/index.ts에서 export
5. 코딩 컨벤션
SQL Injection 방지
절대 규칙: 사용자 입력을 SQL 문자열에 직접 삽입하면 안 돼요.
// Correct approach — use sql-template-tag
import sql from "sql-template-tag";
const query = sql`SELECT * FROM users WHERE id = ${userId}`;
// Wrong approach
const query = `SELECT * FROM users WHERE id = ${userId}`;테이블명, 컬럼명 같은 식별자는 드라이버의 escapeIdentifier() 또는 wrapIdentifier()로 감싸야 해요.
// MySQL: `users`, PostgreSQL: "users"
const tableName = driver.escapeIdentifier("users");Metadata Isolation
// Correct approach — AsyncLocalStorage-based request scope
MetadataContext.run("tenant_1", async () => {
await em.find(User); // Executes in tenant_1 context
});
// Wrong approach — modifying instance state (not safe with concurrent requests)
store.setContext("tenant_1"); // deprecated!TypeScript strict Mode
tsconfig.json에 strict: true가 설정돼 있어요. any 타입 사용을 최소화하되, 프레임워크 메타데이터 처리 때문에 불가피한 경우 eslint-disable 코멘트를 사용해요.
테스트 패턴
유닛 테스트:
// Mock DB connections with jest.mock
jest.mock("../../src/DatabaseClient", () => ({
DatabaseClient: {
getInstance: jest.fn().mockReturnValue({
getConnection: jest.fn(),
type: "mysql",
getOptions: jest.fn().mockReturnValue({ synchronize: false }),
}),
},
}));통합 테스트:
import { createTestConnection, generateTableName, dropTestTable } from "./helpers";
describe("CRUD basic", () => {
let em: EntityManager;
const tableName = generateTableName("crud");
beforeAll(async () => {
em = await createTestConnection();
});
afterAll(async () => {
await dropTestTable(em, tableName);
});
});통합 테스트 파일 상단에는 반드시 다음 가드를 포함해야 해요:
const SKIP = !process.env.INTEGRATION_TEST;
(SKIP ? describe.skip : describe)("Integration: ...", () => { ... });네이밍 규칙
| Subject | Convention | Example |
|---|---|---|
| Entity class | PascalCase | User, BlogPost |
| Table name | snake_case (auto-converted) | user, blog_post |
| Decorator token | STG_ prefix + SCREAMING_SNAKE | Symbol.for("STG_ENTITY") |
| Driver class | {DB}Driver | MySqlDriver, PostgresDriver |
| Connector class | {DB}Connector | MySqlConnector |
| DataSource class | {DB}DataSource | MySqlDataSource |
| Scanner class | {Name}Scanner | ColumnScanner, EntityScanner |
| Error class | {Desc}Error | EntityNotFound, InvalidQueryError |
Commit Messages
feat: Add new feature
fix: Fix bug
docs: Documentation changes
test: Add/modify tests
refactor: Refactoring (no functional changes)
chore: Build, configuration changes6. 자주 하는 실수와 트러블슈팅
reflect-metadata import 누락
TypeError: Reflect.getMetadata is not a function해결: 앱 진입점 최상단에 import "reflect-metadata"를 추가해요. 이 패키지가 없으면 데코레이터 메타데이터(design:type 등)를 읽을 수 없어요.
tsconfig.json 설정 누락
Unable to resolve signature of class decorator...해결: tsconfig.json에 다음 두 옵션이 있어야 해요:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}빌드 없이 예제 실행
Cannot find module '@stingerloom/orm'해결: examples/는 로컬 dist/를 참조해요. 예제 실행 전에 프로젝트 루트에서 pnpm build를 먼저 실행해야 해요.
테스트 간 MetadataLayerRegistry 오염
서로 다른 테스트 파일에서 같은 엔티티를 정의하면, MetadataLayerRegistry가 전역 싱글톤이라 메타데이터가 겹칠 수 있어요.
해결: 각 테스트 전에 레지스트리를 초기화해요:
beforeEach(() => {
MetadataLayerRegistry.reset();
MetadataContext.reset();
});WHERE 조건의 Falsy 값 처리
0, false, "" 같은 falsy 값이 WHERE 조건에서 무시되는 버그가 있었어요 (수정 완료).
참고: 조건 검사 시 if (value) 대신 if (value !== undefined && value !== null)을 사용해요.
// Correct approach
if (value !== undefined && value !== null) {
conditions.push(sql`${column} = ${value}`);
}
// Wrong approach — ignores 0, false, ""
if (value) {
conditions.push(sql`${column} = ${value}`);
}MySQL Connection Pool 이중 해제
MySQL 드라이버에서 commit() 또는 rollback() 후 connection 참조를 undefined로 설정하지 않으면, 같은 connection이 두 번 해제될 수 있어요.
관련 코드: src/dialects/mysql/MySqlDataSource.ts — commit/rollback 후 this.connection = undefined 설정이 필요해요.
7. Contribution Workflow
Branch 전략
main (default branch)
└── feature/feature-name <- New features
└── fix/bug-description <- Bug fixes
└── docs/doc-topic <- Documentation changesmain에서 브랜치를 만들고, 작업 완료 후 PR을 생성해요.
PR Checklist
PR을 열기 전에 다음을 확인해요:
- [ ]
pnpm test전체 통과 (0 failures) - [ ]
pnpm build성공 (TypeScript 컴파일 에러 없음) - [ ] 새 기능에 대한 유닛 테스트 추가
- [ ] SQL 쿼리의 사용자 입력은 parameter binding으로 처리
- [ ] 식별자(테이블명, 컬럼명)는
escapeIdentifier()로 래핑 - [ ] 메타데이터 접근은 layer 시스템을 통해서만 (전역 상태 사용 금지)
- [ ] API가 변경됐으면 관련 문서(
docs/) 업데이트 - [ ] 예제 프로젝트에 영향이 있으면
examples/업데이트
예제 프로젝트 타입 체크
ORM API를 변경했다면, 예제가 깨지지 않았는지 확인해요:
pnpm build
cd examples/nestjs-cats && pnpm install && npx tsc --noEmit
cd ../nestjs-blog && pnpm install && npx tsc --noEmit
cd ../nestjs-multitenant && pnpm install && npx tsc --noEmit8. 참고 자료
기존 문서 상호 참조
| Document | Content | When to reference |
|---|---|---|
| Getting Started | 설치, 첫 엔티티, 첫 CRUD | ORM 사용법을 처음 배울 때 |
| Entity Definition | 컬럼, 인덱스, 훅, 유효성 검사 | 데코레이터 동작을 이해할 때 |
| Relations | ManyToOne, OneToMany, ManyToMany | 관계 로딩 코드를 수정할 때 |
| EntityManager | find, save, delete, aggregation | EntityManager API를 확장할 때 |
| Query Builder | JOIN, GROUP BY, subqueries | QueryBuilder 코드를 수정할 때 |
| Transactions | Isolation levels, Savepoint | TransactionSessionManager를 수정할 때 |
| Migrations | MigrationRunner, CLI | 마이그레이션 시스템을 수정할 때 |
| Configuration Guide | Pooling, timeout, Read Replica | DatabaseClientOptions를 확장할 때 |
| Advanced Features | N+1 감지, 이벤트, 커서 페이지네이션 | QueryTracker, EventEmitter를 수정할 때 |
| Multi-Tenancy | Layered metadata, 스키마 격리 | 멀티테넌시 기능을 수정할 때 |
| API Reference | 메서드 시그니처 목록 | public API를 확인할 때 |
| Manual Testing Guide | 수동 통합 테스트 실행 | 통합 테스트 환경을 설정할 때 |
| Pre-Release Checklist | 릴리스 전 확인 항목 | 버전 릴리스를 준비할 때 |
주요 소스 파일 빠른 참조
| File | One-Line Description |
|---|---|
src/core/EntityManager.ts | CRUD, 관계 로딩, 트랜잭션 -- ORM의 중심 |
src/dialects/SqlDriver.ts | ISqlDriver 인터페이스 정의 |
src/metadata/MetadataContext.ts | AsyncLocalStorage 기반 tenant context |
src/scanner/MetadataScanner.ts | MetadataLayerRegistry를 사용하는 Scanner 기반 클래스 |
src/metadata/LayeredMetadataStore.ts | Trie 기반 layered metadata store |
src/decorators/Entity.ts | @Entity() decorator -- 엔티티 메타데이터 스냅샷 |
src/decorators/Column.ts | @Column() decorator -- 컬럼 메타데이터 + 타입 추론 |
src/DatabaseClient.ts | DB 연결 싱글톤 (named connections 지원) |
src/dialects/TransactionSessionManager.ts | 트랜잭션 라이프사이클 관리 |
src/core/BaseRepository.ts | Repository pattern 기반 클래스 |
src/errors/OrmError.ts | Error 기반 클래스 (OrmErrorCode 기반) |
src/index.ts | 패키지 public API 진입점 |