|
| 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 | + |
| 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) |
0 commit comments