[Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라
이 글의 핵심
C++ try-catch 대신 Go의 명시적 에러 핸들링을 배웁니다. 다중 반환값, if err != nil 패턴, defer로 자원 해제, panic/recover의 올바른 사용법까지 실전 예제로 학습합니다.
시리즈 안내
📚 Go 2주 완성 시리즈 #05 | 전체 목차 보기
이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 8~9 내용입니다.
이전: #04 인터페이스 ← | → 다음: #06 고루틴·채널
💡 초보자를 위한 한 줄: Go에는
try-catch가 없습니다. 에러는error타입으로 반환하고if err != nil로 즉시 처리합니다.defer는 “함수 끝날 때 실행”으로 파일 닫기/락 해제에 씁니다.panic은 C++의abort()처럼 “프로그램 죽이기”이므로 웬만하면 안 씁니다.
들어가며: “try-catch 없이 어떻게 에러 처리를 하죠?”
C++에서는 try-catch로 에러를 한 곳에서 몰아 처리했습니다:
try {
openFile();
processData();
saveResult();
} catch (const std::exception& e) {
// 어디서 에러가 났는지는 스택 추적으로 확인
}
편리하지만, 코드만 봐서는 어디서 예외가 발생할지 알기 어렵습니다.
Go는 완전히 다릅니다. 예외가 없습니다. 대신 에러를 값으로 반환하고, 호출한 곳에서 즉시 처리합니다:
file, err := os.Open("data.txt")
if err != nil {
return fmt.Errorf("파일 열기 실패: %w", err)
}
defer file.Close()
처음에는 if err != nil이 계속 반복되어 번거로워 보이지만, 코드의 흐름이 명확해지고 “예상치 못한 예외로 프로그램이 죽는 일”이 줄어듭니다.
이 글에서 배울 내용:
- 다중 반환값으로 에러 전달
if err != nil패턴defer로 자원 해제 보장errors.New·fmt.Errorf·%w, Sentinel,errors.Is/Aspanic과recover의 올바른 사용
C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.
실무에서의 체감
C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다. 자주 언급되는 장점:
- 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉬운 편입니다.
- 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
- 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.
1. 에러를 값으로: error 타입
C++ vs Go: 에러 처리 방식
// C++: 예외로 에러 처리
#include <iostream>
#include <fstream>
#include <stdexcept>
void readFile(const std::string& path) {
std::ifstream file(path);
if (!file) {
throw std::runtime_error("Failed to open file");
}
std::string line;
while (std::getline(file, line)) {
if (line.empty()) {
throw std::invalid_argument("Empty line found");
}
std::cout << line << "\n";
}
}
int main() {
try {
readFile("data.txt");
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
return 1;
}
return 0;
}
// Go: error 반환으로 에러 처리
package main
import (
"bufio"
"fmt"
"os"
)
func readFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
return fmt.Errorf("empty line found")
}
fmt.Println(line)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan error: %w", err)
}
return nil
}
func main() {
if err := readFile("data.txt"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
핵심 차이점:
- Go는 예외를 던지지 않습니다
- 에러는 반환값으로 전달됩니다
- 호출자는 반드시 에러를 확인해야 합니다
error 인터페이스
// Go: error는 인터페이스
type error interface {
Error() string
}
// 커스텀 에러 타입
type FileError struct {
Path string
Op string
Err error
}
func (e *FileError) Error() string {
return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
}
// 사용
func openFile(path string) error {
_, err := os.Open(path)
if err != nil {
return &FileError{
Path: path,
Op: "open",
Err: err,
}
}
return nil
}
2. if err != nil 패턴
기본 패턴
// Go: 가장 흔한 패턴
func doSomething() error {
result, err := someOperation()
if err != nil {
return err // 에러 전파
}
// result 사용
return nil
}
에러 처리 변형
// Go: 다양한 에러 처리 패턴
package main
import (
"fmt"
"os"
)
// 1. 즉시 반환
func pattern1() error {
f, err := os.Open("file.txt")
if err != nil {
return err
}
defer f.Close()
// ...
return nil
}
// 2. 로깅 후 반환
func pattern2() error {
f, err := os.Open("file.txt")
if err != nil {
fmt.Println("Failed to open file:", err)
return err
}
defer f.Close()
// ...
return nil
}
// 3. 기본값 사용
func pattern3() int {
data, err := fetchData()
if err != nil {
return 0 // 기본값
}
return data
}
// 4. 재시도
func pattern4() error {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
err := tryOperation()
if err == nil {
return nil
}
fmt.Printf("Attempt %d failed: %v\n", i+1, err)
}
return fmt.Errorf("failed after %d retries", maxRetries)
}
3. defer: RAII의 대체재
C++ RAII vs Go defer
// C++: RAII로 자동 정리
void processFile() {
std::ifstream file("data.txt");
std::lock_guard<std::mutex> lock(mtx);
// 작업...
// 스코프 종료 시 자동으로:
// 1. lock 해제 (lock_guard 소멸자)
// 2. file 닫기 (ifstream 소멸자)
}
// Go: defer로 명시적 정리
func processFile() error {
file, err := os.Open("data.txt")
if err != nil {
return err
}
defer file.Close() // 함수 종료 시 실행
mu.Lock()
defer mu.Unlock() // 함수 종료 시 실행
// 작업...
return nil
// 여기서 defer들이 역순으로 실행:
// 1. mu.Unlock()
// 2. file.Close()
}
defer의 실행 순서 (LIFO)
// Go: defer는 LIFO (Last In First Out)
package main
import "fmt"
func example() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
fmt.Println("함수 본문")
}
func main() {
example()
}
// 출력:
// 함수 본문
// 3
// 2
// 1
defer의 평가 시점
// Go: defer의 인자는 즉시 평가됨
package main
import "fmt"
func example() {
x := 1
defer fmt.Println("Deferred:", x) // x=1이 즉시 평가됨
x = 2
fmt.Println("Current:", x)
}
func main() {
example()
}
// 출력:
// Current: 2
// Deferred: 1 (defer 등록 시점의 값)
함수 호출로 지연 평가:
// Go: 함수로 감싸서 지연 평가
func example() {
x := 1
defer func() {
fmt.Println("Deferred:", x) // 실행 시점의 x 사용
}()
x = 2
fmt.Println("Current:", x)
}
// 출력:
// Current: 2
// Deferred: 2 (실행 시점의 값)
defer 활용 패턴
// Go: defer 실전 패턴
package main
import (
"fmt"
"time"
)
// 1. 실행 시간 측정
func measureTime(name string) func() {
start := time.Now()
return func() {
fmt.Printf("%s took %v\n", name, time.Since(start))
}
}
func slowFunction() {
defer measureTime("slowFunction")() // 함수 반환값(함수)를 defer
time.Sleep(100 * time.Millisecond)
}
// 2. 락 보호
func criticalSection() {
mu.Lock()
defer mu.Unlock()
// 여러 return 경로가 있어도 unlock 보장
if condition1 {
return
}
if condition2 {
return
}
// ...
}
// 3. 트랜잭션 롤백
func transaction() error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// 작업...
if err := doWork(tx); err != nil {
return err // defer에서 Rollback 실행
}
return tx.Commit()
}
4. 에러 래핑과 언래핑
에러 래핑 (Error Wrapping)
// Go: 에러 래핑으로 컨텍스트 추가
package main
import (
"fmt"
"os"
)
func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// %w로 원본 에러 래핑
return fmt.Errorf("read config from %s: %w", path, err)
}
if len(data) == 0 {
return fmt.Errorf("config file %s is empty", path)
}
return nil
}
func loadSettings() error {
if err := readConfig("config.json"); err != nil {
return fmt.Errorf("load settings: %w", err)
}
return nil
}
func main() {
if err := loadSettings(); err != nil {
fmt.Println("Error:", err)
// 출력: Error: load settings: read config from config.json: open config.json: no such file or directory
}
}
errors.Is와 errors.As
// Go: 특정 에러 확인
package main
import (
"errors"
"fmt"
"os"
)
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
)
func findUser(id int) error {
if id < 0 {
return ErrNotFound
}
return nil
}
func main() {
err := findUser(-1)
// errors.Is로 에러 확인
if errors.Is(err, ErrNotFound) {
fmt.Println("User not found")
}
}
// Go: errors.As로 타입 확인
package main
import (
"errors"
"fmt"
"os"
)
func openFile(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
return nil
}
func main() {
err := openFile("nonexistent.txt")
// errors.As로 특정 타입 추출
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Path error:")
fmt.Println(" Op:", pathErr.Op)
fmt.Println(" Path:", pathErr.Path)
fmt.Println(" Err:", pathErr.Err)
}
}
fmt.Errorf로 %w를 쓰면 에러 체인이 만들어져, 여러 겹 감싼 뒤에도 errors.Is(err, os.ErrNotExist)처럼 근본 원인을 찾을 수 있습니다. 반면 %v만 쓰면 문자열로만 붙고 Is/As가 따라가지 못합니다.
5. errors.New vs fmt.Errorf, Sentinel, 실전 래핑
errors.New vs fmt.Errorf
| API | 쓰는 때 | 메모 |
|---|---|---|
errors.New("msg") | 문맥 없는 고정 메시지의 단순 에러, Sentinel로 패키지 전역 변수에 두고 errors.Is로 비교 | 포맷 불가 — 문자열 하나 |
fmt.Errorf("format", args...) | 동적 값을 문자열에 넣을 때 | 포맷만 하고 끝내면 래핑 아님 |
fmt.Errorf("...: %w", err) | 원인 에러를 체인에 남길 때(Go 1.13+) | Unwrap 가능 → errors.Is/As가 안쪽까지 따라감 |
package example
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found") // Sentinel — 같은 변수로 Is 비교
func badUser(id int) error {
return fmt.Errorf("user %d not found", id) // OK이지만 Is(ErrNotFound)와 연결하려면 %w 필요
}
func goodUser(id int) error {
return fmt.Errorf("user %d: %w", id, ErrNotFound) // 체인에 ErrNotFound 포함
}
커스텀 에러 타입과 errors.As
errors.Is는 값이 같은지(Sentinel 또는Unwrap체인) 볼 때 쓰고,errors.As는 구체 타입으로 필드(예: HTTP 상태 코드, 에러 코드)를 꺼낼 때 씁니다.- 커스텀 타입은
Error() string만 구현해도 되고, 필요하면Unwrap() error를 추가해 한 겹 더 감쌀 수 있습니다.
package example
import "fmt"
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string {
return fmt.Sprintf("code=%d: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }
Sentinel 에러(전역 비교용) 실무 패턴
- 패키지 상단에
var ErrXxx = errors.New(...)로 선언해 의미를 고정합니다. - 호출부는 errors.Is(err, pkg.ErrNotFound)처럼 비교하고, 중간 함수들은 fmt.Errorf(“context: %w”, err)로 맥락만 덧붙입니다.
- 같은 문장 문자열을 여러 번
errors.New로 만들면Is가 실패합니다. 반드시 공유된 변수를 쓰세요.
에러 래핑 실전 체크리스트
- 감싸기: 실패 지점에서
return fmt.Errorf("작업 X: %w", err). - 판별: 바깥에서는
errors.Is/errors.As만 사용(문자열Contains비교는 최후 수단). - 로그: 최종적으로 사용자·운영에 남길 때 한 번만 상세 스택이 필요한지 팀 규칙으로 정합니다.
6. panic과 recover
panic: 복구 불가능한 오류
// C++: 예외 던지기
void divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("division by zero");
}
std::cout << a / b << "\n";
}
// Go: panic (일반적으로 지양)
func divide(a, b int) int {
if b == 0 {
panic("division by zero") // 프로그램 종료
}
return a / b
}
// ✅ 권장: error 반환
func divideWithError(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
recover: panic 복구
// Go: recover로 panic 복구
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
result = a / b // b=0이면 panic
return result, nil
}
func main() {
result, err := safeDivide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
panic/recover는 언제 쓰나
| 상황 | 권장 |
|---|---|
| 일반적인 실패(파일 없음, 네트워크 타임아웃, 잘못된 입력) | error 반환 |
프로그래밍 오류(불변식 위반, nil 역참조, 도달하면 안 되는 분기) | 패닉 가능 — 가능하면 테스트로 걸러냄 |
초기화 시 반드시 성공해야 하는 값(정규식 MustCompile, template.Must) | panic 허용되는 관용구 |
| HTTP 서버 등에서 한 요청만 격리 | recover는 고루틴 경계에서만 의미 있음 — 미들웨어·핸들러 상단 패턴(#06 이후와 연결) |
recover는 지연 함수(defer) 안에서만 동작합니다. “조용히 삼키기”보다는 로그 남기고 error로 변환하거나 요청 단위로 500을 반환하는 식이 일반적입니다. |
panic 사용 가이드
// ❌ 나쁜 예: 일반 에러에 panic 사용
func fetchUser(id int) *User {
user, err := db.Query(id)
if err != nil {
panic(err) // 나쁨!
}
return user
}
// ✅ 좋은 예: error 반환
func fetchUser(id int) (*User, error) {
user, err := db.Query(id)
if err != nil {
return nil, fmt.Errorf("fetch user %d: %w", id, err)
}
return user, nil
}
// ✅ panic이 적절한 경우: 프로그래밍 오류
func mustCompile(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
panic(fmt.Sprintf("invalid regex pattern: %s", pattern))
}
return re
}
// 초기화 시 사용 (프로그램 시작 시 실패해야 함)
var emailRegex = mustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)
7. 실습 과제
과제 1: 파일 복사 with defer
// Go: defer로 안전한 파일 복사
package main
import (
"fmt"
"io"
"os"
)
func copyFile(src, dst string) error {
// 원본 파일 열기
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer srcFile.Close() // 반드시 닫힘
// 대상 파일 생성
dstFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create destination: %w", err)
}
defer dstFile.Close() // 반드시 닫힘
// 복사
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("copy: %w", err)
}
return nil
}
func main() {
if err := copyFile("source.txt", "dest.txt"); err != nil {
fmt.Fprintf(os.Stderr, "Copy failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Copy successful")
}
과제 2: 커스텀 에러 타입
// Go: 커스텀 에러 타입
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Value string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error [%s=%s]: %s",
e.Field, e.Value, e.Message)
}
func validateEmail(email string) error {
if email == "" {
return &ValidationError{
Field: "email",
Value: email,
Message: "email is required",
}
}
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Value: email,
Message: "invalid email format",
}
}
return nil
}
func registerUser(email string) error {
if err := validateEmail(email); err != nil {
return fmt.Errorf("register user: %w", err)
}
// 등록 로직...
return nil
}
func main() {
err := registerUser("invalid")
// 타입 확인
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println("Validation failed:")
fmt.Println(" Field:", validationErr.Field)
fmt.Println(" Value:", validationErr.Value)
fmt.Println(" Message:", validationErr.Message)
}
}
과제 3: 에러 체인
// Go: 에러 체인 추적
package main
import (
"errors"
"fmt"
)
var (
ErrDatabase = errors.New("database error")
ErrConnection = errors.New("connection error")
)
func connectDB() error {
return ErrConnection
}
func queryUser(id int) error {
if err := connectDB(); err != nil {
return fmt.Errorf("query user %d: %w", id, err)
}
return nil
}
func getProfile(id int) error {
if err := queryUser(id); err != nil {
return fmt.Errorf("get profile: %w", err)
}
return nil
}
func main() {
err := getProfile(123)
fmt.Println("Error:", err)
// 출력: Error: get profile: query user 123: connection error
// 원본 에러 확인
if errors.Is(err, ErrConnection) {
fmt.Println("Root cause: connection error")
}
}
과제 4: defer로 실행 시간 측정
// Go: defer로 함수 실행 시간 측정
package main
import (
"fmt"
"time"
)
func trace(name string) func() {
start := time.Now()
fmt.Printf("Entering %s\n", name)
return func() {
fmt.Printf("Exiting %s (took %v)\n", name, time.Since(start))
}
}
func processData() {
defer trace("processData")()
fmt.Println("Processing...")
time.Sleep(100 * time.Millisecond)
fmt.Println("Done")
}
func main() {
processData()
}
// 출력:
// Entering processData
// Processing...
// Done
// Exiting processData (took 100ms)
정리: Day 8~9 학습 체크리스트
완료해야 할 항목
-
error타입과if err != nil패턴 숙지 - 다중 반환값으로 에러 전달
-
defer로 자원 해제 보장 (RAII 대체) - defer의 LIFO 실행 순서 이해
-
fmt.Errorf와%w로 에러 래핑 -
errors.New(Sentinel) vsfmt.Errorf·%w구분 -
errors.Is,errors.As로 에러 검사 -
panic/recover는 제한적으로만 사용 - 실습 과제 4개 완료
C++에서 Go로 전환 포인트
| C++ | Go | 비고 |
|---|---|---|
throw | return error | 명시적 |
try-catch | if err != nil | 매 호출마다 |
| RAII | defer | 함수 단위 |
| 예외 전파 | 에러 반환 체인 | 명확한 흐름 |
std::exception | error 인터페이스 | 더 간결 |
다음 단계 예고
Day 8~9에서는 Go의 에러 처리를 배웠습니다. 다음 글에서는 Go의 진짜 강점인 고루틴과 채널을 다룹니다. C++ 개발자가 Go에 열광하는 이유를 직접 체험하게 됩니다!
📚 시리즈 네비게이션
| 이전 글 | 목차 | 다음 글 |
|---|---|---|
| ← #04 인터페이스 | 📑 전체 목차 | #06 고루틴·채널 → |
| Go 2주 완성 시리즈: | ||
| 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료 |
한 줄 요약: Go는 예외 대신 명시적 에러 반환으로 코드 흐름을 명확하게 하고, defer로 자원 해제를 보장합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Go 2주 완성 #04] Day 7: 다형성의 재해석, 인터페이스 - 가상 함수 없이 다형성 구현하기
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
이 글에서 다루는 키워드 (관련 검색어)
Go error 처리, if err != nil, defer Go, panic recover, fmt.Errorf, C++ 예외 비교, 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++ try-catch 대신 Go의 명시적 에러 핸들링을 배웁니다. 다중 반환값, if err != nil 패턴, defer로 자원 해제, panic/recover의 올바른 사용법까지 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
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주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(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주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 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 순서를 권장합니다.