2. Types, Structs, and Interfaces
클래스 없이 데이터와 동작을 묶는 법
- Haram Lee
- 2026-10-04
- studies / Topics / Go
Go에는 클래스가 없다. 그런데 Go 코드에도 “데이터와 그 데이터를 다루는 동작"은 있다. 이 글은 Go가 클래스 없이 이것을 어떻게 표현하는지, 그리고 인터페이스가 Java나 Scala와 어떻게 다른지를 정리한다.
2.1 Basic Types and Collections
기본 타입은 int, int64, float64, string, bool, byte(=uint8), rune(유니코드 문자, =int32) 정도다. 문자열은 바이트의 불변 시퀀스이고, 한글처럼 여러 바이트로 이뤄진 문자를 다룰 때는 rune으로 순회한다.
컬렉션은 세 가지만 기억하면 된다.
- 배열
[3]int: 길이가 타입의 일부라 거의 직접 쓰지 않는다. - 슬라이스
[]int: 실제로 쓰는 가변 길이 목록이다. 내부는 “배열을 가리키는 포인터, 길이, 용량” 세 값이다.append로 늘리고,s[1:3]으로 자른다. 자른 슬라이스는 원래 배열을 공유하므로 한쪽을 고치면 다른 쪽도 바뀐다. - 맵
map[string]int: 해시맵이다. 없는 키를 읽으면 0값이 나온다. 있는지 확인하려면v, ok := m["key"]처럼 두 번째 값을 받는다.
events := make([]Event, 0, 100) // 길이 0, 용량 100
events = append(events, e)
counts := map[string]int{}
counts["page_view"]++ // 없는 키도 0에서 시작
if n, ok := counts["click"]; ok { ... }변수 선언은 var x int = 1과 짧은 형태 x := 1이 있다. 함수 안에서는 대부분 :=를 쓴다.
2.2 Structs
struct는 필드의 묶음이다. Scala의 case class에서 메서드와 패턴 매칭을 뺀 것에 가깝다.
type Event struct {
Name string `json:"name"`
Timestamp int64 `json:"timestamp"`
Attrs map[string]string `json:"attrs,omitempty"`
}
e := Event{Name: "page_view", Timestamp: 1759400000}필드 뒤의 백틱 문자열은 태그다. encoding/json 같은 라이브러리가 이 태그를 읽어 JSON 키 이름을 정한다. HTTP 서버에서 요청 본문을 struct로 받을 때 매일 보게 된다.
2.3 Methods and Receivers
메서드는 “리시버가 붙은 함수"다. 타입 정의 밖에 두며, 같은 패키지 안이면 어디서든 정의할 수 있다.
func (e Event) IsValid() bool { // 값 리시버: 복사본을 받는다
return e.Name != "" && e.Timestamp > 0
}
func (b *Batch) Add(e Event) { // 포인터 리시버: 원본을 고친다
b.events = append(b.events, e)
}값 리시버와 포인터 리시버의 차이가 처음에 가장 헷갈린다. 판단 기준은 단순하다.
- 메서드가 리시버를 고쳐야 하면 포인터 리시버를 쓴다.
- struct가 크거나
sync.Mutex처럼 복사하면 안 되는 필드를 가지면 포인터 리시버를 쓴다. - 한 타입의 메서드는 둘 중 하나로 통일한다. 섞으면 아래에서 볼 인터페이스 충족 규칙을 따지기 어려워진다.
생성자 문법은 없다. 관례적으로 NewXxx 함수를 만들어 초기화한 값을 돌려준다.
2.4 Interfaces Are Satisfied Implicitly
Go 인터페이스는 메서드 목록이다. 가장 큰 차이는 구현한다고 선언하지 않는다는 점이다. 어떤 타입이 인터페이스의 메서드를 모두 가지고 있으면 자동으로 그 인터페이스를 만족한다.
type Producer interface {
Produce(ctx context.Context, topic string, value []byte) error
}
// kafkaProducer는 Producer를 구현한다고 어디에도 쓰지 않는다.
// Produce 메서드가 있으니 Producer로 쓸 수 있다.
type kafkaProducer struct{ ... }
func (p *kafkaProducer) Produce(ctx context.Context, topic string, value []byte) error { ... }이 방식 때문에 Go에서는 인터페이스를 쓰는 쪽에서 작게 정의한다. HTTP 핸들러가 Kafka에 메시지를 보내야 한다면, 핸들러 패키지에 Produce 하나짜리 인터페이스를 두고 테스트에서는 가짜 구현을 넣는다. 실제 Kafka 클라이언트 라이브러리는 이 인터페이스를 몰라도 된다. “accept interfaces, return structs”(인자는 인터페이스로 받고, 반환은 구체 타입으로)라는 관용구가 여기서 나온다.
표준 라이브러리의 작은 인터페이스가 이 철학을 잘 보여 준다. io.Reader는 Read(p []byte) (n int, err error) 메서드 하나뿐인데, 파일, 네트워크 연결, HTTP 요청 본문, 압축 스트림이 모두 이것을 만족한다. 그래서 json.NewDecoder(r.Body)처럼 어떤 데이터 원천에도 같은 코드를 쓸 수 있다.
포인터 리시버로 메서드를 만들었다면 그 인터페이스를 만족하는 것은 값인 kafkaProducer가 아니라 포인터인 *kafkaProducer다. 컴파일 에러 “does not implement (method has pointer receiver)“가 나면 이 규칙을 떠올린다.
2.5 Embedding Instead of Inheritance
struct 안에 다른 타입을 이름 없이 넣으면(임베딩) 그 타입의 필드와 메서드가 바깥 struct의 것처럼 보인다.
type Server struct {
*http.Server // http.Server의 메서드(ListenAndServe 등)를 그대로 쓸 수 있다
producer Producer
}상속처럼 보이지만, 실제로는 “is-a"가 아니라 “has-a” 관계를 편하게 쓰게 해 주는 문법이다. 메서드를 오버라이드해도 임베딩된 타입 안에서 부르는 메서드는 바뀌지 않는다. 다형성은 인터페이스로만 얻는다.
2.6 The Empty Interface and Type Switches
any(= interface{})는 메서드가 없는 인터페이스라 모든 값을 담을 수 있다. 구조를 모르는 JSON은 map[string]any로 받는다. 꺼낼 때는 타입 단언이나 타입 스위치를 쓴다.
switch v := attrs["value"].(type) {
case float64: // JSON 숫자는 기본으로 float64가 된다
...
case string:
...
}Scala의 패턴 매칭과 비슷하지만 struct를 분해하지는 못한다. 가능하면 any 대신 struct를 정의해서 받는 것이 Go다운 방식이다.