토스의 상당한 유명인인 문동욱 님이 쓰신 글들이 몇 개 새로 나와서 읽어보았다.
애매한 문제를 제대로 정의한다는 것 #
https://evan-moon.github.io/2026/09/24/defining-ambiguous-problems/
회사에서 일하면 어떤 게 임팩트가 있는지 또 무엇을 해야 하는지 등을 많이 이야기하게 된다. 근데 정확히 무엇이 문제인지, 그게 실제로 존재하는 문제인지, 어떻게 해결해야 하는지, 해결되었다는 건 어떻게 알 수 있는지 같은 구체적인 내용은 없을 때도 많다. 이런 걸 어떻게 정의하는가?
물론 변수명 하나를 좀 더 직관적으로 바꾼다든지(a로 되어 있던 걸 numberOfCustomer로..라든가) 하는 작은 문제는 가볍고 빠르게 해결할 수 있지만, 꽤 큰 시간 혹은 인력이 필요하다면 의미가 있겠다.
문제 상황을 정의하기 #
글에서는 문제를 '기대하는 상태와 실제 상태의 차이'라고 정의한다. 또한 그 두 상태를 정량적으로 알 수 있어야 한다. 그 차이를 줄여가는 과정을 개선이라고 칭한다.
흔히 'A 모듈 코드가 너무 복잡하니 리팩토링을 하자' 고 말하곤 하지만 이는 어떤 상태 때문인지를 전혀 언급하지 않고 그걸 통해 어떤 상태를 얻고 싶은지도 없다.
할 일이 뭔지와 구체적인 문제는 다르기에 문제를 먼저 잘 정의해야 한다. 리팩토링, 혹은 특정 기능 구현 등을 실행하는 건 뭔가를 하고 있다는 기분을 주기 때문에 오히려 문제 정의를 흐릿하게 놔둔 채로 실행부터 해버릴 수도 있다.
문제를 잘 정의하는 건 그럼 어떻게 할까? 대부분 직감에서 시작하기는 한다. 개발자가 보기에 '코드가 복잡하다'거나. 하지만 그로 인해 나타나는 현상을 관측하고 현재의 상태를 정의해서 다른 사람도 그 문제를 공유할 수 있게 해야 한다.
예를 들어 특정 모듈의 코드가 정말로 복잡하다면
- 해당 모듈 관련 일정이 늘어짐
- 온보딩 시에 해당 모듈에 대한 어려움이 많음
등이 관찰될 것이다. 이런 정량적인 기준을 잡을 수 있으면 문제 상태를 정의할 수 있다. 요즘 LLM을 통하면 금방 확인 가능하다.
중요한 거 하나는 이런 현상 관측 전에, 특정 기준 이하면 내 직감이 틀렸다고 인정하겠다는 기준을 먼저 정해놔야 한다. 그렇지 않으면 편향적으로 해석할 가능성이 높다. 예를 들어 모듈이 복잡하더라도 관련 작업이 실제로는 거의 없거나, 일정이 의외로 잘 지켜지고 있다면 당장 비용이라고 하기 힘들 수 있다.
또한 문제 상태를 적을 때도 이렇게 정량적으로 적는다. "코드 품질이 낮다"가 아니라 "지난 분기 A 모듈 작업 8건 중 5건이 일정을 넘겼다"처럼. 이때 그로 인해 무엇을 잃고 있는지도 적어야 한다.
- 현재 상태를 정량적으로 정의한다
- 이때 해당 상태가 어디서 어떤 현상을 겪고 있는지, 그리고 그 현상으로 인해 우리가 뭘 잃고 있는지.
예를 들어 앞선 모듈 이야기라면 해당 모듈이 얼마나 많이 쓰이고 있고 어떤 지표로 인해 뭘 잃고 일정이 얼마나 지연되고 있는지를 들 수 있겠다. 이렇게 하면 우선순위 산정에도 도움이 된다. 데미지가 큰 문제부터 해결해야 하니까.
처음 보는 동료가 읽어도 같은 현상을 떠올리고 공유할 수 있는 게 이상적이다.
해결방법 정의하기 #
해결법을 고민하기 전에 어떤 상태가 되어야 하는지, 그리고 지금과 목표 상태 사이의 차이가 왜 생기는지 따져봐야 한다. 이때 문제 정의부터 정량적인 지표를 통해서 잘 했다면, 목표 상태 역시 그걸로 정의하기가 쉬워진다.
문제 상태와 목표 상태, 이렇게 두 상태를 잘 정의했다면 차이의 원인을 떠올려본다. 다음으로는 계속 왜? 라는 질문을 던지면서 내가 처음 생각한 해결책이 그 원인을 해결하는 게 맞는지 의심하고, 근본적인 원인을 해결할 수 있는 방향으로 나아간다.
예를 들어 A 기능 모듈의 일정 지연이 문제였다면, 따지고 보면 사실 코드가 가장 큰 문제가 아니었을 수도 있다. 팀 간의 일정 조율이 가장 큰 문제였다거나. 왜? 를 5번 묻는다는 도요타의 5 whys는 이런 도구 중 하나.
이때 해결전략은 가급적 작은 걸음으로 쪼개져 있도록 하고 점진적으로 해결되는 걸 관찰할 수 있도록 한다. 모든 걸 한방에 해결하려고 하면 그 결과를 마지막 날에야 알 수 있고 또한 효과가 있더라도, 너무 많은 게 한 번에 바뀌었기 때문에 실제로 뭐가 효과를 낸 건지 가려내기가 힘들다. 만약 뭐가 효과가 있었는지 가려낼 수 있었다면 다음 전략 수립에 도움이 됐을 텐데.
물론 확인할 수 없는 걸 확인하라는 요구로 이어져서는 곤란하지만.. 최대한 노력을 해보는것.
해결 확인하기 #
- 해결법을 실천해 보기 전에 적었던 목표 수치와 대조해본다.
- 하나의 완벽한 수치를 찾으려 하기보다는 불완전하더라도 같은 방향을 가리키는 지표 두세 개를 같이 본다.
- 숫자가 제대로 안 나왔을 때 다음 행동을 바꿀 수 있을 정도의 영향력은 있어야 하고, 지표에 따라 움직이려고 노력해야 한다.
물론 측정이 목표가 되면 더 이상 좋은 측정이 아니게 될 수 있다(굿하트의 법칙). 따라서 정말 본질적인 문제가 해결되었는지는 계속 확인하려고 노력해야 한다.
그리고 만약 목표한 수치와 다르다면 왜 그런가를 고민해본다. 문제를 제대로 반영하지 못하는 지표를 고르는 실수를 했을 수도 있고, 너무 낙관적이었을 수도 있다. 그러면서 적절한 지표와 숫자를 고르는 감각도 길러진다.
문제 정의 역량 기르기 #
이런 과정과 템플릿을 외우는 건 중요하지 않고 스스로 계속 질문을 던진다. 문제란 현재 상태와 목표 상태의 차이라는 가정 하에서 다음 질문을 자문하고 혹은 동료와 토의한다.
- 어떤 문제를 왜 해결해야 하는가? 우선순위가 높은 이유는?
- 그 문제는 어떤 상태로 정의되는가?
- 문제가 해결되었을 때의 상태 즉 목표 상태는 어떻게 정의할 수 있는가?
- 위 둘의 차이를 줄이기 위해 무엇을 해야 하는가?
- 지표가 목표대로 나왔다고 했을 때, 실행한 방법 덕분이었다는 걸 완벽히는 아니더라도 어떻게 알 수 있는가?
이런 질문을 스스로 던지고 갈고 닦아나가는 게 문제 정의 역량이 자라는 것. 이걸 외우고 있는 것과 체화하는 건 다르기 때문이다.
생각해 보면, 이런 걸 늘 생각하고 일하다 보면 나중에 이력서나 면접에서도 크게 도움이 될 듯 하다. 면접에서 질문하는 건 결국 어떤 게 문제였고, 어떻게 해결했고, 어떤 상태가 되었는지 또한 왜 그런 해결책을 선택했는지가 많았기 때문이다.
동일성은 왜 프로그래밍에서 가장 어려운 문제인가 #
https://evan-moon.github.io/2026/08/02/why-identity-is-hard-in-programming/
어떤 걸 기준으로 동일성을 판단할지는 어려운 문제다. 예를 들어 10000원이라는 돈 액수를 나타내는 걸 그냥 정수로 나타낸다면 ==(js의 경우에는 ===)을 쓰면 된다. 그런데 객체라면?
js에서 객체는 참조를 기준으로 비교되기 때문에 그렇게 비교하면 분명 같은 10000원을 나타내는 객체인데도 다르다는 결과가 나올 수 있다. Set에서 중복 제거할 때도 마찬가지다.
JS에서도 동일함을 판정하는 알고리즘이 4개나 있다.
Loose Equality : ==
Strict Equality: ===, indexOf
SameValueZero: includes, Set, Map 키
SameValue: Object.is
따라서 같은 배열에서 같은 값을 찾아도 indexOf는 -1, includes는 true일 수도 있다.(NaN의 경우)
동일성에 대해 #
- 값(value): 내용이 같으면 같은 걸로 쳐도 된다
- 엔티티: 내용이 같아도 남남일 수 있다. js 객체 같은 느낌
- 동치관계(Equivalence): 반사성(a=a), 대칭성(a=b이면 b=a), 추이성(a=b, b=c이면 a=c)
언어마다 이를 다루는 방식이 다르다.
js는 원시값을 값으로, 객체를 엔티티로 다룬다. 단순하고 예측 가능하지만 사용자가 이걸 정할 수 있는 방법이 없다는 단점. Money 객체 같은 걸 만들면 사용자가 값으로 취급하고 싶어도 엔티티가 된다.
자바는 이런 Value Object를 만들기 위해서 equals, hashCode를 오버라이드할 수 있었다. 최근에는 record가 들어왔고 완전 최신기능 Value Objects를 쓰면 값으로만 취급되는 객체를 만들 수 있다고.
러스트는 trait라는 시스템을 통해, 특정 타입이 값인지 엔티티인지 그리고 그 비교가 수학의 동치관계 정의를 지키는지 명시하도록 한다.
그리고 좀 더 본질적으로는 '같다' 고 판단할 수 있는 기준이 코드상에서 3가지 있따.
- 참조 동일성: 메모리에서 같은 대상을 참조하는가?
- 구조적 동등성: 같은 내용을 갖고 있는가? (
JSON.stringify로 비교할 때) - 도메인 동일성: 판별을 위한 고유 id(주민번호와 같은)가 같은가?
또한 값과 엔티티는 다르게 비교해야 하는데 보통 언어는 == 하나만 있기 때문에 어느 한쪽은 의도와 어긋나기 쉽다.
값과 엔티티의 기준 #
값에는 시간 개념이 없다. 엔티티에는 있다. 예를 들어 사람이라면 10년 전과 다르게 생겼으니 '똑같이 생겼다'를 기준으로 비교할 수 없다. 그러나 주민번호나 어떤 정체성을 나타내는 번호(CI 등)를 통해 동일 판정을 내릴 수 있다.
값은 변하지 않는 것이다. 정체성(identity)은 시간에 따라 다른 값과 결부되는 안정적인 논리적 실체다. 상태는 그 정체성이 특정 시점에 갖는 값이다.(https://clojure.org/about/state)
즉 엔티티는 시점에 따른 변화가 있으며 동일성을 위해서는 특정 고유한 id와 같은 번호를 통해 비교할 수 있겠다.
수학적 공리와 버그 #
수학에서 동치(equivalence)는 reflexive, symmetric, transitive 3가지를 만족하는 관계로 정의된다.
그러나 NaN에서의 reflexive 위반, 부동소수점 비교시 EPSILON 미만의 차이를 같다고 했을 때 transitive 위반 등 우리가 쓰는 동일성 판정에서는 수학적 정의를 위반하는 경우가 많다.
문제는 우리가 쓰는 자료구조나 방식들이 이런 equivalence 위에 세워진 경우가 많다는 것이다. 예를 들어 캐시 키가 같으면 캐시 히트로 판정을 하는 상황에서 캐시 키 동일성이 transitive를 어기면, 같은 입력인데도 캐시 히트가 되었다 안 되었다 할 것이다.
이런 식으로 동치관계 공리를 어긴 코드 혹은 동일성 판정의 경계에서 생기는 버그는 그 자리에서 바로 터지지 않는다. 또한 데이터 순서나 크기에 따라 있었다 없었다 해서 재현도 어렵다. 언어와 라이브러리가 알아서 동일성을 판정하는 것처럼 보이지만 사실 위험성이 숨어 있다.
추가: 해시 기반 자료구조에는 '같은 값에는 같은 해시'라는 계약이 하나 더 있다. 다른 값에 같은 해시값이 나오는 해시 충돌도 문제긴 하지만 상대적으로 작고, 자료구조에서 알아서 해결해 준다.
리액트 key와 동일성 #
리액트의 key는 리스트에서 어떤 걸 기준으로 같은 걸 판정할지 선언하는 것이다. 만약 배열 인덱스를 key로 삼으면, 해당 자리에 완전히 다른 대상이 와도 같다고 판단한다. key를 제대로 정하라는 건 성능 최적화만을 위한 게 아니었던 것이다.
즉 key는 '뭘로 같은 item인지를 판정할 것인지'를 정하는 것이다. 예를 들어 주민번호 혹은 DB primary key 같은 게 적절하겠다. 인덱스를 써도 되는 경우는 list 전부가 렌더링 사이에 바뀌지 않을 때뿐이다.
값과 엔티티 분리 #
값과 엔티티는 시간 개념이 있는지 없는지로 분류되고 이 2개의 층을 분리하는 게 좋다. 예를 들어 git은 커밋 내용의 해시로 커밋 이름을 짓고, 특정 커밋을 가리키는 브랜치(엔티티)를 둔다. 불변하는 커밋(일반적으로 그렇다는 거다), 그리고 시간에 따라 변하는 브랜치 엔티티로 2개의 레이어를 분리한 것이다.
프론트에서 상태를 다룰 때도 마찬가지. 상태를 불변으로 다루라는 말을 많이 하는데, 결국 값처럼 만들어두는 것이다. 참조만 비교해도 바뀌었는지 알 수 있다.
그럼 값이어야 하는 대상을 어떻게 값으로 만들까?
- 컴포넌트 바깥으로 빼서 렌더마다 객체가 새로 만들어지지 않도록 한다.
- useMemo로 참조 동일성을 지켜줘서 값과 같이 쓸 수 있게 한다.(물론 리액트에서 전면적으로 이걸 규칙으로 삼은 건 아니고 앞으로 다른 기능 추가에 따라 캐시를 버리는 경우가 생길 수 있다고 한다)
js에서도 이런 값 리터럴을 만들어 주려는 record, tuple 제안이 있었으나 원시타입을 늘리는 부담 때문에 철회되고 다른 후속 제안이 진행중이라고 한다.
코드로 동일성 표현 #
어떤 게 값이고 어떤 게 엔티티인지는 도메인에 따라 다르게 구분해야 할 수 있다. 그런데 언어의 기본값이 도메인에서 필요한 동일성 판단과 어긋난다면?
- 브랜디드 타입 쓰기
// declare이기 때문에 런타임에는 실제 변수가 존재하지 않음
declare const brand: unique symbol;
// 원래 타입에 brand라는 가상의 프로퍼티를 교차시킨다.
// 즉 string이면서 brand 키로 B 태그를 갖는 타입이 됨
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// 일반 string을 브랜드 타입으로
const toUserId = (value: string) => value as UserId;
const toOrderId = (value: string) => value as OrderId;
const findUser = (id: UserId) => {
/* ... */
};
// 런타임에는 둘 다 그냥 문자열이지만 타입 레벨에서는 섞이지 않는다.
findUser(toOrderId("order-1")); // 컴파일 에러
zod를 사용하고 있다면 .brand 와 같은 메서드로 브랜디드 타입을 처리할 수도 있다.
isSameUser처럼 동일성 판정을 하는 걸 도메인 레벨의 함수에서 처리하기. 이때 값으로 취급할 js 객체의 경우 불변을 위해 readonly를 속성에 명시해도 좋겠다.
이때 Money 타입 같은 걸 생각해본다.
interface Money {
readonly amount: number;
readonly currency: "KRW" | "USD";
}
amount를 부동소수점으로 둘 경우 앞에서 본, 부동소수점의 한계로 인해 동일성 비교 시 추이성이 깨지는 문제가 그대로 들어올 수 있다. 따라서 금액은 원의 경우 정수, 달러 센트처럼 최소 단위로 다루는 게 일반적이다. 정밀성 확보뿐 아니라 수학적인 동일성 정의를 지키기 위한 선택이라고 할 수 있겠다. 돈의 경우 이런 동치가 깨지면 그 위의 집계 등이 전부 흔들릴 수 있으니.
이런 방식들에도 비용이 있으니 두 레이어가 만나는 경계에 우선적으로 투자하는 게 낫다고 보는 게 글의 주장이다. 예를 들어 API 응답이 도메인 모델로 들어오는 지점, 캐시 키를 만드는 지점 등.
동일성을 어떻게 판정할지는 대부분 언어에서 처리해 주지만, 뭘 동일성으로 볼지, 어떤 게 값이고 어떤 게 엔티티인지 한번쯤 따져보면 버그를 막을 수 있을 것이다.