<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발.. 한놈만 팬다</title>
    <link>https://hy-ung.tistory.com/</link>
    <description>HY 's  coding study 일지   ✏️</description>
    <language>ko</language>
    <pubDate>Mon, 8 Jun 2026 17:59:40 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>h7ung</managingEditor>
    <image>
      <title>개발.. 한놈만 팬다</title>
      <url>https://tistory1.daumcdn.net/tistory/5757259/attach/80e0996c553243ad9382b3b8f2ba70c5</url>
      <link>https://hy-ung.tistory.com</link>
    </image>
    <item>
      <title>[LOOp:pak vol.4] 스투시 반팔티를 10명이 동시에 주문하면 생기는 일</title>
      <link>https://hy-ung.tistory.com/216</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;`@Transactional`로 묶었다고 동시성이 해결되는 건 아니다. 그건 원자성(A) 얘기지 격리성(I) 얘기가 아니다. 재고 5개짜리 상품에 10명이 동시에 주문을 넣는 상황을 따라가며 비관적 락, 낙관적 락, 그리고 &quot;사실 읽을 필요도 없는&quot; 원자적 UPDATE까지 저울질한 기록.&lt;/blockquote&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;단순한&amp;nbsp;착각이었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 로직을 짜면서 처음엔 이렇게 생각했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;재고 차감이랑 주문 생성을 @Transacional 로 묶었으니, 동시에 주문이 들어와도 알아서 잘 처리하겠지&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;트랜잭션이 ACID 를 보장하니깐 동시성도 트랜잭션이 잘 막아줄 것이라고 막연히 믿었다.&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;i&gt;읽고 -&amp;gt; 확인하고 -&amp;gt; 줄이고 -&amp;gt; 커밋&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;그런데 &quot;10명이 동시에&quot;를 그려보려고 손님을 한명 더 붙여봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손님 A 가 재고를 읽고 아직 줄이기 전인 그 찰나에, 손님 B 도 같은 재고를 읽는다. 둘 다 &quot;재고 있음&quot;을 확인하고 통과한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 순간 알았다. &lt;i&gt;&lt;b&gt;@Transactional&lt;/b&gt;&lt;/i&gt; 이 지켜주는 건 &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;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;pre id=&quot;code_1780811704902&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT stock FROM product WHERE id = 1;      -- (1) 재고 읽기
-- 애플리케이션에서 if (stock &amp;gt; 0) 체크         -- (2) 확인
UPDATE product SET stock = stock - 1 ...;     -- (3) 차감
INSERT INTO orders ...;                        -- (4) 주문 생성
COMMIT;&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;트랜잭션은 이 1~4번을 하나로 묶어, 중간에 실패하면 통째로 되돌린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3번까지 했는데 4번에서 서버가 죽어도, 재고만 깍이고 주문은 없는 유령 상태가 안생긴다.&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;ACID 를 다시 보면 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- A (원자성): 다 되거나 다 안되거나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- C (일관성): 트랜잭션 전후로 DB 규칙이 안깨짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- I (격리성): 동시에 도는 트랜잭션들이 서로 간섭하지 않은 것처럼 보이게 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- D (지속성): 커밋된 건 서버가 죽어도 살아남음&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;은 원자성이 아니라 격리성의 영역이였다.&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;재고 5개, 동시 주문 10명.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각자 위의 1~4번을 자기 트랜잭션으로 돌리다고 하고, 두명만 떼어내 타임라인을 그려봤다.&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;1536&quot; data-origin-height=&quot;909&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CDwZo/dJMcagFPjvS/ZvCqUNOKj2KUZAAN8YmYTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CDwZo/dJMcagFPjvS/ZvCqUNOKj2KUZAAN8YmYTK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CDwZo/dJMcagFPjvS/ZvCqUNOKj2KUZAAN8YmYTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCDwZo%2FdJMcagFPjvS%2FZvCqUNOKj2KUZAAN8YmYTK%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;1536&quot; height=&quot;909&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;909&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 t2와 t4이다. A와 B 둘다 &quot;남이 차감하기 전의 재고 5&quot;를 읽었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘다 5-1=4 를 계산했고, B 의 UPDATE 가 A 의 결과를 덮어썼다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A 가 깎은 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;10명으로 늘리면, 운 나쁘면 10명 전무 stock = 5 를 읽고 전부 통과해서 재고 5개짜리를 10명에게 팔아버린다.&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;Lost Update (갱신 손실) &amp;mdash; &quot;읽고 &amp;rarr; 계산하고 &amp;rarr; 쓰는(read-modify-write)&quot; 패턴에서, 읽은 값이 쓰는 시점엔 이미 낡아버려 한쪽의 &lt;br /&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;처음&amp;nbsp;착각이&amp;nbsp;완전히&amp;nbsp;깨졌다.&amp;nbsp;위&amp;nbsp;두&amp;nbsp;트랜잭션은&amp;nbsp;각각&amp;nbsp;멀쩡히&amp;nbsp;&lt;b&gt;@Transactional&lt;/b&gt;로&amp;nbsp;묶여&amp;nbsp;있었다.&amp;nbsp;원자성도&amp;nbsp;지속성도&amp;nbsp;완벽했다.&amp;nbsp;그런데도&amp;nbsp;깨진&amp;nbsp;건&amp;nbsp;격리성이&amp;nbsp;약해서&amp;nbsp;서로의&amp;nbsp;중간&amp;nbsp;작업을&amp;nbsp;못&amp;nbsp;막았기&amp;nbsp;때문이다.&amp;nbsp;트랜잭션은&amp;nbsp;죄가&amp;nbsp;없다.&amp;nbsp;동시성은&amp;nbsp;별개의&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;수준을&amp;nbsp;올리면&amp;nbsp;되지&amp;nbsp;않을까&amp;nbsp;(두&amp;nbsp;번째&amp;nbsp;착각)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;격리 수준이라는 게 있다던데, 그걸 높이면 막히는 거 아니야?&quot;&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;/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%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;격리수준&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;막아주는 것&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;샐 수 있는 이상현상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;READ&amp;nbsp;UNCOMMITTED&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;(거의 없음)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Dirty&amp;nbsp;Read까지&amp;nbsp;다&amp;nbsp;샘&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;READ&amp;nbsp;COMMITTED&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Dirty&amp;nbsp;Read&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Non-repeatable&amp;nbsp;Read,&amp;nbsp;Phantom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;REPEATABLE&amp;nbsp;READ&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;+&amp;nbsp;Non-repeatable&amp;nbsp;Read&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Phantom&amp;nbsp;(표준상)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;SERIALIZABLE&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;전부&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&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;표만&amp;nbsp;보면&amp;nbsp;&quot;격리&amp;nbsp;수준을&amp;nbsp;더&amp;nbsp;높은&amp;nbsp;단계(REPEATABLE&amp;nbsp;READ나&amp;nbsp;SERIALIZABLE)로&amp;nbsp;올리면&amp;nbsp;lost&amp;nbsp;update도&amp;nbsp;막히겠네&quot;가&amp;nbsp;합리적인&amp;nbsp;추론이다.&amp;nbsp;그런데&amp;nbsp;막상&amp;nbsp;들여다보니&amp;nbsp;아니었다.&amp;nbsp;그&amp;nbsp;이유를&amp;nbsp;매장&amp;nbsp;상황으로&amp;nbsp;바꿔보면&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;스투시 매장에 재고 현황판이 하나 걸려 있고, 직원 A와 B가 각자 손님을 받는다고 하자.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;957&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bV5qdI/dJMcaiKjkdk/iJn2Kv4g8FGCfYiZCTHyyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bV5qdI/dJMcaiKjkdk/iJn2Kv4g8FGCfYiZCTHyyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bV5qdI/dJMcaiKjkdk/iJn2Kv4g8FGCfYiZCTHyyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbV5qdI%2FdJMcaiKjkdk%2FiJn2Kv4g8FGCfYiZCTHyyk%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;1536&quot; height=&quot;957&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;957&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;핵심은,&amp;nbsp;A도&amp;nbsp;B도&amp;nbsp;잘못&amp;nbsp;본&amp;nbsp;게&amp;nbsp;아니라는&amp;nbsp;점이다.&amp;nbsp;둘&amp;nbsp;다&amp;nbsp;봤을&amp;nbsp;때&amp;nbsp;진짜로&amp;nbsp;5였다.&amp;nbsp;읽기는&amp;nbsp;완벽하게&amp;nbsp;정확했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는&amp;nbsp;적기(쓰기)가&amp;nbsp;서로&amp;nbsp;덮어쓴&amp;nbsp;데&amp;nbsp;있다.&amp;nbsp;B가&amp;nbsp;적을&amp;nbsp;때&amp;nbsp;A가&amp;nbsp;이미&amp;nbsp;적어둔&amp;nbsp;4를&amp;nbsp;그냥&amp;nbsp;지워버린&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;격리&amp;nbsp;수준이라는&amp;nbsp;다이얼이&amp;nbsp;조절하는&amp;nbsp;건&amp;nbsp;오직&amp;nbsp;&quot;본다(읽기)&quot;뿐이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표에&amp;nbsp;있는&amp;nbsp;이상현상&amp;nbsp;세&amp;nbsp;개의&amp;nbsp;이름을&amp;nbsp;보면&amp;nbsp;전부&amp;nbsp;Dirty&amp;nbsp;Read,&amp;nbsp;Non-repeatable&amp;nbsp;Read,&amp;nbsp;Phantom&amp;nbsp;Read&amp;nbsp;&amp;mdash;&amp;nbsp;죄다&amp;nbsp;&quot;읽기&quot;다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉&amp;nbsp;격리&amp;nbsp;수준은&amp;nbsp;&quot;현황판을&amp;nbsp;볼&amp;nbsp;때&amp;nbsp;뭐가&amp;nbsp;보이게&amp;nbsp;할&amp;nbsp;거냐&quot;를&amp;nbsp;정하는&amp;nbsp;규칙이지,&amp;nbsp;&quot;적기끼리&amp;nbsp;부딪히는&amp;nbsp;것&quot;과는&amp;nbsp;다른&amp;nbsp;영역이다.&amp;nbsp;문제는&amp;nbsp;쓰기에&amp;nbsp;있는데&amp;nbsp;다이얼은&amp;nbsp;읽기만&amp;nbsp;만지니,&amp;nbsp;아무리&amp;nbsp;돌려도&amp;nbsp;안&amp;nbsp;풀린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그럼&amp;nbsp;&quot;REPEATABLE&amp;nbsp;READ면&amp;nbsp;내가&amp;nbsp;본&amp;nbsp;값을&amp;nbsp;지켜준다던데?&quot;라는&amp;nbsp;의문이&amp;nbsp;남는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로&amp;nbsp;MySQL&amp;nbsp;InnoDB의&amp;nbsp;기본값이&amp;nbsp;REPEATABLE&amp;nbsp;READ인데도&amp;nbsp;위&amp;nbsp;상황은&amp;nbsp;그대로&amp;nbsp;일어난다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함정은&amp;nbsp;이&amp;nbsp;&quot;지켜준다&quot;의&amp;nbsp;뜻에&amp;nbsp;있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REPEATABLE&amp;nbsp;READ가&amp;nbsp;약속하는&amp;nbsp;건&amp;nbsp;&quot;A&amp;nbsp;눈에&amp;nbsp;보이는&amp;nbsp;화면을&amp;nbsp;끝까지&amp;nbsp;5로&amp;nbsp;유지해준다&quot;이지,&amp;nbsp;&quot;현황판의&amp;nbsp;진짜&amp;nbsp;내용을&amp;nbsp;지켜준다&quot;가&amp;nbsp;아니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B가&amp;nbsp;현황판을&amp;nbsp;진짜로&amp;nbsp;4로&amp;nbsp;바꿔놔도,&amp;nbsp;A&amp;nbsp;눈에는&amp;nbsp;친절하게&amp;nbsp;계속&amp;nbsp;5를&amp;nbsp;보여준다.&amp;nbsp;A를&amp;nbsp;&quot;5라고&amp;nbsp;적힌&amp;nbsp;옛날&amp;nbsp;화면&quot;에&amp;nbsp;가둬두는&amp;nbsp;셈이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;오히려&amp;nbsp;A는&amp;nbsp;&quot;내&amp;nbsp;화면은&amp;nbsp;5니까&amp;nbsp;5겠지&quot;&amp;nbsp;하고&amp;nbsp;4를&amp;nbsp;적어버린다.&amp;nbsp;읽기&amp;nbsp;일관성을&amp;nbsp;지켜주는&amp;nbsp;것과&amp;nbsp;쓰기&amp;nbsp;충돌을&amp;nbsp;막아주는&amp;nbsp;것은&amp;nbsp;전혀&amp;nbsp;다른&amp;nbsp;얘기다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;SERIALIZABLE까지&amp;nbsp;올리면&amp;nbsp;막히긴&amp;nbsp;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;수준에선&amp;nbsp;직원이&amp;nbsp;현황판을&amp;nbsp;보는&amp;nbsp;순간부터&amp;nbsp;아예&amp;nbsp;판을&amp;nbsp;잡아버려서,&amp;nbsp;다른&amp;nbsp;직원이&amp;nbsp;동시에&amp;nbsp;보지&amp;nbsp;못하게&amp;nbsp;하기&amp;nbsp;때문이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&amp;nbsp;이건&amp;nbsp;매장의&amp;nbsp;모든&amp;nbsp;조회를&amp;nbsp;다&amp;nbsp;줄&amp;nbsp;세우는&amp;nbsp;것과&amp;nbsp;같다.&amp;nbsp;동시성이&amp;nbsp;바닥으로&amp;nbsp;떨어지고&amp;nbsp;데드락도&amp;nbsp;잦아진다.&amp;nbsp;주문&amp;nbsp;한&amp;nbsp;줄&amp;nbsp;막자고&amp;nbsp;시스템&amp;nbsp;전체를&amp;nbsp;직렬화하는&amp;nbsp;건&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;두&amp;nbsp;번째&amp;nbsp;결론에&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;i&gt;격리&amp;nbsp;수준을&amp;nbsp;올리는&amp;nbsp;건&amp;nbsp;lost&amp;nbsp;update의&amp;nbsp;정답이&amp;nbsp;아니다.&amp;nbsp;격리&amp;nbsp;수준은&amp;nbsp;&quot;읽기의&amp;nbsp;일관성&quot;을&amp;nbsp;다루는&amp;nbsp;다이얼이고,&amp;nbsp;내&amp;nbsp;문제는&amp;nbsp;&quot;특정&amp;nbsp;쓰기가&amp;nbsp;덮어써지는&amp;nbsp;것&quot;이다.&amp;nbsp;연장이&amp;nbsp;애초에&amp;nbsp;안&amp;nbsp;맞았다.&amp;nbsp;내가&amp;nbsp;풀어야&amp;nbsp;할&amp;nbsp;건&amp;nbsp;&quot;이&amp;nbsp;한&amp;nbsp;행을&amp;nbsp;갱신하는&amp;nbsp;동안&amp;nbsp;끼어들지&amp;nbsp;못하게&amp;nbsp;하는&amp;nbsp;것&quot;이고,&amp;nbsp;그건&amp;nbsp;그&amp;nbsp;행을&amp;nbsp;콕&amp;nbsp;집어&amp;nbsp;제어하는&amp;nbsp;락&amp;nbsp;전략의&amp;nbsp;영역이다.&lt;/i&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;락 전략 1 &amp;mdash; 비관적 락: 일단 잠그고 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;비관적&amp;nbsp;락의&amp;nbsp;전제는&amp;nbsp;이렇다.&amp;nbsp;&quot;충돌은&amp;nbsp;자주&amp;nbsp;난다고&amp;nbsp;비관적으로&amp;nbsp;가정한다.&amp;nbsp;그러니&amp;nbsp;건드리기&amp;nbsp;전에&amp;nbsp;미리&amp;nbsp;잠가&amp;nbsp;남이&amp;nbsp;못&amp;nbsp;들어오게&amp;nbsp;한다.&quot;&lt;br /&gt;읽을&amp;nbsp;때부터&amp;nbsp;락을&amp;nbsp;거는&amp;nbsp;방식이다.&amp;nbsp;평범한&amp;nbsp;SELECT가&amp;nbsp;아니라&amp;nbsp;SELECT&amp;nbsp;...&amp;nbsp;FOR&amp;nbsp;UPDATE를&amp;nbsp;쓴다.&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780813744970&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;  -- 이 행에 배타 락
-- if (stock &amp;gt; 0)
UPDATE product SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders ...;
COMMIT;   -- 여기서 락 해제&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;FOR&amp;nbsp;UPDATE가&amp;nbsp;붙으면&amp;nbsp;그&amp;nbsp;행에&amp;nbsp;배타&amp;nbsp;락이&amp;nbsp;걸리고,&amp;nbsp;락이&amp;nbsp;걸린&amp;nbsp;동안&amp;nbsp;다른&amp;nbsp;트랜잭션이&amp;nbsp;같은&amp;nbsp;행을&amp;nbsp;FOR&amp;nbsp;UPDATE로&amp;nbsp;읽으려&amp;nbsp;하면&amp;nbsp;앞&amp;nbsp;트랜잭션이&amp;nbsp;COMMIT할&amp;nbsp;때까지&amp;nbsp;멈춰서&amp;nbsp;기다린다.&amp;nbsp;타임라인이&amp;nbsp;이렇게&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;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uie5Q/dJMcahLsirG/wutYNVFdetmKLkkvaKiuwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uie5Q/dJMcahLsirG/wutYNVFdetmKLkkvaKiuwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uie5Q/dJMcahLsirG/wutYNVFdetmKLkkvaKiuwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fuie5Q%2FdJMcahLsirG%2FwutYNVFdetmKLkkvaKiuwK%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;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&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;t4다.&amp;nbsp;B는&amp;nbsp;A가&amp;nbsp;끝날&amp;nbsp;때까지&amp;nbsp;아예&amp;nbsp;못&amp;nbsp;읽는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;B가&amp;nbsp;읽는&amp;nbsp;시점엔&amp;nbsp;이미&amp;nbsp;A가&amp;nbsp;깎은&amp;nbsp;최신값(4)을&amp;nbsp;보고,&amp;nbsp;lost&amp;nbsp;update가&amp;nbsp;원천&amp;nbsp;차단된다.&amp;nbsp;줄을&amp;nbsp;세운&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;대가가&amp;nbsp;있다.&amp;nbsp;한&amp;nbsp;행에&amp;nbsp;주문이&amp;nbsp;몰리면&amp;nbsp;모두&amp;nbsp;한&amp;nbsp;줄로&amp;nbsp;서서&amp;nbsp;처리량이&amp;nbsp;떨어진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러&amp;nbsp;행을&amp;nbsp;서로&amp;nbsp;다른&amp;nbsp;순서로&amp;nbsp;잠그면&amp;nbsp;데드락이&amp;nbsp;생길&amp;nbsp;수&amp;nbsp;있어&amp;nbsp;&quot;락은&amp;nbsp;항상&amp;nbsp;같은&amp;nbsp;순서로&amp;nbsp;잡는다&quot;는&amp;nbsp;규칙이&amp;nbsp;따라온다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고&amp;nbsp;락&amp;nbsp;보유&amp;nbsp;시간은&amp;nbsp;곧&amp;nbsp;트랜잭션&amp;nbsp;길이라서,&amp;nbsp;FOR&amp;nbsp;UPDATE를&amp;nbsp;잡은&amp;nbsp;채&amp;nbsp;느린&amp;nbsp;작업(외부&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;등)을&amp;nbsp;하면&amp;nbsp;줄&amp;nbsp;선&amp;nbsp;사람&amp;nbsp;전부가&amp;nbsp;그만큼&amp;nbsp;기다린다.&amp;nbsp;트랜잭션은&amp;nbsp;짧게&amp;nbsp;가져가야&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;전략&amp;nbsp;2&amp;nbsp;&amp;mdash;&amp;nbsp;낙관적&amp;nbsp;락:&amp;nbsp;일단&amp;nbsp;진행하고,&amp;nbsp;충돌나면&amp;nbsp;그때&amp;nbsp;처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적&amp;nbsp;락의&amp;nbsp;전제는&amp;nbsp;정반대다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;충돌은&amp;nbsp;드물다고&amp;nbsp;낙관적으로&amp;nbsp;가정한다.&amp;nbsp;미리&amp;nbsp;잠그지&amp;nbsp;말고&amp;nbsp;진행하되,&amp;nbsp;마지막에&amp;nbsp;'내가&amp;nbsp;읽은&amp;nbsp;뒤&amp;nbsp;누가&amp;nbsp;건드렸나'만&amp;nbsp;확인한다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서&amp;nbsp;짚고&amp;nbsp;넘어갈&amp;nbsp;게&amp;nbsp;있다.&amp;nbsp;낙관적&amp;nbsp;락은&amp;nbsp;사실&amp;nbsp;DB의&amp;nbsp;락이&amp;nbsp;아니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜&amp;nbsp;락을&amp;nbsp;거는&amp;nbsp;게&amp;nbsp;아니라&amp;nbsp;version&amp;nbsp;컬럼으로&amp;nbsp;충돌을&amp;nbsp;감지하는&amp;nbsp;애플리케이션&amp;nbsp;레벨&amp;nbsp;기법이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름에&amp;nbsp;'락'이&amp;nbsp;붙어&amp;nbsp;헷갈리기&amp;nbsp;쉬운&amp;nbsp;부분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780813910718&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT stock, version FROM product WHERE id = 1;   -- 락 없이 읽음. stock=5, version=1
-- if (stock &amp;gt; 0)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 1;     -- 내가 읽었던 version 조건
COMMIT;&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;WHERE&amp;nbsp;...&amp;nbsp;AND&amp;nbsp;version&amp;nbsp;=&amp;nbsp;1과,&amp;nbsp;이&amp;nbsp;UPDATE가&amp;nbsp;실제로&amp;nbsp;몇&amp;nbsp;행을&amp;nbsp;바꿨는지(affected&amp;nbsp;rows)&amp;nbsp;확인하는&amp;nbsp;데&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;1이면 그 사이 아무도 안 건드린 것이다. =&amp;gt; 성공.&lt;br /&gt;0이면 누군가 먼저 version을 올린 것이다. 내가 읽은 값은 이미 낡았다. =&amp;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;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EUfJZ/dJMcagTklLN/eHkfgCuIt7dHjjzbzfujwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EUfJZ/dJMcagTklLN/eHkfgCuIt7dHjjzbzfujwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EUfJZ/dJMcagTklLN/eHkfgCuIt7dHjjzbzfujwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEUfJZ%2FdJMcagTklLN%2FeHkfgCuIt7dHjjzbzfujwK%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;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&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;락과&amp;nbsp;결정적으로&amp;nbsp;다른&amp;nbsp;점은&amp;nbsp;B를&amp;nbsp;막지&amp;nbsp;않았다는&amp;nbsp;것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B는&amp;nbsp;자유롭게&amp;nbsp;진행했고,&amp;nbsp;마지막&amp;nbsp;UPDATE&amp;nbsp;순간에&amp;nbsp;&quot;내가&amp;nbsp;읽은&amp;nbsp;게&amp;nbsp;낡았다&quot;를&amp;nbsp;스스로&amp;nbsp;발견했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;B는&amp;nbsp;이제&amp;nbsp;다시&amp;nbsp;읽고&amp;nbsp;다시&amp;nbsp;시도하거나,&amp;nbsp;사용자에게&amp;nbsp;&quot;다시&amp;nbsp;시도해달라&quot;고&amp;nbsp;알려야&amp;nbsp;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;충돌&amp;nbsp;후&amp;nbsp;재시도&amp;nbsp;로직이&amp;nbsp;낙관적&amp;nbsp;락의&amp;nbsp;핵심이자&amp;nbsp;부담이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780813983483&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 충돌 시 최대 3번까지 재시도
async function placeOrder(productId: number) {
  for (let attempt = 0; attempt &amp;lt; 3; attempt++) {
    const { stock, version } = await readProduct(productId);
    if (stock &amp;lt;= 0) throw new Error(&quot;품절&quot;);

    const affected = await db.query(
      `UPDATE product SET stock = stock - 1, version = version + 1
       WHERE id = ? AND version = ?`,
      [productId, version]
    );

    if (affected.rowCount === 1) return; // 성공
    await sleep(randomBackoff());        // 충돌 &amp;rarr; 잠깐 쉬고 재시도
  }
  throw new Error(&quot;주문 실패, 잠시 후 다시 시도해주세요&quot;);
}&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;안&amp;nbsp;잡으니&amp;nbsp;대기가&amp;nbsp;없고,&amp;nbsp;충돌이&amp;nbsp;드문&amp;nbsp;상황에선&amp;nbsp;비관적&amp;nbsp;락보다&amp;nbsp;처리량이&amp;nbsp;높다.&amp;nbsp;데드락&amp;nbsp;걱정도&amp;nbsp;없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&amp;nbsp;충돌이&amp;nbsp;잦으면&amp;nbsp;이야기가&amp;nbsp;달라진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다들&amp;nbsp;같은&amp;nbsp;version을&amp;nbsp;읽고&amp;nbsp;한&amp;nbsp;명만&amp;nbsp;성공,&amp;nbsp;나머지는&amp;nbsp;전부&amp;nbsp;실패&amp;nbsp;후&amp;nbsp;재시도,&amp;nbsp;재시도해도&amp;nbsp;또&amp;nbsp;충돌...&amp;nbsp;이런&amp;nbsp;재시도&amp;nbsp;폭풍이&amp;nbsp;일면&amp;nbsp;오히려&amp;nbsp;비관적&amp;nbsp;락보다&amp;nbsp;느려질&amp;nbsp;수&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;중&amp;nbsp;뭘&amp;nbsp;고를까&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;비관적 락&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;낙관적 락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;가정&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌이 자주 난다&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌이 드물다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;방식&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;미리 잠굼 (for update)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;나중에 감지 (version)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌시&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;애초에 막아서 대기&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;실패 후 재시도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;진짜 DB 락인가&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;o (배타 락)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;x (앱 레벨 기법)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;유리한 상황&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;총돌 잦음 / 짧은 트랜잭션 / 꼭 성공해야 함&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌 드묾 / 읽기 위주 / 대기 비용큼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;주의점&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;대기, 데드락, 처리량 저하&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&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;판단&amp;nbsp;기준을&amp;nbsp;두&amp;nbsp;개로&amp;nbsp;좁히면&amp;nbsp;이렇다.&lt;br /&gt;&lt;br /&gt;- 이 상품에 동시 주문이 진짜 몰리는가? 몰리면 비관적 락(낙관적으로 가면 재시도 지옥). 아니면 낙관적 락.&lt;br /&gt;- 충돌했을 때 &quot;다시 시도하세요&quot;가 자연스러운가? 게시글 동시 수정 같은 건 자연스럽다(낙관적에 적합). 결제나 재고처럼 조용히 정확하게 처리돼야 하는 건 대기를 감수하더라도 비관적이 안전한 경우가 많다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIbRZN/dJMcaftj5Dh/oQ44Xdps4LGKbixiONhUd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIbRZN/dJMcaftj5Dh/oQ44Xdps4LGKbixiONhUd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIbRZN/dJMcaftj5Dh/oQ44Xdps4LGKbixiONhUd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIbRZN%2FdJMcaftj5Dh%2FoQ44Xdps4LGKbixiONhUd0%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&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;여기서 한 가지 의문이 들었다. &quot;스투시 한정판처럼 수천 명이 5개 재고에 몰리면, 비관적 락은 이들을 한 줄로 세워버리는데 &amp;mdash; 뒤쪽 사람은 끝없이 기다리는 거 아닌가?&quot;&lt;br /&gt;맞다.&amp;nbsp;그리고&amp;nbsp;사용자&amp;nbsp;경험&amp;nbsp;관점에서&amp;nbsp;최악은&amp;nbsp;&quot;실패&quot;가&amp;nbsp;아니라&amp;nbsp;&quot;언제&amp;nbsp;끝날지&amp;nbsp;모르는&amp;nbsp;대기&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;이런&amp;nbsp;폭발적&amp;nbsp;트래픽에서는&amp;nbsp;DB&amp;nbsp;락에&amp;nbsp;도달하기&amp;nbsp;전에&amp;nbsp;경쟁&amp;nbsp;자체를&amp;nbsp;줄이는&amp;nbsp;접근(인메모리&amp;nbsp;저장소에서&amp;nbsp;재고를&amp;nbsp;먼저&amp;nbsp;차감해&amp;nbsp;걸러내거나,&amp;nbsp;요청을&amp;nbsp;큐에&amp;nbsp;넣어&amp;nbsp;순서대로&amp;nbsp;처리하는&amp;nbsp;식)을&amp;nbsp;쓴다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은&amp;nbsp;&quot;될&amp;nbsp;사람은&amp;nbsp;빨리&amp;nbsp;되게,&amp;nbsp;안&amp;nbsp;될&amp;nbsp;사람은&amp;nbsp;빨리&amp;nbsp;알게&quot;다.&amp;nbsp;다만&amp;nbsp;이는&amp;nbsp;트랜잭션&amp;middot;락의&amp;nbsp;범위를&amp;nbsp;넘는&amp;nbsp;주제라&amp;nbsp;여기서는&amp;nbsp;방향만&amp;nbsp;짚고&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;의문&amp;nbsp;&amp;mdash;&amp;nbsp;재고&amp;nbsp;차감은&amp;nbsp;꼭&amp;nbsp;읽어야&amp;nbsp;할까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적/낙관적을&amp;nbsp;비교하다&amp;nbsp;보니&amp;nbsp;더&amp;nbsp;근본적인&amp;nbsp;의문이&amp;nbsp;들었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;그런데 재고 차감은 애초에 읽을 필요가 있나?&quot;&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;Lost&amp;nbsp;update의&amp;nbsp;원인은&amp;nbsp;&quot;읽고&amp;nbsp;&amp;rarr;&amp;nbsp;계산하고&amp;nbsp;&amp;rarr;&amp;nbsp;쓰는&quot;&amp;nbsp;패턴이라고&amp;nbsp;했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기와&amp;nbsp;쓰기&amp;nbsp;사이에&amp;nbsp;틈이&amp;nbsp;생기고,&amp;nbsp;그&amp;nbsp;틈에&amp;nbsp;남이&amp;nbsp;끼어드는&amp;nbsp;게&amp;nbsp;문제였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면&amp;nbsp;그&amp;nbsp;틈&amp;nbsp;자체를&amp;nbsp;없애면&amp;nbsp;된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고&amp;nbsp;차감은&amp;nbsp;읽지&amp;nbsp;않고&amp;nbsp;DB가&amp;nbsp;원자적으로&amp;nbsp;계산하게&amp;nbsp;시킬&amp;nbsp;수&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780814433566&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock &amp;gt;= 1;   -- 재고가 1 이상일 때만 차감&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;UPDATE&amp;nbsp;한&amp;nbsp;방으로&amp;nbsp;끝난다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WHERE&amp;nbsp;stock&amp;nbsp;&amp;gt;=&amp;nbsp;1&amp;nbsp;덕분에&amp;nbsp;재고가&amp;nbsp;0이면&amp;nbsp;0행이&amp;nbsp;바뀌고(품절),&amp;nbsp;UPDATE는&amp;nbsp;그&amp;nbsp;행에&amp;nbsp;자동으로&amp;nbsp;락을&amp;nbsp;잡고&amp;nbsp;원자적으로&amp;nbsp;실행되니&amp;nbsp;lost&amp;nbsp;update가&amp;nbsp;구조적으로&amp;nbsp;불가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽고-쓰기&amp;nbsp;사이의&amp;nbsp;틈이&amp;nbsp;없으니&amp;nbsp;끼어들&amp;nbsp;자리도&amp;nbsp;없다.&amp;nbsp;affected&amp;nbsp;rows가&amp;nbsp;0이면&amp;nbsp;품절로&amp;nbsp;처리하면&amp;nbsp;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;단순&amp;nbsp;카운터형&amp;nbsp;재고에는&amp;nbsp;이게&amp;nbsp;가장&amp;nbsp;깔끔하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만&amp;nbsp;차감&amp;nbsp;전에&amp;nbsp;복잡한&amp;nbsp;비즈니스&amp;nbsp;검증이&amp;nbsp;필요하거나,&amp;nbsp;재고&amp;nbsp;외&amp;nbsp;다른&amp;nbsp;필드도&amp;nbsp;함께&amp;nbsp;조건&amp;nbsp;검사해야&amp;nbsp;한다면&amp;nbsp;락&amp;nbsp;전략으로&amp;nbsp;돌아가야&amp;nbsp;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;마지막&amp;nbsp;판단은&amp;nbsp;이&amp;nbsp;질문으로&amp;nbsp;귀결된다.&amp;nbsp;내&amp;nbsp;상황은&amp;nbsp;단순&amp;nbsp;카운터인가,&amp;nbsp;복잡한&amp;nbsp;검증이&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;이번 공부에서 가장 크게 바뀐 생각은 &quot;트랜잭션으로 묶으면 동시성이 해결된다&quot;는 믿음이 깨진 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션은 한 작업의 원자성&amp;middot;지속성을 보장하지만, 여러 작업이 동시에 부딪힐 때의 조율(격리성)은 격리 수준과 락이라는 별도의 도구로 풀어야 했다.&lt;br /&gt;&lt;br /&gt;그리고&amp;nbsp;락을&amp;nbsp;비교하다가&amp;nbsp;&quot;애초에&amp;nbsp;읽지&amp;nbsp;않으면&amp;nbsp;충돌도&amp;nbsp;없다&quot;는&amp;nbsp;데까지&amp;nbsp;와보니,&amp;nbsp;결국&amp;nbsp;정답은&amp;nbsp;하나로&amp;nbsp;고정되어&amp;nbsp;있지&amp;nbsp;않았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;충돌&amp;nbsp;빈도,&amp;nbsp;트랜잭션&amp;nbsp;길이,&amp;nbsp;검증&amp;nbsp;복잡도,&amp;nbsp;트래픽&amp;nbsp;규모에&amp;nbsp;따라&amp;nbsp;비관적&amp;nbsp;락이&amp;nbsp;맞을&amp;nbsp;때도,&amp;nbsp;낙관적&amp;nbsp;락이&amp;nbsp;맞을&amp;nbsp;때도,&amp;nbsp;락&amp;nbsp;없이&amp;nbsp;원자적&amp;nbsp;UPDATE&amp;nbsp;한&amp;nbsp;줄로&amp;nbsp;끝낼&amp;nbsp;때도&amp;nbsp;있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;글을&amp;nbsp;쓰는&amp;nbsp;지금도&amp;nbsp;&quot;내&amp;nbsp;케이스엔&amp;nbsp;뭐가&amp;nbsp;맞나&quot;는&amp;nbsp;결국&amp;nbsp;상황을&amp;nbsp;보고&amp;nbsp;판단할&amp;nbsp;문제라고&amp;nbsp;생각한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/216</guid>
      <comments>https://hy-ung.tistory.com/216#entry216comment</comments>
      <pubDate>Sun, 7 Jun 2026 15:58:45 +0900</pubDate>
    </item>
    <item>
      <title>9.5초 &amp;rarr; 0.27초: 측정으로 시작한 API 성능 개선기</title>
      <link>https://hy-ung.tistory.com/215</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의 목록 API 하나를 &quot;추측 대신 측정&quot;으로 파고들어&lt;br /&gt;캐싱 &amp;rarr; 구조 개선 &amp;rarr; 메모리까지 잡은 기록.&lt;br /&gt;Stack: Node.js &amp;middot; TypeScript &amp;middot; TypeORM(MySQL) &amp;middot; Redis &amp;middot; Vitest&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한눈에 보기&lt;/h2&gt;
&lt;table style=&quot;height: 167px;&quot; width=&quot;799&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 190px;&quot;&gt;지표&lt;/th&gt;
&lt;th style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;Before&lt;/th&gt;
&lt;th style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;After&lt;/th&gt;
&lt;th style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;변화&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;응답 시간(대형 케이스)&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;&lt;b&gt;9.5 s&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;&lt;b&gt;0.27 s&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;97%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;외부 인기도 API&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;7,000 ms&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;20 ms (캐시 히트)&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;99.7%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;요청당 객체 메모리&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;3,560 KB&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;53 KB&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;98.5%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;조립 객체 수&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;1,652개&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;20개&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;98.8%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 20건을 주는 API가 매번 1,652건을 다 만들고 있었다. &amp;rarr; 20건만 만들도록 바꾼 이야기.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개선 여정 (요약)&lt;/h2&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;2308&quot; data-origin-height=&quot;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y95Y9/dJMb99Nml9p/667XOk7bOJ1dZzN3ro9vR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y95Y9/dJMb99Nml9p/667XOk7bOJ1dZzN3ro9vR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y95Y9/dJMb99Nml9p/667XOk7bOJ1dZzN3ro9vR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy95Y9%2FdJMb99Nml9p%2F667XOk7bOJ1dZzN3ro9vR1%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;2308&quot; height=&quot;304&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 측정부터 &amp;mdash; 구간별 타이밍&lt;/h2&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const mark = (label) =&amp;gt; { marks[label] = now() - last; last = now(); };
// 각 await 뒤에 mark('단계')  &amp;rarr;  로그로 한 줄 출력&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 삽질 ① &amp;mdash; &quot;인덱스 없나?&quot; &amp;rarr; ❌&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티에 &lt;code&gt;product_id&lt;/code&gt; 인덱스 선언이 안 보여 풀스캔을 의심했다. 그런데 마이그레이션엔 이미:&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;UNIQUE KEY product_course_unique (product_id, course_id)  -- prefix로 product_id 커버&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;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;로 확인:&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;Index lookup on product_course (product_id=...)  actual time=..4.64  rows=1939&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;height: 80px;&quot; width=&quot;850&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;의심&lt;/th&gt;
&lt;th&gt;실제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;인덱스 없는 풀스캔?&lt;/td&gt;
&lt;td&gt;❌ 인덱스 정상, SQL ~12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;느린 진짜 이유&lt;/td&gt;
&lt;td&gt;✅ &lt;b&gt;1,939행을 객체로 만드는 비용&lt;/b&gt; (전송+ORM 하이드레이션)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  느린 게 SQL인지 행&amp;rarr;객체 변환인지는 전혀 다른 문제. EXPLAIN 한 줄로 갈린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 반전 &amp;mdash; 진범은 외부 인기도 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품에 연결된 &lt;b&gt;전체 강의(1,652건)&lt;/b&gt; 의 인기도를 외부 API에서 가져오는데, 이 호출 하나가 응답 건수에 비례해 폭증했다  &lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;처리 건수: 1,652

인기도 API      ████████████████████████████████████  7,238 ms    83%
상품강의맵         ███  569 ms
강의 상세 조회      ██  462 ms
카테고리 조회        ▏  150 ms
점수/부착          ▏   90 ms
────────────────────────────────────────────────────
총합            ~8.7 s&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;uarr; &amp;rarr; 시간 &amp;uarr;). &lt;b&gt;다른 모든 구간을 합쳐도 외부 API 하나의 1/8.&lt;/b&gt; 나머지 최적화는 일단 곁가지였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  가장 큰 레버부터. 7초짜리 외부 호출 앞에선 수십 ms 최적화는 의미가 작다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 개선 ① &amp;mdash; 외부 호출 Redis 캐싱&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API는 못 고치니 결과를 캐싱. 기존 캐시 서비스(같은 데이터 모양)를 재사용.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;const cached = await popularityCache.get(key);
const courses = cached ?? (await popularityClient.fetch(...)).data;
if (!cached) popularityCache.set(key, courses).catch(...);   // fire-and-forget&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;height: 79px;&quot; width=&quot;635&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 210px;&quot;&gt;호출&lt;/th&gt;
&lt;th style=&quot;width: 211px;&quot; align=&quot;right&quot;&gt;외부 API&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;nbsp;&lt;/th&gt;
&lt;th style=&quot;width: 207px;&quot; align=&quot;right&quot;&gt;전체&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 210px;&quot;&gt;1회차 (캐시 미스)&lt;/td&gt;
&lt;td style=&quot;width: 211px; text-align: center;&quot; align=&quot;right&quot;&gt;~7,000 ms&lt;/td&gt;
&lt;td style=&quot;width: 207px; text-align: center;&quot; align=&quot;right&quot;&gt;~9.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 210px;&quot;&gt;2회차 (캐시 히트)&lt;/td&gt;
&lt;td style=&quot;width: 211px; text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;37 ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 207px; text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;0.58 s&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;⚠️ 첫 요청은 여전히 느림(캐싱의 숙명) &amp;middot; 무효화&amp;middot;TTL은 기존 로직 재사용.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 개선 ② &amp;mdash; 페이지네이션 앞당기기 ⭐&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 히트 0.58초의 남은 절반은 &lt;b&gt;&quot;1,652건 전부 조립 후 20건만 slice&quot;&lt;/b&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;770&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dezazG/dJMcaiXOX9G/kZ3HEFZfKvqQQGKLlJ9E21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dezazG/dJMcaiXOX9G/kZ3HEFZfKvqQQGKLlJ9E21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dezazG/dJMcaiXOX9G/kZ3HEFZfKvqQQGKLlJ9E21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdezazG%2FdJMcaiXOX9G%2FkZ3HEFZfKvqQQGKLlJ9E21%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;770&quot; height=&quot;604&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;604&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;&lt;b&gt;핵심 통찰:&lt;/b&gt; 정렬에 필요한 값은 &lt;code&gt;id&lt;/code&gt; &amp;middot; &lt;code&gt;popularity&lt;/code&gt; &amp;middot; &lt;code&gt;입장시각&lt;/code&gt; 셋뿐 &amp;rarr; 무거운 조립 없이 정렬 가능.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Phase A &amp;mdash; 전체를 가볍게 (정렬&amp;middot;total)
const tuples = ids.filter(visible).map(id =&amp;gt; ({ id, popularity, enteredAt }));
tuples.sort(compare);
const pageIds = tuples.slice(offset, offset + limit).map(t =&amp;gt; t.id);

// Phase B &amp;mdash; 페이지 20건만 무겁게
const [courses, categories] = await Promise.all([fetchFull(pageIds), fetchCategories(pageIds)]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;height: 96px;&quot; width=&quot;566&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구간&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Before&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;전체 1,652건 처리&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;349 ms&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;58 ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체(캐시 히트)&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;0.58 s&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;0.27 s&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❓ 두 번 조회 = 중복? &amp;rarr; 아니다. A는 1,652건&amp;times;2컬럼(정렬용), B는 20건&amp;times;풀컬럼(조립용). 페이지는 A 정렬 후에야 정해져 합칠 수 없다. 겹치는 건 20행 재조회뿐.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 메모리 &amp;mdash; GC 노이즈를 피해 측정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;heapUsed&lt;/code&gt; 델타는 GC 탓에 &amp;minus;81 ~ +28MB로 요동 &amp;rarr; &lt;b&gt;결정적 지표&lt;/b&gt;(조립 배열의 JSON 바이트)로 측정.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const builtKB = JSON.stringify(builtArray).length / 1024;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Before  ████████████████████████████████████████  3,560 KB  (1,652개)
After   ▌                                              53 KB  (   20개)
                                                      &amp;asymp; 67&amp;times; &amp;darr;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  GC 런타임에서 &quot;요청당 메모리&quot;는 heapUsed 델타보다 데이터 볼륨 프록시가 정직하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 테스트 우선 (+ 캐시 함정)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 &lt;b&gt;전에&lt;/b&gt; &quot;응답 동일성&quot;(정렬&amp;middot;total&amp;middot;페이지) 테스트를 통과시켜 두고 시작.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 캐싱 도입 후 기존 테스트가 흔들림 &amp;mdash; &lt;b&gt;캐시 히트로 미소비된 &lt;code&gt;mockResolvedValueOnce&lt;/code&gt;가 누수&lt;/b&gt;되어 다음 테스트를 오염.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;beforeEach(async () =&amp;gt; {
  vi.restoreAllMocks();                 // 누수 spy 제거
  vi.spyOn(client, 'fetch').mockResolvedValue({ data: [...] });
  await cache.flush('popularity:cache:*');
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 스코어보드&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;응답시간   9.5s ████████████████████  &amp;rarr;  0.27s ▌        (&amp;minus;97%)
외부API    7.0s ███████████████       &amp;rarr;  0.02s ▏        (&amp;minus;99.7%)
메모리     3.5MB ██████████████        &amp;rarr;  53KB ▏        (&amp;minus;98.5%)&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 더 똑똑하게 짠 게 아니라, 꼭 필요한 만큼만 하도록 바꿨다.&lt;br /&gt;1,652건을 다 만들어 20건만 쓰던 걸 &amp;mdash; 20건만 만들도록.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>회고/우당탕 개발자 성장기</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/215</guid>
      <comments>https://hy-ung.tistory.com/215#entry215comment</comments>
      <pubDate>Fri, 5 Jun 2026 18:59:54 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 이 규칙은 도메인일까, 유스케이스일까 &amp;mdash; Service / Facade 의 진짜 분기 기준</title>
      <link>https://hy-ung.tistory.com/214</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;멈춘 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜드 도메인을 만들고 있었다. 요구사항은 단순했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;어드민이 브랜드를 등록할 때, 동일한 이름의 soft-deleted 행이 있으면 새 INSERT 대신 그 행을 복구(restore)하라.&quot;&lt;br /&gt;&lt;br /&gt;설계&amp;nbsp;문서는&amp;nbsp;이&amp;nbsp;로직을&amp;nbsp;BrandAdminFacade.create&amp;nbsp;에&amp;nbsp;두라고&amp;nbsp;했다.&amp;nbsp;그대로&amp;nbsp;옮기다가&amp;nbsp;손이&amp;nbsp;멈췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780024691349&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BrandAdminFacade.create()
fun create(name: String): BrandInfo {
    val existing = brandRepository.findByName(name)        // &amp;larr; 왜 Facade 가 이걸 알지?
    return when {
        existing == null -&amp;gt; save(BrandModel(name))
        existing.deletedAt != null -&amp;gt; existing.restore()   // &amp;larr; 이게 진짜 &quot;어드민 흐름&quot; 인가?
        else -&amp;gt; throw CONFLICT
    }
}&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;그냥&amp;nbsp;컨벤션이&amp;nbsp;어긋난&amp;nbsp;느낌&amp;nbsp;정도라고&amp;nbsp;넘기려&amp;nbsp;했다.&amp;nbsp;설계&amp;nbsp;문서를&amp;nbsp;따랐으니&amp;nbsp;일단&amp;nbsp;진행하면&amp;nbsp;되겠지,&amp;nbsp;라고.&lt;br /&gt;&lt;br /&gt;그런데&amp;nbsp;손은&amp;nbsp;계속&amp;nbsp;멈춰&amp;nbsp;있었다.&amp;nbsp;어색함의&amp;nbsp;정체가&amp;nbsp;단순한&amp;nbsp;스타일&amp;nbsp;문제가&amp;nbsp;아닌&amp;nbsp;것&amp;nbsp;같았다.&amp;nbsp;책임&amp;nbsp;위치&amp;nbsp;자체가&amp;nbsp;어긋났다는&amp;nbsp;직감에&amp;nbsp;가까웠다.&amp;nbsp;그래서&amp;nbsp;한&amp;nbsp;단계&amp;nbsp;깊이&amp;nbsp;들어가&amp;nbsp;봤다.&lt;br /&gt;&lt;br /&gt;Service&amp;nbsp;에&amp;nbsp;둘까,&amp;nbsp;Facade&amp;nbsp;에&amp;nbsp;둘까.&amp;nbsp;답을&amp;nbsp;찾으려면&amp;nbsp;한&amp;nbsp;단계&amp;nbsp;위의&amp;nbsp;질문을&amp;nbsp;먼저&amp;nbsp;풀어야&amp;nbsp;했다.&lt;br /&gt;&lt;br /&gt;이건&amp;nbsp;도메인&amp;nbsp;규칙인가,&amp;nbsp;유스케이스인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두&amp;nbsp;단어의&amp;nbsp;경계&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1786&quot; data-origin-height=&quot;380&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDM9NY/dJMcahYSbp5/svtP80GuFcrWPv6hCaIKE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDM9NY/dJMcahYSbp5/svtP80GuFcrWPv6hCaIKE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDM9NY/dJMcahYSbp5/svtP80GuFcrWPv6hCaIKE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDM9NY%2FdJMcahYSbp5%2FsvtP80GuFcrWPv6hCaIKE1%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;1786&quot; height=&quot;380&quot; data-origin-width=&quot;1786&quot; data-origin-height=&quot;380&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;그런데 실제 케이스에 대보면 한 가지 룰을 두고 둘 다 말이 되어 보일 때가 있다. auto-restore 가 정확히 그런 경우였다.&lt;br /&gt;&lt;br /&gt;도메인이라&amp;nbsp;보면:&amp;nbsp;&quot;Brand&amp;nbsp;의&amp;nbsp;이름은&amp;nbsp;영구&amp;nbsp;식별자다&quot;&amp;nbsp;는&amp;nbsp;Brand&amp;nbsp;개념의&amp;nbsp;본질.&lt;br /&gt;유스케이스라&amp;nbsp;보면:&amp;nbsp;&quot;어드민이&amp;nbsp;POST&amp;nbsp;로&amp;nbsp;등록&amp;nbsp;요청&amp;nbsp;시,&amp;nbsp;충돌하면&amp;nbsp;restore&amp;nbsp;한다&quot;&amp;nbsp;는&amp;nbsp;어드민의&amp;nbsp;행동&amp;nbsp;양식.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;둘&amp;nbsp;다&amp;nbsp;말이&amp;nbsp;됐다.&amp;nbsp;표&amp;nbsp;옆에&amp;nbsp;두&amp;nbsp;옵션을&amp;nbsp;놓고&amp;nbsp;한참을&amp;nbsp;갈팡질팡했다.&amp;nbsp;표가&amp;nbsp;답을&amp;nbsp;줄&amp;nbsp;거라&amp;nbsp;기대했는데,&amp;nbsp;표는&amp;nbsp;정의일&amp;nbsp;뿐&amp;nbsp;판별&amp;nbsp;기준은&amp;nbsp;아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다른&amp;nbsp;기준들도&amp;nbsp;거쳐봤다&lt;/h2&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;진입점&amp;nbsp;불변성&quot;이라는&amp;nbsp;기준에&amp;nbsp;도달하기&amp;nbsp;전에&amp;nbsp;몇&amp;nbsp;가지&amp;nbsp;다른&amp;nbsp;잣대를&amp;nbsp;시도했었다.&lt;br /&gt;&lt;br /&gt;&quot;상태가&amp;nbsp;있는가?&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;auto-restore&amp;nbsp;는&amp;nbsp;상태&amp;nbsp;없는&amp;nbsp;연산이고,&amp;nbsp;Service&amp;nbsp;도&amp;nbsp;Facade&amp;nbsp;도&amp;nbsp;stateless&amp;nbsp;라&amp;nbsp;변별이&amp;nbsp;안&amp;nbsp;됐다.&lt;br /&gt;&quot;테스트하기&amp;nbsp;쉬운가?&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;양쪽&amp;nbsp;모두&amp;nbsp;단위/통합으로&amp;nbsp;테스트&amp;nbsp;가능.&amp;nbsp;차이가&amp;nbsp;거의&amp;nbsp;없었다.&lt;br /&gt;&quot;재사용성이&amp;nbsp;높은가?&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;다른&amp;nbsp;도메인이&amp;nbsp;부를&amp;nbsp;일이&amp;nbsp;적었다.&amp;nbsp;약한&amp;nbsp;신호.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그러다&amp;nbsp;&quot;다른&amp;nbsp;진입점이&amp;nbsp;들어와도&amp;nbsp;같은&amp;nbsp;정책이어야&amp;nbsp;하나?&quot;&amp;nbsp;라는&amp;nbsp;질문을&amp;nbsp;던졌더니&amp;nbsp;답이&amp;nbsp;또렷해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;진입점&amp;nbsp;불변성이라는&amp;nbsp;한&amp;nbsp;줄&amp;nbsp;테스트&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;이 규칙은 진입점이 바뀌어도 같아야 하나?&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 진입점이란 코드를 부르는 시작점 &amp;mdash; REST API, 배치 잡, 시드 스크립트, CLI, 혹은 미래에 생길지 모를 어떤 호출자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은&amp;nbsp;정책을&amp;nbsp;다른&amp;nbsp;진입점에서&amp;nbsp;호출했을&amp;nbsp;때&amp;nbsp;일관성이&amp;nbsp;유지되어야&amp;nbsp;하는지&amp;nbsp;묻는&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;1500&quot; data-origin-height=&quot;692&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DQwCC/dJMcab5rbbw/oEEVF7h8gSbbSquz7OfRnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DQwCC/dJMcab5rbbw/oEEVF7h8gSbbSquz7OfRnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DQwCC/dJMcab5rbbw/oEEVF7h8gSbbSquz7OfRnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDQwCC%2FdJMcab5rbbw%2FoEEVF7h8gSbbSquz7OfRnk%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;1500&quot; height=&quot;692&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;692&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약&amp;nbsp;정책이&amp;nbsp;진입점마다&amp;nbsp;달라야&amp;nbsp;한다면&amp;nbsp;&amp;mdash;&amp;nbsp;예를&amp;nbsp;들어&amp;nbsp;어드민&amp;nbsp;화면에선&amp;nbsp;auto-restore,&amp;nbsp;배치는&amp;nbsp;무조건&amp;nbsp;CONFLICT&amp;nbsp;&amp;mdash;&amp;nbsp;이&amp;nbsp;규칙은&amp;nbsp;유스케이스의&amp;nbsp;일부다.&lt;br /&gt;&lt;br /&gt;반대로,&amp;nbsp;진입점이&amp;nbsp;늘어도&amp;nbsp;정책이&amp;nbsp;같아야&amp;nbsp;한다면&amp;nbsp;그&amp;nbsp;규칙은&amp;nbsp;진입점&amp;nbsp;위쪽&amp;nbsp;어딘가에&amp;nbsp;살아야&amp;nbsp;한다.&amp;nbsp;거기가&amp;nbsp;도메인이다.&lt;br /&gt;&lt;br /&gt;이&amp;nbsp;기준이&amp;nbsp;실용적인&amp;nbsp;이유는,&amp;nbsp;미래에&amp;nbsp;진입점이&amp;nbsp;추가될&amp;nbsp;때&amp;nbsp;일관성이&amp;nbsp;자동으로&amp;nbsp;보장되기&amp;nbsp;때문이다.&amp;nbsp;반대&amp;nbsp;상황을&amp;nbsp;그려보면&amp;nbsp;차이가&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;692&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dkv0Ok/dJMcacDjG7p/Yw5glsFERjmJdeTDgYguE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dkv0Ok/dJMcacDjG7p/Yw5glsFERjmJdeTDgYguE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dkv0Ok/dJMcacDjG7p/Yw5glsFERjmJdeTDgYguE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdkv0Ok%2FdJMcacDjG7p%2FYw5glsFERjmJdeTDgYguE1%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;1098&quot; height=&quot;692&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;692&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유스케이스&amp;nbsp;레이어에&amp;nbsp;도메인&amp;nbsp;규칙을&amp;nbsp;두면,&amp;nbsp;진입점이&amp;nbsp;늘&amp;nbsp;때마다&amp;nbsp;룰이&amp;nbsp;분산된다.&amp;nbsp;누군가는&amp;nbsp;복붙하고,&amp;nbsp;누군가는&amp;nbsp;빠뜨린다.&amp;nbsp;시간이&amp;nbsp;지나면&amp;nbsp;어느&amp;nbsp;쪽이&amp;nbsp;정답인지도&amp;nbsp;모호해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;auto-restore&amp;nbsp;에&amp;nbsp;대입하기&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Q.&amp;nbsp;&quot;Brand&amp;nbsp;의&amp;nbsp;auto-restore&amp;nbsp;가&amp;nbsp;진입점&amp;nbsp;무관해야&amp;nbsp;하나?&quot;&lt;br /&gt;A.&amp;nbsp;YES&amp;nbsp;&amp;mdash;&amp;nbsp;누가&amp;nbsp;등록하든&amp;nbsp;&quot;같은&amp;nbsp;이름&amp;nbsp;=&amp;nbsp;같은&amp;nbsp;브랜드&quot;&amp;nbsp;라는&amp;nbsp;정책은&amp;nbsp;유지돼야&amp;nbsp;한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780025917845&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BrandService.register() &amp;mdash; 도메인 계층
@Transactional
fun register(name: String): BrandModel {
    val existing = brandRepository.findByNameIncludingDeleted(name)
    return when {
        existing == null -&amp;gt; brandRepository.save(BrandModel(name))
        existing.deletedAt != null -&amp;gt; existing.also { it.restore() }
        else -&amp;gt; throw CoreException(ErrorType.CONFLICT, &quot;이미 사용 중인 이름입니다.&quot;)
    }
}

// BrandAdminFacade.create() &amp;mdash; 응용 계층
fun create(name: String): BrandInfo =
    BrandInfo.from(brandService.register(name))&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;보고&amp;nbsp;솔직히&amp;nbsp;한&amp;nbsp;번&amp;nbsp;더&amp;nbsp;의심이&amp;nbsp;들었다.&amp;nbsp;&quot;Facade&amp;nbsp;가&amp;nbsp;한&amp;nbsp;줄&amp;nbsp;위임밖에&amp;nbsp;안&amp;nbsp;하면,&amp;nbsp;발제에서&amp;nbsp;까는&amp;nbsp;*Impl&amp;nbsp;안티패턴이랑&amp;nbsp;본질적으로&amp;nbsp;같은&amp;nbsp;거&amp;nbsp;아닌가?&quot;&lt;br /&gt;&lt;br /&gt;그래서&amp;nbsp;한&amp;nbsp;번&amp;nbsp;더&amp;nbsp;자문했다&amp;nbsp;&amp;mdash;&amp;nbsp;&quot;이&amp;nbsp;Facade&amp;nbsp;가&amp;nbsp;미래에도&amp;nbsp;비어&amp;nbsp;있을&amp;nbsp;게&amp;nbsp;확실한가?&quot;&amp;nbsp;답은&amp;nbsp;아니었다.&amp;nbsp;어드민&amp;nbsp;흐름엔&amp;nbsp;슬랙&amp;nbsp;알림이나&amp;nbsp;변경&amp;nbsp;이력&amp;nbsp;같은&amp;nbsp;부수효과가&amp;nbsp;들어올&amp;nbsp;자리가&amp;nbsp;필요하다.&amp;nbsp;도메인은&amp;nbsp;진입점&amp;nbsp;무관,&amp;nbsp;Facade&amp;nbsp;는&amp;nbsp;진입점&amp;nbsp;종속.&amp;nbsp;지금&amp;nbsp;비어&amp;nbsp;있어도&amp;nbsp;그&amp;nbsp;자리는&amp;nbsp;의미가&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;비어있는&amp;nbsp;자리도&amp;nbsp;책임의&amp;nbsp;표현이라는&amp;nbsp;것.&amp;nbsp;결정&amp;nbsp;이후에&amp;nbsp;얻은&amp;nbsp;작은&amp;nbsp;깨달음이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결정&amp;nbsp;이후에도&amp;nbsp;흔들리는&amp;nbsp;것들&lt;/h2&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;글의&amp;nbsp;답이고,&amp;nbsp;여기서부터는&amp;nbsp;정직한&amp;nbsp;자기&amp;nbsp;점검이다.&amp;nbsp;결정한&amp;nbsp;다음에도&amp;nbsp;머릿속에&amp;nbsp;남는&amp;nbsp;의심들이&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;1.&amp;nbsp;YAGNI&amp;nbsp;위반일&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;&quot;미래에&amp;nbsp;다른&amp;nbsp;진입점이&amp;nbsp;추가될&amp;nbsp;가능성&quot;&amp;nbsp;자체가&amp;nbsp;가설이고,&amp;nbsp;그&amp;nbsp;가설을&amp;nbsp;근거로&amp;nbsp;추상화&amp;nbsp;비용을&amp;nbsp;지금&amp;nbsp;지불한&amp;nbsp;거다.&amp;nbsp;진입점이&amp;nbsp;영영&amp;nbsp;REST&amp;nbsp;하나뿐이라면&amp;nbsp;이&amp;nbsp;결정은&amp;nbsp;과한&amp;nbsp;결정이&amp;nbsp;된다.&amp;nbsp;다만&amp;nbsp;그&amp;nbsp;추상화&amp;nbsp;비용이&amp;nbsp;한&amp;nbsp;줄짜리&amp;nbsp;위임&amp;nbsp;메서드&amp;nbsp;한&amp;nbsp;개라&amp;nbsp;부담이&amp;nbsp;작다고&amp;nbsp;봤다.&lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;반대&amp;nbsp;결정도&amp;nbsp;충분히&amp;nbsp;옳을&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;&quot;지금은&amp;nbsp;어드민만&amp;nbsp;등록한다.&amp;nbsp;다른&amp;nbsp;진입점이&amp;nbsp;생길&amp;nbsp;때&amp;nbsp;옮겨도&amp;nbsp;늦지&amp;nbsp;않다.&amp;nbsp;그때까지&amp;nbsp;Facade&amp;nbsp;안에&amp;nbsp;명시적으로&amp;nbsp;두는&amp;nbsp;게&amp;nbsp;가독성이&amp;nbsp;좋다&quot;&amp;nbsp;라는&amp;nbsp;논리도&amp;nbsp;일리가&amp;nbsp;있다.&amp;nbsp;내&amp;nbsp;결정이&amp;nbsp;맞다&amp;nbsp;가&amp;nbsp;아니라&amp;nbsp;내&amp;nbsp;기준에서&amp;nbsp;정합적이다&amp;nbsp;가&amp;nbsp;더&amp;nbsp;정확한&amp;nbsp;표현일&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;h2 data-ke-size=&quot;size26&quot;&gt;마지막으로...&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;기준이&amp;nbsp;만능은&amp;nbsp;아니다.&amp;nbsp;위에&amp;nbsp;적은&amp;nbsp;흔들림들이&amp;nbsp;그&amp;nbsp;증거다.&lt;br /&gt;&lt;br /&gt;그럼에도&amp;nbsp;&quot;진입점이&amp;nbsp;늘어날&amp;nbsp;때&amp;nbsp;규칙이&amp;nbsp;따라가야&amp;nbsp;하는가&quot;&amp;nbsp;라는&amp;nbsp;질문은&amp;nbsp;&quot;복잡도를&amp;nbsp;어디서&amp;nbsp;흡수할&amp;nbsp;것인가&quot;&amp;nbsp;라는&amp;nbsp;더&amp;nbsp;큰&amp;nbsp;질문의&amp;nbsp;작은&amp;nbsp;버전이라고&amp;nbsp;생각한다.&amp;nbsp;도메인&amp;nbsp;위쪽에&amp;nbsp;둔&amp;nbsp;규칙은&amp;nbsp;진입점이&amp;nbsp;늘면&amp;nbsp;자동으로&amp;nbsp;적용되고,&amp;nbsp;유스케이스&amp;nbsp;쪽에&amp;nbsp;둔&amp;nbsp;규칙은&amp;nbsp;진입점이&amp;nbsp;늘면&amp;nbsp;복제된다.&amp;nbsp;시간이&amp;nbsp;갈수록&amp;nbsp;이&amp;nbsp;차이가&amp;nbsp;코드의&amp;nbsp;결을&amp;nbsp;결정한다.&lt;br /&gt;&lt;br /&gt;다음에&amp;nbsp;같은&amp;nbsp;결정&amp;nbsp;앞에&amp;nbsp;서면,&amp;nbsp;나는&amp;nbsp;다시&amp;nbsp;같은&amp;nbsp;자문을&amp;nbsp;할&amp;nbsp;것&amp;nbsp;같다.&amp;nbsp;그리고&amp;nbsp;또&amp;nbsp;잠깐은&amp;nbsp;흔들릴&amp;nbsp;것&amp;nbsp;같다.&lt;br /&gt;&lt;br /&gt;&quot;이&amp;nbsp;규칙은,&amp;nbsp;진입점이&amp;nbsp;바뀌어도&amp;nbsp;같은가?&quot;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/214</guid>
      <comments>https://hy-ung.tistory.com/214#entry214comment</comments>
      <pubDate>Fri, 29 May 2026 12:52:40 +0900</pubDate>
    </item>
    <item>
      <title>MongoDB 쿼리에서 대시보드로: AI와 함께 만든 시청 데이터 관측 도구</title>
      <link>https://hy-ung.tistory.com/213</link>
      <description>&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 data-ke-size=&quot;size16&quot;&gt;AI가 있어서 시작할 수 있었다.&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;문제의식과 업무 맥락을 설명하면 AI가 기능을 나누고 구현 순서를 잡는 데 도움을 줬다.&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;span&gt;&amp;nbsp;&lt;/span&gt;Observatory다.&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;1120&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QASMC/dJMcagZSmeB/1Qdr0udhmSDnmJTCVxZEK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QASMC/dJMcagZSmeB/1Qdr0udhmSDnmJTCVxZEK1/img.png&quot; data-alt=&quot;반복 쿼리에서 업무 대시보드로 바뀐 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QASMC/dJMcagZSmeB/1Qdr0udhmSDnmJTCVxZEK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQASMC%2FdJMcagZSmeB%2F1Qdr0udhmSDnmJTCVxZEK1%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;1120&quot; height=&quot;520&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;520&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;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 앱 화면 캡처는 외부 공개를 위해 실제 수치, 개인 식별 정보, 랭킹/테이블 데이터 영역을 블러 처리했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 만들었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 회사는 교육 콘텐츠를 다루고 있고, 수강생의 시청 기록은 여러 시스템에 쌓인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영성 데이터는 RDB에, 날짜별&amp;middot;콘텐츠별 세부 기록은 MongoDB에 저장되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 내가 자주 확인해야 하는 데이터가 대부분 MongoDB 쪽에 있었다는 점이다.&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;MongoDB Atlas에서 직접 쿼리하면 볼 수는 있었다.&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;table style=&quot;border-collapse: collapse; width: 100%; height: 95px;&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;height: 19px;&quot;&gt;&lt;b&gt;이전 방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;Observatory로 바꾼 뒤&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;MongoDB Atlas에서 직접 쿼리 작성&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;기간, 그룹, 강의, 상품, 멤버 기준으로 필터 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;결과 표를 보고 직접 흐름 해석&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;일별 추이 그래프와 랭킹으로 흐름 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;다음 질문이 생기면 다시 쿼리 작성&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;랭킹 항목 클릭으로 바로 필터 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;커리큘럼과 시청 기록을 수동 비교&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;미시청 콘텐츠와 남은 시간을 한 화면에서 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 화면을 만들었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Observatory는 대시보드에서 출발했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 7일, 14일, 30일 기준으로 총 시청시간, 활성 멤버 수, 일별 추이, 멤버그룹 Top 10을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그룹 막대를 클릭하면 해당 그룹 안의 멤버와 강의 Top 5도 이어서 확인할 수 있다.&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;3448&quot; data-origin-height=&quot;1904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W8K3J/dJMcaaZGD57/qQpz26DlGcnwYFf5FvsOZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W8K3J/dJMcaaZGD57/qQpz26DlGcnwYFf5FvsOZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W8K3J/dJMcaaZGD57/qQpz26DlGcnwYFf5FvsOZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW8K3J%2FdJMcaaZGD57%2FqQpz26DlGcnwYFf5FvsOZk%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;3448&quot; height=&quot;1904&quot; data-origin-width=&quot;3448&quot; data-origin-height=&quot;1904&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Mfgja/dJMcacwsPVK/GOFmyX9xlt9QYienIstCt0/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Mfgja/dJMcacwsPVK/GOFmyX9xlt9QYienIstCt0/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Mfgja/dJMcacwsPVK/GOFmyX9xlt9QYienIstCt0/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMfgja%2FdJMcacwsPVK%2FGOFmyX9xlt9QYienIstCt0%2Ftfile.svg&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;1120&quot; height=&quot;620&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;620&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;h4 data-ke-size=&quot;size20&quot;&gt;화면확인할 수 있는 것업무에서 좋은 점&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;대시보드&lt;/td&gt;
&lt;td&gt;총 시청시간, 활성 멤버, 일별 추이, 그룹 Top 10&lt;/td&gt;
&lt;td&gt;전체 흐름을 빠르게 파악&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Members&lt;/td&gt;
&lt;td&gt;멤버별 시청 기록과 많이 시청한 멤버&lt;/td&gt;
&lt;td&gt;특정 수강생의 이용량 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MemberGroups&lt;/td&gt;
&lt;td&gt;그룹별 시청 기록과 랭킹&lt;/td&gt;
&lt;td&gt;고객사&amp;middot;조직 단위 흐름 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Courses&lt;/td&gt;
&lt;td&gt;강의별 랭킹, 멤버별 랭킹&lt;/td&gt;
&lt;td&gt;인기 강의와 많이 수강한 사람 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Products&lt;/td&gt;
&lt;td&gt;상품별 랭킹, 상품 안의 강의 랭킹&lt;/td&gt;
&lt;td&gt;패키지나 상품 단위 성과 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contents&lt;/td&gt;
&lt;td&gt;콘텐츠별 기록, 미시청 콘텐츠&lt;/td&gt;
&lt;td&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘텐츠 회사에서 &amp;ldquo;무엇이 많이 소비되고 있는가&amp;rdquo;는 자연스럽게 궁금해지는 질문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 멤버, 멤버그룹, 강의, 상품 화면에 총 재생 시간 기준 Top 5, Top 10, Top 20 랭킹을 넣었다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1954&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cCYjST/dJMb99NdeWl/MwaYgFa8lQUMidNcmvkAF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cCYjST/dJMb99NdeWl/MwaYgFa8lQUMidNcmvkAF1/img.png&quot; data-alt=&quot;Courses 랭킹 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cCYjST/dJMb99NdeWl/MwaYgFa8lQUMidNcmvkAF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcCYjST%2FdJMb99NdeWl%2FMwaYgFa8lQUMidNcmvkAF1%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;3000&quot; height=&quot;1954&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1954&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Courses 랭킹 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 업무적인 기능: 미시청 콘텐츠&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B2B 교육 서비스에서 수강 데이터는 민감하다.&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;ldquo;어느 구간을 보지 않았는가&amp;rdquo;는 자주 확인해야 하는 정보다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1841&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LVIXS/dJMcad28p5U/rHNtdaDj2ixivW7GczHs5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LVIXS/dJMcad28p5U/rHNtdaDj2ixivW7GczHs5k/img.png&quot; data-alt=&quot;Contents 시청기록 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LVIXS/dJMcad28p5U/rHNtdaDj2ixivW7GczHs5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLVIXS%2FdJMcad28p5U%2FrHNtdaDj2ixivW7GczHs5k%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;3000&quot; height=&quot;1841&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1841&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Contents 시청기록 화면&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SyvVF/dJMcagS9Zu9/EKkBo1yR5wiXdjKkpP0sCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SyvVF/dJMcagS9Zu9/EKkBo1yR5wiXdjKkpP0sCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SyvVF/dJMcagS9Zu9/EKkBo1yR5wiXdjKkpP0sCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSyvVF%2FdJMcagS9Zu9%2FEKkBo1yR5wiXdjKkpP0sCK%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;1120&quot; height=&quot;420&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;420&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;그래서 콘텐츠 화면에&lt;span&gt;&amp;nbsp;&lt;/span&gt;미시청 컨텐츠&lt;span&gt;&amp;nbsp;&lt;/span&gt;영역을 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멤버 ID, 강의 ID, 상품 ID를 입력하면 백오피스의 강의 커리큘럼과 MongoDB의 시청 기록을 비교해, 아직 보지 않은 콘텐츠와 남은 시간을 보여준다.&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;ldquo;빠르게 본다&amp;rdquo;만큼 &amp;ldquo;정확하게 본다&amp;rdquo;가 중요하기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현하면서 정리한 것&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Next.js 기반 내부 어드민&lt;/td&gt;
&lt;td&gt;로그인, 페이지 라우팅, 화면 구성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB aggregation&lt;/td&gt;
&lt;td&gt;시청시간 합계, 일별 추이, 랭킹 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;백오피스 API 연결&lt;/td&gt;
&lt;td&gt;ID만 있는 데이터에 그룹명, 강의명, 상품명, 멤버 정보 보강&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환경 선택&lt;/td&gt;
&lt;td&gt;DEV / QA / STAGING에 따라 API와 DB 전환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSV 다운로드&lt;/td&gt;
&lt;td&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;/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;무엇보다 AI 덕분에 &amp;ldquo;이런 게 있으면 좋겠다&amp;rdquo;에서 멈추지 않을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 모든 것을 대신 만들어줬다기보다는, 내가 이미 알고 있던 불편함을 실제 도구로 바꾸는 속도를 만들어줬다.&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;Observatory를 만들면서 느낀 것은 업무 개선이 꼭 거창할 필요는 없다는 점이다.&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;다음에도 비슷한 불편함을 만나면 예전보다 조금 더 빨리 생각할 것 같다.&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;&amp;ldquo;이거, 화면으로 만들 수 있지 않을까?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;div id=&quot;codex-browser-sidebar-comments-root&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>회고/우당탕 개발자 성장기</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/213</guid>
      <comments>https://hy-ung.tistory.com/213#entry213comment</comments>
      <pubDate>Fri, 22 May 2026 17:15:29 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 좋아요, 그 단순해 보이는 기능에서 결정해야 했던 것들</title>
      <link>https://hy-ung.tistory.com/212</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;좋아요 등록&amp;middot;취소는 단순한 INSERT/DELETE 같지만, 멱등성을 제대로 보장하려면 다섯 개의 결정을 거친다. DB unique 제약과 hard delete 같은 결정들이 한 트랜잭션 안에서 도메인 정합성을 어떻게 만드는지 정리한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. POST&lt;span&gt;&amp;nbsp;&lt;/span&gt;/products/{id}/likes&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 줄에서 시작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; 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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EAno0/dJMcadB08Os/SgMZdps8BUhrkycurKmRfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EAno0/dJMcadB08Os/SgMZdps8BUhrkycurKmRfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EAno0/dJMcadB08Os/SgMZdps8BUhrkycurKmRfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEAno0%2FdJMcadB08Os%2FSgMZdps8BUhrkycurKmRfK%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;1778&quot; height=&quot;308&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&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;(user_id, product_id)&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 행을 INSERT 하거나 DELETE 하는 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데도 설계 문서를 쓰는 동안 결정해야 했던 것이 다섯이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멱등성, 좋아요 수의 위치, soft delete 와 unique 의 충돌,&lt;span&gt;&amp;nbsp;&lt;/span&gt;BaseEntity&lt;span&gt;&amp;nbsp;&lt;/span&gt;상속 여부, 그리고 그 모두를 묶는 hard delete 결단.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 그 다섯을 거치며 본 트레이드오프를 정리한다. 정답을 안다고 말하기보다는, 그 시점에 어떤 옵션이 있었고 왜 그것을 골랐는지에 가깝다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 멱등성 &amp;mdash; 같은 요청을 N 번 보내도 결과가 같으려면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요는 사용자 입장에서 &quot;한 번 누르면 끝&quot; 인 행위지만, 네트워크 위에서는 그 한 번이 두세 번 도착할 수 있다. 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;POST&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 두 번 와도 좋아요는 1개여야 하고, 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;DELETE&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 두 번 와도 결과는 &quot;없음&quot; 으로 같아야 한다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 단순하게 봤다. 애플리케이션 단에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;exists&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 번 체크하면 끝날 거라고. 문제는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동시 요청&lt;/b&gt;이다. 두 트랜잭션이 동시에&lt;span&gt;&amp;nbsp;&lt;/span&gt;exists&lt;span&gt;&amp;nbsp;&lt;/span&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;false&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 받고 둘 다 INSERT 를 시도하면 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;(user_id, product_id)&lt;span&gt;&amp;nbsp;&lt;/span&gt;행이 두 개 들어가는 race condition 이 생긴다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이걸 막는 가장 단순한 방어선은 DB 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;UNIQUE&lt;span&gt;&amp;nbsp;&lt;/span&gt;제약이다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779424270088&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE UNIQUE INDEX uk_likes_user_product ON likes (user_id, product_id);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 제약이 있으면 두 번째 INSERT 는 무결성 예외로 떨어지고, 애플리케이션은 그 예외를 잡아 &quot;이미 좋아요가 있다&quot; 로 해석하면 그만이다. 두 단계 방어선이 만들어진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1차 &amp;mdash; 애플리케이션&lt;span&gt;&amp;nbsp;&lt;/span&gt;exists&lt;span&gt;&amp;nbsp;&lt;/span&gt;사전 체크&lt;/b&gt;: 흔한 재요청을 SELECT 한 번으로 흡수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2차 &amp;mdash; DB unique 제약&lt;/b&gt;: 1차에서 못 잡은 race condition 을 막는다&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;흔한 경우는 빠르게, 드문 경우는 안전하게. 흐름으로 그리면 이렇다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div style=&quot;background-color: #fdfdfc;&quot; data-gutter=&quot;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1790&quot; data-origin-height=&quot;2148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBAIre/dJMcaaSWkYE/znxJhWL9W5L2VehX1XkUG1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBAIre/dJMcaaSWkYE/znxJhWL9W5L2VehX1XkUG1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBAIre/dJMcaaSWkYE/znxJhWL9W5L2VehX1XkUG1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBAIre%2FdJMcaaSWkYE%2FznxJhWL9W5L2VehX1XkUG1%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;1790&quot; height=&quot;2148&quot; data-origin-width=&quot;1790&quot; data-origin-height=&quot;2148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DELETE&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;쪽은 더 단순하다. 영향 행 수가 0 이면 &quot;원래 없었음&quot; 으로 흡수한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. 좋아요 수, 어디에 두어야 하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;좋아요 수는 두 곳에서 쓰인다 &amp;mdash; 상품 상세 페이지의 표시와 상품 목록의 정렬 옵션 (&lt;/span&gt;sort=likes_desc&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;).&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cBOK0c/dJMcadhPj1G/TRPICO5i700QL3Wo6KilV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cBOK0c/dJMcadhPj1G/TRPICO5i700QL3Wo6KilV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cBOK0c/dJMcadhPj1G/TRPICO5i700QL3Wo6KilV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcBOK0c%2FdJMcadhPj1G%2FTRPICO5i700QL3Wo6KilV0%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;1778&quot; height=&quot;308&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&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;처음에는 ③ (매번 COUNT) 이 가장 깔끔하다고 봤다. 정규화의 교과서적 형태다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;sort=likes_desc&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 결정을 좁혔다. 모든 상품의 좋아요 수를 알아야 하는데, COUNT 를 매 상품마다 돌리면 인덱스 활용이 거의 불가능하고 좋아요가 늘어날수록 ORDER BY 가 풀스캔에 가까워진다. ② 도 비슷한 사정이지만, 우리가 보관하는 통계가&lt;span&gt;&amp;nbsp;&lt;/span&gt;like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;하나뿐인데 테이블을 하나 더 두는 건 과해 보였다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;남은 건 ① &amp;mdash;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Product.like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼. 정렬 인덱스가 본문에 그대로 걸리고, 상품 목록 조회가 단일 쿼리로 끝난다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779424422496&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE INDEX ix_products_like_count ON products (like_count);&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;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;대신 트레이드오프가 분명하다. 인기 상품의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;like_count&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; 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;b&gt;hot row&lt;/b&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 되고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;likes&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;와의 정합성 책임이 애플리케이션으로 옮겨온다. hot row 는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;UPDATE products SET like_count = like_count + 1&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 원자적 증감으로 안전해지고, 정합성은 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;@Transactional&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; 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;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;4. 취소된 좋아요가 다시 좋아요가 되는 순간 &amp;mdash; soft delete &amp;times; unique 충돌&lt;/h2&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&gt;&amp;nbsp;&lt;/span&gt;BaseEntity&lt;span&gt;&amp;nbsp;&lt;/span&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;deleted_at&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼으로 soft delete 를 한다 &amp;mdash; Brand, Product, Order 모두 이 정책을 따른다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;좋아요도 같은 방식으로 가면 이런 상황이 만들어진다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779424461647&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[지난 주]  user=1, product=10, deleted_at=2026-05-15 09:00   &amp;larr; 취소된 행
[오늘]     user=1, product=10                                  &amp;larr; 같은 사용자가 다시 좋아요&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;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;새 INSERT 가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;(user_id, product_id)&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;unique 제약과 충돌한다. 좋아요는 잠깐 누르고 떼는 행위라 이 시나리오가 드물지 않다.&lt;/span&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;1778&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ca5S3b/dJMcabRMOjO/kI95XK7GI8QejaIfd0FRHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ca5S3b/dJMcabRMOjO/kI95XK7GI8QejaIfd0FRHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ca5S3b/dJMcabRMOjO/kI95XK7GI8QejaIfd0FRHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fca5S3b%2FdJMcabRMOjO%2FkI95XK7GI8QejaIfd0FRHK%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;1778&quot; height=&quot;412&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;412&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;A 와 B 는 DB 차원의 영리한 트릭이지만 각자 비용이 있었다. C 는 표준적이지만 짧은 액션에 무거웠다. 남은 건 D &amp;mdash;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Like&lt;span&gt;&amp;nbsp;&lt;/span&gt;만 hard delete 로 가는 결단이었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;좋아요는 다른 도메인과 달리&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;취소가 자주 일어나고&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이력 보존의 가치가 크지 않다&lt;/b&gt;. 누군가 좋아요 했다가 취소했다는 사실을 시스템이 굳이 기억할 이유가 없다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;5.&lt;span&gt;&amp;nbsp;&lt;/span&gt;Like&lt;span&gt;&amp;nbsp;&lt;/span&gt;만 hard delete &amp;mdash; 그리고&lt;span&gt;&amp;nbsp;&lt;/span&gt;BaseEntity&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 줄을 안 썼다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BaseEntity&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;id&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;createdAt&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;updatedAt&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;deletedAt&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼을 자동 관리한다. 상속하는 순간 그 엔티티는 자동으로 soft delete 의 인프라를 갖는다. 그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Like&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 hard delete 다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;deleted_at&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;updated_at&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; 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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btVgak/dJMcacJ0WD8/hrz9LnCs9qtQKgfnqFSM10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btVgak/dJMcacJ0WD8/hrz9LnCs9qtQKgfnqFSM10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btVgak/dJMcacJ0WD8/hrz9LnCs9qtQKgfnqFSM10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtVgak%2FdJMcacJ0WD8%2Fhrz9LnCs9qtQKgfnqFSM10%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;1778&quot; height=&quot;232&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&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;결국 b 를 골랐다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컬럼은 거짓말을 하지 않아야 한다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;deleted_at&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 있다는 건 &quot;이 엔티티는 soft delete 됩니다&quot; 라는 약속이다. 그 약속을 지키지 않는 컬럼을 남겨두는 게 더 큰 비용이라고 봤다.&lt;/p&gt;
&lt;h4 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Brand 와 Like &amp;mdash; 같은 시스템에서 다른 정책&lt;/h4&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;흥미로운 건,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Brand 는 정반대 길을 골랐다&lt;/b&gt;는 점이다. 브랜드도 이름 unique 제약 때문에 같은 충돌을 겪는데, Brand 는 자동 restore (4 의 옵션 C) 로 풀었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; 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;1778&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsMqUl/dJMcafzWL5T/OkAtMWYn2nsdWIunojNrdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsMqUl/dJMcafzWL5T/OkAtMWYn2nsdWIunojNrdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsMqUl/dJMcafzWL5T/OkAtMWYn2nsdWIunojNrdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsMqUl%2FdJMcafzWL5T%2FOkAtMWYn2nsdWIunojNrdk%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;1778&quot; height=&quot;232&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&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;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;처음에는 두 도메인이 같은 정책을 가져가는 게 &quot;일관성&quot; 이라고 봤다. 그런데 일관성에는 두 종류가 있었다 &amp;mdash; 표면 일관성 (모두 같은 패턴) 과 본질 일관성 (각자의 특성에 맞는 패턴). 다른 정책을 가져가도 &quot;왜 그렇게 선택했나&quot; 의 사고가 일관되면 그것이 본질 일관성이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;6. 정리 &amp;mdash; 단순한 기능에 숨어 있던 결정들&lt;/h2&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;1778&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dfxkxr/dJMcadovcc2/4dOkEfhakW74ywJoKokN3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dfxkxr/dJMcadovcc2/4dOkEfhakW74ywJoKokN3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dfxkxr/dJMcadovcc2/4dOkEfhakW74ywJoKokN3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdfxkxr%2FdJMcadovcc2%2F4dOkEfhakW74ywJoKokN3K%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;1778&quot; height=&quot;430&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;430&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 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;이번에 배운 것&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요구사항 한 줄 &amp;mdash;&lt;span&gt;&amp;nbsp;&lt;/span&gt;sort=likes_desc&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 이 데이터 모델의 큰 결정을 좌우할 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;API 명세는 단순한 출입구 같지만, 그 안쪽 데이터의 형태를 결정한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;모든 도메인이 같은 패턴을 가져가는 게 자동으로 옳은 건 아니다. 깰 때는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이유를 명시하고 받아들일 비용을 솔직히 적어두는 것&lt;/b&gt;이 더 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;다음 단계에서 확인할 것&lt;/h4&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;설계는 거기까지였고, 실제 구현은 다음 주의 과제다. 그때 검증할 가설이 몇 개 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Product.like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 hot row 경합 &amp;mdash; 원자적 UPDATE 로 충분한가, 비관/낙관 락이 필요한가&lt;/li&gt;
&lt;li&gt;existsBy&lt;span&gt;&amp;nbsp;&lt;/span&gt;+ DB unique 의 두 방어선이 동시 요청에서 어떻게 작동하는지&lt;/li&gt;
&lt;li&gt;트래픽이 커진 가상의 미래에&lt;span&gt;&amp;nbsp;&lt;/span&gt;like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 비동기 집계로 분리하는 게 정말 필요한지&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글이 만든 답들이 다음 주에 깨질 수도 있다. 그게 깨졌을 때 또 한 번 정리하는 것이 다음 글의 자리다.&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/212</guid>
      <comments>https://hy-ung.tistory.com/212#entry212comment</comments>
      <pubDate>Fri, 22 May 2026 13:49:38 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 회원 도메인을 TDD로 만들며 &amp;mdash; 1주차 회고</title>
      <link>https://hy-ung.tistory.com/211</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루프팩 첫 주, 회원 도메인 세 가지를 TDD로 구현했다. &lt;br /&gt;잘한 것보다 어색했던 게 더 많아서, 일단 기록부터 남긴다. TDD를 막 시작한 사람, 도메인 검증을 어디 둘지 고민하는 사람에게 참고가 될지도.&lt;br /&gt;(Red 실패하는 테스트 Green 최소 구현 Refactor 코드 정리 다음 사이클)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주에 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3개의 유스케이스를 TDD 사이클 한 바퀴씩 돌렸다.&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;code&gt;POST /api/v1/users&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;내 정보 조회&lt;/b&gt; &lt;code&gt;GET /api/v1/users/me&lt;/code&gt; (이름 마지막 글자 &lt;code&gt;*&lt;/code&gt; 마스킹)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비밀번호 수정&lt;/b&gt; &lt;code&gt;PATCH /api/v1/users/me/password&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 기능은 &lt;code&gt;test &amp;rarr; feat &amp;rarr; refactor&lt;/code&gt; 세 커밋으로 끊어서, 한 사이클이 어디서 끝나는지 git 그래프만 봐도 보이도록 했다.&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;1200&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ujshQ/dJMcabYxmd5/dvi85eEQYNKV4vpW2890T1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ujshQ/dJMcabYxmd5/dvi85eEQYNKV4vpW2890T1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ujshQ/dJMcabYxmd5/dvi85eEQYNKV4vpW2890T1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FujshQ%2FdJMcabYxmd5%2Fdvi85eEQYNKV4vpW2890T1%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;1200&quot; height=&quot;458&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;458&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 곱씹었던 결정 &amp;mdash; 검증 책임의 위치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 한 줄 짜는 데 검증이 다섯 종류가 나왔다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;loginId가 영문/숫자만인가?&lt;/li&gt;
&lt;li&gt;이름이 비어있지 않은가?&lt;/li&gt;
&lt;li&gt;이메일 형식이 맞는가?&lt;/li&gt;
&lt;li&gt;비밀번호가 정책(8~16자, 허용 문자, 생년월일 미포함)을 만족하는가?&lt;/li&gt;
&lt;li&gt;이미 가입된 loginId인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &lt;code&gt;UserService&lt;/code&gt; 한 곳에 다 넣었다가, &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;UserModel.init 형식 검증 loginId &amp;middot; 이름 &amp;middot; 이메일 &quot;도메인 불변식&quot; 이게 깨진 객체는 존재해선 안 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserService 정책 + 컨텍스트 검증 비밀번호 규칙 &amp;middot; 중복 &amp;middot; 인증 &quot;정책은 변한다&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB/외부 의존이 필요한 검증도 여기에 Controller 입출력 매핑만 검증 책임 X &quot;얇게 유지&quot; DTO &amp;harr; 도메인 변환만 담당&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;1619&quot; data-origin-height=&quot;971&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SlRQe/dJMcafzRA1D/z3qWgAIuuNLuFk25XHi631/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SlRQe/dJMcafzRA1D/z3qWgAIuuNLuFk25XHi631/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SlRQe/dJMcafzRA1D/z3qWgAIuuNLuFk25XHi631/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSlRQe%2FdJMcafzRA1D%2Fz3qWgAIuuNLuFk25XHi631%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;1619&quot; height=&quot;971&quot; data-origin-width=&quot;1619&quot; data-origin-height=&quot;971&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;도메인 모델 &amp;mdash; 형식 검증&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;init {
    if (!loginId.matches(LOGIN_ID_REGEX)) throw CoreException(BAD_REQUEST, &quot;...&quot;)
    if (name.isBlank()) throw CoreException(BAD_REQUEST, &quot;...&quot;)
    if (!email.matches(EMAIL_REGEX)) throw CoreException(BAD_REQUEST, &quot;...&quot;)
}&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;&lt;code&gt;UserModel&lt;/code&gt;의 &lt;code&gt;init&lt;/code&gt; 블록에 둔 이유는 단순하다. &lt;b&gt;이 규칙이 깨진 &lt;code&gt;UserModel&lt;/code&gt; 인스턴스는 시스템 어디에도 존재해선 안 되기 때문&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;도메인 서비스 &amp;mdash; 정책/컨텍스트 검증&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Transactional
fun signUp(loginId: String, rawPassword: String, ...): UserModel {
    if (userRepository.findByLoginId(loginId) != null) {
        throw CoreException(CONFLICT, &quot;이미 사용 중인 로그인 ID입니다.&quot;)
    }
    validatePassword(rawPassword, birthDate)
    val user = UserModel(...)
    return userRepository.save(user)
}&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;언제든 바뀔 수 있는 정책&lt;/i&gt;이고, 중복 체크는 &lt;i&gt;Repository가 필요한 컨텍스트 검증&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;TYPE 1 형식 검증 위치 &amp;middot; Model.init 깨지면 객체 자체가 성립하지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TYPE 2 정책 검증 위치 &amp;middot; Service 정책은 변하지만 모델은 변하지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TYPE 3 컨텍스트 검증 위치 &amp;middot; Service DB/외부 의존이 필요한 검증&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;1688&quot; data-origin-height=&quot;932&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k8yxf/dJMcafGDygv/FjgU2bmCxzZQayhgtNu2Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k8yxf/dJMcafGDygv/FjgU2bmCxzZQayhgtNu2Ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k8yxf/dJMcafGDygv/FjgU2bmCxzZQayhgtNu2Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk8yxf%2FdJMcafGDygv%2FFjgU2bmCxzZQayhgtNu2Ok%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;1688&quot; height=&quot;932&quot; data-origin-width=&quot;1688&quot; data-origin-height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 번 막혔던 곳 &amp;mdash; 검증 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 작성한 &lt;code&gt;signUp&lt;/code&gt; 흐름은 이랬다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// before
validatePassword(rawPassword, birthDate)        // 1. 비밀번호 정책 검증
if (userRepository.findByLoginId(loginId) != null) { ... }  // 2. 중복 체크
val user = UserModel(...)                       // 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;이미 가입된 사용자가 잘못된 비밀번호로 다시 시도할 때&lt;/b&gt; 응답 메시지가 &lt;code&gt;&quot;비밀번호 형식 오류&quot;&lt;/code&gt; 로 뜨는 게 어색했다. 사용자 입장에서 진짜 원인은 &quot;이미 가입된 ID&quot; 인데, 그게 가려졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서를 뒤집고 나니 두 가지가 좋아졌다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// after &amp;mdash; Refactor 커밋 0dd5742
if (userRepository.findByLoginId(loginId) != null) { ... }  // 1. 중복 먼저
validatePassword(rawPassword, birthDate)        // 2. 정책 검증
val user = UserModel(...)                       // 3. 객체 생성&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;응답이 정직해졌다&lt;/b&gt; &amp;mdash; 진짜 원인이 먼저 보임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;불필요한 정규식&amp;middot;BCrypt 비용이 줄었다&lt;/b&gt; &amp;mdash; 어차피 실패할 요청이면 가장 싼 검증부터.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fail-fast 라는 단어를 책에서만 봤는데, 직접 순서 바꾸고 테스트 돌렸을 때 &quot;아 이게 그거구나&quot; 싶었다. 5줄 순서가 전부였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서 하나 바꿨을 뿐인데 응답이 정직해지고, 불필요한 BCrypt 비용도 같이 사라졌다.&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;900&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vHIis/dJMcabc6Aiz/tUdJXAQda4W5eyydHJtyF0/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vHIis/dJMcabc6Aiz/tUdJXAQda4W5eyydHJtyF0/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vHIis/dJMcabc6Aiz/tUdJXAQda4W5eyydHJtyF0/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvHIis%2FdJMcabc6Aiz%2FtUdJXAQda4W5eyydHJtyF0%2Ftfile.svg&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;900&quot; height=&quot;480&quot; data-origin-width=&quot;900&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;작은 추상화의 큰 효과 &amp;mdash; &lt;code&gt;PasswordEncoder&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이렇게 썼다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class UserService(
    private val passwordEncoder: BCryptPasswordEncoder,  // 구현체에 직접 의존
)&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;Refactor 단계에서 인터페이스로 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class UserService(
    private val passwordEncoder: PasswordEncoder,  // 인터페이스에 의존
)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
class PasswordConfig {
    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄 변화처럼 보이지만 테스트 코드가 달라진다. mockk&amp;lt;PasswordEncoder&amp;gt;() 로 인코딩 결과를 통제할 수 있게 되어, 단위 테스트에서 BCrypt 연산 비용도 사라지고 &quot;이 입력에 이 결과를 반환한다&quot;고 명시적으로 지정할 수 있게 됐다. Refactor 커밋 짜면서 이 변화를 넣을 때 솔직히 처음엔 그냥 인터페이스로 바꾸는 게 뭐가 다른가 싶었는데, 테스트가 훨씬 가벼워지는 걸 보고서야 이해가 됐다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;추상화는 다형성을 위해서가 아니라, 통제 가능성을 위해서 한다&quot;는 말이 이번에 와닿았다.&lt;/p&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3316&quot; data-origin-height=&quot;1016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnYkC1/dJMcafs4ld1/MOXudt7DKMaqtKuZomm2F0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnYkC1/dJMcafs4ld1/MOXudt7DKMaqtKuZomm2F0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnYkC1/dJMcafs4ld1/MOXudt7DKMaqtKuZomm2F0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnYkC1%2FdJMcafs4ld1%2FMOXudt7DKMaqtKuZomm2F0%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;3316&quot; height=&quot;1016&quot; data-origin-width=&quot;3316&quot; data-origin-height=&quot;1016&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;KPT&lt;/h2&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;Keep &amp;mdash; 페이즈 단위 커밋 분리&lt;/b&gt;&lt;br /&gt;PR 리뷰 단위가 명확해지고, &quot;왜 이 코드가 추가되었는지&quot;가 커밋 그래프로 그대로 설명됐다. Refactor 커밋만 따로 보면 &quot;이 주에 뭘 정리했는가&quot;가 한눈에 보여서, 이건 앞으로도 계속 유지하고 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Problem &amp;mdash; Red 단계 한 커밋이 너무 컸다&lt;/b&gt;&lt;br /&gt;회원가입 Red 커밋이 +563줄. stub 프로덕션 코드를 한 번에 다 넣다 보니 &quot;테스트 1개 추가&quot;의 단위가 무너졌다. 진짜 TDD스럽게 한다면 더 잘게 쪼개야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Try &amp;mdash; 다음 사이클부턴 &quot;검증 케이스 1~2개 = Red 1커밋&quot;&lt;/b&gt;&lt;br /&gt;엔드포인트 단위가 아니라 &lt;i&gt;검증 케이스&lt;/i&gt; 단위로 끊어보고 싶다. 그리고 Refactor 단계에서 발견한 인터페이스 추상화는 별도 PR로 분리해도 될지 시도해볼 예정.&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;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ed74Sx/dJMcajoCW5J/ykAXEBkhGZCyb0KLEebL10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ed74Sx/dJMcajoCW5J/ykAXEBkhGZCyb0KLEebL10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ed74Sx/dJMcajoCW5J/ykAXEBkhGZCyb0KLEebL10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fed74Sx%2FdJMcajoCW5J%2FykAXEBkhGZCyb0KLEebL10%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;1672&quot; height=&quot;941&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차 끝났는데 아직 모르겠는 게 더 많다. 그래도 이번 주 테스트 하나하나 돌리면서 건진 게 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;검증 코드 한 줄을 어디 둘지 정하는 건, 결국 &quot;이 테스트가 무엇을 검증해야 하는가&quot;를 먼저 정하는 일이었다. 테스트가 흔들리면 설계가 흔들리고, 설계가 잡히면 테스트도 같이 잡혔다.&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;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/211</guid>
      <comments>https://hy-ung.tistory.com/211#entry211comment</comments>
      <pubDate>Fri, 15 May 2026 13:51:04 +0900</pubDate>
    </item>
    <item>
      <title>[항해 복귀 스터디] 2주차 설계 + 아키텍처 패턴</title>
      <link>https://hy-ung.tistory.com/210</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mock API를 우선 제공해야 하는 필요성에 대한 이해&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock API는 데이터베이스 연결이나 백엔드 처리 없이 정적 응답만 제공합니다. 테스트 및 개발용: 실제 API가 준비되지 않았거나 불완전하거나 불안정한 경우 사용&lt;/p&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;a href=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot;&gt;https://velog.io/@khy226/msw로-모의-서버-만들기&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038251779&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;MSW(Mock Service Worker)로 더욱 생산적인 FE 개발하기&quot; data-og-description=&quot;MSW(Mock Service Worker)는 Service Worker를 이용해 서버를 향한 실제 네트워크 요청을 가로채서(intercept) 모의 응답 (Mocked response)를 보내주는 API Mocking 라이브러리이다.&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot; data-og-url=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/TRDtb/dJMb9cBDhmh/HSWeIc9IBJkqvP41WP57W1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/cZ7dNu/dJMb9iIChr5/fvYRKL4f9vuFOzcnJKYeRk/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/y6vbe/dJMb9kl7Yuy/TEKNzK4MeX4zIxSBoYELYK/img.png?width=1588&amp;amp;height=1328&amp;amp;face=0_0_1588_1328&quot;&gt;&lt;a href=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/TRDtb/dJMb9cBDhmh/HSWeIc9IBJkqvP41WP57W1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/cZ7dNu/dJMb9iIChr5/fvYRKL4f9vuFOzcnJKYeRk/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/y6vbe/dJMb9kl7Yuy/TEKNzK4MeX4zIxSBoYELYK/img.png?width=1588&amp;amp;height=1328&amp;amp;face=0_0_1588_1328');&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;MSW(Mock Service Worker)로 더욱 생산적인 FE 개발하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;MSW(Mock Service Worker)는 Service Worker를 이용해 서버를 향한 실제 네트워크 요청을 가로채서(intercept) 모의 응답 (Mocked response)를 보내주는 API Mocking 라이브러리이다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/20154/&quot;&gt;https://techblog.woowahan.com/20154/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038274874&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;API 모킹으로 테스트를 더 편리하게, Mock Service GUI 소개 | 우아한형제들 기술블로그&quot; data-og-description=&quot;여러분은 프론트엔드의 API 모킹 환경을 어떤 방식으로 구축하고, 사용하고 계신가요? 이 글에서는 API 모킹 환경을 개발자 친화적으로 개선해 나가는 과정을 다루며, 2023 우아한테크콘퍼런스에&quot; data-og-host=&quot;techblog.woowahan.com&quot; data-og-source-url=&quot;https://techblog.woowahan.com/20154/&quot; data-og-url=&quot;https://techblog.woowahan.com/20154/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/7RD5e/dJMb84XTTEH/HChUa70E23APh50kgOAhEK/img.png?width=750&amp;amp;height=410&amp;amp;face=0_0_750_410&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/20154/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://techblog.woowahan.com/20154/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/7RD5e/dJMb84XTTEH/HChUa70E23APh50kgOAhEK/img.png?width=750&amp;amp;height=410&amp;amp;face=0_0_750_410');&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;API 모킹으로 테스트를 더 편리하게, Mock Service GUI 소개 | 우아한형제들 기술블로그&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;여러분은 프론트엔드의 API 모킹 환경을 어떤 방식으로 구축하고, 사용하고 계신가요? 이 글에서는 API 모킹 환경을 개발자 친화적으로 개선해 나가는 과정을 다루며, 2023 우아한테크콘퍼런스에&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;techblog.woowahan.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RESTful한 API 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 REST 아키텍처 스타일을 따르는 API를 말하고, RESTful API는 그 REST 원칙을 충실히 지켜 설계된 이상적인 API를 의미합니다. 모든 RESTful API는 REST API지만, 그 반대는 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RESTful의 주요 특징&lt;/b&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;자원(Resource) 중심&lt;/b&gt; : 모든 것을 자원으로 보고, URI(Uniform Resource Identifier)로 명확하게 식별합니다 (예:&amp;nbsp;/users,&amp;nbsp;/products/123).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표준 HTTP 메서드 활용&lt;/b&gt; : 데이터 생성/조회/수정/삭제(CRUD)를 HTTP 메서드에 매핑합니다 (GET: 조회, POST: 생성, PUT/PATCH: 수정, DELETE: 삭제).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 비저장(Stateless)&lt;/b&gt; : 서버는 클라이언트의 이전 요청 상태를 기억하지 않으며, 각 요청은 독립적입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표현(Representation)&lt;/b&gt; : 클라이언트와 서버는 자원의 상태를 JSON, XML 등의 형식으로 주고받습니다 (Representation of State Transfer)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클라이언트-서버 구조&lt;/b&gt; : 클라이언트와 서버가 분리되어 독립적으로 개발 및 발전할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다양한 아키텍처 패턴에 대한 이해&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;레이어드 아키텍처&lt;/b&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;1️⃣&amp;nbsp;&lt;b&gt;Controller Layer (Presentation Layer)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP 요청/응답 처리&lt;/li&gt;
&lt;li&gt;입력값 검증&lt;/li&gt;
&lt;li&gt;인증/인가 처리&lt;/li&gt;
&lt;li&gt;DTO 변환&lt;/li&gt;
&lt;li&gt;비즈니스 로직 ❌&lt;/li&gt;
&lt;li&gt;DB 접근 ❌&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2️⃣&amp;nbsp;&lt;b&gt;Service Layer (Business Layer)&lt;/b&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;여러 Repository 조합&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3️⃣&amp;nbsp;&lt;b&gt;Repository Layer (Persistence Layer)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 접근&lt;/li&gt;
&lt;li&gt;CRUD 처리&lt;/li&gt;
&lt;li&gt;ORM / Query Builder 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4️⃣&amp;nbsp;&lt;b&gt;Domain / Model Layer (선택적)&lt;/b&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;도메인 규칙&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 48.9535%;&quot;&gt;장점&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;단점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 48.9535%;&quot; rowspan=&quot;2&quot;&gt;유지 보수성&lt;br /&gt;한 레이어 수정 &amp;rarr; 다른 레이어 영향 최소화&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot; rowspan=&quot;2&quot;&gt;과도한 보일러플레이트 &amp;rarr; 작은 프로젝트엔 오히려 복잡&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 48.9535%;&quot; rowspan=&quot;2&quot;&gt;테스트 용이성&lt;br /&gt;service 단위 테스트 쉬우며 repository mock 가능&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot; rowspan=&quot;2&quot;&gt;Service 비대화 &amp;rarr; 도메인 단위로 service 분리 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;헥사고날 아키텍처&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 로직은 중심에 두고, DB&amp;middot;웹&amp;middot;메시지&amp;middot;외부 API 같은 것은 전부 갈아 끼울 수 있게 만든 구조&lt;/p&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;a href=&quot;https://mangkyu.tistory.com/396&quot;&gt;https://mangkyu.tistory.com/396&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1770038289737&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;[Architecture] 헥사고날 아키텍처를 통한 의미 수준과 구현 수준에 대한 이해(semantic and implementation l&quot; data-og-description=&quot;1. 헥사고날 아키텍처에 대하여[ 헥사고날 아키텍처의 도메인 엔티티(Domain Entity) ]우리는 소프트웨어를 개발할 때 어떠한 의미를 갖는 이론적 토대를 바탕으로 개발을 하게 된다. 예를 들어 우리&quot; data-og-host=&quot;mangkyu.tistory.com&quot; data-og-source-url=&quot;https://mangkyu.tistory.com/396&quot; data-og-url=&quot;https://mangkyu.tistory.com/396&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dixYxB/dJMb8WMkIfX/2AUCQYQtZTFFFGwkyoMIW0/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/q9U12/dJMb8SpC4sw/Kj48VPPpSseYzMKcPYSMQk/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/bUqJpF/dJMb8RRM3Yz/8b5YBAsE7lNGyDgkz245vK/img.png?width=956&amp;amp;height=470&amp;amp;face=0_0_956_470&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/396&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://mangkyu.tistory.com/396&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dixYxB/dJMb8WMkIfX/2AUCQYQtZTFFFGwkyoMIW0/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/q9U12/dJMb8SpC4sw/Kj48VPPpSseYzMKcPYSMQk/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/bUqJpF/dJMb8RRM3Yz/8b5YBAsE7lNGyDgkz245vK/img.png?width=956&amp;amp;height=470&amp;amp;face=0_0_956_470');&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;[Architecture] 헥사고날 아키텍처를 통한 의미 수준과 구현 수준에 대한 이해(semantic and implementation l&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;1. 헥사고날 아키텍처에 대하여[ 헥사고날 아키텍처의 도메인 엔티티(Domain Entity) ]우리는 소프트웨어를 개발할 때 어떠한 의미를 갖는 이론적 토대를 바탕으로 개발을 하게 된다. 예를 들어 우리&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ivory-room.tistory.com/91&quot;&gt;https://ivory-room.tistory.com/91&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038299744&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;[Architecture] 헥사고날 아키텍처(Hexagonal Architecture) (ft. 계층형 아키텍처, 클린 아키텍처, DDD)&quot; data-og-description=&quot;헥사고날 아키텍처를 설명하기전에 계층형 아키텍처와 클린 아키텍처, 그리고 도메인 주도 설계(DDD)관련하여 가볍게 짚고 넘어가야한다. 헥사고날 아키텍처는 전통 방식인 계층형 아키텍처의 &quot; data-og-host=&quot;ivory-room.tistory.com&quot; data-og-source-url=&quot;https://ivory-room.tistory.com/91&quot; data-og-url=&quot;https://ivory-room.tistory.com/91&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c8x5tY/dJMb86OWXcw/oXs2NjoSYD4T2zXkXa1js1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/nhgXF/dJMb81GSjtj/HyXZOG6AH5ZprT1LbYR3x1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/KC9Pb/dJMb81fNLuH/eCWYLdXiBgT4KKCsdoPFPk/img.png?width=2000&amp;amp;height=1251&amp;amp;face=0_0_2000_1251&quot;&gt;&lt;a href=&quot;https://ivory-room.tistory.com/91&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ivory-room.tistory.com/91&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c8x5tY/dJMb86OWXcw/oXs2NjoSYD4T2zXkXa1js1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/nhgXF/dJMb81GSjtj/HyXZOG6AH5ZprT1LbYR3x1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/KC9Pb/dJMb81fNLuH/eCWYLdXiBgT4KKCsdoPFPk/img.png?width=2000&amp;amp;height=1251&amp;amp;face=0_0_2000_1251');&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;[Architecture] 헥사고날 아키텍처(Hexagonal Architecture) (ft. 계층형 아키텍처, 클린 아키텍처, DDD)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;헥사고날 아키텍처를 설명하기전에 계층형 아키텍처와 클린 아키텍처, 그리고 도메인 주도 설계(DDD)관련하여 가볍게 짚고 넘어가야한다. 헥사고날 아키텍처는 전통 방식인 계층형 아키텍처의&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ivory-room.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&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;&lt;b&gt;클린 아키텍처&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://daryeou.tistory.com/280#google_vignette&quot;&gt;https://daryeou.tistory.com/280#google_vignette&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038300893&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;클린 아키텍처(Clean Architecture) 개념 및 원칙&quot; data-og-description=&quot;개발이란 마치 여러 개의 기반이 되는 블록을 만들어 설계 원칙에 따라 조립하여 완성해 나아가는 과정이라고 생각합니다. 여기서 설계 원칙은 수 많은 디자인 패턴들을 의미하며, 이번 주는 아&quot; data-og-host=&quot;daryeou.tistory.com&quot; data-og-source-url=&quot;https://daryeou.tistory.com/280#google_vignette&quot; data-og-url=&quot;https://daryeou.tistory.com/280&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/tI0qQ/dJMb9jOh8J8/eb7iZWSV3RhTKUTFCxKIK1/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/FaXkM/dJMb88eVKVu/SKKgJV5MyAofsmDsx0zoAK/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/s2eyH/dJMb9iIChsr/qwOhk2xBHBxbSSvim0y060/img.png?width=1200&amp;amp;height=738&amp;amp;face=0_0_1200_738&quot;&gt;&lt;a href=&quot;https://daryeou.tistory.com/280#google_vignette&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://daryeou.tistory.com/280#google_vignette&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/tI0qQ/dJMb9jOh8J8/eb7iZWSV3RhTKUTFCxKIK1/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/FaXkM/dJMb88eVKVu/SKKgJV5MyAofsmDsx0zoAK/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/s2eyH/dJMb9iIChsr/qwOhk2xBHBxbSSvim0y060/img.png?width=1200&amp;amp;height=738&amp;amp;face=0_0_1200_738');&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;클린 아키텍처(Clean Architecture) 개념 및 원칙&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발이란 마치 여러 개의 기반이 되는 블록을 만들어 설계 원칙에 따라 조립하여 완성해 나아가는 과정이라고 생각합니다. 여기서 설계 원칙은 수 많은 디자인 패턴들을 의미하며, 이번 주는 아&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;daryeou.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&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://tech.kakaopay.com/post/home-hexagonal-architecture/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tech.kakaopay.com/post/home-hexagonal-architecture/&lt;/a&gt; 이런글을 공유를 받았는데 공감이 되면서 슬픈 사실인것 같다는 생각을 했다.&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/210</guid>
      <comments>https://hy-ung.tistory.com/210#entry210comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:23:14 +0900</pubDate>
    </item>
    <item>
      <title>[항해 복귀 스터디] 1주차 TDD</title>
      <link>https://hy-ung.tistory.com/209</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;10주간 항해를 마치고 부족한 부분을 다시 공부하고자 항해 그 후 스터디에 참여를 하여 스터디를 진행을 하였습니다.&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;해당 내용은 항해에서 배웠던 10주간 챕터들 중에서 항해하면서 부족하고 추가로 공부할 필요가 있는 것을 키워드 별로 공부를 진행 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;런던파와 고전파에 대한 이해와 본인의 견해 수립&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런턴파와 고전파의 가장 큰 차이는 테스트에서의 격리(isolation)를 어떻게 정의하고 구현하느냐이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;런던파&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런던파는 테스트 대상 코드가 다른 객체/클래스에 의존할 경우, 이 의존성을 모두 테스트 대역(test double) 으로 대체해야 한다고 본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 대역은 실제 객체 대신 사용하는 &amp;lsquo;단순화된 대체&amp;rsquo; 객체로, 복잡성을 줄이고 테스트를 격리한다.&lt;/p&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;테스트 대상 코드(Class)를 &lt;b&gt;협력자(Collaborator)로부터 완전히 분리&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;실제 객체 대신 &lt;b&gt;mock(모의 객체)&lt;/b&gt; 을 적극적으로 사용&lt;/li&gt;
&lt;li&gt;테스트 실패 시 문제가 &lt;b&gt;반드시 테스트 대상 코드에 있음이 확인됨&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;객체 간 상호작용(interaction)&lt;/b&gt; 를 검증하는 방식 중심&lt;/li&gt;
&lt;li&gt;하나의 클래스만 독립적으로 테스트 하는 구조를 지향&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;public class PaymentService {
    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;

    public PaymentService(PaymentGateway paymentGateway,
                          OrderRepository orderRepository) {
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
    }

    public void pay(long orderId) {
        Order order = orderRepository.find(orderId);
        paymentGateway.charge(order.getAmount());
        orderRepository.markPaid(orderId);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;@ExtendWith(MockitoExtension.class)
class PaymentServiceLondonTest {

    @Mock
    PaymentGateway paymentGateway;

    @Mock
    OrderRepository orderRepository;

    @InjectMocks
    PaymentService paymentService;

    @Test
    void 결제하면_PG와_Repository가_정확히_호출된다() {
        Order order = new Order(1L, 100);
        when(orderRepository.find(1L)).thenReturn(order);

        paymentService.pay(1L);

        verify(orderRepository).find(1L);
        verify(paymentGateway).charge(100);
        verify(orderRepository).markPaid(1L);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; 객체 간 협력 구조가 테스트 대상&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 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;b&gt;공유 상태(shared state)&lt;/b&gt; 를 유발할 수 있는 외부 의존성(예: DB, 파일 시스템)만 테스트 대역을 쓴다&lt;/li&gt;
&lt;li&gt;나머지 협력 객체는 &lt;b&gt;실제 객체(real instance)&lt;/b&gt; 를 사용해 테스트한다&lt;/li&gt;
&lt;li&gt;테스트는 서로 독립적으로 실행되도록 만들고 격리하지만, 의존성 자체는 교체하지 않는다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;class FakePaymentGateway implements PaymentGateway {
    @Override
    public void charge(int amount) {
        // 아무것도 안 함 (항상 성공)
    }
}

class InMemoryOrderRepository implements OrderRepository {
    private final Map&amp;lt;Long, Order&amp;gt; store = new HashMap&amp;lt;&amp;gt;();

    public InMemoryOrderRepository() {
        store.put(1L, new Order(1L, 100, &quot;CREATED&quot;));
    }

    @Override
    public Order find(long id) {
        return store.get(id);
    }

    @Override
    public void markPaid(long id) {
        store.get(id).setStatus(&quot;PAID&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class PaymentServiceClassicalTest {

    @Test
    void 결제하면_주문상태가_PAID가_된다() {
        OrderRepository repo = new InMemoryOrderRepository();
        PaymentGateway gateway = new FakePaymentGateway();

        PaymentService service = new PaymentService(gateway, repo);

        service.pay(1L);

        Order order = repo.find(1L);
        assertEquals(&quot;PAID&quot;, order.getStatus());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;런던파 vs 고전파&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구분 런던파 고전파&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;관심사&lt;/td&gt;
&lt;td&gt;객체 간 &lt;b&gt;호출 관계&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;최종 상태 / 결과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock&lt;/td&gt;
&lt;td&gt;매우 많이 씀&lt;/td&gt;
&lt;td&gt;최소한만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;테스트 실패 이유&lt;/td&gt;
&lt;td&gt;&amp;ldquo;A가 B를 안 불렀음&amp;rdquo;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;결과가 틀림&amp;rdquo;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;리팩토링&lt;/td&gt;
&lt;td&gt;깨지기 쉬움&lt;/td&gt;
&lt;td&gt;비교적 안정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;속도&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;느릴 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;두 분파 차이의 핵심은 &amp;ldquo;격리&amp;rdquo;해석&lt;/b&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;details&gt;
&lt;summary&gt;참고&lt;/summary&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://ohyecloudy.com/pnotes/archives/unit-testing-london-detroit/&quot;&gt;https://ohyecloudy.com/pnotes/archives/unit-testing-london-detroit/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jonghoonpark.com/2023/10/05/단위-테스트의-두-분파&quot;&gt;https://jonghoonpark.com/2023/10/05/단위-테스트의-두-분파&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/details&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;h2 data-ke-size=&quot;size26&quot;&gt;테스트 대역(&lt;b&gt;Test&amp;nbsp;Double&lt;/b&gt;)에 대한 이해&lt;/h2&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;(DB, 외부 API, 결제 모듈 같은 &amp;ldquo;의존성&amp;rdquo;을 진짜로 쓰면 테스트가 느리고 불안정해지니까 대신 넣는 것)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;진짜 대신 써서 테스트를 안정적으로 만들기 위한 모든 가짜들&amp;rdquo; = 테스트 대역&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;381&quot; data-origin-height=&quot;129&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/etehoe/dJMcafk9ULn/attPbS63tkDeLn96O4UuO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/etehoe/dJMcafk9ULn/attPbS63tkDeLn96O4UuO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/etehoe/dJMcafk9ULn/attPbS63tkDeLn96O4UuO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fetehoe%2FdJMcafk9ULn%2FattPbS63tkDeLn96O4UuO1%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;381&quot; height=&quot;129&quot; data-origin-width=&quot;381&quot; data-origin-height=&quot;129&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Dummy&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무 역할도 없는 채우기용 객체&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dummy 객체는 테스트 대상 코드가 정상적으로 실행되도록 타입과 의존성만 충족시키는 용도의 객체이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dummy는 어떤 행동도 수행하지 않으며, 호출되더라도 의미 있는 결과를 만들지 않는다. 또한 테스트의 검증 대상이 아니며, 결과값이나 호출 여부를 확인하는 데 사용되지 않는다.&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;주문을 취소하는 서비스가 있고, 이 서비스는 EmailService와 OrderRepository를 의존한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 cancelOrder()가 delete를 호출하는지만 테스트를 하고 싶은 상황&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public class OrderService {

    private final OrderRepository orderRepository;
    private final EmailService emailService;

    public OrderService(OrderRepository orderRepository,
                        EmailService emailService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
    }

    public void cancelOrder(long orderId) {
        orderRepository.delete(orderId);
        // emailService는 여기서 안 쓰임
    }
}
&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;Dummy 구현&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public class DummyEmailService implements EmailService {
    @Override
    public void sendCancelEmail(long orderId) {
        // 아무것도 하지 않음
    }
}
&lt;/code&gt;&lt;/pre&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 data-ke-size=&quot;size16&quot;&gt;test code&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
void cancelOrder_calls_delete() {
    OrderRepository fakeRepo = new FakeOrderRepository();
    EmailService dummyEmailService = new DummyEmailService();

    OrderService service = new OrderService(fakeRepo, dummyEmailService);

    service.cancelOrder(10L);

    assertTrue(fakeRepo.isDeleted(10L));
}
&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;여기서 dummyEmailService는 호출되든 말든 중요하지 않고, 테스트의 검증 대상이 아니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Stub&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;정해진 값&amp;rdquo;만 돌려주는 객체 Dummy 가 아무것도 하지 않는 객체라면, Stub 은 필요한 값만 돌려주는 객체이다.&lt;/p&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;Stub은 테스트 대상이 외부 의존성(DB, API, 시간, 환경 등)에 의존하지 않도록 하기 위해 사용된다.&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;p data-ke-size=&quot;size16&quot;&gt;특징&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;li&gt;호출 횟수나 인자는 검증하지 않는다.&lt;/li&gt;
&lt;li&gt;테스트는 Stub 이 만든 결과값을 기반으로 판단한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(예시)&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;public class DiscountService {

    private final UserRepository userRepository;

    public DiscountService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public int getDiscount(long userId) {
        User user = userRepository.findById(userId);

        if (user.isVip()) {
            return 30;
        }
        return 10;
    }
}
&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;Stub 구현&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public class VipUserRepositoryStub implements UserRepository {

    @Override
    public User findById(long userId) {
        return new User(userId, true); // 항상 VIP 유저 반환
    }
}
&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;rArr; DB 를 조회하지 않고 항상 vip 유저를 반환하여 테스트 환경을 강제로 vip 상태로 만든다.&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;test code&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
void vipUser_gets_30_percent_discount() {
    UserRepository stub = new VipUserRepositoryStub();
    DiscountService service = new DiscountService(stub);

    int discount = service.getDiscount(1L);

    assertEquals(30, discount);
}
&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;rArr; findById()가 호출됐는지 관심 없으며, 오직 반환된 discount 값만 검증한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Spy&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 여부를 기록하는 객체 Stub 이 값을 통제했다면, Spy 는 무슨 일이 일어났는지 기록 한다.&lt;/p&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;/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;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;li&gt;결과값은 실제 로직에 따른다.&lt;/li&gt;
&lt;li&gt;테스트는 호출여부 + 결과 둘 다 볼 수 있다.&lt;/li&gt;
&lt;/ol&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 class=&quot;java&quot;&gt;&lt;code&gt;public class EmailServiceSpy implements EmailService {

    private int sendCount = 0;

    @Override
    public void sendCancelEmail(long orderId) {
        sendCount++;
        System.out.println(&quot;메일 발송됨&quot;); // 실제 동작
    }

    public int getSendCount() {
        return sendCount;
    }
}
&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;test code&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
void cancelOrder_sends_email() {
    EmailServiceSpy spy = new EmailServiceSpy();
    OrderRepository dummyRepo = new DummyOrderRepository();

    OrderService service = new OrderService(dummyRepo, spy);

    service.cancelOrder(1L);

    assertEquals(1, spy.getSendCount());
}
&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;rArr; 실제 내부 로직이 실행이 되고, 몇번 호출됐는지도 검증한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. Mock&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행위까지 검증하는 객체 Stub 은 값을 검증하고, Mock 은 행동을 검증한다.&lt;/p&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;/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;li&gt;테스트가 끝나면 자동으로 검증을 한다.&lt;/li&gt;
&lt;li&gt;행위기반(Behavior-based) 테스트&lt;/li&gt;
&lt;/ol&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 class=&quot;routeros&quot;&gt;&lt;code&gt;EmailService emailMock = mock(EmailService.class);

OrderService service =
    new OrderService(dummyRepo, emailMock);

service.cancelOrder(1L);

verify(emailMock).sendCancelEmail(1L);
&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;rArr; 메일이 실제로 보내졌는지가 아니라 오직 sendCancelEmail 이 호출됐는지만 검증을 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. Fake&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜처럼 동작하지만 단순한 구현&lt;/p&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;실제 구현(DB, Redis, 외부 API)이 너무 무겁거나 느릴 때, 테스트에서 &amp;ldquo;진짜처럼&amp;rdquo; 여러 로직을 실행해야 할 때 Fake 가 사용된다.&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;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;li&gt;여러 메서드가 서로 영향을 준다.&lt;/li&gt;
&lt;li&gt;Mock 처럼 호출 규칙을 검증하지 않는다.&lt;/li&gt;
&lt;li&gt;Stub 보다 훨씬 많은 로직을 가진다.&lt;/li&gt;
&lt;/ol&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 class=&quot;routeros&quot;&gt;&lt;code&gt;public interface UserRepository {
    void save(User user);
    User findById(long id);
}
&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;Fake&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;public class FakeUserRepository implements UserRepository {

    private Map&amp;lt;Long, User&amp;gt; store = new HashMap&amp;lt;&amp;gt;();

    @Override
    public void save(User user) {
        store.put(user.getId(), user);
    }

    @Override
    public User findById(long id) {
        return store.get(id);
    }
}
&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;rArr; 실제 DB 가 없고 메모리 Map 을 사용하며 동작은 진짜 Repository 와 동일하다.&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;test code&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
void user_can_be_saved_and_loaded() {
    UserRepository fakeRepo = new FakeUserRepository();
    UserService service = new UserService(fakeRepo);

    User user = new User(1L, &quot;user&quot;);
    service.register(user);

    User loaded = service.find(1L);

    assertEquals(&quot;user&quot;, loaded.getName());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;참고&lt;/summary&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://jonghoonpark.com/2023/12/05/test-double-for-well-grounded-java-developer&quot;&gt;https://jonghoonpark.com/2023/12/05/test-double-for-well-grounded-java-developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://beststar-1.tistory.com/29&quot;&gt;https://beststar-1.tistory.com/29&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/details&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단위 테스트와 통합 테스트의 차이와 장단점에 대한 이해&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단위테스트(&lt;b&gt;Unit Test&lt;/b&gt;)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스 하나를 고립시켜서 로직만 검증한다.&lt;/p&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;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;실행 속도가 매우 빠름&lt;/td&gt;
&lt;td&gt;수백~수천 개도 CI에서 몇 초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;실패 원인이 명확함&lt;/td&gt;
&lt;td&gt;어떤 클래스/메서드가 문제인지 바로 앎&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;리팩토링 안전망&lt;/td&gt;
&lt;td&gt;내부 구조 바꿔도 기능 깨졌는지 바로 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;Spring 없이 실행 가능&lt;/td&gt;
&lt;td&gt;IDE에서 바로 실행 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;TDD에 적합&lt;/td&gt;
&lt;td&gt;설계가 자연스럽게 좋아짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;DB, JPA, 트랜잭션 검증 불가&lt;/td&gt;
&lt;td&gt;쿼리, 매핑 오류는 잡지 못함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;실제 Bean 구성과 다를 수 있음&lt;/td&gt;
&lt;td&gt;@Autowired, @Qualifier 오류 못 잡음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;Mock이 많아질수록 테스트가 복잡&lt;/td&gt;
&lt;td&gt;유지보수 비용 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;실제 서비스 동작&amp;rdquo; 보장은 못함&lt;/td&gt;
&lt;td&gt;통과했는데 API는 깨질 수 있음&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;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public class OrderService {
    private final DiscountPolicy discountPolicy;

    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }

    public int calculatePrice(int price) {
        return price - discountPolicy.discount(price);
    }
}
&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;test code&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    DiscountPolicy discountPolicy;

    @InjectMocks
    OrderService orderService;

    @Test
    void 할인정책이_적용된_가격을_계산한다() {
        when(discountPolicy.discount(10000)).thenReturn(1000);

        int result = orderService.calculatePrice(10000);

        assertThat(result).isEqualTo(9000);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;통합테스트(&lt;b&gt;Integration Test&lt;/b&gt;)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 전체를 띄우고 실제 빈, 실제 DB와 함께 테스트&lt;/p&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;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;실제 Spring Bean 조합 검증&lt;/td&gt;
&lt;td&gt;설정 오류, DI 오류를 잡아줌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;JPA, 쿼리, 매핑 검증&lt;/td&gt;
&lt;td&gt;실무 장애의 대부분을 미리 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;트랜잭션 동작 확인&lt;/td&gt;
&lt;td&gt;commit/rollback 문제 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;API 단위 검증 가능&lt;/td&gt;
&lt;td&gt;프론트와의 계약 테스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;실행 속도가 느림&lt;/td&gt;
&lt;td&gt;Spring + DB 기동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;실패 원인 추적이 어려움&lt;/td&gt;
&lt;td&gt;어느 계층이 문제인지 불명확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;테스트 데이터 관리 필요&lt;/td&gt;
&lt;td&gt;초기화, 롤백, 시드 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;CI 비용 큼&lt;/td&gt;
&lt;td&gt;Testcontainer, Docker 필요할 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@SpringBootTest
@Transactional
class OrderServiceIntegrationTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void 주문이_DB에_저장된다() {
        Order order = new Order(10000);

        orderService.placeOrder(order);

        Order saved = orderRepository.findById(order.getId()).get();
        assertThat(saved.getPrice()).isEqualTo(10000);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;좋은 테스트 코드에 대한 이해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://toss.tech/article/test-strategy-server&quot;&gt;https://toss.tech/article/test-strategy-server&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Fast&lt;/b&gt;: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Independent&lt;/b&gt;: 각각의 테스트는 독립적이며 서로 의존해서는 안 됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Repeatable&lt;/b&gt;: 어느 환경에서도 반복 가능해야 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Self-Validating&lt;/b&gt;: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Timely&lt;/b&gt;: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 함&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tech.inflab.com/20230404-test-code/#테스트-코드를-왜-작성하는-것인가&quot;&gt;https://tech.inflab.com/20230404-test-code/#테스트-코드를-왜-작성하는-것인가&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jojoldu.tistory.com/674&quot;&gt;https://jojoldu.tistory.com/674&lt;/a&gt;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/209</guid>
      <comments>https://hy-ung.tistory.com/209#entry209comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:15:57 +0900</pubDate>
    </item>
    <item>
      <title>pub/sub 로직 개선하기</title>
      <link>https://hy-ung.tistory.com/208</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;pub/sub 로직 개선했던 서비스에서 간단하게 배경 설명을 한다면,,&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1626&quot; data-origin-height=&quot;278&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1zKG4/btsOQhmaWOU/SuckIxGVUIIfkPrvO2mx00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1zKG4/btsOQhmaWOU/SuckIxGVUIIfkPrvO2mx00/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1zKG4/btsOQhmaWOU/SuckIxGVUIIfkPrvO2mx00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1zKG4%2FbtsOQhmaWOU%2FSuckIxGVUIIfkPrvO2mx00%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;1626&quot; height=&quot;278&quot; data-origin-width=&quot;1626&quot; data-origin-height=&quot;278&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;수강신청이라는 서비스가 있다.&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;b2e 화면에서 수강생들이 수강신청을 진행을 하면 backoffice 에서 관리자가 해당 상품에 대해서 수강신청 확정 버튼을 클릭해 수강생들이 신청한 강의들을 확정처리하여 강의를 들을 수 있게 하는 서비스가 있다.&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;이 서비스는 처음에 batch 로 작업이 진행이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;batch 로 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;하지만 이 서비스의 확정 처리 기능은 빈번하게 발생하는 작업이 아니였고 비정기적으로 진행이 되는 작업이였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비정기적으로 작업이 되는 서비스에 batch 는 과한 스펙이 되었다.&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;그래서 이 확정처리 기능은 batch 에서 pub/sub 으로 변경이 되었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1166&quot; data-origin-height=&quot;278&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bo7I5B/btsOSeacB6Y/pVz4tXW8z3tpyf4Y03fAu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bo7I5B/btsOSeacB6Y/pVz4tXW8z3tpyf4Y03fAu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bo7I5B/btsOSeacB6Y/pVz4tXW8z3tpyf4Y03fAu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbo7I5B%2FbtsOSeacB6Y%2FpVz4tXW8z3tpyf4Y03fAu0%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;688&quot; height=&quot;164&quot; data-origin-width=&quot;1166&quot; data-origin-height=&quot;278&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pub/sub 은 이렇게 구현을 하였다.&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;pub/sub 을 통해서 수강신청 확정 버튼을 클릭할때마다 확정 처리 기능이 진행이 되었다.&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;1080&quot; data-origin-height=&quot;362&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dEMVJR/btsOSip7cDW/z6AM9sJwX02K0be66oYZAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dEMVJR/btsOSip7cDW/z6AM9sJwX02K0be66oYZAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dEMVJR/btsOSip7cDW/z6AM9sJwX02K0be66oYZAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdEMVJR%2FbtsOSip7cDW%2Fz6AM9sJwX02K0be66oYZAk%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;590&quot; height=&quot;198&quot; data-origin-width=&quot;1080&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;pub/sub 에서 Run 으로 message 로 확정 처리 대상이 되는 id 값을 던지지만 run 에서 run job 을 호출할때 해당 메시지를 전달하지 않고 있다라는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;run job 에서는 전체 수강신청 상품 대상으로 조회한뒤 해당 되는 상품을 찾아 확정 처리 작업을 진행하고 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;464&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RzWmY/btsOSOozcRP/QVKvxE5Blczqk5ZlhqkCf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RzWmY/btsOSOozcRP/QVKvxE5Blczqk5ZlhqkCf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RzWmY/btsOSOozcRP/QVKvxE5Blczqk5ZlhqkCf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRzWmY%2FbtsOSOozcRP%2FQVKvxE5Blczqk5ZlhqkCf1%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;608&quot; height=&quot;275&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;464&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;run 에서 run job 을 호출할때 pub/sub 에서 받은 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;그래서 run 의 소스 코드를 한번 확인을 해보았다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;        # Pub/Sub 메시지 추출
        envelope = request.get_json()
        if not envelope or 'message' not in envelope:
            return &quot;Invalid message&quot;, 200

        message = envelope['message']
        data = ''
        if 'data' in message:
            data = base64.b64decode(message['data']).decode('utf-8')

        print(f&quot;Received Pub/Sub message: {data}&quot;)

        # Cloud Run Job 실행
        client = run_v2.JobsClient()
        job_path = client.job_path(project=project_id, location=location, job=job_name)
        run_request = run_v2.RunJobRequest(name=job_path)

        print(f&quot;Triggering Cloud Run Job: {job_path}&quot;)
        operation = client.run_job(request=run_request)&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;message 만 추출하고 바로 run-job 을 호출하는 로직이였다.&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;여기서 message 를 추출한 다음 run-job 호출할때 해당 값을 넣어 주면 되지 않을까? 라는 생각이 들었다.&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;어떤 방법이 있는지 GPT 에게 물어봤다. GPT 는 환경변수 방식, 커맨드 인자 방식 두가지 방법을 알려주었다.&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%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;기준&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;환경변수 사용&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&gt;커맨드 인자 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;간단한 설정값&lt;/b&gt; 전달&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;✅ 권장&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&gt;가능하지만 과함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;보안 정보&lt;/b&gt; (e.g. API 키)&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;❌ 비추천 (Secret Manager 권장)&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&gt;❌ 비추천&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;실행 시간마다 바뀌는 값 (동적 파라미터)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;✅ 좋음&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&gt;✅ 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;스크립트나 CLI 스타일에 익숙함&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;❌ 적합하지 않음&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&gt;✅ 적합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;가독성 / 로깅이 중요한 경우&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;덜 직관적 (env)&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&gt;✅ 명확하게 로그로 노출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 31.3953%;&quot;&gt;&lt;b&gt;컨테이너 재사용 가능성&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4185%;&quot;&gt;✅ 환경에 따라 분기 쉬움&lt;/td&gt;
&lt;td style=&quot;width: 34.0699%;&quot;&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;547&quot; data-start=&quot;502&quot; data-ke-size=&quot;size16&quot;&gt; 권장 방식 -&amp;gt; 일반적으로는 &lt;b&gt;환경변수 방식이 더 유연하고 안전&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;765&quot; data-start=&quot;557&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;600&quot; data-start=&quot;557&quot;&gt;&lt;b&gt;Cloud Run Job의 실행 오버헤드가 적고 설정이 간단&lt;/b&gt;합니다&lt;/li&gt;
&lt;li data-end=&quot;644&quot; data-start=&quot;601&quot;&gt;&lt;b&gt;Dockerfile이나 ENTRYPOINT 수정 없이도 작동&lt;/b&gt; 가능&lt;/li&gt;
&lt;li data-end=&quot;713&quot; data-start=&quot;645&quot;&gt;&lt;b&gt;컨테이너 내부에서 표준 환경변수 접근 (process.env, os.environ)&lt;/b&gt; 방식으로 활용 가능&lt;/li&gt;
&lt;li data-end=&quot;765&quot; data-start=&quot;714&quot;&gt;Cloud Console이나 gcloud UI에서도 &lt;b&gt;시각적으로 관리하기 쉬움&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 두가지 방식에 대해 특징과 권장방식에 대해서 확인을 해보고 실 사용하는 서비스를 각각의 특징에 대입해서 봤을때 환경변수 사용하는 것이 더 적합한 방식이라고 판단이 되어 환경변수 사용하는 방식을 선택한 뒤 run 의 소스 코드를 수정하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1750854311620&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;        # 3. 메시지에서 필요한 값 추출
        try:
            payload = json.loads(data)
        except json.JSONDecodeError:
            logger.warning(&quot;Message is not valid JSON&quot;)
            payload = {}

        registration_product_id = str(payload.get(&quot;registrationProductId&quot;, '')).strip() or None
        product_id = str(payload.get(&quot;productId&quot;, '')).strip() or None

        # 4. Cloud Run Job 실행을 위한 클라이언트 및 job path
        client = run_v2.JobsClient()
        job_path = client.job_path(project=project_id, location=location, job=job_name)

        # 5. 환경변수 오버라이드 설정
        env_vars = []
        if registration_product_id: # message 로 받은 key 값
            env_vars.append(run_v2.EnvVar(name=&quot;REGISTRATION_PRODUCT_ID&quot;, value=registration_product_id))
        if product_id: # message 로 받은 key 값
            env_vars.append(run_v2.EnvVar(name=&quot;PRODUCT_ID&quot;, value=product_id))

        overrides = None
        if env_vars:
            container_override = run_v2.RunJobRequest.Overrides.ContainerOverride(env=env_vars)
            overrides = run_v2.RunJobRequest.Overrides(container_overrides=[container_override])

        # 6. Job 실행 요청 생성
        run_request = run_v2.RunJobRequest(
            name=job_path,
            overrides=overrides
        )&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;message 에서 필요한 값을 추출한 뒤 해당 값을 환경변수 오버라이드로 설정을 하여 job 실행시에 해당 환경변수를 넣어 주었다.&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;이렇게 run 소스 코드를 수정하고 run-job 을 실행하여 환경 변수가 잘 들어오는지 테스트를 해보았다.&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;blob&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;235&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/P98Gj/btsOQ6ROaDl/89o1i8uE6lIJ57MyCQ50sk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/P98Gj/btsOQ6ROaDl/89o1i8uE6lIJ57MyCQ50sk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/P98Gj/btsOQ6ROaDl/89o1i8uE6lIJ57MyCQ50sk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FP98Gj%2FbtsOQ6ROaDl%2F89o1i8uE6lIJ57MyCQ50sk%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;1280&quot; height=&quot;235&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;235&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;run 에서는 로그가 잘 나오고 있어 run-job 에서 확인해보았다.&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;1204&quot; data-origin-height=&quot;106&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBFsvE/btsORAdOI1L/Yrzi8XHsqIqSuc0noknsrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBFsvE/btsORAdOI1L/Yrzi8XHsqIqSuc0noknsrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBFsvE/btsORAdOI1L/Yrzi8XHsqIqSuc0noknsrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBFsvE%2FbtsORAdOI1L%2FYrzi8XHsqIqSuc0noknsrK%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;1204&quot; height=&quot;106&quot; data-origin-width=&quot;1204&quot; data-origin-height=&quot;106&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;input 을 넣어 로그를 확인해보니 잘 노출 되고 있어 환경 변수가 run-job 에도 잘 전달 되고 있는 것을 확인했다.&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;이렇게 환경변수를 활용하여 run 에서 run-job 을 호출할때 해당 값을 전달 할 수 있었다!!&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>
      <category>gcp</category>
      <category>Google Cloud</category>
      <category>google cloud run</category>
      <category>pub/sub</category>
      <category>개선</category>
      <category>개선작업</category>
      <category>노드</category>
      <category>노드개발자</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/208</guid>
      <comments>https://hy-ung.tistory.com/208#entry208comment</comments>
      <pubDate>Wed, 25 Jun 2025 21:44:32 +0900</pubDate>
    </item>
    <item>
      <title>api 속도 개선 해보기</title>
      <link>https://hy-ung.tistory.com/207</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;사내 서비스를 이용하던 중 전체 강의 화면에서 화면 로딩이 2~3초가 걸려서 원인이 궁금했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면 로딩이 2~3 초면 사용자가 이용하기에 화면 로딩이 너무 느리다고 체감이 되고, 서비스 사용하기에 불편함을 느낄것 같았다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2074&quot; data-origin-height=&quot;178&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/onu2E/btsMK0Fni2M/TLf0n6hvk4OqcQMGy4FRSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/onu2E/btsMK0Fni2M/TLf0n6hvk4OqcQMGy4FRSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/onu2E/btsMK0Fni2M/TLf0n6hvk4OqcQMGy4FRSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fonu2E%2FbtsMK0Fni2M%2FTLf0n6hvk4OqcQMGy4FRSk%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;2074&quot; height=&quot;178&quot; data-origin-width=&quot;2074&quot; data-origin-height=&quot;178&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 를 파악하고 제일 응답 속도가 느린 api 를 확인해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제일 응답 속도가 느린 api 는 filters 였고 이 api 응답값을 통해서 프론트에서 화면을 뿌려주고 있었다.&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;filters API 구조를 확인을 해보자!&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;server to server 통신을 하고 있고, 내부에서 많은 연산이 진행이 되고 있었다.&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;2876&quot; data-origin-height=&quot;1840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9OSEv/btsMKitibLy/6rIqIL9h7QPFG1c4Fs2waK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9OSEv/btsMKitibLy/6rIqIL9h7QPFG1c4Fs2waK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9OSEv/btsMKitibLy/6rIqIL9h7QPFG1c4Fs2waK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9OSEv%2FbtsMKitibLy%2F6rIqIL9h7QPFG1c4Fs2waK%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;2876&quot; height=&quot;1840&quot; data-origin-width=&quot;2876&quot; data-origin-height=&quot;1840&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;여기서 연산이 가장 오래 걸리는 것을 파악하기 위해서 &lt;b&gt;&lt;span style=&quot;background-color: #f3c000;&quot;&gt;console.time(), console.timeEnd()&lt;/span&gt;&lt;/b&gt; 두개를 이용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;속도를 확인해보고 싶은 부분 &lt;b&gt;시작점&lt;/b&gt;에 console.time 을 추가했고 &lt;b&gt;끝점&lt;/b&gt;에 console.timeEnd 를 넣어 연산 속도를 확인할 수있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741930500231&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;this.server.addHook('onRequest', (request, reply, done) =&amp;gt; {
  (request.raw as any).startTime = Date.now();
  done();
});
this.server.addHook('onResponse', (request, reply, done) =&amp;gt; {
  const startTime = (request.raw as any).startTime;
  if (startTime) {
    console.log(`Request took ${Date.now() - startTime} ms`);
  }
  done();
});&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 응답 시간을 확인 하기 위해서 preHandler 쪽에 코드를 추가하여 확인 할 수 있었다.&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;첫번째는 server to server 통신을 하고 있던 부분, course ids 를 통한 course 조회하는 부분이였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 대략 1s 정도가 걸렸다.&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;server to server 통신하는 부분 속도 개선하기!&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1726&quot; data-origin-height=&quot;662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/basNyj/btsMMBlfXV1/YAERow1w6Dd7gzJbPlsqGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/basNyj/btsMMBlfXV1/YAERow1w6Dd7gzJbPlsqGK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/basNyj/btsMMBlfXV1/YAERow1w6Dd7gzJbPlsqGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbasNyj%2FbtsMMBlfXV1%2FYAERow1w6Dd7gzJbPlsqGK%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;1726&quot; height=&quot;662&quot; data-origin-width=&quot;1726&quot; data-origin-height=&quot;662&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;server to server 통신하는 부분의 기능을 확인해보니 모든 사용자마다 다른 응답값을 주는 것이 아니라 동일한 값을 응답하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청하는 query 에 따라서 다르게 응답하겠지만 client 에서 요청하는 값을 확인해보니 sortType 만 영향이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sortType 도 &lt;b&gt;newest/popularity&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;매번 사용자마다 해당 api 를 호출하는 것은 비효율적인 것으로 판단이 되어 cache 사용하는 방안으로 개선을 해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742098482685&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let filteredCourses;
if (cache) {
	filteredCourses = cache;
} else {
    filteredCourses = (
      (await this.classFinderClient.getClassFinderApi(`url`, {
        site: Site.LXP,
        price,
        totalPlayTime,
        state,
        sortType,
      })) as {
        data: FtsResponseDto;
      }
    ).data as unknown as { courseId: number; popularity?: number }[];
    this.curationCacheService
      .setCache({ ...query, site: Site.LXP }, filteredCourses)
      .catch((err) =&amp;gt; console.error('curation cache setting error:', err));
}&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;cache 를 조회하여 있다면 redis 에서 값을 가져와서 사용하였고, 없다면 api 호출을 하는 방안으로 진행하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;redis 에 set 할때는 비동기로 처리하였다. redis 에 set 을 하고 그 외의 다른 작업이 없었고, 그 후에 다른 연산 과정이 많기 때문에 동기로 처리할 이유가 없었다.&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;redis key 로 api 호출할때 사용하는 query 를 사용하였다. 윗 부분에 해당 api 호출할때 sortType 만 다르다고 하여 sortType 만 사용해도 될 것 같다고 생각이 들텐데 추후 api 호출할때 다른 값도 사용할 수도 있으니 확장성을 고려하여 query 전체를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742098648274&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class CurationCacheService {
  private cacheService: FastCache;
  readonly ttlInSec: number; //1day

  constructor({ cacheService = defaultCache, ttl = CURATION_CACHE_TTL } = {}) {
    this.cacheService = cacheService;
    this.ttlInSec = ttl;
  }
  static get CURATION_CACHE_KEY() {
    return 'cache';
  }

  async getCache(query: CourseFilterQuery &amp;amp; { site: string }): Promise&amp;lt;filteredCourses[] | null&amp;gt; {
    const session = await this.cacheService.get(`${CurationCacheService.CURATION_CACHE_KEY}:${JSON.stringify(query)}`);
    return session ? (ObjectUtil.deserialize(session) as filteredCourses[]) : null;
  }

  async setCache(query: CourseFilterQuery &amp;amp; { site: string }, value: filteredCourses[]) {
    const key = `${CurationCacheService.CURATION_CACHE_KEY}:${JSON.stringify(query)}`;
    await this.cacheService.set(key, ObjectUtil.serialize(value) as string, this.ttlInSec);
    this.logger.debug(`grant curation cache ok, cacheKey=${key}`);
  }
}&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;윗 부분의 코드는 redis set / get 하는 class 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ttl 은 1 day 로 설정했다. 1 day 를 설정한 이유는 해당 api 호출 값이 빈번하게 바뀔일이 없어서 1 day 로 설정했다.&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;많은 ids 로 db 조회 시 응답 속도 개선하기!&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1838&quot; data-origin-height=&quot;316&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vgvu0/btsMMDXB4L6/zd1vESPDskDh9iu2eAzQjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vgvu0/btsMMDXB4L6/zd1vESPDskDh9iu2eAzQjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vgvu0/btsMMDXB4L6/zd1vESPDskDh9iu2eAzQjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fvgvu0%2FbtsMMDXB4L6%2Fzd1vESPDskDh9iu2eAzQjK%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;1838&quot; height=&quot;316&quot; data-origin-width=&quot;1838&quot; data-origin-height=&quot;316&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;db 조회하는 부분은 위에 표시한 부분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742099614562&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//courseService
async findCoursesByIds(ids: number[]) {
  return this.courseDao.selectByIds(ids);
}

//courseDao
async selectByIds(ids: number[]) {
  return this.repository.findBy({ id: In(ids) });
}&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;이 부분의 코드를 타고 들어가보면 이렇게 typeorm 을 사용하고 있었다.&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;id 에 index 가 걸려있어서 index 를 타서 검색을 할텐데 왜 오래 걸리는지 의문이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #474747; text-align: left;&quot;&gt;&lt;b&gt;explain&lt;/b&gt; 를 사용해서 실제 쿼리가 index 를 타는지 확인을 해보았는데 full scan 을 하고 있었다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #474747; text-align: left;&quot;&gt;알고 보니 검색하려는 &lt;b&gt;id 갯수가 많아서 index 를 타지 않는다는 것&lt;/b&gt;이였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742100345646&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;this.courseService.searchSelectionIds({
  ids: courseIds,
  select: COURSE_SELECTION_IDS,
}),

//courseService
async searchSelectionIds(query: RequestSelectionQuery) {
  return this.courseDao.searchSelectionIds(query);
}

//courseDao
async searchSelectionIds(query: RequestSelectionQuery) {
  return this.repository
    .createQueryBuilder(this.tableName)
    .select(this.buildSelectQuery(this.tableName, query.select))
    .where((qb) =&amp;gt; {
      qb.andWhere(`${this.tableName}.id IN (:ids)`, { ids: query.ids });
    })
    .getRawMany();
}&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 조회할때 필요한 값만 select 하는 방안으로 개선을 했더니 속도가 개선 되는 것을 확인했다.&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;1664&quot; data-origin-height=&quot;494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dwqkzi/btsMNhmejGw/jvNjgUOMPB2EClU8MyYkmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dwqkzi/btsMNhmejGw/jvNjgUOMPB2EClU8MyYkmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dwqkzi/btsMNhmejGw/jvNjgUOMPB2EClU8MyYkmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdwqkzi%2FbtsMNhmejGw%2FjvNjgUOMPB2EClU8MyYkmk%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;1664&quot; height=&quot;494&quot; data-origin-width=&quot;1664&quot; data-origin-height=&quot;494&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;before / after 을 확인해보았을때 확실히 속도가 개선된 것을 확인 할 수 있었다!!&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>
      <category>api 속도 개선</category>
      <category>console.time</category>
      <category>index 타지 않을때</category>
      <category>노드 개발자</category>
      <category>타입스크립트</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/207</guid>
      <comments>https://hy-ung.tistory.com/207#entry207comment</comments>
      <pubDate>Sun, 16 Mar 2025 13:50:17 +0900</pubDate>
    </item>
  </channel>
</rss>