V 언어 문법: 열거형, 합 타입, 인터페이스

V 언어 문법: 열거형, 합 타입, 인터페이스

6편에서 구조체로 나만의 데이터 타입을 만드는 법을 배웠다. 이번 편에서는 타입 설계의 고급 도구 세 가지를 만나본다. "정해진 선택지 중 하나만 고르게" 하는 열거형, "이 변수는 정수일 수도, 문자열일 수도 있어"를 표현하는 합 타입, 그리고 "이 기능만 있으면 뭐든 OK"를 약속하는 인터페이스다.

열거형(Enum) — 정해진 선택지 만들기

열거형이란?

열거형(enum)미리 정해진 값들의 목록을 타입으로 만든 것이다.

비유하자면 신호등이다. 신호등의 색은 빨강, 노랑, 초록 — 이 세 가지 중 하나일 수밖에 없다. "파란색 신호등"은 존재하지 않는다. 열거형은 이처럼 "가능한 값을 제한" 하는 역할을 한다.

enum Color {
    red
    green
    blue
}

fn main() {
    c := Color.red
    println(c)        // Color.red
}

Color라는 타입을 만들었고, 이 타입의 값은 red, green, blue 세 가지만 가능하다. 다른 값은 넣을 수 없다.

왜 열거형을 쓸까?

"그냥 문자열로 'red' 쓰면 되지 않나?"라고 생각할 수 있다. 열거형이 문자열보다 나은 이유를 보자.

// ❌ 문자열로 상태를 관리하면 생기는 문제
status := 'pendng'     // 'pending'을 오타냈는데 컴파일러가 모른다!

// ✅ 열거형으로 상태를 관리하면
enum Status {
    pending
    approved
    rejected
}

s := Status.pendng     // ❌ 즉시 컴파일 에러! — 'pendng'이라는 값은 없다
s2 := Status.pending   // ✅ OK

오타를 컴파일 시점에 잡아준다. 문자열은 아무 값이나 넣을 수 있어서 오타가 나도 프로그램이 실행될 때까지 모른다. 열거형은 정해진 값 외에는 넣을 수 없으므로, 오타 순간 컴파일러가 알려준다.

열거형과 match — 완벽한 조합

4편에서 match의 "빠짐없는 검사(exhaustive matching)"를 잠깐 봤었다. 열거형과 match를 함께 쓸 때 이 기능이 진가를 발휘한다.

enum Direction {
    north
    south
    east
    west
}

fn describe(d Direction) string {
    return match d {
        .north { '북쪽으로 전진!' }
        .south { '남쪽으로 후퇴!' }
        .east  { '동쪽으로 이동!' }
        .west  { '서쪽으로 이동!' }
    }
}

fn main() {
    println(describe(.north))   // 북쪽으로 전진!
    println(describe(.west))    // 서쪽으로 이동!
}

4편에서 배웠듯이 .northDirection.north의 줄임 표현이다. V 컴파일러가 타입을 이미 알고 있으므로 타입 이름을 생략할 수 있다.

여기서 만약 .west 케이스를 깜빡 잊으면 어떻게 될까?

fn describe(d Direction) string {
    return match d {
        .north { '북쪽' }
        .south { '남쪽' }
        .east  { '동쪽' }
        // .west를 빼먹었다!
        // ❌ 컴파일 에러: "match must be exhaustive — west is not handled"
    }
}

컴파일러가 "west를 처리하지 않았다" 고 알려준다. 나중에 열거형에 새 값을 추가했을 때도 마찬가지다. 모든 match에서 빠진 케이스를 잡아주므로, "이 경우를 깜빡 잊었네" 같은 버그를 원천 차단한다.

열거형의 숫자 값

열거형의 각 값에는 자동으로 0부터 시작하는 정수가 매겨진다.

enum Priority {
    low        // 0
    medium     // 1
    high       // 2
    critical   // 3
}

fn main() {
    p := Priority.high
    println(int(p))      // 2

    // 비교 연산도 가능
    if p == .high {
        println('높은 우선순위!')
    }
}

시작값을 직접 지정할 수도 있다.

enum HttpStatus {
    ok = 200
    not_found = 404
    server_error = 500
}

fn main() {
    status := HttpStatus.not_found
    println(int(status))   // 404
}

열거형에 메서드 달기

6편에서 구조체에 메서드를 붙였듯이, 열거형에도 메서드를 붙일 수 있다.

enum Season {
    spring
    summer
    autumn
    winter
}

fn (s Season) is_warm() bool {
    return s == .spring || s == .summer
}

fn (s Season) korean_name() string {
    return match s {
        .spring { '봄' }
        .summer { '여름' }
        .autumn { '가을' }
        .winter { '겨울' }
    }
}

fn main() {
    s := Season.summer
    println(s.korean_name())   // 여름
    println(s.is_warm())       // true

    w := Season.winter
    println(w.korean_name())   // 겨울
    println(w.is_warm())       // false
}

열거형 메서드는 열거형 값에 의미 있는 동작을 붙이고 싶을 때 유용하다. match를 메서드 안에 넣어두면, 열거형을 사용하는 쪽 코드가 훨씬 깔끔해진다.


합 타입(Sum Type) — 여러 타입을 하나로

합 타입이란?

합 타입(sum type)"이 변수는 A 타입일 수도, B 타입일 수도 있다" 를 표현하는 타입이다.

비유하자면 택배 상자다. 이 상자 안에는 책이 들어있을 수도 있고, 옷이 들어있을 수도 있고, 전자제품이 들어있을 수도 있다. 열어보기 전까지는 뭐가 들어있는지 모른다. 하지만 가능한 종류는 미리 정해져 있다.

struct Circle {
    radius f64
}

struct Rectangle {
    width  f64
    height f64
}

struct Triangle {
    base   f64
    height f64
}

// 합 타입: Shape은 Circle이거나 Rectangle이거나 Triangle이다
type Shape = Circle | Rectangle | Triangle

type Shape = Circle | Rectangle | Triangle이 합 타입 선언이다. |(파이프)는 "또는"을 의미한다. Shape 타입의 변수에는 Circle, Rectangle, Triangle아무거나 하나를 넣을 수 있다.

합 타입 사용하기

struct Circle {
    radius f64
}

struct Rectangle {
    width  f64
    height f64
}

type Shape = Circle | Rectangle

fn main() {
    shapes := [
        Shape(Circle{ radius: 5.0 }),
        Shape(Rectangle{ width: 10, height: 3 }),
        Shape(Circle{ radius: 2.5 }),
    ]

    for shape in shapes {
        println(shape)
    }
}

Shape(Circle{ radius: 5.0 })처럼 합 타입으로 감싸면, 서로 다른 타입의 값을 같은 배열에 담을 수 있다. 일반적으로 V 배열은 같은 타입만 담을 수 있지만, 합 타입을 쓰면 이 제한을 안전하게 우회한다.

match로 합 타입 분기 처리

합 타입의 값이 실제로 어떤 타입인지 확인하려면 match를 쓴다.

struct Circle {
    radius f64
}

struct Rectangle {
    width  f64
    height f64
}

struct Triangle {
    base   f64
    height f64
}

type Shape = Circle | Rectangle | Triangle

fn area(s Shape) f64 {
    return match s {
        Circle {
            // 이 블록 안에서 s는 Circle 타입으로 확정
            3.14159 * s.radius * s.radius
        }
        Rectangle {
            s.width * s.height
        }
        Triangle {
            s.base * s.height / 2.0
        }
    }
}

fn main() {
    c := Shape(Circle{ radius: 5.0 })
    r := Shape(Rectangle{ width: 10, height: 3 })
    t := Shape(Triangle{ base: 8, height: 4 })

    println('원: ${area(c)}')          // 원: 78.53975
    println('직사각형: ${area(r)}')    // 직사각형: 30.0
    println('삼각형: ${area(t)}')      // 삼각형: 16.0
}

여기서 핵심을 짚어보자.

match s {
    Circle {
        // 이 블록 안에서 s는 Circle 타입으로 "확정"된다
        // s.radius에 안전하게 접근 가능
        3.14159 * s.radius * s.radius
    }
    // ...
}

match 블록 안에서는 V가 타입을 자동으로 좁혀준다(type narrowing). Circle { ... } 블록 안에서 sShape가 아니라 Circle로 확정되므로, s.radius에 안전하게 접근할 수 있다. 런타임에 타입을 잘못 접근할 위험이 없다.

열거형과 마찬가지로, 합 타입에서 케이스를 빠뜨리면 컴파일 에러가 난다. 모든 가능한 타입을 빠짐없이 처리해야 한다.

합 타입에 기본 타입 넣기

합 타입에는 구조체뿐 아니라 기본 타입도 넣을 수 있다.

type StringOrInt = string | int

fn print_val(val StringOrInt) {
    match val {
        string { println('문자열: ${val}') }
        int    { println('정수: ${val}') }
    }
}

fn main() {
    print_val(StringOrInt('안녕'))   // 문자열: 안녕
    print_val(StringOrInt(42))       // 정수: 42
}

JSON 파서, 설정 파일 처리 등 "값이 문자열일 수도 있고 숫자일 수도 있는" 상황에서 유용하다.

🔍 다른 언어와 비교:

언어 비슷한 기능 특징
V type A = B \| C 컴파일 타임 안전, match 필수
Rust enum (태그드 유니온) 가장 유사, match 강제
TypeScript type A = B \| C 유사하지만 런타임 검사 없음
Python Union[B, C] (타입 힌트) 런타임에 강제하지 않음
Go interface{} (빈 인터페이스) 타입 안전성 보장이 약함

V의 합 타입은 Rust의 열거형과 가장 비슷하다. 컴파일러가 모든 케이스를 강제하므로 실수가 끼어들 여지가 거의 없다.


인터페이스(Interface) — "이 기능만 있으면 OK"

인터페이스란?

인터페이스(interface)"이런 메서드를 가지고 있으면 이 타입으로 인정해주겠다" 라는 약속이다.

비유하자면 자격증이다. "운전면허가 있으면 누구든 차를 운전할 수 있다." 나이, 성별, 직업과 상관없이, 면허(메서드) 만 있으면 된다. 인터페이스도 마찬가지다. 어떤 구조체든 요구하는 메서드만 가지고 있으면 해당 인터페이스의 자격을 자동으로 얻는다.

기본 사용법

// 인터페이스 정의: "area()를 가진 것은 뭐든 Shape다"
interface Shape {
    area() f64
}

struct Circle {
    radius f64
}

// Circle에 area() 메서드를 구현
fn (c Circle) area() f64 {
    return 3.14159 * c.radius * c.radius
}

struct Rectangle {
    width  f64
    height f64
}

// Rectangle에도 area() 메서드를 구현
fn (r Rectangle) area() f64 {
    return r.width * r.height
}

// Shape 인터페이스를 받는 함수
fn print_area(s Shape) {
    println('넓이: ${s.area()}')
}

fn main() {
    c := Circle{ radius: 5.0 }
    r := Rectangle{ width: 10, height: 3 }

    print_area(c)   // 넓이: 78.53975
    print_area(r)   // 넓이: 30.0
}

핵심을 짚어보자.

  1. interface Shape에서 area() f64를 요구했다
  2. CircleRectangle이 각각 area() f64 메서드를 구현했다
  3. 둘 다 Shape 인터페이스를 만족하므로, print_area(Shape) 함수에 넘길 수 있다

중요한 점: Circle이나 Rectangle은 어디에서도 "나는 Shape입니다"라고 명시적으로 선언하지 않았다. V가 알아서 "이 타입에 area() 메서드가 있네? 그러면 Shape으로 인정!"이라고 판단한다.

암묵적 구현 — 오리 타이핑(Duck Typing)

V의 인터페이스는 암묵적(implicit) 으로 구현된다. 이것을 구조적 타이핑(structural typing) 또는 오리 타이핑(duck typing) 이라고 부른다.

🦆 오리 테스트: "오리처럼 걷고, 오리처럼 꽥꽥거리면, 그것은 오리다."

V도 마찬가지다. "area()를 가지고 있으면 Shape이다." 인터페이스에 정의된 메서드를 가지고 있기만 하면, 별도의 선언 없이 해당 인터페이스로 사용할 수 있다.

interface Greetable {
    greet() string
}

struct Korean {
    name string
}

fn (k Korean) greet() string {
    return '안녕하세요, ${k.name}입니다'
}

struct English {
    name string
}

fn (e English) greet() string {
    return 'Hello, I am ${e.name}'
}

fn say_hello(g Greetable) {
    println(g.greet())
}

fn main() {
    say_hello(Korean{ name: '홍길동' })    // 안녕하세요, 홍길동입니다
    say_hello(English{ name: 'Alice' })    // Hello, I am Alice
}

KoreanEnglish는 서로 전혀 다른 구조체지만, 둘 다 greet() string 메서드를 갖고 있으므로 Greetable 인터페이스를 만족한다. say_hello 함수는 "인사할 수 있는 것(Greetable)이면 뭐든 받겠다"고 선언한 것이다.

🔍 다른 언어와 비교:

언어 인터페이스 구현 방식 명시적 선언 필요?
V 메서드가 있으면 자동 인정 ❌ (암묵적)
Go 메서드가 있으면 자동 인정 ❌ (암묵적)
Java implements Interface 명시 필요 ✅ (명시적)
Rust impl Trait for Type 명시 필요 ✅ (명시적)
Python ABC 상속 또는 프로토콜 혼합

V와 Go는 암묵적 방식이다. Java처럼 "이 클래스는 이 인터페이스를 구현합니다"라고 따로 적지 않아도 된다. 덕분에 기존 타입을 수정하지 않고도 새 인터페이스에 맞출 수 있다.

인터페이스에 필드 포함하기

V의 인터페이스는 메서드뿐 아니라 필드도 요구할 수 있다. 이 점은 Go와 다른 V만의 특징이다.

interface Describable {
    name string             // 이 필드가 있어야 한다
    describe() string       // 이 메서드도 있어야 한다
}

struct Product {
    name  string
    price int
}

fn (p Product) describe() string {
    return '${p.name} — ${p.price}원'
}

struct Person {
    name string
    age  int
}

fn (p Person) describe() string {
    return '${p.name}, ${p.age}살'
}

fn print_info(d Describable) {
    println('이름: ${d.name}')        // 필드에 직접 접근 가능
    println('설명: ${d.describe()}')
}

fn main() {
    print_info(Product{ name: '키보드', price: 50000 })
    // 이름: 키보드
    // 설명: 키보드 — 50000원

    print_info(Person{ name: '홍길동', age: 25 })
    // 이름: 홍길동
    // 설명: 홍길동, 25살
}

Describable 인터페이스는 name string 필드와 describe() string 메서드를 요구한다. ProductPerson 모두 name 필드와 describe() 메서드를 갖고 있으므로, 둘 다 Describable로 사용할 수 있다.

인터페이스 배열 — 다형성

서로 다른 타입의 객체를 같은 배열에 담고 싶을 때 인터페이스가 유용하다.

interface Shape {
    area() f64
}

struct Circle {
    radius f64
}

fn (c Circle) area() f64 {
    return 3.14159 * c.radius * c.radius
}

struct Rect {
    w f64
    h f64
}

fn (r Rect) area() f64 {
    return r.w * r.h
}

fn main() {
    // 서로 다른 타입이지만 Shape 인터페이스로 통일
    shapes := [Shape(Circle{ radius: 5 }),
               Shape(Rect{ w: 10, h: 3 })]

    mut total := 0.0
    for s in shapes {
        total += s.area()
    }
    println('총 넓이: ${total}')   // 총 넓이: 108.53975
}

이것이 다형성(polymorphism) 이다. "형태(타입)가 다르지만 같은 인터페이스로 동일하게 다룬다." shapes 배열에 원과 직사각형이 섞여 있지만, 모든 원소에 대해 .area()를 호출할 수 있다.


세 가지 도구, 언제 어떤 것을?

도구 핵심 질문 예시
열거형 "정해진 선택지 중 하나?" 방향(동서남북), 상태(대기/승인/거절), 요일
합 타입 "여러 타입 중 하나?" JSON 값(문자열·숫자·배열), 도형(원·사각형·삼각형)
인터페이스 "이 기능을 가졌는가?" 넓이 계산 가능한 것, 직렬화 가능한 것, 출력 가능한 것
  • 열거형값이 고정되어 있을 때 쓴다. 새 값을 추가하면 모든 match를 업데이트해야 한다.
  • 합 타입타입이 고정되어 있을 때 쓴다. 새 동작(함수)을 추가하기 쉽다.
  • 인터페이스동작이 고정되어 있을 때 쓴다. 새 타입을 추가하기 쉽다.

📝 정리

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

  • [x] 열거형(enum) — 정해진 값 목록, match와 빠짐없는 검사, 숫자 값 지정 가능, 메서드 부착 가능
  • [x] .값 줄임 문법 — 컴파일러가 타입을 알면 Direction.north 대신 .north 사용 가능
  • [x] 합 타입(type A = B | C) — 여러 타입 중 하나를 담는 타입, match로 안전한 분기 (타입 자동 좁힘)
  • [x] 인터페이스(interface) — 메서드(+필드) 계약, 암묵적 구현(오리 타이핑), 다형성 구현의 핵심
  • [x] 세 도구의 선택 기준 — 값 고정 → enum, 타입 고정 → sum type, 동작 고정 → interface

🧪 직접 해보기

과제 1: 주문 상태 관리

온라인 쇼핑몰의 주문 상태를 열거형으로 만들고, 각 상태에 맞는 메시지를 반환하는 메서드를 구현해보자.

enum OrderStatus {
    pending
    confirmed
    shipped
    delivered
    cancelled
}

// 1) message() 메서드를 구현하세요 — 각 상태에 맞는 한글 메시지 반환
// 2) is_final() 메서드를 구현하세요 — delivered 또는 cancelled면 true

fn main() {
    statuses := [OrderStatus.pending, .shipped, .delivered, .cancelled]

    for s in statuses {
        println('${s.message()} (최종: ${s.is_final()})')
    }
    // 주문 접수 대기 중 (최종: false)
    // 상품 배송 중 (최종: false)
    // 배송 완료 (최종: true)
    // 주문 취소됨 (최종: true)
}

과제 2: 동물 소리

interface Animal을 정의하고, Dog, Cat, Duck 구조체가 각각 동물 소리를 내도록 구현해보자.

interface Animal {
    name string
    speak() string
}

// Dog, Cat, Duck 구조체를 만들고
// 각각 speak() 메서드를 구현하세요

fn introduce(a Animal) {
    println('${a.name}: ${a.speak()}')
}

fn main() {
    // introduce(Dog{ name: '바둑이' })   // 바둑이: 멍멍!
    // introduce(Cat{ name: '나비' })     // 나비: 야옹~
    // introduce(Duck{ name: '꽥꽥이' }) // 꽥꽥이: 꽥꽥!
}

다음 편 예고

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

프로그램에서 에러가 나면 어떻게 대처할까? V에 null이 없는 이유, Option과 Result 타입, or {} 블록, ! 전파 연산자까지 — 안전한 에러 처리의 세계를 탐험한다.