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 structStudent{ name: '홍길동' }Go structStudent{Name: "홍길동"}Python class/dataclassStudent(name="홍길동")C structstruct Student s; s.name = "홍길동";Java classnew 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]가 붙은 필드를 생략하면 컴파일러가 에러를 낸다. "사용자 이름은 반드시 있어야 한다"같은 비즈니스 규칙을 코드 수준에서 강제할 수 있다.
접근 제한자 — pub과 mut
구조체의 필드는 기본적으로 외부에서 읽기 전용이고, 변경 불가다. 접근 범위를 제어하는 키워드가 두 가지 있다.
네 가지 접근 수준
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의 필드)
}
Document가 Serializable과 Printable 두 구조체를 동시에 임베드했다. 양쪽의 필드와 메서드를 모두 사용할 수 있다. 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: 도형 면적 계산
Circle과 Rectangle 구조체를 만들고, 각각 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"인 인터페이스 — 타입 설계의 고급 도구들을 만나보자.