[Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다
이 글의 핵심
Go의 포인터·슬라이스·맵을 C++ 포인터·vector·map과 비교합니다. len·cap·append와 키 존재 확인(ok)까지 실전 패턴으로 정리합니다. 시리즈 Day 3~4입니다.
시리즈 안내
📚 Go 2주 완성 시리즈 #02 | 전체 목차 보기
이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 3~4 내용입니다.
이전: #01 기본 문법 ← | → 다음: #03 객체지향
들어가며: 안전한 포인터의 세계
C++에서는 포인터 연산(p++, p + offset)으로 메모리를 자유롭게 탐색할 수 있지만, 동시에 세그멘테이션 폴트의 공포도 함께했습니다. Go는 포인터는 있지만 포인터 연산은 없습니다. 안전성을 택한 것입니다. 이 글에서는 Go의 포인터와 핵심 자료구조인 Slice, Map을 C++과 비교하며 배웁니다.
이 글에서 배울 내용:
- Go 포인터의 제한과 안전성
- Call by Value vs Call by Reference
- Slice의 Length와 Capacity 이해
- Map의 활용과 주의사항
실무에서의 체감
C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다.
자주 언급되는 장점:
- 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉬운 편입니다.
- 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
- 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.
목차
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 // ✅ 인덱스 접근
핵심 차이점:
- Go는 포인터 연산을 허용하지 않습니다 (안전성)
*(역참조)와&(주소 연산)만 가능- 배열 접근은 인덱스 문법만 사용
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
}
포인터 사용 가이드:
- 작은 타입 (int, bool 등): 값 전달
- 큰 구조체 (64바이트 이상): 포인터 전달
- 수정 필요: 포인터 전달
- 읽기 전용: 값 또는 포인터 (일관성 고려)
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++ vs Go: 배열 선언
// C++: 배열
int arr[5] = {1, 2, 3, 4, 5};
int size = sizeof(arr) / sizeof(arr[0]); // 5
// 함수에 전달 시 포인터로 decay
void process(int* arr, int size) {
// ...
}
// Go: 배열 (크기가 타입의 일부)
var arr [5]int = [5]int{1, 2, 3, 4, 5}
// 또는
arr := [5]int{1, 2, 3, 4, 5}
// 또는 (크기 자동 추론)
arr := [...]int{1, 2, 3, 4, 5}
// 길이 확인
length := len(arr) // 5
// 함수에 전달 시 값 복사 (포인터 decay 없음)
func process(arr [5]int) {
// 배열 전체가 복사됨
}
핵심 차이점:
- Go 배열은 크기가 타입의 일부입니다:
[5]int와[10]int는 다른 타입 - 함수에 전달 시 전체 복사됩니다 (C++의 포인터 decay 없음)
- 실무에서는 배열보다 슬라이스를 주로 사용
3. 슬라이스: Go의 동적 배열
Slice는 Go에서 가장 많이 사용하는 자료구조입니다. C++의 std::vector와 유사하지만 더 강력합니다.
C++ vs Go: 동적 배열
// 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
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
// range로 순회
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 생성 방법
// Go: 다양한 Slice 생성 방법
package main
import "fmt"
func main() {
// 1. nil 슬라이스
var s1 []int
fmt.Println(s1 == nil) // true
// 2. 리터럴
s2 := []int{1, 2, 3}
// 3. make (길이와 용량 지정)
s3 := make([]int, 5) // len=5, cap=5, 모두 0으로 초기화
s4 := make([]int, 5, 10) // len=5, cap=10
// 4. 슬라이싱
arr := [5]int{1, 2, 3, 4, 5}
s5 := arr[1:4] // [2, 3, 4], 원본 배열 공유
fmt.Println(s1, s2, s3, s4, s5)
}
Slice 슬라이싱
// C++: 부분 배열 (수동 복사)
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> sub(vec.begin() + 1, vec.begin() + 4); // [2, 3, 4]
// Go: 슬라이싱 (원본 공유)
slice := []int{1, 2, 3, 4, 5}
sub := slice[1:4] // [2, 3, 4], 인덱스 1~3
sub[0] = 100 // slice[1]도 100으로 변경됨!
// 슬라이싱 문법
// slice[low:high] // low부터 high-1까지
// slice[low:] // low부터 끝까지
// slice[:high] // 처음부터 high-1까지
// slice[:] // 전체 (복사 아님, 같은 배열 참조)
// 복사가 필요하면 명시적으로
copied := make([]int, len(sub))
copy(copied, sub)
주의사항: 슬라이싱은 원본 배열을 공유합니다. 수정하면 원본도 변경됩니다.
append와 재할당
// Go: append의 동작 원리
package main
import "fmt"
func main() {
slice := make([]int, 0, 3) // len=0, cap=3
fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
slice = append(slice, 1) // len=1, cap=3
slice = append(slice, 2) // len=2, cap=3
slice = append(slice, 3) // len=3, cap=3
fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
slice = append(slice, 4) // len=4, cap=6 (재할당!)
fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
// capacity 초과 시 자동으로 2배 확장 (대략)
}
성능 팁: 최종 크기를 알면 make([]T, 0, capacity)로 미리 확보하세요.
// ❌ 비효율적: 여러 번 재할당
slice := []int{}
for i := 0; i < 10000; i++ {
slice = append(slice, i)
}
// ✅ 효율적: 용량 미리 확보
slice := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
slice = append(slice, i)
}
4. Map: 해시 테이블
C++ vs Go: Map 사용
// C++: std::map (정렬) / std::unordered_map (해시)
#include <unordered_map>
#include <iostream>
int main() {
std::unordered_map<std::string, int> m;
// 삽입
m["apple"] = 100;
m["banana"] = 200;
m.insert({"cherry", 300});
// 조회
if (m.find("apple") != m.end()) {
std::cout << "Found: " << m["apple"] << "\n";
}
// 존재하지 않는 키 접근 시 0 삽입됨
int x = m["nonexistent"]; // m에 "nonexistent": 0 추가
// 삭제
m.erase("apple");
// 순회
for (const auto& [key, value] : m) {
std::cout << key << ": " << value << "\n";
}
}
// Go: map (해시 테이블)
package main
import "fmt"
func main() {
// 생성
m := make(map[string]int)
// 삽입
m["apple"] = 100
m["banana"] = 200
m["cherry"] = 300
// 조회 (두 번째 반환값으로 존재 여부 확인)
if v, ok := m["apple"]; ok {
fmt.Println("Found:", v)
}
// 존재하지 않는 키 접근 시 제로 값 반환 (맵에 추가 안 됨)
x := m["nonexistent"] // 0 반환, m은 변경 안 됨
// 삭제
delete(m, "apple")
// 순회 (순서 보장 안 됨)
for key, value := range m {
fmt.Println(key, ":", value)
}
}
핵심 차이점:
- Go map은 순서를 보장하지 않습니다 (매 실행마다 순서 다를 수 있음)
- 존재하지 않는 키 접근 시 제로 값 반환 (C++처럼 삽입 안 됨)
- 두 번째 반환값(
ok)으로 키 존재 여부 확인
Map 생성 방법
// Go: Map 생성 방법
package main
func main() {
// 1. make로 생성
m1 := make(map[string]int)
// 2. 리터럴로 생성
m2 := map[string]int{
"apple": 100,
"banana": 200,
}
// 3. nil map (읽기만 가능, 쓰기 시 패닉)
var m3 map[string]int
// m3["key"] = 1 // ❌ panic: assignment to entry in nil map
v := m3["key"] // ✅ 0 반환 (읽기는 가능)
// 4. 용량 힌트 (성능 최적화)
m4 := make(map[string]int, 100) // 100개 정도 들어갈 것으로 예상
}
Map 활용 패턴
// Go: Map 활용 예시
package main
import "fmt"
// 단어 빈도 계산
func wordCount(words []string) map[string]int {
counts := make(map[string]int)
for _, word := range words {
counts[word]++ // 존재하지 않으면 0에서 시작
}
return counts
}
// Set 구현 (map[T]bool)
func uniqueElements(numbers []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, num := range numbers {
if !seen[num] {
seen[num] = true
result = append(result, num)
}
}
return result
}
func main() {
words := []string{"go", "is", "go", "is", "simple"}
counts := wordCount(words)
fmt.Println(counts) // map[go:2 is:2 simple:1]
nums := []int{1, 2, 2, 3, 3, 3, 4}
unique := uniqueElements(nums)
fmt.Println(unique) // [1 2 3 4]
}
5. 실습 과제
과제 1: 슬라이스 역순 정렬
// Go: 슬라이스 역순으로 뒤집기
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) // [5 4 3 2 1]
}
C++ 버전과 비교:
// C++: std::reverse
#include <algorithm>
#include <vector>
std::vector<int> vec = {1, 2, 3, 4, 5};
std::reverse(vec.begin(), vec.end());
과제 2: 중복 제거
// Go: 슬라이스에서 중복 제거
package main
import "fmt"
func removeDuplicates(slice []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, v := range slice {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
func main() {
nums := []int{1, 2, 2, 3, 3, 3, 4, 5, 5}
unique := removeDuplicates(nums)
fmt.Println(unique) // [1 2 3 4 5]
}
과제 3: 두 슬라이스 병합
// Go: 슬라이스 병합
package main
import "fmt"
func merge(s1, s2 []int) []int {
result := make([]int, 0, len(s1)+len(s2))
result = append(result, s1...) // ... 연산자로 언팩
result = append(result, s2...)
return result
}
func main() {
a := []int{1, 2, 3}
b := []int{4, 5, 6}
merged := merge(a, b)
fmt.Println(merged) // [1 2 3 4 5 6]
}
과제 4: Map으로 그룹화
// Go: 점수별 학생 그룹화
package main
import "fmt"
type Student struct {
Name string
Score int
}
func groupByScore(students []Student) map[int][]string {
groups := make(map[int][]string)
for _, s := range students {
groups[s.Score] = append(groups[s.Score], s.Name)
}
return groups
}
func main() {
students := []Student{
{"Alice", 90},
{"Bob", 85},
{"Charlie", 90},
{"David", 85},
}
groups := groupByScore(students)
fmt.Println(groups)
// map[85:[Bob David] 90:[Alice Charlie]]
}
정리: Day 3~4 학습 체크리스트
완료해야 할 항목
- Go 포인터는 연산 불가, 역참조만 가능
- 함수 인자: 값 전달 vs 포인터 전달
- 배열은 고정 크기, 실무에서는 슬라이스 사용
- Slice의 Length와 Capacity 차이 이해
-
append의 재할당 메커니즘 이해 - 슬라이싱은 원본 배열 공유 (주의!)
- Map 생성과 조회 (
ok패턴) - 실습 과제 4개 완료
C++에서 Go로 전환 포인트
| C++ | Go | 비고 |
|---|---|---|
int* p; p++ | p := &x (연산 불가) | 안전성 우선 |
std::vector<T> | []T (Slice) | 더 간결한 문법 |
vec.size() | len(slice) | 내장 함수 |
vec.capacity() | cap(slice) | 내장 함수 |
std::unordered_map | map[K]V | 내장 타입 |
m.find(k) != m.end() | v, ok := m[k] | 더 간결 |
다음 단계 예고
Day 3~4에서는 포인터와 자료구조를 배웠습니다. 다음 글에서는 클래스 없는 객체지향을 다룹니다. 상속 대신 합성, 메서드와 리시버의 개념을 배웁니다.
📚 시리즈 네비게이션
| 이전 글 | 목차 | 다음 글 |
|---|---|---|
| ← #01 기본 문법 | 📑 전체 목차 | #03 객체지향 → |
Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료
한 줄 요약: Go의 포인터는 안전하고, Slice는 강력하며, Map은 간결합니다. C++의 복잡함 없이 동일한 작업을 더 쉽게 할 수 있습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법 - C++ 개발자의 첫인상
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
이 글에서 다루는 키워드 (관련 검색어)
Go 슬라이스, Go map, Go 포인터, append cap len, C++ vector 비교, Golang 자료구조, Go 2주 완성, 메모리 GC 등으로 검색하시면 이 글이 도움이 됩니다.
실전 팁
실무에서 바로 적용할 수 있는 팁입니다.
디버깅 팁
- 문제가 발생하면 먼저 컴파일러 경고를 확인하세요
- 간단한 테스트 케이스로 문제를 재현하세요
성능 팁
- 프로파일링 없이 최적화하지 마세요
- 측정 가능한 지표를 먼저 설정하세요
코드 리뷰 팁
- 코드 리뷰에서 자주 지적받는 부분을 미리 체크하세요
- 팀의 코딩 컨벤션을 따르세요
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ std::vector와 Go Slice의 결정적 차이를 이해하고, 포인터 연산 없는 안전한 포인터 사용법을 배웁니다. Capacity와 Length, Map 활용까지 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
관련 글
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ 기술 면접 질문 30선 |
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]