[Next.js] Next 의 서비스 계층 Dependency Injection

cgoing·2023년 5월 26일
16

소개글

원본글

안녕하세요! 오늘은 Next.js 프로젝트에서 TypeORM 을 사용하여 별도의 DB서버 없이 데이터베이스를 관리하는 방법에 대해 이야기하려고 합니다. 조금더 나아가 Next.js 프로젝트에서 Service 계층을 나누어 더 좋은 개발 환경을 구축 합니다.

Next.js 프로젝트에서 별도의 서비스 계층을 만드는 것은 코드의 재사용성을 높이고, 유지보수를 용이하게 하며, 비즈니스 로직과 데이터 액세스 로직을 분리하여 코드의 구조를 깔끔하게 유지하는 데 도움이 됩니다.

🧐 시작하기전에

Next.png

Next.js 로 개인 블로그를 만들면서, 매번 /pages/api 에 Service 로직을 작성하기엔 코드 관리가 너무 힘들고 🤬, express 혹은 Nest.js 같은 별도 서버를 분리?부착? 하기엔 서비스 규모가 작기 때문에 고민 하다가 , 유사 Spring boot, Nest.js 와 같은 DI 가 가능한 Soft 하고 Simple 한 서비스 계층을 만들자 라는 생각에서 시작 됐습니다.

0. 결과 코드 미리보기


GitHub example link 🌚
스크린샷 2023-05-25 오후 10.22.42.png
@Service 데코레이터를 통해 Provider 에 등록하게 됩니다.
@InjectRepository, @Inject 를 통해 SingleTon 으로 관리되는 Service, 혹은 Repository 를 주입 받습니다.

스크린샷 2023-05-25 오후 10.26.20.png
/pages/api 혹은 SSG,SSR 에서 Service 를 불러와 사용 할 수 있습니다.

1. Next.js Project 생성


npx create-next-app@latest
...
Would you like to use TypeScript with this project? No / Yes  <<  Yes!

혹은

yarn create next-app --typescript

typescript로 세팅을 해주셔야 합니다.

2. tsconfig.json 수정


서비스 계층 나눌떄 사용할 Provider를 작성하기위해 데코레이션을 작성하기 위해 옵션을 수정 해주세요.

// tsconfig.json
{
   ...,
  "compilerOptions": {
    ...
    "strictPropertyInitialization": false, //
    "experimentalDecorators": true,
  }
}

3. 패키지 설치


typeorm과 사용할 DB 패키지를 install 합니다.

yarn add mysql2 typeorm reflect-metadata

4. 데이터베이스 설정


filetree.png

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 계층을 분리하여 프로젝트를 진행 할 수 있습니다.

5. 사용 예시


// 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 <> ... </>
}

6. 마무리


이렇게 프로젝트를 다양한 구조로 만들어보는 시도는 굉장히 좋은 것 같습니다.
나중에 시간이 되면, 재대로 완벽하게 구현해 볼 생각입니다.

긴글 읽어주셔서 감사합니다. 🚀

profile
사랑해

0개의 댓글