Rocky 's Blog

Node.js WAS에서 일관된 에러 처리 구조 적용하기

  • Node.js
  • WAS
  • 에러 처리
2025. 09. 26.
게시글 썸네일

문제 상황


어떤 작업을 진행 중이고, 현재 상황은 어떤지?

WAS를 구현 중이었다. 따라서 에러가 발생하면 각 에러 코드에 맞춰 응답을 만들어 클라이언트에 전송해야 했다. 현재 WAS의 진입점에서는 라우트를 판별해 도메인별 라우트를 실행하며, 각 도메인은 MVC 패턴으로 구성되어 repository, service, controller, route 계층으로 나뉘어 있다.

각 계층에서 try..catch로 에러를 처리하며 응답을 생성해 전송하지만, 적절한 에러 처리가 이루어지지 않았고, 일관되지 않으며, 일부는 단순히 return null;로 종료하는 부분도 있다.

어떤 문제가 있었는가?

클라이언트에서 게시글 API 를 테스트하는 과정에서 게시글이 정상적으로 불러와지지 않는 문제가 발생했다. 응답 자체는 성공적으로 반환되었음에도 불구하고 원하는 데이터를 받지 못하는 어이없는 상황이었다. 서버의 로그를 꼼꼼히 확인했지만 직접적인 원인을 찾을 수 없었고, 결국 직접 디버깅을 통해 단계별로 확인해볼 수밖에 없었다.

문제의 원인은 무엇이었는가?

서버에서 게시글 정보를 조회할 때 발생한 에러를 명확하게 처리하지 않아 문제가 발생했다. 단순히 return null;로 반환해버렸기 때문에, 실제로는 에러가 발생했음에도 불구하고 에러로 인식되지 않고 조용히 넘어가 버린 것이다. 다양한 함수와 try..catch문을 거치는 과정에서 에러 처리가 일관되지 않았기에, 어디에서 문제가 발생한지 찾아내기 어려웠다.

공장에서 자동차를 만드는 상황에 비유해보자. 생산 라인을 따라 작업이 이루어지는 중에 너트 하나가 어디선가 누락된 상황이다. 각 공정마다 제대로 된 체크나 기록 없이 일이 넘어가 버렸다. 결국 완성된 자동차에서 문제가 발견되어도, 어느 단계에서 누락이 발생했는지, 누구의 책임인지 알기 어려운 상황과 같다.

이런 구조에서는 오류가 발생해도 그 출처와 책임 소재를 파악하기 힘들다.

느낀 점이 있다면?

에러 처리의 필요성과 그 중요성을 확실히 느꼈다. 그동안 에러 처리는 늘 중요도가 떨어진다며 미뤄왔지만, 후순위로 미룬 작업은 실제로 구현에 반영된 적이 한 번도 없었다. 이번을 기회 삼아 제대로 학습하고, 구조적으로 일관성 있게 적용하고자 한다.

어떤 방식으로 해결해보고자 했는가?


내가 지금 부족한 개념은 무엇이지?

에러 처리 경험이 부족해 어떻게 처리해야 할지 명확하지 않았고, 특히 try..catch 문에 대한 이해가 부족했다. 이를 보완하기 위해 코어 자바스크립트의 에러 핸들링 파트를 참고하여 기본 개념부터 학습했다.

그 다음 순서는?

게시글 조회 요청을 처리하는 모듈과 함수를 정리하고, 각 단계에서 발생 가능한 에러 상황을 고려하여 구조를 그려보았다. '굳이..?'라고 생각할 수 있지만 어느 부분에 예외 처리가 필요한지, 비슷한 성격을 가진 부분은 없을지 파악해보고자 했다.

Notion image

이를 보고는 '헤더 파싱 오류나 데이터베이스 불러오기 오류 등 공통으로 처리할 수 있는 부분과 컨트롤러 파라미터 오류나 게시글 존재 오류처럼 도메인에 따라 다른 오류들이 있겠구나' 생각이 들었다.

그렇다면 이제 어떻게 나아갈 생각인가?

공통으로 사용할 전역 에러 클래스를 정의한 뒤, 이를 점차 구체화하여 도메인별 에러 클래스를 만들 예정이다. 하위 모듈에서는 에러가 발생하면 단순히 에러를 던지기만 한다. 최상위 모듈인 was.js 에서 모든 에러를 catch하여 일괄 처리하는 구조로 개선할 계획이다.

하나씩 구현해보자.


첫 번째 할 일 - 기본 에러 클래스

가장 먼저 할 일은 기본 에러 클래스(BaseError)를 만드는 것이다. 매번 개별 에러 클래스를 정의하는 대신, 기본 에러 클래스를 구현해두고 이를 상속받아 사용하는 방식을 적용하고자 했다.

/** 기본 에러 클래스 */
export class BaseError extends Error {
  constructor(message, httpStatus = HTTP_STATUS.INTERNAL_SERVER_ERROR, details = null) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = httpStatus.code;
    this.errorType = httpStatus.message;
    this.details = details;
    this.timestamp = new Date().toISOString();

    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      error: {
        type: this.errorType,
        message: this.message,
        details: this.details,
        timestamp: this.timestamp,
      },
    };
  }
}

이렇게 하면, BaseError를 상속받은 여러 에러 클래스가 있다고 할 때, 에러를 처리하는 코드에서 instanceof 연산자를 활용해 한 번에 잡을 수 있다.

try {
  // ...에러 발생 가능 코드
} catch (error) {
  if (error instanceof BaseError) {
    // BaseError를 상속받은 모든 에러를 한 번에 처리
  } else {
    // 그 외의 에러 처리
  }
}

이처럼 상속 구조를 활용하면, 여러 종류의 커스텀 에러를 공통적으로 관리할 수 있고, 에러 처리 로직을 일관되게 유지할 수 있다. 그 전까지는 몰랐지만, 이번에 공부하면서 새롭게 알았다!

두 번째 할일 - 상태 코드별 에러 클래스

기본 에러 클래스를 상속받아 상태 코드별 에러 클래스를 추가로 만든다. 도메인 에러 클래스에서 직접 사용할 수도 있지만, 도메인 외 다른 부분에서도 상태 코드별 에러 처리가 필요할 수 있기 때문이다. 이렇게 상태 코드별 에러 클래스를 만들어두면, 도메인 에러 클래스들은 이를 상속받아 간편하게 구현할 수 있다.

/** 검증 에러 */
export class ValidationError extends BaseError {
  constructor(message, details = null) {
    super(message, HTTP_STATUS.BAD_REQUEST, details);
  }
}

/** 리소스 없음 에러 */
export class NotFoundError extends BaseError {
  constructor(resource = "리소스") {
    super(`${resource}을(를) 찾을 수 없습니다`, HTTP_STATUS.NOT_FOUND);
  }
}

/** 인증 에러 */
export class UnauthorizedError extends BaseError {
  constructor(message = "인증이 필요합니다") {
    super(message, HTTP_STATUS.UNAUTHORIZED);
  }
}

/** 권한 에러 */
export class ForbiddenError extends BaseError {
  constructor(message = "접근 권한이 없습니다") {
    super(message, HTTP_STATUS.FORBIDDEN);
  }
}

/** 데이터베이스 에러 */
export class DatabaseError extends BaseError {
  constructor(message = "데이터베이스 오류가 발생했습니다") {
    super(message, HTTP_STATUS.INTERNAL_SERVER_ERROR);
  }
}
세 번째 할 일 - 도메인별 에러 클래스

상태 코드별 에러 클래스를 상속받아, 각 도메인에서 사용할 에러 클래스를 정의한다. 상태 코드는 이미 상위 클래스에서 처리되므로, 도메인 에러 클래스에서는 별도 파라미터로 받지 않아도 된다.

대신, 오류 메시지나 상태 메시지를 보다 구체화하여 설정할 수 있다. 이처럼 계층적으로 설계하면 명확한 장점이 있다.

// 게시글 도메인 에러
export class PostNotFoundError extends NotFoundError {
  constructor(postId = null) {
    const message = postId ? `게시글(ID:${postId})` : "게시글";
    super(message);
    this.errorType = "POST_NOT_FOUND";
  }
}

export class InvalidPostIdError extends ValidationError {
  constructor() {
    super("유효한 게시글 ID가 필요합니다");
    this.errorType = "INVALID_POST_ID";
  }
}

// 사용자 도메인 에러
export class InvalidUserIdError extends ValidationError {
  constructor() {
    super("유효한 사용자 ID가 필요합니다");
    this.errorType = "INVALID_USER_ID";
  }
}

export class InvalidUserDataError extends ValidationError {
  constructor(fields) {
    super("사용자 정보가 올바르지 않습니다", { invalidFields: fields });
    this.errorType = "INVALID_USER_DATA";
  }
}
현재 상황

여기까지 진행하고 나면 다음과 비슷한 상태일 것이다.

BaseError (기본 에러 클래스)
├── ValidationError (검증 에러 - 400)
├── NotFoundError (리소스 없음 - 404)
├── UnauthorizedError (인증 에러 - 401)
├── ForbiddenError (권한 에러 - 403)
├── ConflictError (중복 리소스 - 409)
└── DatabaseError (데이터베이스 에러 - 500)
// 게시글 도메인

PostNotFoundError extends NotFoundError
InvalidPostIdError extends ValidationError


// 사용자 도메인

UserNotFoundError extends NotFoundError
InvalidUserIdError extends ValidationError
UserAlreadyExistsError extends ConflictError
InvalidUserDataError extends ValidationError

이제 직접 사용해보자.


Repository 계층 (post.repository.js)

Repository 계층에서는 데이터 접근과 관련된 에러 처리를 담당한다.

/** 게시글 데이터 검증 함수 */
function validatePostDatabase(postDatabase) {
  // 데이터베이스 접근 여부 검증
  if (!postDatabase.data) {
    throw new DatabaseError("게시글 데이터를 읽을 수 없습니다");
  }

  // 데이터 형식 검증
  if (!Array.isArray(postDatabase.data.posts)) {
    throw new DatabaseError("게시글 데이터 형식이 올바르지 않습니다");
  }
}

/** 게시글 조회 */
async function getPostDetailRepository(postId) {
  try {
    await postDatabase.read();
    validatePostDatabase(postDatabase);
    ...

    return post;
  } catch (error) {
    // 이미 도메인 에러인 경우 그대로 던진다
    if (error instanceof DatabaseError) throw error;

    // 일반 에러는 도메인 에러로 감싸서 던진다
    throw new DatabaseError(`게시글 조회 실패:${error.message}`);
  }
}
Service 계층 (post.service.js)

Service 계층에서는 비즈니스 로직 검증 및 데이터 존재 여부를 확인한다.

/** 게시글 상세 정보 조회 */
export async function getPostDetailService({ postId, userId }) {
  const postDetail = await getPostDetailRepository({ postId, userId });

  // 게시글 데이터 존재 여부 확인
  if (!postDetail) throw new PostNotFoundError(postId);

  ...

  return postDetail;
}
Controller 계층 (post.controller.js)

Controller 계층은 클라이언트로부터 전달된 HTTP 요청 파라미터의 유효성을 검증한다.

/** 게시글 조회 파라미터 검증 */
function validateRequestParams({ postId, userId }) {
  // postId 존재 여부 검증
  if (!postId || !Number.isInteger(postId) || postId <= 0) {
	  throw new InvalidPostIdError();
  }

  // userId 존재 여부 검증
  if (!userId || !Number.isInteger(userId) || userId <= 0) {
	  throw new InvalidUserIdError();
  }
}

/** 게시글 조회 컨트롤러 */
export async function getPostDetailController(request, socket) {
  ...

  validateRequestParams({ postId, userId });
  const postDetail = await getPostDetailService({ postId, userId });

  ...
}
Route 계층 (post.route.js)

라우트 계층에서는 요청 URI가 매핑되는 핸들러가 없을 경우 에러를 처리한다.

/** 게시글 라우트 핸들러 */
export function getPostRouteHandler(request) {
  ...

  // 라우트 매칭 실패 시 에러 발생
  if (!handler) throw new RouteNotFoundError(request.uri);

  return handler;
}
WAS 진입점 (was.js)

최상위 모듈에서 모든 에러를 catch하고 응답을 통합 처리한다. 에러 처리는 handleRequestError 함수로 일원화한다.

/** HTTP 요청을 처리하는 핸들러 */
async function handleRequest(data, socket) {
  try {
    const request = parseHttpRequest(data.toString());
    const { method, uri, httpVersion } = request;
    const controller = findApiController(request);

    await controller(request, socket);
  } catch (error) {
    ...
    // 모든 에러를 이 부분에서 catch하여 처리
    handleRequestError({ error, socket, request });
  }
}
에러 응답을 생성하여 전송 (handleRequestError)
/** BaseError 처리 및 HTTP 응답 전송 */
function handleBaseError({ error, socket, requestInfo }) {
  const { name, errorType, message, statusCode } = error;

  return sendJsonResponse({
    socket,
    statusCode,
    data: error.toJSON()
  });
}

/** 시스템 에러 처리 및 500 응답 전송 */
function handleSystemError({ error, socket, requestInfo, request }) {
  const { method, uri, headers } = request || {};

  return sendJsonResponse({
    socket,
    statusCode: HTTP_STATUS_TEMP.INTERNAL_SERVER_ERROR.code,
    data: response
  });
}

/** 공통 에러 핸들러: 에러 타입에 따라 적절한 처리 함수 호출 */
export function handleRequestError({ error, socket, request = null }) {
  ...

  // BaseError를 상속받은 에러 처리
  if (error instanceof BaseError) {
    return handleBaseError({ error, socket, requestInfo });
  }

  // 알 수 없는 일반 시스템 에러 처리
  return handleSystemError({ error, socket, requestInfo, request });
}
이들을 다이어그램으로 표현하자면
Notion image

정리하며


어떤 경험을 통해 무엇을 느꼈는가?

에러 처리가 구조적으로 되어 있지 않으니, 어디에서 에러가 발생했는지 파악하기가 매우 어려웠다. 또한, 소켓 응답을 보내는 코드가 여러 파일에 흩어져 있다 보니, 문제 원인을 추적하는 데 많은 시간이 소요된다는 점을 직접 경험했다.

이 과정을 통해, 분산된 에러 처리 로직을 한 곳으로 모으고, 각 계층에 하나의 책임만을 부여하는 구조가 왜 필요한지 깨달을 수 있었다.

앞으로 지금 방식을 계속 적용할 것인가?

반드시 이 방식만을 고집할 생각은 없다. 이 방법이 항상 정답이라고 생각하지는 않는다. 프로젝트의 성격이나 사용하는 라이브러리에 따라 더 적합한 방식이 있을 수 있다. 하지만 이번 경험을 통해 에러 처리를 구조적으로 관리하는 방법을 알게 되었고, 앞으로도 다양한 상황에서 구조적인 설계를 우선적으로 고민할 것이다.

더 나아가서

만약 Express 같은 프레임워크를 사용한다면 어떻게 에러를 처리할 수 있을까? 프론트엔드에서는 사용자 경험과 직결되기 때문에 더욱 체계적인 에러 처리 구조가 필요하다. 공통 에러 처리만으로는 사용자 경험이 저하될 수 있으므로, 다른 방법이 또 필요하다고 생각한다. 어떻게 하면 일관되면서도 유연하게 에러를 관리할 수 있을지 더 고민해볼 것이다.