Type288은 행동 방식 16가지와 동기 조합 18가지를 곱한 288가지 결과를 보여 주는 성향 테스트다. 사용자는 1~5점 리커트 문항과 A/B 선택 문항에 답하고, 브라우저 안에서 결과 코드가 계산된다. 서버가 없으니 채점 로직은 순수한 TypeScript 함수 몇 개가 전부다. 이 글은 그 함수들을 만들며 가장 오래 고민한 문제, 동점을 어떻게 처리할 것인가를 중심으로 정리한다.

Type288 결과 페이지(ENFJ-1w2 "책임감 있는 조율가"). 행동 코드 칩 E·N·F·J와 날개 1w2, 세츄란조로 캐릭터 이미지, 결과 공유하기와 스토리 이미지 저장 버튼이 보인다

동점은 생각보다 자주 나온다

행동 코드는 네 축(E/I, S/N, T/F, J/P)으로 이루어진다. 각 축에서 양쪽 점수를 비교해 큰 쪽 글자를 고른다. 문제는 문항 수가 많지 않은 "빠른 탐색" 모드에서는 양쪽 점수가 정확히 같아지는 일이 꽤 흔하다는 것이다. 예를 들어 E 문항 3개와 I 문항 3개에 모두 4점으로 답하면 두 점수는 12 대 12다.

얼마나 흔한지는 직접 시뮬레이션해 보았다. 실제 행동 문항(data/questions.ko.json의 behavior 그룹)에서 selectMainQuestions로 고른 문항에 1~5점을 균등 무작위로 답하게 하고, 실제 scoreBehavior로 채점해 축별 점수 차가 정확히 0인 비율을 셌다.

  • 환경: Node v22.22.2, macOS 26.6.2 arm64(Apple M5 Pro), 2026-10-23 측정. 의사난수(mulberry32, 시드 288)로 100,000번씩 시행
모드 사용 문항 수 E/I S/N T/F J/P 한 축이라도 동점
빠른 탐색(quick) 24 11.34% 11.14% 11.28% 11.28% 37.97%
전체(deep) 48 8.19% 7.94% 7.95% 7.82% 28.35%

빠른 탐색에서는 응답자 열 명 중 네 명 가까이가 적어도 한 축에서 정확히 동점을 만난다. 다만 이것은 모든 문항에 같은 확률로 답하는 가상의 응답자이고, 실제 사용자의 응답 분포로 측정한 값이 아니다. 실제 비율은 사람들이 중립이나 극단에 몰리는 정도에 따라 달라질 수 있다.

이때 가장 쉬운 방법은 "같으면 왼쪽 글자"처럼 고정 규칙을 정하는 것이다. 하지만 이렇게 하면 결과 전체가 한쪽으로 치우친다. 애매하게 답한 사용자는 모두 E, S, T, J를 받게 된다. 반대로 Math.random()으로 고르면 같은 응답을 해도 새로고침할 때마다 결과가 바뀐다. 결과 페이지를 공유하는 테스트에서 이건 치명적이다.

필요한 성질은 두 가지였다.

  1. 같은 응답이면 항상 같은 결과 (결정적)
  2. 사용자 전체로 보면 한쪽으로 치우치지 않음 (균등)

응답 내용으로 만든 해시로 고르기

두 성질을 모두 만족하는 방법은 응답 자체를 해시해서 동점을 가르는 것이다. 응답이 같으면 해시도 같으니 결정적이고, 응답이 조금만 달라도 해시는 전혀 다른 값이 되니 전체적으로는 고르게 퍼진다.

// 완전 동점일 때 번호나 글자 순서로 고르면 전체 결과가 한쪽으로 치우친다.
// 응답 내용으로 만든 해시로 고르면 같은 응답은 항상 같은 결과를 얻고, 사용자 전체로는 치우치지 않는다.
export function tieKey(answers: AnswerMap, salt: string) {
  let hash = 2166136261;
  const text = Object.keys(answers).sort()
    .map((key) => `${key}:${answers[key]}`)
    .join("|") + "#" + salt;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

이 함수는 FNV-1a라는 간단한 비암호화 해시다. 초기값 2166136261과 곱하는 수 16777619가 32비트 FNV의 표준 상수다. 몇 가지 구현 포인트가 있다.

  • 키를 정렬한다. 객체의 키 순서는 답한 순서에 따라 달라질 수 있다. 정렬하지 않으면 같은 응답이라도 답한 순서가 다르면 해시가 달라진다.
  • Math.imul을 쓴다. 자바스크립트의 일반 곱셈은 부동소수점이라 큰 수를 곱하면 정밀도를 잃는다. Math.imul은 32비트 정수 곱셈을 정확하게 해 준다.
  • >>> 0으로 부호 없는 정수로 바꾼다. 비트 연산 결과는 음수가 될 수 있어서, 마지막에 부호 없는 32비트 정수로 맞춘다.
  • 축마다 다른 salt를 붙인다. 같은 응답으로 네 축을 모두 가르면 네 결과가 서로 연관된다. 축 이름("E_I", "S_N" 등)을 salt로 붙여 축마다 독립적인 해시를 얻는다.

실제로 쓰는 곳은 이렇다.

code: margins.map(({ axis, left, right, difference }) =>
  difference > 0 || (difference === 0 && tieKey(answers, axis) % 2 === 0) ? left : right
).join("")

차이가 0일 때만 해시의 홀짝으로 왼쪽·오른쪽을 고른다. 동기 순위에서 1·2위가 같을 때, 날개 후보 두 개가 같을 때도 같은 함수에 다른 salt(core3, wing4 등)를 붙여 쓴다.

리커트 문항과 A/B 문항을 같은 무게로

Type288에는 두 종류의 문항이 섞여 있다. "나는 사람들과 어울릴 때 에너지가 난다"에 15점으로 답하는 리커트 문항과, A와 B 두 문장 중 어느 쪽에 가까운지 15로 고르는 A/B 문항이다.

리커트 문항 하나는 한쪽 극에만 점수를 준다. A/B 문항 하나는 양쪽 극에 동시에 영향을 준다. 이 둘을 그냥 더하면 A/B 문항이 두 배의 무게를 갖게 된다. 그래서 A/B 문항은 "리커트 한 쌍"과 같은 무게가 되도록 변환했다.

// A/B 문항은 1(A에 매우 가까움)~5(B에 매우 가까움).
// A쪽에 6-응답, B쪽에 응답값을 더해 리커트 한 쌍과 같은 무게를 준다.
function pairWeights(value: number) { return { left: 6 - value, right: value }; }

응답이 1이면 A쪽 5점, B쪽 1점이 되어 리커트로 "A 문항 5점, B 문항 1점"에 답한 것과 같아진다. 축의 최대 차이도 리커트 문항은 2, A/B 문항은 4씩 따로 누적해서, 결과 화면의 "왼쪽으로 몇 % 기울었나"를 문항 구성과 상관없이 같은 기준으로 계산한다.

비율 대신 차이로 재는 이유

날개를 정할 때는 전용 문항의 결과와, 양옆 두 동기의 점수를 반반 섞는다. 두 후보의 점수를 a ÷ (a + b) 비율로 비교하는 방법이 먼저 떠오르지만, 이 방식에는 함정이 있다. 같은 1점 차이라도 점수대가 낮을 때 비율이 크게 흔들린다. 2점 대 1점은 67%지만, 5점 대 4점은 56%다.

// 두 근거를 모두 "첫 후보 쪽 기울기" -1~+1로 맞춘 뒤 섞는다.
// 나눗셈 비율을 쓰면 같은 점수 차이라도 낮은 점수대에서 한쪽으로 쏠리므로 차이로 잰다.
const motive = (candidate: number) =>
  Math.max(0, ((motivationAverages[candidate - 1] ?? 1) - 1) / 4); // 1~5 → 0~1
const questionLean = maxDifference ? difference / maxDifference : 0;
const motivationLean = motive(a) - motive(b);
const lean = WING_QUESTION_WEIGHT * questionLean + (1 - WING_QUESTION_WEIGHT) * motivationLean;

두 근거를 모두 -1~+1 사이의 "기울기"로 바꾼 뒤 가중 평균을 낸다. 이렇게 하면 근거의 종류가 달라도 같은 단위로 섞을 수 있고, 가중치(WING_QUESTION_WEIGHT = 0.5)를 바꿔 실험하기도 쉽다.

모두 "그렇다"고 답하면?

채점 규칙을 만들 때 꼭 확인해야 하는 경우가 있다. 문항 내용과 상관없이 모든 문항에 같은 점수로 답하는 사용자다. 리커트 문항에서는 이런 응답 습관이 흔하다. 이런 응답이 특정 결과로 쏠리면 안 된다. E 문항과 I 문항에 모두 5점을 주면 두 점수가 같아져 축이 기울지 않는다. 이 성질은 테스트로 고정했다.

의존성 없이 node:test로 규칙 고정하기

채점 함수는 DOM도 React도 쓰지 않는 순수 함수라 테스트하기 좋다. 별도 테스트 프레임워크를 설치하지 않고 Node.js 내장 node:test와 node:assert를 썼다.

import assert from "node:assert/strict";
import test from "node:test";
import { scoreBehavior, scoreMotivation, type Question } from "./scoring.ts";

test("agreeing with every statement does not tilt a balanced axis", () => {
  const questions: Question[] = [
    { id: "e", text: "", axis: "E_I", scoreTarget: "E" },
    { id: "i", text: "", axis: "E_I", scoreTarget: "I" },
  ];
  assert.equal(scoreBehavior(questions, { e: 5, i: 5 }).margins[0].difference, 0);
});

test("pair questions push the axis toward the chosen side", () => {
  const pair: Question = {
    id: "t", text: "", format: "pair", axis: "E_I",
    left: { text: "", scoreTarget: "E" }, right: { text: "", scoreTarget: "I" },
  };
  assert.equal(scoreBehavior([pair], { t: 1 }).code[0], "E");
  assert.equal(scoreBehavior([pair], { t: 5 }).margins[0].leftPercent, 0);
});

test("motivation uses averages rather than raw question counts", () => {
  const questions: Question[] = [
    { id: "a", text: "", coreType: 1 },
    { id: "b", text: "", coreType: 1 },
    { id: "c", text: "", coreType: 2 },
  ];
  // 1번 동기 문항이 더 많아도 평균이 높은 2번이 이긴다
  assert.equal(scoreMotivation(questions, { a: 3, b: 3, c: 4 }).core, 2);
});

실행은 package.json의 스크립트 한 줄이다.

"test": "node --experimental-strip-types --test src/lib/scoring.test.ts"

--experimental-strip-types 옵션은 TypeScript의 타입 표기만 지우고 바로 실행해 준다. 빌드 단계나 ts-node 같은 도구 없이 .ts 파일을 테스트할 수 있다. Node 공식 문서에 따르면 타입 스트리핑은 v22.6.0에서 추가됐고, v22.18.0부터는 기본으로 켜져 있으며 실험 경고도 나오지 않는다(Node.js 문서). 이 글의 테스트는 Node v22.22.2에서 실행했다. 그보다 낮은 22.x에서는 이 플래그가 필요하다. 단, 타입만 지우는 방식이라 enum처럼 런타임 코드를 만드는 TypeScript 문법은 쓸 수 없고, import 경로에 .ts 확장자를 붙여야 한다.

npm test를 실행한 결과는 다음과 같다(Node v22.22.2, macOS 26.6.2 arm64, 2026-10-23).

# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 61.367958

이 저장소의 package.json에는 "type": "module"이 없어서 실행 중에 모듈 형식을 다시 해석한다는 경고가 한 줄 함께 나온다. 테스트 결과에는 영향이 없다.

문항 가중치나 동점 규칙을 바꿀 때마다 이 테스트를 돌리면, 의도하지 않은 결과 쏠림이 생겼는지 바로 알 수 있다. 성향 테스트에서 채점 규칙은 곧 콘텐츠의 신뢰도이기 때문에, 화면 테스트보다 이 몇 줄의 단위 테스트가 훨씬 중요했다.

정리

  • 동점은 고정 규칙(치우침)도 난수(비결정)도 아닌 응답 해시로 가르면 결정적이면서 균등하다.
  • FNV-1a는 몇 줄로 구현할 수 있고, Math.imul과 >>> 0으로 32비트 연산을 정확히 맞춘다.
  • 종류가 다른 문항은 같은 무게가 되도록 변환하고, 근거를 섞을 때는 비율보다 같은 단위의 차이로 비교한다.
  • 순수 함수로 분리한 채점 로직은 node:test만으로 충분히 테스트할 수 있다.