외주 제작 경험

가벼운 Express의 한계를 해결하는 Node.js 백엔드 아키텍처 설계

Frisbeen 2026. 3. 9. 21:36

배경

최근 급하게 스타트업 홈페이지 외주 제작 프로젝트를 총괄하게 되었습니다.

프로젝트 특성상 복잡한 백엔드 시스템이 필요한 것은 아니었고, API의 개수도 많지 않을 것으로 예상되었습니다.

따라서 빠르게 개발을 시작하기 위해 Node.js 기반의 Express를 사용하기로 결정했습니다.

 

Express는 가볍고 유연한 프레임워크이기 때문에,

초기 개발 속도를 확보해야 하는 상황에서 상당히 좋은 선택지였습니다.

하지만 실제로 개발을 진행하면서 한 가지 문제가 생겼습니다.

Express는 매우 자유로운 프레임워크이지만, 그만큼 프로젝트의 구조를 어떻게 설계할지에 대한 기준을 거의 제공하지 않습니다.

처음에는 단순한 API 몇 개로 시작했지만, 기능이 조금씩 추가되면서 자연스럽게 다음과 같은 고민이 생기기 시작했습니다.

  • Controller에 로직이 계속 쌓이기 시작한다.
  • 데이터 접근 코드가 여러 곳에 흩어졌다.
  • 비즈니스 로직과 HTTP 로직이 섞이기 시작했다.

프로젝트의 규모가 그렇게 크지는 않았지만,

이 상태로 계속 개발을 진행하면 코드의 역할이 점점 모호해질 것이라는 느낌이 들었습니다.

 

그래서 저는 Express가 기본적으로 제공하는 미들웨어 기반 요청 처리 구조 위에, Spring에서 많이 사용하는 Layered Architecture(계층형 아키텍처) 를 직접 적용하기로 했습니다.

 

처음에는 단순히 코드 정리를 위한 시도였지만,

결과적으로 Express의 유연함과 레이어 아키텍처의 안정성을 동시에 가져갈 수 있는 구조가 되었습니다.

Express

Express는 Node라는 JS 실행환경에서 그 위에 HTTP 서버를 직접 만드는 방법론 중 하나입니다.

이 프레임워크는 기본적으로 요청을 미들웨어 체이닝으로 처리합니다.

아래와 같은 프로세스로 말이죠.

Client Request
      ↓
app.use() 미들웨어
      ↓
app.use() 미들웨어
      ↓
app.get() / app.post() 라우트 핸들러
      ↓
     
Response

Express는 내부적으로 스택구조를 하고 있습니다.

stack = [
  middleware,
  middleware,
  route handler
]

따라서 요청이 왔을때, 미들웨어들 부터 실행되고 마지막에 되서야 라우트 핸들러가 실행되는 구조인 것이죠.

라우트 핸들러에서 응답(Respond)를 하는 구조입니다.

middleware

미들웨어는 보통 공통 로직을 처리하는 역할을 합니다.

쉽게 말하면, 클라이언트의 요청이 여러개의 ‘중간 단계”를 거쳐 최종 처리되는 것 입니다.

이 ‘중간 단계’가 미들웨어입니다. 공통 정책을 적용하는 필터라고 생각하면 쉽습니다.

정책을 중앙화한다. (미들웨어 체이닝)

예를 들어, 아래와 같은 API 들이 있다고 가정합니다.

  • 로그인 체크
  • 토큰 검증
  • 요청 로그 남기기
  • 에러 잡기

어떤 페이지에 접속하려면 위 같은 공통된 동작을 한다고 가정한다면, 아래와 같이 프로필 페이지에 들어갈 경우, 주문 페이지에 들어갈 경우 등등

모든 라우팅에 들어가야하는 코드들의 공통된 동작이 불필요하게 중복되어 버립니다.

app.get("/profile", (req, res) => {
  // 로그인 체크
  if (!req.headers.authorization) {
    return res.status(401).send("Unauthorized");
  }

  // 로그 남기기
  console.log("profile accessed");

  res.send("profile data");
});

app.get("/orders", (req, res) => {
  if (!req.headers.authorization) {
    return res.status(401).send("Unauthorized");
  }

  console.log("orders accessed");

  res.send("orders data");
});

당연히 코드가 반복이 된다면, 그에 따라오는 많은 부작용들이 자연스럽게 따라오겠습니다.

정책이 변경될 경우의 모든 중복된 부분을 수정해야하고 당연히 그에 따라 유지보수가 쉽지 않겠습니다.

따라서 미들웨어라는것을 도입하여 정책을 중앙화하는 특성이 Express의 중요한 특징입니다.

미들웨어를 등록하는 법

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).send("Unauthorized");
  }

  console.log("request:", req.url);

  next();
});

use 함수 내부에다가 필요한 함수들을 정의합니다.

등록된 미들웨어는 요청이 들어올 때마다 실행되고, next()를 호출하면 다음 단계로 넘어갑니다.

형태는 딱 이거예요.

app.use((req, res, next) => {
  // 여기서 공통 처리
  next(); // 다음 미들웨어/라우트로 넘김
});

app.use(mw1);
app.use(mw2);
app.use('/api/settings', settingsRouter);
app.use(errorHandler);

next()를 호출해야 다음으로 넘어갑니다.

next(err)로 던지면 전역 errorHandler가 받습니다.


미들웨어는 어디다가 붙여야하는가?

Express에서 미들웨어를 붙일 수 있는 대표적인 3가지가 있습니다.

전역으로 붙이기: 모든 요청에 적용 (app.use)

모든 요청이 공통으로 해야 하는 정책이면 전역으로 붙입니다.

  • CORS
  • body parser
  • 요청 로깅
  • 업로드 처리의 공통 부분

src/index.ts

대부분 공통으로 적용되는 미들웨어는 서버의 엔트리 포인트에다가 등록합니다.

app.use(cors(...));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);

이렇게 해두면, 위에서 말한것과 같이 컨트롤러/라우트마다 같은 코드 반복할 필요가 없어집니다.


특정 prefix에만 붙이기: “라우터 단위로 묶어서” 적용하기

이건 “이 경로 아래로 들어오는 요청만 공통 적용” 하고 싶을 때 씁니다.

예를 들면 이런 느낌입니다.

app.use('/api', apiCommonMiddleware, apiRouter);

즉, /api/* 요청은 모두 공통 미들웨어를 타게 만들 수 있습니다.

이렇게 하면 문제는, 현실에서는 같은 Routing prefix 안에도 공개/비공개 API가 섞일 수 있다는 점 참고하시길 바랍니다.

따라서 같은 prefix 내 인증 비인증 API를 구분하기 위해 아래 특정 라우트에만 붙이는 방법 또한 소개하겠습니다.


특정 라우트에만 붙이기 (인증이 필요한 API)

Express의 라우팅 함수 시그니처는 이런 느낌입니다.

router.get(path, middleware1, middleware2, controller);

여러 개의 미들웨어/핸들러를 콜백처럼 순서대로 나열하는 구조이고,

각 미들웨어는 (req, res, next)를 받고, next()를 호출해서 다음 함수로 넘깁니다.

함수들이 비동기(async)여도 상관없고, await 쓰다가 에러 나면 next(error)로 넘기면 됩니다.

이게 실전에서 제일 많이 쓰는 패턴입니다.

router.get('/secret', authenticate, controller);

클라이언트에게 제공되는 여러개의 api 들중에 어드민 인증이 필요한 API 가 잇다고 가정해본다면

모든 클라이언트가 아닌 어드민만 사용할 수 있게 보안조치가 필요합니다.

예를 들어 웹사이트의 Stats 최대 설정값을 바꿀 수 있는 API가 있다고 가정한다면, 이는 보안조치가 무조건 필요할 겁니다.

아래는 일일 진단 최대 한도수를 어드민에서 직접 관리 및 수정할 수 있는 API입니다. 일반 홈페이지 고객은 이를 수정할 수 없습니다.

따라서 router값을 가져올때 , 함수의 콜백 인자에 authenticate 미들웨어를 붙입니다. 다시말하면 직접 미들웨어를 삽입하는 것입니다.

이렇게 되면 위에 존재하는 공용 미들웨어를 모두 거치고, 그 다음 또 한번 인증 미들웨어를 거치고 된 후 업무로직인 컨트롤러로 움직이게 되는 겁니다.

router.get('/daily-diagnostic-max', authenticate, statsController.getDailyDiagnosticMax);
router.patch('/daily-diagnostic-max', authenticate, statsController.updateDailyDiagnosticMax);

반대로, 공개 API는 authenticate를 안 붙입니다.

위와 다르게 바로 컨트롤러(업무 로직)으로 옮겨지게 됩니다.

실제 업무 로직 (미들웨어 필터링이 모두 완료된 이후)

즉, 위같은 모든 필터링이 끝난 이후에야 실제 업무 로직이 실행되겠습니다.

전체 흐름을 단순화하면 다음과 같습니다.

Request
      ↓
Global Middleware
(cors, json parser, requestLogger)
      ↓
Prefix Middleware (/api)
      ↓
Route Middleware (authenticate)
      ↓
Controller
      ↓
Service
      ↓
Repository
      ↓
Sequelize Model
      ↓
Database
      ↓
Response

Express 자체는 여기서 미들웨어 체인과 라우팅까지만 제공하는 프레임워크입니다.

즉 Express는 요청을 어디로 보낼지와 어떤 미들웨어를 거칠지를 처리할 뿐,

그 이후의 비즈니스 로직 구조는 개발자가 직접 설계해야 합니다.

그래서 저는 여기서 Spring 스타일의 계층 구조를 Express 위에 얹었습니다.

Express 위에 다음과 같은 MVC를 확장한 layered Architecture를 삽입하였습니다.

Express의 요청 흐름 위에 Controller / Validator / Service / Repository / Model 로 역할을 분리한 Spring 오마주식 계층 구조를 구축했습니다.

실제 활용 사례

설명하기 쉽게 하기 위해 많은 도메인 로직 중에, Stats라는 도메인 로직이 실제로 각 계층을 어떻게 통과하는지 작게나마 흐름을 정리하겠습니다.

Stats라는 도메인 로직은 회사 내 서비스인 AI 진단을 받은 고객의 수를 현재 시간 진행도를 기반으로 계산하여 보여주는 역할입니다.


1. Controller — HTTP 요청의 입구

Controller는 HTTP 레이어를 담당하는 계층입니다.

  • Request에서 필요한 값 추출
  • Validator 호출
  • Service 호출
  • Response 반환

즉 Controller는 요청과 응답만 관리하고 실제 비즈니스 로직은 직접 수행하지 않습니다.

export async function getDailyDiagnosticCount(_req, res, next) {
  try {
    const statsService = new StatsService();
    const data = await statsService.getDailyDiagnosticCount();

    res.status(200).json({
      status: "success",
      data
    });
  } catch (error) {
    next(error);
  }
}

여기서 볼 수 있듯이 Controller는

  • Service를 호출하고
  • 결과를 HTTP 응답으로 변환하는 역할만 수행합니다.

How Stats Flows? - Controller

예를들어 클라이언트가 /stats/daily-diagnostic-count라는 API를 호출한다고 가정합니다. (실제로 이는 홈페이지 내 일일 진단검사를 받은 사용자수를 가져오는 API입니다.) 이 컨트롤러는 위 코드 기준, 서비스 계층을 호출하여 실제 데이터를 가져옵니다. 그 이후 JSON으로 데이터를 변환하는 역할을 합니다.

컨트롤러는 HTTP 요청을 서비스 계층으로 전달하는 입구 역할은 합니다.


2. Validator — 입력값 검증

Controller로 들어오는 모든 요청이 항상 정상적이라고 보장할 수는 없습니다.

저는 입력값 검증 로직을 별도의 계층으로 분리하였습니다.

예를 들어, email 형식이나 password 길이,필수 입력 항목 등등을 위반하였을 경우를 대비합니다.

Validator는 다음 역할을 담당합니다.

  • req.body / req.query 검증
  • 잘못된 입력에 대한 명확한 에러 메시지 생성

예를 들어:

const validation = validateUpdateDailyDiagnosticMaxBody(req.body);

if (!validation.success) {
  return res.status(400).json({
    status: "error",
    message: validation.message
  });
}

이렇게 하면 입력 검증 로직이 Service로 흘러 들어가지 않게 됩니다.

How Stats Flows? Flows? - Validation

Stats API중에는 관리자가 일일 진단 최대치를 수정해야하는 API도 존재합니다.

아마 예상되는 클라이언트의 POST 값은

{
  "max": 100
}

Validation 계층은 사용자의 값이 숫자인지, 허용범위는 맞는지, 등등을 검증합니다.

이 계층을 통과해야만 Service 계층으로 비로소 들어갈 수 있습니다.


3. Service — 비즈니스 로직의 중심

Service는 실제 비즈니스 규칙을 담당하는 계층입니다.

여기서는 HTTP나 Express에 대해 전혀 알 필요가 없습니다.

즉 Service는

  • req
  • res
  • next

같은 Express 객체를 모릅니다.

오직 비즈니스 로직만 처리만 해야하는 계층입니다.

예시 코드는 아래와 같습니다.

export class StatsService {

  async getDailyDiagnosticCount() {
    const max = await SiteSettingRepo.getDailyDiagnosticMax();

    const progress = getTodayProgressUTC();

    const base = Math.floor(progress * (max + 0.5));
    const add = getTwoHourSeed();

    const count = Math.min(max, base + add);

    return { count };
  }

}

이렇게 하면

  • Controller가 가벼워지고
  • 비즈니스 로직 재사용이 가능해지고
  • 테스트도 훨씬 쉬워집니다.

사실, 이상적인 레이어드 구조라는 가정하에, 서비스 계층만이 유일하게 레포지토리 계층과 대화할 수 있는 계층으로 이해하셔도 좋습니다.

How Stats Flows? - Service

서비스 계층은 비즈니스 로직을 담당합니다. Stats Entitiy에서의 서비스 계층은 현재 시간 기준으로 실제 보여줄 진단 수를 계산합니다.

데이터 자체의 접근할 수 있는 계층은 레포지토리 계층이기에, 먼저 레포지토리에서 설정된 최대 진단수를 가져옵니다.

그 이후, 하루 중 현재 시간이 얼마나 진행되었는지 그리고 그 시간대로 랜덤 보정값을 추가하고 계산하는 해당 비즈니스 로직을 Stats Entitiy의 서비스 계층에서 실담당하는 것 입니다.

일반화 하자면, 서비스 계층은 해당되는 Entitiy가 Repository에서 가져온 Entity를 조합하거나 계산하여, 클라이언트에게 전달할 결과 데이터를 생성합니다.


4. Repository — 데이터 접근 계층

Repository는 DB 접근을 담당하는 계층입니다.

Service는 데이터베이스에 대해 직접 알 필요가 없기 때문에 (단일 책임 원칙을 적용한 레이어 분리)

Sequelize Model (Entitiy) 을 Repository로 한 번 감싸서 사용합니다.

예를 들면

export async function getDailyDiagnosticMax(): Promise<number> {
  const raw = await getValue(KEY_DAILY_DIAGNOSTIC_MAX);

  if (raw == null) return 20;

  const n = parseInt(raw, 10);

  return Number.isNaN(n) ? 20 : Math.min(999, n);
}

Service 입장에서는 레포지토리 내 정의된 데이터 질의 함수만 알면 됩니다.

SiteSettingRepo.getDailyDiagnosticMax()

다시말하면 이 함수만 알면 되고,

DB가 Sequelize인지, MySQL인지, PostgreSQL인지 알 필요가 없습니다.

How Stats Flows? - Repository

Service 계층에서 무심결 함수를 호출하여 데이터를 가져오라고 하죠.

SiteSettingRepo.getDailyDiagnosticMax()

Repository 내부에서는 Sequelize 모델을 통해 DB를 조회하고, 데이터 정규화를 실시합니다.

여기서 나올 수 있는 질문은 왜 데이터 정규화를 레포지토리 계층에서 하는가? 입니다. 어차피 Validation 계층이 있지 않은가? 입니다.

Validation 계층은 클라이언트의 입력 데이터를 검증하는 것이고, Repository 계층은 DB에 존재하는 원본 데이터 자 체가 사용하기 좋은 형태인가를 따지기에 약간 뉘앙스가 다릅니다. 예를 들어 숫자인데, 원본 데이터에는 “20” 이라는 문자열로 되어있을 수가 있겠습니다.

여기서 중요한 점은 Repository가 데이터베이스와 직접 통신하는 유일한 계층이라는 것입니다.

Service나 Controller는 Sequelize 모델이나 SQL에 대해 알 필요가 없습니다.

오직 Repository를 통해서만 데이터를 가져오거나 저장합니다.


5. Sequelize Model — 실제 DB 테이블 정의

가장 아래 계층은 Sequelize Model입니다.

이 계층에서는 실제 데이터베이스의 테이블 구조와 컬럼을 코드로 정의합니다.

Sequelize는 ORM(Object Relational Mapping)이기 때문에 JS/TS 객체와 관계형 데이터베이스의 테이블을 매핑해주는 역할을 합니다.

즉 코드에서 정의한 Model이 데이터베이스의 Entity(테이블) 와 직접 연결되는 구조입니다.

export class SiteSettingModel extends Model {
  public id!: string;
  public key!: string;
  public value!: string;
  public updatedAt!: Date;
}

이렇게 정의된 Model은 내부적으로 다음과 같은 DB 테이블(Entity) 과 매칭됩니다.

site_settings

id
key
value
updated_at

즉 코드에서는 객체로 다루지만, 실제로는 데이터베이스의 레코드(row)와 연결되어 동작하게 됩니다.

왜 Express 환경에서 Sequelize를 선택했는가

Express는 기본적으로 HTTP 요청 처리에 집중된 프레임워크입니다.

즉,

  • 데이터베이스 구조 관리
  • SQL 작성
  • 스키마 변경 이력 관리

같은 기능은 직접 구현해야 합니다.

여기서 ORM을 사용하면 다음과 같은 장점이 있습니다.

  • 데이터베이스 테이블을 Model(Entity) 로 관리할 수 있음
  • SQL을 직접 작성하지 않고도 데이터 접근 가능
  • 관계 설정 (FK / Association)을 코드로 관리
  • Migration 기반으로 스키마 변경 이력 관리

특히 TypeScript 환경에서는 Model을 통해 타입 안정성까지 함께 가져갈 수 있기 때문에 Express와 잘 맞는 조합이라고 판단했습니다.

How Stats Flows? - Sequelize Model (Entity)

Stats 기능에서는 사이트 설정값을 저장하는 Entity 로 SiteSetting 테이블(Entity)을 사용합니다.

예를 들어 아래와 같은 데이터가 데이터베이스에 저장되어 있을 수 있습니다.

key: DAILY_DIAGNOSTIC_MAX
value: 200

이 값은 하루에 허용되는 최대 진단 수를 의미합니다.

Repository 계층에서는 Sequelize Model을 이용해 이 값을 조회합니다.

SiteSettingModel.findOne({
  where: { key:KEY_DAILY_DIAGNOSTIC_MAX }
});

이렇게 조회된 데이터는 Repository에서 가공된 후 Service 계층으로 전달됩니다.

Service 계층에서는 이 값을 기반으로 현재 시간 진행도와 정책 로직을 계산하여 실제 사용자에게 보여줄 진단 수를 결정합니다.


정리

Express는 요청 흐름과 미들웨어 체인을 담당하고, 그 위에서 우리는 Spring 스타일의 레이어 구조를 구현했습니다.

이 구조를 사용하면 Controller가 비대해지는 것을 방지할 수 있고, 비즈니스 로직을 Service에 모을 수 있으며, 데이터 접근을 Repository에서 일관되게 관리할 수 있습니다.

결과적으로 코드의 역할이 명확하게 분리되고, 프로젝트 규모가 커져도 유지보수가 훨씬 쉬운 구조가 됩니다.

즉, Express가 가진 단순하고 유연한 요청 처리 구조 위에, Spring에서 많이 사용하는 레이어 아키텍처의 장점을 결합하려는 시도였습니다.

다시 말해 Express의 가벼움과 Spring 스타일 구조의 안정성을 함께 가져가려는 설계라고 볼 수 있습니다.

Ho