Skip to content

Go 문자열

Go 에서 문자열은 본질적으로 불변의 읽기 전용 바이트 시퀀스입니다. 여기서 '바이트 시퀀스'란 문자열의 하단 데이터가 순서대로 배열된 바이트로 구성되며, 이 바이트들은 연속된 메모리 공간을 차지합니다.

리터럴

앞서 문자열에는 두 가지 리터럴 표현 방식이 있다고 언급했습니다. 일반 문자열과 원시 문자열입니다.

일반 문자열

일반 문자열은 "" 큰따옴표로 표시되며, 이스케이프를 지원하고 다중 행 작성을 지원하지 않습니다. 다음은 일반 문자열의 예시입니다.

go
"이것은 일반 문자열입니다\n"
"abcdefghijlmn\nopqrst\t\\uvwxyz"
이것은 일반 문자열입니다
abcdefghijlmn
opqrst  \uvwxyz

원시 문자열

원시 문자열은 백쿼트로 표시되며, 이스케이프를 지원하지 않고 다중 행 작성을 지원합니다. 원시 문자열 내의 모든 문자는 줄바꿈과 들여쓰기를 포함하여 그대로 출력됩니다.

go
`이것은 원시 문자열입니다, 줄바꿈
  탭 들여쓰기, \t 탭 문자지만 무효함, 줄바꿈
  "이것은 일반 문자열입니다"

  종료
`
이것은 원시 문자열입니다, 줄바꿈
  탭 들여쓰기, \t 탭 문자지만 무효함, 줄바꿈
  "이것은 일반 문자열입니다"

  종료

액세스

문자열은 본질적으로 바이트 시퀀스이므로 인덱스 연산 str[i] 는 i 번째 바이트를 반환하도록 설계되었습니다. 문법적으로 슬라이스와 일치합니다. 예를 들어 문자열의 첫 번째 요소에 액세스합니다.

go
func main() {
   str := "this is a string"
   fmt.Println(str[0])
}

출력은 문자가 아닌 바이트 인코딩 값입니다.

116

문자열 자르기

go
func main() {
   str := "this is a string"
   fmt.Println(string(str[0:4]))
}
this

문자열 요소 수정 시도

go
func main() {
   str := "this is a string"
   str[0] = 'a' // 컴파일 통과 불가
   fmt.Println(str)
}
main.go:7:2: cannot assign to str[0] (value of type byte)

문자열을 수정할 수는 없지만 덮어쓸 수는 있습니다.

go
func main() {
   str := "this is a string"
   str = "that is a string"
   fmt.Println(str)
}
that is a string

변환

문자열은 바이트 슬라이스로 변환할 수 있으며, 바이트 슬라이스나 바이트 시퀀스도 문자열로 변환할 수 있습니다. 예시는 다음과 같습니다.

go
func main() {
   str := "this is a string"
   // 명시적 타입 변환으로 바이트 슬라이스
   bytes := []byte(str)
   fmt.Println(bytes)
   // 명시적 타입 변환으로 문자열
   fmt.Println(string(bytes))
}

문자열의 내용은 읽기 전용이며 불변이지만, 바이트 슬라이스는 수정할 수 있습니다.

go
func main() {
  str := "this is a string"
  fmt.Println(&str)
  bytes := []byte(str)
    // 바이트 슬라이스 수정
  bytes = append(bytes, 96, 97, 98, 99)
    // 원본 문자열에 할당
  str = string(bytes)
  fmt.Println(str)
}

문자열을 바이트 슬라이스로 변환한 후 두 사이에는 아무런 연관성이 없습니다. Go 는 바이트 슬라이스에 새 메모리 공간을 할당한 후 문자열의 메모리를 복사하기 때문입니다. 바이트 슬라이스를 수정해도 원본 문자열에는 아무런 영향을 미치지 않습니다. 이는 메모리 안전을 위한 것입니다.

이 경우 변환할 문자열이나 바이트 슬라이스가 크면 성능 오버헤드가 높아집니다. 하지만 unsafe 패키지를 사용하여 복사 없는 변환을 구현할 수 있습니다. 다만 뒤따르는 보안 문제는 스스로 감수해야 합니다. 예를 들어 아래 코드에서 b1 과 s1 의 주소는 동일합니다.

go
func main() {
  s1 := "hello world"
  b1 := unsafe.Slice(unsafe.StringData(s1), len(s1))
  fmt.Printf("%p %p", unsafe.StringData(s1), unsafe.SliceData(b1))
}
0xe27bb2 0xe27bb2

길이

문자열의 길이는 실제로 문자의 개수가 아니라 바이트 시퀀스의 길이입니다. 대부분의 경우 ASCII 문자를 처리하므로 각 문자가 정확히 하나의 바이트로 표현되어 바이트 길이와 문자 개수가 우연히 일치합니다. 문자열 길이를 구할 때는 내장 함수 len 을 사용합니다. 예시는 다음과 같습니다.

go
func main() {
   str := "this is a string" // 길이가 16 으로 보임
   str2 := "이것은 문자열입니다" // 길이가 7 로 보임
   fmt.Println(len(str), len(str2))
}
16 21

중국어 문자열이 영어 문자열보다 짧아 보이지만 실제로 구한 길이는 영어 문자열보다 깁니다. 이는 unicode 인코딩에서 한자가 대부분의 경우 3 바이트를 차지하고 영어 문자는 1 바이트만 차지하기 때문입니다. 문자열의 첫 번째 요소를 출력하면 결과를 알 수 있습니다.

go
func main() {
   str := "this is a string"
   str2 := "이것은 문자열입니다"
   fmt.Println(string(str[0]))
   fmt.Println(string(str2[0]))
   fmt.Println(string(str2[0:3]))
}
t // 알파벳 t
ì // 한자 '이' 의 '조각' (첫 번째 바이트) 인코딩 값, 우연히 이탈리아어 문자 è 의 인코딩 값과 동일함
이 // 한자

복사

배열 슬라이스 복사 방식과 유사하게 문자열 복사는 실제로 바이트 슬라이스 복사입니다. 내장 함수 copy 를 사용합니다.

go
func main() {
   var dst, src string
   src = "this is a string"
   desBytes := make([]byte, len(src))
   copy(desBytes, src)
   dst = string(desBytes)
   fmt.Println(src, dst)
}

strings.Clone 함수를 사용할 수도 있지만, 실제로 내부 구현은 비슷합니다.

go
func main() {
   var dst, src string
   src = "this is a string"
   dst = strings.Clone(src)
   fmt.Println(src, dst)
}

연결

문자열 연결은 + 연산자를 사용합니다.

go
func main() {
   str := "this is a string"
   str = str + " that is a int"
   fmt.Println(str)
}

바이트 슬라이스로 변환한 후 요소를 추가할 수도 있습니다.

go
func main() {
   str := "this is a string"
   bytes := []byte(str)
   bytes = append(bytes, "that is a int"...)
   str = string(bytes)
   fmt.Println(str)
}

위 두 가지 연결 방식은 성능이 매우 나쁩니다. 일반적으로 사용할 수 있지만, 더 높은 성능이 요구되는 경우 strings.Builder 를 사용할 수 있습니다.

go
func main() {
   builder := strings.Builder{}
   builder.WriteString("this is a string ")
   builder.WriteString("that is a int")
   fmt.Println(builder.String())
}
this is a string that is a int

순회

문서 시작 부분에서 언급했듯이, Go 의 문자열은 읽기 전용 바이트 슬라이스입니다. 즉, 문자열의 구성 단위는 바이트이며 문자가 아닙니다. 이러한 경우는 문자열을 순회할 때 자주 발생합니다. 예를 들어 아래 코드와 같습니다.

go
func main() {
  str := "hello world!"
  for i := 0; i < len(str); i++ {
    fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))
  }
}

예시에서는 바이트의 10 진수 형식과 16 진수 형식을 각각 출력했습니다.

104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
119,77,w
111,6f,o
114,72,r
108,6c,l
100,64,d
33,21,!

예시의 문자가 모두 ASCII 문자이므로 하나의 바이트로 표현할 수 있어 우연히 각 바이트가 하나의 문자에 해당합니다. 하지만 비 ASCII 문자가 포함되면 결과가 다릅니다. 예를 들어

go
func main() {
  str := "hello 세계!"
  for i := 0; i < len(str); i++ {
    fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))
  }
}

일반적으로 한자는 3 바이트를 차지하므로 다음과 같은 결과를 볼 수 있습니다.

104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
228,e4,ä
184,b8,¸
150,96,–
231,e7,ç
149,95,•
140,8c,Œ
33,21,!

바이트 단위로 순회하면 한자가 분리되어 인코딩 오류가 발생할 수 있습니다. Go 문자열은 명시적으로 UTF-8 을 지원하므로, 이러한 경우 rune 타입을 사용해야 합니다. for range 를 사용하여 순회할 때 기본 순회 단위는 rune 입니다. 예를 들어 아래 코드와 같습니다.

go
func main() {
   str := "hello 세계!"
   for _, r := range str {
      fmt.Printf("%d,%x,%s\n", r, r, string(r))
   }
}

출력은 다음과 같습니다.

104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
19990,4e16,세
30028,754c,계
33,21,!

rune 은 본질적으로 int32 타입의 별칭입니다. Unicode 문자 집합의 범위는 0x0000 - 0x10FFFF 사이이며, 최대 3 바이트만 필요합니다. 합법적인 UTF-8 인코딩의 최대 바이트 수는 4 바이트이므로 int32 를 사용하여 저장하는 것은 당연합니다. 위의 예시에서 문자열을 []rune 으로 변환한 후 순회하는 것도 같은 원리입니다. 예를 들어

go
func main() {
   str := "hello 세계!"
   runes := []rune(str)
   for i := 0; i < len(runes); i++ {
      fmt.Println(string(runes[i]))
   }
}

utf8 패키지의 도구를 사용할 수도 있습니다. 예를 들어

go
func main() {
  str := "hello 세계!"
  for i, w := 0, 0; i < len(str); i += w {
    r, width := utf8.DecodeRuneInString(str[i:])
    fmt.Println(string(r))
    w = width
  }
}

이 두 예시의 출력은 모두 동일합니다.

TIP

문자열에 대한 더 많은 세부사항은 Strings, bytes, runes and characters in Go 에서 확인하실 수 있습니다.

Golang by www.golangdev.cn edit