임시저장 기능 고도화 해보기 (2)

들어가며

앞선 글에서 설계한 임시저장 기능을 완성하기 위해서는, 임시 저장소로 사용할 SQLite에 데이터를 저장하기 위해 엔티티를 정의하고 데이터를 CRUD할 수 있는 메서드 작성해야합니다.

이를 구현하는 방법으로는 TypeORM, Sequelize, Drizzle 등의 ORM을 사용하는 방식과 RAW 쿼리를 직접 실행하는 방식이 있었는데요, 몇가지 방법을 시도해 본 후 가장 적합한 방식을 선택해 개발할 수 있도록 환경을 세팅하고 테스트를 진행했습니다.

아키텍쳐

draft_img

전체적인 아키텍쳐는 이와 같다.

구상하고 구현할 아키텍처는 위와 같습니다.

해당 아키텍처에서 SQLite 저장 기능을 구현하기 위한 세팅을 진행할 예정입니다.

ORM을 표방해보기

우선 SQLite의 RAW 쿼리를 활용하되, ORM 방식처럼 동작하는 데코레이터들을 구현해 보았습니다.

왜 ORM을 쓰지 않았는가?

속도 향상을 위해 RAW 쿼리를 최대한 활용하고, I/O를 최소화하고자 했습니다.

그런데 기존 ORM 라이브러리들은 모든 데이터베이스 접근을 I/O 작업으로 가정하기 때문에, CRUD 메서드가 기본적으로 async/await 패턴으로 구현되어 있는 경우가 많습니다.

typeorm/src/driver/better-sqlite3/BetterSqlite3QueryRunner.ts at master · typeorm/typeorm
typeorm/src/driver/better-sqlite3/BetterSqlite3QueryRunner.ts at master · typeorm/typeorm
ORM for TypeScript and JavaScript. Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms. -...

typeORM BetterSqlite3QueryRunner 클래스의 query 메서드만 보아도 asnyc/await 패턴이 적용되어 있는 것을 볼 수 있었습니다.

하지만 인메모리 데이터베이스에서는 async/await가 필수적이지 않고, 오히려 불필요한 병목이 될 수 있다고 생각하였고, 최대한 가능한 극한의 성능을 끌어내고 싶었습니다.

흐름도

flowchart TD A[NestJS init] --> B[SqliteModule] B --> |onModuleInit| C[SqliteService] C --> D[initDatabase] D -->|read all entity files| E[getEntityCreateTableSQL] E --> F{metadata 기반 entity, colunm 가져오기} F --> G[정보 기반</br>table create SQL 생성] G --> H[모든 entity에 대해</br>table create SQL 쿼리 실행]

제가 작성한 데코레이터를 기반으로 하는 SQL 초기화 흐름은 위와 같습니다.

구현 예시

import { Column, Entity, PrimaryKey } from '../decorator/orm.decorator';

@Entity({ name: 'submissions' })
export class Submission {
	@PrimaryKey({ type: 'TEXT' })
	id: number;

	@Column({ type: 'TEXT' })
	title: string | null = null;

	@Column({ type: 'TEXT' })
	description: string | null = null;

	@Column({ type: 'TEXT', notNull: true })
	createUserId: string;

	@Column({ type: 'TEXT' })
	updateUserId: string;
}

먼저, 위와 같이 엔티티를 정의합니다.

해당 커스텀 메타데이터들이 마킹된 엔티티 정보를 RAW 쿼리로 변환하여, 테이블을 생성하고 초기화하는 역할을 수행합니다.

메타데이터 세팅

위에서 언급하였듯이 데코레이터를 명시해두면 원하는 정보들을 저장할 수 있는데요, 앱이 처음 실행될 때 해당 데코레이터를 기반으로 정보들을 저장합니다.

@Enitity

export const globalEntityStorage = new Set<Function>();

export const ENTITY_KEY = Symbol('ENTITY_KEY');
export const COLUMNS_KEY = Symbol('COLUMNS_KEY');

export function Entity({ name }: { name: string }): ClassDecorator {
	return (target) => {
		Reflect.defineMetadata(ENTITY_KEY, name, target);

		if (!Reflect.hasMetadata(COLUMNS_KEY, target)) {
			Reflect.defineMetadata(COLUMNS_KEY, [], target);
		}

		globalEntityStorage.add(target);
	};
}

우선 Enitity 데코레이터는 메타데이터에 정보를 저장하고 globalEntityStorage에 추가하는 역할을 합니다.

globalEntityStorage는 전역적으로 정의된 Set입니다. 추후 한번에 테이블들을 등록하기 위해서 임시로 정보들을 저장해둡니다.

reflect.defineMetadata(ENTITY_KEY, name, target)를 통해 메타데이터를 클래스(target)에 추가하는데요, ENTITY_KEY는 메타데이터 키로 사용되며, 값으로 name(테이블 이름)을 저장합니다.

즉, 이 클래스는 특정 테이블 이름과 연결됩니다.

if (!Reflect.hasMetadata(COLUMNS_KEY, target)) {
	Reflect.defineMetadata(COLUMNS_KEY, [], target);
}

이 부분은 클래스가 아직 컬럼 메타데이터를 가지지 않은 경우 초기화 로직입니다. COLUMNS_KEY를 키로 하고 빈 배열을 값으로 저장하며, 이 배열은 나중에 컬럼 정보를 추가할 때 사용됩니다.

이렇게 초기화하여, 나중에 다른 데코레이터를 통해 컬럼 정보를 이 배열에 추가할 수 있게 되는데요, 이러한 정보들을 globalEntityStorage에 추가합니다.

@Column

export function Column(opts: ColumnOptions): PropertyDecorator {
	return (target, propertyKey) => {
		const cols: (ColumnOptions & { property: string })[] =
			Reflect.getMetadata(COLUMNS_KEY, target.constructor) ?? [];

		cols.push({ property: propertyKey as string, ...opts });
		Reflect.defineMetadata(COLUMNS_KEY, cols, target.constructor);
	};
}

Column 데코레이터는 각 컬럼에 대해서 정의한 여러 조건 정보들을 추가하는 역할을 합니다.

const cols: (ColumnOptions & { property: string })[] =
	Reflect.getMetadata(COLUMNS_KEY, target.constructor) ?? [];

COLUMNS_KEY는 클래스의 컬럼 정보를 담고 있는 메타데이터의 키입니다. 이 키로 클래스에서 컬럼 정보를 가져옵니다.

Reflect.getMetadata(COLUMNS_KEY, target.constructor)는 클래스에 설정된 컬럼 정보를 가져옵니다. 만약 현재 클래스에 이 정보가 없으면 undefined가 반환됩니다.

cols.push({ property: propertyKey as string, ...opts });

cols는 클래스 프로퍼티(컬럼) 정보를 관리하는 배열입니다. 새로 추가된 컬럼 정보를 cols 배열에 푸시합니다.

propertyKey는 데코레이터가 적용된 프로퍼티 이름입니다. 예를 들어, id, name, age 등의 컬럼 이름입니다.

opts는 사용자 정의 컬럼 옵션입니다. SQL에서 흔히 볼 수 있는 타입이나 제약 조건들, type, primary, notNull 같은 옵션이 포함될 수 있습니다.

Reflect.defineMetadata(COLUMNS_KEY, cols, target.constructor);

이제 새롭게 추가된 컬럼 정보 (cols)를 클래스에 다시 저장합니다. Reflect.defineMetadata는 COLUMNS_KEY를 메타데이터 키로 사용해 cols 배열을 업데이트합니다.

이렇게 하면 클래스에 저장된 모든 컬럼 정보가 최신 상태로 유지되며, 나중에 테이블 생성과 같은 작업에서 이 정보를 활용할 수 있습니다.

@PrimaryKey, @ForeignKey

export const PrimaryKey = ({ type }: { type: 'INTEGER' | 'TEXT' }) => {
	return type === 'INTEGER'
		? Column({ type: 'INTEGER', primary: true, autoincrement: true })
		: Column({ type: 'TEXT', primary: true });
};

export const ForeignKey = ({
	table,
	column,
}: {
	table: string;
	column: string;
}) => Column({ foreignKey: { table, column } });

위 Column 데코레이터를 목적에 맞게 한 래핑한 데코레이터입니다. 각각 PrimaryKey, ForeignKey를 위한 필드를 가지고 있습니다.

세팅한 메타데이터 기반 테이블 create 쿼리 생성

이제 데코레이터를 바탕으로 수집되고 만들어진 정보를 바탕으로 테이블 create 쿼리를 생성합니다.

const createTableSQLList = Array.from(globalEntityStorage).map(
    (entity) => {
	    return this.getEntityCreateTableSQL(entity);
	},
);

위에서 완성시킨 globalEntityStorage를 순회하며 작동합니다.

private getEntityCreateTableSQL(target: Function) {
	const table = Reflect.getMetadata(ENTITY_KEY, target);
	const columns = Reflect.getMetadata(COLUMNS_KEY, target) ?? [];

	if (!table || columns.length === 0) {
		throw new Error('Entity must have a table name');
	}

	const columnSQL = this.generateColumnDefinitions(
		columns.filter((col: any) => !col.foreignKey),
	);
	const foreignKeySQL = this.generateForeignKeyDefinitions(
		columns.filter((col: any) => col.foreignKey),
	);

	const createTableSQL = `CREATE TABLE IF NOT EXISTS ${table} (\n  ${[...columnSQL, ...foreignKeySQL].join(',\n  ')}\n);`;

	return createTableSQL;
}
	

우선 데코레이터를 통해 마킹하고 수집한 table / columns 정보를 가져오고, columns를 기반으로 테이블을 만드는 쿼리를 생성합니다.

이때 쿼리문에서 foreignKey 생성의 경우 가장 마지막 열에 추가되어야하기 때문에 따로 분리하여 생성합니다.

private generateColumnDefinitions(columns: any[]): string[] {
    return columns.map((col) => this.buildColumn(col));
}

private buildColumn(c: any) {
	const flags = [
		c.primary ? 'PRIMARY KEY' : '',
		c.autoincrement ? 'AUTOINCREMENT' : '',
		c.notNull ? 'NOT NULL' : '',
		c.unique ? 'UNIQUE' : '',
		c.default
			? `DEFAULT ${typeof c.default === 'function' ? c.default() : c.default}`
			: '',
	]
	.filter(Boolean)
	.join(' ');

	return `${c.property} ${c.type} ${flags}`.trim();
}

먼저 foreign key를 제외한 colunms를 순회하는데요, 해당 컬럼에서 지정한 옵션대로 쿼리를 만듭니다.

예를 들어, @Column({ notNull: true })과 같이 지정하였다면, 이를 title TEXT NOT NULL 쿼리문으로 해석하여 변경합니다.

private generateForeignKeyDefinitions(columns: any[]): string[] {
  return columns.map((col) => this.buildForeignKeyColumn(col));
}

private buildForeignKeyColumn(col: any): string {
	const { foreignKey } = col;

	if (!foreignKey || !foreignKey.table || !foreignKey.column) {
		throw new Error(
			`Invalid foreign key metadata for column: ${JSON.stringify(col)}`,
		);
	}

	return `FOREIGN KEY (${col.property}) REFERENCES ${foreignKey.table}(${foreignKey.column})`;
}

이후에는 foreign key colunms만 순회합니다. 동일하게 지정한 옵션대로 쿼리문을 생성합니다.

const createTableSQL = `CREATE TABLE IF NOT EXISTS ${table} (\n  ${[...columnSQL, ...foreignKeySQL].join(',\n  ')}\n);`;

return createTableSQL;

이렇게 해서 각 컬럼마다 만들어진 쿼리문들을 합치고, 테이블을 생성하는 쿼리문이 생성됩니다.

이제 db.exec을 통해 해당 쿼리들을 실행하기만 하면 테이블들이 초기화 됩니다.

모듈 초기화

@Injectable()
export class SqliteService implements OnModuleInit {
	private db: Database.Database;

	constructor(
		private readonly sqliteInitService: SqliteInitService,
		@InjectLogger()
		private readonly logger: PinoLogger,
	) {
		this.db = new Database('./database.db', { verbose: console.log });
		this.db.pragma('journal_mode = WAL');
	}

	async onModuleInit() {
		await this.initDatabase();
	}
	
    ...

    private async initDatabase() {
		const createTableSQLList =
			await this.sqliteInitService.getEntityCreateTableSQLList();

		createTableSQLList.forEach((createTableSQL) => {
			try {
				this.db.exec(createTableSQL);
			} catch (err) {
				this.logger.error(`Failed to synchronize table:`, err);
				throw err;
			}
		});
	}	
}

모듈이 초기화될 때 위에서 작업한 쿼리를 만들고 실행하게 됩니다. 즉 서버를 실행시킬 때 테이블을 생성합니다. (테이블이 이미 있으면 생성을 진행하지 않음)

결과

custom_orm

Nest가 초기화 되면서 테이블을 초기화하는 것을 볼 수 있다.

하지만, 결국 ORM을 도입하다

운영 환경에서 테스트해본 결과, SQLite 데이터베이스 자체에서는 FK 등 관계형 구조를 정의할 수 있지만 개발 환경에서 스키마 기반으로 관계를 직관적으로 파악하고 간편하게 JOIN을 수행하기는 어려웠습니다.

또한 이러한 한계로 인해 코드를 작성할 때 복잡한 RAW 쿼리를 계속 사용할 수밖에 없었고, 이는 장기적인 코드 유지보수 측면에서 부담이 될 것이라 판단했습니다.

dirzzle ORM

이에 ORM을 도입해 쿼리 빌더를 사용하는 방식이 더 낫다고 생각하였습니다. 이를 판단하게 된 근거는 두가지가 있습니다.

동기 API 지원

추가 리서치 결과, Drizzle ORM은 저희 요구대로 async/await 패턴을 사용하지 않고 동기적으로 쿼리를 실행할 수 있음을 확인했습니다.

better-sqlite3는 동기 API를 제공하는데, 이를 사용하는 Drizzle도 자연스럽게 동기 처리하고 있는 것을 확인할 수 있었습니다.

drizzle-orm/drizzle-orm/src/sqlite-core/db.ts at 33f0374e29014677c29f4b1f1dd1ab8fb68ac516 · drizzle-team/drizzle-orm
drizzle-orm/drizzle-orm/src/sqlite-core/db.ts at 33f0374e29014677c29f4b1f1dd1ab8fb68ac516 · drizzle-team/drizzle-orm
ORM. Contribute to drizzle-team/drizzle-orm development by creating an account on GitHub.

성능 측면

성능 측면에서는 RAW 쿼리가 여전히 더 빨랐습니다.

하지만, 모든 테스트 시나리오에서 차이는 100ms 이내였으며 유지보수성과 확장성을 고려하면 ORM을 사용하는 것이 더 합리적이라고 결론 지었습니다.

성능 테스트 결과 (better-sqlite3 vs Drizzle ORM)

  • Create
    • RAW SQL 19ms / Drizzle ORM 81ms
  • Update
    • RAW SQL 9ms / DrizzleORM 60ms
  • Join
    • RAW SQL 5ms / Drizzle ORM 131ms
  • Transaction Insert
    • RAW SQL 6ms / Drizzle ORM 38ms

도입 결과

결국 Drizzle을 선택하게 되었고, 그동안의 개발 경험과 확장되는 요구사항에 맞춰 유연하게 활용할 수 있다는 점에 매우 만족하고 있습니다.

import { relations } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';

import { timestamps } from './base.schema';
import { step } from './step.schema';
import { theme } from './theme.schema';
import { TABLE_NAMES } from '../constant/table.constant';

export const submission = sqliteTable(TABLE_NAMES.SUBMISSION, {
	id: text().primaryKey(),
	title: text(),
	description: text(),
	createUserId: text().notNull(),
	updateUserId: text(),
	...timestamps,
});

export const submissionRelations = relations(submission, ({ many, one }) => ({
	stepList: many(step),
	theme: one(theme, {
		fields: [submission.id],
		references: [theme.submissionId],
	}),
}));

엔티티 코드가 한층 직관적이고 간결해졌고, relations 설정 역시 깔끔하게 정리되었습니다.

orm

우선은 행복하다..

이어가며

Drizzle로 구현한 엔티티와 메서드들은 이미 웹상의 여러 포스팅에서도 다양한 실무 경험과 사례가 쌓여 있어, 더 이상 상세히 다루지 않아도 될 것 같습니다.

다음 글에서는 임시 저장 기능을 위해 사용자들의 소켓을 어떻게 설계하고 관리할지에 대한 고민과 접근 방법을 정리해보려고 합니다.