• Home
  • Posts
  • Books
  • About
  • EN

동일성은 왜 프로그래밍에서 가장 어려운 문제인가

잘못된 동일성은 그 자리에서 터지지 않는다


동일성은 왜 프로그래밍에서 가장 어려운 문제인가

얼마 전 JEP 401, 그러니까 자바의 Value Objects가 OpenJDK master에 머지됐다는 소식을 봤다. 쉽게 말해 메모리 주소나 식별자(ID) 대신 객체 내부의 값 자체를 기준으로 삼는, 즉 값(Value)처럼 동작하는 객체 타입을 언어 차원에서 도입하자는 제안이다. 물론 아직 다 끝난 건 아니다. JDK 28에 미리보기로 들어가고 정식 출시는 2027년 3월이라, 당분간은 플래그를 켜야 만져볼 수 있다.

JEP 문서가 작성된 건 2020년이지만 그 뿌리인 Project Valhalla가 시작된 게 2014년이니 무려 12년이나 붙들고 있는 셈이다. 메모리 주소가 다른 두 객체를 “언제, 무엇을 기준으로 같다고 판단할 것인가”라는 질문 하나를 언어 표면에 고쳐 넣는 데 그만한 세월이 필요했다. 결국 자바 커뮤니티가 그 긴 시간 동안 씨름한 문제의 본질은 “이 두 개가 같은 것인가”라는 동일성을 언어 차원에서 다시 정의하는 작업이었다.

그런데 이게 12년이나 걸릴 만한 일일까? 어차피 우리는 이미 두 값이 동일함을 판단하는 === 연산자를 쓰고 있는데, 그게 뭐가 어렵다는 건지 잘 와닿지 않는다. 하지만 조금만 더 파고들어보면 왜 이 사람들이 12년 동안이나 이거 하나를 정의하지 못 했었는지 알 수 있다.

이번 포스팅에서는 많은 개발자들을 고통에 빠트리는 주범, 동일성에 대한 이야기를 해보려고 한다.

같은 뿌리를 가진 네 개의 버그

사실 필자가 주로 사용하는 자바스크립트/타입스크립트만 해도 “같음”을 판정하는 알고리즘이 네 개나 있고, 그 네 개는 서로 다른 답을 낸다. 그 네 개 중 어느 것이 호출되는지 우리가 매번 의식하고 코드를 쓰는 것도 아니다.

동일성은 데이터에 들어 있는 속성이 아니라 우리가 코드에 부여하는 계약에 가깝다. 그런데 언어가 === 연산자라는 기본값을 제공하는 순간 그 계약이 언어 안으로 숨어버리고, 우리는 자기가 계약을 맺었다는 사실조차 잊어버린다. 그래서 버그는 계약을 어겼을 때가 아니라, 서로 다른 계약이 만나는 경계에서 터진다.

이렇게 말로만 설명하면 잘 이해되지 않을 수 있으니, 먼저 동일성과 관련해서 실무에서 자주 마주치는 버그 네 가지를 살펴보면서 시작해보자.

첫 번째는 리액트에서 리스트를 렌더링할 때 key를 배열 인덱스로 준 경우다. 아마 프론트엔드 개발자 분들이라면 key를 인덱스로 줘서는 안된다는 이야기를 많이 들어봤을 것이다.

// 중간에서 항목 하나를 삭제하면, 그 아래에 있던 컴포넌트들의
// 입력값이나 포커스 상태가 엉뚱한 곳에 붙어버린다.
{todos.map((todo, index) => (
  <TodoItem key={index} todo={todo} />
))}

두 번째는 Set에 객체를 넣었는데 중복이 제거되지 않는 경우다.

// 우리가 볼 때는 분명히 같은 사용자인데 Set은 이 두 개를 같다고 판별하지 못한다.
const users = new Set([
  { id: 1, name: 'evan' },
  { id: 1, name: 'evan' },
]);

console.log(users.size); // 2

세 번째는 useMemouseEffect의 의존성 배열에 객체를 넣었을 때다.

// 값이 하나도 안 바뀌었는데도 렌더링할 때마다 이펙트가 다시 돈다.
const options = { sortBy: 'date', order: 'desc' };

useEffect(() => {
  fetchList(options);
}, [options]);

마지막 네 번째는 프리즈마(Prisma)나 시퀄라이즈(Sequelize)처럼 조회할 때마다 새 객체를 만들어 반환하는 ORM을 쓸 때다.

// DB상으로는 같은 id를 가진 동일한 데이터인데,
// 메모리상에서는 서로 다른 객체 인스턴스라 === 비교 결과는 false가 나와버린다.
const userA = await prisma.user.findUnique({ where: { id: 1 } });
const userB = await prisma.user.findUnique({ where: { id: 1 } });

console.log(userA === userB); // false

이 네 가지는 발생하는 레이어도 다르고 증상도 다르다. 그런데 원인을 끝까지 따라가면 각 레이어가 “같다”라는 개념을 서로 다르게 정의하고 있고, 우리가 그 차이를 모르고 사용했을 때 의도하지 않은 동작이 발생한다는 공통점이 있다.

자바스크립트에는 “같다”는 개념이 여러 개다

자바스크립트에서 두 값이 같은지 판정하는 알고리즘은, 코드에서 실제로 마주치는 것만 세어봐도 네 가지나 된다. 우리가 자주 쓰는 건 보통 두 개 정도인데, 사실 나머지 두 개도 알게 모르게 매일 호출되고 있다.

알고리즘 표현 NaNNaN +0-0
Loose Equality == false true
Strict Equality ===, indexOf false true
SameValueZero includes, Set, Map true true
SameValue Object.is true false

표만 보면 예외 케이스 몇 개 있는 정도로 보이지만, 실제로 코드를 짜보면 생각보다 훨씬 슬픈 케이스들을 만날 수 있다.

const list = [NaN];

// 같은 배열에서 같은 값을 찾는데 하나는 없다고 하고 하나는 있다고 한다.
console.log(list.indexOf(NaN));   // -1
console.log(list.includes(NaN));  // true

// ===로는 같은데 Object.is로는 다르다.
console.log(0 === -0);         // true
console.log(Object.is(0, -0)); // false

// 그런데 또 Map은 -0으로 조회해도 0으로 넣은 값이 나온다.
const map = new Map();
map.set(0, 'zero');
console.log(map.get(-0));      // 'zero'

둘 중 하나는 버그인 것 같은데, 황당하게도 전부 명세대로 아주 성실하게 동작하는 중이다. indexOf는 Strict Equality를 쓰고 includesMap은 SameValueZero를 쓰기 때문이다. 즉, 같은 비교 연산을 하는데 어떤 방법을 쓰냐에 따라 결과가 다르게 나오는 것이다.

1 정말 어이가 없지만, 전부 명세대로 성실하게 작동하는 중이다

여기까지 보면 “두 개가 같다”라는 동일성을 표현하는데 네 가지의 방법이 전부 동작이 달라서 혼란스러울 것이다. 그런데 여기서 중요한 건 어느 쪽이 옳으냐가 아니다. 네 개의 알고리즘은 각자 합리적인 이유가 있어서 존재하기 때문이다. 진짜 문제는 우리가 코드를 쓸 때 지금 어떤 알고리즘이 호출되는지 대부분 의식하지 않는다는 데 있다.

indexOf 대신 includes를 쓰기로 한 순간 우리는 이미 판정 알고리즘을 하나 골랐다. 그런데 우리 머릿속에서 방금 한 일은 그냥 배열에서 값을 찾은 것이다. 앞에서 말한 선언이 이런 식으로 일어난다. 선언을 하겠다고 마음먹는 자리가 따로 있는 게 아니라, 메서드 이름을 고르는 평범한 자리에 이미 들어와 있다.

언어마다 벌어지는 눈물의 차력쇼

우리만 헷갈리는 게 아니다. 언어를 설계하는 쪽도 같은 문제로 오래 씨름했고, 그 흔적이 각 언어의 문법에 그대로 남아 있다.

미리 두 단어만 쥐고 가자. 하나는 값(Value)과 엔티티(Entity)다. 천 원짜리 지폐 두 장처럼 내용이 같으면 그냥 같은 것으로 쳐도 되는 게 값이고, 사용자나 주문처럼 내용이 같아도 남남일 수 있는 게 엔티티다. 다른 하나는 동치관계 공리인데, “같다”가 수학적으로 지켜야 하는 법칙이라고만 알아두면 된다. 둘 다 뒤에서 제대로 볼 테니 여기서는 이 정도만 들고 각 언어를 훑어보자.

먼저 필자가 자주 사용하는 자바스크립트는 primitive와 object로 나눠서 구분한다. primitive는 값처럼, object는 엔티티처럼 동작한다. 이는 단순하고 예측 가능한 대신, 사용자가 자기 도메인의 값 타입을 정의할 방법이 없다는 단점도 함께 안고 간다. MoneyPoint 같은 걸 만들면 자동으로 엔티티 취급을 받는다.

// 내용이 완전히 같은 천 원짜리 두 장인데 다르다고 나온다.
// 객체로 만든 순간 엔티티 취급을 받기 때문이다.
const a = { amount: 1000, currency: 'KRW' };
const b = { amount: 1000, currency: 'KRW' };

console.log(a === b); // false

자바는 원래 equalshashCode를 직접 오버라이드하는 방식이었다. 사용자에게 동일성에 대한 계약 정의를 짬때린 것인데, 문제는 그 계약을 지키기가 생각보다 어렵고 실수해도 아무도 안 알려준다는 데 있다.

// 옛날 자바에서는 이걸 전부 손으로 짜야 했다.
// equals만 오버라이드하고 hashCode를 빼먹으면 HashMap이 소리 없이 망가진다.
public class Money {
    private final int amount;
    private final String currency;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money m)) return false;
        return amount == m.amount && currency.equals(m.currency);
    }

    @Override
    public int hashCode() { return Objects.hash(amount, currency); }
}

// record로 바꾸면 위의 보일러플레이트가 전부 이 한 줄로 끝난다.
record Money(int amount, String currency) {}

그러다가 점점 자바가 진화하며 record가 등장하면서 조금 나아졌고, 이번에 들어온 Value Objects는 여기서 한 발 더 나가 “정체성이 없는 객체”를 언어 차원에서 선언할 수 있게 해준다. 다만 이쪽의 출발점은 의미론이 아니라 성능이었다. 정체성을 떼어내야 JVM이 객체 포인터를 거치지 않고 내부 필드들을 메모리에 연속으로 Flatten해서 배치할 수 있기 때문인데, 그 과정에서 값인지 엔티티인지를 선언할 자리가 같이 생긴 것이다.

러스트는 이 고민을 축 둘로 나눈 트레이트(Trait) 시스템으로 깔끔하게 해결했다. 첫 번째 축은 값과 엔티티의 소유권/복제 동작이다. 숫자나 좌표처럼 Copy 트레이트가 붙은 타입은 대입할 때 소유권 이동(Move) 대신 비트 복사가 일어나며 원시 ‘값’처럼 취급되고, Copy가 없는 타입은 엔티티 취급을 받아 소유권이 넘어가거나 명시적으로 .clone()을 호출해야 한다.

두 번째 축은 동등성 판정의 완벽함이다. 생김새 비교 연산자(==)를 쓰려면 PartialEq가 필요한데, 그 하위에 Eq라는 트레이트를 하나 더 뒀다. Eq는 아무 메서드도 추가하지 않는 마커 트레이트(Marker Trait)로, “이 타입의 비교는 반사성(a=aa = a)을 포함한 동치관계 공리를 완벽히 지킨다”는 사실을 타입 레벨에서 보장한다. 부동소수점(f64)이 PartialEq만 구현하고 Eq는 구현하지 않는 이유가 바로 이것 때문인데, NaN != NaN이라 반사성이 깨지기 때문이다.

// 이 한 줄에 선언이 세 개 들어 있다.
// Copy: 이건 값이다 (대입하면 복사된다).
// PartialEq: 생김새를 비교할 수 있다.
// Eq: 그 비교가 동치관계 공리를 지킨다.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Money { amount: i64, currency: Currency }

결국 러스트는 개발자가 derive 한 줄을 적을 때, 이 타입이 값인지 엔티티인지, 그리고 그 비교가 수학 공리를 지키는지 코드 표면에 명시하도록 만들어둔 셈이다.

하스켈도 Eq를 타입클래스로 열어뒀고 deriving (Eq) 한 줄이면 컴파일러가 필드별 비교를 만들어주니, 사실 자바 record가 하는 일을 30년 먼저 해둔 셈이다. 다만 하스켈은 법칙을 강제하지 않는다.

Haskell Report는 Eq의 법칙을 아예 정의하지 않고 라이브러리 문서가 반사성이나 추이성을 지켜달라고 권하는 정도라, 하스켈의 DoubleNaN 때문에 반사성을 어기면서도 Eq 인스턴스를 버젓이 갖고 있다. 같은 문제를 두고 러스트는 타입에서 잘라냈고 하스켈은 그냥 넘어간 것이다.

이 스펙트럼에서 어느 쪽이 우월하다고 말하기는 어렵다. 다만 여기에는 서로 다른 질문 두 개가 섞여 있다. 하나는 동등성 판정 로직을 누가 작성하느냐고, 다른 하나는 이 타입이 값인지 엔티티인지를 누가 정하느냐다.

판정 로직 쪽은 계속 언어가 가져가는 추세다. 옛날 자바가 equals를 직접 짜라고 했던 것에 비하면 record는 필드만 보고 알아서 만들어주고, 러스트도 derive 한 줄이면 끝난다. 손으로 짜는 사람이 실수하는 것보다 언어가 기계적으로 도출하는 편이 낫다는 판단이다.

반면 종류를 정하는 자리는 언어마다 역사가 제각각이다. C#은 2002년 1.0부터 structclass로 값과 엔티티를 구분할 자리를 줬고 Swift나 ML 계열 언어도 처음부터 그 자리가 있었지만, 예전 자바에는 “이 타입은 정체성이 없다”고 말할 방법이 아예 없었다. 모든 것이 정체성을 가진 객체였고, 값처럼 굴게 하려면 equals를 오버라이드해서 흉내 내는 수밖에 없었다. 결국 자바가 12년을 들여 Value Objects를 만든 것도, 남들 다 가진 그 자리를 뒤늦게 언어 표면에 갖추는 작업이었던 셈이다.

“같다”는 말에는 세 가지 뜻이 섞여 있다

언어들이 저렇게까지 애쓰는 걸 보면 질문을 한 단계 앞으로 물려야 할 것 같다. 애초에 두 개가 같다는 게 무슨 뜻인가. 잠깐 코드는 잊고, 한번 일상 속의 상황을 상상해보자.

여러분은 어떤 사건을 수사하고 있는 수사관이고, 최근에 목격자 진술 두 건이 들어온 상황이다. 우리는 A가 본 사람과 B가 본 사람이 동일인인지 가려내야 하는 것이다.

가장 확실한 건 두 목격자가 같은 시각 같은 장소에 서있는 사람을 보고 있었다는 상황이다. 이건 뭐 평행우주가 아닌 이상 다른 사람일 수가 없으니 더 따질 것도 없다.

두 번째는 두 목격자가 얘기해주는 생김새를 맞춰보는 것이다. 키도 얼굴도 옷차림도 전부 일치하면 동일인이라고 봐도 되지 않을까 싶다.

마지막 세 번째는 목격자들의 말을 듣지 않고 직접 그 인물의 지문을 채취하여 주민등록번호를 조회해보는 것이다. 생김새가 어떻든 주민등록번호가 같으면 동일인이다.

셋 다 나름 말이 되는 기준이다. 문제는 세 방법이 서로 다른 답을 내놓을 수 있다는 것에 있다. 가령 일란성 쌍둥이는 생김새가 같지만 주민등록번호가 다르고, 10년 전의 나와 지금의 나는 생김새가 완전히 딴판이지만 주민등록번호는 같다.

twins 생김새로 판정하면 같은 사람, 주민번호로 판정하면 남남이다. 우리 코드도 매일 이 둘을 헷갈린다

그러니까 “저 둘이 동일인이냐”는 질문에는 사실 답이 하나가 아니다. 무엇을 보고 판단할지를 먼저 정해야 답이 나온다. 코드에서 벌어지는 일이 정확히 이거다. 세 기준에는 이름도 붙어 있다.

무엇을 보나 부르는 이름 코드에서는
애초에 한 사람인가 참조 동일성 ===로 객체를 비교할 때
생김새가 같나 구조적 동등성 JSON.stringify로 비교할 때
주민번호가 같나 도메인 동일성 id가 같은지 볼 때
const userA = { id: 1, name: 'evan' };
const userB = { id: 1, name: 'evan' };

// 1. 참조 동일성 (장소): false (서로 다른 메모리 객체)
console.log(userA === userB);

// 2. 구조적 동등성 (생김새): true (안의 내용물이 똑같음)
console.log(JSON.stringify(userA) === JSON.stringify(userB));

// 3. 도메인 동일성 (주민번호): true (id가 같으니 같은 사용자)
console.log(userA.id === userB.id);

앞으로는 정식 명칭 대신 장소, 생김새, 주민번호로 부르려고 한다. 동일성이니 동등성이니 하는 말이 한 글자 차이라 읽다 보면 헷갈리기 때문이다. 첫 번째를 장소라고 부르는 건 결국 메모리의 같은 장소냐를 묻는 것이라서다.

이걸 손에 쥐고 아까 그 버그 네 개를 다시 보면 그림이 꽤 달라진다. key에 인덱스를 준 건 리액트에게 값이 있는 장소를 보고 판단하라고 시킨 것이다. Set이 중복을 못 걸러낸 건 우리는 주민번호를 봤는데 Set은 장소를 봤기 때문이고, useEffect가 매번 도는 것도 같은 이유다. ORM에서 객체 두 개가 나온 것도 주민번호는 같은데 장소가 다른 상황이다.

하이버네이트나 EF Core처럼 Identity Map을 두는 ORM들이 있는데, 이건 “주민번호가 같으면 장소도 같게 맞춰주겠다”는 계약을 ORM이 대신 이행해주는 것이다. 다만 그 보장은 세션 안에서만 유효하다. 세션 경계를 벗어나는 순간 다시 두 개가 되고, 그래서 하이버네이트는 비즈니스 키로 equals를 직접 구현하라고 권한다. 결국 경계에서 터진다는 이야기는 여기서도 똑같다.

네 개 전부 우리는 주민번호로 판단했는데 코드는 자리로 판단한 사건이었다. 앞에서 이걸 선언이라고 불러뒀는데, 이제 그 선언에 정확히 뭐라고 적혀 있었는지도 말할 수 있다. 우리는 네 번 다 “장소가 같으면 같은 것으로 쳐라”에 서명했고, 정작 원했던 건 주민번호였다. 사실 실무에서 마주치는 동일성 버그의 대부분이 이 조합이다. 그런데 세 기준에는 아무도 말해주지 않는 전제가 하나 깔려 있다. 비교할 두 개가 지금 우리 눈앞에 다 있어야 한다는 것이다. 이 전제가 깨지는 순간 이야기가 완전히 달라진다.

값과 엔티티는 다른 종류다

세 기준이 왜 하나로 합쳐지지 않는지를 설명하려면 좀 더 심연으로 내려가야 한다. 앞에서 언어들을 훑어볼 때 값이니 엔티티니 하는 말을 계속 썼는데, 이제 그게 정확히 무슨 뜻인지 볼 차례다. 핵심은 우리가 다루는 데이터에 두 종류가 있다는 것이다.

하나는 값이다. 숫자 5, 문자열 "hello", 튜플 [3, 4] 같은 것들이 여기에 속한다. 값에게는 주민등록번호가 없다. 정확히는 발급해줄 이유가 없다고 하는게 맞겠다. 여기 있는 5와 저기 있는 5를 굳이 구별해서 뭘 하겠는가 싶기 때문이다. 값은 생김새가 곧 자기 자신이다.

다른 하나는 엔티티다. 사용자, 주문, 세션 같은 것들이다. 엔티티에게는 주민등록번호가 있고, 그 번호는 생김새와 아무 상관이 없다. 이름이든 이메일이든 전부 갈아치워도 번호가 그대로면 같은 사용자다. 반대로 이름과 이메일이 똑같이 생긴 두 사용자가 완전히 남남일 수도 있다.

즉 값에게는 생김새를 물어야 하고 엔티티에게는 번호를 물어야 한다. 그런데 언어는 대개 == 연산자 하나만 쥐여준다. 질문은 두 종류인데 물어볼 창구가 하나뿐이니, 어느 한쪽은 반드시 어긋날 수밖에 없다.

둘을 가르는 진짜 기준은 시간이다

그런데 값과 엔티티를 나누는 기준이 정확히 뭘까? 원시 타입이냐 객체냐로 가르는 건 언어의 구현 사정일 뿐이고, 더 근본적인 차이가 하나 있다. 바로 값에는 시간이라는 개념이 없고 엔티티에는 있다는 것이다.

5에게는 어제의 5가 없다. 좌표 [3, 4]에게도 작년의 [3, 4] 같은 건 없다. “언제적 5냐”고 묻는 질문 자체가 성립하지 않는다는 것이다. 5는 그냥 5다. 반면 사용자에게는 어제의 사용자 정보가 있다. 주문에도 결제 직전의 주문이 있고, 세션에도 갱신 전의 세션이 있다. 앞에서 나온 10년 전의 나도 마찬가지다. 엔티티를 엔티티로 만드는 게 바로 이 성질이다.

그러고 보면 주민등록번호라는 게 참 잘 만들어진 물건이다. 애초에 저 번호가 존재하는 이유가 사람이 시간을 통과하면서 계속 변하기 때문이다. 생김새로는 10년 전의 나와 지금의 나를 이어붙일 방법이 없으니, 변하지 않는 번호를 하나 붙여두고 그걸로 묶는 것이다.

이걸 프로그래밍 쪽 언어로 옮긴 게 정체성이라는 말이다. 함수형 프로그래밍 언어인 Clojure 설계 문서의 Values and Change에서 리치 히키(Rich Hickey)가 정리한 삼분법이 가장 깔끔한데, 대충 요약하면 이런 느낌이다.

값은 변하지 않는 것이다. 42는 변하지 않는다. 정체성은 시간에 따라 서로 다른 값들과 결부되는 안정적인 논리적 실체다. 상태는 그 정체성이 특정 시점에 갖는 값이다.

설계 문서라 말이 좀 딱딱한데, 결국 방금 한 이야기 그대로다. 매년 찍은 내 증명사진 한 장 한 장이 값이고, 그 사진들을 한 사람 것으로 묶어주는 주민등록번호가 정체성이고, 올해 사진이 상태다.

이 정의를 받아들이면 아까 미뤄뒀던 문제가 풀린다. 이름이 바뀐 사용자를 여전히 같은 사용자라고 부르는 이유는 어떤 비교 알고리즘이 그렇게 판정해줘서가 아니라, user라는 이름이 애초에 사진 한 장이 아니라 사진첩 전체에 붙어 있는 이름이기 때문이다.

시점은 값 안에 들어 있지 않다

이 구분이 왜 실무에서 구체적인 문제로 번지는지 보자. 폼에서 사용자 정보를 수정하는 간단한 예시다.

// 서버에서 불러온 사용자 데이터 (시점 A)
const serverUser = { id: 1, name: 'evan' };

// 사용자가 폼에서 이름을 수정함 (시점 B)
const formUser = { id: 1, name: 'evan (수정됨)' };

두 객체에 앞의 세 기준을 대해보면 꽤 묘한 결론이 나온다. 번호를 보면 id가 같으니 같은 사람이고, 생김새를 보면 name이 다르니 남남이다. 둘 다 맞는 말인데 정작 우리가 진짜 궁금한 질문에는 아무도 답을 주지 못한다.

“그래서 둘 중 어느 게 더 최근에 찍은 사진이지?”

물론 우리는 직관적으로 formUser가 더 최신 데이터라는 것을 인지하지만 그건 그 코드를 작성한 우리 머릿속에만 있는 시점 정보일 뿐이다. 객체는 자기가 언제적 모습인지 말해주지 않고 말해줄 방법도 없다. 그러니 이 질문에 답하려면 날짜나 순서를 사진 바깥에 따로 기록해두는 수밖에 없다. DB 테이블에 updatedAt이나 version 같은 열을 따로 두는 이유가 여기 있다.

리액트가 상태를 직접 수정하지 말고 불변 객체로 다루라고 요구하는 이유도 똑같다. 기존 객체를 직접 수정해버리면 직전 시점의 사진이 증발해버리기 때문이다. 리액트는 이전 스냅샷과 새 스냅샷을 앨범(useState)에 순서대로 보관해둠으로써 비로소 시점의 변화를 다룰 수 있게 된다.

테세우스의 배 이야기가 2천 년째 결론이 안 나는 이유도 여기 있다. 널빤지를 전부 갈아 끼운 배가 이전의 그 배와 같은 배냐는 질문은 배를 아무리 뜯어봐도 답이 안 나온다. 그 배에 등록번호를 붙여둔 사람이 아무도 없기 때문이다.

theseus 2천년 동안 등록번호가 없던 테세우스의 배는 희대의 역설이 되어버렸다

생김새가 같다는 건 대체 무슨 뜻일까

그렇다면 프로그래밍의 세계에서 “같다”라는 것은 어떤 식으로 정의되는 걸까?

일단 세 기준 중에서 가장 만만해 보이는 생김새 비교를 한번 생각해보자. 그냥 단순하게 생각하면 객체 안에 든 값을 다 훑어서 비교하면 그만 아닌가 싶다. 그런데 이게 직접 구현해보면 함정이 끝도 없이 튀어나온다.

const isEqual = (a: unknown, b: unknown) =>
  JSON.stringify(a) === JSON.stringify(b);

// 같은 객체인데 키 순서가 달라서 다르다고 나온다.
isEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // false

// 정규식은 둘 다 {} 로 직렬화되므로 무조건 같다고 나온다.
isEqual(/a/g, /b/g); // true

// toJSON 때문에 Date와 문자열이 같아지고, undefined는 아예 직렬화에서 빠진다.
isEqual(new Date(0), '1970-01-01T00:00:00.000Z'); // true
isEqual({ a: undefined }, {}); // true

객체를 문자열로 바꿔서 비교하는, 아마 한 번쯤은 써봤을 방법이다. 그런데 이 코드는 양쪽으로 다 틀린다. 같은 것을 다르다고 하기도 하고, 다른 것을 같다고 우기기도 한다. 전부 JSON.stringify가 원래 하라고 만들어진 일을 성실히 한 결과다. 직렬화는 객체를 전송 가능한 문자열로 바꾸는 도구지 두 객체가 같은지 판정하는 도구가 아닌데, 우리가 그걸 비교기로 가져다 쓴 것이다. 여기에 순환 참조까지 끼면 아예 예외를 던지고 죽는다.

이것도 앞에서 본 그 구조다. JSON.stringify로 비교하기로 정한 순간 우리는 직렬화 규칙을 그대로 동일성 규칙으로 삼겠다고 선언한 것인데, 그 규칙을 설계한 사람은 동일성 같은 건 염두에 둔 적이 없다. 남이 다른 목적으로 써둔 계약서에 서명한 셈이다.

그럼 문자열 말고 직접 순회하면 되지 않을까 싶은데, 그것도 만만치 않다. 제대로 만들려면 최소한 이 정도는 처리해야 한다.

const isDeepEqual = (a: unknown, b: unknown): boolean => {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object') return false;
  if (a === null || b === null) return false;
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  const keysA = Reflect.ownKeys(a);
  const keysB = Reflect.ownKeys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every(
    (key) =>
      keysB.includes(key) &&
      isDeepEqual(Reflect.get(a, key), Reflect.get(b, key)),
  );
};

이 정도 썼으면 됐겠지 싶지만 여전히 부족하다. DateMap, Set처럼 내부 슬롯에 상태를 숨기고 있는 객체는 키를 순회해봐야 아무것도 안 나오고, 순환 참조를 만나면 스택이 터진다.

게다가 이 코드는 이미 우리 대신 결정을 몇 개 내려버린 상태이다. Reflect.ownKeys를 고른 순간 Symbol 키도 비교하겠다고 정한 것이고, getPrototypeOf를 비교한 순간 프로토타입이 다르면 다른 것으로 치겠다고 정한 것이다. 첫 줄의 Object.is도 마찬가지다. 여기에 ===를 썼다면 NaN을 품고 있는 객체 두 개가 영원히 다르다고 나왔을 텐데, 그걸 피한 대가로 +0-0을 다르다고 보게 됐다.

여기서 알 수 있는 게 있다. 생김새가 같다는 판정은 코드를 잘 짜면 저절로 나오는 게 아니다. “어디까지를 생김새로 칠 것인가”를 하나하나 정의해가며 쌓아올려야 나오는 것이다. es-toolkit의 isEqualWith 함수가 수백 줄인 것도 그래서다. 즉, “같다”라는 것을 정의하려면 알고리즘이 복잡한 게 아니라 기준을 잡아주는 결정이 많이 필요하다.

같음에는 지켜야 할 세 가지 법칙이 있다

앞에서 러스트가 Eq 트레이트에 “이 비교는 동치관계 공리를 지킨다”를 적어둔다고 했는데, 미뤄뒀던 그 공리를 이제 볼 차례다. 수학에서 동치관계는 세 가지 공리를 만족하는 관계로 정의된다.

반사성: a=aa = a가 항상 참이다. 모든 것은 자기 자신과 같다.

대칭성: a=ba = b이면 b=ab = a다. 비교하는 순서가 결과를 바꾸지 않는다.

추이성: a=ba = b이고 b=cb = c이면 a=ca = c다. 같음은 연결된다.

너무 당연해서 굳이 이름까지 붙일 일인가 싶은 성질들이다. 그런데 놀랍게도 실무에서 우리가 쓰는 비교 함수 중 상당수가 이 셋 중 하나 이상을 아무렇지 않게 위반하고 있다.

가장 유명한 반사성 위반은 NaN이다. 분명히 같은 값인데 === 연산자로 비교 연산 때려보면 두 값이 다르다고 나온다.

const value = NaN;

// 자기 자신과 비교했는데 다르다고 나온다.
console.log(value === value); // false

사실 이건 자바스크립트가 이상해서는 아니고, 부동소수점 연산 표준인 IEEE 754에서 ==의 동작을 그렇게 정의해둔 것이라, 부동소수점을 쓰는 거의 모든 언어가 이렇게 동작한다.

재미있는 건 대부분의 언어가 이걸 그냥 두지 않았다는 점이다. 자바에는 Double.equals가, 자바스크립트에는 Object.is가 따로 있고 둘 다 NaN을 자기 자신과 같다고 본다. 포스트그레스는 아예 한술 더 떠서 인덱스를 만들려고 NaN = NaN을 참으로 정해버렸다. 즉 공리를 어긴 기본값 옆에 공리를 지키는 우회로를 하나씩 만들어둔 셈이다. (본격 비교 연산의 온몸 비틀기 대참사…)

앞에서 본 러스트가 부동소수점에 Eq를 안 붙이고 PartialEq만 붙인 게 바로 이것 때문이다. 덕분에 러스트에서는 실수가 담긴 벡터를 그냥 정렬하려고 하면 아예 컴파일 에러를 내버리는데, sort()가 요구하는 OrdEq를 전제로 하기 때문이다. 처음 겪으면 꽤 사람 짜증나게 하는 동작인데, 컴파일러가 사고를 미리 막아주고 있는 것이다.

사실 반사성 위반보다 더 위험한 건 추이성 위반이다. 우리가 보통 부동소수점을 비교할 때는 오차를 감안해서 이런 식으로 코드를 작성하곤 한다.

const EPSILON = 0.1;

const isEqual = (a: number, b: number) => Math.abs(a - b) < EPSILON;

// a와 b가 같고
console.log(isEqual(1.00, 1.09)); // true

// b와 c도 같은데
console.log(isEqual(1.09, 1.18)); // true

// 정작 a와 c는 다르다.
console.log(isEqual(1.00, 1.18)); // false

이 함수는 대칭성만큼은 확실히 만족한다. 반사성은 앞에서 본 그 문제가 여기에도 그대로 딸려 온다. isEqual(NaN, NaN)은 물론이고 isEqual(Infinity, Infinity)false인데, 무한대끼리 빼면 NaN이 나오기 때문이다.

그런데 진짜 문제는 추이성이다. 위 코드처럼 a=b=ca = b = c라는 연결이 성사되지 않는다. 사람으로 치면 나와 A가 동일인이고 A와 B도 동일인인데 나와 B는 남남인 상황인 것이다.

그리고 이건 구현을 잘못한 게 아니라 epsilon 비교라는 방식 자체의 한계다. 값들이 임계값보다 촘촘하게 놓일 수 있는 한 사슬을 길게 이어가면 반드시 규칙이 깨지는 순간이 발생하기 때문이다. 그래서 탈출구는 임계값 쪽이 아니라 반대편에 있다. 값이 놓일 수 있는 자리 자체를 띄엄띄엄하게 만들면 된다. 뒤에서 볼 금액 이야기가 정확히 그 처방이다.

공리를 어기면 자료구조가 티 안 나게 망가진다

여기까지 읽으면 그래서 뭐 어쩌라는 건가 싶을 수 있다. 수학 공리를 좀 어긴들 코드가 돌아가기만 하면 되는 거 아닌가 싶기 때문이다. 그런데 사실 이 지점이 필자가 이 글에서 가장 하고 싶은 이야기다.

우리가 매일 아무 생각 없이 갖다 쓰는 자료구조들은 전부 동치관계 공리가 성립한다는 전제 위에서 설계되어 있다.

SetMap부터 보자. 이 둘은 같은 것은 같은 자리로 간다는 전제 위에서 만들어졌고, 실제 엔진들은 해시 테이블로 구현한다. 결국 이 조회의 마지막 단계는 “넣었을 때 값과 조회한 값이 같나”를 묻는 비교인데, 반사성이 깨져버리면 이 마지막 질문에서 걸려서 분명히 정성스레 담은 값을 영원히 찾지 못하는 사태가 벌어질 것이다.

그런데 막상 Set을 사용해서 NaN을 넣고 조회해보면 의외의 결과가 나온다.

const set = new Set<number>();
set.add(NaN);

// 왜 되지?
console.log(set.has(NaN)); // true

자바스크립트의 Set=== 대신 SameValueZero를 쓰고, 이 알고리즘은 NaN을 특별 취급하기 때문이다. 즉 언어가 공리를 지키려고 별도 알고리즘을 하나 더 만들어둔 것이다. 앞에서 본 네 가지 중 SameValueZero가 굳이 존재하는 이유가 여기 있다.

그런데 여기서 “아 언어가 알아서 처리해주는구나” 하고 안심하면 곤란하다. 언어가 지켜준 건 NaN 하나뿐이고, 그것도 SetMap에서만이다. 똑같은 NaNindexOf로 찾으면 여전히 못 찾고, 객체를 넣으면 애초에 아무것도 안 지켜준다. 언어가 대신 해준 것처럼 보이는 자리에는 이렇게 항상 단서 조항이 붙어 있는데, 그 조항은 우리가 찾아 읽지 않으면 아무 데도 안 적혀 있다.

정렬로 넘어가면 상황이 훨씬 극적이다. 특히 비교 함수가 추이성을 잃으면 결과가 그냥 틀린 순서로 나오는 정도가 아니라 알고리즘 자체가 오작동하는 경우가 많다. 예를 들어 자바의 Arrays.sort는 객체 배열을 정렬하다가 이 상황을 감지하면 아예 예외를 던져버린다.

java.lang.IllegalArgumentException: Comparison method violates its general contract!

이건 Arrays.sort가 쓰는 Tim Sort가 병합 과정에서 추이성을 가정하고 최적화를 하기 때문이다. 두 덩어리를 합칠 때 원소를 하나하나 비교하면 느릴 수 밖에 없으니, 한쪽이 연속으로 작다고 나오면 “아 여기에는 저쪽 첫 원소보다 작은 놈들만 몰려있겠구만” 하고 인덱스를 2n2^n 형태로 건너뛰면서 비교하다가, 큰 놈이 나오면 그 사이 공간을 이진 탐색으로 훑어서 정확한 위치를 찾는 식이다. 그런데 추이성이 깨지면 건너뛴 구간 안에서 앞뒤가 안 맞는 원소가 튀어나오고, 그러면 삽입할 자리 자체가 논리적으로 존재하지 않게 된다. 그래서 자바는 그 자리에서 그냥 에러를 던져버린다.

자바스크립트는 이런 것조차 없다. 명세는 일관성 없는 비교 함수를 넘겼을 때 뭐가 나올지를 아예 사람의 구현에 맡겨버린다. 이 경우 정렬은 끝났고 예외도 안 났는데 원소 순서만 틀린 슬픈 결과를 받게 된다. 필자는 차라리 자바처럼 저렇게 유난스럽게 에러를 던져주는 쪽이 낫다고 본다.

메모이제이션도 같은 위험을 안고 있다. 캐시 키의 동등성이 추이성을 어기면 같은 입력인데도 캐시 히트가 났다 안 났다 한다. 성능 문제처럼 보이지만 사실은 정확성 문제고, 캐시된 값이 잘못 재사용되는 순간 결과 자체가 틀어진다.

그리고 해시 기반 자료구조에는 앞의 공리 세 개 말고 지켜야 할 계약이 하나 더 붙어 있다.

같은 두 값은 반드시 같은 해시를 가져야 한다.

반대 방향은 성립하지 않아도 된다. 해시가 같은데 값이 다른 건 충돌이라고 부르고 자료구조가 알아서 처리해준다. 반면 값이 같은데 해시가 다르면 그 둘은 서로 다른 버킷으로 갈라지고, 그러면 영영 만나지 못한다.

동사무소로 치면 같은 사람 서류가 두 개의 다른 캐비닛에 나뉘어 들어간 상황인데, 담당자는 그 둘을 평생 못 마주친다. 반대로 한 캐비닛에 여러 사람 서류가 섞여 있는 건 딱히 큰일이 아니다. 그냥 열어서 하나씩 확인하면 그만이니 말이다.

catalog 해시가 하는 일이 정확히 이거다. 서랍을 잘못 고르면 그 안을 아무리 뒤져도 안 나온다

자바에서 equals를 오버라이드하면 hashCode도 반드시 같이 오버라이드하라고 하는 이유가 이것이다. 그런데 이 계약을 깨도 코드는 한동안 멀쩡히 돌아간다.

버킷 자리는 해시를 버킷 개수로 나눈 나머지로 정해지니, 해시가 달라도 우연히 같은 칸에 떨어지면 그냥 찾아지기 때문이다. 그러다 맵이 커져서 버킷 수가 재조정되는 순간, 어제까지 잘 되던 조회가 갑자기 실패하기 시작한다.

결국 필자가 이 섹션에서 하고 싶은 말은 이거다.

동치관계 공리를 어긴 코드는 그 자리에서 터져주지 않는다는 것이다.

대신 잘못된 동치관계 공리 위에 쌓아올린 코드들이 오묘하게 이상한 답을 내놓기 시작하는 상황이 발생한다. 심지어 그 어긋나는 값들은 데이터의 순서나 크기에 따라 나타났다 사라졌다 하기 때문에 재현하기가 진짜 빡세다. (이렇게 오늘도 퇴근이 늦어진다)

물론 공리 위반이 항상 눈에 보이는 오작동으로 이어지는 건 아니고, 자료구조와 데이터 분포에 따라 아무 일 없이 넘어가는 경우도 많다. 필자는 그래서 오히려 더 위험하다고 보는 편이다.

그리고 여기까지 오면 지나온 것들이 하나로 꿰인다. SetNaN을 찾아준 건 Set에서만이었다. ORM이 같은 객체를 돌려주는 건 세션 안에서만이고, 언어가 hashCode를 만들어주는 건 record로 선언해줬을 때뿐이고, 자바가 정렬 중에 에러를 던져주는 건 Tim Sort가 마침 눈치챘을 때뿐이다. 언어와 라이브러리가 알아서 처리해주는 것처럼 보이는 자리에는 예외 없이 이런 단서 조항이 달려 있다.

계약서를 안 읽어도 대부분의 날에는 아무 일도 일어나지 않는다. 진짜 문제는 거기다. 계약서를 안 읽었다는 사실을 알려주는 장치가 어디에도 없다.

리액트의 key는 동일성 선언이다

앞에서 꺼낸 리액트 key 문제로 돌아가보자. 이제 다르게 읽을 수 있다.

리액트의 재조정 알고리즘은 이전 트리와 새 트리를 비교하면서 “이 노드와 저 노드는 같은 컴포넌트인가”를 판정해야 한다. 그런데 리액트는 우리 도메인을 모른다. 어떤 게 같은 할 일 항목인지 알 방법이 없다.

그래서 리액트는 그 판정을 우리에게 위임한다. key가 그 위임의 인터페이스다. key는 성능 최적화를 위한 힌트가 아니라, 이 리스트에서 무엇이 같은 것인지를 선언하는 자리다.

이 글에서 지금까지 본 것 중에 계약서를 눈앞에 펼쳐놓고 여기에 서명하라고 대놓고 요구하는 건 사실상 key가 유일하다. 나머지는 전부 서명이 코드 어딘가에 숨어 있었다.

// "위치가 같으면 같은 항목이다"라고 선언하는 것과 같다.
{todos.map((todo, index) => (
  <TodoItem key={index} todo={todo} />
))}

// "주민번호가 같으면 같은 항목이다"라고 선언하는 것과 같다.
{todos.map((todo) => (
  <TodoItem key={todo.id} todo={todo} />
))}

인덱스를 key로 주면 우리는 위치를 정체성으로 삼겠다고 선언한 셈이 된다. 그리고 리액트는 그 선언을 아주 성실하게 믿는다. 그래서 중간 항목을 삭제해도 리액트 입장에서는 3번 자리의 항목이 여전히 거기 있는 것이므로 그 자리의 컴포넌트 상태를 그대로 유지한다. 우리가 잘못 선언했고 리액트는 그저 정확히 따랐을 뿐이다. 반면 todo.id를 주면 주민번호를 정체성으로 삼겠다는 선언이 되고, 대부분의 경우 이게 우리가 실제로 원하던 계약이다.

여기서 key를 인덱스로 주면 안 된다고 외우는 것과, 그게 동일성을 잘못 선언하는 행위라고 이해하는 것은 꽤 다르다. 후자를 이해하면 어디까지가 안전한지도 같이 보이기 때문이다.

인덱스를 써도 되는 건 항목 집합 자체가 렌더링 사이에 바뀌지 않을 때뿐이다. 재정렬과 추가·삭제가 없는 걸로는 부족하다. 길이가 같은 다른 데이터가 같은 자리로 들어와도 리액트는 같은 항목이라고 믿는다. 페이지를 넘기거나, 목록을 다시 불러오거나, 필터를 바꾸는 경우가 전부 여기에 해당한다. 실무에서 인덱스 key 사고가 가장 자주 나는 길목이 사실 이쪽이다.

동일성은 발견하는 것이 아니라 선언하는 것이다

여기까지 오면 슬슬 우울해진다. 기준은 세 개나 되고, 알고리즘도 네 개고, 공리는 자꾸 깨지고, 자료구조는 티 안 나게 틀린 답을 내놓는다. 그런데 이 골치 아픈 문제를 아주 깔끔하게 피해가는 시스템들이 있다.

대표적으로는 바로 우리가 매일 사용하는 깃(Git)이 있다. 깃에서 커밋의 이름은 그 커밋 내용의 해시다. 내용이 같으면 반드시 같은 이름을 갖고, 내용이 다르면 사실상 확실하게 다른 이름을 갖는다.

필자가 “사실상”이라고 표현한 건 무한한 내용을 160비트 안에 밀어 넣는 이상 충돌이 반드시 존재하기 때문인데, 실제로 2017년에 같은 SHA-1을 갖는 서로 다른 두 파일이 만들어졌고 깃은 그 뒤로 SHA-256으로 이사를 준비하기 시작했다. 뭐 이런다고 근본적인 문제가 해결되지는 않으나, 그래도 확률을 무시할 수 있을 만큼 낮추기는 할 것이다.

어쨌든 그래서 깃에서는 “이 두 커밋이 같은가”라는 질문에 문자열 비교 한 방으로 답이 나온다. 도커 이미지 다이제스트도, 콘텐츠 기반 주소를 쓰는 저장소들도 전부 같은 발상 위에 서 있다.

이런 방식을 콘텐츠 어드레싱(Contents Addressing)이라고 부르는데, 이 글의 비유로 옮기면 주민등록번호를 없애버리고 생김새를 그대로 번호로 쓰기로 한 것이다.

생김새가 조금이라도 다르면 그냥 다른 사람 취급한다. 앞에서 값에는 시간이 없고 내용이 곧 자기 자신이라고 했는데, 콘텐츠 어드레싱은 모든 것을 그 규칙 아래로 밀어 넣는다. 그러면 자리와 생김새가 항상 일치하게 되고, 사진에 날짜를 따로 적어둘 이유도 사라진다. 무언가를 수정하면 그건 수정된 게 아니라 새로운 것이 하나 더 생긴 것이기 때문이다.

하지만 이런 방식에도 대가는 존재한다. 엔티티를 값으로 만들려면 불변이어야 하는데, 불변이면 mutable하게 바뀌는 상태를 표현할 수 없다.

그래서 깃은 이 문제를 브랜치라는 별도 장치로 푼다. main이 어제와 오늘 다른 커밋을 가리켜도 우리는 그걸 같은 main이라고 부르지 않는가? 여기서 main은 커밋 하나에 붙은 이름이 아니라 커밋이 이어져온 줄기 전체에 붙은 이름이기 때문이다. 앞에서 본 사진첩 이야기가 그대로 구현되어 있는 셈이다.

즉 깃은 값의 세계와 정체성의 세계를 아예 다른 레이어로 분리해두고 있다. 그리고 이 구조를 한 번 보고 나면 여기저기서 같은 게 보이기 시작한다. 변수 이름과 대입, 데이터베이스의 PK와 로우, useState의 상태 슬롯과 렌더마다 찍히는 스냅샷이 전부 같은 모양이다.

이 분리는 프론트엔드에서도 그대로 유용하다. 리덕스를 비롯한 여러 상태 관리 도구들이 상태를 굳이 불변으로 다루라고 하는 가장 실용적인 이유가 여기 있다. 상태 객체를 값으로 만들어두면 참조만 비교해도 바뀌었는지 알 수 있으니 말이다. 그러면 React.memo가 프로퍼티를 훑을 때도 셀렉터가 이전 결과와 대조할 때도 값싼 비교 한 번으로 끝난다.

뒤집어 말하면 앞에서 본 useEffect 문제도 값이어야 할 것을 엔티티로 방치해둬서 생긴 일이다. 그럼 값이어야 할 것을 값으로 만들려면 어떻게 해야 할까?

앞의 options처럼 내용이 처음부터 끝까지 고정된 객체라면 답은 싱겁다. 그냥 컴포넌트 바깥으로 빼면 된다.

// 컴포넌트 바깥에 두면 참조가 애초에 하나뿐이다.
const OPTIONS = { sortBy: 'date', order: 'desc' } as const;

function TodoList() {
  useEffect(() => {
    fetchList(OPTIONS);
  }, []);
}

매 렌더마다 새로 태어날 일이 없으니 이펙트도 다시 돌지 않는다. “이 객체는 값이다”라는 선언을 가장 싸게 하는 방법이고, 그 선언을 스코프가 지켜준다.

문제는 내용이 프로퍼티나 상태에서 나오는 경우다. 이때는 바깥으로 뺄 수가 없으니 useMemo를 쓰게 된다.

const options = useMemo(() => ({ sortBy, order }), [sortBy, order]);

필자는 이 처방을 오랫동안 그냥 성능 최적화라고만 생각했는데, 지금 보면 “이 객체는 값이다”라고 선언하려는 시도에 훨씬 가깝다. 참조가 유지되는 동안 그 참조가 곧 내용의 이름 노릇을 해주기 때문이다.

다만 리액트는 그 선언을 지켜주겠다고 약속한 적이 없다. 공식 문서는 useMemo를 성능 최적화로만 여기라고 못 박고, 리액트가 캐시를 언제든 버릴 수 있다고 명시한다. 스코프는 지켜주는데 훅은 안 지켜주는 것이다. 결국 선언하고 싶은데 언어도 라이브러리도 그 자리를 제대로 안 주니, 있는 걸로 흉내만 내고 있는 셈이다.

물론 자바스크립트에도 이 자리를 언어 차원에서 만들어주려는 시도는 있었다. 바로 #{ sortBy: 'date' } 같은 불변 값 리터럴을 도입해서 내용으로 비교하고 Map의 키로 바로 꽂아 쓸 수 있게 하자는 Record와 Tuple 제안이다.

값 리터럴을 만들면 그 내용이 곧 이름이 되니, 깃이 커밋에 하는 일을 언어가 모든 객체에 해주는 셈이었다. 다만 이렇게 원시 타입을 하나 더 늘리는 게 엔진 쪽에 부담이 너무 커서 2025년 4월에 철회됐고, 지금은 원시 타입 대신 객체로 접근하는 후속 제안이 그 자리를 이어받고 있다.

코드에 동일성을 남겨두는 방법

그럼 언어가 그 자리를 안 내주는 경우에는 어떻게 해야 할까. 동일성은 데이터를 들여다보면 알아낼 수 있는 사실이 아니라 우리가 도메인 지식을 가지고 정하는 계약인데, 우리가 정하지 않으면 언어가 대신 정해버린다.

문제는 언어의 기본값이 우리 도메인과 어긋나는 순간인데, 그때 어긋난다는 신호가 아무 데도 뜨지 않는다. 그래서 실무에서는 동일성을 코드에 명시적으로 남겨두는 편이 낫다고 생각한다. 타입스크립트에서 쓸 수 있는 방법 몇 가지를 보자.

먼저 식별자에 브랜드를 붙여서 서로 다른 종류의 id가 섞이지 않게 하는 방법이 있다.

declare const brand: unique symbol;

type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

const toUserId = (value: string) => value as UserId;
const toOrderId = (value: string) => value as OrderId;

const findUser = (id: UserId) => { /* ... */ };

// 런타임에는 둘 다 그냥 문자열이지만 타입 레벨에서는 섞이지 않는다.
findUser(toOrderId('order-1')); // 컴파일 에러

문자열 두 개가 어쩌다 값이 같다고 해서 같은 대상일 리는 없다. 그 이야기를 런타임 코드 한 줄 없이 타입만으로 해둔 것이다. 다만 저 toUserId 자리는 어차피 캐스팅이 한 번 필요하니, 형식만 검증하는 로직을 같이 넣어두면 경계에서 한 번 더 걸러진다.

다음으로 엔티티의 동일성 판정을 도메인 코드로 끌어올리는 방법이 있다.

const isSameUser = (a: User, b: User) => a.id === b.id;

별것 아닌 함수처럼 보이지만 isSameUser라는 이름이 붙는 순간, 이 코드를 읽는 사람은 여기서 비교되는 것이 참조도 내용도 아닌 도메인 정체성이라는 걸 안다. a.id === b.id를 인라인으로 쓰면 그 정보가 사라진다.

값 타입은 반대로 생김새 자체가 정체성이므로, 불변으로 만들어두고 생김새 비교를 명시하는 편이 맞다.

interface Money {
  readonly amount: number;
  readonly currency: 'KRW' | 'USD';
}

const isSameMoney = (a: Money, b: Money) =>
  a.amount === b.amount && a.currency === b.currency;

그런데 여기서 amount를 부동소수점으로 두면 앞에서 본 반사성과 추이성 문제가 그대로 딸려 들어온다. 그래서 금액은 원 단위 정수처럼 최소 단위로 다루는 게 일반적인데, 이것도 결국 동일성을 지키기 위한 선택이다. 금융 시스템이 소수점을 피하는 이유를 정밀도 문제로만 설명하는 경우가 많지만, 필자는 동치관계가 깨지는 순간 그 위에 쌓아올린 집계와 대사가 전부 흔들린다는 쪽이 더 근본적인 이유라고 본다.

판정을 자료구조에 넘길 때는 한 단계가 더 필요하다. 자바스크립트에는 hashCode를 정의할 방법이 없고 MapSet은 객체에 대해 사실상 참조 비교를 하니, 주민번호로 중복을 지우려면 키를 직접 만들어 쥐여주는 수밖에 없다.

const dedupeBy = <T, K>(items: T[], toKey: (item: T) => K) =>
  Array.from(new Map(items.map((item) => [toKey(item), item])).values());

const users = [
  { id: 1, name: 'evan' },
  { id: 1, name: 'evan (수정됨)' },
  { id: 2, name: 'moon' },
];

// id가 같은 둘 중 나중 것이 살아남아 'evan (수정됨)'과 'moon'이 남는다.
dedupeBy(users, (user) => user.id);

여기서 toKey가 하는 일은 주민번호를 Map이 알아들을 수 있는 형태로 번역해주는 것이다. hashCode와 비슷해 보이지만 요구 조건이 한 단계 세다. hashCode는 충돌이 나도 뒤에서 equals가 받쳐주지만, Map에는 그런 폴백이 없어서 키가 같으면 그걸로 판정이 끝나버린다.

그래서 복합 키를 만들 때는 방향을 헷갈리면 안 된다. dedupeBy에서는 id가 같은 두 사용자를 같다고 보기로 했으니 둘을 같은 키로 뭉개는 게 맞았다. 반면 좌석은 행과 열이 곧 정체성이라, 다른 좌석이 같은 키를 갖는 순간 하나가 소리소문 없이 사라진다.

// 구분자를 빼먹으면 1행 23열과 12행 3열이 똑같이 "123"이 되어버린다.
const toSeatKey = (seat: { row: number; column: number }) =>
  `${seat.row}:${seat.column}`;

문자열로 조립하는 이 방식이 짜쳐보일 수도 있는데, 이 코드가 하는 일을 정확히 말하면 앞에서 본 콘텐츠 어드레싱의 소형판이다. 언어가 값으로 취급해주는 타입이 문자열밖에 없으니 좌석의 내용을 거기로 환전해서 이름으로 삼는 것이다. 앞에서 본 Record와 Tuple 제안이 없애주려던 게 정확히 이 작업이었다.

물론 이런 방식에도 비용은 있다. 브랜드 타입을 남발하면 변환 함수가 우수수 늘어나고, 도메인 경계마다 캐스팅이 끼어들어서 코드가 장황해진다. 동일성 판정 함수를 모든 타입에 하나씩 만들어두는 것도 만만치 않은 유지보수 부담이다.

그래서 이건 모든 타입에 일괄 적용할 일이 아니라, 두 레이어가 만나는 경계에 우선 투자하는 게 낫다고 생각한다. API 응답이 도메인 모델로 들어오는 지점, 도메인 모델이 뷰로 나가는 지점, 캐시 키를 만드는 지점 같은 곳이다. 글 맨 앞에서 본 버그 네 개가 전부 그런 경계에서 터졌던 걸 떠올려보면 이유가 분명해진다.

마치며

동일성이 어려운 이유는 그것이 데이터에 없기 때문이다. 데이터는 자기가 무엇과 같은지 말해주지 않는다. 그걸 정하는 건 언제나 우리다.

그런데 프로그래밍 언어들은 아주 친절하게도 기본값을 하나씩 쥐여준다. ===가 있고 equals가 있고 ==가 있다. 그래서 우리는 ===를 칠 때마다 “저 둘이 같은 사람인지 자리를 보고 판단하겠다”고 결재를 하고 있는 셈인데, 하루에 수백 번씩 도장을 찍으면서 서류는 한 번도 안 읽는다.

그리고 그 서류에서 언어가 알아서 처리해주겠다고 적어둔 조항에는 전부 조건이 하나씩 달려 있다. Set에서만, 세션 안에서만, record로 선언했을 때만, 눈치챘을 때만. 계약을 맺은 줄도 모르는 사람에게 그 조건들이 보일 리가 없다.

자바가 12년을 들여 Value Objects를 만든 것도, 러스트가 EqPartialEq를 굳이 나눠둔 것도, 리액트가 key를 우리에게 넘긴 것도 결국 같은 자리를 만드는 일이었다. 이 타입이 값인지 엔티티인지, 이 리스트에서 무엇이 같은 것인지를 코드 표면에 적어둘 자리 말이다. 비교를 어떻게 할지는 언어가 알아서 하면 되지만, 무엇을 같다고 볼 것인지는 언어가 대신 정해줄 수 없기 때문이다.

그래서 코드를 읽다가 비교 연산이 나오면 한 번쯤 멈춰서 물어볼 만하다. 지금 이 코드는 자리를 보고 있나, 생김새를 보고 있나, 주민번호를 보고 있나. 대부분은 셋 중 하나로 금방 답이 나온다. 그리고 답이 잘 안 나오는 자리가 대개 버그가 자라고 있는 자리다.

이 주제는 파고들수록 계속 다른 이야기로 이어진다. 부동소수점으로도 가고, 해시 자료구조로도 가고, 불변성으로도 가고, 결국 도메인 모델링까지 간다. 자바 커뮤니티가 왜 12년을 썼는지 이제는 좀 알 것도 같다. 이건 기능 하나를 추가하는 일이 아니라 언어가 세계를 어떻게 나눠 볼 것인지를 정하는 일이었다. 그러니 다음에 ===를 칠 때, 그게 그냥 등호 세 개가 아니라 우리가 매일 아무 생각 없이 서명하고 있는 계약서라는 것 정도만 기억해두면 좋겠다.

이상으로 동일성은 왜 프로그래밍에서 가장 어려운 문제인가 포스팅을 마친다.

프로그래밍프론트엔드동일성자바스크립트타입스크립트도메인모델링수학

관련 포스팅 보러가기

Jul 14, 2019

컴퓨터가 만드는 랜덤은 정말로 랜덤할까?

프로그래밍/알고리즘
May 06, 2017

[JavaScript로 중력 구현하기] 2. 코딩하기

프로그래밍/그래픽스
May 06, 2017

[JavaScript로 중력 구현하기] 1. 중력이란 무엇일까?

프로그래밍/그래픽스
May 03, 2017

[JavaScript로 천체 구현하기] 행성의 움직임을 구현해보자

프로그래밍/그래픽스
Jan 25, 2026

타입 시스템은 왜 증명처럼 동작하는가

프로그래밍