
왜 Go 자료형 선택이 코드의 운명을 결정할까요
어느 날 갑자기 서비스의 결제 금액이 1원씩 어긋나기 시작한다면 어떨까요? 혹은 사용자가 수억 명으로 늘어난 순간, 서버의 메모리가 갑자기 치솟으며 시스템이 멈춰버린다면요? 이런 문제는 대부분 복잡한 알고리즘의 오류가 아니라, 아주 기초적인 자료형 선택의 실수에서 시작돼요.
Go 언어는 매우 빠르고 효율적인 언어지만, 개발자가 기본 자료형을 잘못 사용하면 그 효율성을 완전히 갉아먹을 수 있어요. 예를 들어, 단순히 숫자를 저장하기 위해 `int`를 썼을 뿐인데, 32비트 시스템과 64비트 시스템 사이에서 동작이 달라지는 버그를 마주할 수도 있죠. 이는 단순한 코딩 실수를 넘어 서비스의 안정성을 뒤흔드는 심각한 문제가 돼요.
많은 입문 개발자들이 자료형을 단순히 “숫자를 담는 그릇” 정도로만 생각하곤 해요. 하지만 실무에서는 그 그릇의 크기와 모양이 메모리 배치, CPU 연산 속도, 그리고 나아가 가비지 컬렉션(GC)의 효율성까지 결정한다는 사실을 꼭 기억해야 해요. 잘못 설계된 자료형은 나중에 코드를 수정하기 매우 어렵게 만드는 기술 부채로 돌아와요.
이 글을 끝까지 읽고 나면, 여러분은 더 이상 무심코 `int`나 `float64`를 선택하지 않게 될 거예요. 대신 상황에 딱 맞는 자료형을 선택하고, 안티패턴을 피해 깔끔하고 견고한 코드를 작성하는 눈을 갖게 될 거예요.
- Go 기본 자료형의 메모리 구조와 선택 기준
- 실무에서 흔히 발생하는 5가지 자료형 안티패턴
- 안티패턴을 해결하는 구체적인 리팩터링 방법
- 프로덕션 환경에서 고려해야 할 자료형 설계 전략
리팩터링 전 반드시 이해해야 할 Go 자료형 기초
본격적인 안티패턴 해결에 앞서, 우리가 사용하는 도구들이 어떤 특성을 가지고 있는지 명확히 알아야 해요. Go의 자료형은 매우 엄격하게 관리되는데, 이는 컴파일 시점에 최대한 많은 오류를 잡아내기 위함이에요. 하지만 이 엄격함이 때로는 개발자를 번거롭게 만들기도 하죠.
가장 먼저 이해해야 할 개념은 메모리 크기와 범위예요. 우리가 사용하는 `int` 타입은 시스템의 아키텍처에 따라 크기가 변해요. 32비트 환경에서는 4바이트, 64비트 환경에서는 8바이트가 되죠. 만약 여러분이 작성한 코드가 특정 클라우드 환경의 컨테이너에서 실행될 때, 예상보다 작은 범위를 가진 `int`를 사용했다면 데이터 오버플로우(Overflow)가 발생할 수 있어요.
또한, Go의 자료형은 제로 값(Zero Value)이라는 개념을 가지고 있어요. 변수를 선언만 하고 초기화하지 않았을 때, Go는 자동으로 각 자료형에 맞는 기본값을 할당해요. `int`는 0, `bool`은 `false`, `string`은 빈 문자열이 되죠. 이 특성을 잘 활용하면 안전한 코드를 짤 수 있지만, 의도치 않은 초기 상태를 그대로 방치하면 논리적 오류의 원인이 되기도 해요.
아래 표를 통해 자주 사용되는 기본 자료형의 특성을 비교해 보며 기준을 세워보세요.
| 자료형 | 메모리 크기 | 권장 용도 | 주의 사항 |
|---|---|---|---|
| int | 4 또는 8 바이트 | 일반적인 루프 카운터, 인덱스 | 아키텍처에 따라 크기가 달라짐 |
| int64 | 8 바이트 | 대규모 ID, 타임스탬프, 큰 숫자 | 메모리 사용량이 상대적으로 높음 |
| float64 | 8 바이트 | 과학적 계산, 정밀도가 낮은 소수점 | 금융 계산 시 정밀도 문제 발생 가능 |
| string | 가변적 | 텍스트 데이터 저장 | 불변성(Immutable) 특성 이해 필요 |
위 표를 보면 알 수 있듯이, 용도에 맞는 명확한 타입 선택이 리팩터링의 첫걸음이에요. 단순히 귀찮아서 `int`만 쓰거나, 계산이 편해서 `float64`를 남발하는 습관을 버려야 해요. 이제 이 기초를 바탕으로 실무에서 어떤 실수가 일어나는지 구체적으로 살펴볼까요?
실전에서 만나는 Go 자료형 안티패턴과 개선 방법
이제부터는 실제 코드에서 자주 발견되는 잘못된 사례들을 단계별로 분석해 볼게요. 각 단계는 여러분이 코드를 리뷰하거나 직접 작성할 때 바로 적용할 수 있는 실무적인 가이드가 될 거예요.
STEP 1. 매직 넘버와 타입을 무시한 상수 사용
프로그램을 짜다 보면 상태 값을 나타내기 위해 숫자 0, 1, 2를 사용하는 경우가 많아요. 예를 들어, 사용자의 상태를 `int`로 관리하면서 “0은 탈퇴, 1은 활성, 2는 정지”라고 주석을 다는 방식이죠. 이것은 아주 전형적인 안티패턴이에요.
왜 이 방식이 위험할까요? 바로 의미의 모호함 때문이에요. 다른 개발자가 코드를 볼 때 숫자 1이 무엇을 의미하는지 매번 주석을 확인해야 하고, 실수로 3이라는 값을 넣어도 컴파일러는 아무런 제재를 하지 않아요. 결국 런타임에 알 수 없는 오류가 발생하게 되죠.
해결 방법: 사용자 정의 타입과 iota 활용
단순히 `int`를 쓰는 대신, 새로운 타입을 정의하고 `iota`를 사용해 상수를 만드세요. 이렇게 하면 타입 안전성(Type Safety)을 확보할 수 있고, 코드가 훨씬 읽기 쉬워져요.
나쁜 예:
const Active = 1좋은 예:
type UserStatus intconst ( UserStatusActive UserStatus = iota; UserStatusInactive; UserStatusBanned )STEP 2. 불필요하게 복잡한 불리언(Boolean) 집착
구조체 안에 `IsActive`, `IsAdmin`, `IsDeleted` 같은 불리언 필드가 지나치게 많아지는 현상을 겪어보셨을 거예요. 필드가 늘어날수록 조합의 수가 기하급수적으로 증가하고, 어떤 상태가 유효한 상태인지 판단하기가 매우 복잡해져요.
이런 현상을 Boolean Hell이라고 불러요. 예를 들어, `IsAdmin`이 `true`인데 `IsDeleted`도 `true`라면, 이 사용자는 관리자이면서 동시에 삭제된 사용자인가요? 이런 모순적인 상태를 허용하게 되는 것이죠. 시스템의 논리적 무결성을 깨뜨리는 주범이 돼요.
해결 방법: 상태 기반의 열거형(Enum) 도입
불리언 여러 개를 쓰는 대신, 하나의 상태 타입을 정의하고 그 중 하나의 값만 갖도록 설계하세요. 이렇게 하면 상태 간의 충돌을 원천적으로 차단할 수 있어요.
STEP 3. 금융 데이터에 부동 소수점(float64) 사용하기
이 부분은 정말 중요해요. 결제 시스템이나 정산 로직을 짤 때 `float64`를 사용하는 것은 절대 금물이에요. 부동 소수점 방식은 이진법으로 소수를 표현하기 때문에, 우리가 아는 0.1 같은 숫자를 정확히 표현하지 못하고 미세한 오차를 만들어내요.
예를 들어, 100.1원을 10번 더했을 때 결과가 정확히 1001원이 아니라 1000.9999999999999가 될 수 있다는 뜻이에요. 아주 작은 차이지만, 수백만 건의 거래를 처리하는 대규모 시스템에서는 이 오차가 엄청난 금액 차이로 돌아와요. 이는 단순한 버그를 넘어 회사의 재무적 손실로 이어질 수 있는 치명적인 실수예요.
해결 방법: 정수 기반 단위 계산 또는 고정 소수점 라이브러리
가장 추천하는 방법은 모든 금액을 가장 작은 화폐 단위(예: 원 혹은 센트)의 정수(int64)로 관리하는 거예요. 100.5원을 다뤄야 한다면 1005밀리원을 사용하는 식이죠. 만약 복잡한 소수점 연산이 꼭 필요하다면 `shopspring/decimal` 같은 전문적인 고정 소수점 라이브러리를 사용하세요.
STEP 4. 문자열 처리 시 Rune과 Byte의 혼동
Go에서 `string`은 기본적으로 UTF-8 인코딩된 바이트의 집합이에요. 여기서 많은 개발자가 실수하는 부분이 바로 문자열의 길이를 구할 때 `len()` 함수를 사용하는 거예요. `len()`은 글자 수가 아니라 바이트 수를 반환해요.
한글은 한 글자당 보통 3바이트를 차지하죠. 만약 “안녕하세요”라는 문자열의 길이를 `len()`으로 재면 15가 나와요. 글자 수는 5개인데 말이죠. 만약 이 바이트 수를 기준으로 문자열을 자르는 로직을 짰다면, 한글 글자가 중간에 뚝 끊겨서 깨진 문자가 출력되는 대참사가 벌어질 거예요.
해결 방법: rune 타입 활용
글자 단위로 정확하게 처리해야 한다면 문자열을 `[]rune` 타입으로 변환한 뒤 작업하세요. `rune`은 하나의 유니코드 코드를 의미하므로, 한글이든 이모지든 상관없이 글자 하나를 정확히 하나의 단위로 다룰 수 있게 해줘요.
STEP 5. 슬라이스(Slice)의 메모리 누수 방치
슬라이스는 Go에서 가장 강력한 도구 중 하나지만, 메모리 관리 측면에서는 매우 까다로운 녀석이에요. 슬라이스는 내부적으로 배열을 가리키는 포인터를 가지고 있어요. 만약 거대한 배열에서 아주 작은 일부만 슬라이스로 잘라내어 계속 사용한다면 어떻게 될까요?
원래의 거대한 배열은 더 이상 필요 없더라도, 슬라이스가 그 배열의 일부를 참조하고 있는 한 가비지 컬렉터(GC)가 해당 배열을 메모리에서 해제하지 못해요. 이것이 바로 슬라이스에 의한 메모리 누수예요. 대규모 데이터를 처리하는 서버에서 이런 코드가 방치되면 메모리 사용량이 계속 늘어나 결국 시스템이 다운될 수 있어요.
해결 방법: 데이터 복사(copy) 사용
거대한 데이터에서 작은 부분만 필요하다면, 슬라이싱만 하지 말고 새로운 슬라이스를 만들어 데이터를 copy() 함수로 복사해서 사용하세요. 이렇게 하면 원본 거대 배열과의 연결 고리가 끊어져 GC가 메모리를 안전하게 회수할 수 있어요.
자료형 선택은 단순한 취향의 문제가 아니에요. 시스템의 성능, 정확성, 그리고 메모리 효율성을 결정하는 설계의 핵심임을 잊지 마세요.
자주 하는 실수와 해결법
현장에서 신입부터 경력직까지 흔히 범하는 실수들을 정리해 봤어요. 이 리스트를 체크리스트로 활용해 보세요.
❌ 실수: 모든 데이터에 `interface{}`(any)를 사용하여 타입 정보를 숨김
❌ 원인: 어떤 값이든 담을 수 있어 편리해 보이지만, 런타임에 타입 단언(Type Assertion)을 해야 하므로 성능이 떨어지고 런타임 패닉 위험이 커짐
✅ 해결법: 가능한 구체적인 타입을 정의하고, 인터페이스가 꼭 필요한 경우에만 최소한의 범위로 정의하여 사용하세요.
❌ 실수: 카운트나 인덱스에 `int`를 사용해 음수 값을 허용함
❌ 원인: 개수나 인덱스는 물리적으로 음수가 될 수 없는데, `int`는 음수 범위를 포함하므로 논리적 오류를 막지 못함
✅ 해결법: 양수만 가능한 값이라면 `uint` 계열을 고려하거나, 코드 레벨에서 명확한 유효성 검사를 수행하세요.
❌ 실수: 구조체 필드 정렬(Alignment)을 고려하지 않음
❌ 원인: 필드 순서에 따라 CPU가 메모리에 접근하는 방식이 달라져, 구조체 크기가 불필요하게 커질 수 있음
✅ 해결법: 큰 자료형(int64, float64)을 앞쪽에 배치하고 작은 자료형(bool, int8)을 뒤쪽에 배치하여 패딩(Padding)을 최소화하세요.
❌ 실수: 문자열 연결 시 `+` 연산자를 반복문 안에서 사용함
❌ 원인: Go의 문자열은 불변(Immutable)이므로, `+`를 쓸 때마다 매번 새로운 문자열 객체가 생성되어 메모리 할당이 폭증함
✅ 해결법: 반복적인 문자열 결합이 필요하다면 반드시 `strings.Builder`를 사용하여 효율적으로 처리하세요.
❌ 실수: Error 타입을 단순 문자열로 처리함
❌ 원인: 에러의 종류를 구분할 수 없어, 호출부에서 특정 에러에 따른 복구 로직을 작성하기 매우 어려워짐
✅ 해결법: `errors.Is`나 `errors.As`를 사용할 수 있도록 사용자 정의 에러 타입을 만드세요.
자주 묻는 질문
Q. Go에서 int와 int64 중 무엇을 기본으로 써야 할까요?
특별한 이유가 없다면 일반적인 인덱스나 카운팅에는 `int`를 사용하는 것이 Go의 관습이에요. 하지만 파일 크기, 네트워크 패킷 크기, 혹은 시스템 아키텍처와 무관하게 큰 숫자를 보장해야 한다면 반드시 `int64`를 명시적으로 사용해야 해요.
Q. float32는 언제 사용하면 좋을까요?
최근의 현대적인 시스템에서는 대부분 `float64`를 기본으로 사용해요. `float32`는 메모리 공간을 아껴야 하는 아주 특수한 경우(예: 수백만 개의 점을 찍어야 하는 그래픽 연산)가 아니라면, 정밀도 문제 때문에 권장하지 않아요.
Q. 커스텀 타입을 만들면 코드가 너무 복잡해지지 않을까요?
처음에는 번거롭게 느껴질 수 있지만, 타입 안전성이 주는 이득이 훨씬 커요. 컴파일러가 실수를 잡아준다는 것은 곧 운영 환경에서의 장애를 줄인다는 뜻이니까요. 코드는 읽기 쉬워지고, 유지보수는 훨씬 견고해질 거예요.
Q. 슬라이스 복사가 정말 메모리 누수를 막아주나요?
네, 맞아요. 슬라이싱은 원본 배열의 메모리를 공유하지만, `copy()`를 통해 데이터를 새 배열로 옮기면 원본 배열과의 참조 관계가 끊어져요. 덕분에 GC가 원본 배열을 자유롭게 수거할 수 있게 됩니다.
Q. 문자열 길이를 잴 때 왜 항상 len()을 쓰면 안 되나요?
앞서 설명했듯이 `len()`은 바이트 수를 반환하기 때문이에요. 한글 같은 다국어 환경에서는 글자 수와 바이트 수가 다르므로, 글자 수 기반의 로직이 필요하다면 반드시 `rune` 슬라이스로 변환하여 처리해야 합니다.
더 나은 Go 개발자로 성장하기 위한 마무리
오늘 우리는 Go 프로그래밍의 가장 기초적이면서도 강력한 무기인 자료형을 어떻게 다루어야 하는지 깊이 있게 살펴보았어요. 자료형을 제대로 선택하는 것만으로도 여러분의 코드는 훨씬 더 견고하고, 빠르며, 읽기 쉬워질 거예요.
단순히 돌아가는 코드를 만드는 단계에서 벗어나, 효율적이고 안전한 시스템을 설계하는 개발자로 나아가고 싶다면 오늘 배운 안티패턴들을 반드시 기억해 주세요. 작은 차이가 모여 거대한 시스템의 안정성을 만듭니다.
- 상태 값 관리 시 `int` 대신 `iota`를 활용한 사용자 정의 타입을 사용하세요.
- 불리언 필드 남발을 피하고 상태 기반의 열거형을 설계하세요.
- 금융 데이터 등 정밀함이 생명인 곳에는 절대 `float64`를 쓰지 마세요.
- 한글 등 다국어 문자열 처리 시에는 `rune` 단위를 사용하세요.
- 대규모 슬라이스 사용 시에는 `copy()`를 통해 메모리 누수를 방지하세요.
- 구조체 설계 시 메모리 정렬(Alignment)을 고려하면 성능을 높일 수 있어요.
지금 바로 실천해 보세요!
이론만 아는 것과 직접 코드를 고쳐보는 것은 하늘과 땅 차이예요. 오늘 배운 내용을 바탕으로 여러분의 기존 프로젝트를 한 번 훑어보세요. 혹시 무심코 사용한 `int`나 `float64`가 있나요? 지금 바로 리팩터링을 시작해 보세요.
이번 주에는 자신이 작성한 코드 중 가장 복잡한 구조체를 하나 골라, 메모리 패딩을 최소화하도록 필드 순서를 재배치해 보는 연습을 추천드려요.
더 깊이 있는 Go 프로그래밍 기술이 궁금하다면, Go 자료형 완전 정복 가이드 글도 함께 읽어보시길 권장해요. 여러분의 성장을 응원합니다!