본문으로 건너뛰기

마이크로서비스 패턴 — 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): 트래픽 받을 준비가 됐는가 — 실패 시 로드밸런서에서 제외
  • 그레이스풀 셧다운으로 처리 중인 요청을 완료 후 종료