Go 언어의 string은 정말로 immutable일까?

김성현·2022년 2월 9일
2
post-thumbnail

우선 시작하기 전 이 글은 단순한 호기심에 따른 실험일 뿐이고 가능한지 아닌지만 확인할 뿐 실용적인 용도는 전혀 없는 단순 심심풀이에 불과하다.

Go 언어의 string

우선 시작하기 전, Go 언어의 string은 immutable, 즉 수정 가능하지 않다.

예를들어 아래 코드를 보자.

func main(){
	var a = "Hello"
    a[0] = 'A'
    fmt.Println(a)
}

이 코드는 C같은 언어였다면 분면 Aello라고 출력되었을 것이다.
하지만 Go에서는 컴파일 단계에서 아예 실패한다.

이는 프로그래밍적으로 불가능한 것이 아닌 언어적으로 제한한 것에 불과하다.

따라서 이 코드를 리플렉션과 불안전한 코드를 이용해 강제로 수정하는 것이 가능하다.

그래서 이 제한을 우회해 코드를 작성하면 string을 mutable 하게 사용이 가능하다.

이를 위한 코드는 아래와 같다.

	var a = "Hello"
	{
		aa := (*reflect.StringHeader)(unsafe.Pointer(&a))
		baa := unsafe.Slice((*byte)(unsafe.Pointer(aa.Data)), 5)
		baa[0] = 'A'
	}
	fmt.Println(a)

그런데 이 코드를 실행해 보면 예상과는 다르게 실패할 것이다.

그렇다면 왜 실패하는지 이유를 알아보고 이를 회피하는 방법을 알아보자.

우선 실패 메시지를 보면 다음과 같다.

우선 미리 이야기하면 지금 이 실험이 진행중인 환경은 윈도우즈이다.

그러면 0xc0000005시그널은 msdn에서 어떤 상황에 나오는 에러인지 찾아보자.

MSDN Access Violation Error에 따르면 이 에러는 메모리 엑세스에 대한 규칙 위반이라고 한다.

특히 에러 매개변수가 0x1과 함께 주어지면 쓰기에러, 즉 쓰기가 금지된 메모리 영역에 쓰기 작업을 요청한 경우 나는 에러임을 알려준다.

이를 통해 유추해 보자면 Go 컴파일러는 상수 string을 인식해, Go 내부의 문자열들은 모두 immutable이라고 규칙을 정했으므로 함부로 프로그래머가 손을 대지 못하게 프로그램이 로딩될때 읽기 전용으로 설정된 메모리 영역에 로딩시키는 것 같다.

따라서 위의 코드가 실패했던 이유는 논리적인 오류가 아닌 운영체제와 메모리 관리 방법에 따른 실패임을 알 수 있었다.

하지만 이대로 끝나면 재미없다. 어떻게 이를 회피할 방법이 없을까?

그래서 나는 문자열이 컴파일러가 상수로 인식하지 못하면 읽기 전용으로 설정된 페이지가 아닌 일반 영역에 로딩될 것이라 생각해 다음과 같이 코드를 변경해 보았다.


	const pre = "He"
	const post = "llo"
	var a = pre + post
	{
		aa := (*reflect.StringHeader)(unsafe.Pointer(&a))
		baa := unsafe.Slice((*byte)(unsafe.Pointer(aa.Data)), 5)
		baa[0] = 'A'
	}
	fmt.Println(a)


결과는 뭐... 생각보다 컴파일러는 똑똑해서 상수의 합 역시 컴파일러가 상수로 인식하는 것 같다는 결론을 얻었다. 그래서 절대 상수로 인식 못하도록 함수로 변경해서 실험을 진행해 보았다.

코드는 아래와 같다.

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	const H = "Hello"
	const W = "World"
	help(H, W)
}

func help(a string, b string) {
	var vstr = a + ", " + b
	fmt.Println(vstr)
	{
		a := (*reflect.StringHeader)(unsafe.Pointer(&vstr))
		bts := unsafe.Slice((*byte)(unsafe.Pointer(a.Data)), 5)
		bts[0] = byte('A')
	}
	fmt.Println(vstr)
}

실험 결과는 위와 같다.

강제로 일반 함수의 형태로 만듬으로서 vstr값을 상수로 추론 불가능하게 만들었더니 예상대로 immutable이라고 알려진 문자열 값을 강제로 수정하는데 성공했다.

또는 아래와 같이 동적 할당된 메모리 영역을 string으로 형변환 하는 것으로도 수정 가능하게 만들 수 있다.

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	const H = "Hello"
	const W = "World"

	var buf = make([]byte, len(W)+len(H))
	copy(buf, H)
	copy(buf[len(H):], W)
	var vstr = string(buf)

	fmt.Println(vstr)
	{
		a := (*reflect.StringHeader)(unsafe.Pointer(&vstr))
		bts := unsafe.Slice((*byte)(unsafe.Pointer(a.Data)), 5)
		bts[0] = byte('A')
	}
	fmt.Println(vstr)
}

이 역시도 위와 같이 string 값을 변경하게 된다.

즉 한마디로 요약하면 go의 string은 immutable일 수도, 아닐 수도 있다.
다만 정상적인 문법 사용(unsafe, reflect 사용을 하지 않는 경우)을 할 경우에는 immutable이다.


Go에서는 string을 immutable로 처리한다.
이렇게 처리하는 이유는 아마 string을 기본 자료형으로 취급하기 때문이라고 생각한다.

기존 C, C++, JAVA 에선 string은 기본 자료형이라고 부르기는 애매한 위치였는데 Go에서는 마치 1, 2, 3 같은 숫자형 상수를 다루듯이 문자도 기본 자료형처럼 취급하기 위해 내부 동작을 숨기고 이러한 구조를 채택했다고 여겨진다.

개인적으로는 문자열을 정말 기본 자료형처럼 immutable 한 형태로 다루는 것은 언어적으로 큰 장점이라고 생각된다.

하지만 이런 식으로 구현한다면 문자열의 단순 수정 작업은 귀찮아 질 것 같다.
예를 들어 카이사르 암호 같은 알고리즘을 구현할 때는 이런 특정 위치의 값을 변경하는 작업이 매우 자주 일어나는데, 이런 경우에는 string이 매우 불리할 것이다.

그렇게 생각해 보면 Go를 배울 때 string 타입은 3타입으로 자유롭게 형 변환이 가능하다고 배웠다.

  • 정말 기본적인 문자열 구현에 대한 string 타입
  • 유니코드 단위로 구성된 슬라이스인 []rune타입
  • 그리고 인코딩을 고려치 않은 정말 말 그대로 저수준 제어를 위한 []byte

처음 배울때는 왜 string 하나로 퉁치는 게 아니라, 문자열을 다루는 타입을 3개나 둔 걸까? 라고 생각했는데 string을 immutable한 원시 자료형처럼 취급하기에 랜덤한 쓰기 작업이 비효율적이라 3개로 분리한 것이 아닐까 라는 생각이 들었다.

즉 일반적인 문자열 연산은 string 타입으로 진행하되, 만약 복잡하거나 자주 읽기 쓰기 작업이 일어나는 연산을 필요로 한다면 []rune이나 []byte 타입으로 하라고 만든 것은 아닐까?

생각해 보면 모든 것에는 이유가 있다는 생각이 든다.

profile
수준 높은 기술 포스트를 위해서 노력중...

1개의 댓글

좋은 글 감사합니다~

답글 달기