Go의 panic()은 프로그램의 정상적인 흐름을 이어갈 수 없는 심각한 오류가 발생했을 때만 사용해야 한다. 일반적인 오류 처리는 panic이 아닌 error를 반환하여 처리한다. panic은 Java의 try-catch 문과 같은 예외 처리를 위한 것이 아니다.
그래서 panic은 외부에서 의존하는 라이브러리에서는 가급적 사용하지 않고, 애플리케이션에서도 프로덕션 런타임에 진입하면 panic이 발생하지 않아야 한다. 애플리케이션 내부의 의도치 않은 panic을 대비하여, 미들웨어나 인터셉터에 recover을 위한 체인을 추가하는 것이 권장된다.
아래는 http.HandlerFunc(...)를 활용하여 panic이 발생하면 이를 recover하여 500 에러를 반환하는 미들웨어를 구현한 예시다. 이 미들웨어는 http.ListenAndServe(...)에 전달하여 사용할 수 있다.
package main
import (
"log"
"net/http"
"runtime/debug"
)
// recoveryMiddleware is a middleware to recover panic on http handler.
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, hr *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic occurred: %v\n%s", err, debug.Stack())
http.Error(rw, "Internal Server Error: "+err.(string), http.StatusInternalServerError)
}
}()
next.ServeHTTP(rw, hr)
})
}
// mustPanicHandler is a handler to panic on every request.
func mustPanicHandler(rw http.ResponseWriter, hr *http.Request) {
panic("panic occurred")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", mustPanicHandler)
if err := http.ListenAndServe(":8080", recoveryMiddleware(mux)); err != nil {
log.Fatalf("could not listen on :8080: %v", err)
}
}
만약 내부에서 panic이 발생할 수 있다면 regexp.MustCompile()처럼 must를 접두사로 붙어준다. 이러한 must 함수들은 main()처럼 초기화 시점에서만 사용해야 한다.
log.Panic() vs log.Fatal()표준 패키지 log에서 제공하는 log.Panic()과 log.Fatal()은 모두 프로그램을 종료시키는 함수이다. 하지만 log.Panic()은 스택트레이스가 stderr에 출력되지만, log.Fatal()은 스택트레이스 없이 출력된다. 이렇게 차이가 나는 이유는 log.Panic() 내부에는 panic()를 호출하여 runtime.proc.go에서 정의된 gopanic 함수를 통해 프로그램을 종료시키지만, log.Fatal()은 os.Exit() 함수를 호출하여 프로그램을 바로 종료시키기 때문이다. 그로 인해 deferred function 실행 여부에서도 발생한다. 아래는 필요한 경우에 error handling과 조합하여 스택트레이스를 출력할지 결정해야 하는 예시이다.
package main
import (
"log"
"os"
"my/service"
)
func main() {
// set isStacktrace for printing stacktrace or not
isStacktrace := os.Getenv("IS_STACKTRACE")
if isStacktrace == "" {
log.Fatal("IS_STACKTRACE is not set")
}
// setup service
s := service.SetUp()
// run service
if err := s.Run(); err != nil {
if isStacktrace == "true" {
log.Panicf("%+v", err)
} else {
log.Fatalf("%+v", err)
}
}
}
Go에서는 에러를 built-in type으로 정하고 있다. type error는 Error() string 메서드를 구현하는 인터페이스 타입이다. 앞으로 설명할 errors, fmt 패키지에서 제공하는 함수들은 모두 이 type error를 바탕으로 커스텀 에러를 생성하고 있다.
에러는 오류가 발생한 원인을 보여주되, 오류의 원인에 따라 구문을 분기시키는 데에 유용하다. 에러나 에러가 발생할 수 있는 행위를 명시적으로 반환하고, 호출하는 쪽에서 처리하도록 하는 것이 Go의 일반적인 에러 처리 방식이다. 이러한 에러 처리 방식은 오류가 발생한 원인을 보여주되, 오류의 원인에 따라 구문을 분기시키는 데에 유용하다.
미리 정의된 커스텀 에러를 나타내는 상수 변수를 sentinel error라 하는데, 이러한 패키지 레벨의 오류들은 다른 곳에서 활용하기 위한 에러 타입 구분이 필요하거나, 에러를 외부에 공개할 필요가 있을 때 사용된다. 하지만 패키지 간의 소스 코드 의존성을 줄여야 한다면 이는 권장되지 않는다.
에러를 핸들링하기 위해 표준 패키지 errors를 활용한다. 아래는 파일 내용을 가져오는 과정에서 이를 활용하여 에러를 처리하는 예시다.
errors.New("...")는 새로운 에러를 생성하고, fmt.Errorf("%w", err)는 에러에 메시지를 래핑하여 반환한다. 이때 래핑된 에러는 Error() 외에 Unwrap() 메서드가 구현하여 커스텀 error 인터페이스를 구현하는 type error이다. 또한 Unwrap()을 통해 에러 체인을 추적할 수 있는데, errors.Is()는 에러 체인을 순회하며 특정 오류 값이 있는지 확인한다면, errors.As()는 에러 체인에서 특정 타입을 할당 가능한지 확인하고 해당 타입의 오류를 추출한다.
package main
import (
"errors"
"fmt"
"log"
"os"
)
// errors.New("...") create a new sentinel error
var (
// ErrFileIsEmpty is a error that file is empty.
ErrFileIsEmpty = errors.New("file is empty")
)
func main() {
defer func() {
if err := recover(); err != nil {
log.Printf("main: panic occurred: %v", err)
// errors.Is() check if the value of error is specific error.
if errors.Is(err.(error), os.ErrNotExist) {
log.Printf("main: file not found")
}
// errors.As() check and extract the type of specific error by .
var pathError *os.PathError
if errors.As(err.(error), &pathError) {
log.Printf("main: file not found (%s)", pathError.Path)
}
return
}
}()
mustRead("non_existent_dir", "non_existent_file.txt")
}
// mustRead is a function to read, can be panic if error occurs.
func mustRead(dir, path string) {
if data, err := readFile(dir, path); err != nil {
// DO NOT USE log.Panicf("..."); it just print the error message, will be lost type and stacktrace of the error.
panic(fmt.Errorf("mustRead: failed to read (%s/%s): %w", dir, path, err))
} else {
log.Printf("%s/%s: %s", dir, path, data)
}
}
// readFile is a function to read file on directory.
func readFile(dir, path string) ([]byte, error) {
if err := readDir(dir); err != nil {
// fmt.Errorf("%w") wrap error with context.
return nil, fmt.Errorf("readFile: failed to read file (%s): %w", path, err)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("readFile: failed to read file (%s): %w", path, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("readFile: file is empty (%s/%s): %w", dir, path, ErrFileIsEmpty)
}
return data, nil
}
// readDir is a function to read directory.
func readDir(dir string) error {
if _, err := os.ReadDir(dir); err != nil {
return fmt.Errorf("readDir: failed to read directory (%s): %w", dir, err)
}
return nil
}
/*
2025/06/26 16:47:37 main: panic occurred: mustRead: failed to read (non_existent_dir/non_existent_file.txt): readFile: failed to read file (non_existent_file.txt): readDir: failed to read directory (non_existent_dir): open non_existent_dir: no such file or directory
2025/06/26 16:47:37 main: file not found
2025/06/26 16:47:37 main: file not found (non_existent_dir)
*/
참고로 이렇게 래핑된 에러의 경우 panic()을 통해 에러를 발생시키면 래핑된 에러 타입과 스택트레이스가 출력되지만, log.Panic()는 문자열로 출력하고 panic을 처리하기 때문에 래핑된 타입과 스택트레이스를 잃어버리므로 주의가 필요하다.
현재 표준 패키지 errors에는 스택트레이스를 포함되어 있지 않기에, 만약 스택트레이스가 필요하다면 이를 추가하면서 효과적으로 관리할 수 있는 대안을 찾아야 한다.
1. pkg/errors
pkg/errors는 Go 1.13 이전의 fmt가 단순히 에러 메시지를 포맷팅하는 기능만을 지원하였기에, 스택트레이스를 자동으로 첨부하고 에러 래핑 기능을 추가하기 위해 만들어졌다. 하지만 fmt 패키지에서 fmt.Errorf("%w", err)를 통한 에러 래핑을 자원하면서 필요성이 많이 줄어들었고, 또 2021년에 아카이브되면서 여러 프로젝트에서도 다시 표준 패키지로 마이그레이션하는 추세이다. 만약 해당 패키지를 사용한다면 주의할 부분이 있다. 아래는 errors.New()와 errors.WithStack()을 사용하여 에러를 생성하는 코드이다.
var ErrSomething = errors.New("something went wrong")
errors.WithStack(ErrSomething)
/*
2025/06/26 13:01:08 something went wrong
main.init
<autogenerated>:1
runtime.doInit1
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:7291
runtime.doInit
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:7258
runtime.main
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:254
runtime.goexit
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/asm_arm64.s:1223
main.main
/main.go:10
runtime.main
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:272
runtime.goexit
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/asm_arm64.s:1223
exit status 1
*/
아래의 세 줄은 우리가 원하는 스택트레이스가 맞다. 하지만 <autogenerated> 아래 내용은 main이 init()를 호출할 때 생성된 스택트레이스이다. 즉, var 선언 시점의 스택트레이스가 패키지를 초기화하는 과정에서 중복되어 출력되는 것을 볼 수 있다.
errors.WithStack(errors.New("something went wrong"))
/*
2025/06/26 13:02:37 something went wrong
main.main
/main.go:10
runtime.main
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:272
runtime.goexit
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/asm_arm64.s:1223
main.main
/main.go:10
runtime.main
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:272
runtime.goexit
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/asm_arm64.s:1223
exit status 1
*/
이번에는 errors.WithStack()에 errors.New()을 넣어 생성한 에러를 테스트한 결과이다. 이 경우에는 변수 선언이 아닌 함수 호출 시점의 스택트레이스가 출력되지만, errors 패키지의 두 함수 모두 스택트레이스를 추가하여 동일한 내용의 스택트레이스가 중복되어 출력되는 것을 볼 수 있다. 이를 조합해보면 pkg.errors 패키지는 sentinel error를 작성하는 데에는 적합하지 않다고 볼 수 있고, 만약 sentinel error를 작성해야 한다면, 아래와 같이 스택트레이스를 추가하지 않는 표준 패키지의 errors.New()와 조합하여 사용하는 것이 좋다.
package main
import (
"errors"
"log"
pkgErrors "github.com/pkg/errors"
)
var (
// ErrSomething is error that something went wrong.
ErrSomething = errors.New("something went wrong")
)
func main() {
if err := doSomething(); err != nil {
log.Fatalf("%+v", err)
}
}
// doSomething is a function to do something.
func doSomething() error {
return pkgErrors.WithStack(ErrSomething)
}
/*
2025/06/28 10:57:56 something went wrong
main.doSomething
/main.go:21
main.main
/main.go:15
runtime.main
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/proc.go:272
runtime.goexit
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.6.darwin-arm64/src/ runtime/asm_arm64.s:1223
exit status 1
*/
2. 커스텀 에러 타입 정의
다른 여러 패키지에서 사용하는 것처럼, 직접 커스텀 에러 타입을 정의하여 사용하는 것 또한 하나의 방법이다. 이 경우에는 type error 인터페이스를 구현하는 구조를 정의하고, 여기에 스택트레이스에 필요한 변수와 메서드를 추가한다. 만약 더 확장된 필드나 메서드가 필요하다면 이를 상속해서 확장하면 된다. 아래는 커스텀 에러 타입을 정의해본 예시다.
package errors
import (
"encoding/json"
"errors"
"runtime"
"strconv"
"strings"
)
// Error is a error type.
type Error struct {
// msg is the message on error.
msg string
// cause is the cause of the error.
cause error
// stack is the stack trace of the error.
stack []uintptr
}
const (
// stackTraceForNew is the number of frames to skip for the new error.
stackTraceForNew = 2
// stackTraceForWrap is the number of frames to skip for the wrap error.
stackTraceForWrap = 3
)
// New creates a new Error with the given message.
func New(msg string) *Error {
return &Error{
msg: msg,
stack: captureStack(stackTraceForNew),
}
}
// Error returns the error message.
func (er *Error) Error() string {
if er.cause != nil {
return er.msg + ": " + er.cause.Error()
}
return er.msg
}
// Unwrap returns the cause of the error.
func (er *Error) Unwrap() error {
return er.cause
}
// Wrap wraps the error with the given message.
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return &Error{
msg: msg,
cause: err,
stack: captureStack(stackTraceForWrap),
}
}
// StackTrace returns the stack trace of the error.
func StackTrace(err error) string {
var er *Error
if errors.As(err, &er) {
return er.stackTrace()
}
return ""
}
// stackTrace returns the stack trace of the error.
func (er *Error) stackTrace() string {
frames := runtime.CallersFrames(er.stack)
var sb strings.Builder
for {
frame, more := frames.Next()
sb.WriteString(frame.Function)
sb.WriteString("\n\t")
sb.WriteString(frame.File)
sb.WriteString(":")
sb.WriteString(strconv.Itoa(frame.Line))
sb.WriteByte('\n')
if !more {
break
}
}
return sb.String()
}
// captureStack captures the current stack trace.
func captureStack(skip int) []uintptr {
pcs := make([]uintptr, 32)
n := runtime.Callers(skip, pcs)
return pcs[:n]
}
// Cause returns the cause of the error.
func Cause(err error) error {
if err == nil {
return nil
}
for {
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
err = unwrapped
}
}
// CauseAs returns the cause of the error as the given type.
func CauseAs[T any](err error) *T {
var target *T
for err != nil {
if errors.As(err, &target) {
return target
}
err = errors.Unwrap(err)
}
return nil
}
에러 체인을 지원하기 위해 cause와 Wrap(), Unwrap(), Cause(), CauseAs()를 추가하였고, 스택트레이스 출력을 위한 stack과 StackTrace()를 추가하였다. stack에 스택 프레임을 저장하는 captureStack()는 32개를 기본값으로 하였다.
deferred function에서 발생한 에러는 절대 무시하지 않는다. 아래와 같이 데이터베이스 연결을 닫는 과정에서 에러가 발생하면, 데이터베이스 커넥션 풀이 고갈되거나 메모리 누수가 발생할 수 있다. 만약 에러 레벨을 지원하는 로거라면, 이 부분을 error 레벨로 로깅하여 시스템 모니터링이 가능하도록 한다.
package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
"github.com/rs/zerolog/log"
)
// connect connects to the database.
func connect(host, port, user, password, dbname string) {
db, err := sql.Open("postgres",
fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname))
if err != nil {
log.Fatal().Err(err).Msg("failed to connect db")
}
defer func() {
if err := db.Close(); err != nil {
log.Error().Err(err).Msg("failed to close db connection")
}
}()
}
Go는 goroutine과 channel을 통한 동시성 프로그래밍을 지원한다. 이렇게 동시성 코드를 작성할 때는 고루틴 간의 data race와 고루틴 내부의 panic 발생을 피하기 위해 주의할 필요가 있다. 이를 위해 표준 패키지 sync를 사용하여 공유 자원에 대한 접근을 제어하고, recover()로 panic에서 복구할 수 있도록 해야 한다.
다음은 여러 개의 고루틴을 동시에 실행하고, 모든 고루틴이 완료될 때까지 대기하는 워커를 정의한 예시이다. worker 함수는 작업을 실행하고 함수가 종료될 때 sync.WaitGroup에 작업이 완료되었음을 defer한다. worker 함수를 실행하는 주체인 main에서는 sync.WaitGroup을 사용하여 모든 고루틴을 실행하고 완료될 때까지 대기한다.
package main
import (
"fmt"
"sync"
"time"
)
// worker is a function to work.
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // call Done() when the function is finished
fmt.Printf("worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
// start 5 goroutine
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
// wait for all goroutine to complete
wg.Wait()
}
만약 고루틴이 실행될 때마다 결과를 취합하는 것이 아니라, 모든 고루틴이 완료된 후에 여러 고루틴에서 실행된 결과를 취합하고 싶다면, 즉 단순한 순서 보장이 필요하면 for i, id := range ids; slice[i] = value를, 순서 보장이 필요하지 않다면 sync.Map나 mu와 map의 조합을 사용한다.
package main
import (
"fmt"
"sync"
"time"
)
// worker is a function to work.
func worker(id int, wg *sync.WaitGroup, wm *sync.Map) {
defer wg.Done()
time.Sleep(time.Second)
wm.Store(id, fmt.Sprintf("worker %d", id))
}
func main() {
var wg sync.WaitGroup
var wm sync.Map
// start 5 goroutine
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg, &wm)
}
// wait for all goroutine to complete
wg.Wait()
wm.Range(func(key, value any) bool {
fmt.Printf("%v\n", value)
return true
})
fmt.Println()
}
/*
worker 1
worker 2
worker 3
worker 4
worker 5
*/
또한 장기간 동작할 고루틴을 시작할 때는 해당 고루틴이 언제, 어떻게 종료할지 명확히 해야 한다. 이때 특정한 명령 신호를 받기 위해 표준 패키지 context를, 내부적으로 명령 신호를 관리할 수 있도록 channel을 사용할 수 있다. 다음은 context를 통한 타임아웃 신호와 chan을 통한 내부 완료 신호를 모두 처리하는 워커의 예시이다. 현재는 context를 통해 타임아웃 신호를 받아 종료하지만, 만약 타임아웃 시간을 길게 준다면 내부적으로 채널을 통해 완료 신호를 받아 종료하도록 할 수 있다.
package main
import (
"context"
"fmt"
"sync"
"time"
)
// worker is a function to work.
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
// internal channel for job completion signal
jobDone := make(chan struct{})
// simulate long running job
go func() {
defer close(jobDone)
for i := 0; i < 10; i++ {
// check timeout before processing each step
select {
case <-ctx.Done():
fmt.Printf("worker %d: timeout occurred\n", id)
return
default:
time.Sleep(500 * time.Millisecond)
fmt.Printf("worker %d: processing %d\n", id, i+1)
}
}
fmt.Printf("worker %d: all jobs completed\n", id)
}()
// wait for either context timeout or job completion
select {
case <-ctx.Done():
fmt.Printf("worker %d: stopped by timeout (%v)\n", id, ctx.Err())
case <-jobDone:
fmt.Printf("worker %d: finished normally\n", id)
}
}
func main() {
// set timeout to 3 seconds
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var wg sync.WaitGroup
// start 3 workers
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(ctx, i, &wg)
}
// wait for all workers to finish
wg.Wait()
fmt.Println("main: all workers finished")
}
/*
worker 3: processing 1
worker 1: processing 1
worker 2: processing 1
worker 2: processing 2
worker 3: processing 2
worker 1: processing 2
worker 1: processing 3
worker 2: processing 3
worker 3: processing 3
worker 3: processing 4
worker 1: processing 4
worker 2: processing 4
worker 3: processing 5
worker 2: processing 5
worker 1: processing 5
worker 1: stopped by timeout (context deadline exceeded)
worker 2: stopped by timeout (context deadline exceeded)
worker 3: stopped by timeout (context deadline exceeded)
main: all workers finished
*/
만약 여러 고루틴에서 발생한 에러들을 핸들링하고자 한다면 위의 커스텀 에러 타입 정의를 참고해, 여러 에러를 담을 수 있는 구조체를 만들고 여기에 error 인터페이스를 구현한 다음에 이들을 관리할 핸들러를 만드는 방법도 있다. 이 경우에는 Run(), Wait()와 같이 작업에 필요한 함수를 모두 구현해야 하나, 에러나 패닉이 발생했을 때 이들을 처리할 로직을 커스텀마이징할 수 있다.
// MultiError is struct for storing multi error.
type MultiError struct {
errors []error
mu sync.Mutex
}
// Worker manages goroutine and collects errors.
type Worker struct {
wg sync.WaitGroup
errors *MultiError
}
표준 패키지 net/http는 웹 애플리케이션 개발의 기본인 HTTP 클라이언트 및 서버 구현에 사용된다.
이때 API 서버를 호출할 때는 전역에서 http.DefaultClient를 사용하지 말고, 각 서비스별로 맞춤 설정된 http.Client를 따로 생성해서 사용한다. 이렇게 하면 서비스별로 다른 타임아웃, 재시도 정책, 헤더 설정 등을 적용할 수 있다. 또한 이렇게 정의된 http 클라이언트는 내부적으로 connection pooling을 통해 TCP 연결을 재사용한다. 같은 서버에 여러 번 요청을 보낼 때 매번 새로운 TCP 연결을 맺고 끊는 것이 아니라, 기존 연결을 재사용하여 성능을 향상한다. 이를 통해 TCP 핸드셰이크 오버헤드를 제거하고, 연결에 대한 생성/해제 비용을 감소시킬 수 있다.
http 클라이언트에서 연결을 재사용하기 위해서는 response body를 모두 읽은 다음에 닫아야 한다. 만약 response body를 완전히 읽지 않고 바로 닫으면, http.Client는 해당 연결을 재사용할 수 없다고 판단하고 연결을 닫아버려, 다음 요청에서는 새로운 연결을 맺어야 하므로 성능이 저하된다. 그러므로 http client에서 요청이 완료되었을 때 response body를 사용하지 않는다면, io.CopyN(io.Discard, resp.Body)와 같은 drain 작업을 진행하고 연결을 닫아야 한다.
다음은 위의 내용을 바탕으로 작성한 http client의 예시이다.
package httpc
import (
"context"
"fmt"
"log"
"io"
"net/http"
"time"
)
// HTTPClient is the http client.
type HTTPClient struct {
client *http.Client
baseURL string
}
// New creates a new HTTP client.
func New(baseURL string, timeout time.Duration, maxIdleConnsPerHost int) *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConnsPerHost: maxIdleConnsPerHost,
DisableKeepAlives: false,
},
},
baseURL: baseURL,
}
}
// Get performs a GET request.
func (c *HTTPClient) Get(ctx context.Context, path string) ([]byte, error) {
return c.Request(ctx, "GET", path, nil)
}
// Post performs a POST request.
func (c *HTTPClient) Post(ctx context.Context, path string, body io.Reader) ([]byte, error) {
return c.Request(ctx, "POST", path, body)
}
// Request performs an HTTP request and properly handles response body
func (c *HTTPClient) Request(ctx context.Context, method, path string, body io.Reader, headers map[string]string) ([]byte, error) {
url := c.baseURL + path
// create request with context
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// set headers
for key, value := range headers {
req.Header.Set(key, value)
}
// do request
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request: %w", err)
}
// drain response body
defer func() {
if _, err := io.CopyN(io.Discard, resp.Body, 64); err != nil {
log.Printf("failed to drain response body: %v", err)
}
if err := resp.Body.Close(); err != nil {
log.Printf("failed to close response body: %v", err)
}
}()
// check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
// read response body
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return data, nil
}
package main
import (
"fmt"
)
func main() {
// array
numArr := [5]int{1, 2, 3, 4, 5}
for idx, num := range numArr {
fmt.Printf("array: index: %d, value: %d\n", idx, num)
}
// slice
numSlice := make([]int, 0, 5)
numSlice = append(numSlice, 1, 2, 3, 4, 5)
for idx, num := range numSlice {
fmt.Printf("slice: index: %d, value: %d\n", idx, num)
}
}
/*
array: index: 0, value: 1
array: index: 1, value: 2
array: index: 2, value: 3
array: index: 3, value: 4
array: index: 4, value: 5
slice: index: 0, value: 1
slice: index: 1, value: 2
slice: index: 2, value: 3
slice: index: 3, value: 4
slice: index: 4, value: 5
*/
slice는 내부적으로 array에 대한 포인터, 길이(length), 용량(capacity)을 가지는 구조체이다. 그래서 slice는 arra와 달리 동적으로 크기를 조절할 수 있다. 만약 slice가 커져서 기존 용량을 초과하면, Go는 더 큰 array을 새로 할당하고 기존 데이터를 복사한다. 이때 일반적으로 기존 용량의 2배 크기로 할당하여 빈번한 재할당을 방지한다.
또한 array은 컴파일 타임에 크기가 고정되어 스택에 할당될 수 있지만, slice는 런타임에 크기가 결정되기에 일반적으로 힙에 할당된다. 따라서 작은 크기의 고정된 데이터에는 array이, 동적인 크기나 큰 데이터에는 slice가 더 적합하다.
| 구분 | array | slice |
|---|---|---|
| size | 컴파일 타임에 고정 | 런타임에 동적 변경 |
| type | value type | reference type |
| memory | 일반적으로 스택 | 일반적으로 힙 |
| function | 복사본 전달 (copy) | 참조 전달 (reference) |
| usage | 고정 크기 데이터 | 동적 크기 데이터 |
empty slice보다 nil slice를 선호하는 것이 관례다. nil slice는 실제 배열을 slice에 할당하지 않아 메모리를 절약할 수 있고, JSON 응답 시에 []가 아닌 null로 처리되기에 더 명확함을 보장한다.
package main
import (
"encoding/json"
"fmt"
)
func main() {
// nil slice
var nilSlice []int
// empty slice
emptySlice := []int{}
fmt.Printf("nil slice: %v, len: %d, cap: %d, nil: %t, json: %s\n", nilSlice, len(nilSlice), cap(nilSlice), nilSlice == nil, json.Marshal(nilSlice))
fmt.Printf("empty slice: %v, len: %d, cap: %d, nil: %t, json: %s\n", emptySlice, len(emptySlice), cap(emptySlice), emptySlice == nil, json.Marshal(emptySlice))
}
/*
nil slice: [], len: 0, cap: 0, nil: true, json: null
empty slice: [], len: 0, cap: 0, nil: false, json: []
*/
같은 이유로 slice, map의 emptiness 검사는 if slice == nil, slice의 유무 대신 if len(slice) == 0, 값의 개수로 명확하게 표현하여 판단한다.
map은 키-값 쌍으로 데이터를 저장하는 해시 테이블 기반 자료구조이다. 다른 언어의 dictionary나 hashMap과 유사하며, 키를 통한 빠른 검색, 삽입, 삭제가 가능하다. map 또한 slice처럼 reference type이므로 함수에 전달할 때 참조가 전달되고, 함수 내부에서 변경하면 원본 map도 변경된다.
또한 map 조회 시 ok 체크를 할 때에는 키에 해당하는 값 외에도 boolean 값을 반환받는 방식을 사용한다. 이렇게 작성함으로써 키가 존재하지 않는 경우에 대한 처리를 명확하게 할 수 있다.
package main
import (
"fmt"
)
func main() {
// map
m := make(map[string]int, 3)
m["apple"] = 1
m["banana"] = 2
m["cherry"] = 3
fmt.Println(m)
if v, ok := m["apple"]; ok {
fmt.Println("apple exists")
}
}
/*
map[apple:1 banana:2 cherry:3]
apple exists
*/
Go에서 Set에 해당하는 자료구조를 구현할 때에는 map[T]struct{}를 사용한다. struct{}는 크기가 0바이트인 빈 구조체로, 메모리를 전혀 차지하지 않기 때문에 키의 존재 여부나 고유한 값인지만 확인하면 되는 경우에 메모리를 절약할 수 있다.
package main
import (
"fmt"
"unsafe"
)
func main() {
// create set and add elements
set := make(map[string]struct{})
set["apple"] = struct{}{}
set["banana"] = struct{}{}
set["cherry"] = struct{}{}
// pop specific element
if _, exists := set["apple"]; exists {
fmt.Println("apple popped")
delete(set, "apple")
}
// iterate set
for key := range set {
fmt.Printf("%s ", key)
}
}
/*
apple popped
banana cherry
*/
Go에서는 map의 순회 순서가 보장되지 않는다. 이는 map이 해시 테이블 기반으로 구현되어 있기 때문이며, Go 1.0부터 의도적으로 랜덤화된 해시 함수를 사용하여 순서 의존적인 코드를 방지하고 있다. 따라서 동일한 map을 여러 번 순회하더라도 매번 다른 순서로 출력된다.
그러므로 만약 map을 순회해야 한다면, 꼭 정렬된 순서로 순회하는 경우에 한해 키를 별도로 정렬한 후 순회하거나 아예 순회하지 않는 방법을 사용한다. 다음은 이러한 방법을 사용한 예시이다.
package main
import (
"fmt"
)
func main() {
m := make(map[int]string, 3)
m[1] = "apple"
m[2] = "banana"
m[3] = "cherry"
// iterate map
for key, value := range m {
fmt.Printf("%d: %s ", key, value)
}
// sort keys
keys := make([]int, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Ints(keys)
for _, key := range keys {
fmt.Printf("%d: %s ", key, m[key])
}
}
/*
1: apple 2: banana 3: cherry (random order)
1: apple 2: banana 3: cherry (sorted order)
*/
Go의 string은 immutable이며 UTF-8로 인코딩된 바이트 시퀀스이다. string은 내부적으로 포인터와 길이를 가지는 구조체로 구현되어 있어, 길이가 긴 문자열도 효율적으로 처리할 수 있다.
다만 문자열을 변경할 때 항상 새로운 string을 생성하기에, 메모리 안전성을 보장하는 대신 문자열 연산이 많은 경우 성능 문제가 발생할 수 있다. 또한 immutable한 string과 mutable한 []byte 간의 변환은 메모리 복사가 크기만큼 매번 발생하므로, 불필요한 변환을 피하는 것이 좋다.
아래를 통해 문자열을 포맷팅하는 방법을 비교해보자.
package main
import (
"fmt"
"strconv"
"strings"
)
func main() {
name := "John"
age := 30
// string concatenation : simplest but not efficient
formatted1 := "Name: " + name + ", Age: " + strconv.Itoa(age)
fmt.Println("concat:", formatted1)
// fmt.Sprintf : convenient but slower
formatted2 := fmt.Sprintf("Name: %s, Age: %d", name, age)
fmt.Println("sprintf:", formatted2)
// strings.Builder : faster for complex formatting
var builder strings.Builder
builder.WriteString("Name: ")
builder.WriteString(name)
builder.WriteString(", Age: ")
builder.WriteString(strconv.Itoa(age))
formatted3 := builder.String()
fmt.Println("builder:", formatted3)
}
/*
sprintf: Name: John, Age: 30
builder: Name: John, Age: 30
concat: Name: John, Age: 30
*/
formatted1처럼 "A :" + "B" 와 같은 문자열 연산은 간단한 연결인 경우에 컴파일러가 최적화할 수 있고, 함수 호출, 타입 변환에 대한 오버헤드 없이 메모리 할당 한 번으로 작업이 완료된다. 그러나 기존 문자열과 새로운 문자열의 크기만큼 새로운 메모리를 할당하므로, 반복적인 많은 연결이 이루어져야 하는 경우에는 이를 지양해야 한다. 만약 슬라이스의 모든 요소를 연결해야 하거나 문자열 사이에 구분자가 필요한 경우라면 표준 패키지 strings의 strings.Join를 사용하는 것이 좋다.
formatted2에서 사용되고 있는 표준 패키지 fmt는 보편적으로 사용되는 문자열 포맷팅 함수 fmt.Sprintf를 제공하지만, 런타임에 동적으로 파싱해서 포맷 지시자 형식을 분석하고, 리플렉션으로 각 인자의 타입을 변환하는 오버헤드가 발생하여 성능이 떨어진다.
formatted3에서 사용되고 있는 strings.Builder의 경우엔 내부 []byte를 버퍼로 사용하기에, 데이터 복사 없이 버퍼에 문자열을 계속 추가하다가 마지막에 string으로 변환한다. 또한 명확한 타입을 가지므로 리플렉션으로 인한 오버헤드가 발생하지 않는다. 그러므로 단순 문자열 연결이 아닌 반복적인 많은 연결이 필요할 때 사용하는 것이 좋다.
추가로 표준 패키지 strconv를 활용한다면 문자열 ↔ 다른 타입 간의 양방향 변환이 가능하다.
package main
import (
"fmt"
)
func main() {
s := "안녕"
for i := 0; i < len(s); i++ {
fmt.Println(i, s[i], string(s[i]))
}
for i, r := range s {
fmt.Println(i, r, string(r))
}
}
/*
0 236 ì
1 149
2 136
3 235 ë
4 133
5 149
0 50504 안
3 45397 녕
*/
string은 내부적으로 UTF-8 인코딩된 바이트 시퀀스이므로, 바이트 단위로 순회하는 것이 아닌 문자 단위로 순회하는 것이 좋다. 위의 코드처럼 한글은 3바이트 이상이므로, 바이트 단위로 순회하면 한글 문자를 제대로 처리하지 못한다. 이는 문자열의 길이를 알고자 할 때에도 동일하다. 이 경우에는 len(s) 대신 표준 패키지 unicode/utf8의 RuneCountInString(s)를 사용한다면 제대로 된 값을 얻을 수 있다.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "안녕"
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
}
/*
6
2
*/
표준 패키지 time는 시간 비교, 더하기, 빼기, 포맷팅, 파싱 등의 시간을 다룰 때 활용할 수 있는 기능을 제공한다.
런타임에서는 되도록 time.Second, time.Duration 와 같이 내장된 상수를 사용하여 시간을 표현하고, 외부에서 전달받은 시간을 표현하는 문자열은 입력 지점에서 최대한 빠르게 변환하여 내부적으로는 강타입을 유지하도록 한다. 문자열 변환 시에는 미리 포맷을 정의하여 사용하는 것을 권장한다.
데이터베이스에 시간을 저장할 때에는 UTC 타임존을 사용하고, 사용자 표시 시에만 현지 시간을 사용하는 것을 권장한다. 이는 서머타임으로 인한 시간 중복 및 누락 문제를 방지하고, 전 세계 사용자 간의 일관된 시간 기준점을 제공하며, 시간 정렬 및 비교 연산의 정확성을 보장하기 위함이다.
그러나 time.Now()을 호출하면 로컬 타임존을 반영한 현재 값을 반환하므로, 뒤에 *.UTC()나 *.In(loc)을 붙여서 타임존을 명시적으로 지정하여 사용한다. 이 부분은 처음부터 유틸리티 패키지를 만들고 이를 초기화하여 미리 타임존을 지정하는 것을 권장한다.
time.Timer & time.Tickertype time.Timer는 일회성 타이머로, 특정 시간 후에 한 번만 실행되는 작업을 수행하는 데 사용되는 타입이다. 만약 반복적인 작업이 필요한 경우에는 type time.Ticker를 타이머로 사용한다. 그리고 타이머는 반드시 Stop() 메소드를 호출해 리소스를 정리해야 한다.
표준 패키지 test는 기본적인 테스트 기능만 제공한다. testify에서 제공하는 풍부한 assertion와 mock을 활용한다면 테스트 코드의 가독성과 유지보수성을 향상시킬 수 있다.
예를 들어 여러 시나리오를 검증해야 하는 경우라면 테이블 기반 테스트를 통해 테스트 케이스를 구조화하는 것이 효과적이다. given, when, then의 구조를 준수함으로써 중복되는 테스트 코드를 줄이고, 새로운 테스트 케이스 추가 시에 기존 코드 변경 없이 데이터만 추가하면 되므로 유지보수성이 향상된다. 아래와 같이 testify의 assert 패키지와 조합하면 각 테스트 케이스별로 명확한 검증을 수행할 수 있다.
func Calculate(a, b int, op string) (int, error) {
switch op {
case "+":
return a + b, nil
case "-":
return a - b, nil
case "*":
return a * b, nil
case "/":
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
default:
return 0, errors.New("invalid operator")
}
}
func TestCalculate(t *testing.T) {
tests := []struct {
name string
given struct {
a, b int
op string
}
then struct {
result int
hasErr bool
errMsg string
}
}{
{
name: "addition should return sum",
given: struct{ a, b int; op string }{a: 2, b: 3, op: "+"},
then: struct{ result int; hasErr bool; errMsg string }{result: 5, hasErr: false},
},
{
name: "subtraction should return difference",
given: struct{ a, b int; op string }{a: 10, b: 3, op: "-"},
then: struct{ result int; hasErr bool; errMsg string }{result: 7, hasErr: false},
},
{
name: "multiplication should return product",
given: struct{ a, b int; op string }{a: 4, b: 5, op: "*"},
then: struct{ result int; hasErr bool; errMsg string }{result: 20, hasErr: false},
},
{
name: "division should return quotient",
given: struct{ a, b int; op string }{a: 15, b: 3, op: "/"},
then: struct{ result int; hasErr bool; errMsg string }{result: 5, hasErr: false},
},
{
name: "division by zero should return error",
given: struct{ a, b int; op string }{a: 5, b: 0, op: "/"},
then: struct{ result int; hasErr bool; errMsg string }{result: 0, hasErr: true, errMsg: "division by zero"},
},
{
name: "invalid operator should return error",
given: struct{ a, b int; op string }{a: 5, b: 3, op: "%"},
then: struct{ result int; hasErr bool; errMsg string }{result: 0, hasErr: true, errMsg: "invalid operator"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// When
result, err := Calculate(tt.given.a, tt.given.b, tt.given.op)
// Then
if tt.then.hasErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.then.errMsg)
assert.Equal(t, tt.then.result, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.then.result, result)
}
})
}
}
| TODO
| TODO
| TODO
Go에서는 표준적인 프로젝트 구조가 공식적으로 정의되어 있지 않지만, 커뮤니티에서 널리 사용되는 golang-standards/project-layout을 참고해볼 수 있다. 이는 개발자가 프로젝트에 참여할 때 빠른 이해를 돕고, 코드의 유지보수성을 향상시킨다.
project/
├── cmd/ # entry point (main.go)
│ └── {app}/
│ └── main.go
├── internal/ # private package
│ ├── {app}/ # application package
│ │ ├── handler/
│ │ └── models/
│ └── {service}/ # service package
├── pkg/ # public package
├── api/ # API definition (OpenAPI, Protocol Buffer)
├── configs/ # config file template
├── scripts/ # script for build, install, analysis, etc.
├── build/ # packaging and CI
├── deployments/ # system, container orchestration deployment config
├── test/ # test app and test data
├── docs/ # design and user documentation
├── tools/ # support tools for the project
├── examples/ # application or public library example
├── third_party/ # external tools, forked code, etc.
├── .gitignore
├── .golangci.yml
├── Makefile
├── README.md
└── go.mod
라이브러리는 다른 프로젝트에서 import해서 사용하는 것이 목적이므로, 이보다는 훨씬 단순한 구조를 가져간다. 실행 파일을 만들지 않으므로 cmd/ 디렉토리가 존재하지 않으며, pkg/ 디렉토리 대신 루트와 같은 레벨에 패키지들을 배치한다.
Go는 공식적으로 gofmt를 통해 코드 포맷팅을 표준화한다. 이는 개발자 간의 스타일 논쟁을 줄이고 코드 리뷰에서 포맷팅보다는 로직에 집중할 수 있게 해준다. 다만 import문 관련 기능은 없으므로, gci를 사용하여 패키지 순서를 자동으로 정렬하거나 그룹화하는 것을 권장한다. 아래 명령어를 통해 포맷팅과 패키지 순서 정렬을 한 번에 수행할 수 있다. (패키지 순서는 standard, third-party, local 순이다.)
go fmt ./... && gci write -s standard -s default -s "prefix(github.com/pocj8ur4in)" ./...
| TODO
| TODO
| TODO
| TODO
| TODO
| TODO
| TODO
| TODO
| TODO
| TODO