V 언어 문법: 구조체(Struct)와 메서드

V 언어 문법: 구조체(Struct)와 메서드

5편까지 배열과 맵으로 데이터를 묶는 법을 배웠다. 하지만 현실의 데이터는 "이름(문자열) + 나이(정수) + 이메일(문자열)"처럼 서로 다른 타입이 한 묶음이 되는 경우가 대부분이다. 이번 편에서는 나만의 데이터 타입을 만드는 도구인 구조체(struct) 를 배운다.

구조체란?

구조체(struct)여러 종류의 데이터를 하나의 묶음으로 만든 것이다.

비유하자면 학생 정보 카드다. 하나의 카드에 이름, 나이, 학번, 전공이 모두 적혀 있다. 배열은 "같은 종류의 데이터를 줄 세우는 것"이었다면, 구조체는 "서로 다른 종류의 데이터를 한 장의 카드에 모으는 것"이다.

┌─────────────────────┐
│   📇 학생 정보 카드  │
├─────────────────────┤
│ 이름:  홍길동         │  ← string
│ 나이:  25             │  ← int
│ 학번:  20210001       │  ← int
│ 전공:  컴퓨터공학     │  ← string
└─────────────────────┘

이것을 V 코드로 표현하면 이렇게 된다.


구조체 정의와 인스턴스 생성

구조체 정의

struct Student {
    name   string
    age    int
    id     int
    major  string
}

struct 키워드 뒤에 이름을 적고, 중괄호 안에 필드(field) 를 나열한다. 필드는 "이 카드에 어떤 항목이 있는지"를 정의하는 것이다.

구조를 분해하면 이렇다.

struct Student {
│       │
│       └─ 구조체 이름 (대문자로 시작 — V의 규칙)
└────────── struct 키워드
    name   string     ← 필드: 이름(string)과 타입(string)
    age    int        ← 필드: 이름(age)과 타입(int)
}

💡 이름 규칙: 구조체 이름은 대문자로 시작(PascalCase)한다. 변수와 함수는 snake_case였지만, 구조체(타입)는 PascalCase다. Student, UserProfile, HttpRequest 같은 식이다.

인스턴스 생성

정의한 구조체로 실제 데이터를 만드는 것을 "인스턴스를 생성한다"고 말한다. 카드 양식(구조체 정의)을 가지고 실제 카드를 한 장 작성하는 것이다.

struct Student {
    name  string
    age   int
    major string
}

fn main() {
    // 필드 이름을 명시해서 생성
    s := Student{
        name: '홍길동'
        age: 25
        major: '컴퓨터공학'
    }

    println(s.name)    // 홍길동
    println(s.age)     // 25
    println(s.major)   // 컴퓨터공학
}

Student{ name: '홍길동', age: 25, major: '컴퓨터공학' }이 인스턴스 생성이다. 각 필드에 값을 넣어주면 된다. 생성한 뒤에는 .필드이름 으로 값을 꺼낼 수 있다.

🔍 다른 언어와 비교:

언어 비슷한 개념 예시
V struct Student{ name: '홍길동' }
Go struct Student{Name: "홍길동"}
Python class / dataclass Student(name="홍길동")
C struct struct Student s; s.name = "홍길동";
Java class new Student("홍길동")

V의 구조체는 Go와 매우 유사하다. Python이나 Java의 클래스(class)와 비슷해 보이지만, V의 구조체에는 상속(inheritance)이 없다. 대신 조합(composition) 을 사용한다. (이 편 뒤에서 임베디드 구조체로 다룬다.)


기본값 필드

필드에 기본값(default value) 을 지정할 수 있다. 인스턴스를 만들 때 해당 필드를 생략하면 기본값이 자동으로 들어간다.

struct Config {
    host     string = 'localhost'
    port     int    = 8080
    debug    bool   = false
}

fn main() {
    // 모든 필드를 기본값으로
    c1 := Config{}
    println(c1.host)    // localhost
    println(c1.port)    // 8080
    println(c1.debug)   // false

    // 원하는 필드만 지정 (나머지는 기본값)
    c2 := Config{
        port: 3000
        debug: true
    }
    println(c2.host)    // localhost (기본값 유지)
    println(c2.port)    // 3000 (지정한 값)
    println(c2.debug)   // true (지정한 값)
}

3편에서 "기본값 매개변수" 패턴을 구조체로 구현한 것을 기억하는가? 바로 이 기능을 활용한 것이었다. 설정(Config) 같은 구조체에서 특히 유용하다.

기본값이 없는 필드

기본값을 지정하지 않은 필드는 해당 타입의 제로값(zero value) 이 기본값이 된다.

타입 제로값
int 0
f64 0.0
bool false
string '' (빈 문자열)
struct Point {
    x int      // 기본값 명시 안 함 → 0
    y int      // 기본값 명시 안 함 → 0
}

p := Point{}
println(p.x)   // 0
println(p.y)   // 0

필수 필드 — @[required]

반대로, 반드시 값을 넣어야 하는 필드를 강제하고 싶다면 @[required] 속성을 사용한다.

struct User {
    name string @[required]   // 반드시 값을 넣어야 한다
    age  int
}

fn main() {
    // u := User{}                   // ❌ 컴파일 에러! name이 없다
    // u := User{ age: 25 }          // ❌ 컴파일 에러! name이 없다
    u := User{ name: '홍길동' }      // ✅ OK — age는 기본값 0
    println(u.name)                  // 홍길동
}

@[required]가 붙은 필드를 생략하면 컴파일러가 에러를 낸다. "사용자 이름은 반드시 있어야 한다"같은 비즈니스 규칙을 코드 수준에서 강제할 수 있다.


접근 제한자 — pubmut

구조체의 필드는 기본적으로 외부에서 읽기 전용이고, 변경 불가다. 접근 범위를 제어하는 키워드가 두 가지 있다.

네 가지 접근 수준

struct Foo {
    a int          // 기본: 모듈 내에서만 읽기 가능, 변경 불가
pub:
    b int          // pub: 어디서든 읽기 가능, 변경 불가
mut:
    c int          // mut: 모듈 내에서 읽기+쓰기, 외부에서는 읽기만
pub mut:
    d int          // pub mut: 어디서든 읽기+쓰기
}

콜론(:)으로 섹션을 나누는 것이 특징이다. 한 번 pub:을 적으면 그 아래 필드들은 모두 pub이 적용된다.

접근 수준 선언 같은 모듈에서 다른 모듈에서
(기본) a int 읽기 ✅ / 쓰기 ❌ 읽기 ❌ / 쓰기 ❌
pub pub: b int 읽기 ✅ / 쓰기 ❌ 읽기 ✅ / 쓰기 ❌
mut mut: c int 읽기 ✅ / 쓰기 ✅ 읽기 ✅ / 쓰기 ❌
pub mut pub mut: d int 읽기 ✅ / 쓰기 ✅ 읽기 ✅ / 쓰기 ✅

💡 실전 가이드: 대부분의 경우 아래 두 가지만 기억하면 된다. - 읽기 전용 필드 → 기본(아무것도 안 적기) 또는 pub: - 변경 가능 필드mut: 또는 pub mut:

모듈(9편에서 배움)을 사용하기 전까지는 같은 파일 안에서 작업하므로, mut:만 알아도 충분하다.

mut으로 필드 변경하기

struct Player {
    name string
mut:
    score int       // 점수는 변경 가능
    level int       // 레벨도 변경 가능
}

fn main() {
    mut p := Player{
        name: '용사'
        score: 0
        level: 1
    }

    // mut: 섹션 필드는 변경 가능
    p.score = 100
    p.level = 2
    println('${p.name}: 점수 ${p.score}, 레벨 ${p.level}')
    // 용사: 점수 100, 레벨 2

    // p.name = '마법사'   // ❌ 에러! name은 mut 섹션이 아니다
}

중요한 점이 하나 있다. 필드를 mut:으로 선언하는 것뿐만 아니라, 인스턴스 자체도 mut으로 선언해야 필드를 변경할 수 있다. 위 코드에서 mut p := Player{...}처럼 mut을 붙인 이유가 이것이다.


구조체 업데이트 문법

기존 구조체에서 일부 필드만 바꾼 새 구조체를 만들고 싶을 때 ...(spread) 문법을 사용한다.

struct User {
    name  string
    age   int
    email string
}

fn main() {
    original := User{
        name: '홍길동'
        age: 25
        email: '[email protected]'
    }

    // original을 기반으로 age만 바꾼 새 인스턴스
    updated := User{
        ...original        // original의 모든 필드를 복사
        age: 26            // age만 덮어쓰기
    }

    println(original.age)  // 25 (원본은 그대로)
    println(updated.age)   // 26 (새 인스턴스)
    println(updated.name)  // 홍길동 (original에서 복사됨)
}

...original은 "original의 모든 필드 값을 그대로 가져와라"는 뜻이다. 그 뒤에 age: 26을 적으면 age만 새 값으로 덮어쓴다. 원본(original)은 변하지 않는다. 완전히 새로운 인스턴스가 만들어진다.

💡 이 문법이 유용한 경우: - 설정 객체에서 한두 개 옵션만 바꿀 때 - 불변 데이터를 다룰 때 (원본을 수정하지 않고 변형된 복사본을 만들기) - 함수형 프로그래밍 스타일의 코드


메서드 — 구조체에 기능 붙이기

지금까지 구조체는 데이터만 담았다. 하지만 "학생 카드가 자기소개를 할 수 있다"면 어떨까? 구조체에 기능(함수)을 붙이는 것을 메서드(method) 라고 한다.

메서드 정의

struct Student {
    name  string
    age   int
    major string
}

// Student 구조체의 메서드
fn (s Student) greet() string {
    return '안녕! 나는 ${s.name}, ${s.major} 전공이야'
}

fn (s Student) is_adult() bool {
    return s.age >= 19
}

fn main() {
    student := Student{
        name: '홍길동'
        age: 25
        major: '컴퓨터공학'
    }

    println(student.greet())       // 안녕! 나는 홍길동, 컴퓨터공학 전공이야
    println(student.is_adult())    // true
}

일반 함수와의 차이점은 fn 뒤에 (s Student)가 추가된 것이다.

fn (s Student) greet() string {
    ───────────
    리시버(receiver): "이 메서드는 Student에 붙는다"

(s Student)리시버(receiver) 라고 부른다. "이 메서드는 Student 타입에 속한다"는 선언이다. 메서드 안에서 s.name, s.age 같이 s를 통해 구조체의 필드에 접근할 수 있다.

호출할 때는 student.greet()처럼 인스턴스.메서드() 형태로 쓴다.

메서드에서 필드 변경하기 — mut 리시버

메서드 안에서 구조체의 필드 값을 변경하려면, 리시버를 mut으로 선언해야 한다.

struct Counter {
mut:
    count int
}

// mut 리시버: 구조체를 변경할 수 있다
fn (mut c Counter) increment() {
    c.count += 1
}

fn (c Counter) get() int {
    return c.count
}

fn main() {
    mut counter := Counter{}

    counter.increment()
    counter.increment()
    counter.increment()

    println(counter.get())   // 3
}

fn (mut c Counter) — 리시버 앞에 mut이 붙었다. 이것은 "이 메서드는 구조체를 변경할 수 있다"는 선언이다. mut이 없으면 읽기만 가능하다.

정리하면 이렇다.

리시버 선언 필드 읽기 필드 변경 용도
fn (s Student) 데이터만 읽는 메서드
fn (mut s Student) 데이터를 변경하는 메서드

💡 설계 원칙: 데이터를 변경하지 않는 메서드에는 mut을 붙이지 않는다. "이 메서드는 안전하다(부작용 없다)"는 것을 코드만 보고 알 수 있기 때문이다.


메서드 vs 함수 — 언제 뭘 쓸까?

struct Rect {
    width  f64
    height f64
}

// 방법 1: 메서드
fn (r Rect) area() f64 {
    return r.width * r.height
}

// 방법 2: 일반 함수
fn rect_area(r Rect) f64 {
    return r.width * r.height
}

fn main() {
    r := Rect{ width: 10, height: 5 }
    println(r.area())           // 50.0 (메서드 호출)
    println(rect_area(r))       // 50.0 (함수 호출)
}

결과는 같다. 그렇다면 언제 메서드를 쓰고, 언제 함수를 쓸까?

상황 추천 이유
특정 타입과 밀접한 동작 메서드 r.area()rect_area(r)보다 읽기 쉽다
여러 타입에 공통으로 쓰는 범용 동작 함수 특정 타입에 묶이지 않아 재사용성이 높다
타입의 데이터를 변경하는 동작 메서드 (mut) "누가" 변경하는지 명확하다

경험적으로, "이 동작의 주어가 구조체인가?" 를 기준으로 판단하면 된다. "학생이 자기소개를 한다" → 메서드(student.greet()). "두 학생의 점수를 비교한다" → 함수(compare_scores(s1, s2)).


임베디드 구조체 — 구조체 안에 구조체

V에는 클래스 상속(inheritance)이 없다. 대신 임베디드 구조체(embedded struct) 로 코드를 재사용한다. 구조체 안에 다른 구조체를 통째로 끼워넣는 것이다.

기본 사용법

struct Animal {
    name string
    age  int
}

fn (a Animal) info() string {
    return '${a.name}, ${a.age}살'
}

struct Dog {
    Animal           // Animal을 임베드 (타입명만 적는다)
    breed string     // Dog만의 추가 필드
}

fn main() {
    d := Dog{
        Animal: Animal{
            name: '바둑이'
            age: 3
        }
        breed: '진돗개'
    }

    // Animal의 필드에 직접 접근 가능
    println(d.name)      // 바둑이 (Animal의 name)
    println(d.breed)     // 진돗개 (Dog의 breed)

    // Animal의 메서드도 직접 호출 가능
    println(d.info())    // 바둑이, 3살
}

Dog 구조체 안에 Animal을 타입명만 적으면, Animal모든 필드와 메서드Dog에 자동으로 포함된다. d.name이라고 쓰면 자동으로 d.Animal.name에 접근한다.

상속과의 차이

"상속이랑 다른 게 뭐야?"라고 생각할 수 있다. 핵심 차이가 있다.

구분 상속 (Python/Java) 임베딩 (V/Go)
관계 "Dog Animal이다" (is-a) "Dog 안에 Animal이 있다" (has-a)
다중 지원 보통 단일 상속 여러 구조체 임베드 가능
부모 메서드 오버라이드 가능 ✅ 같은 이름의 메서드 정의 가능
타입 일치 Dog 변수를 Animal로 사용 가능 직접적으로는 불가 (인터페이스로 해결)

V는 상속보다 조합(composition)을 선호한다. "필요한 부품(구조체)을 조립해서 새 타입을 만든다"는 철학이다. 이 방식이 코드의 유연성을 높이고 복잡성을 줄인다는 것이 V(그리고 Go)의 설계 신조다.

여러 구조체 임베딩

struct Serializable {
    id int
}

fn (s Serializable) to_string() string {
    return 'ID: ${s.id}'
}

struct Printable {
    format string = 'default'
}

fn (p Printable) print_format() string {
    return '포맷: ${p.format}'
}

// 두 구조체를 동시에 임베드
struct Document {
    Serializable
    Printable
    title string
}

fn main() {
    doc := Document{
        Serializable: Serializable{ id: 42 }
        Printable: Printable{ format: 'PDF' }
        title: '보고서'
    }

    println(doc.title)           // 보고서
    println(doc.to_string())     // ID: 42 (Serializable의 메서드)
    println(doc.print_format())  // 포맷: PDF (Printable의 메서드)
    println(doc.id)              // 42 (Serializable의 필드)
}

DocumentSerializablePrintable 두 구조체를 동시에 임베드했다. 양쪽의 필드와 메서드를 모두 사용할 수 있다. Java 같은 언어에서 "다중 상속"을 원할 때 겪는 복잡한 문제(다이아몬드 문제 등) 없이, 간단하게 기능을 조합할 수 있다.


구조체를 함수에 전달하기

구조체를 함수의 매개변수로 넘기면 복사본이 전달된다. 원본은 변하지 않는다.

struct Point {
    x int
    y int
}

fn move_right(p Point) Point {
    return Point{
        ...p
        x: p.x + 1     // x만 1 증가시킨 새 Point
    }
}

fn main() {
    a := Point{ x: 3, y: 5 }
    b := move_right(a)

    println('a: (${a.x}, ${a.y})')   // a: (3, 5) — 원본 불변
    println('b: (${b.x}, ${b.y})')   // b: (4, 5) — 새 인스턴스
}

V의 일관된 철학이 여기서도 드러난다: 기본은 불변, 복사, 안전.


📝 정리

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

  • [x] struct로 정의struct 이름 { 필드 타입 }, 이름은 PascalCase
  • [x] 인스턴스 생성타입{ 필드: 값 }, .필드로 접근
  • [x] 기본값 필드field int = 42, 생략 시 기본값 적용
  • [x] 필수 필드@[required], 생략 시 컴파일 에러
  • [x] 접근 제한자 — 기본(읽기만) / pub(외부 공개) / mut(변경 가능) / pub mut(외부 공개+변경)
  • [x] 업데이트 문법{ ...원본, 필드: 새값 }으로 일부만 변경한 복사본 생성
  • [x] 메서드fn (s Student) 리시버로 구조체에 기능 부착, mut 리시버로 변경 가능
  • [x] 임베디드 구조체 — 상속 대신 조합, 필드와 메서드 자동 승격

🧪 직접 해보기

과제 1: 은행 계좌

BankAccount 구조체를 만들고, 입금(deposit)과 출금(withdraw) 메서드를 구현해보자.

struct BankAccount {
    owner string
mut:
    balance int
}

// 입금 메서드를 구현하세요
// fn (mut a BankAccount) deposit(amount int) { ... }

// 출금 메서드를 구현하세요 (잔액 부족 시 출금 거부)
// fn (mut a BankAccount) withdraw(amount int) bool { ... }

fn main() {
    mut account := BankAccount{ owner: '홍길동', balance: 1000 }

    account.deposit(500)
    println(account.balance)      // 1500

    ok := account.withdraw(2000)
    println(ok)                   // false (잔액 부족)
    println(account.balance)      // 1500 (변하지 않음)

    ok2 := account.withdraw(300)
    println(ok2)                  // true
    println(account.balance)      // 1200
}

과제 2: 도형 면적 계산

CircleRectangle 구조체를 만들고, 각각 area() 메서드를 구현해보자.

struct Circle {
    radius f64
}

struct Rectangle {
    width  f64
    height f64
}

// 각 구조체에 area() 메서드를 구현하세요
// Circle: π × 반지름² (π = 3.14159)
// Rectangle: 가로 × 세로

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

    println('원의 넓이: ${c.area()}')       // 약 78.54
    println('직사각형 넓이: ${r.area()}')   // 30.0
}

다음 편 예고

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

정해진 선택지 중 하나만 고르는 열거형(enum), 여러 타입을 하나로 묶는 합 타입(sum type), "이 기능만 있으면 뭐든 OK"인 인터페이스 — 타입 설계의 고급 도구들을 만나보자.