[Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명
이 글의 핵심
[Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명. 시리즈 안내·경량 동시성의 세계.
시리즈 안내
📚 Go 2주 완성 시리즈 #06 | 전체 목차 보기
이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 10~11 내용입니다.
이전: #05 에러 처리 ← | → 다음: #07 테스팅
💡 초보자를 위한 한 줄: 고루틴은
go 함수()로 시작하는 초경량 스레드(수 KB)입니다. C++std::thread(~8MB)와 달리 수만 개를 띄워도 괜찮습니다. 채널은 고루틴 사이에 데이터를 안전하게 주고받는 파이프입니다.ch <- value(보내기),value := <-ch(받기).
들어가며: “스레드 1만 개를 띄워도 괜찮다고요?”
C++에서 std::thread로 스레드를 만들면 스레드당 1~8MB의 스택이 필요합니다. 수백 개만 넘어도 메모리 부담이 커집니다:
// C++: 스레드 1만 개 = 10GB 이상 메모리!
std::vector<std::thread> threads;
for (int i = 0; i < 10000; i++) {
threads.emplace_back(worker, i); // 각 스레드 ~8MB
}
Go는 완전히 다릅니다. 고루틴은 수 KB로 시작해 필요할 때만 스택이 늘어나므로, 1만 개를 띄워도 수백 MB 수준입니다:
for i := 0; i < 10000; i++ {
go worker(i) // 각 고루틴 ~2KB 시작
}
채널(channel)은 고루틴 사이에서 값을 주고받는 파이프입니다. 공유 메모리를 직접 건드리지 않고, 파이프로 데이터를 흘려보내며 동기화하는 방식이 Go의 동시성 철학입니다.
Rust의 채널·Arc/Mutex와 자주 비교되고, Kotlin 코루틴은 스레드 풀·Channel API로 비슷한 목표를 다른 문법으로 풉니다. 전통적인 OS 스레드 API는 Java Thread 글과 나란히 보면 “무겁다”는 느낌의 기준이 잡힙니다.
이 글에서 배울 내용:
- 고루틴: 경량 작업자로 동시 작업 나누기
- 채널: 파이프로 안전하게 통신하기
- select: 여러 채널 중 준비된 쪽 처리하기
- 동시성 패턴: 워커 풀, 파이프라인
C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.
실무에서의 체감
C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 동시성 모델이 단순해 보인다는 점입니다. 다만 프로덕션에서 쓰면 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다. 자주 언급되는 장점:
- 개발 속도: 팀·도메인에 따라 다르지만, 동시성·네트워크 코드를 빠르게 완성하기 쉬운 편입니다.
- 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
- 배포: 단일 바이너리로 옮기기 쉬운 구조입니다. 위 내용은 시리즈 전체를 구성할 때 참고한 실무 관점의 정리입니다.
1. 고루틴: 경량 스레드
C++ vs Go: 스레드 생성
// C++: std::thread (무거움)
#include <thread>
#include <iostream>
#include <vector>
void worker(int id) {
std::cout << "Worker " << id << " running\n";
}
int main() {
std::vector<std::thread> threads;
// 10개 스레드 (각 1~8MB 스택)
for (int i = 0; i < 10; i++) {
threads.emplace_back(worker, i);
}
// 모든 스레드 대기
for (auto& t : threads) {
t.join();
}
return 0;
}
// Go: 고루틴 (가벼움)
// 패키지 선언
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int) {
fmt.Printf("Worker %d running\n", id)
}
func main() {
var wg sync.WaitGroup
// 10,000개 고루틴도 가볍게 생성 가능!
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(id)
}(i)
}
// 모든 고루틴 대기
wg.Wait()
}
핵심 차이점:
- 생성 비용: 고루틴은 OS 스레드보다 훨씬 저렴
- 스택 크기: 고루틴은 2KB로 시작, 필요 시 자동 확장
- 스케줄링: M:N 스케줄링 (M개 고루틴을 N개 OS 스레드에서 실행)
- 수량: C++는 수백 개 한계, Go는 수만~수십만 개 가능
sync.WaitGroup
// Go: WaitGroup으로 고루틴 대기
package main
import (
"fmt"
"sync"
"time"
)
func task(id int, wg *sync.WaitGroup) {
defer wg.Done() // 완료 시 카운터 감소
fmt.Printf("Task %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Task %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 카운터 증가
go task(i, &wg)
}
wg.Wait() // 모든 고루틴 완료 대기
fmt.Println("All tasks completed")
}
2. 채널: 고루틴 간 통신
C++ vs Go: 데이터 공유
// C++: Mutex로 공유 메모리 보호
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> results;
void worker(int id) {
int result = id * 2;
std::lock_guard<std::mutex> lock(mtx);
results.push_back(result);
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(worker, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
// Go: 채널로 통신 (권장 패턴)
// 패키지 선언
package main
import "fmt"
func worker(id int, ch chan int) {
result := id * 2
ch <- result // 채널에 전송
}
func main() {
ch := make(chan int)
// 10개 고루틴 시작
for i := 0; i < 10; i++ {
go worker(i, ch)
}
// 결과 수신
results := make([]int, 0, 10)
for i := 0; i < 10; i++ {
result := <-ch // 채널에서 수신
results = append(results, result)
}
fmt.Println(results)
}
Go의 철학: “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라”
Do not communicate by sharing memory; instead, share memory by communicating.
채널 기본 연산
// Go: 채널 생성과 사용
package main
import "fmt"
func main() {
// 채널 생성
ch := make(chan int)
// 송신 (고루틴에서)
go func() {
ch <- 42 // 전송 (수신자가 받을 때까지 블록)
}()
// 수신
value := <-ch // 수신 (송신자가 보낼 때까지 블록)
fmt.Println(value)
// 채널 닫기 (송신자가 더 이상 보낼 것이 없을 때)
close(ch)
// 닫힌 채널에서 수신 시 제로 값과 false 반환
v, ok := <-ch
fmt.Println(v, ok) // 0 false
}
채널 방향성
// Go: 채널 방향 지정 (타입 안전성)
package main
import "fmt"
// 송신 전용 채널
func sender(ch chan<- int) {
ch <- 1
ch <- 2
ch <- 3
close(ch)
// v := <-ch // ❌ 컴파일 에러: 수신 불가
}
// 수신 전용 채널
func receiver(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
// ch <- 4 // ❌ 컴파일 에러: 송신 불가
}
func main() {
ch := make(chan int) // 양방향 채널
go sender(ch) // 송신 전용으로 전달
receiver(ch) // 수신 전용으로 전달
}
3. 버퍼 채널
버퍼 없는 채널 vs 버퍼 채널
// Go: 버퍼 없는 채널 (동기)
package main
import "fmt"
func unbuffered() {
ch := make(chan int) // 버퍼 없음
// ❌ 데드락: 수신자 없이 송신 시도
// ch <- 1 // 영원히 블록
// ✅ 고루틴에서 송신
go func() {
ch <- 1 // 수신자가 받을 때까지 대기
}()
fmt.Println(<-ch) // 1
}
func buffered() {
ch := make(chan int, 3) // 버퍼 크기 3
// ✅ 버퍼가 차기 전까지 블록 안 됨
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // 버퍼 가득 참, 블록됨
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}
버퍼 채널 활용:
// Go: 버퍼 채널로 생산자-소비자 패턴
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
fmt.Printf("Producing %d\n", i)
ch <- i
time.Sleep(100 * time.Millisecond)
}
close(ch)
}
func consumer(ch <-chan int) {
for v := range ch { // 채널이 닫힐 때까지 수신
fmt.Printf("Consuming %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
func main() {
ch := make(chan int, 5) // 버퍼 크기 5
go producer(ch)
consumer(ch)
}
4. select: 다중 채널 제어
select 기본 사용법
// Go: select로 여러 채널 대기
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "from ch2"
}()
// 먼저 준비된 채널에서 수신
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
}
select with timeout
// Go: 타임아웃 처리
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "result"
}()
select {
case result := <-ch:
fmt.Println("Received:", result)
case <-time.After(1 * time.Second):
fmt.Println("Timeout!")
}
}
select with default (논블로킹)
// Go: 논블로킹 채널 연산
package main
import "fmt"
func main() {
ch := make(chan int, 1)
// 논블로킹 송신
select {
case ch <- 1:
fmt.Println("Sent")
default:
fmt.Println("Channel full")
}
// 논블로킹 수신
select {
case v := <-ch:
fmt.Println("Received:", v)
default:
fmt.Println("No data")
}
}
5. 동시성 패턴
패턴 1: 워커 풀 (Worker Pool)
// C++: 스레드 풀 (복잡)
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
bool stop = false;
public:
ThreadPool(size_t numThreads) {
for (size_t i = 0; i < numThreads; i++) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty()) return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
}
// ....(생략)
};
// Go: 워커 풀 (간단)
package main
import (
"fmt"
"time"
)
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(time.Second)
results <- job * 2
}
}
func main() {
numJobs := 10
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 3개 워커 시작
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 작업 전송
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// 결과 수집
for a := 1; a <= numJobs; a++ {
result := <-results
fmt.Println("Result:", result)
}
}
패턴 2: 파이프라인 (Pipeline)
// Go: 파이프라인 패턴
package main
import "fmt"
// 단계 1: 숫자 생성
func generator(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// 단계 2: 제곱
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
// 단계 3: 출력
func printer(in <-chan int) {
for n := range in {
fmt.Println(n)
}
}
func main() {
// 파이프라인 구성
nums := generator(1, 2, 3, 4, 5)
squared := square(nums)
printer(squared)
}
패턴 3: Fan-out, Fan-in
// Go: Fan-out (하나의 입력을 여러 워커에 분배)
package main
import (
"fmt"
"sync"
)
func fanOut(in <-chan int, numWorkers int) []<-chan int {
outs := make([]<-chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
out := make(chan int)
outs[i] = out
go func(ch chan int) {
defer close(ch)
for v := range in {
ch <- v * 2 // 처리
}
}(out)
}
return outs
}
// Fan-in (여러 채널을 하나로 병합)
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
// 입력 생성
in := make(chan int)
go func() {
defer close(in)
for i := 1; i <= 10; i++ {
in <- i
}
}()
// Fan-out: 3개 워커에 분배
workers := fanOut(in, 3)
// Fan-in: 결과 병합
out := fanIn(workers...)
// 결과 출력
for result := range out {
fmt.Println(result)
}
}
패턴 4: 타임아웃과 취소
// Go: context로 타임아웃과 취소
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done(): // 취소 신호
fmt.Printf("Worker %d cancelled\n", id)
return
default:
fmt.Printf("Worker %d working...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// 2초 타임아웃
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
// 타임아웃 대기
<-ctx.Done()
fmt.Println("Main: timeout reached")
// 고루틴이 정리될 시간 주기
time.Sleep(time.Second)
}
6. 실습 과제
과제 1: 병렬 다운로드
// Go: 여러 URL 병렬 다운로드
package main
import (
"fmt"
"io"
"net/http"
"sync"
)
func download(url string, wg *sync.WaitGroup, results chan<- string) {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
results <- fmt.Sprintf("%s: error - %v", url, err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
results <- fmt.Sprintf("%s: read error - %v", url, err)
return
}
results <- fmt.Sprintf("%s: %d bytes", url, len(body))
}
func main() {
urls := []string{
"https://golang.org",
"https://github.com",
"https://stackoverflow.com",
}
var wg sync.WaitGroup
results := make(chan string, len(urls))
for _, url := range urls {
wg.Add(1)
go download(url, &wg, results)
}
// 고루틴 완료 대기 후 채널 닫기
go func() {
wg.Wait()
close(results)
}()
// 결과 출력
for result := range results {
fmt.Println(result)
}
}
과제 2: 레이트 리미터
// Go: 채널로 레이트 리미터 구현
package main
import (
"fmt"
"time"
)
func rateLimiter(requests <-chan int, rate time.Duration) {
ticker := time.NewTicker(rate)
defer ticker.Stop()
for req := range requests {
<-ticker.C // rate마다 하나씩 처리
fmt.Printf("Processing request %d at %v\n", req, time.Now())
}
}
func main() {
requests := make(chan int, 10)
// 레이트 리미터 시작 (500ms마다 하나씩)
go rateLimiter(requests, 500*time.Millisecond)
// 요청 전송
for i := 1; i <= 5; i++ {
requests <- i
}
close(requests)
time.Sleep(3 * time.Second)
}
과제 3: 타임아웃 있는 작업
// Go: 타임아웃 처리
package main
import (
"fmt"
"time"
)
func longRunningTask(result chan<- string) {
time.Sleep(3 * time.Second)
result <- "Task completed"
}
func main() {
result := make(chan string)
go longRunningTask(result)
select {
case res := <-result:
fmt.Println(res)
case <-time.After(2 * time.Second):
fmt.Println("Timeout: task took too long")
}
}
과제 4: 동시 Map 접근
// Go: sync.Mutex vs 채널
package main
import (
"fmt"
"sync"
)
// 방법 1: Mutex 사용
type SafeCounter1 struct {
mu sync.Mutex
count map[string]int
}
func (c *SafeCounter1) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.count[key]++
}
func (c *SafeCounter1) Value(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count[key]
}
// 방법 2: 채널 사용 (더 Go다운 방식)
type SafeCounter2 struct {
ops chan func(map[string]int)
}
func NewSafeCounter2() *SafeCounter2 {
c := &SafeCounter2{
ops: make(chan func(map[string]int)),
}
go func() {
count := make(map[string]int)
for op := range c.ops {
op(count)
}
}()
return c
}
func (c *SafeCounter2) Inc(key string) {
c.ops <- func(count map[string]int) {
count[key]++
}
}
func (c *SafeCounter2) Value(key string) int {
result := make(chan int)
c.ops <- func(count map[string]int) {
result <- count[key]
}
return <-result
}
func main() {
// Mutex 방식
counter1 := &SafeCounter1{count: make(map[string]int)}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter1.Inc("key")
}()
}
wg.Wait()
fmt.Println("Counter1:", counter1.Value("key"))
// 채널 방식
counter2 := NewSafeCounter2()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter2.Inc("key")
}()
}
wg.Wait()
fmt.Println("Counter2:", counter2.Value("key"))
}
정리: Day 10~11 학습 체크리스트
완료해야 할 항목
-
go키워드로 고루틴 생성 -
sync.WaitGroup으로 고루틴 대기 - 채널 생성 및 송수신 (
<-) - 버퍼 채널과 버퍼 없는 채널 차이 이해
-
select로 다중 채널 제어 - 타임아웃과 논블로킹 연산
- 워커 풀, 파이프라인 패턴 구현
- 실습 과제 4개 완료
C++에서 Go로 전환 포인트
| C++ | Go | 비고 |
|---|---|---|
std::thread | go 키워드 | 훨씬 가벼움 |
thread.join() | sync.WaitGroup | 더 유연 |
std::mutex | sync.Mutex 또는 채널 | 채널 우선 |
std::condition_variable | 채널 또는 sync.Cond | 채널이 더 간단 |
| 공유 메모리 + 락 | 채널 통신 | 패러다임 전환 |
동시성 vs 병렬성
graph TD
A[동시성 Concurrency] --> B[여러 작업을 다루는 구조]
C[병렬성 Parallelism] --> D[여러 작업을 동시에 실행]
B --> E[고루틴으로 구현]
D --> F[멀티코어에서 실행]
E --> G[GOMAXPROCS로 제어]
F --> G
Go의 동시성 모델:
- 동시성: 여러 작업을 구조적으로 다루는 방법 (고루틴, 채널)
- 병렬성: 여러 작업을 물리적으로 동시에 실행 (멀티코어)
- Go는 동시성을 쉽게 만들고, 런타임이 자동으로 병렬성을 처리
다음 단계 예고
Day 10~11에서는 고루틴과 채널을 배웠습니다. 다음 글에서는 의존성 관리와 테스팅을 다룹니다. CMake보다 수백 배 쉬운 Go Modules와 내장 테스트 프레임워크를 배웁니다.
📚 시리즈 네비게이션
| 이전 글 | 목차 | 다음 글 |
|---|---|---|
| ← #05 에러 처리 | 📑 전체 목차 | #07 테스팅 → |
| Go 2주 완성 시리즈: | ||
| 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료 |
한 줄 요약: 고루틴은 가볍고, 채널은 안전하며, select는 강력합니다. Go의 동시성 모델은 C++ 스레드보다 훨씬 우아합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라
- [Go 심화 #09] context.Context·타임아웃·우아한 종료 — 동시성을 프로덕션 수준으로
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
이 글에서 다루는 키워드 (관련 검색어)
Go goroutine, Go channel, select Go, 동시성 프로그래밍, C++ thread 비교, Golang 고루틴, Go 2주 완성, 채널 버퍼 등으로 검색하시면 이 글이 도움이 됩니다.
실전 팁 (Go)
go fmt,go vet, 필요 시golangci-lint로 기본 품질을 맞춥니다.if err != nil을 습관화합니다. 에러를 삼키면 운영에서 원인 추적이 어렵습니다.go test ./...로 패키지 단위 회귀를 확인합니다. 성능은go test -bench로 측정할 수 있을 때만 다룹니다.
실전 체크리스트 (Go)
코드
- 외부 호출·I/O 실패 시 에러가 처리되거나 로깅되는가?
- 고루틴·채널 사용 시 블로킹·누수 가능성을 검토했는가?
모듈
-
go.mod/go.sum이 팀과 합의된 범위인가?
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ std::thread의 무거움에서 벗어나 수만 개의 고루틴을 가볍게 생성하세요. 채널로 안전하게 통신하고, select로 다중 채널을 제어하는 Go 동시성 프로그래밍의 핵심을 배웁니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
관련 글
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
- [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법 - C++ 개발자의 첫인상
- [Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「[Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「[Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.