[Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다
이 글의 핵심
[Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다. 시리즈 안내·안전한 포인터의 세계.
시리즈 안내
📚 Go 2주 완성 시리즈 #02 | 전체 목차 보기
이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 3~4 내용입니다.
이전: #01 기본 문법 ← | → 다음: #03 객체지향
나 Go 처음 배울 때 얘기부터 할게
솔직히 C++만 하다가 Go 문서 처음 펼쳤을 때는 “이거 언어 맞아?” 수준이었다. 헤더도 없고, :=만 써도 변수가 생기고, 에러는 왜 계속 if err != nil이냐 같은 것도 있었고. 그런데 막상 슬라이스에서 같은 배열을 두 슬라이스가 공유하는 걸 모르고 한 번 난리 난 적이 있다. 원본만 고친 줄 알았는데 옆 함수에서 쓰던 뷰까지 같이 바뀌어서, 그때 “아, Go는 편한 대가가 있구나”를 몸으로 깨달았다. 이 글은 그때 내가 정리했으면 덜 당황했을 것들 위주로 갈 거다. 교과서처럼 완벽하게 다 안 적는다. 실전에서 자주 박는 지점만 짚는다.
들어가며: 안전한 포인터의 세계
C++에서는 포인터 연산(p++, p + offset)으로 메모리를 자유롭게 탐색할 수 있지만, 그만큼 세그폴트도 같이 온다. Go는 포인터는 있지만 포인터 연산은 없다. 나는 이게 “표현력을 죽인다”기보다 팀 코드에서 살아남기 쉬운 쪽이라고 본다. 여기서는 포인터·슬라이스·맵을 C++ 감각으로만 붙잡고 가면 된다.
이번에 집중할 것: 포인터 제한, 값/포인터 전달, 슬라이스 len·cap·공유, 맵의 ok 패턴.
실무에서의 체감 (주관적)
Go를 “도입하면 무조건 빨라진다”라고 말하고 싶진 않다. 다만 망가지기 어려운 단순함은 체감된다. GC 덕에 delete 체질을 안 가져가도 되고, 단일 바이너리로 굴리기 좋다. 반대로 말하면, C++에서 익숙한 미세한 메모리 튜닝의 맛은 기대하지 말라. 그 대신 리뷰할 때 “이 포인터 지금 누가 소유하지?” 같은 밤샘은 줄어든다.
1. 포인터: 연산은 없지만 역참조는 있다
C++ vs Go: 포인터 기본
// C++: 포인터 연산 가능
int x = 10;
int* p = &x;
*p = 20; // 역참조
p++; // 포인터 연산 (다음 int 위치)
*(p + 5) = 30; // 오프셋 접근
int arr[10];
int* ptr = arr;
ptr[5] = 100; // 배열 인덱싱 = 포인터 연산
// Go: 포인터 연산 불가
x := 10
p := &x
*p = 20 // ✅ 역참조 가능
// p++ // ❌ 컴파일 에러: 포인터 연산 불가
// *(p + 5) // ❌ 컴파일 에러
// 배열 접근은 인덱스로
arr := [10]int{}
arr[5] = 100 // ✅ 인덱스 접근
내 생각엔 “편의가 아니라 사고방지”에 가깝다. 오프셋 놀이를 못 하게 막아서 대신 인덱스와 슬라이스 문법으로만 가게 만든 거다.
C++ vs Go: 함수 인자 전달
// C++: 값, 포인터, 참조
void byValue(int x) {
x = 100; // 원본 변경 안 됨
}
void byPointer(int* p) {
*p = 100; // 원본 변경됨
}
void byReference(int& r) {
r = 100; // 원본 변경됨
}
int main() {
int x = 10;
byValue(x); // x는 여전히 10
byPointer(&x); // x는 100
byReference(x); // x는 100
}
// Go: 값 또는 포인터 (참조 없음)
func byValue(x int) {
x = 100 // 원본 변경 안 됨
}
func byPointer(p *int) {
*p = 100 // 원본 변경됨
}
func main() {
x := 10
byValue(x) // x는 여전히 10
byPointer(&x) // x는 100
}
내 기준: 작은 타입은 그냥 값으로 넘기고, 바꿀 거면 포인터. “큰 구조체면 무조건 포인터” 같은 말도 많이 나오는데, 팀 컨벤션에 맞추는 게 제일 크다. 일관성이 안 그러면 리뷰가 지옥이다.
nil 포인터
// C++: nullptr
int* p = nullptr;
if (p == nullptr) {
std::cout << "null pointer\n";
}
// 역참조 시 세그멘테이션 폴트
// *p = 10; // 크래시!
// Go: nil
var p *int
if p == nil {
fmt.Println("nil pointer")
}
// 역참조 시 패닉
// *p = 10 // panic: runtime error: invalid memory address
2. 배열: 고정 크기의 값 타입
// C++: 배열
int arr[5] = {1, 2, 3, 4, 5};
int size = sizeof(arr) / sizeof(arr[0]); // 5
void process(int* arr, int size) {
// ...
}
// Go: 배열 (크기가 타입의 일부)
var arr [5]int = [5]int{1, 2, 3, 4, 5}
arr := [...]int{1, 2, 3, 4, 5}
length := len(arr) // 5
func process(arr [5]int) {
// 배열 전체가 복사됨
}
[5]int랑 [10]int는 다른 타입이다. 함수에 넘기면 통째로 복사된다. 그래서 실무 코드에서 배열을 직접 만지는 빈도는 솔직히 낮다. 슬라이스가 주인공이다.
3. 슬라이스: Go의 동적 배열
Slice는 말 그대로 많이 쓴다. C++의 std::vector랑 비슷하다고만 생각하면 초반엔 편한데, 내부 배열을 공유한다는 점만큼은 vector 감각 그대로 두면 큰코다.
// C++: std::vector
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
std::cout << "Size: " << vec.size() << "\n";
std::cout << "Capacity: " << vec.capacity() << "\n";
vec.reserve(100);
for (const auto& v : vec) {
std::cout << v << " ";
}
}
// Go: Slice
package main
import "fmt"
func main() {
var slice []int // nil 슬라이스
slice = append(slice, 1)
slice = append(slice, 2)
slice = append(slice, 3)
fmt.Println("Length:", len(slice))
fmt.Println("Capacity:", cap(slice))
slice2 := make([]int, 0, 100) // len=0, cap=100
for i, v := range slice {
fmt.Println(i, v)
}
}
Slice의 내부 구조
// Slice는 내부적으로 3개 필드를 가진 구조체
type slice struct {
ptr *[...]T // 배열 포인터
len int // 현재 길이
cap int // 용량
}
graph LR
A[Slice] --> B[ptr: 배열 포인터]
A --> C[len: 길이]
A --> D[cap: 용량]
B --> E[실제 배열 메모리]
Slice 생성·슬라이싱
package main
import "fmt"
func main() {
var s1 []int
fmt.Println(s1 == nil) // true
s2 := []int{1, 2, 3}
s3 := make([]int, 5)
s4 := make([]int, 5, 10)
arr := [5]int{1, 2, 3, 4, 5}
s5 := arr[1:4] // 원본 배열 공유
fmt.Println(s1, s2, s3, s4, s5)
}
// C++: 부분은 보통 복사본을 만든다
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> sub(vec.begin() + 1, vec.begin() + 4);
// Go: 슬라이싱은 원본 공유
slice := []int{1, 2, 3, 4, 5}
sub := slice[1:4]
sub[0] = 100 // slice[1]도 같이 바뀜
copied := make([]int, len(sub))
copy(copied, sub)
append는 cap 넘으면 새 배열 잡고 옮긴다. 루프에서 append만 백만 번 돌리면 그냥 망한다. 크기 대략 알 거면 make([]T, 0, n) 한 방이 정신 건강에 이롭다.
// capacity 초과 시 재할당되는 흐름만 기억하면 됨
slice := make([]int, 0, 3)
slice = append(slice, 1, 2, 3)
slice = append(slice, 4) // 여기서 cap 늘어남(구현에 따라 다름)
4. Map: 해시 테이블
C++ unordered_map 감각으로 가면 되는데, Go는 없는 키를 읽으면 제로 값이고 C++처럼 조용히 삽입되진 않는다. 그리고 순서 보장 없다. 테스트에서 map 출력 순서에 의존하면 피 본다.
m := make(map[string]int)
m["a"] = 1
if v, ok := m["a"]; ok {
_ = v
}
delete(m, "a")
v, ok := m[k] 패턴은 익숙해지면 손이 간다. 나는 “그냥 m[k]만 읽어도 되지?” 하다가 타입이 int면 0이 진짜 값인지 없는 건지 구분이 안 될 때가 있어서, 의심스러우면 무조건 ok 쪽이 마음 편하다.
5. 실습은 이 정도만
과제 네 개 풀어보라는 식으로 늘리는 건 좋아하지 않는다. 역순 뒤집기 하나만 보여주고 나머지는 “슬라이스·맵으로 익숙해질 때까지 직접” 쪽이 낫다고 본다.
package main
import "fmt"
func reverse(slice []int) {
for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
slice[i], slice[j] = slice[j], slice[i]
}
}
func main() {
nums := []int{1, 2, 3, 4, 5}
reverse(nums)
fmt.Println(nums)
}
정리하면
- 포인터 연산 없음 → 역참조·
&만. 의도적으로 C스타일 산책 막음. - 배열은 크기가 타입. 실무에선 슬라이스.
- 슬라이스는 len / cap / 공유 세 마디만 머리에 박아라.
- 맵은
ok를 기본으로 습관화하는 게 안전하다.
C++에서 오면 대략 이렇게 매핑하면 된다: int* p; p++ 같은 건 없고 p := &x만 쓴다. std::vector 느낌은 []T인데 슬라이싱은 공유다. unordered_map은 map[K]V, 조회는 find 대신 v, ok := m[k]가 덜 지저분하다. 이게 다가 아니지만, Day 3~4에서 삽질 줄이기엔 충분하다.
다음 글
Day 3~4는 여기까지. 다음은 클래스 없는 객체지향—상속 말고 합성 쪽으로 간다. → #03 객체지향
이전 ← #01 기본 문법 · 목차 📑 전체 · 다음 #03 객체지향 →
Go 2주 완성 시리즈: 커리큘럼 · #01 · #02 · #03 · #04 · #05 · #06 · #07 · #08 · #09
한 줄 요약: 포인터는 안전하게 막아놨고, 슬라이스는 편한 대신 공유를 이해해야 하고, 맵은 ok가 정답에 가깝다. C++보다 덜 화려하지만 밤에 덜 운다.
같이 보면 좋은 글
- [Go 2주 완성 #01] Day 1~2: 기본 문법
- 2주 커리큘럼 전체
실전에서 나는 이렇게만 본다
go fmt는 당연하고, 에러 삼키지 말고 if err != nil은 그냥 체질로. 테스트는 go test ./... 돌릴 수 있을 때 돌린다. 벤치는 “느리다”는게 감으로 올 때만.
자주 묻는 질문 (짧게)
Q. 이걸 실무에서 언제 쓰나?
A. HTTP 핸들러 짜다 보면 슬라이스·맵 매일 나온다. 여기서 len/cap이랑 공유만 이해도 디버깅 시간이 확 줄어든다.
Q. 선행 글은?
A. #01부터 순서대로가 제일 덜 헷갈린다.
관련 글
- 2주 완성 커리큘럼
- C++ vs Go 비교