<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>sen_log</title>
    <link>https://sechoi.tistory.com/</link>
    <description>  github.com/dahyen0o</description>
    <language>ko</language>
    <pubDate>Tue, 30 Jun 2026 09:00:03 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>sechoi</managingEditor>
    <image>
      <title>sen_log</title>
      <url>https://tistory1.daumcdn.net/tistory/5944952/attach/09b5dc1ff3d24facb10d278c5fd6d20c</url>
      <link>https://sechoi.tistory.com</link>
    </image>
    <item>
      <title>Manacher 알고리즘 (Manacher's Algorithm)</title>
      <link>https://sechoi.tistory.com/37</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 팰린드롬(palindrome) 이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팰린드롬은 순방향으로 읽었을 때와 역방향으로 읽었을 때 같은 문자열을 의미한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 87px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 37.6744%; height: 19px; text-align: center;&quot;&gt;문자열&lt;/td&gt;
&lt;td style=&quot;width: 37.3256%; height: 19px; text-align: center;&quot;&gt;뒤집은 문자열&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 37.6744%; height: 17px;&quot;&gt;A&lt;/td&gt;
&lt;td style=&quot;width: 37.3256%; height: 17px;&quot;&gt;A&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;palindrome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 37.6744%; height: 17px;&quot;&gt;BB&lt;/td&gt;
&lt;td style=&quot;width: 37.3256%; height: 17px;&quot;&gt;BB&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;palindrome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 37.6744%; height: 17px;&quot;&gt;ABDDS&lt;/td&gt;
&lt;td style=&quot;width: 37.3256%; height: 17px;&quot;&gt;SDDBA&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;not palindrome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 37.6744%; height: 17px;&quot;&gt;ABDDBA&lt;/td&gt;
&lt;td style=&quot;width: 37.3256%; height: 17px;&quot;&gt;ABDDBA&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;palindrome&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  문제: 가장 긴 팰린드롬 부분 문자열(palindrome substring) 찾기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;BANANANA&lt;/b&gt; 문자열에서 가장 긴 팰린드롬 부분 문자열은 B&lt;b&gt;&lt;i&gt;ANANANA &lt;/i&gt;&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 특정 문자열에서 가장 긴 팰린드롬 부분 문자열을 구하려면 어떻게 해야할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  기존 풀이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마 이 문제가 나오면 문자열의 길이가 O(&lt;i&gt;N^2&lt;/i&gt;)의 시간복잡도로 해결할 수 있을 정도로 짧게 주어질 것이다. 그러니 냅다 풀어보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(1) 팰린드롬의 중심 문자부터 탐색&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;홀수 길이의 팰린드롬은 중심 문자부터 시작해서 맞은 편의 문자가 서로 일치한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;518&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byIyNg/btsGPsN2PHE/Yi0XFDU9TjZh9ctEdigvHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byIyNg/btsGPsN2PHE/Yi0XFDU9TjZh9ctEdigvHK/img.png&quot; data-alt=&quot;홀수 팰린드롬 찾기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byIyNg/btsGPsN2PHE/Yi0XFDU9TjZh9ctEdigvHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyIyNg%2FbtsGPsN2PHE%2FYi0XFDU9TjZh9ctEdigvHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;205&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;518&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;홀수 팰린드롬 찾기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4번째 문자인 A가 중심 문자일 때, 양 옆의 포인터를 이동하면서 일치하지 않는 문자가 나올 때까지 탐색하면 된다. 짝수 길이의 팰린드롬 또한 자신과 같은 문자를 옆에 둔 문자를 중심으로 똑같이 탐색하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Xg9Be/btsGOIDyVba/zl5JvGTuaVSgLkn2mECYa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Xg9Be/btsGOIDyVba/zl5JvGTuaVSgLkn2mECYa1/img.png&quot; data-alt=&quot;짝수 팰린드롬 찾기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Xg9Be/btsGOIDyVba/zl5JvGTuaVSgLkn2mECYa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXg9Be%2FbtsGOIDyVba%2Fzl5JvGTuaVSgLkn2mECYa1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;218&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;짝수 팰린드롬 찾기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 작업을 문자열의 길이만큼 반복하므로 시간 복잡도는 O(N^2) 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(2) 이전에 구한 팰린드롬 활용 (memorization)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 찾은 팰린드롬이 존재할 때, 해당 팰린드롬 양 끝에 같은 문자가 추가되면 새로운 문자열 또한 팰린드롬이 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1176&quot; data-origin-height=&quot;152&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxCOCa/btsGNbz6ILQ/YwNdcqhkZKYgckHqRzK0v0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxCOCa/btsGNbz6ILQ/YwNdcqhkZKYgckHqRzK0v0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxCOCa/btsGNbz6ILQ/YwNdcqhkZKYgckHqRzK0v0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxCOCa%2FbtsGNbz6ILQ%2FYwNdcqhkZKYgckHqRzK0v0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;47&quot; data-origin-width=&quot;1176&quot; data-origin-height=&quot;152&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 이용해 이전에 구한 팰린드롬이 있을 때 새로운 팰린드롬을 구할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 위 (1) 방법과 똑같으나, 중심이 아닌 팰린드롬의 시작 혹은 끝 문자에서 탐색을 시작할 수 있다. 즉 (1)은 중간에서 시작해서 모든 포인터를 움직이는 투 포인터, (2)는 한 쪽에서 시작해서 하나의 포인터만 움직이는 투 포인터 방식이라고 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 경우 이전 연산을 저장하기 위해 추가 메모리가 필요한 대신 (1) 처럼 홀/짝수를 나누지 않아도 되므로 반복문을 한 번만 쓴다. 그래도 시간복잡도는 똑같기 때문에 (1)이 더 효율적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  새로운 풀이: Manacher&amp;nbsp;알고리즘&amp;nbsp;(Manacher's&amp;nbsp;Algorithm)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 문자열의 길이가 길어서 O(&lt;i&gt;N^2&lt;/i&gt;)로는 해결할 수 없는 경우는 어떻게 할까? 바로 Manacher 알고리즘을 사용하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 홀수 팰린드롬만 생각해보자.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;518&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byIyNg/btsGPsN2PHE/Yi0XFDU9TjZh9ctEdigvHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byIyNg/btsGPsN2PHE/Yi0XFDU9TjZh9ctEdigvHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byIyNg/btsGPsN2PHE/Yi0XFDU9TjZh9ctEdigvHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyIyNg%2FbtsGPsN2PHE%2FYi0XFDU9TjZh9ctEdigvHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;205&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;518&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;홀수 팰린드롬은 대칭을 이루는 문자열들이 중심 문자의 양 옆에 붙은 형태다. 이 때 중심 문자별로 만들 수 있는 가장 긴 팰린드롬의 반지름 길이(중심 문자~끝 문자)를 계산하는 것이 목표이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2394&quot; data-origin-height=&quot;350&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lp100/btsGMGtOF6e/W94kGuY4yXJGkk4BmtPZ5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lp100/btsGMGtOF6e/W94kGuY4yXJGkk4BmtPZ5K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lp100/btsGMGtOF6e/W94kGuY4yXJGkk4BmtPZ5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flp100%2FbtsGMGtOF6e%2FW94kGuY4yXJGkk4BmtPZ5K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2394&quot; height=&quot;350&quot; data-origin-width=&quot;2394&quot; data-origin-height=&quot;350&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 계산하기 위해서 이전처럼 투 포인터를 움직일 수 있다. 하지만 포인터를 움직이는 시간복잡도 O(N)보다 더 빠른 방법이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Manacher 알고리즘은 팰린드롬의 '대칭성'을 이용해 방법을 찾아냈다. 현재 찾아낸 가장 긴 팰린드롬이 있고, 탐색하는 문자가 해당 팰린드롬 내에 존재한다고 생각해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1978&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4sH3Z/btsGRfWlRsb/TrOVYoemGBhIhFq7R3oxX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4sH3Z/btsGRfWlRsb/TrOVYoemGBhIhFq7R3oxX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4sH3Z/btsGRfWlRsb/TrOVYoemGBhIhFq7R3oxX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4sH3Z%2FbtsGRfWlRsb%2FTrOVYoemGBhIhFq7R3oxX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;67&quot; data-origin-width=&quot;1978&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 탐색 문자(화살표가 가리키는 문자): 5번째 문자 'N'&lt;/li&gt;
&lt;li&gt;현재 가장 긴 팰린드롬: 4번째 문자 'A'가 중심인 '&lt;i&gt;ANANA&lt;/i&gt;' 문자열&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 현재 가장 긴 팰린드롬의 중심 문자를 기준으로, 탐색 문자의 대칭 문자를 구한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;272&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cwWicA/btsGTdCZT4Y/L13Ai8M62yXwcMXUf2KVZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cwWicA/btsGTdCZT4Y/L13Ai8M62yXwcMXUf2KVZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cwWicA/btsGTdCZT4Y/L13Ai8M62yXwcMXUf2KVZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcwWicA%2FbtsGTdCZT4Y%2FL13Ai8M62yXwcMXUf2KVZ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;70&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;272&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 대칭 문자: 3번째 문자 'N'&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 현재 가장 긴 팰린드롬의 범위 내에서, 대칭 문자가 중심인 가장 긴 팰린드롬을 이용해 현재 탐색 문자를 중심으로 하는 팰린드롬을 알 수 있다. 왜? &lt;b&gt;현재 가장 긴 팰린드롬 내에서는 대칭성이 보장되기 때문이다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2646&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceAQLd/btsGQPDPPI0/Ga8bbPQ0QvarU1ELcGAoi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceAQLd/btsGQPDPPI0/Ga8bbPQ0QvarU1ELcGAoi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceAQLd/btsGQPDPPI0/Ga8bbPQ0QvarU1ELcGAoi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceAQLd%2FbtsGQPDPPI0%2FGa8bbPQ0QvarU1ELcGAoi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;78&quot; data-origin-width=&quot;2646&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이는 현재 가장 긴 팰린드롬의 범위 내에서만 유효하므로, 밖에서 더 긴 팰린드롬이 있는 지 추가적인 탐색이 필요하다. 하지만 투 포인터 시작 위치가 달라졌기 때문에 불필요한 탐색을 줄였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 상황을 일반화하면 다음과 같이 나타낼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2120&quot; data-origin-height=&quot;302&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWIW29/btsGPQQv6Vy/fYFjVLHkIsEw8UJaV4vc70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWIW29/btsGPQQv6Vy/fYFjVLHkIsEw8UJaV4vc70/img.png&quot; data-alt=&quot;출처: https://cp-algorithms.com/string/manacher.html&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWIW29/btsGPQQv6Vy/fYFjVLHkIsEw8UJaV4vc70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWIW29%2FbtsGPQQv6Vy%2FfYFjVLHkIsEw8UJaV4vc70%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2120&quot; height=&quot;302&quot; data-origin-width=&quot;2120&quot; data-origin-height=&quot;302&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://cp-algorithms.com/string/manacher.html&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드 또한 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1713872379893&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def manacher(string: str) -&amp;gt; str:
    max_rounds = [0 for _ in range(len(string))]
    center, outbound = 0, 0
    longest_palindrome = string[0]

    for curr in range(len(string)):
        r = 0

        # 대칭 문자를 통한 최적화 가능
        if curr &amp;lt;= outbound:
            oppo = center * 2 - curr # 대칭 문자 위치
            r = min(outbound - curr, max_rounds[oppo])

        # 최장 팰린드롬 구하기
        while curr - r &amp;gt;= 0 and curr + r &amp;lt; len(string):
            if string[curr - r] != string[curr + r]:
                break
            r += 1
        r -= 1 # 범위 밖이어야 위 while 문이 종료되어 범위에 맞춤

        max_rounds[curr] = r
        if outbound &amp;lt; curr + r:
            outbound = curr + r
            center = curr

        if r * 2 + 1 &amp;gt; len(longest_palindrome):
            longest_palindrome = string[curr - r: curr + r + 1]

    return longest_palindrome&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;시간 복잡도가 O(N)?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 문자를 중심으로 하는 가장 긴 팰린드롬을 탐색할 때 최적화가 일어났다. 하지만 여전히 최악의 경우 전체 문자열을 탐색해야 한다. 그러면 여전히 O(N^2) 아닌가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 알고리즘을 다시 떠올려보면, 현재 탐색 시작 위치를 정할 때 대칭 문자의 가장 긴 팰린드롬을 참고한다. 가장 긴 팰린드롬의 길이는 탐색이 진행될 때마다 감소하지 않으며 최대 N 까지 가능하다. 따라서 O(N + N) = O(N) 이라 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;짝수 길이의 팰린드롬?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중심 문자가 2개인 짝수 팰린드롬은 해당 알고리즘으로 구할 수 없다. 따라서 살짝 꼼수가 필요한데, 문자열의 각 문자들 사이에 더미 문자를 삽입하는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2106&quot; data-origin-height=&quot;180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XHplP/btsGZX1s8Jv/Mf9uIPmZrVwBsAEyg4LCB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XHplP/btsGZX1s8Jv/Mf9uIPmZrVwBsAEyg4LCB0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XHplP/btsGZX1s8Jv/Mf9uIPmZrVwBsAEyg4LCB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXHplP%2FbtsGZX1s8Jv%2FMf9uIPmZrVwBsAEyg4LCB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;31&quot; data-origin-width=&quot;2106&quot; data-origin-height=&quot;180&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 중심 문자가 더미 문자일 때 짝수 팰린드롬을, 일반 문자일 때 홀수 팰린드롬을 구할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Algorithm &amp;amp; Data Structure/개념</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/37</guid>
      <comments>https://sechoi.tistory.com/37#entry37comment</comments>
      <pubDate>Fri, 26 Apr 2024 23:08:20 +0900</pubDate>
    </item>
    <item>
      <title>[heap] heapify(build heap)의 시간 복잡도</title>
      <link>https://sechoi.tistory.com/36</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;힙(heap)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;힙은 아래 속성을 만족하는 완전 이진 트리(&lt;i&gt;complete binary tree&lt;/i&gt;) 기반 자료구조다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최대 힙(&lt;i&gt;max heap&lt;/i&gt;)에서 주어진 노드 C에 대해 P가 C의 부모 노드라면 P의 키(값)는 C의 키보다 크거나 같다.&lt;/li&gt;
&lt;li&gt;최소 힙(&lt;i&gt;min heap&lt;/i&gt;)에서는 P의 키가 C의 키보다 작거나 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1538&quot; data-origin-height=&quot;795&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSIVIX/btsGrgVXSzw/U02CvYmjzoKX83HKfaGT01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSIVIX/btsGrgVXSzw/U02CvYmjzoKX83HKfaGT01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSIVIX/btsGrgVXSzw/U02CvYmjzoKX83HKfaGT01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSIVIX%2FbtsGrgVXSzw%2FU02CvYmjzoKX83HKfaGT01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1538&quot; height=&quot;795&quot; data-origin-width=&quot;1538&quot; data-origin-height=&quot;795&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Sift Up 연산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;힙에 노드를 추가할 때는 &lt;i&gt;&lt;b&gt;sift up&lt;/b&gt;&lt;/i&gt;&amp;nbsp;연산이 동작한다. 해당 연산은 리프 노드의 값으로 시작해 값을 위 노드의 값과 연속적으로 교환하여 값을 루트를 향해 경로 위로 이동한다. 위(부모) 노드와 비교해 트리의 속성을 만족할 때까지, 또는 루트 노드에 도달할 때까지 연산을 계속한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;위 영상에서 새 노드(값 18)은 완전 이진 트리를 만족하기 위해, 최하단 자리 중 비어 있는 가장 왼쪽 자리에 추가되었다. 그리고 부모 노드(값 60)과 값을 비교한 후 최소 힙의 특성을 만족하기 위해 서로 자리를 변경했다. 이렇게 부모 노드가 자신보다 작은 값을 가질 때까지 위로 이동하므로 &lt;i&gt;&lt;b&gt;sift 'up'&lt;/b&gt;&lt;/i&gt; 연산이라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 연산은 최악의 경우 루트 노드까지 이동하므로 시간복잡도가 &lt;b&gt;O(트리의 높이) = O(logN)&lt;/b&gt; 이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Sift Down 연산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 힙에서 원소를 삭제할 때는 &lt;i&gt;&lt;b&gt;sift down&lt;/b&gt;&lt;/i&gt; 연산이 동작한다. 이름만 봐도 알겠지만, &lt;i&gt;sift up &lt;/i&gt;과 반대로 힙의 특성을 만족할 때까지 노드를 아래로 이동하는 연산이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 연산 또한 최악의 경우 리프 노드까지 이동하므로 시간복잡도가 &lt;b&gt;O(트리의 높이) = O(logN)&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;힙 생성 (heapify)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 무작위 값들이 담긴 배열로 힙을 생성해보자. 지금 예시에서는 최소 힙을 생성해보겠다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;164&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dAAYfy/btsGtOdklLv/MlCIQQaGE5xYJxpuIInAD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dAAYfy/btsGtOdklLv/MlCIQQaGE5xYJxpuIInAD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dAAYfy/btsGtOdklLv/MlCIQQaGE5xYJxpuIInAD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdAAYfy%2FbtsGtOdklLv%2FMlCIQQaGE5xYJxpuIInAD1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;92&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;164&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;첫번째 방식 - Sift Up 사용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 &lt;i&gt;sift up&lt;/i&gt; 을 사용해 힙을 생성할 수 있다. 해당 연산이 하나의 노드를 힙에 추가할 때 쓰이므로, 비어있는 힙에 배열의 모든 값들을 순차적으로 추가하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;_shift_up.png&quot; data-origin-width=&quot;1362&quot; data-origin-height=&quot;1642&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpWA2c/btsGrQCYrT6/oUrLYJSTivo3RHcmKkgpCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpWA2c/btsGrQCYrT6/oUrLYJSTivo3RHcmKkgpCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpWA2c/btsGrQCYrT6/oUrLYJSTivo3RHcmKkgpCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpWA2c%2FbtsGrQCYrT6%2FoUrLYJSTivo3RHcmKkgpCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;772&quot; data-filename=&quot;_shift_up.png&quot; data-origin-width=&quot;1362&quot; data-origin-height=&quot;1642&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 시간복잡도는 (1개의 노드가 있는 트리에 sift up) + (2개의 노드가 있는 트리에 sift up) + ... + (N개의 노드가 있는 트리에 sift up) 이므로 최악의 경우 &lt;b&gt;O(log1 + log2 + ... + logN) = O(NlogN)&lt;/b&gt; 이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;참고: &lt;i&gt;O(log1 + log2 + ... + logN) = O(NlogN)&lt;/i&gt;&amp;nbsp;은 어떻게 성립하는가?&lt;br /&gt;&lt;br /&gt;Stirling's approximation(&lt;a href=&quot;https://en.wikipedia.org/wiki/Stirling%27s_approximation&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;링크&lt;/a&gt;)에 의하면 O(log1 + ... + logN) = O(log(N!)) = O(NlogN) 이 된다고 한다.&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1040&quot; data-origin-height=&quot;98&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tzukL/btsGt9n3vor/55Lun1WqDjRoMgSyAk1cR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tzukL/btsGt9n3vor/55Lun1WqDjRoMgSyAk1cR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tzukL/btsGt9n3vor/55Lun1WqDjRoMgSyAk1cR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtzukL%2FbtsGt9n3vor%2F55Lun1WqDjRoMgSyAk1cR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;340&quot; height=&quot;32&quot; data-origin-width=&quot;1040&quot; data-origin-height=&quot;98&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
그렇구나...^^;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;두번째 방식 - Sift Down 사용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;sift down&lt;/i&gt; 연산을 이용해서도 힙을 생성할 수 있다. 이 때는 비어있는 힙에 노드를 순차적으로 삽입할 수 없다. (위에서 아래로 내려와야 하기 때문이다) 그래서 배열로 완전 이진 트리를 생성하고 마지막부터 순서대로 각 노드를 &lt;i&gt;sift down&lt;/i&gt; 연산으로 움직인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;shift_down.png&quot; data-origin-width=&quot;1484&quot; data-origin-height=&quot;3122&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYc1lA/btsGsXn7tRz/54nudRCO2q3AujuKkXeeq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYc1lA/btsGsXn7tRz/54nudRCO2q3AujuKkXeeq1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYc1lA/btsGsXn7tRz/54nudRCO2q3AujuKkXeeq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYc1lA%2FbtsGsXn7tRz%2F54nudRCO2q3AujuKkXeeq1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;1346&quot; data-filename=&quot;shift_down.png&quot; data-origin-width=&quot;1484&quot; data-origin-height=&quot;3122&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 시간복잡도는 높이가 고정된 트리에 대해 N번 연산을 수행하므로 &lt;b&gt;N * O(logN) = O(NlogN)&lt;/b&gt; &lt;b&gt;...이 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리프 노드를 생각해보자. 리프 노드에서는 sift down 연산을 수행하지 않는다. 내려갈 곳이 없기 때문이다. 다음으로 리프 노드의 부모 노드는 연산을 최대 1번만 수행할 것이다. 마지막으로 리프 노드만 최악의 경우 전체 높이만큼 이동하게 된다. 즉 각 노드의 높이에 따라 sift down 연산의 최대 실행 횟수가 달라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 시간복잡도는 높이 H에 대해 &lt;b&gt;O(0 * (N/2) + (1 * (N/4)) + ... + (H * 1))&lt;/b&gt; 이므로 &lt;b&gt;O(N)&lt;/b&gt; 이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;참고: &lt;i&gt;O(0 * (N/2) + (1 * (N/4)) + ... + (H * 1)) = O(N) &lt;/i&gt;은 어떻게 성립하는가?&lt;br /&gt;&lt;br /&gt;Taylor series(테일러 급수)로 증명할 수 있다고 한다.&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2548&quot; data-origin-height=&quot;1108&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/L71HR/btsGt8pbFGV/5WmOKJKF8K0A41KKKUF7k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/L71HR/btsGt8pbFGV/5WmOKJKF8K0A41KKKUF7k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/L71HR/btsGt8pbFGV/5WmOKJKF8K0A41KKKUF7k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FL71HR%2FbtsGt8pbFGV%2F5WmOKJKF8K0A41KKKUF7k0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;278&quot; data-origin-width=&quot;2548&quot; data-origin-height=&quot;1108&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간복잡도를 굳이 계산해보지 않아도, 트리 구조 상 아래로 갈수록 노드의 개수가 많아지기 때문에 &lt;i&gt;sift up&lt;/i&gt; 보다는 &lt;i&gt;sift down&lt;/i&gt; 이 빠를 것임을 추측할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 실제로 자바에서 heap 으로 사용되는 &lt;i&gt;&lt;b&gt;PriorityQueue&lt;/b&gt;&lt;/i&gt; 클래스를 살펴보자.&lt;/p&gt;
&lt;pre id=&quot;code_1712565346622&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class PrioirtyQueue {
    public PriorityQueue(Collection&amp;lt;? extends E&amp;gt; c) {
        if (c instanceof SortedSet&amp;lt;?&amp;gt;) {
            // ...
        }
        else if (c instanceof PriorityQueue&amp;lt;?&amp;gt;) {
            // ...
        }
        else {
            this.comparator = null;
            initFromCollection(c);
        }
    }
    
    private void initFromCollection(Collection&amp;lt;? extends E&amp;gt; c) {
        initElementsFromCollection(c);
        heapify(); 
    }
    
    private void heapify() {
        final Object[] es = queue;
        int n = size, i = (n &amp;gt;&amp;gt;&amp;gt; 1) - 1;
        final Comparator&amp;lt;? super E&amp;gt; cmp;
        if ((cmp = comparator) == null)
            for (; i &amp;gt;= 0; i--)
                siftDownComparable(i, (E) es[i], es, n);
        else
            for (; i &amp;gt;= 0; i--)
                siftDownUsingComparator(i, (E) es[i], es, n, cmp);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 &lt;b&gt;&lt;i&gt;sift down&lt;/i&gt;&lt;/b&gt; 연산을 통해 heap을 생성하고 있다. heapify() 에 달린 설명에서도 시간복잡도가 O(N)임을 명시하고 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Establishes the heap invariant (described above) in the entire tree, assuming nothing about the order of the elements prior to the call. This classic algorithm due to Floyd (1964) is known to be &lt;b&gt;O(size)&lt;/b&gt;.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;힙에 노드 추가 및 삭제는 O(logN)의 시간복잡도를 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 기존 배열로 힙을 생성하는 경우 &lt;i&gt;sift down&lt;/i&gt; 연산을 사용하면 O(N)의 시간복잡도를 가져 효율적으로 생성할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/9755721/how-can-building-a-heap-be-on-time-complexity&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/9755721/how-can-building-a-heap-be-on-time-complexity&lt;/a&gt;&lt;/p&gt;</description>
      <category>Algorithm &amp;amp; Data Structure/개념</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/36</guid>
      <comments>https://sechoi.tistory.com/36#entry36comment</comments>
      <pubDate>Mon, 8 Apr 2024 17:46:41 +0900</pubDate>
    </item>
    <item>
      <title>[ZI9ZA9] 결제 성능 개선기</title>
      <link>https://sechoi.tistory.com/35</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://github.com/wootecam-gugucon/shopping-mall/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;github.com/wootecam-gugucon/shopping-mall&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 ZI9ZA9(이하 지구재구) 쇼핑몰에서는 당연히 결제 기능이 존재한다. 실제 출시할 서비스가 아니지만 최대한 비슷한 환경을 구현하고 싶어 테스트 키로 &lt;a href=&quot;https://docs.tosspayments.com/guides/payment-widget/integration?backend=java&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;토스페이먼츠&lt;/a&gt;를 사용했다. (자사 포인트 결제도 제공하지만 이 포스팅에서는 생략하겠다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  결제 플로우&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 주문서 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 의류를 장바구니에 담고 주문하기 버튼을 누르면 서버에서 &lt;b&gt;주문 및 주문 상품 데이터가 생성&lt;/b&gt;된다. 아직 사용자가 주문을 요청만 했으므로 주문 상태는 &lt;i&gt;CREATED&lt;/i&gt;, 결제 상태는 &lt;i&gt;NONE&lt;/i&gt; 이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;1002&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8m7Uz/btsFMtHInUB/a0uLKF7lMdAMo6fRhUlFk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8m7Uz/btsFMtHInUB/a0uLKF7lMdAMo6fRhUlFk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8m7Uz/btsFMtHInUB/a0uLKF7lMdAMo6fRhUlFk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8m7Uz%2FbtsFMtHInUB%2Fa0uLKF7lMdAMo6fRhUlFk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;375&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;1002&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서는 API 응답으로 받은 주문 데이터 id로 아래 결제하기 페이지를 불러온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1414&quot; data-origin-height=&quot;864&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/32waN/btsFK8c7QWp/CtBLAm8iRxqW1r87VRkF30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/32waN/btsFK8c7QWp/CtBLAm8iRxqW1r87VRkF30/img.png&quot; data-alt=&quot;결제하기 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/32waN/btsFK8c7QWp/CtBLAm8iRxqW1r87VRkF30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F32waN%2FbtsFK8c7QWp%2FCtBLAm8iRxqW1r87VRkF30%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;540&quot; height=&quot;330&quot; data-origin-width=&quot;1414&quot; data-origin-height=&quot;864&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;결제하기 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 수단 중 일반 결제가 토스페이먼츠 결제를 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 결제 요청&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 결제를 요청하면 프론트는 우선 서버에 &lt;b&gt;결제 요청 API&lt;/b&gt;를 보낸다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ba1R98/btsFKPZeZuQ/xEna4pG8IB6mwUubMbQ5E0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ba1R98/btsFKPZeZuQ/xEna4pG8IB6mwUubMbQ5E0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ba1R98/btsFKPZeZuQ/xEna4pG8IB6mwUubMbQ5E0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fba1R98%2FbtsFKPZeZuQ%2FxEna4pG8IB6mwUubMbQ5E0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;540&quot; height=&quot;422&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;587&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 주문 데이터의 주문 상태와 결제 상태가 업데이트 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 전 결제 데이터를 업데이트하는 이유는 추후 서버에서 진행할 &lt;b&gt;결제 승인&lt;/b&gt; 때문이다. 해당 주문이 정상적으로 결제를 요청했었는지, 토스 결제가 맞는 지 확인이 필요하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 결제 진행&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;914&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQ5hOg/btsFLsbmbsY/CJFL1KYNYg84uU0ikbgIV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQ5hOg/btsFLsbmbsY/CJFL1KYNYg84uU0ikbgIV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQ5hOg/btsFLsbmbsY/CJFL1KYNYg84uU0ikbgIV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQ5hOg%2FbtsFLsbmbsY%2FCJFL1KYNYg84uU0ikbgIV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;457&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;914&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 전처리가 완료되면 이후 플로우는 토스페이먼츠에서 제공하는 위 그림을 따라간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 결제위젯을 렌더링하고, 사용자는 위젯을 통해 실제 결제를 진행한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1125&quot; data-origin-height=&quot;1587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bir0kB/btsFKOTygzE/ZVPMyvEJTrEVj5ZPC4oiV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bir0kB/btsFKOTygzE/ZVPMyvEJTrEVj5ZPC4oiV0/img.png&quot; data-alt=&quot;결제위젯&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bir0kB/btsFKOTygzE/ZVPMyvEJTrEVj5ZPC4oiV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbir0kB%2FbtsFKOTygzE%2FZVPMyvEJTrEVj5ZPC4oiV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;280&quot; height=&quot;395&quot; data-origin-width=&quot;1125&quot; data-origin-height=&quot;1587&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;결제위젯&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 결제 승인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제가 완료되면 미리 지정해둔 경로(successUrl)로 페이지가 이동하고, 프론트는 서버로 &lt;b&gt;결제 승인 API&lt;/b&gt;를 요청한다. 이 때 서버에서 토스페이먼츠 API를 호출해 결제를 검사하고 완료되었으면 관련 재고를 차감 후 주문 데이터를 업데이트한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  문제: 재고 감소와 외부 API 호출&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 플로우는 문제가 없어 보인다. 실행하면 잘 동작한다. 다만 걸리는 부분이 있었다. 바로 마지막 단계인 &lt;b&gt;결제 승인 API&lt;/b&gt;다.&lt;/p&gt;
&lt;pre id=&quot;code_1710329393083&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 결제 승인 트랜잭션
@Transactional
public PayValidationResponse validatePay() {
    // 재고 감소
    decreaseStock(order);
        
    // 토스페이먼츠 API로 결제 승인
    payValidator.validatePayment(payValidationRequest);

    return PayValidationResponse.from(orderId);
}

private void decreaseStock(final Order order) {
    order.getOrderItems().forEach(orderItem -&amp;gt; {
    	// 상품 조회
        final Product product = productRepository.findById();
        // 품절인지 검사
        product.validateStockIsNotLessThan(orderItem.getQuantity());
        // 재고 차감
        product.decreaseStockBy(orderItem.getQuantity());
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제를 승인한 후 재고를 차감한다면 사용자 입장에서 결제는 승인되었는데 재고가 부족해 취소되는 불편함이 발생할 수 있다. 따라서 결제 승인 전 재고를 차감한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고를 차감할 때는 상품 데이터를 가져온 후, 재고가 품절 상태인지 검사한다. 품절되지 않았다면 재고를 차감한다. 이 때 여러 스레드가 동시에 실행되는 과정에서&amp;nbsp;'조회 후 업데이트'라는 전형적인 &lt;b&gt;갱신 손실&lt;/b&gt;이 발생한다. (만약 트랜잭션 격리 수준이 &lt;i&gt;Serializable&lt;/i&gt; 라면 애초에 동시에 실행되지 않으므로 그 이하의 수준에서만 발생한다. 우리는 MySQL 기본 수준인 &lt;i&gt;Repeatable Read&lt;/i&gt; 를 사용하고 있다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;722&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vkXc1/btsFJIGik5N/vapoPJn4ow8uYgr3wyEUg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vkXc1/btsFJIGik5N/vapoPJn4ow8uYgr3wyEUg1/img.png&quot; data-alt=&quot;갱신 손실 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vkXc1/btsFJIGik5N/vapoPJn4ow8uYgr3wyEUg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvkXc1%2FbtsFJIGik5N%2FvapoPJn4ow8uYgr3wyEUg1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;333&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;722&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;갱신 손실 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해&amp;nbsp;&lt;b&gt;상품 테이블을 조회할 때  베타 락(&lt;i&gt;select... for update&lt;/i&gt;)을 획득&lt;/b&gt;하도록 했다. 즉, 하나의 트랜잭션 내에서 락 획득과 외부 API 호출이 함께 이루어진다. 성능 저하가 우려되므로 nGrinder를 사용해 간단히 로컬에서 테스트를 해보자. 토스페이먼츠의 결제 승인 API가 성공하려면 주문 ID가 필요한데, 테스트라 결제는 하지 않으므로 임의의 값을 보내 실패하도록 했다. 이 테스트에서 외부 API 성공 여부는 중요하지 않기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;882&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brJJrK/btsFPpMKM3y/EgCa9yeAHvkdmCVfACtfx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brJJrK/btsFPpMKM3y/EgCa9yeAHvkdmCVfACtfx0/img.png&quot; data-alt=&quot;성능 테스트 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brJJrK/btsFPpMKM3y/EgCa9yeAHvkdmCVfACtfx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrJJrK%2FbtsFPpMKM3y%2FEgCa9yeAHvkdmCVfACtfx0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;220&quot; height=&quot;404&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;882&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;성능 테스트 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상 유저 99명이 동시에 접속할 때 평균 TPS가 31.9가 나왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1944&quot; data-origin-height=&quot;670&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dwnCpO/btsFRrCxfg4/hFh5Va2N7OUFr7kuUfNc5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dwnCpO/btsFRrCxfg4/hFh5Va2N7OUFr7kuUfNc5k/img.png&quot; data-alt=&quot;TPS 그래프&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dwnCpO/btsFRrCxfg4/hFh5Va2N7OUFr7kuUfNc5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdwnCpO%2FbtsFRrCxfg4%2FhFh5Va2N7OUFr7kuUfNc5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1944&quot; height=&quot;670&quot; data-origin-width=&quot;1944&quot; data-origin-height=&quot;670&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;TPS 그래프&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;살펴보니 4초가 될 때부터 TPS가 급격히 떨어진다. MariaDB 기본 락 대기 시간(&lt;i&gt;innodb_lock_wait_timeout&lt;/i&gt;) 은 50초이므로 커넥션들이 락을 얻기 위해 끝까지 기다리며 성능이 저하되었음을 유추할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균 테스트 시간은 3s다. 우리 쇼핑몰에 99명이 동시에 결제를 시도하면 3초 정도 기다려야 한다. 실제로는 결제 승인 외에도 다양한 요청이 동시에 일어날 것이기 때문에 더 기다려야 할 것이다. 또 로컬(램 16기가, 맥북 프로 M1)에서 테스트했으므로 실제 서버에선 더 느릴 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;어차피 이 쇼핑몰에는 아무도 접속할 수 없다.&lt;/s&gt;&amp;nbsp;좋지 않은 성능이라 판단해 해결하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  해결: 재고 감소와 외부 API 호출을 분리하자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능을 위해 단순히 둘을 다른 트랜잭션으로 분리하는 순간, 재고에 대한 정합성이 보장되지 않는다. 다른 접근이 필요하다. 해결책을 위해 이미 우리보다 뛰어난 개발자들이 만들어놓은 실제 서비스들을 생각해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 때 영화 예매 서비스가 떠올랐다. 좌석(예매)에 대한 엄격한 정합성이 요구된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1998&quot; data-origin-height=&quot;228&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnv8e5/btsFOCFRaPh/EfhTqLoCRKFpQvGWe6HKGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnv8e5/btsFOCFRaPh/EfhTqLoCRKFpQvGWe6HKGk/img.png&quot; data-alt=&quot;결제'대기' 상태의 유저 A&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnv8e5/btsFOCFRaPh/EfhTqLoCRKFpQvGWe6HKGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbnv8e5%2FbtsFOCFRaPh%2FEfhTqLoCRKFpQvGWe6HKGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1998&quot; height=&quot;228&quot; data-origin-width=&quot;1998&quot; data-origin-height=&quot;228&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;결제'대기' 상태의 유저 A&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;536&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BbGel/btsFQgaZZF0/hvNkAH8kj1zK6Vct62Cszk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BbGel/btsFQgaZZF0/hvNkAH8kj1zK6Vct62Cszk/img.png&quot; data-alt=&quot;같은 좌석을 선택하려는 유저 B &quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BbGel/btsFQgaZZF0/hvNkAH8kj1zK6Vct62Cszk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBbGel%2FbtsFQgaZZF0%2FhvNkAH8kj1zK6Vct62Cszk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;280&quot; height=&quot;214&quot; data-origin-width=&quot;536&quot; data-origin-height=&quot;410&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;같은 좌석을 선택하려는 유저 B &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위는 CGV 예매 서비스다. 유저 A가 좌석을 선택한 순간, 결제하지 않아도 다른 유저들은 해당 좌석을 선택할 수 없다. 심지어 유저 A가 창을 닫아도 몇 분 동안은 여전히 다른 유저들이 예매할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추측할 수 있는 로직은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자가 결제 창으로 이동하는 순간 일정 시간동안 좌석이 예매된 것으로 간주한다.&lt;/li&gt;
&lt;li&gt;만약 일정 시간 이내로 결제가 완료되지 않았다면 다시 예매 가능 상태로 변경한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트도 이 방법을 적용하기로 했다. 따라서 결제 플로우 2번인 &lt;b&gt;결제 요청&lt;/b&gt;에서 재고가 차감된다. 그리고 일정 시간 동안 결제가 완료되지 않으면 해당 주문을 취소한다.&lt;/p&gt;
&lt;pre id=&quot;code_1710578940274&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 결제 요청 트랜잭션
@Transactional
public OrderPayResponse requestPay() {
    final Order order = orderRepository.findByIdAndMemberIdExclusively();

    order.validateNotCanceled();
    order.startPay(orderPayRequest.getPayType());
    
    // * 재고 감소 메서드 추가
    decreaseStock(order);
    
    return OrderPayResponse.from(order);
}
    
// 결제 승인 트랜잭션
@Transactional
public PayValidationResponse validatePay() {
    // * 사라진 재고 감소 메서드
    
    // 토스페이먼츠 API로 결제 승인
    payValidator.validatePayment(payValidationRequest);

    return PayValidationResponse.from(orderId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베타 락이 사라진 결제 승인 API의 성능은 얼마나 향상되었을까? 똑같은 조건으로 다시 성능 테스트를 진행했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;466&quot; data-origin-height=&quot;884&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmyw6o/btsFQvZ4D8Q/6oK96kPefJLzfZ8kPAUQB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmyw6o/btsFQvZ4D8Q/6oK96kPefJLzfZ8kPAUQB0/img.png&quot; data-alt=&quot;성능 테스트 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmyw6o/btsFQvZ4D8Q/6oK96kPefJLzfZ8kPAUQB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbmyw6o%2FbtsFQvZ4D8Q%2F6oK96kPefJLzfZ8kPAUQB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;220&quot; height=&quot;417&quot; data-origin-width=&quot;466&quot; data-origin-height=&quot;884&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;성능 테스트 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TPS 98.8, 평균 실행 시간 1.7s로 3배 정도 성능이 향상되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 베타 락 획득 로직이 결제 요청 API로 이동했기 때문에 해당 요청의 성능은 느려졌지만, 결제 중 특정 구간에서 병목이 발생하지 않게 되었다.&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;019&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/019.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/019.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  해결:&lt;span&gt;&amp;nbsp;&lt;/span&gt;주문 취소 스케줄러&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고를 미리 차감한다면 결제가 실패했을 때 복구해야 한다. 하지만 서버에서 사용자의 결제 취소 액션(ex. 창 닫기)을 모두 감지할 수 없다. 따라서 CGV처럼 일정 시간마다 결제되지 않은 주문을 취소하는 스케줄러가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 Spring Boot와 MariaDB를 사용한다. 즉 &lt;i&gt;Spring Scheduler&lt;/i&gt;와 &lt;i&gt;MariaDB Event &lt;/i&gt;모두 사용할 수 있다. 하지만 최대한 비즈니스 로직을 DB에 주지 않고 어플리케이션에서 객체지향적으로 해결하고 싶어 전자를 선택했다. DB 서버가 추가되면 이벤트를 다루기 까다로워진다는 점도 고려햇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1710580693827&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void cancelIncompleteOrders() {
    final LocalDateTime scanStartTime = lastScanTime == null ? DEFAULT_SCAN_START_TIME : lastScanTime;
    final LocalDateTime scanEndTime = LocalDateTime.now().minus(CANCEL_INTERVAL);

    // 비관적 락으로 취소할 주문들 조회
    final List&amp;lt;Order&amp;gt; incompleteOrders = orderRepository.findAllIncomplete();

    boolean allSucceeded = true;
    for (Order incompleteOrder : incompleteOrders) {
        final boolean succeeded = cancelIncompleteOrders(incompleteOrder);
        if (!succeeded) {
            allSucceeded = false;
        }
    }

    // 모든 취소에 성공하면 lastScanTime 업데이트
    if (allSucceeded) {
        lastScanTime = scanEndTime;
    }
}

private boolean cancelIncompleteOrders(final Order incompleteOrder) {
    // CREATED 주문 취소
    if (incompleteOrder.isCreated()) {
        orderService.cancelCreatedOrder(incompleteOrder);
        return true;
    }

    // PAYING 주문 취소
    try {
        orderService.cancelPayingOrder(incompleteOrder);
    } catch (Exception e) {
        return false;
    }
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스케줄러 플로우는 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;마지막 취소 시간(&lt;i&gt;lastScanTime&lt;/i&gt;)으로 취소할 시간의 범위를 정한다.&lt;/li&gt;
&lt;li&gt;해당 범위에 해당하는 주문 데이터들을 &lt;b&gt;베타 락&lt;/b&gt;을 걸어 조회한다.&lt;br /&gt;- 스케줄러가 작동하는 동안 취소할 주문이 결제 완료가 되면 치명적인 업데이트 동시성 문제가 발생하기 때문이다.&lt;/li&gt;
&lt;li&gt;각 주문을 취소한다.&lt;br /&gt;- &lt;i&gt;PAYING&lt;/i&gt;(결제 시작) 상태는 재고가 감소되었으므로 복구하고, &lt;i&gt;CREATED&lt;/i&gt;(주문서 생성) 상태는 상태만 변경한다.&lt;/li&gt;
&lt;li&gt;모든 주문 취소에 성공하면 &lt;i&gt;마지막 취소 시간&lt;/i&gt;을 업데이트한다.&lt;br /&gt;- 하나라도 실패하는 주문이 있으면 다음 스케줄러가 처리해야 하기 때문이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;재고 복구 트랜잭션&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 3번의 재고 복구에 대해 생각해보자. 스케줄러는 일정 시간(우리 서비스는 30분으로 정했다) 내 결제가 완료되지 않은 주문들을 취소한다. 즉 상황에 따라 매우 많은 주문 데이터를 처리해야 할 수도 있다. 이 때 하나의 트랜잭션에서 모든 상품의 재고를 복구한다면, 트랜잭션이 종료되기 전까지는 재고가 복구되지 않으므로 데이터 양에 따라 주문이 밀릴 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 재고를 복구할 때는 &lt;b&gt;새 물리적 트랜잭션을 생성&lt;/b&gt;해 스케줄러의 영향을 받지 않도록 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1710605455640&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(propagation = Propagation.REQUIRES_NEW)
public void cancelPayingOrder(final Order order) {
    order.getOrderItems()
            .forEach(orderItem -&amp;gt; productRepository.increaseStock();
    order.cancel();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방법은 트레이드 오프가 있다. 스케줄러의 트랜잭션과 별개로 동작해 재고 복구가 신속히 이루어진다는 장점이 있지만, 매번 새 물리적 트랜잭션을 생성하면서 오버헤드가 발생한다. 따라서 스케줄러 내 주문 데이터의 베타 락 획득 시간이 길어진다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;외부 트랜잭션에 연결된 리소스는 내부 트랜잭션이 새 데이터베이스 연결과 같은 자체 리소스를 획득하는 동안 바인딩된 상태로 유지됩니다. 이로 인해 Connection Pool이 고갈될 수 있습니다. 여러 스레드가 활성 외부 트랜잭션을 가지고 있고 내부 트랜잭션에 대한 새 연결을 획득하기 위해 기다리는 경우 풀이 더 이상 이러한 내부 연결을 제공할 수 없어 교착 상태(deadlock)가 발생할 수 있습니다. Connection Pool의 크기가 동시 스레드 수를 1 이상 초과하는 적절한 크기가 아니면 &lt;i&gt;PROPAGATION_REQUIRES_NEW&lt;/i&gt;를 사용하지 마세요.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html#tx-propagation-requires_new&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring 공식 문서&lt;/a&gt;에 나온 것처럼 하나의 요청에서 필요한 connection의 개수가 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 스케줄러는 무조건 하나의 스레드에서만 실행되기 때문에 커넥션이 하나만 더 필요하다는 점, 생성되고 30분이 지난 주문 데이터를 업데이트 할 일이(베타 락을 획득해야 할 일이) 거의 없다는 점을 고려해 이 방법을 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MariaDB(MySQL)의 락&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2번의 베타 락에 대해서도 살펴보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL InnoDB는 레코드 기반의 잠금 방식을 탑재한다. 이 때 레코드 락(Record lock)은 레코드 자체가 아닌 &lt;b&gt;인덱스의 레코드&lt;/b&gt;를 잠근다. 테이블에 인덱스를 추가하지 않았다면 클러스터링 인덱스인 PK(Primary Key)를 잠근다.  그리고 &lt;b&gt;변경해야 할 레코드를 찾기 위해 검색한 인덱스의 레코드를 모두 락을 걸어야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스케줄러는 취소할 주문을 조회할 때 다음과 같은 쿼리를 실행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1710610462281&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM Order o 
WHERE o.status IN ('CREATED', 'PAYING') 
    AND (o.lastModifiedAt BETWEEN '시작 시간' AND '종료 시간')
FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태와 마지막 수정 시간을 조건으로 데이터를 조회하므로 해당 컬럼들을 탐색하며 조건에 맞는 데이터를 필터링한다. 이 때 두 컬럼에 인덱스가 걸려있지 않으면 어떻게 되는지 테스트 해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 테이블에 4개의 데이터가 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOsE5o/btsFQUehgfT/5ozxqlll0pyqnNfLHD5q8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOsE5o/btsFQUehgfT/5ozxqlll0pyqnNfLHD5q8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOsE5o/btsFQUehgfT/5ozxqlll0pyqnNfLHD5q8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOsE5o%2FbtsFQUehgfT%2F5ozxqlll0pyqnNfLHD5q8k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;152&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션을 시작하고 위 쿼리와 비슷하게 실행해보았다.&lt;/p&gt;
&lt;pre id=&quot;code_1710611086639&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;begin;
select * from orders where status = 'CREATED' and last_modified_at = now() for update;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 현재 걸려있는 모든 락을 조회했다.&lt;/p&gt;
&lt;pre id=&quot;code_1710611141894&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;*************************** 1. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: NULL
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: supremum pseudo-record
*************************** 3. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 1
*************************** 4. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2
*************************** 5. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 3
*************************** 6. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 쿼리로 조회되는 데이터는 없음에도 4개의 모든 데이터와 테이블의 마지막(&lt;i&gt;supremum pseudo-record&lt;/i&gt;)이 잠금을 획득했다. 이는 따로 조건절을 포함하는 인덱스가 없어, 조건에 맞는 데이터를 찾기 위해 모든 데이터를 탐색했기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스케줄러가 실행되는 동안 다른 주문에 대한 업데이트와 새로운 주문 추가는 정상적으로 이루어져야 한다. 따라서 &lt;b&gt;카디널리티가 큰 &lt;i&gt;last_modified_at&lt;/i&gt; 컬럼에 인덱스를 추가했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 추가하고 다시 베타 락을 걸어 조회해보자. 이번에는 2번 행이 조회되도록 쿼리를 실행했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1964&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8miy2/btsFRmhkSWk/M1v3GIMtCZKSBzpPbQl2XK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8miy2/btsFRmhkSWk/M1v3GIMtCZKSBzpPbQl2XK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8miy2/btsFRmhkSWk/M1v3GIMtCZKSBzpPbQl2XK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8miy2%2FbtsFRmhkSWk%2FM1v3GIMtCZKSBzpPbQl2XK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;93&quot; data-origin-width=&quot;1964&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1710611848943&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;*************************** 1. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: NULL
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: idx_2
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 0x99B2E22A27, 2
*************************** 3. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2
*************************** 4. row ***************************
          OBJECT_NAME: orders
           INDEX_NAME: idx_2
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 0x99B2E22A28, 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탐색 과정에서 거친 2번 행의 idx_2(&lt;i&gt;last_modified_at) &lt;/i&gt;인덱스와 PK 인덱스만 락이 걸려있다. 취소할 주문 데이터에만 락이 걸리도록 해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 플로우 중 하나의 트랜잭션에서 잠금 획득과 외부 API 호출이 겹치며 병목이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 두 작업을 다른 트랜잭션으로 분리하고 이로 생긴 재고 정합성 문제는 Spring Scheduler를 통해 해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3주 안에 프로젝트를 완성해야 해서 시간이 부족했기 때문에 더 발전시킬 부분이 보이는 게 아쉽다. 스케줄러를 어플리케이션과 별도의 서버에서 실행하고, 재고 복구 쿼리가 비동기적으로 실행되도록 변경한다면 (DB 스펙 때문에 확실하지는 않지만) 더 빠르게 실행되었을 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영할 서비스가 아니었기 때문에 성능 목표를 딱 정하지 못한 점, 토스페이먼츠와의 통신이 포함된 시나리오에 대해 성능 테스트를 제대로 못한 점도 아쉽다. 그래도 다양한 내용을 공부할 수 있어서 좋았다.&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/35</guid>
      <comments>https://sechoi.tistory.com/35#entry35comment</comments>
      <pubDate>Sun, 17 Mar 2024 03:18:46 +0900</pubDate>
    </item>
    <item>
      <title>[Floney] 카테고리 리팩토링 일대기</title>
      <link>https://sechoi.tistory.com/34</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;  기존 상황&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;도메인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 가계부에서 하나의 가계부 내역은 지출/수입/이체로 구분되고, 자산과 분류 항목으로 세부적으로 다시 구분된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 가지를 편의상 &lt;b&gt;카테고리&lt;/b&gt;로 통칭한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;가계부_내역_카테고리.png&quot; data-origin-width=&quot;680&quot; data-origin-height=&quot;928&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VtDp9/btsFoIsJpBf/GcV7BkRjA07IlU7e3vTHLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VtDp9/btsFoIsJpBf/GcV7BkRjA07IlU7e3vTHLK/img.png&quot; data-alt=&quot;가계부 내역 추가 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VtDp9/btsFoIsJpBf/GcV7BkRjA07IlU7e3vTHLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVtDp9%2FbtsFoIsJpBf%2FGcV7BkRjA07IlU7e3vTHLK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;491&quot; data-filename=&quot;가계부_내역_카테고리.png&quot; data-origin-width=&quot;680&quot; data-origin-height=&quot;928&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;가계부 내역 추가 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지출/수입/이체 (이후 &lt;b&gt;상위 카테고리&lt;/b&gt;라 칭함): 가계부 내역의 종류&lt;/li&gt;
&lt;li&gt;분류 (이후 &lt;b&gt;하위 카테고리&lt;/b&gt;라 칭함): 위 지출/수입/이체의 하위 분류 (ex. 식비, 월급 등)&lt;/li&gt;
&lt;li&gt;자산 (이후 &lt;b&gt;하위 자산 카테고리&lt;/b&gt;라 칭함): 가계부 내역의 자산 출처 (ex. 현금, 카드 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 계층적으로 정리하면 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;카테고리_계층구조.png&quot; data-origin-width=&quot;641&quot; data-origin-height=&quot;181&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOVOGZ/btsFogDdaod/JkzvcJRljDsEsPtVn8ZIh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOVOGZ/btsFogDdaod/JkzvcJRljDsEsPtVn8ZIh0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOVOGZ/btsFogDdaod/JkzvcJRljDsEsPtVn8ZIh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOVOGZ%2FbtsFogDdaod%2FJkzvcJRljDsEsPtVn8ZIh0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;641&quot; height=&quot;181&quot; data-filename=&quot;카테고리_계층구조.png&quot; data-origin-width=&quot;641&quot; data-origin-height=&quot;181&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 카테고리들은 가계부 별로 관리된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rLg08/btsFIyaBtam/abXx0VhnUrt9aPwvikF400/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rLg08/btsFIyaBtam/abXx0VhnUrt9aPwvikF400/img.png&quot; data-alt=&quot;분류 항목 (하위 카테고리) 관리 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rLg08/btsFIyaBtam/abXx0VhnUrt9aPwvikF400/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrLg08%2FbtsFIyaBtam%2FabXx0VhnUrt9aPwvikF400%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;503&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;분류 항목 (하위 카테고리) 관리 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 하위 카테고리는 가계부 생성 시 기본적으로  N개가 주어진다. 이를 &lt;b&gt;기본 카테고리&lt;/b&gt;라고 부르겠다. 기본 카테고리는 사용자가 임의로 삭제 및 수정을 할 수 없다. 위 화면에 보이는 자산의 &lt;i&gt;현금~은행 &lt;/i&gt;하위 카테고리가 그러하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 사용자가 직접 카테고리를 추가할 수 있다. 이를 &lt;b&gt;커스텀 카테고리&lt;/b&gt;라고 부르겠다. 화면의 &lt;i&gt;추가항목1&lt;/i&gt; 하위 카테고리가 그러하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나와 서버를 담당하는 팀원 분은 이 도메인을 구현하기 위해 여러 고민을 했고(그 때의 고민 과정은 &lt;a href=&quot;https://sienna1022.tistory.com/entry/%ED%94%8C%EB%A1%9C%EB%8B%88-Spring-Boot-Category-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 포스팅&lt;/a&gt;에서 확인할 수 있다), 최종적으로 다음과 같이 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 카테고리&lt;/p&gt;
&lt;pre id=&quot;code_1710125733534&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Inheritance(strategy = SINGLE_TABLE)
public abstract class Category {

    @Column
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;parent_id&quot;)
    private Category parent;
}

// 상위 카테고리
@DiscriminatorValue(&quot;DefaultRoot&quot;)
public class RootCategory extends Category {
}

// 기본 하위 카테고리
@DiscriminatorValue(&quot;Default&quot;)
public class DefaultCategory extends Category {
}

// 커스텀 하위 카테고리
@DiscriminatorValue(&quot;Book&quot;)
public class BookCategory extends Category {

    @ManyToOne
    private Book book;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;기본적으로 카테고리라는 도메인에 속해 있기 때문에 Category 추상 클래스를 생성했다. 그리고 &lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;상위 카테고리, 하위 기본 카테고리, 하위 커스텀 카테고리를&lt;/span&gt;&amp;nbsp;&lt;b&gt;상속(@Inheritance)&lt;/b&gt;을 이용해 각각의 객체로 나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 74px;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 17px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 17px;&quot;&gt;dtype&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;name&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;parent_id&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;book_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 19px;&quot;&gt;DefaultRoot&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;지출&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 19px;&quot;&gt;Default&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;식비&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 19px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 19px;&quot;&gt;Book&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;내가 만든 카테고리&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속 전략은&lt;i&gt; SINGLE_TABLE&lt;/i&gt; 을 사용했다. 따라서 세 개의 Category 객체가 DB에서 하나의 테이블에 저장된다. 대신 각 객체는 dType으로 구분된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 가계부 내역 - 카테고리 연결&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 가계부 내역은 세 개의 카테고리를 참조한다. 하나의 카테고리는 여러 가계부 내역들을 참조한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로가 다대다 관계를 맺고 있으므로 DB에서 두 테이블을 참조하는 연결 테이블을 생성했다. 이 때 내역이 참조하는 세 개의 카테고리는 각자 자신의 속성이 있으므로, 이를 &lt;i&gt;category_type&lt;/i&gt; 컬럼으로 구분한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 19px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 19px;&quot;&gt;book_id (연결 가계부)&lt;/td&gt;
&lt;td style=&quot;width: 27.7907%; height: 19px;&quot;&gt;category_id (연결 카테고리)&lt;/td&gt;
&lt;td style=&quot;width: 33.8372%; height: 19px;&quot;&gt;category_type (카테고리 종류)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.7907%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.8372%; height: 19px;&quot;&gt;FLOW (상위 카테고리 - 지/수/이)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.7907%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.8372%; height: 19px;&quot;&gt;ASSET (자산 하위 카테고리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 19px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.7907%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.8372%; height: 19px;&quot;&gt;FLOW_LINE (하위 카테고리)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어플리케이션에서는 가계부 내역이 EnumMap으로 세 개의 카테고리 연결 객체를 참조한다. 내역을 조회할 때 거의 연관 카테고리까지 조회하고, 논리적으로도 내역에 카테고리 정보가 포함된다고 판단했다.&lt;/p&gt;
&lt;pre id=&quot;code_1710128614123&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 가계부 내역
@Entity
public class BookLine {

    @OneToMany(fetch = FetchType.LAZY, mappedBy = &quot;bookLine&quot;)
    @MapKeyEnumerated(EnumType.STRING)
    private final Map&amp;lt;CategoryEnum, BookLineCategory&amp;gt; bookLineCategories = new EnumMap&amp;lt;&amp;gt;(CategoryEnum.class);
}

// 한 가계부 내역과 연관된 카테고리 종류
public enum CategoryEnum {

    FLOW, 
    ASSET, 
    FLOW_LINE; 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  변화가 필요하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 여러 기능을 구현하면서 기존 설계 및 코드에 대해 많은 불편함을 느꼈다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;QueryDSL의 projection&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가계부 내역을 조회할 때 연관된 카테고리들을 JOIN  하면 일대다로 참조한 &lt;i&gt;bookLineCategories&lt;/i&gt; 에 알맞게 연결된다.&lt;/p&gt;
&lt;pre id=&quot;code_1709554736078&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// QueryDSL 사용
public BookLine getBookLine() {
    return jpaQueryFactory.from(bookLine)
        .innerJoin(bookLine.bookLineCategories, bookLineCategory)
        .fetchOne();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 QueryProjection, 즉&amp;nbsp;&lt;b&gt;조회용 객체&lt;/b&gt;를 사용할 때다.&lt;/p&gt;
&lt;pre id=&quot;code_1709554854685&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// QueryProjection 사용
public CustomBookLine getBookLine() {
    return jpaQueryFactory
    	.select(new QCustomBookLine(bookLineCategory.name)) // X
    	.from(bookLine)
        .innerJoin(bookLine.bookLineCategories, bookLineCategory)
        .fetchOne();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 가계부 내역에 세 개의 카테고리가 연결될 때 이 정보를 &lt;i&gt;bookLineCategories&lt;/i&gt; 로 모으지 않으므로 각각 따로 조회된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;카테고리_행_분리.png&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xK9Zj/btsFB7S1Ntp/qGRPse18kLF6KRyzQ2MDCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xK9Zj/btsFB7S1Ntp/qGRPse18kLF6KRyzQ2MDCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xK9Zj/btsFB7S1Ntp/qGRPse18kLF6KRyzQ2MDCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxK9Zj%2FbtsFB7S1Ntp%2FqGRPse18kLF6KRyzQ2MDCK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;761&quot; height=&quot;282&quot; data-filename=&quot;카테고리_행_분리.png&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;282&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시에는 마땅한 해결책을 못 찾아 3개의 행을 따로 불러오되, 어플리케이션에서 직접 합쳐줬다.&lt;/p&gt;
&lt;pre id=&quot;code_1709863607471&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 안 읽어도 됨
// 가계부 내역에 여러 개의 카테고리를 하나의 객체로 합치는 함수
public List&amp;lt;DayLines&amp;gt; transfer(List&amp;lt;CustomBookLine&amp;gt; dayLines) {
    Map&amp;lt;Long, DayLineInfo&amp;gt; dayLineWithCategories = new HashMap&amp;lt;&amp;gt;();

    dayLines.forEach((dayLine) -&amp;gt;
    {
        DayLineInfo dayLineInfo = dayLineWithCategories.get(dayLine.getId());

        // 카테고리 외의 데이터 최초 등록
        if (dayLineInfo == null) {
            dayLineWithCategories.put(dayLine.getId(), DayLineInfo.toDayViewInfos(dayLine));
        } else {
            dayLineInfo.addCategory(dayLine.getCategories());
        }
    });

    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대충 봐도 복잡하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SINGLE_TABLE 상속 전략과 유니크 인덱스&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB의 Category 테이블은 다음과 같이 &lt;i&gt;dtype&lt;/i&gt; 으로 카테고리를 구별한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 74px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 17px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 17px;&quot;&gt;dtype&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;name&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;parent_id&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;book_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 19px;&quot;&gt;DefaultRoot&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;지출&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 19px;&quot;&gt;Default&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;식비&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 9.01163%; height: 19px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 15.9884%; height: 19px;&quot;&gt;Book&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;내가 만든 카테고리&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 &lt;i&gt;dtype&lt;/i&gt; 에 따라 constraints 도 달라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DefaultRoot 는 상위 카테고리며&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;모든 가계부가 공유한다. 따라서 &lt;i&gt;parent_id&lt;/i&gt; 와 &lt;i&gt;book_id&lt;/i&gt; 가 모두 null 이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Default 는 기본 하위 카테고리라 모든 가계부가 공유한다. 따라서 &lt;i&gt;book_id&lt;/i&gt; 가 null 이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Book 은 커스텀 하위 카테고리라 가계부 마다 생성 가능하다. 따라서 모든 컬럼에 null 이 허용되지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;논리적으로는 위와 같지만, 모두 하나의 테이블을 사용하므로 &lt;b&gt;&lt;i&gt;parent_id&lt;/i&gt; 와 &lt;i&gt;book_id&lt;/i&gt; 는 null 을 무조건 허용해야 한다. &lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이 때 도메인 상 커스텀 카테고리는 (이름, 가계부, 상위 카테고리) 조합이 유일하다. 기본 카테고리와도 이름이 겹치면 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rLg08/btsFIyaBtam/abXx0VhnUrt9aPwvikF400/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rLg08/btsFIyaBtam/abXx0VhnUrt9aPwvikF400/img.png&quot; data-alt=&quot;분류 항목 (하위 카테고리) 관리 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rLg08/btsFIyaBtam/abXx0VhnUrt9aPwvikF400/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrLg08%2FbtsFIyaBtam%2FabXx0VhnUrt9aPwvikF400%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;503&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;분류 항목 (하위 카테고리) 관리 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 여기서 자산의 분류 항목을 추가할 때&amp;nbsp;&lt;i&gt;현금&lt;/i&gt;부터 &lt;i&gt;추가항목1&lt;/i&gt; 까지 이름이 겹치면 안 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이를 어플리케이션에서 조건에 맞는 하위 카테고리를 먼저 조회하고, 있으면 예외를 던지고 없으면 추가하는 방식으로 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;016&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/016.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/016.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;...뭔가 익숙한 방식이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;맞다. &lt;a href=&quot;https://sechoi.tistory.com/31&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;자산 이슈&lt;/a&gt;에서 똑같이 '조회 후 데이터 삽입'을 하다가 &lt;b&gt;동시성 문제&lt;/b&gt;가 발생했었다.&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 해당 이슈에서는 MySQL의 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;upsert 문법&lt;/a&gt;(&lt;i&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/i&gt;)을 사용한 Atomic Query로 문제를 해결했다. 그래서 똑같이 적용하려고 했으나, 안타깝게도 MySQL에서는 특정 컬럼에 null 값이 포함되면 unique index가 의도대로 작동하지 않는다. MySQL에서 null은 '알 수 없는 값'으로 취급되기 때문이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;The concept of the NULL value is a common source of confusion for newcomers to SQL, who often think that&amp;nbsp;NULL is the same thing as an empty string&amp;nbsp;''. This is not the case.&amp;nbsp;&lt;br /&gt;Conceptually, NULL means&amp;nbsp;&amp;ldquo;a missing unknown value&amp;rdquo;&amp;nbsp;and it is treated somewhat differently from other values.&lt;br /&gt;NULL 값의 개념은 SQL을 처음 접하는 사람들이 흔히 혼동하는 부분으로, NULL이 빈 문자열 ''과 같은 의미라고 생각하는 경우가 많으나 그렇지 않다. 개념적으로 NULL은 &lt;b&gt;&quot;알 수 없는 누락된 값&quot;&lt;/b&gt;을 의미하며 다른 값과 다소 다르게 취급된다.&lt;br /&gt;&lt;br /&gt;&lt;i&gt;- MySQL 8.0 references 중&lt;/i&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 nullable 컬럼에 unique 제약 조건을 추가하는 경우 null 값인 행은 여러 개가 될 수 있다. 기존의 null 값과 새로 추가된 null 값이 같다고 판단할 수 없기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Atomic Query로 동시성 문제를 해결할 수 없으면 상황이 복잡해진다. MySQL의 Named Lock 혹은 Redis 락 등을 사용해야 한다. 고작 카테고리 중복 방지에 이만한 시간을 들여야하나 싶어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;두 종류의 하위 카테고리 - 기본과 커스텀&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하위 카테고리는 특성에 따라 다시 기본과 커스텀 카테고리로 나뉜다. 이를 어플리케이션에서도 각각의 객체(&lt;i&gt;DefaultCategory, BookCategory&lt;/i&gt;)로 구분했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;하지만 대부분의 조회 쿼리에서 그 둘을 구분하지 않고 조회한다. 하위 카테고리를 생성한 이상, 해당 카테고리를 조회할 때는 둘을 구별하는 게 무의미하기 때문이다. 하지만 객체로는 엄연히 구분되어 있고 조건도 다르게 주어야 하므로 쿼리를 두 번 날려야 한다. 불편하다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1709894574426&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public Optional&amp;lt;Category&amp;gt; findFlowLineCategory() {
    // 기본 카테고리 먼저 조회
    Optional&amp;lt;Category&amp;gt; target = Optional.ofNullable(jpaQueryFactory.selectFrom(category)
        .where(
            category.name.eq(name),
            category.parent.eq(parentFlowCategory),
            category.instanceOf(DefaultCategory.class)
        )
        .fetchOne());

    // 기본 카테고리에서 원하는 카테고리를 찾지 못한 경우, 커스텀 카테고리 조회
    if (target.isEmpty()) {
        target = Optional.ofNullable(jpaQueryFactory
            .selectFrom(bookCategory)
            .innerJoin(bookCategory.book, book)
            .where(
                book.bookKey.eq(bookKey),
                bookCategory.name.eq(name),
                bookCategory.parent.eq(parentFlowCategory),
            )
            .fetchOne());
    }
    return target;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;도메인 용어&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;외에도 코드 내에 쓰인 용어들이 헷갈리는 경우가 많았다. 한글로 된 도메인 용어를 영어로 번역하는 과정에서 다른 용어와 겹치는 경우도 있었고, 이름만 봤을 때 무슨 도메인인지 헷갈리는 경우도 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;예를 들어 가계부 내역과 관련된 카테고리를 관리하는 &lt;i&gt;CategoryEnum&lt;/i&gt; 이 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1710132239133&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 한 가계부 내역과 연관된 카테고리 종류
public enum CategoryEnum {

    FLOW(&quot;내역&quot;), 
    ASSET(&quot;자산&quot;), 
    FLOW_LINE(&quot;내역 분류&quot;); 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;여기서는 &lt;i&gt;FLOW&lt;/i&gt; 가 &lt;i&gt;내역&lt;/i&gt;이다. 하지만 가계부 &lt;i&gt;내역&lt;/i&gt;은 &lt;i&gt;Book'Line'&lt;/i&gt; 이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;또 이 객체의 &lt;i&gt;ASSET&lt;/i&gt; 은 자산 분류 항목을 의미한다. 하지만 우리는 가계부 수입 및 지출 내역으로 계산하는 자산 도메인이 따로 있고, DB에서 &lt;i&gt;asset&lt;/i&gt; 테이블에 저장한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이렇게 용어가 헷갈리는 게 개발하면서 생각보다 큰 장애물이 되었다.&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  재설계&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;고려사항&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 겪은 불편함을 바탕으로 생긴 조건은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 하위 카테고리와 커스텀 하위 카테고리는 조회될 때 하나로 취급되어야 한다.&lt;/li&gt;
&lt;li&gt;하나의 테이블로 묶인 카테고리들을 나누어 유니크 인덱스를 추가한다.&lt;/li&gt;
&lt;li&gt;하나의 가계부 내역을 참조하는 세 개의 카테고리 연결 데이터는 하나로 묶는다.&lt;/li&gt;
&lt;li&gt;변경하는 김에 용어들 또한 가독성 좋게 수정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마침 기본 카테고리도 편집하게 해달라는 요청이 많아져 커스텀 카테고리와 똑같이 편집이 가능하도록 기획이  추가되었다. 따라서 첫 번째 조건을 해결할 수 있게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 여러 고민 끝에 어플리케이션에서 카테고리 간의 상속 관계를 없애기로 했다. 우리는 두 번째 조건을 위해 상속 전략 중 &lt;i&gt;TABLE_PER_CLASS&lt;/i&gt; 를 사용하고 싶었다. 하지만 이 전략과 식별자(id) 생성 방식 중&amp;nbsp;&lt;i&gt;IDENTITY&lt;/i&gt; 를 같이 사용하면 오류가 발생한다(&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://docs.jboss.org/hibernate/stable/annotations/reference/en/html/entity.html#d0e1191&quot;&gt;참고&lt;/a&gt;). 논리적으로도 굳이 각 엔티티들이 묶여 있을 필요가 없다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;최종 설계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고민 끝에 다음과 같은 설계를 완성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 카테고리&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;362&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cc59NH/btsFGEve1jz/58WnHKbLOAjZg7dKOK1wK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cc59NH/btsFGEve1jz/58WnHKbLOAjZg7dKOK1wK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cc59NH/btsFGEve1jz/58WnHKbLOAjZg7dKOK1wK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcc59NH%2FbtsFGEve1jz%2F58WnHKbLOAjZg7dKOK1wK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1532&quot; height=&quot;362&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;362&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Category 테이블: 상위 카테고리 (지출/수입/이체/자산)&lt;/li&gt;
&lt;li&gt;Subcategory 테이블: 하위 카테고리&lt;/li&gt;
&lt;li&gt;DefaultSubcategory 테이블: 기본 하위 카테고리 &lt;br /&gt;(가계부를 생성할 때만 해당 테이블의 데이터를 불러와 Subcategory 데이터로 저장한다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 가계부 내역-카테고리&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1222&quot; data-origin-height=&quot;402&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HuX1q/btsFGCEdO1d/KZ71AWJTHPkngGkyzkhAu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HuX1q/btsFGCEdO1d/KZ71AWJTHPkngGkyzkhAu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HuX1q/btsFGCEdO1d/KZ71AWJTHPkngGkyzkhAu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHuX1q%2FbtsFGCEdO1d%2FKZ71AWJTHPkngGkyzkhAu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;237&quot; data-origin-width=&quot;1222&quot; data-origin-height=&quot;402&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 개의 카테고리를 세 개의 행으로 저장하지 않고, 세 개의 column 을 통해 하나의 행으로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  테스트 없는 리팩토링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계는 완성했다. 이제 구현이 필요하다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기존 상황&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안타깝게도 출시 일정에 치여 개발하느라 테스트가 매우 부족했다. 더 안타까운 점은 그 적은 테스트는 모두 단위 테스트라, 객체 자체가 변하면서 단위 테스트들이 모두 사라져야했다. 그래서 테스트가 없는 리팩토링을 진행해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 가계부 내역과 카테고리는 매우 비중이 높은 하위 도메인이라 프로덕션 코드의 대부분이 바뀌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 리팩토링 중 혹여나 잘못 변경한 코드가 있을 지 매우 불안했다. 이미 테스트 없이 급하게 오류를 수정하다가 밤을 새며 운영 데이터를 수정한 전적이 있기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1798&quot; data-origin-height=&quot;452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zHCJZ/btsFDAA9czt/1QddgDKNFHNLZViGp6fYck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zHCJZ/btsFDAA9czt/1QddgDKNFHNLZViGp6fYck/img.png&quot; data-alt=&quot;가계부 200개의 내역들을 불러와 엑셀 함수로 일일히 계산하고 확인한 눈물의 흔적&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zHCJZ/btsFDAA9czt/1QddgDKNFHNLZViGp6fYck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzHCJZ%2FbtsFDAA9czt%2F1QddgDKNFHNLZViGp6fYck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1798&quot; height=&quot;452&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1798&quot; data-origin-height=&quot;452&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;가계부 200개의 내역들을 불러와 엑셀 함수로 일일히 계산하고 확인한 눈물의 흔적&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 시간이 걸리더라도 테스트를 최대한 추가하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DCI 패턴 도입&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 테스트 코드를 작성하면서 불편했던 점은, 하나의 기능에 대해 여러 성공 및 실패 테스트가 존재하는 데 이를 확인하기 쉽지 않았다. 일렬로 나열된 테스트 결과창을 보고 어떤 테스트를 빼먹었는지 혹은 어떤 테스트가 중복되었는지 바로 파악할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 구조화 된 테스트 코드를 위해 DCI 패턴(&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://johngrib.github.io/wiki/junit5-nested/&quot;&gt;참고&lt;/a&gt;)을 제안해 도입했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패턴에 맞추어 코드를 작성하면서 코드 양이 늘어난다는 단점이 있지만, class 단위로 테스트 대상(Describe) 및 환경(Context)을 분리하니 케이스 구별이 쉬워 만족스럽다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cb0rsy/btsFFPj0xIO/KUSPC3H6Krt1O8wGHbaweK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cb0rsy/btsFFPj0xIO/KUSPC3H6Krt1O8wGHbaweK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cb0rsy/btsFFPj0xIO/KUSPC3H6Krt1O8wGHbaweK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcb0rsy%2FbtsFFPj0xIO%2FKUSPC3H6Krt1O8wGHbaweK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;286&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다도 실행 창이 매우 아름답다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;새로운 팀원 영입&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DCI 패턴과 별개로 추가해야 할 테스트가 많았다. 하지만 새로 구현해야 할 기능도 있어 인력이 부족하다고 판단했다. 그래서 운영팀에게 서버 개발자 충원을 요청했고, 한 분이 새로 들어와서 같이 200여 개의 테스트를 추가했다. 지금도 추가하는 중이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;☠️ 데이터 마이그레이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 설계를 바탕으로 코드 작성도 끝났다. 이제 DB에 저장된 기존 데이터들을 새 테이블로 알맞게 이동하면 된다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 상황&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상하긴 했지만 간단한 작업이 아니었다. &lt;b&gt;id가 1&lt;/b&gt;인 한 가계부 내역이 있다고 가정하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 기존 카테고리 테이블 (&lt;i&gt;old_category&lt;/i&gt;)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 119px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;dType (종류)&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;parent_id (상위 카테고리)&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;book_id (연결 가계부)&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;name (이름)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;DefaultRoot&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;수입&lt;/i&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;DefaultRoot&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;지출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;DefaultRoot&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;이체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;DefaultRoot&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;자산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;5&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;Default&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;null&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;월급&lt;/i&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 11.5116%; height: 17px;&quot;&gt;6&lt;/td&gt;
&lt;td style=&quot;width: 19.4186%; height: 17px;&quot;&gt;Book&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;width: 23.7209%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 19.7675%; height: 17px;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;새 자산 분류&lt;/i&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 기존 가계부 내역-카테고리 연결 테이블 (&lt;i&gt;old_book_line_category&lt;/i&gt;)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 15.6976%; height: 19px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 22.2094%; height: 19px;&quot;&gt;book_id (연결 가계부)&lt;/td&gt;
&lt;td style=&quot;width: 27.9069%; height: 19px;&quot;&gt;category_id (연결 카테고리)&lt;/td&gt;
&lt;td style=&quot;width: 34.1861%; height: 19px;&quot;&gt;category_type (카테고리 종류)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 15.6976%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 22.2094%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.9069%; height: 19px;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;1&lt;/i&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.1861%; height: 19px;&quot;&gt;FLOW (지출/수입/이체)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 15.6976%; height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 22.2094%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.9069%; height: 19px;&quot;&gt;&lt;b&gt;&lt;i&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;5&lt;/span&gt;&lt;/i&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.1861%; height: 19px;&quot;&gt;ASSET (자산 분류)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 15.6976%; height: 19px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 22.2094%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.9069%; height: 19px;&quot;&gt;&lt;b&gt;&lt;i&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;6&lt;/span&gt;&lt;/i&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.1861%; height: 19px;&quot;&gt;FLOW_LINE (지/수/이 분류)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상황에서&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;세 개의 행으로 쪼개져있던&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;i&gt;가계부 내역-카테고리 연결&lt;/i&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;데이터를&lt;/span&gt;&amp;nbsp;하나의 행으로 합쳐야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Q. 연결된 카테고리 정보를(&lt;i&gt;category_id&lt;/i&gt; = 1, 5, 6)을 새로운 행의 각 컬럼에 넣어주면 되는 거 아닌가요?&lt;br /&gt;A.  그거 아니야...&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 새 상위 카테고리 테이블 (&lt;i&gt;category&lt;/i&gt;)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 32.4419%; height: 93px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 30.7322%; height: 17px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 69.2678%; height: 17px;&quot;&gt;name (이름)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 30.7322%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 69.2678%; height: 19px;&quot;&gt;&lt;b&gt;&lt;i&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;수입&lt;/span&gt;&lt;/i&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 30.7322%; height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 69.2678%; height: 19px;&quot;&gt;지출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 30.7322%; height: 19px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 69.2678%; height: 19px;&quot;&gt;이체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 30.7322%; height: 19px;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;width: 69.2678%; height: 19px;&quot;&gt;자산&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 새 하위 카테고리 테이블 (&lt;i&gt;subcategory&lt;/i&gt;)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 53px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 14.7675%; height: 19px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 32.7906%; height: 19px;&quot;&gt;parent_id (상위 카테고리)&lt;/td&gt;
&lt;td style=&quot;width: 27.4419%; height: 19px;&quot;&gt;book_id (연결 가계부)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;name (이름)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 14.7675%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 32.7906%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 27.4419%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;월급&lt;/i&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 14.7675%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 32.7906%; height: 17px;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;width: 27.4419%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;새 자산 분류&lt;/i&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 새 가계부 내역-카테고리 연결 테이블 (&lt;i&gt;book_line_category&lt;/i&gt;)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 34px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 3.95351%; height: 17px;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;width: 28.8371%; height: 17px; text-align: center;&quot;&gt;flow_category_id &lt;br /&gt;(상위 카테고리)&lt;/td&gt;
&lt;td style=&quot;width: 33.8372%; height: 17px; text-align: center;&quot;&gt;asset_category_id &lt;br /&gt;(자산 하위 카테고리)&lt;/td&gt;
&lt;td style=&quot;width: 33.3721%; height: 17px; text-align: center;&quot;&gt;flow_line_category_id &lt;br /&gt;(하위 카테고리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 3.95351%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 28.8371%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;&lt;b&gt;1&lt;/b&gt;&lt;/i&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.8372%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;&lt;b&gt;2&lt;/b&gt;&lt;/i&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3721%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;i&gt;&lt;b&gt;1&lt;/b&gt;&lt;/i&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 하나만 생성되던 기본 카테고리가 각 가계부 마다 생성되도록 변경되며 카테고리 id도 바뀌었다. 즉 &lt;b&gt;기존 카테고리의 정보로 새 카테고리 테이블에서 id 를 찾아야 하는 것&lt;/b&gt;이다. 다행인 점은 카테고리에서 (이름, 상위 카테고리, 가계부) 조합이 유일해야 하므로 이 세 가지를 이용해 id를 찾으면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;마이그레이션 스크립트 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션 조건이 복잡하기 때문에 처음에는 이를 담당하는 어플리케이션을 생성하려고 했다. 하지만 새 어플리케이션을 생성하기 위해 작성해야 할 코드가 길고, 이미 운영 중인 서비스기 때문에 마이그레이션 시간을 최소화하고 싶었다. 추가로 현재 데이터베이스 스키마를 flyway 를 통해 관리하고 있어 마이그레이션 sql 스크립트를 작성하기로 결심했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 flyway는 스크립트를 버전 별로 실행하고, 각 스크립트 마다 트랜잭션이 적용된다. 중간에 에러가 발생하면 이후 스크립트는 실행하지 않는다. 이를 이용해 다음과 같이 스크립트를 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 스크립트 1 - 테이블 생성&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기존 &lt;i&gt;category&lt;/i&gt; &amp;amp; &lt;i&gt;book_line_category&lt;/i&gt; 테이블 이름 변경 (새로운 테이블과 이름이 겹친다.)&lt;/li&gt;
&lt;li&gt;새 &lt;i&gt;category&lt;/i&gt; &amp;amp; &lt;i&gt;subcategory&lt;/i&gt; &amp;amp; &lt;i&gt;book_line_category&lt;/i&gt; 테이블 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 스크립트 2 - 데이터 이동&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;새 &lt;i&gt;category&lt;/i&gt; 테이블에 지출/수입/이체/자산 데이터 생성&lt;/li&gt;
&lt;li&gt;새 &lt;i&gt;subcategory&lt;/i&gt; 테이블에 기본 하위 카테고리를 가계부 별로 생성&lt;/li&gt;
&lt;li&gt;기존 &lt;i&gt;category&lt;/i&gt; 테이블에서 새 &lt;i&gt;subcategory&lt;/i&gt; 테이블로 커스텀 하위 카테고리를 이동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기존 &lt;i&gt;book_line_category&lt;/i&gt; 테이블의 데이터들을 새 &lt;i&gt;book_line_category&lt;/i&gt; 테이블로 이동&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;기존&amp;nbsp;테이블 삭제&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 &lt;i&gt;book_line_category&lt;/i&gt; 마이그레이션 쿼리는 복잡하기 때문에 제대로 동작할 지 확신이 없었다.&lt;/p&gt;
&lt;pre id=&quot;code_1709986490981&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 스포일러: 실패할 쿼리이므로 따로 설명을 달지 않았다
INSERT INTO `book_line_category`(book_line_id,
                                 line_category_id,
                                 line_subcategory_id,
                                 asset_subcategory_id,
                                 created_at)
    (SELECT oblc.book_line_id,
            linesub.parent_id,
            linesub.id,
            assetsub.id,
            oblc.created_at

     FROM `subcategory` linesub
              INNER JOIN `old_category` old_linesub
                         ON old_linesub.name = linesub.name
                             AND
                            old_linesub.book_id = linesub.book_id
                             AND
                            old_linesub.parent_id = linesub.parent_id
                            
              INNER JOIN `old_book_line_category` oblc
                         ON oblc.category_id = old_linesub.id
                         
              INNER JOIN `old_book_line_category` oblc_asset
                         ON oblc_asset.book_line_id = oblc.book_line_id
                         
              INNER JOIN `old_category` old_assetsub
                         ON old_assetsub.id = oblc_asset.category_id
                         
              INNER JOIN `subcategory` assetsub
                         ON assetsub.name = old_assetsub.name
                             AND
                            assetsub.book_id = old_assetsub.book_id
                             AND assetsub.parent_id =
                                 old_assetsub.parent_id
                                 
     WHERE old_linesub.dtype = 'Book'
       AND old_linesub.parent_id &amp;lt; 4
       AND old_assetsub.dtype = 'Book'
       AND old_assetsub.parent_id = 4);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패에 대비해 스크립트가 실행되기 직전 DB를 백업했다. RDS를 사용하고 있어서 제공하는 백업 기능을 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;마이그레이션 실패와 재시도&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 팀원들에게 개발 앱이 잠깐 제대로 작동하지 않을 수 있다고 알리고 스크립트를 실행했다. flyway 로그를 살피니 다행히 중간에 에러가 나지는 않았다. 그래서 성공했나? 싶었는데 문제의 쿼리가 잘못 작성되어 &lt;i&gt;book_line_category &lt;/i&gt;데이터가&amp;nbsp;제대로 이전되지 않았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;732&quot; data-origin-height=&quot;86&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/t7OLi/btsFGjkS5BW/AzZsaj1vN44CmFLIWZA4Sk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/t7OLi/btsFGjkS5BW/AzZsaj1vN44CmFLIWZA4Sk/img.png&quot; data-alt=&quot;어디로 갔니...&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/t7OLi/btsFGjkS5BW/AzZsaj1vN44CmFLIWZA4Sk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ft7OLi%2FbtsFGjkS5BW%2FAzZsaj1vN44CmFLIWZA4Sk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;42&quot; data-origin-width=&quot;732&quot; data-origin-height=&quot;86&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;어디로 갔니...&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백업한 DB에서 데이터만 따와(&lt;i&gt;mysqldump&lt;/i&gt;&amp;nbsp;이용) 로컬에서 다시 테스트해보며 새 쿼리를 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1709987492353&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;insert into `book_line_category` (book_line_id,
                                  line_category_id,
                                  line_subcategory_id,
                                  asset_subcategory_id,
                                  created_at)
    (select bl.id,
            line_sub.parent_id,
            line_sub.id,
            asset_sub.id,
            old_line_sub_blc.created_at

     from old_book_line_category old_line_blc

              -- 3개의 행으로 나뉜 기존 book_line_category를 하나의 행으로 JOIN
              inner join old_book_line_category old_line_sub_blc
                         on old_line_sub_blc.book_line_id = old_line_blc.book_line_id
              inner join old_book_line_category old_asset_sub_blc
                         on old_asset_sub_blc.book_line_id = old_line_sub_blc.book_line_id

         -- book의 id를 찾기 위한 JOIN
              inner join book_line bl
                         on bl.id = old_line_blc.book_line_id

         -- 기존 category의 id를 찾기 위한 JOIN
              inner join old_category old_line_sub
                         on old_line_sub.id = old_line_sub_blc.category_id
              inner join old_category old_asset_sub
                         on old_asset_sub.id = old_asset_sub_blc.category_id

         -- (이름, 속한 가계부, 부모 카테고리)를 바탕으로 새로운 category의 id 를 찾기 위한 JOIN
              inner join subcategory line_sub
                         on line_sub.name = old_line_sub_blc.name
                             and line_sub.parent_id = old_line_blc.category_id
                             and line_sub.book_id = bl.book_id
              inner join subcategory asset_sub
                         on asset_sub.name = old_asset_sub_blc.name
                             and asset_sub.parent_id = 4
                             and asset_sub.book_id = bl.book_id

          -- 3개의 행으로 나뉜 기존 book_line_category를 하나의 행으로 JOIN 하는 조건
     where old_line_blc.book_line_categories_key = 'FLOW'
       and old_line_sub_blc.book_line_categories_key = 'FLOW_LINE'
       and old_asset_sub_blc.book_line_categories_key = 'ASSET');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 새 쿼리는 잘 동작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  소감&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버팀의 큰 이슈를 드디어 끝냈다. 코드만 해도 최소 9천줄이 변경된 대공사였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2502&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfT2F8/btsFFRJmxgw/7SK7d70Xcd2VKWn75MziVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfT2F8/btsFFRJmxgw/7SK7d70Xcd2VKWn75MziVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfT2F8/btsFFRJmxgw/7SK7d70Xcd2VKWn75MziVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfT2F8%2FbtsFFRJmxgw%2F7SK7d70Xcd2VKWn75MziVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2502&quot; height=&quot;432&quot; data-origin-width=&quot;2502&quot; data-origin-height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;최선의 설계는 어떻게 할까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 레거시로 취급하고 변경한 이전 설계는 그 당시에선 최선으로 여겼던 설계다. 그렇다면 지금 설계도 미래에 레거시가 되지 않을까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘은 장애 상황이 일어나지 않도록 100% 예방하는 건 불가능하니, 장애 상황이 일어났을 때 빠르게 복구되도록 서비스를 개발한다고 한다. 도메인이 어떻게 변경될 지 100% 예측하는 건 불가능하니 지금의 설계도 추후 레거시가 될 가능성이 있다. 그렇다면 그 때 최대한 안정적으로 변경할 수 있도록 대비하면 되지 않을까? 대비책으로는 아직 인수 테스트 추가만 생각나는데(개발 외에는 QA 시나리오 정리?)... 아직은 잘 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;레거시는 빨리 치우자&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이번에는 알고도 일정에 맞추느라 못 치웠지만... 역시 개발하면서 걸리는 요소는 최대한 빨리 해결해야겠다. 변경할 코드가 많아지기도 하지만 서비스를 운영하니 손댈 데이터도 많아진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/34</guid>
      <comments>https://sechoi.tistory.com/34#entry34comment</comments>
      <pubDate>Mon, 11 Mar 2024 15:09:01 +0900</pubDate>
    </item>
    <item>
      <title>[Unicode] Unicode의 Code Point와 문자열의 길이</title>
      <link>https://sechoi.tistory.com/33</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;utf8mb4을 사용하는 MySQL에서 한 테이블에 자료형이 &lt;b&gt;varchar(1)&lt;/b&gt;인 'name' 컬럼이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 테이블에 다음과 같은 쿼리를 실행하면 결과는 어떻게 될까?&lt;/p&gt;
&lt;pre id=&quot;code_1704869860971&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;insert into test(name) values('✅');
insert into test(name) values(' ');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정답&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째 쿼리는 성공하고, 두번째 쿼리는 &quot;Data too long for column name 'name'&quot;으로 실패한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 이모티콘이고 varchar 자료형을 사용했음에도 왜 다른 길이로 인식된 걸까?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;66&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KWGAv/btsDj1mo3gn/Bntt4kS3ik6wOlq9560xP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KWGAv/btsDj1mo3gn/Bntt4kS3ik6wOlq9560xP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KWGAv/btsDj1mo3gn/Bntt4kS3ik6wOlq9560xP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKWGAv%2FbtsDj1mo3gn%2FBntt4kS3ik6wOlq9560xP0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1792&quot; height=&quot;66&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;66&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;009&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/009.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/009.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Unicode (유니코드)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유니코드(The Unicode Standard)는 전 세계의 모든 문자를 컴퓨터에서 일관되게 표현하고 다룰 수 있도록 설계된 산업 표준이다. 한글, 한자, 영어 등의 언어부터 이모티콘까지 다양한 문자들이 유니코드로 표시된다. 예로 &quot;✅&quot; 이모티콘은 유니코드 &quot;U+2705&quot;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 유니코드를 컴퓨터가 이해할 수 있게 인코딩하는 방식 중 하나가 바로 널리 쓰이는 UTF-8 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Code Point&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 유니코드에서 &lt;b&gt;하나의 의미를 가지는 연속된 bit의 모음&lt;/b&gt;을 코드 포인트라고 한다. 하나의 코드 포인트는 인코딩 방식에 따라 1~4byte의 크기를 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시로 &quot;✅&quot; 이모티콘은 유니코드 &quot;U+2705&quot; 이고, 이를 2진수로 나타내면 &quot;1010 1001 0001(2)&quot; 다. 그리고 해당 이진수를 UTF-8로 인코딩하면 3byte의 크기를 가진다.&lt;/p&gt;
&lt;pre id=&quot;code_1704889076404&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final byte[] bytes = &quot;✅&quot;.getBytes(StandardCharsets.UTF_8);
System.out.println(bytes.length); // 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고&lt;b&gt; MySQL의 char_length() 메서드는 이 코드 포인트를 기준으로 글자 수를 센다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/string-functions.html#function_char-length&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev.mysql.com/doc/refman/8.0/en/string-functions.html#function_char-length&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;CHAR_LENGTH(&lt;i&gt;str&lt;/i&gt;):&lt;br /&gt;Returns the length of the string &lt;i&gt;str&lt;/i&gt;, &lt;b&gt;measured in code points&lt;/b&gt;. A multibyte character counts as a single code point.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 MySQL 만이 아니다. 유니코드를 지원하는 자바나 파이썬 또한 코드 포인트를 기준으로 글자 수를 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유니코드를 인식하지 못하는 언어에서는 다른 기준으로 글자 수를 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중 C에서는 &lt;b&gt;byte 단위&lt;/b&gt;로 글자 수를 계산하므로 &quot;✅&quot;의 글자 수는 3이 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1704970438115&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#include &amp;lt;stdio.h&amp;gt;

int main()
{
    char c[] = &quot;✅&quot;;
    int len = 0;
    while (c[len] != '\0') {
        len++;
    }
    printf(&quot;%d&quot;, len); // 3

    return 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;하나의 문자 != 하나의  Code Point&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 코드 포인트는 하나의 문자가 아닌 하나의 '의미'를 가리킨다. 따라서 하나의 문자는 한 개의 코드 포인트를 필수적으로 가지며, 필요에 따라 추가 코드 포인트를 포함한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;❣️&quot; 이모티콘의 코드 포인트 개수를 세면 2개로 나온다.&lt;/p&gt;
&lt;pre id=&quot;code_1704968423256&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;System.out.println(&quot;❣️&quot;.length()); // 2&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 해당 이모티콘의 코드 포인트는 &quot;U+2763, U+FE0F&quot; 이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유니코드 표에서 찾아보면 U+2763은 &quot;❣&quot;이고 U+FE0F는 &quot;Variation Selectors&quot;이다. 즉 U+2763 이모티콘을 변형한 형태 중 하나가 예시인 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 코드 포인트 여러 개가 하나의 문자가 될 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;#️⃣&quot; 이모티콘은 &quot;U+0023, U+FE0F, U+20E3&quot;의 코드 포인트를 가지고 있으며 이는 각각 &quot;#&quot;, &quot;Variation Selectors&quot;, &quot;Combining Enclosing Keycap&quot; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제에서 여러 글자로 인식 된 &quot; &quot; 이모티콘 역시 여러 개의 코드 포인트를 가진다. 따라서 이모티콘이나 특수 문자의 입력을 허용한다면 글자 수 제한을 넉넉하게 해야한다.&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;niniz&quot; data-emoticon-name=&quot;040&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/040.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/040.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;사담&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영하는 서비스에서 사용자가 이름에 이모티콘을 넣다가 에러가 발생한 덕분에 해당 내용에 대해 자세히 알아보게 되었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;525&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOiyXj/btsDiOIUM2t/rXVrBUPzX8M6hZkg65v2m1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOiyXj/btsDiOIUM2t/rXVrBUPzX8M6hZkg65v2m1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOiyXj/btsDiOIUM2t/rXVrBUPzX8M6hZkg65v2m1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOiyXj%2FbtsDiOIUM2t%2FrXVrBUPzX8M6hZkg65v2m1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;420&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;525&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 짤이 생각나는 에러였다. 끝&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://meetup.nhncloud.com/posts/317&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://meetup.nhncloud.com/posts/317&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>etc</category>
      <category>유니코드</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/33</guid>
      <comments>https://sechoi.tistory.com/33#entry33comment</comments>
      <pubDate>Thu, 11 Jan 2024 19:55:01 +0900</pubDate>
    </item>
    <item>
      <title>[2023년 하반기] preschooler 개발자의 6개월</title>
      <link>https://sechoi.tistory.com/32</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfGM1H/btsC4akG3eh/f9dQMApDQGkChOqIw9SSMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfGM1H/btsC4akG3eh/f9dQMApDQGkChOqIw9SSMk/img.png&quot; data-alt=&quot;제목의 의미&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfGM1H/btsC4akG3eh/f9dQMApDQGkChOqIw9SSMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfGM1H%2FbtsC4akG3eh%2Ff9dQMApDQGkChOqIw9SSMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;303&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;680&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;제목의 의미&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;junior 개발자가 되는 그 날까지...  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;우아한테크캠프&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 달짜리 짧은 과정이었지만 끝난 이후 반 년이 지난 지금까지도 많은 영향을 주고 있다. (배민 앱 삭제... 라던가? ㅋ)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캠프에 지원하면서 가졌던 목표는 크게 두 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발에 열정적인 동료들을 얻는 것이 첫번째였고, 우물 밖 개구리가 되는 게 두번째였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 모두 다 이루었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD, OOP, MySQL, JPA 등 다양한 분야에 대해 접하고 기술적으로 크게 성장할 수 있었다. 캠프 전과 비교했을 때 우물 밖 개구리가 되었다고 확실하게 말할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 가장 큰 업적(?)은 열정적인 동기들을 얻은 것이다. 하루 12시간 동안 개발을 하면서도 밥 먹을 때까지 코드에 대해 토론하는 동기들을 보며 가끔은 체할 것 같았지만 ㅎㅎ 많은 자극을 얻었다. 동기들의 개발 경험이나 지식 수준은 다양했지만 모두와 개발할 때 즐거웠다. 나와 반대되는 의견을 제시해도 합당한 근거가 있기 때문에 토론이 짜증나지 않았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4Zf3g/btsDdH90c6o/LWjVVk2skatP5XJnArLYv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4Zf3g/btsDdH90c6o/LWjVVk2skatP5XJnArLYv1/img.png&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot; data-is-animation=&quot;false&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;blob&quot; id=&quot;kEditorPhotosEditingImage-1&quot; data-widthpercent=&quot;49.62&quot; style=&quot;width: 49.0403%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4Zf3g/btsDdH90c6o/LWjVVk2skatP5XJnArLYv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4Zf3g%2FbtsDdH90c6o%2FLWjVVk2skatP5XJnArLYv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dheM5G/btsC8QUmyUC/03vtTwSjKhulM1H4gC3ZGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dheM5G/btsC8QUmyUC/03vtTwSjKhulM1H4gC3ZGK/img.png&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1426&quot; data-is-animation=&quot;false&quot; id=&quot;kEditorPhotosEditingImage-2&quot; width=&quot;600&quot; height=&quot;591&quot; style=&quot;width: 49.7969%;&quot; data-widthpercent=&quot;50.38&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dheM5G/btsC8QUmyUC/03vtTwSjKhulM1H4gC3ZGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdheM5G%2FbtsC8QUmyUC%2F03vtTwSjKhulM1H4gC3ZGK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1448&quot; height=&quot;1426&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;스터디카페의 일상...&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 일부와는 아직도 연락하고 있다. 알고리즘 등 여러 스터디를 진행하고, 열품타 앱으로 공부시간을 측정하며 스터디카페에서 함께 개발하기도 한다. 특히 스터디카페에서 만나는 동기들은 가족보다 많이 붙어있는 것 같다. (취업해서 가끔만 보고 싶다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Floney&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://github.com/Floney-2023&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/Floney-2023&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;올해 초에 시작했던 프로젝트인데, 대략 반 년동안의 여정 끝에 앱스토어에 출시가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'실제 사용자가 있는 서비스 개발'을 목표로 여러 커뮤니티 등을 돌며 겨우 참여해 시작 전부터 우여곡절이 많았던 프로젝트였는데, 시작하고나니 비교도 안 될 정도의 우여곡절이 많았다. 기술적인 내용보다는 비기술적 요소인 협업 및 소프트스킬과 관련해 많은 걸 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;도메인 주도 설계 첫걸음&amp;gt; 에서 저자는 '전략적 설계' 파트를 통해 &quot;어떤 소프트웨어를 만들고, 왜 그 소프트웨어를 만드는지&quot; 분석하는 방법을 알려준다. 도메인을 분석하고 사용하는 단어(유비쿼터스 언어)를 정하는 등, 프로그래밍과 무관한 내용이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 이 분석이 왜 필요한지 플로니를 개발하면서 알 수 있었다. 도메인 전문가는 도메인을 완전히 알지 못할 수 있다. 기획자가 따로 있어도 내가 모델 설계에 참여할 수 있는 것이다. 이 때 설계해야 하는 모델이 있다는 걸 늦게 알수록(분석을 늦게 할수록) 프로젝트 개발 일정 또한 미뤄진다. 분석이 부족해 잘못된 설계를 한다면 일정은 더 미뤄진다. 그리고 일정을 더 늦추지 않기 위해 성급히 개발을 하면서 악순환이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;천 줄이 넘는 코드를 롤백한 적도 있고 같은 이슈에 대한 PR을 여러 번 올리기도 했다. 이런 악순환을 거치면서 개발한 기간에 비해 결과물은 작아 아쉽다. 출시 전에는 시간에 급급해, 출시 후에는 장애 상황이 우려되어 기술적으로 새로운 시도를 쉽게 못하는 것도 아쉽다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 플로니를 통해 다양한 경험을 해봤다. 사용자 리뷰를 통해 잘못된 로직을 발견해 밤을 새면서 운영 DB의 데이터를 엑셀에 적어가며 확인하기도 했고, 팀원 덕분에 개발바닥 블로그에 우리 서비스가 소개되기도(&lt;a href=&quot;https://jojoldu.tistory.com/763&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;링크&lt;/a&gt;) 했다. (심지어 링크드인에도 올리셨다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 그 동안 싼 똥도 치우고 새로운 기능도 구현하고 장애 대비도 하고 싶은데... 첫번째 작업만 해도 너무 규모가 크다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개백수&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;졸업, 싸피, 우테캠을 거치며 Real 백수가 된 지 벌써 4개월 째다.  고시생처럼 스터디카페 출퇴근을 반복하다보니 취업에 대한 열망이 더 커졌다. 회사 돈으로 밥먹고 싶다... 모아둔 돈이 줄어든다. ㅠㅠ&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;취업을 떠나서 내가 잘 공부하고 있는 지, 제자리걸음을 하고 있지는 않은 지 불안하기도 하다. 근데 몇 달전에 짠 코드를 보거나 모른다고 메모해둔 내용을 보면 잘 공부하고 있는 것 같다. 껄껄&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;24년에는 안정적인 삶을 살면서도 몇 달전의 코드를 여전히 부끄러워하는 개발자가 되길... 다들 새해 복 많이 받으세요 ^_^&lt;/p&gt;</description>
      <category>개발일상/회고</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/32</guid>
      <comments>https://sechoi.tistory.com/32#entry32comment</comments>
      <pubDate>Wed, 10 Jan 2024 02:20:21 +0900</pubDate>
    </item>
    <item>
      <title>[Floney] 자산 데이터 동시성 이슈 해결 삽질기</title>
      <link>https://sechoi.tistory.com/31</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://github.com/Floney-2023&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/Floney-2023&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 가계부에서 자산은 가계부 내역 중 지출(-)과 수입(+)의 합을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 자산이라는 개념은 설계 과정부터 어려움이 있었는데, 하나의 지출 혹은 수입이 생기면 그에 따라 해당 내역 날짜 이후로 매 달의 지출 데이터가 갱신되어야 하기 때문이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 가계부 서비스를 참고해 자산이 갱신되는 특정 기간(5년)을 정해서 가계부 내역 변경 시 매 번 60개(5년 * 12달)의 자산 데이터가 갱신되도록 정하면서 이 문제는 마무리되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 발생 - 의도되지 않은 자산 데이터 추가 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱이 출시된 후, 가계부 내역을 수정하는 과정 중 &lt;b&gt;자산 삭제 메서드&lt;/b&gt;에서 에러가 발생했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvaWpB/btsBKixRsul/TlCZq00mxbgG7nqsbW0Sk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvaWpB/btsBKixRsul/TlCZq00mxbgG7nqsbW0Sk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvaWpB/btsBKixRsul/TlCZq00mxbgG7nqsbW0Sk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvaWpB%2FbtsBKixRsul%2FTlCZq00mxbgG7nqsbW0Sk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;347&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NonUniqueResultException으로 하나의 자산 데이터만 조회하기를 기대했으나 여러 개가 조회되며 에러가 발생한 것이다. 운영 디비에서 실제로 중복 데이터가 있는 것을 확인했고, 자산 관련 코드에서 다양한 정합성 문제가 발생할 수 있음을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기존 상황&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;가계부_내역_삭제.png&quot; data-origin-width=&quot;642&quot; data-origin-height=&quot;182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cClg3L/btsBJg75zPb/LVLZIikHMCjpRemec5k0wk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cClg3L/btsBJg75zPb/LVLZIikHMCjpRemec5k0wk/img.png&quot; data-alt=&quot;자산 데이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cClg3L/btsBJg75zPb/LVLZIikHMCjpRemec5k0wk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcClg3L%2FbtsBJg75zPb%2FLVLZIikHMCjpRemec5k0wk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;642&quot; height=&quot;182&quot; data-filename=&quot;가계부_내역_삭제.png&quot; data-origin-width=&quot;642&quot; data-origin-height=&quot;182&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;자산 데이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2000년 1월 X일의 한 가계부 수입 내역 금액을 1000원에서 500원으로 수정한다고 가정해보자. 이 때 2000년 1월부터 2004년 12월까지 총 60달 동안 포함되어 있던 자산 1000원을 500원으로 수정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드에서는 '가계부 내역 수정'이라는 하나의 트랜잭션에서 자산을 포함한 관련된 모든 데이터의 수정이 이루어지고 있었다. 그리고 자산 수정은 '기존 금액 삭제 &amp;rarr; 변경 금액 추가' 순서로 이루어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;가계부_내역_삭제1.png&quot; data-origin-width=&quot;642&quot; data-origin-height=&quot;182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dSqv0p/btsBQvYns37/wEBvfRe1vlMuW0nwIFnAiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dSqv0p/btsBQvYns37/wEBvfRe1vlMuW0nwIFnAiK/img.png&quot; data-alt=&quot;기존 금액 삭제 후&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dSqv0p/btsBQvYns37/wEBvfRe1vlMuW0nwIFnAiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdSqv0p%2FbtsBQvYns37%2FwEBvfRe1vlMuW0nwIFnAiK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;642&quot; height=&quot;182&quot; data-filename=&quot;가계부_내역_삭제1.png&quot; data-origin-width=&quot;642&quot; data-origin-height=&quot;182&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기존 금액 삭제 후&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;가계부_내역_삭제2.png&quot; data-origin-width=&quot;642&quot; data-origin-height=&quot;182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0Wxcg/btsBR64scVs/HGBlccXcTj3Oe7iOaffnNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0Wxcg/btsBR64scVs/HGBlccXcTj3Oe7iOaffnNK/img.png&quot; data-alt=&quot;변경된 금액 추가 후&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0Wxcg/btsBR64scVs/HGBlccXcTj3Oe7iOaffnNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0Wxcg%2FbtsBR64scVs%2FHGBlccXcTj3Oe7iOaffnNK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;642&quot; height=&quot;182&quot; data-filename=&quot;가계부_내역_삭제2.png&quot; data-origin-width=&quot;642&quot; data-origin-height=&quot;182&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;변경된 금액 추가 후&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;굳이 한 번에 수정하지 않고 삭제와 추가라는 과정을 거치는 이유는, 가계부 내역 수정 시 해당 내역의 날짜도 수정할 수 있기 때문이다. (내가 작성한 코드는 아니지만 그럴 것이다...) JPA의 변경 감지를 이용했기 때문에 쿼리가 2배로 나가지 않으므로 성능 면에서도 괜찮았지만&amp;nbsp; Repeatable Read 격리 수준에서 갱신 손실이 발생할 수 밖에 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이는 NonUniqueResultException이 발생한 실제 원인은 아니다. 가계부 내역을 수정한다는 것은 이미 해당 내역이 저장되었음을 의미하므로 자산 데이터가 생성될 일이 없다.&lt;/p&gt;
&lt;pre id=&quot;code_1702451064223&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 가계부 내역의 money를 자산에 추가
public void createAsset() {

    for (int i = 0; i &amp;lt; FIVE_YEARS; i++) {
        Optional&amp;lt;Asset&amp;gt; asset = assetRepository.find(날짜, 가계부);

        if (asset.isEmpty()) {
            Asset newAsset = new Asset();
            assetRepository.save(newAsset);
        } else {
            asset.get().update();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 원인은 '가계부 내역 추가'에 있다. 이 때만 자산 데이터가 새로 만들어지고 데이터베이스에 insert 되는 상황이 발생할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;자산_데이터_동시성_문제.png&quot; data-origin-width=&quot;390&quot; data-origin-height=&quot;361&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xsyhQ/btsB6lmVK3e/NAtbPrvGtgFh8717xe6fQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xsyhQ/btsB6lmVK3e/NAtbPrvGtgFh8717xe6fQk/img.png&quot; data-alt=&quot;예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xsyhQ/btsB6lmVK3e/NAtbPrvGtgFh8717xe6fQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxsyhQ%2FbtsB6lmVK3e%2FNAtbPrvGtgFh8717xe6fQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;390&quot; height=&quot;361&quot; data-filename=&quot;자산_데이터_동시성_문제.png&quot; data-origin-width=&quot;390&quot; data-origin-height=&quot;361&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예시와 같은 상황에서 중복 생성이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;첫번째 수정 - 비관적 락 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 서버에서 발생하는 hotfix 이슈라, 최대한 코드를 적게 바꾸는 방식으로 오류를 고치고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 생각나는 건 역시나 한 번 써본 비관적 락이었다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1702384147309&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 가계부 내역의 money를 자산에 추가
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createAsset() {

    for (int i = 0; i &amp;lt; FIVE_YEARS; i++) {
        Optional&amp;lt;Asset&amp;gt; asset = assetRepository.findExclusively(); // 비관적 락

        if (asset.isEmpty()) {
            Asset newAsset = new Asset();
            assetRepository.save(newAsset);
        } else {
            asset.get().update();
        }
    }
}

// 가계부 내역의 money를 자산에서 차감
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deleteAsset() {

    BookLine bookLine = bookLineRepository.findExclusively(); // 비관적 락

    for (int i = 0; i &amp;lt; FIVE_YEARS; i++) {
        assetRepository.find().ifPresent(asset -&amp;gt; {
            asset.delete();
        });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 자산 데이터를 조회할 때 비관적 락을 걸었고, 자산 메서드를 호출할 때 새 물리적 트랜잭션을 생성하도록 해 해당 범위에서만 락이 유지되도록 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;눈치챘겠지만 완전히 잘못된 코드이다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션이 분리되어 자산 메서드에 Exception이 발생해도 가계부 내역 메서드가 정상적으로 실행된다.&lt;/li&gt;
&lt;li&gt;자산 데이터가 생성될 때 정합성이 유지되어야 하는데, 락으로는 insert 중복을 막을 수 없다.&lt;/li&gt;
&lt;li&gt;가계부 id와 날짜로 자산 데이터를 조회하는데 가계부 id에만 인덱스가 걸려 있어 해당 가계부 id를 가진 모든 자산 데이터에 락이 전파된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 nGrinder로 성능 테스트를 해보았더니 Vuser가 10명 정도로 매우 적을 때도 DB 커넥션이 모자라다는 에러가 발생했다. DB 커넥션 풀은 기본 설정을 사용하고 있는데 10개의 커넥션과 30초의 커넥션 timeout 시간이 있음에도 발생한 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;두번째 수정 - MySQL 쿼리 이용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째 수정에서 얻은 결론은 아래와 같았다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;1. 하나의 트랜잭션 내에서 insert 중복을 해결한다.&lt;br /&gt;2. 락의 범위를 자산 데이터 한 개로 줄인다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자산 데이터를 조회하는 반복문 내에서 분산 락을 사용해야 하나 고민이 될 때, MySQL의 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;upsert 문법&lt;/a&gt;을 알게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1702547734010&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void createAsset() {

    for (int month = 0; month &amp;lt; SAVED_MONTHS; month++) {
        final LocalDate currentMonth = startMonth.plusMonths(month);
        assetRepository.upsert();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;unique 제약이 걸린 컬럼들을 기준으로, 해당 데이터가 있으면 update 없으면 insert를 하는 문법이다. 이를 사용해 자산 추가 메서드에서 데이터 당 한 개로 쿼리를 줄이고, 락도 사용하지 않을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;upsert 쿼리에 장점만 있는 것은 아니다. 우선 MySQL에서만 지원하는 문법이기 때문에 특정 RDBMS에 대한 의존성이 생긴다. 그리고 JPA EntityListeners 도 사용하지 못해 리스너에서 변경되는 부분을 직접 쿼리에 추가해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 분산 락을 사용하는 대안보다는 공수가 훨씬 적게 들어 선택하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1702548155040&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Modifying
@Query(
        value = &quot;insert into ASSET (date, money, book_id) values (:date, :money, :book) &quot; +
                &quot;on duplicate key update money = money + :money, updated_at = now()&quot;,
        nativeQuery = true
)
void upsertMoneyByDateAndBook(LocalDate date, Book book, double money);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자산을 제외하는 메서드에서도 JPA 변경감지 대신 update 쿼리를 사용해 락 사용 없이 정합성을 유지할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새로운 문제 발생 - SQL 문법 에러&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째 수정을 끝내고 간단한 성능 테스트까지 진행해 동시성 문제가 해결되었음을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 해당 코드를 머지하니 또 에러가 발생했다.  ☠️&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2122&quot; data-origin-height=&quot;828&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bU1nEB/btsCk7ni0of/ymvGxsZTJek1ifdkdX1Tv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bU1nEB/btsCk7ni0of/ymvGxsZTJek1ifdkdX1Tv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bU1nEB/btsCk7ni0of/ymvGxsZTJek1ifdkdX1Tv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbU1nEB%2FbtsCk7ni0of%2FymvGxsZTJek1ifdkdX1Tv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2122&quot; height=&quot;828&quot; data-origin-width=&quot;2122&quot; data-origin-height=&quot;828&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 터진 Exception은 &lt;b&gt;SQLGrammarException&lt;/b&gt;으로 문법이 틀렸다고 해서 더욱 당황스러웠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;수정 - 테이블명 대/소문자 구분&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 문제인지 생각하다가, 로그에서 쿼리가 테이블명만 대문자로 찍혔던 게 떠올랐다. (이게 기억에 남아있던 걸 보면 무의식적으로 거슬렸나보다...)&lt;/p&gt;
&lt;pre id=&quot;code_1703000969528&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Modifying
@Query(
        value = &quot;insert into ASSET (date, money, book_id) values (:date, :money, :book) &quot; +
                &quot;on duplicate key update money = money + :money, updated_at = now()&quot;,
        nativeQuery = true
)
void upsertMoneyByDateAndBook(LocalDate date, Book book, double money);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;upsert 쿼리에서 단순히 가독성을 위해 테이블명만 대문자로 작성했던 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL 공식 문서에서 case sensitivity로 검색했더니 아래와 같은 내용을 찾을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/identifier-case-sensitivity.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev.mysql.com/doc/refman/8.0/en/identifier-case-sensitivity.html&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;How table and database names are stored on disk and used in MySQL is affected by the &lt;i&gt;lower_case_table_names&lt;/i&gt; system variable. This variable does&amp;nbsp;&lt;i&gt;not&lt;/i&gt; affect case sensitivity of trigger identifiers. On Unix, the default value of&amp;nbsp;&lt;i&gt;lower_case_table_names&lt;/i&gt;&amp;nbsp;is 0. On Windows, the default value is 1. On macOS, the default value is 2.&lt;br /&gt;&lt;br /&gt;테이블과 데이터베이스 이름이 디스크에 저장되고 사용되는 방식은 lower_case_table_names 시스템 변수에 의해 좌우된다. 이 변수는 트리거 식별자의 대소문자 구분에 영향을 주지 않는다. Unix에서 기본 값은 0이고, Windows에서는 1이며 macOS에서는 2이다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 운영체제는 macOS 이기 때문에 MySQL 서버에서 소문자로  사용해 오류가 발생하지 않았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 운영 데이터베이스 서버는 RDS에 있고, RDS는 Linux로 unix 계열이다. 따라서 디스크의 저장된 소문자 테이블명과 쿼리의 대문자 테이블명이 다르다고 판단해 오류가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL 서버가 사용 중일 때는 해당 변수 값을 변경할 수 없으므로 코드의 대문자를 수정했다.&lt;/p&gt;
&lt;pre id=&quot;code_1703051842743&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Modifying
@Query(
        value = &quot;insert into asset (date, money, book_id) values (:date, :money, :book) &quot; +
                &quot;on duplicate key update money = money + :money, updated_at = now()&quot;,
        nativeQuery = true
)
void upsertMoneyByDateAndBook(LocalDate date, Book book, double money);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;근본적 원인 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 해결하고 팀원분께 그 과정을 설명하던 중, 다음과 같은 질문을 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그런데 API 실행 시간이 길어야 1초인데, 어떻게 운영 환경에서 동시성 문제가 발생했을까요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문을 받은 당시에는 출시 직후라 이용하는 사람이 많아서 우연히 겹친 것 같다는 식으로 대답했다. 에러는 발생했고, 그에 대한 원인은 명확하니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다 문득 출시 전 했던 QA가 떠올랐다. 한 팀원이 앱에서 '가계부 생성하기' 버튼을 연타해서 API 요청이 여러 번 전송되었고, 결과적으로 똑같은 가계부가 여러 개 생성되어서 앱 개발자분이 연타를 막아주셨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가계부 내역 추가 API를 통해 자산 데이터가 중복 생성된 이유도 여러 명이 동시에 요청해서가 아니라 '한 명이 여러 번 요청해서'가 아닐까 싶다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 현상을 앱에서 막은 이후부터, 수정한 코드가 머지될 때까지 중복 데이터가 발생하지 않았던 것을 생각하면 이쪽이 신빙성이 높다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흔한 동시성 이슈였지만 다양한 내용을 배우고 생각해봐서 의미있던 삽질기였다. ⛏️&lt;/p&gt;</description>
      <category>프로젝트</category>
      <category>floney</category>
      <category>MYSQL</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/31</guid>
      <comments>https://sechoi.tistory.com/31#entry31comment</comments>
      <pubDate>Thu, 14 Dec 2023 19:46:55 +0900</pubDate>
    </item>
    <item>
      <title>[OOP] 'The Single Responsibility Principle' by Robert C. Martin 번역</title>
      <link>https://sechoi.tistory.com/30</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;원글: &lt;a href=&quot;https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  번역이 틀릴 수 있으며, 자의적으로 불필요하게 여겨지는 부분은 제외했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;본문&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1972년, David L. Parnas는 &amp;lt;On the Criteria To Be Used in Decomposing Systems into Modules&amp;gt; 논문을 출간했다. 논문의 마지막에서 그는 다음과 같은 결론을 내린다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;우리는 플로우차트를 기반으로 시스템을 모듈로 분해하는 과정을 시작하는 것이 잘못되었다고 주장해왔다. 대신 어렵거나 &lt;i&gt;변경될 가능성이 있는&lt;/i&gt; 설계 결정 목록으로부터 시작할 것을 제안한다. 각 모듈은 다른 모듈로부터 그러한 결정을 숨기도록 설계되어야 한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 마지막 두 문장에 특히 공감한다. Parnas는 모듈이 변경 가능성을 기반으로 분리되어야 한다고 말한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2년이 지나고, Edsger Dijkstra는 &amp;lt;On the role of scientific thought&amp;gt; 논문을 썼으며 그 안에서 &lt;i&gt;관심사의 분리&lt;/i&gt;라는 용어를 소개한다. 1970년대 및 80년대에는 소프트웨어 아키텍처의 원칙에 대해 활발하게 의논되던 시기이다. &lt;i&gt;구조적 프로그래밍과 설계&lt;/i&gt;는 엄청난 인기를 끌었다. 그 당시 &lt;i&gt;결합도와 응집도&lt;/i&gt;이라는 개념은 Larry Constantine에 의해 소개되었고, 많은 다른 개발자들에 의해 발전되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1990년대 후반에, 나는 이러한 개념들을 하나의 원칙으로 통합하고 싶었다. 이것이 The Single Responsibility Principle(SRP, 단일 책임 원칙)이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SRP는 소프트웨어 모듈이 각각 &lt;b&gt;오직 하나의 변경해야할 이유&lt;/b&gt;를 가져야 한다고 말한다. 이것은 Parnas의 주장과도 맞물리는 좋은 원칙으로 보이나, 하나의 의문을 야기한다. &quot;무엇이 변경의 이유를 정의하는가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누군가는 버그 수정이 변경의 이유로 간주되는지 궁금해한다. 다른 누군가는 리팩토링이 변경의 이유가 되는 지 궁금해한다. 이는 &quot;변경할 이유&quot;와 &quot;책임&quot;을 묶어 설명할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확실히, 코드는 버그 수정이나 리팩토링에 대한 책임을 지지 않는다. 그것들은 프로그램이 아닌 개발자의 책임이다. 그러면 이러한 상황에서 프로그램이 가진 책임은 무엇인가? 다시 얘기하자면, &quot;프로그램의 설계는 누구에게 응답해야 하는가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 비즈니스 조직을 떠올려보자. 꼭대기에는 CEO가 있다. CEO에게 보고하는 것은 C-레벨의 임원들(CFO, COO, CTO...)이다. CFO는 회사의 재정을 조절해야 할 책임이 있다. COO는 회사의 운영을 관리해야 할 책임이 있다. 그리고 CTO는 회사의 기술적 인프라와 개발에 책임이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상황을 자바 코드로 나타내보자.&lt;/p&gt;
&lt;pre id=&quot;code_1699535508787&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Employee {
    public Money calculatePay();
    public void save();
    public String reportHours();
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;calculatePay 메서드는 특정 직원의 급여를 얼마나 지급할지 그 사람의 계약, 상태, 근무 시간 등에 따라 결정하는 알고리즘을 구현한다.&lt;/li&gt;
&lt;li&gt;save 메서드는 Employee 객체에 의해 관리되는 데이터를 회사의 데이터베이스에 저장한다.&lt;/li&gt;
&lt;li&gt;reportHours 메서드는 직원들이 적절한 근무 시간 동안 일하고 보상 또한 적절하게 받는지 확인하기 위해 사용하는 보고서에 추가될 문자열을 반환한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CEO에게 보고하는 C-레벨의 임원들 중 누가 calculatePay 메서드의 행동을 명시할 책임이 있는가? 이 메서드가 심각하게 잘못 명시되어 있으면 해고될 임원이 누구인가? 당연히 CFO이다. 회사 직원들의 급여를 명시하는 것은 금융적인 책임이다. 만약 모든 직원들이 CFO 부서에 속한 누군가가 calculatePay 메서드에 잘못 명시한 규칙으로 인해 두 배의 연봉을 받았다면, CFO는 해고될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 C-레벨 임원은 reportHours 메서드에서 반환되는 문자열의 내용과 형식을 명시할 책임이 있다. 해당 임원은 감사원과 검토자를 관리하며, 이것은 운영 책임이다. 따라서 보고서에 심각하게 잘못 명시된 내용이 있다면 COO는 해고될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 save 메서드에 심각하게 잘못된 내용이 있으면 어떠한 C-레벨 임원이 해고될 지는 명백하다. 만약 기업 데이터베이스가 끔찍하게도 이 때문에 손상된다면 CTO는 해고될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 calcuatePay 메서드 내부의 알고리즘이 변경된다면, 그런 변경에 대한 요청은 CFO가 이끄는 부서에서 일어날 것이다. COO와 CTO가 이끄는 부서에서도 비슷하게 reportHours와 save 메서드의 변경에 대한 요청이 일어날 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 SRP의 핵심으로 다가간다. &lt;i&gt;이 원칙은 사람에 관한 것이다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당신은 소프트웨어 모듈을 개발할 때 변화가 일어나면 그 변화는 오직 하나의 사람, 혹은 하나의 강하게 결합된 조직에서만 일어나기를 원한다. 당신은 모듈을 조직 전체의 복잡성에서 분리시키고, 각자의 모듈이 그저 하나의 비즈니스 기능에 대한 요구에만 책임을 지도록 시스템을 설계하기를 원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐고? 우리는 CTO가 요구한 변경 사항 때문에 COO가 해고되는 일을 막고 싶기 때문이다. 우리의 고객과 매니저들 관점에서, 그들이 요청한 변경 사항과는 완전히 관계 없는 곳에서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;프로그램이 고장났다는 걸 발견하는 것보다 무서운 게 없다. 만약 당신이 calculatePay 메서드를 변경하면서 우연히 reportHours 메서드를 망가트린다면, COO는 당신에게 절대로 calculatePay 메서드를 다시는 변경하지 말 것을 요구할 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 우리가 JSP 파일에 SQL을 넣지 않는 이유다. 결과를 계산하는 모듈에 HTML 코드를 작성하지 않는 이유며, 비즈니스 규칙이 데이터베이스 스키마를 몰라야 하는 이유다. 이것이 우리가 &lt;b&gt;관심사를 분리&lt;/b&gt;하는 이유다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SRP를 다르게 정의하자면 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;같은 이유로 변경되는 것들을 하나로 모아라. 다른 이유로 변경될 것들을 나누어라.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당신은 이제 SRP가 그저 &lt;b&gt;응집도과 결합도&lt;/b&gt;를 정의하는 다른 방법이라는 것을 깨달았을 것이다. 우리는 같은 이유로 인해 변경되는 것들 사이의 응집도를 높이고 싶어하며, 다른 이유로 변경되는 것들 사이의 결합도는 낮추고 싶어한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 당신이 이 원칙에 대해 생각할 때, 변경되는 이유는 &lt;i&gt;사람&lt;/i&gt;이라는 걸 기억해라. 변경을 요청하는 건 &lt;i&gt;사람&lt;/i&gt;이다. 그리고 당신은 많은 사람들이 다른 이유들로 관심을 갖는 코드를 하나로 섞어, 그 사람들 혹은 자신을 혼란스럽게 만들고 싶지 않는다.&lt;/p&gt;</description>
      <category>Programming &amp;amp; Design/OOP</category>
      <category>Robert Martin</category>
      <category>SRP</category>
      <category>객체지향 프로그래밍</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/30</guid>
      <comments>https://sechoi.tistory.com/30#entry30comment</comments>
      <pubDate>Fri, 10 Nov 2023 02:07:22 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Data JPA] jpa.generate-ddl과 jpa.hibernate.ddl-auto 프로퍼티</title>
      <link>https://sechoi.tistory.com/28</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;참고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/docs/2.0.6.RELEASE/reference/html/howto-database-initialization.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://docs.spring.io/spring-boot/docs/2.0.6.RELEASE/reference/html/howto-database-initialization.html&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.jboss.org/hibernate/orm/5.0/manual/en-US/html/ch03.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://docs.jboss.org/hibernate/orm/5.0/manual/en-US/html/ch03.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JPA를 사용한 데이터베이스 초기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA에는 DDL 생성 기능이 있으며, 어플리케이션 시작 시 실행되도록 설정할 수 있다. 이를 다루는 두 가지 속성이 &lt;b&gt;spring.jpa.generate-ddl&lt;/b&gt;과 &lt;b&gt;spring.jpa.hibernate.ddl-auto&lt;/b&gt; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HBM2DDL_AUTO 프로퍼티&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 두 프로퍼티는 Hibernate의 HBM2DDL_AUTO 프로퍼티 값을 결정한다. 그렇다면 이 HBM2DDL_AUTO 프로퍼티는 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Automatically validates or exports schema DDL to the database when the SessionFactory is created. With create-drop, the database schema will be dropped when the SessionFactory is closed explicitly.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로퍼티는 Hibernate의 SessionFactory가 생성될 때, 데이터베이스 스키마 관리 작업(DDL)을 관리하고 제어하는 데 사용된다. 쉽게 얘기하면 데이터베이스 초기화 방식을 담당하는 프로퍼티라 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;spring.jpa.generate-ddl를 사용한 데이터베이스 초기화&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Whether to initialize the schema on startup.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring.jpa.generate-ddl은 boolean 값으로 어플리케이션이 시작할 때 스키마를 초기화할 것인지 결정한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 jpa 공급업체(vendor)에 독립적이다. 이 때 스프링에서 정한 기본 vendor는 Hibernate 이므로 HibernateJpaVendorAdapter 클래스를 살펴보면 다음과 같이 해당 프로퍼티가 true일 경우, &lt;b&gt;HBM2DDL_AUTO를 update로 지정&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre id=&quot;code_1696421557423&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private Map&amp;lt;String, Object&amp;gt; buildJpaPropertyMap(boolean connectionReleaseOnClose) {
    Map&amp;lt;String, Object&amp;gt; jpaProperties = new HashMap&amp;lt;&amp;gt;();

    if (isGenerateDdl()) {
        jpaProperties.put(AvailableSettings.HBM2DDL_AUTO, &quot;update&quot;);
    }
    
    return jpaProperties;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hibernate를 사용한 데이터베이스 초기화&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;DDL mode. This is actually a shortcut for the &quot;hibernate.hbm2ddl.auto&quot; property. Defaults to &quot;create-drop&quot; when using an embedded database and no schema manager was detected. Otherwise, defaults to &quot;none&quot;.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring.jpa.hibernate.ddl-auto는 enum 값으로 DDL을 &lt;i&gt;세밀한 방식&lt;/i&gt;으로 제어하는 Hibernate의 기능이다. enum은 다음과 같이 다섯 가지가 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CREATE: Create&amp;nbsp;the&amp;nbsp;schema&amp;nbsp;and&amp;nbsp;destroy&amp;nbsp;previous&amp;nbsp;data.&lt;/li&gt;
&lt;li&gt;CREATE-DROP: Create&amp;nbsp;and&amp;nbsp;then&amp;nbsp;destroy&amp;nbsp;the&amp;nbsp;schema&amp;nbsp;at&amp;nbsp;the&amp;nbsp;end&amp;nbsp;of&amp;nbsp;the&amp;nbsp;session.&lt;/li&gt;
&lt;li&gt;UPDATE: Update&amp;nbsp;the&amp;nbsp;schema&amp;nbsp;if&amp;nbsp;necessary.&lt;/li&gt;
&lt;li&gt;NONE: Disable DDL handling&lt;/li&gt;
&lt;li&gt;VALIDATE: Validate&amp;nbsp;the&amp;nbsp;schema,&amp;nbsp;make&amp;nbsp;no&amp;nbsp;changes&amp;nbsp;to&amp;nbsp;the&amp;nbsp;database.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 임베디드 데이터베이스를 사용할 경우 기본 값은 CREATE-DROP이고, 나머지 경우에는 NONE이 기본 값이라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HibernateProperties 클래스에서 해당 프로퍼티로 HBM2DDL_AUTO의 값을 결정한다. 만약 NONE 외의 다른 값을 지정해주었을 경우, 해당 값이 들어가며 이외의 경우에는 값 지정이 아예 되지 않는다(속성이 사라진다).&lt;/p&gt;
&lt;pre id=&quot;code_1696429052769&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private Map&amp;lt;String, Object&amp;gt; getAdditionalProperties(Map&amp;lt;String, String&amp;gt; existing, HibernateSettings settings) {

    String ddlAuto = determineDdlAuto(existing, settings::getDdlAuto);
    if (StringUtils.hasText(ddlAuto) &amp;amp;&amp;amp; !&quot;none&quot;.equals(ddlAuto)) {
        result.put(AvailableSettings.HBM2DDL_AUTO, ddlAuto);
    }
    else {
        result.remove(AvailableSettings.HBM2DDL_AUTO);
    }
    return result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HBM2DDL_AUTO 값 결정 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 살펴봤으면 다음과 같은 의문이 생긴다. 두 프로퍼티로 HBM2DDL_AUTO 값을 지정해줄 때, 충돌이 일어나면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 jpa.generate-ddl은 true로, jpa.hibernate.ddl-auto은 create로 지정해줬다면 전자는 update이고 후자는 create로 값의 충돌이 일어날 것임을 추측해볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 알아보기 위해 위 예시 상황으로 각각의 값을 지정한 후 HBM2DDL_AUTO 값 결정 과정을 디버깅해보았고, AbstractEntityManagerFactoryBean 클래스의 afterPropertiesSet 메서드에 도달했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1696430499122&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
public void afterPropertiesSet() throws PersistenceException {

    JpaVendorAdapter jpaVendorAdapter = getJpaVendorAdapter();
    if (jpaVendorAdapter != null) {
        
        Map&amp;lt;String, ?&amp;gt; vendorPropertyMap = (pui != null ? jpaVendorAdapter.getJpaPropertyMap(pui) :
                jpaVendorAdapter.getJpaPropertyMap());
                
        if (!CollectionUtils.isEmpty(vendorPropertyMap)) {
            vendorPropertyMap.forEach((key, value) -&amp;gt; {
                if (!this.jpaPropertyMap.containsKey(key)) {
                    this.jpaPropertyMap.put(key, value);
                }
            });
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lLkwn/btsw8im66x3/4sJ5Wj6XBoTnRH53oOMGLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lLkwn/btsw8im66x3/4sJ5Wj6XBoTnRH53oOMGLk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lLkwn/btsw8im66x3/4sJ5Wj6XBoTnRH53oOMGLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlLkwn%2Fbtsw8im66x3%2F4sJ5Wj6XBoTnRH53oOMGLk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1668&quot; height=&quot;334&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vendorPropertyMap(spring.jpa.generate-ddl)에서는 UPDATE, jpaPropertyMap(jpa.hibernate.ddl-auto)에서는 CREATE로 각자 설정값을 가져왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 vendorPropertyMap이 비어있지 않으면 jpaPropertyMap에 key가 존재하지 않을 때만 value을 옮긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;즉, jpa.hibernate.ddl-auto가 NONE인 경우(key가 존재하지 않음)에는 spring.jpa.generate-ddl이 지정한 값을 따르고, 이외의 경우에는 jpa.hibernate.ddl-auto가 지정한 값을 따른다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 위 상황에서는 CREATE로 결정된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Framework &amp;amp; Library/Spring</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/28</guid>
      <comments>https://sechoi.tistory.com/28#entry28comment</comments>
      <pubDate>Wed, 4 Oct 2023 23:53:39 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] @SpringBootTest를 사용한 테스트의 격리</title>
      <link>https://sechoi.tistory.com/27</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;이전 방식: @DirtiesContext를 통한 격리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/wootecam-gugucon/shopping-mall&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://github.com/wootecam-gugucon/shopping-mall&lt;/a&gt; 프로젝트에서 @SpringBootTest로 통합 테스트를 진행할 때 @DirtiesContext를 사용해 테스트 간 격리를 시켰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1696176672840&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IntegrationTest {

    // 기타 설정
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/testing/annotations/integration-spring/annotation-dirtiescontext.html&quot;&gt;https://docs.spring.io/spring-framework/reference/testing/annotations/integration-spring/annotation-dirtiescontext.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@DirtiesContext는 ApplicationContext가 더러워졌다고 표시하는 어노테이션이다. 컨텍스트가 더러워지면 캐시에서 제거되고 닫히므로 다음 테스트에서 Spring 컨테이너가 다시 빌드되며 ApplicationContext를 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 schema.sql과 data.sql, 그리고 ddl-auto로 지정한 데이터베이스 초기화가 다시 이루어진다. 이는 프로젝트에서 여러 불편함을 야기시켰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 실행 시간 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 어노테이션을 사용한 인수 테스트에서는 매번 스프링 컨테이너를 다시 띄웠기 때문에 실행 시간이 오래 걸렸다. 이는 개발하는 데 있어서 큰 불편함을 주었으며(테스트 실행=화장실 타임이었다...) 테스트 추가를 꺼리게 만들었다. TDD 방식으로 개발했기 때문에 테스트에서의 불편함은 큰 단점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 불필요한 테스트 데이터 주입&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 컨테이너를 새로 띄우며 data.sql 또한 매번 실행되어 테스트용 데이터가 만들어졌다. 따라서 테스트 메서드의 내용과 관계없이 모두 같은 데이터를 주입받아야 했기 때문에 불필요한 데이터를 받는 메서드도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 data.sql가 변하면 테스트가 실패할 수 있으며, 테스트 코드만 보고서는 데이터의 내용을 알 수 없기 때문에 테스트가 해당 파일에 매우 종속적이었다. 이러한 이유로 테스트의 안정성과 가독성 면에서 좋지 않은 방식이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이후 방식: TestExecutionListener를 통한 데이터베이스 초기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/264&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://mangkyu.tistory.com/264&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1696171718759&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Spring] @SpringBootTest의 테스트 격리시키기(TestExecutionListener), @Transactional로 롤백되지 않는 이유&quot; data-og-description=&quot;이번에 넥스트스텝 ATDD 강의를 듣게 되었습니다. 과제 중에 @SpringBootTest를 사용하는 테스트들을 격리시키는 부분이 있었는데, 제가 사용했던 방법을 공유하도록 하겠습니다. 1. SpringBootTest가 @Tran&quot; data-og-host=&quot;mangkyu.tistory.com&quot; data-og-source-url=&quot;https://mangkyu.tistory.com/264&quot; data-og-url=&quot;https://mangkyu.tistory.com/264&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/zy1EI/hyT5VWG3Lu/q51e9iH7xkl40I1xqvpgmk/img.png?width=730&amp;amp;height=160&amp;amp;face=0_0_730_160,https://scrap.kakaocdn.net/dn/bafYeW/hyT53ApZoy/K4cAOibE0KmhKGfCC2y4d1/img.png?width=730&amp;amp;height=160&amp;amp;face=0_0_730_160&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/264&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://mangkyu.tistory.com/264&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/zy1EI/hyT5VWG3Lu/q51e9iH7xkl40I1xqvpgmk/img.png?width=730&amp;amp;height=160&amp;amp;face=0_0_730_160,https://scrap.kakaocdn.net/dn/bafYeW/hyT53ApZoy/K4cAOibE0KmhKGfCC2y4d1/img.png?width=730&amp;amp;height=160&amp;amp;face=0_0_730_160');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Spring] @SpringBootTest의 테스트 격리시키기(TestExecutionListener), @Transactional로 롤백되지 않는 이유&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이번에 넥스트스텝 ATDD 강의를 듣게 되었습니다. 과제 중에 @SpringBootTest를 사용하는 테스트들을 격리시키는 부분이 있었는데, 제가 사용했던 방법을 공유하도록 하겠습니다. 1. SpringBootTest가 @Tran&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;mangkyu.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 포스팅을 참고해 새로운 방식으로 테스트를 격리시킬 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 방식에서 문제였던 것은 데이터의 공유였다. 다른 설정들은 굳이 스프링 컨테이너를 새로 띄우면서 초기화할 필요가 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1695013517735&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class IntegrationTestExecutionListener extends AbstractTestExecutionListener {

    @Override
    public void afterTestMethod(final TestContext testContext) {
        final JdbcTemplate jdbcTemplate = getJdbcTemplate(testContext);
        truncateTables(jdbcTemplate);
    }

    private JdbcTemplate getJdbcTemplate(final TestContext testContext) {
        return testContext.getApplicationContext().getBean(JdbcTemplate.class);
    }

    private void truncateTables(final JdbcTemplate jdbcTemplate) {
        execute(jdbcTemplate, &quot;SET REFERENTIAL_INTEGRITY FALSE&quot;);
        getTruncateQueries(jdbcTemplate).forEach(query -&amp;gt; execute(jdbcTemplate, query));
        execute(jdbcTemplate, &quot;SET REFERENTIAL_INTEGRITY TRUE&quot;);
    }

    private List&amp;lt;String&amp;gt; getTruncateQueries(final JdbcTemplate jdbcTemplate) {
        String sql = &quot;SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') &quot;
            + &quot;FROM INFORMATION_SCHEMA.TABLES &quot;
            + &quot;WHERE table_schema = 'PUBLIC'&quot;;
        return jdbcTemplate.queryForList(sql, String.class);
    }

    private void execute(final JdbcTemplate jdbcTemplate, final String query) {
        jdbcTemplate.execute(query);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 TestExecutionListener의 afterTestMethod()를 오버라이드 하여, 테스트 메서드가 끝날 때마다 데이터만 초기화 하도록 해주었다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;쿼리를 직접 실행하기 위해 getJdbcTemplate()에서 현재 Application Context의 JdbcTemplate 빈을 가져온다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;데이터 삭제 시 순서와 상관없는 원할한 삭제를 위해 truncateTables()에서 `SET REFERENTIAL_INTEGRITY FALSE` 쿼리로 외래키 제약을 잠시 해제한다.&lt;/li&gt;
&lt;li&gt;getTruncateQueries()에서 전체 테이블을 가져와 truncate 쿼리를 생성하고 실행한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1695015213498&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@ActiveProfiles(&quot;test&quot;)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Retention(RetentionPolicy.RUNTIME)
@TestExecutionListeners(value = {IntegrationTestExecutionListener.class,}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface IntegrationTest {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 커스텀 어노테이션으로 간편하게 설정을 추가할 수 있도록 했다. 테스트 코드가 예뻐졌다  &amp;zwj; &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의: truncate 명령어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스의 테이블을 삭제하는 명령어에는 truncate와 drop이 있다. 둘 다 테이블의 전체 데이터(row)를 삭제하는 건 같으나 차이점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- truncate: 테이블 자체는 남아있으며, &lt;b&gt;인덱스 혹은 auto_increment 속성 또한 남아있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- drop: 테이블도 삭제된다. 아무것도 남지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 truncate 명령어로 데이터를 초기화하고 있기 때문에 auto_increment 속성은 공유된다. 따라서 테스트 메서드마다 기본 키가 1부터 시작됨이 보장되지 않는다. 이것도 테스트 격리가 덜 된 것으로 볼 수도 있으나...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 기본 키는 IDENTITY 정책으로 데이터베이스에 생성을 위임했기 때문에, 우리가 직접 기본 키를 정할 일이 없으므로 어떤 값이든지 상관없다. 즉 테스트 내 데이터의 기본 키가 1이든 100이든 테스트는 잘 작동하기 때문에 격리가 된 것으로 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 사실은... drop으로 못바꿨다. 껄껄&lt;/p&gt;
&lt;pre id=&quot;code_1695017800515&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Sql(value = {&quot;classpath:schema.sql&quot;})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커스텀 어노테이션에 @Sql을 추가해 drop으로 삭제되는 테이블을 매번 다시 만들도록 시도해봤었다. 그랬더니 테스트 처음에 한 번 스프링 컨테이너가 올라가면서 schema.sql 문이 실행되는 것과 겹쳐 설정에 따라 맨 앞 혹은 맨 뒤 테스트에서 &lt;b&gt;인덱스 생성에 중복이 생겨&lt;/b&gt; 테스트가 실패했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인수 테스트에 @Transactinoal을 선언해 자동 롤백하는 방법도 생각했으나, 프로덕션 코드와 그에 해당하는 테스트 코드의 성공 여부가 달라질 수 있기 때문에(&lt;a href=&quot;https://javabom.tistory.com/103&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt;) 사용하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로젝트가 끝난 지금 다시 생각해보니, &lt;b&gt;spring.sql.init.mode 프로퍼티&lt;/b&gt; 설정으로 schema.sql로 인한 데이터 초기화를 막으면 될 것 같다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Framework &amp;amp; Library/Spring</category>
      <author>sechoi</author>
      <guid isPermaLink="true">https://sechoi.tistory.com/27</guid>
      <comments>https://sechoi.tistory.com/27#entry27comment</comments>
      <pubDate>Sun, 1 Oct 2023 23:57:00 +0900</pubDate>
    </item>
  </channel>
</rss>