
작은 변수가 부르는 거대한 재앙, 왜 지금 변수와 상수를 점검해야 할까요
협업 중인 프로젝트의 코드를 열었는데, 도대체 이 숫자가 무엇을 의미하는지 몰라 한참을 헤맨 적이 있나요? 혹은 전역 변수 하나를 수정했더니 전혀 상관없어 보이던 다른 모듈에서 갑자기 에러가 터져 밤을 지새운 경험이 있으신가요? 처음에는 아주 작은 실수처럼 보이지만, 이러한 Go 변수 안티패턴은 시간이 흐를수록 눈덩이처럼 불어나 프로젝트 전체의 유지보수 비용을 폭등시켜요.
Go 언어는 단순함과 명확성을 지향하는 언어예요. 하지만 입문 단계에서는 변수의 범위를 잘못 설정하거나, 상수로 관리해야 할 값을 변수로 방치하는 실수를 자주 저지르곤 해요. 이런 사소한 습관들이 모여 코드의 예측 불가능성을 높이고, 결국에는 기술 부채로 돌아오게 돼요. 지금 당장은 코드가 돌아가니까 괜찮다고 생각할 수도 있지만, 서비스의 규모가 커지는 순간 이 문제는 반드시 발목을 잡게 돼요.
이 글에서는 단순히 문법적인 정의를 넘어, 실제 프로덕션 환경에서 개발자를 괴롭히는 나쁜 습관들을 하나씩 파헤쳐 보려고 해요. 코드를 더 안전하고 읽기 쉽게 만드는 리팩터링 전략을 통해, 여러분의 코드를 한 단계 더 높은 수준으로 끌어올려 드릴게요.
이 글을 끝까지 읽고 나면 다음과 같은 능력을 갖추게 돼요.
- 코드 곳곳에 숨겨진 위험한 변수 사용 패턴을 식별할 수 있어요.
- 상수와 변수를 구분하는 명확한 기준을 세울 수 있어요.
- 복잡한 로직을 깔끔한 코드로 바꾸는 리팩터링 기술을 익혀요.
- 동시성 프로그래밍 환경에서 안전한 변수 관리 방법을 이해해요.
리팩터링 전 필수 체크리스트: 변수와 상수의 올바른 기준 세우기
무작정 코드를 고치기 시작하면 오히려 더 큰 버그를 만들어낼 수 있어요. 리팩터링의 핵심은 기존의 동작을 유지하면서 내부 구조만 개선하는 것이기 때문이에요. 본격적인 작업에 들어가기 전에, 우리가 다루는 데이터가 어떤 성격을 가졌는지 명확히 정의하는 과정이 반드시 필요해요.
먼저, 우리가 다루는 값이 변경 가능성을 가지고 있는지 확인해야 해요. 프로그램이 실행되는 동안 값이 변해야 한다면 변수를 사용하고, 한 번 정해지면 절대 변하지 않는 값이라면 상수를 사용해야 해요. 만약 상수로 선언해야 할 값을 변수로 두면, 실수로 값이 변경되었을 때 시스템 전체에 예측할 수 없는 부작용을 일으킬 수 있어요.
Go에서 상수는 컴파일 타임에 결정되는 값이에요. 반면 변수는 런타임에 값이 바뀔 수 있죠. 이 차이를 이해하는 것이 클린 코드의 시작이에요.
다음은 어떤 상황에서 무엇을 선택해야 하는지 판단을 돕기 위한 기준표예요. 이 표를 머릿속에 넣어두면 코드를 작성할 때 훨씬 빠르게 결정할 수 있어요.
| 변수 (var) 사용 시점 | 상수 (const) 사용 시점 | |
|---|---|---|
| 값의 가변성 | 연산이나 조건에 따라 값이 바뀌어야 할 때 | 한 번 정해지면 절대 바뀌지 않을 때 |
| 결정 시점 | 프로그램 실행 중에 결정될 때 | 컴파일 단계에서 이미 알고 있는 값 |
| 데이터 성격 | 사용자 입력, DB 조회 결과, 카운터 등 | 설정값, 수학적 상수, 에러 메시지 등 |
| 안전성 요구 | 수정이 빈번하고 추적이 필요한 데이터 | 오염되면 안 되는 시스템 핵심 규칙 |
이 기준을 바탕으로 현재 작성된 코드를 훑어보세요. 만약 상수로 충분한 값이 변수로 선언되어 있다면, 그것이 바로 여러분이 가장 먼저 해결해야 할 리팩터링 대상이에요. 또한, 변수의 범위(Scope)가 너무 넓지는 않은지도 함께 체크해야 해요. 함수 내부에서만 쓰일 변수가 패키지 수준의 전역 변수로 선언되어 있다면, 그것 역시 잠재적인 폭탄이 될 수 있어요.
실전 리팩터링: 안티패턴을 제거하고 클린 코드로 나아가는 5단계
이제 본격적으로 실전 예제를 통해 나쁜 패턴을 어떻게 찾아내고, 어떻게 우아하게 고칠 수 있는지 알아볼게요. 각 단계는 실제 개발 현장에서 가장 자주 발생하는 문제들을 중심으로 구성했어요.
STEP 1. 매직 넘버(Magic Number)를 상수로 전환하기
코드 중간에 갑자기 나타나는 의미를 알 수 없는 숫자, 이를 매직 넘버라고 불러요. 예를 들어, 결제 로직 중에 if amount > 10000이라는 코드가 있다면, 이 ‘10000’이 무엇을 의미하는지 코드를 읽는 사람은 알 길이 없어요. 무료 배송 기준인지, VIP 등급 기준인지 말이에요.
이런 코드는 가독성을 떨어뜨릴 뿐만 아니라, 나중에 기준이 15,000원으로 바뀌었을 때 코드 전체를 뒤져서 숫자를 찾아 바꿔야 하는 위험을 초래해요. 이를 해결하기 위해서는 의미 있는 이름을 가진 상수를 선언해야 해요.
상수를 사용할 때는 단순히 값을 옮기는 게 아니라, 그 숫자가 갖는 ‘의미’를 이름에 담는 것이 핵심이에요.
[나쁜 예시]func applyDiscount(price int) int {
if price > 50000 {
return price - 5000
}
return price
}
[개선된 예시]const (
VIPDiscountThreshold = 50000
VIPDiscountAmount = 5000
)
func applyDiscount(price int) int {
if price > VIPDiscountThreshold {
return price - VIPDiscountAmount
}
return price
}
이렇게 바꾸면 코드를 읽자마자 ‘VIP 할인 임계값’을 기준으로 할인이 적용된다는 것을 누구나 알 수 있어요. 나중에 기준이 바뀌어도 상수의 값만 수정하면 되니 매우 안전하죠.
STEP 2. 전역 변수의 오남용을 캡슐화로 해결하기
패키지 수준에서 선언된 전역 변수는 가장 위험한 안티패턴 중 하나예요. 전역 변수는 프로그램 어디에서든 접근할 수 있기 때문에, 어떤 함수가 이 값을 언제 바꿨는지 추적하기가 매우 어렵거든요. 특히 Go는 고루틴(Goroutine)을 이용한 동시성 프로그래밍이 기본인데, 여러 고루틴이 동시에 전역 변수에 접근하면 데이터 경합(Race Condition)이 발생하여 프로그램이 죽거나 엉뚱한 결과를 내놓을 수 있어요.
전역 변수를 사용해야만 한다면, 직접적인 접근을 막고 함수나 구조체를 통해서만 접근할 수 있도록 캡슐화를 적용해야 해요. 변수를 소문자로 시작하여 패키지 외부에서는 보이지 않게 만들고, 필요한 기능만 메서드로 노출하는 방식이죠.
[나쁜 예시]var UserCount int // 어디서든 수정 가능
func Increment() {
UserCount++
}
[개선된 예시]type UserManager struct {
mu sync.Mutex
userCount int // 패키지 내부에서만 접근 가능
}
:= 연산자를 통해 타입을 자동으로 추론할 수 있어요. 가끔 초보 개발자들은 모든 변수에 타입을 명시적으로 적어야 한다는 강박 때문에 코드를 장황하게 만들곤 해요. 물론 명확성을 위해 타입을 적는 것이 좋을 때도 있지만, 너무 당연한 경우에는 오히려 가독성을 해칠 수 있어요.
반대로, 변수를 선언만 해두고 사용하지 않거나, 굳이 변수에 담을 필요가 없는 중간 계산 과정을 모두 변수로 만드는 것도 지양해야 해요. 코드가 길어지면 흐름을 놓치기 쉽기 때문이죠.
[나쁜 예시]var age int = 30
var greeting string = name + "님, 안녕하세요!" // 불필요한 중간 변수
return greeting
[개선된 예시]age := 30
return fmt.Sprintf("%s님, 안녕하세요!", name)
코드가 훨씬 간결해졌죠? 불필요한 중간 단계 변수를 제거하고 fmt.Sprintf 같은 표준 라이브러리를 적절히 활용하면 의도가 더 선명하게 드러나요.
STEP 4. 변수 섀도잉(Shadowing)의 함정 피하기
변수 섀도잉은 이름이 같은 변수가 다른 스코프(Scope)에서 재선언되어, 기존 변수가 가려지는 현상을 말해요. 이는 Go 개발자들이 가장 많이 겪는 실수 중 하나로, 버그를 찾기가 매우 까다로워요. 특히 if err := ... 문법을 잘못 사용하면 예상치 못한 결과를 초래해요.
[위험한 시나리오] err := errors.New("ID가 비어있습니다") // 여기서 새로운 err 변수가 생성됨 (Shadowing!)
return err
}
// 여기서 err는 위에서 선언한 err가 아니라, 외부의 err(nil)를 가리킴!
return err
}
위 코드에서 내부 블록의 err :=는 외부의 err를 가리는 새로운 변수를 만들어요. 이로 인해 함수 끝에서 반환하는 err는 개발자가 의도한 에러가 아닌 nil일 확률이 높아요.
[해결책]
변수 이름을 명확하게 다르게 지정하거나, 새로운 변수를 생성하는 := 대신 기존 변수에 값을 할당하는 =를 사용하세요. 또한, 린터(Linter) 도구를 사용하여 섀도잉을 자동으로 감지하는 습관을 들이는 것이 좋아요.
STEP 5. iota를 활용한 효율적인 상수 그룹화
관련된 상수들이 많아질 때, 하나씩 이름을 붙이고 값을 할당하는 것은 번거롭고 실수하기 쉬워요. 이때 Go의 iota 키워드를 사용하면 아주 깔끔하게 상수를 그룹화할 수 있어요. iota는 상수 블록 안에서 0부터 시작해 1씩 자동으로 증가하는 값을 제공해요.
[개선된 상수 관리]상수들의 순서가 중요하다면 iota는 최고의 도구가 될 거예요.
자주 하는 실수와 해결법 및 궁금한 점 정리
실전에서 개발자들이 흔히 저지르는 실수들을 유형별로 정리했어요. 이 패턴들만 피해도 코드의 질이 눈에 띄게 좋아질 거예요.
- ❌ 실수: 모든 값을 `var`로 선언하고 사용함
➡️ 왜 발생하는가: 상수를 쓰는 게 귀찮거나, 나중에 값이 바뀔지도 모른다는 막연한 불안감 때문이에요.
➡️ ✅ 해결법: 값이 변하지 않는다는 확신이 있다면 무조건 `const`를 쓰세요. 컴파일러가 여러분의 코드를 보호해 줍니다. - ❌ 실수: 함수 내부의 변수를 너무 길게 유지함
➡️ 왜 발생하는가: 데이터 흐름을 한눈에 보기 위해 변수를 미리 다 선언해두려는 습관 때문이에요.
➡️ ✅ 해결법: 변수는 필요한 시점에, 가장 좁은 범위에서 선언하세요. 범위가 좁을수록 버그가 숨을 곳이 줄어듭니다. - ❌ 실수: 글로벌 변수에 뮤텍스 없이 접근함
➡️ 왜 발생하는가: 단일 스레드 환경처럼 생각하거나 동시성 문제를 간과하기 때문이에요.
➡️ ✅ 해결법: 전역 상태가 필요하다면 반드시 구조체로 감싸고 `sync.Mutex`를 함께 사용하여 데이터 경합을 방지하세요. - ❌ 실수: `:=` 연산자를 남발하여 섀도잉을 유발함
➡️ 왜 발생하는가: 짧은 선언이 편리하기 때문에 무의식적으로 사용하게 돼요.
➡️ ✅ 해결법: 에러 처리나 기존 변수를 업데이트할 때는 반드시 `=`를 사용해 기존 변수의 값을 할당하는지 확인하세요. - ❌ 실수: 타입 추론에만 의존해 타입 정보를 놓침
➡️ 왜 발생하는가: 코드를 짧게 만드는 데만 집중하기 때문이에요.
➡️ ✅ 해결법: 복잡한 구조체나 명확한 타입 구분이 필요한 경우에는 명시적으로 타입을 적어주는 것이 동료 개발자를 위한 배려예요.
자주 묻는 질문
Q. 상수를 선언할 때 반드시 `const`를 써야 하나요?
네, 프로그램 실행 중에 값이 바뀌면 안 되는 값은 반드시 `const`로 선언해야 해요. 그래야 컴파일 타임에 값이 고정되어 안전성이 보장되고, 의도치 않은 수정을 방지할 수 있어요.
Q. `var`와 `:=`의 차이점은 무엇인가요?
`var`는 변수를 선언할 때 타입을 명시하거나 초기값이 없을 때 유용해요. 반면 `:=`는 함수 내부에서 타입을 추론하여 아주 간결하게 선언할 때 사용해요. 다만, 함수 밖(패키지 수준)에서는 `:=`를 사용할 수 없다는 점을 주의하세요.
Q. 섀도잉(Shadowing)을 방지하는 가장 좋은 방법은 무엇인가요?
가장 확실한 방법은 정적 분석 도구인 'golangci-lint' 같은 린터를 사용하는 거예요. 린터는 코드 작성 중에 섀도잉이 발생하면 즉시 경고를 띄워주어 실수를 방지해 줘요.
Q. iota를 사용할 때 중간에 값을 건너뛰고 싶으면 어떻게 하나요?
iota 중간에 `_` (blank identifier)를 사용하거나, 특정 값에 직접 숫자를 할당하면 돼요. 예를 들어 `StatusSpecial = 10`처럼 직접 지정하면 그 이후의 iota 값들은 그 숫자를 기준으로 이어져요.
Q. 전역 변수를 아예 안 쓸 수는 없나요?
가장 좋은 건 안 쓰는 것이지만, 환경 설정값처럼 정말 필요한 경우에는 패키지 내부 변수로 숨기고(소문자 시작), 읽기 전용 메서드나 함수를 통해서만 값을 전달하는 방식을 권장해요.
지속 가능한 코드를 위한 마지막 점검
오늘 우리는 Go 프로그래밍에서 변수와 상수를 어떻게 다루어야 하는지, 그리고 어떤 안티패턴이 우리의 코드를 망가뜨리는지에 대해 깊이 있게 살펴보았어요. 클린 코드는 한 번에 완성되는 것이 아니라, 매일 작성하는 코드 한 줄 한 줄을 점검하는 습관에서 만들어져요.
- 의미 없는 숫자(매직 넘버)는 반드시 명확한 이름의 상수로 관리하세요.
- 전역 변수는 캡슐화와 뮤텍스를 통해 안전하게 보호해야 해요.
- 변수의 범위는 최대한 좁게 유지하여 예측 가능성을 높이세요.
- 변수 섀도잉을 막기 위해 := 와 =의 차이를 명확히 인지하세요.
- 관련된 상수 그룹은 iota를 활용해 효율적으로 관리하세요.
리팩터링은 단순히 코드를 예쁘게 만드는 작업이 아니에요. 미래의 나, 그리고 동료 개발자가 코드를 읽을 때 겪을 고통을 미리 줄여주는 가장 가치 있는 투자예요. 오늘 배운 내용 중 딱 한 가지만이라도 지금 바로 여러분의 프로젝트에 적용해 보는 건 어떨까요?
🚀 지금 바로 실행해 보세요!
글에서 소개한 예제 코드를 직접 타이핑해 보고, 일부러 버그를 만든 뒤 리팩터링을 통해 고쳐지는 과정을 경험해 보세요. 직접 손으로 익힌 감각은 절대 배신하지 않아요.
더 깊이 있는 Go 언어 활용법이 궁금하다면 Go 변수와 상수 완전 정복 가이드 글도 함께 읽어보시는 것을 추천해요.