[TIL] null과 0은 다르다 — API에서 “데이터 없음”과 “값이 0”을 구분하는 법
API에서 미분석(null·무효 입력)과 분석 완료 후 점수 0을 구분하지 못해 생기는 조용한 버그, 변환 전 유효성 검증·JSON 직렬화·집계 분모 처리까지 정리한 글이다.
📝 TIL (Today I Learned)
null이나 빈 맵을 모두Map.of()로 흡수하면, 미분석·점수 누락·합계 0인 분석이 한데 섞인다. 변환 전에isValidScoreMap()으로 걸러내고, 무효면null을 유지하면 집계·UI에서 의미가 살아난다.
- 들어가며
- 1. 두 종류의 “점수 없음”
- 2. 버그가 생기는 과정
- 3. 집계에서 오차가 누적되는 방식
- 4. 해결: 변환 전에 유효성을 검증한다
- 5. API 응답에서 null vs 필드 제외
- 6. 집계 시 유효한 데이터만 포함
- 7. 패턴 정리
- 8. 이 문제가 나타나는 곳
- 마무리
들어가며
데이터 분석 결과를 조회하는 API를 만들다 보면 이런 요구를 만난다.
- “분석이 완료되지 않은 항목도 목록에 포함하되, 분석 결과가 없다는 것을 명확히 표시해야 한다.”
겉으로는 단순해 보이지만, 구현 중에는 예외도 없고 테스트도 통과하는데 응답 의미만 틀어지는 버그가 나기 쉽다.
1. 두 종류의 “점수 없음”
비슷해 보여도 의미가 다르다.
| 상태 | 의미 | 점수 데이터 |
|---|---|---|
| 미분석 | 분석 자체가 실행되지 않음 | null (데이터 없음) |
| 분석 완료·저점 | 분석했고 특정 항목이 0에 가까움 | { "a": 0.0, "b": 0.05, ... } 등 |
“데이터가 없다”와 “측정했더니 0에 가깝다”는 완전히 다른 정보다. API에서 이 구분이 사라지면 클라이언트는 두 상태를 구별할 수 없다.
2. 버그가 생기는 과정
흔히 이런 변환 함수를 둔다.
private Map<String, Double> toPercentageScores(Map<String, Double> rawScores) {
if (rawScores == null || rawScores.isEmpty()) {
return Map.of();
}
double total = rawScores.values().stream().mapToDouble(v -> v).sum();
if (total <= 0) {
return Map.of();
}
// ... 정규화 로직
}
호출부는 이렇게 쓸 수 있다.
// result가 없으면 미분석, 있으면 변환
Map<String, Double> scores = result != null
? toPercentageScores(result.getScores())
: Map.of();
세 경우를 보면:
result == null— 미분석 →Map.of()result.getScores() == null— 분석 시도했지만 점수 누락 →toPercentageScores(null)→Map.of()result.getScores()의 합계가 0 — 모든 점수가 0 →Map.of()
서로 다른 의미인데 모두 동일한 빈 맵으로 뭉개진다. 미분석과 “합계 0인 분석”을 구별할 수 없게 된다.
3. 집계에서 오차가 누적되는 방식
이 구분이 무너지면 집계에서 조용히 틀어진다.
예: 하루 통화 3건 중 1건만 분석 완료, 2건은 미분석이라 하자.
- 분석 완료: 점수 0.7
- 미분석(1):
Map.of()→ 클라이언트나 후속 로직이 0.0으로 취급 - 미분석(2): 동일
잘못된 평균: (0.7 + 0.0 + 0.0) / 3 ≈ 0.23
올바른 관점: 분석 완료 1건만 분모에 두면 0.70
미분석이 분모에 섞이면 수치가 희석된다. 예외 메시지도 없어 발견이 늦어진다.
4. 해결: 변환 전에 유효성을 검증한다
원칙은 하나다.
- 변환 로직 앞에 유효성 검증을 두고, 유효하지 않은 입력은 변환 자체를 하지 않는다.
private boolean isValidScoreMap(Map<String, Double> scores) {
if (scores == null || scores.isEmpty()) {
return false;
}
for (Double v : scores.values()) {
if (v == null || !Double.isFinite(v) || v < 0) {
return false;
}
}
double sum = scores.values().stream().mapToDouble(v -> v).sum();
return sum > 0;
}
각 조건의 이유:
null/isEmpty()— 데이터 자체가 없음!Double.isFinite(v)—NaN,Infinity방어(외부 API 파싱 오류 등)v < 0— 점수·확률이 음수인 것은 도메인에 맞지 않는 경우가 많음sum == 0— 모두 0이면 “의미 있는 비율 변환”의 전제가 깨짐(제품 정책에 따라 조정)
호출부는 예를 들어 이렇게 바꿀 수 있다.
Map<String, Double> scores = null;
if (result != null && isValidScoreMap(result.getScores())) {
scores = toPercentageScores(result.getScores());
}
// 유효하지 않으면 scores는 null 유지
| 케이스 | scores 반환 |
|---|---|
| 미분석 | null |
| 결과는 있으나 점수 이상 | null |
| 분석 완료·유효한 점수 | { … } |
5. API 응답에서 null vs 필드 제외
null을 쓰더라도 JSON 직렬화 방식에 따라 의미 전달이 달라진다.
@JsonInclude(JsonInclude.Include.NON_NULL)
Map<String, Double> scores;
분석 완료 예시
{
"id": 100,
"scores": { "a": 70.0, "b": 30.0 }
}
미분석 예시
{
"id": 101
}
| 방식 | JSON에서의 모습 |
|---|---|
null을 필드에 넣음 | "scores": null |
NON_NULL로 필드 제외 | scores 키 없음 |
"scores": null은 “분석했는데 값이 없다”로 오해될 수 있다. 팀 규칙에 따라 scores 키 자체를 빼서 “이 항목에는 분석 데이터가 없다”를 더 분명히 할 수도 있다.
6. 집계 시 유효한 데이터만 포함
평균 등을 낼 때는 유효하지 않은 항목을 분모에서도 제외해야 한다.
List<Map<String, Double>> validMaps = allScoreMaps.stream()
.filter(this::isValidScoreMap)
.toList();
if (validMaps.isEmpty()) {
return Map.of();
}
int count = validMaps.size();
// 분모는 전체 행 수가 아니라 validMaps.size()
전체 항목 수가 아니라 유효한 항목 수를 분모로 쓰는 것이 핵심이다.
7. 패턴 정리
[원시 데이터] → isValid() → [유효] → transform() → [표시용 값]
→ [무효] → null (변환 생략)
- 변환 함수는 유효한 입력만 받는다고 가정하고 단순하게 유지한다.
- 유효성 검증은 변환 앞에서 수행한다.
- “결과 없음”을 빈 컬렉션(
[],{})과null로 뒤섞지 말고, 팀에서null= 없음처럼 규칙을 통일한다.
빈 컬렉션은 “있는데 비어 있다”, null은 “없다”로 쓰면 구분이 선명해진다.
8. 이 문제가 나타나는 곳
같은 패턴이 필요한 경우는 흔하다.
- 사용자 프로필 — 작성 이력이 없는 소개(
null) vs 의도적으로 빈 문자열("") - 설문 — 미응답 vs 의도적 0점
- 센서 — 측정 실패(값 없음) vs 실제 0
- 외부 API — 아직 없는 결과 vs 처리 완료 후 빈 결과
어떤 경우든 “데이터가 없다”와 “확인했더니 0(또는 빈 값)”은 API 계약에서 분리하는 편이 안전하다.
마무리
null과 빈 값·0을 한 덩어리로 처리하고 싶은 유혹은 자주 생긴다. 당장 코드가 짧아지기 때문이다. 하지만 이 구분이 무너지면 집계 왜곡·UI 오해처럼 디버깅이 어려운 문제가 쌓인다.
변환 앞에 유효성 검증을 두는 작은 습관만으로도 예방할 수 있다는 점만 기억해도 충분하다.