Go의 fmt 패키지 모범 사례: 형식화된 출력 마스터하기
Ethan Miller
Product Engineer · Leapcell

Go의 fmt
패키지는 인쇄, 스캔 및 오류 보고에 필수적인 기능을 제공하는 형식화된 I/O 작업의 초석입니다. 문자열 및 변수 인쇄에 대한 기본 사용법은 간단하지만, 기능에 대한 더 깊은 이해는 Go 애플리케이션의 가독성, 유지 관리성 및 디버깅 용이성을 크게 향상시킬 수 있습니다. 이 글에서는 fmt
패키지의 다양한 측면을 살펴보고, 해당 기능을 최대한 활용하기 위한 팁, 트릭 및 모범 사례를 제공합니다.
1. 핵심 동사: 복습 및 그 이상
fmt
의 핵심은 다양한 데이터 유형이 표시되는 방식을 결정하는 형식 지정 동사입니다. 일반적으로 사용되는 %v
(기본값), %s
(문자열) 및 %d
(10진수 정수)를 넘어 다른 동사에 대한 강력한 이해가 중요합니다.
-
타입 반성(%T): 디버깅 또는 검사 시
%T
는 변수 유형을 인쇄하는 데 매우 유용합니다. 이는 인터페이스나 제네릭 함수 작업 시 특히 유용합니다.package main import "fmt" func main() { var i interface{} = "hello" fmt.Printf("Value: %v, Type: %T\n", i, i) // Output: Value: hello, Type: string var num int = 42 fmt.Printf("Value: %v, Type: %T\n", num, num) // Output: Value: 42, Type: int data := []int{1, 2, 3} fmt.Printf("Value: %v, Type: %T\n", data, data) // Output: Value: [1 2 3], Type: []int }
-
Go 구문 표현(%#v): 구조체 또는 맵과 같은 복잡한 데이터 구조를 디버깅할 때
%#v
는 값의 Go 구문 표현을 제공합니다. 이를 통해 테스트 또는 복제를 위해 출력을 코드에 쉽게 복사하여 붙여넣을 수 있습니다.package main import "fmt" type User struct { ID int Name string Tags []string } func main() { u := User{ ID: 1, Name: "Alice", Tags: []string{"admin", "developer"}, } fmt.Printf("Default: %v\n", u) // Output: Default: {1 Alice [admin developer]} fmt.Printf("Go-syntax: %#v\n", u) // Output: Go-syntax: main.User{ID:1, Name:"Alice", Tags:[]string{"admin", "developer"}} m := map[string]int{"a": 1, "b": 2} fmt.Printf("Default Map: %v\n", m) // Output: Default Map: map[a:1 b:2] fmt.Printf("Go-syntax Map: %#v\n", m) // Output: Go-syntax Map: map[string]int{"a":1, "b":2} }
-
부동 소수점 정밀도 제어(%f, %g, %e):
%f
: 표준 10진수 형식 (예:123.456
).%g
: 크기에 따라%e
또는%f
를 사용합니다 (작은 숫자의 경우%f
, 큰 숫자의 경우%e
를 선호). 일반적인 부동 소수점 출력에 가장 편리합니다.%e
: 과학적 표기법 (예:1.234560e+02
).
.
다음에 소수점 이하 자릿수를 지정하여 정밀도를 지정할 수 있습니다: 소수점 이하 두 자리는%.2f
.package main import "fmt" func main() { pi := 3.1415926535 fmt.Printf("Pi (default): %f\n", pi) // Output: Pi (default): 3.141593 fmt.Printf("Pi (2 decimal): %.2f\n", pi) // Output: Pi (2 decimal): 3.14 fmt.Printf("Pi (exponential): %e\n", pi) // Output: Pi (exponential): 3.141593e+00 fmt.Printf("Pi (general): %g\n", pi) // Output: Pi (general): 3.1415926535 largeNum := 123456789.123 fmt.Printf("Large number (general): %g\n", largeNum) // Output: Large number (general): 1.23456789123e+08 }
-
**패딩 및 정렬(%Nx, %-Nx):
%Nx
: 총 너비 N에 맞게 왼쪽에 공백으로 채웁니다.%-Nx
: 총 너비 N에 맞게 오른쪽에 공백으로 채웁니다 (왼쪽 정렬).%0Nx
: 총 너비 N에 맞게 왼쪽에 0으로 채웁니다 (숫자 유형에만 해당).
package main import "fmt" func main() { name := "Go" count := 7 fmt.Printf("Right padded: '%-10s'\n", name) // Output: Right padded: 'Go ' fmt.Printf("Left padded: '%10s'\n", name) // Output: Left padded: ' Go' fmt.Printf("Padded int (zeros): %05d\n", count) // Output: Padded int (zeros): 00007 fmt.Printf("Padded int (spaces): %5d\n", count) // Output: Padded int (spaces): 7 }
2. 어떤 인쇄 함수를 사용해야 하는가
fmt
패키지는 각기 고유한 목적을 가진 다양한 인쇄 함수를 제공합니다. 올바른 함수를 선택하면 코드의 명확성과 성능이 향상됩니다.
-
fmt.Print*
대fmt.Print_ln*
:fmt.Print()
/fmt.Printf()
/fmt.Sprint()
: 자동으로 줄 바꿈을 추가하지 않습니다.fmt.Println()
/fmt.Printf_ln()
(존재하지 않음.fmt.Printf
와fmt.Sprintln()
: 끝에 줄 바꿈 문자를 추가합니다. 간단하고 빠른 출력을 위해서는Println
을 사용하십시오. 구조화된 출력을 위해서는 명시적인Printf
가 더 나은 제어를 제공하므로 일반적으로 더 좋습니다.
-
문자열 변환을 위한
fmt.Sprint*
:fmt.Sprint*
계열 (예:fmt.Sprintf
,fmt.Sprintln
,fmt.SPrint
)는 콘솔에 인쇄하지 않습니다. 대신 문자열을 반환합니다. 이는 로그 메시지 빌드, 오류 문자열 구성 또는 콘솔이 아닌 출력 (예: 파일, 네트워크 소켓)을 위한 데이터 형식 지정에 매우 유용합니다.package main import ( "fmt" "log" ) func main() { userName := "Pat" userID := 123 // 로그 메시지 빌드 logMessage := fmt.Sprintf("User %s (ID: %d) logged in.", userName, userID) log.Println(logMessage) // 로거 출력: 2009/11/10 23:00:00 User Pat (ID: 123) logged in. // 오류 문자열 생성 errReason := "file not found" errorMessage := fmt.Errorf("operation failed: %s", errReason) // fmt.Errorf는 오류 래핑에 강력함 fmt.Println(errorMessage) // 출력: operation failed: file not found }
-
오류 생성을 위한
fmt.Errorf
:fmt.Errorf
는error
인터페이스를 구현하는 새로운 오류 값을 생성하도록 특별히 설계되었습니다. 형식화된 오류 메시지를 만드는 관용적인 방법입니다. 또한%w
를 사용한 Go 1.13+ 오류 래핑 기능과 잘 작동합니다.package main import ( "errors" "fmt" ) func readFile(filename string) ([]byte, error) { if filename == "missing.txt" { // 간단한 오류 return nil, fmt.Errorf("failed to open file %q", filename) } if filename == "permission_denied.txt" { // 기존 오류를 컨텍스트와 함께 래핑 (Go 1.13+) originalErr := errors.New("access denied") return nil, fmt.Errorf("failed to read %q: %w", filename, originalErr) } return []byte("file content"), nil } func main() { _, err1 := readFile("missing.txt") if err1 != nil { fmt.Println(err1) } _, err2 := readFile("permission_denied.txt") if err2 != nil { fmt.Println(err2) // 특정 오류가 래핑되었는지 확인 if errors.Is(err2, errors.New("access denied")) { fmt.Println("Permission denied error detected!") } } }
3. 사용자 정의 Stringer 및 String()
메서드
사용자 정의 타입의 경우 fmt
패키지의 기본 출력 (%v
)이 이상적이지 않을 수 있습니다. fmt.Stringer
인터페이스를 구현하면 유형이 인쇄될 때 해당 유형이 어떻게 표현되는지 제어할 수 있습니다. 유형이 String() string
메서드를 가지고 있으면 fmt.Stringer
를 구현합니다.
package main import "fmt" type Product struct { ID string Name string Price float64 } // String은 Product에 대한 fmt.Stringer를 구현합니다 func (p Product) String() string { return fmt.Sprintf("Product: %s (SKU: %s, Price: $%.2f)", p.Name, p.ID, p.Price) } // 시연을 위한 또 다른 사용자 정의 타입 type Coordinate struct { Lat float64 Lon float64 } func (c Coordinate) String() string { return fmt.Sprintf("(%.4f, %.4f)", c.Lat, c.Lon) } func main() { product1 := Product{ ID: "ABC-123", Name: "Wireless Mouse", Price: 24.99, } fmt.Println(product1) // Output: Product: Wireless Mouse (SKU: ABC-123, Price: $24.99) fmt.Printf("%v\n", product1) // Output: Product: Wireless Mouse (SKU: ABC-123, Price: $24.99) fmt.Printf("%s\n", product1) // Output: Product: Wireless Mouse (SKU: ABC-123, Price: $24.99) (참고: Stringer의 경우 %s와 %v는 종종 동일한 결과를 생성합니다) coord := Coordinate{Lat: 40.7128, Lon: -74.0060} fmt.Println("Current location:", coord) // Output: Current location: (40.7128, -74.0060) }
모범 사례: 인쇄되거나 로깅될 수 있는 복잡한 데이터 유형에 대해 String()
을 구현합니다. 이렇게 하면 가독성과 디버깅이 크게 향상됩니다.
4. fmt.Scanner
및 사용자 정의 스캔
fmt.Print
함수는 출력용이지만 fmt.Scan
함수는 입력용입니다. io.Reader
에서 형식화된 입력을 구문 분석할 수 있게 합니다.
-
기본 스캔:
fmt.Scanf
는Printf
와 유사하지만 입력을 구문 분석하는 것과 같습니다.package main import "fmt" func main() { var name string var age int fmt.Print("Enter your name and age (e.g., John 30): ") _, err := fmt.Scanf("%s %d", &name, &age) if err != nil { fmt.Println("Error reading input:", err) return } fmt.Printf("Hello, %s! You are %d years old.\n", name, age) // 예: 문자열에서 읽기 var val1 float64 var val2 string inputString := "3.14 PI" // Fscan은 io.Reader를 사용하므로 strings.NewReader를 사용합니다 _, err = fmt.Fscanf(strings.NewReader(inputString), "%f %s", &val1, &val2) if err != nil { fmt.Println("Error scanning string:", err) return } fmt.Printf("Scanned from string: %.2f, %s\n", val1, val2) }
-
사용자 정의 스캔 메서드 (
Scanner
인터페이스):Stringer
와 유사하게 특별한 구문 분석 로직이 필요한 사용자 정의 유형에 대해fmt.Scanner
인터페이스를 구현할 수 있습니다. 유형이Scan(state fmt.ScanState, verb rune) error
메서드를 가지고 있으면fmt.Scanner
를 구현합니다.Stringer
보다 덜 일반적이지만 특정 사용 사례 (예: 사용자 정의 날짜 형식 구문 분석)에 강력합니다.package main import ( "fmt" "strings" ) // MyDate는 사용자 정의 형식 YYYY/MM/DD를 나타냅니다 type MyDate struct { Year int Month int Day int } // MyDate에 대한 String 메서드 (fmt.Stringer 구현) func (d MyDate) String() string { return fmt.Sprintf("%04d/%02d/%02d", d.Year, d.Month, d.Day) } // MyDate에 대한 Scan 메서드 (fmt.Scanner 구현) func (d *MyDate) Scan(state fmt.ScanState, verb rune) error { // YYYY/MM/DD와 같은 형식을 예상 var year, month, day int _, err := fmt.Fscanf(state, "%d/%d/%d", &year, &month, &day) if err != nil { return err } d.Year = year d.Month = month d.Day = day return nil } func main() { var date MyDate input := "2023/10/26" // 문자열에서 스캔하려면 Sscanf 사용 _, err := fmt.Sscanf(input, "%v", &date) // MyDate가 fmt.Scanner를 구현하므로 %v가 작동합니다 if err != nil { fmt.Println("Error scanning date:", err) return } fmt.Println("Scanned date:", date) // MyDate의 String() 메서드 사용 }
5. 성능 고려 사항: fmt
를 언제 피해야 하는가
fmt
는 다재다능하지만 리플렉션 및 문자열 조작을 포함하므로 특히 고성능 또는 핫 패스 시나리오에서 성능에 영향을 줄 수 있습니다.
-
숫자 변환에는
strconv
사용 선호: 문자열과 숫자 유형 간을 변환할 때strconv
함수는 일반적으로fmt.Sprintf
보다 훨씬 빠릅니다.package main import ( "fmt" "strconv" "testing" // 벤치마킹용 ) func main() { num := 12345 _ = fmt.Sprintf("%d", num) // 느림 _ = strconv.Itoa(num) // 빠름 str := "67890" _, _ = fmt.Sscanf(str, "%d", &num) // 느림 _, _ = strconv.Atoi(str) // 빠름 } // 예제 벤치마크 (go test -bench=. -benchmem 실행) /* func BenchmarkSprintfInt(b *testing.B) { num := 12345 for i := 0; i < b.N; i++ { _ = fmt.Sprintf("%d", num) } } func BenchmarkItoa(b *testing.B) { num := 12345 for i := 0; i < b.N; i++ { _ = strconv.Itoa(num) } } // 결과: // BenchmarkSprintfInt-8 10000000 137 ns/op 32 B/op 1 allocs/op // BenchmarkItoa-8 200000000 6.48 ns/op 0 B/op 0 allocs/op // strconv.Itoa는 훨씬 빠르고 할당이 적습니다. */
-
효율적인 문자열 연결을 위한
strings.Builder
: 문자열을 점진적으로 빌드할 때, 특히 루프 내에서 반복적인+
연결 또는fmt.Sprintf
호출을 피하십시오. 이렇게 하면 많은 중간 문자열이 생성됩니다.strings.Builder
가 가장 효율적인 선택입니다.package main import ( "bytes" "fmt" "strings" ) func main() { items := []string{"apple", "banana", "cherry"} var result string // 비효율적: 루프 내 문자열 연결 for _, item := range items { result += " " + item // 각 반복마다 새 문자열 할당 } fmt.Println("Inefficient:", result) // 효율적: strings.Builder 사용 var sb strings.Builder for i, item := range items { if i > 0 { sb.WriteString(", ") } sb.WriteString(item) } fmt.Println("Efficient (Builder):", sb.String()) // 또한 효율적: bytes.Buffer (이전 버전이지만 여전히 널리 사용됨) var buf bytes.Buffer for i, item := range items { if i > 0 { buf.WriteString(" | ") } buf.WriteString(item) } fmt.Println("Efficient (Buffer):", buf.String()) }
6. 일반적인 함정 방지
-
불일치하는 동사 및 유형: 형식 지정 동사에 주의하십시오.
%s
로int
를 인쇄하면 일반적으로 오류가 발생하거나 예상치 못한 결과가 발생합니다.%v
는 유형 변환을 우아하게 처리하지만. -
누락된 인수:
fmt.Printf
는 형식 동사에 일치하는 수의 인수를 예상합니다. 일반적인 실수는 인수를 잊어버려 런타임에 "누락된 인수" 오류가 발생하는 것입니다. -
Printf
대Println
:Printf
는 기본적으로 줄 바꿈을 추가하지 않는다는 점을 기억하십시오. 줄 바꿈을 원하면 형식 문자열 끝에 항상 -
Stringer 및 포인터: 값 수신자 (
(t MyType)
)로String()
메서드가 정의되어 있지만 포인터 (&myVar
)를fmt.Print
에 전달하는 경우에도String()
메서드가 호출됩니다. 그러나 포인터 수신자 ((t *MyType)
)로String()
메서드가 정의되어 있고 값을 전달하는 경우, 서명과 일치하지 않기 때문에String()
메서드가 직접 호출되지 않고 값의 기본 Go 구문이 표시됩니다. 일반적으로 복잡하거나 큰 구조체의 경우String()
에 포인터 수신자를 사용하는 것이 안전합니다.package main import "fmt" type MyStruct struct { Value int } // 값 수신자에 대한 String 메서드 func (s MyStruct) String() string { return fmt.Sprintf("Value receiver: %d", s.Value) } // 포인터 수신자에 대한 PtrString 메서드 func (s *MyStruct) PtrString() string { return fmt.Sprintf("Pointer receiver: %d", s.Value) } func main() { val := MyStruct{Value: 10} ptr := &val fmt.Println(val) // 값 수신자에 대한 String() 호출: Value receiver: 10 fmt.Println(ptr) // 간접적으로 값 수신자에 대한 String() 여전히 호출: Value receiver: 10 // PtrString만 있는 경우: // fmt.Println(val) // {10} (기본값) 출력 fmt.Println(ptr.PtrString()) // 명시적으로 PtrString() 호출: Pointer receiver: 10 }
fmt.Stringer
의 경우 메서드가 값만 읽으면 되는 경우 값 수신자를 사용하거나, 값을 수정해야 하고 (하지만String()
메서드는 이상적으로 부작용이 없어야 함) 또는 구조체가 커서 복사 비용이 많이 드는 경우 포인터 수신자를 사용하는 것이 일반적입니다.fmt
는 둘 다 올바르게 처리합니다.
결론
fmt
패키지는 Go의 기본 구성 요소로, 형식화된 I/O를 위한 강력하고 유연한 도구를 제공합니다. 다양한 동사를 마스터하고, 함수에 대한 미묘한 차이를 이해하고, 사용자 정의 유형에 대해 Stringer
를 구현하고, 성능 고려 사항을 염두에 두면 더 관용적이고 가독성이 좋으며 효율적인 Go 코드를 작성할 수 있습니다. 이러한 기술을 일상 개발 워크플로에 통합하면 애플리케이션에서 정보 디버깅, 로깅 및 프레젠테이션 능력을 크게 향상시킬 수 있습니다.