PHpullh

LANGUAGE COMPARISON

Error를 언어별로 비교하기

같은 개념을 실제 예제로 나란히 확인하고, 각 언어의 개별 설명으로 이어갈 수 있습니다.

실패를 어디에 적어 두는가

에러 처리 문법은 결국 한 가지 질문에 대한 서로 다른 대답입니다. 함수가 실패할 수 있다는 사실을 어디에 기록할 것인가. 반환 타입에 적으면 호출하는 쪽이 무시할 수 없고, 별도의 채널로 던지면 본문 코드는 깔끔해지지만 실패 경로가 눈에 안 보입니다. 아래 열 개 언어는 이 선택지 위에 세 무리로 나뉘어 있습니다.

명시적 반환 계열은 Go가 대표입니다. 오류를 두 번째 반환값으로 돌려주고, 호출자는 if err != nil을 직접 씁니다. 코드가 길어지는 대신 어떤 호출이 실패할 수 있는지가 파일을 읽는 것만으로 보입니다. PHP에서 결과 모양의 배열을 돌려주는 관용구도 같은 사고방식입니다.

결과 타입 계열은 Rust의 Result와 Kotlin에서 Arrow의 Either가 대표입니다. 성공과 실패를 한 타입 안에 담고, 값을 꺼내려면 반드시 둘 다 다뤄야 합니다. Go와 목적은 같지만 컴파일러가 검사한다는 점이 다르고, ? 같은 전파 연산자가 있어 보일러플레이트도 덜합니다.

예외 계열에는 Java, C#, Python, JavaScript, TypeScript, C++, 그리고 표준 Kotlin이 들어갑니다. 정상 흐름과 실패 흐름을 문법적으로 분리해 본문을 짧게 유지하고, 여러 단계 위로 한 번에 빠져나갈 수 있습니다. 대신 어떤 함수가 무엇을 던지는지는 타입에 남지 않습니다. Java의 checked exception만이 이 규칙의 예외로, 서명에 실패 가능성을 강제로 적게 합니다.

Kotlin

try-catch-finally & 커스텀 예외

Kotlin의 예외 처리. 모든 예외가 unchecked입니다(checked exception 없음).

// 커스텀 예외
class ValidationException(
    message: String,
    val field: String
) : IllegalArgumentException(message)

class NotFoundException(val id: Int)
    : RuntimeException("ID $id를 찾을 수 없습니다")

// try는 표현식 — 값을 반환
fun parseInt(s: String): Int? =
    try { s.toInt() }
    catch (e: NumberFormatException) { null }

fun findUser(id: Int): String {
    if (id <= 0) throw ValidationException("ID는 양수", "id")
    if (id > 100) throw NotFoundException(id)
    return "User#$id"
}

fun main() {
    // try-catch-finally
    try {
        println(findUser(0))
    } catch (e: ValidationException) {
        println("검증 오류 [${e.field}]: ${e.message}")
    } catch (e: NotFoundException) {
        println("없음: ${e.message}")
    } finally {
        println("항상 실행")
    }

    // try 표현식
    val n1 = parseInt("42")    // 42
    val n2 = parseInt("abc")   // null
    println("$n1, $n2")
}

Python

예외 처리 & 커스텀 예외

Python의 예외 계층과 올바른 예외 처리 패턴.

# 커스텀 예외 — 계층 구조 설계
class AppError(Exception):
    """애플리케이션 기반 예외"""
    def __init__(self, message: str, code: int = 0):
        super().__init__(message)
        self.code = code

class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        super().__init__(f"[{field}] {message}", code=400)
        self.field = field

class NotFoundError(AppError):
    def __init__(self, resource: str, id: int):
        super().__init__(f"{resource} #{id} 없음", code=404)

# try / except / else / finally
def process(data: dict) -> str:
    try:
        value = data["key"]          # KeyError 가능
        result = int(value)          # ValueError 가능
        if result < 0:
            raise ValidationError("key", "양수여야 함")
        return f"결과: {result}"
    except KeyError as e:
        raise ValidationError("key", f"{e} 키 없음") from e
    except ValueError as e:
        raise ValidationError("key", "정수 변환 불가") from e
    except ValidationError:
        raise   # 재발생
    else:
        # 예외 없을 때만 실행
        print("성공")
    finally:
        # 항상 실행
        print("정리")

# ExceptionGroup (Python 3.11+)
try:
    raise ExceptionGroup("다중 오류", [
        ValueError("값 오류"),
        TypeError("타입 오류"),
    ])
except* ValueError as eg:
    print(f"ValueError: {eg.exceptions}")
except* TypeError as eg:
    print(f"TypeError: {eg.exceptions}")

Go

에러 처리 패턴 & errors 패키지

Go의 에러 처리 철학: 에러는 값입니다. 예외 대신 명시적 반환으로 처리합니다.

package main

import (
	"errors"
	"fmt"
)

// Sentinel error — 비교 가능한 에러 값
var (
	ErrNotFound   = errors.New("not found")
	ErrPermission = errors.New("permission denied")
)

// 커스텀 에러 타입
type NotFoundError struct {
	Resource string
	ID       int
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("%s #%d를 찾을 수 없습니다", e.Resource, e.ID)
}

// 에러 래핑 (Go 1.13+)
func findUser(id int) (string, error) {
	if id <= 0 {
		return "", fmt.Errorf("잘못된 ID %d: %w", id, ErrNotFound)
	}
	if id > 100 {
		return "", &NotFoundError{"User", id}
	}
	return fmt.Sprintf("User#%d", id), nil
}

func main() {
	// 기본 에러 처리
	user, err := findUser(0)
	if err != nil {
		// errors.Is — Sentinel 에러 체인 검사
		if errors.Is(err, ErrNotFound) {
			fmt.Println("없음:", err)
		}
	} else {
		fmt.Println(user)
	}

	// errors.As — 특정 타입으로 언래핑
	_, err = findUser(999)
	var nfe *NotFoundError
	if errors.As(err, &nfe) {
		fmt.Printf("리소스: %s, ID: %d
", nfe.Resource, nfe.ID)
	}

	// 에러 무시 안 하기
	if _, err := findUser(1); err != nil {
		fmt.Println("예상치 못한 에러:", err)
	} else {
		fmt.Println("성공!")
	}

	// fmt.Errorf + %w로 컨텍스트 추가
	err = fmt.Errorf("서비스 레이어: %w",
		fmt.Errorf("저장소 레이어: %w", ErrNotFound))
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrNotFound)) // true (체인 탐색)
}

Java

예외 처리 & try-with-resources

Checked/Unchecked 예외, 커스텀 예외 계층, try-with-resources로 리소스를 안전하게 관리합니다.

import java.io.*;

// 커스텀 예외 계층
public class AppException extends RuntimeException {  // Unchecked
    private final int errorCode;

    public AppException(String message, int errorCode) {
        super(message);
        this.errorCode = errorCode;
    }

    public AppException(String message, int errorCode, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public int getErrorCode() { return errorCode; }
}

public class NotFoundException extends AppException {
    public NotFoundException(String resource, long id) {
        super("%s #%d를 찾을 수 없습니다".formatted(resource, id), 404);
    }
}

public class ExceptionDemo {
    // try-with-resources — AutoCloseable 자동 닫기
    static String readFile(String path) throws IOException {
        try (var reader = new BufferedReader(new FileReader(path))) {
            var sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append(System.lineSeparator());
            }
            return sb.toString();
        }
    }

    // 멀티 catch (Java 7+)
    static void process(String input) {
        try {
            int n = Integer.parseInt(input);
            int[] arr = new int[n];
            arr[n] = 1;  // 일부러 에러
        } catch (NumberFormatException | NegativeArraySizeException e) {
            System.out.println("입력 오류: " + e.getMessage());
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("범위 초과: " + e.getMessage());
        } finally {
            System.out.println("항상 실행");
        }
    }

    public static void main(String[] args) {
        // 예외 체이닝
        try {
            throw new AppException("DB 연결 실패", 500,
                new RuntimeException("Connection timeout"));
        } catch (AppException e) {
            System.out.println(e.getMessage() + " [" + e.getErrorCode() + "]");
            System.out.println("원인: " + e.getCause().getMessage());
        }
        process("3");
    }
}

PHP

try catch 기본

예외를 발생시키고 잡는 PHP의 가장 기본적인 흐름을 보여주는 예제입니다.

<?php
try {
    throw new RuntimeException('boom');
} catch (RuntimeException $e) {
    echo $e->getMessage();
}

JavaScript

try catch 기본

throw로 던진 RuntimeException을 같은 타입의 catch가 받아 메시지를 꺼냅니다. 잡을 타입을 좁게 적으면 예상한 실패만 처리하고 나머지는 위로 흘려보낼 수 있습니다. 내장 예외 대부분은 Exception을 상속하지만 TypeError 같은 Error 계열은 이 catch에 걸리지 않습니다.

// try catch 기본
try {
  JSON.parse("{");
} catch (error) {
  console.log(error instanceof Error ? error.message : String(error));
}

TypeScript

try catch 기본

throw로 던진 RuntimeException을 같은 타입의 catch가 받아 메시지를 꺼냅니다. 잡을 타입을 좁게 적으면 예상한 실패만 처리하고 나머지는 위로 흘려보낼 수 있습니다. 내장 예외 대부분은 Exception을 상속하지만 TypeError 같은 Error 계열은 이 catch에 걸리지 않습니다.

// try catch 기본
try {
  JSON.parse("{");
} catch (error) {
  console.log(error instanceof Error ? error.message : String(error));
}

C#

try catch 기본

throw로 던진 RuntimeException을 같은 타입의 catch가 받아 메시지를 꺼냅니다. 잡을 타입을 좁게 적으면 예상한 실패만 처리하고 나머지는 위로 흘려보낼 수 있습니다. 내장 예외 대부분은 Exception을 상속하지만 TypeError 같은 Error 계열은 이 catch에 걸리지 않습니다.

// try catch 기본
using System;

try { int.Parse("x"); } catch (Exception ex) { Console.WriteLine(ex.Message); }

C++

try catch 기본

throw로 던진 RuntimeException을 같은 타입의 catch가 받아 메시지를 꺼냅니다. 잡을 타입을 좁게 적으면 예상한 실패만 처리하고 나머지는 위로 흘려보낼 수 있습니다. 내장 예외 대부분은 Exception을 상속하지만 TypeError 같은 Error 계열은 이 catch에 걸리지 않습니다.

// try catch 기본
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}

Rust

try catch 기본

throw로 던진 RuntimeException을 같은 타입의 catch가 받아 메시지를 꺼냅니다. 잡을 타입을 좁게 적으면 예상한 실패만 처리하고 나머지는 위로 흘려보낼 수 있습니다. 내장 예외 대부분은 Exception을 상속하지만 TypeError 같은 Error 계열은 이 catch에 걸리지 않습니다.

// try catch 기본
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
    println!("{:?}", require_positive(1));
}

넓게 잡으면 무엇이 사라지는가

catch (Exception e) 한 줄은 편하지만 세 가지를 동시에 버립니다. 첫째, 오류의 종류입니다. 네트워크 타임아웃과 입력 파싱 실패와 코드 버그가 같은 블록으로 들어오면 재시도해야 할 것과 즉시 포기해야 할 것을 구분할 수 없습니다. 둘째, 원인 사슬입니다. 잡은 예외를 로그만 찍고 새 예외를 던지면 원래 스택 트레이스가 끊깁니다. Java와 C#은 원인 예외를 생성자에 넘겨야 하고, Python은 raise ... from, Go는 fmt.Errorf의 %w 동사로 사슬을 잇습니다. 셋째, 프로그래밍 오류입니다. 널 참조나 배열 범위 초과 같은 버그성 예외까지 삼켜 버리면 잘못된 상태로 계속 굴러갑니다.

언어별로 특히 위험한 지점이 갈립니다. Python의 except Exception은 KeyboardInterrupt와 SystemExit은 잡지 않지만 except:는 그것까지 잡아 프로세스를 종료 불가 상태로 만듭니다. JavaScript는 던지는 값이 반드시 Error일 필요가 없어서 문자열이나 객체가 날아올 수 있고, 그래서 TypeScript는 catch 변수를 unknown으로 다루는 옵션을 제공합니다. C++은 소멸자에서 예외가 새 나가면 프로그램이 종료되며, 예외를 끈 빌드 설정을 쓰는 코드베이스도 흔해서 라이브러리는 오류 코드 반환을 병행하는 경우가 많습니다.

계열을 넘나들 때의 번역 규칙

  • 예외 → 반환값 — Java나 Python 코드를 Go로 옮길 때, 상위 몇 단계를 한 번에 건너뛰던 throw는 매 단계의 if err != nil과 명시적 전파로 풀립니다. 옮기다 보면 중간에 정리해야 할 리소스가 드러나는데, 그것이 원래 예외 코드에 숨어 있던 누수인 경우가 많습니다.
  • 반환값 → 예외 — 반대 방향에서는 무시된 오류가 문제입니다. Go에서 _로 버려진 err가 예외 언어에서는 그냥 사라지지 않고 위로 올라가 호출 체인을 끊습니다. 어디까지 올려 보낼지 경계를 먼저 정해야 합니다.
  • 정리 코드 — Go는 defer, Java는 try-with-resources, C#은 using, Python은 with, Kotlin은 use, C++은 소멸자와 RAII가 같은 역할을 합니다. 이 장치를 쓰지 않고 finally에서 손으로 닫는 코드는 예외가 두 번 겹칠 때 새는 지점이 됩니다.
  • 비어 있는 catch — 어떤 언어에서든 최악입니다. 무시하기로 했다면 왜 무시해도 되는지 한 줄 주석을 남겨야 나중에 이 코드를 지워도 되는지 판단할 수 있습니다.

실무에서 유용한 구분은 "재시도 가능한 실패"와 "계약 위반"입니다. 앞의 것은 값으로 다루어 호출자에게 판단을 넘기고, 뒤의 것은 즉시 크게 실패시키는 편이 낫습니다. Go의 errors.Is·errors.As, Java의 예외 계층, Python의 커스텀 예외 클래스는 모두 이 구분을 코드로 표현하기 위한 도구입니다. 오류 타입을 문자열 메시지로만 구분하는 코드는 나중에 반드시 문자열 비교로 분기하게 됩니다.

언어별 상세 패턴은 Go 에러 처리, Rust 에러 처리, Java 예외 처리, Python 예외 처리 문서에서 이어 보실 수 있습니다.

로그와 오류 메시지

어느 계열을 쓰든 마지막에 남는 것은 사람이 읽을 메시지입니다. 여기서 자주 낭비되는 정보가 맥락입니다. "파일을 열 수 없습니다"는 어떤 파일인지, 어느 단계에서인지 알려 주지 않아 재현이 불가능합니다. 오류를 위로 전달할 때마다 그 층이 알고 있는 정보를 한 겹씩 덧붙이면, 최종 로그가 실패 경로 전체를 설명하게 됩니다. Go의 %w 감싸기와 Java의 원인 예외 체인이 정확히 이 목적을 위한 장치입니다.

동시에 넣지 말아야 할 것도 있습니다. 비밀번호, 토큰, 개인정보가 예외 메시지에 섞여 그대로 로그에 남는 사고가 흔합니다. 예외를 그대로 사용자 응답에 노출하면 내부 경로나 스택 구조까지 함께 나갑니다. 사용자에게 보여 줄 메시지와 내부 로그에 남길 메시지를 처음부터 분리해 두는 것이 안전합니다.