V 언어 문법: Option과 Result로 안전한 에러 처리

V 언어 문법: Option과 Result로 안전한 에러 처리

프로그램은 언제나 실패할 수 있다. 파일이 없을 수도, 네트워크가 끊길 수도, 사용자가 잘못된 값을 넣을 수도 있다. 이런 상황을 어떻게 처리하느냐가 탄탄한 프로그램과 불안정한 프로그램의 차이를 만든다. 이번 편에서는 V가 에러를 다루는 독특하고 안전한 방식을 배운다.

V의 에러 처리 철학 — null이 없다

V에는 null이 없다.

다른 언어에서 "값이 없음"을 표현할 때 흔히 쓰는 null(또는 nil, None, undefined)이 V에는 아예 존재하지 않는다. 왜일까?

"10억 달러짜리 실수"

null을 발명한 컴퓨터 과학자 Tony Hoare는 2009년 강연에서 이렇게 말했다.

"나는 그것을 10억 달러짜리 실수라고 부른다. 1965년에 null 참조를 발명한 것은 그저 쉽게 구현할 수 있었기 때문이었다. 하지만 그 결과 수십 년간 셀 수 없는 에러, 보안 취약점, 시스템 붕괴가 발생했다."

null의 문제는 간단하다: 아무 변수나 null이 될 수 있다면, 모든 곳에서 null 검사를 해야 한다. 하나라도 빼먹으면 "NullPointerException"이나 "segmentation fault" 같은 에러로 프로그램이 터진다.

V는 이 문제를 근본적으로 해결했다. null 자체가 없으니, null 관련 버그도 없다. 대신 "값이 없을 수도 있는 상황" 을 안전하게 표현하는 두 가지 도구를 제공한다.

도구 기호 의미 실패 시
Option ?T 값이 있을 수도, 없을 수도 none 반환
Result !T 성공하면 값, 실패하면 에러 error('메시지') 반환

Option 타입 (?T) — 값이 있을 수도, 없을 수도

개념

Option 타입"이 함수는 값을 돌려줄 수도 있고, 아무것도 돌려주지 않을 수도 있다" 를 표현한다.

비유하자면 선물 상자다. 상자를 열어보면 선물이 들어 있을 수도 있고(값이 있음), 빈 상자일 수도 있다(none). 중요한 것은, 열기 전에 확인해야 한다는 것이다. V는 확인 없이 열려고 하면 컴파일 에러를 낸다.

사용법

반환 타입 앞에 ?를 붙이면 Option 타입이 된다.

fn find_index(arr []string, target string) ?int {
    for i, val in arr {
        if val == target {
            return i       // 찾으면 인덱스를 반환
        }
    }
    return none            // 못 찾으면 none을 반환
}

?int는 "int를 돌려줄 수도 있고, 없을 수도 있다"는 뜻이다. 찾으면 인덱스(int)를 반환하고, 못 찾으면 none을 반환한다.


Result 타입 (!T) — 성공 또는 에러 메시지

개념

Result 타입은 Option과 비슷하지만, 실패할 때 왜 실패했는지 이유(에러 메시지) 를 함께 알려준다.

비유하자면 병원 진료다. 진료 결과 "건강합니다"(성공 + 값)일 수도 있고, "혈압이 높습니다"(실패 + 이유)일 수도 있다. Option은 "이상 없음 / 이상 있음"만 알려주지만, Result는 "무엇이 이상한지"까지 알려준다.

사용법

반환 타입 앞에 !를 붙이면 Result 타입이 된다.

fn divide(a f64, b f64) !f64 {
    if b == 0 {
        return error('0으로 나눌 수 없습니다')   // 실패 이유를 알려준다
    }
    return a / b
}

!f64는 "f64를 돌려줄 수도 있고, 에러를 돌려줄 수도 있다"는 뜻이다. 성공하면 값을 반환하고, 실패하면 error('메시지')를 반환한다.

Option vs Result — 언제 어떤 것을?

상황 선택 이유
값을 못 찾았다 (사전에서 단어 검색 실패) ?T (Option) 실패 이유가 딱 하나 — "없음"
작업이 여러 이유로 실패할 수 있다 (파일 읽기, 네트워크) !T (Result) 왜 실패했는지 알아야 대처 가능

or {} 블록 — 에러가 났을 때의 대비책

Option이나 Result를 반환하는 함수를 호출할 때, "실패하면 어떻게 할까?"를 정해줘야 한다. 이때 쓰는 것이 or {} 블록이다.

비유하자면 보험이다. "평소에는 쓸 일이 없지만, 문제가 생기면 이 보험(or 블록)이 동작한다."

기본 사용법

fn find_user(id int) !string {
    if id <= 0 {
        return error('유효하지 않은 ID입니다')
    }
    if id > 100 {
        return error('존재하지 않는 사용자입니다')
    }
    return '홍길동'
}

fn main() {
    // or 블록: 에러 발생 시 실행
    name := find_user(1) or {
        println('에러: ${err}')   // err는 에러 메시지를 담는 특별 변수
        return                    // main 함수 종료
    }
    println('사용자: ${name}')    // 사용자: 홍길동
}

or { ... } 블록은 find_user가 에러를 반환했을 때 실행된다. 블록 안에서 err이라는 특별한 변수로 에러 메시지에 접근할 수 있다.

or {} 블록의 세 가지 패턴

실전에서 자주 쓰는 or {} 패턴 세 가지를 알아보자.

패턴 1: 기본값 제공

에러가 나면 대신 사용할 기본값을 돌려준다.

fn get_config_value(key string) ?string {
    if key == 'name' {
        return '내 앱'
    }
    return none
}

fn main() {
    // 값이 없으면 기본값 '알 수 없음'을 사용
    value := get_config_value('color') or { '알 수 없음' }
    println(value)   // 알 수 없음
}

or { '알 수 없음' } — 실패하면 '알 수 없음'value에 넣는다. 가장 간결한 패턴이다.

패턴 2: 에러 출력 후 종료

에러를 무시할 수 없는 심각한 상황에서는 프로그램을 종료한다.

fn read_important_file() !string {
    return error('파일을 찾을 수 없습니다')
}

fn main() {
    content := read_important_file() or {
        println('치명적 에러: ${err}')
        return   // main을 종료 → 프로그램 종료
    }
    println(content)
}

패턴 3: panic으로 즉시 중단

복구 불가능한 에러에서는 panic으로 프로그램을 즉시 중단할 수 있다.

fn critical_init() !int {
    return error('초기화 실패')
}

fn main() {
    val := critical_init() or { panic('${err}') }
    println(val)
}

panic은 에러 메시지를 출력하고 프로그램을 강제 종료한다. "이 에러에서 복구하는 것은 불가능하다"는 상황에서만 사용해야 한다.

세 패턴 한눈에 정리

패턴 코드 상황
기본값 or { 기본값 } 실패해도 괜찮을 때
종료 or { println(err); return } 함수를 더 진행할 수 없을 때
패닉 or { panic(err) } 복구 불가능한 치명적 에러

if unwrapping — 값이 있을 때만 꺼내기

4편에서 미리 맛보기를 했던 if unwrapping을 본격적으로 살펴보자. Option이나 Result 값을 안전하게 꺼내는 우아한 패턴이다.

fn find_user(id int) ?string {
    if id == 1 { return '홍길동' }
    if id == 2 { return '김영희' }
    return none
}

fn main() {
    // if unwrapping: 값이 있으면 name에 넣고, 없으면 else로
    if name := find_user(1) {
        println('찾았다: ${name}')    // 찾았다: 홍길동
    } else {
        println('사용자를 찾을 수 없다')
    }

    // find_user(99)는 none을 반환
    if name := find_user(99) {
        println('찾았다: ${name}')
    } else {
        println('사용자를 찾을 수 없다')   // 이것이 출력된다
    }
}

if name := find_user(1) — 이 한 줄이 핵심이다.

  1. find_user(1)을 호출한다
  2. 성공하면 → 반환값을 name에 넣고 if 블록을 실행한다
  3. 실패하면(none 또는 error) → else 블록을 실행한다

or {} 블록과의 차이점은, if unwrapping은 성공/실패 양쪽 흐름을 동시에 다룰 때 적합하다는 것이다.

if unwrapping vs or {} 블록 — 언제 뭘 쓸까?

// or {} — 실패 시 기본값을 넣거나 함수를 종료할 때
name := find_user(1) or { '기본 이름' }

// if unwrapping — 성공/실패 양쪽 모두 별도 처리가 필요할 때
if name := find_user(1) {
    println('환영합니다, ${name}님!')
    // 성공 시에만 실행할 추가 로직...
} else {
    println('로그인 화면으로 이동합니다')
    // 실패 시에만 실행할 로직...
}
패턴 적합한 상황
or { 기본값 } 실패해도 계속 진행, 한 줄로 끝
or { return } 실패하면 함수 종료
if val := fn() 성공/실패 양쪽 모두 처리 로직이 있을 때

! 전파 연산자 — 에러를 위로 넘기기

함수 안에서 또 다른 함수를 호출했는데 에러가 났다면? "나도 이 에러를 처리할 능력이 없으니, 나를 호출한 쪽에 넘기자"라고 할 수 있다. 이것이 에러 전파(propagation) 이고, ! 연산자가 이 역할을 한다.

에러 전파 없이 쓰면

fn read_config() !string {
    return error('config.yml을 찾을 수 없습니다')
}

fn init_app() !string {
    // read_config()의 에러를 직접 처리
    config := read_config() or {
        return error('초기화 실패: ${err}')   // 새 에러로 감싸서 반환
    }
    return '앱 시작: ${config}'
}

매번 or { return error(...) }를 적어야 해서 코드가 길어진다.

!로 간결하게

fn read_config() !string {
    return error('config.yml을 찾을 수 없습니다')
}

fn init_app() !string {
    config := read_config()!   // ← ! 하나로 에러 전파
    return '앱 시작: ${config}'
}

fn main() {
    result := init_app() or {
        println('에러: ${err}')   // 에러: config.yml을 찾을 수 없습니다
        return
    }
    println(result)
}

read_config()! — 함수 호출 뒤에 !를 붙이면 된다.

동작은 이렇다.

  1. read_config()가 성공하면 → 값을 config에 넣고 다음 줄로 진행
  2. read_config()가 에러를 반환하면 → init_app같은 에러를 즉시 반환하고 종료

!를 쓰는 함수 자체도 ! 반환 타입이어야 한다. init_app() !string처럼 말이다. "에러를 넘길 수 있다"는 것을 함수 시그니처에 명시하는 것이다.

에러 전파 체인

여러 함수를 거쳐 에러를 전파할 수 있다.

fn step1() !int {
    return error('1단계 실패')
}

fn step2() !int {
    val := step1()!       // step1 에러를 그대로 전파
    return val + 10
}

fn step3() !int {
    val := step2()!       // step2 → step1 에러를 그대로 전파
    return val + 100
}

fn main() {
    result := step3() or {
        println('에러 발생: ${err}')   // 에러 발생: 1단계 실패
        return
    }
    println(result)
}

step1에서 발생한 에러가 step2step3main까지 자동으로 전파된다. 각 단계에서 !만 붙이면 되므로, 에러 처리 코드를 반복할 필요가 없다.

🔍 다른 언어와 비교:

언어 에러 전파 방식 코드
V ! 연산자 val := fn()!
Rust ? 연산자 let val = fn()?;
Go 명시적 if err != nil 검사 val, err := fn(); if err != nil { return err }
Python 예외(Exception) 자동 전파 val = fn() (try/except 없으면 자동 전파)
Java 예외(Exception) 자동 전파 val = fn() (throws 선언 필요)

V의 !는 Rust의 ?와 거의 동일하다. Go처럼 매번 if err != nil을 쓰는 것보다 훨씬 간결하면서도, Python/Java의 예외처럼 에러가 보이지 않게 전파되는 위험도 없다. 함수 시그니처에 !가 있으면 "이 함수는 실패할 수 있다"는 것이 한눈에 보인다.


반환값 없이 에러만 반환하기

값을 돌려줄 필요 없이, 성공/실패만 알려주고 싶은 경우가 있다.

fn validate_age(age int) ! {
    if age < 0 {
        return error('나이는 음수일 수 없습니다')
    }
    if age > 200 {
        return error('나이가 비현실적입니다')
    }
    // 아무것도 반환하지 않으면 성공
}

fn main() {
    validate_age(25) or {
        println('검증 실패: ${err}')
        return
    }
    println('검증 성공!')   // 검증 성공!

    validate_age(-5) or {
        println('검증 실패: ${err}')   // 검증 실패: 나이는 음수일 수 없습니다
        return
    }
}

!만 쓰고 타입을 적지 않으면 "반환값은 없지만, 에러는 발생할 수 있다"는 뜻이다. Option도 마찬가지로 ?만 쓸 수 있다.


실전 패턴 모음

배운 내용을 조합한 실전 패턴들을 살펴보자.

패턴 1: 설정 파싱

fn parse_port(s string) !int {
    port := s.int()
    if port <= 0 || port > 65535 {
        return error('유효하지 않은 포트: ${s}')
    }
    return port
}

fn main() {
    // 기본값 패턴
    port := parse_port('8080') or { 3000 }
    println('서버 포트: ${port}')   // 서버 포트: 8080

    // 잘못된 값 → 기본값 사용
    port2 := parse_port('abc') or { 3000 }
    println('서버 포트: ${port2}')  // 서버 포트: 3000
}

패턴 2: 연쇄 검증

fn validate_name(name string) !string {
    if name.len == 0 {
        return error('이름이 비어있습니다')
    }
    if name.len > 50 {
        return error('이름이 너무 깁니다 (최대 50자)')
    }
    return name
}

fn validate_email(email string) !string {
    if !email.contains('@') {
        return error('유효하지 않은 이메일 형식')
    }
    return email
}

fn register_user(name string, email string) !string {
    valid_name := validate_name(name)!       // 에러 전파
    valid_email := validate_email(email)!    // 에러 전파
    return '${valid_name} (${valid_email}) 등록 완료'
}

fn main() {
    result := register_user('홍길동', '[email protected]') or {
        println('등록 실패: ${err}')
        return
    }
    println(result)  // 홍길동 ([email protected]) 등록 완료

    result2 := register_user('', '[email protected]') or {
        println('등록 실패: ${err}')   // 등록 실패: 이름이 비어있습니다
        return
    }
}

register_user 함수에서 !로 각 검증의 에러를 전파한다. 어느 단계에서든 에러가 나면 즉시 호출한 쪽으로 전달된다.


에러 처리 전체 맵

지금까지 배운 에러 처리 도구를 전체적으로 정리하자.

함수가 실패할 수 있다
   │
   ├── 실패 이유가 "없음" 하나뿐 ──→ ?T (Option) + none
   │
   └── 실패 이유를 알려줘야 한다 ──→ !T (Result) + error('메시지')
          │
          ├── 호출하는 쪽에서 처리 ──→ or { 기본값 }
          │                          or { return }
          │                          or { panic(err) }
          │
          ├── 성공/실패 분기 ──────→ if val := fn() { ... } else { ... }
          │
          └── 에러를 위로 전파 ────→ fn()!

📝 정리

이번 글에서 배운 핵심 포인트를 체크리스트로 정리한다.

  • [x] V에는 null이 없다 — "10억 달러짜리 실수"를 근본적으로 방지
  • [x] Option (?T) — 값이 있거나 none, 실패 이유가 하나뿐일 때
  • [x] Result (!T) — 값이 있거나 error('메시지'), 실패 이유를 알려줘야 할 때
  • [x] or {} 블록 — 기본값 제공 / 함수 종료 / panic, 세 가지 패턴
  • [x] if unwrappingif val := fn() { ... } else { ... }, 성공/실패 양쪽 처리
  • [x] ! 전파 연산자fn()!로 에러를 호출한 쪽에 자동 전달
  • [x] 반환값 없는 에러!만 적으면 성공/실패만 표현

🧪 직접 해보기

과제 1: 안전한 계산기

두 수와 연산자(문자열)를 받아서 계산 결과를 반환하는 함수를 만들어보자. 잘못된 연산자나 0으로 나누기 시 에러를 반환해야 한다.

fn calculate(a f64, b f64, op string) !f64 {
    // 여기를 완성하세요!
    // '+' → 덧셈
    // '-' → 뺄셈
    // '*' → 곱셈
    // '/' → 나눗셈 (b가 0이면 에러)
    // 그 외 → error('알 수 없는 연산자')
    return 0
}

fn main() {
    println(calculate(10, 3, '+') or { panic('${err}') })   // 13.0
    println(calculate(10, 3, '/') or { panic('${err}') })   // 3.333...
    println(calculate(10, 0, '/') or { panic('${err}') })   // 패닉: 0으로 나눌 수 없습니다
}

과제 2: 사용자 조회 체인

find_userget_email 함수를 만들고, ! 전파를 사용해서 get_user_email 함수를 작성해보자.

fn find_user(id int) !string {
    // id가 1이면 '홍길동', 2이면 '김영희' 반환
    // 나머지는 error('사용자를 찾을 수 없습니다') 반환
    return error('todo')
}

fn get_email(name string) !string {
    // '홍길동'이면 '[email protected]' 반환
    // 나머지는 error('이메일이 등록되지 않았습니다') 반환
    return error('todo')
}

fn get_user_email(id int) !string {
    // find_user와 get_email을 ! 전파로 연결하세요
    return error('todo')
}

fn main() {
    email := get_user_email(1) or {
        println('실패: ${err}')
        return
    }
    println(email)   // [email protected]
}

다음 편 예고

9편: V 언어 문법 — 모듈 시스템과 패키지 관리

코드가 길어지면 어떻게 파일을 나누고 정리할까? 모듈 만들기, import, pub 공개 제어, 순환 의존 금지, 그리고 V 패키지 매니저까지 — 실제 프로젝트를 구성하는 법을 배운다.