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

  • 설계
  • 에러 처리
  • Node.js
  • WAS
2025-09-26

들어가며

게시글 API를 테스트하던 중, 응답은 정상적으로 돌아오는데 게시글 데이터만 비어 있는 상황을 만났다. 서버 로그에는 에러 흔적이 전혀 없었고, 여러 원인을 하나씩 의심해가며 확인했지만 답이 나오지 않아 결국 요청이 들어오는 지점부터 응답이 나가는 지점까지 함수를 한 줄씩 따라가는 수밖에 없었다.

이 WAS는 진입점에서 라우트를 판별해 도메인별 라우트를 실행하고, 각 도메인은 MVC 패턴으로 repository, service, controller, route 네 계층으로 나뉜 구조였다. 각 계층은 try..catch로 에러를 처리했지만 그 처리 방식이 계층마다 제각각이었고, 그중 일부는 단순히 return null;로 종료해버리는 코드도 있었다.

원인은 게시글 정보를 조회하는 과정에서 발생한 에러를 return null;로 그냥 반환해버린 코드에 있었다. 실제로는 에러가 발생했는데도 에러로 인식되지 않고 조용히 다음 단계로 넘어간 것이다. 계층마다 에러 처리 방식이 다르다 보니, 같은 문제를 다시 겪어도 어느 지점에서 값이 사라졌는지 한눈에 보이지 않는 구조였다. 조립 라인 중간에서 너트 하나가 빠졌는데 어느 공정에서도 체크 없이 다음 단계로 넘어간 것과 같은 상황이었다.

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

해결 방향을 잡다

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

그다음 게시글 조회 요청을 처리하는 모듈과 함수를 정리하고, 각 단계에서 발생 가능한 에러 상황을 고려해 구조를 그려보았다. '굳이 여기까지 해야 하나' 싶은 생각도 들었지만, 어느 부분에 예외 처리가 필요한지, 비슷한 성격을 가진 부분은 없는지 미리 파악해두고 싶었다.

정리하고 보니 헤더 파싱 오류나 데이터베이스 불러오기 오류처럼 공통으로 처리할 수 있는 부분과, 컨트롤러 파라미터 오류나 게시글 존재 오류처럼 도메인에 따라 다른 오류들이 뚜렷하게 나뉘었다. 그래서 공통으로 사용할 전역 에러 클래스를 먼저 정의한 뒤, 이를 점차 구체화해 도메인별 에러 클래스를 만들기로 했다. 하위 모듈에서는 에러가 발생하면 단순히 에러를 던지기만 하고, 최상위 모듈인 was.js에서 모든 에러를 catch해 일괄 처리하는 구조로 개선할 계획을 세웠다.

BaseError 상속 구조로 계층을 나누다

가장 먼저 기본 에러 클래스(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'
  }
}

여기까지 진행하고 나면 다음과 같은 상속 구조가 만들어진다.

// 게시글 도메인
 
PostNotFoundError extends NotFoundError
InvalidPostIdError extends ValidationError
 
 
// 사용자 도메인
 
UserNotFoundError extends NotFoundError
InvalidUserIdError extends ValidationError
UserAlreadyExistsError extends ConflictError
InvalidUserDataError extends ValidationError

계층마다 다른 책임을 맡기다

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

/** 게시글 데이터 검증 함수 */
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)에서는 비즈니스 로직 검증과 데이터 존재 여부를 확인한다.

/** 게시글 상세 정보 조회 */
export async function getPostDetailService({ postId, userId }) {
  const postDetail = await getPostDetailRepository({ postId, userId });
 
  // 게시글 데이터 존재 여부 확인
  if (!postDetail) throw new PostNotFoundError(postId);
 
  ...
 
  return postDetail;
}

Controller 계층(post.controller.js)은 클라이언트로부터 전달된 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 });
  }
}

에러 응답을 생성해 전송하는 부분은 BaseError 여부에 따라 handleBaseError와 handleSystemError 두 함수로 나눠 구성했다.

/** 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 });
}

지금까지 만든 계층 구조를 다이어그램으로 정리하면, 하위 계층에서 던진 에러가 계층을 거슬러 올라가 결국 was.js 한 곳에서만 처리되는 흐름이 한눈에 보인다.

정리하며

에러 처리가 구조적으로 되어 있지 않으니 어디에서 에러가 발생했는지 파악하기가 매우 어려웠다. 소켓 응답을 보내는 코드가 여러 파일에 흩어져 있다 보니 문제 원인을 추적하는 데도 많은 시간이 걸렸다. 이 과정을 거치며 분산된 에러 처리 로직을 한 곳으로 모으고 각 계층에 하나의 책임만 부여하는 구조가 왜 필요한지 확인할 수 있었다.

앞으로도 이 방식만을 고집할 생각은 없다. 프로젝트의 성격이나 사용하는 라이브러리에 따라 더 적합한 방식이 있을 수 있다. 다만 이번 경험으로 에러 처리를 구조적으로 관리하는 방법을 알게 됐고, 앞으로도 다양한 상황에서 구조적인 설계를 먼저 고민할 생각이다.

만약 Express 같은 프레임워크를 쓴다면 어떻게 에러를 처리할 수 있을지도 궁금하다. 프론트엔드에서는 사용자 경험과 직결되는 만큼 더 체계적인 에러 처리 구조가 필요하고, 공통 에러 처리만으로는 사용자 경험이 저하될 수 있어 다른 방법도 함께 고민해야 한다고 생각한다. 일관되면서도 유연하게 에러를 관리하는 방법은 앞으로도 계속 고민할 부분이다.