Java to Go: #4. 동시성: Goroutine과 Channel
이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Go의 동시성 모델은 Java와 근본적으로 다르다. Thread, synchronized, ExecutorService에 익숙한 개발자라면 처음엔 낯설겠지만, Go의 방식이 얼마나 우아한지 금방 알게 될 것이다. “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라"는 Go의 철학을 이해해보자.
1. Goroutine: 경량 스레드#
go 키워드 하나로 시작하는 동시성#
Java에서 새 스레드를 만들려면:
new Thread(() -> {
System.out.println("Hello from thread");
}).start();
// 또는 ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
System.out.println("Hello from thread pool");
});
Go에서는 go 키워드 하나면 된다:
go func() {
fmt.Println("Hello from goroutine")
}()
// 또는 기존 함수 호출
go processData(data)
Goroutine vs Thread#
메모리 효율:
- Java Thread: 약 1MB 스택 (기본값)
- Java Virtual Thread (Loom): 수 KB
- Go Goroutine: 약 2KB 초기 스택 (필요에 따라 증가)
생성 비용:
- Java Thread: OS 스레드와 1:1 매핑, 생성 비용 높음
- Java Virtual Thread: 경량화되었지만 여전히 오버헤드 있음
- Goroutine: Go 런타임이 관리, 매우 저렴
// 10만 개의 goroutine도 가볍게 생성 가능
for i := 0; i < 100000; i++ {
go func(n int) {
time.Sleep(time.Second)
fmt.Println(n)
}(i)
}
Java에서 10만 개의 Thread를 생성하면 OOM이 발생할 것이다. Virtual Thread로도 이 정도의 가벼움을 얻기 어렵다.
M:N 스케줄링#
Go 런타임은 M개의 goroutine을 N개의 OS 스레드에 다중화한다.
Goroutines: G1 G2 G3 G4 G5 G6 G7 G8 ...
\ | / \ | / \ | /
OS Threads: T1 T2 T3
개발자는 goroutine만 신경 쓰면 된다. 스레드 풀 크기나 스케줄링은 Go 런타임이 알아서 처리한다.
주의사항: Goroutine 누수#
Goroutine은 가볍지만, 종료되지 않으면 메모리 누수가 발생한다.
// 누수 예시: 영원히 대기하는 goroutine
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 아무도 보내지 않으면 영원히 대기
fmt.Println(val)
}()
// 함수 종료, goroutine은 계속 살아있음
}
해결책: context로 취소 시그널을 보내거나, 채널을 닫아서 goroutine을 종료시킨다 (뒤에서 설명).
2. Channel: 고루틴 간 통신#
기본 개념#
Channel은 goroutine 간에 데이터를 주고받는 파이프다.
// 채널 생성
ch := make(chan int)
// 보내기
ch <- 42
// 받기
value := <-ch
Java 비교:
// BlockingQueue와 유사
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
queue.put(42); // 보내기
int value = queue.take(); // 받기
하지만 Channel은 언어 수준에서 지원되어 문법이 간결하고, select와 결합해 강력한 패턴을 만들 수 있다.
Unbuffered vs Buffered Channel#
Unbuffered Channel (기본):
ch := make(chan int) // 버퍼 없음
- 송신자는 수신자가 받을 때까지 블록
- 수신자는 송신자가 보낼 때까지 블록
- 동기화 지점 역할
ch := make(chan int)
go func() {
ch <- 42 // 수신자가 받을 때까지 여기서 대기
fmt.Println("Sent")
}()
time.Sleep(time.Second)
val := <-ch // 이 시점에 송신자가 해제됨
fmt.Println("Received:", val)
Buffered Channel:
ch := make(chan int, 3) // 버퍼 크기 3
- 버퍼가 가득 찰 때까지 송신 블록 없음
- 버퍼가 빌 때까지 수신 블록 없음
ch := make(chan int, 2)
ch <- 1 // 블록 없음
ch <- 2 // 블록 없음
// ch <- 3 // 버퍼 가득, 여기서 블록됨
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
언제 무엇을 쓰는가:
- Unbuffered: 동기화가 필요할 때, goroutine 간 핸드셰이크
- Buffered: 생산자/소비자 속도가 다를 때, 백프레셔 제어
채널 닫기#
더 이상 보낼 값이 없으면 채널을 닫는다.
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // 송신 완료
}()
// 방법 1: range로 순회 (채널이 닫히면 자동 종료)
for val := range ch {
fmt.Println(val)
}
// 방법 2: 닫힘 확인
val, ok := <-ch
if !ok {
fmt.Println("Channel closed")
}
주의:
- 닫힌 채널에 송신하면 panic
- 닫힌 채널에서 수신하면 zero value 반환 (블록 없음)
- 채널은 한 번만 닫을 수 있음 (중복 close는 panic)
select: 여러 채널 다루기#
select는 여러 채널 연산 중 준비된 것을 처리한다. Java의 selector나 poll과 개념이 비슷하지만 훨씬 간결하다.
select {
case msg := <-ch1:
fmt.Println("Received from ch1:", msg)
case msg := <-ch2:
fmt.Println("Received from ch2:", msg)
case ch3 <- 42:
fmt.Println("Sent to ch3")
default:
fmt.Println("No channel ready")
}
타임아웃 구현:
select {
case result := <-ch:
fmt.Println("Got result:", result)
case <-time.After(3 * time.Second):
fmt.Println("Timeout!")
}
Java 비교:
// CompletableFuture로 비슷하게
CompletableFuture<String> future = ...;
try {
String result = future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
System.out.println("Timeout!");
}
비블록 채널 연산:
select {
case msg := <-ch:
fmt.Println("Received:", msg)
default:
fmt.Println("No message available") // 즉시 실행
}
3. 동시성 패턴#
Worker Pool#
고정된 수의 worker가 작업을 처리하는 패턴. Java의 ThreadPoolExecutor와 유사.
func workerPool() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Worker 3개 시작
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 작업 5개 전송
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// 결과 수집
for a := 1; a <= 5; a++ {
<-results
}
}
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, j)
time.Sleep(time.Second)
results <- j * 2
}
}
채널 방향 표시:
chan<- int: 송신 전용<-chan int: 수신 전용chan int: 양방향
Fan-out / Fan-in#
Fan-out: 여러 goroutine이 같은 채널에서 읽기
// 하나의 작업 채널, 여러 worker
jobs := make(chan Job)
for i := 0; i < 10; i++ {
go worker(jobs) // 10개 worker가 jobs에서 경쟁적으로 읽음
}
Fan-in: 여러 채널의 결과를 하나로 합치기
func fanIn(ch1, ch2 <-chan int) <-chan int {
merged := make(chan int)
go func() {
for {
select {
case v := <-ch1:
merged <- v
case v := <-ch2:
merged <- v
}
}
}()
return merged
}
Pipeline#
단계별로 데이터를 처리하는 패턴.
func main() {
// 파이프라인: numbers -> square -> print
numbers := generate(1, 2, 3, 4, 5)
squared := square(numbers)
for n := range squared {
fmt.Println(n)
}
}
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
Java의 Stream API 비교:
// 비슷한 패턴이지만 동시성 없음 (parallelStream은 다름)
List.of(1, 2, 3, 4, 5)
.stream()
.map(n -> n * n)
.forEach(System.out::println);
Go 파이프라인은 각 단계가 별도 goroutine에서 동시에 실행된다.
4. sync 패키지#
채널만으로 모든 동시성 문제를 해결할 수 있지만, 때로는 전통적인 동기화 프리미티브가 더 적합하다.
Mutex#
공유 데이터를 보호할 때 사용한다.
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
Java 비교:
public class SafeCounter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized int value() {
return count;
}
}
RWMutex: 읽기가 많고 쓰기가 적을 때 성능 향상
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) string {
c.mu.RLock() // 읽기 락 (동시 읽기 허용)
defer c.mu.RUnlock()
return c.data[key]
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // 쓰기 락 (배타적)
defer c.mu.Unlock()
c.data[key] = value
}
WaitGroup#
여러 goroutine의 완료를 기다릴 때 사용한다.
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("Worker %d done\n", n)
}(i)
}
wg.Wait() // 모든 goroutine이 Done() 호출할 때까지 대기
fmt.Println("All workers completed")
}
Java 비교:
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
// work
latch.countDown();
});
}
latch.await();
Once#
초기화를 단 한 번만 실행할 때 사용한다.
var (
instance *Database
once sync.Once
)
func GetDatabase() *Database {
once.Do(func() {
instance = &Database{
// expensive initialization
}
})
return instance
}
Java 비교:
// Double-checked locking 또는
public class Database {
private static final Database INSTANCE = new Database();
public static Database getInstance() { return INSTANCE; }
}
Map (sync.Map)#
동시성 안전한 맵. 하지만 대부분의 경우 Mutex + map이 더 나은 선택이다.
var m sync.Map
m.Store("key", "value")
val, ok := m.Load("key")
m.Delete("key")
언제 sync.Map을 쓰는가:
- 키가 한 번 쓰이고 여러 번 읽힐 때
- 여러 goroutine이 서로 다른 키를 읽고 쓸 때
대부분의 경우 RWMutex + map이 더 성능이 좋다.
5. context.Context#
취소, 타임아웃, 값 전달#
context는 Go 1.7에서 도입된 핵심 패키지다. API 경계를 넘어 취소 시그널, 타임아웃, 요청 범위 값을 전달한다.
func main() {
// 3초 타임아웃 컨텍스트
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := fetchData(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
}
func fetchData(ctx context.Context) (string, error) {
ch := make(chan string)
go func() {
// 시뮬레이션: 느린 작업
time.Sleep(5 * time.Second)
ch <- "data"
}()
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return "", ctx.Err() // context.DeadlineExceeded
}
}
Context 종류#
// 빈 컨텍스트 (루트)
ctx := context.Background()
ctx := context.TODO() // 아직 어떤 컨텍스트를 쓸지 모를 때
// 취소 가능한 컨텍스트
ctx, cancel := context.WithCancel(parent)
// 타임아웃 컨텍스트
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
// 데드라인 컨텍스트
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
// 값을 담은 컨텍스트
ctx := context.WithValue(parent, "userID", "123")
Java의 CompletableFuture와 비교#
Java:
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchData())
.orTimeout(3, TimeUnit.SECONDS);
try {
String result = future.get();
} catch (TimeoutException e) {
// 타임아웃
}
Go:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := fetchData(ctx)
if errors.Is(err, context.DeadlineExceeded) {
// 타임아웃
}
차이점:
- Java: Future가 결과를 감싸고, 예외로 에러 처리
- Go: Context가 취소 시그널을 전파하고, 에러는 별도 반환
Context 사용 규칙#
- 함수의 첫 번째 파라미터로:
func DoSomething(ctx context.Context, ...) - struct에 저장하지 말 것: 매번 파라미터로 전달
- nil context 전달 금지: 확실치 않으면
context.TODO()사용 - Value는 요청 범위 데이터에만: 함수 파라미터 대체용으로 쓰지 말 것
6. Race Condition 탐지#
go run -race#
Go는 내장 race detector를 제공한다.
// race condition이 있는 코드
var counter int
func main() {
for i := 0; i < 1000; i++ {
go func() {
counter++ // 동시 접근!
}()
}
time.Sleep(time.Second)
fmt.Println(counter)
}
$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x... by goroutine 7:
main.main.func1()
main.go:10 +0x3a
Previous write at 0x... by goroutine 6:
main.main.func1()
main.go:10 +0x4e
==================
실전 팁#
# 테스트에서 race detection
go test -race ./...
# 빌드 시 race detection (성능 저하 있음, 프로덕션 비권장)
go build -race
Race detector는 런타임 오버헤드가 있으므로 CI/CD에서 테스트 시에만 활성화한다.
7. 실전 예제: HTTP 서버의 동시 요청 처리#
func main() {
http.HandleFunc("/search", searchHandler)
http.ListenAndServe(":8080", nil)
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // 요청의 컨텍스트 (클라이언트 취소 시 취소됨)
query := r.URL.Query().Get("q")
// 세 곳에서 동시에 검색
results := make(chan string, 3)
go func() { results <- searchGoogle(ctx, query) }()
go func() { results <- searchBing(ctx, query) }()
go func() { results <- searchDuckDuckGo(ctx, query) }()
// 가장 빠른 결과 반환
select {
case result := <-results:
fmt.Fprintf(w, "Result: %s", result)
case <-ctx.Done():
http.Error(w, "Request cancelled", http.StatusRequestTimeout)
}
}
func searchGoogle(ctx context.Context, query string) string {
// 실제로는 HTTP 요청
select {
case <-time.After(100 * time.Millisecond):
return "Google: " + query
case <-ctx.Done():
return ""
}
}
요약: Java → Go 동시성 마인드셋 전환#
| Java | Go |
|---|---|
new Thread() |
go func() |
ExecutorService |
goroutine (직접 관리 불필요) |
BlockingQueue |
chan |
synchronized |
sync.Mutex |
CountDownLatch |
sync.WaitGroup |
CompletableFuture |
goroutine + channel + context |
@Async |
go 키워드 |
핵심 원칙:
- “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라”
- Goroutine은 가볍다. 필요하면 만들어라.
- Channel로 동기화하고 데이터를 전달하라.
- Context로 취소와 타임아웃을 전파하라.
- Race detector로 동시성 버그를 잡아라.