[친구하자] 백분율을 정확히 100%로 만드는 방법 — Largest Remainder Method
감정 분석 API에서 7가지 감정 점수를 소수점 둘째 자리 백분율로 보여주되 합계는 반드시 100.00%가 되게 하는 문제, 반올림 함정과 Largest Remainder Method(최대 잔여 방식) 해법, 구현 시 부동소수점·동점 처리 등을 정리합니다.
감정 분석 API를 만들면서 “각 항목은 소수점 둘째 자리로 보이게 하되, 합계는 반드시 100.00%”라는 요구를 만났다. 단순 반올림과 흔한 보정 방식의 함정, 최대 잔여 방식(Largest Remainder, Hamilton 방식으로도 알려짐)으로 맞춘 과정과 구현 시 체크 포인트를 정리한다.
- 들어가며
- 1. 문제: 반올림하면 합계가 어긋난다
- 2. 흔한 해결 시도와 한계
- 3. 해법: Largest Remainder Method
- 4. 동작 검증
- 5. 왜 음수가 나오지 않을까
- 6. 주의: 반올림된 값으로 대표값을 뽑지 말 것
- 7. 이 패턴이 필요한 곳
- 8. 개발자가 알아두면 좋은 것
- 마무리
들어가며
감정 분석 API를 개발하던 중 이런 요구가 생겼다.
- “7가지 감정 점수를 소수점 둘째 자리 백분율로 보여주되, 합계는 반드시 100.00%여야 한다.”
겉으로는 단순해 보이지만, 구현보면 꽤 까다롭다. 이 글은 그 과정에서 만난 함정과 해법을 정리한 글이다.
1. 문제: 반올림하면 합계가 어긋난다
모델이 반환한 7개 감정 점수가 있다고 하자.
| 감정 | 원본 | ×100 (%) |
|---|---|---|
| joy | 0.1432 | 14.32 |
| neutral | 0.1431 | 14.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 이상”을 말하기 어렵다.
핵심 단계는 다음과 같다.
- 총 단위(예: 소수점 둘째 자리면
10000 = 100 × 100)로 스케일한다. - 각 항목에 바닥값(floor)을 배분한다.
- 목표 합(
10000)과의 차이만큼, 소수부가 큰 순으로 1씩 더 나눠준다. - 최종 정수를 다시 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. 동작 검증
케이스 A — floor 합이 이미 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 의사결정 데이터 분리는 따로 챙기면 된다.
한 번 흐름을 익혀 두면, 백분율·비율을 다루는 다른 기능에서도 같은 패턴을 적용하기 쉽다.