본문 바로가기
Java Script

[JAVA SCRIPT] 비동기 에러 완벽 해결 방법! .then(), .catch(), .finally() 3가지 핵심 차이와 실무 활용 가이드

by Papa Martino V 2026. 5. 5.
728x90

.then(), .catch(), .finally()
.then(), .catch(), .finally()

 

1. 서론: 왜 우리는 Promise의 후속 처리 메서드를 알아야 하는가?

자바스크립트 생태계에서 비동기(Asynchronous) 프로그래밍은 피할 수 없는 숙명입니다. 서버로부터 데이터를 가져오거나(Fetch API), 타이머를 설정하거나, 사용자 이벤트를 처리할 때 메인 스레드를 블로킹(Blocking)하지 않기 위해 비동기 처리가 필수적입니다. 과거에는 이러한 비동기 흐름을 제어하기 위해 콜백 함수(Callback Function)를 사용했으나, 코드가 깊어지는 이른바 '콜백 지옥(Callback Hell)' 현상과 에러 추적의 어려움이라는 치명적인 단점이 존재했습니다. 이러한 비동기 프로그래밍의 한계를 해결하기 위해 ES6(ECMAScript 2015)에서 공식적으로 도입된 것이 바로 프로미스(Promise)입니다. Promise는 비동기 작업의 미래 상태(성공, 실패, 대기)를 캡슐화한 객체입니다. 하지만 Promise 객체 자체만으로는 아무것도 할 수 없습니다. 비동기 작업이 끝난 후 그 결과값을 받아 실제 비즈니스 로직을 수행하거나, 네트워크 장애 등으로 발생한 에러를 우아하게 해결하기 위해서는 반드시 후속 처리 메서드(Follow-up Methods)를 사용해야 합니다. 이 글에서는 Promise 체이닝의 핵심인 .then(), .catch(), .finally() 세 가지 메서드의 정확한 역할과 차이를 분석하고, 실무에서 발생할 수 있는 복잡한 에러 상황을 완벽하게 통제하는 방법을 전문적인 관점에서 심도 있게 다룹니다.

2. 작업의 성공과 흐름의 제어: .then()의 역할과 방법

.then() 메서드는 Promise가 성공적으로 이행(Fulfilled)되었을 때 실행될 콜백 함수를 등록하는 핵심 메서드입니다. 비동기 처리 결과를 받아 화면에 렌더링하거나, 데이터를 2차 가공하는 등의 작업은 모두 .then() 내부에서 이루어집니다.

2.1. 두 개의 콜백 함수를 인자로 받는다

많은 개발자가 간과하는 사실 중 하나는 .then()이 사실 두 개의 인자를 받을 수 있다는 것입니다. 첫 번째 인자는 Promise가 성공(Fulfilled)했을 때 호출되는 함수이고, 두 번째 인자는 실패(Rejected)했을 때 호출되는 함수입니다.

promise.then(onFulfilled, onRejected);

하지만 실무에서는 가독성과 에러 처리의 일관성을 위해 .then()의 두 번째 인자는 거의 사용하지 않으며, 에러 처리는 .catch()에게 위임하는 패턴(Promise Chaining)을 권장합니다.

2.2. 항상 새로운 Promise를 반환한다 (체이닝의 비밀)

.then() 메서드가 가진 가장 강력한 특징이자 방법론은 바로 '항상 새로운 Promise를 반환한다'는 것입니다. .then() 내부의 콜백 함수가 일반 값(숫자, 문자열 등)을 반환하더라도, 자바스크립트 엔진은 이를 암묵적으로 `resolve` 하는 Promise로 감싸서 반환합니다. 만약 콜백 내부에서 또 다른 Promise를 명시적으로 반환한다면, 그 Promise의 상태와 결과값이 다음 체인으로 그대로 이어집니다. 이것이 콜백 지옥을 평평한 형태(Flat)로 펴서 가독성을 높이는 '프로미스 체이닝(Promise Chaining)'의 핵심 원리입니다.

3. 에러의 방파제: .catch()의 역할과 해결 방법

.catch() 메서드는 Promise 체인 도중 발생한 모든 에러(Rejection)를 처리하기 위해 존재합니다. 네트워크 단절, 잘못된 데이터 파싱, 서버 500 에러 등 비동기 과정에서 발생할 수 있는 예측 불가능한 상황들을 안전하게 해결하는 방파제 역할을 수행합니다.

3.1. .then(null, onRejected)의 문법적 설탕(Syntactic Sugar)

자바스크립트 엔진 내부적으로 .catch(errHandler).then(undefined, errHandler)와 완벽하게 동일하게 동작합니다. 즉, .catch()는 오직 실패 상태만을 감지하기 위해 디자인된 직관적인 편의 문법입니다.

3.2. 체이닝을 통한 중앙 집중식 에러 핸들링

콜백 패턴에서는 매 콜백마다 if (err) return ... 형태의 방어 코드를 중복해서 작성해야 했습니다. 하지만 Promise 체이닝에서는 여러 개의 .then()을 연결하더라도 맨 마지막에 단 하나의 .catch()만 붙여두면, 앞선 어떤 단계에서 에러가 발생하든 즉시 실행 흐름이 .catch()로 건너뛰어 에러를 일괄 처리할 수 있습니다. 이는 코드의 유지보수성을 극적으로 향상시키는 해결 방법입니다.

4. 무조건적인 실행 보장: .finally()의 역할

ES2018(ES9)에 도입된 .finally() 메서드는 Promise의 성공(Fulfilled) 또는 실패(Rejected) 상태와 관계없이, 비동기 작업이 종료(Settled)되면 무조건 한 번은 실행되는 콜백 함수를 등록합니다.

4.1. 상태와 결과값에 관여하지 않음 (투명성)

.finally()의 콜백 함수는 어떠한 인자도 전달받지 않습니다. 왜냐하면 이 메서드는 앞선 작업이 성공했는지 실패했는지 여부를 알 필요가 없기 때문입니다. 오직 "작업이 끝났다"는 사실 자체가 중요할 때 사용합니다. 또한 .finally()는 자신이 반환하는 값을 다음 체인으로 넘기지 않고(에러를 throw하지 않는 한), 이전 상태와 값을 그대로 통과(Pass-through)시키는 특징이 있습니다.

4.2. 실무에서의 존재 가치: 리소스 정리 (Cleanup)

데이터 요청을 시작할 때 로딩 스피너(Loading Spinner)를 화면에 띄웠다고 가정해 봅시다. 성공했을 때는 .then()에서 스피너를 끄고, 실패했을 때는 .catch()에서 스피너를 꺼야 합니다. 이처럼 중복되는 정리 코드를 .finally() 한 곳에 모아 작성하면 코드가 훨씬 간결해지고 누락으로 인한 메모리 누수나 UI 버그를 해결할 수 있습니다.

5. 요약: .then(), .catch(), .finally() 3가지 핵심 차이 비교

앞서 다룬 세 가지 메서드의 문법적 특징과 실무에서의 역할을 한눈에 파악할 수 있도록 3가지 핵심 차이를 정리한 표입니다.

메서드 구분 실행 조건 (Promise 상태) 인자 (Parameters) 주요 역할 및 실무 활용 방법 반환값 (Return)
.then() Fulfilled (성공)
※ 두번째 인자로 Rejected 처리 가능
결과값 (Result Value) 정상적인 비동기 결과를 받아 UI 렌더링, 데이터 연속 가공, 다음 비동기 API 호출 등 정상 흐름 제어 새로운 Promise 객체 (체이닝 가능)
.catch() Rejected (실패 / 에러) 에러 객체 (Error / Reason) 네트워크 오류, 타임아웃, 예외 처리 발생 시 사용자에게 에러 알림 노출, 복구(Fallback) 데이터 제공 등 에러 해결 새로운 Promise 객체 (에러 복구 가능)
.finally() Settled (성공 또는 실패 완료 시) 없음 (인자를 받지 않음) 로딩 상태(스피너) 종료, 열려있는 데이터베이스 연결 해제, 스트림 닫기 등 필수적인 리소스 정리(Cleanup) 이전 Promise의 상태와 값을 그대로 전달

6. 개발자가 실무에 바로 적용 가능한 Example 7선

이론적인 이해를 넘어 실제 현업 프론트엔드 및 백엔드(Node.js) 개발 환경에서 마주치는 상황들을 해결하기 위한 7가지 실전 코드를 제공합니다. 복사해서 바로 크롬 개발자 도구나 Node 환경에서 테스트해 보실 수 있습니다.

Example 1: Fetch API를 활용한 기본적인 데이터 체이닝 방법

가장 흔하게 사용되는 REST API 호출 및 JSON 데이터 파싱 패턴입니다.

// Example 1: 기본적인 비동기 데이터 조회
fetch('https://jsonplaceholder.typicode.com/users/1')
  .then(response => {
    // 1. HTTP 상태 코드가 200~299 사이인지 확인
    if (!response.ok) {
      throw new Error(`HTTP 에러 발생! 상태: ${response.status}`);
    }
    // 2. Response 객체를 JSON 형태의 Promise로 변환 (다음 then으로 전달)
    return response.json(); 
  })
  .then(userData => {
    // 3. 파싱이 완료된 순수 자바스크립트 객체를 받아 비즈니스 로직 수행
    console.log('[Ex 1] 조회된 사용자 이름:', userData.name);
    console.log('[Ex 1] 이메일 주소:', userData.email);
  })
  .catch(error => {
    // 4. 네트워크 단절이나 파싱 에러 등을 일괄 처리
    console.error('[Ex 1] 데이터 조회 실패:', error.message);
  });

Example 2: .finally()를 이용한 무조건적인 로딩 UI 종료 해결

성공과 실패 여부에 상관없이 로딩 상태를 false로 변경하는 깔끔한 방법입니다.

// Example 2: 로딩 스피너 상태 관리
let isLoading = true;
console.log('[Ex 2] 로딩 스피너 시작...', isLoading);

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(res => res.json())
  .then(data => {
    console.log('[Ex 2] 게시물 제목:', data.title);
  })
  .catch(err => {
    console.error('[Ex 2] 게시물 로드 에러:', err);
  })
  .finally(() => {
    // 앞의 성공/실패와 무조건 실행됨. 코드 중복 제거.
    isLoading = false;
    console.log('[Ex 2] 데이터 통신 종료. 로딩 스피너 해제 완료.', isLoading);
  });

Example 3: 에러 복구(Error Recovery) 패턴 차이

에러가 발생했을 때 프로그램이 멈추는 것이 아니라, .catch() 내부에서 기본값(Default Data)을 반환하여 다음 체인이 정상적으로 동작하게 만드는 고급 해결 방법입니다.

// Example 3: 에러 발생 시 캐시나 기본값으로 복구하기
function getUserSettings() {
  // 고의로 에러를 발생시키는 가짜 API 호출
  return Promise.reject(new Error("서버에서 설정값을 가져올 수 없습니다."));
}

getUserSettings()
  .catch(error => {
    console.warn(`[Ex 3] 경고: ${error.message} (기본 테마를 적용합니다)`);
    // 에러를 처리하고 '정상적인' 객체를 반환 (여기서 Promise 상태가 Fulfilled로 복구됨)
    return { theme: 'light', notifications: true }; 
  })
  .then(settings => {
    // 에러가 났지만 위 catch에서 복구되었으므로 정상 실행됨
    console.log('[Ex 3] 애플리케이션 적용 설정:', settings.theme);
  });

Example 4: 의도적인 에러 던지기 (Throwing Custom Errors)

비즈니스 로직상 조건에 맞지 않을 때 명시적으로 에러를 발생시켜 흐름을 .catch()로 강제 이동시키는 방법입니다.

// Example 4: 데이터 검증 실패 시 흐름 중단하기
const checkStock = (productId) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const stock = 0; // 재고 없음
      if (stock <= 0) {
        // 비즈니스 로직 상의 에러 발생
        reject(new Error("해당 상품은 현재 품절(Out of Stock) 상태입니다."));
      }
      resolve("상품 주문 가능");
    }, 500);
  });
};

checkStock(101)
  .then(message => {
    console.log('[Ex 4] 다음 결제 프로세스 진행:', message);
    // 재고가 없으므로 이 라인은 실행되지 않음
  })
  .catch(err => {
    // 사용자에게 품절 알림 UI 표시
    console.error(`[Ex 4] 주문 중단: ${err.message}`);
  });

Example 5: .catch() 위치에 따른 에러 제어 흐름 차이

.catch()를 체인 중간에 배치하느냐, 끝에 배치하느냐에 따라 에러를 삼킬지, 뒤로 전파할지 결정할 수 있는 아키텍처적 차이를 보여줍니다.

// Example 5: 부분적 에러 허용 아키텍처
Promise.resolve("Step 1 통과")
  .then(result => {
    console.log('[Ex 5]', result);
    throw new Error("Step 2에서 치명적이지 않은 에러 발생!");
  })
  .catch(err => {
    // 중간에서 에러를 잡아서 삼켜버림 (뒤로 전파 안됨)
    console.warn('[Ex 5] 중간 에러 처리:', err.message);
    return "Step 2 복구 데이터";
  })
  .then(result => {
    // 바로 앞의 catch가 에러를 해결했으므로 계속 진행됨
    console.log('[Ex 5] 체인 계속 진행:', result);
    return "Step 3 완료";
  })
  .catch(err => {
    // 만약 앞의 catch가 없었다면 여기까지 에러가 넘어옴
    console.error('[Ex 5] 최종 에러 처리기:', err);
  });

Example 6: 비동기 루프와 순차적 실행 제어

배열의 데이터를 순차적으로 (동시에 병렬 처리가 아니라, 하나가 끝나면 다음 것을 시작) 처리하기 위해 reduce와 Promise를 조합하는 방법입니다.

// Example 6: 배열의 항목을 순차적으로 비동기 처리하기
const taskIds = [1, 2, 3];

const processTask = (id) => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`[Ex 6] Task ${id} 처리 완료`);
      resolve(`결과 ${id}`);
    }, 1000); // 각각 1초씩 걸림
  });
};

// reduce를 사용한 순차적 Promise 체이닝
taskIds.reduce((promiseChain, currentId) => {
  return promiseChain.then(() => processTask(currentId));
}, Promise.resolve())
  .then(() => {
    console.log("[Ex 6] 모든 태스크가 순차적으로 완료되었습니다.");
  });

Example 7: 다중 비동기 트랜잭션 시뮬레이션 및 정리

데이터베이스 트랜잭션처럼 성공하면 커밋하고, 실패하면 롤백을 알리며, 마지막엔 연결을 끊는 종합적인 해결 방법입니다.

// Example 7: 종합 트랜잭션 시나리오
let dbConnection = true; // DB 연결 상태

const startTransaction = () => Promise.resolve("트랜잭션 시작");
const executeQuery = () => Promise.reject(new Error("SQL 문법 오류 발생!"));

console.log(`[Ex 7] 초기 DB 상태 (연결됨): ${dbConnection}`);

startTransaction()
  .then(msg => {
    console.log('[Ex 7]', msg);
    return executeQuery(); // 여기서 에러 발생
  })
  .then(() => {
    console.log('[Ex 7] 쿼리 성공, COMMIT 진행');
  })
  .catch(err => {
    console.error('[Ex 7] 시스템 예외 감지! ROLLBACK 실행:', err.message);
  })
  .finally(() => {
    // 성공/실패 여부와 무조건 연결 해제
    dbConnection = false;
    console.log(`[Ex 7] 자원 반환 완료. 현재 DB 상태 (연결됨): ${dbConnection}`);
  });

7. 결론: 견고한 프론트엔드/백엔드 아키텍처를 향하여

자바스크립트의 Promise 모델은 초보 개발자들에게는 다소 난해한 개념일 수 있습니다. 하지만 오늘 다룬 .then(), .catch(), .finally()의 명확한 역할 분담과 차이를 이해한다면, 마치 블록을 조립하듯 유연하고 강력한 비동기 흐름을 설계할 수 있습니다. 성공적인 로직은 .then()에 위임하고, 예외 상황에 대한 해결 방법.catch()에 집중시키며, 마지막 청소(Cleanup) 작업은 .finally()에 맡기는 '관심사의 분리(Separation of Concerns)' 원칙을 지키십시오. 비록 최근에는 async/await 문법이 대세로 자리 잡았으나, async/await 역시 내부적으로는 완벽하게 동일한 Promise 기반으로 동작하기 때문에 본질적인 체이닝 기법에 대한 깊은 이해는 전문성을 결정짓는 중요한 척도가 됩니다.

[참고 문헌 및 출처]
  • Mozilla Developer Network (MDN) Web Docs 
  • ECMAScript 2015 (ES6) Specification (ECMA-262)
  • JavaScript.info 
728x90