본문으로 건너뛰기

문자열 처리

문자열 기초 복습

Go의 문자열(string)은 불변(immutable)의 바이트 시퀀스 입니다. 내부적으로는 []byte와 유사하지만, 한 번 생성되면 내용을 변경할 수 없습니다.

package main

import "fmt"

func main() {
s := "Hello, 세계"

// 문자열은 바이트 슬라이스로 변환 가능
b := []byte(s)
b[0] = 'h' // []byte는 수정 가능
fmt.Println(string(b)) // hello, 세계

// 원본 s는 변경되지 않음
fmt.Println(s) // Hello, 세계

// 문자열 인덱싱은 바이트 단위
fmt.Printf("s[0] = %d (%c)\n", s[0], s[0]) // 72 (H)
fmt.Printf("s[7] = %d\n", s[7]) // 236 (한글 '세'의 첫 바이트)
}

len(s) vs utf8.RuneCountInString(s)

len(s)는 문자열의 바이트 수 를 반환합니다. UTF-8에서 한글, 한자, 이모지 등 비ASCII 문자는 여러 바이트를 사용하므로, 실제 문자 수 와 다릅니다.

package main

import (
"fmt"
"unicode/utf8"
)

func main() {
examples := []string{
"Hello", // ASCII만
"안녕하세요", // 한글 5자
"Hello 世界", // ASCII + 한자
"Go 🚀", // ASCII + 이모지
}

fmt.Printf("%-15s %8s %8s\n", "문자열", "바이트 수", "문자 수")
fmt.Println("-----------------------------------")
for _, s := range examples {
byteLen := len(s)
runeLen := utf8.RuneCountInString(s)
fmt.Printf("%-15s %8d %8d\n", s, byteLen, runeLen)
}
}

실행 결과:

문자열         바이트 수   문자 수
-----------------------------------
Hello 5 5
안녕하세요 15 5
Hello 世界 12 9
Go 🚀 7 4

rune 순회 — range로 유니코드 문자 다루기

문자열을 range로 순회하면 바이트가 아닌 rune(유니코드 코드포인트) 단위로 순회합니다. 인덱스는 해당 rune의 바이트 위치, 값은 rune 자체입니다.

package main

import "fmt"

func main() {
s := "Go 언어"

// range로 rune 순회 (권장)
fmt.Println("=== range 순회 (rune 단위) ===")
for i, r := range s {
fmt.Printf("인덱스 %2d: 바이트위치=%2d, rune=%c, U+%04X\n",
i, i, r, r)
}

// 바이트 직접 순회 (비ASCII에서 깨짐 주의)
fmt.Println("\n=== 바이트 직접 순회 ===")
for i := 0; i < len(s); i++ {
fmt.Printf("s[%d] = 0x%02X\n", i, s[i])
}

// []rune 변환으로 인덱스 접근
runes := []rune(s)
fmt.Printf("\n총 문자 수: %d\n", len(runes))
fmt.Printf("마지막 문자: %c\n", runes[len(runes)-1])
}

실행 결과:

=== range 순회 (rune 단위) ===
인덱스 0: 바이트위치= 0, rune=G, U+0047
인덱스 1: 바이트위치= 1, rune=o, U+006F
인덱스 2: 바이트위치= 2, rune= , U+0020
인덱스 3: 바이트위치= 3, rune=언, U+C5B8
인덱스 6: 바이트위치= 6, rune=어, U+C5B4

strings 패키지 — 주요 함수 완전 정리

검색과 확인

package main

import (
"fmt"
"strings"
)

func main() {
s := "The quick brown fox jumps over the lazy dog"

// 포함 여부 확인
fmt.Println(strings.Contains(s, "fox")) // true
fmt.Println(strings.ContainsAny(s, "aeiou")) // true (모음 포함)
fmt.Println(strings.ContainsRune(s, '퀵')) // false

// 접두사/접미사
fmt.Println(strings.HasPrefix(s, "The")) // true
fmt.Println(strings.HasSuffix(s, "dog")) // true
fmt.Println(strings.HasSuffix(s, "cat")) // false

// 개수 세기
fmt.Println(strings.Count(s, "the")) // 1 (대소문자 구분)
fmt.Println(strings.Count(s, "o")) // 4

// 위치 찾기
fmt.Println(strings.Index(s, "fox")) // 16
fmt.Println(strings.LastIndex(s, "o")) // 41
fmt.Println(strings.Index(s, "cat")) // -1 (없으면 -1)
fmt.Println(strings.IndexByte(s, 'q')) // 4
fmt.Println(strings.IndexRune(s, '퀵')) // -1
}

변환

package main

import (
"fmt"
"strings"
)

func main() {
s := " Hello, Go World! "

// 대소문자 변환
fmt.Println(strings.ToUpper(s)) // " HELLO, GO WORLD! "
fmt.Println(strings.ToLower(s)) // " hello, go world! "
fmt.Println(strings.Title("hello world")) // "Hello World" (단어 첫 글자 대문자)

// 공백 제거
fmt.Printf("%q\n", strings.TrimSpace(s)) // "Hello, Go World!"
fmt.Printf("%q\n", strings.Trim("***hello***", "*")) // "hello"
fmt.Printf("%q\n", strings.TrimLeft("***hello***", "*")) // "hello***"
fmt.Printf("%q\n", strings.TrimRight("***hello***", "*")) // "***hello"

// 접두사/접미사 제거
url := "https://example.com"
fmt.Println(strings.TrimPrefix(url, "https://")) // example.com
fmt.Println(strings.TrimSuffix(url, ".com")) // https://example

// 교체
fmt.Println(strings.Replace("aababab", "a", "x", 2)) // "xxbabab" (2회만)
fmt.Println(strings.ReplaceAll("aababab", "a", "x")) // "xxbxbxb" (전체)

// 반복
fmt.Println(strings.Repeat("Go!", 3)) // "Go!Go!Go!"
}

분리와 결합

package main

import (
"fmt"
"strings"
)

func main() {
// Split — 구분자로 분리 (빈 문자열도 포함)
parts := strings.Split("a,b,c,d", ",")
fmt.Println(parts) // [a b c d]
fmt.Println(len(parts)) // 4

// SplitN — 최대 n개로 분리
parts2 := strings.SplitN("a:b:c:d", ":", 3)
fmt.Println(parts2) // [a b c:d]

// SplitAfter — 구분자를 결과에 포함
parts3 := strings.SplitAfter("a,b,c", ",")
fmt.Println(parts3) // [a, b, c]

// Fields — 공백으로 분리 (연속 공백 처리)
words := strings.Fields(" foo bar baz ")
fmt.Println(words) // [foo bar baz]

// Join — 슬라이스를 구분자로 합치기
joined := strings.Join([]string{"apple", "banana", "cherry"}, ", ")
fmt.Println(joined) // apple, banana, cherry

// 줄 분리
text := "첫 번째 줄\n두 번째 줄\n세 번째 줄"
lines := strings.Split(text, "\n")
for i, line := range lines {
fmt.Printf("줄 %d: %s\n", i+1, line)
}
}

strings.Builder — 효율적인 문자열 연결

+ 연산자로 문자열을 반복 연결하면 매번 새로운 메모리를 할당합니다. strings.Builder는 내부 버퍼를 재사용해 성능을 크게 향상시킵니다.

package main

import (
"fmt"
"strings"
"time"
)

func buildWithPlus(n int) string {
result := ""
for i := 0; i < n; i++ {
result += fmt.Sprintf("item%d,", i)
}
return result
}

func buildWithBuilder(n int) string {
var b strings.Builder
for i := 0; i < n; i++ {
fmt.Fprintf(&b, "item%d,", i)
}
return b.String()
}

func main() {
n := 10_000

start := time.Now()
_ = buildWithPlus(n)
fmt.Printf("+ 연산자: %v\n", time.Since(start))

start = time.Now()
_ = buildWithBuilder(n)
fmt.Printf("strings.Builder: %v\n", time.Since(start))

// strings.Builder 상세 사용법
var sb strings.Builder
sb.WriteString("Hello") // 문자열 추가
sb.WriteRune(',') // rune 추가
sb.WriteByte(' ') // byte 추가
sb.WriteString("World!") // 문자열 추가
fmt.Println(sb.String()) // Hello, World!
fmt.Println("길이:", sb.Len())

sb.Reset() // 버퍼 초기화 (메모리 재사용)
sb.WriteString("Reset!")
fmt.Println(sb.String())
}

fmt 패키지 — 포맷 동사 완전 정리

package main

import "fmt"

type Person struct {
Name string
Age int
}

func main() {
p := Person{"Alice", 30}
x := 255

// 일반 포맷
fmt.Printf("%v\n", p) // {Alice 30}
fmt.Printf("%+v\n", p) // {Name:Alice Age:30}
fmt.Printf("%#v\n", p) // main.Person{Name:"Alice", Age:30}
fmt.Printf("%T\n", p) // main.Person

// 정수 포맷
fmt.Printf("%d\n", x) // 255 (10진수)
fmt.Printf("%b\n", x) // 11111111 (2진수)
fmt.Printf("%o\n", x) // 377 (8진수)
fmt.Printf("%x\n", x) // ff (16진수 소문자)
fmt.Printf("%X\n", x) // FF (16진수 대문자)
fmt.Printf("%08b\n", x) // 11111111 (폭 8, 0으로 채움)
fmt.Printf("%-8d|\n", x) // "255 |" (왼쪽 정렬)
fmt.Printf("%+d\n", x) // +255 (항상 부호 표시)

// 부동소수점 포맷
f := 3.14159265
fmt.Printf("%f\n", f) // 3.141593 (기본 6자리)
fmt.Printf("%.2f\n", f) // 3.14 (소수점 2자리)
fmt.Printf("%e\n", f) // 3.141593e+00 (과학적 표기법)
fmt.Printf("%g\n", f) // 3.14159265 (간결한 표기)
fmt.Printf("%9.2f\n", f) // " 3.14" (폭 9, 소수점 2자리)

// 문자열 포맷
s := "Hello"
fmt.Printf("%s\n", s) // Hello
fmt.Printf("%q\n", s) // "Hello" (따옴표 포함)
fmt.Printf("%10s\n", s) // " Hello" (오른쪽 정렬)
fmt.Printf("%-10s|\n", s) // "Hello |" (왼쪽 정렬)

// 기타
fmt.Printf("%p\n", &x) // 포인터 주소 (예: 0xc000012088)
fmt.Printf("%c\n", 65) // A (유니코드 코드포인트)
fmt.Printf("%U\n", '한') // U+D55C
}

Sprintf, Fprintf, Errorf

package main

import (
"fmt"
"os"
"strings"
)

func main() {
// Sprintf — 문자열 반환
name := "Alice"
age := 30
msg := fmt.Sprintf("이름: %s, 나이: %d세", name, age)
fmt.Println(msg)

// Fprintf — io.Writer에 출력 (파일, 네트워크, 버퍼 등)
var sb strings.Builder
fmt.Fprintf(&sb, "Hello, %s!", name)
fmt.Println(sb.String())

// 표준 에러에 출력
fmt.Fprintf(os.Stderr, "경고: %s\n", "예상치 못한 값")

// Errorf — 에러 생성 (fmt.Errorf)
id := 42
err := fmt.Errorf("사용자 ID %d를 찾을 수 없음", id)
fmt.Println(err)

// %w로 에러 래핑 (Go 1.13+)
originalErr := fmt.Errorf("데이터베이스 연결 실패")
wrappedErr := fmt.Errorf("서비스 초기화 중 에러: %w", originalErr)
fmt.Println(wrappedErr)
}

strconv 패키지 — 타입 변환

strconv는 기본 타입을 문자열로 변환하거나, 문자열을 기본 타입으로 파싱하는 함수를 제공합니다.

package main

import (
"fmt"
"strconv"
)

func main() {
// 정수 변환
// Itoa: int → string (Integer to ASCII)
n := 42
s := strconv.Itoa(n)
fmt.Printf("Itoa(%d) = %q\n", n, s) // "42"

// Atoi: string → int (ASCII to Integer)
s2 := "123"
n2, err := strconv.Atoi(s2)
if err != nil {
fmt.Println("에러:", err)
} else {
fmt.Printf("Atoi(%q) = %d\n", s2, n2) // 123
}

// 잘못된 입력
_, err = strconv.Atoi("abc")
fmt.Println("Atoi error:", err) // strconv.Atoi: parsing "abc": invalid syntax

// ParseInt: 진법 지정, 비트 크기 지정
hex, _ := strconv.ParseInt("ff", 16, 64) // 16진수 파싱
fmt.Println("0xff =", hex) // 255

bin, _ := strconv.ParseInt("1010", 2, 64) // 2진수 파싱
fmt.Println("0b1010 =", bin) // 10

// FormatInt: 정수 → 지정 진법 문자열
fmt.Println(strconv.FormatInt(255, 2)) // "11111111"
fmt.Println(strconv.FormatInt(255, 16)) // "ff"
fmt.Println(strconv.FormatInt(255, 8)) // "377"

// 부동소수점 변환
f := 3.14159
fStr := strconv.FormatFloat(f, 'f', 2, 64) // 소수점 2자리
fmt.Println(fStr) // "3.14"

f2, _ := strconv.ParseFloat("3.14159", 64)
fmt.Println(f2) // 3.14159

// bool 변환
bStr := strconv.FormatBool(true)
fmt.Println(bStr) // "true"

b2, _ := strconv.ParseBool("true")
b3, _ := strconv.ParseBool("1")
b4, _ := strconv.ParseBool("yes") // 에러 발생 ("yes"는 지원 안 함)
fmt.Println(b2, b3, b4)
}

실전 예제 1: CSV 파싱

package main

import (
"fmt"
"strconv"
"strings"
)

type Student struct {
Name string
Age int
Score float64
Grade string
}

func parseCSV(line string) (Student, error) {
parts := strings.Split(line, ",")
if len(parts) != 4 {
return Student{}, fmt.Errorf("잘못된 CSV 형식: %q (필드 수: %d)", line, len(parts))
}

// 각 필드 파싱
name := strings.TrimSpace(parts[0])

age, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return Student{}, fmt.Errorf("나이 파싱 실패: %w", err)
}

score, err := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
if err != nil {
return Student{}, fmt.Errorf("점수 파싱 실패: %w", err)
}

grade := strings.TrimSpace(parts[3])

return Student{Name: name, Age: age, Score: score, Grade: grade}, nil
}

func main() {
csvData := `Alice, 20, 95.5, A
Bob, 22, 78.3, B
Charlie, 21, 88.0, B+
invalid line
Dave, 19, 92.7, A-`

lines := strings.Split(csvData, "\n")
var students []Student

for i, line := range lines {
student, err := parseCSV(line)
if err != nil {
fmt.Printf("줄 %d 파싱 에러: %v\n", i+1, err)
continue
}
students = append(students, student)
}

fmt.Println("\n=== 학생 목록 ===")
for _, s := range students {
fmt.Printf("%-10s 나이:%2d 점수:%5.1f 등급:%s\n",
s.Name, s.Age, s.Score, s.Grade)
}

// 평균 점수 계산
total := 0.0
for _, s := range students {
total += s.Score
}
fmt.Printf("\n평균 점수: %.2f\n", total/float64(len(students)))
}

실전 예제 2: 한글 문자열 처리

package main

import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)

// 한글만 포함하는지 확인
func isKoreanOnly(s string) bool {
for _, r := range s {
if !unicode.Is(unicode.Hangul, r) {
return false
}
}
return len(s) > 0
}

// 문자열을 rune 단위로 뒤집기
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}

// n번째 문자(rune) 반환
func charAt(s string, n int) (rune, error) {
runes := []rune(s)
if n < 0 || n >= len(runes) {
return 0, fmt.Errorf("인덱스 %d가 범위를 벗어남 (길이: %d)", n, len(runes))
}
return runes[n], nil
}

func main() {
text := "안녕하세요, Go 언어!"

fmt.Printf("원본: %s\n", text)
fmt.Printf("바이트 수: %d\n", len(text))
fmt.Printf("문자 수: %d\n", utf8.RuneCountInString(text))
fmt.Printf("역순: %s\n", reverseString(text))

// 3번째 문자
r, err := charAt(text, 2)
if err != nil {
fmt.Println("에러:", err)
} else {
fmt.Printf("3번째 문자: %c\n", r) // 하
}

// 한글 여부 확인
tests := []string{"안녕", "Hello", "안녕Hello", "한글123"}
for _, t := range tests {
fmt.Printf("%q: 한글전용=%v\n", t, isKoreanOnly(t))
}

// 초성 추출 (한글 자음/모음 분리의 기초)
koreanWords := []string{"가나다", "Go언어", "프로그래밍"}
for _, w := range koreanWords {
words := strings.Fields(w)
_ = words
fmt.Printf("%s: %d글자\n", w, utf8.RuneCountInString(w))
}
}

고수 팁

팁 1: 문자열 연결이 많으면 항상 strings.Builder를 사용하라

루프 안에서 +=로 문자열을 쌓으면 O(n²) 시간복잡도가 됩니다. 10개 이상 연결한다면 strings.Builder를 사용하세요.

// 나쁜 예: O(n²)
result := ""
for _, item := range items {
result += item + ","
}

// 좋은 예: O(n)
var sb strings.Builder
for _, item := range items {
sb.WriteString(item)
sb.WriteByte(',')
}
result := sb.String()

팁 2: []byte 연산 후 string으로 변환하라

문자열을 반복적으로 수정해야 한다면 []byte로 변환해 수정한 후, 마지막에 한 번만 string()으로 변환하세요.

b := []byte(original)
// b에 대해 여러 수정 작업
result := string(b) // 마지막에 한 번만 변환

팁 3: fmt.Sprintf 대신 strconv가 더 빠르다

단순한 숫자 → 문자열 변환은 strconv.Itoastrconv.FormatFloatfmt.Sprintf보다 훨씬 빠릅니다.

// 느린 방법
s := fmt.Sprintf("%d", n)

// 빠른 방법
s := strconv.Itoa(n)

팁 4: 문자열 비교에 대소문자를 무시하려면 strings.EqualFold

// 대소문자 구분
strings.ToLower(a) == strings.ToLower(b) // 두 번 변환 — 비효율

// 권장
strings.EqualFold(a, b) // 단일 패스, 유니코드 지원

팁 5: 문자열이 특정 패턴인지 확인할 때는 strings.ContainsAny 활용

// 문자열에 숫자가 포함되어 있는지
hasDigit := strings.ContainsAny(s, "0123456789")

// 특수문자 포함 여부
hasSpecial := strings.ContainsAny(s, "!@#$%^&*()")