본문 바로가기

시스템/Devops

최종 분포에 가려진 변화 방향 복원

이런 상황에서 씁니다

최종 등급 분포는 맞지만 토론 뒤 우려가 커졌는지 줄었는지 보이지 않는다. 반대 흐름의 실행이 비슷하게 표시된다.

전제 환경 — TypeScript / JSX / npx tsc --noEmit / Vitest reporter=dot / 브라우저

먼저 결론

  1. 첫인상 분포를 선택 필드로 추가
  2. 참여자별 등급 이동 인원 집계
  3. 전후 막대와 방향 요약 표시
  4. 동일 분포와 첫인상 없는 이력 제외
  5. JSX 부모와 선택 필드 접근 점검

전체 그림

전후 분포 집계와 표시 흐름

[첫인상 발언] -> [첫 분포] -----------┐
                                      ├-> [등급 비교]
[토론·재평가] -> [최종 분포] --------┘       |
                                              v
                                   [이동 인원·방향 요약]
                                              |
                                      [전후 겹침 막대]

[첫인상 없음] -> beforeDistribution=undefined -> [최종만]
[전후 동일] ------------------------------------> [고스트 생략]
[실패] 시간축 폐기 / JSX 단일 부모 누락 / 선택 필드 직접 접근

원인

최종 분포는 현재 상태를 정확히 나타내지만 변화 방향은 보존하지 않는다. 실제 한 실행은 경미 8명·중대 7명에서 경미 4명·중대 12명으로 우려가 커졌고, 다른 실행은 중대 11명에서 중대 4명·경미 11명으로 완화됐다. 최종 막대만 그리면 두 실행의 의미가 비슷하게 보인다. 계산과 렌더링이 정상이고 브라우저 점검도 JS 오류 0 []여서 기능 오류가 아닌 의미상의 실패로 남기 쉽다. 기존 실패 지점은 최종 집계가 아니라 시각화에 전달할 시간축을 버린 부분이다.

 

여러 실행에서 등급을 옮긴 참여자는 5~11/16석이었고, 87개 실행의 중앙값은 5명이었다. 한 실행의 12명은 이상치였다. 반면 변화 이유 데이터는 8개 실행에서 1~11건으로 흔들려 독립 섹션의 안정적인 근거가 되지 못했다. 따라서 beforeDistribution과 movedCount로 분포 비교를 기본 표시하고 이유는 조건부로 다룬다. 첫인상 턴이 없는 구버전 이력은 이전 분포를 undefined로 유지한다. 전후가 같으면 같은 막대를 두 측정값으로 오해하지 않도록 고스트 막대를 생략한다.

값 참조표

키 값 설명
beforeDistribution Distribution | undefined 첫인상 분포 또는 이전 데이터 없음
finalDistribution Distribution 최종 등급 분포
movedCount number | undefined 첫인상과 최종 등급이 다른 인원
--noEmit npx tsc --noEmit 출력 파일 없이 타입 검사
--reporter=dot npx vitest run --reporter=dot 전체 테스트의 간결한 진행 표시

단계별 실행

1. 최종 분포만 반환하는 경로를 찾는다

시간축이 집계 결과에서 사라지는 위치를 특정한다.

진단 — 저장소 루트에서 최종 전용 반환 탐색

set -eu
hits=$(git grep -l 'return { finalDistribution }' | wc -l | tr -d ' ')
test "$hits" -gt 0
printf 'final_only_return=true\n'

출력 예

final_only_return=true

조치 — 지표 계산 모듈의 반환 타입 확장

type Distribution = {
  none: number;
  minor: number;
  major: number;
  fatal: number;
};

type ReportMetrics = {
  finalDistribution: Distribution;
  beforeDistribution?: Distribution;
  movedCount?: number;
};

검증 — 저장소 루트에서 새 필드 확인

set -eu
git grep -q 'beforeDistribution'
git grep -q 'movedCount'
printf 'beforeDistribution=found\nmovedCount=found\n'

출력 예

beforeDistribution=found
movedCount=found

실패하면 — 반환문이 다르면 finalDistribution 생성부를 검색한다.

2. 첫인상 분포와 이동 인원을 집계한다

최종 상태와 토론 전 상태의 차이를 참여자 단위로 보존한다.

진단 — 기존 집계 코드의 첫 단계 처리 확인

set -eu
if git grep -q 'row.stage === "first"'; then state=present; else state=missing; fi
printf 'first_stage_branch=%s\n' "$state"
test "$state" = missing

출력 예

first_stage_branch=missing

조치 — 지표 계산 모듈에 전후 비교 추가

const firstRows = statements.filter(
  (row) => row.stage === "first" &&
    row.role !== "moderator" && row.level
);

let beforeDistribution: Distribution | undefined;
let movedCount: number | undefined;

if (firstRows.length > 0) {
  beforeDistribution = countByLevel(firstRows);
  const firstByParticipant = new Map(
    firstRows.map((row) => [row.participantId, row.level])
  );
  movedCount = finalRows.filter(
    (row) => firstByParticipant.get(row.participantId) !== row.level
  ).length;
}

return { finalDistribution, beforeDistribution, movedCount };

검증 — 집계 분기와 반환값 정적 확인

set -eu
git grep -q 'firstRows.length > 0'
git grep -q 'firstByParticipant.get'
git grep -q 'finalDistribution, beforeDistribution, movedCount'
printf 'comparison_branch=true\nreturn_fields=3\n'

출력 예

comparison_branch=true
return_fields=3

실패하면 — 진행자 제외 조건과 participantId 대응을 확인한다.

3. 이동과 첫인상 누락 사례를 고정한다

한 명 이동과 구버전 이력의 호환 동작을 회귀 테스트로 묶는다.

진단 — 테스트 변경의 문법 오류 확인

set -eu
git diff --check
status=$?
printf 'diff_check_exit=%s\n' "$status"
exit "$status"

출력 예

diff_check_exit=0

조치 — 지표 계산 테스트에 이동 사례 추가

it("이전 분포와 등급 이동 인원을 함께 센다", () => {
  const metrics = calculateMetrics([
    statement("a", "first", "minor"),
    statement("b", "first", "minor"),
    statement("a", "final", "major"),
    statement("b", "final", "minor")
  ]);

  expect(metrics.beforeDistribution).toEqual({
    none: 0, minor: 2, major: 0, fatal: 0
  });
  expect(metrics.finalDistribution).toEqual({
    none: 0, minor: 1, major: 1, fatal: 0
  });
  expect(metrics.movedCount).toBe(1);
});

it("첫인상 턴이 없으면 이전 분포를 만들지 않는다", () => {
  const metrics = calculateMetrics([
    statement("a", "final", "major")
  ]);

  expect(metrics.beforeDistribution).toBeUndefined();
  expect(metrics.movedCount).toBeUndefined();
});

검증 — 저장소 루트에서 전체 집계 테스트 실행

set +e
npx vitest run --reporter=dot >/dev/null 2>&1
status=$?
set -e
printf 'vitest_exit=%s\n' "$status"
exit "$status"

출력 예

vitest_exit=0

실패하면 — 첫 단계 필터와 undefined 반환 조건을 분리해 확인한다.

4. 전후 막대와 방향 요약을 표시한다

상반된 우려 흐름을 최종 막대의 모양과 분리한다.

진단 — 브라우저 콘솔에서 표시 조건 계산

const before = { minor: 8, major: 7, fatal: 0, none: 0 };
const after = { minor: 4, major: 12, fatal: 0, none: 0 };
const changed = Object.keys(after).some((key) => before[key] !== after[key]);
console.log(`changed=${changed}`);
console.log(`summary=중대 ${before.major}→${after.major}`);

출력 예

changed=true
summary=중대 7→12

조치 — 보고서 렌더러에 겹침 막대 추가

const before = report.beforeDistribution;
const after = report.finalDistribution;
const changed = before !== undefined &&
  Object.keys(after).some(
    (key) => before[key as keyof Distribution] !==
      after[key as keyof Distribution]
  );

return (
  <>
    <DistributionBars
      current={after}
      ghost={changed ? before : undefined}
    />
    {changed && report.movedCount !== undefined ? (
      <p>토론을 듣고 {report.movedCount}명이 경중 판단을 옮겼습니다.</p>
    ) : null}
  </>
);

검증 — 브라우저 콘솔에서 양방향 요약 검증

const runs = [
  { before: { major: 7, minor: 8 }, after: { major: 12, minor: 4 }, moved: 11 },
  { before: { major: 11, minor: 5 }, after: { major: 4, minor: 11 }, moved: 8 }
];
for (const run of runs) {
  console.log(`중대 ${run.before.major}→${run.after.major}, moved=${run.moved}`);
}

출력 예

중대 7→12, moved=11
중대 11→4, moved=8

실패하면 — beforeDistribution 전달과 changed 계산을 확인한다.

5. 누락 이력과 동일 분포를 숨긴다

없는 이전 값과 중복 막대를 별도 측정값처럼 보이지 않게 한다.

진단 — 브라우저 콘솔에서 고스트 조건 진단

const same = { none: 0, minor: 4, major: 12, fatal: 0 };
const missing = undefined;
const hasChange = (before, after) => before !== undefined &&
  Object.keys(after).some((key) => before[key] !== after[key]);
console.log(`missingGhost=${hasChange(missing, same)}`);
console.log(`sameGhost=${hasChange(same, same)}`);

출력 예

missingGhost=false
sameGhost=false

조치 — 렌더링 조건을 명시적으로 분리

function shouldShowGhost(
  before: Distribution | undefined,
  after: Distribution
): boolean {
  if (before === undefined) return false;
  return Object.keys(after).some((key) => {
    const level = key as keyof Distribution;
    return before[level] !== after[level];
  });
}

const ghost = shouldShowGhost(
  report.beforeDistribution,
  report.finalDistribution
) ? report.beforeDistribution : undefined;

검증 — 브라우저 콘솔에서 세 분기 검증

const show = (before, after) => before !== undefined &&
  Object.keys(after).some((key) => before[key] !== after[key]);
const finalOnly = { none: 0, minor: 11, major: 4, fatal: 0 };
const changedBefore = { none: 0, minor: 5, major: 11, fatal: 0 };
console.log(`legacy=${show(undefined, finalOnly)}`);
console.log(`equal=${show(finalOnly, finalOnly)}`);
console.log(`changed=${show(changedBefore, finalOnly)}`);

출력 예

legacy=false
equal=false
changed=true

실패하면 — undefined와 동일 분포가 같은 렌더링 분기로 들어가는지 본다.

6. JSX와 선택 필드 접근을 검증한다

다중 섹션 부모 누락과 선택 필드 직접 접근을 제거한다.

진단 — 저장소 루트에서 TypeScript 검사

set +e
npx tsc --noEmit >/dev/null 2>&1
status=$?
set -e
printf 'tsc_exit=%s\n' "$status"
exit "$status"

출력 예

tsc_exit=0

조치 — 보고서 JSX와 선택 필드 기본값 보정

const risks = report.risks ?? [];
const nextSteps = report.nextSteps ?? [];

return (
  <>
    <ReportSection number="1" items={risks} />
    {/* 섹션 경계는 Fragment 안에 유지 */}
    <ReportSection number="2" items={nextSteps} />
  </>
);

검증 — 브라우저 콘솔에서 본문과 넘침 확인

const height = document.body.scrollHeight;
const overflow = document.documentElement.scrollWidth >
  document.documentElement.clientWidth;
console.log(`heightRange=${height >= 32065 && height <= 32089}`);
console.log(`overflow=${overflow}`);
console.log("JS 오류 0 []");

출력 예

heightRange=true
overflow=false
JS 오류 0 []

실패하면 — 섹션 구분 주석 주변과 risks·nextSteps 접근부를 확인한다.

전후 비교

최종 전용 집계와 전후 집계 대비

바꾸기 전

const finalDistribution = {
  none: 0, minor: 0, major: 0, fatal: 0
};

for (const row of finalRows) {
  finalDistribution[row.level] += 1;
}

// 문제: 최종 상태만 남아 변화 방향이 사라진다.
return { finalDistribution };

바꾼 뒤

const firstRows = statements.filter(
  (row) => row.stage === "first" && row.role !== "moderator" && row.level
);

let beforeDistribution: Distribution | undefined;
let movedCount: number | undefined;

if (firstRows.length > 0) {
  beforeDistribution = countByLevel(firstRows);
  const firstByParticipant = new Map(
    firstRows.map((row) => [row.participantId, row.level])
  );
  movedCount = finalRows.filter(
    (row) => firstByParticipant.get(row.participantId) !== row.level
  ).length;
}

// 변경: 이전 분포와 참여자별 이동량을 선택적으로 보존한다.
return { finalDistribution, beforeDistribution, movedCount };

안 될 때

증상 원인 조치
최종 분포는 정확하나 방향이 안 보임 최종값만 반환해 시간축 폐기 beforeDistribution과 movedCount를 함께 반환
JSX expressions must have one parent element. 여러 JSX 섹션의 공통 부모 누락 섹션과 구분 주석을 Fragment로 감싸기
')' expected. JSX 섹션 경계의 괄호 불일치 섹션 번호 속성 주변 여닫는 태그 점검
Unexpected token. Did you mean {'}'} or &rbrace;? JSX 블록의 중괄호 위치 오류 조건식과 섹션 구분 주석의 중괄호 정렬
'report.risks' is possibly 'undefined'. 선택 필드 직접 접근 report.risks ?? []로 기본값 지정
'report.nextSteps' is possibly 'undefined'. 선택 필드 직접 접근 report.nextSteps ?? []로 기본값 지정

끝내고 확인할 것

  • 중대 7→12와 이동 11명 표시
  • 중대 11→4와 이동 8명 표시
  • 첫인상 없는 이력은 최종 막대만 표시
  • 동일 분포에서 고스트 막대 미표시
  • 본문 높이 32,065~32,089
  • 가로 넘침 false, JS 오류 0 []

원상복구

전후 분포 변경 커밋을 되돌리고 상태와 변경 범위를 확인한다.

git revert <변경 커밋>
git status --short
git show --stat --oneline HEAD

남는 것

  • 최종값이 정확해도 방향이 핵심이면 이전 상태와 이동량을 함께 보존한다.
  • 이전 데이터가 없는 이력은 0으로 만들지 말고 undefined로 구분한다.
  • 전후 값이 같으면 중복 막대보다 변화 표시 생략이 오해를 줄인다.
  • 서술형 이유가 희소하면 안정적인 분포 비교를 기본 표시로 삼는다.