입문서에서는 변수를 자주 상자에 비유합니다. 시작점으로는 괜찮지만, 값이 계속 바뀌는 프로그램을 이해하기에는 금방 한계가 옵니다.
변수는 값 보관함이기도 하지만, 더 본질적으로는 지금 프로그램이 어디까지 왔는지를 기억하는 수단입니다. 그래서 변수를 읽을 때는 "여기에 뭐가 들어 있지"보다 "이 값이 언제, 왜 바뀌지"를 같이 봐야 합니다.
값을 보지 말고 변화를 따라갑니다
점수를 누적하는 프로그램에서 score는 숫자 하나가 아닙니다. 지금까지 처리한 만큼의 결과를 나타내는 표식입니다. 반복문 안에서 이 값이 바뀐다면, 코드는 계산을 하는 것이 아니라 상태를 갱신하고 있는 것입니다.
이 관점이 왜 중요한지는 버그를 만났을 때 드러납니다. 결과가 이상할 때 "값이 왜 이렇지"라고 물으면 답이 없습니다. 대신 "어느 시점부터 값이 어긋났지"라고 물으면 코드의 특정 줄로 좁혀집니다. 그래서 변수를 다룰 때 가장 먼저 익혀야 할 기술은 문법이 아니라 손으로 값을 따라 적는 것입니다.
TRACE TABLE
score = 0
for point in [10, 20, 15]:
score = score + point
print(score)
# 바퀴 | point | 계산 | 바퀴 끝의 score
# -----+-------+-------------+----------------
# 시작 | - | - | 0
# 1 | 10 | 0 + 10 | 10
# 2 | 20 | 10 + 20 | 30
# 3 | 15 | 30 + 15 | 45
# 출력: 45
머리로 돌리지 말고 이렇게 적습니다. 네 줄이면 대부분의 루프는 정리됩니다.
이 표를 그리는 데는 1분도 안 걸립니다. 그런데 초보 단계에서 겪는 반복문 버그의 상당수가 이 표 한 장에서 끝납니다. 초기값이 잘못됐는지, 갱신하는 줄이 루프 밖에 있는지, 마지막 바퀴가 빠졌는지가 표에서는 즉시 보이기 때문입니다. 디버거 사용법을 배우는 것보다 이 습관이 먼저입니다.
표를 다 채우지 못하겠다면 그 자체가 정보입니다. 어느 칸에서 손이 멈췄는지가 이해가 끊긴 지점이고, 대개 그 근처에 버그가 있습니다. 그 줄에 print를 하나 넣어 실제 값을 찍어 보면 내가 적으려던 값과 다른 경우가 많습니다. 예상과 실제가 갈리는 첫 지점을 찾는 것, 그것이 디버깅의 전부라고 해도 크게 틀리지 않습니다.
이름과 값이 사는 자리는 다릅니다
상자 비유가 무너지는 대표적인 지점이 여기입니다. 리스트나 딕셔너리 같은 값을 다른 이름에 대입하면, 값이 복사되는 것이 아니라 같은 값을 가리키는 이름이 하나 더 생기는 경우가 많습니다.
SHARED REFERENCE
original = [1, 2, 3]
backup = original # 복사가 아니라 같은 것을 가리킴
original.append(4)
print(original) # [1, 2, 3, 4]
print(backup) # [1, 2, 3, 4] ← 같이 바뀜
# 실제로 복사하려면
backup2 = original.copy() # 또는 list(original)
original.append(5)
print(original) # [1, 2, 3, 4, 5]
print(backup2) # [1, 2, 3, 4] ← 그대로
backup이라는 이름은 백업을 만들어 주지 않습니다.
이 버그가 까다로운 이유는 에러가 나지 않기 때문입니다. 프로그램은 멈추지 않고, 그냥 답이 틀립니다. 그리고 원인이 있는 줄과 증상이 보이는 줄이 멀리 떨어져 있어서, 증상 근처만 들여다보면 아무리 봐도 이상한 곳이 없습니다.
같은 일이 함수에서도 벌어집니다. 리스트를 함수에 넘긴 뒤 함수 안에서 수정하면, 호출한 쪽의 리스트도 바뀝니다. 그래서 함수를 쓸 때 "이 함수가 내가 넘긴 값을 바꾸는가"를 확인하는 습관이 필요합니다. 함수 경계를 어디에 그을지에 대한 이야기는 함수는 언제 나누는 게 좋을까에 이어집니다.
숫자나 문자열처럼 값을 바꿀 수 없는 종류는 이 문제가 없습니다. 그래서 a = b가 안전할 때와 위험할 때가 나뉩니다. 리스트, 딕셔너리, 집합, 그리고 직접 만든 객체를 다룰 때만 조심하면 됩니다. 자료형별 성질은 배열, 리스트, 딕셔너리 첫 가이드에서 정리했습니다.
이름이 상태를 설명해야 합니다
변수를 상태로 보기 시작하면 이름 짓는 기준도 달라집니다. data, temp, result 같은 이름은 무엇이 들어 있는지는 말해 주지만 지금 어느 단계인지는 말해 주지 않습니다. 같은 값이 여러 단계를 거치는 코드에서는 이 차이가 크게 벌어집니다.
예를 들어 파일에서 읽은 줄을 다듬고 걸러 내는 코드에서 lines라는 이름 하나를 계속 재사용하면, 어느 지점의 lines가 원본이고 어느 지점이 정리된 것인지 알 수 없게 됩니다. raw_lines, trimmed_lines, valid_lines처럼 단계가 드러나는 이름을 쓰면 코드를 위에서 아래로 읽는 것만으로 데이터가 어떤 변환을 거쳤는지 따라갈 수 있습니다.
같은 이유로 참과 거짓을 담는 변수는 flag보다 is_valid나 has_error처럼 질문에 답하는 형태가 낫습니다. 조건문에서 읽을 때 문장이 되기 때문입니다. 이름을 이렇게 붙여 두면, 나중에 그 조건이 뒤집혔을 때 코드를 안 읽고 이름만 봐도 이상하다는 것이 느껴집니다.
이름을 고민하는 시간이 아깝게 느껴질 수 있는데, 실제로는 주석을 쓰는 시간과 나중에 코드를 다시 읽는 시간을 함께 줄여 줍니다. 좋은 이름은 상태 변화를 설명하는 가장 짧은 문서입니다.
변수는 언제 태어나고 언제 사라지는가
변수에는 값 말고도 두 가지 성질이 더 있습니다. 어디서 보이는가, 그리고 언제까지 살아 있는가입니다.
함수 안에서 만든 변수는 그 함수가 끝나면 사라집니다. 그래서 함수 안에서 아무리 값을 잘 계산해도 return으로 꺼내지 않으면 바깥에서는 쓸 수 없습니다. 입문 단계에서 "함수는 만들었는데 결과가 안 나온다"는 상황의 대부분이 이것입니다. 함수 안에서 print로 확인은 되는데 바깥에서는 값이 없는 상태입니다.
반대 방향의 문제도 있습니다. 값이 필요할 때마다 전역 변수를 하나씩 늘리면 처음에는 편합니다. 어디서든 읽고 쓸 수 있으니까요. 그런데 그 값이 이상해졌을 때 누가 바꿨는지 찾을 방법이 없어집니다. 프로그램 전체가 용의자가 됩니다. 지역 변수와 매개변수로 값을 주고받으면 그 값을 건드릴 수 있는 곳이 몇 줄로 제한되고, 그만큼 추적이 쉬워집니다.
그래서 상태를 다루는 기본 방향은 "값을 어디서든 볼 수 있게 만들기"가 아니라 "값을 바꿀 수 있는 곳을 좁히기"입니다. 이 원칙 하나만 지켜도 프로그램이 커질 때 무너지는 속도가 크게 달라집니다.
다만 변수를 줄이는 것이 목표는 아닙니다
여기까지 읽고 변수를 최대한 없애야겠다고 생각할 수 있는데, 그 방향으로 밀어붙이면 다른 문제가 생깁니다. 상태를 줄이라는 조언과 변수를 줄이라는 조언은 같은 말이 아닙니다.
중간 변수는 디버깅의 발판입니다. 여러 계산을 한 줄에 밀어 넣으면 짧아 보이지만, 결과가 틀렸을 때 어느 단계에서 어긋났는지 볼 방법이 없습니다. 이름이 붙은 중간 변수가 세 개 있으면 세 곳에서 값을 확인할 수 있습니다. 게다가 이름 자체가 설명이 되어서 주석이 필요 없어지는 경우도 많습니다. 짧은 코드와 읽기 쉬운 코드는 다른 목표입니다.
"전부 불변으로 짜라"는 조언도 초보 단계에서는 조심해야 합니다. 값을 바꾸지 않고 매번 새로 만드는 방식은 상태 추적이 쉬워지는 장점이 분명히 있습니다. 다만 그 방식에 익숙해지려면 함수형 표현들을 함께 익혀야 하는데, 기초 흐름이 아직 불안정한 상태에서 두 가지를 동시에 하면 코드가 오히려 읽기 어려워집니다. 누적하는 반복문을 한 줄짜리 표현으로 바꿔 놓고 결과가 틀렸을 때, 손댈 곳이 없어지는 상황이 대표적입니다.
순서를 권한다면, 먼저 변수를 바꿔 가며 값을 추적하는 감각을 충분히 익히고, 그다음에 바꾸지 않는 방식이 왜 편한지를 체감하는 쪽입니다. 두 방식 모두 상태를 다루는 방법이고, 어느 쪽이든 "지금 이 값이 무엇을 대표하는가"를 말할 수 있어야 한다는 점은 같습니다.
루프 안에서 결과를 담을 변수를 선언하면 매 바퀴 초기화됩니다. total = 0은 루프 밖에 있어야 합니다. 에러가 나지 않고 마지막 바퀴 값만 남기 때문에, 입력이 하나일 때는 정상으로 보이는 것이 함정입니다.
변수를 마주쳤을 때 확인하는 다섯 가지
- 이 변수는 지금 무엇을 대표하는가를 한 문장으로 말해 봅니다.
- 초기값이 왜 그 값인지 설명할 수 있는지 확인합니다.
- 값이 바뀌는 줄을 전부 찾아 표시합니다. 세 곳을 넘으면 줄일 수 있는지 봅니다.
- 리스트나 딕셔너리를 대입한 곳이 있다면 복사가 필요한지 확인합니다.
- 루프가 있다면 바퀴별 값을 표로 세 줄만 적어 봅니다.
문법 자체를 다시 다지고 싶다면 Python 기초 가이드나 JavaScript 기초 가이드에서 예제를 바꿔 가며 돌려 보시면 됩니다.
변수를 이해한다는 것은 값을 보는 일이 아니라 변화의 의미를 읽는 일에 가깝습니다.