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편에서 배웠듯이 .north는 Direction.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 { ... } 블록 안에서 s는 Shape가 아니라 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
}
핵심을 짚어보자.
interface Shape에서area() f64를 요구했다Circle과Rectangle이 각각area() f64메서드를 구현했다- 둘 다
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
}
Korean과 English는 서로 전혀 다른 구조체지만, 둘 다 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 메서드를 요구한다. Product와 Person 모두 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 {}블록,!전파 연산자까지 — 안전한 에러 처리의 세계를 탐험한다.