종합 실습: 텍스트 처리 파이프라인
이 챕터에서 다루는 것
Part 4의 여덟 가지를 한 프로그램에 넣는다. 메서드와 리시버 선택, 함수 타입 어댑터,
선택적 인터페이스, io.Reader/io.Writer 수용, 커스텀 에러 타입과 Unwrap,
errors.Is/AsType/Join, 그리고 nil 인터페이스 함정.
만들 것
줄 단위 텍스트 처리 파이프라인이다.
- 입력은
io.Reader, 출력은io.Writer. 파일이든 메모리든 표준 입출력이든 상관없다. - 처리 단계(stage)를 순서대로 꽂는다. 각 단계는 한 줄을 받아 한 줄을 돌려준다.
- 단계는 함수로도, 상태를 가진 구조체로도 만들 수 있다.
- 실패에는 어느 단계 몇 번째 줄인지가 붙는다.
- 두 가지 모드: 실패해도 계속하는 관대 모드, 첫 실패에서 멈추는 엄격 모드.
설계 결정 다섯 가지
1. Stage는 메서드 하나, 이름은 선택적 인터페이스
type Stage interface {
Apply(line string) (string, error)
}
type Named interface {
Name() string
}
Name()을 Stage에 넣으면 모든 단계가 그것을 구현해야 한다. 함수 하나로 만든
단계에는 이름을 붙일 자리가 없다. 그래서 필수는 Apply 하나로 두고, 이름은
있으면 쓰고 없으면 %T로 대체했다. 4-4에서 본
선택적 인터페이스 패턴이다.
2. 함수 타입 어댑터
type StageFunc func(string) (string, error)
func (f StageFunc) Apply(line string) (string, error) { return f(line) }
4-5의 http.HandlerFunc 패턴이다. 상태가 없는 단계
(trimSpace, skipEmpty, rejectWord)는 구조체를 만들 이유가 없다.
3. 리시버 선택
numberer는 줄 번호를 누적하므로 포인터 리시버다.limiter는 설정만 있고 고칠 것이 없지만 포인터 리시버로 통일했다. 4-1의 네 번째 기준 — 일관성 — 이다.Name과Apply가 섞이면limiter값이Stage를 만족하지 못한다.
4. 에러 계층을 셋으로 나눈다
| 층 | 정체 | 역할 |
|---|---|---|
errTooLong, errForbidden | 센티널 | 종류 식별 (errors.Is) |
LengthError | 커스텀 타입 | 숫자를 들고 있음 (errors.AsType) |
StageError | 커스텀 타입 | 위치(단계·줄 번호)를 들고 있음 |
LengthError.Unwrap()이 errTooLong을 돌려주고, StageError.Unwrap()이 안쪽
에러를 돌려준다. 그래서 errors.Is(err, errTooLong)이 두 겹을 뚫고 동작한다.
5. errSkipLine은 실패가 아니라 신호다
빈 줄을 건너뛰는 것은 에러가 아니다. 하지만 Apply가 "이 줄은 버려라"를 알릴
방법이 반환값에 없다. 그래서 센티널을 제어 신호로 쓴다. 표준 라이브러리도
같은 일을 한다 — io.EOF, filepath.SkipDir.
대가는 호출자가 그것을 알아야 한다는 것이다. 문서에 적어야 하고, 그러지 않으면 "에러인데 왜 로그에 안 남지?"가 된다.
전체 코드
package main
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
)
// ---- 에러 ----
var (
// errSkipLine은 실패가 아니라 제어 신호다. 파이프라인이 직접 해석한다.
errSkipLine = errors.New("건너뜀")
errTooLong = errors.New("줄이 너무 길다")
errForbidden = errors.New("금지어 포함")
)
// StageError는 어느 단계, 몇 번째 줄에서 실패했는지를 필드로 들고 있다.
type StageError struct {
Stage string
LineNo int
Err error
}
func (e *StageError) Error() string {
return fmt.Sprintf("%d번째 줄 [%s]: %v", e.LineNo, e.Stage, e.Err)
}
func (e *StageError) Unwrap() error { return e.Err }
// LengthError는 한계와 실제 길이를 호출자에게 알려 준다.
type LengthError struct {
Limit int
Got int
}
func (e *LengthError) Error() string {
return fmt.Sprintf("%d자 제한인데 %d자다", e.Limit, e.Got)
}
func (e *LengthError) Unwrap() error { return errTooLong }
// ---- 단계 ----
// Stage는 메서드 하나다. 이름은 선택적 인터페이스로 따로 받는다.
type Stage interface {
Apply(line string) (string, error)
}
// Named는 선택적 인터페이스다. 구현하지 않아도 파이프라인은 동작한다.
type Named interface {
Name() string
}
// StageFunc는 상태 없는 단계를 함수 하나로 쓰게 해 준다.
type StageFunc func(string) (string, error)
func (f StageFunc) Apply(line string) (string, error) { return f(line) }
func stageName(s Stage) string {
if n, ok := s.(Named); ok {
return n.Name()
}
return fmt.Sprintf("%T", s)
}
// limiter는 글자 수를 제한한다. 상태가 없지만 설정이 있어서 구조체다.
type limiter struct {
max int
}
func (l *limiter) Name() string { return "limiter" }
func (l *limiter) Apply(line string) (string, error) {
if n := utf8.RuneCountInString(line); n > l.max {
return "", &LengthError{Limit: l.max, Got: n}
}
return line, nil
}
// numberer는 상태를 들고 있으므로 포인터 리시버다.
type numberer struct {
n int
}
func (n *numberer) Name() string { return "numberer" }
func (n *numberer) Apply(line string) (string, error) {
n.n++
return fmt.Sprintf("%2d| %s", n.n, line), nil
}
// rejectWord는 금지어를 담은 단계를 만든다.
func rejectWord(word string) Stage {
return StageFunc(func(line string) (string, error) {
if strings.Contains(strings.ToLower(line), word) {
return "", fmt.Errorf("%q: %w", word, errForbidden)
}
return line, nil
})
}
var trimSpace = StageFunc(func(line string) (string, error) {
return strings.TrimSpace(line), nil
})
var skipEmpty = StageFunc(func(line string) (string, error) {
if line == "" {
return "", errSkipLine
}
return line, nil
})
// newLimiterBuggy는 4-4의 함정을 재현한다.
// max가 0 이하면 "제한 없음"을 뜻하는 nil을 돌려주려 했지만, 반환 타입이 인터페이스다.
func newLimiterBuggy(max int) Stage {
var l *limiter
if max > 0 {
l = &limiter{max: max}
}
return l
}
// newLimiter는 성공/실패 경로에서 각각 명시적으로 값을 돌려준다.
func newLimiter(max int) Stage {
if max <= 0 {
return nil
}
return &limiter{max: max}
}
// ---- 파이프라인 ----
type Stats struct {
In int
Out int
Skipped int
Failed int
}
type Pipeline struct {
stages []Stage
strict bool // true면 첫 실패에서 멈춘다
}
// addStage는 nil 단계를 걸러 낸다. "제한 없음"을 nil로 표현할 수 있게 하는 장치다.
func (p *Pipeline) addStage(s Stage) {
if s == nil {
return
}
p.stages = append(p.stages, s)
}
func (p *Pipeline) Run(dst io.Writer, src io.Reader) (Stats, error) {
var (
stats Stats
errs []error
)
sc := bufio.NewScanner(src)
for lineNo := 1; sc.Scan(); lineNo++ {
stats.In++
line, err := p.applyAll(sc.Text(), lineNo)
switch {
case errors.Is(err, errSkipLine):
stats.Skipped++
continue
case err != nil:
stats.Failed++
if p.strict {
return stats, err
}
errs = append(errs, err)
continue
}
if _, werr := fmt.Fprintln(dst, line); werr != nil {
return stats, fmt.Errorf("출력 쓰기 %d번째 줄: %w", lineNo, werr)
}
stats.Out++
}
if err := sc.Err(); err != nil {
return stats, fmt.Errorf("입력 읽기: %w", err)
}
return stats, errors.Join(errs...)
}
func (p *Pipeline) applyAll(line string, lineNo int) (string, error) {
for _, s := range p.stages {
out, err := s.Apply(line)
if err != nil {
// errSkipLine은 감싸도 errors.Is로 찾을 수 있다.
return "", &StageError{Stage: stageName(s), LineNo: lineNo, Err: err}
}
line = out
}
return line, nil
}
// ---- 보고 ----
func reportErrors(err error) {
if err == nil {
fmt.Println("에러 없음")
return
}
var joined interface{ Unwrap() []error }
list := []error{err}
if errors.As(err, &joined) {
list = joined.Unwrap()
}
for _, e := range list {
// 타입 스위치로 처리 방식을 가른다.
switch {
case errors.Is(e, errForbidden):
fmt.Println(" [정책] ", e)
case errors.Is(e, errTooLong):
// 커스텀 타입에서 숫자를 꺼내 쓴다.
if le, ok := errors.AsType[*LengthError](e); ok {
fmt.Printf(" [길이] %v (초과 %d자)\n", e, le.Got-le.Limit)
continue
}
fmt.Println(" [길이] ", e)
default:
fmt.Println(" [기타] ", e)
}
}
}
const sample = ` 안녕하세요
Go 인터페이스는 메서드 목록이다
이 줄은 일부러 아주 길게 써서 글자 수 제한을 넘기도록 만든 문장이다
spam 광고입니다
마지막 줄 `
func main() {
build := func(strict bool, max int) *Pipeline {
p := &Pipeline{strict: strict}
p.addStage(trimSpace)
p.addStage(skipEmpty)
p.addStage(rejectWord("spam"))
p.addStage(newLimiter(max))
p.addStage(&numberer{})
return p
}
fmt.Println("=== 관대 모드 (os.Stdout으로 출력)")
p := build(false, 20)
stats, err := p.Run(os.Stdout, strings.NewReader(sample))
fmt.Printf("통계: 입력 %d / 출력 %d / 건너뜀 %d / 실패 %d\n",
stats.In, stats.Out, stats.Skipped, stats.Failed)
fmt.Println("에러 목록:")
reportErrors(err)
fmt.Println()
fmt.Println("=== 엄격 모드 (strings.Builder로 출력)")
var buf strings.Builder
p = build(true, 20)
stats, err = p.Run(&buf, strings.NewReader(sample))
fmt.Printf("통계: 입력 %d / 출력 %d / 건너뜀 %d / 실패 %d\n",
stats.In, stats.Out, stats.Skipped, stats.Failed)
fmt.Println("멈춘 원인:", err)
if se, ok := errors.AsType[*StageError](err); ok {
fmt.Println("실패 단계:", se.Stage, "/ 줄 번호:", se.LineNo)
}
fmt.Printf("버퍼에 쌓인 내용(%d바이트):\n%s", buf.Len(), buf.String())
fmt.Println()
fmt.Println("=== 제한 없음: nil 인터페이스 함정")
fmt.Println("newLimiter(0) == nil: ", newLimiter(0) == nil)
fmt.Println("newLimiterBuggy(0) == nil: ", newLimiterBuggy(0) == nil)
bad := &Pipeline{}
bad.addStage(newLimiterBuggy(0)) // nil이 아니라서 걸러지지 않는다
fmt.Println("걸러진 단계 수:", len(bad.stages))
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("실행 시점 패닉:", rec)
}
}()
_, _ = bad.Run(io.Discard, strings.NewReader("한 줄"))
}()
}
go run ./08-processor
=== 관대 모드 (os.Stdout으로 출력)
1| 안녕하세요
2| Go 인터페이스는 메서드 목록이다
3| 마지막 줄
통계: 입력 6 / 출력 3 / 건너뜀 1 / 실패 2
에러 목록:
[길이] 4번째 줄 [limiter]: 20자 제한인데 39자다 (초과 19자)
[정책] 5번째 줄 [main.StageFunc]: "spam": 금지어 포함
=== 엄격 모드 (strings.Builder로 출력)
통계: 입력 4 / 출력 2 / 건너뜀 1 / 실패 1
멈춘 원인: 4번째 줄 [limiter]: 20자 제한인데 39자다
실패 단계: limiter / 줄 번호: 4
버퍼에 쌓인 내용(69바이트):
1| 안녕하세요
2| Go 인터페이스는 메서드 목록이다
=== 제한 없음: nil 인터페이스 함정
newLimiter(0) == nil: true
newLimiterBuggy(0) == nil: false
걸러진 단계 수: 1
실행 시점 패닉: runtime error: invalid memory address or nil pointer dereference
출력에서 확인할 것
1. 같은 파이프라인이 두 개의 목적지에 썼다
관대 모드는 os.Stdout, 엄격 모드는 strings.Builder. Run의 시그니처가
io.Writer라서 코드를 한 줄도 안 고쳤다. 입력도 마찬가지로 strings.NewReader가
io.Reader다. 파일로 바꾸려면 os.Open/os.Create의 결과를 그대로 넣으면 된다.
4-3에서 말한 "인터페이스는 내가 필요한 최소한을 적는 자리" 의 실제 효과다.
2. 선택적 인터페이스가 두 가지 이름을 냈다
4번째 줄 [limiter]: ...
5번째 줄 [main.StageFunc]: ...
limiter는 Name()이 있어서 예쁜 이름이 나왔고, rejectWord가 만든 StageFunc은
없어서 %T가 대신 나왔다. Name()을 필수로 만들지 않은 대가가 정확히 이만큼이다.
필요하면 이름을 담는 래퍼 단계를 하나 만들면 된다 — 연습문제로 남긴다.
3. 두 겹을 뚫고 종류를 식별했다
[길이] 4번째 줄 [limiter]: 20자 제한인데 39자다 (초과 19자)
체인은 이렇게 생겼다.
*StageError ──Unwrap──▶ *LengthError ──Unwrap──▶ errTooLong
"4번째 줄 [limiter]: …" "20자 제한인데 39자다" "줄이 너무 길다"
errors.Is(e, errTooLong)이 맨 끝을 찾아 분류하고,
errors.AsType[*LengthError](e)가 중간에서 숫자를 꺼내 "초과 19자"를 계산했다.
분류와 데이터 추출이 서로 다른 도구로 나뉜다는 것이 4-7의
요점이었다.
LengthError.Unwrap()이 errTooLong을 돌려준다는 점을 다시 보자. Unwrap이 반드시
"안쪽에서 받은 에러"일 필요는 없다. 자기 자신을 어떤 종류로 분류시킬지 정하는
장치로도 쓸 수 있다.
4. errSkipLine은 실패로 세지 않았다
통계: 입력 6 / 출력 3 / 건너뜀 1 / 실패 2
6줄 중 빈 줄 1개는 Skipped, 길이 초과와 금지어 2개는 Failed, 나머지 3개가 출력됐다.
Run의 switch에서 errors.Is(err, errSkipLine)을 err != nil보다 먼저 검사한
덕이다. 순서를 바꾸면 건너뛰기가 실패로 집계된다.
errSkipLine이 StageError에 감싸여 있는데도 찾아진 것은 Unwrap 덕이다.
5. 엄격 모드는 중간까지 쓴 것을 남겼다
버퍼에 쌓인 내용(69바이트):
1| 안녕하세요
2| Go 인터페이스는 메서드 목록이다
4번째 줄에서 멈췄지만 앞의 두 줄은 이미 쓰였다. 부분 출력이 남는다. 이것이 스트리밍 파이프라인의 본질적 성질이고, 원자성이 필요하다면 버퍼에 모았다가 마지막에 한 번에 써야 한다. 설계 결정으로 명시해야 할 지점이다.
6. nil 인터페이스 함정을 다시 밟았다
newLimiter(0) == nil: true
newLimiterBuggy(0) == nil: false
걸러진 단계 수: 1
실행 시점 패닉: runtime error: invalid memory address or nil pointer dereference
addStage의 if s == nil 방어가 newLimiterBuggy에는 통하지 않는다.
(*limiter, nil)이 담긴 인터페이스는 nil이 아니기 때문이다
(4-4). 걸러지지 않은 단계가 파이프라인에 들어갔고,
Apply 안에서 l.max를 읽는 순간 죽었다.
이 버그가 4-5의 "인터페이스를 반환하지 마라"와
직결된다. newLimiter가 *limiter를 반환했다면 newLimiterBuggy의 형태로도
nil이 정직하게 nil이었을 것이다. 인터페이스를 반환해야만 하는 자리라면
성공 경로와 실패 경로에서 각각 값을 직접 return 해야 한다.
직접 확장해 보기
1. 이름 붙이는 래퍼 만들기
main.StageFunc 대신 사람이 읽을 이름이 나오게 해 보자.
type named struct {
Stage
name string
}
func (n named) Name() string { return n.name }
4-2의 인터페이스 임베딩이다. Apply는 위임되고 Name만
새로 생긴다. rejectWord가 named{Stage: ..., name: "rejectWord"}를 돌려주게 고쳐
보자. 이 래퍼의 위험은 무엇인가? (힌트: Stage에 메서드가 추가된다면?)
2. 파일 입출력으로 바꾸기
main을 고쳐 os.Args[1]의 파일을 읽고 os.Args[2]에 쓰게 해 보자.
Run은 한 글자도 고치지 않아야 한다. Close의 에러를 어디서 어떻게 받을지가
진짜 문제다 — 4-6의 writeReport가 답의 형태다.
3. 원자적 출력 모드 추가하기
Pipeline에 atomic bool 필드를 더해, 참이면 전부 성공했을 때만 dst에 쓰게 해
보자. 힌트: 안에서 strings.Builder에 모았다가 마지막에 io.Copy나
io.WriteString으로 옮긴다. 메모리 사용량 측면에서 무엇을 포기하는가?
4. 병합 단계 만들기
지금 각 단계는 한 줄을 받아 한 줄을 돌려준다. 한 줄을 받아 여러 줄을 돌려주거나
(분할), 여러 줄을 모아 한 줄을 돌려주는(병합) 단계는 이 인터페이스로 표현할 수
없다. Apply(line string) ([]string, error)로 바꾸면 어떻게 되는가?
표현력과 단순함 중 어느 쪽을 택하겠는가?
5. 에러를 위치별로 정렬해 보고하기
관대 모드의 errors.Join 결과는 발생 순서다. StageError.LineNo로 정렬해
출력해 보자. 힌트: Unwrap() []error로 꺼낸 뒤 slices.SortFunc을 적용한다.
*StageError가 아닌 에러가 섞여 있으면 어떻게 처리할 것인가?
6. 실패한 줄을 별도 출력으로 보내기
Run에 두 번째 io.Writer(예: rejects io.Writer)를 받아, 실패한 원본 줄을
그쪽에 쓰게 해 보자. nil을 넘기면 "버림"으로 동작해야 한다.
io.Discard와 nil 중 어느 쪽을 기본값으로 삼겠는가? 왜인가?
Part 4 정리
여덟 챕터를 관통하는 것은 하나다. Go의 추상화는 "무엇을 가졌는가"가 아니라 "무엇을 할 수 있는가"로 이루어진다.
| 도구 | 하는 일 |
|---|---|
| 메서드 | 타입에 동작을 붙인다 |
| 임베딩 | 동작을 물려받는다 (계층을 만드는 것이 아니다) |
| 인터페이스 | 필요한 동작만 적어서 구현을 갈아 끼운다 |
error | 실패도 그냥 값이라는 결론 |
그리고 그 위에 판단 기준들이 쌓인다.
- 리시버는 고쳐야 하는가·복사가 비싼가·
nil이 의미를 갖는가·일관성. - 임베딩은 안쪽 메서드를 바깥의 공개 API로 삼을 때만.
- 인터페이스는 소비자 쪽에서, 작게, 발견해서.
- 인터페이스를 받고 구조체를 반환한다.
- 에러는 받은 즉시 확인하고, 각 계층이 자기 맥락만 감싸고, 최상위에서 한 번 로깅한다.
반복해서 나온 함정도 정리해 두자. 전부 인터페이스가 (타입, 값) 쌍이라는 한 사실에서 나왔다.
nil포인터를 담은 인터페이스는nil이 아니다 (4-4, 4-8).- 인터페이스에 담긴 값은 주소를 얻을 수 없어 포인터 리시버 메서드를 못 부른다 (4-1).
- 인터페이스 비교는 동적 타입이 비교 불가능하면 패닉이다 (4-4).
- 인터페이스를 반환하면 위 셋이 전부 호출자에게 전가된다 (4-5).
Part 5에서는 제네릭을 다룬다. "동작이 다르면 인터페이스, 타입만 다르면 제네릭"이라는
경계선을 여기서 여러 번 언급했는데, 그 반대편이 무엇인지 보게 된다.
이미 errors.AsType[*StageError](err)에서 그 문법을 한 번 썼다.