DEV Community

Cover image for 서비스 패턴으로 조립하기 쉬운 백엔드 만들기
Danny Kim for Danny Kim - 한국어

Posted on

서비스 패턴으로 조립하기 쉬운 백엔드 만들기

조립하기 쉬운 시스템이란

잘 설계된 백엔드를 작업할 때는 마치 레고를 가지고 노는 기분이 듭니다. 많은 백엔드 개발자들이 (스프링, 루비온레일즈, 네스트 같은) 강한 컨벤션의 프레임워크를 선호하는 이유는 바로 코드를 간단한 컴포넌트로 분리하는게 쉽기 때문입니다.

이 포스트를 통해 조립하기 쉬운 시스템의 본질을 알아보고, (약한 컨벤션의) ExpressJS 프로젝트에도 적용하는 법을 보여드리겠습니다.

조립하기 쉬운 시스템을 구축하기 위해서는 각 컴포넌트가 간단하고 명확한 경계면을 가져야 합니다. 조립을 힘들게 하는 요소는 크게 두가지가 있는데요:

글로벌 & 탑레벨 변수

글로벌 변수나 모듈 레벨에 선언된 변수는 컴포넌트 간의 경계를 흐립니다. 환경변수가 좋은 예인데요, process.env는 어디서나 쓰기 쉽기 때문에 별 생각없이 남용하기 마련입니다. 하지만 이런 패턴은 어떤 함수가 어떤 환경변수에 의존하는지 확인하기 힘들게 만듭니다. 다시말해 함수의 의존성이 모호해진다는 뜻입니다.

큰 인터페이스

간혹 인터페이스가 명확하긴 하지만 너무 커다란 경우가 있습니다. 이 경우도 모호한 의존성만큼이나 나쁩니다. 큰 인터페이스를 가진 컴포넌트는 다른 컴포넌트로 바꿔 끼우는게 불편하기 때문이죠. ORM과 (object-relational mapping) 데이터베이스 어댑터가 이런 문제의 대표적인 예입니다.

개발자들은 ORM을 사용함으로써 명료한 인터페이스로 데이터베이스에 접근할 수 있지만, 대부분의 ORM들은 인터페이스 크기가 어마어마합니다. 따라서 한 ORM을 다른 컴포넌트로 바꿔 끼우는건 거의 불가능합니다.

간단한 코드 구조를 위한 리팩터링

이제 주요 문제점을 파악했으니 개선 방안을 찾을 차례입니다. 각 문제점 별 코드 예시와 리팩터링 기법을 살펴봅시다.

글로벌 & 탑레벨 변수

예시

환경변수는 흔히 아래와 같이 사용됩니다:

// env.ts
import dotenv from "dotenv";
dotenv.configure();
export const SESSION_SECRET = process.env.SESSION_SECRET;
export const DATABASE_URI = process.env.DATABASE_URI;
Enter fullscreen mode Exit fullscreen mode
import { DATABASE_URI } from "./env";
mongoose.connect(DATABASE_URI);
Enter fullscreen mode Exit fullscreen mode

이와 같은 탑레벨 변수는 앞서 언급했듯이 컴포넌트 간의 경계면을 흐립니다.

리팩터링

DATABASE_URI를 탑레벨 변수로 노출하는 대신 함수 스코프에 집어넣을 수 있습니다. 감싸는 함수의 이름은 EnvService라고 합시다.

// env.ts
import dotenv from "dotenv";
export const EnvService = () => {
  dotenv.configure();
  return {
    SESSION_SECRET: process.env.SESSION_SECRET,
    DATABASE_URI: process.env.DATABASE_URI,
  };
};
export type EnvService = ReturnType<typeof EnvService>;
Enter fullscreen mode Exit fullscreen mode

데이터베이스가 환경변수에 의존한다는걸 표현하기 위해서 데이터베이스 관련 코드도 함수로 감싸줄 수 있습니다.

import { EnvService } from "./env";
export const DatabaseService = (env: EnvService) => {
  mongoose.connect(env.DATABASE_URI);
  ...
};
Enter fullscreen mode Exit fullscreen mode

이렇게 하면 데이터베이스 코드가 환경변수에 의존한다는게 아주 명확하게 보입니다. 글로벌 변수를 사용하지 않아도 말이죠.

큰 인터페이스

그렇다면 큰 인터페이스는 어떻게 처리하면 될까요?

예시

Express 핸들러 안에서 흔히들 ORM 클래스를 직접 사용합니다.

app.get("/user/:id", async (req, res, next) => {
  try {
    const user = await UserModel.findById(req.params.id);
    //                 ^ 이렇게 말이죠
    res.send({ user });
  } catch (err) {
    next(err);
  }
});
Enter fullscreen mode Exit fullscreen mode

이 구현에는 두가지 문제점이 있습니다.

  1. UserModel이 탑레벨 클래스입니다. "/user/:id" 핸들러는 UserModel에 의존적이지만, 이 의존성이 명시적이지가 않습니다.
  2. UserModel의 인터페이스가 너무 큽니다. 대부분의 ORM 라이브러리들이 거대한 인터페이스를 가지죠. 데이터베이스의 다양한 기능들과 설정들을 전부 다루는 만큼, ORM이 피할수 없는 운명이라고 할 수 있습니다.

한마디로 핸들러와 UserModel 사이의 결합도가 지나치게 높습니다. 이런 구현에서는 두 컴포넌트를 깔끔하게 분리하기 쉽지 않습니다.

리팩터링

앞서 사용한 리팩터링 기법을 활용해서 의존성을 명시적으로 표현할 수 있습니다. ORM쪽부터 시작합시다.

// user.ts
export const UserService = () => {
  return {
    getById: (userId: string) => UserModel.findById(userId),
  };
};
export type UserService = ReturnType<typeof UserService>;
// type: { getById: (userId: string) => Promise<User> }
Enter fullscreen mode Exit fullscreen mode

복잡한 탑레벨 UserModelUserService 함수로 감싸주었습니다. UserService 함수는 UserModel에 비해 인터페이스가 훨씬 작다는걸 볼 수 있습니다.

이제 UserService와 핸들러 사이의 의존성을 표현해주면 됩니다.

먼저 인라인으로 선언된 핸들러를 밖으로 추출하는 작업을 해줍니다.

const userHandler: RequestHandler = async (
  req, res, next
) => {
  try {
    const user = await UserModel.findById(req.params.id);
    res.send({ user });
  } catch (err) {
    next(err);
  }
};
app.get("/user/:id", userHandler);
Enter fullscreen mode Exit fullscreen mode

그리고 핸들러를 함수로 감싸서 의존성을 표현해줍니다.

const UserHandler = (
  userService: UserService
): RequestHandler => async (req, res, next) => {
  try {
    const user = await userService.getById(req.params.id);
    res.send({ user });
  } catch (err) {
    next(err);
  }
};
app.get("/user/:id", UserHandler(userService));
Enter fullscreen mode Exit fullscreen mode

핸들러와 서비스 사이에 아주 깔끔한 인터페이스가 생긴걸 확인할 수 있습니다!

마무리 작업

탑레벨 변수를 함수로 감싸고 의존성을 파라미터로 표현함으로써 컴포넌트 간의 경계면을 단순화 할 수 있었습니다. 이 기법을 "inversion of control" (제어의 반전)이라고 부릅니다.

In software engineering, inversion of control (IoC) is a programming principle. IoC inverts the flow of control as compared to traditional control flow. In IoC, custom-written portions of a computer program receive the flow of control from a generic framework. A software architecture with this design inverts control as compared to traditional procedural programming: in traditional programming, the custom code that expresses the purpose of the program calls into reusable libraries to take care of generic tasks, but with inversion of control, it is the framework that calls into the custom, or task-specific, code.

눈치채셨을지도 모르겠지만, 아직 구현에 한가지 빠진 부분이 있습니다. 핸들러가 파라미터로 서비스를 전달받아야 하는데, 이걸 제공하는 코드가 없습니다.

app.get("/user/:id", UserHandler(userService));
// `userService`는 어디서 와야 할까요?
Enter fullscreen mode Exit fullscreen mode

이걸 위해 "메타 서비스"를 만들어 봅시다. 서비스를 제공하는 서비스 말이죠.

import { EnvService } from "./env";
import { SessionService } from "./session";
import { UserService } from "./user";

type ServiceMap = {
  user: UserService;
  env: EnvService;
  session: SessionService;
};

// Meta service
export const ServiceProvider = () => {
  // 서비스들을 선언.
  const env = EnvService();
  // 각 서비스는 다른 서비스에 의존할 수 있습니다.
  const user = UserService(env);
  // 물론 여러 서비스에 의존하는것도 가능합니다.
  const session = SessionService(env, user);

  const serviceMap: ServiceMap = {
    user,
    env,
    session,
  };

  /**
   * Get service by service name.
   */
  const getService = <TServiceName extends keyof ServiceMap>(
    serviceName: TServiceName
  ) => serviceMap[serviceName];

  return getService;
};

export type ServiceProvider = ReturnType<typeof ServiceProvider>;
Enter fullscreen mode Exit fullscreen mode

이 메타 서비스는 아래와 같이 사용 가능합니다:

const service = ServiceProvider();
const env = service("env"); // EnvService
Enter fullscreen mode Exit fullscreen mode

App이 ServiceProvider에 의존한다는걸 표현해줍니다.

// app.ts
import express from "express";
import { ServiceProvider } from "./service";
import { UserHandler } from "./handlers";

export const App = (service: ServiceProvider) => {
  const app = express();
  app.get("/user/:id", UserHandler(
    // 필요한 서비스는 `ServiceProvider`를 통해 제공.
    service("user"),
  ));
  return app;
};
Enter fullscreen mode Exit fullscreen mode

마지막으로, ServiceProvider를 생성해서 App에 넘겨줍니다!

// index.ts
import { App } from "./app";
import { ServiceProvider } from "./service";

const service = ServiceProvider();
const app = App(service);
app.listen(service("env").PORT);
Enter fullscreen mode Exit fullscreen mode

그래서 이게 왜 좋은가요?

필요한 기능들을 서비스의 형태로 묶어줌으로써 아주 이해하기 쉬운 프로젝트 구조를 갖게 됩니다.

  • 인터페이스가 작고 명시적이라면, 서비스의 구현을 수정하는 작업이 쉬워집니다. Firestore를 MongoDB로 교체하고 싶으시다고요? 해당 작업은 데이터베이스와 직접 상호작용하는 서비스에만 영향을 끼칩니다. 나머지 코드베이스는 거의 건드릴 필요가 없습니다. 만약 핸들러에서 ORM 클래스를 직접 사용하고 있었다면 이야기가 다르겠죠. 물론 데이터베이스를 갈아끼우는게 자주있는 일은 아니지만, 요점은 코드 수정이 쉬워진다는 것입니다.
  • 테스팅도 훨씬 쉬위집니다. 첫번째 논점과 연결되는 부분인데요, 테스트를 하기 위해서는 서비스를 mock으로 교체해야 할 일이 많기 때문입니다. 인터페이스가 작고 명시적이면 (mock, fake, stub 같은) test double을 만드는게 쉽습니다. 또한 어떤 컴포넌트를 mocking 해줘야하는지도 확인하기 간편합니다. 제어 반전을 사용했다면 의존성은 그냥 파라미터 목록으로 다 보이니까요.

요약

아래는 이 포스트에서 사용한 리팩터링 기법에 대한 요약입니다.

  1. 탑레벨 변수/함수를 서비스 함수로 감싸기. 서비스 함수의 인터페이스는 작아야 함.
  2. 핸들러를 외부 함수로 추출하기.
  3. 의존성을 파라미터로 표현하기 (제어 반전).
  4. 메타 서비스 구현하기 (ServiceProvider).
  5. ServiceProvider를 통해 핸들러들에게 서비스 제공하기.

이 패턴이 실제 프로젝트에서 어떻게 사용되는지 보고 싶으시다면 제 개인 프로젝트에서 확인하실 수 있습니다:

Top comments (0)