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