Ayden's journal

02. Router를 통한 모듈 분리

NestJs와 달리 ExpressJs(이하 EJ)는 모듈 구성이 강제되지 않는다. 따라서 어떤 식으로 코드를 분리할 것인지는 전적으로 개발자의 몫이라고 생각된다(이러한 점이 NestJs를 더 선호하게 만드는 이유이지 않을까). EJ에서는 Router라는 메소드를 제공하는데, 이를 사용하면 각각의 코드를 라우트에 따라 분리해줄 수 있다. 그리고 이렇게 분리된 라우트는 use 메소드를 사용해 한 곳에서 호출한다.

// app.ts
const app = express();

app.use(express.json());
app.use(cookieParser());

app.use("/products", productRoutes);
app.use("/articles", articleRoutes);

app.listen(process.env.PORT || 3000, () => console.log("Server Started"));

 

컨트롤러

나에게 있어 컨트롤러는 요청에 실려있는 정보를 분리 및 검증하고, 필요한 서비스를 호출하며, 이 결과를 클라이언트로 돌려보내는 역할을 한다. 그리고 Router 메소드를 통해 분리된 최상단 레이어가 컨트롤러 역할을 맡게 된다(NestJs에서도 컨트롤러가 라우팅 인터페이스 역할을 한다).

// prouduct.controller.ts
export const productRoutes = Router();

productRoutes.post('/', (req: Request, res: Response) => {
  // 로그인 여부 확인
  authChecker(req.cookies.email)
  
  // 서비스에 필요한 값 추출
  const { email } = req.cookies
  const productField = req.header
  
  // 값이 유효한지 superstruct로 확인
  assert(productField, CreateProduct);

  // 서비스 호출
  const product = createProduct(email, productField)

  // 호출 결과를 클라이언트에 제공
  if (product) return res.status(201).send()
  else return res.sendStatus(500)
});

 

요청에 따라서는 인증Authentication된 사용자인지 여부도 확인해줘야 한다. 나는 인증 자체는 미들웨어를 통해서 처리하고, 그 결과 여부를 각 컨트롤러에서 확인할 수 있게 해두었다. 만약 쿠키 객체에 email 항목이 없다면 authChecker가 예외를 던지는 식이다.

// CustomError.ts
export class CustomError extends Error {
	constructor(
		public code: string,
		public message: string,
	) {
		super(message);
	}
}


// authChecker.ts
export const authChecker = (email: string | null) => {
	if (!email) {
		throw new CustomError('CE_Unauthorized', '로그인이 필요한 서비스입니다');
	}
};

 

이렇게 에러를 던지면 어딘가에서는 에러를 받아주어야 한다. 내 경우에는 이를 asyncErrorHandler 함수에 모두 위임하고 있다. 이 함수는 컨트롤러에서 발생할 수 있는 각종 에러를 받아서 응답으로 처리해준다. 덕분에 에러로 인해 서버가 뻗어버리는 경우를 방지할 수 있다. 필요에 따라서는 각 에러 발생 사유에 따른 http 응답 코드나 메세지도 보내줄 수 있다.

export function asyncErrorHandler(
  handler: (req: Request, res: Response) => void | Promise<void>,
) {
  return async function (req: Request, res: Response) {
    try {
      await handler(req, res);
    } catch (e: any) {
      switch (true) {
        case e.name === 'StructError' || e instanceof Prisma.PrismaClientValidationError:
          res.status(400).send({ message: e.message });
          break;
          
      case e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025':
          res.sendStatus(404);
          
          break;
      case e instanceof CustomError && e.code === 'CE_Unauthorized':
          res.status(401).send({ message: e.message });
          break;
          
      default:
          res.status(500).send({ message: e.message });
      }
    }
  }
}

 

따라서 실제 컨트롤러는 asyncErrorHandler 함수로 감싸져있다. 그 형태를 간단히 나타내자면 아래와 같을 것이다.

productRoutes.post('/', asyncErrorHandler(
  (req: Request, res: Response) => {
  
}))

 

서비스

내가 생각하는 서비스는 컨트롤러와 레포지토리 사이를 매개하며, 비즈니스 로직을 주입하는 곳이다. 필요에 따라서는 둘 이상의 레포지토리를 호출하는 경우도 있으며, ws를 사용해서 로그인된 유저에게 즉시 알림을 보내는 것도 서비스 영역에서 진행하고 있다.

// product.service.ts
export async function createProduct(email: string, productField: ProductField) {
  // 레포지토리 호출
  const product = await Product_Create(email, productField)
  
  // DB 내용에 비즈니스 로직 추가
  const formattedProduct = ownerIdFormatter(product);
  
  return formattedProduct
}

 

레포지토리

서비스나 레포지토리 둘 다 Provider에 해당하지만, 서비스가 비즈니스 로직에 초점이 맞춰져있다면, 레포지토리는 DB와의 통신에 초점이 맞춰져있다. 따라서 하나의 레포지토리는 하나의 모델을 대표하며, 레포지토리의 각 함수는 모델에서 제공하는 각 메소드와 대응된다.

// product.repository.ts
export async function Product_Create(email: string, productField: ProductField) {
  // 데이터베이스와 통신은 레포지토리의 주된 업무
  const product = await prisma.product.create({
    data: {
      ...productField,
      favoriteCount: 0,
      ownerId: {
        connect: {
          email,
        },
      },
    }
  });
  
  return product
}

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기