Skip to content

Commit 65e6ae1

Browse files
committed
docs: 같은 입력은 같은 결과 - 장바구니와 해시 함수로 배우는 멱등성 (해시 테이블)
1 parent 98563cd commit 65e6ae1

File tree

2 files changed

+238
-0
lines changed

2 files changed

+238
-0
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
---
2+
title: 같은 입력은 같은 결과 - 장바구니와 해시 함수로 배우는 멱등성 (해시 테이블)
3+
createdAt: 2025-10-26
4+
category: DataStructure
5+
description: 해시테이블의 핵심은 같은 키에 같은 값이 매핑된다는 단순한 원리입니다. 프론트엔드에서 장바구니 기능을 구현할 때 멱등성(idempotency) 원칙을 사용해 해결하는 방법을 알아봅니다. 규모가 작다면 단순 순회 비교로 충분하지만, 확장성/재사용이 필요하면 정규화된 해시 함수를 도입하는 방법도 살펴봅니다.
6+
comment: true
7+
head:
8+
- - meta
9+
- name: keywords
10+
content: 멱등성, idempotency, 해시 함수, hash function, 장바구니, 데이터 구조, 해시 테이블, hash table
11+
---
12+
13+
장바구니 기능을 만들다 보면 이런 로직을 자주 만나게 됩니다.
14+
15+
> 1. 추가하려는 상품이 이미 장바구니에 있는 경우 <br/>
16+
> a. 해당 상품의 옵션또한 동일하다면 수량을 증가시킨다 <br/>
17+
> b. 해당 상품의 옵션이 다르다면 새로운 항목으로 추가한다
18+
> 2. 추가하려는 상품이 장바구니에 없는 경우<br/>
19+
> a. 새로운 항목으로 추가한다
20+
21+
문제는 `같음` 을 정의하는게 쉽지 않다는 것입니다.
22+
옵션의 순서가 다르거나, JSON 필드의 순서가 달라지면 사실상 같은 조합도 다른 항목으로 인식될 수 있습니다.
23+
24+
결국 `같은 입력은 같은 결과여야 한다` 라는 멱등성(idempotency) 원칙을 적용해야 합니다.
25+
26+
> 같은입력 = 장바구니의 상품ID + 옵션 조합 <br/>
27+
> 같은결과 = 장바구니 항목의 고유키
28+
29+
## 멱등성(idempotency) 이란?
30+
31+
먼저 멱등성이 무엇인지 살펴보겠습니다. <br/>
32+
멱등(Idempotent) 하다는 것은 어떤 연산을 여러 번 수행하더라도 결과가 동일한 것을 의미합니다.
33+
34+
$$f(f(x)) = f(x)$$
35+
36+
이 수식이 참이라면 함수 $f$ 는 멱등하다고 할 수 있습니다.
37+
38+
예를들어, 다음과 같은 함수는 멱등합니다.
39+
40+
```ts
41+
function updateName(user, name) {
42+
user.name = name;
43+
return user;
44+
}
45+
```
46+
47+
이 함수는 몇 번을 실행하더라도 결과가 동일합니다.
48+
49+
```ts
50+
updateName({ name: "Alpha" }, "Bravo"); // {name: "Bravo"}
51+
updateName({ name: "Charlie" }, "Bravo"); // {name: "Bravo"}
52+
updateName(updateName({ name: "Delta" }, "Bravo"), "Bravo"); // {name: "Bravo"}
53+
```
54+
55+
반면, 다음과 같은 함수는 멱등하지 않습니다.
56+
57+
```ts
58+
function increaseViewCount(article) {
59+
article.viewCount += 1;
60+
return article;
61+
}
62+
```
63+
64+
이 함수는 실행할 때마다 결과가 달라집니다.
65+
66+
```ts
67+
increaseViewCount({ viewCount: 10 }); // {viewCount: 11}
68+
increaseViewCount({ viewCount: 10 }); // {viewCount: 12}
69+
```
70+
71+
:::details 🤨 순수함수랑 멱등함수랑 뭐가 다르나요
72+
순수함수(Pure Function)와 멱등함수(Idempotent Function)는 서로 다른 개념입니다.
73+
74+
- 순수함수: 동일한 입력에 대해 항상 동일한 출력을 반환하며, 외부 상태를 변경하지 않는 (사이드 이펙트가 없는) 함수입니다.
75+
- 멱등함수: 동일한 입력에 대해 여러 번 호출하더라도 결과가 동일한 함수입니다.
76+
77+
예를들어, `updateName` 함수는 순수함수이면서 멱등함수입니다. <br/>
78+
반면, `increaseViewCount` 함수는 순수함수가 아니며 멱등함수도 아닙니다.
79+
80+
```ts
81+
function pureButNotIdempotent(x) {
82+
return x * 2;
83+
}
84+
```
85+
86+
이 함수는 외부 상태 변화를 일으키지 않기 때문에 순수함수이지만, 멱등하지는 않습니다. <br/>
87+
왜냐하면, 동일한 입력에 대해 항상 동일한 출력을 반환하지만, 여러 번 호출할 때마다 결과가 달라지기 때문입니다.
88+
:::
89+
90+
## 장바구니 기능 살펴보기
91+
92+
예를들어, 샌드위치 주분 서비스 (서브웨이) 를 만든다고 해보겠습니다. <br/>
93+
사용자는 동일 샌드위치를 주분하더라도, 커스터마이징 조합에 따라 다른 상품이 됩니다.
94+
95+
### 🥪 샌드위치 장바구니 로직의 기본 요구사항
96+
97+
> 1. 추가하려는 샌드위치가 이미 장바구니에 있는 경우 <br/>
98+
> a. 빵, 야채, 소스 등 모든 옵션이 동일하다면 수량을 1 증가시킨다 <br/>
99+
> b. 옵션이 하나라도 다르다면 새로운 항목으로 추가한다
100+
> 2. 추가하려는 샌드위치가 장바구니에 없는 경우<br/>
101+
> a. 새로운 항목으로 추가한다
102+
103+
사용자가 다름과 같은 두 가지 주문을 추가했다고 가정해보겠습니다.
104+
105+
```ts
106+
const cartItem1 = {
107+
itemId: 1,
108+
name: "터키 베이컨 아보카도",
109+
options: [
110+
{ id: 1, label: "화이트 브레드" },
111+
{ id: 2, label: "양상추" },
112+
{ id: 3, label: "스위트 어니언 소스" },
113+
],
114+
};
115+
const cartItem2 = {
116+
itemId: 1,
117+
name: "터키 베이컨 아보카도",
118+
options: [
119+
{ id: 3, label: "스위트 어니언 소스" },
120+
{ id: 1, label: "화이트 브레드" },
121+
{ id: 2, label: "양상추" },
122+
],
123+
};
124+
```
125+
126+
두 주문은 옵션의 순서만 다를 뿐, 사실상 동일한 조합입니다. <br/>
127+
때문에, 장바구니의 상태를 변경할 때 마다 `itemId`, `options` 가 모두 동일한지 비교해야 합니다.
128+
129+
### 🤓 그래서 이게 멱등성이랑 뭔상관인데 ?
130+
131+
> 같은 샌드위치 조합이면 몇 번을 담아도 같은 장바구니 항목에 매핑되어야 한다
132+
133+
이 요구사항은 멱등성 원칙과 똑같습ㄴㅣ다! <br/>
134+
135+
> 같은 입력 = 샌드위치 id + 옵션 조합 <br/>
136+
> 같은 결과 = 장바구니 항목의 고유키
137+
138+
![alt text](./idempotency-hash-table/cart-hash-func.png)
139+
140+
### 1️⃣ 단순 순회하면서 비교하기
141+
142+
가장 직관적인 방법은 장바구니의 모든 항목을 순회하면서, `itemId``options` 가 동일한지 비교하는 것입니다.
143+
144+
```ts
145+
function isEqualItems(item1: CartItem, item2: CartItem): boolean {
146+
if (item1.itemId !== item2.itemId) return false;
147+
if (item1.options.length !== item2.options.length) return false;
148+
149+
const sortedOptions1 = [...item1.options].sort((a, b) => a.id - b.id);
150+
const sortedOptions2 = [...item2.options].sort((a, b) => a.id - b.id);
151+
152+
for (let i = 0; i < sortedOptions1.length; i++) {
153+
if (sortedOptions1[i].id !== sortedOptions2[i].id) return false;
154+
}
155+
return true;
156+
}
157+
// 1회 비교 = O(k log k)
158+
// 장바구니 탐색 = O(m k log k)
159+
```
160+
161+
또는 [`lodash/isEqual`](https://lodash.com/docs/4.17.15#isEqual), [`es-toolkit/isEqual`](https://es-toolkit.dev/reference/compat/predicate/isEqual.html#isequal-lodash-compatibility) 같은 라이브러리를 사용해도 됩니다.
162+
163+
### 2️⃣ JSON.stringify 로 비교하기
164+
165+
또 다른 방법은 `JSON.stringify` 를 사용해 객체를 문자열로 변환한 뒤 비교하는 것입니다.
166+
167+
```ts
168+
function isEqual(item1: CartItem, item2: CartItem): boolean {
169+
return JSON.stringify(item1) === JSON.stringify(item2);
170+
}
171+
```
172+
173+
하지만, 옵션의 순서가 다른 경우 다른 문자열로 변환되고, <br/>
174+
`undefined` 는 직렬화에서 제외되고, `NaN`, `Infinity``null` 로 변환되고, `Date` 는 ISO 문자열로 변환되는 문제점이 있습니다.
175+
176+
따라서 비교전 정규화(normalization) 작업이 필요합니다.
177+
178+
```ts
179+
function normalize(item: CartItem): string {
180+
// 옵션을 id 기준으로 정렬해서 순서 보장
181+
const sortedOptions = [...item.options].sort((a, b) => a.id - b.id);
182+
const normalizedItem = { ...item, options: sortedOptions };
183+
return JSON.stringify(normalizedItem);
184+
}
185+
186+
function isEqual(item1: CartItem, item2: CartItem): boolean {
187+
return normalize(item1) === normalize(item2);
188+
}
189+
```
190+
191+
### 3️⃣ 해시 함수로 비교하기
192+
193+
마지막으로, 해시 함수를 사용해 객체를 고유한 해시값으로 변환한 뒤 비교하는 방법이 있습니다. <br/>
194+
해시 함수는 입력값을 고정된 크기의 해시값으로 매핑하는 함수입니다. <br/>
195+
196+
- 동일한 입력값은 항상 동일한 해시값을 생성합니다.
197+
- 서로 다른 입력값이 동일한 해시값을 생성할 확률은 매우 낮습니다.
198+
199+
이 방식을 사용하면 장바구니 항목을 하나의 멱등키(idempotency key) 로 만들어 장바구니 항목을 빠르게 조회할 수 있습니다.
200+
201+
```ts
202+
async function createHashOfCartItem(item: CartItem) {
203+
const normalized = normalize(item);
204+
const encoder = new TextEncoder();
205+
const data = encoder.encode(normalized);
206+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
207+
const hashArray = Array.from(new Uint8Array(hashBuffer));
208+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
209+
}
210+
```
211+
212+
- 장바구니 항목 비교 대신 해시 문자열 한 번 비교 $(O(1))$
213+
- 옵션 순서가 바뀌어도 정규화(normalization)로 동일하게 처리
214+
- 같은 샌드위치 조합은 항상 동일한 멱등키로 매핑
215+
216+
되어 장바구니 기능을 멱등하게 구현할 수 있습니다.
217+
218+
| 방법 | 비교 복잡도 | 확장성 | 장점 | 단점 |
219+
| -------------- | --------------------------------------- | ------ | -------------- | -------------- |
220+
| 순회 비교 | $O(m · k log k)$ | 낮음 | 간단함 | 느림 |
221+
| JSON.stringify | $O(m · k)$ | 중간 | 직관적 | 순서/타입 민감 |
222+
| 정규화 + 해시 | $O(k log k)$ (1회 생성) / $O(1)$ (비교) | 높음 | 빠름, 재사용성 | 해시 생성비용 |
223+
224+
## 결론..
225+
226+
장바구니 기능을 멱등하게 구현하는 방법으로 해시 함수를 사용하는 방법을 살펴보았습니다. <br/>
227+
사실 규모가 작다면 단순 순회 비교나 `lodash/isEqual` 같은 라이브러리를 사용하는 것도 충분합니다. <br/>
228+
하지만, 확장성이나 재사용성이 필요하다면 정규화된 해시 함수를 도입하는 방법도 고려해볼만 하다고 생각합니다.
229+
230+
참고로, 멱등성은 HTTP 메서드 설계, 데이터베이스 트랜잭션 처리, TanstackQuery 의 쿼리키 설계 등 다양한 분야에서 활용되는 중요한 개념입니다. <br/>
231+
232+
[해시 테이블이 궁금하다면 ? - 타입스크립트로 구현하는 해시테이블 (HashTable)](./hash-table.md)
233+
234+
## 참고자료
235+
236+
- [멱등성이 뭔가요? - 토스페이먼츠 개발자 센터](https://docs.tosspayments.com/blog/what-is-idempotency)
237+
- [멱등법칙 - Wikipedia](https://ko.wikipedia.org/wiki/%EB%A9%B1%EB%93%B1%EB%B2%95%EC%B9%99)
238+
- [해시 함수 - Wikipedia](https://ko.wikipedia.org/wiki/%ED%95%B4%EC%8B%9C_%ED%95%A8%EC%88%98)
1.12 MB
Loading

0 commit comments

Comments
 (0)