0 과 빈 문자열과 null 은 다른 말입니다 — Go 의 zero value 에서 TypeScript 로
예전에 Go 로 짜인 서버 코드를 TypeScript 로 다시 짠 적이 있습니다. 이유를 솔직하게 적자면 제가 Go 에 약했기 때문입니다. 매일 읽고 고쳐야 할 코드라면, 제가 가장 자신 있게 읽을 수 있는 언어로 두는 편이 낫다고 판단했습니다.
다만 다시 짜면서 고친 문제들은 언어 탓이 아니었습니다. 언어를 바꾸는 김에 설계를 처음부터 다시 볼 기회가 생겼을 뿐입니다. 이 연재는 그때 구현하면서 다시 공부한 정석들을 한 편에 하나씩 적는 기록입니다. 첫 주제는 가장 사소해 보이지만 가장 자주 사고를 내는 것, "값이 없다"는 말이 실제로는 여러 뜻이라는 사실입니다.
"값이 없다"에는 적어도 네 가지 뜻이 있습니다
설문지를 떠올려 보면 쉽습니다. 어떤 사람은 "보유 대수" 칸에 0 을 적습니다. 어떤 사람은 칸을 비워 둡니다. 어떤 사람은 "해당 없음"에 표시합니다. 그리고 어떤 설문지는 아예 돌아오지 않습니다. 넷 다 "값이 없다"처럼 보이지만, 집계하는 사람에게는 전혀 다른 사실입니다.
| 설문지에서는 | 프로그램에서는 | 뜻 |
|---|---|---|
| 0 을 적었다 | 0 | 값이 있고, 그 값이 0 이다 |
| 칸을 비웠다 | '' (빈 문자열) | 글자 칸에 아무것도 적지 않았다 |
| "해당 없음"에 표시했다 | null | 값이 없다고 분명히 말했다 |
| 설문지가 오지 않았다 | undefined · 키 없음 | 아무 말도 하지 않았다. 모른다 |
프로그램에서도 이 넷은 다르게 다뤄야 합니다. 예를 들어 회원 정보를 일부만 고치는 요청을 생각해 보겠습니다. 이런 요청을 부분 수정이라고 부르고, 웹 API 에서는 보통 PATCH 라는 방식으로 보냅니다. 이때 "메모" 항목이 null 이면 메모를 지우라는 뜻이고, 항목 자체가 없으면 메모는 건드리지 말라는 뜻입니다. 둘을 같은 것으로 처리하면 사용자가 손대지도 않은 메모가 지워집니다.
Go 에서는 "말하지 않음"이 0 으로 채워집니다
Go 는 값을 넣지 않은 변수에 타입마다 정해진 기본값을 채웁니다. 이것을 제로 값(zero value)이라고 부릅니다. 숫자는 0, 글자는 빈 문자열, 참·거짓은 false 입니다. 변수가 엉뚱한 값을 품지 않게 해 주는 좋은 규칙입니다. 그런데 바깥에서 들어온 데이터를 받을 때는 "안 보냈다"와 "0 을 보냈다"가 같은 모양이 됩니다.
직접 확인해 봤습니다. 재고 수량(stock)과 메모(memo)를 받는 구조체에, 빈 JSON 과 0·빈 문자열을 담은 JSON 을 각각 넣어 보았습니다.
type Patch struct {
Stock int `json:"stock"`
Memo string `json:"memo"`
}
var absent, zeros Patch
json.Unmarshal([]byte(`{}`), &absent)
json.Unmarshal([]byte(`{"stock":0,"memo":""}`), &zeros)
fmt.Println(absent == zeros)
{} -> {Stock:0 Memo:}
{"stock":0,"memo":""} -> {Stock:0 Memo:}
둘이 같은가? true
받는 쪽에서는 사용자가 수량을 0 으로 바꾸려 한 것인지, 수량은 건드리지 않은 것인지 구분할 방법이 없습니다. Go 에서 흔히 쓰는 해법은 필드를 포인터로 바꾸는 것입니다. 포인터는 값이 놓인 자리를 가리키는 변수인데, "가리키는 것이 없음(nil)"을 표현할 수 있습니다. 그래서 안 보낸 항목은 nil 이 되고, 0 을 보낸 항목은 0 을 가리킵니다.
포인터 필드로 바꾼 뒤
{} -> Stock == nil ? true
{"stock":0,"memo":""} -> Stock == nil ? false, *Stock = 0
그런데 포인터로도
{} -> Memo == nil ? true
{"memo":null} -> Memo == nil ? true
포인터를 써도 한 가지가 남습니다. "키가 없음"과 "null 을 보냄"이 둘 다 nil 이 됩니다. 지우라는 요청과 건드리지 말라는 요청이 다시 같은 모양이 되는 셈입니다. 이 둘까지 가르려면 "보냈는지"와 "무슨 값인지"를 따로 담는 타입을 직접 만들어야 합니다.
같은 규칙은 데이터베이스 도구에도 이어집니다. ORM 은 데이터베이스의 표를 코드의 객체처럼 다루게 해 주는 도구인데, Go 에서 많이 쓰는 GORM 은 공식 문서에서 구조체로 수정하면 제로 값이 아닌 필드만 갱신한다고 안내합니다. 어떤 값을 0 이나 false 로 "끄려는" 수정이 구조체로 들어가면 조용히 반영되지 않을 수 있다는 뜻입니다. 문서는 그럴 때 map 이나 Select 로 수정할 필드를 직접 지정하라고 권합니다.
환경 변수도 마찬가지입니다. 환경 변수는 프로그램 바깥에서 설정 값을 넣어 주는 방법입니다. Go 의 os.Getenv 는 없는 변수와 빈 변수에 똑같이 빈 문자열을 돌려주고, 둘을 가르려면 os.LookupEnv 를 써야 합니다.
os.Getenv("UNSET_VAR") = ""
os.Getenv("EMPTY_VAR") = ""
os.LookupEnv("UNSET_VAR") = ("", false)
os.LookupEnv("EMPTY_VAR") = ("", true)
TypeScript 는 구분할 수단이 더 많지만, 그만큼 섞이기도 쉽습니다
TypeScript, 정확히는 그 아래에서 도는 JavaScript 는 null 과 undefined 를 따로 갖고 있어서 네 가지를 모두 표현할 수 있습니다. 문제는 기본값을 채우는 연산자가 둘이고, 둘이 "비었다"고 보는 범위가 다르다는 점입니다.
| 값 | v ?? '기본값' | v || '기본값' |
|---|---|---|
0 | 0 | 기본값 |
'' | '' | 기본값 |
null | 기본값 | 기본값 |
undefined | 기본값 | 기본값 |
|| 는 0 과 빈 문자열까지 "비었다"고 보고 기본값으로 바꿉니다. 재고 0 을 기본값으로 덮어 버리는 실수가 여기서 나옵니다. ?? 는 null 과 undefined 만 기본값으로 바꾸므로 0 은 지켜 줍니다. 대신 빈 문자열은 그대로 통과시킵니다.
부분 수정 요청의 세 상태는 이렇게 가를 수 있습니다. 키가 있는지를 먼저 보고, 그다음에 null 인지를 봅니다.
type Profile = { memo: string | null };
function applyPatch(current: Profile, patch: { memo?: string | null }): Profile {
if (!('memo' in patch)) return current; // 키 없음: 그대로 둔다
if (patch.memo === null) return { ...current, memo: null }; // null: 지운다
return { ...current, memo: patch.memo ?? current.memo }; // 값: 바꾼다('' 포함)
}
{} -> 그대로 둔다(키 없음) | 결과 memo = "기존 메모"
{"memo":null} -> 지운다(null) | 결과 memo = null
{"memo":""} -> 바꾼다("") | 결과 memo = ""
검증 라이브러리도 이 경계를 정확히 알고 써야 합니다. NestJS 에서 흔히 쓰는 class-validator 의 @IsOptional() 은 공식 설명대로 값이 null 이거나 undefined 일 때만 나머지 검증을 건너뜁니다. 빈 문자열은 "선택 항목"으로 취급되지 않고 다음 검증으로 넘어갑니다. 빈 문자열을 "안 보냄"으로 볼지는 코드가 따로 정해야 합니다.
설정 파일은 "없음"을 빈 문자열로 바꿔서 넣습니다
이 차이를 가장 크게 밟은 곳은 코드가 아니라 설정이었습니다. Docker Compose 는 여러 컨테이너의 실행 설정을 한 파일에 적어 두는 도구입니다. 설정 파일에 ${API_ORIGIN} 처럼 쓴 자리는 실행하는 컴퓨터의 환경 변수로 채워집니다. 그런데 그 변수가 설정돼 있지 않으면, Compose 는 멈추지 않고 경고와 함께 빈 문자열을 넣습니다.
level=warning msg="The \"API_ORIGIN\" variable is not set. Defaulting to a blank string."
API_ORIGIN: ""
API_ORIGIN_WITH_DEFAULT: https://example.com
앱 안에서 process.env.API_ORIGIN ?? 'https://example.com' 처럼 기본값을 걸어 두었다면, 이 기본값은 걸리지 않습니다. 변수는 "없는" 것이 아니라 "빈 문자열로 있는" 상태이기 때문입니다. 앱은 빈 주소를 들고 조용히 뜨고, 문제는 한참 뒤에 전혀 다른 증상으로 드러납니다.
Compose 쪽에서 막는 방법은 공식 문서에 두 가지가 있습니다. ${API_ORIGIN:-기본값} 은 변수가 없거나 비었을 때 기본값을 넣고, ${API_ORIGIN:?메시지} 는 그 경우 설정 해석을 멈추고 에러를 냅니다. 콜론(:)이 빠진 ${API_ORIGIN-기본값} 은 "없을 때"만 기본값을 넣고 빈 값은 그대로 둔다는 점도 확인했습니다.
API_ORIGIN= (빈 값)으로 실행했을 때
WITH_COLON: https://example.com # ${API_ORIGIN:-https://example.com}
WITHOUT_COLON: "" # ${API_ORIGIN-https://example.com}
API_ORIGIN 이 없을 때 ${API_ORIGIN:?API_ORIGIN is required}
error while interpolating ...: required variable API_ORIGIN is missing a value
앱 쪽에서는 필수 설정을 부팅할 때 한 번에 검사하고, 빈 문자열도 "없음"으로 판정해 아예 뜨지 않게 했습니다. 늦게 발견되는 조용한 실패보다, 시작하자마자 이유를 말하며 멈추는 편이 훨씬 싸다는 생각이 들었습니다.
function envRequired(name: string): string {
const value = process.env[name];
if (value === undefined || value.trim() === '') {
throw new Error(`환경 변수 ${name} 가 비어 있습니다`);
}
return value;
}
"모름"을 0 으로 접으면 합계가 거짓말을 합니다
마지막은 계산입니다. 세 곳의 값을 더하는데, 그중 한 곳은 이번에 읽지 못했다고 해 보겠습니다. 읽지 못한 값을 0 으로 채우면 합계는 깔끔하게 나옵니다. 하지만 그 합계에서는 "한 곳을 모른다"는 사실이 사라져 있습니다.
값: [120, 모름, 80]
0 으로 접은 합계 = 200 (모름 1건이 사라짐)
모름을 남긴 결과 = { 합계: 200, 모름: 1 }
숫자는 같아도 두 번째 결과만 정직합니다. 그래서 확인하지 못한 칸은 0 으로 채우지 않고 비워 두고, 화면에는 몇 칸을 모르는지 함께 보여 주는 규칙을 세웠습니다.
정리하며
- 빈 값의 뜻을 먼저 정합니다. 0, 빈 문자열, null, 키 없음이 각각 무엇을 뜻하는지 항목마다 정해 둡니다.
- 경계에서 한 번 해석합니다. 설정을 읽는 곳, 요청을 받는 곳, 데이터베이스에 쓰는 곳에서 해석하고, 안쪽 코드에는 해석된 값만 넘깁니다.
- 모름은 모름으로 남깁니다. 모르는 값을 0 이나 빈 값으로 접지 않습니다.
Go 에 약해서 TypeScript 로 옮겼는데, 정작 가장 먼저 다시 배운 것은 Go 의 기본 규칙이었습니다. 제로 값을 이해하고 나서야 옛 코드가 왜 그렇게 쓰였는지, 새 코드에서 무엇을 지켜야 하는지가 보였다는 생각이 들었습니다. 다음 글에서는 같은 데이터를 두 번 받아도 불어나지 않게 하는 방법과, 그때 데이터베이스가 돌려주는 숫자가 무엇을 뜻하는지 적어 보겠습니다.
실험 환경: Node.js v26.5.0 · Go 1.25.6 · Docker Compose · 2026-09-29. 예제 코드는 이 글을 위해 새로 만든 것이고, 결과는 각 조건에서 한 번 실행한 출력입니다.
관련 글
타입체크도 통과하고 파싱도 정상인데 메시지만 사라졌습니다 — zod 4 무음 실패
메이저 업그레이드에서 무서운 건 깨지는 API라고 생각했습니다. 실제로 위험했던 건 안 깨지는 쪽이었습니다. 타입도 통과하고 파싱도 정상 동작하는데 커스텀 메시지만 조용히 사라지고 있었습니다.
급락 경보가 정작 급락에서 침묵했습니다 — GSC API가 0인 날짜를 생략하는 함정
검색 성과가 급락하면 Slack으로 알려주는 경보를 만들었는데, 정작 트래픽이 완전히 소멸하는 최악의 순간에 조용히 침묵했습니다. GSC Search Analytics API가 0인 날짜의 행을 아예 생략하는 성질과 위치 기반 슬라이스가 맞물린 역설을 한 줄씩 추적하고, 날짜 경계로 나눠 고친 버그 회고입니다.
삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다
서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.