TELEPASI

검색하기 전에 통하다

폴더와 Command로 확장하는 Node.js API Server 구조

강병우
2026.07.17 👁️ 93

폴더와 Command로 확장하는 Node.js API Server 구조

들어가며

Node.js와 Express로 API 서버를 만들 때 기능이 늘어나면 하나의 라우터 파일에 요청 처리, 인증, SQL, 로그, 응답 코드가 계속 쌓이기 쉽습니다. 작은 프로젝트에서는 빠르게 개발할 수 있지만, 여러 사람이 동시에 작업하기 시작하면 충돌이 잦아지고 코드의 책임도 불분명해집니다.

이 글에서 소개하는 node-api-server는 이 문제를 다음 세 단계로 나누어 해결합니다.

  1. apiModules.js에서 외부에 공개할 API 폴더를 선언한다.
  2. 각 API 폴더의 _post.js가 해당 URL에서 사용할 수 있는 Command를 선언한다.
  3. 실제 업무 로직은 inc_*.js 파일 하나에 Command 하나씩 분리한다.

핵심은 URL, HTTP Method, 업무 Command를 각각 다른 계층에서 관리하는 것입니다. 이 구조를 사용하면 기능별 담당자가 서로 다른 파일을 작업할 수 있어 Git 충돌을 줄이고, 신규 기능의 영향 범위를 작게 유지할 수 있습니다.


1. 전체 구조

핵심 파일만 추리면 다음과 같습니다.

node-api-server/
├── package.json
├── src/
│   ├── apiServer.js                  # Express 생성, 공통 미들웨어, 라우트 자동 등록
│   ├── apiModules.js                 # 공개할 API 폴더 목록
│   ├── common/
│   │   ├── verifyToken.js            # JWT 및 로그인 세션 검증
│   │   ├── reqUtil.js                # getBodyPara: 공통 요청 형식 해석
│   │   ├── resUtil.js                # resOk / resErrorMsg / resErrorAlert
│   │   ├── fileUtil.js               # fileExists, serverInfo
│   │   └── commonUtil.js             # thisDir 등 유틸
│   ├── lib/
│   │   ├── dbms/                     # SQL 작성 및 DB 실행
│   │   └── log/                      # 요청·SQL 로그
│   └── routes/api/
│       ├── common/
│       │   └── runApi.js             # Command를 실제 컨트롤러로 전달
│       └── app/
│           └── order/
│               ├── _post.js
│               ├── inc_getOrderDetail.js
│               ├── inc_getOrderUserInfo.js
│               └── inc_updateOrderShipping.js

요청 흐름은 다음과 같습니다.

flowchart LR
    A[Client] -->|POST /api/app/order| B[Express 공통 미들웨어]
    B --> C[verifyToken]
    C --> D[order/_post.js]
    D --> E[runApi.js]
    E -->|ctrl.cmd 비교| F{Command 허용 목록}
    F -->|get.order.detail| G[inc_getOrderDetail.js]
    F -->|get.order.user.info| H[inc_getOrderUserInfo.js]
    F -->|update.order.shipping| I[inc_updateOrderShipping.js]
    G --> J[DB/업무 처리]
    H --> J
    I --> J
    J --> K[공통 응답]

이 글에서는 API_INDICATOR 값을 api로 가정하므로 app/order의 실제 URL은 다음과 같이 만들어집니다.

POST /api/app/order

API_INDICATOR가 비어 있으면 /app/order 형태가 됩니다.

현재 코드 기준으로 보면 이 구조의 실제 사용 비율도 명확합니다.

  • apiModules.js에 선언된 API Method는 GET, POST, PUT, PATCH, DELETE 모두 가능
  • HTTP 진입 파일: _post.js가 대다수, _get.js는 일부
  • _put.js, _patch.js, _delete.js는 현재 사용하지 않음

즉 서버는 여러 HTTP Method를 지원하도록 설계되어 있지만, 실제 업무 API는 대부분 POST 단일 진입점과 Command 분배 방식을 사용합니다.
큰 회사의 경우 보안상의 이유로 method 이름으로 업무를 쉽게 파악하지 못하도록 POST로 통일하는 경우가 많습니다.

또한 이 구조는 완전한 디렉터리 자동 탐색이 아닙니다. 다음 세 단계가 함께 동작합니다.

1. API 폴더 공개 여부     → apiModules.js 화이트리스트
2. HTTP Method 파일 연결  → _get.js / _post.js 등 자동 감지
3. Command 컨트롤러 연결  → _post.js의 avaliableCmdList 화이트리스트

2. 첫 번째 계층: apiModules.js

apiModules.js는 서버에서 사용할 API 폴더의 등록부이자 화이트리스트입니다.

const apiModules = [
    { ver: 'app', path: 'order', useToken: true, desc: '주문 관리' },
    { ver: 'app', path: 'product', useToken: true, desc: '상품 관리' },
    { ver: 'v1', path: 'login', useToken: false, desc: '로그인' },
];

각 속성의 의미는 다음과 같습니다.

  • ver: API의 첫 번째 분류이자 폴더명입니다. 반드시 숫자 버전일 필요는 없으며 app, shop, biz, s처럼 업무 영역으로도 사용합니다.
  • path: 실제 기능 폴더명이며 URL의 마지막 경로가 됩니다.
  • useToken: true이면 실제 핸들러 앞에 verifyToken 미들웨어를 연결합니다.
  • desc: API의 용도를 설명하는 메타데이터입니다.

예를 들어 아래 설정은,

{ ver: 'app', path: 'order', useToken: true, desc: '주문 관리' }

다음 폴더와 URL을 연결합니다.

폴더: src/routes/api/app/order
URL : /api/app/order
인증: JWT 및 로그인 세션 검증 사용

폴더 안에 _post.js가 있더라도 apiModules에 등록하지 않으면 라우트는 공개되지 않습니다. 반대로 목록에 등록했지만 해당 Method 파일이 없으면 그 Method는 등록되지 않습니다. 따라서 이 파일 하나만 보면 서버가 외부에 제공하는 API 범위를 파악할 수 있습니다.

이 방식이 협업에 유리한 이유

  • 팀원이 업무 영역별 폴더를 나누어 맡을 수 있습니다.
  • 라우트 등록 코드가 서버 엔트리 파일에 계속 늘어나지 않습니다.
  • 인증 필요 여부를 API 단위로 일관되게 적용할 수 있습니다.
  • 폴더를 만들었다고 자동 공개되지 않아 의도하지 않은 엔드포인트 노출을 줄입니다.
  • URL 구조와 실제 디렉터리 구조가 같아 담당 코드를 찾기 쉽습니다.

3. 핵심: apiServer.js

apiServer.js는 단순히 Express를 실행하는 파일이 아닙니다. 선언된 모듈과 파일 규칙을 실제 Express 라우트로 바꾸는 부트스트랩 역할을 합니다.

3.1 Express와 공통 미들웨어 준비

const app = express();

app.use(cors());
app.use(express.json());
  • express()로 애플리케이션을 만듭니다.
  • cors()로 브라우저의 교차 출처 요청을 허용합니다.
  • express.json()으로 JSON 요청 본문을 req.body에 담습니다.

그 다음 미들웨어는 시간, HTTP Method, 경로, Command, Origin, IP, Referer를 로그로 출력합니다. 모든 API에 공통으로 적용되므로 개별 Command 파일에서 요청 기본 정보를 반복해서 기록할 필요가 없습니다.

요청 Command는 기본적으로 req.body.ctrl.cmd에서 읽고, GET 호환을 위해 req.query.cmd도 보조적으로 사용합니다.

3.2 개발 환경과 배포 환경의 경로 통일

서버는 serverInfo()를 이용해 실행 위치를 판단합니다.

개발: <project>/src/routes/api
배포: <project>/dist/routes/api

fileExists()serverInfo()의 참고 구현은 다음과 같습니다.

// common/fileUtil.js
import fs from 'fs';
import path, { join } from 'path';

export function fileExists(filePath) {
    try {
        fs.accessSync(filePath, fs.constants.F_OK);
        return true;
    } catch {
        return false;
    }
}

export function serverInfo(production, prjFolder) {
    const devMode = fileExists(join(prjFolder, 'src'));
    const indexDir = devMode ? join(prjFolder, 'src') : join(prjFolder, 'dist');
    const apiFolder = join(indexDir, 'routes/api');

    return {
        devMode,
        indexDir,
        apiFolder,
        packageJsonFile: join(prjFolder, 'package.json'),
        logDir: path.dirname(prjFolder) + '/log',
        dataFolder: production === false
            ? path.resolve(prjFolder, '..') + '/prjData'
            : null,
    };
}

현재 구현은 프로젝트 루트에 src가 있으면 개발 모드로 판단하고, 그렇지 않으면 dist를 사용합니다. 이후 계산된 server.apiFolderapiRoot로 사용하므로 라우트 등록 코드는 개발·배포 환경의 차이를 알 필요가 없습니다.

DB 스크립트, 로그, 엑셀, 데이터 폴더, 패키지 정보 등도 시작 시 계산하여 global 값으로 공유합니다. 레거시 또는 사내 공통 모듈이 많은 서버에서 설정 객체를 모든 함수에 전달하지 않고 공통 실행 환경을 제공하려는 설계입니다.

다만 전역 값은 테스트 격리와 의존성 추적을 어렵게 할 수 있으므로, 새 프로젝트에서는 명시적인 설정 객체나 의존성 주입도 함께 검토할 수 있습니다.

3.3 파일 이름으로 HTTP Method 연결

서버의 핵심 규칙은 methodMap입니다.

const methodMap = {
    get: 'getData',
    post: 'postData',
    put: 'putData',
    patch: 'patchData',
    delete: 'deleteData',
};

이 객체는 HTTP Method, 파일명, export 함수명을 연결합니다.

GET     → _get.js    → export const getData
POST    → _post.js   → export const postData
PUT     → _put.js    → export const putData
PATCH   → _patch.js  → export const patchData
DELETE  → _delete.js → export const deleteData

즉, 새 Method를 지원하기 위해 apiServer.jsapp.post(...) 같은 코드를 반복해서 작성하지 않습니다. 약속된 파일과 export만 만들면 됩니다.

3.4 API 폴더를 Express 라우트로 바꾸는 과정

핵심 로직은 다음 순서로 동작합니다. 운영에서는 forEach(async ...)보다 등록이 끝난 뒤 listen하는 형태를 권장합니다.

// apiServer.js (라우트 등록 핵심)
import { join } from 'path';
import apiModules from './apiModules.js';
import verifyToken from './common/verifyToken.js';
import { fileExists, serverInfo } from './common/fileUtil.js';

const API_INDICATOR = 'api'; // 없으면 '' 로 두면 /app/order 형태
const methodMap = {
    get: 'getData',
    post: 'postData',
    put: 'putData',
    patch: 'patchData',
    delete: 'deleteData',
};

const prjRootDir = process.cwd();
const server = serverInfo(false, prjRootDir); // production 여부는 환경설정으로 전달
const apiRoot = server.apiFolder;

for (const { ver, path, useToken } of apiModules) {
    const modulePath = join(apiRoot, ver, path);
    const api = API_INDICATOR
        ? `/${API_INDICATOR}/${ver}/${path}`
        : `/${ver}/${path}`;

    for (const method of Object.keys(methodMap)) {
        const methodFilename = join(modulePath, `_${method}.js`);
        if (!fileExists(methodFilename)) continue;

        const mod = await import(methodFilename);
        const handler = mod[methodMap[method]];
        if (typeof handler !== 'function') {
            console.warn(`[warn] ${methodFilename} missing export ${methodMap[method]}`);
            continue;
        }

        app[method](api, useToken ? [verifyToken, handler] : handler);
    }
}

app.listen(7777, () => {
    console.log('API Server listening on port 7777');
});

app/order를 예로 들면 다음과 같습니다.

  1. apiRoot, app, order를 합쳐 실제 폴더 경로를 만든다.
  2. /api/app/order라는 URL을 만든다.
  3. _get.js, _post.js, _put.js, _patch.js, _delete.js를 차례로 찾는다.
  4. 존재하는 파일만 동적으로 import한다.
  5. 파일에서 Method에 대응하는 함수를 꺼낸다.
  6. useTokentrue이면 verifyToken과 핸들러를 함께 등록한다.

order 폴더에는 _post.js만 있으므로 아래와 같은 결과가 만들어집니다.

app.post('/api/app/order', [verifyToken, postData]);

동적 import에 사용하는 파일 경로는 클라이언트 입력이 아니라 서버 내부의 apiModulesmethodMap에서 만들어집니다. 따라서 클라이언트가 임의의 파일명을 보내 서버 파일을 import하는 구조는 아닙니다.


4. 두 번째 계층: _post.js는 Command 라우터

일반적인 REST API는 URL과 HTTP Method 조합마다 하나의 업무를 연결합니다. 이 프로젝트는 기업 내부망, 프록시, WAF 정책 또는 기존 클라이언트 호환성을 고려해 하나의 POST URL 안에서 여러 업무 Command를 처리할 수도 있습니다.

app/order/_post.js의 역할은 업무 로직을 직접 구현하는 것이 아니라, 이 URL에서 허용할 Command와 컨트롤러 파일을 연결하는 것입니다.

// routes/api/app/order/_post.js
import { runApi } from '../../common/runApi.js';

export const postData = async (req, res) => {
    // 이 URL에서 허용할 Command 화이트리스트
    const avaliableCmdList = [
        { cmd: 'get.order.user.info', controller: 'inc_getOrderUserInfo' },
        { cmd: 'update.order.shipping', controller: 'inc_updateOrderShipping' },
        { cmd: 'get.order.detail', controller: 'inc_getOrderDetail' },
    ];

    const response = await runApi({
        req,
        log: console, // 실서비스에서는 HtmlLog 같은 공통 로거 사용
        avaliableCmdList,
        metaUrl: import.meta.url, // 같은 폴더의 inc_*.js 를 찾기 위함
    });

    res.send(response);
};

이 파일은 다음 책임만 가집니다.

  • 해당 URL에서 허용할 Command 선언
  • 요청 단위 로그 시작과 종료
  • 공통 Command 실행기 호출
  • 결과 전송

업무 SQL이나 복잡한 분기문은 inc_*.js에 두기 때문에 _post.js는 API 폴더의 목차처럼 읽힙니다.

현재 코드의 avaliableCmdListavailableCmdList의 오탈자이지만 runApi.js와 호출부가 같은 이름을 사용하므로 동작합니다. 이름을 수정하려면 전체 호출부를 함께 변경해야 합니다.


5. 세 번째 계층: runApi.js의 Command 디스패치

runApi.js는 요청 Command를 실제 컨트롤러로 전달하는 공통 디스패처입니다.

// routes/api/common/runApi.js
import { getBodyPara } from '../../../common/reqUtil.js';
import { fileExists } from '../../../common/fileUtil.js';
import { thisDir } from '../../../common/commonUtil.js';
import { resErrorMsg } from '../../../common/resUtil.js';

export async function runApi(api) {
    const { req, log, avaliableCmdList, metaUrl } = api;
    const reqBody = getBodyPara(req, log);
    const reqApi = avaliableCmdList.find((item) => item.cmd === reqBody.cmd);

    if (!reqApi) {
        return resErrorMsg(`API Command [${reqBody.cmd}] is not supported.`);
    }

    // 클라이언트가 보낸 cmd 문자열이 아니라, 서버 화이트리스트의 controller만 import
    const controllerFile = `${thisDir(metaUrl)}/${reqApi.controller}.js`;
    if (!fileExists(controllerFile)) {
        return resErrorMsg(`API Controller [${controllerFile}] is not found.`);
    }

    const apiController = await import(controllerFile);
    return await apiController.default(api);
}

thisDir()는 ES Module에서 현재 파일의 폴더 경로를 구합니다.

// common/commonUtil.js
import path from 'path';
import { fileURLToPath } from 'url';

export function thisDir(metaUrl) {
    return path.dirname(fileURLToPath(metaUrl));
}

중요한 점은 사용자가 보낸 cmd를 파일명으로 직접 사용하지 않는다는 것입니다. 먼저 서버가 선언한 허용 목록에서 완전 일치하는 Command를 찾고, 목록에 기록된 controller만 import합니다.

또한 runApi()named export가 아니라 default export만 호출합니다.

response = await apiController.default(api);

따라서 inc_*.js 안의 함수 이름은 중요하지 않습니다. 파일마다 모두 handler처럼 같은 이름을 써도 되고, 익명 함수로 작성해도 됩니다. 실제로 구분되는 것은 _post.jscontroller 값과 그에 대응하는 파일명입니다.

// 둘 다 정상 동작
// inc_getOrderUserInfo.js
export default async function handler(api) { ... }

// inc_getOrderDetail.js
export default async function handler(api) { ... }

이 과정에서 두 가지 오류도 공통 처리합니다.

  • 허용 목록에 없는 Command: API Command [...] is not supported.
  • 목록에는 있지만 파일이 없는 경우: API Controller [...] is not found.

따라서 각 컨트롤러가 Command 유효성 검사와 파일 탐색 오류를 반복해서 처리할 필요가 없습니다.

공통 요청 형식

getBodyPara()는 요청을 다음 구조로 해석합니다.

{
  "ctrl": {
    "cmd": "get.order.user.info",
    "cmdEncoded": false
  },
  "reqPara": {
    "orderId": "ORD-001"
  },
  "reqData": {}
}

참고 구현은 다음과 같습니다.

// common/reqUtil.js
export function getBodyPara(req, logInstance = null) {
    const reqCmd = req.body?.ctrl?.cmd ?? req.query?.cmd ?? '';
    const cmdEncoded = req.body?.ctrl?.cmdEncoded ?? false;
    const reqPara = req.body?.reqPara ?? null;
    const reqData = req.body?.reqData ?? null;

    // cmdEncoded 가 true 이면 별도 디코더로 복호화
    const cmd = cmdEncoded && reqCmd ? decodeCmd(reqCmd) : reqCmd;

    return {
        cmd,
        reqPara,
        reqData,
        user: req.body?.user ?? null, // verifyToken 이 넣어 줌
        logInstance,
        originalUrl: req.originalUrl,
        query: req.query,
        files: req.files,
    };
}

function decodeCmd(reqCmd) {
    // 프로젝트별 인코딩 규칙을 여기에 연결
    return reqCmd;
}
  • ctrl.cmd: 실행할 업무 Command
  • ctrl.cmdEncoded: Command 인코딩 여부
  • reqPara: 조회 조건, 키, 페이지 등 요청 파라미터
  • reqData: 저장하거나 수정할 본문 데이터
  • user: 토큰 검증 후 미들웨어가 넣어 주는 사용자 정보

각 Command 컨트롤러는 getBodyPara()를 통해 같은 형식으로 데이터를 받습니다.


6. Command 하나를 파일 하나로 분리하기

6.1 조회 Command

inc_getOrderUserInfo.js는 주문 번호로 주문자와 주문 정보를 조회합니다.

여기서 중요한 규칙은 다음 두 가지뿐입니다.

  1. _post.jscontroller 값과 파일명이 일치할 것
    예: controller: 'inc_getOrderUserInfo'inc_getOrderUserInfo.js
  2. 파일은 export default로 비동기 함수를 내보낼 것

함수 이름 자체는 자유입니다. getOrderUserInfo, handler, main처럼 파일마다 달라도 되고 같아도 됩니다.

// routes/api/app/order/inc_getOrderUserInfo.js
import { getBodyPara } from '../../../../common/reqUtil.js';
import { resOk, resErrorAlert } from '../../../../common/resUtil.js';

// 함수명은 아무거나 가능. default export 만 맞으면 됨
export default async function handler(api) {
    const reqBody = getBodyPara(api.req, api.log);
    const { orderId } = reqBody.reqPara ?? {};

    if (!orderId) {
        return resErrorAlert(null, {
            windowTitle: '에러',
            mainMessage: 'orderId가 필요합니다.',
            subMessage: '요청 파라미터를 확인해 주세요.',
        });
    }

    try {
        // 실제 프로젝트에서는 DB 풀/쿼리 유틸을 사용
        // const [rows] = await db.execute(
        //   'SELECT * FROM orders WHERE order_id = ?',
        //   [orderId]
        // );
        const rows = []; // 예시

        if (rows.length === 1) {
            return resOk(rows[0]);
        }
        if (rows.length === 0) {
            return resErrorAlert(null, {
                windowTitle: '에러',
                mainMessage: '주문 정보가 없습니다.',
                subMessage: '주문번호를 다시 확인해 주세요.',
            });
        }

        return resErrorAlert(null, {
            windowTitle: '에러',
            mainMessage: '주문 정보가 올바르지 않습니다.',
            subMessage: '관리자에게 문의해 주세요.',
        });
    } catch (error) {
        api.log?.error?.(error);
        return resErrorAlert(null, {
            windowTitle: '에러',
            mainMessage: reqBody.cmd,
            subMessage: '관리자에게 문의해 주세요.',
        });
    }
}

runApi()가 공통으로 호출하는 형태는 다음과 같습니다.

입력 : { req, log, ... }
출력 : Promise<공통 응답 객체>
연결 : avaliableCmdList.controller  →  같은 폴더의 파일명.js  →  default export

예를 들어 아래처럼 파일명을 짧게 써도 됩니다.

// _post.js
const avaliableCmdList = [
    { cmd: 'get.order.user.info', controller: 'inc_get_1' },
    { cmd: 'get.order.detail', controller: 'inc_get_2' },
];
// inc_get_1.js
export default async function handler(api) { /* 조회 1 */ }

// inc_get_2.js
export default async function handler(api) { /* 조회 2 */ }

다만 협업할 때는 inc_getOrderUserInfo.js처럼 파일명만으로 역할을 알 수 있는 이름을 권장합니다. 함수명은 같아도 괜찮지만, 파일명은 구분되어야 합니다.

inc_getOrderDetail.js도 같은 방식으로 주문 상세 데이터를 조회합니다. 조회 업무가 추가되어도 기존 조회 파일을 수정하는 대신 새 inc_*.js를 만들 수 있습니다.

6.2 변경 Command와 트랜잭션

inc_updateOrderShipping.js는 하나의 Command 안에서 다음 업무를 처리합니다.

  1. 주문 정보 조회
  2. 배송지 정보 조회
  3. 배송비·상태 계산
  4. 재고 확인
  5. 주문 배송 정보 INSERT 또는 UPDATE
  6. 주문 상태 UPDATE
  7. 모두 성공하면 COMMIT, 하나라도 실패하면 ROLLBACK
// routes/api/app/order/inc_updateOrderShipping.js
import { getBodyPara } from '../../../../common/reqUtil.js';
import { resOk, resErrorAlert } from '../../../../common/resUtil.js';

export default async function handler(api) {
    const reqBody = getBodyPara(api.req, api.log);
    const orderId = reqBody.reqPara?.orderId;
    const shipping = reqBody.reqData ?? {};
    let dbClient;
    let errorExist = false;

    try {
        // dbClient = await db.getConnection();
        // await dbClient.query('BEGIN');

        // 1) 주문 조회
        // 2) 배송지 검증
        // 3) 배송 정보 upsert
        // 4) 주문 상태 update
        // ...

        return resOk({ orderId, shipping });
    } catch (error) {
        errorExist = true;
        return resErrorAlert(null, {
            windowTitle: '에러',
            mainMessage: error.message,
            subMessage: '주문 배송 정보 업데이트에 실패했습니다.',
        });
    } finally {
        // if (errorExist) await dbClient.query('ROLLBACK');
        // else await dbClient.query('COMMIT');
        // dbClient?.release();
    }
}

트랜잭션이 필요한 복잡한 변경 로직도 독립 파일에 들어가므로 다른 Command에 영향을 주지 않습니다. 코드 리뷰 시에도 해당 기능의 변경만 집중해서 확인할 수 있습니다.


7. POST 하나로 SELECT·INSERT·UPDATE·DELETE 처리하기

이 서버는 다양한 HTTP Method를 지원하는 구조를 이미 갖추고 있습니다. 그러나 실제 기업 환경에서는 다음과 같은 이유로 POST 중심의 단일 진입점을 선택하기도 합니다.

  • 사내 방화벽이나 WAF에서 허용 Method를 단순화해야 하는 경우
  • 오래된 프록시 또는 클라이언트가 PUT, PATCH, DELETE를 안정적으로 처리하지 못하는 경우
  • 모든 업무 요청을 같은 JSON envelope와 로그 형식으로 남겨야 하는 경우
  • URL보다 업무 Command를 기준으로 권한과 감사를 관리하는 기존 시스템과 연동하는 경우

이때 HTTP Method는 POST로 통일하고, 실제 작업의 의미는 Command로 구분합니다.

POST /api/app/order
  cmd: get.order.user.info      → SELECT
  cmd: get.order.detail               → SELECT 및 계산
  cmd: update.order.shipping   → SELECT + INSERT/UPDATE

삭제 업무가 필요하다면 예를 들어 다음과 같이 추가할 수 있습니다.

{
    cmd: 'delete.order.item',
    controller: 'inc_deleteOrderItem'
}

그리고 inc_deleteOrderItem.js 안에서 권한, 대상 상태, 참조 관계를 확인한 뒤 DELETE 또는 소프트 삭제를 수행합니다.

같은 패턴의 실제 사례는 s/prjCode/_post.js에서도 확인할 수 있습니다.

const avaliableCmdList = [
    { cmd: 'get.code.list', controller: 'inc_getCodeList' },   // SELECT
    { cmd: 'new.code', controller: 'inc_newCode' },             // INSERT
    { cmd: 'update.code', controller: 'inc_updateCode' },       // UPDATE
    { cmd: 'delete.code', controller: 'inc_deleteCode' },       // Soft Delete
];

여기서 delete.code는 물리 DELETE보다 deleted = 1과 삭제 일시·삭제 사용자를 기록하는 Soft Delete인 경우가 많습니다. 기업 업무에서는 감사와 복구를 위해 Soft Delete를 자주 사용하므로, HTTP DELETE Method와 실제 DB DELETE 연산을 반드시 일치시킬 필요는 없습니다.

결국 이 서버의 API 모델은 REST Resource 중심보다 RPC Command 중심에 가깝습니다.

POST 사용 자체가 보안은 아니다

POST로 통일하면 네트워크 정책과 운영 규칙을 단순화할 수 있지만, GET·PUT·DELETE를 막는 것만으로 보안이 강화되는 것은 아닙니다. HTTPS를 사용하면 모든 Method의 전송 내용이 암호화되며, 공격자는 허용된 POST도 호출할 수 있습니다.

실제 보안은 다음 요소로 확보해야 합니다.

  • HTTPS 강제
  • JWT 검증과 세션 검증
  • Command별 권한 검사
  • Command 암호화(Client에서 호출시 cmdEncoded: true)
  • 입력 스키마 검증
  • 파라미터 바인딩 또는 Prepared Statement
  • 허용 Origin을 명시한 CORS 설정
  • Rate Limit과 요청 크기 제한
  • 민감 정보가 제거된 감사 로그
  • 오류 응답에서 내부 경로와 SQL 숨김

따라서 이 구조의 장점은 “POST라서 안전하다”가 아니라, 외부 진입점을 제한하고 Command 화이트리스트, 인증, 권한, 로그 정책을 한 흐름에 적용하기 쉽다는 데 있습니다.


8. 인증 흐름

apiModules.js에서 useToken: true로 지정하면 서버는 다음과 같이 미들웨어 체인을 구성합니다.

app.post(api, [verifyToken, handler]);

verifyToken.js의 핵심 흐름은 다음과 같습니다.

// common/verifyToken.js
import jwt from 'jsonwebtoken';
import { resErrorMsg } from './resUtil.js';

const JWT_SECRET = process.env.JWT_SECRET; // 코드에 하드코딩하지 말 것

export default async function verifyToken(req, res, next) {
    const token = req.headers.authorization?.split(' ')[1]; // Bearer <token>

    if (!token) {
        return res.status(401).json(resErrorMsg('ACCESS_TOKEN_NOT_PROVIDED'));
    }

    try {
        const decoded = jwt.verify(token, JWT_SECRET);

        // 프로젝트에 맞게 사용자 정보 추출
        // 예: decoded.user 또는 암호화된 encInfo 복호화
        req.body.user = decoded.user ?? {
            userId: decoded.sub,
            sessionId: decoded.sessionId,
            pLevel: decoded.pLevel,
        };

        // 선택: DB 세션과 sessionId 일치 여부 검사
        // const ok = await checkSession(req.body.user.userId, req.body.user.sessionId);
        // if (!ok) return res.status(401).json(resErrorMsg('SESSION_INVALID'));

        next();
    } catch (error) {
        const message =
            error.name === 'TokenExpiredError'
                ? 'ACCESS_TOKEN_EXPIRED'
                : 'ACCESS_TOKEN_INVALID';
        return res.status(401).json(resErrorMsg(message));
    }
}

따라서 Command 파일에서는 아래처럼 인증된 사용자 정보를 공통 요청 객체에서 사용할 수 있습니다.

const reqBody = getBodyPara(api.req, api.log);
const user = reqBody.user;

useToken: false는 로그인, 토큰 갱신, 외부 공개 기능처럼 인증 전 접근이 필요한 API에만 제한적으로 사용해야 합니다.


9. 새 API를 추가하는 실전 과정

예제로 app/customer API에 고객 목록 조회 기능을 추가해 보겠습니다.

9.1 API 모듈 등록

apiModules.js에 한 줄을 추가합니다.

{ ver: 'app', path: 'customer', useToken: true, desc: '고객 관리' }

9.2 폴더와 _post.js 생성

src/routes/api/app/customer/
├── _post.js
└── inc_getCustomerList.js

_post.js는 허용할 Command를 선언합니다.

// routes/api/app/customer/_post.js
import { runApi } from '../../common/runApi.js';

export const postData = async (req, res) => {
    const avaliableCmdList = [
        { cmd: 'get.customer.list', controller: 'inc_getCustomerList' },
    ];

    const response = await runApi({
        req,
        log: console,
        avaliableCmdList,
        metaUrl: import.meta.url,
    });

    res.send(response);
};

9.3 Command 컨트롤러 생성

// routes/api/app/customer/inc_getCustomerList.js
import { getBodyPara } from '../../../../common/reqUtil.js';
import { resOk, resErrorAlert } from '../../../../common/resUtil.js';

export default async function handler(api) {
    const reqBody = getBodyPara(api.req, api.log);
    const { page = 1, pageSize = 20 } = reqBody.reqPara ?? {};

    if (page < 1 || pageSize < 1 || pageSize > 100) {
        return resErrorAlert(null, {
            windowTitle: '에러',
            mainMessage: '잘못된 페이지 요청입니다.',
            subMessage: 'page, pageSize를 확인해 주세요.',
        });
    }

    // 권한 검사 예: reqBody.user.pLevel
    // DB 조회는 placeholder 사용
    // const [rows] = await db.execute(
    //   'SELECT id, name FROM customers ORDER BY id DESC LIMIT ? OFFSET ?',
    //   [pageSize, (page - 1) * pageSize]
    // );

    return resOk({
        page,
        pageSize,
        items: [],
    });
}

이제 클라이언트는 다음과 같이 호출할 수 있습니다.

POST /api/app/customer
Authorization: Bearer <access-token>
Content-Type: application/json
{
  "ctrl": {
    "cmd": "get.customer.list"
  },
  "reqPara": {
    "page": 1,
    "pageSize": 20
  }
}

같은 고객 관리 URL에 저장 기능을 추가할 때는 inc_createCustomer.js를 만들고 _post.js의 목록에 Command 한 줄을 추가하면 됩니다. 기존 조회 컨트롤러는 수정하지 않습니다.


10. 협업 규칙으로 발전시키기

이 구조는 파일 규칙을 팀 규칙으로 명확히 할 때 효과가 커집니다.

권장 규칙

  1. API 폴더는 업무 도메인 단위로 만든다.
  2. _post.js에는 Command 목록과 공통 실행 코드만 둔다.
  3. Command 하나는 inc_*.js 파일 하나를 원칙으로 한다.
  4. 파일명은 역할을 드러내는 이름을 쓰고, 함수명은 handler처럼 동일해도 된다. (default export만 맞으면 됨)
  5. Command는 동작.대상.세부대상 형식으로 통일한다.
  6. 입력 검증, 권한 검사, 트랜잭션 경계를 컨트롤러에서 명확히 한다.
  7. 공통 처리만 routes/api/common 또는 common으로 올린다.
  8. 응답은 공통 성공·실패 형식을 사용한다.
  9. Command 추가 시 성공, 권한 없음, 잘못된 입력, DB 실패 테스트를 함께 작성한다.

Git 충돌이 줄어드는 이유

하나의 거대한 라우터에서는 여러 개발자가 같은 switch 문이나 같은 파일의 import 영역을 수정합니다. 이 구조에서도 _post.js의 Command 목록은 함께 수정할 수 있지만, 실제 구현 대부분은 각자의 inc_*.js에 있으므로 충돌 범위가 크게 줄어듭니다.

기능별 코드 소유권도 명확합니다.

담당자 A: inc_getOrderUserInfo.js
담당자 B: inc_getOrderDetail.js
담당자 C: inc_updateOrderShipping.js
공통 검토: order/_post.js의 Command 등록

11. 운영 전 보완할 부분

현재 구조의 방향은 명확하지만, 실제 코드를 기준으로 운영 전에 점검할 부분도 있습니다.

11.1 비동기 라우트 등록을 완료한 뒤 서버 시작하기

현재 apiModules.forEach(async (...) => {})는 내부 Promise를 기다리지 않습니다. 따라서 app.listen()이 먼저 실행되고 동적 import와 라우트 등록이 아직 끝나지 않은 짧은 구간이 생길 수 있습니다.

다음처럼 for...ofawait를 사용하거나 Promise.all()로 등록 완료를 보장하는 편이 안전합니다.

for (const { ver, path, useToken } of apiModules) {
    // Method 파일 확인, import, app[method] 등록
}

app.listen(port, ...);

11.2 Command 디코더 import 확인

요청 로그 미들웨어는 cmdEncodedtrue일 때 Command 디코더를 호출합니다. apiServer.js에서 디코더를 명시적으로 import하지 않으면 인코딩된 Command 요청에서 ReferenceError가 발생할 수 있습니다.

11.3 SQL 문자열 결합 제거

일부 컨트롤러는 다음처럼 요청 값을 SQL 문자열에 직접 넣습니다.

.where(`_O.order_id = '${orderId}'`)

POST 본문에 넣었다고 해서 SQL Injection 위험이 사라지지 않습니다. DB 드라이버의 placeholder와 바인딩 값을 사용하도록 DB 공통 계층을 개선해야 합니다.

const [rows] = await connection.execute(
    'SELECT * FROM orders WHERE order_id = ?',
    [orderId]
);

11.4 CORS와 로그 최소화

인자 없는 cors()는 모든 Origin을 허용합니다. 서비스에서 사용하는 도메인만 화이트리스트로 설정하는 것이 좋습니다.

또한 인증 미들웨어에서 요청 본문 전체를 출력하면 개인정보나 토큰 관련 정보가 로그에 남을 수 있습니다. 비밀번호, 전화번호, 이메일, 토큰, 세션 ID는 마스킹하거나 기록 대상에서 제외해야 합니다. DB 설정 객체를 콘솔에 출력하는 경우에도 비밀번호가 노출될 수 있으므로 시작 로그는 민감값을 제거해야 합니다.

공통 계층에 Helmet, Rate Limit, 요청 본문 크기 제한을 추가하면 POST 단일 진입점 구조에서도 기본 방어선이 강화됩니다.

11.5 비밀값과 암호화 키를 코드에서 분리하기

JWT 서명 키, 요청 파라미터 인코딩 키, 메일 앱 비밀번호처럼 서버 비밀값은 소스코드에 두지 않아야 합니다. 현재 구조에는 이런 값이 코드나 고정 키에 남아 있는 부분이 있으므로 환경변수 또는 Secret Manager로 분리하는 것이 우선입니다.

특히 XOR 기반 단순 인코딩은 전송 암호화를 대체하지 않습니다. Command나 요청 파라미터를 가리는 용도로만 이해하고, 실제 보호는 HTTPS와 JWT, 서버측 인가에 의존해야 합니다.

11.6 Command별 권한 분리

useToken: true는 로그인 여부를 검증하지만, 같은 URL의 모든 Command가 같은 권한을 가져야 한다는 뜻은 아닙니다. 조회, 수정, 삭제 Command마다 역할 또는 권한 코드를 검사해야 합니다.

인증(Authentication): 누구인가?
인가(Authorization): 이 Command를 실행할 수 있는가?

특히 POST 한 URL에서 여러 종류의 DB 작업을 수행할수록 Command별 인가가 중요합니다.

11.7 중복 등록과 시작 시 검증

apiModules.js에는 동일한 ver/path 조합이 중복될 가능성이 있습니다. 같은 항목이 두 번 선언되면 라우트가 중복 등록될 수 있으므로 서버 시작 시 다음 항목을 검사해야 합니다.

  • ver/path 중복
  • Method 파일의 export 함수 누락
  • Command 문자열 중복
  • Command에 연결된 컨트롤러 파일 누락
  • 인증이 필요한 업무의 useToken: false 설정

11.8 빌드 결과와 실행 경로 일치시키기

빌드 스크립트가 소스 파일을 dist/src/로 복사한다면 package.json의 시작 명령과 serverInfo()의 배포 경로도 같은 구조를 사용해야 합니다. 빌드 산출물의 실제 위치와 실행 명령이 어긋나지 않도록 다음 중 하나로 규칙을 통일합니다.

방식 A: dist/apiServer.js + dist/routes/api
방식 B: dist/src/apiServer.js + dist/src/routes/api

빌드 후 깨끗한 디렉터리에서 서버 시작과 전체 라우트 등록을 검증하는 테스트를 두면 개발 환경의 src 폴더 때문에 경로 오류가 가려지는 문제를 예방할 수 있습니다.


12. 구조의 장점과 트레이드오프

장점

  • 폴더 구조만으로 API 위치를 예측할 수 있습니다.
  • 서버 엔트리 파일을 수정하지 않고 API를 확장할 수 있습니다.
  • 인증 적용 여부를 중앙 등록부에서 확인할 수 있습니다.
  • Command 화이트리스트로 허용 업무를 제한합니다.
  • 기능별 파일 분리로 협업과 코드 리뷰가 쉬워집니다.
  • 조회와 변경이 복합된 기업 업무를 하나의 트랜잭션으로 묶기 쉽습니다.
  • 공통 로그, 요청 해석, 오류 응답을 재사용할 수 있습니다.

트레이드오프

  • URL과 HTTP Method만 보고 업무 의미를 알기 어렵고 ctrl.cmd까지 확인해야 합니다.
  • REST 표준 도구, 캐시, API Gateway의 Method별 기능을 충분히 활용하기 어렵습니다.
  • 하나의 URL에 Command가 많아지면 _post.js 목록도 커집니다.
  • OpenAPI 문서를 자동 생성하려면 Command 기반 요청을 위한 별도 규칙이 필요합니다.
  • API 단위 인증 외에 Command 단위 인가 체계가 반드시 필요합니다.

따라서 외부 공개 API는 REST 방식으로 제공하고, 사내 업무 API는 이와 같은 Command 방식을 사용하는 혼합 구조도 현실적인 선택입니다. 이 서버는 _get.js, _post.js, _put.js, _patch.js, _delete.js를 모두 지원하므로 API 성격에 따라 두 방식을 함께 사용할 수 있습니다.


부록. 바로 가져다 쓰는 공통 응답 유틸

컨트롤러마다 응답 형식이 달라지면 프론트엔드와 로그 분석이 어려워집니다. 아래처럼 성공·실패 응답을 통일하면 됩니다.

// common/resUtil.js
const OK = 'OK';
const FAIL = 'FAIL';

export function resOk(content = null, reqBody = null) {
    const r = {
        result: OK,
        content,
        alert: null,
    };
    if (process.env.NODE_ENV !== 'production' && reqBody) {
        r.reqBody = reqBody;
    }
    return r;
}

export function resErrorMsg(errorMsg, reqBody = null) {
    const r = {
        result: FAIL,
        content: null,
        error: { message: errorMsg },
        alert: null,
    };
    if (process.env.NODE_ENV !== 'production' && reqBody) {
        r.reqBody = reqBody;
    }
    return r;
}

export function resErrorAlert(error, alert, reqBody = null) {
    const r = {
        result: FAIL,
        content: null,
        error,
        alert: {
            windowTitle: alert?.windowTitle ?? '에러',
            mainMessage: alert?.mainMessage ?? '요청 처리 중 오류가 발생했습니다.',
            subMessage: alert?.subMessage ?? '',
        },
    };
    if (process.env.NODE_ENV !== 'production' && reqBody) {
        r.reqBody = reqBody;
    }
    return r;
}

클라이언트 호출 예시는 다음과 같습니다.

// 프론트엔드 fetch 예시
const res = await fetch('/api/app/order', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
        ctrl: { cmd: 'get.order.user.info' },
        reqPara: { orderId: 'ORD-001' },
        reqData: null,
    }),
});

const data = await res.json();
if (data.result === 'OK') {
    console.log(data.content);
} else {
    console.error(data.alert?.mainMessage || data.error?.message);
}

최소 구성으로 새 프로젝트를 시작할 때 필요한 파일은 아래와 같습니다.

src/
├── apiServer.js
├── apiModules.js
├── common/
│   ├── fileUtil.js      # fileExists, serverInfo
│   ├── commonUtil.js    # thisDir
│   ├── reqUtil.js       # getBodyPara
│   ├── resUtil.js       # resOk, resErrorMsg, resErrorAlert
│   └── verifyToken.js
└── routes/api/
    ├── common/
    │   └── runApi.js
    └── app/
        └── order/
            ├── _post.js
            ├── inc_getOrderUserInfo.js
            ├── inc_getOrderDetail.js
            └── inc_updateOrderShipping.js

위 파일만 준비되면 apiModules.js에 한 줄 추가하고, _post.js에 Command를 등록한 뒤, inc_*.js에 업무 로직만 작성하면 됩니다.


마무리

이 Node.js API Server 구조의 핵심은 Express 라우터를 많이 만드는 데 있지 않습니다. 등록부 → Method 파일 → Command 컨트롤러라는 세 단계의 규칙으로 변경 범위를 통제하는 데 있습니다.

apiModules.js
    └─ 어떤 API 폴더를 공개할 것인가?

apiServer.js
    └─ 어떤 Method 파일이 있는지 찾아 Express에 어떻게 연결할 것인가?

_post.js
    └─ 이 URL에서 어떤 Command를 허용할 것인가?

inc_*.js
    └─ 해당 Command의 실제 업무를 어떻게 수행할 것인가?

새 기능을 추가할 때 기존의 거대한 라우터를 수정하는 대신 Command 파일 하나를 추가하고 허용 목록에 등록합니다. 이 작은 규칙이 기능별 독립성, 협업 효율, 리뷰 용이성, 운영 정책의 일관성을 만들어 냅니다.

다만 POST 단일 진입점은 보안 그 자체가 아니라 운영 방식입니다. 인증과 Command별 인가, 입력 검증, SQL 파라미터 바인딩, 제한된 CORS, 안전한 로그 정책을 함께 적용할 때 이 구조의 장점을 실제 서비스에서도 안전하게 활용할 수 있습니다.

주파수 소통방 (0)

로딩 중...