<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발 갤러리</title>
    <link>https://dev-gallery.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 19 May 2026 12:42:46 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>옐리yelly</managingEditor>
    <image>
      <title>개발 갤러리</title>
      <url>https://tistory1.daumcdn.net/tistory/5624543/attach/62aefacf52534349860e4808a6754a08</url>
      <link>https://dev-gallery.tistory.com</link>
    </image>
    <item>
      <title>BDD 스타일의 테스트 코드 작성 가이드</title>
      <link>https://dev-gallery.tistory.com/99</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7xI0c/dJMcagjxpwc/Cc5G3WimPqoo5952G6Crk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7xI0c/dJMcagjxpwc/Cc5G3WimPqoo5952G6Crk0/img.png&quot; data-alt=&quot;gemini로 생성한 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7xI0c/dJMcagjxpwc/Cc5G3WimPqoo5952G6Crk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7xI0c%2FdJMcagjxpwc%2FCc5G3WimPqoo5952G6Crk0%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;300&quot; height=&quot;300&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;gemini로 생성한 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사내에서 프로젝트를 수행하며 테스트 코드의 중요성을 동료들에게 설파했지만, 테스트 작성에 익숙하지 않은 동료들도 있어 가이드를 작성할 겸 정리하며 모호했던 개념들이 정리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 피라미드를 이해하고 있다면 작성해야 할 테스트와 그렇지 않은 테스트를 좀 더 빨리 알 수 있는데, 이 글의 목적에서 벗어나는 내용도 많으므로 다른 정리 글에서 정리하겠다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;용어 정리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경험상 용어를 정확히 알고 작성하면 전체적인 코드의 가독성이 좋아짐을 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 작성하고 있는 코드 한 줄이 Stub인지, Mock인지를 모르고 작성하면, 읽는 사람이 이해하는데 시간과 에너지를 더 많이 써야한다.&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;a href=&quot;https://velog.io/@lxxjn0/Test-Double%EC%9D%84-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 블로그&lt;/a&gt;를 참고했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Test Double (&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;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&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;size18&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;스턴트 더블(위험한 액션 씬을 대신해서 수행하는 스턴트 배역)에서 유래한 단어로, xUnit Test Pattern의 저자인 제라드 메스자로스가 만든 용어. &lt;/span&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;/span&gt;Dummy, Fake, Stub, Spy, Mock으로 세분화할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Dummy&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;객체가 필요하지만 사용되지는 않을 때 사용&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Fake&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;실제 구현을 단순화한 대체 구현체. 예를 들어, 실제 DB 대신 인메모리 DB를 사용하는 경우가 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Stub&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;미리 준비된 결과를 제공하는 객체.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터페이스 또는 기본 클래스가 최소한으로 구현된 상태이며, 하드 코딩된 정해진 값을 반환하도록 프로그래밍됨&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Spy&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Stub의 역할을 하면서 호출된 정보를 기록하는 객체.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 객체를 감싸거나(래핑 하거나) 상속해서 실제 객체처럼 행동하지만, 메서드가 호출된 횟수나 전달된 인자 등의 정보를 기록한다. 테스트가 끝난 후 이 기록을 검사해서 상호작용을 검증한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Mock&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;호출에 대한 기대를 명세하고, 그 충족 여부를 검증할 때 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stub, Spy의 기능을 모두 가지며, 테스트 시작 전에 어떤 인자로 몇 번 호출되어야 하는지 등 기대(Expectation)를 미리 정의한다. 테스트 종료 후 이 명세가 충족되지 않으면 테스트가 실패한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;BDD 스타일이란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;BDD는 Behaviour-Driven Development(행위 주도 개발)의 약자다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;핵심은 &lt;i&gt;&lt;b&gt;테스트 대상의 행동이 야기하는 상태의 변화가 의도한 결과인지 테스트하는 것&lt;/b&gt;&lt;/i&gt;이다.&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;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;하나의 시나리오는 Given, When, Then 구조를 기본 패턴으로 한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Feature&lt;/b&gt;: 테스트 대상의 기능/책임을 명시&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Scenario&lt;/b&gt;: 테스트 목적을 설명&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Given&lt;/b&gt;: 시나리오 진행을 위해 필요한 값을 설정 (테스트 대상의 &lt;i&gt;&lt;b&gt;환경&lt;/b&gt;&lt;/i&gt;을 설정)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;When&lt;/b&gt;: 시나리오 진행을 위해 필요한 조건을 설정 (테스트 대상의 &lt;i&gt;&lt;b&gt;행동&lt;/b&gt;&lt;/i&gt;을 요구)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Then&lt;/b&gt;: 시나리오 완료 시 보장해야 하는 결과를 명시 (기대하는 &lt;i&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/i&gt;를 검증)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;테스트 코드 작성 원칙&lt;/b&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;Test &lt;b&gt;WHAT (behavior)&lt;/b&gt;, not &lt;b&gt;HOW (implementation)&lt;/b&gt;&lt;/span&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;b&gt;&amp;lsquo;무엇(what)&amp;rsquo;을 해야 하는지 (행동)&lt;/b&gt;에 초점을 맞추고, 내부가 &lt;b&gt;&amp;lsquo;어떻게(how)&amp;rsquo; 구현되었는지&lt;/b&gt;는 테스트의 주 목표가 아니라는 의미다.&lt;/li&gt;
&lt;li&gt;이 원칙을 따라야 하는 이유는 두 가지를 들 수 있다.
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;구현 변경에 대한 유연성:&lt;/b&gt;&amp;nbsp;구현 방식이 달라지더라도 요구사항이 동일하다면 테스트 케이스는 유효할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;요구사항 중심의 테스트:&lt;/b&gt;&amp;nbsp;개발자의 구현 방식이 아니라, 사용자가 기대하는 요구사항 중심의 테스트를 보장한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;BDD 스타일로 테스트 코드 작성하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;예제: 시험 결과를 입력하면 합격 기준에 따라 심사를 거쳐 자격증을 발급하는 시스템이 있다. 이 시스템에선 &lt;/b&gt;&lt;b&gt;시험 결과를 입력하는 이벤트가 발행되면, 합격 기준에 속하는 이벤트만 자격증 취득 심사를 생성한다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;BDD 테스트 패턴: 테스트 대상의 행동이 야기하는 상태 변화 검증하기&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;테스트 대상 (feature): 테스트 대상의 기능/책임을 명시한다.&lt;/b&gt;&lt;/h4&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;시나리오 (scenario): 테스트 목적을 설명한다.&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예제에선 필기평가 결과(ExamResult)를 입력했을 때, 그 결과가 합격 기준에 해당하면 자격 취득 심사(InitialReview)가 생성되는 동작을 검증함을 설명한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;given-when-then 패턴&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;given&lt;/b&gt;: 시나리오 진행을 위해 필요한 &lt;i&gt;&lt;b&gt;초기 조건 등을 설정&lt;/b&gt;&lt;/i&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;b&gt;불합격&lt;/b&gt;들을 포함하는 필기평가 결과 (ExamResult)들을 입력한다(=엔티티를 생성한다).
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;합격 기준: 총 점수 70점 이상 + 응시완료 상태&lt;/li&gt;
&lt;li&gt;불합격 기준: 1) 합격 기준 점수 미달이거나, 2) 부정행위 등에 의해 응시무효이거나, 3) 아예 응시하지 않은 경우&lt;/li&gt;
&lt;li&gt;필기평가 결과를 최초로 입력하는 상황이며, 필기평가와 연결된 자격 취득 심사는 존재하지 않는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;when&lt;/b&gt;: 테스트 대상에게 &lt;i&gt;&lt;b&gt;특정 행동을 요구&lt;/b&gt;&lt;/i&gt;하거나 &lt;i&gt;&lt;b&gt;이벤트를 발생&lt;/b&gt;&lt;/i&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;&lt;b&gt;then&lt;/b&gt;: 시나리오 완료 시 &lt;i&gt;&lt;b&gt;보장해야 할 결과&lt;/b&gt;&lt;/i&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;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구체적인 예제&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. JUnit 5를 활용한 테스트 대상과 시나리오 작성&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1762786481177&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.&quot;)
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. given-when-then 패턴으로 작성&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;given&lt;/b&gt;: 합격 및 불합격 결과들이 준비되었을 때&lt;/p&gt;
&lt;pre id=&quot;code_1762786517266&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.&quot;)
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given: 합격 및 불합격 결과들이 준비
    /* Phase 1-1. 테스트 진행을 위한 Dummy 설정: 원서 접수 생성 */
    ExamApplication examApplication = mock(ExamApplication.class); // 실제 기능을 하지 않는 Dummy 객체

    /* Phase 1-2. 테스트 진행을 위한 Stub 설정: 협력 관계의 대상들의 행동 설정 */
    given(reviewRepository.findByExamResultId(any())) // 필기평가 최초 입력 당시엔 연결된 자격 취득 심사가 존재하지 않음
            .willReturn(Optional.empty());

    /* Phase 1-3. 테스트 대상을 호출할 때 전달하는 Mock 설정: 필기평가 결과 설정 */
    // case 1. 합격 케이스 - 시험에 응시했으며, 시험 결과가 합격 -&amp;gt; 자격 취득 심사 생성
    ExamResult passed = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(90)
            .build();

    // case 2. 불합격 케이스 - 시험에 미응시 -&amp;gt; 자격 취득 심사 생성 X
    ExamResult notPassed1 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.NOT_TAKEN)
            .examOutcome(ExamOutcome.NOT_TAKEN)
            .totalScore(null)
            .build();

    // case 3. 불합격 케이스 - 응시 무효 -&amp;gt; 자격 취득 심사 생성 X
    ExamResult notPassed2 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.INVALID)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(100)
            .build();

    // case 4. 불합격 케이스 - 기준 점수 미달 -&amp;gt; 자격 취득 심사 생성 X
    ExamResult notPassed3 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.FAIL)
            .totalScore(60)
            .build();

    // 필기시험 결과 조회에 대한 Stub 설정
    given(examResultRepository.findById(passed.getId())).willReturn(Optional.of(passed));
    given(examResultRepository.findById(notPassed1.getId())).willReturn(Optional.of(notPassed1));
    given(examResultRepository.findById(notPassed2.getId())).willReturn(Optional.of(notPassed2));
    given(examResultRepository.findById(notPassed3.getId())).willReturn(Optional.of(notPassed3));

    // 필기평가 결과 입력 이벤트 생성 (최초 입력=FIRST_SUBMISSION)
    ExamResultCompletedEvent event = ExamResultCompletedEvent.of(
            List.of(passed.getId(), notPassed1.getId(), notPassed2.getId(), notPassed3.getId()),
            ExamResultSubmissionFlag.FIRST_SUBMISSION
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;when&lt;/b&gt;: 필기평가 결과 입력 이벤트가 발행되면&lt;/p&gt;
&lt;pre id=&quot;code_1762786546106&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.&quot;)
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given 생략
		
    // when: 필기평가 결과 입력 이벤트를 발행한다.
    eventListener.handleExamResultCompleted(event);
}&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;size18&quot;&gt;&lt;b&gt;then&lt;/b&gt;: 오직 합격 결과에 대해서만 심사 저장(save)이 단 한 번 일어난다.&lt;/p&gt;
&lt;pre id=&quot;code_1762786573237&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.&quot;)
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given 생략
	
    // when 생략
		
    // then: 합격 기준에 해당하는 케이스만 자격 취득 심사를 생성하는지 검증한다.
    then(reviewRepository).should(times(1)).save(any(InitialQualificationReview.class));
    
    // 이 부분은 '무엇(what)'이 아닌 '어떻게(how)'에 가까우르모 분리 또는 생략한다.
    then(reviewRepository).should(times(4)).findByExamResultId(any(UUID.class));
    then(examResultRepository).should(times(4)).findById(any(UUID.class));
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;전체 코드&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1762786602717&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.&quot;)
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given: 합격과 불합격들을 포함하는 필기평가 결과 (ExamResult)들을 입력
    /* Phase 1-1. 테스트 진행을 위한 Dummy 설정: 원서 접수 생성 */
    ExamApplication examApplication = mock(ExamApplication.class); // 실제 기능을 하지 않는 Dummy 객체

    /* Phase 1-2. 테스트 진행을 위한 Stub 설정: 협력 관계의 대상들의 행동 설정 */
    given(reviewRepository.findByExamResultId(any())) // 필기평가 최초 입력 당시엔 연결된 자격 취득 심사가 존재하지 않음
            .willReturn(Optional.empty());

    /* Phase 1-3. 테스트 대상을 호출할 때 전달하는 Mock 설정: 필기평가 결과 설정 */
    // case 1. 합격 케이스 - 시험에 응시했으며, 시험 결과가 합격 -&amp;gt; 자격 취득 심사 생성
    ExamResult passed = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(90)
            .build();

    // case 2. 불합격 케이스 - 시험에 미응시 -&amp;gt; 자격 취득 심사 생성 X
    ExamResult notPassed1 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.NOT_TAKEN)
            .examOutcome(ExamOutcome.NOT_TAKEN)
            .totalScore(null)
            .build();

    // case 3. 불합격 케이스 - 응시 무효 -&amp;gt; 자격 취득 심사 생성 X
    ExamResult notPassed2 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.INVALID)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(100)
            .build();

    // case 4. 불합격 케이스 - 기준 점수 미달 -&amp;gt; 자격 취득 심사 생성 X
    ExamResult notPassed3 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.FAIL)
            .totalScore(60)
            .build();

    // 필기시험 결과 조회에 대한 Stub 설정
    given(examResultRepository.findById(passed.getId())).willReturn(Optional.of(passed));
    given(examResultRepository.findById(notPassed1.getId())).willReturn(Optional.of(notPassed1));
    given(examResultRepository.findById(notPassed2.getId())).willReturn(Optional.of(notPassed2));
    given(examResultRepository.findById(notPassed3.getId())).willReturn(Optional.of(notPassed3));

    // 필기평가 결과 입력 이벤트 생성 (최초 입력=FIRST_SUBMISSION)
    ExamResultCompletedEvent event = ExamResultCompletedEvent.of(
            List.of(passed.getId(), notPassed1.getId(), notPassed2.getId(), notPassed3.getId()),
            ExamResultSubmissionFlag.FIRST_SUBMISSION
    );

    // when: 필기평가 결과 입력 이벤트를 발행한다.
    eventListener.handleExamResultCompleted(event);

    // then: 합격 기준에 해당하는 케이스만 자격 취득 심사를 생성하는지 검증한다.
    then(reviewRepository).should(times(1)).save(any(InitialQualificationReview.class));
    
    // 이 부분은 '무엇(what)'이 아닌 '어떻게(how)'에 가까우르모 분리 또는 생략한다.
    then(reviewRepository).should(times(4)).findByExamResultId(any(UUID.class));
    then(examResultRepository).should(times(4)).findById(any(UUID.class));
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 테스트 결과 (PASS)&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;98&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/matjA/dJMcagRngKq/ZmYmyrGI8ME6cn7FXeqHZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/matjA/dJMcagRngKq/ZmYmyrGI8ME6cn7FXeqHZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/matjA/dJMcagRngKq/ZmYmyrGI8ME6cn7FXeqHZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmatjA%2FdJMcagRngKq%2FZmYmyrGI8ME6cn7FXeqHZ1%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;949&quot; height=&quot;98&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;98&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 테스트 코드 리팩토링&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;테스트 코드는 프로덕션 코드와 똑같이 중요하다. 따라서 &lt;b&gt;누구나 읽기 쉽게 작성하고, 유지 관리를 해야 한다.&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;작성된 코드에는 문제가 몇 가지가 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;given&lt;/b&gt; 부분: 테스트 더블을 작성하는 코드들이 지저분해서 가독성이 떨어진다. 반복적으로 생성하는 부분을 메서드로 분리하면 가독성이 개선된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;then&lt;/b&gt; 부분: 테스트 대상(합격 기준을 만족하는 이벤트만 심사를 생성)과 연관되지만 무엇(what)이 아닌 어떻게(how)에 가까운 부분들이 있다. 해당 검증은 별도의 테스트로 분리하거나 생략해서 가독성을 개선할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;개선된 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1762786802652&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;필기평가 결과가 '응시완료' 및 '합격'일 때만 자격취득심사가 생성되어야 한다.&quot;)
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given: (준비) 합격/불합격 케이스를 포함하는 필기평가 결과들이 준비되고, 기존 심사가 없도록 설정
    ExamResult passedResult = createPassedExamResult(90); // 합격 케이스
    ExamResult notTakenResult = createNotTakenExamResult(); // 불합격 케이스 1: 미응시
    ExamResult invalidResult = createInvalidExamResult(100); // 불합격 케이스 2: 응시 무효
    ExamResult failedResult = createFailedExamResult(60); // 불합격 케이스 3: 기준 점수 미달

    // 협력 객체 설정: 필기평가 결과 ID로 조회 시 해당 결과 반환, 기존 심사는 없다고 설정
    setUpExamResultStub(passedResult, notTakenResult, invalidResult, failedResult);
    setUpNoExistingReviewStub();

    // 필기평가 결과 입력 이벤트 생성
    ExamResultCompletedEvent event = createEvent(
        List.of(passedResult.getId(), notTakenResult.getId(), invalidResult.getId(), failedResult.getId())
    );

    // when: (행동) 필기평가 결과 입력 완료 이벤트를 발행한다.
    eventListener.handleExamResultCompleted(event);

    // then: (결과 검증) 합격 케이스에 대해서만 InitialQualificationReview가 생성(저장)되었는지 확인한다.
    // **핵심 검증:** 오직 한 번 (합격 케이스)만 InitialQualificationReview 객체가 저장되어야 한다.
    then(reviewRepository).should(times(1)).save(any(InitialQualificationReview.class));
}

// BDDMockito를 사용하여 given() 메소드를 static import했다고 가정
private ExamResult createPassedExamResult(int score) {
    // 합격 기준 케이스: COMPLETED, PASS, 70점 이상
    return ExamResult.builder()
            .id(UUID.randomUUID())
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(score)
            .examApplication(mock(ExamApplication.class)) // Dummy 객체
            .build();
}

private ExamResult createNotTakenExamResult() {
    // 불합격 케이스 1: NOT_TAKEN
    return ExamResult.builder()
            .id(UUID.randomUUID())
            .participationStatus(ParticipationStatus.NOT_TAKEN)
            .examOutcome(ExamOutcome.NOT_TAKEN)
            .totalScore(null)
            .examApplication(mock(ExamApplication.class)) // Dummy 객체
            .build();
}

private void setUpExamResultStub(ExamResult... results) {
    // 필기시험 결과 조회(findById)에 대한 Stub 설정 일괄 처리
    for (ExamResult result : results) {
        given(examResultRepository.findById(result.getId()))
                .willReturn(Optional.of(result));
    }
}

private void setUpNoExistingReviewStub() {
    // 자격 취득 심사 존재 여부(findByExamResultId)에 대한 Stub 설정 (항상 없다고 가정)
    given(reviewRepository.findByExamResultId(any()))
            .willReturn(Optional.empty());
}

private ExamResultCompletedEvent createEvent(List&amp;lt;UUID&amp;gt; resultIds) {
    // 이벤트 생성
    return ExamResultCompletedEvent.of(
            resultIds,
            ExamResultSubmissionFlag.FIRST_SUBMISSION
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;br /&gt;정리하며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 작성할 때 항상 고민되는 부분은 두 가지 정도였다.&lt;/p&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;1. 하나는 어디서부터 Stub으로 만들고, 어디까지 Mock으로 두느냐에 대한 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 다른 하나는 모두가 읽기 쉬운 깔끔한 코드를 작성하는 방법이다.&lt;/p&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;1번에 대한 고민은 동료의 힌트로 알게 된 두 분파&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;i&gt;Mockist&lt;/i&gt;와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;i&gt;Classicist&lt;/i&gt;의 철학을 찾아보며 갈피를 잡게 되었고, 빠르게 실행해 피드백을 얻을 수 있도록 협력 관계의 객체들을 Mocking 하는 방식을 채택해 해결했다. 그럼에도 여전히 실험을 하며 더 읽기 쉽고 명확한 테스트가 되도록 많이 작성하며 경험적으로 터득해야 한다.&lt;/p&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;2번에 대한 고민은 많은 테스트를 작성하고, 검토하며, 모범 사례들을 찾아보는 것이 도움이 됐다. 그러다 보면 자연스럽게 테스트 프레임워크에 대한 숙련도가 올라가기 마련이고, 동료들과 작성된 코드로부터 피드백을 얻어 개선해 나가는 것이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여담으로... 프로젝트를 진행하며 고객의 요구사항이 자주 변경되는 파트를 담당했을 때다.&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;그때 테스트 코드를 작성하는 가이드가 있었더라면... 어떻게(how)가 아니라 무엇(what)을 해야 하는지에 초점을 맞추는 것을 염두에 두고 작성했더라면... 이런 생각이 프로젝트 내내 마음 한편에 있었는데, 이번 기회에 테스트에 익숙하지 않은 동료들에게 공유할 글을 정리할 수 있어서 뿌듯하다.&lt;/p&gt;</description>
      <category>Java</category>
      <category>BDD</category>
      <category>TEST</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/99</guid>
      <comments>https://dev-gallery.tistory.com/99#entry99comment</comments>
      <pubDate>Tue, 11 Nov 2025 00:02:51 +0900</pubDate>
    </item>
    <item>
      <title>JPA Entity에서 Set 사용 시 equals, hashCode 구현과 해시 충돌 해결기</title>
      <link>https://dev-gallery.tistory.com/98</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;서론&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate 6로 업그레이드하면서 기존에 잘 동작하던 코드에서 성능 문제가 발생했다. 문제의 원인은 JPA Entity에서 Set 컬렉션을 사용할 때 equals와 hashCode를 제대로 구현하지 않아서 생긴 해시 충돌이었다. 특히 클래스 기반 hashCode 구현으로 인해 모든 같은 타입의 Entity가 동일한 해시값을 가지면서 HashSet이 O(n) 성능으로 동작하는 치명적인 문제를 겪었다. 오늘은 이 문제의 원인과 해결 과정을 정리해보자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;본론&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 상황: 클래스 기반 hashCode의 함정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 JPA Buddy가 생성해주는 equals, hashCode를 그대로 사용했다. JPA Buddy를 선택한 이유는 다음과 같았다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;JPA Buddy를 사용한 이유:&lt;/b&gt;&lt;br /&gt;- Hibernate 프록시 문제를 고려한 안전한 구현 제공&lt;br /&gt;-&amp;nbsp;ID기반 비교의 문제점(새 Entity의 null ID)을 비즈니스 키로 해결&lt;br /&gt;-&amp;nbsp;개발자가 직접 구현할 때 놓치기 쉬운 엣지 케이스들을 처리&lt;br /&gt;-&amp;nbsp;코드 생성 속도와 일관성 확보&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 JPA Buddy의 기본 hashCode 구현에는 성능상 치명적인 문제가 숨어있었다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Hibernate 프록시란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 코드를 이해하기 위해 Hibernate의 프록시 개념을 간단히 알아보자. Hibernate는 지연 로딩을 위해 실제 Entity 대신 프록시 객체를 생성한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 지연 로딩 시 실제로는 프록시 객체가 반환됨
ExamSubject subject = entityManager.getReference(ExamSubject.class, 1L);
System.out.println(subject.getClass().getName());
// com.example.ExamSubject$HibernateProxy$... 이런 식으로 나온다

// 실제 클래스 정보를 얻으려면
Class&amp;lt;?&amp;gt; realClass = subject instanceof HibernateProxy
    ? ((HibernateProxy) subject).getHibernateLazyInitializer().getPersistentClass()
    : subject.getClass();
// 이제야 com.example.ExamSubject가 나온다
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 프록시 문제 때문에 단순히 getClass()로 비교하면 같은 Entity임에도 불구하고 다르다고 판별될 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JPA Buddy는 이런 복잡성을 처리해준다.&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
public class ExamSubject {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = &quot;exam_session_id&quot;)
    private ExamSession examSession;
    
    @ManyToOne
    @JoinColumn(name = &quot;cert_subject_id&quot;)
    private CertSubject certSubject;
    
    // JPA Buddy가 생성한 클래스 기반 hashCode
    @Override
    public final int hashCode() {
        return this instanceof HibernateProxy 
            ? ((HibernateProxy) this).getHibernateLazyInitializer()
                .getPersistentClass().hashCode()
            : getClass().hashCode();
    }
    
    @Override
    public final boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        
        Class&amp;lt;?&amp;gt; oEffectiveClass = o instanceof HibernateProxy
            ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
            : o.getClass();
        Class&amp;lt;?&amp;gt; thisEffectiveClass = this instanceof HibernateProxy
            ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass()
            : this.getClass();
            
        if (thisEffectiveClass != oEffectiveClass) return false;
        
        ExamSubject that = (ExamSubject) o;
        
        // 비즈니스 키 기반 equals
        Long thisCertSubjectId = this.getCertSubject() != null 
            ? this.getCertSubject().getId() : null;
        Long thatCertSubjectId = that.getCertSubject() != null 
            ? that.getCertSubject().getId() : null;
            
        return Objects.equals(thisCertSubjectId, thatCertSubjectId);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 발견: 빈번한 해시 충돌&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 모든 ExamSubject 객체가 같은 hashCode를 가짐
ExamSubject subject1 = new ExamSubject(session1, cert1);
ExamSubject subject2 = new ExamSubject(session2, cert2);
ExamSubject subject3 = new ExamSubject(session3, cert3);

subject1.hashCode() == subject2.hashCode() == subject3.hashCode(); // TRUE

// HashSet 성능이 O(n)으로 저하
Set&amp;lt;ExamSubject&amp;gt; subjects = new HashSet&amp;lt;&amp;gt;();
subjects.add(subject1); // 버킷[123] = [subject1]
subjects.add(subject2); // 버킷[123] = [subject1, subject2]
subjects.add(subject3); // 버킷[123] = [subject1, subject2, subject3]

// contains 연산이 선형 탐색으로 동작
subjects.contains(subject2); // O(n) 시간 복잡도
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해시 충돌로 인한 실제 성능 문제&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영 환경에서 ExamSession에 여러 ExamSubject를 관리하는 Set 컬렉션이 있었는데, 데이터가 많아질수록 성능이 급격히 저하되었다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Entity
public class ExamSession {
    @OneToMany(mappedBy = &quot;examSession&quot;, cascade = ALL, orphanRemoval = true)
    private Set&amp;lt;ExamSubject&amp;gt; examSubjects = new HashSet&amp;lt;&amp;gt;();
    
    // 성능 문제 발생 지점
    public void addCertSubject(CertSubject certSubject) {
        // contains 체크가 O(n)으로 동작
        boolean exists = examSubjects.stream()
            .anyMatch(es -&amp;gt; es.getCertSubject().equals(certSubject));
            
        if (exists) {
            throw new BusinessException(&quot;이미 등록된 자격종목입니다&quot;);
        }
        
        ExamSubject examSubject = ExamSubject.of(this, certSubject);
        examSubjects.add(examSubject); // 해시 충돌로 인한 성능 저하
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;equals/hashCode 계약 규칙 이해하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 해결하기 전에 기본 원칙부터 정리했다. Java의 equals/hashCode 계약은 다음과 같다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;핵심 계약:&lt;/b&gt;&lt;br /&gt;- equals가 true를 반환하면 hashCode도 반드시 동일해야 함 (필수)&lt;br /&gt;- hashCode가 같아도 equals는 false일 수 있음 (해시 충돌 허용)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 계약을 어기면 HashMap, HashSet 등의 해시 기반 컬렉션에서 예상치 못한 동작이 발생한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 필수 규칙: equals가 true면 hashCode도 반드시 동일해야 함
if (a.equals(b) == true) {
    then a.hashCode() == b.hashCode(); // 반드시 성립해야 함
}

// 역은 성립하지 않아도 됨 (해시 충돌 허용)
if (a.hashCode() == b.hashCode()) {
    then a.equals(b); // true일 수도, false일 수도 있음
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;올바른 구현 예시:&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class Person {
    String name;
    int age;
    
    @Override
    public boolean equals(Object o) {
        // name과 age 둘 다 비교
        return Objects.equals(name, that.name) 
               &amp;amp;&amp;amp; Objects.equals(age, that.age);
    }
    
    @Override
    public int hashCode() {
        // equals보다 적은 필드 사용 가능 (name만)
        return Objects.hash(name);
    }
}

Person p1 = new Person(&quot;김철수&quot;, 30);
Person p2 = new Person(&quot;김철수&quot;, 40);

p1.hashCode() == p2.hashCode(); // TRUE (같은 name)
p1.equals(p2);                  // FALSE (다른 age)
// 이건 정상적인 해시 충돌 상황이다
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;잘못된 구현 예시:&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;class Person {
    String name;
    int age;
    
    @Override
    public boolean equals(Object o) {
        // name만 비교
        return Objects.equals(name, that.name);
    }
    
    @Override
    public int hashCode() {
        // equals보다 많은 필드 사용 (문제!)
        return Objects.hash(name, age);
    }
}

Person p1 = new Person(&quot;김철수&quot;, 30);
Person p2 = new Person(&quot;김철수&quot;, 40);

p1.equals(p2);                  // TRUE (같은 name)
p1.hashCode() == p2.hashCode(); // FALSE (다른 age)
// 이건 계약 위반이다! equals가 true면 hashCode도 같아야 한다
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 방법 1: 비즈니스 키 기반 hashCode&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 효과적인 해결책은 비즈니스 키를 활용한 hashCode 구현이었다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
public class ExamSubject {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = &quot;exam_session_id&quot;)
    private ExamSession examSession;
    
    @ManyToOne
    @JoinColumn(name = &quot;cert_subject_id&quot;)
    private CertSubject certSubject;
    
    @Override
    public final boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        
        Class&amp;lt;?&amp;gt; oEffectiveClass = o instanceof HibernateProxy
            ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
            : o.getClass();
        Class&amp;lt;?&amp;gt; thisEffectiveClass = this instanceof HibernateProxy
            ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass()
            : this.getClass();
            
        if (thisEffectiveClass != oEffectiveClass) return false;
        
        ExamSubject that = (ExamSubject) o;
        
        // 비즈니스 키 기반 equals
        Long thisCertSubjectId = this.getCertSubject() != null 
            ? this.getCertSubject().getId() : null;
        Long thatCertSubjectId = that.getCertSubject() != null 
            ? that.getCertSubject().getId() : null;
            
        return Objects.equals(thisCertSubjectId, thatCertSubjectId);
    }
    
    @Override
    public final int hashCode() {
        // 비즈니스 키 기반 hashCode로 해시 충돌 최소화
        Long certSubjectId = this.getCertSubject() != null 
            ? this.getCertSubject().getId() : null;
        Long examSessionId = this.getExamSession() != null 
            ? this.getExamSession().getId() : null;
            
        return Objects.hash(certSubjectId, examSessionId);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 개선 결과:&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 이제 각 객체가 다른 hashCode를 가짐
ExamSubject subject1 = new ExamSubject(session1, cert1); // hash: 1001
ExamSubject subject2 = new ExamSubject(session2, cert2); // hash: 1002  
ExamSubject subject3 = new ExamSubject(session3, cert3); // hash: 1003

// HashSet이 O(1) 성능으로 동작
Set&amp;lt;ExamSubject&amp;gt; subjects = new HashSet&amp;lt;&amp;gt;();
subjects.add(subject1); // 버킷[1001] = [subject1]
subjects.add(subject2); // 버킷[1002] = [subject2]
subjects.add(subject3); // 버킷[1003] = [subject3]

subjects.contains(subject2); // O(1) 시간 복잡도
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 방법 2: UUID 기반 @Id 활용&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로 설계하는 Entity라면 @Id 필드 자체를 UUID로 사용하는 것도 좋은 방법이다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
public class Product {
    @Id
    @GeneratedValue(generator = &quot;UUID&quot;)
    @GenericGenerator(name = &quot;UUID&quot;, strategy = &quot;org.hibernate.id.UUIDGenerator&quot;)
    @Column(columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;
    
    private String name;
    private BigDecimal price;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return Objects.equals(id, product.id);
    }
    
    @Override
    public int hashCode() {
        // UUID 기반 hashCode로 해시 충돌 최소화
        return Objects.hash(id);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;UUID @Id의 장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별도 컬럼 추가 없이 해시 충돌 해결&lt;/li&gt;
&lt;li&gt;분산 환경에서 ID 충돌 방지&lt;/li&gt;
&lt;li&gt;예측 불가능한 ID로 보안성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&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;성능상 Long 타입보다 다소 느림&lt;/li&gt;
&lt;li&gt;인덱스 크기가 더 큼&lt;/li&gt;
&lt;li&gt;기존 시스템에서는 마이그레이션 비용 고려 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 방법 3: List 사용 + 비즈니스 로직 검증&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Set 대신 List를 사용하고 중복 검증을 비즈니스 로직으로 처리하는 방법도 있다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Entity
public class ExamSession {
    // Set 대신 List 사용
    @OneToMany(mappedBy = &quot;examSession&quot;, cascade = ALL, orphanRemoval = true)
    private List&amp;lt;ExamSubject&amp;gt; examSubjects = new ArrayList&amp;lt;&amp;gt;();
    
    // 비즈니스 로직으로 중복 방지
    public void addCertSubject(CertSubject certSubject) {
        validateNotDuplicate(certSubject);
        
        ExamSubject examSubject = ExamSubject.of(this, certSubject);
        examSubjects.add(examSubject);
    }
    
    public void removeCertSubject(CertSubject certSubject) {
        examSubjects.removeIf(es -&amp;gt; es.getCertSubject().equals(certSubject));
    }
    
    private void validateNotDuplicate(CertSubject certSubject) {
        boolean exists = examSubjects.stream()
            .anyMatch(es -&amp;gt; es.getCertSubject().equals(certSubject));
            
        if (exists) {
            throw new BusinessException(&quot;이미 등록된 자격종목입니다: &quot; + 
                certSubject.getName());
        }
    }
    
    // Set 스타일 조회 (필요 시)
    public Set&amp;lt;CertSubject&amp;gt; getCertSubjectSet() {
        return examSubjects.stream()
            .map(ExamSubject::getCertSubject)
            .collect(Collectors.toSet());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Hibernate 성능 차이:&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;// Set은 add() 시 중복 체크를 위해 equals() 호출
examSession.getExamSubjects().add(newExamSubject);
// 메모리에서 기존 모든 객체와 equals() 비교
// 해시 충돌 시 O(n) 시간 복잡도

// List는 단순히 끝에 추가
examSession.getExamSubjects().add(newExamSubject);
// 중복 체크 없이 즉시 추가, O(1) 시간 복잡도
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Set의 성능 문제 핵심:&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Set.add()는 중복 방지를 위해 내부적으로 equals() 비교 수행&lt;/li&gt;
&lt;li&gt;해시 충돌이 발생하면 같은 버킷의 모든 객체와 equals() 비교 필요&lt;/li&gt;
&lt;li&gt;클래스 기반 hashCode로 인한 해시 충돌 시 O(n) 성능 저하&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DB 레벨 중복 방지&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 데이터베이스 레벨에서도 중복을 완전히 차단할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
@Table(
    name = &quot;exam_subject&quot;,
    uniqueConstraints = {
        @UniqueConstraint(
            name = &quot;uk_exam_session_cert_subject&quot;,
            columnNames = {&quot;exam_session_id&quot;, &quot;cert_subject_id&quot;}
        )
    }
)
public class ExamSubject {
    // 데이터베이스 레벨에서 중복 완전 차단
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;실제 테스트 코드로 검증하기&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void hashCodeCollisionTest() {
    // 클래스 기반 hashCode (문제 상황)
    ExamSubject subject1 = new ExamSubject(session1, cert1);
    ExamSubject subject2 = new ExamSubject(session2, cert2);
    
    // 모든 객체가 같은 hashCode (문제 상황)
    assertThat(subject1.hashCode()).isEqualTo(subject2.hashCode());
    
    // 비즈니스 키 기반 hashCode (해결책)
    subject1.optimizeHashCode(); // 비즈니스 키 기반으로 변경
    subject2.optimizeHashCode();
    
    // 이제 다른 hashCode를 가짐 (해결됨)
    assertThat(subject1.hashCode()).isNotEqualTo(subject2.hashCode());
}

@Test
void setPerformanceTest() {
    Set&amp;lt;ExamSubject&amp;gt; subjects = new HashSet&amp;lt;&amp;gt;();
    
    // 1000개 추가
    for (int i = 0; i &amp;lt; 1000; i++) {
        subjects.add(new ExamSubject(session, certSubjects.get(i)));
    }
    
    long startTime = System.nanoTime();
    
    // contains 연산 100번 수행
    for (int i = 0; i &amp;lt; 100; i++) {
        subjects.contains(subjects.iterator().next());
    }
    
    long endTime = System.nanoTime();
    long duration = endTime - startTime;
    
    // 클래스 기반: 수 밀리초, 비즈니스 키 기반: 마이크로초
    System.out.println(&quot;Contains operation time: &quot; + duration + &quot; ns&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;주의사항&lt;/b&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;불변 값 사용&lt;/b&gt;: hashCode에 사용되는 필드는 가능한 한 불변이어야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;null 체크&lt;/b&gt;: 비즈니스 키가 null일 수 있는 경우 적절한 처리가 필요하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Override
public int hashCode() {
    Long certSubjectId = this.getCertSubject() != null 
        ? this.getCertSubject().getId() : null;
    Long examSessionId = this.getExamSession() != null 
        ? this.getExamSession().getId() : null;
        
    // null 안전 처리
    return Objects.hash(certSubjectId, examSessionId);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;연관 관계 필드 주의&lt;/b&gt;: 연관 관계 필드를 직접 hashCode에 사용하면 지연 로딩 문제가 발생할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Entity에서 Set 컬렉션을 사용할 때 equals와 hashCode 구현은 단순히 정상 동작만 보장하면 되는 것이 아니다. 성능까지 고려한 적절한 구현이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스 기반 hashCode는 안전하지만 모든 같은 타입 객체가 동일한 해시값을 가져서 HashSet의 O(1) 성능을 O(n)으로 만드는 치명적인 문제가 있다. 반면 비즈니스 키 기반 hashCode는 안전성과 성능을 모두 확보할 수 있는 최적의 해결책이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Entity 설계 시점부터 Set 컬렉션 사용 시 적절한 equals, hashCode 구현을 고려하는 것이 중요하다. 특히 해시 충돌이 빈번하게 발생할 수 있는 환경에서는 성능에 미치는 영향이 더욱 클 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;참고자료&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.jboss.org/hibernate/orm/6.2/userguide/html_single/Hibernate_User_Guide.html&quot;&gt;Hibernate Documentation - Implementing equals() and hashCode()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/&quot;&gt;Vlad Mihalcea - The best way to implement equals, hashCode, and toString with JPA and Hibernate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.baeldung.com/jpa-entity-equality&quot;&gt;Baeldung - JPA Entity Equality&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.korecmblog.com/blog/jpa-equals-and-history&quot;&gt;JPA Buddy가 생성해주는 메소드 살펴보기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>JPA</category>
      <category>hash</category>
      <category>JPA</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/98</guid>
      <comments>https://dev-gallery.tistory.com/98#entry98comment</comments>
      <pubDate>Mon, 18 Aug 2025 01:35:50 +0900</pubDate>
    </item>
    <item>
      <title>[Mybatis] 로컬 캐시 문제: 프로시저 호출 시 발생하는 캐시 이슈</title>
      <link>https://dev-gallery.tistory.com/97</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;서론&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진행 중인 프로젝트에서 `sequence` 테이블을 별도로 두고 프로시저를 통해 PK를 채번하는 시스템에서 예상치 못한 캐시 문제를 마주했던 내용을 정리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mybatis 로컬 캐시는 기본적으로 활성화되어 있는데, 이게 별도 트랜잭션에서 실행되는 프로시저와 만나면서 예상치 못한 동작을 하는 바람에 원인을 찾기까지 꽤 많은 삽질을 했다. 특히 이 프로젝트는 Mybatis 캐시 정책에 대한 제대로 된 이해 없이 &quot;JPA와 비슷하겠지&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;하지만 막상 파보니 JPA의 영속성 컨텍스트와는 완전히 다른 캐시 정책을 가지고 있어서 더욱 혼란스러웠고, 결국 근본적인 차이점부터 다시 공부해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&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;프로시저를 통해 채번한 `sequence` 값이 캐시되어 다음 호출에서 같은 값을 반환&lt;/li&gt;
&lt;li&gt;update 쿼리 실행 후에도 select 캐시가 유지되어 이전 데이터를 조회&lt;/li&gt;
&lt;li&gt;한 세션 내에서 여러 번 같은 쿼리 실행 시 첫 번째 결과만 계속 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제들을 해결하면서 Mybatis 로컬 캐시의 동작 원리를 깊이 파보게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Mybatis 로컬 캐시 vs JPA 영속성 컨텍스트&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Mybatis 로컬 캐시의 특징&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753025485837&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Mybatis 기본 설정
&amp;lt;configuration&amp;gt;
    &amp;lt;settings&amp;gt;
        &amp;lt;setting name=&quot;localCacheScope&quot; value=&quot;SESSION&quot;/&amp;gt; &amp;lt;!-- 기본값 --&amp;gt;
    &amp;lt;/settings&amp;gt;
&amp;lt;/configuration&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mybatis 로컬 캐시는 `&lt;b&gt;SESSION`&lt;/b&gt; 레벨에서 동작한다. 이는 `SqlSession` 하나당 하나의 캐시를 의미한다.&lt;/p&gt;
&lt;pre id=&quot;code_1753025516924&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Mapper
public interface UserMapper {
    User selectUserById(@Param(&quot;id&quot;) Long id);
    void updateUser(User user);
}

// 문제 상황 재현
public class UserService {
    @Autowired
    private UserMapper userMapper;
    
    @Transactional
    public void problematicMethod() {
        User user1 = userMapper.selectUserById(1L); // DB 조회
        System.out.println(&quot;첫번째: &quot; + user1.getName());
        
        // 다른 세션에서 해당 user의 name을 변경했다고 가정
        
        User user2 = userMapper.selectUserById(1L); // 캐시에서 조회
        System.out.println(&quot;두번째: &quot; + user2.getName()); // 여전히 이전 값
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;JPA 영속성 컨텍스트와의 차이점&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753025585671&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// JPA의 경우
@Entity
public class User {
    @Id
    private Long id;
    private String name;
    // ...
}

@Service
public class UserService {
    @PersistenceContext
    private EntityManager em;
    
    @Transactional
    public void jpaMethod() {
        User user1 = em.find(User.class, 1L); // DB 조회
        
        // JPA는 더티체킹으로 변경사항을 추적
        user1.setName(&quot;새로운 이름&quot;);
        
        User user2 = em.find(User.class, 1L); // 영속성 컨텍스트에서 조회
        // user2는 user1과 같은 인스턴스이며, 변경된 값을 가짐
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&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;Mybatis&lt;/b&gt;: &lt;u&gt;쿼리 기반 캐시&lt;/u&gt;, 같은 SQL과 파라미터면 캐시된 결과 반환&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JPA&lt;/b&gt;: &lt;u&gt;엔티티 기반 캐시&lt;/u&gt;, 더티체킹과 변경사항 추적 지원&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Mybatis&lt;/b&gt;: 캐시 무효화가 제한적 (INSERT, UPDATE, DELETE 시에만 flush)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JPA&lt;/b&gt;: 엔티티 상태 변화를 실시간으로 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Mybatis Select와 Update 캐시 정책 차이&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Select 쿼리의 캐시 정책&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753025675611&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Mapper 인터페이스
@Select(&quot;SELECT * FROM users WHERE id = #{id}&quot;)
User selectUserById(@Param(&quot;id&quot;) Long id);

@Select(&quot;SELECT next_seq FROM sequence_table WHERE table_name = #{tableName}&quot;)
Long getNextSequence(@Param(&quot;tableName&quot;) String tableName);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1753025690073&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 서비스에서의 문제 상황
@Service
public class SequenceService {
    
    @Transactional
    public void demonstrateCacheProblem() {
        Long seq1 = sequenceMapper.getNextSequence(&quot;user_seq&quot;); // 예: 100
        Long seq2 = sequenceMapper.getNextSequence(&quot;user_seq&quot;); // 캐시에서 100 반환함
        
        // 실제로는 sequence가 증가해야 하는데 캐시 때문에 같은 값이 됨
        assert seq1.equals(seq2); // true (❌ 예외 발생)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Update 쿼리의 캐시 무효화&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753025772501&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Update(&quot;UPDATE users SET name = #{name} WHERE id = #{id}&quot;)
void updateUserName(@Param(&quot;id&quot;) Long id, @Param(&quot;name&quot;) String name);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753025779240&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Update 후 캐시 무효화 확인
@Transactional
public void updateAndSelect() {
    User before = userMapper.selectUserById(1L); // DB 조회, 캐시에 저장
    
    userMapper.updateUserName(1L, &quot;김이박&quot;); // UPDATE 실행 시 캐시 무효화
    
    User after = userMapper.selectUserById(1L); // 다시 DB 조회 (캐시 무효화됨)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&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;: Mybatis는 `&lt;b&gt;INSERT`, `UPDATE`, `DELETE`&lt;/b&gt; 쿼리가 실행될 때 &lt;u&gt;&lt;b&gt;해당 Mapper의 모든 캐시를 무효화&lt;/b&gt;&lt;/u&gt;한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2차 캐시(Second Level Cache) 설정과 주의사항&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mybatis에서는 SqlSession 레벨의 1차 캐시 외에도 2차 캐시를 지원한다. 하지만 이 2차 캐시는 꽤 까다로운 특성을 가지고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예시는 &lt;a href=&quot;https://mybatis.org/mybatis-3/sqlmap-xml.html#cache&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;u&gt;&lt;b&gt;공식 문서 (link)&lt;/b&gt;&lt;/u&gt;&lt;/a&gt;에 있는 예시를 활용했다.&lt;/p&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1753025888157&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- MyBatis 2차 캐시 설정 --&amp;gt;
&amp;lt;mapper namespace=&quot;com.example.UserMapper&quot;&amp;gt;
    &amp;lt;cache 
        eviction=&quot;LRU&quot;
        flushInterval=&quot;60000&quot; 
        size=&quot;512&quot; 
        readOnly=&quot;false&quot;/&amp;gt;
    
    &amp;lt;!-- flushCache 속성으로 개별 제어 --&amp;gt;
    &amp;lt;select id=&quot;selectUser&quot; flushCache=&quot;false&quot; useCache=&quot;true&quot;&amp;gt;
        SELECT * FROM users WHERE id = #{id}
    &amp;lt;/select&amp;gt;
    
    &amp;lt;update id=&quot;updateUser&quot; flushCache=&quot;true&quot;&amp;gt;
        UPDATE users SET name = #{name} WHERE id = #{id}
    &amp;lt;/update&amp;gt;
&amp;lt;/mapper&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2차 캐시 옵션들:&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;LRU (기본값): 가장 오래 사용되지 않은 객체부터 제거&lt;/li&gt;
&lt;li&gt;FIFO: 먼저 들어온 객체부터 제거&lt;/li&gt;
&lt;li&gt;SOFT: GC의 Soft Reference 정책에 따라 제거&lt;/li&gt;
&lt;li&gt;WEAK: GC의 Weak Reference 정책에 따라 더 적극적으로 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;(Soft Reference, Weak Reference 내용은 Naver D2를 참고한 &lt;a href=&quot;https://dev-gallery.tistory.com/84#GC(Garbage%20collector)-1-15&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;u&gt;&lt;b&gt;예전 글&lt;/b&gt;&lt;/u&gt;&lt;/a&gt;에 정리했다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;XML과 Annotation 혼용 시 주의사항:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에 따르면, XML 매핑 파일에 &amp;lt;cache&amp;gt; 태그가 있어도 Java 인터페이스의 애너테이션 기반 쿼리들은 기본적으로 캐시되지 않는다고 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1753028255835&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이런 상황에서 문제 발생 가능
@Mapper
public interface UserMapper {
    
    // XML에 cache 설정이 있어도 이 메서드는 캐시 안됨
    @Select(&quot;SELECT * FROM users WHERE id = #{id}&quot;)
    User selectUserById(@Param(&quot;id&quot;) Long id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우엔 `@CacheNamespaceRef` 애너테이션을 사용해야 한다:&lt;/p&gt;
&lt;pre id=&quot;code_1753028266645&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Mapper
@CacheNamespaceRef(UserMapper.class) // 명시적으로 캐시 참조
public interface UserMapper {
    
    @Select(&quot;SELECT * FROM users WHERE id = #{id}&quot;)
    User selectUserById(@Param(&quot;id&quot;) Long id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;readOnly 설정의 성능 임팩트:&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1753028294316&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- readOnly=true: 성능 우선, 같은 인스턴스 반환 --&amp;gt;
&amp;lt;cache readOnly=&quot;true&quot;/&amp;gt;

&amp;lt;!-- readOnly=false: (기본값) 안전성 우선, 직렬화를 통한 복사본 반환 --&amp;gt;  
&amp;lt;cache readOnly=&quot;false&quot;/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1753028315106&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// readOnly=true일 때의 위험성
@Transactional
public void dangerousMethod() {
    User user1 = userMapper.selectUserById(1L); // 캐시에서 가져옴
    User user2 = userMapper.selectUserById(1L); // 같은 인스턴스!
    
    user1.setName(&quot;변경된 이름&quot;); 
    // user2.getName()도 &quot;변경된 이름&quot;이 됨 (같은 객체이므로)
    // ⚡️ 다른 스레드에서도 영향 받을 수 있음
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;트랜잭션과 2차 캐시:&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2차 캐시는 트랜잭션과 연동된다. SqlSession이 커밋되거나 롤백될 때만 캐시가 업데이트되고, `flushCache=true`인 &lt;b&gt;INSERT/UPDATE/DELETE&lt;/b&gt;가 실행되면 즉시 무효화된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;별도 채번 테이블과 프로시저 호출 시 캐시 문제&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;실제 운영 환경에서 만난 문제&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에서는 PK 채번을 위해 별도의 `sequence` 테이블을 운영하고 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1753026003029&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- sequence 테이블 구조
CREATE TABLE sequence_table (
    table_name VARCHAR(50) PRIMARY KEY,
    current_value BIGINT NOT NULL,
    increment_by INT DEFAULT 1
);

-- 채번용 프로시저
DELIMITER $$
CREATE PROCEDURE get_next_sequence(
    IN p_table_name VARCHAR(50),
    OUT p_next_value BIGINT
)
BEGIN
    -- 별도 트랜잭션으로 실행 (AUTONOMOUS TRANSACTION과 유사)
    START TRANSACTION;
    
    UPDATE sequence_table 
    SET current_value = current_value + increment_by 
    WHERE table_name = p_table_name;
    
    SELECT current_value INTO p_next_value 
    FROM sequence_table 
    WHERE table_name = p_table_name;
    
    COMMIT;
END$$
DELIMITER ;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Mapper에서 프로시저 호출&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753026020374&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Mapper
public interface SequenceMapper {
    
    @Select(&quot;CALL get_next_sequence(#{tableName}, #{nextValue, mode=OUT, jdbcType=BIGINT})&quot;)
    @Options(statementType = StatementType.CALLABLE)
    void getNextSequence(@Param(&quot;tableName&quot;) String tableName, 
                         @Param(&quot;nextValue&quot;) ParameterHolder&amp;lt;Long&amp;gt; nextValue);
    
    // 또는 직접 조회 방식
    @Select(&quot;SELECT current_value FROM sequence_table WHERE table_name = #{tableName}&quot;)
    Long getCurrentSequence(@Param(&quot;tableName&quot;) String tableName);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 발생 코드 (예시)&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753026077284&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class UserService {
    
    @Transactional
    public List&amp;lt;User&amp;gt; createMultipleUsers(List&amp;lt;String&amp;gt; names) {
        List&amp;lt;User&amp;gt; users = new ArrayList&amp;lt;&amp;gt;();
        
        for (String name : names) {
            // ❌ 매번 같은 sequence 값을 받아옴 (캐시 문제)
            ParameterHolder&amp;lt;Long&amp;gt; holder = new ParameterHolder&amp;lt;&amp;gt;();
            sequenceMapper.getNextSequence(&quot;user_seq&quot;, holder);
            Long userId = holder.getValue(); // ❌ 계속 같은 값
            
            User user = new User(userId, name);
            userMapper.insertUser(user);
            users.add(user);
        }
        
        return users; // ❌ 여기서 PK 중복으로 인한 에러 발생
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;왜 이런 문제가 발생했을까?&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;프로시저 내부의 별도 트랜잭션&lt;/b&gt;: 프로시저 안에서 `START TRANSACTION; ... COMMIT;`을 실행하지만, Mybatis는 이를 단순한 SELECT 쿼리로 인식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 키 생성 방식&lt;/b&gt;: Mybatis는 SQL문과 파라미터를 조합해서 캐시 키를 생성하는데, 같은 프로시저 호출은 같은 캐시 키를 가짐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프로시저 결과의 캐시&lt;/b&gt;: 프로시저 호출 결과도 SELECT와 동일하게 캐시됨&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1753026126887&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 내부적으로 Mybatis가 생성하는 캐시 키 (pseudo 코드)
String cacheKey = &quot;CALL get_next_sequence&quot; + &quot;user_seq&quot;; 
// 매번 같은 키가 생성되어 같은 결과를 반환&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 방법들&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 캐시 완전 비활성화 (✅ 채택한 방법)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1753026177241&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Mapper별 캐시 비활성화
@CacheNamespace(flushInterval = 0)
public interface SequenceMapper {
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는&lt;/p&gt;
&lt;pre id=&quot;code_1753026187026&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- XML 설정 --&amp;gt;
&amp;lt;select id=&quot;getNextSequence&quot; useCache=&quot;false&quot; flushCache=&quot;true&quot;&amp;gt;
    CALL get_next_sequence(#{tableName}, #{nextValue, mode=OUT, jdbcType=BIGINT})
&amp;lt;/select&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. SqlSession 직접 제어&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1753026217929&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class SequenceService {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    public Long getNextSequence(String tableName) {
        // 매번 새로운 SqlSession 사용
        try (SqlSession session = sqlSessionFactory.openSession()) {
            SequenceMapper mapper = session.getMapper(SequenceMapper.class);
            ParameterHolder&amp;lt;Long&amp;gt; holder = new ParameterHolder&amp;lt;&amp;gt;();
            mapper.getNextSequence(tableName, holder);
            return holder.getValue();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Redis를 활용한 분산 채번&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1753026372705&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class RedisSequenceService {
    
    @Autowired
    private RedisTemplate&amp;lt;String, String&amp;gt; redisTemplate;
    
    public Long getNextSequence(String tableName) {
        String key = &quot;sequence:&quot; + tableName;
        return redisTemplate.opsForValue().increment(key);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;✅ 현재 채택한 해결책&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;프로젝트는 DB가 이중화되어 있긴 하지만 failover만 설정된 상태라 실질적으로는 read-write 모두 하나의 DB에서 처리하고 있다. 게다가 분산 환경을 고려할 필요가 없는 단일 인스턴스 아키텍처라서, 복잡한 분산 캐시를 사용할 이유가 없었다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;color: #333333; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 가장 간단하면서도 확실한 첫 번째 방법인 &lt;u&gt;&lt;b&gt;캐시 무효화 방식&lt;/b&gt;&lt;/u&gt;이 실무에 가장 적합하다고 판단했다. 개별 쿼리마다 `flushCache=true`를 설정해서 매번 캐시를 무효화하는 방식으로 해결했다.&lt;/p&gt;
&lt;pre id=&quot;code_1753027323709&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Mapper
public interface SequenceMapper {
    
    // flushCache=true로 매번 캐시 무효화
    @Select(&quot;SELECT get_next_sequence_func(#{tableName})&quot;)
    @Options(flushCache = true, useCache = false)
    Long getNextSequenceValue(@Param(&quot;tableName&quot;) String tableName);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mybatis 로컬 캐시는 성능 향상을 위한 좋은 기능이지만, 다음과 같은 상황에서는 예상치 못한 문제를 일으킬 수 있다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;별도 트랜잭션을 가진 프로시저/함수 호출&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;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 JPA에만 익숙한 개발자라면 Mybatis의 캐시 정책이 생소할 수 있다. JPA는 엔티티 단위의 더티체킹을 지원하지만, Mybatis는 순수하게 쿼리 기반으로만 캐시를 관리한다는 점을 배웠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mybatis.org/mybatis-3/sqlmap-xml.html#cache&quot;&gt;MyBatis Official Documentation - Local Cache&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mybatis.org/mybatis-3/configuration.html#settings&quot;&gt;MyBatis Configuration - localCacheScope&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <category>mybatis</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/97</guid>
      <comments>https://dev-gallery.tistory.com/97#entry97comment</comments>
      <pubDate>Mon, 21 Jul 2025 00:51:53 +0900</pubDate>
    </item>
    <item>
      <title>[Mybatis] Generic 기반 TypeHandler를 자동 등록하기</title>
      <link>https://dev-gallery.tistory.com/96</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Java Enum 타입과 RDB의 enum 데이터 타입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 프로젝트에서 `Y`, `N` 같이 특정 값들을 가지면서 해당 그룹이 잘 변하지 않으면 Enum 타입으로 설계하곤 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDB에 이런 Enum 성격을 갖는 값들을 저장할 땐 데이터 타입을 `char`, `varchar`, `enum`을 사용한다. (MySQL, MariaDB, PostgreSQL 등)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Mybatis EnumTypeHandler&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;MyBatis에선 Java의 Enum 타입을 멤버 변수 이름 그대로 매핑해 주는 `EnumTypeHandler`를 기본 TypeHandler로 사용하는데, 모종의 이유(레거시 프로젝트 등)로 DB에는 소문자나, 다른 값으로 저장해야 할 때가 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이때 Java Enum 타입의 멤버 변수는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;u&gt;&lt;b&gt;상수&lt;/b&gt;&lt;/u&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이기 때문에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html&quot;&gt;&lt;b&gt;공식 문서 (link)&lt;/b&gt;&lt;/a&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;에 따라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;대문자&lt;/b&gt;로 작성하는데, RDB에 저장할 땐 &lt;b&gt;소문자&lt;/b&gt;로 저장하는 컨벤션이 있다면 RDB로부터 읽거나 쓸 때 멤버 변수와 매핑해 줄 커스텀 TypeHandler를 작성하고, `mybatis-config.xml`에 등록해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, Enum 타입의 멤버 변수 이름과 RDB 레코드 값이 불일치하는 경우가 빈번하다면, 일일이 커스텀 TypeHandler를 작성해야 하는 수고로움은 물론 보일러 플레이트 코드를 담는 파일이 계속해서 추가되고, 관리 포인트가 증가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1753019920912&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// UserStatus Enum - DB에는 소문자로 저장되야 함
public enum UserStatus {
    ACTIVE,
    INACTIVE,
    SUSPENDED
}

// UserStatus용 TypeHandler
public class UserStatusTypeHandler extends BaseTypeHandler&amp;lt;UserStatus&amp;gt; {
    
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, UserStatus parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, parameter.name().toLowerCase());
    }
    
    @Override
    public UserStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String value = rs.getString(columnName);
        return rs.wasNull() ? null : getUserStatusByValue(value);
    }
    
    // ... 나머지 메서드들도 비슷한 패턴
    
    private UserStatus getUserStatusByValue(String value) {
        if (value == null) return null;
        
        return Arrays.stream(UserStatus.values())
                .filter(status -&amp;gt; status.name().toLowerCase().equals(value))
                .findFirst()
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;Unknown value: &quot; + value));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;매번 동일한 패턴의 TypeHandler 작성 (Enum.name().toLowerCase() 변환)&lt;/li&gt;
&lt;li&gt;mybatis-config.xml에 수동으로 등록 필요&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;이렇게 생성된 TypeHandler들은 본인 뿐만아니라 동료 개발자들도 파악해야 할 맥락이 커져 피로해질 것 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제점들을 개선해보자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;해결 방법: Generic TypeHandler와 자동 등록&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이 문제를 해결하기 위해 두 가지 접근 방식을 사용했다. (이펙티브 자바 아이템 29, 아이템 41의 적절한 예시인 것 같다.)&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;b&gt;Generic TypeHandler 작성&lt;/b&gt;: 공통된 패턴을 추상화한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TypeHandler 자동 등록&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 애너테이션 기반으로 클래스를 찾을 수 있는 Spring의 클래스 패스 스캐닝을 활용한다. (`ClassPathScanningCandidateComponentProvider`)&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1단계: 마커 애너테이션 생성&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 등록 대상을 식별할 &lt;b&gt;마커 애너테이션&lt;/b&gt;을 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1753020753955&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LowerCaseEnum {
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2단계: Generic TypeHandler 구현&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제네릭을 활용해 재사용 가능한 TypeHandler를 구현한다.&lt;/p&gt;
&lt;pre id=&quot;code_1753020786026&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class LowerCaseEnumTypeHandler&amp;lt;E extends Enum&amp;lt;E&amp;gt;&amp;gt; extends BaseTypeHandler&amp;lt;E&amp;gt; {

    private final Class&amp;lt;E&amp;gt; type;

    public LowerCaseEnumTypeHandler(Class&amp;lt;E&amp;gt; type) {
        if (type == null) {
            throw new IllegalArgumentException(&quot;Type argument cannot be null&quot;);
        }
        this.type = type;
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {
        // Enum 값을 소문자로 변환하여 PreparedStatement에 설정
        ps.setString(i, parameter.name().toLowerCase());
    }

    @Override
    public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String value = rs.getString(columnName);
        return rs.wasNull() ? null : getEnumByValue(value);
    }

    @Override
    public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String value = rs.getString(columnIndex);
        return rs.wasNull() ? null : getEnumByValue(value);
    }

    @Override
    public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String value = cs.getString(columnIndex);
        return cs.wasNull() ? null : getEnumByValue(value);
    }

    private E getEnumByValue(String value) {
        if (value == null) {
            return null;
        }

        return Arrays.stream(type.getEnumConstants())
                .filter(e -&amp;gt; e.name().toLowerCase().equals(value))
                .findFirst()
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(
                        &quot;No enum constant &quot; + type.getCanonicalName() + &quot; with value &quot; + value));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3단계: 자동 등록 Configuration 구현&lt;/b&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;Spring의 `ClassPathScanningCandidateComponentProvider`를 활용해 자동 등록 로직을 구현했다.&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;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;자동 등록은 `@PostConstruct` 애너테이션을 활용해 `SqlSessionFactory` Bean이 완전히 초기화된 이후로 설정해야 한다.&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;에 대해 정리하면,&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;`ClassPathScanningCandidateComponentProvider`의 `addIncludeFilter()`로 클래스 필터를 추가한다.&lt;/li&gt;
&lt;li&gt;`AnnotationTypeFilter`는 특정 애너테이션이 붙은 클래스만 필터링하도록 한다.&lt;/li&gt;
&lt;li&gt;`ClassPathScanningCandidateComponentProvider`의 `findCandidateComponents({스캐닝 위치})`를 통해 필터링한 결과를 `Set&amp;lt;BeanDefinition&amp;gt;`으로 반환한다. (`BeanDefinition`은 Spring IoC 컨테이너가 Bean을 생성하기 위해 필요한 메타데이터를 담고 있는 인터페이스다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1753020863346&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Configuration
@RequiredArgsConstructor
public class EnumTypeHandlerAutoRegisterConfig {

    private static final String BASE_PACKAGE = &quot;com.example&quot;;
    private final SqlSessionFactory sqlSessionFactory;

    @PostConstruct
    public void registerEnumTypeHandlers() {
        TypeHandlerRegistry typeHandlerRegistry = sqlSessionFactory.getConfiguration().getTypeHandlerRegistry();
        
        // LowerCaseEnum 애너테이션이 적용된 enum 등록
        registerLowerCaseEnums(typeHandlerRegistry);
    }

    /**
     * LowerCaseEnum 애너테이션이 적용된 enum을 등록
     */
    private void registerLowerCaseEnums(TypeHandlerRegistry typeHandlerRegistry) {
        ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
        provider.addIncludeFilter(new AnnotationTypeFilter(LowerCaseEnum.class));

        Set&amp;lt;BeanDefinition&amp;gt; beanDefinitions = provider.findCandidateComponents(BASE_PACKAGE);

        for (BeanDefinition beanDefinition : beanDefinitions) {
            try {
                Class&amp;lt;?&amp;gt; clazz = Class.forName(beanDefinition.getBeanClassName());

                if (clazz.isEnum()) {
                    registerLowerCaseEnumTypeHandler(clazz, typeHandlerRegistry);
                }
            } catch (ClassNotFoundException e) {
                log.error(&quot;ClassNotFoundException : {}&quot;, e.getMessage());
            }
        }
    }

    /**
     * Enum 클래스에 대한 LowerCaseEnumTypeHandler를 타입 안전하게 등록
     *
     * @param enumClass 등록할 Enum 클래스
     * @param registry  MyBatis TypeHandlerRegistry
     * @param &amp;lt;E&amp;gt;       Enum 타입
     */
    @SuppressWarnings(&quot;unchecked&quot;)
    private &amp;lt;E extends Enum&amp;lt;E&amp;gt;&amp;gt; void registerLowerCaseEnumTypeHandler(Class&amp;lt;?&amp;gt; enumClass, TypeHandlerRegistry registry) {
        assert enumClass.isEnum() : &quot;enum class가 아닙니다.&quot;;

        // 여기서 타입 안전을 보장하면서 캐스팅
        Class&amp;lt;E&amp;gt; typedEnumClass = (Class&amp;lt;E&amp;gt;) enumClass;

        // LowerCaseEnumTypeHandler에 등록
        registry.register(typedEnumClass, new LowerCaseEnumTypeHandler&amp;lt;&amp;gt;(typedEnumClass));
        log.info(&quot;LowerCaseEnumTypeHandler registered for: {}&quot;, typedEnumClass.getName());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4단계: 실제 사용&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Enum을 간단하게 정의하고 애너테이션만 붙이면 된다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1753021361365&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@LowerCaseEnum
public enum UserStatus {
    ACTIVE,      // DB에는 &quot;active&quot;로 저장
    INACTIVE,    // DB에는 &quot;inactive&quot;로 저장
    SUSPENDED    // DB에는 &quot;suspended&quot;로 저장
}

@LowerCaseEnum
public enum OrderStatus {
    PENDING,
    CONFIRMED,
    SHIPPED,
    DELIVERED,
    CANCELLED
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;마무리&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;개선 전엔&lt;/span&gt;&lt;span&gt; Enum 멤버 변수를 DB에 소문자로 저장해야 할 때 매번 새로운 TypeHandler 클래스 작성해야 했다. 그리고 이 클래스는 약 50줄의 보일러플레이트 코드를 유발한다. &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;이 핸들러를 작성하면&lt;/span&gt;&lt;span&gt; `mybatis-config.xml`에 수동 등록까지 해줘야 하는데, 여기서 휴먼 에러가 발생할 수도 있다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;개선 후엔 마커용&lt;/span&gt;&lt;span&gt; 애너테이션 하나만 추가하는 것으로 Java 코드와 DB 값을 자동으로 매핑할 수 있게 되었다.&lt;/span&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; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;자동 등록 설정으로 `mybatis-config.xml` 설정 파일을 관리하지 않아도 되었고, 매번 50 줄의&lt;/span&gt;&lt;span&gt; 중복 코드들을 제거할 수 있게 되었다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;참고&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mybatis.org/mybatis-3/configuration.html#typeHandlers&quot;&gt;https://mybatis.org/mybatis-3/configuration.html#typeHandlers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.html&quot;&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev-seonghun.medium.com/java-spring-특정-인터페이스를-구현한-클래스-찾기-cb8c38a586eb&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev-seonghun.medium.com/java-spring-특정-인터페이스를-구현한-클래스-찾기-cb8c38a586eb&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.baeldung.com/java-scan-annotations-runtime&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/java-scan-annotations-runtime&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <category>mybatis</category>
      <category>Spring</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/96</guid>
      <comments>https://dev-gallery.tistory.com/96#entry96comment</comments>
      <pubDate>Sun, 20 Jul 2025 23:41:21 +0900</pubDate>
    </item>
    <item>
      <title>MySQL NULL 정렬 (ORDER BY)</title>
      <link>https://dev-gallery.tistory.com/92</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;pngimg.com - mysql_PNG23.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;1280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wto4Y/btsNcTleXQk/cQtqNJwJn0UR8fWX3IpJ70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wto4Y/btsNcTleXQk/cQtqNJwJn0UR8fWX3IpJ70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wto4Y/btsNcTleXQk/cQtqNJwJn0UR8fWX3IpJ70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwto4Y%2FbtsNcTleXQk%2FcQtqNJwJn0UR8fWX3IpJ70%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;300&quot; height=&quot;300&quot; data-filename=&quot;pngimg.com - mysql_PNG23.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;SQL에서 `NULL`은 '값이 없음'을 의미하지만, 이는 단순히 비어 있다는 의미가 아니라 '값이 아직 정해지지 않았거나 알 수 없는 상태'를 뜻합니다. 이처럼 `NULL` 값은 비교나 정렬에서 특별한 처리를 필요로 하며, DBMS마다 `NULL` 정렬 처리 방식이 다릅니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;DBMS마다 다른 NULL 정렬 처리 방식&lt;/span&gt;&lt;/b&gt;&lt;span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 94px;&quot; border=&quot;1&quot; data-pm-slice=&quot;3 3 []&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;DBMS&lt;/b&gt; &lt;span&gt;&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;오름차순 (ASC) 정렬 시 NULL 위치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;&lt;span&gt; &lt;b&gt;내림차순 (DESC) 정렬 시 NULL 위치&lt;/b&gt; &lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;&lt;b&gt;MySQL&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;&lt;span&gt;맨 위 (가장 작은 값 취급)&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;&lt;span&gt;맨 아래 (가장 큰 값 취급)&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;&lt;b&gt;PostgreSQL&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;맨 아래&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;맨 위&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;&lt;b&gt;Oracle&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;맨 아래&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;맨 위&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;&lt;b&gt;SQL Server&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;맨 위 (기본 설정 시)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;span&gt;맨 아래 (기본 설정 시)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;표준 ANSI 에 따르면 `NULL` 값을 어떻게 취급할지에 대해 정의하지 않아 데이터베이스마다 다르게 취급할 수 있습니다.&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;MySQL은 `NULL` 값을 가장 작은 값으로 취급하는데, `NULL`을 허용하는 컬럼에 ORDER BY절을 통해 오름차순(ASC) 정렬을 수행하면 최상단에 `NULL` 값을 위치하게 할 수 있습니다.&lt;/p&gt;
&lt;h3 data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;NULL 값을 허용하는 컬럼에서 NULL을 가장 큰 값으로 처리하는 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL에서 NULL은 가장 작은 값으로 취급되기 때문에, `ORDER BY [컬럼명] ASC`을 사용하면 `NULL` 값이 먼저 출력됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 NULL을 가장 큰 값으로 취급하고 싶을 때는 `ORDER BY [대상 컬럴명] IS NULL ASC, [대상 컬럼명] ASC`를 사용하면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1744034235841&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *
FROM TBL_MY_TABLE
ORDER BY NAME IS NULL ASC, NAME ASC;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;동작 흐름&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;`[대상 컬럼명] IS NULL`&lt;/span&gt;&lt;span&gt;은 해당 컬럼이 &lt;/span&gt;&lt;span&gt;NULL&lt;/span&gt;&lt;span&gt;이면 &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, 아니면 &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;을 반환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;따라서 먼저 `[대상 컬럼명] &lt;/span&gt;&lt;span&gt;IS NULL ASC`&lt;/span&gt;&lt;span&gt;로 정렬하면 &lt;/span&gt;&lt;span&gt;NULL&lt;/span&gt;&lt;span&gt;이 아닌 값이 먼저 나옴&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이후 `&lt;/span&gt;&lt;span&gt;[대상 컬럼명] ASC`&lt;/span&gt;&lt;span&gt; 정렬로 실제 값 기준 정렬을 수행&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, 첫 번째 정렬 기준으로 `NULL` 여부를 확인해 `NULL`을 뒤로 밀고, 두 번째 기준으로는 실제 값을 정렬하는 방식입니다. 이 방식은 MySQL에서 `NULLS LAST` 효과를 흉내 내는 패턴으로 자주 사용된다고 합니다.&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정렬 시 주의사항&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ORDER BY로 정렬을 수행할 때 주의할 점은, ORDER BY로 정렬을 효과적으로 사용하려면 정렬하려는 컬럼들에 인덱스가 어떻게 구성되어 있는지도 확인해야 합니다.&lt;/p&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 인덱스는 B-Tree 구조로 항상 오름차순으로 정렬되어 있기 때문에 (별도로 정렬 순서를 바꾸지 않는 이상) ORDER BY 절에 사용되는 &lt;i&gt;&lt;b&gt;모든 컬럼이 오름차순이거나 내림차순이어야&lt;/b&gt;&lt;/i&gt; 인덱스를 사용할 수 있습니다. (아직 부족한 경험으로 모든 컬럼이 오름차순이거나 내림차순으로 되는 쿼리가 흔치 않은 것 같습니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 인덱스가 여러 컬럼(`COL_1`, `COL_2`, `COL_3` 순서)으로 구성되어 있다면, 모든 컬럼이 정렬 조건에 사용되지 않더라도 &lt;i&gt;&lt;b&gt;반드시 앞에 있는 `COL_1` 컬럼의 왼쪽부터(left most) 일치해야 합니다.&lt;/b&gt;&lt;/i&gt; (순서도 일치해야 합니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 외에도, GROUP BY 절과 함께 ORDER BY절이 사용될 때는 &lt;b&gt;&lt;i&gt;1)&lt;/i&gt; 둘 다 인덱스를 타거나, 둘 다 인덱스를 못타고&lt;/b&gt;, 인덱스를 타기 위해서는 &lt;b&gt;&lt;i&gt;2) GROUP BY 절과 ORDER BY 절에 있는 모든 컬럼의 순서가 동일해야 한다&lt;/i&gt;&lt;/b&gt;는 제약도 있지만 NULL 처리 관련 글의 범위를 벗어나므로 RealMySQL 8.0의 64~65p를 참고하면 좋을 것 같습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL로 되어있는 데이터베이스에서 작업을 하다, NULL 값에 대한 정렬 순서를 바꿔야 했는데, 이번 글로 정리를 해봤습니다. 정렬 결과에 영향을 줄 수 있는 `NULL` 처리 방식은 데이터베이스마다 다르기 때문에 간단한 쿼리로 한 번은 꼭 확인하고 쓰는 것이 예상치 못한 정렬 오류를 방지하기 위해서라도 필요하다고 생각합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://ismydream.tistory.com/158&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ismydream.tistory.com/158&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;RealMySQL 8.0&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>MySQL</category>
      <category>MySQL</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/92</guid>
      <comments>https://dev-gallery.tistory.com/92#entry92comment</comments>
      <pubDate>Fri, 14 Mar 2025 08:13:59 +0900</pubDate>
    </item>
    <item>
      <title>쿠버네티스에서 MySQL dump 하기 (uuid 필드를 사용할 때)</title>
      <link>https://dev-gallery.tistory.com/91</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅은 네이버 클라우드(NKS)에서 구글 클라우드(GCP)로 마이그레이션을 하며 MySQL dump를 수행했을 때 일어났던 문제를 다룹니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL 덤프한 파일로 import 수행 시 아래와 같이 `ERROR 1062 (23000)` 에러가 발생했는데요,&lt;/p&gt;
&lt;pre id=&quot;code_1740733240938&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ERROR 1062 (23000) at line 347: Duplicate entry '\xEF\xBF\xBB\xEF\xBF\xBD\xEF\xBF\xBD\xEF\xBF\xBD\xEF\xBF\xBD\xEF' for key 'letter.PRIMARY'
command terminated with exit code 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PK에 중복된 값이 있어서 실패했음을 알 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PK 필드를 UUID로 사용했는데, 타입을 BINARY(16)로 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 덤프 수행 시 `\xEF\xBF\xBB`같은 이진 데이터(유니코드)가 `replacement character (�)`로 대체된 문제입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;24&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kUtCG/btsMy0FVFR1/R0dkqaxi06yiKOi34aGu3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kUtCG/btsMy0FVFR1/R0dkqaxi06yiKOi34aGu3K/img.png&quot; data-alt=&quot;덤프된 `.sql` 파일의 일부&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kUtCG/btsMy0FVFR1/R0dkqaxi06yiKOi34aGu3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkUtCG%2FbtsMy0FVFR1%2FR0dkqaxi06yiKOi34aGu3K%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;632&quot; height=&quot;24&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;24&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;덤프된 `.sql` 파일의 일부&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 덤프 명령어의 인수로 `--hex-blob`을 추가하는 것입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`--hex-blob`: 이진 데이터(`BINARY(16)`) 필드를 16진수(`HEX()`) 문자열로 변환합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;MySQL dump&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL에서 dump 명령어는 아래와 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1740734014888&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;kubectl exec -it [MySQL Pod] -- mysqldump -u root -p[패스워드] --hex-blob [데이터베이스] &amp;gt; [파일_이름].sql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1740733113006&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;kubectl exec -it mysql-deployment-55bc667810-n42t8 -- \
mysqldump -u root -psecretpassword --default-character-set=utf8mb4 --hex-blob --single-transaction --skip-lock-tables mydb &amp;gt; 20250228_161714.sql&lt;/code&gt;&lt;/pre&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;--default-character-set=utf8mb4: 덤프할 때 문자열 인코딩을 `utf8mb4`로 지정합니다.&lt;/li&gt;
&lt;li&gt;--single-transaction: 덤프할 때 트랜잭션을 사용합니다. InnoDB 스토리지 엔진을 사용한다면 글로벌 락 없이 덤프할 수 있습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그래서 덤프 중에 테이블이 변경되도 덤프 시작 시점의 데이터를 MVCC로 읽어 스냅샷 형태로 백업합니다.&lt;/li&gt;
&lt;li&gt;주의할 점으로는 DDL(ALTER TABLE) 같은 테이블 구조를 변경하는 명령어가 백업 중간에 실행되면 백업할 때 반영되지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot;&gt;--skip-lock-tables: MyISAM 같은 엔진을 사용할 때 잠금없이 덤프할 수 있도록 합니다. InnoDB만 사용하면 사용하지 않아도 됩니다.&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;background-color: #ffffff; color: #000000; text-align: left;&quot;&gt;MyISAM을 사용하면 `FLUSH TABLES WITH READ LOCK;`이 실행되서 불필요한 락이 걸릴 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;해당 인수를 사용하면 잠금을 건너뛰고 수행할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;주의할 점&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`mysqldump` 명령어로 덤프하면, 기본적으로 모든 데이터를 읽어 메모리에 올린 후, 한 번에 파일로 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 테이블을 덤프할 때 메모리를 많이 사용할 수 있기 때문에, 운영 환경에서 사용할 때 주의해서 사용해야 하는데요,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 사용할 수 있는 옵션은 `--quick`이 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`--quick`: 한 번에 한 행씩 읽어 바로 파일로 출력합니다. 메모리에 올리지 않기 때문에 대용량 테이블을 빠르게 덤프할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;MySQL import&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덤프한 파일을 적용하려면 아래 명령어를 입력합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1740732922760&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;kubectl exec -it [MySQL Pod] -- mysql -u [계정] -p[비밀번호] --default-character-set=utf8mb4 [데이터베이스] &amp;lt; [파일_이름].sql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서도 마찬가지로 인코딩 인수를 추가했습니다. (--default-character-set=utf8mb4)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1740732984260&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;kubectl exec -it mysql-deployment-55bc667810-wttz8 -- mysql -u root -psecretpassword --default-character-set=utf8mb4 mydb &amp;lt; 20250228_161714.sql&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;정리하며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL을 백업할 때 자주 사용하는 명령어인데, 수동으로 백업을 자주하지 않다보니 덤프할 때마다 구글링해서 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매번 구글링하며 아무 게시글에서 명령어를 참조해 쓰다보니 UUID 필드를 덤프할 때 발생했던 문제를 다시 맞딱뜨리는 실수를 했었는데요,&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;덤프 명령어에 사용되는 인수 정리글: &lt;a href=&quot;https://hahagogo.tistory.com/277&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://hahagogo.tistory.com/277&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Kubernetes</category>
      <category>Dump</category>
      <category>kubernetes</category>
      <category>MySQL</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/91</guid>
      <comments>https://dev-gallery.tistory.com/91#entry91comment</comments>
      <pubDate>Fri, 28 Feb 2025 18:36:05 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트 리팩토링 (5) - HTTP 요청 비동기 처리</title>
      <link>https://dev-gallery.tistory.com/90</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 여러 번 반복되는 HTTP 요청을 비동기로 처리해 응답 속도를 단축시키는 과정을 담았습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev-gallery.tistory.com/89&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 포스팅&lt;/a&gt; 마지막에서 다뤘던 문제는 반복문 안에서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;HTTP 요청을&lt;span&gt; 보내는 것이었습니다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;기존 코드&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738861314740&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void preCreateDailyReport(UUID userId, LocalDate startDate, LocalDate endDate) {
    // 편지 서비스에게 `분석 가능한 편지들` 찾기 위임
    List&amp;lt;DailyLetters&amp;gt; analyzableLetters = letterService.findAnalyzableLettersInRange(userId, startDate, endDate);

    for (DailyLetters dailyLetters : analyzableLetters) {
        // 외부 API 호출
        CreateResponse createResponse = clovaService.sendWithPromptTemplate(promptTemplate, dailyLetters.getMessages());

        // 응답 결과로부터 하루치 분석 추출
        DailyAnalysisResult analysisResult = DailyAnalysisExtractor.extract(createResponse);

        // 하루치 분석으로부터 편지 분석, 데일리 리포트 엔티티 저장
        letterAnalysisService.saveAnalysisAndDailyReport(dailyLetters, analysisResult);
    }
}&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;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;결과&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;351&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zuaV0/btsL9uudQmQ/oeBNeJrxLqFm9LdKWpgqg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zuaV0/btsL9uudQmQ/oeBNeJrxLqFm9LdKWpgqg0/img.png&quot; data-alt=&quot;응답까지 1분 5.64초가 걸리는 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zuaV0/btsL9uudQmQ/oeBNeJrxLqFm9LdKWpgqg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzuaV0%2FbtsL9uudQmQ%2FoeBNeJrxLqFm9LdKWpgqg0%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;868&quot; height=&quot;351&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;351&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;응답까지 1분 5.64초가 걸리는 문제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;해결: HTTP 요청 비동기 처리하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 HTTP 요청을 비동기로 처리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에서 HTTP 요청을 보낼 때 FeignClient를 사용하고 있으며, 기본적으로 동기 방식으로 작동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 비동기 방식으로 요청하기 위해 Java에서 지원하는 CompletableFuture와 스프링에서 지원하는 @Async 애너테이션을 이용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Async 애너테이션을 사용하기 앞서 비동기 작업 처리를 위해 알아야 할 것들을 정리했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;TaskExecutor&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;876&quot; data-origin-height=&quot;91&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q8jBT/btsL81stXpj/iwAViO3plTKO9NklRXifJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q8jBT/btsL81stXpj/iwAViO3plTKO9NklRXifJk/img.png&quot; data-alt=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/EnableAsync.html&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q8jBT/btsL81stXpj/iwAViO3plTKO9NklRXifJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq8jBT%2FbtsL81stXpj%2FiwAViO3plTKO9NklRXifJk%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;876&quot; height=&quot;91&quot; data-origin-width=&quot;876&quot; data-origin-height=&quot;91&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/EnableAsync.html&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot에서 @Async 애너테이션을 사용하기 위해서는 먼저 @EnableAsync를 적용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EnableAsync javadoc 문서를 보면 Spring은 기본적으로 TaskExcutor 빈 또는 &quot;taskExecutor&quot; 이름의 빈을 찾는데, 만약 둘 다 찾지 못하면 `SimpleAsyncTaskExecutor` 구현체가 사용된다는 것을 알 수 있습니다.&lt;/p&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;바닐라 스프링을 사용하면 별도로 제공되는 TaskExcutor Bean이 없기 때문에 `SimpleAsyncTaskExecutor`가 사용되지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 부트는 별도로 `ThreadPoolTaskExecutor`를 Bean으로 제공하기 때문에 해당 빈을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록 과정은 `@SpringBootApplication` &amp;rarr; `@EnableAutoConfiguration`에 의해 `TaskExecutionAutoConfiguration`가 등록되고, 내부 설정 값으로 `TaskExecutionProperties`를 이용합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;754&quot; data-origin-height=&quot;309&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4nlXF/btsL9Jq6RTE/5PQkKjX8H3jXqdH2yFkh51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4nlXF/btsL9Jq6RTE/5PQkKjX8H3jXqdH2yFkh51/img.png&quot; data-alt=&quot;TaskExecutionAutoConfiguration&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4nlXF/btsL9Jq6RTE/5PQkKjX8H3jXqdH2yFkh51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4nlXF%2FbtsL9Jq6RTE%2F5PQkKjX8H3jXqdH2yFkh51%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;650&quot; height=&quot;266&quot; data-origin-width=&quot;754&quot; data-origin-height=&quot;309&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;TaskExecutionAutoConfiguration&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;674&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XFtC4/btsL9cAALKM/PMBFec8aIeqyMLvnHxkJQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XFtC4/btsL9cAALKM/PMBFec8aIeqyMLvnHxkJQk/img.png&quot; data-alt=&quot;TaskExecutionProperties&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XFtC4/btsL9cAALKM/PMBFec8aIeqyMLvnHxkJQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXFtC4%2FbtsL9cAALKM%2FPMBFec8aIeqyMLvnHxkJQk%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;650&quot; height=&quot;528&quot; data-origin-width=&quot;674&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;TaskExecutionProperties&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 TaskExecutionProperties의 설정을 보면 스레드 개수, 큐의 크기 등 기본 값이 설정되어 있는 것을 확인할 수 있는데요,&lt;br /&gt;이 설정 값들이 서비스에 최적화된 값은 아닙니다. 따라서 서비스 성격에 맞는 값으로 설정해야 할 필요가 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;TreadPoolTaskExecutor 주요 설정 옵션&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 168px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;&lt;b&gt;옵션&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;&lt;b&gt;기본값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;&lt;span&gt;corePoolSize&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;기본적으로&amp;nbsp;유지할&amp;nbsp;스레드&amp;nbsp;개수&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;maxPoolSize&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;최대&amp;nbsp;생성&amp;nbsp;가능한&amp;nbsp;스레드&amp;nbsp;개수&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;Integer.MAX_VALUE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;queueCapacity&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;&lt;b&gt;태스크 큐 크기&lt;/b&gt; (스레드가 부족할 때 작업을 담아둘 버퍼)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;Integer.MAX_VALUE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;keepAliveSeconds&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;최대 풀 사이즈 초과 시, 유휴 스레드가 종료되기까지 유지되는 시간&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;60초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;threadNamePrefix&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;스레드 이름 접두사 (디버깅용)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 20px;&quot;&gt;class 이름 + &quot;-&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;allowCoreThreadTimeOut&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;`true`면 `corePoolSize` 이하의 스레드도 유휴 상태가 되면 종료됨&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;waitForTasksToCompleteShutdown&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;`true`이면 &lt;b&gt;컨테이너 종료 시 대기 중인 태스크가 완료될 때까지 대기&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;awaitTerminationSeconds&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;`waitForTasksToCompleteOnShutdown=true`일 때, &lt;b&gt;최대 대기 시간&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;rejectedExecutionHandler&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;스레드 풀이 꽉 차서 &lt;b&gt;새로운 작업을 받을 수 없을 때&lt;/b&gt; 실행할 정책을 결정&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;AbortPolicy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;daemon&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;스레드가 데몬 스레드인지 여부&lt;/b&gt;를 결정&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;적절한 설정을 위해 고려할 사항들&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://code-lab1.tistory.com/269&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이상적인 스레드 풀의 적정 크기에 대하여, 스레드 풀 크기 공식, 리틀의 법칙&lt;/a&gt; 글에서 워크로드에 따른 적절한 설정 공식을 참고해 아래와 같이 고려할 사항들을 정리했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;작업의 성격이 `CPU 바운드`인지, `I/O 바운드`인지&lt;/li&gt;
&lt;li&gt;클로바 API의 이용량 제어 정책&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;작업의 성격&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기로 처리할 작업의 주 목적은 HTTP 요청입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청같은 I/O 바운드 작업은 네트워크 응답을 기다리는 시간이 대부분이므로 &lt;b&gt;CPU 코어 수보다 외부 API 요청 패턴에 맞게 설정&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균적으로 응답까지 걸리는 시간은 &lt;b&gt;7초&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;클로바 API 이용량 제어 정책&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거의 모든 외부 API 호출에는 이용량 제어 정책이 있습니다.&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;이용량 제어 정책은 테스트 앱과 서비스 앱별로 차이가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bD0U1J/btsL96USbH1/leFKYFKfGoCMwS56HrLOvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bD0U1J/btsL96USbH1/leFKYFKfGoCMwS56HrLOvk/img.png&quot; data-origin-width=&quot;711&quot; data-origin-height=&quot;417&quot; data-is-animation=&quot;false&quot; width=&quot;650&quot; height=&quot;381&quot; style=&quot;width: 53.615%; margin-right: 10px;&quot; data-widthpercent=&quot;54.25&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bD0U1J/btsL96USbH1/leFKYFKfGoCMwS56HrLOvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbD0U1J%2FbtsL96USbH1%2FleFKYFKfGoCMwS56HrLOvk%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;711&quot; height=&quot;417&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kxWf8/btsMaNtzWrI/KEslmewSZrYcVDkR2KkPYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kxWf8/btsMaNtzWrI/KEslmewSZrYcVDkR2KkPYK/img.png&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;493&quot; data-is-animation=&quot;false&quot; width=&quot;650&quot; height=&quot;452&quot; style=&quot;width: 45.2222%;&quot; data-widthpercent=&quot;45.75&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kxWf8/btsMaNtzWrI/KEslmewSZrYcVDkR2KkPYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkxWf8%2FbtsMaNtzWrI%2FKEslmewSZrYcVDkR2KkPYK%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;709&quot; height=&quot;493&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;이용량 제어 정책&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용 중인 모델은 최상단의 HCX-003입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;QPM(Querys Per Minute, 분당 작업 요청 수): 200 (테스트 앱) | &lt;b&gt;900 (서비스 앱)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;TPM(Tokens Per Minute, 분당 처리할 토큰 수): 30,000 (테스트 앱) | &lt;b&gt;180,000 (서비스 앱)&lt;/b&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;/ul&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;사용자당 필요한 동시 요청 수&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청을 비동기로 처리하려는 목적은 사용자당 `7일치` 편지 분석을 요청하기 위함입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 `7일치`를 `하루치`로 나눠 &lt;b&gt;&quot;7번&quot;&lt;/b&gt; 요청합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;피크 시 예상되는 동시 요청 수&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 활성 사용자 수가 많이 없는 관계로 피크 시 예상 활성 사용수는 10명 정도로 생각됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 최대 동시 요청 수는 &lt;b&gt;&quot;70번(=&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;사용자당 7번 요청 x 10명)&quot;&lt;/b&gt;으로 예상했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;호출 한도 시 추가 조치&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API 호출 한도에 걸리지 않도록 처리율 제한을 둬야하지만, 현재 서비스의 처리율 제한은 초기 &quot;답장 서비스&quot; 워크로드만 고려해 운영상 정책에 불과합니다.&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;옵션 1: 한도를 넘은 요청은 거절 처리한다.&lt;/li&gt;
&lt;li&gt;옵션 2: 한도를 넘은 요청은 별도의 큐에 넣고 지연 처리하거나, 별도로 저장 후 배치 프로그램을 통해 순차처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션 1은 사용자에게 왜 거절이 되었는지, 언제 다시 요청할 수 있는지 등 별도의 응답을 해야합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션 2 는 사용자에게 지연 처리된다는 응답(202 Accepted 등)과 함께 이용량 제어 정책 내에서 처리할 수 있도록 구현해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;스레드 풀 설정을 위한 의사 결정 과정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&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;7초&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;최소 동시 요청 수: &lt;b&gt;7회&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;피크 시 예상 동시 요청 수: &lt;b&gt;70회 &lt;/b&gt;(10명 기준)&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&amp;rarr; QPM 900회 아래이므로 &lt;b&gt;TPM을 우선 고려&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전체 토큰 한도에 따른 최대 호출 건수&lt;/b&gt;: &lt;b&gt;128건/분&lt;/b&gt; (=&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;180,000 토큰/분&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&amp;divide; 1,400 토큰(건)/사용자)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;TPM:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;180,000 토큰&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;사용자당 `7일치 편지들` 분석 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;평균 1,400 토큰 사용&lt;/b&gt;&lt;/li&gt;
&lt;/ul&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;`CorePoolSize`: 70 (피크 시 최대 동시 요청 수)&lt;/li&gt;
&lt;li&gt;`MaxPoolSize`: 100 (버스트 상황)&lt;/li&gt;
&lt;li&gt;`QueueCapacity`: 100 (대기 큐 용량)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 호출은 분당 128회가 넘지 않도록 별도로 처리해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;`WaitForTasksToCompleteShutdown`: true (스프링 IOC 컨테이너 종료 시 대기 중인 작업까지 기다림)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스프링 부트 설정 시 graceful shutdown을 원해 서버 설정에도 적용되어 있습니다. (server.shutdown=`graceful`)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;`AwaitTerminationSeconds`: 30 (`waitForTasksToCompleteShutdown` 설정 시 무한 대기 방지)&lt;/li&gt;
&lt;li&gt;`RejectedExecutionHandler`: AbortPolicy&lt;br /&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;따라서 기본 정책을 사용합니다. (`AbortPolicy`)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`CallerRunsPolicy`는 호출하는 메인 스레드에서 실행합니다. 이 경우 응답까지 최대 1분 넘는 시간이 소요될 수 있기때문에 다른 서비스와 경합이 발생할 수 있습니다. 따라서 사용하지 않습니다.&lt;/li&gt;
&lt;li&gt;`DiscardPolicy`는 거부된 작업을 그냥 버리는 정책입니다. 사용자에게 응답할 수 없으므로 사용하지 않습니다.&lt;/li&gt;
&lt;li&gt;`DiscardOldestPolicy`는 대기 큐에서 가장 오래된 작업을 제거하고 새로운 작업을 추가합니다. 기존 사용자의 요청과 경합이 발생하므로 사용하지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;최종 설정&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1738866415823&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean
    public Executor httpRequestExecutor() {
        // 이 예시는 최대 10명의 사용자가 동시에 각각 7건씩 요청할 수 있는 상황 (총 70건 동시 요청)
        // 외부 API의 한도와 평균 응답 시간(7초)을 고려
        // FIXME: 실제 호출은 레이트 리미터로 조절하여 분당 128건을 넘지 않도록 해야 함.
        int corePoolSize = 70;    // 최대 동시 요청 수 예상 (10명 x 7)
        int maxPoolSize = 100;    // 버스트 상황을 고려하여 확장
        int queueCapacity = 100;  // 대기 큐 용량

        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(corePoolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setQueueCapacity(queueCapacity);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.setThreadNamePrefix(&quot;HttpAsyncExecutor-&quot;);
        executor.setRejectedExecutionHandler(new AbortPolicy());
        executor.initialize();
        return executor;
    }
}&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;실제 호출은 분당 128회가 넘지 않도록 처리율 제한기를 구현해야 합니다.&lt;br /&gt;궁극적으로는 정확한 한도까지 이용해야 하기 때문에 TPM을 넘지 않는 것을 목표로 해야합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;@Async 사용 시 주의 사항&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 트랜잭션 관리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@Async`가 적용된 메서드는 스프링에서 Bean으로 등록한 `ThreadPoolTaskExecutor`에 의해 별도의 스레드에서 실행됩니다.&lt;br /&gt;따라서 메인 스레드의 트랜잭션 컨텍스트가 자동으로 전파되지 않습니다. 따라서 비동기 메서드 내에서 데이터베이스 작업을 수행한다면 해당 메서드에 별도로 `@Transactional`을 적용해 트랜잭션 전파를 설정해야 합니다.&lt;/p&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; HTTP 요청과 트랜잭션 커밋을 분리했기 때문에 이 문제에 대해 고려하지 않아도 됐습니다.)&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. Self-Invocation(자기 호출)의 제한&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@Transactional`과 마찬가지로 스프링 AOP를 이용하기 때문에 &lt;b&gt;동일한 Bean 내에서 호출(self-invocation)&lt;/b&gt; 시 비동기로 동작하지 않고 동기적으로 실행됩니다. 따라서 별도의 Bean으로 분리하거나 스프링 IOC 컨테이너로부터 자기 자신을 주입받아 &lt;b&gt;프록시 객체를 통해 호출&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 예외 처리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@Async`는 기본적으로 예외가 메인 스레드로 전파되지 않습니다. 심지어 반환 값이 `void`라면 &lt;b&gt;예외가 로깅되지도 않는 문제&lt;/b&gt;가 있습니다. 이 경우(`void` 타입 반환)에는 `AsyncUncaughtExceptionHandler` 인터페이스를 구현해 직접 처리해야 합니다.&lt;br /&gt;(정리하면 `void` 반환 타입은 메인 스레드에서 `.exceptionally()`나 `.whenComplete()` 등의 콜백을 통해 핸들링할 수 없습니다. 그래서 CompletableFuture&amp;lt;Void&amp;gt;로 감싸는 것이 낫다고 생각합니다.)&lt;br /&gt;&lt;br /&gt;하지만, `CompletableFuture&amp;lt;T&amp;gt;` (CompletableFuture&amp;lt;Void&amp;gt; 포함) 타입을 반환한다면 메인 스레드에서 `.exceptionally()` 같은 콜백으로 예외를 핸들링 할 수 있습니다. &lt;br /&gt;만약 콜백으로 예외를 핸들링하지 않는다면, `.get()`을 통해 예외를 핸들링할 수 있습니다. (`ExecutionException`, `.join()`의 경우 `CompletionException`로 래핑됨)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;@Async 적용&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1738916246620&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 ClovaService {

    private final ClovaFeignClient client;

    @Async(&quot;httpRequestExecutor&quot;)
    public CompletableFuture&amp;lt;CreateResponse&amp;gt; sendAsyncWithPromptTemplate(PromptTemplate promptTemplate,
                                                                         String userMessage) {
        return CompletableFuture.completedFuture(client.sendToClova(CreateRequest.of(promptTemplate, userMessage)))
                .exceptionally(t -&amp;gt; {
                    log.error(&quot;외부 API 호출 중 예외 발생&quot;, t);
                    throw new CompletionException(t);
                })
                .orTimeout(20, TimeUnit.SECONDS);
    }
}&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;HTTP 요청 전송을 책임지는 `클로바 서비스`에 새로운 인터페이스를 추가합니다.&lt;/li&gt;
&lt;li&gt;@Async에 사용할 때 TaskExecutor Bean으로 등록한 이름(`httpRequestExecutor`)을 지정합니다.&lt;/li&gt;
&lt;li&gt;`exceptionally()`를 적용해 API 호출 시 예외 발생에 대해 로그를 출력하고 해당 작업을 종료합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;만약 `resilience4j`와 함께 동작하고, FallbackFactory가 설정되어 있다면 서킷 브레이커가 트리거(open) 됐을 때 `exceptionally()`로 핸들링 할 수 없게되니 주의가 필요합니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;또한, `RestApiControllerAdvice`를 사용한다면 `CompletionException`를 언래핑할 필요가 있습니다.&lt;br /&gt;비동기 처리 중 발생한 원래 예외를 올바르게 처리하기 위함입니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;`orTimeout()`을 설정해 작업 당 20초가 넘으면 `TimeoutException`을 발생시킵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;CompletableFuture 적용&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Async 애너테이션이 적용된 메서드는 `CompleatableFuture`로 감싼 응답을 반환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 비동기 HTTP 요청을 책임지는 `편지 분석 서비스`에 새로운 인터페이스를 추가합니다.&lt;/p&gt;
&lt;p 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;size18&quot;&gt;&lt;b&gt;비동기 요청 내부 구현 (편지 분석 서비스)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738920841904&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public List&amp;lt;DailyAnalysisResult&amp;gt; createAsyncDailyAnalyses(List&amp;lt;DailyLetters&amp;gt; dailyLetters) {
    List&amp;lt;CompletableFuture&amp;lt;DailyAnalysisResult&amp;gt;&amp;gt; futures = dailyLetters.stream()
            .map(each -&amp;gt; clovaService.sendAsyncWithPromptTemplate(promptTemplate, each.getMessages())
                    .thenApply(DailyAnalysisExtractor::extract))
            .toList();

    CompletableFuture&amp;lt;List&amp;lt;DailyAnalysisResult&amp;gt;&amp;gt; combinedFuture =
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                    .thenApply(v -&amp;gt; futures.stream().map(CompletableFuture::join).toList());

    return combinedFuture.join();
}&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;`하루치 편지들(DailyLetters)`을 7일치로 모은 `List&amp;lt;DailyLetters&amp;gt;`를 파라미터로 받습니다.&lt;/li&gt;
&lt;li&gt;Stream API를 이용해 HTTP 비동기 요청을 보냅니다.&lt;/li&gt;
&lt;li&gt;콜백으로 `thenApply()`를 적용해 응답으로부터 분석 정보를 추출합니다.&lt;/li&gt;
&lt;li&gt;CompletableFuture.allOf().thenApply(): 위에서 만든 비동기 작업들을 실행하고 기다립니다.&lt;br /&gt;이때, `thenApply()`를 통해 응답을 받을 때마다 추가적인 블로킹 없이 모든 결과를 기다립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;엔티티 저장 (편지 분석 서비스)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738916888004&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 메서드 추가
@Transactional
public void saveAllAnalysesAndDailyReports(List&amp;lt;DailyLetters&amp;gt; dailyLetters, List&amp;lt;DailyAnalysisResult&amp;gt; results) {
    for (int i = 0; i &amp;lt; results.size(); i++) {
        saveAnalysisAndDailyReport(dailyLetters.get(i), results.get(i)); // 기존 저장 코드
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지 분석(LetterAnalysis)`와 `데일리 리포트(DailyReport)` 엔티티는 `ManyToOne`입니다.&lt;br /&gt;따라서 전체 결과의 원자적인 영속화를 위해 기존 `saveAnalysisAndDailyReport()` 메서드를 순차 실행합니다.&lt;br /&gt;(`List&amp;lt;LetterAnalysis&amp;gt;`와 `DailyReport`간의 연관 관계 매핑 후 `.saveAll(List&amp;lt;LetterAnalysis&amp;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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;AS-IS (데일리 리포트 서비스)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738916531720&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void preCreateDailyReport(UUID userId, LocalDate startDate, LocalDate endDate) {
    // 편지 서비스에게 `분석 가능한 편지들` 찾기 위임
    List&amp;lt;DailyLetters&amp;gt; analyzableLetters = letterService.findAnalyzableLettersInRange(userId, startDate, endDate);

    for (DailyLetters dailyLetters : analyzableLetters) {
        // 외부 API 호출
        CreateResponse createResponse = clovaService.sendWithPromptTemplate(promptTemplate, dailyLetters.getMessages());

        // 응답 결과로부터 하루치 분석 추출
        DailyAnalysisResult analysisResult = DailyAnalysisExtractor.extract(createResponse);

        // 하루치 분석으로부터 편지 분석, 데일리 리포트 엔티티 저장
        letterAnalysisService.saveAnalysisAndDailyReport(dailyLetters, analysisResult);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;TO-BE &lt;b&gt;(데일리 리포트 서비스)&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738916555442&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void preCreateDailyReport(UUID userId, LocalDate startDate, LocalDate endDate) {
    // 편지 서비스에게 `분석 가능한 편지들` 찾기 위임
    List&amp;lt;DailyLetters&amp;gt; analyzableLetters = letterService.findAnalyzableLettersInRange(userId, startDate, endDate);

    // 편지 분석 서비스에게 `편지 분석`, `데일리 리포트` 생성 요청
    List&amp;lt;DailyAnalysisResult&amp;gt; results = letterAnalysisService.createAsyncDailyAnalyses(analyzableLetters);

    // 편지 분석 서비스에게 트랜잭션 내에서 저장 요청
    letterAnalysisService.saveAllAnalysesAndDailyReports(analyzableLetters, results);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;비동기 요청 적용 결과 평가&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;175&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1qc5k/btsMbCFcP66/4TcF3fctyuV4zxqAPAL33k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1qc5k/btsMbCFcP66/4TcF3fctyuV4zxqAPAL33k/img.png&quot; data-alt=&quot;작업마다 응답 시간이 다르다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1qc5k/btsMbCFcP66/4TcF3fctyuV4zxqAPAL33k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1qc5k%2FbtsMbCFcP66%2F4TcF3fctyuV4zxqAPAL33k%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;328&quot; height=&quot;175&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;175&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;작업마다 응답 시간이 다르다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비동기 요청이 성공적으로 수행되었습니다. 비동기 요청마다 응답 받는 시간이 다름을 확인할 수 있습니다.&lt;/li&gt;
&lt;li&gt;`7일치 편지 분석`에 소요된 전체 시간은 10.5초 정도입니다.
&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;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;861&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Dvy8k/btsMb1dxL5J/NMW4gaKa9QwczWfRzDqx6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Dvy8k/btsMb1dxL5J/NMW4gaKa9QwczWfRzDqx6k/img.png&quot; data-alt=&quot;1분 5초 &amp;amp;rarr; 18.5초&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Dvy8k/btsMb1dxL5J/NMW4gaKa9QwczWfRzDqx6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDvy8k%2FbtsMb1dxL5J%2FNMW4gaKa9QwczWfRzDqx6k%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;861&quot; height=&quot;333&quot; data-origin-width=&quot;861&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;1분 5초 &amp;rarr; 18.5초&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위클리 리포트까지 생성하는데 걸리는 시간은 약 18.5초가 걸렸습니다. (데일리 리포트 생성: 10.5초 + 위클리 리포트 생성: 8초)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 작업으로 처리하기 전에는 1분 5.5초정도 소요됐었으니, 약 47초 단축했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;남은 개선 사항 - 처리율 제한기&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 외부 API 호출은 TPM을 넘지 않도록 별도로 처리율 제한기를 구현해야 합니다.&lt;/li&gt;
&lt;li&gt;처리율 제한기는 RabbitMQ 같은 메시지 큐를 이용해 구현할 계획입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부 API 호출에 의존적인 서비스의 경우, API 이용량 정책과 예산에 따라 성능이 제한될 수 있습니다. 사용자당 토큰 소모량과 사용 패턴 분석을 통해 워크로드를 면밀하게 파악하는 것이 중요합니다.&lt;/li&gt;
&lt;li&gt;@Async 애너테이션을 사용하는 것만으론 문제가 해결되지 않습니다. 워크로드에 적합한 스레드 풀 설정을 찾아야 하는데, 이는 CS 지식뿐만 아니라 운영 예산, 사용자 패턴 등 복잡한 요소들을 고려해야 하므로 최적값을 찾기가 쉽지 않았습니다.&lt;/li&gt;
&lt;li&gt;join(), 콜백(thenApply 등) 활용법을 충분히 익히고 사용해야 합니다. 스레드 블로킹을 최소화하기 위해 join()을 적절히 사용하는 것이 중요한데, 특히 비동기 작업 대기 과정(`CompletableFuture.allOf()`)에서 `.thenApply()`같은 콜백을 활용하여 불필요한 블로킹을 줄여야 합니다.&lt;/li&gt;
&lt;li&gt;시간 제약으로 인해 스트레스 테스트를 수행하지 못한 점이 아쉽습니다. 실제 서비스 환경에서의 성능을 검증하기 위해서는 스트레스 테스트가 필수적인 것을 설정 값을 찾는 과정에서 느낄 수 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/EnableAsync.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/EnableAsync.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://xxeol.tistory.com/44&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://xxeol.tistory.com/44&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dkswhdgur246.tistory.com/82#enableasync-%EC%84%A4%EB%AA%85&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dkswhdgur246.tistory.com/82#enableasync-%EC%84%A4%EB%AA%85&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://code-lab1.tistory.com/269&quot;&gt;https://code-lab1.tistory.com/269&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://wonit.tistory.com/669&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wonit.tistory.com/669&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://devoong2.tistory.com/entry/Spring-Async-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95-%EB%B0%8F-TaskExecutor-ThreadPool&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devoong2.tistory.com/entry/Spring-Async-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95-%EB%B0%8F-TaskExecutor-ThreadPool&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dkswnkk.tistory.com/706&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dkswnkk.tistory.com/706&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project</category>
      <category>@Async</category>
      <category>OOP</category>
      <category>threadpooltaskexecutor</category>
      <category>리팩토링</category>
      <category>비동기</category>
      <category>비사이드</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/90</guid>
      <comments>https://dev-gallery.tistory.com/90#entry90comment</comments>
      <pubDate>Fri, 7 Feb 2025 20:41:07 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트 리팩토링 (4) - 책임 재할당과 트랜잭션에서 외부 API 분리하기</title>
      <link>https://dev-gallery.tistory.com/89</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅에서는 객체들의 책임이 올바르게 할당되지 않았던 문제를 다뤘습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다 보니 거대한 몬스터 메서드들이 생겨났고, 이로 인해 유지보수와 확장이 어려웠었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해소하기 위해 책임의 재할당을 다뤘었는데요, 이번 포스팅에서도 객체 지향 관점에서 책임의 재할당을 다룹니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책임의 재할당 과정에서 트랜잭션에서 외부 API 호출 로직을 분리하고, 데이터 주도 협력 관계에서 책임 주도 협력 관계로 나아가는 과정을 담았습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;의미 있는 타입으로 묶기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;이전 포스팅에서 책임 재할당의 결과&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1635&quot; data-origin-height=&quot;553&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o4gc7/btsL93pa6Rc/Mz2BWKb2E8DFyTCfwLCR8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o4gc7/btsL93pa6Rc/Mz2BWKb2E8DFyTCfwLCR8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o4gc7/btsL93pa6Rc/Mz2BWKb2E8DFyTCfwLCR8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo4gc7%2FbtsL93pa6Rc%2FMz2BWKb2E8DFyTCfwLCR8k%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;1635&quot; height=&quot;553&quot; data-origin-width=&quot;1635&quot; data-origin-height=&quot;553&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왼쪽은 `편지 서비스`가 해야 할 책임이 `데일리 리포트 서비스`에 있었던 부분입니다. (코드 20줄)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오른쪽은 `편지 서비스`에게 책임을 재할당하고, 해당 서비스에게 요청을 보내는 부분으로 개선된 코드입니다. (코드 1줄)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;이때, `분석 가능한 편지들`을 `List &amp;lt;Letter&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;요점은 `List&amp;lt;Letter&amp;gt;` 타입만으로 `분석 가능한 편지들`을 `clovaService`를 이용해 생성형 AI에게 전달하고, 분석 결과를 받는다는 의미를 파악하기 어렵다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 `List&amp;lt;Letter&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;아래는 Stream API를 이용해 그룹화한 날짜 별 편지들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;날짜 별 그룹화된 편지들(Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738836894755&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt; analyzableLettersByDate = analyzableLetters.stream()
                .collect(Collectors.groupingBy(letter -&amp;gt; letter.getCreatedAt().toLocalDate()));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비로소 `Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt;` 타입이 되어서야 날짜 별로 묶인 편지들이라는 의미가 보이기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이렇게 했음에도 value로 갖고 있는 `List&amp;lt;Letter&amp;gt;`들은 key가 없으면 `하루치 편지`라는 의미로 받아들이기가 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 아래와 같이 `하루치 편지`라는 의미를 갖도록 래퍼 클래스`(wrapper class)`를 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;DailyLetter&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738836480017&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class DailyLetters {

    private static final String MERGE_FORMAT = &quot;&amp;lt;%s:%s&amp;gt;\n%s\n&amp;lt;/%s:%s&amp;gt;&quot;;
    private static final String LETTER_SEPARATOR = &quot;sharpie-sep&quot;;
    private static final String ESCAPE_LT = &quot;&amp;lt;&quot;;
    private static final String ESCAPE_GT = &quot;&amp;gt;&quot;;
    private static final String ESCAPE_AMP = &quot;&amp;amp;&quot;;
    private static final String ESCAPE_QUOT = &quot;\&quot;&quot;;
    private static final String ESCAPE_APOS = &quot;'&quot;;

    private List&amp;lt;Letter&amp;gt; letters;

    public static DailyLetters from(List&amp;lt;Letter&amp;gt; letters) {
        return new DailyLetters(letters);
    }

    public Letter getLetter(int naturalSequence) {
        return letters.get(naturalSequence - 1);
    }

    public LocalDate getCreatedDate() {
        return letters.stream()
                .findAny()
                .orElseThrow(() -&amp;gt; new LetterNotFoundException(&quot;편지가 존재하지 않아 날짜를 가져올 수 없습니다.&quot;))
                .getCreatedAt()
                .toLocalDate();
    }

    public String getMessages() {
        String messageSeparator = Long.toHexString(Double.doubleToLongBits(Math.random()));

        return letters.stream()
                .map(letter -&amp;gt; String.format(MERGE_FORMAT,
                        LETTER_SEPARATOR, messageSeparator,
                        replaceEscapeCharacters(letter.getMessage()),
                        LETTER_SEPARATOR, messageSeparator))
                .collect(Collectors.joining(&quot;\n&quot;));
    }

    private String replaceEscapeCharacters(String message) {
        return message
                .replace(ESCAPE_LT, &quot;&amp;amp;lt;&quot;)
                .replace(ESCAPE_GT, &quot;&amp;amp;gt;&quot;)
                .replace(ESCAPE_AMP, &quot;&amp;amp;amp;&quot;)
                .replace(ESCAPE_QUOT, &quot;&amp;amp;quot;&quot;)
                .replace(ESCAPE_APOS, &quot;&amp;amp;apos;&quot;);
    }
}&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`getLetter(int naturalIndex)`: 주어진 자연수 인덱스(1부터 시작)에 해당하는 편지를 가져옵니다.&lt;br /&gt;(생성형 AI 응답에서 편지의 순서를 자연수로 주도록 설정했기 때문입니다.)&lt;/li&gt;
&lt;li&gt;`getCreatedDate()`: `하루치`가 의미하는 해당 날짜를 의미합니다.&lt;/li&gt;
&lt;li&gt;`getMessages()`: `하루치 편지들`에서 편지 내용들을 모두 합친 메시지를 의미합니다.&lt;br /&gt;생성형 AI에게 전달되며, 편지들을 구분하기 위해 내부에서 구분자가  사용되었습니다.&lt;/li&gt;
&lt;li&gt;`replaceEscapeCharacters(String message)`: 편지 내용들 중 HTML escape 문자들을 치환하는 역할을 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 위의 분석 가능한 편지들 `Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;`가 아니라 `List&amp;lt;DailyLetters&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;size18&quot;&gt;&lt;b&gt;LetterService 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738838106647&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
public List&amp;lt;DailyLetters&amp;gt; findAnalyzableLetters(UUID userId, LocalDate startDate, LocalDate endDate) {
    List&amp;lt;Letter&amp;gt; analyzableLetters = letterRepository.findAnalyzableLetters(userId, toStartDateTime(startDate),
            toEndDateTime(endDate));

    Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt; analyzableLettersByDate = analyzableLetters.stream()
            .collect(Collectors.groupingBy(letter -&amp;gt; letter.getCreatedAt().toLocalDate()));

    return analyzableLettersByDate.values().stream()
            .map(DailyLetters::from)
            .toList();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 `분석 가능한 편지들`을 반환할 때 `List&amp;lt;DailyLetters&amp;gt;` 타입으로 반환하기 때문에 startDate와 endDate 사이에 존재하는 &quot;하루치 편지들의 리스트&quot;라는 의미가 됐습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;편지 분석(LetterAnalysis)의 책임&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편지 분석(LetterAnalysis)은 편지 하나에 대해 &lt;b&gt;감정 분석 관련 정보&lt;/b&gt;를 가장 많이 알고 있는 엔티티입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev-gallery.tistory.com/87&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 포스팅(도메인 모델을 리팩토링)&lt;/a&gt;에서 리팩토링한 결과로 `편지`와 `데일리 리포트`와 연관 관계를 아래 그림처럼 갖고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1078&quot; data-origin-height=&quot;222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WA8FW/btsL976cULG/6iwCiRTGMmfkEhrqdrFbvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WA8FW/btsL976cULG/6iwCiRTGMmfkEhrqdrFbvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WA8FW/btsL976cULG/6iwCiRTGMmfkEhrqdrFbvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWA8FW%2FbtsL976cULG%2F6iwCiRTGMmfkEhrqdrFbvk%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;1078&quot; height=&quot;222&quot; data-origin-width=&quot;1078&quot; data-origin-height=&quot;222&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LetterAnalysis는 연관 관계의 주인으로, 연관된 편지와 데일리 리포트를 알고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트는 DDD(Domain Driven Design)로 설계되진 않았지만, 엔티티 간 단방향 연관 관계는 모두 LetterAnalysis가 갖고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;696&quot; data-origin-height=&quot;239&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chDXWx/btsL86tmey0/EHY9OMKUb4VIZrn595Xduk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chDXWx/btsL86tmey0/EHY9OMKUb4VIZrn595Xduk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chDXWx/btsL86tmey0/EHY9OMKUb4VIZrn595Xduk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchDXWx%2FbtsL86tmey0%2FEHY9OMKUb4VIZrn595Xduk%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;650&quot; height=&quot;223&quot; data-origin-width=&quot;696&quot; data-origin-height=&quot;239&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림은 프로젝트에서 생성형 AI가 편지들을 분석한 결과를 이용해 감정 분석(LetterAnalysis)과 데일리 리포트(DailyReport) 엔티티가 생성되는 흐름을 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 종합하면, 편지 분석(LetterAnalysis) 엔티티의 책임은 아래로 정의할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;편지 분석(LetterAnalysis)의 책임들&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;`하루치 편지들(DailyLetters)`을 통해 감정 분석과 요약 정보를 생성할 책임이 있다.&lt;/li&gt;
&lt;li&gt;단방향 연관 관계에서 데일리 리포트(DailyReport)는 단독으로 영속화될 수 없다.&lt;br /&gt;따라서 편지 분석(LetterAnalysis)이 영속화(persist) 될 때 함께 영속화할 책임이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 전체 협력 관계 수준에서 정리하면 아래 그림과 같이 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;793&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYitN6/btsL8MI1k9h/Cs8ErOhGhUuioeOInAo2L1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYitN6/btsL8MI1k9h/Cs8ErOhGhUuioeOInAo2L1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYitN6/btsL8MI1k9h/Cs8ErOhGhUuioeOInAo2L1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYitN6%2FbtsL8MI1k9h%2FCs8ErOhGhUuioeOInAo2L1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1668&quot; height=&quot;793&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;793&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 바탕으로 `편지 분석 서비스(LetterAnalysisService)`는 &quot;편지들을 분석하라&quot;라는 메시지와 &quot;편지 분석 결과와 데일리 리포트를 함께 저장하라&quot;라는 메시지를 수신할 인터페이스가 필요한데요, 아래와 같이 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;LetterAnalysisService&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738840701727&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 LetterAnalysisService {

    private final ClovaService clovaService;
    private final DailyReportPromptTemplate promptTemplate;
    private final LetterAnalysisRepository letterAnalysisRepository;

    /**
     * '하루치 편지들'에 대한 감정 분석 생성 요청을 클로바 서비스에게 위입합니다.
     *
     * @param dailyLetters 분석에 사용될 하루치 편지들
     * @return 편지들에 대한 분석 결과
     */
    public DailyAnalysisResult createDailyAnalysis(DailyLetters dailyLetters) {
        CreateResponse createResponse = clovaService.sendWithPromptTemplate(promptTemplate, dailyLetters.getMessages());

        return DailyAnalysis.extract(createResponse);
    }

    /**
     * 분석에 사용된 편지들과 그에 대응하는 분석 결과를 매핑하고, 데이터 액세스 계층에 저장을 위임합니다.
     *
     * @param dailyLetters        분석에 사용될 하루치 편지들
     * @param dailyAnalysisResult 편지들에 대한 분석 결과
     */
    @Transactional
    public void saveAnalysisAndDailyReport(DailyLetters dailyLetters, DailyAnalysisResult dailyAnalysisResult) {
        // 데일리 리포트 엔티티 생성
        DailyReport dailyReport = DailyReport.builder()
                .coreEmotion(dailyAnalysisResult.getDailyCoreEmotion())
                .description(dailyAnalysisResult.getDescription())
                .targetDate(dailyLetters.getCreatedDate())
                .build();

        // 감정 분석 추출
        List&amp;lt;EmotionAnalysis&amp;gt; emotionAnalyses = dailyAnalysisResult.getEmotionAnalyses();

        // 편지 분석 엔티티 (연관 관계 주인: cascade.PERSIST)
        List&amp;lt;LetterAnalysis&amp;gt; letterAnalyses = emotionAnalyses.stream()
                .map(emotionAnalysis -&amp;gt; LetterAnalysis.builder()
                        .letter(dailyLetters.getLetter(emotionAnalysis.getSequence()))
                        .dailyReport(dailyReport)
                        .topic(emotionAnalysis.getTopic())
                        .coreEmotions(emotionAnalysis.getCoreEmotions())
                        .sensitiveEmotions(emotionAnalysis.getSensitiveEmotions())
                        .build())
                .toList();

        letterAnalysisRepository.saveAll(letterAnalyses);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 눈여겨봐야 할 부분은 외부 API를 호출하는 부분(`createDailyAnalysis()`)과 결과를 저장하는 부분(`saveAnalysisAndDailyReport()`)으로 나뉘었다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API를 분리하는 이유는&amp;nbsp;&lt;a href=&quot;https://dev-gallery.tistory.com/81&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 포스팅&lt;/a&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;아래 그림은 문제가 되는 외부 API 호출하는 부분을 트랜잭션에서 분리하기 위해서 `외부 API를 호출하는 부분`과 `결과를 저장하는 부분`을 나눈 것을 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;897&quot; data-origin-height=&quot;510&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/barWv3/btsL93iudJp/wlLkPAgphpiWafvGlJ03Y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/barWv3/btsL93iudJp/wlLkPAgphpiWafvGlJ03Y1/img.png&quot; data-alt=&quot;트랜잭션에서 외부 API 호출을 분리해 잠재적 위험을 제거한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/barWv3/btsL93iudJp/wlLkPAgphpiWafvGlJ03Y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbarWv3%2FbtsL93iudJp%2FwlLkPAgphpiWafvGlJ03Y1%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;897&quot; height=&quot;510&quot; data-origin-width=&quot;897&quot; data-origin-height=&quot;510&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;트랜잭션에서 외부 API 호출을 분리해 잠재적 위험을 제거한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;데일리 리포트 서비스의 책임&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 편지들에 대한 감정 분석을 생성하고, 저장하는 책임은 `편지 분석 서비스(LetterAnalysisService)`에게 재할당 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev-gallery.tistory.com/88&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 포스팅&lt;/a&gt;에 이어서 `위클리 리포트`를 생성하기 위한 `데일리 리포트를 사전 생성` 책임은 편지 분석 서비스에게 다시 메시지를 전달하도록 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;AS-IS (TL;DR)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738853311535&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void createDailyReportsBy(UUID userId, LocalDate startDate, LocalDate endDate) {
    // 주간분석을 요청한 기간 동안 사용자가 작성한 편지들 찾기
    List&amp;lt;Letter&amp;gt; userLettersByLatest = letterRepository.findByCreatedAtDesc(userId,
                startDate.atStartOfDay(),
                LocalDateTime.of(endDate, LocalTime.MAX));
                
    // 날짜별로 편지들을 3개씩 묶기
    Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt; latestLettersByDate = userLettersByLatest.stream()
            .collect(Collectors.groupingBy(
                    letter -&amp;gt; letter.getCreatedAt().toLocalDate()));
                    
    // 이미 일일 분석이 생성된 날짜는 제거
    latestLettersByDate.values().removeIf(
            letters -&amp;gt; letters.stream().anyMatch(letter -&amp;gt; letter.getDailyReport() != null)
    );
    
    // 일일 분석을 생성하려는 편지들을 날짜당 3개로 제한
    Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt; latestThreeLettersByDate = latestLettersByDate.entrySet().stream()
            .collect(Collectors.toMap(
                    Entry::getKey,
                    entry -&amp;gt; entry.getValue().stream()
                            .limit(3)
                            .collect(Collectors.toList())
            ));
    
    // 편지 3개에 대한 분석을 Clova에게 요청해서 받은 결과물들
    Map&amp;lt;DailyAnalysisResult, List&amp;lt;Letter&amp;gt;&amp;gt; lettersByAnalysisResult = analyzableLettersByDate.values().stream()
            .collect(Collectors.toMap(
                    letters -&amp;gt; DailyAnalysisExtractor.extract(requestClovaAnalysis(letters)),
                    letters -&amp;gt; letters
            ));
            
    // 분석결과와 편지들을 가지고 데일리 리포트 생성
    Map&amp;lt;DailyReport, List&amp;lt;Letter&amp;gt;&amp;gt; lettersByDailyReport = lettersByAnalysisResult.entrySet().stream()
            .collect(Collectors.toMap(
                    entry -&amp;gt; buildDailyReport(entry.getValue().get(0).getCreatedAt().toLocalDate(), entry.getKey()),
                    Entry::getValue
            ));
    dailyReportRepository.saveAll(lettersByDailyReport.keySet());

    // 편지들에 알맞는 데일리 리포트를 setter 주입
    lettersByDailyReport.forEach((key, value) -&amp;gt;
            value.forEach(
                    letter -&amp;gt; letter.setDailyReport(key)
            ));

    // 편지와 분석결과를 가지고 편지분석엔티티들 생성 및 저장
    List&amp;lt;LetterAnalysis&amp;gt; letterAnalyses = lettersByAnalysisResult.entrySet().stream()
            .flatMap(entry -&amp;gt; buildLetterAnalyses(entry.getValue(), entry.getKey()).stream())
            .toList();
    letterAnalysisRepository.saveAll(letterAnalyses);
}&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;이전 코드는 `편지 저장소(letter repository)`를 데일리 리포트 서비스가 직접 제어합니다.&lt;/li&gt;
&lt;li&gt;`하루치 편지들`을 Stream API를 통해 직접 분류하며, 편지 분석(LetterAnalysis)과 데일리 리포트(DailyReport)의 영속화를 직접 다룹니다.&lt;/li&gt;
&lt;li&gt;`편지 저장소(letter repository)`, `편지 분석 저장소(letter analysis repository)`, `데일리 리포트 저장소(daily report repository)`와 강한 결합을 갖고 있습니다.&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;size18&quot;&gt;&lt;b&gt;TO-BE&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738853324523&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void createDailyReportsBy(UUID userId, LocalDate startDate, LocalDate endDate) {
    // 편지 서비스에게 `분석 가능한 편지들` 찾기 위임
    List&amp;lt;DailyLetters&amp;gt; analyzableLetters = letterService.findAnalyzableLetters(userId, startDate, endDate);

    for (DailyLetters dailyLetters : analyzableLetters) {
        // 외부 API 호출
        CreateResponse createResponse = clovaService.sendWithPromptTemplate(promptTemplate, dailyLetters.getMessages());

        // 응답 결과로부터 하루치 분석 추출
        DailyAnalysisResult analysisResult = DailyAnalysisExtractor.extract(createResponse);

        // 트랜잭션 안에서 하루치 분석으로부터 편지 분석, 데일리 리포트 엔티티 저장
        letterAnalysisService.saveAnalysisAndDailyReport(dailyLetters, analysisResult);
    }
}&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;&quot;묻지 말고 시켜라&quot;를 적용한 코드입니다.&lt;br /&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;추가 문제 (동기적인 외부 API 호출)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 해결되지 않은 문제가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 반복문(for loop) 안에서 외부 API를 호출하는 부분인데요,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는 FeignClient를 통해 동기적으로 HTTP 요청을 보냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청으로부터 응답은 평균 7초 정도인데, 7일 치 편지 묶음을 보내면 응답을 받기까지 1분이 넘게 걸리는 문제가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;351&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AAwX5/btsL9otVRvL/MwOgaymCku52XFpOKGy00K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AAwX5/btsL9otVRvL/MwOgaymCku52XFpOKGy00K/img.png&quot; data-alt=&quot;응답까지 1분 5.64초가 걸리는 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AAwX5/btsL9otVRvL/MwOgaymCku52XFpOKGy00K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAAwX5%2FbtsL9otVRvL%2FMwOgaymCku52XFpOKGy00K%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;868&quot; height=&quot;351&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;351&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;응답까지 1분 5.64초가 걸리는 문제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 사용자의 서비스 경험을 저해하는 요소이며, 사용 중인 nginx ingress의 리드 타임이 60초로 되어있어 커넥션이 끊어지지 않도록 인프라 설정까지 수정해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 포스팅에선 동기적인 HTTP 요청을 비동기적으로 보내 이 문제를 해결하는 과정을 다룹니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&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;`하루치 편지들(DailyLetters)`이라는 타입을 정의하고, 각 객체에게 책임을 재할당해 메시지 처리를 위임하는 방식으로 코드를 개선했습니다. 또한, 트랜잭션과 외부 API 호출을 분리하여 코드의 안정성과 유지보수성을 높이는 작업도 진행했습니다.&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-sourcepos=&quot;15:1-19:0&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;15:1-15:39&quot;&gt;책임 주도 설계는 코드의 응집도를 높이고 결합도를 낮춰준다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;16:1-16:52&quot;&gt;&quot;묻지 말고 시켜라&quot; 원칙은 객체 간의 협력을 증진시키고 코드의 유연성을 높여준다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;17:1-17:54&quot;&gt;트랜잭션과 외부 API 호출을 분리하면 코드의 안정성과 유지보수성을 향상할 수 있다.&lt;/li&gt;
&lt;/ul&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;다음 포스팅에선 동기적으로 요청하는 외부 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;긴 글 읽어주셔서 감사합니다.&lt;/p&gt;</description>
      <category>Project</category>
      <category>OOP</category>
      <category>리팩토링</category>
      <category>비사이드</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/89</guid>
      <comments>https://dev-gallery.tistory.com/89#entry89comment</comments>
      <pubDate>Fri, 7 Feb 2025 00:28:05 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트 리팩토링 (3) - 책임 재할당과 실행 계획 분석으로 검증하기</title>
      <link>https://dev-gallery.tistory.com/88</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;간략한 위클리 리포트 서비스 소개&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cXdv3Y/btsL75zOPM3/1bkMue5oKuBrAqiRVFlQqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cXdv3Y/btsL75zOPM3/1bkMue5oKuBrAqiRVFlQqk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cXdv3Y/btsL75zOPM3/1bkMue5oKuBrAqiRVFlQqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcXdv3Y%2FbtsL75zOPM3%2F1bkMue5oKuBrAqiRVFlQqk%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;485&quot; height=&quot;232&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위클리 리포트 서비스는 이름대로 한 주 동안 유저가 작성한 편지(또는 일기)에 대한 분석을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 정책 안에서 왕성히 활동한 유저의 7일 치 글을 모두 합치면 &lt;b&gt;최대 16만 글자&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;16만 글자&lt;/b&gt;를 생성형 AI에게 전달하는 것은 &lt;i&gt;비용 문제&lt;/i&gt;와 &lt;i&gt;서비스 품질 문제&lt;/i&gt;로 이어질 수 있기 때문에 `데일리 리포트`를 이용하는데요, `데일리 리포트`에 하루치가 요약되어 있는 내용을 이용해 `위클리 리포트`를 생성합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;서비스 정책 간략 소개&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`위클리 리포트`는 절대적으로 `데일리 리포트`에 의존하기 때문에 반드시 `데일리 리포트`가 존재해야 합니다.&lt;/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;그리고 `분석 가능한 편지들`을 골라내는데&amp;nbsp;몇 가지 &lt;b&gt;제약&lt;/b&gt;을 간략하게 소개 합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;`분석 가능한 편지들` 관련 제약 조건:&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;매 일자마다 최신 글 3개만&lt;/b&gt; `분석 가능한 편지들`에 포함된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt; 데일리 리포트가 생성된 일자&lt;/b&gt;에 작성된 &lt;b&gt;모든 편지들&lt;/b&gt;은 (분석되지 않은 편지가 일부 있더라도) `분석 가능한 편지들`에서 &lt;b&gt;제외된다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;위클리 리포트 생성 과정 정리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 `위클리 리포트`를 생성하는 과정을 정리한 그림입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1495&quot; data-origin-height=&quot;730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/N4t0N/btsL6qk1367/lA8J8qqBK7OFTXoK2sKxAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/N4t0N/btsL6qk1367/lA8J8qqBK7OFTXoK2sKxAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/N4t0N/btsL6qk1367/lA8J8qqBK7OFTXoK2sKxAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FN4t0N%2FbtsL6qk1367%2FlA8J8qqBK7OFTXoK2sKxAk%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;1495&quot; height=&quot;730&quot; data-origin-width=&quot;1495&quot; data-origin-height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정은 모두 하나의 거대한 메서드 안에서 실행되며, 이를 전문 용어로 &lt;i&gt;몬스터 메서드&lt;/i&gt;라고 하죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 &lt;i&gt;몬스터 메서드&lt;/i&gt;가 야기하는 수많은 문제가 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 발단: 협력 관계에서 잘못 할당된 책임&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;몬스터 메서드들 (전체)&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1633&quot; data-origin-height=&quot;910&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bG2X9G/btsL4aQwJdK/Ws0KI31xNFiRlC9lIsns3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bG2X9G/btsL4aQwJdK/Ws0KI31xNFiRlC9lIsns3k/img.png&quot; data-alt=&quot;몬스터 메서드를 호출하는 몬스터 메서드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bG2X9G/btsL4aQwJdK/Ws0KI31xNFiRlC9lIsns3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbG2X9G%2FbtsL4aQwJdK%2FWs0KI31xNFiRlC9lIsns3k%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;1633&quot; height=&quot;910&quot; data-origin-width=&quot;1633&quot; data-origin-height=&quot;910&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;몬스터 메서드를 호출하는 몬스터 메서드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노란색 박스만 보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대충 보더라도 오른쪽 일부 메서드를 호출하는 로직이 왼쪽의 몬스터 메서드를 호출합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;호출되는 몬스터 메서드 (일부)&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1634&quot; data-origin-height=&quot;1063&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQ4AQd/btsL4LDmjiN/rxfyZInj6Lbm3abh4oBo60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQ4AQd/btsL4LDmjiN/rxfyZInj6Lbm3abh4oBo60/img.png&quot; data-alt=&quot;호출되는 몬스터 메서드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQ4AQd/btsL4LDmjiN/rxfyZInj6Lbm3abh4oBo60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQ4AQd%2FbtsL4LDmjiN%2FrxfyZInj6Lbm3abh4oBo60%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;1634&quot; height=&quot;1063&quot; data-origin-width=&quot;1634&quot; data-origin-height=&quot;1063&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;호출되는 몬스터 메서드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출되는 데일리 리포트 서비스의 메서드입니다. (전체 그림에서 왼쪽 부분)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 로직을 나누면 크게 3가지로 나눌 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`분석 가능한(=데일리 리포트 생성 가능한) 편지들` 찾기&lt;/li&gt;
&lt;li&gt;찾은 편지들로 `데일리 리포트` 생성하기 (외부 API 호출)&lt;/li&gt;
&lt;li&gt;`데일리 리포트` DB 저장하기&lt;/li&gt;
&lt;/ol&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;문제 1. `데일리 리포트 서비스`가 `분석 가능한 편지들`을 찾는 책임을 집니다. SRP를 준수하지 못하고 있습니다.&lt;/li&gt;
&lt;li&gt;문제 2. 트랜잭션 내부에 &lt;b&gt;외부 API를 호출&lt;/b&gt;하는 로직이 존재해 SRP를 준수하지 못할뿐더러 잠재적인 문제를 야기합니다.&lt;/li&gt;
&lt;li&gt;문제 3. 전체적으로 코드의 가독성이 떨어져 &lt;b&gt;유지 보수하기가 어렵습니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 서비스들에게 &lt;b&gt;잘못 할당된 책임&lt;/b&gt; 때문에 유지 보수하기가 어려웠던 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덤으로 전체적인 코드의 가독성이 떨어져 &lt;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;을 집중적으로 다루며, 외부 API를 호출하는 로직을 다루는 문제는 다음 포스팅에서 다루니 참고 부탁드립니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;문제 해결하기: 책임 재할당&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데일리 리포트 서비스는 어떤 책임을 갖고 있을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 캡슐화를 하기 전에 &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;&lt;b&gt;협력 관계:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`위클리 리포트 서비스`는  `위클리 리포트`를 생성하기 위해 한 주 동안의 `데일리 리포트`가 필요하다.&lt;br /&gt;따라서 `데일리 리포트` 관련&amp;nbsp;&lt;i&gt;정보 전문가&lt;/i&gt;인 `데일리 리포트 서비스`와 협력해야 한다.&lt;/li&gt;
&lt;li&gt;`데일리 리포트 서비스`는 `데일리 리포트`를 생성하기 위해 `분석 가능한 편지들`이 필요하다.&lt;br /&gt;따라서 `편지` 관련&amp;nbsp;&lt;i&gt;정보 전문가&lt;/i&gt;인 `편지 서비스`와 협력해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;책임:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`위클리 리포트 서비스`: 일주일 치 `데일리 리포트`를 통해 `위클리 리포트`를 생성할 책임이 있다.&lt;/li&gt;
&lt;li&gt;`데일리 리포트 서비스`: 일주일 치 `데일리 리포트`를 사전에 생성할 책임이 있다.&lt;/li&gt;
&lt;li&gt;`편지 서비스`: 일주일 치 `분석 가능한 편지들`을 찾을 책임이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메시지:&lt;/b&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1. `분석 가능한 편지들` 반환을 요청하는 메시지는 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;`편지 서비스` 객체를 선택한다.&lt;/span&gt;&lt;br /&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: #333333; text-align: left;&quot;&gt;&amp;rarr; `편지 서비스`는 `분석 가능한 편지들`을 반환하는 메시지를 수신한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;2. `데일리 리포트` 반환을 요청하는 메시지는 `데일리 리포트 서비스` 객체를 선택한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;rarr; `데일리 리포트 서비스`는 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;`데일리 리포트`를&lt;span&gt; 반환하는 메시지를 수신한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;책임 재할당하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데일리 리포트 서비스에 있던 코드는 아래와 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주석 포함해서 20줄이 넘으니 읽을 필요는 없고, `데일리 리포트 서비스`에 `편지 서비스`의 책임이 할당되어 있는다는 것만 봐주시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;TL;DR)&lt;span&gt; 데일리 리포트 서비스 코드&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738600360945&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void createDailyReportsBy(UUID userId, LocalDate startDate, LocalDate endDate) {
        // 주간분석을 요청한 기간 동안 사용자가 작성한 편지들 찾기
        List&amp;lt;Letter&amp;gt; userLettersByLatest = letterRepository.findByCreatedAtDesc(userId,
                startDate.atStartOfDay(),
                LocalDateTime.of(endDate, LocalTime.MAX));

        // 날짜별로 편지들을 3개씩 묶기
        Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt; latestLettersByDate = userLettersByLatest.stream()
                .collect(Collectors.groupingBy(
                        letter -&amp;gt; letter.getCreatedAt().toLocalDate()));

        // 이미 일일 분석이 생성된 날짜는 제거
        latestLettersByDate.values().removeIf(
                letters -&amp;gt; letters.stream().anyMatch(letter -&amp;gt; letter.getDailyReport() != null)
        );

        // 일일 분석을 생성하려는 편지들을 날짜당 3개로 제한
        Map&amp;lt;LocalDate, List&amp;lt;Letter&amp;gt;&amp;gt; latestThreeLettersByDate = latestLettersByDate.entrySet().stream()
                .collect(Collectors.toMap(
                        Entry::getKey,
                        entry -&amp;gt; entry.getValue().stream()
                                .limit(3)
                                .collect(Collectors.toList())
                ));
        // 생략 ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`분석 가능한 편지들`을 찾는 이 로직을 `편지 서비스`에게 재할당해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이 코드를 `편지 서비스(LetterService)`에게 재할당하되, 실제 구현은 `편지 저장소(LetterRepository)`에서 네이티브 쿼리로 구현하겠습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;편지 서비스 (LetterService)&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738602008518&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
public List&amp;lt;Letter&amp;gt; findAnalyzableLetters(UUID userId, LocalDate startDate, LocalDate endDate) {
    return letterRepository.findAnalyzableLetters(userId, toStartDateTime(startDate), toEndDateTime(endDate));
}

private LocalDateTime toStartDateTime(LocalDate date) {
    return LocalDateTime.of(date, LocalTime.MIN);
}

private LocalDateTime toEndDateTime(LocalDate date) {
    return LocalDateTime.of(date, LocalTime.MAX);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;편지 저장소 (LetterRepository)&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738602039093&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(value = &quot;&quot;&quot;
        SELECT letter_id, user_id, created_at, message, preference, published, like_f, like_t
        FROM (
          SELECT *, ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY created_at DESC) AS row_num
          FROM letter
          WHERE user_id = :userId
            AND created_at BETWEEN :start AND :end
            AND DATE(created_at) NOT IN (
              SELECT DISTINCT DATE(l.created_at)
              FROM letter l
              JOIN letter_analysis la ON l.letter_id = la.letter_id
              WHERE l.user_id = :userId
                AND l.created_at BETWEEN :start AND :end
          )) AS analyzable_letters
        WHERE analyzable_letters.row_num &amp;lt;= 3
        ORDER BY created_at
        &quot;&quot;&quot;, nativeQuery = true)
List&amp;lt;Letter&amp;gt; findAnalyzableLetters(UUID userId, LocalDateTime startDate, LocalDateTime endDate);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;책임이 재할당된 데일리 리포트 서비스 (DailyReportService)&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738602399953&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void createDailyReportsBy(UUID userId, LocalDate startDate, LocalDate endDate) {
    // 편지 서비스에게 `분석 가능한 편지들` 찾기 위임
    List&amp;lt;Letter&amp;gt; analyzableLetters = letterService.findAnalyzableLetters(userId, startDate, endDate);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;659&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SPTRU/btsL4NnGkcW/nw0pXFrlrEAvo9KcCMafSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SPTRU/btsL4NnGkcW/nw0pXFrlrEAvo9KcCMafSK/img.png&quot; data-alt=&quot;편지 서비스에게 위임한 책임&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SPTRU/btsL4NnGkcW/nw0pXFrlrEAvo9KcCMafSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSPTRU%2FbtsL4NnGkcW%2Fnw0pXFrlrEAvo9KcCMafSK%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;1876&quot; height=&quot;659&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;659&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;편지 서비스에게 위임한 책임&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책임을 올바르게 재할당하는 것만으로 20줄이 넘는 코드가 `편지 서비스`로, 그 구현은 다시 `편지 저장소`로 위임되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 남은 것은 `분석 가능한 편지들`을 찾는 로직을 검증해야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;검증: 실행 계획 분석&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는  MySQL 8.0 버전을 사용 중입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상의 시나리오를 만들어 작성한 쿼리가 최적화된 쿼리인지 검증하기 위해 실행 계획을 분석해 보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;가상 시나리오&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1126&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zFfcH/btsL5uhwtif/urmeD6aAVVejvtSypkRbr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zFfcH/btsL5uhwtif/urmeD6aAVVejvtSypkRbr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zFfcH/btsL5uhwtif/urmeD6aAVVejvtSypkRbr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzFfcH%2FbtsL5uhwtif%2FurmeD6aAVVejvtSypkRbr0%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;1126&quot; height=&quot;416&quot; data-origin-width=&quot;1126&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같은 시나리오를 작성했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저(User)는 100명&lt;/li&gt;
&lt;li&gt;100명의 모든 유저는 하루에 1개의 편지를 작성, 10년 동안 지속&lt;br /&gt;&amp;rArr; 편지 테이블(letter) 레코드 수는 365,000건 (100명 * 365일 * 10년)&lt;/li&gt;
&lt;li&gt;매월 첫 주만 `데일리 리포트`를 생성 (7일 치)&lt;/li&gt;
&lt;li&gt;`분석 가능한 편지들`을 검증하기 위한 추가 레코드 (4건, 테스트 유저 1명 대상)
&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;b&gt;분석되지 않은 편지&lt;/b&gt;가 1건 추가로 존재 (2024-01-04 1건 추가)&lt;br /&gt;&amp;rarr; &lt;b&gt;데일리 리포트가 생성된 일자의 편지들은&lt;/b&gt; &lt;b&gt;더 이상 분석 대상이 되지 않음&lt;/b&gt;을 검증하기 위함&lt;/li&gt;
&lt;li&gt;`분석 가능한 편지들`을 찾는 기간에 3건 더 작성된  일자가 포함 (2024-01-08 3건 추가)&lt;br /&gt;&amp;rarr; 하루에 작성된 편지들 중 &lt;b&gt;최신 편지 3개만 분석 대상&lt;/b&gt;이 됨을 검증하기 위함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;따라서 전체 레코드 수는 365,000 + 4건으로 365,004건입니다.&lt;/li&gt;
&lt;li&gt;`분석 가능한 편지` 목표 개수: &lt;b&gt;7건&lt;/b&gt;&lt;br /&gt;`분석 가능한 편지들`을 찾는 기간은 `2024-01-04 - 2024-01-11 (8일)`이며, `2024-01-01 - 2024-01-07` 기간은 첫 주이기 때문에 이미 데일리 리포트가 생성되어 있습니다. 따라서 목표 개수는 총 7건입니다. (`2024-01-08 3건`, `2024-01-09 1건`, `2024-01-10 1건`, `2024-01-11 1건`)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;`분석 가능한 편지들`을 찾는 쿼리&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1738603281206&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT letter_id, user_id, message, preference, published, created_at
FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY created_at DESC) AS row_num
  FROM letter
  WHERE user_id = :userId
    AND created_at BETWEEN :startDate AND :endDate
    AND DATE(created_at) NOT IN (
      SELECT DISTINCT DATE(l.created_at)
      FROM letter l
      JOIN letter_analysis la ON l.letter_id = la.letter_id
      WHERE l.user_id = :userId
        AND l.created_at BETWEEN :startDate AND :endDate
  )) AS analyzable_letters
WHERE analyzable_letters.row_num &amp;lt;= 3
ORDER BY created_at&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;이 쿼리를 쉽게 이해하기 위해 가장 바깥쪽의 FROM 절부터 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;바깥쪽의 FROM 절&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738603521843&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *, ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY created_at DESC) AS row_num
FROM letter
WHERE user_id = :userId
AND created_at BETWEEN :startDate AND :endDate
AND DATE(created_at) NOT IN (
  SELECT DISTINCT DATE(l.created_at)
  FROM letter l
  JOIN letter_analysis la ON l.letter_id = la.letter_id
  WHERE l.user_id = :userId
    AND l.created_at BETWEEN :startDate AND :endDate
)&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;letter 테이블을 읽을 때 WHERE 절로 필터링을 합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WHERE 절 3번째 조건: &lt;b&gt;서브쿼리&lt;/b&gt;를 사용합니다. (`NOT IN`)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조건으로 사용된 서브쿼리: 조건으로 사용된(7일 치) 분석된 편지가 &lt;b&gt;작성된 일자&lt;/b&gt;를 중복 없이 읽습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;정리하면 날짜 조건(`BETWEEN`)에 따라 편지들을 읽되, 데일리 리포트가 생성된 &lt;b&gt;일자&lt;/b&gt;를 제외합니다.&lt;/li&gt;
&lt;li&gt;SELECT 절에서 MySQL에서 제공하는 `ROW_NUMBER()` 윈도우 함수를 이용해 DATETIME을 DATE로 변환한 일자를 기준으로 나누고(`PARTITION BY`), 해당 일자를 기준으로 내림차순 정렬(`ORDER BY DESC`)을 통해 최신 편지 순으로 정렬합니다.&lt;br /&gt;이후, 레코드마다 번호(row_num)를 부여합니다. 이 레코드 번호는 가장 바깥쪽 쿼리에서 최신 레코드 3개를 찾을 때(`WHERE row_num &amp;lt;= 3`) 사용됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;`NOT IN` 서브쿼리 실행 계획 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 쿼리에서 조건절로 사용되는 서브쿼리의 실행 계획을 분석해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 안티 세미 조인이라 불리는 `NOT IN`을 사용하는 서브쿼리는 Not-Equal 비교처럼(`&amp;lt;&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;MySQL 옵티마이저는 이 경우 `NOT EXISTS` 또는 `Materialization`을 통해 최적화를 수행하지만 단독으로 사용된 NOT IN 서브쿼리는 풀 테이블 스캔을 피할 수 없기 때문에 주의해야 합니다. &lt;i&gt;(Real MySQL 8.0 2권 - p.114)&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 NOT IN 서브쿼리가 들어있는 전체 쿼리에 대해 실행 계획을 분석해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지 테이블(letter)`에는 PK, FK 같이 자동으로 생성되는 인덱스와 `감정 분석 테이블(letter_analysis)`의 유니크 키를 제외하고 어떤 인덱스도 생성하지 않았습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;분석 대상 쿼리 (NOT IN 서브쿼리)&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738605000784&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;EXPLAIN ANALYZE
SELECT *, ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY created_at DESC) AS row_num
FROM letter
WHERE user_id = :userId
AND created_at BETWEEN :start AND :end
AND DATE(created_at) NOT IN (
  SELECT DISTINCT DATE(l.created_at)
  FROM letter l
  JOIN letter_analysis la ON l.letter_id = la.letter_id
  WHERE l.user_id = :userId
    AND l.created_at BETWEEN :start AND :end)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;실행 계획&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738605726816&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(13)-&amp;gt; Window aggregate: row_number() OVER (PARTITION BY `cast(letter.created_at as date)` ORDER BY letter.created_at desc )   (actual time=101..101 rows=7 loops=1)
    (12)-&amp;gt; Sort: `cast(letter.created_at as date)`, letter.created_at DESC  (cost=954 rows=3654) (actual time=101..101 rows=7 loops=1)
       (11) -&amp;gt; Filter: ((letter.created_at between '2024-01-04 00:00:00' and '2024-01-11 00:00:00') and &amp;lt;in_optimizer&amp;gt;(cast(letter.created_at as date),cast(letter.created_at as date) in (select #2) is false))  (cost=954 rows=3654) (actual time=90.8..101 rows=7 loops=1)
           (10) -&amp;gt; Index lookup on letter using fk_letter_user (user_id=uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')), with index condition: (letter.user_id = &amp;lt;cache&amp;gt;(uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')))  (cost=954 rows=3654) (actual time=6.51..14.8 rows=3654 loops=1)
            (9) -&amp;gt; Select #2 (subquery in condition; run only once)
                (8) -&amp;gt; Filter: ((cast(letter.created_at as date) = `&amp;lt;materialized_subquery&amp;gt;`.`DATE(l.created_at)`))  (cost=1137..1137 rows=1) (actual time=8.39..8.39 rows=0.4 loops=10)
                    (7) -&amp;gt; Limit: 1 row(s)  (cost=1137..1137 rows=1) (actual time=8.39..8.39 rows=0.4 loops=10)
                        (6) -&amp;gt; Index lookup on &amp;lt;materialized_subquery&amp;gt; using &amp;lt;auto_distinct_key&amp;gt; (DATE(l.created_at)=cast(letter.created_at as date))  (actual time=8.39..8.39 rows=0.4 loops=10)
                            (5) -&amp;gt; Materialize with deduplication  (cost=1137..1137 rows=406) (actual time=83.9..83.9 rows=4 loops=1)
                                (4) -&amp;gt; Nested loop inner join  (cost=1096 rows=406) (actual time=0.42..83.9 rows=4 loops=1)
                                    (2) -&amp;gt; Filter: (l.created_at between '2024-01-04 00:00:00' and '2024-01-11 00:00:00')  (cost=954 rows=406) (actual time=0.312..83.7 rows=12 loops=1)
                                        (1) -&amp;gt; Index lookup on l using fk_letter_user (user_id=uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')), with index condition: (l.user_id = &amp;lt;cache&amp;gt;(uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')))  (cost=954 rows=3654) (actual time=0.307..81.8 rows=3654 loops=1)
                                    (3) -&amp;gt; Single-row covering index lookup on la using uk_letter_analysis_letter (letter_id=l.letter_id)  (cost=0.25 rows=1) (actual time=0.014..0.014 rows=0.333 loops=12)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 이 실행 계획은 &lt;b&gt;특정 유저(user_id)&lt;/b&gt;의 letter 테이블에서 &lt;b&gt;특정 기간(2024-01-04 ~ 2024-01-11)&lt;/b&gt; 동안 작성된 레코드를 조회하고, &lt;b&gt;날짜별(row_number() PARTITION BY created_at)&lt;/b&gt;로 최신(created_at DESC) 1개씩을 제외한 결과를 가져오는 쿼리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 계획을 읽는 순서에 번호를 붙였으니 하나씩 읽어보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(1) letter 테이블에서 user_id로 필터링&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738606223254&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(1) -&amp;gt; Index lookup on l using fk_letter_user (user_id=uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')), with index condition: (l.user_id = &amp;lt;cache&amp;gt;(uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')))  (cost=954 rows=3654) (actual time=0.307..81.8 rows=3654 loops=1)&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;letter 테이블에 있는 유저 아이디(user_id)에 대한 외래키인 `fk_letter_user` 인덱스를 이용해 읽었습니다.&lt;br /&gt;이때 `with index condition`인 것을 보아&amp;nbsp;&lt;i&gt;인덱스 컨디션 푸시다운(index condition pushdown)&lt;/i&gt; 기능을 이용해 효율적으로 읽었습니다. (인덱스 컨디션 푸시다운: MySQL 엔진에서 스토리지 엔진으로 인덱스를 전달해서 스토리지 엔진이 불필요한 디스크 읽기(I/O)를 줄이는 기능)&lt;/li&gt;
&lt;li&gt;읽어야 하는 예상 레코드 수는 3,654건입니다.&lt;br /&gt;(전체 레코드 건수가 365,004건(=100*100*365 + 4; 100명이 하루에 100건씩 10년 치 + 해당 유저에 대한 테스트를 위한 레코드 4건)이며, 테스트 유저로 필터링하면 3,654건이 됩니다.)&lt;/li&gt;
&lt;li&gt;실제 읽은 레코드 수도 3,654건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(2) 날짜 범위 필터링&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738606471596&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(2) -&amp;gt; Filter: (l.created_at between '2024-01-04 00:00:00' and '2024-01-11 00:00:00')  (cost=954 rows=406) (actual time=0.312..83.7 rows=12 loops=1)&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;조회된 편지들을 2024년 1월 4일부터 1월 11일 사이로 필터링합니다.&lt;br /&gt;(`created_at BETWEEN '2024-01-04 00:00:00' AND '2024-01-11 00:00:00'`)&lt;/li&gt;
&lt;li&gt;필터링될 예상 레코드 수는 406건입니다.&lt;/li&gt;
&lt;li&gt;실제로 필터링된 레코드는 12건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(3) letter_analysis(감정 분석) 테이블 조회&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738607421380&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(3) -&amp;gt; Single-row covering index lookup on la using uk_letter_analysis_letter (letter_id=l.letter_id)  (cost=0.25 rows=1) (actual time=0.014..0.014 rows=0.333 loops=12)&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;커버링 인덱스(인덱스를 활용해 디스크 읽기를 하지 않음)를 이용해 letter_analysis(감정 분석) 테이블을 조회합니다.&lt;br /&gt;(인덱스는 letter_analysis 테이블의 유니크 키 인덱스(`uk_letter_analysis_letter`)입니다.)&lt;/li&gt;
&lt;li&gt;이때, `letter_id=l.letter_id` 조건은 프라이머리 키를 이용하기 때문에 테이블을 읽을 때 1건의 레코드가 보장됩니다. (eq_ref)&lt;br /&gt;따라서 읽어야 하는 예상 레코드 수는 1건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(4) 조인 (Nested loop inner join)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738607769085&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(4) -&amp;gt; Nested loop inner join  (cost=1096 rows=406) (actual time=0.42..83.9 rows=4 loops=1)&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;(2)번 테이블(letter)과 (3)번 테이블(letter_analysis)을 조인합니다. (Nested loop inner join)&lt;/li&gt;
&lt;li&gt;조인에 사용될 예상 레코드 수는 406건입니다.&lt;/li&gt;
&lt;li&gt;실제 조인된 결과 레코드는 4건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(5) Materialize with deduplication&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738607902795&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(5) -&amp;gt; Materialize with deduplication  (cost=1137..1137 rows=406) (actual time=83.9..83.9 rows=4 loops=1)&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;중복을 제거하고 구체화(Materialize)합니다.&lt;/li&gt;
&lt;li&gt;예상되는 구체화 레코드는 406건입니다.&lt;/li&gt;
&lt;li&gt;실제 구체화된 레코드는 4건으로 유지됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(6) 인덱스 조회 단계 (날짜 기반 인덱스 조회 수행)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608115695&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(6) -&amp;gt; Index lookup on &amp;lt;materialized_subquery&amp;gt; using &amp;lt;auto_distinct_key&amp;gt; (DATE(l.created_at)=cast(letter.created_at as date))  (actual time=8.39..8.39 rows=0.4 loops=10)&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;구체화된 서브 쿼리(materialized_subquery)와 distinct_key를 이용해 최적화된 읽기를 수행합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(7) 제한 단계&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608265948&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(7) -&amp;gt; Limit: 1 row(s)  (cost=1137..1137 rows=1) (actual time=8.39..8.39 rows=0.4 loops=10)&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;결과를 1개의 레코드로 제한합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(8) 필터 단계 (날짜 기반 필터링)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608318586&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(8) -&amp;gt; Filter: ((cast(letter.created_at as date) = `&amp;lt;materialized_subquery&amp;gt;`.`DATE(l.created_at)`))  (cost=1137..1137 rows=1) (actual time=8.39..8.39 rows=0.4 loops=10)&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;서브쿼리의 결과와 현재 letter 테이블의 날짜를 비교합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(9) (1) ~ (8) 과정(서브쿼리)을 처리합니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608405287&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(9) -&amp;gt; Select #2 (subquery in condition; run only once)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(10) 메인 쿼리 부분&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608492871&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(10) -&amp;gt; Index lookup on letter using fk_letter_user (user_id=uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')), with index condition: (letter.user_id = &amp;lt;cache&amp;gt;(uuid_to_bin('fc700d72-e215-11ef-b651-0242ac120002')))  (cost=954 rows=3654) (actual time=6.51..14.8 rows=3654 loops=1)&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;다시 편지(letter) 테이블에서 유저 아이디(user_id)로 인덱스 조회를 수행합니다.&lt;br /&gt;(1)번 과정과 마찬가지로 &lt;i&gt;인덱스 컨디션 푸시다운(index condition pushdown)&lt;/i&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;기능을 이용해 효율적으로 읽었습니다.&lt;br /&gt;(인덱스 컨디션 푸시다운: MySQL 엔진에서 스토리지 엔진으로 인덱스를 전달해서 스토리지 엔진이 불필요한 디스크 읽기(I/O)를 줄이는 기능)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;읽어야 하는 예상 레코드 수는 3,654건입니다.&lt;/li&gt;
&lt;li&gt;실제로 읽은 레코드 수도 3,654건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(11) 필터 조건&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608589331&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(11) -&amp;gt; Filter: ((letter.created_at between '2024-01-04 00:00:00' and '2024-01-11 00:00:00') and &amp;lt;in_optimizer&amp;gt;(cast(letter.created_at as date),cast(letter.created_at as date) in (select #2) is false))  (cost=954 rows=3654) (actual time=90.8..101 rows=7 loops=1)&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;(9)번 테이블과 (10)번 테이블을 날짜 조건(2024년 1월 4일부터 1월 11일)으로 필터링합니다.&lt;/li&gt;
&lt;li&gt;읽어야 하는 예상 레코드 수는 3,654건입니다.&lt;/li&gt;
&lt;li&gt;두 테이블에 대해 날짜로 필터링된 레코드 수는 7건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(12) 정렬&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608790148&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(12)-&amp;gt; Sort: `cast(letter.created_at as date)`, letter.created_at DESC  (cost=954 rows=3654) (actual time=101..101 rows=7 loops=1)&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;letter 테이블의 생성 일자(created_at)를 기준으로 내림차순 정렬을 수행합니다.&lt;/li&gt;
&lt;li&gt;정렬되는 예상 레코드 수는 3,654건입니다.&lt;/li&gt;
&lt;li&gt;실제 정렬된 레코드 수는 7건입니다. (필터링된 레코드 수와 일치)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(13) 윈도우 집계(window aggregate)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738608900155&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(13)-&amp;gt; Window aggregate: row_number() OVER (PARTITION BY `cast(letter.created_at as date)` ORDER BY letter.created_at desc )   (actual time=101..101 rows=7 loops=1)&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;날짜별로 `row_number()` 윈도우 함수를 적용합니다.&lt;/li&gt;
&lt;li&gt;실제 적용된 레코드 수는 7건입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;실행 계획 분석 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 계획 분석 결과 NOT IN 서브쿼리가 있는 부분은 풀 테이블 스캔이 발생하지 않고, 외래키와 유니크 키 인덱스를 활용해 비교적 빠른 조회를 하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 하나 짚고 넘어가고 싶은 부분이 있는데요, &lt;b&gt;예상 레코드 수와 실제 레코드 수의 차이&lt;/b&gt;가 생각보다 많이 발생한다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;불필요한 읽기&lt;/b&gt;가 많이 발생하는 것을 알 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRwh29/btsL6LJbNcr/31W1XF1aKXhAkOo02bo3rk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRwh29/btsL6LJbNcr/31W1XF1aKXhAkOo02bo3rk/img.png&quot; data-alt=&quot;필터링된 비율(읽은 레코드 중 11.11%만 사용될 것으로 예상)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRwh29/btsL6LJbNcr/31W1XF1aKXhAkOo02bo3rk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRwh29%2FbtsL6LJbNcr%2F31W1XF1aKXhAkOo02bo3rk%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;633&quot; height=&quot;100&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;100&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;필터링된 비율(읽은 레코드 중 11.11%만 사용될 것으로 예상)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 계획의 `row`는 읽어드릴 예상 레코드 수를 의미하고, `filtered`는 통계 정보에 따라 읽을 레코드에서 필터링될 비율을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림처럼 11.11%로 되어있다면 디스크에서 읽어 들인 레코드 건수는 3,654건이지만 이 중 11.11%에 해당하는 401건만 필터링되어 사용될 것으로 예상됨으로 읽을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 말하면, 읽어들인 전체 레코드에서 &lt;b&gt;88.89%만큼 불필요한 레코드를 읽을 것으로 예상&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;고려할만한 최적화 옵션들&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불필요한 읽기를 해결하기 위한 방법으로 여러 선택지를 고민해 봤습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;❌ 첫 번째 옵션: 쿼리를 두 개로 분리하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 NOT IN 서브쿼리를 2개의 쿼리로 나누는 방법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1) 분석이 완료된 편지의 날짜 구하기&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738655335808&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT DISTINCT DATE(l.created_at)
FROM letter l
  JOIN letter_analysis la ON l.letter_id = la.letter_id
WHERE l.user_id = :userId
  AND l.created_at BETWEEN :start AND :end;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1-1) 첫 번째 쿼리의 필터링 효율&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;78&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CmIHv/btsL7ejVCfZ/qoOOu6wXzi0SsMRxugBTp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CmIHv/btsL7ejVCfZ/qoOOu6wXzi0SsMRxugBTp0/img.png&quot; data-alt=&quot;유저 아이디로 필터링하는 쿼리는 동일한 효율을 보인다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CmIHv/btsL7ejVCfZ/qoOOu6wXzi0SsMRxugBTp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCmIHv%2FbtsL7ejVCfZ%2FqoOOu6wXzi0SsMRxugBTp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;77&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;78&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;유저 아이디로 필터링하는 쿼리는 동일한 효율을 보인다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림은 첫 번째 쿼리의 필터링 효율입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저 아이디로 필터링하는 부분은 동일한 효율이 예상되는 것을 확인할 수 있습니다. (11.11%)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2) 분석 완료된 편지의 날짜로 NOT IN (상수) 조건으로 필터링하기&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738655453170&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *, ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY created_at DESC) AS row_num
FROM letter l
WHERE l.user_id = :userId
  AND l.created_at BETWEEN :start AND :end
  AND DATE(l.created_at) NOT IN ('2024-01-04', '2024-01-05', '2024-01-06', '2024-01-07')&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;첫 번째 쿼리의 결과 즉, 분석이 이미 완료된 일자(=데일리 리포트가 생성된 일자)가 NOT IN 조건절에 상수로 들어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2-1) 두 번째 쿼리의 필터링 효율&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;51&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c5PdTi/btsL5APwRXw/OKhN2ptjKRGsbkYUGMKoK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c5PdTi/btsL5APwRXw/OKhN2ptjKRGsbkYUGMKoK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c5PdTi/btsL5APwRXw/OKhN2ptjKRGsbkYUGMKoK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc5PdTi%2FbtsL5APwRXw%2FOKhN2ptjKRGsbkYUGMKoK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;48&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;51&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림은 두 번째 쿼리의 필터링 효율입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 쿼리 역시 유저 아이디로 필터링을 수행하기 때문에 동일한 효율임을 확인할 수 있습니다. (11.11%)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 해당 선택지는 네트워크를 2번 타고 효율은 증가하지 않기 때문에 최적화가 아닌 &lt;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;&lt;b&gt;✅ 두 번째 옵션: 다중 컬럼 인덱스 생성&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WHERE 조건절에 필터링될 컬럼들과 순서를 이용해 다중 컬럼 인덱스를 생성하는 방법입니다.&lt;/p&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;size18&quot;&gt;&lt;b&gt;1) 다중 컬럼 인덱스 생성&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738656679349&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;create index `idx_userId_createdAt`
    on letter (user_id, created_at);&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;size18&quot;&gt;&lt;b&gt;1-1) 실행 계획&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;636&quot; data-origin-height=&quot;107&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DJktM/btsL76yHBnu/SMeTvbjFPMvzy5OtEdOiDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DJktM/btsL76yHBnu/SMeTvbjFPMvzy5OtEdOiDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DJktM/btsL76yHBnu/SMeTvbjFPMvzy5OtEdOiDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDJktM%2FbtsL76yHBnu%2FSMeTvbjFPMvzy5OtEdOiDk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;108&quot; data-origin-width=&quot;636&quot; data-origin-height=&quot;107&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 결과를 보면 읽을 레코드 수가 3,654건에서 12건으로 줄었고, 필터링 효율도 11.11%에서 100%를 달성했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1825&quot; data-origin-height=&quot;105&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8NcG2/btsL6SnUE9v/qGKNjQxcZxY80HWzXIOZM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8NcG2/btsL6SnUE9v/qGKNjQxcZxY80HWzXIOZM1/img.png&quot; data-alt=&quot;전체 실행 계획&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8NcG2/btsL6SnUE9v/qGKNjQxcZxY80HWzXIOZM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8NcG2%2FbtsL6SnUE9v%2FqGKNjQxcZxY80HWzXIOZM1%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;1825&quot; height=&quot;105&quot; data-origin-width=&quot;1825&quot; data-origin-height=&quot;105&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;전체 실행 계획&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 계획 전체를 보면 letter 테이블을 조회할 때와 letter 테이블과 letter_analysis 테이블을 조인할 때 모두 `idx_userId_createdAt` 인덱스를 활용해 필요한 레코드만 조회한 것을 확인할 수 있습니다.&lt;/p&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;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;다중 컬럼 인덱스 사용 시 주의사항&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다중 컬럼을 활용하기 위해선 반드시 조건절에 사용되는 컬럼의 순서를 인덱스를 생성할 때 지정한 순서와 동일하게 해야 한다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 선행 컬럼에 대한 조건에 의해 작업 범위가 결정되기 때문인데, 컬럼의 순서가 바뀌면 인덱스를 활용할 수 없기 때문에 순서를 잘 지켜야 합니다.&lt;/p&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 리팩토링을 통해 유지 보수를 어렵게 했던 문제점 중 하나가 다른 객체의 책임이 섞여 있던 코드였다는 사실을 깨달았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 적절한 책임 재분배를 통해 코드 가독성을 높일 수 있었습니다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;6:1-7:30&quot; data-ke-size=&quot;size16&quot;&gt;특히 리팩토링 과정에서 재밌었던 부분이 있었는데요, NOT IN 서브쿼리 검증을 위해 시나리오를 만들고 실행 계획 분석을 통해 쿼리 효율성을 검증하는 과정이었습니다. 내가 작성한 쿼리가 효율적인지 확인하는 좋은 경험을 했다고 생각합니다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;6:1-7:30&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-sourcepos=&quot;9:1-10:28&quot; data-ke-size=&quot;size16&quot;&gt;다음 포스팅에서는 트랜잭션과 외부 API 분리 과정을 다룰 예정입니다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;9:1-10:28&quot; data-ke-size=&quot;size16&quot;&gt;관심 있으신 분들은 추가되는 링크를 통해 확인해 주세요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev-gallery.tistory.com/89&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;프로젝트 리팩토링 (4) - 책임&amp;nbsp;재할당과&amp;nbsp;트랜잭션에서&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;분리하기&lt;/a&gt;&lt;/p&gt;
&lt;p data-sourcepos=&quot;9:1-10:28&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-sourcepos=&quot;12:1-12:16&quot; data-ke-size=&quot;size16&quot;&gt;긴 글 읽어주셔서 감사합니다.&lt;/p&gt;</description>
      <category>Project</category>
      <category>OOP</category>
      <category>리팩토링</category>
      <category>비사이드</category>
      <category>실행 계획</category>
      <category>프로젝트</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/88</guid>
      <comments>https://dev-gallery.tistory.com/88#entry88comment</comments>
      <pubDate>Tue, 4 Feb 2025 18:21:32 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트 리팩토링 (2) - 도메인 모델 리팩토링</title>
      <link>https://dev-gallery.tistory.com/87</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅은 올려 올려 라디오 프로젝트의 도메인 모델 개선에 대해 다룹니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 도메인 모델의 문제점을 짚어보고, 더 나은 도메인 모델을 제시하면서 그 근거를 정리하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시작하기 앞서, 프로젝트 리팩토링 과정을 잘 이해하기 위해 우리 프로젝트가 어떻게 동작하는지 간단하게 짚고 넘어가겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;프로젝트 동작 구조&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;답장 서비스 동작 구조&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;383&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNSI69/btsL49ijqEA/M8JEZJwHR81KcdaGKKIsdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNSI69/btsL49ijqEA/M8JEZJwHR81KcdaGKKIsdK/img.png&quot; data-alt=&quot;답장 서비스 동작 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNSI69/btsL49ijqEA/M8JEZJwHR81KcdaGKKIsdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNSI69%2FbtsL49ijqEA%2FM8JEZJwHR81KcdaGKKIsdK%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;450&quot; height=&quot;218&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;383&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;답장 서비스 동작 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 그림은 우리 프로젝트의 첫 번째 MVP인 `답장 서비스`가 동작하는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저가 `편지`를 써서 전달하면 생성형 AI가 `답장`을 생성해 응답합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;691&quot; data-origin-height=&quot;210&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beqiQr/btsL3ZgNhq9/6pN8s6SnOeEQl3yp1bMAl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beqiQr/btsL3ZgNhq9/6pN8s6SnOeEQl3yp1bMAl0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beqiQr/btsL3ZgNhq9/6pN8s6SnOeEQl3yp1bMAl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeqiQr%2FbtsL3ZgNhq9%2F6pN8s6SnOeEQl3yp1bMAl0%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;450&quot; height=&quot;137&quot; data-origin-width=&quot;691&quot; data-origin-height=&quot;210&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 우리 프로젝트는 유저가 작성한 `편지`와 `답장`을 각각 별개의 엔티티로 관리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 엔티티 간 관계는 1:1 관계를 갖고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;데일리 리포트 서비스 동작 구조&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1093&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dDbOnb/btsL4QwuJA9/OJ2XRFQ7EKRAynsaoFUWS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dDbOnb/btsL4QwuJA9/OJ2XRFQ7EKRAynsaoFUWS0/img.png&quot; data-alt=&quot;데일리 리포트 서비스 동작 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dDbOnb/btsL4QwuJA9/OJ2XRFQ7EKRAynsaoFUWS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdDbOnb%2FbtsL4QwuJA9%2FOJ2XRFQ7EKRAynsaoFUWS0%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;550&quot; height=&quot;201&quot; data-origin-width=&quot;1093&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;데일리 리포트 서비스 동작 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트의 두 번째 MVP인 `데일리 리포트 서비스`가 동작하는 구조입니다.&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;를 생성형 AI를 통해 통해 각 편지당 `감정 분석`을 하나씩 생성하며, `데일리 리포트`를 함께 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 하루에 편지 10개를 작성했다면 정책상 3개의 편지만 분석 대상이 되며, 편지 3개에 대응하는 각각의 `감정 분석`과 그 감정 분석들을 기반으로 한 `데일리 리포트` 1개가 생성됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/djyYUt/btsL5kD1zmh/VMg9M6SEVchLgmBH712EMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/djyYUt/btsL5kD1zmh/VMg9M6SEVchLgmBH712EMK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/djyYUt/btsL5kD1zmh/VMg9M6SEVchLgmBH712EMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdjyYUt%2FbtsL5kD1zmh%2FVMg9M6SEVchLgmBH712EMK%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;450&quot; height=&quot;274&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;180&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;161&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSoQwj/btsL3pNCvVp/mZKxKkF45Bmg8TCAsumVWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSoQwj/btsL3pNCvVp/mZKxKkF45Bmg8TCAsumVWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSoQwj/btsL3pNCvVp/mZKxKkF45Bmg8TCAsumVWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSoQwj%2FbtsL3pNCvVp%2FmZKxKkF45Bmg8TCAsumVWK%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;450&quot; height=&quot;274&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;161&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 `편지`와 `감정 분석` 엔티티는 1:1 관계를, `편지`와 `데일리 리포트` 관계를 N:1 관계를 갖습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;현재 도메인 모델의 문제점&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 프로젝트가 어떻게 동작하는지 간략하게 살펴보고, 엔티티 간 관계에 대해 간략하게 알아봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 엔티티 간 연관 관계 매핑도 동작 관계에서 설명한 그대로 설정했는데요, 여기에 문제가 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제가 되는 엔티티 간 연관 관계 매핑&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트는 JPA를 이용해 엔티티 간 연관 관계를 아래와 같이 매핑했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 `편지`와 `감정 분석` 엔티티 간 연관 관계입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1) 감정 분석-편지 (OneToOne)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지`와 `감정 분석`의 연관 관계 주인은 1:1 관계기 때문에 연관 관계의 주인은 어디에 있더라도 상관없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 로직 상 `편지`가 단독으로 조회되는 경우가 없고, `감정 분석`만 단독으로 조회되기 때문에 `감정 분석`이 연관 관계의 주인이 되도록 설계되었습니다. (`감정 분석` 테이블이 FK를 가짐)&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;size18&quot;&gt;&lt;b&gt;감정 분석 (연관 관계의 주인)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738428848804&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;letter_analysis&quot;)
public class LetterAnalysis extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;letter_analysis_id&quot;)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;letter_id&quot;)
    private Letter letter;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;편지 엔티티&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738429023070&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = &quot;letter&quot;)
public class Letter extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = &quot;letter_id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2) 편지-데일리 리포트 (ManyToOne)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지`와 `데일리 리포트`의 연관 관계 주인은 데이터베이스 상 FK를 갖는 `편지`입니다.&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;size18&quot;&gt;&lt;b&gt;편지 엔티티 (연관 관계의 주인)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738428132408&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = &quot;letter&quot;)
public class Letter extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = &quot;letter_id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;daily_report_id&quot;)
    private DailyReport dailyReport;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;데일리 리포트 엔티티&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738428257424&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;daily_report&quot;)
public class DailyReport extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = &quot;daily_report_id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지만 봤을 때 문제가 없어 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 생성된 데이터베이스를 보면 이야기가 달라집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;연관 관계 매핑에 의해 생성된 테이블들&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;808&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dnVc1o/btsL4bBjov8/tidj2raV3RWngqlT3tUozk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dnVc1o/btsL4bBjov8/tidj2raV3RWngqlT3tUozk/img.png&quot; data-alt=&quot;도메인 모델 설계 이후 데이터베이스 테이블 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dnVc1o/btsL4bBjov8/tidj2raV3RWngqlT3tUozk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdnVc1o%2FbtsL4bBjov8%2Ftidj2raV3RWngqlT3tUozk%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;808&quot; height=&quot;333&quot; data-origin-width=&quot;808&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도메인 모델 설계 이후 데이터베이스 테이블 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;연관 관계 주인&lt;/i&gt;과 데이터베이스 상 &lt;i&gt;FK가 위치하는 테이블&lt;/i&gt;이 일치하도록 설계했기 때문에 `감정 분석` 테이블은 `편지` 테이블을 참조합니다. 마찬가지로 `편지` 테이블은 `데일리 리포트` 테이블을 참조합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 배포된 이후, 데이터가 비교적 적을 때까지만 해도 무엇이 문제인지 감이 잘 안 잡혔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 문제는 `편지` 테이블에 레코드가 쌓일 때 문제가 드러나기 시작합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGSzN6/btsL3AhhB9g/2kI1KXKzvVRGW108uarjIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGSzN6/btsL3AhhB9g/2kI1KXKzvVRGW108uarjIk/img.png&quot; data-alt=&quot;편지-데일리 리포트 간 연관 관계에서 드러나는 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGSzN6/btsL3AhhB9g/2kI1KXKzvVRGW108uarjIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGSzN6%2FbtsL3AhhB9g%2F2kI1KXKzvVRGW108uarjIk%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;790&quot; height=&quot;620&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;편지-데일리 리포트 간 연관 관계에서 드러나는 문제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림은 `편지` 테이블에 분석을 수행하지 않은 레코드가 늘어날 때 문제가 발생함을 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇이 문제가 되는 걸까요?&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 1. 편지 테이블에 레코드가 쌓일 때마다 데이터 낭비가 발생한다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 문제는 시간이 흐를수록 편지 테이블에 데이터 낭비가 심각해질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그럴까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 엔티티 간 &lt;i&gt;&lt;b&gt;잘못된 연관 관계&lt;/b&gt;&lt;/i&gt;가 문제라고 진단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 엔티티가 다른 엔티티를 포함할 때 &lt;b&gt;&lt;i&gt;제약의 정도&lt;/i&gt;&lt;/b&gt;에 따라 연관 관계가 달라져야 한다는 것을 설명하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시를 통해 두 엔티티 간 관계가 단순히 N:1이라고 해서 ManyToOne으로 설정한 것은 잘못된 설계가 될 수 있음을 설명하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;case 1. 한 엔티티는 다른 엔티티에 &quot;속해야만&quot; 하는 제약이 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;246&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SX8UO/btsL4O6yX8R/PfkyrZBNYeVbFKLF4MsKx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SX8UO/btsL4O6yX8R/PfkyrZBNYeVbFKLF4MsKx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SX8UO/btsL4O6yX8R/PfkyrZBNYeVbFKLF4MsKx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSX8UO%2FbtsL4O6yX8R%2FPfkyrZBNYeVbFKLF4MsKx1%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;450&quot; height=&quot;180&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;246&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`축구 선수(Player)`와 `팀(Team)`의 관계가 있습니다.&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;물론, 방출되는 등의 특정 상황에서는 &quot;잠시동안&quot; 선수가 무소속일 수 있습니다. 하지만 &lt;b&gt;&quot;일반적인 상황&quot;&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;이 경우 축구 선수와 팀의 연관 관계는 ManyToOne이 되는 것이 바람직합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일시적으로 선수가 무소속일 때는 `null`이 될 수 있지만 대다수의 상황에선 레코드가 팀에 해당하는 FK를 갖고 있기 때문에 `축구 선수`와 `팀`의 연관 관계는 명확하게 표현되었다고 말할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;case 2. 한 엔티티는 다른 엔티티에 속하지 않는 것이 일반적이다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;655&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tK5o7/btsL4La3tTD/Bwf7BPkXXSO32gVbUjWIFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tK5o7/btsL4La3tTD/Bwf7BPkXXSO32gVbUjWIFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tK5o7/btsL4La3tTD/Bwf7BPkXXSO32gVbUjWIFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtK5o7%2FbtsL4La3tTD%2FBwf7BPkXXSO32gVbUjWIFK%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;450&quot; height=&quot;165&quot; data-origin-width=&quot;655&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트의 `편지`와 `데일리 리포트`의 관계입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 case 1과 달리 일반적인 상황에서 모든 `편지`는 `데일리 리포트`에 속하지 않아도 됩니다.&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;일반적으로 NULL을 포함한 레코드가 더 많은 상황이라면 비즈니스 로직 상 `편지`와 `데일리 리포트`의 예상된 관계가 깨지는 경우가 많아지게 되며, 테이블 간 관계에서 &lt;i&gt;&lt;b&gt;&quot;선택적인 관계&quot;&lt;/b&gt;&lt;/i&gt;인지 &lt;i&gt;&lt;b&gt;&quot;필수적인 관계&quot;&lt;/b&gt;&lt;/i&gt;인지 직관적으로 판단하기 어렵게 된다는 점이 문제가 된다고 생각합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 2. 엔티티마다 연관 관계 주인이 달라 관리가 복잡하다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`감정 분석`과 `편지` 관계에서 연관 관계의 주인은 `감정 분석`입니다. 이 경우, 연관 관계의 주인인 `감정 분석` 쪽에서 `cascade`나 `orphanRemoval` 등으로 관리가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지`와 `데일리 리포트`의 관계에서 연관 관계의 주인은 `편지` 쪽에서 `cascade`나 `orphanRemoval` 같은 관리가 필요합니다.&lt;/p&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;해결: 개선된 모델&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지`와 `감정 분석`, `편지`와 `데일리 리포트`의 관계는 단순히 OneToOne, ManyToOne으로 설정했을 때 두 가지 문제가 발생한다는 점을 살펴봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 세 엔티티 간의 연관 관계를 명확하게 표현하기 위해서 다대다 관계를 풀어낼 때처럼 중간 테이블로 풀어내는 것이 합리적이라고 할 수 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;`편지`-`중간 테이블`은 OneToOne, `중간 테이블`-`데일리 리포트`는 ManyToOne으로 설정하는 것이 가장 최적이라고 생각합니다. 추가로&lt;span&gt;&amp;nbsp;&lt;/span&gt;중간 테이블을 둘 경우 `감정 분석` 테이블 이 중간 테이블 자체가 될 수 있다는 장점도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 최종적으로 개선된 모델입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;767&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bswTus/btsL31eE1Nc/YFds0a8044TeYDjDuEkzm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bswTus/btsL31eE1Nc/YFds0a8044TeYDjDuEkzm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bswTus/btsL31eE1Nc/YFds0a8044TeYDjDuEkzm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbswTus%2FbtsL31eE1Nc%2FYFds0a8044TeYDjDuEkzm0%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;912&quot; height=&quot;767&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;767&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;추가 장점: MySQL 트랜잭션 관점에서 동시성 문제도 해결된다.&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간 테이블을 뒀을 때 동시성 문제(레이스 컨디션)도 어느 정도 해결이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시를 들어보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;가정: InnoDB 엔진 / 트랜잭션 격리 수준은 REPEATABLE READ&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;size18&quot;&gt;&lt;b&gt;1단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 트랜잭션 없이 `스레드 A`와 `스레드 B`는 `편지 테이블`에서 편지 1, 2, 3을 읽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 스레드는 외부 API(&lt;i&gt;생성형 AI API&lt;/i&gt;) 호출을 통해 `감정 분석 1, 2, 3`과 `데일리 리포트`를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제  스레드 각각 트랜잭션 A와 트랜잭션 B가 동시에 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편의상 `스레드 A`에서 트랜잭션 A가, `스레드 B`에서 트랜잭션 B가 시작됐다고 하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 A와 B는 `데일리 리포트` 테이블에 INSERT를 수행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REPEATABLE READ 격리 수준에서 다른 테이블에 대한 참조가 없기 때문에 두 트랜잭션으로부터 두 개의 레코드가 삽입되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 A와 트랜잭션 B는 이제 `감정 분석` 테이블에 INSERT 작업을 수행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`편지` 테이블에 대한 참조와 `데일리 리포트`에 대한 참조 무결성을 위해 락(Lock)을 획득해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, `편지` 테이블에 대한 유니크 인덱스가 있기 때문에 &lt;i&gt;&lt;b&gt;배타 락(X Lock)&lt;/b&gt;&lt;/i&gt;을 획득하고, `데일리 리포트`에 대해서는 &lt;i&gt;&lt;b&gt;공유 락(S Lock)&lt;/b&gt;&lt;/i&gt;을 획득해야 한다는 차이점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 두 트랜잭션 중 단 하나의 트랜잭션만 `편지` 테이블의 &lt;b&gt;&lt;i&gt;편지 1, 2, 3 레코드에 대한 배타 락&lt;/i&gt;&lt;/b&gt;을 획득하고, 다른 트랜잭션은 락 대기 상태에 들어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 두 트랜잭션 중 단 하나의 트랜잭션만 `감정 분석` 테이블에 INSERT가 성공합니다.&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;그 이유는, 먼저 배타 락을 획득한 트랜잭션이 `감정 분석` 테이블에 편지 1, 2, 3에 대한 레코드를 INSERT 성공하고 커밋을 마치면 배타 락을 대기하고 있던 다른 트랜잭션에서는 이미 편지 1, 2, 3에 대해 물리적으로 INSERT가 수행됐음을 인지하고 유니크 제약 조건에 의해 삽입이 실패하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고로 유니크 인덱스는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;다른 인덱스와는 달리&lt;span&gt; &lt;/span&gt;&lt;/span&gt;중복 체크를 위해 &lt;b&gt;체인지 버퍼(디스크 I/O를 줄이기 위한 임시 메모리 공간)를 사용할 수 없습니다.&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;size18&quot;&gt;&lt;b&gt;그러나&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;제가 사용한 조치는 기존 프로젝트에서 사용했던 간단한 방법인 네임드 락을 이용하는 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA는 개발자가 Java 코드에서 엔티티 간의 관계를 쉽게 정의하고 데이터베이스에 반영할 수 있도록 도와주는 강력한 도구입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 그 편리함 뒤에는 &lt;b&gt;신중한 설계&lt;/b&gt;가 필수적이라는 것을 이번 경험을 통해 절실히 느꼈습니다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;5:1-5:159&quot; data-ke-size=&quot;size16&quot;&gt;엔티티 간의 관계를 깊이 고려하지 않은 매핑은 &lt;b&gt;예상치 못한 문제&lt;/b&gt;를 야기할 수 있습니다. 특히 &quot;선택적&quot; 관계와 &quot;필수적&quot; 관계를 명확히 구분하지 못하면, 비즈니스 로직의 의미가 모호해지고 예상된 연관 관계가 깨져 &lt;b&gt;코드의 가독성&lt;/b&gt;과 &lt;b&gt;유지 보수성&lt;/b&gt;을 저해할 수 있습니다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;7:1-7:148&quot; data-ke-size=&quot;size16&quot;&gt;이번 도메인 모델 설계 문제를 해결하는 과정에서 연관 관계에 대한 깊이 있는 이해를 얻을 수 있었습니다. 단순히 JPA의 기능을 사용하는 것을 넘어, &lt;b&gt;비즈니스 요구사항&lt;/b&gt;을 정확하게 파악하고 &lt;b&gt;적절한 매핑 전략&lt;/b&gt;을 선택하는 것이 얼마나 중요한지 깨달았습니다.&lt;/p&gt;</description>
      <category>Project</category>
      <category>도메인 모델</category>
      <category>리팩토링</category>
      <category>비사이드</category>
      <author>옐리yelly</author>
      <guid isPermaLink="true">https://dev-gallery.tistory.com/87</guid>
      <comments>https://dev-gallery.tistory.com/87#entry87comment</comments>
      <pubDate>Sun, 2 Feb 2025 05:02:12 +0900</pubDate>
    </item>
  </channel>
</rss>