Go 언어 구조체와 메서드: 데이터와 동작을 함께 다루는 방법
제어문과 반복문까지 익혔다면, 이제부터는 코드를 어떻게 구조화할 것인가가 중요해진다.
Go에서는 이 역할을 구조체(struct) 와 메서드(method) 가 담당한다.
객체지향 언어에 익숙한 사람이라면 “클래스가 없는 객체지향”이라는 설명을 자주 접했을 텐데,
실제로 Go의 구조체와 메서드를 이해하면 그 표현이 왜 나왔는지 자연스럽게 납득하게 된다.
이 글에서는
- 구조체가 어떤 역할을 하는지
- 메서드는 왜 함수와 분리되어 있는지
- 실제로 쓰면서 어떤 패턴이 많이 등장하는지
를 중심으로 정리해본다.
구조체(struct): 여러 값을 하나의 의미로 묶기
구조체는 여러 필드를 하나의 타입으로 묶는 방법이다.
type User struct {
ID int
Name string
Age int
}
- type 키워드로 새로운 타입을 정의한다
- 필드 이름은 대문자로 시작하면 외부 패키지에서도 접근 가능하다
실제로 써보면, 구조체는 데이터의 의미를 코드에 그대로 드러내는 역할을 한다.
관련 없는 값들이 흩어져 있을 때보다, 훨씬 읽기 쉬워진다.
[이미지: Go struct 기본 구조 설명]
구조체 생성과 초기화
u1 := User{ID: 1, Name: "Alice", Age: 30}
u2 := User{2, "Bob", 25}
- 필드명을 명시하는 방식이 가장 안전하다
- 순서 기반 초기화는 필드가 많아질수록 실수가 잦아진다
실무에서는 필드명 명시 방식을 기본으로 사용하는 경우가 많다.
구조체 포인터 사용
u := &User{Name: "Charlie"}
u.Age = 20
- Go에서는 포인터를 사용해도 -> 같은 문법이 없다
- 컴파일러가 자동으로 역참조를 처리한다
이 덕분에 포인터를 사용하면서도 문법 부담이 크지 않다.
메서드(method): 구조체에 동작을 붙이기
Go에는 클래스가 없지만, 타입에 메서드를 정의할 수 있다.
func (u User) IsAdult() bool {
return u.Age >= 18
}
- (u User) 부분을 리시버(receiver) 라고 부른다
- 이 함수는 User 타입에 종속된다
사용할 때는 다음과 같다.
if u.IsAdult() {
fmt.Println("adult")
}
함수와 거의 같지만, 의미상으로 데이터와 행동이 묶여 있다는 점이 다르다.
[이미지: Go method receiver 구조]
값 리시버 vs 포인터 리시버
func (u *User) UpdateName(name string) {
u.Name = name
}
- 값 리시버: 구조체 복사본 사용
- 포인터 리시버: 원본 구조체 수정
⚠️ 주의할 점
구조체가 크거나, 내부 값을 변경해야 한다면 포인터 리시버를 사용하는 게 일반적이다.
실제로 프로젝트를 하다 보면,
“이 메서드가 상태를 바꾸는가?”가 리시버 선택의 기준이 된다.
함수와 메서드의 역할 구분
Go에서는 모든 로직을 메서드로 만들 필요는 없다.
- 특정 타입에 밀접한 동작 → 메서드
- 여러 타입에 걸친 일반 로직 → 함수
이 구분을 의식하면 코드가 과도하게 객체지향적으로 흐르는 걸 막을 수 있다.
구조체 중심 설계의 특징
Go의 구조체와 메서드는
상속보다는 조합(composition) 을 전제로 설계되어 있다.
- 불필요한 계층 구조를 만들지 않는다
- 데이터와 동작의 관계가 단순하다
- 코드 흐름을 따라가기가 쉽다
초반에는 “기능이 적다”고 느껴질 수 있지만,
규모가 커질수록 오히려 유지보수가 편해진다.
이런 경우에 잘 맞는다
- 데이터 구조를 명확히 표현하고 싶은 경우
- 상속보다 조합 중심 설계를 선호하는 경우
- 팀 코드에서 복잡한 객체 계층을 피하고 싶은 경우
반대로, 전통적인 클래스 기반 객체지향에 익숙하다면
초반에는 다소 심심하게 느껴질 수도 있다.
다음 글에서는 이 구조체를 기반으로
interface와 다형성, 그리고 Go에서의 설계 패턴으로 이어가는 게 자연스럽다.