안녕하세요! 오늘은 Next.js 프로젝트에서 TypeORM 을 사용하여 별도의 DB서버 없이 데이터베이스를 관리하는 방법에 대해 이야기하려고 합니다. 조금더 나아가 Next.js 프로젝트에서 Service 계층을 나누어 더 좋은 개발 환경을 구축 합니다.
Next.js 프로젝트에서 별도의 서비스 계층을 만드는 것은 코드의 재사용성을 높이고, 유지보수를 용이하게 하며, 비즈니스 로직과 데이터 액세스 로직을 분리하여 코드의 구조를 깔끔하게 유지하는 데 도움이 됩니다.
Next.js 로 개인 블로그를 만들면서, 매번 /pages/api 에 Service 로직을 작성하기엔 코드 관리가 너무 힘들고 🤬, express 혹은 Nest.js 같은 별도 서버를 분리?부착? 하기엔 서비스 규모가 작기 때문에 고민 하다가 , 유사 Spring boot, Nest.js 와 같은 DI 가 가능한 Soft 하고 Simple 한 서비스 계층을 만들자 라는 생각에서 시작 됐습니다.
GitHub example link 🌚
@Service 데코레이터를 통해 Provider 에 등록하게 됩니다.
@InjectRepository, @Inject 를 통해 SingleTon 으로 관리되는 Service, 혹은 Repository 를 주입 받습니다.
/pages/api 혹은 SSG,SSR 에서 Service 를 불러와 사용 할 수 있습니다.
npx create-next-app@latest
...
Would you like to use TypeScript with this project? No / Yes << Yes!
혹은
yarn create next-app --typescript
typescript로 세팅을 해주셔야 합니다.
서비스 계층 나눌떄 사용할 Provider를 작성하기위해 데코레이션을 작성하기 위해 옵션을 수정 해주세요.
// tsconfig.json
{
...,
"compilerOptions": {
...
"strictPropertyInitialization": false, //
"experimentalDecorators": true,
}
}
typeorm과 사용할 DB 패키지를 install 합니다.
yarn add mysql2 typeorm reflect-metadata
main 폴더 아래 server 라는 별도의 디렉토리를 생성합니다.
# 파일 트리
server
│ ├───database # DB 커넥션 config 폴더 입니다.
│ ├───entities # 엔티티 폴더 입니다.
│ ├───provider # 서비스를 관리할 Context Provider 입니다.
└─ └───service # 서비스를 작성하는 폴더 입니다.
// ....src/server/database/orm.config.ts
// DataSource Option 을 정의하는 곳입니다.
import { DataSourceOptions } from 'typeorm';
import { Todo } from '../entities/todo.entity'; // entity
const ORM_CONFIG: DataSourceOptions = {
type: 'mysql',
port: Number(process.env.DB_PORT) || 3306,
host: process.env.DB_HOST || 'localhost',
entities: [Todo],
username: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '1234',
database: process.env.MYSQL_DATABASE || 'cgoing',
synchronize: false,
logging: true,
};
export default ORM_CONFIG;
// ....src/server/database/datasource.ts
// config 를 불러와 DataSource 를 생성합니다.
import { DataSource } from 'typeorm';
import 'reflect-metadata';
import ORM_CONFIG from './orm.config';
import Holder from '@/lib/Holder';
export const connectionHolder = new Holder();
let dataSource: DataSource | undefined;
export const getDataSource = () => {
if (!dataSource) {
dataSource = new DataSource(ORM_CONFIG);
console.log(`create DataSource`);
dataSource.initialize().then(() => {
console.log(`connect complete`);
connectionHolder.resolve();
});
}
return dataSource;
};
// src/lib/Holder.ts
// async Function 들을 관리하는 용도의 class
class Holder<T> {
promise: Promise<T>;
resolve: Function;
reject: Function;
constructor() {
this.promise = new Promise((resolve, reject) =>
Object.assign(this, { reject, resolve }),
);
}
}
Holder class 가 중요한 역할을 해주는데요,
Next.js 에서는 DB initialize 과 같은 Bootstrap 을 하기위한 정식적인 방식을 제공하지 않습니다.
물론 다양한 방법이 있지만, 이 부분은 Holder Class 로 해결했습니다.
getDataSource ( db initialize ) 함수를 async Function 으로 작성하게되면 Repository 를 주입 받거나 처음 생성할때 매번 await 을 해줘야 합니다. 그러기 때문에 getDataSource 는 sync 한 함수로 두고
repository 를 사용하는 시점 (실제 데이터베이스에 커넥션을해서 쿼리를 수행하는 함수 ) 에 Proxy 를 통해 connectionHolder 의 promise 를 기다릴수 있게 됩니다. 자세한 설명은 아래 Service Provider 를 작성할때 하겠습니다.
// src/server/entities/todo.entity.ts
// 간단한 Todo Entity 생성
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('todo')
export class Todo {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar' })
content: string;
@Column({ type: 'tinyint', default: 0 })
complete: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
그리고 코어 소스인 Provider 입니다.
간단하게 작성되어 퓨어하거나 완벽한 소스는 아닙니다.
import { connectionHolder, getDataSource } from '@/server/database/datasource';
import { ObjectLiteral, Repository } from 'typeorm';
interface Class {
new (...args: any[]): any;
}
/** @Read Service에서 작성 될 함수는 async 하지 않을 일이 없어서 무조건 커넥션 홀더를 거치고 실행시킨다 async 한 DB initial 과정을 bootstrap 하기 힘든 프레임워크다 여럿 시도를 했는데 이게 깔끔 한 것 같다.*/
const serviceAsyncProxy = (obj: any) =>
new Proxy(obj, {
get(target, key) {
const value = target[key];
if (typeof value == 'function') {
return async function (...args: any) {
await connectionHolder.promise; // getDataSource 의 처음 db initial 을 기다림
return value.call(target, ...args);
};
}
return value;
},
});
export class Provider {
// Repository 와 Service 를 Singleton 으로 관리하는 객채
private static repositories: Map<string, Repository<ObjectLiteral>> =
new Map();
private static services: Map<string, ObjectLiteral> = new Map();
static registerService<T extends ObjectLiteral>(
constructor: new () => T,
obj?: any,
) {
console.log(`register services ${constructor.name}`);
if (!Provider.services.has(constructor.name))
Provider.services.set(
constructor.name,
obj ?? serviceAsyncProxy(new constructor()), // service 의 모든 함수는 connectionHolder 를 거쳐감
);
}
static registerRepository<Entity extends Class>(entity: Entity) {
console.log(`register repository ${entity.name}`);
if (!Provider.repositories.has(entity.name))
Provider.repositories.set(
entity.name,
getDataSource().getRepository(entity),
);
}
static getService<Service extends ObjectLiteral>(
constructor: new (...args: any[]) => Service,
): Service {
!Provider.services.has(constructor.name) &&
Provider.registerService(constructor);
return Provider.services.get(constructor.name) as Service;
}
static getRepository<Entity extends Class>(
constructor: new (...args: any[]) => Entity,
): Repository<Entity> {
!Provider.repositories.has(constructor.name) &&
Provider.registerRepository(constructor);
return Provider.repositories.get(constructor.name) as Repository<Entity>;
}
}
export function Service(constructor: new (...args: any[]) => any) {
// @Service 데코레이션, Provider 에 해등 클래스가 스캔되면 등록된다
Provider.registerService(constructor);
}
export function Inject<Service extends ObjectLiteral>(
service: new (...args: any[]) => Service,
): any {
return (target: ObjectLiteral, filedName: string, index?: number) => {
Object.defineProperty(target, filedName, {
writable: false,
value: Provider.getService(service),
});
};
}
export function InjectRepository<Entity extends Class>(entity: Entity): any {
return (target: ObjectLiteral, filedName: string, index?: number) => {
Object.defineProperty(target, filedName, {
writable: false,
value: Provider.getRepository(entity),
});
};
}
이제 서비스를 작성하면 완성입니다.
// /src/server/service/todo.service.ts
import { Repository } from 'typeorm';
import { Todo } from '../entities/todo.entity';
import { Inject, InjectRepository, Service } from '../provider';
@Service
export class TodoService {
@InjectRepository(Todo) todoRepository: Repository<Todo>;
// @Inject(UserService) userService:UserService; // 다른 서비스 inject
async findAll() {
return this.todoRepository.find({});
}
async createTodo(content: string) {
return this.todoRepository.save({ content });
}
async updateComplete(id: string, complete: boolean) {
const todo = await this.todoRepository.findOneBy({ id });
if (!todo) throw new Error('400');
todo.complete = complete;
return this.todoRepository.save(todo);
}
async deleteTodo(id: string) {
const exist = await this.todoRepository.exist({ where: { id } });
if (!exist) throw new Error('400');
return this.todoRepository.delete({ id });
}
}
이렇게 서비스 로직을 분리하여, 셋팅을 한다면, 별도의 서버를 확장할 필요없이 Service 계층을 분리하여 프로젝트를 진행 할 수 있습니다.
// src/pages/api/todo/index.ts
// api router 에서 사용
import { getService } from '@/server/provider';
import { TodoService } from '@/server/service/todo.service';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const todoService = getService(TodoService);
const { content, id } = JSON.parse(req.body);
if (req.method == 'POST') {
console.log(content);
const entity = await todoService.createTodo(content);
res.send(JSON.parse(JSON.stringify(entity)));
} else if (req.method == 'PUT') {
await todoService.updateComplete(id, true);
} else return res.status(400).send('error');
res.send('ok');
}
// src/pages/index.tsx
// SSR 에서 사용
export const getServerSideProps = async () => {
const todoService = await getService(TodoService).findAll();
return {
props: {
items: JSON.parse(JSON.stringify(todoService)), // entity 로 오기 때문에
},
};
};
export function Home(props:{items:Todo[]}){
...
return <> ... </>
}
이렇게 프로젝트를 다양한 구조로 만들어보는 시도는 굉장히 좋은 것 같습니다.
나중에 시간이 되면, 재대로 완벽하게 구현해 볼 생각입니다.
긴글 읽어주셔서 감사합니다. 🚀