Java to Go: #3. 에러 처리와 Null 안전성
이 글은 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)
}
처음엔 번거롭지만, 시간이 지나면 장점을 느끼게 된다:
- 명시성: 에러가 어디서 발생하고 어떻게 전파되는지 코드만 봐도 알 수 있다.
- 지역성: 에러 처리가 에러 발생 지점 바로 옆에 있다.
- 선택의 명확함: 에러를 처리하거나, 반환하거나, 무시하는 결정이 코드에 드러난다.
에러 처리 팁:
// 에러가 발생하면 즉시 반환하고, 성공 케이스를 들여쓰기 없이 유지
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을 사용하는가?#
사용해야 하는 경우:
- 프로그래머 오류: nil 포인터 역참조, 배열 인덱스 초과 등
- 복구 불가능한 상황: 초기화 실패로 프로그램 실행이 무의미한 경우
- 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 |
포인터 사용 |
핵심 원칙:
- 에러는 값이다. 무시하지 말고 명시적으로 처리하라.
- 에러에 컨텍스트를 추가하라 (
%w로 래핑). - panic은 정말 복구 불가능한 상황에서만 사용하라.
- nil 체크를 습관화하고, zero value를 활용하라.