[Node.js] 2. 핸들러, 서비스, 레포지토리 구조로 만들기 (번역)

E-ρ(rho) 이로·2022년 6월 7일
0

✅ [번역] Tao of Node

목록 보기
2/3
post-thumbnail
이 블로그 글은 제시된 원문을 직접 번역한 것입니다. (오역이 있을 수 있으니 꼭 원문을 참고하시길 바라요!)

This article will give you a translated version of Original Article.

Structure & coding Practices

🗨 Start with a modular monolith
:: 모듈화된 monolith(architecture)로 시작하기

새로운 app을 만들기 전에 반드시 app이 monolith인지 microservice 기반인지를 생각해보는 것이 가장 중요할 것이다. (monolith architecture vs. microservice architecture 정리한 글 링크)

최근 대부분의 개발자와 아키텍터들은 microservice achitecture를 선택하고있다. 서비스를 확장하기에도 좋고, (코드) 독립성을 가질 수 있으며 큰 규모의 프로젝트를 진행시키면서 나오는 조직적인 어려움을 해결하기 때문이다.

Microservice는 하나의 어플리케이션을 작은 서비스들로 쪼개고 서로 다른 것끼리 communication하는 패턴으로, 널리 쓰이고 있다. 가장 간단한 예시는 사용자(user), 물품(product), 주문(order)에 대한 컴포넌트를 가지고 있는 시스템을 들 수 있다. 각각의 독립체(컴포넌트)사이의 경계가 잘 정의된 이커머스가 종종 언급되는 예시이다. (항상 그런 것은 아니다!)

개발하고 있는 도메인에 따라서 그 경계는 아주 모호할 수도 있다. 이런 모호함으로 어떤 서비스에서 어떤 운영 방식이 도입되어야하는지 구별하기 어려워진다. 서비스를 분리하는 것은 많은 이점을 제공하지만, 분배 시스템의 문제를 가져온다. 그래서 나(필자)는 항상 모듈로 된 monolith로 시작해서 어플리케이션의 진화를 가능케 하라고 조언하는 편이다.

나는 monolith 구조가 저평가되고 있다는 마이너한 의견을 지지한다(ㅋㅋ). monolith는 개발자로하여금 더 빨리 움직이게 하고, 특정한 모듈 하나에 초점을 맞추면서 반-독립(고립)된 상태에서 일할 수 있게 한다. 모든게 같은 레포지토리에 있기때문에 이리저리 옮겨다니기에도 편하고, 만약 모듈성(modularity)을 잘 유지한다면, monolith에서 서비스를 추출해내는건 그리 어렵지 않을 것이다.

각 모듈이 잠재적으로 분리된 서비스라고 생각하고, 그 모듈들 사이를 연결(communication)할 약속(contracts)에 의존해보라.

🗨 Split the implementation in layers
:: 층으로 나누기

대부분의 Node 서비스 디자인에서 가장 큰 결함은 핸들러 기능에서 너무 많은 것을 한다는 것이다. 이것은 MVC 구조를 사용하는 app에서 컨트롤러 클래스가 경험하는 문제이다. 전송(transport), 데이터 액세스, 및 비즈니스 로직을 단일 기능으로 처리함으로써 긴밀하게 결합된 기능들을 만들어낼 수 있다.

유효성 검사(validation), 비즈니스 로직, 요청 객체(request object)에서 값을 직접적으로 사용하는 데이터베이스 호출을 볼 일은 잘 없긴 하다. 아래 예시는 매우 간단한 예시다!

// 👎 Avoid creating handlers with too many responsibilities
// unless the scope of the application is small
const handler = async (req, res) => {
  const { name, email } = req.body

  if (!isValidName(name)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  if (!isValidEmail(email)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  await queryBuilder('user').insert({ name, email })]

  if (!isPromotionalPeriod()) {
    const promotionalCode = await queryBuilder
      .select('name', 'valid_until', 'percentage', 'target')
      .from('promotional_codes')
      .where({ target: 'new_joiners' })

    transport.sendMail({
      // ...
    })
  }

  return res.status(httpStatus.CREATED).send(user)
}

위의 예시는 많은 유지보수를 요구하지 않는 작은 app에서는 수용가능한 접근법이다. 그러나 이런 결정으로 인해 더 큰 규모의 확대가 어려워진다.

핸들러들은 더 길고, 읽기 어려워지고, 테스트하기 어려워진다. 기능이 한 가지에 집중해야하는 것은 아주 흔한 이해이지만, 이 경우, 핸들러 기능은 너무 많은 일을 하고 있다. 핸들러에서 validation, business logic, data fetching을 처리해서는 안된다.

대신에, 핸들러 기능은 전송(HTTP) 층에 초점을 맞춰야 한다. 데이터 불러오기(fetching)와 외부 연결(communication)에 관한 모든 것은 그 자체의 기능이나 모듈에서 추출되어야 한다.

// 👍 Handlers should only handle the HTTP logic
const handler = async (req, res) => {
  const { name, email } = req.body

  if (!isValidName(name)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  if (!isValidEmail(email)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  try {
    const user = userService.register(name, email)
    return res.status(httpStatus.CREATED).send(user)
  } catch (err) {
    return res.status(httpStatus.INTERNAL_SERVER_ERROR).send()
  }
}

위에서 언급한 데이터 불러오기와 외부적인 연결을 처리하는 모듈은 "서비스"라고 불린다. 이 배경에 어떤 역사적인 이유가 있는지는 확실치 않지만, 우리가 말한 뜻을 모두가 이해하기 위해서 용어를 자세히 들여다보자.

이러한 로직을 "서비스"로 묶음으로써 우리는 층으로 움직이는 시스템(layer-driven system)을 구축하게 된다. 핸들러는 전송에 관련된 것을 처리하고, 우리의 서비스는 HTTP 요청에 응답하는 것인지, 이벤트로 부터 발생된 메시지인지 알지 못한 채 도메인과 데이터에 접근하는 로직을 다룬다.

이렇게 하는 이유는 app 내에서 쓰임이 다른 것끼리 구분하고, 경계선을 그으려고 하는 것이다.조금 덜 복잡한 app의 경우 이 단계만으로도 큰 개선이 될 것이다.

그러나 서비스가 계속해서 유저, 프로모션 코드, 이메일과 관련된 로직을 처리한다는 것을 깨닫게 될 것이다. 우리가 이미 전송 로직과 다른 것들 사이에 경계를 두었음에도 불구하고 역할(책임; responsibilities)의 측면에서는 서비스가 아직도 너무 많은 일을 하고있다.

// user-service.js
export async function register(name, email) {
  const user = await queryBuilder('user').insert({ name, email })]  // 아래와 차이점

  if (!isPromotionalPeriod()) {
    const promotionalCode = await promotionService.getNewJoinerCode()
    await emailService.sendNewJoinerPromotionEmail(promotionalCode)
  }

  return user
}

유저와 직접 관련되지 않은 로직을 따로 추출함으로써 이 서비스를 스스로 모든 것을 다하는것 대신 대표자(delegate)로 만들 수 있다. 하지만 말할 수 있는 것은, 서비스의 역할이 아직도 너무 방대하다.

넓게 통용되는 패턴은 데이터 접근 로직을 "레포지토리(repository)"로 빼는 것이다.

// user-service.js
export async function register(name, email) {
  const user = await userRepository.insert(name, email)  // 위와 차이점

  if (!isPromotionalPeriod()) {
    const promotionalCode = await promotionService.getNewJoinerCode()
    await emailService.sendNewJoinerPromotionEmail(promotionalCode)
  }

  return user
}

데이터 접근을 레포지토리로 요약함으로써 서비스는 오직 비즈니스 로직을 위한 것이 된다. 게다가 테스트와 가독성이 높아진다. 가장 중요한건, 비즈니스와 데이터 사이를 기능적으로 구분할 수 있게 된 것이다.

이제 app의 로직이 전송, 도메인, 그리고 데이터 접근 층으로 나눠졌다. 각각을 바꿀 때 다른 것을 조금 바꾸거나 혹은 전혀 바꾸지 않아도 된다.만약 app이 Kafka 메세지를 소화시킬 필요가 있다면 우리는 다른 수송 층을 추가하고, 도메인과 데이터는 재사용하면 된다.

REST에서 gPRC 또는 messaging으로 옮겨가는건 정말 잘 일어나지 않는 일이다. 데이터 베이스를 바꾸는 경우는 더 희박하다. 그러나 그러한 가능성들을 인정함으로써 우리는 확장성, 가독성, 테스트가능성을 매우 향상시킬 수 있다.

아직 이러한 구조는 Node에서 유명하지는 않다. 모든 app이 이런 구조가 도움이 되는 것은 아니기 때문이다. 내가 주는 충고는 복잡성이 커지면 층을 추가하라는 것이다. 처음에는 핸들러만 가지고 시작해보다가 구조를 추가하는 위의 단계를 따라가보라.

My comment
이전에도 3계층으로 나누는걸 했었던 것 같은데... 이전에는 db(schema, models로 구성), routers, service, (필요시 middlewares)로 구성했었다.
1. schema는 데이터 베이스에 저장되는 필드 값의 속성을 지정해줄 수 있었다.
2. router에서는 HTTP 요청과 응답이 이루어지는 층이었다.
3. service는 라우터와 연결되어 에러메시지가 작성되고 여러가지 기본적인 로직들이 작성되는 곳이었다.
4. model은 데이터베이스에 접근해서 직접 query가 일어나는 곳이었다.
아마도 위 글에서 읽었던 handler-service-repository가 router-service-model 인걸까..? 이건 여쭤봐야겠당

0개의 댓글