<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>뚝딱 뚝딱 개발일기</title>
    <link>https://newfangled.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 18 Jul 2026 16:50:17 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>경딩</managingEditor>
    <item>
      <title>Spring Data JPA save() vs saveAll(), 진짜 차이는 SQL 이 아니라 트랜잭션 경계였다</title>
      <link>https://newfangled.tistory.com/222</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;부제 : async-profiler&amp;nbsp; Flame Graph 로 직접 파헤쳐 본 다건 저장 성능 차이&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;span&gt;saveAll() 이 빠른 진짜 이유를 설명할 수 있게 된다.&lt;/span&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;span&gt;(&quot;INSERT 한 방&quot;이라는 오해를 정확히 깨뜨릴 수 있다)&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;SimpleJpaRepository.saveAll() 내부 코드를 보고, saveAll도 결국 save()를 반복 호출함을 이해한다.&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;span&gt;트랜잭션 경계가 다건 저장 성능에 미치는 영향을 async-profiler Flame Graph 실측 데이터로 확인한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333; font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;서비스 계층 @Transactional의 의미를 성능 관점에서 이해한다.&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;saveAll()과&amp;nbsp; JDBC Batch insert 를 구분할 수 있게된다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;1. 시작 - 흔한 오해&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;처음 saveAll()을 쓸 때 나도 당연하게 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;save() 여러 번 호출하는 것보다 한 방에 INSERT 날리니까 빠르겠지.&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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;실제로 빠르긴 하다. 그런데 이유가 틀렸다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;직접 확인해봤다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;2.코드부터 보기 - save() vs saveAll() 내부&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;먼저 saveAll() 의 코드를 보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Spring Data JPA의 SimpleJpaRepository 에서 발췌한 내용이다. (버전에 따라 세부 코드는 다를 수 있지만 핵심 구조는 동일하다)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779639110258&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SimpleJpaRepository.java
@Transactional
public &amp;lt;S extends T&amp;gt; List&amp;lt;S&amp;gt; saveAll(Iterable&amp;lt;S&amp;gt; entities) {

	List&amp;lt;S&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();
    
    for (S entity : entities) {
        save(entity);   // &amp;larr; 결국 save()를 호출한다
    }
    return result;
}

@Transactional
public &amp;lt;S extends T&amp;gt; S save(S entity) {
    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
    } else {
        entityManager.merge(entity);
    }
    return entity;
}&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;saveAll() 내부를 보면 루프를 돌며 save()를 하나씩 호출한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;SQL 을 한 방에 날리는 마법 같은 건 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그렇다면 왜 saveAll()이 빠를까?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;두 방식의 진짜 차이는 @Transactional의 범위에 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779639304585&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 방식 1: save() 반복 (서비스 메서드에 @Transactional 없음)
public void saveUsersIndividually(List&amp;lt;String&amp;gt; names) {
    for (String name : names) {
        userRepository.save(new User(name));
        // save()마다 SimpleJpaRepository의 @Transactional이 동작
        // &amp;rarr; 트랜잭션 시작 &amp;rarr; INSERT &amp;rarr; 커밋  이 N번 반복
    }
}

// 방식 2: saveAll()
public void saveUserBatch(List&amp;lt;String&amp;gt; names) {
    List&amp;lt;User&amp;gt; users = names.stream().map(User::new).toList();
    userRepository.saveAll(users);
    // saveAll() 하나의 @Transactional 안에서 전부 처리
    // &amp;rarr; 트랜잭션 시작 &amp;rarr; INSERT &amp;times; N &amp;rarr; 커밋  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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이론적으로는 이렇다. 실제로 얼마나 차이가 나는지 직접 측정해봤다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;3. 성능 측정 - async-profiler + Flame Graph&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;단순 시간 측정은 JVM 워밍업 없이 재면 신뢰하기 어렵다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;async-profiler를 사용해서 CPU 샘플 기반으로 측정했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779639400231&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
public void testPerformanceComparisonWithProfiler() throws Exception {
    profiler = AsyncProfiler.getInstance();

    // JVM 워밍업 (측정 전 필수)
    warmup();

    final int MEASURE_COUNT = 3;
    // ...
    for (int i = 0; i &amp;lt; MEASURE_COUNT; i++) {
        // save() 측정
        profiler.execute(&quot;start,event=cpu,interval=10ms&quot;);
        userService.saveUsersIndividually(names1);
        profiler.execute(&quot;stop,file=save-profile-&quot; + i + &quot;.html&quot;);

        // saveAll() 측정
        profiler.execute(&quot;start,event=cpu,interval=10ms&quot;);
        userService.saveUserBatch(names2);
        profiler.execute(&quot;stop,file=saveAll-profile-&quot; + i + &quot;.html&quot;);
    }
}&lt;/code&gt;&lt;/pre&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;스크린샷 2026-05-25 오전 1.20.44.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;1884&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cU8Z70/dJMcacDgm3e/qj9ouXuK2r1KNEmqjwvjA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cU8Z70/dJMcacDgm3e/qj9ouXuK2r1KNEmqjwvjA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cU8Z70/dJMcacDgm3e/qj9ouXuK2r1KNEmqjwvjA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcU8Z70%2FdJMcacDgm3e%2Fqj9ouXuK2r1KNEmqjwvjA0%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;2666&quot; height=&quot;1884&quot; data-filename=&quot;스크린샷 2026-05-25 오전 1.20.44.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;1884&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-05-25 오전 1.22.30.png&quot; data-origin-width=&quot;2224&quot; data-origin-height=&quot;1886&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4zN1q/dJMcafGKrm4/Q7HAVBqRtxBldoEIJp72TK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4zN1q/dJMcafGKrm4/Q7HAVBqRtxBldoEIJp72TK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4zN1q/dJMcafGKrm4/Q7HAVBqRtxBldoEIJp72TK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4zN1q%2FdJMcafGKrm4%2FQ7HAVBqRtxBldoEIJp72TK%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;2224&quot; height=&quot;1886&quot; data-filename=&quot;스크린샷 2026-05-25 오전 1.22.30.png&quot; data-origin-width=&quot;2224&quot; data-origin-height=&quot;1886&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;측정 조건: 10만 건, 3회 측정 후 대표값 기재, JVM 워밍업 포함&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;br /&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;CPU Samples (대표값)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;save() 반복&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;849&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;saveAll()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;274&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;대표 CPU profile 기준으로 save() 반복 쪽이 saveAll() 보다 약 3.1배 더 많은 CPU sample을 보였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;단 이는 wall-clock 실행 시간이 정확히 3.1배라는 뜻이 아니라, CPU sample 기준으로 약 3.1 배 더 많이 관측된 것이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그런데 왜일까?&lt;/span&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;4. Flame Graph 분석 &amp;mdash; 느린 진짜 이유&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Flame Graph에서 save() 반복 쪽 경로를 보면 이런 프레임이 넓게 잡힌다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779639981307&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UserService.saveUsersIndividually
 └─ $Proxy.save
     └─ TransactionAspectSupport.invokeWithinTransaction
         ├─ createTransactionIfNecessary       &amp;larr; 트랜잭션 시작
         │   └─ JpaTransactionManager.doBegin
         │       └─ HikariDataSource.setAutoCommit
         └─ commitTransactionAfterReturning    &amp;larr; 트랜잭션 커밋
             └─ JpaTransactionManager.doCommit
                 └─ HikariProxyConnection.commit&lt;/code&gt;&lt;/pre&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;스크린샷 2026-05-25 오전 1.30.17.png&quot; data-origin-width=&quot;2362&quot; data-origin-height=&quot;1328&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byCTAF/dJMcaiXGTGQ/CBYMH9WYeXfcVHSBUYOeR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byCTAF/dJMcaiXGTGQ/CBYMH9WYeXfcVHSBUYOeR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byCTAF/dJMcaiXGTGQ/CBYMH9WYeXfcVHSBUYOeR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyCTAF%2FdJMcaiXGTGQ%2FCBYMH9WYeXfcVHSBUYOeR1%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;2362&quot; height=&quot;1328&quot; data-filename=&quot;스크린샷 2026-05-25 오전 1.30.17.png&quot; data-origin-width=&quot;2362&quot; data-origin-height=&quot;1328&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 경로가 save() 1번마다 반복된다. 10만 건이면 이 흐름이 10만번 생성된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이번 실험처럼 서비스 메서드에 @Transacional이 없는 상태에서 save()를 반복 호출하면, Repository save() 의 트랜잭션 경계가 호출 횟수만큼 반복될 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;saveAll()쪽은 다르다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779640514990&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UserService.saveUserBatch
 └─ SimpleJpaRepository.saveAll   &amp;larr; 트랜잭션 1번 시작
     └─ SimpleJpaRepository.save  &amp;larr; N번 반복, 외부 트랜잭션에 참여 (새 트랜잭션 생성 안 함)
         └─ EntityManager.persist
             └─ ActionQueue
                 └─ EntityInsertAction&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;doBegin, doCommit, setAutoCommit 같은 트랜잭션 관리 프레임이 눈에 띄지 않는다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트랜잭션이 딱 1번만 열리기 때문이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;save()가 느린 핵심 원인은 SQL 실행이 아니라, AOP 트랜잭션 처리가&amp;nbsp; N번 반복되는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;5. 그럼 @Transactional을 붙이면 같아질까?&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;여기서 자연스럽게 따라오는 질문이 있다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&quot;서비스 메서드에 @Transactional을 붙이면 save() 반복도 빨라지지 않을까?&quot;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre id=&quot;code_1779640661154&quot; class=&quot;arduino&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional   // &amp;larr; 추가
public void saveUsersIndividually(List&amp;lt;String&amp;gt; names) {
    for (String name : names) {
        userRepository.save(new User(name));
        // 이제 외부 트랜잭션에 참여 &amp;rarr; save() 내부 @Transactional은 새 트랜잭션을 만들지 않고 기존 트랜잭션에 합류
    }
}&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이렇게 하면 save() 반복도 트랜잭션이 1번으로 묶인다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 상태에서 비교하면 save() 와 saveAll()의 성능 차이는 현저히 줄어든다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;결국 saveALL() 이 빠르다는 말은 조건이 붙는다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;서비스 메서드에 @Transactional이 없을 때, saveAll()이 save() 반복보다 빠르다. @Transactional 로 묶이 상태라면 두 방식은 거의 같다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;6. JDBC Batch - 추가로 얻을 수 있는 것&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트랜잭션을 하나로 묶는 것만으로도 큰 차이가 난다. 여기서 한발 더 나아가면 JDBC Batch를 활용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;application.yml에 설정을 추가한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779640881301&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 2
        generate_statistics: true   # 아래 로그 확인용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;batch_size 를 설정하면 Hibernate는 INSERT 를 즉시 보내지 않고, 지정한 수만큼 쌓아뒀다가 한 번에 DB로 전송한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779640956959&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// batch_size=2, 엔티티 3개일 때 내부 동작
ps.setValues(&quot;User 1&quot;); ps.addBatch();   // 큐에 쌓기만
ps.setValues(&quot;User 2&quot;); ps.addBatch();
ps.executeBatch();   // &amp;larr; 1번째 전송 (2개 &amp;rarr; 네트워크 1번)
ps.setValues(&quot;User 3&quot;); ps.addBatch();
ps.executeBatch();   // &amp;larr; 2번째 전송 (1개 &amp;rarr; 네트워크 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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;결과는 generate_statistics 로그에서 확인할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779641009098&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// batch 적용 후 Session Metrics 로그
executing 1 JDBC statements;
executing 2 JDBC batches;   &amp;larr; batch_size=2로 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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;배치 없이 3번의 네트워크 왕복이 2번으로 줄었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;10만 건에 batch_size=1000이면 100번의 왕복으로 끝난다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;주의할 점&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;saveAll() != JDBC batch insert 자동 적용&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;batch_size 설정이 되어 있어야 한다&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;GenerationType.IDENTITY는 사용 불가&lt;/b&gt; &amp;mdash; INSERT 직후 DB가 ID를 생성해야 해서 Hibernate가 배치를 원천 차단한다&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;GenerationType.SEQUENCE&lt;/b&gt; 또는 TABLE을 써야 한다&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779641950132&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)  // &amp;larr; 필수
    private Long id;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;7. 정리&lt;/span&gt;&lt;/h4&gt;
&lt;div&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;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;save() 반복&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;saveAll()&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;내부 동작&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;save() N번 호출&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;save() N번 호출 (동일)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트랜잭션 (@Tx 없을 때)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;N번&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;1번&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;성능 차이 원인&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트랜잭션 AOP 반복&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트랜잭션 1번&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;JDBC Batch 적용&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;@Tx + batch_size + SEQUENCE 필요&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;batch_size + SEQUENCE 필요&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;save()가 느린 이유 우선순위 (이번 실험 기준)&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트랜잭션 반복 비용 &amp;mdash; AOP doBegin/doCommit이 N번 실행 (Flame Graph에서 직접 확인)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;네트워크 왕복 &amp;mdash; batch_size 설정으로 추가 개선 가능 (CPU profile로는 단정 불가)&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&quot;saveAll()이&amp;nbsp;더&amp;nbsp;빠릅니다&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;맞는&amp;nbsp;말이다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&quot;INSERT를&amp;nbsp;한&amp;nbsp;방에&amp;nbsp;날려서요&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;틀린&amp;nbsp;이유다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이번 실험 기준을 saveAll() 이 빠른 진짜 이유는 이거다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&amp;nbsp;saveAll() 은 하나의 @Transactional 안에서 모든 저장을 처리한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;save() 반복은 (서비스 트랜잭션 없이) 호출할 때마다 트랜잭션이 반복된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그 차이가 AOP, JpaTransactionManager, Hikari Connection 비용을 n 배로 키운다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;save() 반복 방식도 서비스 메서드 명에 @Transactional 을 붙이면 saveAll() 과 거의 같은 성능이 나온다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그렇다면&amp;nbsp; saveAll() 을 쓰는 이유는 성능 때문만이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&quot;다건 저장&quot; 이라는 의도를 코드로 명확하게 표현하는데 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;다건 저장 성능을 개선할 때는 &quot;어떤 메서드를 쓸까&quot; 보다 &quot;트랜잭션 경계가 어디에 있는가&quot; 를 먼저 확인하는 것이 맞다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 글은 async-profiler CPU profile과 Flame Graph 실측 데이터를 기반으로 작성되었습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;측정 조건(서비스 메서드 @Transactional 없음)이 결과에 핵심적인 영향을 미쳤으며,&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;표현에서도 &quot;이번 실험 기준&quot;, &quot;이번 조건에서는&quot; 등을 의도적으로 사용했습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;다른 환경에서는 결과가 다를 수 있으므로, 직접 측정해보는 것을 권장합니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <category>JPA</category>
      <category>save</category>
      <category>saveall</category>
      <category>spring data jpa</category>
      <category>sync-profiler CPU profile</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/222</guid>
      <comments>https://newfangled.tistory.com/222#entry222comment</comments>
      <pubDate>Mon, 25 May 2026 02:04:55 +0900</pubDate>
    </item>
    <item>
      <title>질문이 답변을 수정해서는 안 된다 - CQS, 읽히는 코드를 만드는 원칙</title>
      <link>https://newfangled.tistory.com/219</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 동작하는 것만 봤는데, 이번에 왜 이렇게 설계했는지 보려고 했다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;동작은 되는데 찜찜한 코드의 정체&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span&gt;명령과 쿼리를 왜 나눠야 하는지에 대한 실무적인 이유&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;메서드 시그니처만 보고 부수효과를 예측하는 방법&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;CQS 를 단순 개념이 아닌 설계 기준으로 사용하는 방법&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;값 객체와 결합해 부수효과 범위를 좁히는 패턴&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;CQS가 오히려 독이 되는 상황과 그 판단 기준&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 하다 보면 이런 코드를 만나게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1776771730184&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;boolean result = schedule.includes(day);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉으로 보면 &quot;값을 반환하는 함수&quot;다.&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;/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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&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%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&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;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;명령 (Command)&amp;nbsp;&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;쿼리 (Query)&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;CQS - 질문이 답변을 수정해서는 안된다&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Command-Query Separation (CQS)&lt;br /&gt;메서드는 명령이거나 쿼리여야 하고, 둘 다여서는 안된다.&lt;/blockquote&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;명령은 반환값을 가져서는 안 된다.&lt;/b&gt; 상태를 바꾸면 void로 끝낸다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;쿼리는 상태를 바꿔선 안 된다.&lt;/b&gt; 값을 반환하면 읽기 전용이어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이게 중요할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령과 쿼리의 역할이 섞이면, &lt;b&gt;코드를 실행해보기 전까지 결과를 예측할 수 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 구현을 직접 열어보지 않으면 &quot;이 메서드를 부르면 상태가 바뀌는가&quot; 를 알 수 없게 된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나쁜 예시 - includes()가 상태를 바꾸는 순간&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복 일정을 관리하는 Schedule 클래스가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 요구사항이 추가됐다.&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;이걸 구현한 결과가 이렇다.&lt;/p&gt;
&lt;pre id=&quot;code_1776772227525&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public boolean includes(LocalDate day) {
    if (!plan.includes(day)) {
        plan.reschedule(day); // &amp;larr; 상태를 변경!
        return false;
    }
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시그니처만 보면 boolean includes(LocalDate day) - 쿼리처럼 생겼다. &quot;오늘 일정이 있나요&quot; 라고 묻는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제로는 일정을 조정해버린다.&lt;/p&gt;
&lt;pre id=&quot;code_1776772290885&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isFalse(); // false &amp;mdash; 그리고 일정이 조정됨
assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isTrue();  // true &amp;mdash; 방금 조정됐으니까&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; CQS 위반이 만들어내는 함정이다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;리펙토링 - 명령과 쿼리를 분리한다.&lt;/h4&gt;
&lt;pre id=&quot;code_1776772454180&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 쿼리: 값만 반환, 상태 변경 없음
public boolean includes(LocalDate day) {
    return plan.includes(day);
}

// 명령: 상태만 변경, 반환값 없음
public void adjust(LocalDate day) {
    plan.reschedule(day);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이제 includes()를 몇 번 불러도 결과가 항상 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1776772485656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;assertThat(schedule.includes(day)).isFalse();
assertThat(schedule.includes(day)).isFalse();
assertThat(schedule.includes(day)).isFalse(); // 몇 번을 불러도 안전

schedule.adjust(day); // 명시적으로 상태 변경

assertThat(schedule.includes(day)).isTrue();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 여기서 중요한 게 하나 더 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령 전후로 쿼리를 여러 번 호출해도 결과가 동일하다는 보장이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;includes() 를 열 번 부르고 adjust()를 한 번 부른 뒤 다시 includes()를 열 번 불러도, adjust()호출 전까지는 모든 includes() 결과가 동일하다고 확신할 수 있다.&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;이게 단순히 &quot;코드가 깔끔해진다&quot; 를 넘어서, &lt;b&gt;읽는 사람이 머릿속에 추적해야 할 상태의 양이 줄어드는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;메서드 시그니처가 계약이 되는 순간&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CQS 를 팀 전체가 따르면, 메서드 시그니처가 계약서가 된다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;시그니처&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;의미&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;사용방식&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;boolean includes(...)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;쿼리 &amp;mdash; 상태 변경 없음&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;void adjust(...)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;명령 &amp;mdash; 상태 변경 있음&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;신중하게, 의도적으로 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환 타입이 있으면 쿼리다. 부수효과 없이 안전하게 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환 타입이 없으면 명령이다. 부수효과가 있으니 주의가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 처음 보는 사람이 내부를 열지 않고도 이해 할 수&amp;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;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;한 단계 더 - 값 객체와 결합하면 부수효과 범위가 좁아진다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령과 쿼리를 분리해도 문제가 하나 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;schedule.adjust(day)를 호출하면 상태 변경이 일어나는데, 실제 변경은 Schedule이 아니라 WeeklyPlan 또는 MonthlyPlan 내부에서 일어난다.&lt;/p&gt;
&lt;pre id=&quot;code_1776772915329&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;schedule.adjust() 호출
  &amp;rarr; plan.reschedule() 호출
    &amp;rarr; WeeklyPlan 또는 MonthlyPlan 내부 상태 변경&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부수 효과를 이해하려면 세 개의 클래스를 동시에 봐야 한다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지를 받는 객체와 상태가 바뀌는 객체가 다르기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 RecurringPlan을 &lt;b&gt;불변 값 객체&lt;/b&gt;로 만드는 것이다&lt;/p&gt;
&lt;pre id=&quot;code_1776773000494&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 기존: 자신의 상태를 직접 변경 (명령)
public void reschedule(LocalDate day) {
    this.dayOfWeek = day.getDayOfWeek();
    this.ordinal = (day.getDayOfMonth() / DAYS_IN_WEEK) + 1;
}

// 변경 후: 새로운 객체를 반환 (쿼리처럼 동작)
public MonthlyPlan reschedule(LocalDate day) {
    return new MonthlyPlan(
        day.getDayOfWeek(),
        (day.getDayOfMonth() / DAYS_IN_WEEK) + 1
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이제 Schedule.adjust()는 이렇게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1776773100516&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void adjust(LocalDate day) {
    this.plan = plan.reschedule(day); // &amp;larr; 여기서만 상태 변경
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;RecurringPlan 계층은 더 이상 스스로 상태를 바꾸지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부수효과가 Schedule.adjust()하나로 집약된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 코드에서 상태가 어디서 바뀌는가&quot; 라는 질문에 이제 Schedule 클래스 하나만 보면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적으로 어떤 값이 바뀌는지 궁금할 때만&amp;nbsp; RecurringPlan 계층을 들여다보면 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;값 객체는&amp;nbsp; 단순히 불변성을 위한 기술이 아니다. 부수효과의 &lt;b&gt;범위를 명시적으로 좁히는 설계 전략&lt;/b&gt;이다. 부수효과가 어디서 일어나는지 눈에 보이게 만들면, 버그가 생겼을 때 찾아야 하는 코드의 양이 극적으로 줄어든다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;실전 예제 &amp;mdash; 텍스트 어드벤처 게임&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 플레이어의 이동 코드다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776773464769&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public boolean move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        return false;
    }
    this.position = this.position.shift(direction); // 부수효과!
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시그니처만 보면 쿼리처럼 보인다. boolean 을 반환하니까, &quot;동쪽으로 이동할 수 있나요&quot; 라고 묻는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 내부에서는 위치를 바꾸고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1776773524494&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;player.move(Direction.EAST); // 이동하면서 true 반환
player.move(Direction.EAST); // 이미 벽에 닿았으니 false 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&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;br /&gt;&lt;br /&gt;어떤 문제가 발생하는지 알아보기 위해 테스트 케이스를&amp;nbsp;하나&amp;nbsp;만들어&amp;nbsp;보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1776608700273&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    public void day_of_week_in_month_includes() {
        Schedule schedule = new Schedule(
                &quot;월간 회의&quot;, LocalTime.of(13, 0), Duration.ofHours(1),
                new MonthlyPlan(DayOfWeek.MONDAY, 2));

        assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isFalse();
        assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isTrue();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;리팩토링:&lt;/p&gt;
&lt;pre id=&quot;code_1776773578687&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 쿼리: 이동 가능 여부만 확인
public boolean canMove(Direction direction) {
    return !worldMap.isBlocked(position.shift(direction));
}

// 명령: 실제 이동 (이동 불가 시 예외)
public void move(Direction direction) {
    if (!canMove(direction)) {
        throw new IllegalStateException();
    }
    this.position = this.position.shift(direction);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;사용하는 쪽은 이렇게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1776773600634&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void tryMove(Direction direction) {
    if (player.canMove(direction)) {
        player.move(direction);
        showRoom();
    } else {
        showBlocked();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 하나 짚고 넘어갈 것이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누군가는 이렇게 물을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Game이 player에게 canMove()로 물어보고 나서 move()를 시키는 건 묻지말고 시켜라 원칙 위반 아닌가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Player.move() 내부를 보면, canMove()를 호출해서 이동 가능한 경우에만 위치를 변경한다.&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;Game은 그저 &quot;이동해라&quot;고 시킬 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단과 실행의 주체가 Game이 아니라 Player 자신이라는게 핵심이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CQS 를 따를 때 이 패턴은 자주 나온다. 클라이언트가 쿼리로 상태를 확인하고, 명령으로 변경을 요청하는 구조다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CQS의 트레이드오프 &amp;mdash; 원칙이 독이 되는 순간&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CQS를 배우고 나면 모든 메서드에 적용하고 싶어진다. 그런데 현업에서는 이 원칙을 기계적으로 적용했다가 오히려 코드가 나빠지는 경우가 있다.&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;canMove() &amp;rarr; move() 패턴을 보면, canMove() 내부에서 이미 벽 충돌 여부를 계산한다. 그리고 move() 내부에서도 같은 계산을 다시 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1776609300267&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public boolean canMove(Direction direction) {
    return !worldMap.isBlocked(position.shift(direction)); // 계산
}

public void move(Direction direction) {
    if (!canMove(direction)) { // 같은 계산을 다시
        throw new IllegalStateException();
    }
    this.position = this.position.shift(direction);
}&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;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;지금 예제에서는 단순한 위치 계산이라 비용이 거의 없다.&amp;nbsp; 하지만 이 검증 로직이 DB 조회거나 외부 API&amp;nbsp; 호출이라면 이야기가 달라진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;같은 검증을 두 번 수행하는 셈이 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일정 조정이 필요한 경우에는 adjust 명령을 이용해서 명시적으로 상태를 변경시켜야 합니다.&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;pre id=&quot;code_1776773976309&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        throw new IllegalStateException(&quot;이동 불가&quot;);
    }
    this.position = this.position.shift(direction);
}&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;호출하는 쪽은 분기 없이 명령만 던지고, 실패 시 예외로 처리한다. 검증이 한 번만 일어나고, 책임도 Player 안에 있다.&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;물론 이 방식은 CQS 를 엄격하게 따르지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;move()가 성공/실패를 예외로 전달하기 때문에, 호출자가 사전에 가능 여부를 확인 할 수 없다.&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;div&gt;&lt;br /&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 57px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;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;canMove() + move() 분리&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;/div&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;CQS 는 복잡도를 낮추기 위한 도구다. 원칙을 지키기 위해 복잡도가 올라간다면, 그건 도구를 잘 못 쓰는것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;원칙을 처음 배울 때는 &quot;항상 적용해야 한다&quot;고 생각하기 쉽다. 하지만 실무에서는 원칙과 판단을 위한 기준이지, 사고를 대신하는 규칙이 아니다. CQS를 이해했다면 다음 질문은 &quot;어떻게 적용하지&quot; 가 아니라 &quot;지금 이 상황에 분리하는 게 맞는가&quot; 여야 한다.&lt;br /&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;&lt;/span&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;h4 data-ke-size=&quot;size20&quot;&gt;정리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CQS가 주는 것을 한 줄씩 정리하면 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;쿼리는 믿을 수 있다.&lt;/b&gt; 몇 번을 불러도 결과가 같다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;명령은 표시가 난다.&lt;/b&gt; void 시그니처가 여기서 상태가 바뀐다고 선언한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;명령 사이 쿼리는 안전하다.&lt;/b&gt; 명령이 호출되기 전까지 쿼리 결과는 동일하다고 확신할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;값 객체와 결합하면 부수효과 범위가 좁아진다.&lt;/b&gt; 분석해야 할 클래스가 줄어든다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분리가 복잡도를 높인다면 분리하지 않는다.&lt;/b&gt; 원칙은 목적이 아니라 수단이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 코드는 단순히 오해하지 않는 코드가 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽는 사람이 이전 호출과 숨겨진 상태 변화를 머릿속에 계속 추적하지 않아도 되는 코드다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CQS 는 그 인지 비용을 줄이기 위한 설계 원칙이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 명령 쿼리 분리 원칙과 값 객체를 혼합하면 도&lt;br /&gt;수 효과를 이해하기 위해 분석해야 하는 코드의 수를 감소시킬 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>객체지향</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/219</guid>
      <comments>https://newfangled.tistory.com/219#entry219comment</comments>
      <pubDate>Tue, 21 Apr 2026 21:29:23 +0900</pubDate>
    </item>
    <item>
      <title>결제는 중요할까? 주문이 중요할까 - 결제 코드를 읽다가 깨달은 설계의 기준</title>
      <link>https://newfangled.tistory.com/218</link>
      <description>&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 style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;결제 vs 주문 - 무엇이 본질인지 판단하는 기준&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span&gt;&quot;돈이 빠지는 순간&quot; 을 기준으로 시스템을 나누는 방법&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;요구사항을 기능이 아니라 흐름으로 읽는 시각&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;도메인 객체로 로직을 캡슐화하는 패턴 - PaymentDiscount 설계&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;상태, 이력, 실패를 설계하는 방식&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333;&quot;&gt;핵심 요악&lt;/span&gt;&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;결제는 복잡하지만 본질이 아니다. &lt;b&gt;주문이 본질이고, 결제는 그 주문을 완성시키는 수단&lt;/b&gt;이다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 결제 시스템에서 반드시 새겨야 할 한 가지&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;돈이 빠지기 전과 후는 완전히 다른 세계다. 이 경계를 모르면 시스템은 반드시 깨진다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 결제를 공부하면서 처음 든 질문&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 결제 코드를 봤을 때는 API 3개, PG 연동, 할인 계산 정도로 읽혔다. 그냥 외부 API 붙이는 거 아닌가? 싶었는데, 파고들수록 다른 기능들과 성질이 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 조회, 장바구니, 주문 생성은 전부 우리 서버 안에서 끝난다. 실패해도 재시도하면 그만이다. 결제는 다르다. 외부 시스템(PG사) 이 개입하는 순간, 우리가 통제할 수 없는 변수가 들어온다.&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;PG사가 장애나면?&lt;/li&gt;
&lt;li&gt;응답이 오긴 했는데 금액이 다르면?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 걸 생각하다 보니 결제 설계의 핵심이 &quot;성공을 만드는 것&quot;&quot; 이 아니라 &quot;실패를 흡수하는 구조를 만드는 것&quot; 이라는 게 보이기 시작했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 진짜 요구사항은 해피케이스 바깥에 있다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의에서 인상 깊었던 부분이 있다. 커머스 서비스에서 기획서는 보통 이런 식으로 온다고 했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;주문 생성 -&amp;gt; 결제 -&amp;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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PG사가 장애 났을 때 주문이 어떻게 됩니까?&lt;/li&gt;
&lt;li&gt;결제 실패한 주문을 다시 결제할 수 있어야 합니까?&lt;/li&gt;
&lt;li&gt;잔액부족, 한도 초과 실패 이력을 별도로 보관해야 합니까?&lt;/li&gt;
&lt;li&gt;결제 타임아웃이 발생하면 주문 상태는 어떻게 처리합니까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름도만 받아서 구현하면 기능은 돌아간다. 하지만 실제 운영에서 터지는 문제의 대부분은 이 흐름도에 없다는 것, 이게 공부하면서 가장 크게 느낀 점이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;요구사항은 주어지는 것이 아니라, 개발자가 끌어내는 것이다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 세 개의 API 가 말해주는 구조&lt;/h4&gt;
&lt;pre id=&quot;code_1774872371831&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;POST /v1/payments                    &amp;larr; 우리가 받는다 (결제 계획 생성)
POST /v1/payments/callback/success   &amp;larr; PG사가 우리를 호출한다
POST /v1/payments/callback/fail      &amp;larr; PG사가 우리를 호출한다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 그냥 API 3개라고 봤는데, 콜백 두 개는 성질이 다르다. 우리가 호출하는게&amp;nbsp; 아니라 PG사가 결과를 우리에게 알려주는 것이다.&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;그래서 사실 우리가 능동적으로 설계하는 건 1개뿐이고, 나머지 2개는 외부의 언어에 맞춰 열어두는 창구다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 이해하니까 왜 콜백 파라미터가 우리 컨벤션이 아닌 PG사 스펙을 따르는지도 납득이 됐다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 결제 생성 - &quot;의도&quot;를 저장하는 단계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 생성 API 에서 실제 돈은 빠지지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서 본질은&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;이 주문을 어떻게 결제할지 확정하는 것&lt;/blockquote&gt;
&lt;pre id=&quot;code_1774872568400&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostMapping(&quot;/v1/payments&quot;)
fun create(user: User, @RequestBody request: CreatePaymentRequest): ApiResponse&amp;lt;CreatePaymentResponse&amp;gt; {
    val order = orderService.getOrder(user, request.orderKey, OrderState.CREATED)
    val ownedCoupons = ownedCouponService.getOwnedCouponsForCheckout(user, order.items.map { it.productId })
    val pointBalance = pointService.balance(user)
    val id = paymentService.createPayment(
        order = order,
        paymentDiscount = request.toPaymentDiscount(ownedCoupons, pointBalance),
    )
    return ApiResponse.success(CreatePaymentResponse(id))
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보면 순서가 있다. 주문 조회 -&amp;gt; 보유 쿠폰 조회 -&amp;gt; 포인트 잔액 조회.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세가지를 먼저 가져오는 이유는 &lt;b&gt;사용자가 요청한 할인이 실제로 가능한지 검증하기 위해서&lt;/b&gt;다. 그리고 이 검증과 계산을 PaymentDiscount라는 객체에 위임한다.&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;PaymentDiscount&amp;nbsp;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체의 진짜 역할은 단순 계산이 아니다.&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;비즈니스 규칙을 객체 안에 가두는 것&lt;/blockquote&gt;
&lt;pre id=&quot;code_1774872748884&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data class CreatePaymentRequest(
    val orderKey: String,
    val useOwnedCouponId: Long?,
    val usePoint: BigDecimal?,
) {
    fun toPaymentDiscount(ownedCoupons: List&amp;lt;OwnedCoupon&amp;gt;, pointBalance: PointBalance): PaymentDiscount {
        return PaymentDiscount(
            ownedCoupons = ownedCoupons,
            pointBalance = pointBalance,
            useOwnedCouponId = useOwnedCouponId ?: -1,
            usePointAmount = usePoint ?: BigDecimal.valueOf(-1),
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1774872722346&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data class PaymentDiscount(
    private val ownedCoupons: List&amp;lt;OwnedCoupon&amp;gt;,
    private val pointBalance: PointBalance,
    val useOwnedCouponId: Long,
    private val usePointAmount: BigDecimal,
) {
    val couponDiscount: BigDecimal
    val usePoint: BigDecimal

    init {
        // 쿠폰을 쓰겠다고 했으면, 실제 보유 쿠폰 목록에서 찾아서 할인 금액 계산
        // 없거나 유효하지 않으면 예외 &amp;mdash; 서비스 레이어가 이 로직을 알 필요 없음
        couponDiscount = if (useOwnedCouponId &amp;gt; 0) {
            ownedCoupons.firstOrNull { it.id == useOwnedCouponId }
                ?.coupon?.discount ?: throw CoreException(ErrorType.OWNED_COUPON_INVALID)
        } else {
            BigDecimal.ZERO
        }

        // 포인트를 쓰겠다고 했으면, 잔액 초과 여부 먼저 검증
        // 포인트 잔액은 이미 위에서 조회해서 넘겨줬기 때문에 DB 재조회 없음
        usePoint = if (usePointAmount &amp;gt; BigDecimal.ZERO) {
            if (usePointAmount &amp;gt; pointBalance.balance)
                throw CoreException(ErrorType.POINT_EXCEEDS_BALANCE)
            usePointAmount
        } else {
            BigDecimal.ZERO
        }
    }

    fun paidAmount(orderPrice: BigDecimal): BigDecimal {
        val amount = orderPrice - (couponDiscount + usePointAmount)
        // 할인 금액이 주문 금액보다 크면 음수 &amp;mdash; 비즈니스상 허용하지 않음
        if (amount &amp;lt; BigDecimal.ZERO) throw CoreException(ErrorType.PAYMENT_INVALID_AMOUNT)
        return amount
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 이 코드를 보면서 &quot;왜 굳이 클래스로 만들었지?&quot; 라는 생각이 든다. 서비스에서 직접 계산해도 되지 않을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 서비스 레이어에 쿠폰 검증 로직, 포인트 잔액 검증 로직, 최종 금액 계산 로직을 다 넣으면 어느 순간 함수가 50줄을 넘는다. 그리고 할인 정책이 바뀌는 날, 어디를 고쳐야 할지 몰라서 코드 전체를 뒤진다.&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;PaymentDiscount 는 &quot;할인&quot; 이라는 개념을 하나의 경계로 묶은 것이다. 쿠폰과 포인트가 어떻게 계산되는지는 이 객체만 알면 된다. 서비스는 그냥 결과를 받아 쓰면 된다. 할인 정책이 바뀌어도 변경 범위가 이 객체 안으로 제한된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;한 가지 더 눈에 띈 것. paidAmount 에서 0원을 허용하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠폰 + 포인트로 주문 금액이 전부 커버되면 0원 결제가 가능하다는 뜻이다. &quot;0원 결제를 허용할 것인가&quot; 는 비즈니스 결정이고, 코드는 그 결정의 기록이다. 이런 것들이 요구 사항으로 확인돼야 하는 것들이라는 걸 이번에 배웠다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. 결제 성공 - 이 코드에서 가장 중요한 경계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 이 글의 핵심이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;경계&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;승인 전 &amp;rarr; 돈이 안 빠짐&lt;/li&gt;
&lt;li&gt;승인 후 &amp;rarr; 돈이 이미 빠짐&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1774873144007&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ── 여기까지는 고객 돈이 빠지지 않는다 ──
val order = orderRepository.findByOrderKeyAndStateAndStatus(
    orderKey, OrderState.CREATED, EntityStatus.ACTIVE
) ?: throw CoreException(ErrorType.NOT_FOUND_DATA)
// CREATED 상태인지 확인 &amp;mdash; 이미 결제된 주문이 중복 처리되는 걸 막는다

val payment = paymentRepository.findByOrderId(order.id)
    ?: throw CoreException(ErrorType.NOT_FOUND_DATA)

if (payment.userId != order.userId) throw CoreException(ErrorType.NOT_FOUND_DATA)
// 결제건의 유저와 주문의 유저가 같은지 확인 &amp;mdash; 타인의 주문을 결제하는 케이스를 막는다

if (payment.state != PaymentState.READY) throw CoreException(ErrorType.PAYMENT_INVALID_STATE)
// READY가 아니면 이미 처리됐거나 비정상 상태 &amp;mdash; 중복 승인 방지

if (payment.paidAmount != amount) throw CoreException(ErrorType.PAYMENT_AMOUNT_MISMATCH)
// 우리 DB의 결제 금액과 PG사가 실제 청구한 금액이 다르면 위험 신호 &amp;mdash; 반드시 차단

/**
 * NOTE: PG 승인 API 호출 =&amp;gt; 성공 시 다음 로직으로 진행 | 실패 시 예외 발생
 * 이 줄 이후부터 실제로 고객 돈이 빠진다
 */

// ── 여기부터는 고객 돈이 이미 빠졌다 ──
payment.success(externalPaymentKey, PaymentMethod.CARD, approveCode)
order.paid()
// 쿠폰 사용 처리
// 포인트 차감 + 적립
// 이력 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;승인 전 : 얼마든지 검증하고 예외를 던져도 된다. 고객 돈은 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;승인 후: 고객 통장에서 이미 돈이 나갔다. 여기서 예외가 터지면 &quot;돈은 빠졌는데 주문 상태가 완료가 안 된&quot; 최악의 상황이 된다. 커머스 장애 중 상당수가 이 지점에서 발생한다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복구하려면 수동으로 DB를 직접 조작하거나, PG사와 데이터를 대사(reconciliation)해서 불일치 건을 하나씩 찾아야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 승인 이후의 부수 작업(이력 저장 등)은 실패해도 예외를 삼키고 로깅만 하도록 처리하는 게 더 안전할 수 있다. 이력 저장 실패 때문에 결제 완료 처리 자체가 막혀서는 안 되기 때문이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;6. 결제 실패 - 상태가 아니라 기록이다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 실패 처리는 의도적으로 심플하다.&lt;/p&gt;
&lt;pre id=&quot;code_1774873361521&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun fail(orderKey: String, code: String, message: String) {
    val order = orderRepository.findByOrderKeyAndStateAndStatus(
        orderKey, OrderState.CREATED, EntityStatus.ACTIVE
    ) ?: throw CoreException(ErrorType.NOT_FOUND_DATA)
    val payment = paymentRepository.findByOrderId(order.id)
        ?: throw CoreException(ErrorType.NOT_FOUND_DATA)

    // 주문 상태: 변경하지 않는다
    // 결제 상태: 변경하지 않는다
    // 트랜잭션 히스토리에 실패 이력만 남긴다
    transactionHistoryRepository.save(
        TransactionHistoryEntity(
            type = TransactionType.PAYMENT_FAIL,
            ...
            message = &quot;[$code] $message&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;처음 보면 이상하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 변경 없음, 주문도 그대로, 결제 상태도 그대로&amp;nbsp; 대신 &quot;이력&quot;만 남긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패했으면 상태를 PAYMENT_FAILED로 바꿔야 하지 않나?&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;바꾸지 않은 이유가 있다. 결제가 실패해도 주문은 여전히 CREATED상태다. 고객은 같은 주문으로 다시 결제를 시도할 수 있다. 만약 PAYMENT_FAILED 상태를 추가하면, 이 상태에서 제결재를 허용하는 로직이 새로 필요해진다. 상태 전이 규칙이 하나 더 생기고, 그걸 처리하는 코드도 늘어난다.&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; &quot;결제 실패한 주문 현황을 보고 싶다&quot; 는 요청이 오면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 히스토리로 조회하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1774873576057&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 주문은 생성됐지만 결제가 실패한 건
SELECT o.*
FROM orders o
WHERE o.state = 'CREATED'
  AND EXISTS (
      SELECT 1 FROM transaction_history th
      WHERE th.order_id = o.id
        AND th.type = 'PAYMENT_FAIL'
  )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태를 늘리지 않아도 필요한 정보는 다 꺼낼 수 있다. 이 부분이 공부하면서 인상 깊었다. 상태를 추가하는게 기본값처럼 생각됐는데, 이력 테이블을 잘 설계해두면 상태 없이도 충분한 경우가 있다는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;7. 왜 주문이 결제보다 중요한가?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의 초반부터 &quot;결제가 중요할까, 주문이 중요할까?&quot; 라는 질문을 계속 가져가라고 했다. 코드를 다 보고 나서 내린 결론은 주문이 더 중요하다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;b&gt;주문 (Order)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;결제 (Payment)&lt;/b&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;tr&gt;
&lt;td&gt;의존성&lt;/td&gt;
&lt;td&gt;내부 중심&lt;/td&gt;
&lt;td&gt;외부 의존 (PG사)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;확장 방향&lt;/td&gt;
&lt;td&gt;배송, 취소, 반품 등 연결&lt;/td&gt;
&lt;td&gt;PG사 연동에 국한&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;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;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;언어에서 드러나는 차이&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문이 배송된다. 결제가 배송된다고는 하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 언어의 차이가 설계의 방향을 알려준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 수단은 바뀔 수 있다. PG사를 갈아타거나, 간편결제가 추가될 수 있다. 하지만 &quot;고객이 무엇을 얼마에 샀는가&quot; 라는 주문의 본질은 바뀌면 안된다. 그래서 결제의 복잡도가 주문 쪽으로 스며들지 않도록 경계를 지키는 게 중요하다는 것. 코드를 통해 실제로 느꼈다.&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;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계로 번역하면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;결제 로직이 주문을 오염시키면 안된다.&lt;/li&gt;
&lt;li&gt;외부 복잡도가 내부로 전파되면 안된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&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;b&gt; 그 코드가 왜 그 선택을 했는지&lt;/b&gt;를 이해하는 것이다. 이번 공부에서 그 차이를 조금 느낀 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제는 수단이고, 주문이 본질이다. 이걸 이해하는 순간 코드는 단순해지고 설계는 강해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/218</guid>
      <comments>https://newfangled.tistory.com/218#entry218comment</comments>
      <pubDate>Mon, 30 Mar 2026 21:35:25 +0900</pubDate>
    </item>
    <item>
      <title>좋은 객체는 부끄럼을 탄다</title>
      <link>https://newfangled.tistory.com/215</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;디미터 법칙과 Tell, Don't Ask 로 만드는 자율적인 객체 설계&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Anemic Domain Model (빈혈 도메인 모델이 무엇인지, 그리고 내 코드가 이미 그 상태인지 알게 된다.&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;디미터 법칙과 Tell, Don't Ask를 추상적 원인이 아니라 코드 한 줄 단위에서 적용할 수 있게 된다.&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Feature Envy, 열차 충돌 같은 코드 냄새를 감지하는 기준과 코드 패턴을 갖게 된다&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Spring + JPA 환경에서 이 원칙들이 @Transactional, Dirty Checking과 어떻게 연결되는지 이해하게 된다&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;규칙을 언제 지키고 언제 의도적으로 어겨야 하는지 &amp;mdash; 이유를 설명할 수 있는 판단 기준을 얻는다&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프폴로그 - 이미 짜고 있을지도 모른다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 기반 프로젝트에서 아래 패턴, 한 번쯤 써봤을 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1773840481864&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class OrderService {

    @Transactional
    public void shipOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();

        if (order.getStatus() != OrderStatus.PAID) {
            throw new IllegalStateException(&quot;결제 완료 상태에서만 배송 가능합니다.&quot;);
        }

        order.setStatus(OrderStatus.SHIPPED);
        order.setShippedAt(LocalDateTime.now());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;동작한다. 테스트도 가능하다. 그런데 시간이 지나면 문제가 드러난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;배송 가능 조건&quot; 이 바뀌면 어디를 찾아가야 하는가? Order 가 아니라 OrderService 다. 이 로직이 다른 서비스에도 흩어져 있다면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Order를 열어보면 getter/setter 만 있고 비즈니스 로직은 단 한줄도 없다.&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;이것이 Anemic Domain Model(빈혈 도메인 모델)이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 빈혈 도메인 모델의 본질&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Martin Fowler 가 2003년 명명한 안티패턴이다.&lt;/p&gt;
&lt;pre id=&quot;code_1773840644881&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Anemic Domain Model &amp;mdash; 상태는 있지만 행동이 없다
public class Order {
    private OrderStatus status;
    private LocalDateTime shippedAt;

    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public LocalDateTime getShippedAt() { return shippedAt; }
    public void setShippedAt(LocalDateTime shippedAt) { this.shippedAt = shippedAt; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 객체는 상태만 있고 행동이 없다. 겉보기에는 객체지향 같지만 실제 구조는 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터 -&amp;gt; Entity&lt;/li&gt;
&lt;li&gt;로직 -&amp;gt; Service (절차지향 트랜잭션 스트립트)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Rich Domain Model은 행동이 데이터와 함께 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1773840761369&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Rich Domain Model &amp;mdash; 스스로 판단하고 행동한다
public class Order {
    private OrderStatus status;
    private LocalDateTime shippedAt;

    public void ship() {
        if (this.status != OrderStatus.PAID) {
            throw new IllegalStateException(&quot;결제 완료 상태에서만 배송 가능합니다.&quot;);
        }
        this.status = OrderStatus.SHIPPED;
        this.shippedAt = LocalDateTime.now();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Order 를 열면 배송인 언제 가능한지 바로 알 수 있다. 로직이 데이터 곁에 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Anemic Domain Model과 Rich Domain Model 의 차이는 getter/setter 의 유무가 아니다. &quot;이 객체에 대한 결정을 이 객체가 내리고 있는가&quot; 다. 그리고 더 중요한 건, &quot;그 결정이 외부에서 중복되고 있는가&quot;다. 빈혈 도메인 모델의 진짜 위험은 로직이 여러 Service에 흘어져 일관성을 잃는 것이다.&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;2. 문제 정의 - 책임이 섞이면 테스트가 무너진다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 코드의 tryMove를 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1773840972823&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Game.java
private void tryMove(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        showBlocked();
    } else {
        position = position.shift(direction);
        showRoom();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&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;상태 변경 - position 업데이트&lt;/li&gt;
&lt;li&gt;외부 출력 - 콘솔에 결과 출력&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 테스트는 이렇게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1773841053229&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 로직 검증이 아니라 &quot;환경 제어&quot;가 된다
OutputStream output = new ByteArrayOutputStream();
System.setOut(new PrintStream(output));

player.tryMove(Direction.EAST);

assertThat(output.toString()).containsSequence(&quot;당신은 [(1,0)]에 있습니다.\n&quot;, &quot; 방 (1,0)&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;픽스처(초기 상태)와 결과의 관계가 눈에 들어오지 않는다. 플레이어 위치가 바뀌었는지 검증하는게 아니라, 콘솔이 어떤 문자열이 출력됐는지 검증하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;테스트가 어려운 이유는 private이기 때문이 아니다. 책임과 외부 의존성이 섞여 있기 때문이다. private 은 증상이고, 설계 과부하가 원인이다.&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;h4 data-ke-size=&quot;size20&quot;&gt;3. 1차 리펙토링 - 책임의 주인을 바꾼다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tryMove는 플레이어를 이동시킨다. 플레이어 스스로가 이 책임을 져야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드를 이동시킬 때는 그 메서드가 접근하는 인스턴스 변수와 관련 메서드도 함께 이동해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773841280703&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Player.java &amp;mdash; 1차 분리
public class Player {
    private WorldMap worldMap;
    private Position position;

    public void tryMove(Direction direction) {
        if (worldMap.isBlocked(position.shift(direction))) {
            showBlocked();
        } else {
            position = position.shift(direction);
            showRoom();
        }
    }

    private void showRoom() {
        System.out.println(&quot;당신은 [&quot; + worldMap.roomAt(position).name() + &quot;]에 있습니다.&quot;);
    }

    private void showBlocked() {
        System.out.println(&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;테스트는 여전히 콘솔 스트림을 가로채야 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;h4 data-ke-size=&quot;size20&quot;&gt;4. 2차 리펙토링 - 외부 의존성 제거&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;System.out은 정적 멤버다. 외부에서 교체하거나 주입할 수 없다. 이게 테스트를 어렵게 만드는 근본 원인이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Player 에서 출력을 걷어내고, 이동 성공 여부만 반환하게 만든다.&lt;/p&gt;
&lt;pre id=&quot;code_1773841477416&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Player.java
public boolean move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        return false;
    }
    this.position = this.position.shift(direction);
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773841489196&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Game.java &amp;mdash; 흐름만 조율한다
public void tryMove(Direction direction) {
    if (player.move(direction)) {
        showRoom();
    } else {
        showBlocked();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 바뀐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773752830321&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before &amp;mdash; 환경을 제어해야 했다
assertThat(output.toString()).containsSequence(&quot;당신은 [(1,0)]에 있습니다.\n&quot;, &quot; 방 (1,0)&quot;);

// After &amp;mdash; 상태를 직접 검증한다
assertThat(player.move(Direction.EAST)).isTrue();
assertThat(player.position()).isEqualTo(Position.of(1, 0));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순하고 명확하다. 픽스처와 결과의 관계가 한눈에 들어온다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;테스트가 쉬어진 건 테스트 기술이 좋아진 게 아니다. 객체의 역할이 명확해졌기 때문이다. 테스트 용이성 목표가 아니라 좋은 설계의 결과물이다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 3차 리팩토링 - 디미터 법칙: 내부를 드러내지 말라&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;showRoom 을 보면 새 문제가 보인다.&lt;/p&gt;
&lt;pre id=&quot;code_1773841858053&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Game.java &amp;mdash; 열차 충돌(Train Wreck)
player.worldMap().roomAt(player.position()).name()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점(.)이 연이어 나오는 이 코드를 열차 충돌(Train Wreck)이라 부른다. 겉모습 문제가 아니다. 구조적 위험이다.&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;Game은 Player의 내부 구조를 모두 알고 있다. WorldMap이 있고, Posion이 있고, worldMap.roomAt(position)이 Room을 반환한다는 것까지. Player 내부 구조가 바뀌면 Game도 함께 바뀐다. 결합도가 높다.&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;그리고 실질적인 버그도 있다. player.worldMap().roomAt(player.position())를 두 번 호출하고 있다. 같은 내부 탐색을 중복 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디미터 법칙: 오직 인접한 이웃에게만 말하라&lt;/p&gt;
&lt;pre id=&quot;code_1773842081832&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Player.java &amp;mdash; 내부 구조를 캡슐화한다
public Room currentRoom() {
    return worldMap.roomAt(position);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1773842098307&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Game.java
public void showRoom() {
    Room room = player.currentRoom(); // 한 번만 호출, 내부 구조 비노출
    System.out.println(&quot;당신은 [&quot; + room.name() + &quot;]에 있습니다.&quot;);
    System.out.println(room.description());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;디미터 법칙의 본질은 점의 개수 줄이기가 아니다. 변경될 가능성이 있는 내부 구조를 숨기는 것이다. player.currentRoom().name()이 괜찮은 이유는 Room의 name()이 내부 구조가 아니라 Room의 공개 인터페이스이기 때문이다. 판단 기준은 점의 수가 아니라 내부 구조가 노출되는가다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 4차 리팩토링 &amp;mdash; Tell, Don't Ask: Feature Envy 제거&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tryMove의 최초 형태를 다시 보자&lt;/p&gt;
&lt;pre id=&quot;code_1773842316408&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Game이 Player를 대신해서 판단한다 &amp;mdash; Feature Envy
if (player.worldMap().isBlocked(player.position().shift(direction))) {
    showBlocked();
} else {
    player.move(player.position().shift(direction));
    showRoom();
}&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; 코드는 Player 상태를 꺼내 Game이 직접 판단하고, 판단 결과에 따라 Player의 상태를 변경한다. 이것을 Featur Envy(기능에 대한 부러움)라고 부른다. Fowler 의 Refactoring에서 정의한 코드 냄새다. Game이 Player의 데이터를 지나치게 부러워하며 가져다 쓰고 있는 것이다.&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;묻지 말고 시켜라(Tell, Don' Ask): 상태를 꺼내 외부에서 판단하는 대신, 객체에게 직접 수행하도록 위임하라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773755623322&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Player.java &amp;mdash; 스스로 판단하고 행동한다
public boolean move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        return false;
    }
    this.position = this.position.shift(direction);
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1773842627652&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Game.java &amp;mdash; 묻지 않고 시킨다
public void tryMove(Direction direction) {
    if (player.move(direction)) {
        showRoom();
    } else {
        showBlocked();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;데이터와 그 데이터를 이용하는 로직이 서로 다른 클래스에 있으면., 그 로직은 반드시 중복된다. if (order.getStatus() == PAID)가 OrderService, ShippingService, NotificationService에 각각 흩어지는 것이 그 예다. GRASP의 Information Expert 원칙은 이 문제의 해법을 명확히 말한다. &quot;어떤 책임을 수행하기 위해 필요한 정보를 가진 클래스에 그 책임을 부여하라.&quot;&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;7. 실무 적용 - Spring/JPA에서 이게 어떤 의미인가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나쁜 코드 - Servie 가 Entity를 대신해서 결정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773842810056&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void shipOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();

    // Feature Envy: Service가 Order 상태를 꺼내 판단한다
    if (order.getStatus() != OrderStatus.PAID) {
        throw new IllegalStateException(&quot;결제 완료 상태에서만 배송 가능합니다.&quot;);
    }

    // Anemic Model: Order는 setter만 제공한다
    order.setStatus(OrderStatus.SHIPPED);
    order.setShippedAt(LocalDateTime.now());
    // orderRepository.save(order) &amp;mdash; 이걸 명시적으로 호출하는 코드도 많다
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 코드 - Entity가 스스로 결정하고 행동한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773842817515&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Order.java
public void ship() {
    if (this.status != OrderStatus.PAID) {
        throw new IllegalStateException(&quot;결제 완료 상태에서만 배송 가능합니다.&quot;);
    }
    this.status = OrderStatus.SHIPPED;
    this.shippedAt = LocalDateTime.now();
    // &quot;배송 가능 조건&quot;이 바뀌면 이 메서드 하나만 수정한다
}

// OrderService.java
@Transactional
public void shipOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.ship(); // Order가 스스로 판단하고 상태를 변경한다
    // orderRepository.save(order) 불필요
}&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;좋은 코드 - Entity가 스스로 결정하고 행동한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773843100580&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Order.java
public void ship() {
    if (this.status != OrderStatus.PAID) {
        throw new IllegalStateException(&quot;결제 완료 상태에서만 배송 가능합니다.&quot;);
    }
    this.status = OrderStatus.SHIPPED;
    this.shippedAt = LocalDateTime.now();
    // &quot;배송 가능 조건&quot;이 바뀌면 이 메서드 하나만 수정한다
}

// OrderService.java
@Transactional
public void shipOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.ship(); // Order가 스스로 판단하고 상태를 변경한다
    // orderRepository.save(order) 불필요
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Dirty Checking과의 연결&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional 안에서 영속성&amp;nbsp; 컨텍스트가 관리하는 Entity 의 상태가 변경되면,&amp;nbsp; 트랜잭션 커밋 시점에 JPA가 자동으로 변경을 감지하고, UPDATE 쿼리를 실행한다. 이를 Dirty Checking 이라 한다.&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;order.ship() 한 줄이 내부 상태 (status, shippedAt)를 바꾸면 JPA 가 그 변경을 감지해서 save() 호출없이 DB에 반영한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rich Domain Model 은 Dirty Checking과 자연스럽게 맞는다.&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;8. 코드 냄새 감지 체크 리스트&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 코드를 짜거나 리뷰할 때 아래 팬턴이 보이면 멈추고 생각하라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Feature Envy&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773843437303&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이 패턴이 보이면 경고
if (something.getX() == SOME_CONDITION) {
    something.setY(newValue);
}&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;somthing 에게 상태를 묻고, 판단하고, 다시 something 의 상태를 바꾼다. 이 판단과 변경 로직을 something 안으로 옮겨라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;something.doY() 형태로 위임할 수 있다면 거의 항상 그게 낫다.&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;열차 충돌 (Train Wreck)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773843570505&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 점이 두 개 이상 연속으로 나오면 의심하라
a.getB().getC().doSomething();&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;a 가 B 를 갖고 있고, B 가 C 를 갖고 있다는 내부 구조가 외부에 노출됐다. B 와 C 사이에 뭔가 끼어들면 a를 사용하는 모든 곳이 바뀐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;a 에게 doSomthingOnC() 메서드를 위임할 수 있는지 확인하라.&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;Anemic Domain Model&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773843690010&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Entity 파일을 열었을 때 이게 전부라면 경고
public class Order {
    private OrderStatus status;
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Order에 관한 비즈니스 판단이 Service에 있지 않은지 찾아라. if (order.getStatus() == ...) 패턴이 Service 여러 곳에 흩어져 있다면 빈혈 상태다.&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;과도한 Mock 설정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773843738444&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 테스트에서 Mock 설정이 4개 이상 필요하다면 경고
when(mockA.getB()).thenReturn(b);
when(mockB.getC()).thenReturn(c);
when(mockC.doSomething()).thenReturn(result);
when(mockRepository.findById(id)).thenReturn(Optional.of(order));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 대상이 너무 많은 외부 의존성을 갖고 있거나, 디미터 법칙을 위반하고 있다는 신호다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순수한 도메인 로직 테스트에는 Mock이 거의 필요 없어야 한다.&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;거대한 Service&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Servive 메서드가 50줄을 넘거나, Service 클래스가 500 줄을 넘기 시작하면 거의 확실하게 Anemic Domain Model 상태다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 가 Entity 대신 모든 결정을 내리고 있기 때문이다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;9. 언제 규칙을 어겨도 되는가&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;규칙을 외운 사람과 이해한 사람의 차이는 &lt;b&gt;예외를 이유와 함께 말할 수 있는가&lt;/b&gt;에서 드러난다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;Builder / Fluent Interface &amp;mdash; OK&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1773844008319&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;new StringBuilder()
    .append(&quot;Hello&quot;)
    .append(&quot;, World&quot;)
    .toString();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;같은 타입을 반환하는 체이닝이다. 내부 구조가 바뀌어도 사용하는 쪽은 영향받지 않는다. 열차 충돌처럼 보이지만 내부 구조를 노출하지 않기 때문에 디미터 법칙 위반이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;Stream API &amp;mdash; OK&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1773844048881&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;list.stream()
    .filter(Item::isActive)
    .map(Item::getName)
    .collect(Collectors.toList());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;각 연산이 스트림 파이프라인의 공개 인터페이스를 통해 연결된다. 어떤 컬렉션인지 내부 구조가 노출되지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;조회용 getter &amp;mdash; OK&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773844064639&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 판단하려는 게 아니라 표시하려는 것
String displayName = order.getCustomerName();
response.setName(displayName);&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;상태를 꺼내 &lt;b&gt;표시&lt;/b&gt;하는 건 Tell, Don't Ask 위반이 아니다. 문제는 상태를 꺼내서 &lt;b&gt;그 객체를 대신해 판단하고 변경&lt;/b&gt;할 때 발생한다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;디미터 법칙과 Tell, Don't Ask는 목적이 있는 원칙이다. 결합도를 낮추고 캡슐화를 지키기 위한 것이다. &lt;b&gt;이 목적에 부합하지 않는 경우에는 의도적으로 어겨도 된다.&lt;/b&gt; 중요한 건 &quot;왜 어기는지 설명할 수 있는가&quot;다. 설명할 수 있는 예외는 판단이고, 설명 못 하는 예외는 실수다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;10. 최종 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4단계의 리펙토링을 거쳐 두 클래스의 역할이 명확해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Player - 자율적인 도메인 객체&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;외부 의존성 (콘솔, DB) 이 없다.&lt;/li&gt;
&lt;li&gt;테스트가 단순하다. Mock 없이 , 상태 기반으로 검증 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Game - 흐름 조율자&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;Player에게 이동을 위임하고 결과에 따라 흐름을 결정한다.&lt;/li&gt;
&lt;li&gt;콘솔 출력(외부 의존성)을 담당한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 분리 완료- 각자 하나의 이유로만 변경된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;에필로그 - 누가 결정해야 하는가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 변화의 출발점은 기술이 아니었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 결정은 누가 내려야 하는가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 질문 하나가 tryMove를 Player로 옮기게 했고, 콘솔 의존성을 분리하게 했고, Game이 묻는 대신 시키도록 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;나는 코드를 작성할 때 이 질문으로 설계를 검증한다.&lt;/li&gt;
&lt;li&gt;테스트가 어색하다 -&amp;gt; 책임이 잘못된 위치에 있다&lt;/li&gt;
&lt;li&gt;코드가 질문을 많이 한다 -&amp;gt; 객체가 수동적이다.&lt;/li&gt;
&lt;li&gt;점이 연이어 나온다 -&amp;gt; 내부 구조가 노출되고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 신호들을 읽는 눈이 생겼다면, 코드를 고치는 방향은 이미 보이기 시작한 것이다.&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>객체지향</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/215</guid>
      <comments>https://newfangled.tistory.com/215#entry215comment</comments>
      <pubDate>Tue, 17 Mar 2026 23:51:03 +0900</pubDate>
    </item>
    <item>
      <title>테스트가 무거운 건 의지 문제가 아니라 설계 문제다</title>
      <link>https://newfangled.tistory.com/214</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;레이어트 아키텍처의 함정과 도메인 중심 설계로의 탈출&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;테스트가 무거운 이유가 의지 문제가 아니라 설계 문제라는 것&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;레이어드 아키텍처가 Fat Service 와&amp;nbsp; Anemic Domain Model을 만들기 쉬운 구조적 이유&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;이 문제가 코드 수준을 넘어 팀 협업과 생산성에 어떤 영향을 주는지&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;의존성 역전(DIP)을 통해 외부 기술을 분리하는 실전 흐름&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;Mock과 Fack 테스트의 차이와 언제 무엇을 써야 하는지&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;Spring Context 없이도 가능한 순수 Java&amp;nbsp; 단위 테스트 구조&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc; color: #000000;&quot;&gt;이 내용이 클린 아키텍처, 헥사고날 아키텍처와와 어떻게 연결되는지 큰 그림&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 레이어드 아키텍처는 왜 기본이 되었을까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sprig 기반 프로젝트를 시작하면 대부분 자연스럽게 다음 구조를 사용한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773578918803&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Controller &amp;rarr; Service &amp;rarr; Repository &amp;rarr; Database&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직관적이고 배우기 쉽다. 빠르게 기능을 만들 수 있고, 초기에 이해도 어렵지 않다. 많은&amp;nbsp; CRUD 중심 서비스에서는 지금 충분히 실용적인 구조다.&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;blockquote data-ke-style=&quot;style2&quot;&gt;테스트 한 번 돌리는 게 왜 이렇게 부담스럽지?&lt;/blockquote&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;현재 거의 모든 테스트가 h2 를 필요로 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;h2 를 사용하는 순간 그 테스트는 중형 테스트가 된다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 코드 문제이기 전에, 팀 문제다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어드 아키텍처의 문제는 코드에서 시작하지만 팀에서 완성된다. 이 구조가 무너지는 건 항상 같은 패턴이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;온보딩이 오래 걸린다. controller, service , respository 패키지를 열어도 이 시스템이 어떤 도메인을 다루는지 보이지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 하나를 이해하려면 Controller &amp;rarr; Service &amp;rarr; Repository &amp;rarr; Entity 를 계속 따라가야 한다. 도메인이 코드 구조에서 드러나지 않기 때문이다.&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; Repository가 준비되기 전까지 Service&amp;nbsp; 개발이 블로킹되고, Service가 나오기 전까지 Controller를 짤 수 없다. 팀 규모가 커질수록 이 병목은 선형이 아니라 지수적으로 커진다.&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;리펙토링이 두려운 코드가 된다. Service가 DB와 강결합되어 있으며 Service 를 건드릴 때 DB 코드도 영향을 받는다. 테스트도 무겁기 때문에 변경 검증이 느려진다. 자연스럽게 &quot;이 부분은 건드리지 말자&quot; 는 문화가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 레거시가 되는 건 오래되어서가 아니라 아무도 건드리지 않기 때문이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 테스트가 무거워지는 이유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어드 아키텍처에서 테스트를 작성하다 보면 대부분 이런 환경이 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1773579543221&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Spring Context + H2 + Mockito&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;왜 이런 상황이 생길까? Service 가 외부 기술에 직접 의존하기 때문이다.&lt;/p&gt;
&lt;pre id=&quot;code_1773579582399&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public UserEntity create(UserCreateDto dto) {
    UserEntity user = new UserEntity();
    user.setEmail(dto.getEmail());
    user.setNickname(dto.getNickname());
    userRepository.save(user);
    sendCertificationEmail(user.getEmail());
    return user;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 코드의 문제는 테스트 코드가 아니라 의존성 구조다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;UserRepository, Database, MailSender, Spring Transaction에 직접 의존하기 때문에&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 메서드를 검증하려는 순간 테스트는 자연스럽게 &lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;Spring Context와 DB를 필요로 하는 테스트&lt;/b&gt;가 된다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 시점에서 테스트는 이미 단위 테스트가 아니라 중형 통합 테스트가 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이런 테스트는 보통 1-3초 이상 걸리고, 수백 개가 쌓이면 CI 시간이 크게 늘어난다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;테스트 실행 시간이 느려지고, 작성 비용은 올라가며 개발자는 테스트 자체를 부담스럽게 느끼기 시작한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이것 역시 테스트 문제가 아니라 설계 문제다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;더 근본적인 문제도 있다. Elasticsearch 처럼 임베디드 테스트 서버가 없는 외부 시스템은 어떻게 테스트할 것인가? H2 나 Mockito 사용법을 익히는 것이 테스트의 전부가 되어버리는 구조다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;4. 레이어드 아키텍처가 만드는 구조적 문제&lt;/span&gt;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;데이터베이스 중심 사고&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;레이어드 아키텍처에서는 개발 흐름이 자연스럽게 이렇게 된다&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773582469943&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;테이블 설계 &amp;rarr; Entity &amp;rarr; Repository &amp;rarr; Service &amp;rarr; Controller&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;pre id=&quot;code_1773582494819&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Use Case &amp;rarr; Domain Model &amp;rarr; Database&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구조를 따라가게 된다.&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;Fat Servie 와 Anemic Domain Model&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어드 아키텍처에서는 도메인 객체가 다음처럼 되기 쉽다.&lt;/p&gt;
&lt;pre id=&quot;code_1773513756723&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class UserEntity {
    private String email;
    private String nickname;
    // getter/setter만 존재. 스스로 아무것도 하지 않는다.
}&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;데이터만 담는 수동적인 컨테이너다 이를 Anemic Domain Model (빈혈 도메인 모델) 이라 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 실제 로직은 모두 Servie 에 몰린다. Service&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;낮은 테스트 가능성&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 가 외부 기수로가 직접 결합되어 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1773582759848&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Service
  ├── JpaRepository
  ├── JavaMailSender
  └── Database&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 작은 단위 테스트가 만들어지기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결 방안 - 도메인을 중심으로 설계하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 레이어드 아키텍처가 아니라 도메인이 구조에서 드러나지 않는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD의 창시자 Eric Evans 는 이렇게 말했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;pre id=&quot;code_1773582875064&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Presentation  (Controller)
Application   (Service)
Domain        (순수 Java 비즈니스 객체)
Infrastructure (DB, 메일 등 외부 기술)&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;Domain Layer 가 핵심이다. 도메인은 외부 기술에 의존하지 않는 순수 Java&amp;nbsp; 객체들의 협력 공간이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 분리가 하나있다. JPA Entity(영속성 객체) 와 Domain Entity를 분리하는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1773582953377&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;User          // Domain Entity &amp;mdash; 비즈니스 규칙을 담는다
UserJpaEntity // Persistence Entity &amp;mdash; DB에 저장하기 위한 객체&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 기술에 종속된다. 분리하는 순간, 도메인은 DB가 바뀌어도 영향받지 않는 독립적이 존재가 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;참고: Business Layer = Application Layer + Domain Layer. 기존에 &quot;비즈니스 레이어&quot;라고 통칭하니 도메인이 눈에 보이지 않았던 것이다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;6. 의존성에는 방향이 있다 - 클린 아키텍처&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Robert C. Martin은 클린 아키텍처에서 이것을 하나의 규칙으로 정리했다.&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;'안쪽' 이란 도메인이다. '바깥쪽'이란 DB, 메일, 프레임워크 같은 세부기술이다. 이 규칙을 어기는 순간, Service 테스트에 DB가 따라오기 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인은 세부 기술이 바뀌어도 영향받이 않아야 한다. MySQL 을 MongoDB로 바꾸든,&amp;nbsp; JavaMailSender 를 다른 서비스로 교체하든, 비즈니스 규칙 자체는 변하지 않는다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 적용하는 것이 바로 이 원칙 구현이다.&amp;nbsp; 이 구조를 일반적으로 Port-Adapter 패턴, 또는 헥사고날 아키텍처라고 부른다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;clean_arch_dependency_final.svg&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;137&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zXgtG/dJMcadnHzJH/bknqThNq7xLetkAGikXAo0/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zXgtG/dJMcadnHzJH/bknqThNq7xLetkAGikXAo0/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zXgtG/dJMcadnHzJH/bknqThNq7xLetkAGikXAo0/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzXgtG%2FdJMcadnHzJH%2FbknqThNq7xLetkAGikXAo0%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;705&quot; height=&quot;322&quot; data-filename=&quot;clean_arch_dependency_final.svg&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;137&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;h4 data-ke-size=&quot;size20&quot;&gt;7.Port-Adapter 의존성 역전하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 는 이제 인터페이스 (Port)에만 의존한다. 구현체(Adapter)는 Infrastructure 레이어에 둔다.&lt;/p&gt;
&lt;pre id=&quot;code_1773583511336&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Service Layer / port 패키지 &amp;mdash; Port (인터페이스)
public interface UserRepository {
    Optional&amp;lt;UserEntity&amp;gt; findById(long id);
    UserEntity save(UserEntity user);
}

// Infrastructure Layer &amp;mdash; Adapter (구현체)
public class UserRepositoryImpl implements UserRepository {
    private final UserJpaRepository jpaRepository;
    // JPA는 구현체 안에서만 존재한다
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메일 발송도 동일하게 분리한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773588966623&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Port
public interface MailSender {
    void send(String email, String title, String content);
}

// Adapter
public class MailSenderImpl implements MailSender {
    private final JavaMailSender javaMailSender;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #24292f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 UserService 에서 인증 메일 로직을 분리해&amp;nbsp; CertificationService 로 독립시킨다. SRP (단일 책임 원칙) 실천이다.&lt;/p&gt;
&lt;pre id=&quot;code_1773589082376&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class CertificationService {

    private final MailSender mailSender; // 구현체가 아닌 인터페이스에 의존

    public void send(String email, long userId, String certificationCode) {
        String url = generateCertificationUrl(userId, certificationCode);
        mailSender.send(email,
            &quot;Please certify your email address&quot;,
            &quot;Please click the following link: &quot; + url);
    }

    private String generateCertificationUrl(long userId, String certificationCode) {
        return &quot;http://localhost:8080/api/users/&quot; + userId
            + &quot;/verify?certificationCode=&quot; + certificationCode;
    }
}&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;이 단순한 변경이 만들어내는 효과가 크다. MySQL을 MongoDB로 교체해도 Service 코드는 변하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트할 때는 Facke 구현체를 자유롭게 교체할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;8. Fack 객체로 만드는 단위 테스트&amp;nbsp;&lt;/h4&gt;
&lt;pre id=&quot;code_1773589175194&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class FakeMailSender implements MailSender {
    String email;
    String title;
    String content;

    @Override
    public void send(String email, String title, String content) {
        this.email = email;
        this.title = title;
        this.content = content;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 이렇게 작성된다.&lt;/p&gt;
&lt;pre id=&quot;code_1773589193745&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void 이메일이_정상적으로_전송되는지_확인한다() {
    FakeMailSender mailSender = new FakeMailSender();
    CertificationService service = new CertificationService(mailSender);

    service.send(&quot;test@test.com&quot;, 2L, &quot;code&quot;);

    assertThat(mailSender.email).isEqualTo(&quot;test@test.com&quot;);
    assertThat(mailSender.content).contains(&quot;certificationCode=code&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;Spring 없음. DataBase. 없음. Mockito 없음. 순수 Java 단위 테스트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트는 보통 수 밀리초(ms)안에 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말한 1-3초짜리 중현 테스트와 비교해보면, 설계 하나가 테스트 속도를 수십배 바꾼다. 같은 기능을 테스트 하더라고 어떻게 테스트 하느냐가 테스트 크기를 결정한다.&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;테스트하기 어려운 코드에는 공통점이 있다. 책임이 분리되지 않았거나, 의존성이 잘못 설계되었거나, 도메인 로직이 Service 에 몰려있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 테스트 하기 쉬운 코드는 결국 변경하기 쉬운 코드다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;그리고 중요한 역설이 있다. 테스트 커버리지를 측정했는데 Domain/Sevice 제외하면 너무 낮게 나온다면, 그건 도메인이 빈약하다는 신호다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CRUD 외에 비즈니스 로직이 거의 없다는 의미다. 커버리지를 높이기 위해 테스트를 추가하기 전에, 도메인이 충분히 표현되어 있는지를 먼저 검점하는 것이 좋다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;9.Mock 과 Fack - 무엇을 언제 쓸까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock은 행동을 검증한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773589500266&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 호출 여부를 검증
verify(mailSender).send(eq(&quot;test@test.com&quot;), anyString(), anyString());&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 id=&quot;code_1773589530232&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 결과값을 검증
assertThat(mailSender.email).isEqualTo(&quot;test@test.com&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;&lt;span style=&quot;color: #333333;&quot;&gt;Mock 자체가 나쁜 것은 아니다. 외부 API 호출 횟수나 호출 순서가 비즈니스 요구사항인 경우, , 임베디드 서버가 없는 외부 시스템을 다룰 때는 여전히 Mock이 유용하다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;다만 Mock이 지나치게 많다면 설계가 테스트 친화적이지 않을 가능성을 의심해볼 수 있다. Fack를 만들 수 없다는 건 인터페이스로 추상화되지 않았다는 의미이기도 하기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;10. 패키지 구조 개선&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전 (계층형)&lt;/p&gt;
&lt;pre id=&quot;code_1773589726270&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;controller / service / repository / entity / dto&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 후 (도메인형)&lt;/p&gt;
&lt;pre id=&quot;code_1773589749436&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;user/
  ├── domain/           &amp;larr; User, UserStatus (순수 Java)
  ├── service/
  │    └── port/        &amp;larr; UserRepository (인터페이스)
  ├── infrastructure/   &amp;larr; UserJpaEntity, UserRepositoryImpl
  └── controller/
post/
  ├── domain/
  ├── service/
  │    └── port/
  ├── infrastructure/
  └── controller/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지를 열면 이 시스템이 무슨 문제를 해결했는지 바로 드러난다. 이 구조는&lt;span style=&quot;color: #000000;&quot;&gt; Vertical Slice Architecture라고도 불린다. 응집도가 높아지고, MSA 전환 시 도메인 단위 분리도 훨씬 쉬워진다.&lt;/span&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;h4 data-ke-size=&quot;size20&quot;&gt;11. 언제 이런 구조가 필요한가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;효과적인 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 비즈니스 규칙이 복잡한 서비스&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 테스트 자동화가 중요한 서비스&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;- 단순 CRUD 서비스&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;중요한 건 특정 패턴을 따르는 것이 아니라 &lt;b&gt;도메인을 명확하게 드러내고 변경 비용을 낮추는 방향으로 설계&lt;/b&gt;하는 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;마치며&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 아키텍처의 목적은 새로운 패턴을 적용하는 것이 아니다. 변화를 쉽게 만드는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인이 살아있는 시스템은 이해하기 쉽고, 테스트하기 쉽고, 확장하기 쉽다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 도메인이 죽어 있는 시스템은 결국 Service 가 모든 것을 처리하는 거대한 절차형 프로그램이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아키텍처 패턴은 정단이 아니다. 하지만 방향은 있다. &quot;도메인을 명확히 드러내고, 외부 기술로부터 핵심 규칙을 보호하는 것.&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;이 글에서 다룬 개념들의 위치를 정리하면 이렇다&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773590182396&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;이 글에서 다룬 것
  ├── Fat Service &amp;rarr; 도메인 분리        (DDD - Rich Domain Model)
  ├── 의존성 역전 + Port-Adapter       (Hexagonal Architecture)
  ├── 계층형 &amp;rarr; 도메인형 패키지         (Vertical Slice Architecture)
  └── Fake 기반 단위 테스트            (Clean Architecture 테스트 전략)&lt;/code&gt;&lt;/pre&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;/blockquote&gt;</description>
      <category>객체지향</category>
      <category>ddd</category>
      <category>DIP</category>
      <category>Java</category>
      <category>SringBoot</category>
      <category>레이어드아키텍처</category>
      <category>리팩토링</category>
      <category>의존성역전</category>
      <category>클린아키텍처</category>
      <category>테스트코드</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/214</guid>
      <comments>https://newfangled.tistory.com/214#entry214comment</comments>
      <pubDate>Mon, 16 Mar 2026 00:57:22 +0900</pubDate>
    </item>
    <item>
      <title>HTTP 메서드와 멱등성 - REST API 설계에서 중요한 이유</title>
      <link>https://newfangled.tistory.com/213</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&lt;/b&gt;&lt;b&gt;&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 메서드(GET, POST, PUT, PATCH, DELETE) 의 역할과 차이를 명확히 이해할 수 있다.&lt;/li&gt;
&lt;li&gt;멱등성(Idempotency)이 무엇인지, 왜 실무에서 중요한지 체감할 수 있다.&lt;/li&gt;
&lt;li&gt;결제 시스템 등에서 Idempotency Key 전략이 왜 필요한지 이해할 수 있다.&lt;/li&gt;
&lt;li&gt;REST API 설계에서 흔히 발생하는 실수 TOP 7 을 점검할 수 있다.&lt;/li&gt;
&lt;li&gt;Spring 기반 Java 백엔드에서 HTTP 메서드를 어떻게 적용하는지 코드로 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 HTTP 메서드를 제대로 알아야 할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API를 설계할 때 단순히 CRUD 에 맞춰 HTTP 메서드를 선택하는 것만으로는 충분하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 서비스에서는 언제든 다음과 같은 상황이 발생할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;네트워크 타임아웃으로 인한 재시도&lt;/li&gt;
&lt;li&gt;클라이언트 버그로 인한 중복 요청&lt;/li&gt;
&lt;li&gt;로드밸런서의 자동 재전송&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 멱등성을 고려하지 않은 API는 &lt;b&gt;데이터 정합성 문제&lt;/b&gt;로 직결됩니다. 특히 결제, 주문, 포인트 적립처럼 금전이 오가는 도메인에서는 멱등성 개념이 서비스 품질을 가르는 핵심 설계 기준이 됩니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;네트워크는 항상 불안정하다고 가정하고 설계해야 합니다. &quot;요청이 한 번만 도달할 것&quot; 이라는 전제는 개발 환경에서만 유효합니다.&lt;br /&gt;실제 서비스에서 멱등성을 고려하지 않은 API는 장애가 났을 때 비로소 그 빈틈이 드러난다. 멱등성은 좋은 습관이 아리나 실제 장애를 막는 방어선이다.&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;h4 data-ke-size=&quot;size20&quot;&gt;1.HTTP 메서드란?&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 메서드는 클라이언트가 서버에 요청을 보낼 때 &quot;&lt;b&gt;이 요청이 어떤 행위를 수행하는지&lt;/b&gt;&quot; 를 명시하는 수단입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP는 이미지, 영상, HTML, JSON, 텍스트 등 모든 데이터를 바이너리로 전송할 수 있는 프로토콜이며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드는 요청이 &lt;b&gt;조회인지, 생성인지, 수정인지, 삭제인지&lt;/b&gt;를 서버에 알려주는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HTTP 메시지 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 메시지 (Request)&lt;/p&gt;
&lt;pre id=&quot;code_1773421702484&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GET /users/1 HTTP/1.1
Host: api.example.com
Content-Type: application/json

(바디가 없으면 생략)&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;응답 메시지 (Response)&lt;/p&gt;
&lt;pre id=&quot;code_1773421742399&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HTTP/1.1 200 OK
Content-Type: application/json

{&quot;id&quot;:1,&quot;name&quot;:&quot;홍길동&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;공백라인은 단순 구분자가 아니라 HTTP 파서가 헤더와 바디를 분리하는 기준입니다.&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;REST API에서 메서드 역할&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST 설계의 핵심 원칙은 단순하다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773425547284&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;URI 는 리소스(명사) 만 표현하고, 행위(동사) 는 HTTP Method 로 표현합니다.&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;잘못된 설계: /getUsers, /createUser, /deleteUser&lt;/li&gt;
&lt;li&gt;올바른 설계:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1773421928643&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GET /users
POST /users
DELETE /users/1&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;URI 에 동사를 넣으면 메서드와 역할이 중복됩니다. POST /createUser 는 &quot;POST 로 생성한다&quot; 는 의미가 이미 담겨 있느데 URL 에 또 create를 넣는 건 같은 말을 두 번 하는 것과 같습니다. 코드는 읽히는 것입니다. 중복은 혼란을 만듭니다.&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 주요 HTTP 메서드&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GET&amp;nbsp; - 조회&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422035377&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GET /users&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 상태를 변경하지 않는다. (Safe)&lt;/li&gt;
&lt;li&gt;멱등성을 가진다.&lt;/li&gt;
&lt;li&gt;캐싱이 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GET은 멱등성을 보장하므로 캐싱 전략을 활용하면 트래픽 폭주 상황에서도 서버 부하를 크게 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 캐싱 헤더: Cache-Control, ETag, Last-Modified&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;POST&amp;nbsp; - 생성/처리&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422178845&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;POST /users&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새로운 리소스를 생성하거나 서버 측 특정 처리를 수행&lt;/li&gt;
&lt;li&gt;멱등성이 없다 -&amp;gt; 호출할 때마다 상태가 바뀔 수 있음&lt;/li&gt;
&lt;li&gt;생성된 리소스 ID는 서버가 결정&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;POST 는 본질적으로 멱등하지 않으므로, 결제나 주문 같은 핵심 도메인에서는 Idempotency Key 전략을 반드시 도입해야 합니다.&lt;br /&gt;이를 통해 중복 요청 시 안전하게 기존 결과를 반환할 수 있습니다.&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PUT&amp;nbsp; - 전체 교체&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422370092&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;PUT /users/1&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 지정한 URI의 리소스를 전체 교체&lt;/li&gt;
&lt;li&gt;리소스 존재 시 -&amp;gt; 전체 교체/ 없으면-&amp;gt; 생성 (Upsert)&lt;/li&gt;
&lt;li&gt;멱등성을 가진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;PUT upsert는 실무에서 거의 사용하지 않습니다.&lt;br /&gt;클라이언트가 ID를 직접 결정해야 하기 때문에, 중복 ID 충돌이나 보안/정합성 문제를 야기할 수 있습니다.&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;b&gt;PATCH - 부분 수정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422541389&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;PATCH /users/1
{
  &quot;email&quot;: &quot;new@email.com&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지정한 필드만 변경하고 나머지는 유지&lt;/li&gt;
&lt;li&gt;멱등성은 설계에 따라 달라진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;실무에서는 수정 API 는 PATCH를 기본으로 사용하고,&amp;nbsp; PUT은 &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;b&gt;DELETE - 삭제&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422635749&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;DELETE /users/1&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;리소스를 삭제&lt;/li&gt;
&lt;li&gt;멱등성을 가진다. -&amp;gt; 이미 없는 리소스를 삭제해도 서버 상태 동일&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;DELETE는 Safe 하지 않지만 멱등입니다.&lt;br /&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;h4 data-ke-size=&quot;size20&quot;&gt;3. PUT vs PATCH - 실무에서 PATCH를 선호하는 이유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 유저 데이터&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773422774615&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;Hwang&quot;,
  &quot;email&quot;: &quot;hwang@email.com&quot;,
  &quot;age&quot;: 30,
  &quot;phone&quot;: &quot;010-1111-2222&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PUT 요청:&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422798888&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;PUT /users/1
{
  &quot;name&quot;: &quot;Hwang&quot;,
  &quot;email&quot;: &quot;hwang@email.com&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;결과(서버 구현에 따라):&lt;/p&gt;
&lt;pre id=&quot;code_1773422814578&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;Hwang&quot;,
  &quot;email&quot;: &quot;hwang@email.com&quot;,
  &quot;age&quot;: null,       // 데이터 손실
  &quot;phone&quot;: null      // 데이터 손실
}&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;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;PATCH 요청:&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773422868358&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;PATCH /users/1
{
  &quot;email&quot;: &quot;new@email.com&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;결과:&lt;/p&gt;
&lt;pre id=&quot;code_1773422883818&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;Hwang&quot;,
  &quot;email&quot;: &quot;new@email.com&quot;,   // 변경됨
  &quot;age&quot;: 30,                   // 유지
  &quot;phone&quot;: &quot;010-1111-2222&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;기존 데이터 유지, 필요한 부분만 안전하게 수정&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;PUT을 쓰다가 실수로 일부 필드만 전송하면 기존 데이터가 null로 덮어씌어지는 사고가 발생합니다. 실제 운영 환경에서 이런 실수는 생각보다 자주 일어납니다. 수정 API는 기본적으로 PATCH를 선택하고. PUT은 &quot;리소스 전체를 명시적으로 교체 하는 경우&quot; 에만 의도적으로 사용하는 것을 권장합니다.&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 멱등성(Idempotency)이란?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 요청을 여러 번 보내도 서버의 최종 상태가 항상 동일한 성질입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수학적으로 표현하면&lt;/p&gt;
&lt;pre id=&quot;code_1773424023873&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;f(f(x)) = f(x)&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;한 번 호출하든 100번 호출하든 결과가 같아야 합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 104px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;메서드&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;멱등성&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Safe&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;캐싱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;GET&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;POST&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;PUT&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;PATCH&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;설계에 따라&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;DELETE&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Safe 와 멱등성 - 헷갈리기 쉬운 개념&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Safe는 서버 상태를 아예 변경하지 않는 것이고(GET, HEAD만 해당), 멱등은 여러 번 호출해도 최종 상태가 같은 것입니다. DELETE는 서버의 리소스를 삭제하므로 Safe 하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 몇 번을 호출해도 &quot;해당 리소스가 없음&quot; 상태가 유지되므로 멱등합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PATCH의 멱등성은 설계에 따라 달라집니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773423524468&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 멱등한 PATCH &amp;mdash; 몇 번 호출해도 결과 동일
PATCH /users/1  { &quot;status&quot;: &quot;active&quot; }

// 비멱등 PATCH &amp;mdash; 호출할 때마다 값이 달라짐
PATCH /counter  { &quot;op&quot;: &quot;increment&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;PATCH 는 어떤 로직을 담느냐에 따라 멱등할 수도, 아닐 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PATCH 는 비멱등이다 라고 단정 짓는 것 자체가 틀렸습니다. 내가 설계하는 PATCH가 멱등인지 아닌지를 의식적으로 판단하는 것이 핵심입니다.&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;h4 data-end=&quot;4182&quot; data-start=&quot;4153&quot; data-section-id=&quot;okh5z6&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;5. 멱등성과 결제 시스템 - Idempotency Key 전략&lt;/span&gt;&lt;/h4&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4184&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;멱등성 개념이 실제로 어떻게 쓰이는지는 결제 도메인을 보면 가장 잘 이해됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4184&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4184&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4184&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;문제 상황&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773424083008&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;1. 사용자가 결제 버튼 클릭
2. POST /payments 요청
3. 서버 결제 처리 완료
4. 응답이 클라이언트에 도달하지 않음
5. 재시도 &amp;rarr; POST /payments 재전송
6. 결제 두 번 발생 ⚠️&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;서버 입장에서는 두 요청 모두 유효한 새 요청입니다. 멱등성을 보장하는 장치가 없다면 결제는 두 번 일어납니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;해결책 - Idempotency Key:&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773424113107&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;POST /payments
Idempotency-Key: payment-unique-key-abc123

{
  &quot;amount&quot;: 10000,
  &quot;method&quot;: &quot;card&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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;서버는 이 키를 기준으로 요청을&amp;nbsp; 판단합니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음 요청 -&amp;gt; 결제 처리 후 키와 결과를 저장&lt;/li&gt;
&lt;li&gt;같은 키 재요청 -&amp;gt; 새로 처리하지 않고 기존 결과 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POST 는 본질적으로 멱등하지 않기 때문에, 안전한 결제 처리를 위해 반드시 API 레벨에서 구현해야 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;6. REST API 설계 실수 TOP 7&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;URI에 동사를 사용합니다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;nbsp;잘못된 설계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/getUsers&lt;span&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;/createUser&lt;span&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;/deleteUser&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;올바른 설계&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET /users&lt;span&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;POST /users&lt;span&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;DELETE /users/1&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;URL은 자원을 표현하고, 동사는 메서드가 담당합니다. 두 곳에 동사를 넣는 건 역할 중복입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;POST 를 모든 곳에 사용합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;잘못된 설계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;POST /users/update&lt;/li&gt;
&lt;li&gt;POST /users/getList&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;올바른 설계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;POST /users/delete&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;GET&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp; &amp;nbsp; &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;/users&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;PATCH&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp; &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;/users/1&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;DELETE /users/1&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;HTTP 메서드 자체가 행위를 표현하는 수단인데 이를 무시하고 POST로 통일하면 API를 읽는 사람이 의도를 파악하기 어렵습니다,&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;리소스를 단수로 표현합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;nbsp;컬렉션을 나타낼 때는 복수 명사를 사용하는 것이 관례다. 팀 내에서 통일하는 것이 중요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;리소스의 소유 관계를 URI 로 표현하지 않습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿼리 파라미터로 조건을 거는 방식&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET /orders?userId=1&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;소유 관계를 URI 계층 구조로 표현하는 방식&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET /users/1/orders&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;두 설계 모두 틀리지 않습니다 차이는 의도에 있습니다.&lt;/li&gt;
&lt;li&gt;GET /orders?userId=1&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;은 &quot;전체 주문 중 userId=1인 것을 필터링&quot;하는 느낌입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt; 반면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;GET /users/1/orders&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;는 URI 계층 구조 자체가 &quot;이 주문들은 유저 1에 종속된 것&quot;이라는 소유 관계를 명확히 드러냅니다. 읽는 사람이 URI만 봐도 관계를 바로 파악할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;실무에서는 이렇게 구분하는 경우가 많다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;GET&amp;nbsp;/users/1/orders&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;유저&amp;nbsp;1의&amp;nbsp;주문&amp;nbsp;목록&amp;nbsp;(소유&amp;nbsp;관계)&lt;br /&gt;GET&amp;nbsp;/orders?status=pending&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;전체&amp;nbsp;주문&amp;nbsp;중&amp;nbsp;대기&amp;nbsp;중인&amp;nbsp;것&amp;nbsp;(필터링/검색)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&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;/ul&gt;
&lt;/li&gt;
&lt;li&gt;멱등성 고려 안함 -&amp;gt; 중복 요청 시 Idempotency Key전략 필요&lt;/li&gt;
&lt;li&gt;API 버전 관리하지 않습니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버전 없음 - 스펙 변경 시 하위 호환이 불가합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;users&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;URI 버전 관리&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/v1/users, /v2/users 등 명시&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;서비스가 커지면 반드시 API 버전 관리가 필요해집니다. 처음부터 /v1을 붙이는 습관을 들이십다.&lt;/li&gt;
&lt;li&gt;나중에 추가하려면 이미 배포된 클라이언트가 있어 되돌리기 어렵습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;실무에서는 사소한 설계 실수도 장애, 버그. UX 문제로 이어집니다.&lt;/blockquote&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;h4 data-ke-size=&quot;size20&quot;&gt;7. Spring에서 HTTP 메서드 적용&lt;br /&gt;&lt;br /&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1773424515923&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/v1/users&quot;)
public class UserController {
    private final UserService userService;
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public ResponseEntity&amp;lt;List&amp;lt;UserResponse&amp;gt;&amp;gt; getUsers() {
        return ResponseEntity.ok(userService.findAll());
    }

    @GetMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;UserResponse&amp;gt; getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @PostMapping
    public ResponseEntity&amp;lt;UserResponse&amp;gt; createUser(@RequestBody UserCreateRequest request) {
        UserResponse response = userService.create(request);
        URI location = URI.create(&quot;/v1/users/&quot; + response.getId());
        return ResponseEntity.created(location).body(response);
    }

    @PutMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;UserResponse&amp;gt; replaceUser(@PathVariable Long id, @RequestBody UserReplaceRequest request) {
        return ResponseEntity.ok(userService.replace(id, request));
    }

    @PatchMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;UserResponse&amp;gt; updateUser(@PathVariable Long id, @RequestBody UserUpdateRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }

    @DeleteMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;최종 정리&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 129px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;메서드&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;멱등&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;Safe&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;캐싱&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;실무 포인트&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 19px;&quot;&gt;GET&lt;/td&gt;
&lt;td style=&quot;width: 12.907%; height: 19px;&quot;&gt;조회&lt;/td&gt;
&lt;td style=&quot;width: 5.69767%; height: 19px;&quot;&gt;✅&lt;/td&gt;
&lt;td style=&quot;width: 5.81395%; height: 19px;&quot;&gt;✅&lt;/td&gt;
&lt;td style=&quot;width: 5.5814%; height: 19px;&quot;&gt;✅&lt;/td&gt;
&lt;td style=&quot;width: 57.4419%; height: 19px;&quot;&gt;서버 상태 변경 금지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 19px;&quot;&gt;POST&lt;/td&gt;
&lt;td style=&quot;width: 12.907%; height: 19px;&quot;&gt;생성/처리&lt;/td&gt;
&lt;td style=&quot;width: 5.69767%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 5.81395%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 5.5814%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 57.4419%; height: 19px;&quot;&gt;ID는 서버 결정, 중복 주의, Idempotency Key 고려&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 19px;&quot;&gt;PUT&lt;/td&gt;
&lt;td style=&quot;width: 12.907%; height: 19px;&quot;&gt;전체 교체&lt;/td&gt;
&lt;td style=&quot;width: 5.69767%; height: 19px;&quot;&gt;✅&lt;/td&gt;
&lt;td style=&quot;width: 5.81395%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 5.5814%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 57.4419%; height: 19px;&quot;&gt;일부 필드만 보내면 null 위험, Upsert 거의 안 씀&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 19px;&quot;&gt;PATCH&lt;/td&gt;
&lt;td style=&quot;width: 12.907%; height: 19px;&quot;&gt;부분 수정&lt;/td&gt;
&lt;td style=&quot;width: 5.69767%; height: 19px;&quot;&gt;⚠️&lt;/td&gt;
&lt;td style=&quot;width: 5.81395%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 5.5814%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 57.4419%; height: 19px;&quot;&gt;설계에 따라 멱등 여부 달라짐, 수정 API 기본 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 19px;&quot;&gt;DELETE&lt;/td&gt;
&lt;td style=&quot;width: 12.907%; height: 19px;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;width: 5.69767%; height: 19px;&quot;&gt;✅&lt;/td&gt;
&lt;td style=&quot;width: 5.81395%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 5.5814%; height: 19px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 57.4419%; height: 19px;&quot;&gt;200 vs 204 팀 기준 통일 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;마치며&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;HTTP 메서드는 CRUD 표현 도구가 아니라, &lt;b&gt;서비스 안정성과 데이터 정합성을 지키는 설계 기&lt;/b&gt;반입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;항상 스스로 물어야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 요청이 여러 번 호출되면 어떤 일이 발생하는가?&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&amp;nbsp;이 사고 방식은 HTTP 에만 국한되지 않습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Kafka Consumer 중복 처리, 분산 트랜잭션, 이벤트 소싱 등 안정적 시스템 설계 전반에서 반복적으로 등장합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;HTTP 메서드와 멱등성 개념을 이해한 개발자는, 그 사고방식을 더 큰 시스템 설계에도 자연스럽게 적용할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Network</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/213</guid>
      <comments>https://newfangled.tistory.com/213#entry213comment</comments>
      <pubDate>Sat, 14 Mar 2026 02:57:16 +0900</pubDate>
    </item>
    <item>
      <title>왜 모든 데이터 처리에는 map 이 존재할까?</title>
      <link>https://newfangled.tistory.com/212</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Java Stream, Spark, Reactive가 공유하는 하나의 아이디어.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;map&lt;/span&gt;을&lt;span&gt; &lt;/span&gt;직접&lt;span&gt; &lt;/span&gt;구현하면서&lt;span&gt; &lt;/span&gt;람다&lt;span&gt;, &lt;/span&gt;제네릭&lt;span&gt;, &lt;/span&gt;선언형&lt;span&gt; &lt;/span&gt;프로그래밍의&lt;span&gt; &lt;/span&gt;본질을&lt;span&gt; &lt;/span&gt;이해한다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;개발을 하다보면 어떤 API는 너무 자연스럽게 사용하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중 하나가 바로 Java의 map() 이다.&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;존재하는지 깊게 생각해본 적 있는가?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;단순히&amp;nbsp; &quot;&lt;b&gt;있으니까 쓰는&lt;/b&gt;&quot; API가 아니다. map 의 본질은 데이터 변환이다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 글에서 얻어갈 것들&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;map의 본질&lt;/b&gt; 을 개념이 아닌 코드 수준에서 직접 이해할 수 있다,&lt;/li&gt;
&lt;li&gt;&lt;b&gt;람다와 Function 인터페이스&lt;/b&gt; 가 왜 등장했는지 그 필연성을 이해하게 된다,&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제네릭 &lt;/b&gt;을 활용해 어떤 타입에도 동작하는 범용 map을 직접 만들어 볼 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;명령형 vs 선언형 프로그래밍&lt;/b&gt; 의 차이를 코드로 직접 체감한다,&lt;/li&gt;
&lt;li&gt;Java Stream API 가 &lt;b&gt;어떤 사고방식에서 탄생했는지&lt;/b&gt;, 왜 그런 구조인지 이해한다.&lt;/li&gt;
&lt;li&gt;filter + map 조합이 &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;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;map 이란 무엇인가?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;map&lt;/b&gt; 은 mapping(매핑)의 줄임말이다. 매핑이란 &lt;b&gt;어떤 값을 다른 값으로 변환하는 과정&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;문자열 &quot;1&quot; 을 숫자 1로 바꾸는 것, &quot;apple&quot; 을 &quot;APPLE&quot;로 바꾸는 것, 학생 객체에서 이름만 꺼내는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 것이 매핑이다. 하나의 데이터를 다른 형태로 변환하는 작업이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 왜 map 은 어디에나 존재할까?&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;&quot;어떤 형태의 데이터를 다른 형태로 바꾸는 것&quot;&lt;/b&gt; 이기 때문이다. 이름은 달라도 내부에는 동일한 철학이 흐른다. map을 이해하면 이 모든 기술이 낯설지 않게 된다.&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java Stream뿐 아니라&amp;nbsp;Spark, Reactive, Kafka Streams&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; 등 모든 데이터 처리 프레임워크에 map 이 존재한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;처음엔 항상 중복이 보인다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 방식으로 시작해보자. 문자열 리스트를 두 가지 방식으로 변환하는 코드다.&lt;/p&gt;
&lt;pre id=&quot;code_1773234241377&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private static List&amp;lt;Integer&amp;gt; mapStringToInteger(List&amp;lt;String&amp;gt; list) {
    List&amp;lt;Integer&amp;gt; numbers = new ArrayList&amp;lt;&amp;gt;();
    for (String s : list) {
        numbers.add(Integer.valueOf(s));  // &amp;larr; 이 한 줄만 다름
    }
    return numbers;
}

private static List&amp;lt;Integer&amp;gt; mapStringToLength(List&amp;lt;String&amp;gt; list) {
    List&amp;lt;Integer&amp;gt; numbers = new ArrayList&amp;lt;&amp;gt;();
    for (String s : list) {
        numbers.add(s.length());  // &amp;larr; 이 한 줄만 다름
    }
    return numbers;
}&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;/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;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;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;여기서 변하지 않는 것은 &quot;리스트를 순회하면서 변환한 값을 모은다&quot; 는 구조, 변하는 것은 &quot;어떻게 변환하느냐&quot; 다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;람다로 변환 전략을 외부에서 주입하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변하는 부분, 즉 &quot;변환 방법&quot; 을 외부에서 주입하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1773244863320&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public static void main(String[] args) {
    List&amp;lt;String&amp;gt; list = List.of(&quot;1&quot;, &quot;12&quot;, &quot;123&quot;, &quot;1234&quot;);

    // 변환 전략을 람다로 전달
    Function&amp;lt;String, Integer&amp;gt; toNumber = s -&amp;gt; Integer.valueOf(s);
    List&amp;lt;Integer&amp;gt; numbers = map(list, toNumber);

    Function&amp;lt;String, Integer&amp;gt; toLength = s -&amp;gt; s.length();
    List&amp;lt;Integer&amp;gt; lengths = map(list, toLength);
}

private static List&amp;lt;Integer&amp;gt; map(List&amp;lt;String&amp;gt; list,
                                   Function&amp;lt;String, Integer&amp;gt; mapper) {
    List&amp;lt;Integer&amp;gt; numbers = new ArrayList&amp;lt;&amp;gt;();
    for (String s : list) {
        Integer value = mapper.apply(s);  // 전달받은 람다를 실행
        numbers.add(value);
    }
    return numbers;
}&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&gt;&lt;b&gt;map 은 이제 변환 방법을 모른다.&lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;단지 &quot;리스트를 순회하며 mapper를 적용하고 결과를 모은다&quot; 는 역할만 한다. 변환 전략은 완전히 외부에 위임됐다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이&lt;/span&gt; &lt;span&gt;구조는&lt;/span&gt; &lt;span&gt;객체지향의&lt;/span&gt;&amp;nbsp;&lt;span&gt;전략&lt;/span&gt; &lt;span&gt;패턴&lt;/span&gt;(Strategy Pattern)&lt;span&gt;과&lt;/span&gt; &lt;span&gt;본질적으로&lt;/span&gt; &lt;span&gt;동일하다&lt;/span&gt;. &lt;span&gt;전략&lt;/span&gt; &lt;span&gt;패턴에서는 인터페이스를 구현한 클래스를 주입했다면, 람다에서는 클래스 없이 &lt;b&gt;코드 조각 자체를 직접 주입&lt;/b&gt;한다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;제네릭으로 범용성 획득하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 map은 String -&amp;gt; Integer 변환만 가능하다. 제네릭을 도입하면&lt;b&gt; 어떤 타입 조합도 처리할 수 있는 범용 map&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1773245205316&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class GenericMapper {

    public static &amp;lt;T, R&amp;gt; List&amp;lt;R&amp;gt; map(List&amp;lt;T&amp;gt; list,
                                          Function&amp;lt;T, R&amp;gt; mapper) {
        List&amp;lt;R&amp;gt; results = new ArrayList&amp;lt;&amp;gt;();
        for (T item : list) {
            R value = mapper.apply(item);
            results.add(value);
        }
        return results;
    }
}&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;T&lt;span&gt;는&lt;/span&gt; &lt;span&gt;입력&lt;/span&gt; &lt;span&gt;타입&lt;/span&gt;,&amp;nbsp;R&lt;span&gt;은&lt;/span&gt; &lt;span&gt;출력&lt;/span&gt; &lt;span&gt;타입이다&lt;/span&gt;. String&lt;span&gt;이었던&lt;/span&gt; &lt;span&gt;자리에&lt;/span&gt; T&lt;span&gt;가&lt;/span&gt;, Integer&lt;span&gt;였던&lt;/span&gt; &lt;span&gt;자리에&lt;/span&gt; R&lt;span&gt;이&lt;/span&gt; &lt;span&gt;들어갔을&lt;/span&gt; &lt;span&gt;뿐이다&lt;/span&gt;.&lt;/p&gt;
&lt;pre id=&quot;code_1773237265533&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;String&amp;gt; fruits = List.of(&quot;apple&quot;, &quot;banana&quot;, &quot;orange&quot;);

// String &amp;rarr; String
List&amp;lt;String&amp;gt; upper = map(fruits, s -&amp;gt; s.toUpperCase());

// String &amp;rarr; Integer
List&amp;lt;Integer&amp;gt; lengths = map(fruits, s -&amp;gt; s.length());

// Integer &amp;rarr; String
List&amp;lt;Integer&amp;gt; nums = List.of(1, 2, 3);
List&amp;lt;String&amp;gt; stars = map(nums, n -&amp;gt; &quot;*&quot;.repeat(n));&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1773237309410&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;upper = [APPLE, BANANA, ORANGE]
lengths = [5, 6, 6]
stars = [*, **, ***]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 단순히 코드가 유연해졌다는게 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;처음 구현에서 코드는 이렇게 생각했다. &quot;String 을 Integer 로 바꾼다&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제네릭을 도입 시 &quot;어떤 타입이든 다른 타입으로 변환할 수 있다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를&lt;span&gt; &lt;/span&gt;바라보는&lt;span&gt; &lt;/span&gt;관점이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;구체적인&lt;span&gt; &lt;/span&gt;타입&lt;span&gt; &amp;rarr; &lt;/span&gt;변환이라는&lt;span&gt; &lt;/span&gt;행위&lt;span&gt; &lt;/span&gt;자체&lt;/b&gt;로&lt;span&gt; &lt;/span&gt;올라간다&lt;span&gt;. &lt;/span&gt;이것이&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;고차&lt;span&gt; &lt;/span&gt;함수&lt;span&gt;(Higher-Order Function)&lt;/span&gt;&lt;/b&gt;의&lt;span&gt; &lt;/span&gt;특징이다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉 행위&amp;nbsp;자체(변환)에&amp;nbsp;집중하게&amp;nbsp;된다&amp;nbsp;&amp;rarr;&amp;nbsp;고차&amp;nbsp;함수(Higher-Order&amp;nbsp;Function)&amp;nbsp;특징&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;filter + map: &lt;/b&gt;&lt;span&gt;&lt;b&gt;데이터&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;파이프라인의&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;탄생&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;filter 로 걸러내고 map으로 변환하는 조합은 강력하다. 이 두 가지를 함께 사용하는 순간 단순한 유틸리티가 아니라 &lt;b&gt;데이터 파이프라&lt;/b&gt;인이라는 더 큰 개념이 모습을 드러낸다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;학생 목록에서 80점 이상인 학생의 이름만 추출해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;IN&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;List&amp;lt;Student&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apple/100, Banana/80, Berry/50, Tomato/40&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;filter( score &amp;gt;= 80 )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apple/100, Banana/80 &amp;rarr; 통과&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;map( s &amp;rarr; s.getName() )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Student &amp;rarr; String 변환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OUT&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;List&amp;lt;String&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[&quot;Apple&quot;, &quot;Banana&quot;]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;명령형 스타일 - 어떻게 (HOW)&lt;/b&gt;&lt;span style=&quot;background-color: #0f0f13; color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773237768110&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;static List&amp;lt;String&amp;gt; direct(List&amp;lt;Student&amp;gt; students) {
    List&amp;lt;String&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();
    for (Student s : students) {
        if (s.getScore() &amp;gt;= 80) {
            result.add(s.getName());
        }
    }
    return result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;집중 포인트: 어떻게 순회하고, 조건 검사하고, 결과를 수집할지&lt;/li&gt;
&lt;li&gt;문제점: 실행 흐름을 머릿속에서 시뮬레이션해야 로직을 이해&lt;/li&gt;
&lt;li&gt;중복/변화:&amp;nbsp;조건과&amp;nbsp;수집&amp;nbsp;로직이&amp;nbsp;코드&amp;nbsp;곳곳에&amp;nbsp;섞여&amp;nbsp;있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선언형 스타일 - 무엇을(WHAT)&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;/ul&gt;
&lt;pre id=&quot;code_1773238481861&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;static List&amp;lt;String&amp;gt; lambda(List&amp;lt;Student&amp;gt; students) {
    List&amp;lt;Student&amp;gt; filtered =
        GenericFilter.filter(students,
            s -&amp;gt; s.getScore() &amp;gt;= 80);   // 80점 이상만

    List&amp;lt;String&amp;gt; names =
        GenericMapper.map(filtered,
            s -&amp;gt; s.getName());            // 이름만 추출

    return names;
}&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;876&quot; data-start=&quot;832&quot; data-section-id=&quot;9qmzfj&quot;&gt;&lt;b&gt;집중 포인트:&lt;/b&gt; &quot;80점 이상 학생의 이름&quot;이라는 의도 자체를 선언&lt;/li&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;877&quot; data-section-id=&quot;13brb9q&quot;&gt;&lt;b&gt;장점:&lt;/b&gt; 코드만 봐도 요구사항과 데이터 흐름이 직관적으로 이해됨&lt;/li&gt;
&lt;li data-end=&quot;968&quot; data-start=&quot;920&quot; data-section-id=&quot;1klvtvf&quot;&gt;&lt;b&gt;추상화:&lt;/b&gt; 변하는 부분(람다)과 변하지 않는 부분(순회/수집) 완전히 분리&lt;/li&gt;
&lt;li data-end=&quot;1021&quot; data-start=&quot;969&quot; data-section-id=&quot;141fxqo&quot;&gt;&lt;b&gt;재사용성:&lt;/b&gt; 제네릭과 고차 함수를 활용하면 다른 타입/조건에도 그대로 재사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;direct() 는 for 와 if 를 직접 제어한다. 어떻게 할지를 설명한다.&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;lambda() 는 filter와 map 으로 선언한다. 무엇을 원하는지 말한다.&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;같은 결과를 내지만 코드를 읽는 방식이 완전히 다르다. direct() 는 실행 흐름을 따라가야 의도가 보이고, lambad() 는 코드 자체가 요구사항이다.&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;이게 filter + map 조합이 단순한 유틸리티를 넘어서는 이유다. 데이터가 어떤 조건으로 걸러지고, 어떤 형태로 변환되는지를 &lt;b&gt;파이프라인처럼 선언적으로 표현&lt;/b&gt;할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1038&quot; data-start=&quot;1028&quot; data-ke-size=&quot;size20&quot;&gt;핵심 차이&lt;/h4&gt;
&lt;div&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-end=&quot;1310&quot; data-start=&quot;1040&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;b&gt; 명령형(HOW) &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 선언형(WHAT) &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1139&quot; data-start=&quot;1108&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1116&quot; data-start=&quot;1108&quot;&gt;사고 초점&lt;/td&gt;
&lt;td data-end=&quot;1127&quot; data-start=&quot;1116&quot; data-col-size=&quot;sm&quot;&gt;어떻게 구현할까&lt;/td&gt;
&lt;td data-end=&quot;1139&quot; data-start=&quot;1127&quot; data-col-size=&quot;sm&quot;&gt;무엇을 원하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1185&quot; data-start=&quot;1140&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1148&quot; data-start=&quot;1140&quot;&gt;로직 이해&lt;/td&gt;
&lt;td data-end=&quot;1165&quot; data-start=&quot;1148&quot; data-col-size=&quot;sm&quot;&gt;실행 흐름 시뮬레이션 필요&lt;/td&gt;
&lt;td data-end=&quot;1185&quot; data-start=&quot;1165&quot; data-col-size=&quot;sm&quot;&gt;코드만 읽어도 요구사항 직관적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1232&quot; data-start=&quot;1186&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1194&quot; data-start=&quot;1186&quot;&gt;코드 구조&lt;/td&gt;
&lt;td data-end=&quot;1208&quot; data-start=&quot;1194&quot; data-col-size=&quot;sm&quot;&gt;조건/순회/수집 혼합&lt;/td&gt;
&lt;td data-end=&quot;1232&quot; data-start=&quot;1208&quot; data-col-size=&quot;sm&quot;&gt;변하는 부분과 변하지 않는 부분 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1271&quot; data-start=&quot;1233&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1240&quot; data-start=&quot;1233&quot;&gt;재사용성&lt;/td&gt;
&lt;td data-end=&quot;1256&quot; data-start=&quot;1240&quot; data-col-size=&quot;sm&quot;&gt;타입마다 코드 반복 필요&lt;/td&gt;
&lt;td data-end=&quot;1271&quot; data-start=&quot;1256&quot; data-col-size=&quot;sm&quot;&gt;제네릭+람다로 범용적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1310&quot; data-start=&quot;1272&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1278&quot; data-start=&quot;1272&quot;&gt;가독성&lt;/td&gt;
&lt;td data-end=&quot;1290&quot; data-start=&quot;1278&quot; data-col-size=&quot;sm&quot;&gt;복잡해질수록 난해&lt;/td&gt;
&lt;td data-end=&quot;1310&quot; data-start=&quot;1290&quot; data-col-size=&quot;sm&quot;&gt;요구사항이 코드 그대로 드러남&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;blockquote data-end=&quot;1403&quot; data-start=&quot;1312&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1403&quot; data-start=&quot;1314&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론:&lt;/b&gt; map + 람다 + 제네릭 + 선언형 패턴은, 단순히 문법이 아니라 &lt;b&gt;데이터 변환 사고방식을 '무엇을 원하는가' 중심으로 바꾸는 도구&lt;/b&gt;다.&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;h4 data-ke-size=&quot;size20&quot;&gt;명령형 vs 선언형 : 사고 방식의 전환&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 스타일의 차이는 단순히 코드 길이가 아니다. 문제를 바라보는 방식 자체가 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령형 프로그래밍&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떻게(HOW) 할 것인가에 집중&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;/li&gt;
&lt;/ul&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;무엇을(WAHT) 할 것인가에 집중&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;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;2911&quot; data-start=&quot;2758&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;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 data-end=&quot;2854&quot; data-start=&quot;2801&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2807&quot; data-start=&quot;2801&quot;&gt;명령형&lt;/td&gt;
&lt;td data-end=&quot;2818&quot; data-start=&quot;2807&quot; data-col-size=&quot;sm&quot;&gt;어떻게(HOW)&lt;/td&gt;
&lt;td data-end=&quot;2854&quot; data-start=&quot;2818&quot; data-col-size=&quot;sm&quot;&gt;for, if, 상태 직접 제어, 로직 따라가며 이해 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2911&quot; data-start=&quot;2855&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2861&quot; data-start=&quot;2855&quot;&gt;선언형&lt;/td&gt;
&lt;td data-end=&quot;2872&quot; data-start=&quot;2861&quot; data-col-size=&quot;sm&quot;&gt;무엇(WHAT)&lt;/td&gt;
&lt;td data-end=&quot;2911&quot; data-start=&quot;2872&quot; data-col-size=&quot;sm&quot;&gt;요구사항을 직접 표현, 내부 구현 추상화, 가독성&amp;middot;유지보수 유리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;filter(score &amp;gt;= 80)&lt;span&gt;를&lt;/span&gt; 읽으면 &quot;80점 이상 필터: 라는 비즈니스 요구사항이 한눈에 보인다.&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;반면 명령형 코드는 for, if, add가 뒤섞여 의도를 파악하려면 머릿속에서 시뮬레이션을 돌려야한다. 비즈니스 로직이 복잡해질수록 이 차이는 더 극명하게 드러난다.&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;&quot;무엇을 하고 싶은지&quot; 가 더 잘 드러나는 코드&lt;/b&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;h4 data-ke-size=&quot;size20&quot;&gt;결국 중요한 건 사고 방식이다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글 앞에서 이 글에서 얻어갈 것들을 나열했다. 하나씩 되짚어보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;map 의 본질은 변환&lt;/b&gt;이다. 어떤 값을 다른 값으로 바꾸는 행위.&amp;nbsp; 단순한 메서드가 아니라 데이터 처리의 본질을 표현하는 추상화다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3502&quot; data-start=&quot;3479&quot; data-section-id=&quot;154qwxe&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;람다 &amp;rarr; 변환 전략을 외부에서 주입&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;3519&quot; data-start=&quot;3503&quot; data-section-id=&quot;unylfv&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;제네릭 &amp;rarr; 타입 추상화&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;3539&quot; data-start=&quot;3520&quot; data-section-id=&quot;1qg6sg5&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;선언형 &amp;rarr; 무엇을 원하는지 명시&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 데이터를 어떻게 변환할 것인가라는&lt;b&gt; 사고 방식 자체를 바꿔주는 도구&lt;/b&gt;가 바로 map 이다.&lt;/p&gt;
&lt;h3 data-end=&quot;140&quot; data-start=&quot;116&quot; data-section-id=&quot;18p8y3k&quot; data-ke-size=&quot;size23&quot;&gt;map이 바꾸는 사고방식: 전후 비교&lt;/h3&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;449&quot; data-start=&quot;142&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;b&gt; 후(선언형) &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;229&quot; data-start=&quot;190&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;208&quot; data-start=&quot;190&quot;&gt;어떻게(HOW) 할지를 고민&lt;/td&gt;
&lt;td data-end=&quot;229&quot; data-start=&quot;208&quot; data-col-size=&quot;sm&quot;&gt;무엇(WHAT)을 원하는지 표현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;286&quot; data-start=&quot;230&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;260&quot; data-start=&quot;230&quot;&gt;for/if로 직접 순회, 조건 체크, 결과 담기&lt;/td&gt;
&lt;td data-end=&quot;286&quot; data-start=&quot;260&quot; data-col-size=&quot;sm&quot;&gt;filter/map으로 의도 그대로 선언&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;347&quot; data-start=&quot;287&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;316&quot; data-start=&quot;287&quot;&gt;실행 흐름을 머릿속으로 시뮬레이션해야 로직 이해&lt;/td&gt;
&lt;td data-end=&quot;347&quot; data-start=&quot;316&quot; data-col-size=&quot;sm&quot;&gt;코드만 읽어도 요구사항과 데이터 흐름 직관적 이해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;408&quot; data-start=&quot;348&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;373&quot; data-start=&quot;348&quot;&gt;중복과 변하는 부분이 코드 곳곳에 흩어짐&lt;/td&gt;
&lt;td data-end=&quot;408&quot; data-start=&quot;373&quot; data-col-size=&quot;sm&quot;&gt;변하는 부분(람다)과 변하지 않는 부분(순회/수집) 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;449&quot; data-start=&quot;409&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;425&quot; data-start=&quot;409&quot;&gt;타입마다 별도 코드 필요&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;449&quot; data-start=&quot;425&quot;&gt;제네릭으로 모든 타입에서 재사용 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-end=&quot;463&quot; data-start=&quot;451&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 차이:&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-end=&quot;567&quot; data-start=&quot;464&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;567&quot; data-start=&quot;466&quot; data-ke-size=&quot;size16&quot;&gt;map + 람다 + 제네릭 + 선언형 스타일을 쓰면, &amp;ldquo;어떻게 구현할지&amp;rdquo;가 아니라 &amp;ldquo;무엇을 원하는지&amp;rdquo;에 집중할 수 있고, 데이터 변환 로직을 추상화하여 재사용과 가독성이 극대화된다.&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;b&gt;람다는 &quot;변하는 것&quot; 을 꺼내기 위해 등장&lt;/b&gt;했다. 두 메서드가 한 줄만 다를 때, 그 한 줄을 밖으로 꺼내는 도구가 람다다. Function&amp;lt;T ,R&amp;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;. String -&amp;gt; Integer 가 아니라, T -&amp;gt;R. &quot;변환한다&quot; 는 행위만 남긴 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;명령형은 어떻게를, 선언형은 무엇을 말한다.&lt;/b&gt; for/if 로 흐름을 제어하는 코드와, filter / map 으로 의도를 선언하는 코드는 읽히는 방식 자체가 다르다.&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;Java Stream API 는 이 모든 것의 완성형이다.&lt;/b&gt; 우리가 GenericFilter, GenericMapper 를 직접 만든 것처럼, Java 가 범용적으로 만들어둔 것이 Stream 이다. filter + map 의 체이닝, 그게 Stream 의 본질이다.&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;filter + map 은 데이터 파이프 라인의 시작이다.&lt;/b&gt; Spark도, Reactive도, Kafka Streams도 결국 같은 질문에서 출발한다. &quot;데이터를 어떻게 걸러내고, 어떤 형태로 바꿀 것인가&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이걸 직접 만들면 어떤 구조가 나올까?&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 API 를 배울 때마다 이 질문을 한 번씩 던져보는 것을 권한다. 직접 구현해 보면 단순한 문법 뒤에 숨어 있던 설계 의도가 보이기 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;stream().map() 한 줄 안에는 세 가지가 들어 있다. 변하는 것과 변하지 않은 것의 분리, 타입을 넘어선 추상화, 의도를 드러내는 코드. 그게 람다와 map 이 단순한 문법 설탕이 아닌 이유다.&lt;/p&gt;</description>
      <category>JAVA</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/212</guid>
      <comments>https://newfangled.tistory.com/212#entry212comment</comments>
      <pubDate>Thu, 12 Mar 2026 01:26:08 +0900</pubDate>
    </item>
    <item>
      <title>테스트가 어렵다면 코드 탓이 아니라 설계 탓이다 - 숨겨진 의존성의 진실</title>
      <link>https://newfangled.tistory.com/211</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 글에서 얻어갈 것들&lt;/span&gt;&lt;/h2&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;&quot;테스트 짜기 어렵다&quot;는 느낌이 사실은 무엇을 의미하는가?&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;외부 연동 (I/O, DB, 네트워크) 을 왜 도메인 로직과 받드시 분리해야 하는가?&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분리했을 때 의존성 방향은 어느 쪽이어야 하며, 외 그 방향이어야한 하는가?&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;의존성 역전 원칙(DIP) 이 이론이 아니라 생존 전략인 이유는 무엇인가?&lt;/b&gt;&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;프롤로그: 수동 테스트를 반복하는 개발자의 하루&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;아마 이런 경험이 있을 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;코드를 수정했다. 프로그램을 직접 실행하고, 키보드로 명령어를 입력하고, 눈으로 결과를 확인한다. 또 수정했다. 또 실행한다. 또 확인한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;자동화 테스트를 작성하려고 시도해봤지만 뭔가 이상하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;System.setIn()으로 입력을 주입하고 ByteArrayOutputStream으로 출력을 가로채야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;테스트 코드가 점점 마법처럼 보이기 시작한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이걸 처음 겪을 떄는 보통 두 가지 결론 중 하나를 내린다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;첫째, &quot;나는 테스트를&amp;nbsp; 못짠다&quot; 둘째, &quot;이 코드는 테스트하기 어려운 코드다&quot;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;둘다 틀렸다. 진짜 결론은 이것이다. &quot;이 코드는 설계가 잘못됐다&quot;&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;1. 테스트 케이스는 설계의 거울이다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;아래 테스트 코드를 보자. 텍스트 어드벤처 게임의 이동 테스트다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772900803332&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
public void move_north_passed() {
    OutputStream output = new ByteArrayOutputStream();
    System.setOut(new PrintStream(output));
    System.setIn(new ByteArrayInputStream(&quot;go north\nquit\n&quot;.getBytes()));

    Game game = new Game();
    game.run();

    assertThat(output.toString().split(&quot;\\R&quot;)).containsSequence(
            &quot;&amp;gt; 당신은 [다리]에 있습니다.&quot;,
            &quot;큰 강 위에 돌로 만든 커다란 다리가 있습니다.&quot;,
            &quot;&amp;gt; &quot;,
            &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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;go north\nquit\n&quot; 이 문자열은 게임과 어떤 관련이 있나? 명령어인가? 어디서 처리되나?&lt;/li&gt;
&lt;li&gt;플레이어가 왜 &quot;다리&quot; 로 이동했나? 원래 어디 있었다?&lt;/li&gt;
&lt;li&gt;System.setIn과 Game 사이를 연결하는 코드가 어디 있나?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 어디를 봐도 힌트가 없다. 정보가 숨겨져 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 숨겨진 정보의 원인은 테스트 코드를 못 짠게 아니다. Game 클래스 내부를 열어보면 원인이 드러난다.&lt;/p&gt;
&lt;pre id=&quot;code_1772901144597&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package hello.objectprinciple;


// 단일 책임 원칙 사전 작업- 단일 추상화 수준 원칙 적용 (추상화 수준이 동일한 조합 메서드 형태로 만들기)
// 객체 지향 핵심: 자신의 상태를 스스로 책임지는 객체
public class Game {

    private WorldMap worldMap;
    private Position position;
    private boolean running = false;
    private Console console;

    public Game() {
        this.position = Position.of(0, 2);
        this.worldMap = new WorldMap(
                Size.with(2,3),
                new Room(Position.of(0,0), &quot;샘&quot;, &quot;아름다운 샘물이 흐르는 곳입니다. 이곳에서 휴식을 취할 수 있습니다.&quot;),
                new Room(Position.of(0, 1), &quot;다리&quot;, &quot;큰 강 위에 돌로 만든 커다란 다리가 있습니다.&quot;),
                new Room(Position.of(1, 1), &quot;성&quot;, &quot;용왕이 살고 있는 성에 도착했습니다.&quot;),
                new Room(Position.of(0,2), &quot;언덕&quot;, &quot;저 멀리 성이 보이고 언덕 아래로 좁은 길이 나 있습니다.&quot;),
                new Room(Position.of(1,2), &quot;동굴&quot;, &quot;어둠에 잠긴 동굴 안에 작은 화톳불이 피어 있습니다.&quot;)
        );
        this.console = new Console();
    }

    // 1. 책임을 한문장으로 적어본다.
    // 게임이 종료될때까지 루프를 실행하고,
    // 사용자에게서 입력을 받고,
    // 입력을 파싱하고,
    // 명령을 처리하고,
    // 처리 결과를 출력한다.
    private void play() {
        // 게임 플레이

        start();
        while (isRunning()) {
            showPrompt();
            parseCommand();
        }
    }



    public void run() {
        welcome();
        play();
        farewell();
    }


    private void welcome() {
        showGreeting();
        showRoom();
        showHelp();
    }

    private void showHelp() {
        console.showLine(&quot;다음 명령어를 사용할 수 있습니다.&quot;);
        console.showLine(&quot;go {north|east|south|west} - 이동, quit - 게임 종료&quot;);
    }

    private void showRoom() {
        console.showLine(&quot;당신은 [&quot;+ worldMap.roomAt(position).name() + &quot;]에 있습니다.&quot;);
        console.showLine(worldMap.roomAt(position).description());
    }

    private  void showGreeting() {
        console.showLine(&quot;환영합니다!&quot;);
    }


    private void parseCommand() {
        String[] commands = console.input().split(&quot;\\s+&quot;);
        switch (commands[0]) {
            case &quot;go&quot; -&amp;gt; {
                switch (commands[1]) {
                    case &quot;north&quot; -&amp;gt; tryMove(Direction.NORTH);
                    case &quot;south&quot; -&amp;gt; tryMove(Direction.SOUTH);
                    case &quot;east&quot; -&amp;gt; tryMove(Direction.EAST);
                    case &quot;west&quot; -&amp;gt; tryMove(Direction.WEST);

                    default -&amp;gt; showUnknown();
                }
            }
            case &quot;quit&quot; -&amp;gt; stop();
            default -&amp;gt; showUnknown();
        }
    }



    private void showPrompt() {
        console.show(&quot;&amp;gt; &quot;);

    }

    private void showUnknown() {
        console.showLine(&quot;이해할 수 없는 명령어입니다.&quot;);
    }

    private void stop() {
        running = false;
    }

    private boolean isRunning() {
        return running;
    }

    private void start() {
        running = true;
    }




    private void tryMove(Direction direction) {
        if (worldMap.isBlocked(position.shift(direction))) {
            showBlocked();
        } else {
            position = position.shift(direction);
            showRoom();
        }
    }




    private void showBlocked() {
        console.showLine(&quot;이동할 수 없습니다.&quot;);
    }



    private  void farewell() {
        // 작별 문구 출력
        console.showLine(&quot;\n게임을 종료합니다.&quot;);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Game은 내부에서 Scanner 를 직접 생성해 입력을 받고, System.out으로 출력한다. 외부에서는 이 사실을 알 방법이 없다. 의존성이 클래스 안에 완전히 감춰져 있기 때문이다.&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;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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;2. 의존성을 분리해봤다 &amp;mdash; 하지만 여전히 어렵다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;문제를 인식했다. 외부 의존성을 별도 클래스로 분리해 명시적으로 드러내본다. 입출력 의존성을 Console 클래스로 분리한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772901564390&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Console {
    private Scanner scanner;

    public Console() {
        this.scanner = new Scanner(System.in);
    }

    public String input() {
        return scanner.nextLine().toLowerCase().trim();
    }

    public void showLine(String text) {
        System.out.println(text);
    }

    public void show(String text) {
        System.out.print(text);
    }
}&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;이제 Game은 Scanner와 System에 직접 의존하지 않는다. Console에만 의존한다.&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;ConsoleTest도 별도로 작성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1772901630894&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
public void showLine() {
    OutputStream output = new ByteArrayOutputStream();
    System.setOut(new PrintStream(output));

    new Console().showLine(&quot;showLine&quot;);

    Assertions.assertThat(output.toString()).isEqualToIgnoringNewLines(&quot;showLine&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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;입출력 테스트는 ConsoleTest 안에서만 검증한다. 논리적으로 맞다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그런데... GameTest를 다시 열어본다. 여전히 System.setIn()을 써야 하고, ByteArrayOutputStream으로 출력을 가로채야 한다. 달라진 게 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;왜일까?&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;3. 의존성 전이 &amp;mdash; 분리해도 해결이 안 된 진짜 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 현상의 이름은 &lt;b&gt;의존성 전이(Dependency Transitivity)&lt;/b&gt; 다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772901764403&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Game &amp;rarr; Console &amp;rarr; Scanner / System &amp;rarr; 키보드 / 콘솔 화면&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Game이 Console에 의존하면, Console이 의존하는 Scanner와 System의 의존성이 Game으로 전파된다. Game은 Scanner를 직접 쓰지 않지만, 간접적으로 의존하게 된다.&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;Console이라는 중간계층을 만들었지만, 그 콘솔이 여전히 외부 세계와 단단히 연결되어 있다. 의존성의 실뭉치가 풀린게 아니라 한겹 더 감긴 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성 전이가 발목을 잡는 이유는 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;첫째, 구체 클래스에 의존하고 있다.&lt;/b&gt; Game은 구체 클래스인 Console에 의존하고, Console은 구체 클래스인 Scanner와 System에 의존한다. 구체 클래스에 의존한다는 건 그 내부 구현이 바뀔 때 영향을 받는다는 뜻이고, 필요할 때 다른 구현으로 교체하기도 어렵다는 뜻이다. 결합도가 높아진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;둘째, 의존성이 숨겨져 있다.&lt;/b&gt; Game은 생성자에서 Console을 직접 생성한다. Console은 생성자에서 Scanner를 직접 생성한다. 이 모든 의존성이 클래스 내부에 숨어 있어서, 인터페이스만 봐서는 무엇에 의존하는지 알 수 없다. 내부 구현을 열어봐야 비로소 드러난다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;정리하면 이렇다. 구체 클래스에 의존하고, 그 의존성이 숨겨져 있는 클래스는 테스트 하기 어렵다. 외부 의존성을 감추고 있다면 문제가 더 커진다. 트릭을 동원한 난해한 테스트를 쓰거나, 개발자가 직접 손으로 테스트할 수밖에 없게 된다.&lt;/span&gt;&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;4. 변경의 관점에서 본 의존성 &amp;mdash; 진짜 위험은 여기에 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 문제만으로 끝나면 그나마 다행이다. 숨겨진 의존성의 진짜 위험은 &lt;b&gt;변경&lt;/b&gt;에서 드러난다.&amp;nbsp;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 수정의 여파는 의존성의 반대 방향으로 흐른다.&lt;/p&gt;
&lt;pre id=&quot;code_1772902330395&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Scanner 수정 &amp;rarr; Console 영향 &amp;rarr; Game 영향&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;저수준 세부사항이 바뀌면 고수준 핵심 로직이 흔들린다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이건 설계에서 가장 위험하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;왜 위험한지, 클래스가 담당하는 책임을 정리하면 명확해진다.&amp;nbsp;&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Console: 키보드 입력과 화면 출력을 담당하는 &lt;b&gt;저수준 메커니즘&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Game: 게임 실행에 필요한 규칙과 흐름을 담당하는 &lt;b&gt;고수준 정책&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이게 왜 문제일까? 게임 정보를 화면이 아니라 파일에 출력하고 싶어졌다고 가정해보자. Disk라는 클래스가 필요하다. 그런데 Console은 Game 생성자 안에 숨겨져 있다. Console 대신 Disk를 쓰려면 Game 클래스를 직접 수정해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력 방식 변경 때문에 게임 로직을 건드려야 한다.&lt;/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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이건 단순히 불편한 게 아니다. &lt;b&gt;핵심 정책을 세부사항 때문에 수정하는 건 위험하다.&lt;/b&gt; 출력 방식을 바꾸다가 게임 로직에 버그가 생길 수 있다. 변경의 이유와 변경의 영향 범위가 일치하지 않는 상태다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바람직한 방향은 반대다. 고수준 정책이 변경됐을 떄 저수준 메커니즘이 그에 맞춰 변경되어야 한다. 저수준 세부사향의 변경이 고수준 정책에 영향을 주어선 안된다.&lt;/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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;5. 의존성 역전 &amp;mdash; 방향을 바꾸면 모든 게 달라진다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 문제를 근본적으로 해결하는 원칙이 &lt;b&gt;의존성 역전 원칙(Dependency Inversion Principle, DIP)&lt;/b&gt; 이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;DIP의 핵심은 인터페이스를 쓰는 것이 아니라, 의존성의 방향을 바꾸는 것이다.&lt;/span&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;구체 클래스가 아닌 추상화(인터페이스) 에 의존할 때 비로소 바꿀 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;지금의 의존성 구조:&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772903097225&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Game(구체) &amp;rarr; Console(구체) &amp;rarr; Scanner/System(구체)&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;pre id=&quot;code_1772903150400&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Game &amp;rarr; IO 인터페이스 &amp;larr; Console
                     &amp;larr; Disk
                     &amp;larr; MockIO (테스트용)&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;Game 은 Console 이 무엇인지 모른다. 그냥 &quot;입력을 받고 출력을 할 수 있는 무언가&quot; 라는 계약(인터페이스)만 알고 있다. 실제 구현이 Console인지&amp;nbsp; Disk인지는 Game 바깥에서 결정된다.&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;테스트할 때는 MockIO를 주힙하면 된다. System.setIn() 트릭이 사라진다. 파일 출력으로 바꾸고 싶으면 Disk를 주입한다. Game은 한줄도 건드리지 않는다. 의존성이 인터페이스를 통해 외부에 드러나기 때문에, 숨겨진 의존성도 사라진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이게 가능한 이유는 의존성의 방향이 바뀌었기 때문이다. Console이 IO 인터페이스를 구현함으로써 Game이 원하는 방식으로 동작한다. 저수준이 고수준의 규약을 따르는 구조다.&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;6. 패키지로 분리했다 &amp;mdash; 그것만으로 충분한가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;외부 연동을 client나 infrastructure 같은 별도 패키지로 분리하는 것은 좋은 방향이다. 도메인과 물리적으로 분리한 것만으로도 코드 수정 시 영향 범위가 줄어드는 효과가 있다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;하지만 패키지 분리는 필요조건이지 충분 조건이 아니다. 진짜 질문은 이것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;도메인이 외부 연동 구현을 알고 있나?&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;만약 도메인 코드 안에서 외부 연동 구현체를 직접 생성하거나 참조하고 있다면, 물리적으로 패키지를 나눴더라도 논리적 결합은 그대로다. Console을 별도 클래스로 분리했지만 Game안에서 직접 생성했던 것처럼.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이상적인 구조는 이렇다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772903777272&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;domain 패키지
  ├── Game (고수준 정책)
  ├── IO (인터페이스 - 도메인이 정의)
  └── ...

infrastructure 패키지 (또는 client, adapter)
  ├── ConsoleIO implements IO
  ├── DiskIO implements IO
  └── ...&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;도메인이 인터페이스를 정의하고, infrastructure 가 그 인터페이스를 구현한다. 의존성의 방향이&amp;nbsp; infrastructure &amp;rarr; domain이다. 도메인은 외부 연동 구현을 모른다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;패키지 이름이 client든 infrastructure든 adapter든 크게 중요하지 않다. 중요한 건 &lt;b&gt;의존성의 방향&lt;/b&gt;과 &lt;b&gt;인터페이스를 누가 소유하느냐&lt;/b&gt;다.&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;7. 외부 연동을 분리하는 진짜 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;마지막으로 가장 본질적인 질문이다. 왜 외부 연동은 도메인 로직과 분리해야 할까?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;표면적 대답은 &quot;테스트하기 쉬워서&quot; 다. 맞지만 충분하지 않다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;더 깊은 이유는 변경의 이유가 다르기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;게임 로직은 게임 규칙이 바뀔 때 변경된다. 콘솔 코드는 출력 방식이 바뀔 때 변경된다. DB 연결 코드는DB 가 바뀔 때 변경된다. HTTP 클라이언트는 외부 API 스펙이 바뀔 떄 변경된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이것들의 변경 주기와 변경 이유는 전혀 다르다. 다른 이유로 변하는 것을 같은 클래스에 두면, 하나가 바뀔 때 다른 것도 수정해야 하는 상황이 반복된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;특히 외부 연동은 도메인보다 훨씬 자주, 예측하기 어려운 이유로 변한다. DB가 MySQL에서 PostgreSQL로 바뀔 수 있고, 콘솔 출력이 REST API 응답으로 바뀔 수 있으며, 키보드 입력이 메시지 큐 이벤트로 바뀔 수 있다. 이 변화들이 핵심 도메인 로직을 건드리게 두어선 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;한 문장으로 정리하면 이렇다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;에필로그: 테스트가 어려우면 설계를 먼저 의심하라&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;테스트 코드를 짜다가 막힐 때 가장 쉬운 반응은 &quot;이건 원래 테스트하기 어려운 코드야&quot;라고 합리화하는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;하지만 그 막힘은 거의 항상 설계 문제에서 온다. 숨겨진 의존성이 있지 않은지, 구체 클래스에 직접 의존하고 있지 않은지, 고수준 정책이 저수준 세부사항에 묶여 있진 않은지 확인해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 쉬운 코드는 대부분 좋은 설계를 갖고 있다. 반대도 성립한다. 좋은 설계를 가진 코드는 대부분 테스트하기 쉽다.&lt;/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;&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>JAVA</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/211</guid>
      <comments>https://newfangled.tistory.com/211#entry211comment</comments>
      <pubDate>Sun, 8 Mar 2026 02:25:58 +0900</pubDate>
    </item>
    <item>
      <title>왜 실무에서는 Criteria를 안 쓰고 QueryDSL을 선택할까? (JPA 쿼리 선택 기준 정리)</title>
      <link>https://newfangled.tistory.com/210</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;JPA&amp;nbsp; 가 지원하는 다양한 쿼리 방법 - JPQL부터 QueryDSL까지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA를 처음 배우면 em.find() 로 단건 조회하는 것부터 시작한다. 엔티티를 조회하고 연관관계를 따라 객체 그래프 탐색하는 방식은 매우 직관적이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실무로 넘어가면 상황이 완전히 달라진다. 다양한 검색 조건, 동적필터, 조인, 정렬, 페이징, 집계까지 단순 조회만으로 해결할 수 없는 복잡한 쿼리가 필수적으로 등장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 읽고 나면 네 가지를 명확히 판단할 수 있게 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPA 가 지원하는 쿼리 방법의 종류와 각각의 실무 활용도를 이해하게 된다.&lt;/li&gt;
&lt;li&gt;JPQL이 왜 &quot;객체 지향 SQL&quot;이라고 불리는지 본질적인 의미를 알게 된다.&lt;/li&gt;
&lt;li&gt;Criteria 가 이론적으로 좋지만 실무에서 외면받는 이유를 납득하게 된다.&lt;/li&gt;
&lt;li&gt;QueryDSL이 단순한 편의 도구가 아니라 설계 도구로 평가받는 이유를 이해하게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JPA가 지원하는 쿼리 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA는 다음과 같은 다양한 쿼리 방법을 지원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPQL&lt;/li&gt;
&lt;li&gt;JPA Criteria&lt;/li&gt;
&lt;li&gt;QueryDSL&lt;/li&gt;
&lt;li&gt;네이티브 SQL&lt;/li&gt;
&lt;li&gt;JDBC API 직접 사용 (MyBatis, SpringJdbcTemplate 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 중 Criteria와 QueryDSL은 새로운 쿼리 언어가 아니라 자바 코드로 JPQL 을 생성하는 (Generator) 계열 도구다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 대부분의 쿼리가 JPQL과 QueryDSL로 해결되며, 특정 데이터베이스에 종속적인 기능이 필요할 때만 네이티브 SQL이나 MyBatis&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;h3 data-ke-size=&quot;size23&quot;&gt;JPQL - 객체를 대상으로 하는 객체 지향 SQL&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;em.find()나 객체 그래프 탐색만으로는 복잡한 검색 조건을 처리할 수 없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;18세 이상 회원 전체 조회&quot; 같은 요구사항이 있다면 결국 WHERE&amp;nbsp; 조건이 포함된 쿼리가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 모든 데이터를 애플리케이션으로 가져온 뒤 메모리에서 필터링하는 것은 현실적으로 불가능하다. 네트워크 비용과 메모리 사용량이 폭발적으로 증가하고 DB 성능도 나오지 않는다. 결국 필요한 데이터만 DB에서 조회하려면 조건이 포함된 쿼리를 DB&amp;nbsp; 레벨에서 실행해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 등장한 것이&amp;nbsp; JPQL이다.&lt;/p&gt;
&lt;pre id=&quot;code_1772299108169&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;Member&amp;gt; resultList = em.createQuery(
        &quot;select m from Member m where m.name like '%kim%'&quot;,
        Member.class
).getResultList();&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;SQL과 문법이 매우 유사하지만 가장 본질적인 차이는 하나다. &lt;b&gt;테이블이 아니라 엔티티 객체를 대상으로 쿼리한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 로그를 보면&amp;nbsp; JPQL 이 먼저 주석으로 나오고, 엔티티 매핑 정보를 기반으로 실제 SQL&amp;nbsp; 로 변환되어 실행된다.&lt;/p&gt;
&lt;pre id=&quot;code_1772299176652&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/* select m from Member m where m.name like '%kim%' */
select m1_0.id, m1_0.age, m1_0.name
from MBR m1_0
where m1_0.name like '%kim%' escape ''&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;h3 data-ke-size=&quot;size23&quot;&gt;Criteria &amp;mdash; 이론적으로 완벽하지만 실무에서 외면받는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL의 가장 큰 한계는 문자열 기반이라는 점이다. 동적 쿼리를 작성할 때 조건을 상황에 따라 붙였다 뗏다 해야 하는데, 오타 위험,&amp;nbsp; WHERE/AND 순서 관리, 가독성 저하 같은 문제가 따라온다.&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; JPA Criteria다.&lt;/p&gt;
&lt;pre id=&quot;code_1772299280408&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery&amp;lt;Member&amp;gt; query = cb.createQuery(Member.class);
Root&amp;lt;Member&amp;gt; m = query.from(Member.class);

CriteriaQuery&amp;lt;Member&amp;gt; cq = query.select(m);

String name = &quot;test&quot;;
if (name != null) {
    cq = cq.where(cb.equal(m.get(&quot;name&quot;), &quot;kim&quot;));
}

List&amp;lt;Member&amp;gt; resultList = em.createQuery(cq).getResultList();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 코드 기반이라 컴파일 타임 검증이 가능하고 동적 쿼리 작성도 용이하다. 장점은 분명하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실무에서는 거의 사용도지 않는다. 이유는 단순히 복잡해서가 아니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;컴파일 안전성보다 가독성과 유지보수 비용이 더 중요하기 때문이다&lt;/b&gt;. Criteria 로 복잡한 쿼리를 작성하면 코드만 보고 어떤&amp;nbsp; SQL이 실행되는지 직관적으로 파악하기 어렵다. 이론적으로 우수하지만 실무 생산성 관점에서는 불리한 기술이라는 평가를 받는 이유다.&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;h3 data-ke-size=&quot;size23&quot;&gt;QueryDSL &amp;mdash; 가독성과 타입안전성을 동시에 해결한 실무 표준&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Criteria의 단점을 실질적으로 해결한 것이 QueryDSL이다.&lt;/p&gt;
&lt;pre id=&quot;code_1772299504135&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;JPAQueryFactory query = new JPAQueryFactory(em);
QMember m = QMember.member;

List&amp;lt;Member&amp;gt; list = query
        .selectFrom(m)
        .where(m.age.gt(18))
        .orderBy(m.name.desc())
        .fetch();&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;문자열이 아닌 자바 코드로 JPQL 을 작성하면서도 SQL 과 유사한 형태를 유지하기 때문에 가독성이 훨씬 좋다. 컴파일 타임 문법 검증, 타입 안전성, 동적 쿼리 작성의 용이성, 리팩토링 안정성까지 Criteria 의 장점을 그대로 가져오면서 단점을 극복했다.&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;한 가지 중요한 점은 QueryDSL이 새로운 쿼리 언어가 아니라는 것이다. QueryDSL&amp;nbsp; 은 JPQL을 타입 안정하게 생성해주는&amp;nbsp; DSL 이다. 따라서 QueryDSL을 제대로 사용하려면 JPQL 에 대한 이해가 선행되어야 한다.&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;네이티브 SQL &amp;mdash; DB 종속 기능이 필요할 때&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL로 해결할 수 없는 특정 데이터베이스 종속 기능이 필요한 경우 네이티브 SQL 을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Oracle의 CONNECT BY나 특정 DB 성능 튜닝 쿼리가 대표적인 예다.&lt;/p&gt;
&lt;pre id=&quot;code_1772295934102&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;em.createNativeQuery(&quot;select id, name from Member&quot;, Member.class)
        .getResultList();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 종속성이 생기므로 유지보수 관점에서 신중하게 사용해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;JDBC 직접 사용 시 주의사항 &amp;mdash; flush를 놓치지 말자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA를 사용하면서 JDBC, MyBatis, SpringJdbcTemplate을 함께 사용하는 것은 가능하다. 단 반드시 알아야 할 중요한 동작원리가 있다.&amp;nbsp; JPA를 우회하는 SQL은 영속성 컨텍스트가 인식하지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772299921819&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Member member = new Member();
member.setName(&quot;test1&quot;);
em.persist(member);

// JPA를 우회하는 SQL 실행 전 수동 flush
// createNativeQuery 의 경우 jpa 가 자동 플러시함
em.flush();

List&amp;lt;Member&amp;gt; resultList = em.createNativeQuery(
        &quot;select id, name from Member&quot;, Member.class)
        .getResultList();&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;JPQL은 실행 전에 자동으로 flush가 발생하지만, JDBC를 직접 사용할 경우 JPA가 쿼리 실행을 인지하지 못하기 때문에 명시적인 flush 처리가 필요하다. 이 부분을 놓피면 정합성 문제로 이어질 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&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;mdash; 실무에서의 JPA 쿼리 선택 기준&lt;/h2&gt;
&lt;div&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;b&gt;특징&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;실무 활용&lt;span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPQL&lt;/td&gt;
&lt;td&gt;객체 지향 SQL, 문자열 기반&lt;/td&gt;
&lt;td&gt;대부분의 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Criteria&lt;/td&gt;
&lt;td&gt;자바 코드 기반, 가독성 낮음&lt;/td&gt;
&lt;td&gt;거의 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QueryDSL&lt;/td&gt;
&lt;td&gt;자바 코드 기반, 가독성 높음&lt;/td&gt;
&lt;td&gt;동적 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네이티브 SQL&lt;/td&gt;
&lt;td&gt;DB 종속 기능 필요 시&lt;/td&gt;
&lt;td&gt;필요할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDBC / MyBatis&lt;/td&gt;
&lt;td&gt;JPA 우회, flush 주의&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;가장 현실적인 접근 순서는&amp;nbsp; JPQL을 먼저 정확히 이해하고, 동적 쿼리와 유지보수 복잡도가 증가하는 지점에 QueryDSL 을 도입하는 것이다. 중요한 것은 특정 기술을 맹목적으로 선택하는 것이 아니라 쿼리의 복잡도, 유지보수 비용, 팀 생산성을 기준으로 적절한 도구를 선택하는 설계 관점이다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JPA</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/210</guid>
      <comments>https://newfangled.tistory.com/210#entry210comment</comments>
      <pubDate>Sun, 1 Mar 2026 02:37:37 +0900</pubDate>
    </item>
    <item>
      <title>JPQL이 있는데 왜 Querydsl을 쓸까?</title>
      <link>https://newfangled.tistory.com/209</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;같은 쿼리로 비교하며 이해하는 Querydsl 의 진짜 가치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl 을 접하면 이런 의문이 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL도 있는데 왜 또 다른 쿼리 도구를 써야 하지?&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;겉으로 보면 JPQL과 Querydsl 은 결국 같은 JPQL을 실행한다. 하지만 실무에서의 차이는 문법이 아니라 오류를 발견하는 시점과 안전성에 갈린다.&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;이 글에서는 동일한 쿼리를 JPQL과 Querydsl로 각각 작성해보면서 두 방식의 차이를 코드와 동작 관점에서 직접 비교해본다.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPQL과 Querydsl의 차이를 단순 문법이 아닌 안전성 관점에서 비교할 수&amp;nbsp; 있다.&lt;/li&gt;
&lt;li&gt;Q 타입이 왜 존재하는지, 컴파일 타임 검증 구조를 이해하게 된다.&lt;/li&gt;
&lt;li&gt;Querydsl 의 자동 파라미터 바인딩이 보안과 성능에 주는 이점을 알 수 있다.&lt;/li&gt;
&lt;li&gt;JPAQueryFactory 를 필드로 선언해도 안전한 이유를 스프링 동작 원리 관점에서 납득하게 된다.&lt;/li&gt;
&lt;/ul&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;테스트 환경 세팅&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;먼저 JPQL과 Querydsl을 동일한 조건에서 비교하기 위해 테스트 환경을 구성한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772028945787&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootTest
@Transactional
public class QuerydslBasicTest {

    @Autowired
    private EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void setUp() {
        queryFactory = new JPAQueryFactory(em);

        Team teamA = new Team(&quot;teamA&quot;);
        Team teamB = new Team(&quot;teamB&quot;);
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member(&quot;member1&quot;, 10, teamA);
        Member member2 = new Member(&quot;member2&quot;, 20, teamA);
        Member member3 = new Member(&quot;member3&quot;, 30, teamB);
        Member member4 = new Member(&quot;member4&quot;, 40, teamB);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
    }
}&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;JPAQueryFactory 생성 시 EntityManager 를 전달받는다. 내부적으로 이 EntityManager 를 기반으로 JPQL을 생성하고, JPA 구현체(Hibernate)를&amp;nbsp; 통해 최종적으로 SQL이 실행되는 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 Querydsl 은 새로운 쿼리 언어가 아니라 &lt;b&gt;JPQL을 타입 안전하게 생성하는 DSL(Domain Specific Language)&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JPQL로&amp;nbsp; member1 조회하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 전통적인 JPQL방식이다.&lt;/p&gt;
&lt;pre id=&quot;code_1772029059532&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
public void startJPQL() {
    String jpql = &quot;select m from Member m where m.name = :name&quot;;

    Member findMember = em.createQuery(jpql, Member.class)
            .setParameter(&quot;name&quot;, &quot;member1&quot;)
            .getSingleResult();

    Assertions.assertThat(findMember.getName()).isEqualTo(&quot;member1&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;JPQL의 특징은 쿼리를 &lt;b&gt;문자열&lt;/b&gt;로 작성한다는 점이다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;:name 과 같은 파라미터를 선언하고 setParameter() 로 값을 직접 바인딩한다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;문법 자체는 단순하지만 구조적인 한계가 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;가장 대표적인 문제는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2405&quot; data-start=&quot;2378&quot;&gt;필드명 오타를 컴파일 단계에서 검증할 수 없음&lt;/li&gt;
&lt;li data-end=&quot;2423&quot; data-start=&quot;2406&quot;&gt;쿼리 오류가 런타임에 발견됨&lt;/li&gt;
&lt;li data-end=&quot;2439&quot; data-start=&quot;2424&quot;&gt;리팩토링 시 안전성 낮음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 오타가 있어도 컴파일 에러는 발생하지 않는다.&lt;/p&gt;
&lt;pre id=&quot;code_1772301070596&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;select m from Member m where m.nmae = :name&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;이 경우 애플리케이션 실행 시점에서야 예외가 발생한다.&lt;br /&gt;즉, 코드가 정상 컴파일되더라도 운영 환경에서 쿼리 오류가 발생할 수 있는 구조다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Querydsl로 member1 조회하기&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이제 동일한 쿼리를 Querydsl로 작성해보자.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772029210787&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
public void startQuerydsl() {
    QMember member = QMember.member; // 이미 생성된 Q타입 사용

    Member findMember = queryFactory
            .select(member)
            .from(member)
            .where(member.name.eq(&quot;member1&quot;))
            .fetchOne();

    Assertions.assertThat(findMember.getName()).isEqualTo(&quot;member1&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Querydsl은 JPAQueryFactory를 시작점으로, Q 타입(메타모델 클래스)을 이용해 쿼리를 자바 코드 형태로 작성한다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;QMember 는 Querydsl 이 빌드 과정에서 자동으로 생성하는 클래스이며, 엔티티 필드를 타입 안전하게 참조할 수 있도록 돕는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;일반적으로 이미 생성된 정적 인스턴스를 사용한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772029307717&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;QMember member = QMember.member;&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;new QMember(&quot;m&quot;)처럼 직접 생성할 수도 있지만, 생성자에 전달된 문자열은 별칭(alias)용도이기 때문에 특수한 조인 상황이 아니라면 기본 인스턴스를 사용하는 것이 일반적이다.&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;JPQL과 Querydsl의 핵심 차이&lt;/span&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;1. 오류 발견 시점 &amp;mdash; 런타임 vs 컴파일 타임&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;JPQL은 문자열 기반 쿼리다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772029406170&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;select m from Member m where m.nmae = :name&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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;위와 같이 필드명을 잘못 작성해도 컴파일러가 이를 검증할 수 없다. 애플리케이션이 실행되고 해당 쿼리가 호출되는 시점에서야 오류가 발생한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;반면 Querydsl 은 Q타입을 기반으로 쿼리를 작성한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772029503148&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;member.name.eq(&quot;member1&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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;존재하지 않는 필드를 호출하면 컴파일 단계에서 즉시 오류가 발생한다.&amp;nbsp; Querydsl 의 진짜 가치는 문법 편의성이 아니라 컴파일 타임 안전성에 있다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 파라미터 바인딩 방식과 안정성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL은 파라미터를 직접 선언하고 바인딩해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772029591432&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.setParameter(&quot;name&quot;, &quot;member1&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;반면 Querydsl은 값을 직접 전달하는 형태로 작성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1772029615032&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.where(member.name.eq(&quot;member1&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;하지만 내부적으로 문자열을 직접 이어붙이는 방식이 아니라 JDBC의 PreparedStatement 기반 파라미터 바인딩이 자동으로 적용된다. 실행 로그를 보면 값이 ? 로 바인딩되는 것을 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1772301299135&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;where member0_.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;이 구조는 두 가지 장점을 가진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SQL Injection&amp;nbsp; 공격에 대한 방어,&lt;/li&gt;
&lt;li&gt;그리고 동일한 쿼리 구조 재사용으로 인한 DB 성능 이점이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl은 편의성뿐 아니라 보안과 성능 측면에서도 안전한 기본 구조를 제공한다.&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;JPAQueryFactory를 필드로 선언해도 안전한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 자주 나오는 질문 중 하나가 이것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;JPAQueryFactory를 필드로 선언하면 멀티스레드 환경에서 안전할까?&quot;&lt;/p&gt;
&lt;/blockquote&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_1772029794102&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootTest
@Transactional
public class QuerydslBasicTest {

    @Autowired
    private EntityManager em;

    JPAQueryFactory queryFactory; // 필드 레벨 선언
}&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;그 이유는 스프링이 주입하는 EntityManager 가 실제 구현체가 아니라 &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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청마다 새로운 EntityManager 를 직접 공유하는 것이 아니라&lt;/li&gt;
&lt;li&gt;현재 트랜잭션에 바인딩된 EntityManager 가 동적으로 할당된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 여러 스레드가 동시에 접근하더라고 각 트랜잭션에 맞는 EntityManager 가 사용되며 동시성 문제 없이 동작한다.&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;이 구조 덕분에 JPAQueryFactory 는 필드로 선언하여 재사용하는 것이 일반적으로 권장된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;정리: Querydsl의 진짜 가치는 가독성이 아니다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;많은 사람들이 Querydsl을 &quot;코드가 더 깔끔해지는 쿼리 도구&quot;라고 생각한다. 하지만 실무에서의 핵심 가치는 다르다.&lt;/span&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;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;JPQL&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;Querydsl&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;쿼리 작성 방식&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;문자열&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;자바 코드&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;오류 발견 시점&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;런타임&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;컴파일 타임&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;파라미터 바인딩&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;직접 처리&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;자동 처리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;SQL Injection 방어&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;주의 필요&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;자동 방어&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;리팩토링 안정성&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;낮음&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;높음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;결국&amp;nbsp;Querydsl은 단순히 쿼리를 편하게 작성하는 도구라기보다, &lt;b&gt;런타임에 발생할 수 있는 쿼리 리스크를 컴파일 타임으로 끌어올리는 설계 도구에 가깝다.&amp;nbsp; &lt;/b&gt;기능 구현 속도보다 유지보수성과 안정성이 더 중요한 문제가 되는 경우가 많다.&lt;br /&gt;이러한 관점에서 Querydsl은 단순한 생산성 도구가 아니라 장기적인 코드 안정성을 위한 선택지라고 볼 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5179&quot; data-start=&quot;5159&quot; data-ke-size=&quot;size16&quot;&gt;따라서 현실적인 접근은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-end=&quot;5274&quot; data-start=&quot;5181&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;5274&quot; data-start=&quot;5183&quot; data-ke-size=&quot;size16&quot;&gt;JPQL의 동작 원리를 먼저 정확히 이해하고,&lt;br /&gt;동적 쿼리와 유지보수 복잡도가 증가하는 시점에&amp;nbsp; &lt;br /&gt;Querydsl을 도입하는 것이 가장 합리적인 전략이다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>JPA</category>
      <author>경딩</author>
      <guid isPermaLink="true">https://newfangled.tistory.com/209</guid>
      <comments>https://newfangled.tistory.com/209#entry209comment</comments>
      <pubDate>Wed, 25 Feb 2026 23:36:03 +0900</pubDate>
    </item>
  </channel>
</rss>