Go 언어를 접하면서 가장 처음 사용하는 것이 fmt 패키지다. 기본적인 출력과 입력 포맷 관련한 기능을 제공하는데 이 글에서는 fmt에서 제공하는 함수와 타입의 사용법에 대해서 살펴보겠다.

템플릿

fmt 패키지는 포맷 템플릿을 이해하는 것이 핵심이다. 이것은 출력할 텍스트와 동사(verb)라고 부르는 변수를 출력하는 방식으로 구성된 문자열이다. 가령 age라는 변수 값을 출력하려면 fmt 패키지의 Printf() 함수를 사용한다.

age := 7
fmt.Printf("age: %d", age) // "age: 7"

정수 값을 출력할 때 사용하는 %d가 바로 동사인데 목적에 따라 다양한 기호를 사용한다.

문자열 값을 출력하려면 %s를 사용한다.

name := "Gopher"
fmt.Printf("Name: %s", name) // "Name: Gopher"

기본 데이터 타입을 출력하기 위한 다양한 동사 사용법은 Go by Example을 참고하자.

구조체 인스턴스도 동사를 지원하는데 %v를 사용한다.

type User struct {
    name string
    age  int
}

func main() {
    u := User{"Gopher", 10}
    fmt.Printf("%v", u) // {Gopher 10}
}

구조체의 필드명까지 출력하려면 + 기호를 붙인다.

fmt.Printf("%+v", u) // {name:Gopher, age:10}

기호를 추가하면 코드 정보까지 출력할 수 있다. 아마도 디버깅 용도로 사용하라는 의도 같다.

fmt.Printf("%#v", u) // main.user{name:"Gopher", age:10}

출력시 길이(width)를 고정하려면 숫자를 추가한다. 가령 정수를 길이 6으로 고정하려면 %6d 처럼 사용한다.

age := 10
fmt.Printf("|%6d|", age) // "|    10|"

기본적으로 고정된 길이에서는 우측 정렬을 따른다. 만약 좌측으로 정렬하려면 - 기호를 숫자 앞에 붙인다.

fmt.Printf("|%-6d|", age) // "|10    |"

제로 패딩(Zero Padding)도 지원하는데 고정 길이 값 앞에 0을 붙여 준다. 그러면 값을 제외한 나머지 빈 공간을 0으로 채워 출력할 수 있다.

fmt.Printf("%06d", age) // "000010"

표준 출력

운영 체제의 표준 출력(os.Stdout)인 터미널로 결과를 전달하기 위한 세 가지 함수를 제공한다.

name := "World"

fmt.Print("Hello", name) // "HelloWorld"
fmt.Println("Hello", world") // "Hello world\n"
fmt.Printf("Hello %s", name) // "Hello world"

Print() 함수는 단순히 문자열을 표준 출력으로 내보내서 터미널에 “HelloWorld”란 문자를 기록한다. 인자가 2개 이상일 경우 순서대로 붙여서 출력한다.

반면 Println()은 여러개 인자를 출력할 때 사이사이 빈 문자를 넣는다. 마지막엔 개행문자까지 추가하기 때문에 가독성이 필요할 경우 Print()보다 Println()을 선호하는 편이다.

Printf()는 앞에서 다루었듯이 문자열과 변수를 함께 출력할 때 사용한다. 동사를 포함한 템플릿 문자열과 변수를 인자로 받는다.

파일 출력

fmt 패키지 코드를 보면 Print() 함수는 내부적으로 Fprint() 함수를 사용한다.

func Print(a ...interface{}) (n int, err error) {
    return Fprint(os.Stdout, a...)
}

Fprint()는 파일(File) 출력을 의미하는 F로 시작하는데 첫 번째 인자로 io.Writer 타입을 받는다. Print()는 표준출력인 os.Stdout를 넘겨 주기 때문에 표준 출력인 터미널에 기록하는 원리다. 유닉스 시스템은 표준 입출력을 모두 파일로 처리한다 (/dev/stdin, dev/stdout, /dev/stderr)

var (
    Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin") // 표준 입력
    Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout") // 표준 출력
    Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr") // 표준 에러
)

Fprint() 함수를 직접 사용하면 표준 출력 뿐만 아니라 파일에 쓸 수 있다.

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    // 파일과 쓰기 버퍼 생성
    f, _ := os.Create("/tmp/result")
    w := bufio.NewWriter(f)

    // 파일에 쓰기
    fmt.Fprint(w, "Hello World\n")

    // 종료
    w.Flush()
}

실행한 결과 /tmp/result 파일이 생성되고 그 안에 “Hello World” 문자열이 기록되는 것을 확인할 수 있다.

파일 출력은 앞에 “F”가 붙은 이름의 함수를 제공한다. 유심히 보면 표준 출력 함수와 비슷하다.

func Fprint(w io.Writer, a ...interface{}) (n int, err error)
func Fprintln(w io.Writer, a ...interface{}) (n int, err error)
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)

문자열 만들기

파일을 의미하는 F처럼 문자열(String)을 의미하는 S로 시작하는 출력함수가 있다.

func Sprint(a ...interface{}) string
func Sprintln(a ...interface{}) string
func Sprintf(format string, a ...interface{}) string

이 함수들은 화면이나 파일에 출력하지 않고 문자열 값을 반환한다. 메모리에 출력하는 셈이다. 변수 값을 합쳐서 문자열을 만드는 경우 Sprintf()를 사용한다.

name := "Gopher"
msg := fmt.Sprintf("Hello %s", name) // msg := "Hello Gopher"

스캐닝

데이터를 입력받아 변수에 할당하는 것은 스캐닝(scanning)이라고 한다. fmt 패키지에는 출력을 위한 함수와 비슷한 종류의 스캐닝 함수를 제공한다. Scan() 함수는 표준 입력에서 데이터를 받을 때 사용한다.

var name string
var age  int

if _, err := fmt.Scan(&name, &age); err != nil {
    panic(err)
}

fmt.Printf("%s (%d)\n", name, age)

결과:

Gopher 10
Gopher (10)

Scan()를 포함해 표준입력 스캐닝 함수는 총 세 가지다.

func Scan(a ...interface{}) (n int, err error)
func Scanln(a ...interface{}) (n int, err error)
func Scanf(format string, a ...interface{}) (n int, err error)

Scanln() 함수는 개행문자(엔터)를 마지막 입력으로 받는 점이 Scan()와 차이다. Scanf()는 동사를 이용하는 점에서 Printf()와 동일한 쓰임새다.

파일, 문자열 출력처럼 스캐닝도 파일과 문자열 스캐닝 함수를 제공한다.

func Fscan(r io.Reader, a ...interface{}) (n int, err error)
func Fscanln(r io.Reader, a ...interface{}) (n int, err error)
func Fscanf(r io.Reader, format string, a ...interface{}) (n int, err error)

func Sscan(str string, a ...interface{}) (n int, err error)
func Sscanln(str string, a ...interface{}) (n int, err error)
func Sscanf(str string, format string, a ...interface{}) (n int, err error)

에러 출력

fmt 패키지에는 Errorf() 함수는 화면에 출력하지 않고 에러 메세지가 담긴 errors 타입의 인스턴스를 만들어 반환한다.

func Errorf(format string, a ...interface{}) error {
    return errors.New(Sprintf(format, a...))
}

함수 반환 값으로 에러를 리턴할 때 주로 사용했다.

func createUser(f form) (user, error) {
    // 중복 유저 발생시 에러를 반환
    return nil, fmt.Errorf("duplicated user")
}

Stringer

Go에서는 “er”로 끝나는 인터페이스 네이밍 규칙이 있다. Stringer라는 이름의 인터페이스는 문자열 관련한 인터페이스라는 것을 추측을 할 수 있는데 출력 함수의 특정 동사가 이 인터페이스를 사용하는 것 같다.

type Stringer interface {
    String() string
}

기본 패키지에 보면 Stringer 인테페이스를 따르는 구조체가 있는데 net.IP 와 bytes.Buffer의 String() 메소드가 그렇다.

Name과 Age 프로퍼티를 가지고 있는 User 구조체가 Stringer 인터페이스를 충족하도록 만들어 보자.

package main

import "fmt"

type User struct {
   Name string
   Age  int
}

// String 은 Stringer 인터페이스를 구현한다
func (a User) String() string {
   return fmt.Sprintf("%s (%d)", a.Name, a.Age)
}

func main() {
   u := User{"Gopher", 7}
   fmt.Println(u) // "Gopher (7)"
}

만약 String() 메소드를 구현하지 않고 u 인스턴스를 출력하면 “{Gopher 7}” 결과가 나왔을 것이다. 위 예제에서는 String() 메소드를 구현하여 Stringer 인터페이스를 충족시켰기 때문에 “Gopher (7)” 이란 결과가 나온 것이다.

이와 유사하게 GoStringer는 구조체 인스턴스를 출력할 때 문자열을 반환하도록하는 인터페이스다.

type GoStringer interface {
    GoString() string
}

GoString() 메소드를 구현해서 User 구조체가 GoStringer 인터페이스를 만족하도록 해보자.

// GoString 은 GoStriner 인터페이스를 충족한다
func (u User) GoString() string {
   return fmt.Sprintf("User: %s (%d)", u.Name, u.Age)
}

func main() {
   u := User{"Gopher", 7}
   fmt.Printf("%#v\n", u) // "User: Gopher (7)"
}

GoString() 메소드 구현 전에 “%#v” 동사로 출력하면 “main.User{Name:”Gopher”, Age:7}” 결과가 나올 것이다. 구현 후에는 메소드에서 반환한 것처럼 “User: Gopher (7)”로 결과가 나온다.

사용자 정의 출력

Stringer나 GoStringer 인터페이스를 구현함으로써 fmt 패키지가 제공하는 출력 함수의 결과 일부를 변경할 수 있었다. 출력 형식을 좀 더 자유롭게 제어 할 수 있는데 Formatter와 State 인터페이스를 사용한다.

type Formatter interface {
    Format(f State, c rune)
}

type State interface {
    // Write is the function to call to emit formatted output to be printed.
    Write(b []byte) (n int, err error)
    // Width returns the value of the width option and whether it has been set.
    Width() (wid int, ok bool)
    // Precision returns the value of the precision option and whether it has been set.
    Precision() (prec int, ok bool)

    // Flag reports whether the flag c, a character, has been set.
    Flag(c int) bool
}

계속해서 User 타입을 이용해서 코드를 살펴보자.

func (u User) Format(f fmt.State, c rune) {
   w, _ := f.Width()
   p, _ := f.Precision()

   f.Write([]byte(strings.Repeat("^", w)))
   f.Write([]byte(u.Name))
   f.Write([]byte(strconv.Itoa(u.Age)))
   f.Write([]byte(strings.Repeat("$", p)))
}

func main() {
   u := User{"Gopher", 7}
   fmt.Printf("%6.3s\n", u) // "^^^^^^Gopher7$$$"
}

Format() 함수의 인자인 f는 Width()와 Precision() 메소드를 제공하는데 동사 숫자 값의 소수점 앞 부분과 뒷 부분을 각 각 조회할 수 있다. 값을 출력하기 위해서는 Write() 메소드를 사용한다. 위 예제는 동사에 지정한 숫자만큼 특정 기호를 반복하고 그 중간에 User 타입의 값을 출력한다.

한편 스캐닝도 사용자 정의 형식을 지원하는데 Scanner와 ScanState 인터페이스가 그 역할을 한다.

정리

C를 처음 접할때 마주한 printf(), scanf() 처럼 Go fmt 패키지도 유사한 네이밍을 사용한다. 새로운 언어임에도 불구하고 꽤 친숙하다. 문서에서도 C 스타일의 동사를 사용한다고 말한다. 좀 더 심플한 방법으로 말이다. (The format ‘verbs’ are derived from C’s but are simpler.)

정리해 보면 총 세 가지 출력 및 스캔 함수를 제공한다. Print/Scan, Println/Scanln, Printf/Scanf

입출력 대상에 따라서 표준출력, 파일(F), 문자열(S)을 지원한다.

에러를 만들 때는 fmt.Errorf()를 사용한다.

사용자 형식을 정의하려면 Stringer, GoStringer를 사용한다. Fommter/Scanner 와 State/ScanState를 사용하면 훨씬 구체적으로 포맷을 제어할 수 있다.