[TIL] null과 0은 다르다 — API에서 “데이터 없음”과 “값이 0”을 구분하는 법

API에서 미분석(null·무효 입력)과 분석 완료 후 점수 0을 구분하지 못해 생기는 조용한 버그, 변환 전 유효성 검증·JSON 직렬화·집계 분모 처리까지 정리한 글이다.

📝 TIL (Today I Learned)
null이나 빈 맵을 모두 Map.of()로 흡수하면, 미분석·점수 누락·합계 0인 분석이 한데 섞인다. 변환 전에 isValidScoreMap()으로 걸러내고, 무효면 null을 유지하면 집계·UI에서 의미가 살아난다.


들어가며

데이터 분석 결과를 조회하는 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();

세 경우를 보면:

  1. result == null — 미분석 → Map.of()
  2. result.getScores() == null — 분석 시도했지만 점수 누락 → toPercentageScores(null)Map.of()
  3. 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 오해처럼 디버깅이 어려운 문제가 쌓인다.

변환 앞에 유효성 검증을 두는 작은 습관만으로도 예방할 수 있다는 점만 기억해도 충분하다.