
예외 처리: try-catch-finally
과학 실험(try) 중에 불이 나면 소화기를 쏘고(catch), 불이 나든 말든 실험실 청소(finally)는 해야 한다.

과학 실험(try) 중에 불이 나면 소화기를 쏘고(catch), 불이 나든 말든 실험실 청소(finally)는 해야 한다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

첫 사이드 프로젝트 런칭하고 나서 사흘째 되던 날이었다. 새벽 3시에 슬랙 알림이 울렸다. 서버가 죽었다는 거였다. 급히 로그를 열어봤더니 TypeError: Cannot read property 'id' of undefined 한 줄 남기고 프로세스가 종료되어 있었다. 사용자가 프로필 이미지를 업로드하려는데 파일 사이즈 검증 로직에서 에러가 났고, 그 에러를 아무도 잡아주지 않아서 서버 전체가 뻗어버린 거였다.
그때 나는 깨달았다. "에러는 무조건 난다. 문제는 에러가 났을 때 프로그램이 죽느냐, 우아하게 대처하느냐"라는 걸. 예외 처리(Exception Handling)는 선택이 아니라 필수였다. 이건 내가 비전공자로서 가장 늦게 받아들인 진실 중 하나였다.
처음 코딩을 배울 때는 "행복한 경로(Happy Path)"만 생각했다. 사용자는 항상 올바른 값을 입력하고, API는 항상 200을 반환하고, 파일은 항상 존재한다고 가정했다. 그런데 현실은 달랐다.
코드를 작성하는 시간보다 "이게 왜 안 돌아가지?"를 디버깅하는 시간이 더 길었다. 그리고 깨달았다. 좋은 개발자와 나쁜 개발자의 차이는 에러를 얼마나 잘 예측하고 대비하느냐에 있다는 걸.
try-catch를 처음 배웠을 때는 "그냥 에러 나면 잡는 거구나" 정도로만 이해했다. 그런데 실제로 써보니 이건 단순한 에러 처리 도구가 아니었다. 프로그램의 흐름을 제어하는 제어 구조였다.
나는 이런 비유가 와닿았다. try-catch는 서커스 공중 그네 아래 설치된 안전망이다.
공연이 완벽하게 성공해도 무대는 정리해야 한다. 실수로 떨어져도 무대는 정리해야 한다. 그게 finally의 역할이었다.
JavaScript로 코딩하면서 처음엔 모든 에러가 똑같아 보였다. 그냥 빨간 글씨로 뭔가 나오면 "에러 났네"라고만 생각했다. 그런데 에러에도 종류가 있었다.
// 따옴표를 안 닫았다
const message = "Hello;
// SyntaxError: Invalid or unexpected token
이건 코드를 실행하기도 전에 파싱 단계에서 터진다. try-catch로 잡을 수조차 없다. 애초에 코드가 유효하지 않아서 JavaScript 엔진이 읽지를 못한다.
const user = null;
console.log(user.name); // TypeError: Cannot read property 'name' of null
내가 가장 많이 만났던 에러다. "객체일 거라고 생각했는데 null이었네?" 이런 상황. API 응답이 예상과 다를 때 엄청 자주 발생한다.
console.log(notDefined); // ReferenceError: notDefined is not defined
변수 이름을 오타냈거나, 스코프 바깥에서 접근하려고 할 때 난다.
const arr = new Array(-1); // RangeError: Invalid array length
배열 길이를 음수로 지정하거나, 재귀 호출이 너무 깊어져서 스택 오버플로우가 날 때 발생한다.
나는 이 에러들을 "실행 중에 터지는 폭탄들"이라고 이해했다. 코드는 문법적으로 맞지만, 실행하다 보니 뭔가 잘못된 상황을 만난 거다. 그리고 이런 폭탄들은 try-catch로 잡을 수 있다.
JavaScript에서 Node.js로 백엔드를 하다가 프로젝트에서 Java Spring을 써야 하는 상황이 왔다. 그때 나는 Java의 "Checked Exception"이라는 개념에 충격을 받았다.
// 이건 컴파일이 안 된다!
public void readFile(String path) {
FileReader reader = new FileReader(path); // Compile Error!
// Unhandled exception: java.io.FileNotFoundException
}
// try-catch를 쓰거나
public void readFile(String path) {
try {
FileReader reader = new FileReader(path);
} catch (FileNotFoundException e) {
System.out.println("파일이 없습니다: " + e.getMessage());
}
}
// throws로 위임하거나
public void readFile(String path) throws FileNotFoundException {
FileReader reader = new FileReader(path);
}
Java는 "이 함수는 FileNotFoundException이 날 수 있으니까 무조건 처리해"라고 컴파일러가 강제한다. JavaScript는 그냥 실행하다가 터지면 터지는 거였는데, Java는 컴파일 시점에 미리 예외 처리를 요구한다.
처음엔 귀찮았다. "왜 나한테 이걸 강요해?" 근데 프로젝트 규모가 커지니까 이해가 됐다. 예외가 발생할 수 있는 모든 지점이 코드에 명시되어 있으니까, 나중에 유지보수할 때 "어디서 에러가 날 수 있지?"를 고민할 필요가 없었다.
// 이건 처리 안 해도 컴파일된다
int result = 10 / 0; // ArithmeticException (RuntimeException의 하위 클래스)
RuntimeException과 그 하위 클래스들(NullPointerException, ArrayIndexOutOfBoundsException 등)은 Checked가 아니다. 왜? 프로그래머의 실수로 인한 에러이기 때문이다. 이건 try-catch로 잡기보다는 코드를 고쳐야 한다.
나는 이렇게 정리했다.
프로젝트가 커지면서 "그냥 Error"만으로는 부족했다. API 요청이 실패했을 때, 그게 네트워크 문제인지, 인증 실패인지, 서버 에러인지 구분해야 했다.
// Before: 모든 게 그냥 Error
throw new Error("API 요청 실패");
// After: 세분화된 에러
class NetworkError extends Error {
constructor(message) {
super(message);
this.name = "NetworkError";
this.statusCode = 0; // 네트워크 자체가 안 됨
}
}
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = "AuthenticationError";
this.statusCode = 401;
}
}
class ServerError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "ServerError";
this.statusCode = statusCode;
}
}
이렇게 하니까 catch 블록에서 에러 종류별로 다른 처리를 할 수 있었다.
try {
await api.fetchUserProfile();
} catch (error) {
if (error instanceof NetworkError) {
showToast("인터넷 연결을 확인해주세요");
} else if (error instanceof AuthenticationError) {
redirectToLogin();
} else if (error instanceof ServerError) {
logToSentry(error);
showToast("서버 오류입니다. 잠시 후 다시 시도해주세요");
} else {
// 예상 못한 에러
console.error("Unknown error:", error);
showToast("알 수 없는 오류가 발생했습니다");
}
}
나는 이걸 "에러에게도 신분증을 주는 것"이라고 받아들였다. 에러가 자기소개를 하니까, 나는 그에 맞는 대응을 할 수 있었다.
처음엔 "try-catch를 모든 곳에 붙여야 하나?"라고 생각했다. 그런데 그건 아니었다. 에러는 호출 스택을 따라 위로 전파된다.
function validateEmail(email) {
if (!email.includes("@")) {
throw new Error("잘못된 이메일 형식");
}
}
function processUserInput(formData) {
validateEmail(formData.email); // 여기서 에러가 나면
// 아래 코드는 실행 안 됨
saveToDatabase(formData);
}
function handleSubmit() {
try {
processUserInput({ email: "invalid" }); // 여기까지 에러가 전파됨
} catch (error) {
console.error("입력 처리 실패:", error.message);
}
}
validateEmail에서 에러가 발생하면, processUserInput을 건너뛰고 handleSubmit의 catch로 바로 간다. 마치 건물에 화재 경보가 울리면 모든 층을 건너뛰고 1층 비상구로 내려가는 것처럼.
이걸 이해하고 나서는, 비즈니스 로직에서는 에러를 throw만 하고, 컨트롤러나 최상위 레벨에서만 catch하는 패턴을 쓰기 시작했다.
Promise를 처음 배울 때 가장 헷갈렸던 게 에러 처리였다.
// 이건 에러를 못 잡는다!
try {
fetch("https://api.example.com/data")
.then(res => res.json())
.then(data => console.log(data));
} catch (error) {
// 여기로 안 온다!
console.error(error);
}
Promise는 비동기로 실행되기 때문에 try-catch가 작동하지 않는다. fetch가 실행되는 시점엔 이미 try 블록을 벗어난 상태다.
fetch("https://api.example.com/data")
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.json();
})
.then(data => console.log(data))
.catch(error => {
// 여기서 에러를 잡는다
console.error("API 요청 실패:", error);
})
.finally(() => {
// 성공하든 실패하든 실행
hideLoadingSpinner();
});
async function fetchUserData() {
try {
const response = await fetch("https://api.example.com/user");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("사용자 데이터 로딩 실패:", error);
return null; // 기본값 반환
} finally {
hideLoadingSpinner();
}
}
나는 async/await를 쓰면서 "드디어 비동기 코드도 동기 코드처럼 에러를 처리할 수 있구나"라고 느꼈다. 코드가 훨씬 읽기 쉬워졌다.
아무리 try-catch를 잘 써도, 놓치는 에러는 있다. 그래서 최후의 안전망으로 전역 에러 핸들러를 설정했다.
// 동기 에러
process.on("uncaughtException", (error) => {
console.error("잡히지 않은 예외:", error);
// 로그를 남기고
logErrorToFile(error);
// 프로세스를 종료 (상태가 불안정할 수 있음)
process.exit(1);
});
// Promise rejection
process.on("unhandledRejection", (reason, promise) => {
console.error("처리되지 않은 Promise 거부:", reason);
logErrorToSentry(reason);
});
window.addEventListener("error", (event) => {
console.error("전역 에러:", event.error);
// Sentry 같은 에러 추적 서비스로 전송
Sentry.captureException(event.error);
});
window.addEventListener("unhandledrejection", (event) => {
console.error("처리 안 된 Promise 에러:", event.reason);
Sentry.captureException(event.reason);
});
하지만 전역 핸들러는 마지막 보루일 뿐이다. 여기까지 온 에러는 이미 프로그램 상태가 불안정할 수 있다. 그래서 여기서 할 수 있는 건 로그를 남기고 우아하게 종료하는 것뿐이었다.
에러가 발생했을 때 "console.log만 찍고 끝"이면 나중에 디버깅할 때 아무것도 모른다. 나는 이런 원칙을 세웠다.
try {
await updateUserProfile(userId, newData);
} catch (error) {
// Bad: 에러만 로깅
console.error(error);
// Good: 컨텍스트와 함께
console.error("사용자 프로필 업데이트 실패", {
userId,
attemptedData: newData,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
}
// 경고 수준 (복구 가능)
logger.warn("캐시 갱신 실패, 기본값 사용", { error });
// 에러 수준 (기능 동작 안 함)
logger.error("결제 처리 실패", { error, orderId });
// 치명적 수준 (서비스 전체 다운)
logger.fatal("데이터베이스 연결 불가", { error });
// Bad: 비밀번호가 로그에 남는다
console.error("로그인 실패", { email, password });
// Good: 민감 정보 제외
console.error("로그인 실패", { email, errorCode: error.code });
실제로 가장 많이 쓰는 패턴을 정리해본다.
async function fetchWithErrorHandling(url, options = {}) {
try {
const response = await fetch(url, options);
// HTTP 상태 코드 체크
if (!response.ok) {
// 4xx: 클라이언트 에러
if (response.status >= 400 && response.status < 500) {
const errorData = await response.json();
throw new AuthenticationError(errorData.message);
}
// 5xx: 서버 에러
if (response.status >= 500) {
throw new ServerError(`서버 오류 (${response.status})`);
}
}
const data = await response.json();
return { success: true, data };
} catch (error) {
// 네트워크 자체가 안 될 때
if (error instanceof TypeError && error.message.includes("fetch")) {
return {
success: false,
error: new NetworkError("인터넷 연결을 확인해주세요")
};
}
// 우리가 만든 커스텀 에러면 그대로 전달
if (error instanceof NetworkError ||
error instanceof AuthenticationError ||
error instanceof ServerError) {
return { success: false, error };
}
// 예상 못한 에러
console.error("Unexpected error:", error);
return {
success: false,
error: new Error("알 수 없는 오류가 발생했습니다")
};
}
}
// 사용
const result = await fetchWithErrorHandling("/api/user/profile");
if (result.success) {
displayProfile(result.data);
} else {
showErrorMessage(result.error.message);
}
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
function validateLoginForm(formData) {
if (!formData.email) {
throw new ValidationError("email", "이메일을 입력해주세요");
}
if (!formData.email.includes("@")) {
throw new ValidationError("email", "올바른 이메일 형식이 아닙니다");
}
if (!formData.password) {
throw new ValidationError("password", "비밀번호를 입력해주세요");
}
if (formData.password.length < 8) {
throw new ValidationError("password", "비밀번호는 8자 이상이어야 합니다");
}
}
function handleLogin(formData) {
try {
validateLoginForm(formData);
// 검증 통과 후 로그인 진행
await login(formData);
} catch (error) {
if (error instanceof ValidationError) {
// 특정 필드에 에러 표시
showFieldError(error.field, error.message);
} else {
showToast("로그인에 실패했습니다");
}
}
}
async function connectDatabase(retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const db = await mongoose.connect(process.env.DB_URL);
console.log("DB 연결 성공");
return db;
} catch (error) {
console.error(`DB 연결 실패 (시도 ${attempt}/${retries})`, error);
if (attempt === retries) {
// 마지막 시도까지 실패
throw new Error("데이터베이스 연결에 실패했습니다");
}
// 재시도 전 대기 (exponential backoff)
const waitTime = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
// Bad: 에러가 났는지도 모른다
try {
riskyOperation();
} catch (error) {
// 아무것도 안 함
}
// Good: 최소한 로깅이라도
try {
riskyOperation();
} catch (error) {
console.error("riskyOperation 실패:", error);
// 또는 복구 로직
}
// Bad: 모든 에러를 하나로 뭉갬
try {
await api.call();
} catch (error) {
showToast("오류가 발생했습니다");
}
// Good: 에러 타입별 처리
try {
await api.call();
} catch (error) {
if (error.status === 401) {
redirectToLogin();
} else if (error.status === 500) {
showToast("서버 오류입니다");
logToSentry(error);
} else {
showToast("요청 실패: " + error.message);
}
}
// Bad: 프로그래머 실수를 숨김
try {
const result = someUndefinedVariable.property;
} catch (error) {
// 이건 코드를 고쳐야 하는데 숨겨버림
}
// Good: 이런 건 그냥 터지게 놔둬서 개발 중에 발견해야 함
예외 처리는 단순히 "에러 나면 잡는 것"이 아니었다. 프로그램이 예측 불가능한 상황에서도 제어권을 유지하는 것이었다. 나는 이제 코드를 작성할 때 항상 이렇게 생각한다.
try는 희망을 담아 시도하는 것이고, catch는 실패를 인정하고 대처하는 것이며, finally는 어떤 상황이든 책임을 다하는 것이다. 이 세 가지가 모여서 안정적인 소프트웨어가 만들어진다.
새벽 3시에 서버가 죽었던 그 날 이후로, 나는 모든 중요한 로직에 예외 처리를 넣는다. 에러는 피할 수 없지만, 에러로 인한 재앙은 피할 수 있다. 그게 내가 받아들인 예외 처리의 본질이다.