[친구하자] 백분율을 정확히 100%로 만드는 방법 — Largest Remainder Method

감정 분석 API에서 7가지 감정 점수를 소수점 둘째 자리 백분율로 보여주되 합계는 반드시 100.00%가 되게 하는 문제, 반올림 함정과 Largest Remainder Method(최대 잔여 방식) 해법, 구현 시 부동소수점·동점 처리 등을 정리합니다.

감정 분석 API를 만들면서 “각 항목은 소수점 둘째 자리로 보이게 하되, 합계는 반드시 100.00%”라는 요구를 만났다. 단순 반올림과 흔한 보정 방식의 함정, 최대 잔여 방식(Largest Remainder, Hamilton 방식으로도 알려짐)으로 맞춘 과정과 구현 시 체크 포인트를 정리한다.


들어가며

감정 분석 API를 개발하던 중 이런 요구가 생겼다.

  • “7가지 감정 점수를 소수점 둘째 자리 백분율로 보여주되, 합계는 반드시 100.00%여야 한다.”

겉으로는 단순해 보이지만, 구현보면 꽤 까다롭다. 이 글은 그 과정에서 만난 함정과 해법을 정리한 글이다.


1. 문제: 반올림하면 합계가 어긋난다

모델이 반환한 7개 감정 점수가 있다고 하자.

감정원본×100 (%)
joy0.143214.32
neutral0.143114.31

각 값에 100을 곱하고 소수점 둘째 자리로 반올림하면, 운이 좋으면 합이 딱 100.00이 된다. 하지만 실제로는 자주 어긋난다.

예를 들어 비슷한 비율이 여러 개일 때:

16.667% → 16.67 (×6) + 16.665% → 16.67 등
→ 반올림만으로는 합이 100.02%처럼 벗어날 수 있다.

각 항목의 반올림은 그 자체로는 타당해도, 합계 제약과 동시에 만족시키기 어렵다.


2. 흔한 해결 시도와 한계

시도 1: 마지막 항목에 나머지 할당

double sum = 0;
for (int i = 0; i < values.size() - 1; i++) {
    rounded[i] = Math.round(values.get(i) * 10000) / 100.0;
    sum += rounded[i];
}
rounded[last] = 100.0 - sum;

직관적이지만, 앞쪽 항목이 과하게 올림되어 합이 이미 100을 넘으면 마지막 항목이 음수가 될 수 있다. 감정 점수로는 말이 안 되는 결과다.

시도 2: 합계로 정규화한 뒤 반올림

double total = scores.values().stream().mapToDouble(v -> v).sum();
scores.replaceAll((k, v) -> Math.round(v / total * 10000) / 100.0);

정규화해도 항목별 반올림의 누적 오차는 그대로 남는다. 합계 100.00을 보장하지 못한다.


3. 해법: Largest Remainder Method

Largest Remainder Method(최대 잔여 방식)는 비율을 정수 몫으로 나눈 뒤 남는 단위를 가장 큰 소수부(잔여)부터 채우는 방식이다. 미국 초기 의회 의석 배분 논의 등에서 Hamilton이 제안한 방식과 같은 계열로 자주 인용되며, 오늘날에도 일부 선거·의석 배분 맥락에서 쓰인다(국가·제도마다 d’Hondt 등 다른 배분법을 쓰는 경우도 많으니, “의석 배분 = 항상 LRM”은 아니다).

전제: 각 항목의 가중치가 0 이상이고, 합을 total로 나눠 비중이 합쳐서 1이 되도록 정규화한 값에 대해 적용한다. 음수 가중치가 있으면 floor만으로는 “항상 0 이상”을 말하기 어렵다.

핵심 단계는 다음과 같다.

  1. 총 단위(예: 소수점 둘째 자리면 10000 = 100 × 100)로 스케일한다.
  2. 각 항목에 바닥값(floor)을 배분한다.
  3. 목표 합(10000)과의 차이만큼, 소수부가 큰 순으로 1씩 더 나눠준다.
  4. 최종 정수를 다시 100으로 나눠 백분율로 표현한다.

Java 구현 예시

private Map<EmotionType, Double> toPercentageScores(Map<EmotionType, Double> rawScores) {
    double total = rawScores.values().stream().mapToDouble(v -> v).sum();
    if (total <= 0) {
        return Map.of();
    }

    Map<EmotionType, Long> intParts = new LinkedHashMap<>();
    Map<EmotionType, Double> fracParts = new LinkedHashMap<>();
    long intTotal = 0;

    for (Map.Entry<EmotionType, Double> entry : rawScores.entrySet()) {
        double scaled = entry.getValue() / total * 10000;
        long floor = (long) scaled;
        intParts.put(entry.getKey(), floor);
        fracParts.put(entry.getKey(), scaled - floor);
        intTotal += floor;
    }

    long remainder = 10000 - intTotal;

    // 소수부 내림차순, 동점이면 키(예: enum 이름)로 결정적 정렬 — 테스트·API 응답 재현성
    List<EmotionType> sortedByFrac = fracParts.entrySet().stream()
            .sorted(Map.Entry.<EmotionType, Double>comparingByValue().reversed()
                    .thenComparing(e -> e.getKey().name()))
            .map(Map.Entry::getKey)
            .toList();

    for (int i = 0; i < remainder; i++) {
        EmotionType key = sortedByFrac.get(i);
        intParts.merge(key, 1L, Long::sum);
    }

    Map<EmotionType, Double> result = new LinkedHashMap<>();
    for (Map.Entry<EmotionType, Long> entry : intParts.entrySet()) {
        result.put(entry.getKey(), entry.getValue() / 100.0);
    }
    return result;
}

4. 동작 검증

케이스 Afloor 합이 이미 10000인 경우:

  • 나머지 배분이 0이면, 그대로 합계 100.00이 된다.

케이스 B — 7등분에 가까운 비율:

  • floor만으로는 합이 9996처럼 모자라면 remainder만큼 소수부가 큰 항목에 +1씩 부여한다.
  • 항목별로 14.29와 14.28이 섞여도 합은 정확히 100.00이 된다.

음수가 나올 여지가 없고, 목표 정수 합을 맞추는 구조라 합계 오차도 구조적으로 없어진다.


5. 왜 음수가 나오지 않을까

아래는 비율이 모두 0 이상이고, 위처럼 정규화된 가중치에 LRM을 적용할 때의 이야기다.

  • 각 항목의 하한은 floor(비율 × 10000) / 100.0이므로 0 이상이다.
  • 나머지 분배는 덧셈만 하므로 값이 줄지 않는다.
  • “마지막 항목 = 100 − (나머지 합)”처럼 빼서 맞추는 방식과 달리, 그 차이 때문에 마지막만 음수가 되는 경로가 없다.

항목별 가장 가까운 반올림값을 동시에 만족시키는 알고리즘은 아니다. “정수 단위(여기서는 만분율) 합을 정확히 맞추면서, 가능한 한 원 비율에 가깝게” 분배하는 방식이라는 점만 기억하면 된다.


6. 주의: 반올림된 값으로 대표값을 뽑지 말 것

구현 후 또 하나의 함정이 있었다.

  • “오늘의 대표 감정(dominant emotion)을 어디서 고를 것인가?”

처음에는 toPercentageScores() 결과에서 최댓값을 찾았다. 그러면 반올림 때문에 원래 2위였던 감정이 1위와 같은 표시값이 되어 대표가 바뀌는 일이 생길 수 있다.

// ❌ 표시용 맵에서 최댓값 선택 — 반올림 왜곡 가능
Map<EmotionType, Double> display = toPercentageScores(raw);
EmotionType dominant = display.entrySet().stream()
        .max(Map.Entry.comparingByValue())
        .map(Map.Entry::getKey)
        .orElse(null);

의사결정(대표 감정)은 반올림 전 원시 평균에서 고르고, 화면 표시만 LRM 결과를 쓰는 편이 안전하다.

// ✅ 원시 평균에서 대표 선택
Map<EmotionType, Double> rawAverages = computeRawAverages(scoreMaps);
EmotionType dominant = rawAverages.entrySet().stream()
        .filter(e -> e.getValue() > 0)
        .max(Map.Entry.comparingByValue())
        .map(Map.Entry::getKey)
        .orElse(null);

Map<EmotionType, Double> display = toPercentageScores(rawAverages);

집계·판단용 값표시용 값을 분리하는 설계가 필요하다.


7. 이 패턴이 필요한 곳

분야예시
선거정당별 의석 배분
데이터 시각화파이·도넛 차트
통계 대시보드카테고리별 비율 합계
재무세금·수수료 분담
게임확률 표시

항목이 많고 비율이 비슷할수록 단순 반올림 오차가 커진다. API에서 서버가 합계 100.00을 보장해 주면 클라이언트는 별도 보정 없이 차트를 그릴 수 있다.


8. 개발자가 알아두면 좋은 것

부동소수점(double)

이론상 value / total * 10000을 항목마다 더하면 합이 정확히 10000이지만, double 연산에서는 항목별 scaled가 미세하게 틀어질 수 있다. 그 결과 floor가 한 단계 내려가거나, remainder 계산이 기대와 어긋날 여지가 있다.

  • 금액·감사 로그처럼 재현성·정확도가 중요하면 BigDecimal으로 스케일·floor·나머지 배분을 하거나, 모델 출력을 고정 소수 자릿수의 정수(예: 백만분율)로 받은 뒤 정수만으로 LRM을 돌리는 편이 안전하다.
  • 감정 점수처럼 소수 자릿수가 제한되면 double로도 실무에서 통하는 경우가 많지만, 단위 테스트에 “동률 소수부”“합이 1에 아주 가까운 값” 같은 케이스를 넣어 두는 것이 좋다.

동점(소수부가 같을 때)

나머지를 나눠줄 때 소수부가 같은 항목이 여러 개면, 정렬 순서에 따라 누가 +1을 받는지가 바뀐다. API 응답·스냅샷 테스트가 흔들리지 않도록 2차 정렬 키(enum 이름, 도메인 고정 순서 등)를 반드시 둔다. 위 코드 예시의 thenComparing(e -> e.getKey().name())이 그 역할이다.

total == 0·전부 0

total <= 0이면 비율을 정의할 수 없다. 빈 맵을 반환할지, 7축 모두 0%를 보낼지는 제품 요구에 맞춰 명시적으로 정한다.

LRM과 “항목마다 반올림”의 차이

클라이언트가 “각 줄은 반올림한 것처럼 보여야 해”라고 기대하면, LRM 결과가 항목별로는 가장 가까운 반올림 숫자와 다를 수 있다는 점을 UI·기획과 맞춰 두는 것이 좋다. 대신 합계 100.00은 보장된다.


마무리

백분율은 단순해 보여도 반올림이 끼면 항목 정확도합계 제약이 동시에 걸린다. Largest Remainder Method는 그 둘을 수학적으로 맞추는 실용적인 해법이다.

  • (비음수·정규화된 비율에서) 마지막 항목만 음수로 맞추는 식의 함정을 피할 수 있다.
  • 목표 합(예: 100.00%)을 정확히 만족한다.
  • 구현 부담도 크지 않다. 대신 double 정밀도·동점 정렬·표시 vs 의사결정 데이터 분리는 따로 챙기면 된다.

한 번 흐름을 익혀 두면, 백분율·비율을 다루는 다른 기능에서도 같은 패턴을 적용하기 쉽다.