고루틴과 동시성 프로그래밍
고루틴(goroutine)은 Go 언어에서 관리하는 경량 스레드로 함수나 명령을 동시에 실행할 때 사용합니다. 프로그램 시작점인 main() 함수 역시 고루틴에 의해서 실행됩니다.
한 번에 한 프로세스만 동작시키는 것을 싱글태스킹, 여럿을 동작시키는 것을 멀티태스킹이라고 합니다. 프로세스는 메모리 공간에 로딩되어 동작하는 프로그램을 말하는데, 프로세스는 스레드를 한 개 이상 갖고 있습니다. 스레드가 하나면 싱글 스레드 프로세스, 여럿이면 멀티 스레스 프로세스라 합니다. 스레드는 프로세스 안의 세부 작업 단위입니다.
CPU 코어는 한 번에 하나의 명령어 다발 즉 하나의 스레드를 수행할 수 있는데, 이러한 CPU 코어가 스레드를 빠르게 전환해가면서 수행하면 사용자 입장에서는 마치 동시에 수행하는 것처럼 보이게 됩니다. 그래서 싱글코어 CPU에서도 여러 프로그램을 동시에 실행할 수 있습니다. 이렇게 CPU 코어가 여러 스레드를 전환하면서 수행하는 것을 컨텍스트 스위칭(context switching)이라고 하며, 스레드를 전환했다가 다시 스레드가 돌아올 때 마지막 실행 시점부터 이어서 실행하기 위해서 현재 상태를 보관해야 하는데 이것을 컨텍스트 스위칭 비용이라고 합니다. 이때 스레드의 명령 포인터(instruction pointer), 스택 메모리 등의 정보를 저장하게 되는데 이를 스레드 컨텍스트라고 합니다.
이렇듯 컨텍스트 스위칭을 하는데에는 컨텍스트 스위칭 비용을 들기 때문에 적정 개수를 넘어 한 번에 너무 많은 스레드를 수행하면 성능이 저하됩니다. (보통 코어 개수의 두 배 이상의 스레드를 만들면 스위칭 비용이 많이 발생한다고 합니다.) 그러나 Go 언어에서는 이러한 걱정을 할 필요가 없습니다. CPU 코어마다 OS 스레드를 하나만 할당해서 사용하기 때문에 컨텍스트 스위칭 비용이 발생하지 않기 때문입니다.
고루틴 사용
모든 프로그램은 고루틴을 최소한 하나는 갖고 있습니다. 바로 메인 루틴입니다.
이 고루틴은 main() 함수와 함께 시작되고, main() 함수가 종료되면 종료됩니다. 또, 메인 루틴이 종료되면 프로그램 또한 종료하게 됩니다.
고루틴은 다음과 같이 함수를 호출할 때 앞에 go 키워드를 붙여 생성할 수 있습니다.
package main
func PrintString() {
// 300ms마다 문자 7개 출력
}
func PrintNumber() {
// 400ms마다 숫자 5개 출력
}
func main() [
go PrintString() // 고루틴 생성
go PrintNumber() // 고루틴 생성
time.Sleep(3 * time.Second) // 3초간 대기
}
위와 같이 작성하면 총 3개의 고루틴이 돌아가는데, 코어의 개수가 3개 이상이 되지 않으면 실제로 동시에 실행되지는 않고, 컨텍스트 스위칭을 통해 동시에 실행되는 것처럼 보입니다.
메인 함수가 종료되면 프로그램도 종료되기 때문에 다른 고루틴이 종료될 때까지 3초간 대기해주도록 했습니다. 그러나 위와 같은 경우에는 서브 고루틴의 실행 시간을 알 수 있어서 종료될 때까지 3초간 대기했었지만, 실행 시간을 알 수 없을 때에는 WaitGroup 을 사용해야 합니다.
var wg sync.WaitGroup
wg.Add(3) // 작업 개수 설정
wg.Done() // 작업이 완료될 때마다 호출
wg.Wait() // 모든 작업이 완료될 때까지 대기
메인 함수에서 Wait() 함수로 대기하고, 서브 고루틴에서 작업이 완료될 때마다 Done()을 호출하면 남은 작업 개수가 하나씩 줄어드는데 남은 작업의 개수가 0이 되는 순간 Wait() 메서드가 종료되고 다음 줄로 넘어가게 됩니다.
고루틴 동작 방법(심화)
고루틴은 명령을 수행하는 단일 흐름으로 운영체제가 제공하는 OS 스레드를 이용하는 경량 스레드입니다. 고루틴과 스레드 간의 관계를 알아보기 위해서 2개의 코어를 가진 컴퓨터에서 고루틴이 어떻게 동작하는지 알아보겠습니다.
1. 고루틴이 하나일 때
모든 명령은 OS 스레드를 통해서 CPU 코어에서 실행됩니다. Go로 만든 프로그램 역시 OS 위에서 돌아가기 때문에 명령을 수행하려면 OS 스레드를 만들어서 명령을 수행해야 합니다.main() 루틴만 존재하면 OS 스레드를 하나 만들어 첫 번째 코어와 연결합니다. 그리고 OS 스레드에서 고루틴을 실행하게 됩니다.
2. 고루틴이 두 개일 때
이때 고루틴이 하나 더 생기게 된다면, 첫 번째 코어가 첫 번째 고루틴을 실행하고 있지만, 두 번째 코어가 남아 있기 때문에 두 번째 OS 스레드를 생성하여 두 번째 고루틴을 실행할 수 있습니다.
3. 고루틴이 세 개일 때
이 상황에서 고루틴 하나가 더 생성되면 이 컴퓨터의 코어는 2개이기 때문에 남는 코어가 없습니다. 그래서 세 번째 고루틴용 스레드를 만들지 않고 남는 코어가 생길 때까지 대기합니다. 즉 세 번째 고루틴은 남는 코어가 생길때까지 실행되지 않고 멈춰있습니다.
만약 두 번째 고루틴이 모두 실행 완료되면 고루틴 2는 사라지게 되고 코어 2가 비게 됩니다. 이때 대기하던 고루틴 3이 실행됩니다.
4. 시스템 콜 호출 시
시스템 콜이란 운영체제가 지원하는 서비스를 호출할 때를 말합니다. 대표적으로 네트워크 기능 등이 있습니다.시스템 콜을 호출하면 운영체제에서 해당 서비스가 완료될 때까지 대기해야 합니다. 예를 들어 네트워크로 데이터를 읽을 때는 데이터가 들어올 때까지 대기 상태가 됩니다. 이런 대기 상태인 고루틴에 CPU 코어와 OS 스레드를 할당한다면 CPU 자원 낭비가 발생합니다. 그래서 Go 언어는 이런 상태에 들어간 루틴을 대기 상태로 보내고, 실행을 기다리는 다른 루틴에 CPU 코어와 OS 스레드를 할당하여 실행하도록 합니다.
이런식으로 동작한다면 컨텍스트 스위칭 비용이 발생하지 않는다는 장점이 있습니다. 컨텍스트 스위칭은 CPU 코어가 스레드를 변경할 때 발생하는데 고루틴을 이용하면 코어와 스레드는 변경되지 않고 오직 고루틴만 옮겨 다니기 때문입니다. OS 스레드를 직접 사용하는 다른 언어에서는 스레드 개수가 많아지면 컨텍스트 스위칭 비용이 증가되기 때문에 프로그램 성능이 떨어지지만 Go 언어에서는 고루틴이 증가되어도 컨텍스트 스위칭 비용이 발생하지 않아 수백, 수천 고루틴을 만들어서 사용할 수 있습니다.
Java에서는 스레드를 추가할 때마다 실제 OS 스레드가 추가되기 때문에 컨텍스트 스위칭 비용이 발생합니다.
뮤텍스를 이용한 동시성 문제 해결
동시성 프로그래밍의 문제점은 동일한 메모리 자원에 여러 고루틴이 접근할 때 발생합니다. 고루틴은 각 CPU 코어에서 별도로 동작하지만 고루틴은 같은 메로리 공간에 동시에 접근해서 값을 변경시킬 수 있습니다.
func DepositAndWithdraw(account *Account) {
if accout.Balance < 0 {
panic(fmt.Sprintf("balance should not bt negative value: %d", account.Balance))
}
account.Balance += 1000 // 문제점
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
위 코드와 같은 함수를 여러 고루틴에서 실행한다면 동시성 문제가 발생할 것입니다. 여러 개의 함수가 동시에 실행되기 때문에 첫 번째 함수가 실행 중인채로 두 번째 함수가 실행되는 것입니다. 그러면 첫 번째 고루틴이 문제점에서 Balance 필드를 읽었을 때에는 0원이었습니다.
그런데 1000원을 더하기 전에 두 번째 고루틴이 문제점에서 Balance 필드를 읽게 된다면 똑같이 0원으로 읽게 됩니다. 즉 고루틴 2개가 각각 입금을 했는데 누적된 값은 1000원이되고, 출금을 2번하게 되면 잔고가 -가 되면서 패닉이 발생하게 됩니다.
이러한 문제를 해결하는 가장 단순한 방법은 한 고루틴에서 값을 변경할 때 다른 고루틴이 접근하지 못하게 하는 것입니다. 뮤텍스(mutex)를 이용하면 자원 접근 권한을 통제할 수 있습니다.
뮤텍스는 mutual exclusion의 약자로 번역하면 상호 배제라는 의미입니다. 뮤텍스의 Lock() 메서드를 호출해 뮤텍스를 획득할 수 있습니다. 이미 Lock() 메서드를 호출해서 다른 고루틴이 뮤텍스를 획득했다면 나중에 호출한 고루틴은 앞서 획득한 뮤텍스가 반납될 때까지 대기하게 됩니다. 사용 중이던 뮤텍스는 Unlock() 메서드를 호출해서 반납하고 이후 대기하던 고루틴 중 하나가 뮤텍스를 획득합니다.
뮤텍스를 사용해 위 예제의 동시성 문제를 해결해 보겠습니다.
func DeposiAndWithdraw(account *Account) {
mutex.Lock()
defer mutex.Unlock()
if accout.Balance < 0 {
panic(fmt.Sprintf("balance should not bt negative value: %d", account.Balance))
}
account.Balance += 1000\
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
한 번 획득한 뮤텍스는 반드시 Unlock()을 호출해서 반납해야 합니다.
뮤텍스와 데드락
뮤텍스를 사용하면 동시성 프로그래밍 문제를 해결할 수 있지만, 또 다른 문제가 발생할 수 있습니다.
첫 번째 문제는 동시성 프로그래밍으로 얻는 성능 향상을 얻을 수 없다는 점입니다. 뮤텍스는 오직 하나의 고루틴만 공유 자원에 접근할 수 있도록 제한하기 때문에 여러 고루틴 중 뮤텍스를 획득한 고루틴만 실행됩니다.
두 번째 문제는 데드락이 발생할 수 있다는 점입니다. 데드락은 프로그램을 완전히 멈추게 만들어 버리는 아주 무서운 문제입니다. 어떤 고루틴도 원하는 만큼 뮤텍스를 확보하지 못해서 무한히 대기하게 되는 경우 데드락이라고 합니다.
이러한 두 문제점을 방지하기 위해서는 뮤텍스가 꼬이지 않도록 좁은 범위에서 데드락이 걸리지 않는지 철저히 확인해서 사용하면 됩니다.
Google cloud profiler와 같은 프로파일링 툴을 이용해서 뮤텍스 검사를 할 수 있습니다.
모든 문제는 같은 자원을 여러 고루틴이 접근하기 때문에 발생합니다. 만약 각 고루틴이 같은 자원에 접근하지 않으면 애당초 문제가 발생하지 않습니다. 즉 여전히 멀티코어의 이점을 얻으면서 뮤텍스를 쓰지 않아도 되어서 뮤텍스로 인한 문제도 발생하지 않게 됩니다.
각 고루틴이 서로 다른 자원에 접근하게 만드는 두 가지 방법이 있습니다.
- 영역을 나누는 방법 (무엇을 다루느냐)
- 역할을 나누는 방법 (무엇을 처리하느냐)
영역을 나누는 예시는 DB, Redis 커넥션을 각각 고루틴으로 분리하는 것이고, 역할을 나누는 예시는 읽기, 검증, 저장 단계를 각각 고루틴으로 분리하는 것입니다.
채널과 컨텍스트
채널(channel)과 컨텍스트(context)는 Go 언어에서 동시성 프로그래밍을 도와주는 기능입니다. 채널을 사용하면 뮤텍스 없이 동시성 프로그래밍이 가능해지고, 컨텍스트는 고루틴에 작업을 요청할 때 작업 취소나 작업 시간 등을 설정할 수 있는 작업 명세서 역할을 합니다.
채널
채널이란 고루틴끼리 메시지를 전달할 수 있는 메시지 큐입니다. 메시지 큐에 메시지들은 차례대로 쌓이게 되고 메시지를 읽을 때는 맨 처음 온 메시지부터 차례대로 읽게 됩니다.
채널을 사용하기 위해서는 먼저 채널 인스턴스를 만들어야 합니다.
var messages chan string = make(chan string)
채널은 위와 같이 슬라이스, 맵 등과 같이 make() 함수로 만들 수 있습니다. 채널 타입이란 채널을 의미하는 chan 키워드와 메시지 타입을 합쳐서 표현합니다. 위 예시에서는 chan string 이 채널 타입입니다.
messages <- "This is message"
var msg string = <- message
채널에 데이터를 넣을 때는 <- 연산자를 이용합니다. 좌변에 채널 인스턴스를 놓고, 우변에 넣을 데이터를 놓으면 됩니다.
채널에서 데이터를 가져올 때도 마찬가지로 <- 연산자를 사용합니다. 만약 가져올 데이터가 없으면 데이터가 들어올 때까지 대기합니다.
◎ buffered channel
일반적으로 채널을 생성하면 크기가 0인 채널(unbuffered channel)이 만들어집니다. 크기가 0이라는 뜻은 채널에 들어온 데이터를 담아둘 곳이 없다는 얘기가 뒵니다. 즉 데이터를 보관할 수 없기 때문에 수신자(다른 고루틴)가 데이터를 가져갈 때까지 대기하게 됩니다. 예를 들면 택배를 운송했는데 보관함이 없다면 택배 수신자가 올 때까지 다른 곳에 못가고 택배를 지키고 있어야하는 것과 비슷합니다.
func main() {
ch := make(chan int)
ch <- 9
fmt.Println("Never print")
}
위 예제와 같이 unbuffered channel을 생성하고 데이터를 넣은 경우 다른 고루틴에서 데이터를 가져갈 때까지 무한정 대기하게 되면서 deadlock 메세지를 출력하고 프로그램이 강제 종료됩니다.
내부에 데이터를 보관할 수 있는 메모리 영역인 버퍼(buffer)라고 부르고, 저장 공간이 있는 채널을 buffered channel 이라고 합니다.
var chan string messages = make(chan string, 2)
위 코드는 버퍼의 크기가 2개인 채널을 생성하는 코드입니다. 이제 2개까지는 데이터를 보관할 수 있지만, 버퍼가 가득 차면 unbuffered channel과 마찬가지로 수신자가 버퍼에 저장된 데이터를 가져가 보관할 수 있는 공간이 생길 때까지 대기하게 됩니다.
◎ channel consumer
앞의 예시에서는 데이터를 한 개 넣고 뺐습니다. 이번에는 consumer를 만들어 데이터를 계속 기다리면서 데이터가 들어오면 작업을 수행하는 예제를 만들어보겠습니다.
func square(wg *sync.WaitGroup, ch chan int) {
for n := range ch {
fmt.Printf(n * n)
time.Sleep(time.second)
}
wg.Done() // Error: Deadlock
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go sqaure(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
wg.Wait()
}
위 코드에서 sqaure() 함수에 for range문으로 채널 consumer를 만들어 채널이 종료되기 전까지 계속 데이터를 소비하며 기다리기 때문에 절대로 wg.Done()이 실행되지 않아 Deadlock에 걸리게 됩니다.
이렇게 채널을 제때 닫아주지 않아서 고루틴에서 데이터를 기다리며 무한 대기하는 경우를 좀비 루틴 또는 고루틴 릭(leak)이고 합니다. 아무리 경량 스레드라고 해도 고루틴 또한 메모리와성능을 차지하기 때문에 이런 좀비 루틴이 많아지면 프로그램 자원을 소모하게 되고 프로그램이 느려지거나 메모리 부족으로 강제 종료될 수 있습니다.
따라서 다음과 같이 main() 함수에서 채널에 데이터를 모두 넣고 나면 채널을 닫아줘야 합니다.
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go sqaure(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
close(ch) // 채널 닫기
wg.Wait()
}
◎ select문
switch문 같이 여러 채널을 동시에 대기하고 각 채널 case에 맞게 처리하고 싶을 때 사용합니다. 하나의 case가 실행되면 종료되기 때문에 반복해서 데이터를 처리하고 싶다면 for문과 함께 사용해야 합니다.
package main
import (
"fmt"
"sync"
"time"
)
func square(wg *sync.WaitGroup, ch chan int) {
tick := time Tick(time.Second)
terminate := time.After(10*time.Second)
for {
select {
case <- tick:
fmt.Println("Tick")
case <- terminate:
fmt.Println("Terminated")
case n := <- ch:
fmt.Println(n * n)
time.Sleep(time.Second)
}
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go sqaure(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
wg.Wait()
}
- time.Tick()은 일정 시간 간격 주기로 신호를 보내주는 채널을 생성해서 반환하는 함수
- time.After()는 현재 시간 이후로 일정 시간 경과 후에 신호를 보내주는 채널을 생성해서 반환하는 함수
한 쪽에서 데이터를 생성해서 넣어주면 다른 쪽에서 생성된 데이터를 빼서 사용하는 방식을 Producer Consumer Pattern 이라고 합니다. 이 패턴과 채널로 역할을 나눠 작업하면 고루틴 하나를 사용할 때보다 더 빠르게 작업을 완료할 수 있고, 뮤텍스도 필요없습니다.
컨텍스트
컨텍스트는 context 패키지에서 제공하는 기능으로 작업을 지시할 때 작업 가능 시간, 작업 취소 등의 조건을 지시할 수 있는 작업 명세서 역할을 합니다. 새로운 고루틴으로 작업을 시작할 때 일정 시간 동안만 작업을 지시하거나 외부에서 작업을 취소할 때 사용합니다. 또한 작업 설정에 관한 데이터를 전달할 수도 있습니다.
◎ cancel context
ctx, cancel := context.WithCancel(context.Background())
취소 컨텍스트를 생성하면 컨텍스트 객체와 취소 함수를 반환합니다. 인수로 상위 컨텍스트를 넣으면 그 컨텍스트를 감싼 새로운 컨텍스트를 만들어 줍니다. 상위 컨텍스트가 없다면 가장 기본적인 컨텍스트인 context.Background()를 넣어줍니다.
원하는 시점에 취소 함수를 호출하면 컨텍스트의 Done() 채널에 시그널이 들어오고 작업(고루틴)은 종료됩니다.
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx, cancel := context.WithCancel(context.Background())
go PrintEverySecond(ctx)
time.Sleep(5 * time.Second)
cancel() // 취소 컨텍스트 함수
wg.Wait()
}
func PrintEverySecond(ctx context.Context) {
tick := time.Tick(time.Second)
for {
select {
case <-ctx.Done(): // 취소 채널
wg.Done()
return
case <-tick:
fmt.Println("Tick")
}
}
}
◎ timeout context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
두 번째 인수로 전달된 시간이 지나면 컨텍스트의 Done() 채널에 시그널을 보내서 작업(고루틴)을 종료합니다.
◎ value context
특정 값을 컨텍스트에서 키값을 통해 읽어올 수도 있습니다.
ctx := context.WithValue(context.Background(), "key", 9)
v := ctx.Value("key")
컨텍스트의 두 번째 인수로 key 값, 세 번째 인수로 value를 넣어주면 됩니다. 여기서 value는 interface{} 타입입니다.
◎ nested context
컨텍스트는 중첩해서 설정이 가능합니다. 컨텍스트를 생성할 때 첫 번째 인자로 상위 컨텍스트를 넣어줬는데, 여기에 기본 컨텍스트인 context.Background()가 아닌 사용자 정의 컨텍스트를 넣어도 됩니다.
ctx, cancel := context.WithCancel(context.Background())
ctx = context.WithValue(ctx, "number", 9)
ct = context.WithValue(ctx, "name", "Jin")
위 코드와 같이 취소 기능도 있고 값도 포함하고 있는 컨텍스트를 생성할 수 있습니다.
Generic 프로그래밍
Go 언어는 강타입의 언어이기 때문에 여러 타입을 지원하는 함수를 만들기 쉽지 않았습니다. 물론 아래와 같이 interface{} 를 사용하면 어느정도 구현이 가능했지만 두 번째 예시처럼 여러 타입들 중에서 일부 타입만 지원하는 대소 비교 기능을 사용한다면 interface{} 로는 표현할 수 없었습니다.
func min(a interface{}) { // O
fmt.Println(a)
}
func min(a, b interface{}) interface{} { // X
if a < b {
return a
}
return b
}
Generic
그런데 Go 1.18 버전에 Generic이 추가되면서 이제 Go 언어에서도 다양한 타입을 지원하는 함수를 만들수 있게 되었습니다. 아래 코드에서 T는 타입 파라미터이고 any는 제약(constraint)입니다.
func print[T any](a T) {
fmt.Println(a)
}
하지만 주의할 점은 Generic에서도 일부 타입만 지원하는 기능을 사용한다면 에러가 발생한다는 점입니다. 이 경우에는 | 를 사용하여 Generic의 여러 타입 제약을 열거할 수 있는데 이를 타입 집합이라고 합니다. 아래 두 번째 예시처럼 대소 비교가 가능한 타입 제약들만 열거한다면 에러가 발생하지 않습니다.
func min[T any}(a, b T) T {
if a < b {
return a
}
return b
}
func min[T int | int16 | float32 | float64 | int64](a, b T) T {
if a < b {
return a
}
return b
}
이러한 타입들을 매번 사용할 때마다 타입을 열거해서 사용한다면 매우 번거로울 것입니다. 이 경우에는 interface를 사용해 사용자 정의 타입 제약을 생성할 수 있습니다.
type Integer interface {
int | int8 | int16 | int32 | int64
}
type Float interface {
float32 | float64
}
func min[T Integer | Float](a, b T) T {
if a < b {
return a
}
return b
}
자주 사용되는 타입 제약들은 "golang.org/x/exp/constrains" 패키지에서 기본적으로 제공되고 있습니다.
(https://pkg.go.dev/golang.org/x/exp/constraints)
constraints package - golang.org/x/exp/constraints - Go Packages
Discover Packages golang.org/x/exp constraints Version: v0.0.0-...-7e4ce0a Opens a new window with list of versions in this module. Published: Apr 8, 2025 License: BSD-3-Clause Opens a new window with license information. Imports: 1 Opens a new window with
pkg.go.dev
Go 언어 공식 문서에서 해당 패키지를 확인해보면 타입 앞에 ~ 연산자가 사용된 것을 확인해볼 수 있는데, 이는 해당 타입을 기반으로 한 사용자 정의 타입도 모두 포함한다는 의미입니다.
Generic 함수
1. interface => 타입 제약 ✅
인터페이스를 타입 제약으로 사용하는 것도 가능합니다.
type Stringer interface {
String() string
}
type Integer interface {
~int8 | ~int16 | ~int32 | ~int64 | ~int
}
func Print1(a Stringer) {
fmt.Println(a.String())
}
func Print2[T Stringer](a T) {
fmt.Println(a.String())
}
2. 타입 제약 => interface ❌
반면에 타입 제약을 인터페이스처럼 사용하는 것은 불가능합니다.
func Print1(a Integer) {
fmt.Println(a)
}
3. 타입 제약 + interface ✅
타입 제약과 인터페이스의 메서드를 모두 가지는 것은 가능하며, 이때는 타입 제약처럼 동작합니다.
type Stringer interface {
~int8 | ~int16 | ~int32 | ~int64 | ~int
String() string
}
func PrintMin[T Stringer](a, b T) {
if a < b {
fmt.Println(a.String())
} else {
fmt.Println(b.String())
}
}
Generic 타입(구조체)
구조체를 만들 때 아래와 같이 interface{}를 사용하여 선언한다면 변수를 사용할 때 구체화된 타입으로 타입 단언을 해야합니다.
type Node1 struct {
val interface{}
next *Node1
}
func NewNode(v interface{}) *Node1 {
return &Node1{val: v}
}
func main() {
node := NewNode(1)
num := node.val.(int)
}
그러나 Generic을 사용한다면 이미 val이 구체적인 타입(T)이기 때문에 바로 사용 가능합니다.
type Node2[T any] struct {
val T
next *Node2[T]
}
func NewNode[T any](v T) *Node2[T] {
return &Node2[T]{val: v}
}
func main() {
node := NewNode(1) // *Node2[int]
num := node.val
}
Generic 메서드
타입을 정의할 때 일반 타입(구조체)로 정의했다면 메서드에 타입 파라미터를 사용할 수 없고, 타입을 정의할 때 Generic 타입(구조체)로 선언했다면 메서드에 타입 파라미터를 사용할 수 있습니다.
> 일반 타입(구조체) ❌
type Node struct {
val int
next *Node
}
func (n *Node) Push[T any](a T) {
}
> Generic 타입(구조체) ✅
type Node[T any] struct {
val T
next *Node[T]
}
func (n *Node[T]) Push[U any](a T) {
}
Generic 타입 파라미터는 컴파일 타임에 결정되며, 런타임에는 제네릭에 대한 정보는 사라지고 구체화된 타입만 사용됩니다.
'Go' 카테고리의 다른 글
Go (8) - Go 문법 추가 내용 (0) | 2025.04.25 |
---|---|
Go (7) - 테스트와 벤치마크, 웹 서버 (1) | 2025.04.17 |
Go (5) - 함수 고급, 자료구조, 에러 핸들링 (0) | 2025.04.13 |
Go (4) - 슬라이스, 메서드, 인터페이스 (0) | 2025.04.13 |
Go (3) - 배열, 구조체, 포인터, 문자열, 패키지 (1) | 2025.04.13 |