본문으로 건너뛰기

마이크로서비스 패턴 — Go로 구현하는 실전 아키텍처

마이크로서비스는 독립적으로 배포 가능한 작은 서비스들의 집합입니다. Go는 빠른 시작 시간, 낮은 메모리 사용량, 단일 바이너리 배포로 마이크로서비스에 최적화된 언어입니다.


서비스 구조 설계​

표준 프로젝트 레이아웃​

user-service/
├── cmd/
│ └── server/
│ └── main.go ← 진입점
├── internal/
│ ├── domain/
│ │ └── user.go ← 도메인 모델 & 비즈니스 규칙
│ ├── repository/
│ │ ├── interface.go ← 저장소 인터페이스
│ │ └── postgres.go ← PostgreSQL 구현
│ ├── service/
│ │ └── user_service.go ← 비즈니스 로직
│ ├── handler/
│ │ ├── http.go ← HTTP 핸들러
│ │ └── grpc.go ← gRPC 핸들러
│ └── config/
│ └── config.go ← 설정 로드
├── api/
│ └── proto/ ← proto 파일
├── migrations/ ← DB 마이그레이션
├── Dockerfile
└── go.mod

도메인 모델​

// internal/domain/user.go
package domain

import (
"errors"
"time"
)

// 도메인 에러 정의
var (
ErrUserNotFound = errors.New("사용자를 찾을 수 없음")
ErrEmailAlreadyInUse = errors.New("이미 사용 중인 이메일")
ErrInvalidEmail = errors.New("유효하지 않은 이메일 형식")
)

type User struct {
ID int64
Name string
Email string
CreatedAt time.Time
UpdatedAt time.Time
}

// 도메인 규칙
func (u *User) Validate() error {
if u.Name == "" {
return errors.New("이름은 필수입니다")
}
if !isValidEmail(u.Email) {
return ErrInvalidEmail
}
return nil
}

서비스 간 통신 패턴​

HTTP 클라이언트 — 재시도 & 서킷 브레이커​

// internal/client/product_client.go
package client

import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)

type ProductClient struct {
baseURL string
httpClient *http.Client
}

func NewProductClient(baseURL string) *ProductClient {
return &ProductClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
}

func (c *ProductClient) GetProduct(ctx context.Context, id int64) (*Product, error) {
url := fmt.Sprintf("%s/products/%d", c.baseURL, id)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("요청 생성 실패: %w", err)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("요청 실패: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil, ErrProductNotFound
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("예상치 못한 상태 코드: %d", resp.StatusCode)
}

var product Product
if err := json.NewDecoder(resp.Body).Decode(&product); err != nil {
return nil, fmt.Errorf("응답 디코딩 실패: %w", err)
}
return &product, nil
}

서킷 브레이커 패턴 (gobreaker)​

import "github.com/sony/gobreaker"

func NewProductClientWithBreaker(baseURL string) *ProductClient {
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "product-service",
MaxRequests: 3, // 반열린 상태에서 허용할 최대 요청
Interval: 10 * time.Second, // 닫힌 상태에서 카운터 초기화 주기
Timeout: 30 * time.Second, // 열린 상태 유지 시간
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
OnStateChange: func(name string, from, to gobreaker.State) {
log.Printf("서킷 브레이커 상태 변경: %s → %s", from, to)
},
})

return &ProductClient{baseURL: baseURL, breaker: cb}
}

func (c *ProductClient) GetProductSafe(ctx context.Context, id int64) (*Product, error) {
result, err := c.breaker.Execute(func() (interface{}, error) {
return c.GetProduct(ctx, id)
})
if err == gobreaker.ErrOpenState {
// 폴백: 캐시에서 반환 또는 기본값
return c.getFallbackProduct(id)
}
if err != nil {
return nil, err
}
return result.(*Product), nil
}

이벤트 기반 통신 — Kafka​

go get github.com/segmentio/kafka-go

이벤트 발행자​

// internal/events/publisher.go
package events

import (
"context"
"encoding/json"

"github.com/segmentio/kafka-go"
)

type UserCreatedEvent struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
Timestamp int64 `json:"timestamp"`
}

type EventPublisher struct {
writer *kafka.Writer
}

func NewEventPublisher(brokers []string) *EventPublisher {
return &EventPublisher{
writer: &kafka.Writer{
Addr: kafka.TCP(brokers...),
Balancer: &kafka.LeastBytes{},
RequiredAcks: kafka.RequireAll, // 강한 내구성
},
}
}

func (p *EventPublisher) PublishUserCreated(ctx context.Context, userID int64, email, name string) error {
event := UserCreatedEvent{
UserID: userID,
Email: email,
Name: name,
Timestamp: time.Now().Unix(),
}

data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("이벤트 직렬화 실패: %w", err)
}

return p.writer.WriteMessages(ctx, kafka.Message{
Topic: "user.created",
Key: []byte(fmt.Sprintf("%d", userID)),
Value: data,
})
}

func (p *EventPublisher) Close() error {
return p.writer.Close()
}

이벤트 소비자​

// internal/events/consumer.go
package events

type EventConsumer struct {
reader *kafka.Reader
handlers map[string]EventHandler
}

type EventHandler func(ctx context.Context, data []byte) error

func NewEventConsumer(brokers []string, groupID string) *EventConsumer {
return &EventConsumer{
reader: kafka.NewReader(kafka.ReaderConfig{
Brokers: brokers,
GroupID: groupID,
Topic: "user.created",
MinBytes: 10e3,
MaxBytes: 10e6,
CommitInterval: time.Second,
}),
handlers: make(map[string]EventHandler),
}
}

func (c *EventConsumer) Start(ctx context.Context) error {
for {
msg, err := c.reader.FetchMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return nil // 정상 종료
}
return fmt.Errorf("메시지 수신 실패: %w", err)
}

if err := c.processMessage(ctx, msg); err != nil {
log.Printf("메시지 처리 실패 (offset=%d): %v", msg.Offset, err)
continue // 에러 시 다음 메시지로 (DLQ 활용 권장)
}

// 수동 커밋 (at-least-once 처리)
c.reader.CommitMessages(ctx, msg)
}
}

분산 트레이싱 — OpenTelemetry​

go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/sdk
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace
// internal/telemetry/tracer.go
package telemetry

import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/trace"
)

func InitTracer(serviceName, endpoint string) (func(), error) {
ctx := context.Background()

exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}

tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
)),
)
otel.SetTracerProvider(tp)

shutdown := func() {
tp.Shutdown(context.Background())
}
return shutdown, nil
}

// 서비스 코드에서 사용
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
tracer := otel.Tracer("user-service")
ctx, span := tracer.Start(ctx, "UserService.GetUser")
defer span.End()

span.SetAttributes(attribute.Int64("user.id", id))

user, err := s.repo.FindByID(ctx, id)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}

return user, nil
}

헬스 체크 & 그레이스풀 셧다운​

// cmd/server/main.go
func main() {
srv := &http.Server{Addr: ":8080", Handler: newRouter()}

// 헬스 체크 엔드포인트
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
status := map[string]string{
"status": "ok",
"version": version,
}
json.NewEncoder(w).Encode(status)
})

http.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
http.Error(w, "DB not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})

// 그레이스풀 셧다운
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh

log.Println("셧다운 시작...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
log.Printf("셧다운 오류: %v", err)
}
}()

log.Fatal(srv.ListenAndServe())
}

핵심 정리​

패턴도구용도
동기 통신gRPC, REST즉각 응답 필요
비동기 통신Kafka, NATS이벤트 드리븐
서킷 브레이커gobreaker장애 전파 방지
분산 트레이싱OpenTelemetry요청 추적
헬스 체크/health, /ready배포 자동화
  • Liveness (/health): 서비스가 살아있는가 — 실패 시 재시작
  • Readiness (/ready): 트래픽 받을 준비가 됐는가 — 실패 시 로드밸런서에서 제외
  • 그레이스풀 셧다운으로 처리 중인 요청을 완료 후 종료