<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>pandaterry's 개발로그</title>
    <link>https://pandaterry.tistory.com/</link>
    <description>드립커피 앤 데브 
&amp;lt;&amp;lt;&amp;lt;
https://blog.naver.com/ljk041180로부터 이전했습니다.</description>
    <language>ko</language>
    <pubDate>Fri, 3 Jul 2026 00:17:14 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>pandaterry</managingEditor>
    <image>
      <title>pandaterry's 개발로그</title>
      <url>https://tistory1.daumcdn.net/tistory/7697607/attach/4ae98cb63f45487d8a3ff63368f73728</url>
      <link>https://pandaterry.tistory.com</link>
    </image>
    <item>
      <title>모던 자바 안티패턴: 설계 의도를 벗어나는 API 사용 사례</title>
      <link>https://pandaterry.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%EC%84%A4%EA%B3%84-%EC%9D%98%EB%8F%84%EB%A5%BC-%EB%B2%97%EC%96%B4%EB%82%98%EB%8A%94-API-%EC%82%AC%EC%9A%A9-%EC%82%AC%EB%A1%80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;모던 자바의 새로운 API들은 개발자에게 더 나은 코드를 작성할 수 있는 도구를 제공합니다. 하지만 이러한 API들의 설계 의도를 제대로 이해하지 못하고 사용하면 오히려 코드 복잡도를 높이고 성능을 저하시키며, 시스템 설계에 구조적인 문제를 야기할 수 있습니다. 이는 해외 자바 커뮤니티와 언어 설계자들도 지적하는 중요한 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Optional: 메서드 반환 타입으로만 설계된 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도: null을 명시적으로 표현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Optional의 핵심 설계 의도&lt;/b&gt;는 &quot;null을 반환할 수 있는 메서드의 반환 타입&quot;으로 사용하는 것입니다. Java Language Architect Brian Goetz는 &quot;Optional은 메서드 반환 타입으로만 사용하도록 설계되었다&quot;고 명시했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 메서드 파라미터로 Optional 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 흔한 남용 사례&lt;/b&gt;는 Optional을 메서드 파라미터로 사용하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반
public void processOrder(Optional&amp;lt;String&amp;gt; couponCode) {
    String code = couponCode.orElse(&quot;NONE&quot;);
    // ...
}

// 호출자는 항상 래핑해야 함
processOrder(Optional.of(&quot;SUMMER2024&quot;));
processOrder(Optional.empty());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 호출자에게 불필요한 부담을 주며, 코드베이스 전체를 오염시킵니다. &lt;b&gt;오버로딩이 더 나은 설계&lt;/b&gt;입니다:&lt;a href=&quot;https://blog.indrek.io/articles/misusing-java-optional/&quot;&gt;^4&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1763735076384&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Misusing Java&amp;rsquo;s Optional type&quot; data-og-description=&quot;Java&amp;rsquo;s Optional type has been criticised for its flaws. It&amp;rsquo;s also being used for things it was perhaps not designed. This post shows some situations where I think Optional is being misused.&quot; data-og-host=&quot;blog.indrek.io&quot; data-og-source-url=&quot;https://blog.indrek.io/articles/misusing-java-optional/&quot; data-og-url=&quot;https://blog.indrek.io/articles/misusing-java-optional/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Tc2er/hyZNCkHDUB/kqnjgiNvzHgOgnD2gfqUek/img.jpg?width=1200&amp;amp;height=400&amp;amp;face=0_0_1200_400,https://scrap.kakaocdn.net/dn/brOZus/hyZOghpTNK/Rxpe1U7F9Fep7vlWuiZ8Jk/img.jpg?width=1200&amp;amp;height=400&amp;amp;face=0_0_1200_400,https://scrap.kakaocdn.net/dn/b6WOWl/hyZNJjRQLB/KLZKhg5aziljeBofaKu93K/img.png?width=2324&amp;amp;height=1310&amp;amp;face=0_0_2324_1310&quot;&gt;&lt;a href=&quot;https://blog.indrek.io/articles/misusing-java-optional/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://blog.indrek.io/articles/misusing-java-optional/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Tc2er/hyZNCkHDUB/kqnjgiNvzHgOgnD2gfqUek/img.jpg?width=1200&amp;amp;height=400&amp;amp;face=0_0_1200_400,https://scrap.kakaocdn.net/dn/brOZus/hyZOghpTNK/Rxpe1U7F9Fep7vlWuiZ8Jk/img.jpg?width=1200&amp;amp;height=400&amp;amp;face=0_0_1200_400,https://scrap.kakaocdn.net/dn/b6WOWl/hyZNJjRQLB/KLZKhg5aziljeBofaKu93K/img.png?width=2324&amp;amp;height=1310&amp;amp;face=0_0_2324_1310');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Misusing Java&amp;rsquo;s Optional type&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Java&amp;rsquo;s Optional type has been criticised for its flaws. It&amp;rsquo;s also being used for things it was perhaps not designed. This post shows some situations where I think Optional is being misused.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;blog.indrek.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public void processOrder(String couponCode) { /* ... */ }
public void processOrder() { processOrder(&quot;NONE&quot;); }

// 호출이 훨씬 간단
processOrder(&quot;SUMMER2024&quot;);
processOrder();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: Optional 필드 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JPA 엔티티나 일반 클래스의 필드로 Optional을 사용&lt;/b&gt;하면 직렬화 문제와 성능 저하가 발생합니다.&lt;a href=&quot;https://www.reddit.com/r/learnjava/comments/f7crfb/why_is_it_considered_bad_practice_to_use_optional/&quot;&gt;^5&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1763735093276&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Reddit의 learnjava 커뮤니티&quot; data-og-description=&quot;learnjava 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요&quot; data-og-host=&quot;www.reddit.com&quot; data-og-source-url=&quot;https://www.reddit.com/r/learnjava/comments/f7crfb/why_is_it_considered_bad_practice_to_use_optional/&quot; data-og-url=&quot;https://www.reddit.com/r/learnjava/comments/f7crfb/why_is_it_considered_bad_practice_to_use_optional/?seeker-session=true&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dkTSpg/hyZN6y7xOS/3civlc8MdoFq2jkHtYCQ90/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584,https://scrap.kakaocdn.net/dn/Q03On/hyZNGOabbJ/vg2McKcNGBKM6mAkR8xYuk/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584&quot;&gt;&lt;a href=&quot;https://www.reddit.com/r/learnjava/comments/f7crfb/why_is_it_considered_bad_practice_to_use_optional/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.reddit.com/r/learnjava/comments/f7crfb/why_is_it_considered_bad_practice_to_use_optional/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dkTSpg/hyZN6y7xOS/3civlc8MdoFq2jkHtYCQ90/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584,https://scrap.kakaocdn.net/dn/Q03On/hyZNGOabbJ/vg2McKcNGBKM6mAkR8xYuk/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Reddit의 learnjava 커뮤니티&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;learnjava 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.reddit.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
public class Order {
    @Id
    private Long id;

    // 설계 의도 위반: Optional 필드
    private Optional&amp;lt;String&amp;gt; comment;  // 직렬화 실패
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA 엔티티에 Optional 필드를 사용하면 Hibernate 직렬화가 실패하고, Jackson으로 JSON 변환 시에도 예상치 못한 결과가 발생합니다. 데이터베이스에 저장할 수도, API 응답으로 반환할 수도 없게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;올바른 접근&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class Order {
    private String comment;  // 실제 필드는 일반 타입

    public Optional&amp;lt;String&amp;gt; getComment() {
        return Optional.ofNullable(comment);  // getter에서만 Optional 반환
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 컬렉션을 Optional로 감싸기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;컬렉션을 Optional로 감싸는 것&lt;/b&gt;은 &quot;너무 과한&quot; 사용 사례입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반: 컨테이너 in 컨테이너
public Optional&amp;lt;List&amp;lt;User&amp;gt;&amp;gt; getUsers() {
    List&amp;lt;User&amp;gt; users = repository.findAll();
    return users.isEmpty()
        ? Optional.empty()
        : Optional.of(users);
}&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;Effective Java는 명시합니다: &quot;컨테이너 타입(컬렉션, 맵, 스트림, 배열, Optional)은 Optional로 감싸면 안 된다&quot;. 빈 컬렉션 자체가 이미 &quot;값 없음&quot;을 표현하는 표준 방법이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;올바른 설계&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;public List&amp;lt;User&amp;gt; getUsers() {
    return repository.findAll();  // 빈 리스트 반환 가능
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: Optional 자체를 null로 반환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Optional을 반환하는 메서드에서 null을 반환&lt;/b&gt;하는 것은 설계 의도를 완전히 배신하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 최악의 안티패턴
public Optional&amp;lt;User&amp;gt; findUser(Long id) {
    if (id == null) return null;  // Optional이 null!
    // ...
}

// 호출자는 이중 검사가 필요
Optional&amp;lt;User&amp;gt; result = findUser(id);
if (result != null &amp;amp;&amp;amp; result.isPresent()) {  // 두 번 체크
    User user = result.get();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Joshua Bloch는 &quot;절대로 Optional을 반환하는 메서드에서 null을 반환하지 마라. 이는 이 기능의 전체 목적을 무너뜨린다&quot;고 경고했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 영향: 메모리 오버헤드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Optional 객체의 메모리 비용&lt;/b&gt;은 실제 값보다 훨씬 큽니다. 단순한 &lt;code&gt;Integer&lt;/code&gt; 하나를 &lt;code&gt;Optional&amp;lt;Integer&amp;gt;&lt;/code&gt;로 감싸면:&lt;a href=&quot;https://stackoverflow.com/questions/34696884/performance-of-java-optional&quot;&gt;^7&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;&lt;code&gt;Integer&lt;/code&gt; 자체: 16바이트&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Optional&lt;/code&gt; 객체: 16바이트&lt;/li&gt;
&lt;li&gt;총 32바이트 + 참조 오버헤드&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;null로 표현하면 0바이트인데, Optional을 사용하면 최소 32바이트가 필요합니다. 대용량 컬렉션이나 데이터베이스 엔티티에 Optional을 남발하면 메모리 사용량이 기하급수적으로 증가합니다.&lt;a href=&quot;https://stackoverflow.com/questions/34696884/performance-of-java-optional&quot;&gt;^7&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 성능 측정 결과&lt;/b&gt;:&lt;a href=&quot;https://stackoverflow.com/questions/34696884/performance-of-java-optional&quot;&gt;^7&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 벤치마크: 100만 개의 Optional 처리
// null 체크: 12ms
// Optional 체크: 89ms
// 약 7배 느림 + GC 압력 증가&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Stream API: 선언적 데이터 변환을 위한 도구&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도: 복잡한 데이터 변환 파이프라인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Stream API의 핵심 설계 의도&lt;/b&gt;는 복잡한 데이터 변환과 파이프라인 처리를 선언적으로 표현하는 것입니다. 단순한 반복문을 대체하기 위한 것이 아닙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 단순 작업에 Stream 남발&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 근본적인 설계 의도 위반&lt;/b&gt;은 단순한 루프를 Stream으로 억지로 변환하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 설계 의도 위반
List&amp;lt;Integer&amp;gt; numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
    .filter(n -&amp;gt; n % 2 == 0)
    .collect(Collectors.toList());

// 올바른 접근: 단순 작업은 루프로
List&amp;lt;Integer&amp;gt; evenNumbers = new ArrayList&amp;lt;&amp;gt;();
for (Integer number : numbers) {
    if (number % 2 == 0) {
        evenNumbers.add(number);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 Stream을 사용하면 오히려 코드가 복잡해지고, 88바이트의 추가 메모리가 소비되며, GC 압력이 증가합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: Stream to Collection to Stream 체이닝&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;터미널 연산으로 컬렉션을 반환한 후 즉시 다시 &lt;code&gt;.stream()&lt;/code&gt;을 호출&lt;/b&gt;하는 것은 명백한 설계 의도 위반입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반
List&amp;lt;String&amp;gt; result = orderIds.stream()
    .map(this::getOrderDetails)
    .collect(Collectors.toList())  // 터미널 연산
    .stream()                      // 다시 스트림 생성
    .map(OrderDetails::getUserId)
    .collect(Collectors.toList());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stream은 &lt;b&gt;단일 파이프라인&lt;/b&gt;으로 설계되었습니다. 중간에 컬렉션으로 변환했다가 다시 스트림으로 만드는 것은 불필요한 오버헤드를 발생시킵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;올바른 접근&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// 단일 파이프라인으로 유지
List&amp;lt;String&amp;gt; result = orderIds.stream()
    .map(this::getOrderDetails)
    .map(OrderDetails::getUserId)
    .collect(Collectors.toList());&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 반복적인 Stream 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;같은 컬렉션에서 반복적으로 Stream을 생성&lt;/b&gt;하는 것은 Stream의 일회성 설계를 이해하지 못한 것입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반
List&amp;lt;String&amp;gt; allNames = new ArrayList&amp;lt;&amp;gt;(...);
for (int i = 0; i &amp;lt; 1000; i++) {
    allNames.stream()
        .filter(name -&amp;gt; name.startsWith(&quot;A&quot;))
        .forEach(System.out::println);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stream은 &lt;b&gt;한 번의 데이터 변환 파이프라인&lt;/b&gt;을 위해 설계되었습니다. 반복문 안에서 매번 Stream을 생성하는 것은 엄청난 성능 오버헤드를 발생시킵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: Stateful 연산 남발&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;sorted()&lt;/code&gt;, &lt;code&gt;distinct()&lt;/code&gt; 같은 stateful 연산을 과도하게 사용&lt;/b&gt;하는 것은 Stream의 stateless 설계 원칙을 위반합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반
List&amp;lt;String&amp;gt; list = Arrays.asList(&quot;B&quot;, &quot;C&quot;, &quot;A&quot;, &quot;D&quot;, &quot;C&quot;);
list.stream()
    .distinct()     // Stateful
    .sorted()       // Stateful
    .filter(s -&amp;gt; s.length() &amp;gt; 1)
    .distinct()     // 또 Stateful
    .forEach(System.out::println);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stream은 기본적으로 &lt;b&gt;stateless&lt;/b&gt; 연산을 위해 설계되었습니다. Stateful 연산은 내부 상태를 유지해야 하므로 병렬 처리 시 성능 저하와 예측 불가능한 동작을 유발합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CompletableFuture: 비동기 체이닝을 위한 API&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도: 블로킹 없이 비동기 연산 체이닝&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CompletableFuture의 핵심 설계 의도&lt;/b&gt;는 &quot;블로킹 없이 비동기 연산을 체이닝&quot;하는 것입니다. 그러나 개발자들은 이를 잘못 이해하고 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 동기적 &lt;code&gt;.join()&lt;/code&gt; 남발&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비동기를 블로킹으로 되돌리는 패턴&lt;/b&gt;은 설계 의도의 정반대입니다:&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// 설계 의도 위반: 비동기를 블로킹으로 되돌림
CompletableFuture&amp;lt;String&amp;gt; future1 = fetchDataAsync();
String data = future1.join();  // ❌ 메인 스레드 블로킹

CompletableFuture&amp;lt;Integer&amp;gt; future2 = processAsync(data);
Integer result = future2.join();  // ❌ 또 블로킹&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 &quot;비동기의 힘과 정반대&quot;이며, 비동기 커뮤니티에서는 &quot;매우 권장되지 않는&quot; 패턴입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;올바른 접근&lt;/b&gt;: 체이닝으로 블로킹 제거:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: &lt;code&gt;thenCompose&lt;/code&gt; 대신 &lt;code&gt;thenApply&lt;/code&gt; 남용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발자들이 자주 혼동하는 부분&lt;/b&gt;은 &lt;code&gt;thenApply&lt;/code&gt;와 &lt;code&gt;thenCompose&lt;/code&gt;의 차이입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반: 중첩된 Future
CompletableFuture&amp;lt;CompletableFuture&amp;lt;User&amp;gt;&amp;gt; nested =
    getUserIdAsync()
        .thenApply(id -&amp;gt; fetchUserAsync(id));  // ❌ 이중 래핑

// 올바른 접근: flatMap 개념
CompletableFuture&amp;lt;User&amp;gt; flat =
    getUserIdAsync()
        .thenCompose(id -&amp;gt; fetchUserAsync(id));  // ✅ 평탄화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;thenCompose&lt;/code&gt;는 Stream의 &lt;code&gt;flatMap&lt;/code&gt;에 해당하며, &quot;Future를 반환하는 함수&quot;를 체이닝할 때 사용해야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;var 키워드: 타입 추론을 통한 가독성 향상&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도: 중복 제거, 타입 숨기기 아님&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JEP 286 설계자의 의도&lt;/b&gt;는 &quot;더 나은 가독성(cleaner/better readability)&quot;이지 타입 시스템 수정이 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;공식 JEP 286 예제&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;// 개선 전
URL url = new URL(&quot;http://www.oracle.com/&quot;);
URLConnection conn = url.openConnection();
Reader reader = new BufferedReader(
    new InputStreamReader(conn.getInputStream()));

// 개선 후: 더 읽기 쉬움
var url = new URL(&quot;http://www.oracle.com/&quot;);
var conn = url.openConnection();
var reader = new BufferedReader(
    new InputStreamReader(conn.getInputStream()));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설계 의도&lt;/b&gt;는 &quot;타입이 명확할 때 중복을 제거&quot;하는 것입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 타입 추론이 불가능한 곳에 var 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;커뮤니티 불만&lt;/b&gt;은 &quot;타입이 복잡해서 모르겠으니 var로 해결&quot;하는 패턴입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 설계 의도 위반
var result = someComplexBuilder()
    .withOption1()
    .withOption2()
    .build();  // 무슨 타입인지 알 수 없음

// IDE 없이는 코드 이해 불가
var data = repository.findSomething();  // ❌ 뭘 반환?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설계자의 경고&lt;/b&gt;: &quot;코드가 실제로 더 읽기 어려워진다. 타입을 알기 위해 소스를 열거나 디컴파일해야 한다.&quot; 이는 설계 의도의 정반대입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Java Serialization: &quot;재앙&quot; 수준의 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Brian Goetz의 공식 입장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Brian Goetz의 공식 입장&lt;/b&gt;은 &quot;Java Serialization은 재앙(disaster)이었다&quot;는 것입니다. &quot;마법적(magical) 동작&quot;과 &quot;언어 외적(extra-linguistic) 메커니즘&quot;으로 인해 객체 모델의 무결성을 훼손합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도 위반: 캡슐화 파괴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Serialization의 가장 큰 문제&lt;/b&gt;는 캡슐화를 완전히 우회한다는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public class BankAccount implements Serializable {
    private final BigDecimal balance;

    public BankAccount(BigDecimal balance) {
        if (balance.compareTo(BigDecimal.ZERO) &amp;lt; 0) {
            throw new IllegalArgumentException(&quot;음수 잔액 불가&quot;);
        }
        this.balance = balance;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 클래스는 생성자로 음수 잔액을 방어하지만, 역직렬화는 생성자를 우회하므로 &lt;b&gt;음수 잔액을 가진 BankAccount를 만들 수 있습니다&lt;/b&gt;. &quot;코드를 검사해도 정확성과 보안을 검증할 수 없게&quot; 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Brian Goetz의 발언&lt;/b&gt; (2021):&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;1997년에 악마와 거래를 했다. 플랫폼 성공에 중요했지만 끔찍한 방식으로 구현했다. 완전히 언어 밖에 존재하고 객체 모델 밖에 존재한다. 여기서 모든 악이 나온다. 캡슐화를 훼손하고, 진화를 어렵게 만들고, 보안을 추론하기 불가능하게 만든다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Records와 Serialization의 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Java 14+ Records는 Serialization 문제를 부분적으로 해결&lt;/b&gt;합니다. Record는 &quot;API가 곧 상태&quot;이므로, 역직렬화 시 &lt;b&gt;반드시 생성자를 거칩니다&lt;/b&gt;.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;record BankAccount(BigDecimal balance) implements Serializable {
    public BankAccount {
        if (balance.compareTo(BigDecimal.ZERO) &amp;lt; 0) {
            throw new IllegalArgumentException(&quot;음수 잔액 불가&quot;);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역직렬화가 생성자를 호출하므로 &quot;객체를 잘못된 상태로 만들 수 없습니다&quot;.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;sun.misc.Unsafe: &quot;아무도 사용하면 안 되는&quot; API&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OpenJDK의 공식 경고&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OpenJDK의 공식 경고&lt;/b&gt;는 &quot;sun.misc.Unsafe는 종국적으로 폐기(terminally deprecated)될 것&quot;이라는 점입니다. 그러나 수많은 라이브러리(Lombok, Netty, Hibernate)가 이를 사용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도: &quot;내부 전용&quot; 명시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Unsafe는 설계상 public API가 아닙니다&lt;/b&gt;. &lt;code&gt;sun.misc&lt;/code&gt; 패키지는 &quot;Java 표준이 아닌 내부 구현&quot;을 의미하며, JavaDoc에도 명시되지 않았습니다.&lt;/p&gt;
&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;14가지 사용 패턴이 발견&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;/li&gt;
&lt;li&gt;초기화되지 않은 객체 생성&lt;/li&gt;
&lt;li&gt;생성자를 우회한 필드 직접 조작&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: Unsafe로 캡슐화 우회&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 설계 의도 완전 위반
public class UnsafeUser {
    private static final Unsafe unsafe = getUnsafe();

    public void breakEncapsulation(Object obj, long offset, int value) {
        unsafe.putInt(obj, offset, value);  // ❌ private 필드 직접 수정
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Stack Overflow 경고&lt;/b&gt;: &quot;해커도 당신 인터페이스의 클라이언트가 될 수 있다. 바운드 체크 없는 메모리 접근은 재앙적 결과를 초래할 수 있다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Lombok의 문제&lt;/b&gt;: JDK 24에서 &lt;code&gt;objectFieldOffset&lt;/code&gt;이 제거 예정이며, Lombok은 이를 대체할 방법을 모색 중입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모던 자바의 새로운 API들은 각각 명확한 설계 의도를 가지고 있습니다:&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;Optional&lt;/b&gt;: 메서드 반환 타입으로만 사용하여 null을 명시적으로 표현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Stream&lt;/b&gt;: 복잡한 데이터 변환 파이프라인을 선언적으로 표현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CompletableFuture&lt;/b&gt;: 비동기 연산을 블로킹 없이 체이닝&lt;/li&gt;
&lt;li&gt;&lt;b&gt;var&lt;/b&gt;: 타입이 명확할 때 중복을 제거하여 가독성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 API들을 설계 의도에 맞게 사용하면 코드의 가독성과 유지보수성이 향상됩니다. 하지만 설계 의도를 무시하고 남용하면 오히려 코드 복잡도를 높이고 성능을 저하시키며, 시스템 설계에 구조적인 문제를 야기할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 것은 각 API의 설계 의도를 이해하고, 적절한 상황에서만 사용하는 것입니다. &quot;모던한 코드&quot;처럼 보이기 위해 억지로 사용하는 것은 진정한 시니어의 모습이 아닙니다. 문제에 맞는 적절한 도구를 선택할 줄 아는 것이 진정한 전문성입니다.&lt;/p&gt;
&lt;div align=&quot;center&quot;&gt;⁂&lt;/div&gt;</description>
      <category>개발/OOP</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/21</guid>
      <comments>https://pandaterry.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%EC%84%A4%EA%B3%84-%EC%9D%98%EB%8F%84%EB%A5%BC-%EB%B2%97%EC%96%B4%EB%82%98%EB%8A%94-API-%EC%82%AC%EC%9A%A9-%EC%82%AC%EB%A1%80#entry21comment</comments>
      <pubDate>Fri, 21 Nov 2025 23:28:49 +0900</pubDate>
    </item>
    <item>
      <title>JPA와 도메인 모델: 복잡한 조회 쿼리의 딜레마와 실전 해결책</title>
      <link>https://pandaterry.tistory.com/entry/JPA%EC%99%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EC%A1%B0%ED%9A%8C-%EC%BF%BC%EB%A6%AC%EC%9D%98-%EB%94%9C%EB%A0%88%EB%A7%88%EC%99%80-%EC%8B%A4%EC%A0%84-%ED%95%B4%EA%B2%B0%EC%B1%85</link>
      <description>&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;size16&quot;&gt;이는 해외 스프링부트 및 자바 커뮤니티에서도 오래 논의된 주제입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무에서 겪는 문제들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;객체지향과 SQL의 근본적 불일치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 모델은 비즈니스 로직과 불변성 보장을 위해 풍부한 연관관계를 가져야 합니다. 하지만 이는 조회 쿼리를 복잡하게 만듭니다. JPA는 CRUD 보일러플레이트를 피할 뿐만 아니라 관리되는 엔티티에서 작업할 때 대부분의 수동 레포지토리 호출을 피할 수 있게 해줍니다. 하지만 동작이 신비롭게 느껴질 수 있으며, &lt;b&gt;쓰기에는 편리하지만 읽기에는 제어권이 부족&lt;/b&gt;한 것이 문제입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;N+1 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관관계를 로딩할 때마다 추가 쿼리가 발생합니다. Reddit의 한 개발자는 &quot;거의 모든 JPA 메서드는 결국 N+1 유사 쿼리를 생성한다. findAll()을 호출하면 자식이 즉시 로딩으로 설정된 경우 각 부모 엔티티에 대해 N개의 추가 쿼리가 발생한다&quot;고 지적합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메모리 페이징 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JOIN FETCH와 페이지네이션을 함께 사용하면 Hibernate가 경고를 출력합니다:&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;WARN: HHH90003004: firstResult/maxResults specified with collection fetch;
applying in memory&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 &lt;b&gt;메모리에서 페이징&lt;/b&gt;이 발생한다는 의미입니다. 실행 계획을 보면 25개 Post를 가져오려고 했는데 실제로는 100,000개 행(10,000 Post &amp;times; 10 Comment)을 조회합니다. 쿼리 실행 시간이 &lt;b&gt;368ms&lt;/b&gt;나 걸립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;불필요한 데이터 로딩과 동적 쿼리의 복잡성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 컬럼만 조회하기 어렵고, &lt;code&gt;@Query&lt;/code&gt;는 정적 쿼리만 지원하며, Criteria API와 Specification은 지나치게 복잡하고 제한이 많습니다. 한 개발자는 &quot;왜 Spring Boot JPA가 이렇게 흔한 문제를 해결하지 못하는지 이해할 수 없다&quot;고 불만을 표출하며, 이 질문은 &lt;b&gt;24 upvotes&lt;/b&gt;를 받았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커뮤니티에서 오가는 실제 논의&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Reddit r/SpringBoot의 세 진영&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;복잡한 SQL 쿼리를 어떻게 구현하나요?&quot;&lt;/b&gt; 라는 질문에서 전형적인 고민이 드러납니다. 개발자는 &quot;여러 조인, 중첩 쿼리, 다양한 WHERE 절이 있는 복잡한 쿼리를 Spring JPA로 구현하면 비실용적이고 @Query 어노테이션은 읽기 어려운 코드가 된다&quot;고 토로합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 대한 커뮤니티 답변들은 크게 &lt;b&gt;세 진영&lt;/b&gt;으로 나뉩니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;JOOQ 진영&lt;/b&gt;: &quot;JOOQ를 써라, 나중에 고마워할 것이다. 복잡하고 타입 안전한 쿼리를 작성하기 정말 쉽다&quot; (5 upvotes)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네이티브 쿼리 진영&lt;/b&gt;: &quot;JDBC와 매퍼를 사용해 ResultSet을 매핑하라. JPA는 SQL의 모든 구문을 지원하지 않는다&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JPA 옹호 진영&lt;/b&gt;: &quot;뷰를 만들어서 JPA로 조회하거나, JdbcTemplate을 사용하라. 복잡한 쿼리가 몇 개만 있다면 JOOQ는 오버킬일 수 있다&quot; (5 upvotes)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;StackOverflow의 근본적 질문&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;JPA 엔티티를 도메인 모델로 쓸 수 있나?&quot;라는 질문에서 &lt;b&gt;가장 많은 추천을 받은 답변&lt;/b&gt;(5 upvotes)은 세 가지 접근법을 제시합니다:&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;: &quot;DDD에 '고정된 규칙'이 있다고 말하는 사람은 없다. JPA 엔티티를 도메인 모델로 사용하되 DDD 규칙을 따르라&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 사용&lt;/b&gt;: &quot;JPA 엔티티를 도메인 엔티티로 변환하기 전에 캐시에 저장하라&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JPA를 버리고 SQL 직접 사용&lt;/b&gt;: &quot;도메인 엔티티가 이미 있다면, SQL을 직접 작성하면 JPA 오버헤드 없이 완벽하게 코드를 튜닝할 자유를 얻는다&quot;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책 비교 분석&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CQRS 패턴: 읽기/쓰기 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 개념&lt;/b&gt;: Command(명령)는 CUD 작업을 도메인 모델로 객체지향적으로 처리하고, Query(조회)는 별도 모델로 분리하여 성능 최적화에 집중합니다.&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 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;b&gt;같은 DB 내에서 논리적으로 분리&lt;/b&gt;하는 방식이 일반적입니다. Reddit의 한 개발자는 &quot;풀 DDD 방식으로 엔티티, 값 객체, 애그리거트를 만들었고, 조회 모델은 이 모든 걸 우회해서 Dapper 쿼리로 엔드포인트가 반환할 모델에 직접 매핑했다. 단일 스키마에서 모두 처리했다&quot;고 공유합니다 (12 upvotes).&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DTO Projection: 필요한 데이터만 조회&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개념&lt;/b&gt;: 엔티티가 아닌 DTO로 직접 조회하여 필요한 컬럼만 SELECT합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;: 메모리 사용량 감소, 스냅샷 생성이나 변경 감지가 없어 성능 향상, Vlad Mihalcea는 쿼리 시간이 &lt;b&gt;368ms에서 8ms로&lt;/b&gt; 단축된다고 실측 데이터를 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;: DTO 클래스 추가 관리 필요&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;h3 data-ke-size=&quot;size23&quot;&gt;QueryDSL: 타입 안전한 동적 쿼리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개념&lt;/b&gt;: 타입 안전한 동적 쿼리를 작성할 수 있는 도구입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;: 컴파일 타임 검증, 동적 쿼리 작성 용이, JPA의 &lt;code&gt;Pageable&lt;/code&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;&lt;b&gt;적용 시나리오&lt;/b&gt;: 복잡한 동적 조건이 많은 검색 기능&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Blaze-Persistence Entity Views: 자동 쿼리 최적화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개념&lt;/b&gt;: JPA 위에서 동작하며, 인터페이스로 View를 정의하면 자동으로 최적화된 쿼리를 생성해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;: 인터페이스 기반으로 간단한 정의, 자동 최적화, JOIN FETCH가 있는 쿼리를 보고 자동으로 id 서브쿼리를 생성하여 페이지네이션 쿼리를 만듭니다&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;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 코드 예제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황: 도메인 엔티티와 문제가 있는 조회&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
public class Post {
    @Id
    @GeneratedValue
    private Long id;

    private String title;
    private String content;

    @OneToMany(mappedBy = &quot;post&quot;, fetch = FetchType.LAZY)
    private List&amp;lt;Comment&amp;gt; comments = new ArrayList&amp;lt;&amp;gt;();

    @ManyToOne(fetch = FetchType.LAZY)
    private User author;
}

@Entity
public class Comment {
    @Id
    @GeneratedValue
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;
}

// 문제가 있는 조회 코드
@Query(&quot;select p from Post p left join fetch p.comments&quot;)
Page&amp;lt;Post&amp;gt; findAllWithComments(Pageable pageable);&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;이 쿼리는 메모리 페이징을 발생시키고, N+1 문제를 야기합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 1: CQRS 패턴 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Command Repository (쓰기용)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;public interface PostCommandRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
    // 쓰기 작업은 도메인 모델 사용
    Post save(Post post);
    void deleteById(Long id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Query Repository (읽기용, DTO 직접 조회)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public interface PostQueryRepository {
    Page&amp;lt;PostDto&amp;gt; findAllPosts(Pageable pageable);
}

@Repository
@RequiredArgsConstructor
public class PostQueryRepositoryImpl implements PostQueryRepository {
    private final EntityManager em;

    @Override
    @Transactional(readOnly = true)
    public Page&amp;lt;PostDto&amp;gt; findAllPosts(Pageable pageable) {
        // DTO로 직접 조회
        String jpql = &quot;SELECT new com.example.dto.PostDto(p.id, p.title, u.name, COUNT(c.id)) &quot; +
                      &quot;FROM Post p &quot; +
                      &quot;LEFT JOIN p.author u &quot; +
                      &quot;LEFT JOIN p.comments c &quot; +
                      &quot;GROUP BY p.id, p.title, u.name&quot;;

        List&amp;lt;PostDto&amp;gt; content = em.createQuery(jpql, PostDto.class)
            .setFirstResult((int) pageable.getOffset())
            .setMaxResults(pageable.getPageSize())
            .getResultList();

        Long total = em.createQuery(&quot;SELECT COUNT(DISTINCT p.id) FROM Post p&quot;, Long.class)
            .getSingleResult();

        return new PageImpl&amp;lt;&amp;gt;(content, pageable, total);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Service 레이어&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PostService {
    private final PostCommandRepository postCommandRepository;
    private final PostQueryRepository postQueryRepository;

    // 쓰기: 도메인 모델 사용
    public Post createPost(String title, String content, User author) {
        Post post = new Post(title, content, author);
        return postCommandRepository.save(post);
    }

    // 읽기: DTO 직접 조회
    public Page&amp;lt;PostDto&amp;gt; getPosts(Pageable pageable) {
        return postQueryRepository.findAllPosts(pageable);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 2: DTO Projection 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인터페이스 기반 프로젝션&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface PostProjection {
    Long getId();
    String getTitle();
    String getAuthorName();
    Long getCommentCount();
}

public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
    @Query(&quot;SELECT p.id as id, p.title as title, u.name as authorName, &quot; +
           &quot;COUNT(c.id) as commentCount &quot; +
           &quot;FROM Post p &quot; +
           &quot;LEFT JOIN p.author u &quot; +
           &quot;LEFT JOIN p.comments c &quot; +
           &quot;GROUP BY p.id, p.title, u.name&quot;)
    Page&amp;lt;PostProjection&amp;gt; findAllProjected(Pageable pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클래스 기반 DTO 프로젝션&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public class PostDto {
    private Long id;
    private String title;
    private String authorName;
    private Long commentCount;

    public PostDto(Long id, String title, String authorName, Long commentCount) {
        this.id = id;
        this.title = title;
        this.authorName = authorName;
        this.commentCount = commentCount;
    }
    // getters...
}

@Query(&quot;SELECT new com.example.dto.PostDto(p.id, p.title, u.name, COUNT(c.id)) &quot; +
       &quot;FROM Post p &quot; +
       &quot;LEFT JOIN p.author u &quot; +
       &quot;LEFT JOIN p.comments c &quot; +
       &quot;GROUP BY p.id, p.title, u.name&quot;)
Page&amp;lt;PostDto&amp;gt; findPostDtos(Pageable pageable);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@Transactional(readOnly = true) 최적화&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;

    @Transactional(readOnly = true)  // 스냅샷 생성 생략, 변경 감지 생략
    public Page&amp;lt;PostDto&amp;gt; getPosts(Pageable pageable) {
        return postRepository.findPostDtos(pageable);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 3: QueryDSL 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;build.gradle 설정&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;QueryDSL을 활용한 동적 쿼리&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;@Repository
@RequiredArgsConstructor
public class PostQueryRepository {
    private final JPAQueryFactory queryFactory;

    public Page&amp;lt;PostDto&amp;gt; searchPosts(PostSearchCondition condition, Pageable pageable) {
        // 데이터 조회 쿼리
        List&amp;lt;PostDto&amp;gt; content = queryFactory
            .select(new QPostDto(
                post.id,
                post.title,
                user.name,
                comment.id.count()
            ))
            .from(post)
            .leftJoin(post.author, user)
            .leftJoin(post.comments, comment)
            .where(
                titleContains(condition.getTitle()),
                authorNameEq(condition.getAuthorName()),
                hasComments(condition.getHasComments())
            )
            .groupBy(post.id, post.title, user.name)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        // count 쿼리 최적화 (join 제거)
        JPAQuery&amp;lt;Long&amp;gt; countQuery = queryFactory
            .select(post.countDistinct())
            .from(post)
            .leftJoin(post.author, user)
            .leftJoin(post.comments, comment)
            .where(
                titleContains(condition.getTitle()),
                authorNameEq(condition.getAuthorName()),
                hasComments(condition.getHasComments())
            );

        // PageableExecutionUtils: 첫/마지막 페이지에서 불필요한 count 쿼리 생략
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    private BooleanExpression titleContains(String title) {
        return hasText(title) ? post.title.contains(title) : null;
    }

    private BooleanExpression authorNameEq(String authorName) {
        return hasText(authorName) ? user.name.eq(authorName) : null;
    }

    private BooleanExpression hasComments(Boolean hasComments) {
        return hasComments != null &amp;amp;&amp;amp; hasComments
            ? comment.id.isNotNull()
            : null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 4: 페이지네이션 N+1 문제 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계 페이지네이션&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Repository
@RequiredArgsConstructor
public class PostRepository {
    private final EntityManager em;

    public Page&amp;lt;Post&amp;gt; findAllWithComments(Pageable pageable) {
        // 1단계: 부모 ID만 페이징하여 조회
        List&amp;lt;Long&amp;gt; postIds = em.createQuery(
            &quot;SELECT p.id FROM Post p ORDER BY p.id DESC&quot;, Long.class)
            .setFirstResult((int) pageable.getOffset())
            .setMaxResults(pageable.getPageSize())
            .getResultList();

        if (postIds.isEmpty()) {
            return new PageImpl&amp;lt;&amp;gt;(Collections.emptyList(), pageable, 0);
        }

        // 2단계: ID로 부모 엔티티 조회
        List&amp;lt;Post&amp;gt; posts = em.createQuery(
            &quot;SELECT DISTINCT p FROM Post p WHERE p.id IN :ids&quot;, Post.class)
            .setParameter(&quot;ids&quot;, postIds)
            .getResultList();

        // 3단계: 자식 엔티티를 bulk로 조회 (IN 절 활용)
        Map&amp;lt;Long, List&amp;lt;Comment&amp;gt;&amp;gt; commentsMap = em.createQuery(
            &quot;SELECT c FROM Comment c WHERE c.post.id IN :postIds&quot;, Comment.class)
            .setParameter(&quot;postIds&quot;, postIds)
            .getResultList()
            .stream()
            .collect(Collectors.groupingBy(c -&amp;gt; c.getPost().getId()));

        // Java에서 조합하여 반환
        posts.forEach(post -&amp;gt; post.setComments(
            commentsMap.getOrDefault(post.getId(), Collections.emptyList())));

        Long total = em.createQuery(&quot;SELECT COUNT(p) FROM Post p&quot;, Long.class)
            .getSingleResult();

        return new PageImpl&amp;lt;&amp;gt;(posts, pageable, total);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@BatchSize 어노테이션 활용&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
public class Post {
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = &quot;post&quot;)
    @BatchSize(size = 100)  // N+1 문제를 N/batchSize + 1로 줄여줌
    private List&amp;lt;Comment&amp;gt; comments = new ArrayList&amp;lt;&amp;gt;();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 5: Cursor 기반 페이지네이션&lt;/h3&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
    // Offset 방식의 한계를 극복
    List&amp;lt;Post&amp;gt; findTop10ByIdLessThanOrderByIdDesc(Long cursor);
}

@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;

    public List&amp;lt;PostDto&amp;gt; getPostsByCursor(Long cursor, int size) {
        List&amp;lt;Post&amp;gt; posts = cursor == null
            ? postRepository.findTop10ByOrderByIdDesc()
            : postRepository.findTop10ByIdLessThanOrderByIdDesc(cursor);

        return posts.stream()
            .map(this::toDto)
            .collect(Collectors.toList());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 6: JPA Specification 패턴&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt;,
                                          JpaSpecificationExecutor&amp;lt;Post&amp;gt; {
}

public class PostSpecification {
    public static Specification&amp;lt;Post&amp;gt; hasTitle(String title) {
        return (root, query, cb) -&amp;gt;
            hasText(title) ? cb.like(root.get(&quot;title&quot;), &quot;%&quot; + title + &quot;%&quot;) : null;
    }

    public static Specification&amp;lt;Post&amp;gt; hasAuthor(String authorName) {
        return (root, query, cb) -&amp;gt;
            hasText(authorName)
                ? cb.equal(root.join(&quot;author&quot;).get(&quot;name&quot;), authorName)
                : null;
    }
}

@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;

    public Page&amp;lt;Post&amp;gt; search(PostSearchCondition condition, Pageable pageable) {
        Specification&amp;lt;Post&amp;gt; spec = Specification
            .where(PostSpecification.hasTitle(condition.getTitle()))
            .and(PostSpecification.hasAuthor(condition.getAuthorName()));

        return postRepository.findAll(spec, pageable);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무 선택 가이드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stack Overflow와 Reddit의 Spring Boot 커뮤니티에서 제시되는 실무 패턴은 다음과 같습니다:&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;: Spring Data JPA의 Pageable + DTO 프로젝션&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중간 복잡도&lt;/b&gt;: QueryDSL + PageableExecutionUtils&lt;/li&gt;
&lt;li&gt;&lt;b&gt;매우 복잡한 조회&lt;/b&gt;: Native Query 또는 Blaze-Persistence&lt;/li&gt;
&lt;li&gt;&lt;b&gt;도메인 로직&lt;/b&gt;: 객체지향적 도메인 모델 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;아키텍처&lt;/b&gt;: CQRS로 읽기/쓰기 명확히 분리&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 것은 조회와 변경의 요구사항이 다르다는 점을 인식하고, 각각에 최적화된 방식을 적용하는 것입니다. 도메인 모델의 객체지향적 장점은 유지하면서도, 복잡한 조회에서는 성능을 위해 실용적인 접근을 취하는 균형이 필요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모놀리식에서도 CQRS는 매우 일반적이며, 오히려 &lt;b&gt;페이지네이션이 필요한 복잡한 조회 문제를 해결하기 위한 가장 현실적인 접근법&lt;/b&gt;으로 받아들여지고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 상황에 맞는 해결책을 선택하는 것이 중요하며, 단일 모델로 모든 것을 해결하려는 시도의 한계를 인식하고 읽기와 쓰기를 분리하는 것이 자연스러운 귀결입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 도메인 모델의 풍부함을 유지하면서도, 복잡한 조회에서는 DTO Projection, QueryDSL, 3단계 페이지네이션 등의 기법을 활용하여 성능을 최적화하는 것이 핵심입니다.&lt;/p&gt;</description>
      <category>개발/OOP</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/20</guid>
      <comments>https://pandaterry.tistory.com/entry/JPA%EC%99%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EC%A1%B0%ED%9A%8C-%EC%BF%BC%EB%A6%AC%EC%9D%98-%EB%94%9C%EB%A0%88%EB%A7%88%EC%99%80-%EC%8B%A4%EC%A0%84-%ED%95%B4%EA%B2%B0%EC%B1%85#entry20comment</comments>
      <pubDate>Thu, 20 Nov 2025 23:45:08 +0900</pubDate>
    </item>
    <item>
      <title>[책 | 오브젝트] CH5. 책임 할당하기</title>
      <link>https://pandaterry.tistory.com/entry/%EC%B1%85-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-CH5-%EC%B1%85%EC%9E%84-%ED%95%A0%EB%8B%B9%ED%95%98%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;오브젝트 책 표지.jpg&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buIESR/dJMb99Lwrdc/HT8mh4cJymAK60vgq4f3k0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buIESR/dJMb99Lwrdc/HT8mh4cJymAK60vgq4f3k0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buIESR/dJMb99Lwrdc/HT8mh4cJymAK60vgq4f3k0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuIESR%2FdJMb99Lwrdc%2FHT8mh4cJymAK60vgq4f3k0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;500&quot; data-filename=&quot;오브젝트 책 표지.jpg&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;

&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;CH5. 책임 할당하기 - GRASP 패턴과 책임 주도 설계&lt;/title&gt;
    &lt;style&gt;
      h1 {
        font-size: 2.5em;
        margin-top: 0;
        margin-bottom: 20px;
        color: #2c3e50;
        border-bottom: 3px solid #3498db;
        padding-bottom: 10px;
      }

      h2 {
        font-size: 2em;
        margin-top: 60px;
        margin-bottom: 20px;
        color: #34495e;
        border-left: 5px solid #3498db;
        padding-left: 15px;
      }

      h3 {
        font-size: 1.5em;
        margin-top: 40px;
        margin-bottom: 15px;
        color: #555;
      }

      h4 {
        font-size: 1.2em;
        margin-top: 30px;
        margin-bottom: 10px;
        color: #666;
      }

      p {
        margin-bottom: 15px;
        text-align: justify;
      }

      ul,
      ol {
        margin-left: 30px;
        margin-bottom: 20px;
      }

      li {
        margin-bottom: 10px;
      }

      code {
        background-color: #f4f4f4;
        padding: 2px 6px;
        border-radius: 3px;
        font-family: &quot;Courier New&quot;, Courier, monospace;
        font-size: 0.9em;
        color: #c7254e;
      }

      pre {
        background-color: #282c34;
        color: #abb2bf;
        padding: 20px;
        border-radius: 8px;
        overflow-x: auto;
        margin: 20px 0;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      }

      pre code {
        background: none;
        padding: 0;
        color: inherit;
        font-size: 0.9em;
        line-height: 1.6;
      }

      .keyword {
        color: #c678dd;
      }
      .string {
        color: #98c379;
      }
      .comment {
        color: #5c6370;
        font-style: italic;
      }
      .type {
        color: #e5c07b;
      }
      .annotation {
        color: #e06c75;
      }

      blockquote {
        border-left: 4px solid #3498db;
        padding-left: 20px;
        margin: 20px 0;
        color: #666;
        font-style: italic;
      }

      hr {
        border: none;
        border-top: 2px solid #ecf0f1;
        margin: 40px 0;
      }

      .highlight-box {
        background-color: #fff3cd;
        border-left: 4px solid #ffc107;
        padding: 15px;
        margin: 20px 0;
        border-radius: 4px;
      }

      .insight-box {
        background-color: #d1ecf1;
        border-left: 4px solid #0c5460;
        padding: 15px;
        margin: 20px 0;
        border-radius: 4px;
      }

      .code-file {
        font-size: 0.85em;
        color: #95a5a6;
        margin-bottom: 5px;
        font-style: italic;
      }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;CH5. 책임 할당하기&lt;/h1&gt;

    &lt;section&gt;
      &lt;h2&gt;도입부&lt;/h2&gt;

      &lt;div class=&quot;highlight-box&quot;&gt;
        &lt;h4&gt;공감이 되는 문장&lt;/h4&gt;
        &lt;p&gt;
          &lt;strong
            &gt;책임 할당하는 것의 가장 어려운 지점은 어떤 객체에게 어떤 책임을
            할당할지 정하는 것이다.&lt;/strong
          &gt;
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;p&gt;
        객체지향 설계에서 가장 중요한 것은 올바른 책임 할당입니다. 하지만 어떤
        객체에게 어떤 책임을 할당해야 할지 결정하는 것은 쉽지 않습니다. 책임
        할당 작업 자체가 트레이드오프 과정이며, 다양한 관점에서 설계를 평가할 수
        있어야 합니다.
      &lt;/p&gt;

      &lt;p&gt;
        이번 장에서는 &lt;strong&gt;GRASP 패턴&lt;/strong&gt;을 사용하여 책임을 올바르게
        할당하는 방법을 학습합니다. GRASP는 General Responsibility Assignment
        Software Pattern의 약자로, 객체에게 책임을 할당할 때 지침으로 삼을 수
        있는 원칙들의 집합입니다.
      &lt;/p&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;1. 책임 주도 설계를 향해&lt;/h2&gt;

      &lt;h3&gt;데이터보단 행동을 먼저 결정&lt;/h3&gt;

      &lt;p&gt;
        초보자들은 종종 행동보다 상태에만 초점을 맞춥니다. 하지만 데이터에
        초점을 맞추면 객체의 캡슐화가 약화되기 때문에 낮은 응집도와 높은 결합도를
        가진 객체들로 넘쳐나게 됩니다.
      &lt;/p&gt;

      &lt;div class=&quot;highlight-box&quot;&gt;
        &lt;p&gt;
          &lt;strong
            &gt;객체지향 설계에서는 데이터가 아닌 행동을 먼저 결정해야
            합니다.&lt;/strong
          &gt;
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;h3&gt;협력이라는 문맥 안에서 책임을 결정&lt;/h3&gt;

      &lt;p&gt;
        협력에 적합한 책임이란 전송자에게 적합한 책임을 의미합니다. 협력은
        참고로 전송자와 수신자가 있습니다. 클라이언트 의도에 맞는 책임을 할당해야
        합니다.
      &lt;/p&gt;

      &lt;p&gt;
        협력에 적합한 책임을 할당하려면 메시지를 선택한 후에 객체를 선택해야
        합니다. 클라이언트(전송자)는 어떤 객체가 메시지를 수신할지 모릅니다.
        단지 누가 받을지는 모르지만 의도를 전달할 뿐입니다.
      &lt;/p&gt;

      &lt;p&gt;
        수신자에 대한 어떠한 것도 가정할 수 없기에 메시지 전송자의 관점에서는
        수신자가 완전히 캡슐화됩니다.
      &lt;/p&gt;

      &lt;h3&gt;책임 주도 설계&lt;/h3&gt;

      &lt;p&gt;책임 주도 설계는 다음과 같은 프로세스를 따릅니다:&lt;/p&gt;

      &lt;ol&gt;
        &lt;li&gt;시스템의 책임을 파악합니다.&lt;/li&gt;
        &lt;li&gt;시스템 책임을 더 작은 책임으로 분할합니다.&lt;/li&gt;
        &lt;li&gt;책임을 수행할 수 있는 객체 또는 역할에 할당합니다.&lt;/li&gt;
        &lt;li&gt;
          책임을 수행하던 도중, 다른 객체의 도움이 필요하다면 또 적절한 객체
          혹은 역할을 찾아 할당합니다.
        &lt;/li&gt;
        &lt;li&gt;이렇게 할당함으로써 두 객체가 협력하게 됩니다.&lt;/li&gt;
      &lt;/ol&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;2. 책임 할당을 위한 GRASP 패턴&lt;/h2&gt;

      &lt;blockquote&gt;
        GRASP 패턴은 General Responsibility Assignment Software Pattern의
        약자로 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의
        집합입니다.
      &lt;/blockquote&gt;

      &lt;h3&gt;도메인 개념에서 출발하기&lt;/h3&gt;

      &lt;p&gt;
        설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려보는 것도
        유용합니다. 영화 예매 시스템의 도메인 모델은 다음과 같습니다:
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;도메인 모델 다이어그램&lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;comment&quot;&gt;Screening &quot;1&quot; -- &quot;0..n&quot; Reservation : 예매
Screening &quot;n&quot; -- &quot;1&quot; Movie : 영화
Movie &amp;lt;|-- 금액할인영화
Movie &amp;lt;|-- 비율할인영화
Movie &quot;1&quot; -- &quot;1..n&quot; DiscountCondition : 할인조건
DiscountCondition &amp;lt;|-- 순번조건
DiscountCondition &amp;lt;|-- 기간조건&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;
        여기서 중요한 것은 완벽하게 도메인을 정리하는 것이 아닌, 이렇게
        시각화해보는 작업입니다. 너무 많은 시간을 들일 필요는 없습니다.
      &lt;/p&gt;

      &lt;h3&gt;정보 전문가에게 책임을 할당&lt;/h3&gt;

      &lt;h4&gt;1. 시스템의 책임 파악&lt;/h4&gt;

      &lt;p&gt;
        애플리케이션(시스템)이 제공해야 하는 기능을 앱의 책임으로 생각하고 이걸
        책임질 첫 번째 객체를 선택해야 합니다. 영화를 예매하는 것이 시스템의
        책임입니다. 그럼 첫 메시지는 &lt;code&gt;'예매하라'&lt;/code&gt;가 됩니다.
      &lt;/p&gt;

      &lt;h4&gt;2. 예매하라 - Screening에게 책임 할당&lt;/h4&gt;

      &lt;p&gt;
        이 메시지를 수신하는 객체는 무엇이 되어야 할까요? 이 메시지를 수행할
        정보를 가장 잘 알고 있는 객체, 즉 &lt;strong&gt;정보 전문가&lt;/strong&gt;에게
        할당해야 합니다.
      &lt;/p&gt;

      &lt;div class=&quot;highlight-box&quot;&gt;
        &lt;p&gt;
          &lt;strong&gt;중요한 포인트&lt;/strong&gt;: '정보 == 데이터'가 아닙니다. 책임을
          수행하는 정보를 알고 있다고 해서 그 정보를 저장할 필요가 없습니다.
          왜냐하면 그런 다른 객체를 알고 있을 수도 있기 때문입니다.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;p&gt;
        '상영'이 그 후보가 될 텐데, 상태와 별개로 '상영'은 영화에 대한 정보와
        상영 시간, 상영 순번처럼 영화 예매에 필요한 다양한 정보를 알고 있습니다.
        이것은 상태를 정의하기 전에 '상영'에 대해 개념적으로 알고 있는
        정보입니다.
      &lt;/p&gt;

      &lt;p&gt;
        그래서 상영에 예매를 위한 책임을 할당합니다. 여기서 중요한 포인트는
        Screening이 책임을 수행하는데 스스로 처리할 수 없는 작업이 무엇인지
        가릴 정도의 수준이면 됩니다. 모두 다 알 필요는 없고 많이 아는 전문가면
        되기 때문입니다. 혼자 못하면 외부에 도움을 요청해서 협력하면 됩니다.
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/Screening.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; movie;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; sequence;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalDateTime&lt;/span&gt; whenScreened;

    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Reservation&lt;/span&gt; reserve(&lt;span class=&quot;type&quot;&gt;Customer&lt;/span&gt; customer, &lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; audienceCount) {
        &lt;span class=&quot;keyword&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Reservation&lt;/span&gt;(customer, &lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;, calculateFee(audienceCount));
    }

    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateFee(&lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; audienceCount) {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; movie.calculateMovieFee(&lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;);
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;
        &lt;code&gt;Screening&lt;/code&gt;은 예매 책임을 수행하기 위해 예매 가격을 계산해야
        합니다. 하지만 Screening은 가격 계산을 위해 필요한 정보를 모릅니다.
        그래서 외부에 요청해야 합니다.
      &lt;/p&gt;

      &lt;h4&gt;3. 예매 가격을 계산하라 - Movie에게 책임 할당&lt;/h4&gt;

      &lt;p&gt;
        영화 가격을 가장 잘 알고 있는 객체는 &lt;code&gt;Movie&lt;/code&gt;입니다. 영화별로
        가격이 다른 경우를 가정한다고 보면 됩니다. 그런데 가격을 계산하려고 하니
        할인 가격을 고려해야 한다는 사실을 알게 되었습니다. 할인 정보는 Movie는
        알지 못합니다. 할인 여부를 판단하지도 못합니다.
      &lt;/p&gt;

      &lt;h4&gt;4. 할인 여부를 판단하라 - DiscountCondition에게 책임 할당&lt;/h4&gt;

      &lt;p&gt;
        그래서 할인 여부 판단의 정보 전문가인 &lt;code&gt;DiscountCondition&lt;/code&gt;에
        책임을 할당합니다.
      &lt;/p&gt;

      &lt;h3&gt;높은 응집도와 낮은 결합도&lt;/h3&gt;

      &lt;p&gt;
        동일 기능을 구현하는 방법은 무수히 많습니다. 이 많은 방법 중에 올바른
        방법으로 가기 위해 정보 전문가 패턴이 있는 것입니다.
      &lt;/p&gt;

      &lt;h4&gt;Screening이 DiscountCondition과 협력하는 경우&lt;/h4&gt;

      &lt;p&gt;
        Screening이 Movie 대신 DiscountCondition과 협력하는 방법도 있습니다.
        하지만 이 경우 결합도가 높아집니다. 왜냐하면 Movie와 DiscountCondition과의
        결합이 있고, Screening과 DiscountCondition이 추가되는 거라 결합도가
        높아집니다. 결국 결합도가 높아져서 좋지 않은 설계가 됩니다.
      &lt;/p&gt;

      &lt;h3&gt;창조자에게 객체 생성 책임을 할당&lt;/h3&gt;

      &lt;p&gt;
        Screening이 Reservation이라는 예매를 생성하도록 하는 것을 의미합니다.
        Screening은 예매에 대한 정보를 가장 잘 알고 있으므로, Reservation 객체를
        생성하는 책임을 가집니다.
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/Reservation.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Reservation&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Customer&lt;/span&gt; customer;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee;
    
    &lt;span class=&quot;comment&quot;&gt;// 생성자 및 getter...&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;3. 구현을 통한 검증&lt;/h2&gt;

      &lt;p&gt;
        이 부분은 chapter05 디렉토리에 있는 코드를 참고하여 실제 구현을 통해
        설계를 검증해보겠습니다.
      &lt;/p&gt;

      &lt;h3&gt;코드의 변경 이유를 찾기&lt;/h3&gt;

      &lt;h4&gt;1. 인스턴스 변수가 초기화되는 시점을 살펴보자&lt;/h4&gt;

      &lt;p&gt;
        응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께
        초기화합니다. 하지만 &lt;code&gt;DiscountCondition_legacy&lt;/code&gt;는 type에 따라
        부분적으로 초기화를 합니다. 이는 응집도가 낮다는 것입니다.
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/DiscountCondition_legacy.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountCondition_legacy&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt; type;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; sequence;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DayOfWeek&lt;/span&gt; dayOfWeek;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; startTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; endTime;

    &lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isSatisfiedBy(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (type == &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt;.PERIOD) {
            &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; isSatisfiedByPeriod(screening);
        }
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; isSatisfiedBySequence(screening);
    }

    &lt;span class=&quot;keyword&quot;&gt;private boolean&lt;/span&gt; isSatisfiedByPeriod(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &amp;&amp;
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) &amp;lt;= 0 &amp;&amp;
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) &amp;gt;= 0;
    }

    &lt;span class=&quot;keyword&quot;&gt;private boolean&lt;/span&gt; isSatisfiedBySequence(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; sequence == screening.getSequence();
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;
        함께 초기화되는 속성을 기준으로 코드를 분리해야 합니다. 순번 조건일 때는
        &lt;code&gt;sequence&lt;/code&gt;만 사용하고, 기간 조건일 때는
        &lt;code&gt;dayOfWeek&lt;/code&gt;, &lt;span class=&quot;string&quot;&gt;startTime&lt;/span&gt;,
        &lt;code&gt;endTime&lt;/code&gt;만 사용합니다.
      &lt;/p&gt;

      &lt;h4&gt;2. 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보자&lt;/h4&gt;

      &lt;p&gt;
        모든 메서드가 객체의 모든 속성을 사용한다면 응집도가 높습니다. 하지만
        메서드들 간에 사용하는 속성이 나뉜다면 응집도가 낮습니다.
        &lt;code&gt;isSatisfiedByPeriod&lt;/code&gt;, &lt;code&gt;isSatisfiedBySequence&lt;/code&gt;가
        바로 그 예입니다.
      &lt;/p&gt;

      &lt;h3&gt;타입 분리하기&lt;/h3&gt;

      &lt;p&gt;
        &lt;code&gt;DiscountCondition&lt;/code&gt;을 각각 &lt;code&gt;SequenceCondition&lt;/code&gt;과
        &lt;code&gt;PeriodCondition&lt;/code&gt;으로 분리하는 것입니다.
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/DiscountCondition.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;boolean&lt;/span&gt; isSatisfiedBy(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening);
}&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/SequenceCondition.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;SequenceCondition&lt;/span&gt; &lt;span class=&quot;keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; sequence;

    &lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isSatisfiedBy(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; sequence == screening.getSequence();
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/PeriodCondition.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;PeriodCondition&lt;/span&gt; &lt;span class=&quot;keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DayOfWeek&lt;/span&gt; dayOfWeek;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; startTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; endTime;

    &lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isSatisfiedBy(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &amp;&amp;
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) &amp;lt;= 0 &amp;&amp;
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) &amp;gt;= 0;
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;
        이제 각 클래스는 자신이 필요한 속성만 가지고 있으며, 모든 메서드가 모든
        속성을 사용합니다. 응집도가 높아졌습니다.
      &lt;/p&gt;

      &lt;h3&gt;변경으로부터 보호하기&lt;/h3&gt;

      &lt;p&gt;
        &lt;code&gt;PeriodCondition&lt;/code&gt;은 기간 조건이 변경되면 변경이 이뤄지고,
        &lt;code&gt;SequenceCondition&lt;/code&gt;은 순번 조건이 변경되면 변경이 가해집니다.
        서로 변경의 이유가 다른 것입니다.
      &lt;/p&gt;

      &lt;p&gt;그럼 만약에 새로운 할인 조건이 추가된다면?&lt;/p&gt;

      &lt;ul&gt;
        &lt;li&gt;
          어차피 &lt;code&gt;DiscountCondition&lt;/code&gt;으로 캡슐화되어 있기 때문에
          상관이 없습니다. 이를 상속받는 추가 조건을 만들면 됩니다.
        &lt;/li&gt;
        &lt;li&gt;
          이렇게 변경으로부터 보호하는 것을 GRASP 패턴에서
          &lt;strong&gt;변경 보호&lt;/strong&gt;라고 합니다.
        &lt;/li&gt;
        &lt;li&gt;
          &lt;code&gt;DiscountCondition&lt;/code&gt;처럼 다형성을 통해 하나의 클래스가 여러
          타입의 행동을 하는 것을 분해하여 책임을 분산시킬 수 있다는 것을 알게
          되었습니다.
        &lt;/li&gt;
      &lt;/ul&gt;

      &lt;h3&gt;Movie 클래스 개선하기&lt;/h3&gt;

      &lt;p&gt;
        chapter05 디렉토리의 &lt;code&gt;Movie&lt;/code&gt;는 추상 클래스로 변경되었고,
        &lt;code&gt;AmountDiscountMovie&lt;/code&gt;, &lt;code&gt;PercentDiscountMovie&lt;/code&gt;,
        &lt;code&gt;NoneDiscountMovie&lt;/code&gt;로 분리되었습니다.
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/Movie.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public abstract class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;String&lt;/span&gt; title;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Duration&lt;/span&gt; runningTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt;&amp;gt; discountConditions;

    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateMovieFee(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (isDiscountable(screening)) {
            &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; fee.minus(calculateDiscountAmount());
        }
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; fee;
    }

    &lt;span class=&quot;keyword&quot;&gt;private boolean&lt;/span&gt; isDiscountable(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening) {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; discountConditions.stream()
                .anyMatch(condition -&amp;gt; condition.isSatisfiedBy(screening));
    }

    &lt;span class=&quot;keyword&quot;&gt;abstract protected&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateDiscountAmount();
}&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;
        &lt;code&gt;Movie&lt;/code&gt;는 이제 추상 클래스가 되었고, 할인 금액 계산은 추상
        메서드로 위임했습니다. 각 할인 정책별로 구체적인 구현을 제공합니다.
      &lt;/p&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/AmountDiscountMovie.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;AmountDiscountMovie&lt;/span&gt; &lt;span class=&quot;keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; discountAmount;

    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;AmountDiscountMovie&lt;/span&gt;(&lt;span class=&quot;type&quot;&gt;String&lt;/span&gt; title, &lt;span class=&quot;type&quot;&gt;Duration&lt;/span&gt; runningTime, 
            &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee, &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; discountAmount, 
            &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt;... discountConditions) {
        &lt;span class=&quot;keyword&quot;&gt;super&lt;/span&gt;(title, runningTime, fee, discountConditions);
        &lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;.discountAmount = discountAmount;
    }

    &lt;span class=&quot;annotation&quot;&gt;@Override&lt;/span&gt;
    &lt;span class=&quot;keyword&quot;&gt;protected&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateDiscountAmount() {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; discountAmount;
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/PercentDiscountMovie.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;PercentDiscountMovie&lt;/span&gt; &lt;span class=&quot;keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private double&lt;/span&gt; percent;

    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;PercentDiscountMovie&lt;/span&gt;(&lt;span class=&quot;type&quot;&gt;String&lt;/span&gt; title, &lt;span class=&quot;type&quot;&gt;Duration&lt;/span&gt; runningTime, 
            &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee, &lt;span class=&quot;keyword&quot;&gt;double&lt;/span&gt; percent,
            &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt;... discountConditions) {
        &lt;span class=&quot;keyword&quot;&gt;super&lt;/span&gt;(title, runningTime, fee, discountConditions);
        &lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;.percent = percent;
    }

    &lt;span class=&quot;annotation&quot;&gt;@Override&lt;/span&gt;
    &lt;span class=&quot;keyword&quot;&gt;protected&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateDiscountAmount() {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; getFee().times(percent);
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;code-file&quot;&gt;
        src/main/java/.../chapter05/NoneDiscountMovie.java
      &lt;/div&gt;
      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;NoneDiscountMovie&lt;/span&gt; &lt;span class=&quot;keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;NoneDiscountMovie&lt;/span&gt;(&lt;span class=&quot;type&quot;&gt;String&lt;/span&gt; title, &lt;span class=&quot;type&quot;&gt;Duration&lt;/span&gt; runningTime, 
            &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee, &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt;[] discountConditions) {
        &lt;span class=&quot;keyword&quot;&gt;super&lt;/span&gt;(title, runningTime, fee, discountConditions);
    }

    &lt;span class=&quot;annotation&quot;&gt;@Override&lt;/span&gt;
    &lt;span class=&quot;keyword&quot;&gt;protected&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateDiscountAmount() {
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt;.ZERO;
    }
}&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;
        이제 각 할인 정책별로 클래스가 분리되었고, 각 클래스는 자신이 필요한
        속성만 가지고 있습니다. 응집도가 높아졌고, 새로운 할인 정책을 추가할 때도
        기존 코드를 수정할 필요 없이 새로운 클래스를 추가하기만 하면 됩니다.
      &lt;/p&gt;

      &lt;h3&gt;변경과 유연성&lt;/h3&gt;

      &lt;h4&gt;변경에 대응하기&lt;/h4&gt;

      &lt;ol&gt;
        &lt;li&gt;
          코드를 이해하기 쉽게 최대한 단순하게 설계합니다.
        &lt;/li&gt;
        &lt;li&gt;
          코드를 수정하지 않고도 변경을 수용할 수 있게 코드를 유연하게 짜기 →
          유사한 변경이 반복적으로 발생한다면 유연성을 추가합니다!
        &lt;/li&gt;
      &lt;/ol&gt;

      &lt;h4&gt;상속 vs 합성&lt;/h4&gt;

      &lt;p&gt;
        현재 영화에 할인 정책을 실행 중에 선택적으로 변경이 가능해야 한다면?
        새로운 인스턴스를 생성한 후 필요한 정보를 복사해야 합니다. 그럼 추가될
        때마다 복사해야 하는가?
      &lt;/p&gt;

      &lt;div class=&quot;highlight-box&quot;&gt;
        &lt;p&gt;
          &lt;strong&gt;이럴 땐 상속보다 합성이 낫습니다!&lt;/strong&gt;
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;p&gt;
        &lt;code&gt;Movie&lt;/code&gt;와 &lt;code&gt;DiscountCondition&lt;/code&gt; 사이에
        &lt;code&gt;DiscountPolicy&lt;/code&gt;를 추가하여 동적으로 변경이 가능하게 해줄 수
        있습니다.
      &lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;comment&quot;&gt;// 예시: 실행 중 할인 정책 변경&lt;/span&gt;
movie.changeDiscountPolicy(&lt;span class=&quot;keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;PercentDiscountPolicy&lt;/span&gt;())&lt;/code&gt;&lt;/pre&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;4. 책임 주도 설계의 대안&lt;/h2&gt;

      &lt;p&gt;
        아무것도 없는 상태에서 책임과 협력을 고민하기보다는 일단 실행되는 코드를
        작성하고, 코드 상에 명확히 드러나는 책임들을 올바른 위치로 이동시키는 게
        낫습니다.
      &lt;/p&gt;

      &lt;div class=&quot;highlight-box&quot;&gt;
        &lt;p&gt;
          &lt;strong&gt;주의할 점&lt;/strong&gt;: 코드를 수정한 후에 겉으로 드러나는 동작이
          바뀌면 안 됩니다. 이것을 리팩토링이라고 합니다.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;h3&gt;메서드 응집도&lt;/h3&gt;

      &lt;p&gt;
        메서드가 명령문들의 그룹으로 구성되고 각 그룹에 주석을 달아야 한다면, 그
        메서드는 응집도가 낮은 것입니다. 차라리 메서드를 작게 분해해서 응집도를
        높여야 합니다.
      &lt;/p&gt;

      &lt;p&gt;
        클래스도 동일합니다. 클래스도 작게 분해하여 응집도를 높이는 방향이 더
        좋습니다. 그래서 리팩토링의 순서는 아래와 같습니다:
      &lt;/p&gt;

      &lt;ol&gt;
        &lt;li&gt;우선 응집도가 높게끔 작은 메서드로 분리합니다.&lt;/li&gt;
        &lt;li&gt;이제 각 응집도가 높은 메서드를 적절한 객체에 위치시킵니다.&lt;/li&gt;
      &lt;/ol&gt;

      &lt;h3&gt;객체를 자율적으로 만들기&lt;/h3&gt;

      &lt;blockquote&gt;
        메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드를 이동시키면
        됩니다.
      &lt;/blockquote&gt;

      &lt;p&gt;
        메서드가 사용하고 있는 데이터를 가진 클래스로 해당 메서드를 옮기면
        캡슐화, 높은 응집도, 낮은 결합도를 가지게 됩니다.
      &lt;/p&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;느낀점 및 인사이트&lt;/h2&gt;

      &lt;div class=&quot;insight-box&quot;&gt;
        &lt;h4&gt;학습을 통해 얻은 인사이트&lt;/h4&gt;

        &lt;p&gt;
          이번 장을 통해 객체지향 설계에서 책임 할당의 중요성을 깊이 이해할 수
          있었습니다. 단순히 클래스를 만들고 메서드를 추가하는 것이 아니라,
          어떤 객체에게 어떤 책임을 할당할지 신중하게 결정해야 한다는 것을
          배웠습니다.
        &lt;/p&gt;

        &lt;p&gt;
          특히 인상 깊었던 점은 &lt;strong&gt;정보 전문가 패턴&lt;/strong&gt;이었습니다.
          '정보 == 데이터'가 아니라는 점이 매우 중요했습니다. 책임을 수행하는데
          필요한 정보를 알고 있다는 것과 그 정보를 직접 저장하고 있다는 것은
          다른 개념입니다. 이는 객체 간의 협력을 통해 정보를 얻을 수 있다는
          것을 의미합니다.
        &lt;/p&gt;

        &lt;p&gt;
          또한 &lt;strong&gt;GRASP 패턴&lt;/strong&gt;을 통해 체계적으로 책임을 할당하는
          방법을 배울 수 있었습니다. 정보 전문가, 창조자, 변경 보호 등의
          패턴들은 실제 개발에서 매우 유용한 가이드라인이 될 것입니다.
        &lt;/p&gt;

        &lt;p&gt;
          코드를 통한 검증 과정에서 응집도가 낮은 코드의 문제점을 직접 확인할
          수 있었습니다. &lt;code&gt;DiscountCondition_legacy&lt;/code&gt;처럼 type에 따라
          부분적으로 초기화되는 클래스는 응집도가 낮고, 이를
          &lt;code&gt;SequenceCondition&lt;/code&gt;과 &lt;code&gt;PeriodCondition&lt;/code&gt;으로
          분리함으로써 응집도를 높일 수 있다는 것을 실제 코드로 확인했습니다.
        &lt;/p&gt;

        &lt;p&gt;
          &lt;code&gt;Movie&lt;/code&gt; 클래스를 추상 클래스로 만들고 각 할인 정책별로
          클래스를 분리한 것도 매우 인상적이었습니다. 이렇게 하면 새로운 할인
          정책을 추가할 때 기존 코드를 수정할 필요 없이 새로운 클래스만 추가하면
          되므로, 변경에 강한 설계가 됩니다.
        &lt;/p&gt;

        &lt;p&gt;
          책임 주도 설계의 대안으로 제시된 리팩토링 접근법도 실용적이었습니다.
          처음부터 완벽한 설계를 하기보다는, 일단 동작하는 코드를 작성한 후
          점진적으로 개선해 나가는 것이 현실적인 접근 방법이라는 것을 알게
          되었습니다.
        &lt;/p&gt;

        &lt;p&gt;
          앞으로 코드를 작성할 때는 &quot;이 객체가 무엇을 해야 하는가?&quot;라는 질문을
          먼저 던지고, 데이터가 아닌 책임을 중심으로 설계를 시작해야겠다는
          다짐을 하게 되었습니다. 또한 GRASP 패턴을 활용하여 더 체계적으로
          책임을 할당할 수 있을 것 같습니다.
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;hr /&gt;

    &lt;section&gt;
      &lt;h2&gt;결론&lt;/h2&gt;

      &lt;p&gt;
        객체지향 설계에서 가장 중요한 것은 올바른 책임 할당입니다. 책임 할당
        작업 자체가 트레이드오프 과정이며, 다양한 관점에서 설계를 평가할 수
        있어야 합니다.
      &lt;/p&gt;

      &lt;p&gt;
        &lt;strong&gt;GRASP 패턴&lt;/strong&gt;은 객체에게 책임을 할당할 때 지침으로 삼을 수
        있는 원칙들의 집합입니다. 정보 전문가 패턴을 통해 책임을 수행하는데
        필요한 정보를 가장 잘 알고 있는 객체에게 책임을 할당하고, 창조자 패턴을
        통해 객체 생성 책임을 적절한 객체에게 할당할 수 있습니다.
      &lt;/p&gt;

      &lt;p&gt;
        책임 주도 설계는 데이터가 아닌 행동을 먼저 결정하고, 협력이라는 문맥 안에서
        책임을 결정합니다. 시스템의 책임을 파악하고, 이를 더 작은 책임으로
        분할한 후, 책임을 수행할 수 있는 객체 또는 역할에 할당하는 프로세스를
        따릅니다.
      &lt;/p&gt;

      &lt;p&gt;
        코드를 통한 검증 과정에서 응집도가 낮은 코드의 문제점을 확인하고, 타입을
        분리함으로써 응집도를 높일 수 있다는 것을 배웠습니다. 또한 상속을 통한
        다형성을 활용하여 변경에 강한 설계를 만들 수 있다는 것도 확인했습니다.
      &lt;/p&gt;

      &lt;p&gt;
        책임 주도 설계의 대안으로 리팩토링 접근법이 있습니다. 처음부터 완벽한
        설계를 하기보다는, 일단 동작하는 코드를 작성한 후 점진적으로 개선해
        나가는 것이 현실적인 접근 방법입니다. 메서드가 사용하는 데이터를 저장하고
        있는 클래스로 메서드를 이동시키면 캡슐화, 높은 응집도, 낮은 결합도를
        가지게 됩니다.
      &lt;/p&gt;

      &lt;p&gt;
        앞으로 코드를 작성할 때는 책임과 협력에 집중하고, GRASP 패턴을 활용하여
        더 체계적으로 책임을 할당할 수 있을 것입니다. 이를 통해 변경에 강하고
        유지보수가 용이한 설계를 만들 수 있을 것입니다.
      &lt;/p&gt;
    &lt;/section&gt;
  &lt;/body&gt;
&lt;/html&gt;</description>
      <category>개발/OOP</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/19</guid>
      <comments>https://pandaterry.tistory.com/entry/%EC%B1%85-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-CH5-%EC%B1%85%EC%9E%84-%ED%95%A0%EB%8B%B9%ED%95%98%EA%B8%B0#entry19comment</comments>
      <pubDate>Sun, 16 Nov 2025 17:27:27 +0900</pubDate>
    </item>
    <item>
      <title>[책 | 오브젝트] CH4. 설계품질과 트레이드오프</title>
      <link>https://pandaterry.tistory.com/entry/%EC%B1%85-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-CH4-%EC%84%A4%EA%B3%84%ED%92%88%EC%A7%88%EA%B3%BC-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;오브젝트 책 표지.jpg&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/59iOn/dJMcaaRckmW/U5BG3Rr4ik0HD4iRaY03B0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/59iOn/dJMcaaRckmW/U5BG3Rr4ik0HD4iRaY03B0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/59iOn/dJMcaaRckmW/U5BG3Rr4ik0HD4iRaY03B0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F59iOn%2FdJMcaaRckmW%2FU5BG3Rr4ik0HD4iRaY03B0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;500&quot; data-filename=&quot;오브젝트 책 표지.jpg&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;

&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;CH4. 설계 품질과 트레이드오프 - 객체지향 설계의 핵심&lt;/title&gt;
    &lt;style&gt;
        h1 {
            font-size: 2.5em;
            margin-top: 0;
            margin-bottom: 20px;
            color: #2c3e50;
            border-bottom: 3px solid #3498db;
            padding-bottom: 10px;
        }

        h2 {
            font-size: 2em;
            margin-top: 60px;
            margin-bottom: 20px;
            color: #34495e;
            border-left: 5px solid #3498db;
            padding-left: 15px;
        }

        h3 {
            font-size: 1.5em;
            margin-top: 40px;
            margin-bottom: 15px;
            color: #555;
        }

        h4 {
            font-size: 1.2em;
            margin-top: 30px;
            margin-bottom: 10px;
            color: #666;
        }

        p {
            margin-bottom: 15px;
            text-align: justify;
        }

        ul, ol {
            margin-left: 30px;
            margin-bottom: 20px;
        }

        li {
            margin-bottom: 10px;
        }

        code {
            background-color: #f4f4f4;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: &quot;Courier New&quot;, Courier, monospace;
            font-size: 0.9em;
            color: #c7254e;
        }

        pre {
            background-color: #282c34;
            color: #abb2bf;
            padding: 20px;
            border-radius: 8px;
            overflow-x: auto;
            margin: 20px 0;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        }

        pre code {
            background: none;
            padding: 0;
            color: inherit;
            font-size: 0.9em;
            line-height: 1.6;
        }

        .keyword { color: #c678dd; }
        .string { color: #98c379; }
        .comment { color: #5c6370; font-style: italic; }
        .type { color: #e5c07b; }
        .annotation { color: #e06c75; }

        blockquote {
            border-left: 4px solid #3498db;
            padding-left: 20px;
            margin: 20px 0;
            color: #666;
            font-style: italic;
        }

        hr {
            border: none;
            border-top: 2px solid #ecf0f1;
            margin: 40px 0;
        }

        .highlight-box {
            background-color: #fff3cd;
            border-left: 4px solid #ffc107;
            padding: 15px;
            margin: 20px 0;
            border-radius: 4px;
        }

        .insight-box {
            background-color: #d1ecf1;
            border-left: 4px solid #0c5460;
            padding: 15px;
            margin: 20px 0;
            border-radius: 4px;
        }

        .code-file {
            font-size: 0.85em;
            color: #95a5a6;
            margin-bottom: 5px;
            font-style: italic;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

        &lt;h1&gt;CH4. 설계 품질과 트레이드오프&lt;/h1&gt;

        &lt;section&gt;

            &lt;h2&gt;도입부&lt;/h2&gt;

            &lt;div class=&quot;highlight-box&quot;&gt;
                &lt;h4&gt;공감이 되는 문장&lt;/h4&gt;
                &lt;p&gt;&lt;strong&gt;설계는 변경을 위해 존재하고 변경에는 어떤 식으로든 비용이 발생한다.&lt;/strong&gt;&lt;/p&gt;
            &lt;/div&gt;

            &lt;h4&gt;훌륭한 설계는?&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;합리적인 비용 안에서 변경을 수용할 수 있는 구조&lt;/li&gt;
                &lt;li&gt;결합도가 낮고 응집도가 높은 설계&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h4&gt;훌륭하지 못한 설계는?&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;객체를 단순히 데이터의 집합으로 보는 설계 → 변경에 취약 (구현이 인터페이스에 노출)&lt;/li&gt;
            &lt;/ul&gt;
        &lt;/section&gt;

        &lt;section&gt;

            &lt;h2&gt;1. 데이터 중심의 영화 예매 시스템&lt;/h2&gt;

            &lt;h3&gt;시스템을 객체로 분할하는 법&lt;/h3&gt;
            &lt;ol&gt;
                &lt;li&gt;상태를 중심으로 분할 (상태 = 데이터)&lt;/li&gt;
                &lt;li&gt;책임 중심으로 분할&lt;/li&gt;
            &lt;/ol&gt;

            &lt;h4&gt;상태(데이터)를 중심으로 객체로 분할&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;&lt;strong&gt;객체&lt;/strong&gt;: 자신이 포함하고 있는 데이터를 조작하는데 필요한 오퍼레이션(CRUD 등)을 정의, 독립된 데이터 덩어리&lt;/li&gt;
                &lt;li&gt;&lt;strong&gt;초점&lt;/strong&gt;: 객체의 상태에 초점&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h4&gt;책임을 중심으로 객체를 분할&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;&lt;strong&gt;객체&lt;/strong&gt;: 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관, 협력하는 공동체의 일원&lt;/li&gt;
                &lt;li&gt;&lt;strong&gt;초점&lt;/strong&gt;: 객체의 행동에 초점&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h4&gt;왜 상태 중심 분할은 변경에 취약한가?&lt;/h4&gt;
            &lt;ol&gt;
                &lt;li&gt;객체의 상태 = 상태 자체는 구현에 속한다. 구현에 사용되는 것이 상태이다.&lt;/li&gt;
                &lt;li&gt;구현은 변하기 쉽다.&lt;/li&gt;
                &lt;li&gt;그럼 구현이 변하면 상태도 당연히 변할 수 있다.&lt;/li&gt;
                &lt;li&gt;의존하는 모든 객체에 변경이 전파된다.&lt;/li&gt;
                &lt;li&gt;그래서 변경에 취약하다는 것이다.&lt;/li&gt;
            &lt;/ol&gt;

            &lt;h4&gt;반대로, 책임 중심 분할은 왜 변경에 튼튼한가?&lt;/h4&gt;
            &lt;ol&gt;
                &lt;li&gt;객체의 책임은 인터페이스에 속한다.&lt;/li&gt;
                &lt;li&gt;필요한 상태를 캡슐화하여 절대 외부에 노출하지 않는다.&lt;/li&gt;
                &lt;li&gt;변경을 해도 파장이 외부로 나가는 것을 방지한다.&lt;/li&gt;
            &lt;/ol&gt;

            &lt;hr&gt;

            &lt;h3&gt;데이터 중심 설계&lt;/h3&gt;

            &lt;h4&gt;Movie 객체&lt;/h4&gt;
            &lt;p&gt;프로젝트의 실제 &lt;code&gt;Movie&lt;/code&gt; 클래스를 살펴보겠습니다:&lt;/p&gt;
            
            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/Movie.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;comment&quot;&gt;/**
 * 데이터 중심 설계 방식(잘못된 설계)
 */&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;String&lt;/span&gt; title;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Duration&lt;/span&gt; runningTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt;&amp;gt; discountConditions;

    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;MovieType&lt;/span&gt; movieType;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; discountAmount;
    &lt;span class=&quot;keyword&quot;&gt;private double&lt;/span&gt; discountPercent;

    &lt;span class=&quot;comment&quot;&gt;// ... 할인 금액 계산 메서드들 ...&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;p&gt;&lt;code&gt;title&lt;/code&gt;, &lt;code&gt;runningTime&lt;/code&gt;, &lt;code&gt;fee&lt;/code&gt; 등은 기존 설계와 동일하다. 하지만, &lt;code&gt;discountConditions&lt;/code&gt;가 변수로 &lt;code&gt;Movie&lt;/code&gt;에 직접 들어가고, 할인 금액인 &lt;code&gt;discountAmount&lt;/code&gt;, 할인 비율인 &lt;code&gt;discountPercent&lt;/code&gt;가 직접 들어가 있다는 큰 차이가 있다.&lt;/p&gt;

            &lt;h4&gt;영화에 사용된 할인 정책 종류를 아는 법&lt;/h4&gt;
            
            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/MovieType.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public enum&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;MovieType&lt;/span&gt; {
    AMOUNT_DISCOUNT,    &lt;span class=&quot;comment&quot;&gt;// 금액 할인 정책&lt;/span&gt;
    PERCENT_DISCOUNT,   &lt;span class=&quot;comment&quot;&gt;// 비율 할인 정책&lt;/span&gt;
    NONE_DISCOUNT       &lt;span class=&quot;comment&quot;&gt;// 미적용&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;p&gt;이 enum을 통해 &lt;code&gt;discountAmount&lt;/code&gt;를 사용할지 &lt;code&gt;discountPercent&lt;/code&gt;를 사용할지 정하는 방식이다.&lt;/p&gt;

            &lt;h4&gt;데이터 집중하는 사고방식&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;이 객체가 포함해야 하는 데이터, 그 자체에 집중한다.&lt;/li&gt;
                &lt;li&gt;만약 책임을 결정하기 전에 이런 질문을 반복했다면 &lt;code&gt;데이터 중심 설계&lt;/code&gt;에 매몰돼 있을 확률이 높다.&lt;/li&gt;
                &lt;li&gt;가장 많이 보이는 유형이 &lt;code&gt;movieType&lt;/code&gt;과 &lt;code&gt;discountAmount&lt;/code&gt;/&lt;code&gt;discountPercent&lt;/code&gt;처럼 종류와 사용될 변수를 같이 저장하는 것이 이런 설계에서 자주 보이는 패턴이다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h4&gt;예매를 처리하는 ReservationAgency&lt;/h4&gt;
            &lt;p&gt;데이터 중심 설계에서의 &lt;code&gt;ReservationAgency&lt;/code&gt;는 다음과 같습니다. 실제 프로젝트에서는 리팩토링 전 코드가 주석으로 남아 있습니다:&lt;/p&gt;

            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/ReservationAgency.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;ReservationAgency&lt;/span&gt; {
    &lt;span class=&quot;comment&quot;&gt;// 리팩토링 후&lt;/span&gt;
    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Reservation&lt;/span&gt; reserve(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening, &lt;span class=&quot;type&quot;&gt;Customer&lt;/span&gt; customer, &lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; audienceCount){
        &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee = screening.calculateFee(audienceCount);
        &lt;span class=&quot;keyword&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Reservation&lt;/span&gt;(customer, screening, fee, audienceCount);
    }

    &lt;span class=&quot;comment&quot;&gt;// 리팩토링 전 (데이터 중심 설계)&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;// public Reservation reserve(Screening screening, Customer customer, int audienceCount){&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     Movie movie = screening.getMovie();&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     boolean discountable = false;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     for(DiscountCondition condition : movie.getDiscountConditions()){&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         if(condition.getType() == DiscountConditionType.PERIOD){&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//             discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &amp;&amp;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//                     condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) &lt;= 0 &amp;&amp;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//                     condition.getEndTime().compareTo(screening.getWhenScreened().toLocalTime()) &gt;= 0;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         }else{&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//             discountable = condition.getSequence() == screening.getSequence();&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         }&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         if(discountable){ break; }&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     }&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     Money fee;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     if(discountable){&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         Money discountAmount = Money.ZERO;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         switch (movie.getMovieType()){&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//             case AMOUNT_DISCOUNT: discountAmount = movie.getDiscountAmount(); break;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//             case PERCENT_DISCOUNT: discountAmount = movie.getFee().times(movie.getDiscountPercent()); break;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//             case NONE_DISCOUNT: discountAmount = Money.ZERO; break;&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         }&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         fee = movie.getFee().minus(discountAmount);&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     }else{&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//         fee = movie.getFee();&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     }&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;//     return new Reservation(customer, screening, fee, audienceCount);&lt;/span&gt;
    &lt;span class=&quot;comment&quot;&gt;// }&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;h4&gt;reserve 메서드 고찰&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;&lt;code&gt;reserve&lt;/code&gt;는 2개의 영역으로 분할됩니다:
                    &lt;ul&gt;
                        &lt;li&gt;&lt;code&gt;DiscountCondition&lt;/code&gt;에 대해 루프를 돌면서 할인 가능 여부를 확인하는 for 문&lt;/li&gt;
                        &lt;li&gt;&lt;code&gt;discountable&lt;/code&gt; 변수의 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산하는 if문&lt;/li&gt;
                    &lt;/ul&gt;
                &lt;/li&gt;
                &lt;li&gt;이 클래스만 봐도 클래스를 다양하게 만든다고 해서 무조건 OOP가 아니라는 것을 알 수 있습니다.&lt;/li&gt;
            &lt;/ul&gt;
        &lt;/section&gt;

        &lt;section&gt;

            &lt;h2&gt;2. 설계 트레이드오프&lt;/h2&gt;

            &lt;h3&gt;데이터 중심 VS 객체 중심&lt;/h3&gt;
            &lt;blockquote&gt;캡슐화, 응집도, 결합도라는 3가지 측면에서 평가할 생각이다.&lt;/blockquote&gt;

            &lt;h3&gt;캡슐화란?&lt;/h3&gt;
            &lt;ul&gt;
                &lt;li&gt;&lt;strong&gt;객체 안에 상태와 행동을 모으는 이유&lt;/strong&gt;: 객체의 내부 구현(변하기 쉬운 것)을 외부로부터 감추기 위함이다.&lt;/li&gt;
                &lt;li&gt;객체를 통해 감추게 되면 변경의 여파를 통제할 수 있다.
                    &lt;ul&gt;
                        &lt;li&gt;&lt;strong&gt;구현&lt;/strong&gt;: 변경이 쉬운 영역&lt;/li&gt;
                        &lt;li&gt;&lt;strong&gt;인터페이스&lt;/strong&gt;: 상대적으로 안정적인 부분&lt;/li&gt;
                    &lt;/ul&gt;
                &lt;/li&gt;
                &lt;li&gt;&lt;strong&gt;감추는 행위 = 캡슐화&lt;/strong&gt;&lt;/li&gt;
                &lt;li&gt;&lt;strong&gt;설계의 존재 목적&lt;/strong&gt;: 요구사항이 변경되기 쉽기 때문에 이를 통제하기 위해 설계를 하는 것. 이때 불안정한 부분과 안정된 부분을 구분하자는 것이다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h3&gt;응집도 &amp; 결합도&lt;/h3&gt;

            &lt;h4&gt;응집도&lt;/h4&gt;
            &lt;blockquote&gt;모듈에 포함된 내부 요소들이 연관돼 있는 정도. 하나의 목적을 위해 긴밀하게 협력하는가에 대한 척도&lt;/blockquote&gt;

            &lt;h4&gt;결합도&lt;/h4&gt;
            &lt;blockquote&gt;의존성 정도를 나타내며, 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지 나타내는 척도.&lt;/blockquote&gt;

            &lt;h4&gt;좋은 설계란?&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;&lt;strong&gt;좋은 설계 = 응집도가 높고, 결합도가 낮은 설계&lt;/strong&gt;&lt;/li&gt;
                &lt;li&gt;&lt;strong&gt;응집도&lt;/strong&gt;: 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도
                    &lt;ul&gt;
                        &lt;li&gt;모듈 전체가 함께 변경되면 응집도가 높고, 모듈의 일부만 변경되면 응집도가 낮은 것&lt;/li&gt;
                        &lt;li&gt;하나의 변경에 대해 하나의 모듈만 변경되면 응집도가 높은 것, 다수의 모듈이 함께 변경되면 낮은 것&lt;/li&gt;
                    &lt;/ul&gt;
                &lt;/li&gt;
                &lt;li&gt;&lt;strong&gt;결합도&lt;/strong&gt;: 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도
                    &lt;ul&gt;
                        &lt;li&gt;한 모듈의 변경으로 변경해야 하는 모듈의 수가 많다면 결합도가 높은 것&lt;/li&gt;
                    &lt;/ul&gt;
                &lt;/li&gt;
            &lt;/ul&gt;
        &lt;/section&gt;

        &lt;section&gt;

            &lt;h2&gt;3. 데이터 중심의 영화 예매 시스템의 문제점&lt;/h2&gt;

            &lt;p&gt;캡슐화의 정도 = 응집도와 결합도를 결정한다.&lt;/p&gt;

            &lt;h3&gt;캡슐화 위반&lt;/h3&gt;

            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/Movie2.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;comment&quot;&gt;// 캡슐화 위반&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie2&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;ul&gt;
                &lt;li&gt;객체가 수행할 책임이 아닌 저장할 데이터에 초점을 맞췄다. (&lt;code&gt;set~&lt;/code&gt;, &lt;code&gt;get~&lt;/code&gt;)&lt;/li&gt;
                &lt;li&gt;이렇게 되면 과도하게 접근자와 수정자에 의존하게 되는데 이는 &lt;code&gt;추측에 대한 설계 전략&lt;/code&gt;이라 부른다.
                    &lt;ul&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;/ul&gt;

            &lt;h3&gt;높은 결합도&lt;/h3&gt;

            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;ReservationAgency&lt;/span&gt;{
    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Reservation&lt;/span&gt; reserve(&lt;span class=&quot;type&quot;&gt;Screening&lt;/span&gt; screening, &lt;span class=&quot;type&quot;&gt;Customer&lt;/span&gt; customer, &lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; audienceCount) {
        &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee;
        &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (discountable) {
            &lt;span class=&quot;comment&quot;&gt;...&lt;/span&gt;
            fee = &lt;span class=&quot;comment&quot;&gt;...&lt;/span&gt;
        } &lt;span class=&quot;keyword&quot;&gt;else&lt;/span&gt; {
            fee = movie.getFee();  &lt;span class=&quot;comment&quot;&gt;// 내부 구현에 직접 접근&lt;/span&gt;
        }
        &lt;span class=&quot;comment&quot;&gt;...&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;

            &lt;ul&gt;
                &lt;li&gt;&lt;code&gt;fee&lt;/code&gt;의 타입을 변경한다고 가정할 때, &lt;code&gt;getFee()&lt;/code&gt;의 메서드의 반환 타입도 수정이 되고, &lt;code&gt;getFee&lt;/code&gt; 메서드를 호출하는 &lt;code&gt;reserve&lt;/code&gt; 구현도 당연히 수정해야 한다.&lt;/li&gt;
                &lt;li&gt;이는 정상적으로 캡슐화가 안된 것일뿐더러 이를 호출하는 클라이언트가 객체의 구현에 강하게 결합된다.&lt;/li&gt;
                &lt;li&gt;데이터 중심 설계의 큰 단점이 이처럼 하나의 특정 객체에 제어 로직이 집중된다는 것이다.
                    &lt;ul&gt;
                        &lt;li&gt;이렇게 되면 데이터가 변경되면 제어 객체를 함께 변경할 수밖에 없다.&lt;/li&gt;
                    &lt;/ul&gt;
                &lt;/li&gt;
            &lt;/ul&gt;

            &lt;h3&gt;낮은 응집도&lt;/h3&gt;
            &lt;p&gt;&lt;code&gt;ReservationAgency&lt;/code&gt;는 다음과 같은 수정사항이 있을 때, 코드를 수정해야만 한다:&lt;/p&gt;
            &lt;ul&gt;
                &lt;li&gt;할인 정책이 추가된다면&lt;/li&gt;
                &lt;li&gt;할인 정책별로 할인 요금을 계산하는 방법이 변경된다면&lt;/li&gt;
                &lt;li&gt;할인 조건이 추가된다면&lt;/li&gt;
                &lt;li&gt;할인 조건별로 할인 여부를 판단하는 방법이 변경된다면&lt;/li&gt;
                &lt;li&gt;예매 요금을 계산하는 방법이 변경된다면&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h4&gt;낮은 응집도가 발생시키는 문제&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;변경의 이유가 다른 코드를 하나의 모듈에 뭉치면 → 변경과 상관없는 코드들이 영향&lt;/li&gt;
                &lt;li&gt;하나의 요구사항을 반영하기 위해 → 동시에 상관없는 여러 모듈을 수정&lt;/li&gt;
            &lt;/ul&gt;
        &lt;/section&gt;

        &lt;section&gt;

            &lt;h2&gt;4. 자율적인 객체를 향해&lt;/h2&gt;

            &lt;h3&gt;캡슐화를 지켜라&lt;/h3&gt;

            &lt;p&gt;다음과 같은 &lt;code&gt;Rectangle&lt;/code&gt; 클래스가 있다고 가정해보자:&lt;/p&gt;

            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;annotation&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@AllArgsConstructor&lt;/span&gt;
&lt;span class=&quot;keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Rectangle&lt;/span&gt;{
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; left;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; top;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; right;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; bottom;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;p&gt;이 사각형의 너비와 높이를 증가시키는 코드가 필요하다고 가정해보자.&lt;/p&gt;

            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;AnyClass&lt;/span&gt;{
    &lt;span class=&quot;keyword&quot;&gt;void&lt;/span&gt; anyMethod(&lt;span class=&quot;type&quot;&gt;Rectangle&lt;/span&gt; rectangle, &lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; multiple){
        rectangle.setRight(rectangle.getRight() * multiple);
        rectangle.setBottom(rectangle.getBottom() * multiple);
    }
}&lt;/code&gt;&lt;/pre&gt;

            &lt;h4&gt;문제점&lt;/h4&gt;
            &lt;ul&gt;
                &lt;li&gt;코드 중복이 발생할 확률이 높다. 다른 클래스도 필요하다면 계속 &lt;code&gt;get~&lt;/code&gt;을 통해 가져와서 계산하는 코드가 필요해진다.&lt;/li&gt;
                &lt;li&gt;변경에 취약하다. &lt;code&gt;get~&lt;/code&gt;을 통해 내부 구현이 노출되었기 때문에 이 중에 하나라도 변경되면 이 메서드들은 모두 수정이 가해져야 한다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;p&gt;개선된 방식은 다음과 같다:&lt;/p&gt;

            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Rectangle&lt;/span&gt;{
    &lt;span class=&quot;keyword&quot;&gt;public void&lt;/span&gt; enlarge(&lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; multiple){
        right *= multiple;
        bottom *= multiple;
    }
}&lt;/code&gt;&lt;/pre&gt;

            &lt;h3&gt;스스로 자신의 데이터를 책임지는 객체&lt;/h3&gt;
            &lt;blockquote&gt;상태와 행동을 객체라는 단위로 하나로 묶는 이유는 객체 스스로 상태를 처리할 수 있게 하기 위해서이다.&lt;/blockquote&gt;

            &lt;p&gt;다음 두 질문으로 객체에 내부 상태를 저장하는 방식과 저장된 상태에 대해 호출할 수 있는 오퍼레이션의 집합을 얻을 수 있다:&lt;/p&gt;
            &lt;ol&gt;
                &lt;li&gt;이 객체가 어떤 데이터를 포함해야 하는가?&lt;/li&gt;
                &lt;li&gt;이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?&lt;/li&gt;
            &lt;/ol&gt;

            &lt;h3&gt;ReservationAgency를 고쳐보자&lt;/h3&gt;

            &lt;h4&gt;1. 어떤 데이터를 포함해야 하는가?&lt;/h4&gt;

            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/DiscountCondition.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;annotation&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt; type;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; sequence;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DayOfWeek&lt;/span&gt; dayOfWeek;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; startTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; endTime;

    &lt;span class=&quot;comment&quot;&gt;// ...&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;p&gt;이건 이미 &lt;code&gt;DiscountCondition&lt;/code&gt;이 관리해야 하는 데이터를 결정해놓았으니 넘어가자.&lt;/p&gt;

            &lt;h4&gt;2. 이 데이터에 대해 수행할 수 있는 오퍼레이션은?&lt;/h4&gt;
            &lt;p&gt;&lt;code&gt;DiscountCondition&lt;/code&gt;은 순번 조건일 경우 &lt;code&gt;sequence&lt;/code&gt;를 이용해서 할인 여부를 결정, 기간 조건일 경우엔 &lt;code&gt;dayOfWeek&lt;/code&gt;, &lt;code&gt;startTime&lt;/code&gt;, &lt;code&gt;endTime&lt;/code&gt;을 통해 결정한다.&lt;/p&gt;

            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isDiscountable(&lt;span class=&quot;type&quot;&gt;DayOfWeek&lt;/span&gt; dayOfWeek, &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; time) {
    &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (type != &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt;.PERIOD) {
        &lt;span class=&quot;keyword&quot;&gt;throw new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;IllegalArgumentException&lt;/span&gt;();
    }
    &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;.dayOfWeek.equals(dayOfWeek) &amp;&amp;
        &lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;.startTime.compareTo(time) &lt;= 0 &amp;&amp;
        &lt;span class=&quot;keyword&quot;&gt;this&lt;/span&gt;.endTime.compareTo(time) &gt;= 0;
}

&lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isDiscountable(&lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; sequence) {
    &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (type != &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt;.SEQUENCE) {
        &lt;span class=&quot;keyword&quot;&gt;throw new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;IllegalArgumentException&lt;/span&gt;();
    }
    &lt;span class=&quot;keyword&quot;&gt;return this&lt;/span&gt;.sequence == sequence;
}&lt;/code&gt;&lt;/pre&gt;

            &lt;h3&gt;Movie를 고쳐보자&lt;/h3&gt;

            &lt;h4&gt;1. Movie는 이미 상태가 정의되어 있다&lt;/h4&gt;

            &lt;p&gt;실제 프로젝트의 &lt;code&gt;Movie&lt;/code&gt; 클래스는 다음과 같습니다:&lt;/p&gt;

            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/Movie.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;comment&quot;&gt;/**
 * 데이터 중심 설계 방식(잘못된 설계)
 */&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Movie&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;String&lt;/span&gt; title;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Duration&lt;/span&gt; runningTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; fee;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt;&amp;gt; discountConditions;

    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;MovieType&lt;/span&gt; movieType;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; discountAmount;
    &lt;span class=&quot;keyword&quot;&gt;private double&lt;/span&gt; discountPercent;

    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateAmountDiscountedFee() {
        &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (movieType != &lt;span class=&quot;type&quot;&gt;MovieType&lt;/span&gt;.AMOUNT_DISCOUNT) {
            &lt;span class=&quot;keyword&quot;&gt;throw new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;IllegalArgumentException&lt;/span&gt;();
        }
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; fee.minus(discountAmount);
    }
    
    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculatePercentDiscountedFee() {
        &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (movieType != &lt;span class=&quot;type&quot;&gt;MovieType&lt;/span&gt;.PERCENT_DISCOUNT) {
            &lt;span class=&quot;keyword&quot;&gt;throw new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;IllegalArgumentException&lt;/span&gt;();
        }
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; fee.minus(fee.times(discountPercent));
    }
    
    &lt;span class=&quot;keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;Money&lt;/span&gt; calculateNoneDiscountedFee() {
        &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (movieType != &lt;span class=&quot;type&quot;&gt;MovieType&lt;/span&gt;.NONE_DISCOUNT) {
            &lt;span class=&quot;keyword&quot;&gt;throw new&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;IllegalArgumentException&lt;/span&gt;();
        }
        &lt;span class=&quot;keyword&quot;&gt;return&lt;/span&gt; fee;
    }

    &lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isDiscountable(&lt;span class=&quot;type&quot;&gt;LocalDateTime&lt;/span&gt; whenScreened, &lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; sequence) {
        &lt;span class=&quot;keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt; condition : discountConditions) {
            &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (condition.getType() == &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt;.PERIOD) {
                &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
                    &lt;span class=&quot;keyword&quot;&gt;return true&lt;/span&gt;;
                }
            } &lt;span class=&quot;keyword&quot;&gt;else&lt;/span&gt; {
                &lt;span class=&quot;keyword&quot;&gt;if&lt;/span&gt; (condition.isDiscountable(sequence)) {
                    &lt;span class=&quot;keyword&quot;&gt;return true&lt;/span&gt;;
                }
            }
        }
        &lt;span class=&quot;keyword&quot;&gt;return false&lt;/span&gt;;
    }
}&lt;/code&gt;&lt;/pre&gt;

            &lt;h4&gt;2. 이 데이터를 처리하기 위해 어떤 오퍼레이션이 필요한지?&lt;/h4&gt;
            &lt;p&gt;&lt;code&gt;Movie&lt;/code&gt;는 영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요할 것 같다.&lt;/p&gt;

            &lt;p&gt;위 코드에서 볼 수 있듯이, &lt;code&gt;Movie&lt;/code&gt; 클래스에 여러 할인 계산 메서드들이 추가되었습니다. 하지만 여전히 문제점이 남아 있습니다.&lt;/p&gt;

            &lt;h2&gt;5. 하지만 여전히 부족하다&lt;/h2&gt;
            &lt;blockquote&gt;이 정도 캡슐화도 사실 만족할 정도는 아니다. 이것마저도 데이터 중심의 설계 방식에 속한다고 볼 수 있다. 아직도 문제가 있기 때문이다.&lt;/blockquote&gt;

            &lt;h3&gt;캡슐화 위반&lt;/h3&gt;

            &lt;p&gt;개선된 &lt;code&gt;DiscountCondition&lt;/code&gt; 클래스도 여전히 문제가 있습니다:&lt;/p&gt;

            &lt;div class=&quot;code-file&quot;&gt;src/main/java/.../chapter04/DiscountCondition.java&lt;/div&gt;
            &lt;pre&gt;&lt;code&gt;&lt;span class=&quot;annotation&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;annotation&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;keyword&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountCondition&lt;/span&gt; {
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DiscountConditionType&lt;/span&gt; type;
    &lt;span class=&quot;keyword&quot;&gt;private int&lt;/span&gt; sequence;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;DayOfWeek&lt;/span&gt; dayOfWeek;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; startTime;
    &lt;span class=&quot;keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; endTime;

    &lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isDiscountable(&lt;span class=&quot;type&quot;&gt;DayOfWeek&lt;/span&gt; dayOfWeek, &lt;span class=&quot;type&quot;&gt;LocalTime&lt;/span&gt; time) {
        &lt;span class=&quot;comment&quot;&gt;// ...&lt;/span&gt;
    }

    &lt;span class=&quot;keyword&quot;&gt;public boolean&lt;/span&gt; isDiscountable(&lt;span class=&quot;keyword&quot;&gt;int&lt;/span&gt; sequence) {
        &lt;span class=&quot;comment&quot;&gt;// ...&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;

            &lt;ul&gt;
                &lt;li&gt;&lt;code&gt;isDiscountable&lt;/code&gt;은 객체 내부의 상태인 &lt;code&gt;DayOfWeek&lt;/code&gt;, &lt;code&gt;LocalTime&lt;/code&gt;이 인자로 받게 되면서 인스턴스 변수로 사용되고 있다는 사실을 외부에 노출하고 있는 것이다. &lt;code&gt;sequence&lt;/code&gt;도 동일하다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;p&gt;&lt;code&gt;Movie&lt;/code&gt; 클래스도 비슷한 문제가 있습니다:&lt;/p&gt;

            &lt;ul&gt;
                &lt;li&gt;&lt;code&gt;Movie&lt;/code&gt;도 &lt;code&gt;isDiscountable&lt;/code&gt;과는 다르지만, 인자가 아닌 메서드명에 할인 정책의 종류를 노출시키고 있다. 만약 할인 정책이 추가되거나 제거되면 의존하는 클라이언트가 모두 영향을 받을 것이다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h3&gt;높은 결합도&lt;/h3&gt;
            &lt;ul&gt;
                &lt;li&gt;&lt;code&gt;DiscountCondition&lt;/code&gt;의 기간 할인 조건의 명칭이 &lt;code&gt;PERIOD&lt;/code&gt;에서 다른 값으로 변경된다면 &lt;code&gt;Movie&lt;/code&gt;를 수정해야 한다.&lt;/li&gt;
                &lt;li&gt;&lt;code&gt;DiscountCondition&lt;/code&gt;의 종류가 추가되거나 삭제된다면 &lt;code&gt;Movie&lt;/code&gt;의 if-else문을 수정해야 한다.&lt;/li&gt;
                &lt;li&gt;&lt;code&gt;DiscountCondition&lt;/code&gt; 만족 여부 판단 로직의 정보가 변경된다면 파라미터를 변경해야 한다. 이러면 &lt;code&gt;Screening&lt;/code&gt;도 변경이 된다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;h3&gt;낮은 응집도&lt;/h3&gt;
            &lt;ul&gt;
                &lt;li&gt;할인 조건의 종류를 변경하려면 &lt;code&gt;DiscountCondition&lt;/code&gt;, &lt;code&gt;Movie&lt;/code&gt;, &lt;code&gt;Screening&lt;/code&gt;을 모두 수정해야 한다. 이렇게 여러 곳을 변경하는 것은 응집도가 낮다는 것이다. 왜냐면 캡슐화를 위반했으니까 말이다.&lt;/li&gt;
                &lt;li&gt;원래 &lt;code&gt;DiscountCondition&lt;/code&gt;이나 &lt;code&gt;Movie&lt;/code&gt;에 위치해야 하는 로직이 &lt;code&gt;Screening&lt;/code&gt;까지 새어나왔기 때문이다.&lt;/li&gt;
            &lt;/ul&gt;

            &lt;hr&gt;

            &lt;h2&gt;데이터 중심 설계의 문제점&lt;/h2&gt;

            &lt;div class=&quot;insight-box&quot;&gt;
                &lt;h4&gt;핵심 문제점 요약&lt;/h4&gt;
                &lt;ul&gt;
                    &lt;li&gt;&lt;strong&gt;너무 이른 데이터 중심 사고&lt;/strong&gt;: 데이터 중심 설계는 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요하기 때문에, 너무 이르게 내부 구현에 초점을 맞춘다.&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;절차적 프로그래밍 방식&lt;/strong&gt;: 데이터 중심 설계는 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다. 접근자(getter)와 수정자(setter)를 과도하게 추가하게 된다.&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;캡슐화 실패&lt;/strong&gt;: 결론적으로, 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패한다.&lt;/li&gt;
                &lt;/ul&gt;
            &lt;/div&gt;

            &lt;h2&gt;느낀점 및 인사이트&lt;/h2&gt;

            &lt;div class=&quot;insight-box&quot;&gt;
                &lt;h4&gt;학습을 통해 얻은 인사이트&lt;/h4&gt;
                &lt;p&gt;이번 장을 통해 객체지향 설계의 핵심을 다시 한 번 생각해볼 수 있었다. 단순히 클래스를 만들고 getter/setter를 추가하는 것이 객체지향 프로그래밍이 아니라, 객체가 자신의 데이터와 행동을 책임지고 다른 객체와 협력하는 것이 진정한 객체지향 설계임을 깨달았다.&lt;/p&gt;

                &lt;p&gt;특히 인상 깊었던 점은 &quot;책임 중심으로 객체를 분할&quot;하는 것의 중요성이었다. 데이터 중심 설계에서는 변경이 발생하면 여러 곳에 영향이 퍼지는 반면, 책임 중심 설계에서는 변경의 여파가 최소화된다. 이는 실제 개발에서도 매우 중요한 교훈이다.&lt;/p&gt;

                &lt;p&gt;또한, 완벽한 캡슐화를 달성하는 것이 얼마나 어려운지도 느꼈다. 처음에는 단순히 getter/setter를 제거하는 것으로 해결될 것 같았지만, 실제로는 메서드 시그니처나 내부 상태 노출 등 다양한 형태로 캡슐화 위반이 발생할 수 있다는 것을 알게 되었다.&lt;/p&gt;

                &lt;p&gt;앞으로 코드를 작성할 때는 &quot;이 객체가 무엇을 해야 하는가?&quot;라는 질문을 먼저 던지고, 데이터가 아닌 책임을 중심으로 설계를 시작해야겠다는 다짐을 하게 되었다.&lt;/p&gt;
            &lt;/div&gt;

            &lt;hr&gt;

            &lt;h2&gt;결론&lt;/h2&gt;
            &lt;p&gt;데이터 중심 설계는 직관적이고 이해하기 쉽지만, 변경에 취약하다는 치명적인 단점이 있다. 반면 책임 중심 설계는 초기 이해가 어려울 수 있지만, 변경에 강하고 유지보수가 용이한 설계를 만들 수 있다.&lt;/p&gt;

            &lt;p&gt;훌륭한 설계는 결합도가 낮고 응집도가 높은 설계이며, 이를 달성하기 위해서는 적절한 캡슐화가 필수적이다. 객체지향 프로그래밍에서 객체는 단순한 데이터 저장소가 아니라, 자신의 상태와 행동을 책임지는 자율적인 존재여야 한다.&lt;/p&gt;

            &lt;p&gt;설계할 때는 &quot;무엇을 할 것인가(What)&quot;를 먼저 결정하고, 그 다음에 &quot;어떻게 할 것인가(How)&quot;를 결정해야 한다. 데이터 중심 설계는 이 순서를 뒤집어서 발생하는 문제점들이 많다. 올바른 객체지향 설계를 위해서는 책임과 협력에 집중해야 한다.&lt;/p&gt;
        &lt;/section&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/18</guid>
      <comments>https://pandaterry.tistory.com/entry/%EC%B1%85-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-CH4-%EC%84%A4%EA%B3%84%ED%92%88%EC%A7%88%EA%B3%BC-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84#entry18comment</comments>
      <pubDate>Sun, 16 Nov 2025 15:11:09 +0900</pubDate>
    </item>
    <item>
      <title>[책 | 오브젝트] CH3. 역할/책임/협력</title>
      <link>https://pandaterry.tistory.com/entry/%EC%B1%85-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-CH3-%EC%97%AD%ED%95%A0%EC%B1%85%EC%9E%84%ED%98%91%EB%A0%A5</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;오브젝트 책 표지.jpg&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7VOA5/dJMcajURrya/LoKqVQXomg54FXfViaqKg1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7VOA5/dJMcajURrya/LoKqVQXomg54FXfViaqKg1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7VOA5/dJMcajURrya/LoKqVQXomg54FXfViaqKg1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7VOA5%2FdJMcajURrya%2FLoKqVQXomg54FXfViaqKg1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;500&quot; data-filename=&quot;오브젝트 책 표지.jpg&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옛날에도 읽었지만 업무를 하면서 체화가 안되었다고 판단해서 다시 읽어보면서 정리해보려 합니다. 여러번 읽어도 괜찮은 책이라 생각하기도 하고, 더 광범위한 사례가 필요할 때 다른 책도 구매해보려 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암튼 목표는! 데이터 중심 사고에서 어느정도 벗어나서 객체지향적인 사고를 해봐야겠습니다.&lt;/p&gt;
&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h1&gt;1. 협력&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;협력이란?&lt;/h2&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;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메시지 전송&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체 사이 협력을 위해 사용할 수 있는 유일한 커뮤니케이션 수단&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;왜 유일하냐면, 내부 구현에 직접 접근할 수 없기 때문. 메시지 전송만을 통해 요청을 전달가능&lt;/li&gt;
&lt;li&gt;메시지 발신자가 메서드를 실행하여 메시지를 보내고, 수신자는 메서드를 실행하여 수신한다.(상태에 담기는 것도 사실상 메서드 실행)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;협력 = 설계를 위한 문맥을 결정&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 말은 설계를 하려면 협력관계를 잘 정의해야한다는 말이다. 서로 독립적으로 작동하는 객체는 없고 협력간에 이뤄진다. 그래서 이 협력을 위한 적절한 행동파악에 유의해야한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Movie 객체는 어떤 행동을 수행해야할까?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일반적으론, play 라는 행동을 수행할 것이라 생각한다.&lt;/li&gt;
&lt;li&gt;하지만, Movie 객체안에는 상영을 위한 어떠한 코드도 없다. 대부분 요금 계산을 위한 것일뿐.&lt;/li&gt;
&lt;li&gt;결국 Movie는 요금 계산의 책임을 지고 있다는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;협력 -&amp;gt; 행동 -&amp;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;/li&gt;
&lt;li&gt;객체의 행동을 통해 객체의 성태를 결정한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;절대 반대로 되면 안된다. 추상화가 깨질 뿐이라 유연하지 못한 설계가 나올 확률이 높다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇기 때문에 협력이 행동과 그에 따른 상태까지 결정하기 때문에 &lt;code&gt;문맥&lt;/code&gt;을 제공한다는 것이다.&lt;/p&gt;
&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h1&gt;2. 책임&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;책임이란?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;행위의 집합&lt;/li&gt;
&lt;li&gt;객체가 유지해야하는 정보 + 수행할 수 있는 행동 -&amp;gt; 이거를 서술한 문장&lt;/li&gt;
&lt;li&gt;무엇을 알고 있는가(knowing)? + 무엇을 할 수 있는가(doing)?&lt;/li&gt;
&lt;li&gt;객체지향 설계의 핵심(객체에 책임을 능숙하게 할당하면 그 자체로 능통한 것). 왜냐면 책임을 할당하는 순간부터 맥락이 형성된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Screening의 책임은?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아는 것 : 상영할 영화를 알아야 한다.(Movie)&lt;/li&gt;
&lt;li&gt;하는 것 : 예매 정보를 생성해야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Movie의 책임은?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아는 것 : 영화 정보를 알아야한다.&lt;/li&gt;
&lt;li&gt;하는 것 : 예매 가격을 계산해야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DiscountPolicy 의 책임은?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아는 것 : 할인 정책을 알아야한다.&lt;/li&gt;
&lt;li&gt;하는 것 : 할인된 가격을 계산해야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DiscountCondition 의 책임은?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아는 것 : 할인 조건을 알고 있어야한다.&lt;/li&gt;
&lt;li&gt;하는 것 : 할인 여부를 판단해야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;책임과 메시지의 차이?&lt;/h2&gt;
&lt;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;메세지는 구체적이며 개념적으로 좁다.(ex. 예매하라, 가격을 계산하라)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;객체에게 책임 할당하기(전문가를 찾자)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&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;code&gt;'예매하라'&lt;/code&gt; 라는 책임을 추출하기는 쉽다.&lt;/li&gt;
&lt;li&gt;하지만, &lt;code&gt;어떤 객체에게 할당할지&lt;/code&gt;가 가장 문제가 되는 부분이다.&lt;/li&gt;
&lt;li&gt;이 때, 예매를 하기 위한 정보를 소유하고 있거나, 해당 정보의 소유자를 가장 잘 알고 있는 &lt;code&gt;전문가&lt;/code&gt;를 찾는다.&lt;/li&gt;
&lt;li&gt;Screening 은 예매를 하기 위한 대상인 Movie를 잘 알고 있어서 여기 할당한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;가격을 계산하라!&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;영화를 예매하기 위해선 &lt;code&gt;가격을 계산해야한다.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Screening은 예매 가격을 위한 정보를 &lt;code&gt;충분히 알고 있지 않다.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;그럼 외부 객체에, &lt;code&gt;가격을 잘 아는 객체&lt;/code&gt;에 요청해야한다.&lt;/li&gt;
&lt;li&gt;이를 위해선 '가격을 계산하기' 위한 &lt;code&gt;새로운 메세지&lt;/code&gt;를 만들고 이를 위한 적절한 객체를 찾아야한다.&lt;/li&gt;
&lt;li&gt;가격에 대한 정보를 가장 잘 아는 객체는 &lt;code&gt;Movie&lt;/code&gt; 이다.(영화 가격이 있으니) 그래서 가격계산의 책임은 Movie가 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;할인 정보를 계산하라!&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Movie는 할인 정보에는 &lt;code&gt;문외한&lt;/code&gt;이다.(=전문가가 아니다.)&lt;/li&gt;
&lt;li&gt;이 또한 '할인 정보를 계산하라' 라는 메세지를 만든다.&lt;/li&gt;
&lt;li&gt;....&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 반복되는 설계를 통해 책임 할당이 이뤄진다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;메세지가 결국 객체를 결정한다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 과정에서 느낀 가장 중요한 포인트는 아래와 같다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;원칙&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;책임을 할당하는데 필요한 메시지를 먼저 식별한다.&lt;/li&gt;
&lt;li&gt;메시지를 처리할 객체를 나중에 선택했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 다음과 같은 이유가 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이유&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;객체는 최소한의 인터페이스를 가질 수 있다.&lt;br /&gt;-&amp;gt; 메시지를 알아야만 추가하기 때문에 불필요한 내용이 추가되지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;/span&gt;객체는 충분히 추상적인 인터페이스를 가질 수 있게 된다.&lt;br /&gt;-&amp;gt; 객체는 &lt;code style=&quot;letter-spacing: 0px;&quot;&gt;어떻게&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 수행하는지를 노출하면 안된다. &lt;/span&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;무엇&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;을 하는지만 메시지에 노출되기 때문에 추상적이게 되는 것이다.&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;행동이 상태를 결정한다.&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;객체를 추가하는 시점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 객체는 협력에 참여하기 위해 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 반대로 말하면 협력이 필요없는 상태에선 추가 객체를 만들 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 그래서 객체가 객체다워지려면 객체의 상태가 아닌 객체가 &lt;code&gt;다른 객체에게 제공하는 행동&lt;/code&gt;(협력)에 집중해야한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태먼저? -&amp;gt; 데이터 주도 설계(O), 객체 주도 설계(X)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자들은 객체에 먼저 필요한 상태가 무엇인지 파악한다.&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;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체의 내부 구현이 객체의 인터페이스에 노출되고마니 캡슐화를 저해한다.&lt;br /&gt;객체 내부 구현 변경이 의존하는 객체에도 영향이 간다는 말이다&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 개발 방법은 데이터 주도 설계이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태는 신경쓰지 마라.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태는 단순히 협력을 위해 만들어진 부산물이라 생각해라.&lt;/p&gt;
&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h1&gt;3. 역할&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합이다. 역할에게 책임을 할당한다고 보면 된다. 그리고 객체와 역할이 1:1 대응이 될수도 혹은 더 추상적인 개념이 될수도 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;역할을 찾기&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;영화를 예매하라!&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;영화를 예매할 수 있는 적절한 역할은 무엇일까?&lt;/li&gt;
&lt;li&gt;역할을 수행할 객체로 익명의 역할은 &lt;code&gt;상영&lt;/code&gt;이다.&lt;/li&gt;
&lt;li&gt;상영에 어울리는 객체는 Screening이니 이 객체를 선택한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;가격을 계산하라!&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가격을 계산할 수 있는 적절한 역할은 무엇일까?&lt;/li&gt;
&lt;li&gt;역할을 수행할 객체로 익명의 역할로 &lt;code&gt;영화&lt;/code&gt;이다.&lt;/li&gt;
&lt;li&gt;영화에 어울리는 객체는 Movie 이니 이 객체를 선택한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;'유연한 &amp;amp; 재사용가능한' 협력이란?&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 역할이란게 없으면 유연하지 못하고 재사용이 불가능한 협력이 만들어질 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;할인가격을 계산하라!(잘못된 예시)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Movie는 단순 가격 계산이 아닌 할인가격을 계산하기 위해 아까 DiscountPolicy에게 책임을 할당한 걸 보았을 것이다.&lt;/li&gt;
&lt;li&gt;하지만!, 할인정책은 비율할인, 금액 할인 2가지가 있다.&lt;/li&gt;
&lt;li&gt;역할이 없이 이 정책에 따라 바로 메시지에 따른 책임을 할당하려면 AmountDiscountPolicy, PercentDiscountPolicy 인스턴스 2개가 만들어져야한다.&lt;/li&gt;
&lt;li&gt;이러면 코드중복이 발생해서 유연하지 못하고, 재사용이 불가능해진다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;할인가격을 계산하라!(잘된 예시)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;AmountDiscountPolicy, PercentDiscountPolicy 는 모두 할인 요금 계산이라는 동일한 책임을 갖고 있다.&lt;/li&gt;
&lt;li&gt;할인 요금을 계산하는 역할을 만들면 된다.&lt;/li&gt;
&lt;li&gt;그게 바로 DiscountPolicy 이다. 객체를 포괄하는 추상화된 개념이 역할이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;객체 VS 역할&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;역할&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체가 참여할 수 있는 일종의 슬롯이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;오직 하나의 객체만 협력에 참여한다면?&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 객체가 협력한다면 객체는 역할과 1:1이 반드시 된다고 말할 수 없지만, 그게 아니라 하나의 객체만 협력에 참여한다면 객체:역할 = 1:1에 대응한다고 볼 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;역할, 추상화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&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;/li&gt;
&lt;li&gt;설계가 좀 더 유연해진다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할 -&amp;gt; 추상화&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할도 추상화의 일종이기 때문에 위 2가지 장점을 모두 가져갈 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;세부사항에 억눌리지 않고 상위 수준의 정책을 쉽고 간단하게 표현가능&lt;br /&gt;-&amp;gt; 할인 가격 계산도 사실상 금액할인 정책, 비율 할인정책을 순번조건, 기간 조건과 조합해서 다양한 요금 계산 규칙을 설정할 수 있다. 하지만 이는 큰 그림을 파악하는데 방해만 할 뿐이다. 이걸 DiscountPolicy -&amp;gt; DiscountCondition으로 표현하면 바로 이해가 된다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;/span&gt;설계를 유연하게 만들 수 있다.&lt;br /&gt;-&amp;gt; 협력 안에서 동일한 책임을 수행하는 객체들은 동일한 역할을 하기에 서로 대체 가능해진다. 역할은 환경에 따라 다양한 객체들을 수용하기 때문에 협력이 유연해진다. DiscountPolicy, DiscountCondition 이라는 역할을 수행하는 어떤 객체라도 요금 계산 협력에 참여가 가능하다.&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비유 : 배우 &amp;amp; 배역&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;대응 관계&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배우 : 객체&lt;/li&gt;
&lt;li&gt;배역 : 역할&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;예시&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배우는 하나의 영화에서 여러 배역을 가질 수 있다.(1인 2역)
&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;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;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;</description>
      <category>개발/OOP</category>
      <category>OOP</category>
      <category>객체지향적사고</category>
      <category>오브젝트</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/17</guid>
      <comments>https://pandaterry.tistory.com/entry/%EC%B1%85-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-CH3-%EC%97%AD%ED%95%A0%EC%B1%85%EC%9E%84%ED%98%91%EB%A0%A5#entry17comment</comments>
      <pubDate>Sun, 9 Nov 2025 16:55:19 +0900</pubDate>
    </item>
    <item>
      <title>25년 기준, 우리가 인공지능을 이길 수 있는 방법</title>
      <link>https://pandaterry.tistory.com/entry/25%EB%85%84-%EA%B8%B0%EC%A4%80-%EC%9A%B0%EB%A6%AC%EA%B0%80-%EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5%EC%9D%84-%EC%9D%B4%EA%B8%B8-%EC%88%98-%EC%9E%88%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oqhYZ/dJMcahW2LSf/yQ8bBpArK40B5BzkVtsKWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oqhYZ/dJMcahW2LSf/yQ8bBpArK40B5BzkVtsKWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oqhYZ/dJMcahW2LSf/yQ8bBpArK40B5BzkVtsKWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoqhYZ%2FdJMcahW2LSf%2FyQ8bBpArK40B5BzkVtsKWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;인공지능의 뇌와 인간의 뇌를 구분짓는 기준은 뭘까요?&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인간은 세상이 어떻게 될지를 먼저 예측하고 그에 맞게 학습하고 재시도하는 등의 루프를 만들어서 행동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뇌는 정답을 만드는 방향보단 오답을 줄이는 방향으로 작동한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 받아들이는게 아닌 예측을 하고 빗나가면 수정하는게 인간의 뇌입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 인공지능도 예측하고 수정하는 등의 작업을 거칩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 배운 것만 하게 되고, 오감이 없기에 세상을 계속 먼저 앞서서 느끼기엔 한계가 있을 수 밖에 없죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 인간은 많은 시도를 하면서 예측하고 수정하는 행동을 많이 해야 해당 영역에 능통하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 여기서 반문을 할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;당연한거 아니야?&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연하다고 느낄 수는 있습니다. 하지만, 여기서 중요한 포인트는 세상이 어떻게 될지 먼저 예측한다는 점입니다. 이걸 &lt;b&gt;능동적인 추론&lt;/b&gt;이라고 하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 바쁜 생활속에서 회사를 다닌다거나 의지가 없거나 흥미가 없으면 수동적으로만 받아들이는 행동을 자주 하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 나이가 들면서 의지를 끌어올리는 힘도 같이 줄어들게 되구요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oZAvL/dJMcadtzCu3/wScgW9kNfxMcSBU7daUzi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oZAvL/dJMcadtzCu3/wScgW9kNfxMcSBU7daUzi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oZAvL/dJMcadtzCu3/wScgW9kNfxMcSBU7daUzi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoZAvL%2FdJMcadtzCu3%2FwScgW9kNfxMcSBU7daUzi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수동적인 추론은 내가 일하면서 보게 된 문서나, 현상에 대해 단순히 받아들이고 거기서 추론하는 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같은 표로 차이를 한번 봐보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;능동 추론 (Active Inference)&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;수동 추론 (Passive Inference)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;핵심 아이디어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;환경을 &lt;b&gt;예측하고 조작&lt;/b&gt;하여 불확실성을 줄임&lt;/td&gt;
&lt;td&gt;주어진 정보를 &lt;b&gt;관찰하고 분석&lt;/b&gt;하여 결론을 도출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;행위 주체&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;스스로 환경에 개입 (행동 포함)&lt;/td&gt;
&lt;td&gt;외부 입력을 그대로 받아들임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;목표&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;예측 오류를 최소화하며 원하는 상태에 도달&lt;/td&gt;
&lt;td&gt;정확한 설명이나 인과관계 이해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;왜 이런 결과가 나왔을까?&amp;rdquo; &amp;rarr; 직접 실험하거나 질문&lt;/td&gt;
&lt;td&gt;&amp;ldquo;이 현상의 원인은 무엇일까?&amp;rdquo; &amp;rarr; 자료를 보고 분석&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 그런 고민을 한 적이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;완벽주의가 내 인생을 갉아먹는 것 같다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p 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;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;원 사이클(One-Cycle) 법칙&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 한 사이클을 돌리고, 당연히 불완전할 것이고, 그 사이클이 진행된 걸 보면서 수정해나가면서 완벽하게 만들자! 이런 법칙이었습니다. 처음부터 완벽해지려고 해서 계획하고 설계하다가 진이 빠져서 포기할바엔 개요를 짜고 바로 들어가서 일련의 과정을 겪어보고 그걸 수정해나가는게 낫겠다는 생각을 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 생각을 하고 근 7개월이 지난 시점인 지금 아래 뇌과학 유튜브에 '능동 추론' 이라는 영역을 보고 내 생각이 틀린게 아니었구나! 하는 생각이 들어 블로그 글로 남깁니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적인 견해로 너무 좋은 주제이고, 한 분야에 능통해지고 싶은 욕구가 있기에 아래 출처 남기면서 글을 마칩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=PC4KNLdWPGY&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=PC4KNLdWPGY&lt;/a&gt;&lt;/p&gt;</description>
      <category>생활</category>
      <category>뇌과학</category>
      <category>능동</category>
      <category>수동</category>
      <category>완벽주의</category>
      <category>추론</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/16</guid>
      <comments>https://pandaterry.tistory.com/entry/25%EB%85%84-%EA%B8%B0%EC%A4%80-%EC%9A%B0%EB%A6%AC%EA%B0%80-%EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5%EC%9D%84-%EC%9D%B4%EA%B8%B8-%EC%88%98-%EC%9E%88%EB%8A%94-%EB%B0%A9%EB%B2%95#entry16comment</comments>
      <pubDate>Sun, 9 Nov 2025 14:17:59 +0900</pubDate>
    </item>
    <item>
      <title>[OOP 안티패턴] God Object? 이 정도는 괜찮지 않나요</title>
      <link>https://pandaterry.tistory.com/entry/OOP-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-God-Object-%EC%9D%B4-%EC%A0%95%EB%8F%84%EB%8A%94-%EA%B4%9C%EC%B0%AE%EC%A7%80-%EC%95%8A%EB%82%98%EC%9A%94</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;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btWJMk/btsOObLMree/8Ok89viAEvvCgkg8jNe9s1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btWJMk/btsOObLMree/8Ok89viAEvvCgkg8jNe9s1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btWJMk/btsOObLMree/8Ok89viAEvvCgkg8jNe9s1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtWJMk%2FbtsOObLMree%2F8Ok89viAEvvCgkg8jNe9s1%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;506&quot; height=&quot;759&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 클래스가 점점 무거워질 때, 우리는 한 번쯤 이런 생각을 합니다.&lt;/p&gt;
&lt;blockquote data-end=&quot;381&quot; data-start=&quot;303&quot; data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&amp;ldquo;나는 오케스트레이션만 하니까 괜찮지 않나?&amp;rdquo;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&amp;ldquo;도메인 객체는 그냥 데이터 구조잖아.&amp;rdquo;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&amp;ldquo;유효성 검사는 서비스에서 다 하면 되는 거 아냐?&amp;rdquo;&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-end=&quot;483&quot; data-start=&quot;383&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;483&quot; data-start=&quot;383&quot; data-ke-size=&quot;size16&quot;&gt;실제로 몇몇 개발자들도 이런 구조를 문제 삼지 않고 넘기곤 합니다.&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;483&quot; data-start=&quot;383&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;483&quot; data-start=&quot;383&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SRP를 지켰다&lt;/b&gt;거나, &lt;b&gt;도메인을 분리했다&lt;/b&gt;는 형식적 명분으로 충분하다고 느낄 수도 있기 때문입니다.&lt;/p&gt;
&lt;p data-end=&quot;536&quot; data-start=&quot;485&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;536&quot; data-start=&quot;485&quot; data-ke-size=&quot;size16&quot;&gt;하지만 진짜 중요한 건 그 구조가 &lt;b&gt;정말 도메인의 책임과 역할을 반영하고 있는가&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-end=&quot;639&quot; data-start=&quot;538&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;639&quot; data-start=&quot;538&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서는 흔히 &quot;이 정도면 괜찮다&quot;라고 여겨지는 코드들이 실제로는 어떤 문제를 유발할 수 있는지를 검토하고, 그에 대한 개선 방향을 구체적인 코드와 함께 살펴보았습니다.&lt;/p&gt;
&lt;p data-end=&quot;639&quot; data-start=&quot;538&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;639&quot; data-start=&quot;538&quot; data-ke-size=&quot;size16&quot;&gt;총 4가지 예시를 준비해봤습니다&amp;nbsp; :)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;639&quot; data-start=&quot;538&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;예시 1. OrderService &amp;ndash; &quot;나는 오케스트레이션만 하니까 괜찮지 않나요?&quot;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A 개발자&lt;/b&gt; : SRP를 지켰어요. 각각 결제, 재고, 쿠폰 처리를 전담 객체에 위임했으니까요.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B 개발자&lt;/b&gt; : &amp;ldquo;서비스는 단지 조율자일 뿐이에요. OrderService는 흐름만 담당하고, 나머지는 각 도메인 로직이 처리하고 있죠.&amp;rdquo;&lt;br /&gt;&amp;ldquo;그래서 이 정도면 괜찮은 거 아닐까요?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&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;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentProcessor paymentProcessor;
    private final InventoryManager inventoryManager;
    private final CouponValidator couponValidator;
    private final NotificationService notifier;

    public OrderService(
            OrderRepository orderRepository,
            PaymentProcessor paymentProcessor,
            InventoryManager inventoryManager,
            CouponValidator couponValidator,
            NotificationService notifier
    ) {
        this.orderRepository = orderRepository;
        this.paymentProcessor = paymentProcessor;
        this.inventoryManager = inventoryManager;
        this.couponValidator = couponValidator;
        this.notifier = notifier;
    }

    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);

        paymentProcessor.process(order);
        inventoryManager.reserve(order);
        couponValidator.validate(order);

        order.markAsCompleted();
        orderRepository.save(order);

        notifier.sendOrderCompleted(order.getId());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 1.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;도메인 객체는 아무것도 하지 않고, 서비스가 모든 흐름을 알고 있습니다&lt;/blockquote&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;paymentProcessor.process(order);
inventoryManager.reserve(order);
couponValidator.validate(order);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서비스가 모든 처리 순서를 결정&lt;/b&gt;하고 있으며, 각 객체는 단순히 &amp;ldquo;수행 대상&amp;rdquo;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;결제 정책이 바뀌면 서비스가 바뀌고&lt;/b&gt;, &lt;b&gt;재고 처리 방식이 바뀌어도 서비스가 바뀌게 됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 도메인 객체에 책임을 분산하지 못한 구조입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 2.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;오케스트레이터처럼 보이지만, 사실상 업무 정책을 서비스에 몰아넣었습니다&lt;/blockquote&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;public void completeOrder(Long orderId) { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;completeOrder()는 이름만 보면 하나의 책임처럼 보이지만, 실제로는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;결제를 처리하고&lt;/li&gt;
&lt;li&gt;재고를 예약하고&lt;/li&gt;
&lt;li&gt;쿠폰을 검증하고&lt;/li&gt;
&lt;li&gt;주문을 완료시키고&lt;/li&gt;
&lt;li&gt;알림까지 보내고 있습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 것은 하나의 변경 사유로 보기 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정책별로 서로 다른 변화가 발생할 수 있고, 이는 &lt;b&gt;단일 책임 원칙 위반&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 3.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;도메인 객체는 자신의 상태와 관련된 행동을 외부에 의존합니다&lt;/blockquote&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;Order order = orderRepository.findById(orderId);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Order는 자신의 결제수단, 상품 목록, 쿠폰을 알고 있음에도&lt;/p&gt;
&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;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;도메인 객체는 행위의 주체여야 하며&lt;/b&gt;, 내부 상태와 관련된 행동은 스스로 수행할 수 있어야 합니다.&lt;/p&gt;
&lt;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;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentProcessor paymentProcessor;
    private final InventoryManager inventoryManager;
    private final CouponValidator couponValidator;
    private final NotificationService notifier;

    public OrderService(
            OrderRepository orderRepository,
            PaymentProcessor paymentProcessor,
            InventoryManager inventoryManager,
            CouponValidator couponValidator,
            NotificationService notifier
    ) {
        this.orderRepository = orderRepository;
        this.paymentProcessor = paymentProcessor;
        this.inventoryManager = inventoryManager;
        this.couponValidator = couponValidator;
        this.notifier = notifier;
    }

    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);

        order.processPaymentWith(paymentProcessor);
        order.reserveInventoryWith(inventoryManager);
        order.validateCouponWith(couponValidator);

        order.markAsCompleted();
        orderRepository.save(order);

        notifier.sendOrderCompleted(order.getId());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class Order {

    public void processPaymentWith(PaymentProcessor processor) {
        processor.process(this.paymentInfo());
    }

    public void reserveInventoryWith(InventoryManager manager) {
        manager.reserve(this.items());
    }

    public void validateCouponWith(CouponValidator validator) {
        validator.validate(this.appliedCoupon());
    }

    public void markAsCompleted() {
        this.status = OrderStatus.COMPLETED;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3901&quot; data-start=&quot;3884&quot; data-ke-size=&quot;size26&quot;&gt;왜 이렇게 개선했는가?&lt;/h2&gt;
&lt;h4 data-end=&quot;3951&quot; data-start=&quot;3903&quot; data-ke-size=&quot;size20&quot;&gt;개선1.&amp;nbsp;&lt;/h4&gt;
&lt;blockquote data-end=&quot;3951&quot; data-start=&quot;3903&quot; data-ke-style=&quot;style2&quot;&gt;서비스에서 흐름만 담당하게 함으로써 정책 책임을 도메인으로 분리&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4095&quot; data-start=&quot;3953&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4036&quot; data-start=&quot;3953&quot;&gt;order.processPaymentWith(...) 형태로 개선함으로써, &lt;b&gt;실제 '행동'의 주체가 도메인 객체로 전환&lt;/b&gt;됩니다.&lt;/li&gt;
&lt;li data-end=&quot;4095&quot; data-start=&quot;4037&quot;&gt;이는 &quot;결제를 어떻게 처리할지&quot;라는 &lt;b&gt;정책적 책임이 서비스가 아니라 도메인에 있도록 보장&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;4152&quot; data-start=&quot;4102&quot; data-ke-size=&quot;size20&quot;&gt;개선2.&lt;/h4&gt;
&lt;blockquote data-end=&quot;4152&quot; data-start=&quot;4102&quot; data-ke-style=&quot;style2&quot;&gt;도메인은 협력을 이끄는 주체가 되며, 외부 기능을 의존성으로만 활용&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4286&quot; data-start=&quot;4154&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4242&quot; data-start=&quot;4154&quot;&gt;Order 객체가 PaymentProcessor를 직접 사용하는 것이 아닌, &lt;b&gt;그와 협력할 수 있는 명시적인 메서드를 정의&lt;/b&gt;했습니다.&lt;/li&gt;
&lt;li data-end=&quot;4286&quot; data-start=&quot;4243&quot;&gt;도메인은 &lt;b&gt;스스로 결정하고, 외부는 단지 도우미 역할&lt;/b&gt;로 위치시킵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;4340&quot; data-start=&quot;4293&quot; data-ke-size=&quot;size20&quot;&gt;개선3&lt;/h4&gt;
&lt;blockquote data-end=&quot;4340&quot; data-start=&quot;4293&quot; data-ke-style=&quot;style2&quot;&gt;서비스가 무거워지는 것을 방지하고, 변경에 유연한 구조로 전환&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4499&quot; data-start=&quot;4342&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4411&quot; data-start=&quot;4342&quot;&gt;서비스는 &quot;어떤 일이 일어나야 하는가&quot;에만 집중합니다. (예: 결제 &amp;rarr; 재고 &amp;rarr; 쿠폰 &amp;rarr; 완료 &amp;rarr; 알림 순서)&lt;/li&gt;
&lt;li data-end=&quot;4499&quot; data-start=&quot;4412&quot;&gt;실제 &lt;b&gt;어떻게 처리되는가는 도메인이 캡슐화&lt;/b&gt;하므로, 재고 처리 정책이나 결제 방식이 바뀌더라도 OrderService는 그대로 유지됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;4541&quot; data-start=&quot;4506&quot; data-ke-size=&quot;size20&quot;&gt;개선4&lt;/h4&gt;
&lt;blockquote data-end=&quot;4541&quot; data-start=&quot;4506&quot; data-ke-style=&quot;style2&quot;&gt;테스트 및 유지보수성이 획기적으로 향상됨&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4651&quot; data-start=&quot;4543&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4574&quot; data-start=&quot;4543&quot;&gt;각각의 도메인 메서드는 단위 테스트가 가능합니다.&lt;/li&gt;
&lt;li data-end=&quot;4608&quot; data-start=&quot;4575&quot;&gt;서비스는 흐름을 검증하는 통합 테스트 대상이 됩니다.&lt;/li&gt;
&lt;li data-end=&quot;4651&quot; data-start=&quot;4609&quot;&gt;따라서 &lt;b&gt;단위 수준에서도, 흐름 수준에서도 테스트가 더 쉬워집니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;예시 2. SubscriptionService &amp;ndash; &quot;이벤트 퍼블리싱은 서비스에서 다 해야지 않나요?&quot;&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;A 개발자&lt;/b&gt;: &lt;br /&gt;&quot;도메인은 말 그대로 데이터와 상태 변경만 담당해야지.&lt;br /&gt;이벤트 퍼블리싱은 외부 시스템과의 연결이니까, 당연히 서비스에서 해줘야죠.&lt;br /&gt;그리고 도메인에서는 이벤트만 모아두고, 호출한 쪽에서 퍼블리시하면 책임이 깔끔하게 나뉘잖아요.&quot;&lt;/blockquote&gt;
&lt;blockquote data-end=&quot;530&quot; data-start=&quot;367&quot; data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;B 개발자:&lt;/b&gt;&lt;br /&gt;&quot;그렇게 보면 맞는 것 같지만, 진짜 위험한 건 이벤트를 '발생시켰는지 여부'가 호출자에게 전가된다는 거예요.&lt;br /&gt;비즈니스 로직은 그 자체로 닫혀 있어야 신뢰할 수 있는데, 이벤트 퍼블리싱을 서비스에 위임하면&lt;br /&gt;그걸 빼먹는 순간 로직이 '절반만 실행된 셈'이 되지 않을까요.....??&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;초기 코드&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class SubscriptionService {

    public void renew(Subscription subscription) {
        subscription.renew(); // 내부에서 상태 전이 및 이벤트 생성
        eventPublisher.publishAll(subscription.pullDomainEvents());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public class Subscription {

    private final List&amp;lt;DomainEvent&amp;gt; domainEvents = new ArrayList&amp;lt;&amp;gt;();

    public void renew() {
        if (this.expired()) {
            this.status = SubscriptionStatus.ACTIVE;

            if (this.plan.isPremium()) {
                domainEvents.add(new PremiumRenewalCompletedEvent(this.userId));
            }
        }
    }

    public List&amp;lt;DomainEvent&amp;gt; pullDomainEvents() {
        List&amp;lt;DomainEvent&amp;gt; copy = new ArrayList&amp;lt;&amp;gt;(domainEvents);
        domainEvents.clear();
        return copy;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 1.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;퍼블리싱 누락 가능성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼블리싱 호출 안 했을 경우 이벤트 발생이 없게 됩니다. 이는 결국 누락으로 이어져 비즈니스 로직에 문제가 발생하게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;subscription.renew();&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 2. &lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;도메인 로직의 폐쇄성 부족&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 로직을 한 번 호출한 뒤, 이벤트가 쌓였다는 걸 개발자가 인지하고 퍼블리시를 수동 호출해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 다른 개발자가 이어서 개발할 때 모든 로직을 꼼꼼히 안보면 문제가 생기겠죠?&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;eventPublisher.publishAll(subscription.pullDomainEvents());&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 3.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;반복되는 퍼블리싱 보일러플레이트&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 서비스 메서드마다 이 패턴을 반복적으로 작성해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;eventPublisher.publishAll(도메인.pullDomainEvents());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개선 코드&amp;nbsp;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class Subscription {

    private final List&amp;lt;DomainEvent&amp;gt; domainEvents = new ArrayList&amp;lt;&amp;gt;();

    public List&amp;lt;DomainEvent&amp;gt; renewAndGetEvents() {
        if (this.expired()) {
            this.status = SubscriptionStatus.ACTIVE;

            if (this.plan.isPremium()) {
                domainEvents.add(new PremiumRenewalCompletedEvent(this.userId));
            }
        }

        return pullDomainEvents(); // 내부에서 자동 수집하여 반환
    }

    private List&amp;lt;DomainEvent&amp;gt; pullDomainEvents() {
        List&amp;lt;DomainEvent&amp;gt; copy = new ArrayList&amp;lt;&amp;gt;(domainEvents);
        domainEvents.clear();
        return copy;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;public class SubscriptionService {

    public void renew(Subscription subscription) {
        List&amp;lt;DomainEvent&amp;gt; events = subscription.renewAndGetEvents();
        eventPublisher.publishAll(events); // 한 문장으로 위임 처리
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;3884&quot; data-end=&quot;3901&quot; data-ke-size=&quot;size26&quot;&gt;왜 이렇게 개선했는가?&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 1.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;퍼블리싱 누락 가능성 제거&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출자가 이벤트 존재 여부를 신경 쓰지 않아도 되도록 한 곳에 묶음 처리합니다. 메서드만 호출해도 이벤트가 처리되게끔 하는 겁니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;List&amp;lt;DomainEvent&amp;gt; events = subscription.renewAndGetEvents();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 2.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;도메인 로직의 폐쇄성 회복&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인이 상태 전이와 이벤트 반환을 묶어서 호출자의 책임을 최소화해야합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;subscription.renewAndGetEvents();

public List&amp;lt;DomainEvent&amp;gt; renewAndGetEvents() {
        if (this.expired()) {
            this.status = SubscriptionStatus.ACTIVE;

            if (this.plan.isPremium()) {
                domainEvents.add(new PremiumRenewalCompletedEvent(this.userId));
            }
        }

        return pullDomainEvents(); // 내부에서 자동 수집하여 반환
    }&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 3.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;서비스 계층에서 보일러플레이트 제거&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 추출과 퍼블리싱을 분리하는 대신 단일 책임화된 메서드로 만들어 이를 호출함으로서 보일러플레이트를 최소화합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;eventPublisher.publishAll(events);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;예시 3. ProductCommandService &amp;ndash; &quot;나는 도메인이 자기 책임은 져야 한다고 생각함&quot;&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&quot;서비스에서 유효성 검사 다 하면 되지 않나요?&quot; &lt;br /&gt;&quot;도메인은 데이터만 가지면 되는 거 아니에요?&quot; &lt;br /&gt;&quot;컨트롤러 &amp;rarr; 서비스 &amp;rarr; 도메인 호출이면 그 흐름대로 검증해도 문제없지 않나요?&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 코드&lt;/h3&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;public class ProductCommandService {

    public void register(ProductCreateCommand command) {
        Product product = new Product(command.name(), command.price(), command.inventory());

        if (product.price().isNegative()) {
            throw new IllegalArgumentException(&quot;음수 가격은 허용되지 않습니다.&quot;);
        }

        repository.save(product);
    }
}
&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 1.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;유효하지 않은 도메인 객체가 생성될 수 있음&lt;/blockquote&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;Product product = new Product(command.name(), command.price(), command.inventory());
&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;이 시점에 이미 음수 가격이라는 잘못된 상태가 객체로 만들어집니다.&lt;/li&gt;
&lt;li&gt;객체가 외부로 유출되면 의도치 않은 오류가 발생할 수 있습니다.&lt;/li&gt;
&lt;li&gt;도메인이 유효하지 않은 상태로 만들어지면 테스트, 배포, 운영 중 치명적인 장애를 유발할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 2. &lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;유효성 검증 책임이 도메인 바깥에 있음&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자만 열어두고 외부에서 조건을 걸기 시작하면, 언젠가는 누락됩니다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;if (product.price().isNegative()) {
    throw new IllegalArgumentException(&quot;음수 가격은 허용되지 않습니다.&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;/li&gt;
&lt;/ul&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;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class ProductCommandService {

    public void register(ProductCreateCommand command) {
        Product product = Product.create(command.name(), command.price(), command.inventory());
        repository.save(product);
    }
}

public class Product {

    public static Product create(String name, Money price, int inventory) {
        if (price.isNegative()) {
            throw new IllegalArgumentException(&quot;음수 가격은 허용되지 않습니다.&quot;);
        }

        return new Product(name, price, inventory);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;3884&quot; data-end=&quot;3901&quot; data-ke-size=&quot;size26&quot;&gt;왜 이렇게 개선했는가?&lt;/h2&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;3884&quot; data-end=&quot;3901&quot; data-ke-size=&quot;size20&quot;&gt;개선 1.&lt;/h4&gt;
&lt;blockquote data-start=&quot;3884&quot; data-end=&quot;3901&quot; data-ke-style=&quot;style2&quot;&gt;정적 팩토리 메서드를 통한 검증 일원화&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;public static Product create(...) {
    if (price.isNegative()) {
        throw new IllegalArgumentException(...);
    }
    return new Product(...);
}
&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;Product 객체는 무조건 유효한 상태로만 존재하게 됩니다.&lt;/li&gt;
&lt;li&gt;검증을 객체 내부로 넣는 건 단순히 &quot;깔끔해 보이기 위해서&quot;가 아니라,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;도메인의 완전성 보장을 위한 필수 조건&lt;/b&gt;입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 2.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;도메인 객체의 책임 명확화&lt;/blockquote&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;Product product = Product.create(...);
&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;Product 스스로 유효성을 판단하게 되면서 책임이 명확해집니다.&lt;/li&gt;
&lt;li&gt;서비스 계층은 객체를 &quot;어떻게&quot; 만드는지는 몰라도 되고, &quot;정상적인 객체만 받아 저장&quot; 하면 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;예시 4. SignupService &amp;ndash; 이벤트를 도메인이 아닌 서비스에서 직접 발생시키는 경우&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&quot;referral 코드는 비즈니스 로직이지, 굳이 도메인까지 갈 필요 있나요?&quot; &lt;br /&gt;&quot;이벤트는 서비스에서 처리하는 게 더 깔끔하죠.&quot; &lt;br /&gt;&quot;도메인은 그냥 데이터 구조잖아요. 이벤트는 서비스 책임 아닐까요?&quot;&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 코드&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;public class SignupService {

    public void signup(UserSignupCommand command) {
        User user = new User(command.name(), command.email(), command.password());
        userRepository.save(user);

        if (command.referralCode() != null) {
            eventPublisher.publish(new ReferralBonusEvent(command.referralCode(), user.id()));
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 1.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;도메인이 아닌 서비스가 이벤트를 생성&lt;/blockquote&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;eventPublisher.publish(new ReferralBonusEvent(command.referralCode(), user.id()));
&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;이벤트가 User 객체의 상태 변화와 무관하게 서비스 계층에서 독립적으로 생성됩니다.&lt;/li&gt;
&lt;li&gt;User는 referral 코드가 적용되었는지, 어떤 이벤트가 발생했는지 알 수 없습니다.&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 2.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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;/li&gt;
&lt;li&gt;향후 도메인 로직이 변경되거나 이벤트 구조가 달라질 경우, 서비스 로직도 직접 수정해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개선 코드&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;public class SignupService {

    public void signup(UserSignupCommand command) {
        User user = User.create(command.name(), command.email(), command.password());
        userRepository.save(user); // ID 생성 이후에 이벤트 필요

        if (command.referralCode() != null) {
            user.applyReferral(command.referralCode());
            eventPublisher.publishAll(user.pullDomainEvents());
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class User {

    private final List&amp;lt;DomainEvent&amp;gt; domainEvents = new ArrayList&amp;lt;&amp;gt;();
    private Long id;

    public static User create(String name, String email, String password) {
        return new User(name, email, password);
    }

    public void applyReferral(String referralCode) {
        domainEvents.add(new ReferralBonusEvent(referralCode, this.id));
    }

    public List&amp;lt;DomainEvent&amp;gt; pullDomainEvents() {
        List&amp;lt;DomainEvent&amp;gt; copy = new ArrayList&amp;lt;&amp;gt;(domainEvents);
        domainEvents.clear();
        return copy;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;3901&quot; data-start=&quot;3884&quot; data-ke-size=&quot;size26&quot;&gt;왜 이렇게 개선했는가?&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 1.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;이벤트 생성 책임을 도메인에 위임&lt;/blockquote&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;user.applyReferral(referralCode);
&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;이벤트 생성 책임을 User 내부로 이동시켜 응집도를 높입니다.&lt;/li&gt;
&lt;li&gt;User는 자신의 상태 변화와 그에 따른 이벤트 발행을 함께 관리하게 됩니다.&lt;/li&gt;
&lt;li&gt;상태를 바꾸는 주체가 이벤트까지 생성해야,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;변화의 근거&lt;/b&gt;와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;의미&lt;/b&gt;가 일치합니다.&lt;/li&gt;
&lt;li&gt;이벤트 생성이 서비스에 존재하면 도메인은 이벤트와 무관한 단순 데이터 구조가 되어,&lt;br /&gt;&amp;rarr; 유지보수 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;응집도는 낮고&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;버그 발생 가능성은 높아집니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 2.&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;이벤트 발행 흐름의 명확한 분리&lt;/blockquote&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;eventPublisher.publishAll(user.pullDomainEvents());
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스는 이벤트를 직접 생성하지 않고, 도메인이 만든 이벤트만 발행합니다.&lt;/li&gt;
&lt;li&gt;이로써 도메인은 이벤트의 생성자, 서비스는 전달자로서 명확히 역할이 구분됩니다.&lt;/li&gt;
&lt;li&gt;도메인 주도 설계 관점에서 이벤트는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;도메인 모델의 상태 변화의 부산물&lt;/b&gt;이어야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;724&quot; data-end=&quot;745&quot; data-ke-size=&quot;size26&quot;&gt;실무에서 어디까지 개선해야 하는가&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;747&quot; data-end=&quot;834&quot; data-ke-size=&quot;size16&quot;&gt;많은 개발자들이 &quot;서비스가 너무 무겁다&quot;는 진단을 하면서도&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;왜 그렇게 되었는지&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;혹은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;어디까지가 도메인 책임인지&lt;/b&gt;는 구분을 힘들어합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;836&quot; data-end=&quot;891&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;836&quot; data-end=&quot;891&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서 다룬 사례들을 종합해 보면&lt;span&gt;&amp;nbsp;&lt;/span&gt;God Object 문제는 다음과 같은 특성을 가집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;893&quot; data-end=&quot;1000&quot;&gt;
&lt;li data-start=&quot;893&quot; data-end=&quot;923&quot;&gt;&lt;b&gt;의사 결정이 모두 서비스에 집중되어 있고&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;924&quot; data-end=&quot;963&quot;&gt;&lt;b&gt;도메인은 단지 전달자 또는 수동적 데이터 구조에 머무르며&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;964&quot; data-end=&quot;1000&quot;&gt;&lt;b&gt;변경 발생 시, 도메인보다 서비스부터 바뀌게 되는 구조&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1002&quot; data-end=&quot;1060&quot; data-ke-size=&quot;size16&quot;&gt;이는 단순히 SRP 위반 문제가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&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;1024&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1XNCB/btsOMlI0ke1/duQ4WMDEJsuJLYjTnLm6c0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1XNCB/btsOMlI0ke1/duQ4WMDEJsuJLYjTnLm6c0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1XNCB/btsOMlI0ke1/duQ4WMDEJsuJLYjTnLm6c0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1XNCB%2FbtsOMlI0ke1%2FduQ4WMDEJsuJLYjTnLm6c0%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;451&quot; height=&quot;677&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;1067&quot; data-end=&quot;1081&quot; data-ke-size=&quot;size26&quot;&gt;실무에서의 선택 기준&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1083&quot; data-end=&quot;1135&quot; data-ke-size=&quot;size16&quot;&gt;도메인이 어떤 행동을 할 수 있다면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;그 행동의 시작점은 도메인 내부에 있어야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1137&quot; data-end=&quot;1214&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1137&quot; data-end=&quot;1214&quot; data-ke-size=&quot;size16&quot;&gt;서비스는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;도메인을 사용하는 사용자&lt;/b&gt;에 가깝고, 도메인은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;자기 상태를 관리하며, 외부 기능과 협력하는 주체&lt;/b&gt;가 되어야 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1216&quot; data-end=&quot;1266&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1216&quot; data-end=&quot;1266&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이벤트는 도메인 내부에서 발생되어야만 &lt;b&gt;그 의미와 근거가 일치&lt;/b&gt;하게 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;실무에서는 &quot;서비스를 얼마나 얇게 만들 수 있는가&quot;가 중요한 게 아니라&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&quot;도메인이 스스로 결정할 수 있는가&quot;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;그리고&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&quot;서비스는 그 결정만을 위임받고 있는가&quot;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1268&quot; data-end=&quot;1384&quot; data-ke-size=&quot;size16&quot;&gt;를 기준으로 판단해야 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;1391&quot; data-end=&quot;1427&quot; data-ke-size=&quot;size26&quot;&gt;마무리하며 &amp;ndash; 아직 God Object를 놓지 못하고 있다면&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1429&quot; data-end=&quot;1513&quot; data-ke-size=&quot;size16&quot;&gt;이 글의 모든 예시들은 실제 실무에서 흔히 보이는 코드들입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1429&quot; data-end=&quot;1513&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1429&quot; data-end=&quot;1513&quot; data-ke-size=&quot;size16&quot;&gt;오히려 &quot;그렇게까지 도메인을 쓰면 너무 과한 거 아닌가?&quot;라는 말을 듣기도 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1515&quot; data-end=&quot;1585&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1515&quot; data-end=&quot;1585&quot; data-ke-size=&quot;size16&quot;&gt;하지만 도메인을 신뢰할 수 있어야, &lt;b&gt;테스트도 단순해지고, 구조도 명확해지며, 서비스가 의사 결정에서 자유로워집니다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1587&quot; data-end=&quot;1641&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1587&quot; data-end=&quot;1641&quot; data-ke-size=&quot;size16&quot;&gt;도메인이 더 많은 책임을 질수록, 서비스는 더 가벼워지고, 시스템은 더 예측 가능해집니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1643&quot; data-end=&quot;1681&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1643&quot; data-end=&quot;1681&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;God Object는 결국, 책임을 나누지 못한 결과물&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1683&quot; data-end=&quot;1694&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1683&quot; data-end=&quot;1694&quot; data-ke-size=&quot;size16&quot;&gt;한 줄로 정리하자면,&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-start=&quot;1696&quot; data-end=&quot;1750&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-start=&quot;1698&quot; data-end=&quot;1750&quot; data-ke-size=&quot;size16&quot;&gt;&quot;도메인은 단순히 데이터를 담는 그릇이 아니라,&lt;br /&gt;행동의 주체이며 정책의 수호자입니다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>개발/OOP</category>
      <category>god object</category>
      <category>OOP</category>
      <category>객체지향</category>
      <category>도메인</category>
      <category>이벤트</category>
      <category>자바</category>
      <category>테스트코드</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/15</guid>
      <comments>https://pandaterry.tistory.com/entry/OOP-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-God-Object-%EC%9D%B4-%EC%A0%95%EB%8F%84%EB%8A%94-%EA%B4%9C%EC%B0%AE%EC%A7%80-%EC%95%8A%EB%82%98%EC%9A%94#entry15comment</comments>
      <pubDate>Mon, 23 Jun 2025 23:34:46 +0900</pubDate>
    </item>
    <item>
      <title>[Saas 개발로그] LLM 호출비 줄이는 법, Redis Search로 검증해봤습니다</title>
      <link>https://pandaterry.tistory.com/entry/Saas-%EA%B0%9C%EB%B0%9C%EB%A1%9C%EA%B7%B8-%EC%9E%90%EC%97%B0%EC%96%B4%EB%8F%84-%EC%BA%90%EC%8B%B1%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-Redis-Vector-Search%EB%A1%9C-%ED%95%9C%EA%B8%80-%EC%BA%90%EC%8B%B1-%EC%8B%A4%ED%97%98%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
      <description>&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r5wUu/btsOMRluRwL/FrWL5UPntCCFkl6RI7tcTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r5wUu/btsOMRluRwL/FrWL5UPntCCFkl6RI7tcTK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r5wUu/btsOMRluRwL/FrWL5UPntCCFkl6RI7tcTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr5wUu%2FbtsOMRluRwL%2FFrWL5UPntCCFkl6RI7tcTK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;웹 백엔드를 운영하다 보면 캐시는 거의 본능처럼 사용하게 됩니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;대부분은 Redis나 Memcached에 “정확히 일치하는” 키를 기반으로 결과를 저장하곤 합니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;예를 들어, product:summary:2024-05 같은 키는 5월 상품 요약 데이터를 조회할 때 잘 맞는 구조입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이렇게 &lt;b&gt;명확히 정해진 키로 식별할 수 있는 요청&lt;/b&gt;은 캐시하기가 쉽습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;그런데 최근에는 자연어 기반 인터페이스가 많아지고 있습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;LLM을 활용해 데이터를 조회하거나, 자연어로 분석 보고서를 생성하는 시스템이 점점 늘어나고 있습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;문제는 이런 자연어 요청은 매번 다르다는 점입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;예를 들어 사용자가 이런 식으로 요청할 수 있습니다.&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;“5월 매출 요약 보여줘”&lt;/li&gt;&lt;li&gt;“지난달 실적 정리해줘”&lt;/li&gt;&lt;li&gt;“이번 분기 매출 추이는?”&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이 요청들은 단어는 다르지만 &lt;b&gt;요청 의도는 거의 동일합니다.&lt;/b&gt;&lt;br&gt;그렇다면, 이들을 같은 요청으로 간주하고 캐싱할 수는 없을까요?&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;자연어 캐싱이 필요해졌던 배경&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;제가 개발 중인 시스템은 &lt;b&gt;LLM을 통해 SQL을 생성하고&lt;/b&gt;, 이를 실행한 뒤 &lt;b&gt;Excel 보고서로 다운로드하는&lt;/b&gt; SaaS 형태의 백엔드입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;고객은 자연어로 요청을 보내고, 저희는 이를 SQL로 변환해 백엔드 데이터베이스를 조회한 뒤 최종 결과를 파일로 제공합니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;SaaS 구조 특성상 &lt;b&gt;사용량 측정&lt;/b&gt;이 중요한 과제입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;보고서 1건당 응답 행 수, 처리 비용, 트래픽 등을 측정해 요금제 제한을 걸어야 하기 때문입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이 과정에서 Redis나 Kafka를 이용한 &lt;b&gt;이벤트 유실 없는 로그 전송&lt;/b&gt;도 설계되어 있지만,&lt;br&gt;&amp;nbsp;&lt;br&gt;한편으로는 “같은 요청인데 매번 LLM을 호출하는 것이 과연 합리적인가?”라는 고민도 들었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;LLM 호출 비용은 결코 싸지 않고&lt;/b&gt;, 또 GPT나 자체 LLM 처리 서버 구조에서는 요청이 많아질수록 처리 속도에도 영향을 줄 수 있기 때문입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;그러다가, 이 글을 본 겁니다. &lt;a href=&quot;https://redis.io/blog/what-is-semantic-caching/&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;https://redis.io/blog/what-is-semantic-caching/&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;Redis - The Real-time Data Platform&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;Developers love Redis. Unlock the full potential of the Redis database with Redis Enterprise and start building blazing fast apps.&quot; data-og-host=&quot;redis.io&quot; data-og-source-url=&quot;https://redis.io/blog/what-is-semantic-caching/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/nDM58/hyZbvyFBh8/nuVGMPWJKUcsDOP2pBXQQK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/AyC4w/hyY8cneaHq/LWKREZfckcqklvqpE8Esvk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot; data-og-url=&quot;https://redis.io/&quot;&gt;&lt;a href=&quot;https://redis.io/&quot; target=&quot;_blank&quot; data-source-url=&quot;https://redis.io/blog/what-is-semantic-caching/&quot;&gt;&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/nDM58/hyZbvyFBh8/nuVGMPWJKUcsDOP2pBXQQK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/AyC4w/hyY8cneaHq/LWKREZfckcqklvqpE8Esvk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630')&quot;&gt; &lt;/div&gt;&lt;div class=&quot;og-text&quot;&gt;&lt;p class=&quot;og-title&quot;&gt;Redis - The Real-time Data Platform&lt;/p&gt;&lt;p class=&quot;og-desc&quot;&gt;Developers love Redis. Unlock the full potential of the Redis database with Redis Enterprise and start building blazing fast apps.&lt;/p&gt;&lt;p class=&quot;og-host&quot;&gt;redis.io&lt;/p&gt;&lt;/div&gt;&lt;/a&gt;&lt;/figure&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;그래서 실험을 해봤습니다. 시멘틱 캐싱을.....&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 주제 정의&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 글에서는 다음과 같은 궁금증을 실험적으로 풀어보고자 합니다.&lt;/p&gt;&lt;blockquote data-end=&quot;1327&quot; data-start=&quot;1248&quot; data-ke-style=&quot;style1&quot;&gt; 
 &lt;p data-end=&quot;1327&quot; data-start=&quot;1250&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;“한국어 자연어 요청을 벡터화해서 Redis에 저장해두면,&lt;br&gt;다음에 유사한 질문이 들어왔을 때 캐시처럼 재사용할 수 있을까?”&lt;/b&gt;&lt;/p&gt; 
&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;조금 더 구체적으로 말하자면,&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;한글 자연어 질의들을 벡터 임베딩하고&lt;/li&gt;&lt;li&gt;Redis 벡터 검색 기능을 통해 유사 쿼리를 찾아낸 뒤&lt;/li&gt;&lt;li&gt;찾은 쿼리들을 다른 유사도 검사기를 통해 일정 유사도 이상이면 LLM 호출 없이 캐시된 응답을 재사용할 수 있는지 실험합니다.&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 환경 및 구성&lt;/h3&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;&lt;b&gt;Redis Stack 7.2 이상&lt;/b&gt; 사용&lt;/li&gt;&lt;li&gt;&lt;b&gt;Jedis 5.2.0 이상&lt;/b&gt; 사용(이 버전부터 백터 검색을 지원한다고 합니다.)&lt;/li&gt;&lt;li&gt;&lt;b&gt;Redis 벡터 인덱스&lt;/b&gt; 설정 (HNSW 기반, COSINE 거리)&lt;/li&gt;&lt;li&gt;&lt;b&gt;LLM 임베딩 모델&lt;/b&gt;: text-embedding-3-small (OpenAI), 향후 국산 Ko-SBERT 대체 가능&lt;/li&gt;&lt;li&gt;&lt;b&gt;질문 샘플&lt;/b&gt;: 유사도를 확인하기 위해 미리 정의한 자연어 질문 370개&lt;/li&gt;&lt;li&gt;&lt;b&gt;실제 LLM 호출 여부&lt;/b&gt; 및 &lt;b&gt;유사도 기반 캐시 적중률&lt;/b&gt; 측정&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;자연어 질의 샘플 370개&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;정말 SQL로서 요청할만한 질의들을 만들어봤습니다.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;지역별 회원 수 알려줘
가장 유저 많은 지역은?
회원 수 많은 도시는?
어느 지역에 회원이 많을까?
지역별 사용자 통계 보여줘
도시별 가입자 수 조회
회원 분포가 가장 높은 지역
사용자 수가 많은 지역 순위
지역별 고객 수 통계
도시별 유저 분포 확인
월별 매출액 조회
매출이 가장 높은 달은?
월별 수익 통계 보여줘
어느 달에 매출이 많을까?
월별 판매액 분석
매출이 좋은 달 순위
월별 수익 분포 확인
매출 통계 월별로 보여줘
상품별 판매량 조회
가장 많이 팔린 상품은?
인기 상품 순위 보여줘
어떤 상품이 잘 팔릴까?
상품별 판매 통계
베스트셀러 상품 목록
....
....&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;이제 코드를 짜봅시다!&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;모든 코드보단 비즈니스 로직 위주로 어떤 시나리오로 작업을 했는지 소개합니다. 아키텍처나 OOP 등은 실험의 주제와 큰 연관이 없기에 약간 소홀히 했습니다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;RedisVectorStore&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;1536이라는 OpenAI 의 임베딩 모델과 &lt;b&gt;같은 차원&lt;/b&gt;으로 벡터를 vector_index 라는 &lt;b&gt;인덱스에 저장&lt;/b&gt;하기 위한 용도로 만든 클래스입니다.&lt;br&gt;가장 중요한 부분은 toBytes() 메서드입니다. &lt;b&gt;little endian 설정&lt;/b&gt;을 하지 않으면 쿼리로서 검색이 작용하지 않으니 주의해주세요.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class RedisVectorStore {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Logger logger = LoggerFactory.getLogger(RedisVectorStore.class);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final String INDEX_NAME = &quot;vector_index&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final int VECTOR_DIMENSION = 1536; // text-embedding-3-small 차원

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final UnifiedJedis jedis;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public RedisVectorStore(UnifiedJedis jedis) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.jedis = jedis;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * HNSW 벡터 인덱스가 존재하지 않으면 생성
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void initializeIndexIfNotExists() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 인덱스 존재 여부 확인
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;jedis.ftInfo(INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;Redis 벡터 인덱스가 이미 존재합니다: {}&quot;, INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (JedisDataException e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;인덱스 존재 확인 중 오류 (정상): {}&quot;, e.getMessage());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;Redis 벡터 인덱스 생성 시작: {}&quot;, INDEX_NAME);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// Jedis 5.2.0 벡터 검색 스키마 생성[1]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SchemaField[] schema = {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;TextField.of(&quot;text&quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;VectorField.builder()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.fieldName(&quot;vector&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.algorithm(VectorField.VectorAlgorithm.HNSW)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.attributes(Map.of(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;TYPE&quot;, &quot;FLOAT32&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;DIM&quot;, VECTOR_DIMENSION,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;DISTANCE_METRIC&quot;, &quot;COSINE&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;M&quot;, 16,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;EF_CONSTRUCTION&quot;, 200
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.build()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;};

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 인덱스 생성[1]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;jedis.ftCreate(INDEX_NAME,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FTCreateParams.createParams()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addPrefix(&quot;vector:&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.on(IndexDataType.HASH),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;schema
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;Redis 벡터 인덱스 생성 완료: {} (차원: {})&quot;, INDEX_NAME, VECTOR_DIMENSION);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (e.getMessage() != null &amp;amp;&amp;amp; e.getMessage().contains(&quot;Index already exists&quot;)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;인덱스가 이미 존재합니다: {}&quot;, INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;Redis 벡터 인덱스 생성 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;벡터 인덱스 생성 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 기존 인덱스를 삭제하고 새로 생성
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void recreateIndex() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;기존 인덱스 삭제 시작: {}&quot;, INDEX_NAME);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;jedis.ftDropIndex(INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;기존 인덱스 삭제 완료: {}&quot;, INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (JedisDataException e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;인덱스 삭제 중 오류 (정상): {}&quot;, e.getMessage());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 새 인덱스 생성
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;initializeIndexIfNotExists();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;인덱스 재생성 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 모든 벡터 데이터 삭제
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void clearAllData() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;모든 벡터 데이터 삭제 시작&quot;);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Set&amp;lt;String&amp;gt; keys = jedis.keys(&quot;vector:*&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (keys != null &amp;amp;&amp;amp; !keys.isEmpty()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;jedis.del(keys.toArray(new String[0]));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;{}개의 벡터 데이터 삭제 완료&quot;, keys.size());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;삭제할 벡터 데이터가 없습니다&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;데이터 삭제 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 벡터와 원본 텍스트를 Redis에 저장
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void save(String id, float[] vector, String rawText) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String key = &quot;vector:&quot; + id;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] vectorBytes = toBytes(vector);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// Hash 형태로 저장
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;jedis.hset(key.getBytes(), Map.of(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;text&quot;.getBytes(), rawText.getBytes(StandardCharsets.UTF_8),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;vector&quot;.getBytes(), vectorBytes
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;));

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;벡터 저장 완료: {} (텍스트: {})&quot;, id,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rawText.substring(0, Math.min(50, rawText.length())) + &quot;...&quot;);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;벡터 저장 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;벡터 저장 실패: &quot; + id, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 벡터 유사도 검색
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SearchResult searchSimilarVectors(float[] queryVector, int k) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] queryBytes = toBytes(queryVector);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// KNN 쿼리 생성[4]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Query query = new Query(&quot;*=&amp;gt;[KNN $K @vector $BLOB AS vector_score]&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addParam(&quot;K&quot;, k)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addParam(&quot;BLOB&quot;, queryBytes)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.setSortBy(&quot;vector_score&quot;, false)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.limit(0, k)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.returnFields(&quot;text&quot;, &quot;vector_score&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.dialect(2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SearchResult result = jedis.ftSearch(INDEX_NAME, query);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;벡터 검색 완료: {}개 결과&quot;, result.getDocuments().size());

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return result;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;벡터 검색 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;벡터 검색 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 하이브리드 검색 (텍스트 필터 + 벡터 검색)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SearchResult hybridSearch(String textFilter, float[] queryVector, int k) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] queryBytes = toBytes(queryVector);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 하이브리드 쿼리[2]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Query query = new Query(String.format(&quot;(@text:%s)=&amp;gt;[KNN $K @vector $BLOB AS vector_score]&quot;, textFilter))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addParam(&quot;K&quot;, k)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addParam(&quot;BLOB&quot;, queryBytes)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.setSortBy(&quot;vector_score&quot;, false)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.limit(0, k)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.returnFields(&quot;text&quot;, &quot;vector_score&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.dialect(2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return jedis.ftSearch(INDEX_NAME, query);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;하이브리드 검색 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;하이브리드 검색 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * float 배열을 바이트 배열로 변환
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private byte[] toBytes(float[] vector) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ByteBuffer buffer = ByteBuffer.allocate(vector.length * 4);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;buffer.order(ByteOrder.LITTLE_ENDIAN); // 추가
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (float value : vector) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;buffer.putFloat(value);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return buffer.array();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 저장된 벡터 개수 조회
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public long getVectorCount() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Map&amp;lt;String, Object&amp;gt; info = jedis.ftInfo(INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Object numDocsObj = info.get(&quot;num_docs&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (numDocsObj != null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return Long.parseLong(numDocsObj.toString());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return 0L;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;벡터 개수 조회 중 오류: {}&quot;, e.getMessage());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return 0L;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;JedisVectorSearchService&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;저장한 백터들을 대상으로 하여 같은 인덱스에서 &lt;b&gt;K개만큼 유사도가 가장 높은 쿼리&lt;/b&gt;들을 뽑아내는 작업을 합니다. 이걸 전공에서 자주보았던 KNN이라고 하죠.&lt;br&gt;여기서도 저장할 때와 동시에 toBytes() 메서드에서 &lt;b&gt;Little endian&lt;/b&gt; 영역을 주의하시면 됩니다.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class JedisVectorSearchService {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Logger logger = LoggerFactory.getLogger(JedisVectorSearchService.class);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final String INDEX_NAME = &quot;vector_index&quot;;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final UnifiedJedis jedis;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public JedisVectorSearchService(UnifiedJedis jedis) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.jedis = jedis;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 벡터 유사도 검색 (Top-K)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; *
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param queryVector 검색할 벡터
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param topK&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;반환할 결과 개수
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @return 유사한 벡터들의 목록
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public List&amp;lt;RedisResult&amp;gt; searchTopK(float[] queryVector, int topK) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;벡터 검색 시작: Top-{}&quot;, topK);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] vectorBytes = toBytes(queryVector);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Query query = new Query(&quot;*=&amp;gt;[KNN &quot; + topK + &quot; @vector $BLOB AS score]&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addParam(&quot;BLOB&quot;, vectorBytes)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.setSortBy(&quot;score&quot;, true)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.returnFields(&quot;__key&quot;, &quot;score&quot;, &quot;text&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.dialect(2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SearchResult result = jedis.ftSearch(INDEX_NAME, query);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;RedisResult&amp;gt; results = new ArrayList&amp;lt;&amp;gt;();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (var doc : result.getDocuments()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String key = doc.getId();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String text = doc.getString(&quot;text&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String scoreStr = doc.getString(&quot;score&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;float score = Float.parseFloat(scoreStr);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 키에서 ID 추출 (vector: 제거)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String id = key.replace(&quot;vector:&quot;, &quot;&quot;);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RedisResult redisResult = new RedisResult(id, text, score);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;results.add(redisResult);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;검색 결과: {} (점수: {})&quot;, text, score);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;벡터 검색 완료: {}개 결과&quot;, results.size());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return results;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;벡터 검색 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;벡터 검색 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 텍스트 필터와 함께 하는 하이브리드 검색
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public List&amp;lt;RedisResult&amp;gt; hybridSearch(String textFilter, float[] queryVector, int topK) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;하이브리드 검색 시작: '{}', Top-{}&quot;, textFilter, topK);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] vectorBytes = toBytes(queryVector);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Query query = new Query(String.format(&quot;(@text:%s)=&amp;gt;[KNN %d @vector $BLOB AS score]&quot;, textFilter, topK))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.addParam(&quot;BLOB&quot;, vectorBytes)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.setSortBy(&quot;score&quot;, true)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.returnFields(&quot;__key&quot;, &quot;score&quot;, &quot;text&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.dialect(2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SearchResult result = jedis.ftSearch(INDEX_NAME, query);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;RedisResult&amp;gt; results = new ArrayList&amp;lt;&amp;gt;();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (var doc : result.getDocuments()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String key = doc.getId();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String text = doc.getString(&quot;text&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String scoreStr = doc.getString(&quot;score&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;float score = Float.parseFloat(scoreStr);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String id = key.replace(&quot;vector:&quot;, &quot;&quot;);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RedisResult redisResult = new RedisResult(id, text, score);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;results.add(redisResult);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;하이브리드 검색 완료: {}개 결과&quot;, results.size());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return results;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;하이브리드 검색 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;하이브리드 검색 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 검색 인덱스 상태 확인
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean isIndexAvailable() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;jedis.ftInfo(INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (JedisDataException e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.warn(&quot;인덱스가 존재하지 않습니다: {}&quot;, INDEX_NAME);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * float 배열을 바이트 배열로 변환 (ByteBuffer 사용)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private byte[] toBytes(float[] vector) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ByteBuffer buffer = ByteBuffer.allocate(vector.length * 4);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;buffer.order(ByteOrder.LITTLE_ENDIAN); // 핵심: Little Endian 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (float v : vector) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;buffer.putFloat(v);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return buffer.array();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;이제 Redis Search를 사용해서 저장하고 상위 k개의 유사도 높은 쿼리 가져오는 건 가능해졌습니다.&lt;br&gt;하지만 이 실험에서 가장 중요한 사안은 그래서&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;이 유사도가 정말 의미가 있는가?&lt;/span&gt;&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;그래서 아래 Cosine 유사도와 Dot product 유사도 검증을 위한 평가기(Evaluator) 클래스를 추가로 만들었습니다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;CosineSimilarityEvaluator&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;코사인 유사도는 전공에서 질리도록 듣던 자주 사용되는 유사도 알고리즘인데, 쉽게 설명하면 단어들을 숫자로 바꾼 뒤, 두 숫자 줄 사이의 각도를 재서 각도가 작을수록 더 비슷하다고 하는 알고리즘입니다.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class CosineSimilarityEvaluator {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Logger logger = LoggerFactory.getLogger(CosineSimilarityEvaluator.class);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final EmbeddingClient embeddingClient;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CosineSimilarityEvaluator(EmbeddingClient embeddingClient) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.embeddingClient = embeddingClient;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 두 텍스트 간의 Cosine 유사도 계산
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param query1 첫 번째 텍스트
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param query2 두 번째 텍스트
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @return Cosine 유사도 점수 (0.0 ~ 1.0)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public double compare(String query1, String query2) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;Cosine 유사도 계산: '{}' vs '{}'&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;query1.substring(0, Math.min(30, query1.length())),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;query2.substring(0, Math.min(30, query2.length())));

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 두 텍스트를 벡터로 변환
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;float[] vector1 = embeddingClient.embed(query1);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;float[] vector2 = embeddingClient.embed(query2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// Cosine 유사도 계산
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;double similarity = calculateCosineSimilarity(vector1, vector2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;Cosine 유사도 결과: {:.4f}&quot;, similarity);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return similarity;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;Cosine 유사도 계산 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return 0.0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 두 벡터 간의 Cosine 유사도 계산
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param vector1 첫 번째 벡터
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param vector2 두 번째 벡터
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @return Cosine 유사도 점수
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private double calculateCosineSimilarity(float[] vector1, float[] vector2) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (vector1.length != vector2.length) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new IllegalArgumentException(&quot;벡터 차원이 일치하지 않습니다: &quot; +
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;vector1.length + &quot; vs &quot; + vector2.length);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;double dotProduct = 0.0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;double norm1 = 0.0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;double norm2 = 0.0;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (int i = 0; i &amp;lt; vector1.length; i++) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;dotProduct += vector1[i] * vector2[i];
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;norm1 += vector1[i] * vector1[i];
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;norm2 += vector2[i] * vector2[i];
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;norm1 = Math.sqrt(norm1);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;norm2 = Math.sqrt(norm2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (norm1 == 0.0 || norm2 == 0.0) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return 0.0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return dotProduct / (norm1 * norm2);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;DotProductSimilarityEvaluator&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;Dot Product 유사도는 두 벡터가 같은 방향으로 얼마나 강하게 뻗어 있는지를 수치로 보여줍니다.&lt;br&gt;예를 들어 &quot;서울 회원 수 알려줘&quot;와 &quot;서울 지역 유저 수 보여줘&quot;는 의미가 비슷하고 길이도 비슷하므로, 내적(dot product) 값이 크게 나옵니다.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class DotProductSimilarityEvaluator {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Logger logger = LoggerFactory.getLogger(DotProductSimilarityEvaluator.class);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final EmbeddingClient embeddingClient;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public DotProductSimilarityEvaluator(EmbeddingClient embeddingClient) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.embeddingClient = embeddingClient;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 두 텍스트 간의 Dot Product 유사도 계산
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; *
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param query1 첫 번째 텍스트
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @param query2 두 번째 텍스트
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * @return 내적 유사도 점수 (값 범위는 정규화되지 않음)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public double compare(String query1, String query2) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;DotProduct 유사도 계산: '{}' vs '{}'&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;query1.substring(0, Math.min(30, query1.length())),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;query2.substring(0, Math.min(30, query2.length())));

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;float[] vector1 = embeddingClient.embed(query1);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;float[] vector2 = embeddingClient.embed(query2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;double similarity = calculateDotProduct(vector1, vector2);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.debug(&quot;DotProduct 유사도 결과: {}&quot;, similarity);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return similarity;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.error(&quot;DotProduct 유사도 계산 중 오류: {}&quot;, e.getMessage(), e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return 0.0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private double calculateDotProduct(float[] v1, float[] v2) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (v1.length != v2.length) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new IllegalArgumentException(&quot;벡터 차원이 일치하지 않습니다: &quot; + v1.length + &quot; vs &quot; + v2.length);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;double dot = 0.0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (int i = 0; i &amp;lt; v1.length; i++) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;dot += v1[i] * v2[i];
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return dot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;자, 이제 다 준비가 되었습니다.&lt;br&gt;이제 로그로 한번 확인해보겠습니다. 사실 처음엔 36개로 테스트해봤는데, 데이터 수가 적으면 적을수록 신뢰도가 낮기 때문에 과감하게 10배인 370개로 실험해봤습니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그로 보는 실험&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;일단, 위 코드에서도 그렇듯이 로그로 하나하나 확인할 수 있게 달아놨습니다.&lt;br&gt;우선 인덱스를 생성하고 거기에 백터를 저장합니다. 위 370개를 저장해놓습니다.&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2030&quot; data-origin-height=&quot;1406&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfpg6b/btsOMkVOfet/nj2hvsmdrEXcIwbf9m9Nk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfpg6b/btsOMkVOfet/nj2hvsmdrEXcIwbf9m9Nk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfpg6b/btsOMkVOfet/nj2hvsmdrEXcIwbf9m9Nk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbfpg6b%2FbtsOMkVOfet%2Fnj2hvsmdrEXcIwbf9m9Nk0%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;2030&quot; height=&quot;1406&quot; data-origin-width=&quot;2030&quot; data-origin-height=&quot;1406&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;이렇게 370개가 저장될때까지 지루하게 기다립니다.&lt;br&gt;...&lt;br&gt;...&lt;br&gt;&amp;nbsp;&lt;br&gt;그리고 사용자 입력창을 뜨게 했고, 그에 따라 제가 질의를 해봅니다. 캐싱이 되었으면 하는 질의를 해보는거죠.&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2016&quot; data-origin-height=&quot;582&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhehoX/btsOMoYaENU/Vcy0Q5hTrmqoh4mXMPyO20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhehoX/btsOMoYaENU/Vcy0Q5hTrmqoh4mXMPyO20/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhehoX/btsOMoYaENU/Vcy0Q5hTrmqoh4mXMPyO20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdhehoX%2FbtsOMoYaENU%2FVcy0Q5hTrmqoh4mXMPyO20%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;2016&quot; height=&quot;582&quot; data-origin-width=&quot;2016&quot; data-origin-height=&quot;582&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;자 이제 질의를 입력해봅니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;가장 높은 매출을 기록했던 월은?&lt;/span&gt;&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;4594&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZ7mFc/btsOKiyHOQk/jd8kAbhUwKVtGiRJQHCrS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZ7mFc/btsOKiyHOQk/jd8kAbhUwKVtGiRJQHCrS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZ7mFc/btsOKiyHOQk/jd8kAbhUwKVtGiRJQHCrS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZ7mFc%2FbtsOKiyHOQk%2Fjd8kAbhUwKVtGiRJQHCrS1%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;2048&quot; height=&quot;4594&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;4594&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;이미지를 보면 맨 아래 '개별 결과'에 top 5위까지의 redis search의 백터 검색으로 나온 유사도가 높은 결과물입니다.&lt;br&gt;그리고 각각에 대해 코사인 유사도와 dotProduct 유사도를 별도로 검증해봤습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;그래서 결론적으로!&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;신뢰도는 0.58이라 보통(0.6부터 높다고 해서ㅋㅋ)이라고 하네요. (기준 세우기 나름이라)&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;하지만 오차 범위가 그렇게 크지 않다는 점에서 유의미하다고 봅니다.&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;그래서 한번더 해봤습니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;카테고리 매출의 순위&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;/blockquote&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;4422&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbKwrc/btsOLBjD9oF/Fjsc8KDtJHD7hjF9ZvOJS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbKwrc/btsOLBjD9oF/Fjsc8KDtJHD7hjF9ZvOJS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbKwrc/btsOLBjD9oF/Fjsc8KDtJHD7hjF9ZvOJS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbKwrc%2FbtsOLBjD9oF%2FFjsc8KDtJHD7hjF9ZvOJS1%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;2048&quot; height=&quot;4422&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;4422&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;오 이건 그래도 redis search의 결과가 평가기를 통해 나온 유사도랑 확인해보니 0.75로 굉장히 유사도가 높은 것을 볼 수 있습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;모든 질의가 항상 깔끔하게 매칭되진 않았습니다.&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&quot;가장 높은 매출을 기록했던 월은?&quot;&lt;/span&gt;&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;이라는 질의에서도 보았듯이 상위 결과들의 Cosine 유사도가 약 0.58 수준으로, 보통 이하의 일관성을 보였습니다.&lt;/span&gt;&lt;br&gt;이러한 경우, 캐시 적중을 시도하더라도 &lt;b&gt;LLM을 호출하는 편이 더 안정적&lt;/b&gt;일 수 있습니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;적중 기준선 설정&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 서비스를 운영하려면 &quot;이 정도 유사도 이상이면 캐시 재사용&quot;이라는 기준이 있어야 합니다.&lt;br&gt;제가 실험한 환경에서는 아래와 같은 기준이 유효했습니다.&lt;/p&gt;&lt;div&gt; 
 &lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;908&quot; data-start=&quot;607&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt; 
  &lt;tbody&gt; 
   &lt;tr&gt; 
    &lt;td&gt;유사도&lt;/td&gt; 
    &lt;td&gt;점수판단&lt;/td&gt; 
    &lt;td&gt;기준조치&lt;/td&gt; 
   &lt;/tr&gt; 
   &lt;tr data-end=&quot;797&quot; data-start=&quot;741&quot;&gt; 
    &lt;td data-col-size=&quot;sm&quot; data-end=&quot;753&quot; data-start=&quot;741&quot;&gt;0.75 이상&lt;/td&gt; 
    &lt;td data-end=&quot;772&quot; data-start=&quot;753&quot; data-col-size=&quot;sm&quot;&gt;거의 동일한 요청&lt;/td&gt; 
    &lt;td data-end=&quot;797&quot; data-start=&quot;772&quot; data-col-size=&quot;sm&quot;&gt;캐시 즉시 재사용&lt;/td&gt; 
   &lt;/tr&gt; 
   &lt;tr data-end=&quot;853&quot; data-start=&quot;798&quot;&gt; 
    &lt;td data-col-size=&quot;sm&quot; data-end=&quot;812&quot; data-start=&quot;798&quot;&gt;0.5 ~ 0.75&lt;/td&gt; 
    &lt;td data-end=&quot;830&quot; data-start=&quot;812&quot; data-col-size=&quot;sm&quot;&gt;비슷하지만 불완전&lt;/td&gt; 
    &lt;td data-end=&quot;853&quot; data-start=&quot;830&quot; data-col-size=&quot;sm&quot;&gt;사용자의 추가 확인 필요&lt;/td&gt; 
   &lt;/tr&gt; 
   &lt;tr data-end=&quot;908&quot; data-start=&quot;854&quot;&gt; 
    &lt;td data-col-size=&quot;sm&quot; data-end=&quot;866&quot; data-start=&quot;854&quot;&gt;0.5 미만&lt;/td&gt; 
    &lt;td data-end=&quot;886&quot; data-start=&quot;866&quot; data-col-size=&quot;sm&quot;&gt;유사도 불충분&lt;/td&gt; 
    &lt;td data-end=&quot;908&quot; data-start=&quot;886&quot; data-col-size=&quot;sm&quot;&gt;LLM 재호출 + 캐시 저장 시도&lt;/td&gt; 
   &lt;/tr&gt; 
  &lt;/tbody&gt; 
 &lt;/table&gt; 
 &lt;div&gt; 
  &lt;div&gt;
    이건 물론 데이터를 얼마나 다양하고, 많이 넣어서 임베딩 계산에 최적화했는가에 따른 문제가 될 수도 있어서 상황 By 상황이라 생각합니다. 
  &lt;/div&gt; 
 &lt;/div&gt; 
&lt;/div&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무에서 어떻게 활용할 수 있을까?&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이 실험은 단순한 Redis 벡터 검색을 넘어, 아래와 같은 &lt;b&gt;현실적인 시사점&lt;/b&gt;을 제공합니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 비즈니스 데이터 질의에 적합&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 텍스트 응답보다는 &lt;b&gt;SQL 기반 데이터 질의 결과 캐싱&lt;/b&gt;에 적합합니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;자연어 요청은 다르지만, 실제로는 같은 SQL을 생성하는 경우가 많기 때문입니다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 비용 절감 효과&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 호출은 단가가 높은 연산입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;특히 GPT-4 Turbo나 자체 호스팅 LLM을 사용한다면 &lt;b&gt;자연어 요청을 필터링해서 캐시로 우회할 수 있는 구조&lt;/b&gt;는 서버 비용에 직접적인 영향을 줄 수 있습니다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 한국어에서도 일정 수준 유효&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;Ko-SBERT가 아닌 OpenAI의 영어 모델(text-embedding-3-small)을 사용했음에도 &lt;b&gt;의미 있는 유사도 결과&lt;/b&gt;가 확인되었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이는 향후 Ko-SBERT, KLUE 기반 모델로 대체한다면 &lt;b&gt;보다 정확하고 일관된 캐싱 전략으로 확장&lt;/b&gt; 가능하다는 뜻이기도 합니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리: Semantic Cache, 단순하지만 강력한 접근&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험을 통해 알게 된 사실은 간단합니다.&lt;/p&gt;&lt;blockquote data-end=&quot;1577&quot; data-start=&quot;1553&quot; data-ke-style=&quot;style1&quot;&gt; 
 &lt;p data-end=&quot;1577&quot; data-start=&quot;1555&quot; data-ke-size=&quot;size16&quot;&gt;자연어는 달라도, 의미는 같을 수 있다.&lt;/p&gt; 
&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;그리고 의미가 같다면, 굳이 LLM을 매번 호출하지 않아도 됩니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;Redis 벡터 인덱싱, KNN 검색, 평가기 기반 판단 등&lt;br&gt;&amp;nbsp;&lt;br&gt;이런 구조들을 적절히 조합하면, &lt;b&gt;기존 캐시의 한계를 넘어서는 새로운 캐싱 전략&lt;/b&gt;을 구축할 수 있습니다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;TODO 및 한계점&lt;/h2&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;현재는 text-embedding-3-small 기반 실험이므로 &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;LLM 결과 자체를 요약/클러스터링하여 캐시 키로 변환&lt;/b&gt;하는 방식 실험 예정&lt;/li&gt;&lt;/ul&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;단순한 Redis 캐시는 “키 = 값”에 기반한 구조였습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;하지만 시맨틱 캐시는 “의미 = 값”이라는 전혀 다른 차원의 가능성을 열어줍니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이 글에서 살펴본 것처럼 기존의 캐싱 한계를 넘어, 자연어 기반 인터페이스에서도 &lt;b&gt;효율적이고 경제적인 결과 재사용 구조&lt;/b&gt;를 만드는 것이 충분히 가능합니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;특히 SaaS 구조나 LLM API 사용이 잦은 시스템이라면 Semantic Caching은 반드시 고려해야 할 전략 중 하나일겁니다.&lt;/p&gt;</description>
      <category>개발/Saas 개발로그</category>
      <category>jedis</category>
      <category>redis</category>
      <category>redissearch</category>
      <category>SaaS</category>
      <category>레디스</category>
      <category>시맨틱캐싱</category>
      <category>임베딩</category>
      <category>자연어</category>
      <category>자연어캐싱</category>
      <category>코사인</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/14</guid>
      <comments>https://pandaterry.tistory.com/entry/Saas-%EA%B0%9C%EB%B0%9C%EB%A1%9C%EA%B7%B8-%EC%9E%90%EC%97%B0%EC%96%B4%EB%8F%84-%EC%BA%90%EC%8B%B1%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-Redis-Vector-Search%EB%A1%9C-%ED%95%9C%EA%B8%80-%EC%BA%90%EC%8B%B1-%EC%8B%A4%ED%97%98%ED%95%B4%EB%B3%B4%EA%B8%B0#entry14comment</comments>
      <pubDate>Sat, 21 Jun 2025 01:08:05 +0900</pubDate>
    </item>
    <item>
      <title>혼자서도 잘해요: 메세지 브로커 없이도 살아남은 Logging System 구조 이야기</title>
      <link>https://pandaterry.tistory.com/entry/%EB%A9%94%EC%84%B8%EC%A7%80-%EB%B8%8C%EB%A1%9C%EC%BB%A4-%EC%97%86%EC%9D%B4-%EC%97%94%ED%8B%B0%ED%8B%B0-%EB%B3%80%EA%B2%BD-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%80%EC%9E%A5%ED%95%98%EB%8A%94-%EA%B5%AC%EC%A1%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
      <description>&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;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CYi0K/btsOGYliCD5/EtgLnXJJYmKyzrdLZkYKhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CYi0K/btsOGYliCD5/EtgLnXJJYmKyzrdLZkYKhk/img.png&quot; data-alt=&quot;Github 바로가기 : https://github.com/terry960302/concurrent-entity-change-logger&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CYi0K/btsOGYliCD5/EtgLnXJJYmKyzrdLZkYKhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCYi0K%2FbtsOGYliCD5%2FEtgLnXJJYmKyzrdLZkYKhk%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;1024&quot; height=&quot;1024&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Github 바로가기 : https://github.com/terry960302/concurrent-entity-change-logger&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;운영 중 발생한 장애를 원인부터 끝까지 추적하려면, &lt;b&gt;신뢰할 수 있는 로그&lt;/b&gt;가 반드시 필요합니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;특히 JPA를 사용하는 시스템에서는, 엔티티가 변경될 때마다 &lt;b&gt;무엇이 바뀌었고, 누가 변경했으며, 언제 어떤 요청에서 발생했는지 &lt;/b&gt;그 이력을 상세히 남기는 일이 중요합니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;많은 시스템에서는 Kafka나 Redis Stream, 또는 Change Data Capture 기반 구조를 활용합니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;하지만 다음과 같은 환경에서는 그런 선택이 불가능합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부망과 완전히 단절된 &lt;b&gt;폐쇄망 환경&lt;/b&gt; (군/금융기관 등)&lt;/li&gt;
&lt;li&gt;Kafka, Redis, ELK 등 &lt;b&gt;인프라 설치 자체가 제한된 경우&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;외부 브로커 없이도 &lt;b&gt;로그 유실 없는 처리가 요구되는 상황&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 제가 경험한 한 시스템은 &lt;b&gt;배치 업로드 한 번에 수만 건 이상의 엔티티가 변경&lt;/b&gt;되는 구조였습니다.&lt;br /&gt;이때 Kafka 없이도 로그를 안정적으로 쌓아야 하는 요구가 있었고, 단순한 AOP나 JPA Auditing만으로는 &lt;b&gt;변경 필드 단위의 추적&lt;/b&gt;이나 &lt;b&gt;운영 종료 시 flush 보장&lt;/b&gt; 같은 요구를 만족할 수 없었습니다.&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 시작했습니다.&lt;/h2&gt;
&lt;blockquote data-end=&quot;931&quot; data-start=&quot;886&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;931&quot; data-start=&quot;888&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;외부 인프라 없이도 운영 가능한, 고신뢰의 비동기 변경 로그 시스템&amp;rdquo;&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;이 글에서는 Hibernate의 EventListener를 활용하여 멀티스레드 환경에서 안정적으로 엔티티 변경사항을 감지하고,&lt;br /&gt;JDBC batch insert로 빠르게 디스크에 저장하는 구조를 설계하고 검증한 흐름을 공유드리고자 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 TPS 500 이상의 부하 환경에서도 평균 응답 지연은 5ms 이하&lt;/li&gt;
&lt;li&gt;배치 insert 적용으로 JVM 힙 사용량 70MB 수준으로 유지&lt;/li&gt;
&lt;li&gt;유실된 로그 0건&lt;/li&gt;
&lt;li&gt;별도 모니터링 도구 없이도 Prometheus 기반 지표 수집 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조는 단순하지만, &lt;b&gt;운영 종료 시 flush 보장, shutdown hook &lt;/b&gt;등의 안정성을 갖췄으며&lt;br /&gt;향후에는 LMAX Disruptor와 같은 &lt;b&gt;lock-free 구조&lt;/b&gt;로 확장 가능한 기반도 포함하고 있습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전역 엔티티 리스너 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;: 여기서 엔티티들의 변화를 감지합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class EntityChangeListener
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;implements PostUpdateEventListener, PostDeleteEventListener, PostInsertEventListener {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final LoggingStrategy loggingStrategy;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final EntityLoggingProperties loggingProperties;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final EntityStateCopier stateCopier;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final Logger log = LoggerFactory.getLogger(EntityChangeListener.class);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void onPostInsert(PostInsertEvent event) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (loggingProperties.shouldLogChanges(event.getEntity())) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;loggingStrategy.logChange(null, event.getEntity(), Operation.CREATE);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void onPostUpdate(PostUpdateEvent event) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (loggingProperties.shouldLogChanges(event.getEntity())) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Object oldEntity = stateCopier.cloneEntity(event);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;loggingStrategy.logChange(oldEntity, event.getEntity(), Operation.UPDATE);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void onPostDelete(PostDeleteEvent event) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (loggingProperties.shouldLogChanges(event.getEntity())) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;loggingStrategy.logChange(event.getEntity(), null, Operation.DELETE);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean requiresPostCommitHandling(EntityPersister persister) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;BlockingQueue 로그 엔티티 처리기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;: 여러 스레드로부터 들어오는 엔티티의 변화를 이 클래스에서 동시성 문제가 없게 처리합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class BlockingQueueLoggingStrategy implements LoggingStrategy {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final LogEntryRepository logEntryRepository;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final EntityLoggingProperties loggingProperties;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final LogEntryFactory logEntryFactory;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final ObjectMapper objectMapper;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final LogStorage logStorage;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final MicrometerLogMetricsRecorder metrics;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private BlockingQueue&amp;lt;LogEntry&amp;gt; logQueue;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private ExecutorService logProcessorPool;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public BlockingQueueLoggingStrategy(LogEntryRepository logEntryRepository,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;EntityLoggingProperties loggingProperties,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;LogEntryFactory logEntryFactory,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;LogStorage logStorage,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ObjectMapper objectMapper,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;MicrometerLogMetricsRecorder metrics) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.logEntryRepository = logEntryRepository;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.loggingProperties = loggingProperties;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.logEntryFactory = logEntryFactory;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.metrics = metrics;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.logStorage = logStorage;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.objectMapper = objectMapper;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@PostConstruct
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void init() throws IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logStorage.init();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.logQueue = new LinkedBlockingQueue&amp;lt;&amp;gt;(loggingProperties.getStrategy().getQueueSize());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.logProcessorPool = Executors.newFixedThreadPool(loggingProperties.getStrategy().getThreadPoolSize());

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (int i = 0; i &amp;lt; loggingProperties.getStrategy().getThreadPoolSize(); i++) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logProcessorPool.submit(this::processLogs);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void logChange(Object oldEntity, Object newEntity, Operation operation) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!loggingProperties.shouldLogChanges(oldEntity != null ? oldEntity : newEntity)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;LogEntry entry = logEntryFactory.create(oldEntity, newEntity, operation);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logStorage.write(entry);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (IOException e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;offerToQueue(entry);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void offerToQueue(LogEntry entry) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long startNanos = System.nanoTime();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long tookNanos = System.nanoTime() - startNanos;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (logQueue.remainingCapacity() == 0)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;boolean offered = logQueue.offer(entry);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.recordOfferLatency(tookNanos);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!offered) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.incrementDroppedCount();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.gaugeQueueSize(logQueue.size());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/**
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * 배치를 저장시 batchUpdate에서 안하는 이유
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; * : 스케쥴러로 동시에 배치업로드하는 방식이라 그럼. 배치를 repository 레이어에서 관리하는 순간 스케쥴러에서 관리가 어려워짐.
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; */
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void processLogs() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int batchSize = loggingProperties.getJpaBatchSize();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while (!Thread.currentThread().isInterrupted()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while (logQueue.size() &amp;lt; batchSize) { // 배치사이즈에 도달하지 않으면 flush 스케쥴러에서 drainsTo 하기전에 미리 처리해줌.
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Thread.sleep(50);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;LogEntry&amp;gt; batch = new ArrayList&amp;lt;&amp;gt;(batchSize);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 동시성을 고려하여 batch.add 가 아닌 큐에서 바로 drainsTo 를 사용함.
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int drained = logQueue.drainTo(batch, batchSize);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (drained &amp;gt; 0) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;saveBatch(batch);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (InterruptedException e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Thread.currentThread().interrupt();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void saveBatch(List&amp;lt;LogEntry&amp;gt; toSave) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int success = 0;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int batchCount = loggingProperties.getJpaBatchSize();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long startNanos = System.nanoTime();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logEntryRepository.saveBatch(toSave);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;success++;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.incrementSaveErrorCount();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long tookNanos = System.nanoTime() - startNanos;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.recordBatchSize(batchCount);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.recordBatchLatency(tookNanos);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.incrementProcessedCount(success);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.gaugeQueueSize(logQueue.size());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 배치사이즈까지 안모으고 바로 플러시
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Scheduled(fixedDelayString = &quot;${logging.strategy.flush-interval:5000}&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public int flush() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int batchSize = loggingProperties.getJpaBatchSize();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;LogEntry&amp;gt; batch = new ArrayList&amp;lt;&amp;gt;(batchSize);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int drained = logQueue.drainTo(batch, batchSize);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (drained &amp;gt; 0) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 플러시 지연 측정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long start = System.nanoTime();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;saveBatch(batch);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long took = System.nanoTime() - start;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.recordFlushLatency(took);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// (선택) 큐 사이즈 계측
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.gaugeQueueSize(logQueue.size());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return drained;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void shutdown() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logProcessorPool.shutdown();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!logProcessorPool.awaitTermination(10, TimeUnit.SECONDS)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logProcessorPool.shutdownNow();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (InterruptedException e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logProcessorPool.shutdownNow();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int flushed = flush();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logStorage.close();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;metrics.incrementShutdownFlushedCount(flushed);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JDBC 배치 인서트 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;: 참고로 배치 개수 분할 처리는 스케쥴러도 동시에 사용해야하기 때문에 다른 클래스에서 담당합니다. 이 클래스에선 오직 batch 메서드를 호출하여 여러건을 단건으로 만드는 역할만 합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class LogEntryRepository {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final JdbcTemplate jdbcTemplate;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final ObjectMapper objectMapper;


&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Transactional
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void saveBatch(List&amp;lt;LogEntry&amp;gt; toSave) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (toSave == null || toSave.isEmpty()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.warn(&quot;저장할 로그 엔트리가 없습니다.&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String sql = &quot;&quot;&quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;INSERT INTO log_entries (id, entity_name, entity_id, operation, changes, created_at) 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;VALUES (?::uuid, ?, ?, ?, ?::jsonb, ?)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;&quot;&quot;;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;Object[]&amp;gt; batchArgs = toSave.stream()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.map(this::convertToObjectArray)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.filter(Objects::nonNull)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.collect(Collectors.toList());

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!batchArgs.isEmpty()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int[] results = jdbcTemplate.batchUpdate(sql, batchArgs);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.info(&quot;배치 저장 완료: {} 건&quot;, results.length);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// 실패한 건수 체크
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long failedCount = java.util.Arrays.stream(results)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.filter(result -&amp;gt; result == PreparedStatement.EXECUTE_FAILED)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.count();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (failedCount &amp;gt; 0) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.warn(&quot;배치 처리 중 실패한 건수: {}&quot;, failedCount);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception e) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.error(&quot;배치 저장 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(&quot;로그 엔트리 배치 저장 실패&quot;, e);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트 환경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- K6 부하테스트 툴 사용&lt;br /&gt;- 총 20분동안 Insert, Update로 3가지 엔티티에 대해 변화를 주며 테스트&lt;br /&gt;- 테스트 후 확인결과, 총 26만건 이상의 로그가 실패없이 생성.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트(k6) 결과&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;테스트 개요&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 274,728회&lt;/li&gt;
&lt;li&gt;테스트 시간: 약 2분(Iteration_rate &amp;asymp; 13.1반복/초)&lt;/li&gt;
&lt;li&gt;가상 사용자(VUs): 최대 38명(설정 상 최대 100명)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;데이터 전송량&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수신된 데이터: 약 73.4 MB (73,432,426 bytes) &amp;rarr; 초당 약 84 KB/s&lt;/li&gt;
&lt;li&gt;전송된 데이터: 약 49.3 MB (49,270,316 bytes) &amp;rarr; 초당 약 56 KB/s&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;응답 시간(latency)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;평균 HTTP 요청 지연: 4.15 ms&lt;/li&gt;
&lt;li&gt;중앙값(median): 2.91 ms&lt;/li&gt;
&lt;li&gt;90th percentile: 5.12 ms&lt;/li&gt;
&lt;li&gt;95th percentile: 8.23 ms&lt;/li&gt;
&lt;li&gt;최대 지연: 179.26 ms&lt;/li&gt;
&lt;li&gt;연결(connecting) 시간 평균: 0.002 ms (최대 28.6 ms)&lt;/li&gt;
&lt;li&gt;요청 전송(sending) 평균: 0.016 ms&lt;/li&gt;
&lt;li&gt;서버 응답 대기(waiting) 평균: 4.05 ms&lt;/li&gt;
&lt;li&gt;응답 수신(receiving) 평균: 0.08 ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Prometheus + Grafana 로 로그 처리 지표 수집&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단건 Insert vs JDBC Batch Insert(1000건)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;: 기본적으로 5초에 한번씩 스케쥴러로 flush하는 건 유지했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1887&quot; data-origin-height=&quot;798&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQz7zw/btsOGUC7yhE/7GTnnUMFdo3Px2kkBvtBxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQz7zw/btsOGUC7yhE/7GTnnUMFdo3Px2kkBvtBxK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQz7zw/btsOGUC7yhE/7GTnnUMFdo3Px2kkBvtBxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQz7zw%2FbtsOGUC7yhE%2F7GTnnUMFdo3Px2kkBvtBxK%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;1887&quot; height=&quot;798&quot; data-origin-width=&quot;1887&quot; data-origin-height=&quot;798&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;- 단건 Insert : G1 Eden Space 힙 메모리 사용률이 최대 300MB 도달, G1 Old Gen은 별로 사용하질 않아서 40~50MB 정도 입니다.&lt;br /&gt;- JDBC Batch Insert : G1 Eden Space 힙 메모리 사용률이 최대 30MB 도달, G1 Old Gen은 비슷하게 40~50MB 유지&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;Batch Insert 로 평균 JVM 힙메모리 180MB -&amp;gt; 70MB로 2.5배 효율화&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Drop - 유실(큐에 들어가지 못하고 유실된 로그) 건수(0건)&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZOU8I/btsOF7iNT2I/TV9fWyC0GySVBYxPkJHlq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZOU8I/btsOF7iNT2I/TV9fWyC0GySVBYxPkJHlq1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZOU8I/btsOF7iNT2I/TV9fWyC0GySVBYxPkJHlq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZOU8I%2FbtsOF7iNT2I%2FTV9fWyC0GySVBYxPkJHlq1%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;1898&quot; height=&quot;808&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하테스트동안 한건의 유실된 로그가 없었습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;스루풋&amp;nbsp; - 초당 처리된 배치(1,000건) 개수&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1908&quot; data-origin-height=&quot;809&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GoLzg/btsOHalgbPa/TSRky01dMugIdd61R1feKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GoLzg/btsOHalgbPa/TSRky01dMugIdd61R1feKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GoLzg/btsOHalgbPa/TSRky01dMugIdd61R1feKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGoLzg%2FbtsOHalgbPa%2FTSRky01dMugIdd61R1feKK%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;1908&quot; height=&quot;809&quot; data-origin-width=&quot;1908&quot; data-origin-height=&quot;809&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;- 초반에 0에서 &lt;b&gt;300건(0.3)&lt;/b&gt; 로그 처리하고, 점점 올라가면서 피크에서 &lt;b&gt;655건(0.655)&lt;/b&gt; 처리를 합니다.&lt;br /&gt;- 그러다가 다시 0건에 가깝게 내려가는데 이건 k6테스트 특성상 동시성 처리에서 테스트상 잠깐 멈춘 것으로 보입니다.&lt;br /&gt;- 결론적으로 평균적으로 초당 4~500건의 로그를 처리합니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;평균 처리량 : 초당 400~500건&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;최대 처리량 : 초당 655건&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Latency - 배치별 평균 지연율&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1546&quot; data-origin-height=&quot;399&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tWHEJ/btsOG73bZ5U/zkU0PSVDx1hmn4dC6HwgJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tWHEJ/btsOG73bZ5U/zkU0PSVDx1hmn4dC6HwgJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tWHEJ/btsOG73bZ5U/zkU0PSVDx1hmn4dC6HwgJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtWHEJ%2FbtsOG73bZ5U%2FzkU0PSVDx1hmn4dC6HwgJ0%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;1546&quot; height=&quot;399&quot; data-origin-width=&quot;1546&quot; data-origin-height=&quot;399&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 계속 오르다가 &lt;b&gt;0.00442&lt;/b&gt;에 머무는 걸 볼 수 있습니다. 거의 지연이 없습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Error - 배치 저장시 발생한 오류의 빈도수(0건)&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1877&quot; data-origin-height=&quot;801&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8thf9/btsOFt78rXH/xeJBSycDSWzmiI7XEWGakk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8thf9/btsOFt78rXH/xeJBSycDSWzmiI7XEWGakk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8thf9/btsOFt78rXH/xeJBSycDSWzmiI7XEWGakk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8thf9%2FbtsOFt78rXH%2FxeJBSycDSWzmiI7XEWGakk%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;1877&quot; height=&quot;801&quot; data-origin-width=&quot;1877&quot; data-origin-height=&quot;801&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하테스트동안 한건의 오류도 없었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Queue - 큐에 남아있는 로그 수(배치 사이즈 : 1000, 큐 사이즈 : 10만)&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1901&quot; data-origin-height=&quot;820&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWm0AV/btsOHdhX1ul/zbvLliz5p61PUDELerWpHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWm0AV/btsOHdhX1ul/zbvLliz5p61PUDELerWpHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWm0AV/btsOHdhX1ul/zbvLliz5p61PUDELerWpHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWm0AV%2FbtsOHdhX1ul%2FzbvLliz5p61PUDELerWpHk%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;1901&quot; height=&quot;820&quot; data-origin-width=&quot;1901&quot; data-origin-height=&quot;820&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초반에는 배치 사이즈인 &lt;b&gt;1,000&lt;/b&gt;까지 치솟았다가 인서트를 하고 초기화되며 &lt;b&gt;왔다갔다&lt;/b&gt;를 반복합니다.&lt;/li&gt;
&lt;li&gt;같은 주기로 작동하지 않는 이유는 &lt;b&gt;스케쥴러가 5초단위&lt;/b&gt;로 큐에 있는 데이터를 처리하기 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로, 스케쥴러는 배치 인서트 전에 로그를 저장함으로서 로그 확인의 사용성을 높이기 위한 목적으로 세팅했습니다. 그리고 서버 다운으로 인한 유실확률을 낮추기 위함입니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;Queue Size는 의도했던대로 &lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;배치 사이즈(1,000건) 범위 내에서 잘 동작.&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Latency - 로그를 큐에 넣는 지연시간&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1899&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dfOhVz/btsOHCV6Xlb/VAFQ2tkWYwlRO6NvCctjv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dfOhVz/btsOHCV6Xlb/VAFQ2tkWYwlRO6NvCctjv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dfOhVz/btsOHCV6Xlb/VAFQ2tkWYwlRO6NvCctjv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdfOhVz%2FbtsOHCV6Xlb%2FVAFQ2tkWYwlRO6NvCctjv1%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;1899&quot; height=&quot;808&quot; data-origin-width=&quot;1899&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 큐에 한번에 많은 로그를 넣느라 지연시간이&amp;nbsp; 높았으나 점차 줄어드는 양상을 확인할 수 있었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Latency - Batch Insert를 하는데 걸리는 시간&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1889&quot; data-origin-height=&quot;810&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8WRzI/btsOF29DO5k/NOUjFptyc9PuiXXfj4z8F1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8WRzI/btsOF29DO5k/NOUjFptyc9PuiXXfj4z8F1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8WRzI/btsOF29DO5k/NOUjFptyc9PuiXXfj4z8F1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8WRzI%2FbtsOF29DO5k%2FNOUjFptyc9PuiXXfj4z8F1%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;1889&quot; height=&quot;810&quot; data-origin-width=&quot;1889&quot; data-origin-height=&quot;810&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그를 큐에 넣는데 걸리는 시간처럼 초반에 초기화를 하는데 있어서 시간을 많이 잡아먹고, 그 다음부턴 &lt;b&gt;0.04~0.045&lt;/b&gt; 부근을 횡보합니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론: 실무에서 이 구조를 선택한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &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;Kafka, Redis 없이도 순수 Java + Spring + JDBC로만 구현&lt;/li&gt;
&lt;li&gt;폐쇄망(군&amp;middot;금융&amp;middot;국가기관 등)에서도 바로 적용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &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;BlockingQueue + ThreadPoolExecutor 구조로 동시성 처리 보장&lt;/li&gt;
&lt;li&gt;유실 없이 초당 400~600건 이상 로그 처리 성능 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. &lt;b&gt;운영 종료 시 flush 보장&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;shutdown hook, 스케줄 기반 flush, 배치 처리 조합으로&lt;br /&gt;로그 유실 없이 graceful shutdown 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. &lt;b&gt;JDBC batch insert 기반의 고성능&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;JPA saveAll() 대신 JdbcTemplate.batchUpdate() 사용&lt;br /&gt;&amp;rarr; 평균 메모리 사용량 2.5배 이상 절감&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. &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;Prometheus 지표 수집&lt;/li&gt;
&lt;li&gt;flush coverage 확인용 강제 장애 테스트 포함&lt;/li&gt;
&lt;li&gt;JSON diff, 파일 fallback 등 확장성 고려&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결국, 이 구조는 &quot;최고&quot;는 아니지만 &quot;현실적으로 가장 쓸만한 솔루션&quot;&lt;/h2&gt;
&lt;blockquote data-end=&quot;852&quot; data-start=&quot;750&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;852&quot; data-start=&quot;752&quot; data-ke-size=&quot;size16&quot;&gt;제약 조건이 명확한 환경에서&lt;br /&gt;&lt;b&gt;신뢰성과 운영성을 모두 확보하는 가장 실용적인 선택지&lt;/b&gt;였으며,&lt;br /&gt;확장 가능한 구조로 설계되어 추후 고성능 요구사항도 수용 가능합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-end=&quot;909&quot; data-start=&quot;854&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;909&quot; data-start=&quot;856&quot; data-ke-size=&quot;size16&quot;&gt;Kafka 없이도 이 정도까지 가능하다는 것을&lt;br /&gt;실제 실험과 수치로 검증해보고 싶었습니다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>개발/대규모</category>
      <category>blockingqueue</category>
      <category>java</category>
      <category>JDBC</category>
      <category>log</category>
      <category>springboot</category>
      <category>로그</category>
      <category>멀티스레드</category>
      <category>배치</category>
      <category>배치처리</category>
      <category>비동기</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/13</guid>
      <comments>https://pandaterry.tistory.com/entry/%EB%A9%94%EC%84%B8%EC%A7%80-%EB%B8%8C%EB%A1%9C%EC%BB%A4-%EC%97%86%EC%9D%B4-%EC%97%94%ED%8B%B0%ED%8B%B0-%EB%B3%80%EA%B2%BD-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%80%EC%9E%A5%ED%95%98%EB%8A%94-%EA%B5%AC%EC%A1%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0#entry13comment</comments>
      <pubDate>Wed, 18 Jun 2025 16:41:16 +0900</pubDate>
    </item>
    <item>
      <title>[Saas개발로그] JVM 2GB로 1,000만 행 Excel 처리는 가능한가? 직접 실험해봤습니다</title>
      <link>https://pandaterry.tistory.com/entry/Saas%EA%B0%9C%EB%B0%9C%EB%A1%9C%EA%B7%B8-2GB-%EC%84%9C%EB%B2%84-%EB%A9%94%EB%AA%A8%EB%A6%AC%EB%A1%9C-1000%EB%A7%8C-%ED%96%89-Excel-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kOcy8/btsOAyn0nWJ/68YB9fIQ4QNH3prxbuxwv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kOcy8/btsOAyn0nWJ/68YB9fIQ4QNH3prxbuxwv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kOcy8/btsOAyn0nWJ/68YB9fIQ4QNH3prxbuxwv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkOcy8%2FbtsOAyn0nWJ%2F68YB9fIQ4QNH3prxbuxwv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br&gt;배경&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 &lt;b&gt;SQL ResultSet을 받아 데스크톱 앱에서 바로 엑셀 파일로 내려주는 서비스&lt;/b&gt;를 개발 중입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;초기에는 수만 건 단위의 다운로드만 고려했지만,&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;“고객사의 요구에 따라 향후 수백만 건, 심지어 수천만 건까지 조회량이 늘어날 수도 있겠다”&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;는 생각이 들었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;아직 실제로 그런 대규모 요청이 들어온 것은 아니지만, 미리 대비하지 않으면 나중에 갑작스러운 부하에 허용하지 못할 수 있습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;그래서 이번에는 JVM 힙 2 GB 환경에서 잠재적인 극한 부하를 가정해 보고자 합니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;다음 세 가지 기법을 동일 조건에서 비교·검증하며, “어떤 구조가 가장 안정적으로 견딜 수 있을까?”를 확인해 보겠습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;근데 왜 JVM 힙을 2GB로 제한했는가?&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험에서 JVM 힙을 2GB로 제한한 이유는 단순한 테스트 편의를 위한 것이 아니라, &lt;b&gt;실제 배포될 환경의 자원 제약 조건을 정확히 반영한 설정&lt;/b&gt;이기 때문입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;제가 설계한 구조는 다음과 같은 특수한 실행 환경을 가집니다:&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;&lt;b&gt;Tauri 기반 데스크탑 앱&lt;/b&gt;이 실행될 때&lt;/li&gt;&lt;li&gt;내부적으로 Micronaut 서버가 &lt;b&gt;로컬에서 함께 실행&lt;/b&gt;되며&lt;/li&gt;&lt;li&gt;사용자가 요청한 데이터를 기반으로 &lt;b&gt;1,000만 건 Excel 파일을 생성&lt;/b&gt;합니다.&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;즉, 이 서버는 전용 백엔드 서버가 아니라,&lt;br&gt;&lt;b&gt;일반 사무용 PC에서 Tauri 앱과 함께 동작하는 로컬 서버&lt;/b&gt;입니다.&lt;br&gt;이런 환경에서는 다음과 같은 제약 조건을 가집니다:&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;현실적인 메모리 사용 환경&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;구성 요소 평균 메모리 사용량 출처 요약&lt;/p&gt;&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;구성요소&lt;/td&gt;&lt;td&gt;평균 메모리 사용량&lt;/td&gt;&lt;td&gt;검증 내용&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;운영체제 (Windows/macOS)&lt;/b&gt;&lt;/td&gt;&lt;td&gt;3~5GB&lt;/td&gt;&lt;td&gt;Windows 11 유휴 시 3~5GB 이상 증가&amp;nbsp;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;Tauri 앱&lt;/b&gt;&lt;/td&gt;&lt;td&gt;0.5~1.5GB&lt;/td&gt;&lt;td&gt;WebView 기반이라 Electron보다 적지만, 복잡도 따라 증가 가능&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;기타 앱 (Teams, 브라우저 등)&lt;/b&gt;&lt;/td&gt;&lt;td&gt;1~2GB&lt;/td&gt;&lt;td&gt;Microsoft Teams 단독으로 0.7~1.3GB, Slack 등 추가 시 합산&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;여유 메모리 (Total 16GB 기준)&lt;/b&gt;&lt;/td&gt;&lt;td&gt;약 7~10GB&lt;/td&gt;&lt;td&gt;사용자 앱과 시스템 포함 계산&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;JVM 힙 설정의 상한선: 2~4GB가 현실적&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;JVM 힙은 사용 가능한 여유 메모리 중에서 &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;너무 큰 힙은 GC 일시정지(Pause Time)를 늘리고&amp;nbsp;&lt;/li&gt;&lt;li&gt;OS 스와핑이 발생해 전체 성능을 저하시킬 수 있으며&amp;nbsp;&lt;/li&gt;&lt;li&gt;메모리 단편화, 과도한 메타영역 사용까지 유발할 수 있습니다&lt;/li&gt;&lt;/ul&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;그래서!&lt;/h4&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt; 
 &lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Micronaut 서버는 Tauri와 동시에 로컬에서 동작하며, 사무용 PC 환경에서 배포됩니다.&lt;/b&gt;&lt;/p&gt; 
 &lt;p data-ke-size=&quot;size16&quot;&gt;이 조건에서 JVM 힙을 2GB로 제한하는 것은,&lt;br&gt;&lt;b&gt;실제 배포 환경에서 감내할 수 있는 리소스의 현실적인 상한을 반영한 실험 조건&lt;/b&gt;입니다.&lt;/p&gt; 
&lt;/blockquote&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;왜 더 높게 잡지 않았는가?&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;메모리를 6GB, 8GB 이상 할당하면 1,000만 건 Excel 생성은 &lt;b&gt;좀 더 쉽게&lt;/b&gt; 처리될 수 있습니다.&lt;br&gt;하지만 이는 다음과 같은 문제를 초래합니다:&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;고사양 장비에서만 동작 → 일반 회사 환경에서는 &lt;b&gt;실패하거나 버벅임&lt;/b&gt;&lt;/li&gt;&lt;li&gt;사용자 입장에서는 앱이 갑자기 꺼지거나 시스템이 느려지는 것처럼 보일 수 있음&lt;/li&gt;&lt;li&gt;메모리 부족으로 인한 &lt;b&gt;OutOfMemoryError&lt;/b&gt;보다 &lt;b&gt;스와핑 + GC 병목 현상&lt;/b&gt;이 더 위험&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;따라서 이번 실험의 목적은&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt; 
 &lt;p data-ke-size=&quot;size16&quot;&gt;“&lt;b&gt;가능한 최소한의 JVM 힙 구성에서 얼마나 안전하게 Excel을 생성할 수 있는가&lt;/b&gt;”를 검증하는 것입니다.&lt;/p&gt; 
&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;Excel/CSV 처리 3가지 라이브러리 비교&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Apache POI SXSSF 스트리밍&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;무엇인가?&lt;/b&gt;&lt;br&gt;POI 라이브러리의 SXSSFWorkbook을 사용해&lt;br&gt;메모리에 모든 데이터를 올리지 않고&lt;br&gt;내부 디스크 기반 버퍼에 일정 행 단위로만 유지하면서&lt;br&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;b&gt;표준성&lt;/b&gt;: Java 진영에서 가장 널리 쓰이는 엑셀 처리 라이브러리&lt;/li&gt; 
   &lt;li&gt;&lt;b&gt;유연성&lt;/b&gt;: 셀 스타일, 수식, 차트 삽입 등 고급 기능 지원&lt;/li&gt; 
   &lt;li&gt;&lt;b&gt;메모리 절약&lt;/b&gt;: 기본 POI보다 낮은 메모리 사용&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Alibaba EasyExcel 버퍼 기반 매핑&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;무엇인가?&lt;/b&gt;&lt;br&gt;Alibaba에서 만든 EasyExcel 라이브러리는&lt;br&gt;도메인 모델 클래스에 @ExcelProperty 어노테이션을 붙여&lt;br&gt;한 번에 일정 크기의 버퍼(List&amp;lt;T&amp;gt;)로 데이터를 모은 뒤&lt;br&gt;SAX 이벤트 방식으로 엑셀을 생성하는 방식입니다.&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;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;: 대규모 서비스(Alibaba Cloud)에서 검증&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Univocity-parsers CSV 스트리밍&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;무엇인가?&lt;/b&gt;&lt;br&gt;CSV 형식으로 ResultSet을 한 행씩 읽어&lt;br&gt;CsvWriter를 통해 즉시 네트워크에 쓰는 방식입니다.&lt;br&gt;Apache Commons CSV 대비 높은 처리량과 낮은 메모리 사용을 자랑합니다.&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;b&gt;극한 경량&lt;/b&gt;: 텍스트 기반이므로 바이너리 포맷보다 파일 크기·메모리 사용량이 작음&lt;/li&gt; 
   &lt;li&gt;&lt;b&gt;단순성&lt;/b&gt;: 셀 스타일이나 복잡 포맷이 필요 없는 상황에서 최적&lt;/li&gt; 
   &lt;li&gt;&lt;b&gt;성능&lt;/b&gt;: 벤치마크에서 초당 수백만 행 처리 성능 입증&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;이 세 가지는&lt;/p&gt;&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;&lt;li&gt;Java에서 가장 흔히 쓰이는 엑셀 처리 기법,&lt;/li&gt;&lt;li&gt;코드 간결성과 성능을 모두 잡은 대안,&lt;/li&gt;&lt;li&gt;엑셀 대신 최경량 CSV로 접근하는 옵션&lt;/li&gt;&lt;/ol&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;으로 서로 &lt;b&gt;메모리 사용&lt;/b&gt;, &lt;b&gt;처리 속도&lt;/b&gt;, &lt;b&gt;개발·운영 복잡도&lt;/b&gt; 면에서 합리적인 비교 대상으로 판단하여 선정했습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이제 실험을 해보죠. 실험은 우선 요약하자면, 1,000만건의 DB에 있는 데이터를 서버가 받아서 제한적인 스펙에서 엑셀을 다운로드하는 시나리오입니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 실험 개요&lt;/h2&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;413&quot; data-start=&quot;87&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li data-end=&quot;274&quot; data-start=&quot;87&quot;&gt;&lt;b&gt;목적&lt;/b&gt;: 1,000만 건 orders 테이블을 내보낼 때 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;274&quot; data-start=&quot;130&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;156&quot; data-start=&quot;130&quot;&gt;Apache POI SXSSF(스트리밍)&lt;/li&gt; 
   &lt;li data-end=&quot;180&quot; data-start=&quot;159&quot;&gt;EasyExcel(버퍼 메모리)&lt;/li&gt; 
   &lt;li data-end=&quot;274&quot; data-start=&quot;183&quot;&gt;Univocity-parsers(CSV 스트리밍)&lt;br&gt;세 방식의 &lt;b&gt;메모리 사용량&lt;/b&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;/li&gt; 
 &lt;li data-end=&quot;413&quot; data-start=&quot;275&quot;&gt;&lt;b&gt;환경&lt;/b&gt; 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;413&quot; data-start=&quot;288&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;312&quot; data-start=&quot;288&quot;&gt;Spring Boot, Java 17&lt;/li&gt; 
   &lt;li data-end=&quot;350&quot; data-start=&quot;315&quot;&gt;JVM 힙 2 GB 고정 (-Xms2g -Xmx2g)&lt;/li&gt; 
   &lt;li data-end=&quot;380&quot; data-start=&quot;353&quot;&gt;동시 사용자 1명(부하 없이 순차 테스트)&lt;/li&gt; 
   &lt;li data-end=&quot;413&quot; data-start=&quot;383&quot;&gt;Linux 서버, 1 vCPU, 4 GB 메모리&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 실험시작&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;우선 시작하기 전에 측정 지표 선정을 해봤습니다. 제한된 리소스에서 어떤게 가장 성능이 좋은지 판가름하는 것이라 기준이라 생각합니다.&lt;/p&gt;&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 296px;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;tbody&gt;&lt;tr style=&quot;height: 20px;&quot;&gt;&lt;td style=&quot;width: 14.1861%; height: 20px;&quot;&gt;지표&lt;/td&gt;&lt;td style=&quot;width: 16.0465%; height: 20px;&quot;&gt;단위&lt;/td&gt;&lt;td style=&quot;width: 69.6512%; height: 20px;&quot;&gt;선정이유&lt;/td&gt;&lt;/tr&gt;&lt;tr style=&quot;height: 40px;&quot;&gt;&lt;td style=&quot;width: 14.1861%; height: 40px;&quot;&gt;&lt;b&gt;배치 처리 속도&lt;/b&gt;&lt;/td&gt;&lt;td style=&quot;width: 16.0465%; height: 40px;&quot;&gt;batches/sec&lt;/td&gt;&lt;td style=&quot;width: 69.6512%; height: 40px;&quot;&gt;초당 몇 개의 배치가 처리되고 있는지를 나타냅니다. &lt;br&gt;실시간 처리 성능을 모니터링하기 위한 가장 직접적인 지표로, 속도 저하나 중단 시 즉시 감지할 수 있습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr style=&quot;height: 40px;&quot;&gt;&lt;td style=&quot;width: 14.1861%; height: 40px;&quot;&gt;&lt;b&gt;배치 처리 흐름&lt;/b&gt;&lt;/td&gt;&lt;td style=&quot;width: 16.0465%; height: 40px;&quot;&gt;batches/interval&lt;/td&gt;&lt;td style=&quot;width: 69.6512%; height: 40px;&quot;&gt;일정 시간 구간 내에 얼마나 많은 배치가 처리되었는지를 보여줍니다. &lt;br&gt;시점별 처리 분포를 확인하여 작업의 집중 시간대나 공백 구간을 시각화할 수 있습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr style=&quot;height: 40px;&quot;&gt;&lt;td style=&quot;width: 14.1861%; height: 40px;&quot;&gt;&lt;b&gt;평균 처리 시간&lt;/b&gt;&lt;/td&gt;&lt;td style=&quot;width: 16.0465%; height: 40px;&quot;&gt;seconds&lt;/td&gt;&lt;td style=&quot;width: 69.6512%; height: 40px;&quot;&gt;Export 1건을 완료하는 데 걸린 평균 시간을 나타냅니다. &lt;br&gt;라이브러리 간 처리 효율 비교나 병목 파악에 핵심적인 기준입니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr style=&quot;height: 40px;&quot;&gt;&lt;td style=&quot;width: 14.1861%; height: 40px;&quot;&gt;&lt;b&gt;최대 처리 시간&lt;/b&gt;&lt;/td&gt;&lt;td style=&quot;width: 16.0465%; height: 40px;&quot;&gt;seconds&lt;/td&gt;&lt;td style=&quot;width: 69.6512%; height: 40px;&quot;&gt;일정 시간 내 export 중 가장 오래 걸린 작업의 처리 시간입니다. &lt;br&gt;SLA 위반 여부나 간헐적 병목을 탐지하기 위한 지표입니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr style=&quot;height: 40px;&quot;&gt;&lt;td style=&quot;width: 14.1861%; height: 40px;&quot;&gt;&lt;b&gt;평균 파일 크기&lt;/b&gt;&lt;/td&gt;&lt;td style=&quot;width: 16.0465%; height: 40px;&quot;&gt;bytes&lt;/td&gt;&lt;td style=&quot;width: 69.6512%; height: 40px;&quot;&gt;생성된 엑셀 파일의 평균 크기를 나타냅니다. &lt;br&gt;처리된 데이터 양이나 압축 효율을 간접적으로 파악할 수 있으며, 비정상적으로 큰 결과물도 감지할 수 있습니다.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;Apache-POI SXSSF Streaming&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;엑셀 추출 메트릭&lt;/b&gt;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1545&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qRnxt/btsOCRssu8X/NnSjO171BZfPjHt042jKkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qRnxt/btsOCRssu8X/NnSjO171BZfPjHt042jKkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qRnxt/btsOCRssu8X/NnSjO171BZfPjHt042jKkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqRnxt%2FbtsOCRssu8X%2FNnSjO171BZfPjHt042jKkk%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;1545&quot; height=&quot;412&quot; data-origin-width=&quot;1545&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;/figure&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;초당 처리 행수 : 대략 10만rows(101,814 rows)&lt;/li&gt;&lt;li&gt;한번 엑셀 추출시 시간 : 127초 정도 소요&lt;/li&gt;&lt;li&gt;파일 용량 : 625MB&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;JVM 메트릭&lt;/b&gt;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1303&quot; data-origin-height=&quot;481&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cS6aU0/btsOCNp7zzs/vLpK1Ksc8QCjZkaApRa0dK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cS6aU0/btsOCNp7zzs/vLpK1Ksc8QCjZkaApRa0dK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cS6aU0/btsOCNp7zzs/vLpK1Ksc8QCjZkaApRa0dK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcS6aU0%2FbtsOCNp7zzs%2FvLpK1Ksc8QCjZkaApRa0dK%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;1303&quot; height=&quot;481&quot; data-origin-width=&quot;1303&quot; data-origin-height=&quot;481&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;(노랑) Heap 메모리(G1 Old Gen) 사용량 : 평균 2,205,628,416 (2.2GB)&lt;/li&gt;&lt;li&gt;(초록) Heap 메모리(G1 Eden Space) 사용량 : 평균 752,877,568 (0.7GB)&lt;/li&gt;&lt;li&gt;(파랑) Heap 메모리(G1 Survivor Space) 사용량 : 평균 11,881,704 (120MB)&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;Apache-POI (SXSSF Streaming)&amp;nbsp; vs&amp;nbsp; EasyExcel&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;: 우선 Prometheus, Grafana를 통해 매트릭을 시각화했습니다. 비교는 각 요청별로 어떤 라이브러리인지와 요청마다 UUID를 달아서 비교를 했습니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;처리량(노랑 - Apache POI, 빨강 - EasyExcel)&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;917&quot; data-origin-height=&quot;414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bK7bdg/btsOB2awEmu/yGcMn2agYiU7B2cpZLwI9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bK7bdg/btsOB2awEmu/yGcMn2agYiU7B2cpZLwI9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bK7bdg/btsOB2awEmu/yGcMn2agYiU7B2cpZLwI9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbK7bdg%2FbtsOB2awEmu%2FyGcMn2agYiU7B2cpZLwI9k%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;917&quot; height=&quot;414&quot; data-origin-width=&quot;917&quot; data-origin-height=&quot;414&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;=&amp;gt; 지표를 보면 최대 피크에서 EasyExcel 이 Apache POI 보다 6배가량 더 높은 처리량을 보여줍니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;처리시간 및 파일크기&lt;/b&gt;&lt;b&gt;(노랑 - Apache POI, 빨강 - EasyExcel)&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;923&quot; data-origin-height=&quot;571&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOmliB/btsOCzeDZjz/aLOVVIb34IRjvA47HK8sok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOmliB/btsOCzeDZjz/aLOVVIb34IRjvA47HK8sok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOmliB/btsOCzeDZjz/aLOVVIb34IRjvA47HK8sok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOmliB%2FbtsOCzeDZjz%2FaLOVVIb34IRjvA47HK8sok%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;923&quot; height=&quot;571&quot; data-origin-width=&quot;923&quot; data-origin-height=&quot;571&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;처리시간은 Apache POI가 3분정도 더 빠르고, 용량도 EasyExcel에 비해 30MB 정도 더 가볍습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;참고로 30MB면 1/10인 100만건을 처리할 때의 파일크기이며, 3분이라는 시간도 100만건을 테스트해봤을 때 평균적으로 나오는 시간대였습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;그러므로, 100만건 정도 Apache POI가 빠르고 가볍게 처리한다는 걸 의미합니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이걸 보면서 다음과 같은 궁금증이 들겁니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;위에서 처리량은 EasyExcel이 더 높은데, Apache POI가 처리시간과 파일크기에서 더 나은 이유가 뭐지?&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;이에 대해 답변을 드리자면,&lt;br&gt;&amp;nbsp;&lt;br&gt;우선 Apache POI는 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;압축&lt;/b&gt;&lt;/span&gt;한 Workbook에 write가 가능합니다. 하지만 EasyExcel은 그렇지 않죠.&lt;br&gt;&amp;nbsp;&lt;br&gt;그래서 Apache POI는 처리량은 적었으나 용량을 30MB정도 줄일 수 있습니다. 그리고 그렇기 때문에 디스크 IO에서 3분정도 단축이 가능한거죠.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JVM 힙 메모리(노랑 - Apache POI, 빨강 - EasyExcel)&lt;/b&gt;&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;: 높이가 사용량이며 byte단위입니다. 예를 들어, 20억이면 2GB입니다.&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDdIwz/btsODjvGNTD/3wpXifkg9K6lFU2KOYsWuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDdIwz/btsODjvGNTD/3wpXifkg9K6lFU2KOYsWuk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDdIwz/btsODjvGNTD/3wpXifkg9K6lFU2KOYsWuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDdIwz%2FbtsODjvGNTD%2F3wpXifkg9K6lFU2KOYsWuk%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;928&quot; height=&quot;780&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;=&amp;gt; 그래프에선 둘의 Old Gen 메모리 사용량은 큰 차이가 없어보입니다. 하지만 EasyExcel에서 G1 Eden Space 사용량이 올라가는걸 볼 수 있는데 3배 정도 Apache POI에 비해 더 높음을 알 수 있습니다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Apache POI에서&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;SXSSFWorkbook&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;을 사용했다면 Old Gen 사용량이 비슷한 것이 정상입니다&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;br&gt;&lt;span style=&quot;color: #333333;&quot;&gt;SXSSFWorkbook&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;은&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;스트리밍 방식으로 일부 데이터만 메모리에 유지&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하기 때문입니다.&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;EasyExcel은 EasyExcel은 행별로 새로운 객체를 생성하고 즉시 해제합니다.&amp;nbsp;&lt;br&gt;그래서 따끈따끈한 갓 태어난 객체 위주로 다루기 때문에 Eden Space가 높은 구조를 갖고 있습니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;Univocity CSV Parsers&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;: 여기도 Prometheus, Grafana를 통해 매트릭을 시각화했습니다. 빨강 화살표만 보면 됩니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Excel 처리량&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;957&quot; data-origin-height=&quot;425&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b49bLw/btsOCxhKQjI/2pNURJzhLyX82W5QdpbEn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b49bLw/btsOCxhKQjI/2pNURJzhLyX82W5QdpbEn0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b49bLw/btsOCxhKQjI/2pNURJzhLyX82W5QdpbEn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb49bLw%2FbtsOCxhKQjI%2F2pNURJzhLyX82W5QdpbEn0%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;957&quot; height=&quot;425&quot; data-origin-width=&quot;957&quot; data-origin-height=&quot;425&quot;/&gt;&lt;/span&gt;&lt;/figure&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;초반에 38까지 튀어오르다가 25부근에 횡보하는 것을 볼 수 있습니다.&lt;/li&gt;&lt;li&gt;Univocity는 Apache POI, EasyExcel과 다르게 매번 디스크 IO를 쓰기 때문에 점점 IO가 늘어나면서 처리량이 어느 수준에 머무는 것을 볼 수 있습니다.&lt;/li&gt;&lt;/ul&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;Excel 처리시간 및 파일크기&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;936&quot; data-origin-height=&quot;590&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8yZQa/btsODMrK4QZ/sKLGPoSBknCw5Una0cjRR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8yZQa/btsODMrK4QZ/sKLGPoSBknCw5Una0cjRR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8yZQa/btsODMrK4QZ/sKLGPoSBknCw5Una0cjRR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8yZQa%2FbtsODMrK4QZ%2FsKLGPoSBknCw5Una0cjRR1%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;936&quot; height=&quot;590&quot; data-origin-width=&quot;936&quot; data-origin-height=&quot;590&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;솔직히 처리시간과 파일크기에서 실망했던 부분이었습니다.&lt;/li&gt;&lt;li&gt;Apache POI, EasyExcel에 비해 천만건일 때는 처리시간과 파일크기 모두 거의 2배 수준으로 비효율적이었습니다.&amp;nbsp;&lt;/li&gt;&lt;li&gt;그러나 건수가 적었을 때는 확실히 빨랐는데, 데이터가 많으면 많을수록 비효율적인 구조임을 알 수 있었습니다.&lt;/li&gt;&lt;/ul&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JVM 힙 메모리&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;947&quot; data-origin-height=&quot;447&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VowYQ/btsOCRNQauw/IyuiroUeackL12lelxho9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VowYQ/btsOCRNQauw/IyuiroUeackL12lelxho9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VowYQ/btsOCRNQauw/IyuiroUeackL12lelxho9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVowYQ%2FbtsOCRNQauw%2FIyuiroUeackL12lelxho9k%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;947&quot; height=&quot;447&quot; data-origin-width=&quot;947&quot; data-origin-height=&quot;447&quot;/&gt;&lt;/span&gt;&lt;/figure&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;빨강 화살표가 있는 노란선을 보면 2GB가 넘게 G1 Old Gen을 사용하고 있는 것을 확인가능합니다.&lt;/li&gt;&lt;li&gt;아래 초록선은 G1 Eden Space인데, 지금 계속 줄어들었다가 늘었다가를 반복하는 중인데, 이건 배치단위로 배열을 만들고 해제하느라 발생하는 상황입니다.(어느정도 속도를 줄이는 과정에서 이 방식이 가장 빨랐습니다.)&lt;/li&gt;&lt;li&gt;전반적으로 총 G1 Old Gen 은 조금 덜 사용하고, G1 Eden Space는 적게 사용하는 편이었습니다. 하지만 반복적인 디스크 IO때문에 속도가 느리니 트레이드오프라 생각합니다.&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 실험결과&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;: 'Apache-POI (SXSSF Streaming)'&amp;nbsp; vs&amp;nbsp; 'EasyExcel' vs 'Univocity CSV Parsers' 3가지를 한번에 비교해봤습니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Excel 처리량(빨강 - Univocity, 노랑 - Apache POI, 파랑 - EasyExcel)&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;955&quot; data-origin-height=&quot;423&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dA0YVN/btsODRNudDZ/nuV1vksgMKEjhYf8k1K1z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dA0YVN/btsODRNudDZ/nuV1vksgMKEjhYf8k1K1z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dA0YVN/btsODRNudDZ/nuV1vksgMKEjhYf8k1K1z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdA0YVN%2FbtsODRNudDZ%2FnuV1vksgMKEjhYf8k1K1z0%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;955&quot; height=&quot;423&quot; data-origin-width=&quot;955&quot; data-origin-height=&quot;423&quot;/&gt;&lt;/span&gt;&lt;/figure&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;Univocity : 초당 0.4배치(4,000행) 처리, 분당 24배치(240,000 행)&lt;/li&gt;&lt;li&gt;Apache POI : 초당 9배치(90,000행), 분당 549배치(5,490,000행)&lt;/li&gt;&lt;li&gt;EasyExcel : 초당 12배치(120,000행), 분당 748배치(7,480,000행)&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;EasyExcel &amp;gt; Apache POI &amp;gt; Univocity&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;천만건에서 처리량은 EasyExcel이 가장 좋으며, &lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;Univocity 대비 30배정도 높습니다.&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Excel 처리시간 및 파일크기(빨강 - Univocity, 노랑 - Apache POI, 파랑 - EasyExcel)&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;955&quot; data-origin-height=&quot;603&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbtbsE/btsOEARIk9a/uyaBAPE1HrrQzUITe6jce0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbtbsE/btsOEARIk9a/uyaBAPE1HrrQzUITe6jce0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbtbsE/btsOEARIk9a/uyaBAPE1HrrQzUITe6jce0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbtbsE%2FbtsOEARIk9a%2FuyaBAPE1HrrQzUITe6jce0%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;955&quot; height=&quot;603&quot; data-origin-width=&quot;955&quot; data-origin-height=&quot;603&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;Univocity : 39분 / 1210MB&lt;/li&gt;&lt;li&gt;Apache POI : 22분 / 625MB&lt;/li&gt;&lt;li&gt;EasyExcel : 24분 / 651MB&lt;/li&gt;&lt;/ul&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;Apache POI &amp;gt; EasyExcel &amp;gt; Univocity&lt;br&gt;&lt;br&gt;Apache POI가 처리시간이나 파일크기면에서 가장 효율적입니다.&lt;br&gt;가장 느린 Univocity 대비 2배정도 효율적입니다.&lt;/blockquote&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JVM 힙 메모리(빨강 - Univocity, 노랑 - Apache POI, 파랑 - EasyExcel)&lt;/b&gt;&lt;/h4&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;957&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHamiN/btsOBDQAIav/fgQGroZxkPHwkDrDQPc3Jk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHamiN/btsOBDQAIav/fgQGroZxkPHwkDrDQPc3Jk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHamiN/btsOBDQAIav/fgQGroZxkPHwkDrDQPc3Jk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHamiN%2FbtsOBDQAIav%2FfgQGroZxkPHwkDrDQPc3Jk%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;957&quot; height=&quot;446&quot; data-origin-width=&quot;957&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;Univocity : 2.3GB(G1 Old Gen) / 100MB(G1 Eden Space)&lt;/li&gt;&lt;li&gt;Apache POI : 2.5GB(G1 Old Gen) / 230MB(G1 Eden Space)&lt;/li&gt;&lt;li&gt;EasyExcel : 2.5GB(G1 Old Gen) / 620MB(G1 Eden Space)&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;그러나, 위 지표는 연속적으로 측정한 것이라 누적된 것일 수도 있습니다. 참고만 하세요.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt; &lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;Univocity &amp;gt; Apache POI &amp;gt; EasyExcel&lt;br&gt;&lt;br&gt;&lt;/span&gt;G1 Old Gen에 대해선 그리 큰 차이는 없습니다. 
 &lt;br&gt;하지만 G1 Eden Space를 고려하면 Univocity가 
 &lt;br&gt;디스크IO를 주로 하기 때문에 효율적입니다. 
 &lt;br&gt; 
 &lt;br&gt; 
 &lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt; 
 &lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt; 
&lt;/blockquote&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;“JVM 힙 2GB로는 1,000만 건 엑셀 생성, 불가능에 가깝다”&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;이번 실험의 핵심 목적은 **“JVM 힙 2GB 환경에서 얼마나 안전하게 대용량 Excel 파일을 생성할 수 있는가?”**였습니다.&lt;br&gt;결론부터 말씀드리면, &lt;b&gt;현실적으로는 거의 불가능에 가깝습니다.&lt;/b&gt;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&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;3가지 방식 모두 G1 Old Gen이 2.3GB~2.5GB까지 상승&lt;/b&gt;&lt;br&gt;→ 실질적으로 JVM이 허용하는 메모리 경계를 초과했고, GC 일시정지가 빈번하게 발생했습니다.&lt;br&gt;→ 특히 Tauri 앱과 동시에 구동되는 환경에서는 메모리 충돌 여지도 큽니다.&lt;/li&gt;&lt;li&gt;&lt;b&gt;파일 생성 시간이 20~40분 소요되며 사용자 경험에 치명적&lt;/b&gt;&lt;br&gt;→ UI가 멈춘 것처럼 보이거나, 작업이 실패할 가능성 존재&lt;br&gt;→ 시스템 메모리 여유가 조금만 부족해도 OOM 발생 위험&lt;/li&gt;&lt;li&gt;&lt;b&gt;처리량이 아무리 좋아도, 메모리와 IO 병목으로 전체 시스템에 부담&lt;/b&gt;&lt;br&gt;→ EasyExcel이 빠르긴 하지만 Eden 영역 급증, GC 튜닝 없이는 지속적인 운영 불가&lt;br&gt;→ Apache POI는 용량은 적지만 처리량이 상대적으로 부족해 결국 시간 지연&lt;/li&gt;&lt;/ol&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무 판단 기준&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 실무에서는 &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;1,000만 건 이상의 엑셀 다운로드를 허용하지 않거나&lt;/b&gt;,&lt;/li&gt;&lt;li&gt;&lt;b&gt;JVM 힙을 4GB 이상으로 확보 가능한 구조에서만 동작하도록 제한하거나&lt;/b&gt;,&lt;/li&gt;&lt;li&gt;&lt;b&gt;백그라운드 처리 + 알림 방식(비동기, 링크 다운로드 등)으로 전환&lt;/b&gt;해야 합니다.&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;그럼, 어떤 구조가 가장 적합한가?&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험을 통해 예상보다 훨씬 다양한 인사이트를 얻을 수 있었습니다.&lt;br&gt;단순히 “메모리를 적게 쓰는 구조가 좋다”거나 “처리 속도가 빠른 게 최고다”라고 말하기는 어렵습니다.&lt;br&gt;각 방식은 &lt;b&gt;서로 다른 장단점을 명확하게&lt;/b&gt; 가지고 있었기 때문입니다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;상황별 선택 기준&lt;/h2&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;단기 요청 처리량이 최우선이라면 – EasyExcel&lt;/b&gt;&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;EasyExcel은 초당 처리량 기준으로 가장 우수했습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;특히 1,000만 건이라는 극한 상황에서도 &lt;b&gt;전체 처리량이 Apache POI보다 약 36% 더 높았고&lt;/b&gt;,&lt;br&gt;&amp;nbsp;&lt;br&gt;배치 처리 흐름 또한 안정적으로 유지되었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이는 &lt;b&gt;Eden Space를 많이 활용한 설계 구조&lt;/b&gt;와 &lt;b&gt;배치 버퍼 기반의 지속적인 가비지 회수 전략&lt;/b&gt;이 주효했기 때문으로 보입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;다만, 처리 결과물의 &lt;b&gt;용량이 약간 크고 &lt;/b&gt;JVM의 &lt;b&gt;Eden 영역 소비가 급증&lt;/b&gt;하기 때문에,&lt;br&gt;&lt;b&gt;&lt;/b&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;JVM 튜닝&lt;/b&gt; 없이 운영한다면 갑작스런 GC Pause의 리스크가 존재합니다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;파일 크기와 안정성 중심 – Apache POI (SXSSF Streaming)&lt;/b&gt;&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;처리량은 EasyExcel에 미치지 못했지만, &lt;b&gt;단일 요청에 대한 응답 시간, 생성 파일 크기, IO 효율성&lt;/b&gt;에서는 가장 좋은 성과를 냈습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;압축된 워크북 구조 덕분에 디스크 IO가 줄었고, 파일 크기는 EasyExcel 대비 &lt;b&gt;약 30MB 줄었으며&lt;/b&gt;, 이는 &lt;b&gt;다운로드 네트워크 비용, 스토리지 비용&lt;/b&gt; 측면에서 장점이 됩니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;실제로 100만 건 단위의 중간급 요청에서는 EasyExcel보다 빠르고 가벼웠습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;스트리밍 방식이기 때문에 &lt;b&gt;OutOfMemoryError에 대한 안정성도 높았습니다.&lt;/b&gt;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;극한의 경량, CSV 처리 – Univocity Parsers&lt;/b&gt;&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;Univocity는 기대했던 “극한 경량”에 비해 &lt;b&gt;천만 건 단위에서는 오히려 비효율적&lt;/b&gt;이었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;처리 시간은 &lt;b&gt;Apache POI의 2배&lt;/b&gt;, 파일 크기는 &lt;b&gt;1.9배&lt;/b&gt;, 게다가 JVM Old 영역 사용량은 크게 차이나지 않으면서도 &lt;b&gt;처리량은 30배 이상 낮았습니다.&lt;/b&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;이는 &lt;b&gt;반복적인 디스크 IO와 캐시 최적화 부족&lt;/b&gt;에서 기인한 결과로 보입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;물론, &lt;b&gt;100만 건 이하의 소형 요청&lt;/b&gt;에서는 여전히 강력한 선택지입니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;간결한 구조, 빠른 응답 시간, 낮은 복잡도 측면에서 가장 우수합니다.&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;종합 평가표&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;항목&lt;/td&gt;&lt;td&gt;EasyExcel&lt;/td&gt;&lt;td&gt;Apache POI (SXSSF)&lt;/td&gt;&lt;td&gt;&amp;nbsp;Univocity Parsers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;처리량 (건/분)&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;7,480,000&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;td&gt;5,490,000&lt;/td&gt;&lt;td&gt;240,000&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;처리시간 (1,000만건)&lt;/td&gt;&lt;td&gt;24분&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;22분&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;td&gt;39분&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;파일 크기&lt;/td&gt;&lt;td&gt;651MB&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;625MB&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;td&gt;1,210MB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;G1 Old Gen&lt;/td&gt;&lt;td&gt;2.5GB&lt;/td&gt;&lt;td&gt;2.5GB&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;2.3GB&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;G1 Eden Space&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;620MB&lt;/span&gt;&lt;/td&gt;&lt;td&gt;230MB&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;100MB&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;객체 생성 부담&lt;/td&gt;&lt;td&gt;높음&lt;/td&gt;&lt;td&gt;중간&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;낮음&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;스타일/차트 지원&lt;/td&gt;&lt;td&gt;약함&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;강력&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;td&gt;불가능&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;실무 안정성&lt;/td&gt;&lt;td&gt;중간&lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;높음&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;&lt;td&gt;낮음&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;적합 환경&lt;/td&gt;&lt;td&gt;고속 처리&lt;/td&gt;&lt;td&gt;중형 안정성&lt;/td&gt;&lt;td&gt;소형 경량&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무에서의 선택 기준&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로, 실무에서 어떤 방식을 선택해야 하는가는 다음과 같이 요약할 수 있습니다:&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;: EasyExcel (속도 중시)&lt;/li&gt;&lt;li&gt;&lt;b&gt;전체적인 안정성과 양호한 처리속도가 필요하다면&lt;/b&gt;: Apache POI (균형 중시)&lt;/li&gt;&lt;li&gt;&lt;b&gt;요청 크기가 작고, 단순 CSV로도 충분하다면&lt;/b&gt;: Univocity Parsers (최소 리소스 중시)&lt;/li&gt;&lt;/ul&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리하며 – 확장 가능성에 대한 시사점&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;이번 실험은 어디까지나 &lt;b&gt;단일 사용자, 순차 요청 기준&lt;/b&gt;으로 수행되었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;실제로 다수의 사용자가 동시에 요청을 보낼 경우, GC 압력은 훨씬 더 크게 작용할 수 있습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;추후에는 다음과 같은 시나리오로도 실험을 확장해볼 예정입니다:&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;&lt;b&gt;동시 5~10명 요청 시의 GC 및 처리량 변화&lt;/b&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;압축되지 않은 xlsx, 압축 수준별 csv 파일 비교&lt;/b&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;FlatFile vs DatabaseStreamingReader 기반 확장 구조&lt;/b&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;또한, 이번 실험을 통해 구조적으로 &lt;b&gt;ExportService를 어떻게 분리하고 관리해야 하는가&lt;/b&gt;에 대한 감각도 얻을 수 있었습니다.&lt;br&gt;&amp;nbsp;&lt;br&gt;이러한 경험은 SaaS 구조에서의 &lt;b&gt;데이터 추출 모듈 설계&lt;/b&gt;, 그리고 &lt;b&gt;비동기 처리/백오피스 자동화 설계 시나리오&lt;/b&gt;에도 충분히 적용될 수 있을 것입니다.&lt;/p&gt;</description>
      <category>개발/Saas 개발로그</category>
      <author>pandaterry</author>
      <guid isPermaLink="true">https://pandaterry.tistory.com/12</guid>
      <comments>https://pandaterry.tistory.com/entry/Saas%EA%B0%9C%EB%B0%9C%EB%A1%9C%EA%B7%B8-2GB-%EC%84%9C%EB%B2%84-%EB%A9%94%EB%AA%A8%EB%A6%AC%EB%A1%9C-1000%EB%A7%8C-%ED%96%89-Excel-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0#entry12comment</comments>
      <pubDate>Sat, 14 Jun 2025 13:41:58 +0900</pubDate>
    </item>
  </channel>
</rss>