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] Go —
if 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로 외부 명령을 호출하고, 프로젝트 초기 세팅 자동화를 완성한다.