이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

Java 개발자가 Go를 배울 때 가장 큰 패러다임 전환이 필요한 부분이 에러 처리다. try-catch에 익숙한 우리에게 if err != nil의 반복은 처음엔 원시적으로 느껴진다. 하지만 이 방식에는 명확한 철학이 있다. 이번 편에서 그 철학을 이해하고, Go 방식의 에러 처리에 익숙해져 보자.


1. Java의 Exception vs Go의 error#

근본적인 차이#

Java: 예외는 “특별한 상황"이다. 정상 흐름과 분리되어 throw/catch로 처리된다.

public User findUser(String id) throws UserNotFoundException {
    User user = repository.findById(id);
    if (user == null) {
        throw new UserNotFoundException("User not found: " + id);
    }
    return user;
}

// 호출 측
try {
    User user = findUser("123");
    processUser(user);
} catch (UserNotFoundException e) {
    logger.error("User lookup failed", e);
}

Go: 에러는 “그냥 값"이다. 함수의 반환값으로 명시적으로 전달된다.

func findUser(id string) (User, error) {
    user, err := repository.FindByID(id)
    if err != nil {
        return User{}, fmt.Errorf("user not found: %s: %w", id, err)
    }
    return user, nil
}

// 호출 측
user, err := findUser("123")
if err != nil {
    log.Printf("User lookup failed: %v", err)
    return
}
processUser(user)

Checked Exception이 없는 세상#

Java는 Checked Exception과 Unchecked Exception을 구분한다.

// Checked: 컴파일러가 처리를 강제
public void readFile() throws IOException { ... }

// Unchecked: 처리 선택적
public void divide(int a, int b) {
    if (b == 0) throw new IllegalArgumentException();
}

Go는 이 구분이 없다. 모든 에러는 그냥 error 인터페이스를 구현한 값이다.

// error 인터페이스는 매우 단순
type error interface {
    Error() string
}

함수 시그니처를 보면 에러 가능성을 알 수 있지만, 호출자가 에러를 무시해도 컴파일러가 강제하지 않는다.

// 에러를 무시할 수 있음 (권장하지 않음)
user, _ := findUser("123")

// 반환값 자체를 무시할 수도 있음
findUser("123")

if err != nil 패턴에 적응하기#

Go 코드에서 가장 많이 보는 패턴이다.

file, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("failed to open config: %w", err)
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
    return fmt.Errorf("failed to read config: %w", err)
}

var config Config
if err := json.Unmarshal(data, &config); err != nil {
    return fmt.Errorf("failed to parse config: %w", err)
}

처음엔 번거롭지만, 시간이 지나면 장점을 느끼게 된다:

  1. 명시성: 에러가 어디서 발생하고 어떻게 전파되는지 코드만 봐도 알 수 있다.
  2. 지역성: 에러 처리가 에러 발생 지점 바로 옆에 있다.
  3. 선택의 명확함: 에러를 처리하거나, 반환하거나, 무시하는 결정이 코드에 드러난다.

에러 처리 팁:

// 에러가 발생하면 즉시 반환하고, 성공 케이스를 들여쓰기 없이 유지
func process() error {
    if err := step1(); err != nil {
        return err
    }
    
    if err := step2(); err != nil {
        return err
    }
    
    if err := step3(); err != nil {
        return err
    }
    
    return nil
}

이 “early return” 패턴은 Go의 관용적 스타일이다. 들여쓰기 수준을 낮게 유지하고 “happy path"를 명확히 한다.


2. 에러 래핑과 체이닝#

에러에 컨텍스트 추가하기#

Go 1.13부터 %w 포맷 지시자로 에러를 래핑할 수 있다.

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // 원본 에러를 래핑하며 컨텍스트 추가
        return nil, fmt.Errorf("loadConfig: failed to read %s: %w", path, err)
    }
    
    var config Config
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, fmt.Errorf("loadConfig: failed to parse JSON: %w", err)
    }
    
    return &config, nil
}

에러 메시지가 체이닝되어 디버깅이 쉬워진다.

loadConfig: failed to read config.json: open config.json: no such file or directory

errors.Is와 errors.As#

래핑된 에러 체인에서 특정 에러를 찾을 때 사용한다.

errors.Is: 특정 에러와 동일한지 확인

import "errors"

var ErrNotFound = errors.New("not found")

func findUser(id string) (User, error) {
    // ...
    return User{}, fmt.Errorf("findUser: %w", ErrNotFound)
}

// 호출 측
user, err := findUser("123")
if errors.Is(err, ErrNotFound) {
    // 사용자가 없는 경우 처리
    log.Println("User does not exist")
} else if err != nil {
    // 다른 에러
    log.Printf("Unexpected error: %v", err)
}

errors.As: 특정 타입의 에러로 변환

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

func validateUser(user User) error {
    if user.Email == "" {
        return &ValidationError{Field: "email", Message: "required"}
    }
    return nil
}

// 호출 측
err := validateUser(user)
var valErr *ValidationError
if errors.As(err, &valErr) {
    log.Printf("Validation failed: field=%s, msg=%s", valErr.Field, valErr.Message)
}

Java 비교:

// Java의 instanceof / catch 특정 예외
try {
    validateUser(user);
} catch (ValidationException e) {
    log.error("Validation failed: " + e.getField());
}

3. 커스텀 에러 타입 정의#

간단한 센티넬 에러#

패키지 레벨에서 에러 상수를 정의한다.

package user

import "errors"

var (
    ErrNotFound     = errors.New("user not found")
    ErrDuplicate    = errors.New("user already exists")
    ErrInvalidInput = errors.New("invalid input")
)

func Find(id string) (*User, error) {
    user := db.Query(id)
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

구조체 에러 타입#

더 많은 정보를 담으려면 구조체를 사용한다.

type QueryError struct {
    Query   string
    Err     error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query failed [%s]: %v", e.Query, e.Err)
}

// Unwrap 메서드로 에러 체이닝 지원
func (e *QueryError) Unwrap() error {
    return e.Err
}

// 사용
func executeQuery(q string) error {
    result, err := db.Exec(q)
    if err != nil {
        return &QueryError{Query: q, Err: err}
    }
    return nil
}

에러 정의 패턴 비교#

Java:

public class UserNotFoundException extends RuntimeException {
    private final String userId;
    
    public UserNotFoundException(String userId) {
        super("User not found: " + userId);
        this.userId = userId;
    }
    
    public String getUserId() {
        return userId;
    }
}

Go:

type UserNotFoundError struct {
    UserID string
}

func (e *UserNotFoundError) Error() string {
    return fmt.Sprintf("user not found: %s", e.UserID)
}

// 사용
return &UserNotFoundError{UserID: id}

4. panic과 recover#

panic이란?#

panic은 프로그램을 중단시키는 런타임 에러다. Java의 RuntimeException과 비슷하지만, 훨씬 드물게 사용해야 한다.

func mustLoadConfig(path string) *Config {
    config, err := loadConfig(path)
    if err != nil {
        panic(fmt.Sprintf("failed to load config: %v", err))
    }
    return config
}

언제 panic을 사용하는가?#

사용해야 하는 경우:

  1. 프로그래머 오류: nil 포인터 역참조, 배열 인덱스 초과 등
  2. 복구 불가능한 상황: 초기화 실패로 프로그램 실행이 무의미한 경우
  3. Must 패턴: 에러가 발생하면 안 되는 상황을 명시적으로 표현
// 표준 라이브러리의 Must 패턴 예시
var re = regexp.MustCompile(`^[a-z]+$`)  // 컴파일 실패 시 panic

사용하지 말아야 하는 경우:

  • 일반적인 에러 처리 (파일 없음, 네트워크 오류 등)
  • 외부 입력 검증 실패
  • 복구 가능한 상황

recover로 panic 복구#

recover는 panic을 잡아서 프로그램을 계속 실행할 수 있게 한다. 반드시 defer 내에서 호출해야 한다.

func safeExecute(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    
    fn()
    return nil
}

// 사용
err := safeExecute(func() {
    panic("something went wrong")
})
// err: "panic recovered: something went wrong"

주요 사용처:

  • HTTP 핸들러에서 하나의 요청 실패가 서버 전체를 중단시키지 않도록
  • 테스트 프레임워크에서 panic을 잡아 테스트 실패로 처리
// 웹 서버 미들웨어 예시
func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v", err)
                http.Error(w, "Internal Server Error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

5. Null 안전성#

Java의 null vs Go의 nil#

Java:

String name = null;
name.length();  // NullPointerException!

Go:

var s string   // zero value는 "" (빈 문자열)
len(s)         // 0, 안전함

var p *string  // nil 포인터
*p             // panic: nil pointer dereference

중요한 차이:

  • Go의 string, int, bool 등 기본 타입은 nil이 될 수 없다.
  • 포인터, slice, map, channel, interface만 nil이 될 수 있다.

Optional vs Go의 접근법#

Java (Optional):

public Optional<User> findUser(String id) {
    User user = repository.findById(id);
    return Optional.ofNullable(user);
}

// 사용
findUser("123")
    .map(User::getName)
    .orElse("Unknown");

Go (포인터 + 에러):

func findUser(id string) (*User, error) {
    user := repository.FindByID(id)
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

// 사용
user, err := findUser("123")
if err != nil {
    name = "Unknown"
} else {
    name = user.Name
}

Go (zero value 활용):

존재 여부만 중요하고 에러 구분이 불필요할 때:

func findUser(id string) *User {
    return repository.FindByID(id)  // 없으면 nil
}

// 사용
if user := findUser("123"); user != nil {
    fmt.Println(user.Name)
}

포인터와 nil 처리 전략#

전략 1: 빠른 반환 (Guard Clause)

func processUser(user *User) error {
    if user == nil {
        return errors.New("user is nil")
    }
    
    // 이 시점부터 user는 안전하게 사용 가능
    fmt.Println(user.Name)
    return nil
}

전략 2: nil-safe 메서드

type User struct {
    Name string
}

func (u *User) GetName() string {
    if u == nil {
        return ""
    }
    return u.Name
}

// 사용
var user *User  // nil
name := user.GetName()  // 안전하게 "" 반환

전략 3: zero value 설계

struct를 설계할 때 zero value가 유용하도록 만든다.

// 좋은 예: zero value가 바로 사용 가능
type Counter struct {
    count int
}

func (c *Counter) Increment() {
    c.count++
}

var counter Counter
counter.Increment()  // OK, count는 0에서 시작

// 나쁜 예: zero value가 무효
type Server struct {
    listener net.Listener  // nil이면 사용 불가
}

slice, map의 nil 처리#

slice:

var s []int          // nil slice
len(s)               // 0
cap(s)               // 0
s = append(s, 1)     // OK! append는 nil slice에 안전

// nil과 empty slice는 보통 동일하게 취급
s1 := []int(nil)     // nil
s2 := []int{}        // empty, not nil
// 둘 다 len=0, range 순회 가능

map:

var m map[string]int  // nil map
m["key"]              // OK, zero value(0) 반환
m["key"] = 1          // panic! nil map에 쓰기 불가

// 항상 make로 초기화하거나 리터럴 사용
m = make(map[string]int)
m = map[string]int{}

실용적인 패턴:

type Cache struct {
    data map[string]string
}

func (c *Cache) Get(key string) string {
    if c.data == nil {
        return ""
    }
    return c.data[key]
}

func (c *Cache) Set(key, value string) {
    if c.data == nil {
        c.data = make(map[string]string)
    }
    c.data[key] = value
}

6. 실전 에러 처리 패턴#

패턴 1: 에러 감싸기 (Error Wrapping)#

func (s *UserService) CreateUser(req CreateUserRequest) (*User, error) {
    if err := req.Validate(); err != nil {
        return nil, fmt.Errorf("CreateUser: validation failed: %w", err)
    }
    
    user, err := s.repo.Save(req.ToUser())
    if err != nil {
        return nil, fmt.Errorf("CreateUser: failed to save: %w", err)
    }
    
    return user, nil
}

패턴 2: 에러 집계 (Multiple Errors)#

여러 에러를 모아서 처리해야 할 때:

func validateUser(u User) error {
    var errs []error
    
    if u.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if u.Email == "" {
        errs = append(errs, errors.New("email is required"))
    }
    if u.Age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    
    if len(errs) > 0 {
        return errors.Join(errs...)  // Go 1.20+
    }
    return nil
}

패턴 3: 에러 핸들링 헬퍼#

반복을 줄이는 헬퍼 함수:

func must[T any](val T, err error) T {
    if err != nil {
        panic(err)
    }
    return val
}

// 사용 (초기화 시에만)
var config = must(loadConfig("config.json"))

패턴 4: 지연 에러 처리 (defer)#

리소스 정리 시 에러를 놓치지 않기:

func writeFile(path string, data []byte) (err error) {
    file, err := os.Create(path)
    if err != nil {
        return err
    }
    
    defer func() {
        closeErr := file.Close()
        if err == nil {
            err = closeErr
        }
    }()
    
    _, err = file.Write(data)
    return err
}

요약: Java → Go 에러 처리 마인드셋 전환#

Java Go
throw new Exception() return fmt.Errorf(...)
try { ... } if err := ...; err != nil
catch (Exception e) if errors.Is(err, ...)
catch (MyException e) if errors.As(err, &myErr)
throw new RuntimeException() panic() (드물게)
finally defer
Optional<T> (*T, error) 또는 *T
@Nullable 포인터 사용

핵심 원칙:

  1. 에러는 값이다. 무시하지 말고 명시적으로 처리하라.
  2. 에러에 컨텍스트를 추가하라 (%w로 래핑).
  3. panic은 정말 복구 불가능한 상황에서만 사용하라.
  4. nil 체크를 습관화하고, zero value를 활용하라.