본문으로 건너뛰기

운영 준비

이 챕터에서 다루는 것

파트 12의 마지막이자 파트 13으로 넘어가는 다리다. 지금까지 만든 조각들 — 에러 정책, 로거, 설정, 프로파일, 이미지 — 을 "실제로 트래픽을 받는 서비스"의 모양으로 묶는다.

11장이 남긴 두 숙제도 여기서 갚는다. 11-2 연습문제 1의 "context 타임아웃 아래에서의 커넥션 풀 고갈"과, 11-7 연습문제 3이 이름 없이 가리켰던 트랜잭션 아웃박스다.

라이브니스와 레디니스는 다른 질문이다

파트 9와 10에서 /healthz를 만들었다. 그때는 "200을 돌려주는 엔드포인트" 정도였다. 운영에서는 이것을 두 개로 나눠야 한다.

  • 라이브니스(/healthz) — "이 프로세스가 살아 있는가?" 실패하면 오케스트레이터가 컨테이너를 죽이고 새로 띄운다.
  • 레디니스(/readyz) — "지금 요청을 받아도 되는가?" 실패하면 로드밸런서가 트래픽만 빼고 프로세스는 그대로 둔다.

둘을 하나로 합치면, 정확히는 라이브니스에 DB 검사를 넣으면 사고가 난다.

examples/12-production/08-production-readiness/health/health.go
// Package health는 라이브니스와 레디니스를 나눠 다룬다.
//
// 둘을 구분하지 않으면 사고가 난다. DB가 잠깐 느려졌을 때
//
// - 라이브니스가 DB를 확인하면 → 오케스트레이터가 컨테이너를 죽인다.
// 모든 인스턴스가 동시에 죽고, 재시작한 인스턴스도 같은 DB를 보고
// 또 죽는다. 재시작 폭풍이다.
// - 레디니스만 DB를 확인하면 → 트래픽만 빠지고 프로세스는 살아 있다.
// DB가 돌아오면 스스로 복귀한다.
//
// 규칙: 라이브니스는 "프로세스가 살아 있는가", 레디니스는 "지금 요청을
// 받아도 되는가". 라이브니스는 의존성을 절대 확인하지 않는다.
package health

DB가 30초 느려진 것은 DB의 문제다. 애플리케이션 인스턴스를 전부 재시작한다고 해결되지 않고, 오히려 재시작 직후의 캐시 미스와 커넥션 재수립이 DB에 부하를 더한다. 그래서 상황이 더 나빠지는 방향으로 증폭된다.

examples/12-production/08-production-readiness/health/health.go
// LivenessHandler는 언제나 200이다.
//
// 여기에 의존성 확인을 넣고 싶은 유혹이 늘 있는데, 넣으면 안 된다.
// 이 엔드포인트가 실패한다는 것은 "이 프로세스를 죽이고 새로 띄우라"는
// 뜻이고, DB 장애에 대한 올바른 대응이 아니다.
func LivenessHandler() http.Handler {

실행해 보면 차이가 분명하다.

== 1. 라이브니스와 레디니스는 다른 질문이다 ==
등록된 레디니스 검사: [cache db]
-- DB 정상 --
/healthz -> 200 ok
/readyz -> 200 {"ready":true,"details":{"cache":"ok","db":"ok"}}
-- DB 장애 --
/healthz -> 200 ok
/readyz -> 503 {"ready":false,"details":{"cache":"ok","db":"connection refused"}}
라이브니스는 200 그대로다. 여기서 실패하면 컨테이너가 죽는다.

503이라는 코드도 정해진 것이다. 로드밸런서와 쿠버네티스가 기대하는 값이고, 404나 500은 다르게 해석될 수 있다.

검사마다 타임아웃, 그리고 병렬로

examples/12-production/08-production-readiness/health/health.go
// timeout은 검사 하나에 허용하는 시간이다. 이것이 없으면
// 느린 DB 하나가 헬스체크 응답 전체를 붙잡고, 프로브가
// 타임아웃되어 결국 라이브니스 실패로 번진다.
timeout time.Duration
examples/12-production/08-production-readiness/health/health.go
// Ready는 등록된 검사를 전부 병렬로 돌린다.
//
// 순차로 돌리면 검사 개수만큼 시간이 곱해진다. 병렬이면 가장 느린
// 하나만큼만 걸린다.
func (r *Registry) Ready(ctx context.Context) Result {
examples/12-production/08-production-readiness/health/health.go
var wg sync.WaitGroup
for i := range names {
wg.Go(func() {
cctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
errs[i] = fns[i](cctx)
})
}
wg.Wait()

wg.Go는 파트 7에서 다룬 Go 1.25의 형태다. 그리고 클로저가 i를 그대로 잡는 것도 주목할 만하다 — Go 1.22 이전이라면 i := i가 필요했고, 지금은 반복마다 새 변수라 그대로 안전하다. 인덱스마다 다른 슬롯에 쓰므로 뮤텍스도 필요 없다.

병렬로 도는지도 테스트로 확인한다.

examples/12-production/08-production-readiness/health/health_test.go
func TestChecksRunInParallel(t *testing.T) {
// 100ms짜리 검사 다섯 개가 순차라면 500ms, 병렬이면 100ms 남짓이다.
reg := health.New(time.Second)

종료 순서 — 레디니스부터 내린다

http.Server.Shutdown만 부르면 부족하다. 순서가 있다.

  1. 레디니스를 실패로 바꾼다. 로드밸런서가 이 인스턴스를 뺀다.
  2. 몇 초 기다린다. 로드밸런서의 헬스체크 주기만큼. 이미 라우팅 테이블에 들어 있던 요청이 도착할 수 있기 때문이다.
  3. Shutdown을 부른다. 새 연결을 거부하고 진행 중인 요청을 기다린다.
  4. 백그라운드 작업을 정리한다. 워커, 릴레이, 큐 소비자.
  5. DB 커넥션을 닫는다.

2번을 빠뜨리는 것이 가장 흔하다. Shutdown진행 중인 요청만 기다린다. 아직 도착하지 않았지만 로드밸런서가 이미 이쪽으로 보내기로 한 요청은 연결 거부를 맞는다. 사용자에게는 502로 보인다.

examples/12-production/08-production-readiness/health/health.go
// Drain은 "이제 새 트래픽을 받지 않겠다"고 표시한다.
//
// 종료 절차의 첫 단계다. 이것을 켜고 로드밸런서가 알아챌 시간만큼
// 기다린 뒤에 서버를 닫아야, 처리 중이 아닌 새 요청이 끊기지 않는다.
func (r *Registry) Drain() { r.draining.Store(true) }
== 2. 종료를 시작하면 레디니스부터 내린다 ==
종료 전: 200 {"ready":true,"details":{"db":"ok"}}
Drain() 후: 503 {"ready":false,"details":{"status":"draining"}}
이 상태로 몇 초 버틴 뒤에 리스너를 닫아야 새 요청이 안 끊긴다.

배수 중에도 라이브니스는 계속 200이라는 점이 중요하다. 여기서 실패하면 오케스트레이터가 정상 종료를 기다리지 않고 SIGKILL한다.

signal.NotifyContext의 취소 원인 — 실제로 무엇이 오는가

Go 1.26에서 signal.NotifyContextCancelCauseFunc로 취소하도록 바뀌었다. "시그널이 원인으로 담긴다"고 알려져 있는데, 정확히 무엇이 담기는지 확인해 봤다.

examples/12-production/08-production-readiness/main.go
cause := context.Cause(ctx)
fmt.Println(" ctx.Err() =", ctx.Err())
fmt.Println(" context.Cause() =", cause)
// 시그널로 끝났는지 아닌지는 원인이 context.Canceled 그 자체인지로 가른다.
// errors.Is로는 구별되지 않는다 — 시그널 에러의 Is가 context.Canceled에
// 대해 참을 돌려주도록 만들어져 있기 때문이다.
fmt.Println(" errors.Is(cause, context.Canceled) =", errors.Is(cause, context.Canceled))
fmt.Println(" 시그널 때문인가:", cause != context.Canceled)
== 6. 시그널과 취소 원인 ==
ctx.Err() = context canceled
context.Cause() = terminated signal received
errors.Is(cause, context.Canceled) = true
시그널 때문인가: true

:::warning 원인은 os.Signal이 아니라 문자열 에러다 표준 라이브러리 소스를 보면 이렇게 되어 있다.

os/signal/signal.go:294
c.cancel(signalError(s.String() + " signal received"))

os/signal/signal.go:340
type signalError string

func (s signalError) Error() string { return string(s) }

func (s signalError) Is(target error) bool {
if target == context.Canceled {
return true
}
return false
}

세 가지가 따라 나온다.

  1. os.Signal 값을 꺼낼 수 없다. signalError는 문자열일 뿐이고 Signal() 메서드도 없다. errors.Asos.Signal을 뽑으려는 시도는 실패한다. 시그널 종류를 구분해야 한다면 메시지 문자열을 보거나("terminated signal received" vs "interrupt signal received"), 애초에 signal.Notify로 채널을 직접 받아야 한다.
  2. errors.Is(cause, context.Canceled)는 항상 참이다. Is가 그렇게 만들어져 있다. 그래서 이것으로는 "시그널 때문"과 "부모가 취소" 를 구별할 수 없다.
  3. 구별하려면 값 비교를 쓴다. cause != context.Canceled. stop()으로 끝났을 때는 원인이 context.Canceled 그 자체이고, 시그널일 때는 다른 값이다.

측정한 네 경우다.

어떻게 끝났나context.Cause(ctx)
SIGTERMterminated signal received
SIGINTinterrupt signal received
부모 context 취소부모의 원인이 그대로
stop() 호출context canceled
:::

타임아웃 계층

타임아웃은 하나가 아니라 여러 겹이고, 겹끼리의 관계가 중요하다.

== 3. 타임아웃 계층 ==
30s 클라이언트(브라우저)
25s 로드밸런서
20s http.Server WriteTimeout
10s 핸들러 context
3s DB 쿼리
2s 외부 API 호출
바깥에서 안쪽으로 갈수록 짧아지는가: true
안쪽 먼저 끝났는가: true
안쪽 취소 원인: context deadline exceeded

규칙은 하나다. 바깥이 안쪽보다 넉넉해야 한다.

거꾸로 되어 있으면 안쪽 타임아웃이 영영 발동하지 않는다. DB 쿼리 타임아웃을 30초로 두고 로드밸런서를 25초로 두면, 사용자는 항상 25초를 기다린 뒤 502를 받고 서버 쪽 로그에는 아무 에러도 안 남는다. "왜 로그에 없는 장애가 보고되는가"의 흔한 원인이다.

context.WithTimeout은 이 계층을 자동으로 지켜 준다. 부모보다 긴 마감을 걸어도 부모가 먼저 끝나면 자식도 끝난다. 그래도 값 자체를 제대로 잡아 두는 편이, "어느 계층에서 끊겼는지"가 에러 메시지로 구분되므로 진단에 좋다.

12-3의 설정 구조체에 ReadTimeout, WriteTimeout, ShutdownGrace가 있었던 것이 이 계층의 일부다. 값들의 관계를 Validate에서 확인하게 만들 수도 있다.

커넥션 풀 고갈이 타임아웃으로 위장한다 (11-2의 숙제)

11-2에서 db.Stats()WaitCountWaitDuration을 지표로 내보내야 한다고 했다. 왜 그런지가 여기 있다.

examples/12-production/08-production-readiness/main.go
// poolExhaustion은 11-2 연습문제 1이 남긴 질문에 답한다.
//
// 커넥션이 하나뿐인 풀에서 그 하나를 잡고 있으면, 다음 쿼리는
// 커넥션을 기다린다. context에 마감이 있으면 그 마감에 걸린다.
func poolExhaustion() {
examples/12-production/08-production-readiness/main.go
// 하나뿐인 커넥션을 트랜잭션이 붙잡는다.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Fatal(err)
}

// 그 상태에서 100ms 마감을 건 쿼리를 시도한다.
waitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()

start := db.Stats()
_, qerr := db.ExecContext(waitCtx, `INSERT INTO t (id) VALUES (1)`)
== 4. 커넥션 풀 고갈은 타임아웃으로 드러난다 ==
에러: context deadline exceeded
DeadlineExceeded인가: true
WaitCount 증가: 1
MaxOpenConnections=1 InUse=1
이 에러는 DB가 느린 것이 아니라 풀이 모자란 것이다.
둘을 구별하려면 WaitCount와 WaitDuration을 지표로 내보내야 한다.

에러가 context deadline exceeded다. 쿼리는 시작조차 하지 않았는데, 에러만 보면 "DB가 느리다"로 읽힌다. 실제 원인은 커넥션을 기다리다 마감에 걸린 것이다.

이 둘은 대응이 정반대다.

  • DB가 느린 것이라면 → 쿼리를 고치거나 인덱스를 만든다(11-5).
  • 풀이 모자란 것이라면 → SetMaxOpenConns를 늘리거나, 커넥션을 오래 잡는 코드를 찾는다. 풀을 늘리면 DB 쪽 최대 커넥션 수에 걸릴 수 있으므로 함께 봐야 한다.

구별하는 유일한 방법이 db.Stats()다. WaitCount가 증가하고 있으면 풀 문제다. 12-4의 runtime/metrics와 같은 수집기에 붙여 두면, "느리다"는 신고를 받았을 때 그래프 두 개로 원인을 가른다.

:::tip 커넥션을 오래 잡는 코드

  • 트랜잭션 안에서 외부 API를 호출한다 (11-5에서 경고했다)
  • rows.Close()를 빠뜨린다 — defer rows.Close()는 습관이 아니라 필수다
  • 트랜잭션을 열어 두고 사용자 입력을 기다린다
  • SetConnMaxLifetime이 없어 죽은 커넥션이 풀에 남아 있다 :::

트랜잭션 아웃박스 (11-7의 숙제)

11-7 연습문제 3이 이름 없이 가리켰던 패턴이다. 문제부터 보자.

tx.Commit() // DB에 주문이 저장됐다
queue.Publish(event) // ← 여기서 프로세스가 죽으면?

주문은 있는데 이벤트는 없다. 순서를 바꾸면 이벤트는 갔는데 주문이 없을 수 있다. 서로 다른 두 시스템에 원자적으로 쓸 방법이 없다는 것이 근본 문제다.

아웃박스는 문제를 비껴간다. 이벤트를 같은 DB의 테이블에 같은 트랜잭션으로 쓴다.

examples/12-production/08-production-readiness/outbox/outbox.go
// Enqueue는 이벤트를 아웃박스에 넣는다.
//
// 첫 인자가 *sql.Tx인 것이 이 패턴의 전부다. *sql.DB를 받게 만들면
// 호출자가 트랜잭션 밖에서 부를 수 있고, 그러면 아웃박스를 쓰는
// 의미가 사라진다. 시그니처가 올바른 사용법을 강제한다.
func Enqueue(ctx context.Context, tx *sql.Tx, now time.Time, topic, payload string) error {

11-7에서 TxManager.WithinTx가 콜백에 자기만의 Repository를 넘겨 실수를 어렵게 만들었던 것과 같은 발상이다. 시그니처가 정책을 표현한다.

발행은 별도의 릴레이가 한다.

examples/12-production/08-production-readiness/outbox/outbox.go
// Once는 미발행 메시지를 한 묶음 발행한다. 발행한 개수를 돌려준다.
//
// 순서가 중요하다. (1) 읽고 (2) 발행하고 (3) 표시한다.
// 2와 3 사이에서 죽으면 같은 메시지가 다시 발행된다 — 최소 한 번이다.
// 순서를 (1)(3)(2)로 바꾸면 최대 한 번이 되는데, 그쪽은 이벤트가
// 아예 사라질 수 있어서 대개 더 나쁘다.
func (r *Relay) Once(ctx context.Context) (int, error) {
== 5. 트랜잭션 아웃박스 ==
주문 3건 중 1건이 롤백됐다. 대기 중인 이벤트: 2개
롤백된 주문의 이벤트는 애초에 남지 않았다 — 같은 트랜잭션이기 때문이다.
브로커 장애 시 발행: 0개, 에러: true
아직 대기: 2개 (사라지지 않았다)
복구 후 발행: 2개 [order.placed:sohee order.placed:jiwoo]
남은 대기: 0개

세 가지가 보인다.

롤백된 주문의 이벤트는 존재한 적이 없다. 별도 큐로는 얻을 수 없는 성질이다.

브로커가 죽어도 이벤트가 사라지지 않는다. 표시하지 않았으므로 그대로 남아 있고, 브로커가 돌아오면 릴레이가 알아서 비운다.

최소 한 번(at-least-once)이다. 발행 후 표시 전에 죽으면 중복 발행된다. 소비자가 멱등해야 한다는 요구가 이 패턴의 대가다.

이 성질들을 테스트가 못 박고 있다.

examples/12-production/08-production-readiness/outbox/outbox_test.go
// TestRollbackDropsTheEvent가 이 패턴의 존재 이유다.
//
// 주문이 롤백되면 이벤트도 함께 사라진다. 둘이 같은 트랜잭션이기
// 때문이고, 별도의 메시지 큐로는 얻을 수 없는 성질이다.
func TestRollbackDropsTheEvent(t *testing.T) {

감시할 숫자도 하나 정해져 있다.

examples/12-production/08-production-readiness/outbox/outbox.go
// Pending은 아직 발행되지 않은 메시지 수다. 지표로 내보낼 값이다.
//
// 이 값이 계속 늘어난다면 릴레이가 죽었거나 발행 대상이 막힌 것이다.
// 아웃박스를 쓴다면 반드시 감시해야 하는 하나의 숫자다.
func Pending(ctx context.Context, db *sql.DB) (int, error) {

:::note 아웃박스를 언제 쓰는가 공짜가 아니다. 테이블 하나, 릴레이 프로세스 하나, 정리 작업 하나가 늘고, 쓰기마다 INSERT가 하나 더 붙는다.

필요한 경우: 이벤트가 유실되면 돈이나 신뢰가 걸리는 것 — 결제 완료, 주문 확정, 계정 생성.

과한 경우: 유실돼도 되는 것 — 조회수 집계, 추천 갱신, 캐시 무효화 알림. 이런 것은 그냥 커밋 후에 발행하고, 실패하면 로그만 남긴다. :::

CI 파이프라인

지금까지의 모든 검사를 자동화한다. 순서에 이유가 있다.

examples/12-production/08-production-readiness/ci.yml
# 순서에 이유가 있다. 빠르고 값싼 검사를 먼저 두어, 포맷이 틀린
# 커밋이 5분짜리 테스트를 기다리지 않고 30초 만에 실패하게 한다.
name: ci
examples/12-production/08-production-readiness/ci.yml
- uses: actions/setup-go@v5
with:
# go.mod의 go 지시자를 그대로 쓴다. 버전을 두 곳에 적으면
# 반드시 어긋난다.
go-version-file: examples/12-production/go.mod
cache-dependency-path: examples/12-production/go.sum

go-version-file이 이 워크플로에서 가장 실수하기 쉬운 부분의 답이다. go-version: "1.26"처럼 적어 두면 go.mod를 올릴 때 CI를 같이 올리는 것을 잊게 되고, 그러면 로컬과 CI가 다른 컴파일러로 빌드하게 된다.

빠뜨리기 쉬운 스텝이 둘 더 있다.

examples/12-production/08-production-readiness/ci.yml
# 2. go.mod/go.sum이 최신인지.
- name: go mod tidy 확인
working-directory: examples/12-production
run: |
go mod tidy
git diff --exit-code go.mod go.sum
examples/12-production/08-production-readiness/ci.yml
# 4. 빌드 태그가 걸린 코드도 컴파일되는지 확인한다.
# 태그 뒤에 숨은 코드는 CI가 보지 않으면 조용히 썩는다.
- name: 빌드 태그 코드 vet
working-directory: examples/12-production
run: go vet -tags pprofserver ./...

빌드 태그 뒤의 코드는 go build ./...에 잡히지 않는다. 이 파트의 pprofserver, 파트 11의 dupdriver, 12-5의 !race 파일이 전부 그렇다. 태그마다 한 번씩 컴파일해 보지 않으면 몇 달 뒤 쓰려고 할 때 안 돌아간다.

테스트를 두 번 도는 것에도 이유가 있다.

examples/12-production/08-production-readiness/ci.yml
# 6. 테스트 + 커버리지. race 없이 한 번.
# 할당 횟수를 단언하는 !race 테스트가 여기서만 돈다.
- name: 테스트
working-directory: examples/12-production
run: go test -count=1 -coverprofile=cover.out ./...

# 7. 레이스 검출기. 느리므로 마지막에.
- name: 레이스 검출
working-directory: examples/12-production
run: go test -race -count=1 ./...

12-5에서 본 그 이유다. -race는 할당 횟수를 바꾸므로 AllocsPerRun을 단언하는 테스트는 -race 없는 실행에서만 돈다.

-count=1은 테스트 캐시를 끈다. CI에서는 캐시가 "통과했다"는 거짓 신호를 줄 수 있다.

전체를 묶으면

main이 하는 일의 순서다.

  1. config.Load — 설정을 읽고 검증한다. 실패하면 여기서 종료한다 (12-3)
  2. applog.New + slog.SetDefault — 로거를 세운다 (12-2)
  3. 시작 로그 한 줄: buildinfo.Short()와 해석된 설정 (12-6, 12-3)
  4. DB 연결, 풀 설정, 마이그레이션 (11장)
  5. health.Registry에 검사 등록
  6. 서비스 서버(:8080)와 관리 서버(:6060 — pprof, /healthz, /readyz, 로그 레벨)
  7. 백그라운드 워커와 아웃박스 릴레이를 ctx와 함께 띄운다
  8. signal.NotifyContext로 종료를 기다린다
  9. Drain() → 대기 → Shutdown → 워커 정리 → DB 닫기

이 형태가 파트 13의 캡스톤에서 그대로 쓰인다.

흔한 실수

라이브니스에 DB 검사를 넣는다. 이 챕터의 첫 경고다.

레디니스를 안 만든다. 배포할 때마다 짧은 502가 난다.

Shutdown 전에 배수하지 않는다. 위와 같은 증상이다.

Shutdown에 타임아웃을 안 준다. 느린 요청 하나가 종료를 영원히 막는다. context.WithTimeout을 꼭 준다.

타임아웃 계층이 거꾸로다. 안쪽 타임아웃이 발동하지 않아 로그에 아무것도 안 남는다.

context deadline exceeded를 전부 "DB가 느리다"로 읽는다. db.Stats()를 본다.

커밋 후에 이벤트를 발행하고 그것으로 충분하다고 생각한다. 프로세스는 아무 때나 죽는다. 유실되면 안 되는 이벤트라면 아웃박스다.

아웃박스의 Pending을 감시하지 않는다. 릴레이가 조용히 죽으면 아무도 모른다.

CI에 빌드 태그 코드를 안 넣는다. 조용히 썩는다.

CI에서 -count=1을 빼먹는다. 캐시된 결과가 통과로 보고된다.

정리

  • 라이브니스는 프로세스, 레디니스는 의존성. 라이브니스에 DB를 넣으면 재시작 폭풍이다.
  • 레디니스 검사는 각자 타임아웃을 갖고 병렬로 돈다.
  • 종료는 배수 → 대기 → Shutdown → 워커 → DB 순서.
  • signal.NotifyContext의 원인은 문자열 에러다. os.Signal을 꺼낼 수 없고, errors.Is(cause, context.Canceled)는 항상 참이므로 값 비교로 구별한다.
  • 타임아웃은 바깥에서 안쪽으로 짧아진다.
  • context deadline exceeded는 커넥션 풀 고갈일 수 있다. db.Stats().WaitCount가 답한다.
  • 유실되면 안 되는 이벤트는 트랜잭션 아웃박스로. 최소 한 번이므로 소비자는 멱등해야 한다.
  • CI는 값싼 검사부터. 빌드 태그 코드와 -race/비-race 두 실행을 모두 넣는다.

연습문제

  1. health.Registry에 "선택적 의존성" 개념을 추가해 보자. 캐시가 죽어도 서비스는 (느리지만) 동작한다면, 그것은 레디니스 실패여야 하는가? 등록할 때 Required bool을 받게 하고 필수가 아닌 검사는 결과에 기록만 하도록 바꿔 보자. 그리고 이 구분이 위험해지는 경우를 하나 생각해 보자.

  2. 아웃박스 릴레이를 두 개 띄우면 어떻게 되는가? 같은 메시지를 둘이 동시에 집어 중복 발행할 수 있다. SELECT ... FOR UPDATE SKIP LOCKED(PostgreSQL)나 상태 컬럼 + 조건부 UPDATE로 막는 방법을 찾아보자. SQLite에서는 왜 이 문제가 덜 심각한가?

  3. 12-1부터 12-8까지의 조각을 하나의 main으로 합쳐 보자. configapplogbuildinfo → DB → health → 서비스/관리 서버 두 개 → 아웃박스 릴레이 → signal.NotifyContext → 배수 후 종료. 다 하고 나면 그것이 파트 13에서 만들 서비스의 뼈대다. 어느 조각이 가장 손이 많이 갔는지, 그것을 라이브러리로 뽑아낼 수 있는지 생각해 보자.