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