V vs Rust vs Go: 에러 처리 철학 대결

V vs Rust vs Go: 에러 처리 철학 대결

프로그래밍에서 에러 처리는 "코드가 실패했을 때 어떻게 할 것인가?"에 대한 답이다. 이 질문에 대해 V, Go, Rust는 각각 매우 다른 철학을 가지고 있다. 같은 문제를 세 언어로 풀면서, 각자의 접근이 어떻게 다른지 비교해보자.

비교할 세 언어

언어 에러 처리 핵심 한 줄 요약
V !T, or {}, ! 전파 "간결하고 안전하게"
Go (T, error), if err != nil "명시적으로 하나하나"
Rust Result<T, E>, ? 연산자, match "타입으로 완벽하게"

세 언어 모두 예외(exception)를 사용하지 않는다는 공통점이 있다. Java나 Python처럼 try/catch로 에러를 잡는 방식을 의도적으로 피했다. 대신 반환값으로 에러를 표현한다. 그 방법론이 서로 다를 뿐이다.


1라운드: 기본 에러 처리

파일을 읽는 가장 기본적인 코드를 비교해보자.

V

import os

fn main() {
    content := os.read_file('config.txt') or {
        println('파일 읽기 실패: ${err}')
        return
    }
    println(content)
}

Go

package main

import (
    "fmt"
    "os"
)

func main() {
    content, err := os.ReadFile("config.txt")
    if err != nil {
        fmt.Println("파일 읽기 실패:", err)
        return
    }
    fmt.Println(string(content))
}

Rust

use std::fs;

fn main() {
    match fs::read_to_string("config.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => println!("파일 읽기 실패: {}", e),
    }
}

비교 분석

요소 V Go Rust
에러 있는 반환값 !string ([]byte, error) Result<String, Error>
에러 처리 문법 or { ... } if err != nil { ... } match or if let
에러 접근 err (자동) err (직접 선언) Err(e) (패턴 매칭)
코드 줄 수 4줄 6줄 5줄

V가 가장 간결하다. or {} 블록이 에러 처리를 한 곳에 깔끔하게 모아준다. Go는 err를 변수로 받은 뒤 if err != nil로 검사하는 2단계 과정이 필요하다. Rust는 match로 성공/실패를 패턴 매칭하는데, 가장 엄밀하지만 코드가 약간 길다.


2라운드: 에러 전파 — 핵심 차이

에러 전파는 "에러를 처리하지 않고, 호출한 쪽에 넘기는 것"이다. 실전에서 가장 자주 쓰이는 패턴이며, 세 언어의 차이가 가장 극적으로 드러나는 부분이다.

시나리오: 파일을 읽고 → 파싱하고 → 처리하기

read_file("config.txt")   → 파일이 없을 수 있다
parse_config(content)      → 형식이 잘못될 수 있다
validate(config)           → 값이 유효하지 않을 수 있다

세 단계 모두 실패할 수 있다. 각 언어가 이 체인을 어떻게 처리하는지 보자.

V

import os

fn load_and_validate(path string) !string {
    content := os.read_file(path)!           // 실패하면 즉시 전파
    config := parse_config(content)!         // 실패하면 즉시 전파
    validate(config)!                        // 실패하면 즉시 전파
    return config.name
}

fn main() {
    name := load_and_validate('config.txt') or {
        println('에러: ${err}')
        return
    }
    println('설정 이름: ${name}')
}

! 한 글자로 전파. 세 줄의 에러 처리가 각각 ! 한 글자로 끝난다. "실패하면 즉시 함수를 빠져나가서 호출자에게 에러를 전달하라"는 뜻이다.

Go

package main

import (
    "fmt"
    "os"
)

func loadAndValidate(path string) (string, error) {
    content, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }

    config, err := parseConfig(string(content))
    if err != nil {
        return "", err
    }

    err = validate(config)
    if err != nil {
        return "", err
    }

    return config.Name, nil
}

func main() {
    name, err := loadAndValidate("config.txt")
    if err != nil {
        fmt.Println("에러:", err)
        return
    }
    fmt.Println("설정 이름:", name)
}

if err != nil 3번 반복. 세 단계 각각에서 에러를 검사하고, 있으면 반환한다. 같은 패턴이 3번 반복되어 코드가 길어진다.

Rust

use std::fs;

fn load_and_validate(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?;      // ? 로 전파
    let config = parse_config(&content)?;          // ? 로 전파
    validate(&config)?;                            // ? 로 전파
    Ok(config.name)
}

fn main() {
    match load_and_validate("config.txt") {
        Ok(name) => println!("설정 이름: {}", name),
        Err(e) => println!("에러: {}", e),
    }
}

? 한 글자로 전파. V의 !와 거의 같은 역할이다. 실패하면 즉시 Err를 반환한다.

나란히 비교

에러 전파 3번을 나란히 놓으면 차이가 선명하다.

V:
    content := os.read_file(path)!
    config := parse_config(content)!
    validate(config)!
    → 3줄

Go:
    content, err := os.ReadFile(path)
    if err != nil { return "", err }
    config, err := parseConfig(string(content))
    if err != nil { return "", err }
    err = validate(config)
    if err != nil { return "", err }
    → 9줄

Rust:
    let content = fs::read_to_string(path)?;
    let config = parse_config(&content)?;
    validate(&config)?;
    → 3줄

V와 Rust는 전파 연산자(!, ?) 덕분에 3줄이면 끝난다. Go는 같은 작업에 9줄이 필요하다. 에러 전파가 많아질수록 이 차이는 더 벌어진다.


3라운드: 기본값 제공

"에러가 나면 대체 값을 사용하자"는 패턴이다.

V

import os

fn main() {
    // 파일이 없으면 기본값 사용 — 깔끔한 한 줄
    content := os.read_file('config.txt') or { '기본 설정' }
    println(content)

    // 숫자 변환 실패 시 기본값
    port := parse_port('abc') or { 8080 }
    println(port)
}

Go

package main

import (
    "fmt"
    "os"
)

func main() {
    content, err := os.ReadFile("config.txt")
    if err != nil {
        content = []byte("기본 설정")
    }
    fmt.Println(string(content))
}

Rust

use std::fs;

fn main() {
    // unwrap_or_else로 기본값 제공
    let content = fs::read_to_string("config.txt")
        .unwrap_or_else(|_| "기본 설정".to_string());
    println!("{}", content);

    // 더 간결하게
    let content = fs::read_to_string("config.txt")
        .unwrap_or("기본 설정".to_string());
}

비교

요소 V Go Rust
기본값 문법 or { 기본값 } if err != nil { 변수 = 기본값 } .unwrap_or(기본값)
줄 수 1줄 3줄 1~2줄
가독성 ★★★★★ ★★★ ★★★★

V의 or { 기본값 } 패턴이 가장 직관적이다. 코드를 읽으면 "파일을 읽되, 실패하면 '기본 설정'을 써라"라는 의도가 한눈에 들어온다. Rust의 unwrap_or도 비슷하게 간결하지만, 메서드 체이닝 형태라 V보다 약간 길다.


4라운드: 에러 타입 시스템

에러의 종류를 구분하는 능력에서 Rust가 빛난다.

V — 문자열 기반 에러

fn divide(a f64, b f64) !f64 {
    if b == 0 {
        return error('0으로 나눌 수 없습니다')    // 에러 메시지 문자열
    }
    return a / b
}

V의 에러는 기본적으로 문자열 메시지다. 간단하고 사용하기 쉽지만, 에러의 종류를 프로그래밍적으로 구분하기는 어렵다.

Go — 인터페이스 기반 에러

import (
    "errors"
    "fmt"
)

var ErrDivisionByZero = errors.New("0으로 나눌 수 없습니다")
var ErrNegativeNumber = errors.New("음수는 허용되지 않습니다")

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, ErrDivisionByZero
    }
    return a / b, nil
}

func main() {
    _, err := divide(10, 0)
    if errors.Is(err, ErrDivisionByZero) {
        fmt.Println("0 나누기 에러를 특별 처리")
    }
}

Go는 errors.Is()로 에러를 비교할 수 있다. V보다 더 세밀한 에러 구분이 가능하다.

Rust — 열거형 기반 에러

#[derive(Debug)]
enum MathError {
    DivisionByZero,
    NegativeNumber,
    Overflow,
}

fn divide(a: f64, b: f64) -> Result<f64, MathError> {
    if b == 0.0 {
        return Err(MathError::DivisionByZero);
    }
    Ok(a / b)
}

fn main() {
    match divide(10.0, 0.0) {
        Ok(result) => println!("결과: {}", result),
        Err(MathError::DivisionByZero) => println!("0 나누기!"),
        Err(MathError::NegativeNumber) => println!("음수!"),
        Err(MathError::Overflow) => println!("오버플로우!"),
    }
}

Rust는 열거형(enum) 으로 에러 종류를 정의한다. 컴파일러가 모든 에러 경우를 처리했는지 검사해주므로, 에러를 빠뜨릴 수 없다.

비교

요소 V Go Rust
에러 표현 문자열 인터페이스 열거형 (타입)
종류 구분 문자열 비교 errors.Is() 패턴 매칭
빠짐 검사 없음 없음 컴파일러가 강제
복잡도 ★ (간단) ★★ ★★★★ (정교)

Rust의 타입 기반 에러 시스템이 가장 강력하다. 하지만 그만큼 코드량도 많고 학습 곡선도 가파르다. V는 단순하고 빠르게 쓸 수 있는 대신, 에러 종류 구분이 약하다. Go는 그 중간이다.


총 평가

간결함 (코드 양)

V    ████████████████████  ★★★★★  — 가장 짧고 간결
Rust ████████████████      ★★★★   — ? 연산자로 간결, 타입 정의 필요
Go   ████████████          ★★★    — if err != nil 반복으로 길어짐

안전성 (에러 누락 방지)

Rust ████████████████████  ★★★★★  — 컴파일러가 모든 에러 처리 강제
V    ████████████████      ★★★★   — or 블록 없으면 컴파일 에러
Go   ████████████          ★★★    — 에러 무시 가능 (_ 사용)

유연성 (에러 종류 구분)

Rust ████████████████████  ★★★★★  — 열거형으로 정밀 제어
Go   ████████████████      ★★★★   — 커스텀 에러 타입 가능
V    ████████████          ★★★    — 주로 문자열 메시지

학습 난이도

V    ████████████████████  ★★★★★  — 가장 쉬움 (or + ! 두 개만)
Go   ████████████████      ★★★★   — 패턴이 단순 (반복적이지만)
Rust ████████████          ★★★    — 타입 시스템 이해 필요

언제 어떤 언어가 좋을까?

상황 추천 언어 이유
빠른 프로토타입 V 간결한 에러 처리로 빠르게 작성
안전이 최우선인 시스템 Rust 컴파일러가 에러 누락을 원천 차단
대규모 팀 프로젝트 Go 에러 처리가 눈에 보여 리뷰하기 쉬움
개인 CLI 도구 V 보일러플레이트 최소화
서버리스 / 임베디드 Rust 작은 바이너리, 제로 오버헤드
웹 백엔드 Go 풍부한 생태계와 검증된 안정성

📝 정리

  • [x] 공통점 — 세 언어 모두 예외(exception) 대신 반환값으로 에러 표현
  • [x] V!, or {} 으로 가장 간결, 문자열 기반 에러
  • [x] Goif err != nil 반복은 장황하지만 명시적, 인터페이스 기반
  • [x] Rust?, match 로 간결하면서도 정밀, 컴파일러 강제 검사
  • [x] 에러 전파 — V(!)와 Rust(?)는 1글자, Go는 3줄
  • [x] 기본값 — V의 or { 값 }이 가장 직관적
  • [x] 타입 안전성 — Rust > V > Go 순서
  • [x] 정답은 없다 — 프로젝트 특성에 맞는 언어를 선택

다음 편 예고

실전 6편: Bash를 V로 대체하기 — 크로스 플랫폼 스크립트

다시 실전으로 돌아간다! V를 쉘 스크립트 대신 사용해서, Windows/Mac/Linux 모두에서 동작하는 자동화 스크립트를 만든다. v run으로 즉시 실행하고, os.execute로 외부 명령을 호출하고, 프로젝트 초기 세팅 자동화를 완성한다.