<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ho-tea.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 28 Apr 2025 16:19:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ho-tea.log</title>
            <url>https://velog.velcdn.com/images/ho-tea/profile/bcfbf95c-2c52-4b69-824e-03655e21ba1f/image.JPG</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ho-tea.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ho-tea" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[성능 테스트를 통한 TPS 개선]]></title>
            <link>https://velog.io/@ho-tea/%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%ED%86%B5%ED%95%9C-TPS-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@ho-tea/%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%ED%86%B5%ED%95%9C-TPS-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Mon, 28 Apr 2025 16:19:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.</strong></p>
</blockquote>
<p>해당 글은 현재 운영 중인 서버가 트래픽 급증 시에도 안전하게 서비스를 제공할 수 있도록 인스턴스 단위의 성능 튜닝 및 최적화 과정에서 선택한 기술적 접근 방식을 설명합니다.</p>
<hr>
<h1 id="요약">[요약]</h1>
<p>현재 운영 중인 서버는 <code>Scale-Out</code>과 <code>Scale-Up</code> 방식에 한계가 존재합니다. </p>
<p>트래픽이 급증하는 상황에 대비하여 애플리케이션의 성능을 튜닝하여 안전성있는 서비스를 제공하는 것이 필수적이라고 판단했습니다. 따라서, <strong>인스턴스 한 대 기준</strong>으로 핵심기능이 안정적으로 서비스 될 수 있게 설정하는 과정을 거치게 되었습니다.</p>
<hr>
<h1 id="tomcat과-hikaricp의-설정값만을-바꾸면서-테스트를-진행한-이유">[Tomcat과 HikariCP의 설정값만을 바꾸면서 테스트를 진행한 이유]</h1>
<p><strong>성능 개선에서 무분별한 변경은 원인 분석을 어렵게 만든다고 생각했습니다.</strong></p>
<p>저희는 우선, 애플리케이션의 응답 지연이 <code>DB 커넥션 풀</code>과 <code>서버 쓰레드 처리량</code>에 기인할 수 있다는 가설을 세웠고, 이에 따라 <strong>Tomcat</strong>의 최대 스레드 수, <strong>HikariCP</strong>의 최소/최대 커넥션 수와 같은 <strong>I/O 병목 지점에 직접적인 영향을 주는 설정값부터 조정</strong>해 테스트를 진행했습니다.</p>
<p>정해진 일정에 차질이 가지 않도록 <strong>하나의 요인을 기준으로 우선 테스트를 진행하였고</strong>, 추가적인 성능 테스트를 위해 APM, 캐시 도입 등 추가적인 영역으로 테스트 범위를 확장하려는 계획을 갖고 있습니다. </p>
<hr>
<h1 id="과정">[과정]</h1>
<h2 id="테스트-시나리오-선정-및-ngrinder-setting">[테스트 시나리오 선정 및 Ngrinder Setting]</h2>
<blockquote>
<p>Ngrinder Setting -&gt; Vuser 1000 (process 1, thread 1000)
test 기간: 3m</p>
</blockquote>
<p>Firebase Analytics 을 확인해 보았을 때 조회 API를 호출하는 화면의 비중이 높았습니다.</p>
<p>그로인해 로그인, 추억 조회, 기록 조회 API 를 Think Time이 적용된 하나의 테스트 시나리오로 묶어 스크립트를 작성, 각 테스트 데이터는 100만건을 삽입한 후 Ngrinder를 통해 부하 테스트를 진행하였습니다.</p>
<p>또한 활성 사용자수와 현재 서비스 크기를 고려했을때, 약 1000명의 사용자가 요청을 보내는 상황으로 가정하였습니다.</p>
<blockquote>
<p><strong>Firebase Analytics</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/05707fce-6ec1-451e-bd55-d6dd3975cb0e/image.png" alt=""></p>
<hr>
<h2 id="초기-값default-value으로-성능-측정">[초기 값(Default Value)으로 성능 측정]</h2>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/1ec59dc0-a088-49e7-aad3-ba3215212394/image.png" alt=""> <strong>Default Value</strong>| <img src="https://velog.velcdn.com/images/ho-tea/post/43a51d92-d595-4203-9df9-f6d75f65ea70/image.png" alt=""> <strong>Default Value 성능 측정 결과</strong>
|---|---</p>
<p>팀 프로젝트에서 Spring Boot(3.3.1)를 사용하였기에 위와 같은 Default 값들이 설정되어있었습니다.</p>
<p>각 설정들을 하나씩 <strong><code>독립 변인</code></strong>으로 설정한 후, 다른 설정들은 <strong><code>통제 변인</code></strong>으로 설정, 그로 인해 도출되는 결과 TPS와 성공 응답 비율을 <strong><code>종속 변인</code></strong>으로 설정하여 테스트를 진행하였습니다.</p>
<h2 id="tomcat-max-connections">[Tomcat-Max-Connections]</h2>
<p>1000명 기준 평균 TPS가 30인 것에 비해 <code>Max Connections</code>이 과도하게 크다고 생각되었습니다.</p>
<p>따라서 연결할 수 있는 개수를 줄여 리소스 낭비를 줄이는 방식을 선택하였습니다. 대신, <code>Connection</code>을 얻지 못해서 테스트 실패율이 증가하였습니다.</p>
<p>테스트 실패율과 TPS를 두고 저울질 한다면, 테스트 실패율이 더 낮은 것이 중요하다고 생각해서 <code>Connection</code>의 개수를 2048개로 선택하게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/fa08118b-139e-463e-92e1-71e2e1be4c86/image.png" alt=""> <strong>[Max-Connection = 8192]</strong>| <img src="https://velog.velcdn.com/images/ho-tea/post/f9890d86-6050-43da-a71b-850aa6ba91bd/image.png" alt=""> <strong>[Max-Connection = 2048]</strong>
|---|---</p>
<hr>
<h2 id="성능-테스트-진행-중-nginx-worker-connection-error">[성능 테스트 진행 중 Nginx Worker Connection Error]</h2>
<p>부하 테스트를 진행하던 중 Nginx 로그를 확인해보니 아래와 같은 에러 로그가 기록되고 있었습니다.</p>
<blockquote>
<p><strong>에러 로그</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/347b17d6-c2c1-4c3f-b367-96d94ccee05f/image.png" alt=""></p>
<p>1000개의 사용자 요청이 일어났을 때 발생하는 이 에러 메시지는 Nginx에서 동시에 처리할 수 있는 연결 수(worker_connections)가 부족하다는 경고이며, 구체적으로 Nginx의 현재 설정으로는 768개의 연결을 처리할 수 있도록 되어 있는데, 더 많은 연결이 들어와서 이 한계를 초과하였기에 발생한 에러로 판단하였습니다.</p>
<p><strong>따라서 기존에 768개의 사용자 요청을 처리할 수 있게 설정된 값을 2048로 변경하였습니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/ba8e30e2-6cfb-4f0f-8cde-cca4feab0164/image.png" alt=""> <strong>worker_connections 설정 변경</strong>| <img src="https://velog.velcdn.com/images/ho-tea/post/a8780649-0263-459e-8e2b-da4bbfc7fbfc/image.png" alt=""> <strong>병목 현상 발생 지점</strong>
|---|---</p>
<p>위의 그림과 같이 병목 현상을 최대한 줄이는 방식으로 구성하기 위해 <code>WorkerConnection</code> ≥ <code>Max-Connection</code>로 설정을 하는 것이 합리적이라고 생각했습니다. 따라서 <code>Max-Connection</code>의 수와 같이 2048개의 동시 처리 커넥션 수를 가져가기로 결정하였습니다.</p>
<hr>
<h2 id="tomcat-accept-count">[Tomcat-Accept-Count]</h2>
<p>Max-Connections 이상의 연결 시도가 있을 시 요청 대기열 큐에 저장되는데, 요청 대기열 큐의 사이즈인 Accept-Count 보다도 연결 시도가 많아지면 연결을 거부하게 됩니다.</p>
<p>Max-Connections도 충분히 작은 값으로 설정했기 때문에 수정하지 않기로(100개) 결정했습니다.</p>
<hr>
<h2 id="tomcat-max-thread">[Tomcat-Max-Thread]</h2>
<p><strong>Max-Thread</strong>의 크기가 크면 <code>Context Switching</code> 비용이 많이 발생하므로,</p>
<p>값을 줄이는 방향으로 테스트를 수행하였을 때 30일 때의 TPS가 가장 높게 측정되었습니다.</p>
<p><strong>Max-Thread</strong>의 수가 감소함에 따라 동일한 테스트 구간에서 TPS가 상승한 모습을 볼 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/6b4ad72f-c453-4e30-a83d-8f0582419f37/image.png" alt=""> | <img src="https://velog.velcdn.com/images/ho-tea/post/056a0fe9-162d-4bc0-8cdf-b92a751cb2aa/image.png" alt=""> | <img src="https://velog.velcdn.com/images/ho-tea/post/5b18ec8d-1bb6-43f5-b49e-0424038749db/image.png" alt="">
|---|---|---</p>
<hr>
<h2 id="tomcat-min-spare">[Tomcat-Min-Spare]</h2>
<p>항상 있는 최소한의 스레드 수로 너무 낮게 설정하면, 커넥션이 즉시 사용 가능한 상태가 아니어서 추가 요청 시 지연이 발생할 수 있고, 너무 높게 설정하면 불필요한 커넥션 유지로 자원 낭비가 발생할 수 있다고 판단되었습니다.</p>
<p>쓰레드 개수 10개 혹은 20개가 메모리 부하에 큰 영향을 미칠지, 성능에 큰 영향을 줄지 고민되어 값을 변경하면서 테스트를 해보았지만 큰 영향을 끼치지 않는다고 판단하여 수정하지 않기로(10개) 결정했습니다.</p>
<hr>
<h2 id="hikaricpmaximum-pool-size--hikaricpminimum-idle">[HikariCP.maximum-pool-size] &amp; [HikariCP.minimum-idle]</h2>
<p><strong>maximum-pool-size 와 minimum-idle을 동일하게가져가는 것이 권장사항</strong>이며, minimum-idle을 pool size보다 작게 가져갈 시 <strong>Connection을 생성하고 삭제하는 비용</strong>이 발생하므로, 동일하게 가져가는 방식을 선택하였습니다. </p>
<p><strong>maximum-pool-size</strong>을 기존 10보다 더 적게(5개) 혹은 더 많게(15개) 구성하였을 때, TPS와 성공응답 비율의 차이가 크지 않았다고 판단하여 <code>Default</code> 값 10으로 유지하였습니다.</p>
<p><a href="https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing">[🔗 HikariCP 공식 문서 참고]</a></p>
<hr>
<h2 id="hikaricpconnection-timeout">[HikariCP.connection-timeout]</h2>
<p>HikariCP의 <strong>connection-timeout</strong>이 클라이언트 측으로부터의 <strong>요청 timeout</strong>보다 짧아야 하기에 8초로 설정하였습니다.</p>
<p><strong>사용자가 10초 이상 기다리는 행동을 취하는 것 보다, 10초 내에 Connection이 맺어지지 않으면 빠른 실패를 한 후 재시도하게끔 구성하는 것이 사용자 경험 측면에서 이점이 있다고 생각되었습니다.</strong></p>
<p>따라서 클라이언트 측 timeout은 10초로 설정하게 되었고, 만약 HikariCP의 connection-timeout이 클라이언트 측보다 길게 된다면 서버에서 디비로 조회를 완료한 후 반환하려 할 때, 응답을 돌려주지 못하는 문제가 발생할 수 있기에 <strong>HikariCP의 connection-timeout은 클라이언트 측보다 짧게 가져간 8초로 설정하였습니다.</strong></p>
<hr>
<h1 id="tps-개선-결과">[TPS 개선 결과]</h1>
<blockquote>
<p><strong>TPS <code>30.6</code> → <code>44.3</code>으로 약 44%개선</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/eea702ca-5590-4bd8-9fb1-322aeba54f12/image.png" alt=""></p>
<p>이 과정은 시스템 리소스를 효율적으로 활용하고 병목 지점을 최소화하여 애플리케이션의 처리 성능과 응답 속도를 크게 향상시킬 수 있는 중요한 작업이라고 생각됩니다. 서비스의 특성, 성격에 맞춰 애플리케이션을 튜닝하며 최적의 값을 찾아가는 과정은 서비스에 대한 깊은 이해를 쌓는 것에 큰 도움이 되었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TransactionManager가 일하는 순서]]></title>
            <link>https://velog.io/@ho-tea/Spring-Transaction%EC%9D%98-2%EA%B0%80%EC%A7%80-%ED%8A%B9%EC%84%B1%EA%B3%BC-TransactionManager</link>
            <guid>https://velog.io/@ho-tea/Spring-Transaction%EC%9D%98-2%EA%B0%80%EC%A7%80-%ED%8A%B9%EC%84%B1%EA%B3%BC-TransactionManager</guid>
            <pubDate>Tue, 22 Apr 2025 20:46:10 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h3 id="오늘의-목표">오늘의 목표</h3>
<p>스프링에서의 트랜잭션 추상화와 동작 순서 그리고 트랜잭션 매니저 종류에 대해 알아보겠습니다.</p>
</blockquote>
<p><a href="https://velog.io/@ho-tea/TransactionAutoConfiguration-%EA%B7%B8-%ED%9B%84%EB%A1%9C">TransactionAutoConfiguration 그 후로...</a>를 통해 최종적으로 아래와 같은 <code>flow</code>로 <code>PlatformTransactionManager</code>이 트랜잭션을 시작한다는 것을 알 수 있었습니다.</p>
<pre><code class="language-java">... 
    ↓
MyService.method() 호출 → 프록시의 invoke()
    ↓
TransactionInterceptor → invokeWithinTransaction()
    ↓
PlatformTransactionManager → getTransaction() / commit() / rollback()
</code></pre>
<p><a href="https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8">자동구성..근데-이제-Data-JPA를-곁들인</a> 을 통해 <code>JPA</code>를 사용하게 되었을 때는 <code>JpaTransactionManager</code>가 등록된다는 것을 알 수 있었는데요. 
마지막에 <code>JpaTransactionManager</code>가 아닌 <code>PlatformTransactionManager</code> 을 통해 <code>transaction</code>을 실행한다는 것이 조금은 어색하게 다가옵니다.</p>
<blockquote>
<p><strong>JpaTransactionManager</strong></p>
</blockquote>
<ul>
<li><code>JPA</code> 기반의 <code>DataSource</code>를 사용하는 경우에 트랜잭션을 관리해주는 스프링의 트랜잭션 매니저 구현체
<img src="https://velog.velcdn.com/images/ho-tea/post/eacecb83-519d-4032-8e33-05d9c53269e9/image.png" alt=""></li>
</ul>
<blockquote>
<p><strong>DataSourceTransactionManager</strong></p>
</blockquote>
<ul>
<li><code>JDBC</code> 기반의 <code>DataSource</code>를 사용하는 경우에 트랜잭션을 관리해주는 스프링의 트랜잭션 매니저 구현체
<img src="https://velog.velcdn.com/images/ho-tea/post/da0a5e10-9d4d-4aa4-801a-a00b159530ce/image.png" alt=""></li>
</ul>
<blockquote>
<p><strong>AbstractPlatformTransactionManager</strong>
<img src="https://velog.velcdn.com/images/ho-tea/post/b2efaf4a-63cf-40a6-9617-748a29dfdda6/image.png" alt=""></p>
</blockquote>
<p><code>JpaTransactionManager</code> 코드를 살펴보면 그 이유를 알 수 있는데요. <code>JpaTransactionManager</code>은 <code>AbstractPlatformTransactionManager</code>을 상속하고 <code>AbstractPlatformTransactionManager</code>는 <code>PlatformTransactionManager</code>를 확장한 형태인 것을 확인할 수 있습니다.</p>
<p><strong>이를 통해 <code>PlatformTransactionManager</code>는 <code>JpaTransactionManager</code>을 추상화한 인터페이스임을 알 수 있습니다.</strong></p>
<p>이번 글에서는 스프링이 제공하는 이러한 <strong>트랜잭션 추상화 기능</strong>과 <strong>리소스 동기화</strong>에 대해 살펴보겠습니다.</p>
<ul>
<li><strong>트랜잭션 추상화</strong></li>
<li><strong>리소스 동기화</strong> -&gt; 다음글에서 다룰 예정</li>
</ul>
<h2 id="트랜잭션-추상화">트랜잭션 추상화</h2>
<blockquote>
<p>트랜잭션을 시작하고, 커밋하거나 롤백하는 트랜잭션 제어 책임 객체 -&gt; <code>Transaction Manager</code> (=<code>DataSourceTransactionManager</code>, <code>JpaTransactionManager</code> 등)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/4487f97d-922c-4ec5-bc3e-5776558af9f3/image.png" alt=""></p>
<p>구글에 <code>TransactionManager</code>를 검색하게 된다면 관련 검색어로 <code>PlatformTransactionManager</code>이 먼저 나오는 것을 알 수 있습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/cc4723cc-0db4-4dad-b034-b968fe91b1db/image.png" alt=""></p>
<h3 id="platformtransactionmanager">PlatformTransactionManager</h3>
<p> 스프링은 애플리케이션에서 트랜잭션을 보다 쉽게 관리할 수 있도록 하기 위해, 다양한 데이터 접근 기술(JDBC, JPA 등)에 종속되지 않는 트랜잭션 추상화를 제공합니다. 이 추상화의 핵심 인터페이스가 바로 <code>PlatformTransactionManager</code>입니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/a9fd8e8b-1e37-40cb-9dd6-a709b26a0d2c/image.png" alt=""></p>
<h3 id="왜-트랜잭션-추상화를-할까">왜 트랜잭션 추상화를 할까?</h3>
<p>초기에는 <code>JDBC</code>를 사용해 직접 트랜잭션을 제어했다가 나중에 <code>JPA</code>를 도입하는 경우, 서비스 계층의 코드가 데이터 접근 기술에 맞춰 수정되어야 하는 문제를 겪게 됩니다. 하지만 스프링의 <code>PlatformTransactionManager</code>를 사용하면, 해당 기술별 구현체(<code>DataSourceTransactionManager</code> or <code>JpaTransactionManager</code> 등)를 선택하여 적용할 수 있으므로, 서비스 계층에서는 동일한 트랜잭션 관리 코드를 사용할 수 있습니다. 
<strong>즉, 데이터 접근 기술이 변경되더라도 애플리케이션 코드의 변경 없이 트랜잭션 관리를 유지할 수 있게 됩니다.</strong></p>
<blockquote>
<p><strong>JpaTransactionManager 등록</strong>  - <code>JPA</code> <img src="https://velog.velcdn.com/images/ho-tea/post/36c05e64-af98-48d1-8a3d-c76998642388/image.png" alt=""> <strong>DataSourceTransactionManager 등록</strong> - <code>JDBC</code>
<img src="https://velog.velcdn.com/images/ho-tea/post/c4349e81-a07c-485c-896b-3351700051c1/image.png" alt=""><code>transactionManager()</code> 메서드에 동일하게 <code>@ConditionalOnMissingBean(TransactionManager.class)</code>가 존재합니다.
이건 <code>PlatformTransactionManager</code> 타입 빈이 존재하지 않을 때만 등록하라는 뜻으로 내부적으로 <code>TransactionManager.class = PlatformTransactionManager.class</code> 라고 생각해도 무방합니다.</p>
</blockquote>
<h3 id="내-프로젝트에는-어떤-transactionmanager가-일을하고-있을까">내 프로젝트에는 어떤 TransactionManager가 일을하고 있을까?</h3>
<h4 id="data-jdbc일-경우---jdbctransactionmanager">Data-JDBC일 경우 -&gt; JdbcTransactionManager</h4>
<ul>
<li><code>implementation &#39;org.springframework.boot:spring-boot-starter-data-jdbc&#39;</code>
<img src="https://velog.velcdn.com/images/ho-tea/post/8ffc2547-a104-4a72-9171-7f4daa98d552/image.png" alt="">
현재 <code>JdbcTransactionManager</code>가 <code>TransactionManager</code>의 구현체로 등록이 되어있는데 <code>JdbcTransactionManager</code>는 <code>Spring 6.0</code> 부터 <code>DataSourceTransactionManager</code>를 확장하여 거의 모든 기능을 그대로 사용하면서 이름을 더 직관적으로 바꾼 것이라고 보면 됩니다.<blockquote>
<ul>
<li><code>JdbcTransactionManager</code>
<img src="https://velog.velcdn.com/images/ho-tea/post/0bc472d6-e680-4532-b378-69204290aff0/image.png" alt=""></li>
</ul>
</blockquote>
</li>
</ul>
<h4 id="data-jpa일-경우---jpatransactionmanager">Data-JPA일 경우 -&gt; JpaTransactionManager</h4>
<ul>
<li><code>implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;</code>
<img src="https://velog.velcdn.com/images/ho-tea/post/3f6fdc78-696a-4901-8510-b55e7ee02914/image.png" alt=""></li>
</ul>
<h3 id="jdbc-vs-data-jdbc-vs-jpa-vs-data-jpa">JDBC vs Data JDBC vs JPA vs Data JPA</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>JDBC</th>
<th>Spring Data JDBC</th>
<th>JPA (with Hibernate)</th>
<th>Spring Data JPA</th>
</tr>
</thead>
<tbody><tr>
<td><strong>핵심 목표</strong></td>
<td>DB와 직접 통신</td>
<td>간단한 CRUD 자동화</td>
<td>ORM 기반 객체-DB 매핑</td>
<td>JPA 위에 추상화된 CRUD + 쿼리 자동화</td>
</tr>
<tr>
<td><strong>추상화 수준</strong></td>
<td>가장 낮음 (SQL 직접 작성)</td>
<td>중간 (도메인 매핑 + SQL 일부 자동화)</td>
<td>높은 수준의 ORM</td>
<td>ORM + 자동 Repository 구현</td>
</tr>
<tr>
<td><strong>사용 방식</strong></td>
<td><code>Connection</code>, <code>Statement</code>, <code>ResultSet</code> 또는 <code>JdbcTemplate</code> 사용</td>
<td><code>JdbcTemplate</code> 기반 + Spring Data 철학에 따라 Repository 자동 생성 <br>➡️ <em>Repository는 인터페이스로서, DAO(데이터 접근 객체) 역할을 대신함</em></td>
<td><code>EntityManager</code>를 사용해 엔티티 관리, 연관관계 매핑도 포함</td>
<td><code>JpaRepository</code> 상속만으로 CRUD 구현, <code>@Query</code>로 복잡 쿼리도 가능</td>
</tr>
<tr>
<td><strong>연관관계 매핑</strong></td>
<td>직접 <code>JOIN</code> SQL 작성</td>
<td>제한적 (1:1 정도만, 깊은 연관 매핑은 불가능)</td>
<td><code>@Entity</code>, <code>@OneToMany</code>, <code>@ManyToOne</code> 등을 통해 객체 간 연관관계 선언</td>
<td>JPA의 모든 매핑 기능 지원, 연관관계 탐색과 자동 조회 가능</td>
</tr>
<tr>
<td><strong>복잡 쿼리 처리</strong></td>
<td>SQL 직접 작성</td>
<td>SQL 기반 <code>@Query</code> 작성</td>
<td>JPQL (객체 지향 쿼리 언어), Criteria API</td>
<td>JPQL, <code>@Query</code>, QueryDSL, Specification 등 다양한 방식 지원</td>
</tr>
<tr>
<td><strong>학습 난이도</strong></td>
<td>낮음 (SQL 익숙하면 쉽게 시작)</td>
<td>중간 (Spring과 도메인 중심 모델 이해 필요)</td>
<td>높음 (ORM 개념, 연관관계, 영속성 컨텍스트 이해 필요)</td>
<td>중간 (Spring Data의 추상화 개념만 익히면 활용 쉬움)</td>
</tr>
<tr>
<td><strong>주요 구현체</strong></td>
<td>JDBC 표준 (Oracle, MySQL, H2 등 DB 드라이버)</td>
<td>Spring Data JDBC 모듈</td>
<td>Hibernate, EclipseLink, TopLink 등</td>
<td>Hibernate + Spring Data JPA 조합</td>
</tr>
</tbody></table>
<h3 id="트랜잭션-매니저-동작-방식">트랜잭션 매니저 동작 방식</h3>
<p>데이터 접근 기술이 변경되더라도 애플리케이션 코드의 변경 없이 트랜잭션 관리를 유지할 수 있게끔 <code>PlatformTransactionManager</code>이 사용된다는 것을 알 수 있었습니다. 이제는 <code>PlatformTransactionManager</code>의 구현체인 <code>JpaTrnasactionManager</code>을 사용하였을때 어떠한 방식으로 메서드 호출이 일어나는지 알아보겠습니다.</p>
<pre><code class="language-java">... 
    ↓
MyService.method() 호출 → 프록시의 invoke()
    ↓
TransactionInterceptor → invokeWithinTransaction()
    ↓
PlatformTransactionManager → getTransaction() / commit() / rollback()
</code></pre>
<ol>
<li><p>우선 <code>PlatformTransactionManager</code>의 추상메서드 <code>getTransaction()</code> 이 시작되면
<img src="https://velog.velcdn.com/images/ho-tea/post/3ea441ec-7fe7-490f-8bad-4cbe1b0a88c3/image.png" alt=""></p>
</li>
<li><p>해당 메서드를 정의한 <code>AbstractPlatformTransactionManager</code>의 <code>getTransaction()</code>이 호출되게 됩니다. 
<img src="https://velog.velcdn.com/images/ho-tea/post/3862cf79-c82f-4493-ab41-5153f47fd382/image.png" alt="">
내부를 자세히 살펴보면 아래의 2가지 메서드 호출이 존재합니다.
```java</p>
</li>
<li><p>Object transaction = this.doGetTransaction();</p>
</li>
<li><p>return this.handleExistingTransaction(def, transaction, debugEnabled);</p>
<pre><code>#### `this.doGetTransaction();`
![](https://velog.velcdn.com/images/ho-tea/post/ec460e89-2de9-4b43-82ed-c21469c63f35/image.png)
``` java

 protected Object doGetTransaction() {
     JpaTransactionObject txObject = new JpaTransactionObject();
     txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
     EntityManagerHolder emHolder = (EntityManagerHolder)TransactionSynchronizationManager.getResource(this.obtainEntityManagerFactory());
     if (emHolder != null) {
         if (this.logger.isDebugEnabled()) {
             this.logger.debug(&quot;Found thread-bound EntityManager [&quot; + emHolder.getEntityManager() + &quot;] for JPA transaction&quot;);
         }

         txObject.setEntityManagerHolder(emHolder, false);
     }

     if (this.getDataSource() != null) {
         ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(this.getDataSource());
         txObject.setConnectionHolder(conHolder);
     }

     return txObject;
 }</code></pre></li>
</ol>
<ul>
<li><code>doGetTransaction()</code>은 추상화 메서드로 현재 쓰레드에서 이미 시작된 트랜잭션이 있는지 확인하고, 트랜잭션 객체(여기서는 <code>JpaTransactionObject</code>)를 생성해 반환합니다.</li>
<li>이 메서드는 트랜잭션의 시작 전 준비단계로서, 이미 쓰레드에 바인딩된 <code>EntityManager</code>나 <code>Connection</code>이 있으면 그것을 활용하고 없으면 이후 <code>doBegin()</code> 단계에서 새로 트랜잭션을 시작하도록 도와주는 역할을 합니다.</li>
</ul>
<h4 id="thishandleexistingtransactiondef-transaction-debugenabled"><code>this.handleExistingTransaction(def, transaction, debugEnabled);</code></h4>
<ul>
<li>이 메서드는 현재 쓰레드에 이미 존재하는 트랜잭션이 있을 때의 처리 흐름을 담당합니다. 즉, 전파 속성(<code>Propagation</code>)에 따라 트랜잭션을 어떻게 이어받거나 새로 만들지를 결정하는 중심 메서드입니다.</li>
<li>해당 메서드를 통해 <code>startTransaction()</code> 이 호출되는데, <code>handleExistingTransaction()</code> 자체는 조건에 따라 <code>startTransaction()</code>을 호출하지 않기도 합니다.</li>
<li>전파 속성에 따라 기존 트랜잭션에 참여하게 된다면 <code>prepareTransactionStatus()</code> 을 호출하게 됩니다.</li>
</ul>
<blockquote>
<p>여기서는 새롭게 트랜잭션을 만든다고 가정하고 진행하겠습니다. -&gt; <code>startTransaction()</code></p>
</blockquote>
<ol start="3">
<li><p><code>AbstractPlatformTransactionManager.handleExistingTransaction()</code> -&gt; <code>AbstractPlatformTransactionManager.startTransaction()</code> -&gt; <code>구현체.doBegin()</code></p>
<pre><code class="language-java"> private TransactionStatus startTransaction(TransactionDefinition definition, Object transaction, boolean nested, boolean debugEnabled, @Nullable SuspendedResourcesHolder suspendedResources) {
     boolean newSynchronization = this.getTransactionSynchronization() != 2;
     DefaultTransactionStatus status = this.newTransactionStatus(definition, transaction, true, newSynchronization, nested, debugEnabled, suspendedResources);
     this.transactionExecutionListeners.forEach((listener) -&gt; {
         listener.beforeBegin(status);
     });

     try {
         this.doBegin(transaction, definition);
     } catch (Error | RuntimeException var9) {
         Throwable ex = var9;
         this.transactionExecutionListeners.forEach((listener) -&gt; {
             listener.afterBegin(status, ex);
         });
         throw ex;
     }

     this.prepareSynchronization(status, definition);
     this.transactionExecutionListeners.forEach((listener) -&gt; {
         listener.afterBegin(status, (Throwable)null);
     });
     return status;
 }

</code></pre>
</li>
</ol>
<pre><code>protected void prepareSynchronization(DefaultTransactionStatus status, TransactionDefinition definition) {
    if (status.isNewSynchronization()) {
        TransactionSynchronizationManager.setActualTransactionActive(status.hasTransaction());
        TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(definition.getIsolationLevel() != -1 ? definition.getIsolationLevel() : null);
        TransactionSynchronizationManager.setCurrentTransactionReadOnly(definition.isReadOnly());
        TransactionSynchronizationManager.setCurrentTransactionName(definition.getName());
        TransactionSynchronizationManager.initSynchronization();
    }

}

```</code></pre><p><code>startTransaction()</code> 에서 주의깊게 보아야 할 단계는 크게 3가지입니다. </p>
<table>
<thead>
<tr>
<th>단계</th>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>1번째. 트랜잭션 상태 객체 생성</td>
<td>상태 저장</td>
<td><code>DefaultTransactionStatus</code> 객체를 생성하여, 현재 트랜잭션이 새 트랜잭션인지, 중첩 트랜잭션인지 등의 상태 정보를 저장합니다. 이 객체는 이후 커밋/롤백 판단 시 사용됩니다.</td>
</tr>
<tr>
<td>2번째. 실제 트랜잭션 시작</td>
<td>트랜잭션 열기</td>
<td><code>JpaTransactionManager#doBegin()</code> 메서드에서 <code>EntityManager.getTransaction().begin()</code> 을 호출하여 JPA 트랜잭션을 시작합니다. 이때 <code>EntityManager</code>는 트랜잭션 동기화 매니저에 바인딩됩니다.</td>
</tr>
<tr>
<td>3번째. 동기화 설정</td>
<td>컨텍스트 바인딩</td>
<td><code>TransactionSynchronizationManager</code>를 통해 현재 쓰레드에 트랜잭션 이름, 읽기 전용 여부, 격리 수준 등을 바인딩합니다. 이후 동작하는 DAO나 Repository는 이 정보를 참조하여 동일 트랜잭션에 참여할 수 있습니다.</td>
</tr>
</tbody></table>
<ul>
<li><p><code>1번째</code>에서 <code>DefaultTransactionStatus</code>는 트랜잭션의 현재 상태(신규 여부, 읽기 전용 여부, 저장점 보유 여부 등)를 표현하고 관리하는 객체입니다.<img src="https://velog.velcdn.com/images/ho-tea/post/fcf0f86d-1010-4c42-8db0-80fe8272f8a3/image.png" alt=""></p>
</li>
<li><p><code>3번째</code>에서 <code>TransactionSynchronizationManager</code>를 통해 현재 쓰레드에 트랜잭션 <code>정보</code>를 바인딩하게 되는데, <strong>이때 주의할 점은 <code>Connection</code>을 현재 쓰레드에 바인딩하는 작업은 <code>startTransaction()</code>이 아니라 <code>2번째</code> <code>doBegin()</code> 내부에서 수행된다는 것입니다.</strong></p>
</li>
</ul>
<p><code>startTransaction()</code> 은 구현체의 <code>doBegin()</code>을 호출합니다.</p>
<ol start="4">
<li><p><code>JpaTransactionManager.doBegin()</code></p>
<pre><code class="language-java">
 protected void doBegin(Object transaction, TransactionDefinition definition) {
     JpaTransactionObject txObject = (JpaTransactionObject)transaction;
     if (txObject.hasConnectionHolder() &amp;&amp; !txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
         throw new IllegalTransactionStateException(&quot;Pre-bound JDBC Connection found! JpaTransactionManager does not support running within DataSourceTransactionManager if told to manage the DataSource itself. It is recommended to use a single JpaTransactionManager for all transactions on a single DataSource, no matter whether JPA or JDBC access.&quot;);
     } else {
         try {
             EntityManager em;
             if (!txObject.hasEntityManagerHolder() || txObject.getEntityManagerHolder().isSynchronizedWithTransaction()) {
                 em = this.createEntityManagerForTransaction();
                 if (this.logger.isDebugEnabled()) {
                     this.logger.debug(&quot;Opened new EntityManager [&quot; + em + &quot;] for JPA transaction&quot;);
                 }

                 txObject.setEntityManagerHolder(new EntityManagerHolder(em), true);
             }

             em = txObject.getEntityManagerHolder().getEntityManager();
             int timeoutToUse = this.determineTimeout(definition);
             Object transactionData = this.getJpaDialect().beginTransaction(em, new JpaTransactionDefinition(definition, timeoutToUse, txObject.isNewEntityManagerHolder()));
             txObject.setTransactionData(transactionData);
             txObject.setReadOnly(definition.isReadOnly());
             if (timeoutToUse != -1) {
                 txObject.getEntityManagerHolder().setTimeoutInSeconds(timeoutToUse);
             }

             if (this.getDataSource() != null) {
                 ConnectionHandle conHandle = this.getJpaDialect().getJdbcConnection(em, definition.isReadOnly());
                 if (conHandle != null) {
                     ConnectionHolder conHolder = new ConnectionHolder(conHandle);
                     if (timeoutToUse != -1) {
                         conHolder.setTimeoutInSeconds(timeoutToUse);
                     }

                     if (this.logger.isDebugEnabled()) {
                         this.logger.debug(&quot;Exposing JPA transaction as JDBC [&quot; + conHandle + &quot;]&quot;);
                     }

                     TransactionSynchronizationManager.bindResource(this.getDataSource(), conHolder);
                     txObject.setConnectionHolder(conHolder);
                 } else if (this.logger.isDebugEnabled()) {
                     this.logger.debug(&quot;Not exposing JPA transaction [&quot; + em + &quot;] as JDBC transaction because JpaDialect [&quot; + this.getJpaDialect() + &quot;] does not support JDBC Connection retrieval&quot;);
                 }
             }

             if (txObject.isNewEntityManagerHolder()) {
                 TransactionSynchronizationManager.bindResource(this.obtainEntityManagerFactory(), txObject.getEntityManagerHolder());
             }

             txObject.getEntityManagerHolder().setSynchronizedWithTransaction(true);
         } catch (TransactionException var9) {
             TransactionException ex = var9;
             this.closeEntityManagerAfterFailedBegin(txObject);
             throw ex;
         } catch (Throwable var10) {
             Throwable ex = var10;
             this.closeEntityManagerAfterFailedBegin(txObject);
             throw new CannotCreateTransactionException(&quot;Could not open JPA EntityManager for transaction&quot;, ex);
         }
     }
 }</code></pre>
<p>해당 <code>doBegin()</code> 메서드는 <code>JpaTransactionManager</code>에서 <code>JPA</code> 트랜잭션을 실제로 시작하는 로직의 핵심입니다.
이 코드 하나로 트랜잭션의 연결, 동기화, 커넥션 노출까지 모두 처리되기 때문에, 아주 중요한 코드입니다.
하나씩 살펴보겠습니다.</p>
</li>
</ol>
<blockquote>
<ol>
<li><code>JDBC 커넥션 충돌 검사</code></li>
</ol>
</blockquote>
<pre><code class="language-java">    if (txObject.hasConnectionHolder() &amp;&amp; !txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
        throw new IllegalTransactionStateException(...);
    }</code></pre>
<p><code>Spring</code>에서 <code>JPA</code>와 <code>JDBC</code>를 동시에 같은 <code>DataSource</code>에서 사용하려 할 때 충돌이 발생할 수 있습니다
→ <code>JpaTransactionManager</code>를 쓰는 경우엔 <code>DataSourceTransactionManager</code>를 병행하면 안 됨</p>
<blockquote>
<ol start="2">
<li><code>EntityManager</code> 생성 또는 재사용</li>
</ol>
</blockquote>
<pre><code class="language-java">if (!txObject.hasEntityManagerHolder() || ...) {
    em = this.createEntityManagerForTransaction();
    txObject.setEntityManagerHolder(new EntityManagerHolder(em), true);
}</code></pre>
<p>없거나 재사용하면 안 되는 상황이면 새로 만들게 됩니다. 
<code>EntityManager</code>는 실제로 <code>JPA</code> 트랜잭션을 제어할 핵심 객체로, <code>true</code>는 이 <code>EntityManager</code>가 새로 만들어졌다는 표시입니다.</p>
<blockquote>
<ol start="3">
<li><code>리소스 바인딩</code></li>
</ol>
</blockquote>
<pre><code class="language-java">TransactionSynchronizationManager.bindResource(this.obtainEntityManagerFactory(), txObject.getEntityManagerHolder());</code></pre>
<p>트랜잭션 범위 안에서 동작하는 <code>DAO</code>나 <code>Repository</code>들이 <code>EntityManager</code>나 <code>Connection</code>을 자동으로 참조할 수 있도록 현재 쓰레드에 리소스를 등록합니다
<strong>주의 포인트: TransactionSynchronizationManager는 쓰레드 로컬 기반 → 반드시 커밋/롤백 이후 unbind 필요 (Spring이 자동 정리)</strong></p>
<blockquote>
<ol start="4">
<li><code>트랜잭션 동기화 완료 표시</code></li>
</ol>
</blockquote>
<pre><code class="language-java">txObject.getEntityManagerHolder().setSynchronizedWithTransaction(true);</code></pre>
<p>이 <code>EntityManager</code>는 현재 트랜잭션과 연결돼 있음을 명시합니다.</p>
<p>마지막 <code>doBegin()</code>을 호출하는 것으로 실제 트랜잭션이 시작되며 전체 흐름을 요약하면 아래와 같습니다.</p>
<h3 id="🔁-전체-흐름-요약-transactional-적용-후의-트랜잭션-처리-사이클">🔁 전체 흐름 요약: <code>@Transactional</code> 적용 후의 트랜잭션 처리 사이클</h3>
<pre><code class="language-text">@Transactional 메서드 호출
   ↓
AOP 프록시: TransactionInterceptor.invoke()
   ↓
invokeWithinTransaction() → 트랜잭션 전처리
   ↓
createTransactionIfNecessary()
   ↓
→ PlatformTransactionManager.getTransaction()
   ↓
→ AbstractPlatformTransactionManager.getTransaction()
   ↓
→ doGetTransaction() + handleExistingTransaction()
   ↓
→ startTransaction()
   ↓
→ ✅ doBegin() ← 실제 트랜잭션 시작
   ↓
비즈니스 로직 실행 (invocation.proceedWithInvocation())
   ↓
정상 종료 → commitTransactionAfterReturning()
         → PlatformTransactionManager.commit()
         → AbstractPlatformTransactionManager.commit()
         → doCommit() ← 진짜 커밋
OR
예외 발생 → completeTransactionAfterThrowing()
         → PlatformTransactionManager.rollback()
         → AbstractPlatformTransactionManager.rollback()
         → doRollback() ← 진짜 롤백
   ↓
TransactionInfo cleanup → ThreadLocal 해제</code></pre>
<h3 id="마무리하며">마무리하며</h3>
<p>지금까지 <code>@Transactional</code>이 동작하는 내부 구조를 따라가며, 트랜잭션이 어떻게 시작되고, 어떤 과정을 거쳐 커밋 또는 롤백되며, 마지막에 어떻게 정리되는지까지의 전체 흐름을 살펴보았습니다.</p>
<p>정리하자면, <code>PlatformTransactionManager</code>라는 추상화 덕분에 데이터 접근 기술이 변경되더라도 애플리케이션 코드를 수정하지 않고도 동일한 방식으로 트랜잭션을 관리할 수 있다는 점을 확인할 수 있었습니다.
또한 실제 코드를 따라가면서 트랜잭션 시작부터 종료까지 어떤 메서드가 어떤 순서로 호출되는지도 명확히 이해할 수 있었습니다.</p>
<p>다음 글에서는 이번 글에서 다루지 못한 리소스 동기화를 담당하는 <code>TransactionSynchronizationManager</code>의 역할과
트랜잭션 종료 시 실행되는 후처리 작업들에 대해 이어서 정리해보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[BulkInsert와 No JpaRepository]]></title>
            <link>https://velog.io/@ho-tea/BulkInsert%EC%99%80-No-JpaRepository</link>
            <guid>https://velog.io/@ho-tea/BulkInsert%EC%99%80-No-JpaRepository</guid>
            <pubDate>Fri, 18 Apr 2025 11:46:23 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.</strong></p>
</blockquote>
<p>해당 글은 <code>BulkInsert</code>를 적용하면서 발생한 문제를 해결하기 위해 진행한 기술적 접근 방식을 설명합니다.</p>
<hr>
<h1 id="요약">[요약]</h1>
<p><code>BulkDelete</code>를 통해 삭제 쿼리 수는 줄였으나, 부모 엔티티가 자식 엔티티의 생성 생명주기를 담당함에 따라 데이터 행 수 만큼 <code>Insert</code> 쿼리가 발생하는 문제가 있었습니다. 이를 해결하고자 <code>JdbcTemplate</code>을 활용한 <code>BulkInsert</code>를 도입하였으며, 추가 <code>Repository</code> 구현으로 인한 <strong>Service 로직 변경 전파 문제를 새로운 인터페이스 상속 방식을 통해 개선하며, 불필요한 기능 사용을 제한하였습니다.</strong> </p>
<p><strong>이를 통해 의도치 않은 사용으로 인한 잠재적 문제를 효과적으로 방지할 수 있었습니다.</strong></p>
<hr>
<h1 id="문제-상황">[문제 상황]</h1>
<p><strong>BulkDelete를 적용하면서 부모 엔티티가 자식 엔티티의 삭제 생명주기를 더이상 관리하지 않게 되었습니다.</strong>
<img src="https://velog.velcdn.com/images/ho-tea/post/33e79bbb-5b70-4e7f-a0d7-a59da4377a07/image.png" alt="">
하지만, 여전히 부모 엔티티가 자식 엔티티의 <strong>생성 생명주기</strong>를 담당하고 있었기에 아래와 같이 저장되는 <code>Data Row</code> 갯수 만큼 <code>Insert Query</code>가 나가는 문제가 존재했습니다.</p>
<h1 id="문제-상황-분석">[문제 상황 분석]</h1>
<p>이를 해결하고자, 여러 Entity를 저장할 때 <code>saveAll()</code> 메서드를 사용하였습니다.</p>
<blockquote>
<p><strong>SimpleJpaRepository.saveAll()</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/fdd0de37-92f5-416d-841f-29b7ee37f79a/image.png" alt=""></p>
<p><code>saveAll</code>은 내부적으로 각 엔티티마다 <code>save</code> 메서드를 호출하여 개별적으로 처리합니다.</p>
<p>메서드 레벨에 트랜잭션이 설정되어 있기 때문에, saveAll을 실행하는 동안 동일한 트랜잭션 내에서 모든 작업이 수행됩니다. <strong>이로 인해 save 메서드를 반복적으로 호출하는 것보다 효율적일 수 있었습니다.</strong></p>
<p>하지만, 대용량 데이터를 처리할 때는 여전히 각 엔티티마다 <code>개별적인 Query</code>가 실행되기 때문에 매번 네트워크를 타고 데이터베이스 I/O가 증가하기 때문에 성능 저하가 발생할 수 있습니다.</p>
<p><strong>즉, <code>saveAll()</code>을 통해서는 문제를 해결할 수 없었습니다.</strong></p>
<hr>
<h1 id="✅-해결-방안---bulkinsert-도입">✅ [해결 방안 - BulkInsert 도입]</h1>
<p><code>BulkDelete</code>와 같이 JPQL 쿼리를 통해 문제를 풀어나가려고 했지만, 기본키 생성 전략 중 <code>IDENTITY</code> 전략의 경우 <strong>Hibernate는 insert batching을 지원하지 않는 상태</strong>였습니다.</p>
<blockquote>
<p><strong>Hibernate 공식문서</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/967d92cb-b018-4e9f-8b3b-1e622e99d7aa/image.png" alt=""></p>
<p>영속성 컨텍스트 내부에서는 엔티티를 식별할때 <strong>엔티티 타입과 PK값으로 식별</strong>하지만, <code>IDENTITY</code>전략의 경우 DB에 Insert 한 후 PK 확인이 가능하기 때문에 <strong>batch insert</strong>를 비활성화 한 것으로 확인되었습니다.</p>
<p><strong>(Hibernate가 채택한 transactional write-behind (쓰기 지연) 전략을 방해하기 때문입니다.)</strong></p>
<hr>
<h2 id="기본-키-생성전략을-교체">[기본 키 생성전략을 교체?]</h2>
<p><code>BulkInsert</code>를 위해 기본 키 생성전략을 <code>SEQUENCE</code> 혹은 <code>TABLE</code> 로의 변경 또한 고려해보았습니다.</p>
<p>하지만 운영환경 DB는 MySQL로 <code>SEQUENCE</code> 테이블 전략을 지원하지 않았으며, <code>TABLE</code> 전략은 테이블 마다 키를 관리하는 테이블을 만든다는 것이 더 큰 비용을 야기한다고 판단하였습니다.</p>
<hr>
<h2 id="jdbc-or-native-sql">[jdbc or Native SQL]</h2>
<p>따라서 기본키 생성 전략을 <code>IDENTITY</code>로 가져가면서 <code>BulkInsert</code>를 하기 위해서는 jdbc 혹은 Native SQL을 이용해야 하는 상황이였고, <code>jdbc</code>를 활용하기로 결정하였습니다.</p>
<p><strong>(Native SQL은 애플리케이션이 특정 데이터베이스에 종속적으로 작성되면, 다른 데이터베이스로의 이식성이 떨어진다는 단점으로 jdbc를 활용하게 되었습니다.)</strong></p>
<hr>
<h1 id="문제-해결">[문제 해결]</h1>
<blockquote>
<p><strong>MomentImageBulkInsertRepository.bulkInsert()</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/898fa935-10a9-4e76-b515-dbe655f00058/image.png" alt=""></p>
<p>위와 같이 JdbcTemplate을 통해 <code>BulkInsert</code>를 구성하였고, <strong>아래와 같이 하나의 Insert Query문을 통해 데이터가 BulkInsert 되었습니다.</strong></p>
<blockquote>
<p><strong>BulkInsert Log</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/43d98bec-77ab-4858-986e-cb3416126e8f/image.png" alt=""></p>
<hr>
<h1 id="새로운-문제의-발견">[새로운 문제의 발견]</h1>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/8751baf5-c725-4434-9a84-45919f6355a5/image.png" alt=""></p>
<p>하지만 새로운 <code>Bulk Method</code>를 담당하는 클래스를 구현함으로 인해 <code>Service</code>에서 의존해야 할 빈의 수가 증가하였습니다.</p>
<p><strong>즉, <code>Repository</code>가 추가될수록 <code>Service</code> 로직에 변화가 전파되는 문제점이 발생하였습니다.</strong></p>
<p>이는 변경 관리가 어렵게 만들고, 개발자가 직접 관리해야 할 요소가 늘어나면서 실수 발생 가능성을 높이는 원인이 될 수 있다고 판단하였습니다.</p>
<hr>
<h1 id="✅-새로운-문제의-해결-방안">✅ [새로운 문제의 해결 방안]</h1>
<p>아래와 같이 새로운 인터페이스를 상속받는 방식으로 문제를 해결했으며, 이를 통해 두 가지 주요 장점을 얻을 수 있었습니다.</p>
<blockquote>
<p><strong>개선된 MomentService</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/69a3c3c5-4bc8-4e92-afcd-3572c9bbcef1/image.png" alt=""></p>
<blockquote>
<p><strong>Diagram</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/df43b20a-cffb-4887-a285-12de8a448255/image.png" alt=""></p>
<h2 id="첫번째-장점---변경의-전파를-방지">[첫번째 장점 - 변경의 전파를 방지]</h2>
<p>새로운 <code>JdbcTemplate</code> 기반 메서드 혹은 클래스가 추가되더라도 변경 사항이 전파되지 않습니다.</p>
<p><code>Service</code>는 단순히 <code>MomentImageRepository</code>만을 의존하면 되므로 구조가 더욱 간결해집니다.</p>
<blockquote>
<p><strong>MomentImageRepository</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/6305bbef-45e6-4b6b-8f5f-cadec5be43d7/image.png" alt=""></p>
<hr>
<h2 id="두번째-장점---jparepository의-단점을-보완">[두번째 장점 - JpaRepository의 단점을 보완]</h2>
<p><code>MomentImageRepository</code>를 살펴보면 <code>JpaRepository</code>가 아닌 <strong><code>Repository</code></strong>를 상속받은 것을 확인할 수 있습니다.</p>
<p><code>JpaRepository</code>를 그대로 상속받을 경우, 필요하지 않은 기능들도 함께 포함되어 불필요한 메서드들이 자동으로 사용 가능해지는 문제가 발생합니다.</p>
<p>배치 처리를 지원하는 메서드나 페이징 처리를 위한 메서드, 영속성 컨텍스트 초기화를 수행하는 메서드, 프록시 객체를 조회하는 메서드 등 <strong>기본적으로 제공되지만, 이러한 기능들이 실제로 필요하지 않을 수 있습니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/3fa2f275-659d-4f55-986b-df9f3ee40649/image.png" alt=""></p>
<h3 id="그러면-crud는-어떻게-수행할까">[그러면 CRUD는 어떻게 수행할까?]</h3>
<blockquote>
<p><strong>Repository JavaDoc</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/59cfa0bc-2e39-4071-8638-7cbeddf7406b/image.png" alt=""></p>
<p>해당 JavaDoc은 <strong>&quot;Spring이 클래스패스 스캐닝을 통해 특정 인터페이스를 확장한 다른 인터페이스들을 발견하고, 이를 기반으로 빈을 자동으로 생성하는 과정을 간소화할 수 있다&quot;</strong>는 것을 의미합니다.</p>
<p>개발자가 직접 구현체를 만들 필요 없이, 인터페이스 설계만으로 Spring이 이를 처리해 준다는 장점을 설명하는 내용입니다.</p>
<p><strong>따라서 쿼리 메서드 규칙대로 메서드 시그니처를 정의하기만 한다면, 구현체의 구현은 Spring Data JPA가 수행합니다.</strong></p>
<h1 id="항상-제약을-두자">[항상 제약을 두자!]</h1>
<p><strong>좋은 코드를 작성하기 위해서는 꼭 필요한 기능만을 명시적으로 정의하고, 제약을 두고 사용하는 것이 중요하다고 생각합니다.</strong></p>
<p>위와 같이 필요하지 않은 기능들이 포함되면, 불필요한 메서드를 잘못 사용하는 실수를 할 가능성이 더 높아지기 때문입니다.</p>
<p>특히, 제가 정의한 <code>BulkDelete</code>와 같은 메서드는 다른 개발자가 사용할 때 <code>JpaRepository</code>에서 제공하는 <code>Batch</code> 메서드와 혼동하지 않도록 적절한 제한이 필요합니다.</p>
<p><strong>(JpaRepository를 통해 제공되어지는 Batch 메서드는 의도한 바와 다르게 작동할 수 있고, 의도하지 않은 데이터까지 삭제될 위험이 있습니다.)</strong></p>
<hr>
<h1 id="결론">[결론]</h1>
<p><code>BulkDelete</code>와 <code>BulkInsert</code>를 수행하는 과정에서 발생할 수 있는 다양한 문제를 분석하고, 이를 개선하는 과정을 거쳤습니다. 이 과정에서 메서드가 오용되지 않도록 제약을 둔 설계를 적용했으며, <strong>이를 통해 의도치 않은 사용으로 인한 잠재적 문제를 효과적으로 방지할 수 있었습니다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DataRow Count & Query Count 의존 끊기]]></title>
            <link>https://velog.io/@ho-tea/DataRow-Count-Query-Count-%EC%9D%98%EC%A1%B4-%EB%81%8A%EA%B8%B0</link>
            <guid>https://velog.io/@ho-tea/DataRow-Count-Query-Count-%EC%9D%98%EC%A1%B4-%EB%81%8A%EA%B8%B0</guid>
            <pubDate>Fri, 18 Apr 2025 11:33:23 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.</strong></p>
</blockquote>
<p>해당 글은 기록 프로젝트 진행 중, 특정 레코드 삭제 시 JpaRepository와 Cascade로 인해 불필요한 SELECT 및 개별 DELETE 쿼리가 다량 발생하는 문제를 해결하고자 선택한 기술적 접근 방식에 대해 설명합니다.</p>
<hr>
<h1 id="요약">[요약]</h1>
<p>Memory 삭제 API 호출 시, 관련 Moment와 그에 속한 Comment, MomentImage 등 연관 엔티티 삭제 과정에서 약 200개의 쿼리가 발생하는 문제를, JPQL 기반의 <strong><code>Bulk Delete</code></strong>를 활용해 조건에 맞는 데이터만 일괄 삭제함으로써 총 쿼리 수를 5개로 줄여 성능 최적화를 달성하였습니다.</p>
<hr>
<h1 id="배경-지식">[배경 지식]</h1>
<pre><code class="language-java">// Moment.java

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = &quot;memory_id&quot;, nullable = false)
private Memory memory;
@Embedded
private MomentImages momentImages = new MomentImages();
@OneToMany(mappedBy = &quot;moment&quot;, orphanRemoval = true, cascade = CascadeType.ALL)
private List&lt;Comment&gt; comments = new ArrayList&lt;&gt;();

// MomentImages.java
@OneToMany(mappedBy = &quot;moment&quot;, orphanRemoval = true, cascade = CascadeType.ALL)
private List&lt;MomentImage&gt; images = new ArrayList&lt;&gt;();</code></pre>
<p>Memory(부모 엔티티)가 N개의 Moment(자식 엔티티)를 가지고, Moment(부모 엔티티)가 N개의 Comment, MomentImage (자식 엔티티)를 가집니다. <strong>(양방향 연관관계 설정)</strong></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/74100d76-e6ba-4c8f-a9f6-31722b93c581/image.png" alt=""></p>
<h1 id="문제-상황">[문제 상황]</h1>
<p>특정 Memory(추억에 해당하는 도메인) 삭제 API 호출 시, 불필요한 <code>Select Query</code>와 <code>Delete Query</code>가 <code>Data row</code> 수만큼 나가는 문제상황이 발생했습니다.</p>
<pre><code class="language-java">// Memory.java

@Transactional
public void deleteMemory(long memoryId, Member member) {
    memoryRepository.findById(memoryId).ifPresent(memory -&gt; {
        validateOwner(memory, member);
        momentRepository.deleteAllByMemoryId(memoryId);
        memoryRepository.deleteById(memoryId);
    });
}</code></pre>
<ul>
<li>moment 별 comment, moment_image에 대해 <code>Select Qeury</code> 발생</li>
<li>조회된 모든 것들마다 개별적으로 <code>Delete Query</code> 발생</li>
</ul>
<hr>
<h2 id="문제-상황-예시">[문제 상황 예시]</h2>
<p><strong>moment가 10개, moment 당 comment가 10개, moment_image가 5개라고 한다면</strong></p>
<ul>
<li>특정 memory id를 가지는 moment 를 조회하는 쿼리 <code>1</code>개</li>
<li>특정 moment에서 모든 comment 조회하는 쿼리 <code>10</code>개<ul>
<li>comment 삭제 쿼리 10(moment 수) X 10(comment 수) = <code>100</code>개</li>
</ul>
</li>
<li>특정 moment에서 모든  moment_image 조회하는 쿼리 <code>10</code>개<ul>
<li>moment_image 삭제 쿼리 10(moment 수) X 5(moment_image 수) = <code>50</code>개</li>
</ul>
</li>
<li>moment 삭제 쿼리 <code>10</code>개</li>
<li>memory_member 삭제 쿼리 <code>1</code>개</li>
<li>memory 삭제 쿼리 <code>1</code>개</li>
</ul>
<p><code>= 약 200개의 쿼리가 나가게 됩니다.</code></p>
<hr>
<h2 id="문제-상황-분석">[문제 상황 분석]</h2>
<pre><code class="language-java">// Memory.java

@Transactional
public void deleteMemory(long memoryId, Member member) {
    memoryRepository.findById(memoryId).ifPresent(memory -&gt; {
        validateOwner(memory, member);
        **momentRepository.deleteAllByMemoryId(memoryId);**
        **memoryRepository.deleteById(memoryId);**
    });
}

// SimpleJpaRepository.java

@Transactional
**public void deleteById(ID id) {**
    Assert.notNull(id, &quot;The given id must not be null&quot;);
    this.findById(id).ifPresent(this::delete);
}

...

@Transactional
**public void deleteAllById(Iterable&lt;? extends ID&gt; ids) {**
    Assert.notNull(ids, &quot;Ids must not be null&quot;);
    Iterator var3 = ids.iterator();

    while(var3.hasNext()) {
        ID id = (Object)var3.next();
        this.deleteById(id);
    }
}

...

**public Optional&lt;T&gt; findById(ID id) {**
    Assert.notNull(id, &quot;The given id must not be null&quot;);
    Class&lt;T&gt; domainType = this.getDomainClass();
    if (this.metadata == null) {
        return Optional.ofNullable(this.entityManager.find(domainType, id));
    } else {
        LockModeType type = this.metadata.getLockModeType();
        Map&lt;String, Object&gt; hints = this.getHints();
        return Optional.ofNullable(type == null ? this.entityManager.find(domainType, id, hints) : this.entityManager.find(domainType, id, type, hints));
    }
}
...
@Transactional
**public void delete(T entity) {**
    Assert.notNull(entity, &quot;Entity must not be null&quot;);
    if (!this.entityInformation.isNew(entity)) {
        Class&lt;?&gt; type = ProxyUtils.getUserClass(entity);
        T existing = this.entityManager.find(type, this.entityInformation.getId(entity));
        if (existing != null) {
            this.entityManager.remove(this.entityManager.contains(entity) ? entity : this.entityManager.merge(entity));
        }
    }
}</code></pre>
<ol>
<li><strong>deleteAllById(Iterable&lt;? extends ID&gt; ids)</strong> 호출 시 <strong>ids 를 순회하면서 deleteById(ID id)</strong> 를 호출</li>
<li><strong>deleteById(ID id)</strong>는 내부적으로 <strong>em.find()</strong> 를 통해 지우려는 <strong><code>entity 를 영속성 컨텍스트에 등록</code></strong></li>
<li>이후 <strong>em.remove()</strong> 를 호출. <ol>
<li>여기서 <code>Cascade</code> 가 걸려있는 엔티티도 삭제하기 위해 영속성 컨텍스트에 등록하는 과정에서 <code>select</code> 쿼리가 추가적으로 발생.</li>
</ol>
</li>
</ol>
<p><strong><code>JpaRepository</code> 가 제공하는 <code>delete</code> 를 활용하게 되면 예상치 못한 <code>select</code> 쿼리가 발생한다는 것을 알 수 있었습니다.</strong></p>
<hr>
<h1 id="해결-방안">[해결 방안]</h1>
<h2 id="✅-해결-방안-1---직접적인-delete-쿼리-사용으로-일괄-삭제bulk-delete">✅ [해결 방안 1 - 직접적인 DELETE 쿼리 사용으로 일괄 삭제(Bulk Delete)]</h2>
<ul>
<li>성능 최적화를 위해 직접적인 DELETE 쿼리를 작성</li>
<li><strong>JPQL을 사용하여 조회 없이 한 번의 쿼리로 삭제</strong></li>
<li><strong>in절을 활용해 여러 조건에 해당하는 데이터를 한 번에 삭제할 수 있도록 구성</strong></li>
</ul>
<pre><code class="language-java">@Modifying
@Query(&quot;DELETE FROM Comment c WHERE c.moment.id IN :momentIds&quot;)
void deleteAllByMomentIdInBulk(@Param(&quot;momentIds&quot;) List&lt;Long&gt; momentIds);</code></pre>
<hr>
<h2 id="❌-해결-방안-2---spring-data-jpa가-제공하는-deleteallinbatch-메서드-사용">❌ [해결 방안 2 - Spring Data JPA가 제공하는 deleteAllInBatch 메서드 사용]</h2>
<ul>
<li><code>deleteAllInBatch()</code> 메서드는 엔티티 리스트 전체를 한 번의 SQL 문으로 삭제</li>
</ul>
<pre><code class="language-java">momentRepository.deleteAllInBatch();</code></pre>
<hr>
<h2 id="해결방안-1bulk-delete-선택-이유">[해결방안 1(Bulk Delete) 선택 이유]</h2>
<h3 id="조건-기반-삭제의-필요성">[조건 기반 삭제의 필요성]</h3>
<p><code>Bulk delete</code>는 특정 조건에 맞는 데이터를 선택적으로 삭제할 수 있는 장점이 있습니다. 만약 특정 조건에 맞는 일부 데이터만 삭제하고자 할 경우, <code>deleteAllInBatch()</code>는 사용할 수 없고 <code>Bulk Delete</code>를 사용해야 합니다. 물론 <code>deleteAllByIdInBatch()</code>와 같이 특정 식별자 목록을 기반으로 일괄 삭제하는 메서드를 활용할 수도 있지만, 이는 자신 테이블의 ID에 해당하는 데이터만 삭제하기에 사용할 수 없었습니다.</p>
<p>따라서 아래와 같이 <strong><code>BulkDelete</code></strong>를 활용하는 코드로 개선되었습니다.</p>
<blockquote>
<p><strong>Memory → Category, Moment → Staccato 로 도메인 명 변경이 이루어졌습니다.</strong></p>
</blockquote>
<pre><code class="language-java">@Transactional
public void deleteCategory(long categoryId, Member member) {
    categoryRepository.findById(categoryId).ifPresent(category -&gt; {
        validateOwner(category, member);
        deleteAllRelatedCategory(categoryId);
        categoryRepository.deleteById(categoryId);
    });
}

private void deleteAllRelatedCategory(long categoryId) {
    List&lt;Long&gt; staccatoIds = staccatoRepository.findAllByCategoryId(categoryId)
            .stream()
            .map(Staccato::getId)
            .toList();
    staccatoImageRepository.deleteAllByStaccatoIdInBulk(staccatoIds);
    commentRepository.deleteAllByStaccatoIdInBulk(staccatoIds);
    staccatoRepository.deleteAllByCategoryIdInBulk(categoryId);
    categoryMemberRepository.deleteAllByCategoryIdInBulk(categoryId);
}</code></pre>
<hr>
<h2 id="개선된-문제-상황">[개선된 문제 상황]</h2>
<h3 id="문제-상황-예시에서-발생했던-200개의-쿼리가-5개로-줄일-수-있었습니다">[문제 상황 예시에서 발생했던 200개의 쿼리가 5개로 줄일 수 있었습니다.]</h3>
<p><strong>Category가 10개, Staccato 당 comment가 10개, staccato_image가 5개라고 한다면</strong></p>
<ul>
<li>특정 category_id를 가지는 staccato_ids 를 조회하는 쿼리 <code>1</code>개</li>
<li>staccato_ids에 포함되는 staccato_id를 가지는 모든 comment 삭제하는 쿼리 <code>1</code>개</li>
<li>staccato_ids에 포함되는 staccato_id를 가지는 모든 staccato_image 삭제하는 쿼리 <code>1</code>개</li>
<li>category_member 삭제 쿼리 <code>1</code>개</li>
<li>category 삭제 쿼리 <code>1</code>개</li>
</ul>
<h1 id="결론"><strong>[결론]</strong></h1>
<p>연관 엔티티 삭제 시 JpaRepository와 Cascade 설정으로 인해 불필요하게 대량의 SELECT 및 DELETE 쿼리가 발생하는 문제를 확인하였습니다. 이를 해결하기 위해, JPQL 기반의 Bulk Delete 기법을 도입하여 조건에 맞는 데이터를 일괄 삭제함으로써 쿼리 발생 횟수를 약 200개에서 5개로 줄일 수 있었습니다.</p>
<p>이와 같은 접근 방식은 단순히 쿼리 수를 줄여 데이터베이스 부하를 경감시키는 것을 넘어, 시스템의 성능과 응답 속도를 향상시키는 효과를 가져올 수 있다고 생각합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 개선과정 - 정말 무중단 배포일까?]]></title>
            <link>https://velog.io/@ho-tea/%EC%A0%95%EB%A7%90-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EC%9D%BC%EA%B9%8C</link>
            <guid>https://velog.io/@ho-tea/%EC%A0%95%EB%A7%90-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EC%9D%BC%EA%B9%8C</guid>
            <pubDate>Fri, 18 Apr 2025 11:27:31 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.</strong></p>
</blockquote>
<p>해당 글은 기록 프로젝트 진행 중, 애플리케이션 서버의 단일 장애점(SPOF) 문제와 CI/CD 과정에서의 전체 서비스 중단 위험 등을 해결하기 위해 선택한 기술적 접근 방식에 대해 설명합니다. </p>
<hr>
<h1 id="요약">[요약]</h1>
<p>초기 인프라는 애플리케이션 서버가 SPOF 문제가 존재하며 CI/CD 과정에서 전체 서비스 중단 위험이 있었으나, <strong>Active-Active 서버 이중화</strong>, <strong>AWS ALB 도입</strong>, <strong>DB Replication</strong> 등을 통해 안정성과 가용성을 개선하였습니다. </p>
<p>또한, CI/CD 과정에서 발생한 서비스 중단 문제를 해결하기 위해 <strong>Blue-Green 무중단 배포 전략</strong>과 <strong>포트 스위칭</strong>, <strong>롤백 전략</strong>을 도입하여 <strong>트리거 기반 무중단 배포 환경</strong>을 구축하였습니다.</p>
<hr>
<h1 id="인프라-개선-과정">[인프라 개선 과정]</h1>
<h2 id="초기-인프라">[초기 인프라]</h2>
<p>초기 단계에서 인프라 아키텍처 구조는 아래와 같았습니다</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/038ce47a-ea99-4b20-a98d-0b6f82dc43f6/image.png" alt=""></p>
<p>이러한 구조는 두 가지 주요 문제를 야기했습니다.</p>
<p><strong>첫째, 애플리케이션 서버가 외부 공격에 직접적으로 노출되어 보안에 취약합니다.</strong></p>
<p><strong>둘째, 단일 장애점(SPOF)이 존재하여 애플리케이션 서버에 장애가 발생하면 전체 서비스가 중단됩니다.</strong></p>
<p>이러한 문제점을 해결하고자 안정성과 가용성이 향상된 새로운 인프라 아키텍처를 도입했습니다.</p>
<h2 id="spof-문제를-개선한-인프라-active-active">[SPOF 문제를 개선한 인프라 (Active-Active)]</h2>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/12499193-69fa-407c-802a-1008c63186e5/image.png" alt=""></p>
<p>서버 이중화 구성은 Active-Active 방식을 선택하였습니다.</p>
<p>Active-Standby 방식에서는 한 서버가 대기 상태로 유지되므로 자원의 비효율적 활용 문제가 있다고 판단하였기에, Active-Active 방식을 진행하면서 로드밸런서를 앞단에 두어 클라이언트 요청을 여러 애플리케이션 서버에 분산시키는 구성을 선택하였습니다.</p>
<p>이 방식은 각 서버가 동시에 요청을 처리하여 자원을 최대한 활용하고, 가용성을 높이는 효과가 있다고 판단하였습니다.</p>
<h3 id="로드밸런서를-aws의-alb로-선택한-이유">[로드밸런서를 AWS의 ALB로 선택한 이유]</h3>
<p>로드밸런서를 AWS의 ALB가 아닌 Nginx를 사용하는 방법 또한 고려하였지만, 아래와 같은 이유로 ALB를 사용하게 되었습니다.</p>
<ul>
<li>EC2 인스턴스의 IP주소가 바뀔때마다 Nginx를 사용하게 된다면, ip 설정을 변경해야 하기에 관리의 어려움이 존재합니다.</li>
<li>ALB는 AWS의 여러 가용성 영역(AZ)에 걸쳐 자동으로 트래픽을 분산시키며, 이를 통해 고가용성을 확보할 수 있지만, Nginx를 로드 밸런서로 사용할 경우 이러한 설정을 수동으로 구성해야 하며, 특히 다중 AZ를 고려하는 경우 추가적인 설정과 관리가 필요하다고 판단하였습니다.</li>
</ul>
<h3 id="그럼에도-불구하고-왜-nginx가">[그럼에도 불구하고 왜 Nginx가?]</h3>
<p>그럼에도 불구하고 Nginx를 인스턴스내에서 유지한 이유로는 Nginx가 리버스 프록시 기능 외에도 캐싱을 활용하여 정적 파일을 서버 메모리에 저장하고, 클라이언트가 같은 파일을 요청할 때마다 빠르게 응답할 수 있도록 돕기 때문에 이를 통해 네트워크 대역폭 사용을 줄이고 응답 속도를 향상시킬 수 있다고 판단하였습니다.</p>
<h3 id="replication">[Replication]</h3>
<p>또한 Reader DB와 Writer DB를 분리하여</p>
<p>하나의 디비 서버로 몰리는 트래픽을 분산하여 가용성과 안정성을 증가시킬 수 있었습니다.</p>
<h2 id="spof-문제를-개선한-인프라-active-active의-문제점">[SPOF 문제를 개선한 인프라 (Active-Active)의 문제점]</h2>
<p>하지만 해당 인프라 구조에서도 CI/CD 과정에서 일정시간동안 서비스가 제공되지 않는다는 문제점이 존재하였습니다.</p>
<p>이를 해결하기 위해 무중단 배포를 계획하였고, 그로 인해 인프라 구조가 변경되었습니다.</p>
<h2 id="최종-인프라-구조">[최종 인프라 구조]</h2>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/e65585f5-67f0-4db6-a035-3e7c9facbace/image.png" alt=""></p>
<p>무중단 배포 전략으로는 Blue-Green 배포 전략을 선택하였습니다.</p>
<p>Blue-Green 배포전략은 다른 배포전략과는 다르게 구버전과 신버전의 공존이 존재하지 않기에 롤백이 용이하다는 장점이 매력적으로 다가왔고, 저희가 추구하는 목표 중 하나인 안정성과 연관되어 서비스가 안정적으로 제공된다는 점이 프로젝트의 방향성과 맞다고 판단하였습니다.</p>
<p>ELB의 Auto-Sacaling을 사용하여 손쉽게 블루그린 배포전략을 사용할 수 있다고 생각되었지만, 해당 Auto-Sacaling에 권한이 없기에 인스턴스 내부에 Docker Container로 Blue-Green 배포를 시도하였습니다.</p>
<h1 id="배포-과정">[배포 과정]</h1>
<p>CD Workflow에서 prod-a서버와 prod-b 서버에서 <code>deploy.sh</code> 스크립트를 실행시키는 것으로 시작됩니다.</p>
<p>아래와 같은 과정으로 배포가 진행됩니다.</p>
<ol>
<li>모든 서버에 Green 배포를 수행한다.</li>
<li>스크립트로 기존에 Nginx가 바라보고 있는 포트를 찾아서 Blue로 판단한다.</li>
<li>존재하는 Green 컨테이너가 있다면, 중지시키고 새롭게 생성한다.</li>
<li>새롭게 생성한 Green 컨테이너가 정상적으로 동작하는지 HealthCheck를 시도한다.</li>
<li>Health Check가 성공한다면 Nginx가 바라보고 있는 포트를 Green으로 변경한다.</li>
</ol>
<blockquote>
<p><strong>CD WorkFlow</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/268772af-808d-4157-a438-6b1e015cb44b/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/4831dac2-9dbc-4a6e-b82c-81bcae646915/image.png" alt=""></th>
</tr>
</thead>
</table>
<blockquote>
<p><strong>deploy.sh</strong></p>
</blockquote>
<p>(하나의 인스턴스 내부에 애플리케이션들을 pat와 mat로 명칭)</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/cea7c8d7-1fa1-481c-ab0d-1f35121152bd/image.png" alt=""></p>
<hr>
<h2 id="이것이-정말-blue-green-무중단-배포일까">[이것이 정말 Blue-Green 무중단 배포일까?]</h2>
<p>해당 인프라 구조에서도 문제가 있다고 판단하였습니다.</p>
<p><strong><code>문제 예시 상황 1</code></strong> : A-Prod 환경에서는 신버전으로 포트가 변경되었는데, <strong>아직 B-Prod 환경에서는 구버전으로 포트포워딩이 이루어지고 있는 경우</strong></p>
<p><strong><code>문제 예시 상황 2</code></strong> : A-Prod 환경에서는 신버전으로 포트가 변경되었는데, <strong>B-Prod 환경에서는 신버전 배포가 실패하여 구버전을 바라보고 있는 경우</strong></p>
<p>두가지 상황에서 구버전과 신버전의 공존이 특정 시점에 존재한다는 문제점이 존재했습니다.</p>
<p><strong>Blue-Green 배포전략을 선택한 이유는 구버전과 신버전의 공존이 없기 때문이였는데, 기존에 목표했던 바를 완벽히 이루지 못했습니다.</strong></p>
<h2 id="진정한-무중단이-되기-위해서는">[진정한 무중단이 되기 위해서는]</h2>
<p>GithubActions가 모든 인스턴스에서 <strong>Green 컨테이너가 정상적으로 띄워졌다는 Trigger를 받고</strong>, 그 이후 일괄적으로 Nginx가 가리키고 있던 <strong>포트를 바꿔버린다면?</strong></p>
<ul>
<li><strong>진정한 무중단에 가까워진다고 판단하였습니다.</strong>
(Nginx가 포트를 스위칭하는 과정에서 발생하는 딜레이는 현재 고려하지 않았습니다)</li>
</ul>
<h3 id="deploysh-수정">[deploy.sh 수정]</h3>
<p>기존 <code>deploy.sh</code> 에서 처리하던 Nginx가 가리키던 포트를 스위칭하는 작업과 이전 Container를 down 시키는 부분이 <code>switch.sh</code>로 위임되었습니다.</p>
<ul>
<li><strong>Health Check 성공 시 exit 0 반환</strong></li>
<li><strong>Health Check 실패 시 exit 1 반환</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/74d13ced-33a1-40ea-a832-75d5913d8271/image.png" alt=""></p>
<h3 id="rollbacksh-생성">[rollback.sh 생성]</h3>
<p>배포 실패 시 실행되는 롤백 스크립트로 pat와 mat 컨테이너 간의 상태를 확인하고, 필요한 경우 롤백 작업을 수행하는 역할을 담당합니다. </p>
<ul>
<li><strong>pat와 mat 모두 띄워져있으면 최신에 띄워진 컨테이너 down</strong></li>
<li><strong>pat 혹은 mat가 띄워져있으면 다운받은 docker image만 삭제</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/3eb220f1-fb08-4f98-b9c2-7e4ecb3dc33c/image.png" alt=""></p>
<h3 id="cicd-workflow-수정">[CI/CD Workflow 수정]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/bfb81398-ba9f-4387-8435-52c3c066f563/image.png" alt=""></p>
<p>배포가 실패되었을 때 실행되는 <strong>롤백 전략</strong>과 배포가 성공했을 때 <strong>포트 스위칭 전략</strong>이 추가되었습니다.</p>
<hr>
<h3 id="switchsh-생성">[switch.sh 생성]</h3>
<p>두 개의 인스턴스에서 deploy.sh가 모두 성공하여 GithubActions의 <code>port_switch job</code> 이 실행된다면해당 스크립트가 실행됩니다. </p>
<p>pat와 mat 두 개의 Docker 컨테이너 중에서 가장 최신에 시작된 컨테이너를 식별하여, Nginx가 가리키는 포트를 해당 컨테이너로 설정하고, 이전 Container를 down 시키는 역할을 담당합니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/51268828-1901-43c1-b5ff-a40edd772a33/image.png" alt=""></p>
<hr>
<h2 id="무중단-배포-최종-cicd">[무중단 배포 최종 CI/CD]</h2>
<h3 id="1-run-deploy-script">1. Run deploy script</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/b0aeab8e-4122-47d0-a9e4-eacba89ddbf9/image.png" alt=""></p>
<h3 id="2-switch-nginx-ports">2. Switch Nginx Ports</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/30f12fe2-38c9-45a1-b804-f50e261d491d/image.png" alt=""></p>
<hr>
<h3 id="무중단-배포-서버-container--health-check">[무중단 배포 서버 Container &amp; Health Check]</h3>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/b2668247-b27a-4642-92f8-5bcef5eeaabd/image.png" alt=""> Container Check</th>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/aa24c2dc-127a-45d4-ac02-b76762b4d5fa/image.png" alt=""> Health Check</th>
</tr>
</thead>
</table>
<hr>
<h1 id="이제-정말-무중단-배포-끝">[이제 정말 무중단 배포 끝..?]</h1>
<ul>
<li><strong>모든 서버에게 성공 트리거를 받은 후 일괄적으로 포트를 스위칭하게 설정해놓았기에 구버전과 신버전이 공존하는 시간이 줄어들었고, 우리의 제어 하에 무중단 배포가 정상적으로 이루어졌다!</strong></li>
</ul>
<p>고 볼 수도 있지만 추가로 고려해야 할 사항이 있습니다.</p>
<ol>
<li><strong>DB 레벨에서의 변경(ex: 스키마 변경 작업)이 이루어져도 정상적으로 작동할까?</strong></li>
<li><strong>포트스위칭 작업에서의 지연은 고려하지 않아도 될까?</strong></li>
</ol>
<p>만약 운영환경에서 flyway를 통해 스키마 버전을 관리하고 있다면, 신 버전(=스키마의 변경작업이 일어난) 배포가 완료되는 순간 DB 스키마도 변경 사항이 반영이 되었을 것입니다. 만약, 하위호환이 불가능한 변경 사항이었다면, 롤백 및 포트 전환까지의 시간동안 서비스는 장애가 발생할 것 입니다.</p>
<p>또한, 일부 서버에서 배포에 실패하여 롤백이 발생해도, DB는 자동으로 롤백되지 않습니다.</p>
<p>하위호환이 불가능한 변경작업이 아니었다면 호환 측면에서는 문제가 되지 않을 수 있겠지만, flyway를 사용하게 된다면 버전 체크 시 오류로 판단할 수 있습니다.</p>
<hr>
<p>이와 같이 무중단 배포를 진행하면서 <code>Infra</code>, <code>Application</code>, <code>DB</code> 등 다양한 계층에서의 고려사항들을 탐구했으며, 현재 구조에서의 문제점을 지속적으로 발견하고 개선해 나가는 과정을 거쳤습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모니터링 알람 시스템 구축 및 비즈니스 매트릭 등록]]></title>
            <link>https://velog.io/@ho-tea/%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%95%8C%EB%9E%8C-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95-%EB%B0%8F-%EB%B9%84%EC%A6%88%EB%8B%88%EC%8A%A4-%EB%A7%A4%ED%8A%B8%EB%A6%AD-%EB%93%B1%EB%A1%9D</link>
            <guid>https://velog.io/@ho-tea/%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%95%8C%EB%9E%8C-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95-%EB%B0%8F-%EB%B9%84%EC%A6%88%EB%8B%88%EC%8A%A4-%EB%A7%A4%ED%8A%B8%EB%A6%AD-%EB%93%B1%EB%A1%9D</guid>
            <pubDate>Fri, 18 Apr 2025 11:15:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.</strong></p>
</blockquote>
<p>해당 글은 기록 프로젝트 진행하면서 직면했던 모니터링 범위 산정의 미흡함과 신속한 대응의 어려움을 해결하기 위해 선택한 기술적 접근 방식을 설명합니다.</p>
<hr>
<h1 id="요약">[요약]</h1>
<p>개발 환경 EC2에서 도커 이미지 누적으로 디스크 용량 초과 문제가 발생하면서, <code>모니터링 범위 산정 미흡</code>과 <code>즉각적 에러 대응 체계 부재</code>를 깨달았습니다. 이를 해결하기 위해 <strong>애플리케이션이 동작하는 환경의 상태</strong>, <strong>애플리케이션 자체 상태</strong>, <strong>요청/응답을</strong> 종합적으로 모니터링하기로 결정, <strong>Alert Rule</strong>을 추가해 Slack 알람을 구축했습니다. 또한, <strong>AOP 기반의 비즈니스 매트릭</strong>을 구현하여 사용자 경험 개선을 시도하였습니다.</p>
<hr>
<h1 id="문제상황">[문제상황]</h1>
<h2 id="개발-환경-ec2의-용량-초과-문제-발생"><strong>[개발 환경 EC2의 용량 초과 문제 발생]</strong></h2>
<p>개발 환경에서 아래와 같이 docker container에 접근할려고 했으나 접근이 제한되는 문제가 발생하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/3153a3a7-566f-4549-b12f-99b99ba2664a/image.png" alt=""></p>
<p>여러 방식으로 원인을 분석하는 과정에서 내부 디스크 사용률을 확인해 보았는데, EC2 내부에 디스크 사용률이 100%이기에 발생하는 문제였습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/d879481c-7188-46b4-a0c7-a9d658578b1f/image.png" alt=""></p>
<h3 id="개발-환경-ec2의-용량-초과-문제-분석"><strong>[개발 환경 EC2의 용량 초과 문제 분석]</strong></h3>
<p>이전에 CI/CD 작업은 도커 이미지를 도커 허브에 올린 후 Self Hosted Runner가 해당 이미지를 pull 받아오는 방식으로 이루어졌습니다. 그러나 GithubActions Workflow에 기존 이미지를 삭제하는 명령어를 포함하지 않은 상태로 여러 번의 CD 작업이 이루어졌고, 이로 인해 방대한 도커 이미지의 용량이 쌓여 하드 디스크 용량이 100%가 되었던 것이였습니다.</p>
<blockquote>
<p><strong>삭제되지 않은 Docker Images</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/7376f280-0cdb-488b-8ecc-e5cfa67f793d/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/cf57749c-fb08-4ba8-861e-fc241022677f/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/658793a3-cc3a-4c74-90b3-20d95ad884a4/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>이 경험을 통해, <strong><code>모니터링 범위 산정의 미흡함</code></strong>과 <strong><code>즉각적인 에러 대응 체계 부재</code></strong>로 인해 문제 발생 시 신속한 대응이 어려웠음을 깨달았습니다.</p>
<hr>
<h1 id="해결방안">[해결방안]</h1>
<h2 id="✅-모니터링-범위-산정"><strong>✅ [모니터링 범위 산정]</strong></h2>
<p>운영환경과 개발환경 모두 <code>Grafana</code>를 통해 아래와 같이 로그를 확인하고 스프링의 메모리 사용률 등을 모니터링하고 있었지만,</p>
<blockquote>
<p><strong>Log 모니터링 - 애플리케이션의 요청/응답</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/53f318ab-070c-4f0b-9d81-8425e2096af3/image.png" alt=""></p>
<blockquote>
<p><strong>Spring 모니터링 - 애플리케이션 자체 상태</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/c3450503-cbe5-4623-8ac2-21f5bfe8f4b3/image.png" alt=""></p>
<blockquote>
<p><strong>EC2 모니터링 - 애플리케이션이 동작하는 환경의 상태</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/745cfd3a-c668-4fda-978c-6b25cfdc93f9/image.png" alt=""></p>
<p><strong>당시에는 EC2의 용량 문제를 전혀 예상하지 못했고, EC2 인스턴스를 모니터링하지 않았기에 해당 에러에 대한 대처가 늦었다고 생각되었습니다.</strong></p>
<p>운영환경에서 이러한 문제가 발생했다면 <strong>심각한 서비스 장애</strong>로 이어질 수 있었으므로, <code>모니터링 범위 산정의 중요성</code>을 절실히 깨달았습니다. </p>
<p>이에 <strong>애플리케이션이 동작하는 환경의 상태</strong>, <strong>애플리케이션 자체 상태</strong>, <strong>요청/응답</strong> 을 종합적으로 모니터링하기로 결정하였으며, 이를 위해 <strong>NodeExporter</strong>를 활용해 모든 EC2 인스턴스를 추가로 모니터링하기 시작하였습니다.</p>
<hr>
<h2 id="✅-알람-시스템-구축">✅ [알람 시스템 구축]</h2>
<p>지금과 같은 문제 상황을 포함하여 여러 사용자의 요청으로 인해 하나의 인스턴스 서버는 메모리가 가득차거나, CPU가 많이 사용되는경우 쉽게 장애를 일으킬 수 있겠다는 생각이 들었습니다.</p>
<p>서버를 <strong>24시간/7일</strong> 내내 직접 모니터링하는 것은 현실적으로 어려워, <code>신속한 에러 대응 전략</code>이 필요했습니다. 이에 <strong>Alert Rule</strong>을 추가하여 알람 시스템을 구축하기로 결정하였습니다.</p>
<blockquote>
<p><strong>등록된 Alert Rules</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/375e079d-76cd-4020-ae75-6b49c52deb42/image.png" alt=""></p>
<blockquote>
<p><strong>Alert</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/70d6f0d0-2090-4535-8aa9-96378333f6ab/image.png" alt=""> <strong>Error, Warn Log Alert</strong></th>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/c9c8c209-f2de-473d-902e-61589af4be76/image.png" alt=""><strong>Heap Usage Alert</strong></th>
</tr>
</thead>
</table>
<p><strong>이와 같이, 에러 로그가 발생하거나 Heap 사용률이 임계치를 초과하는 경우 등 Slack으로 자동 알람이 전송되도록 설정하여, 문제 발생 시 즉각적인 대응이 가능해졌습니다.</strong></p>
<hr>
<h1 id="더-나아가">[더 나아가]</h1>
<h2 id="✅-비즈니스-매트릭-모니터링">✅ [비즈니스 매트릭 모니터링]</h2>
<p>추가적으로 기존에는 CPU 사용량, 메모리 사용량, 톰캣 쓰레드, DB 커넥션 풀 등 공통적으로 사용되는 기술 메트릭이 이미 등록되어 있었습니다.</p>
<p>기존에 등록된 메트릭들을 활용하여 대시보드를 구성하고 모니터링을 진행하던 중, 사용자 경험에 더욱 부합하는 프로젝트 개선 방향을 모색하기 위해 별도의 비즈니스 매트릭을 새롭게 등록하게 되었습니다.</p>
<h3 id="비즈니스-매트릭---기록-시점이-과거현재미래인지-카운터-매트릭">[비즈니스 매트릭 - 기록 시점이 과거/현재/미래인지 카운터 매트릭]</h3>
<p>기록 저장 시점(과거, 현재, 미래)에 대한 빈도 체크를 통해, 가장 많이 사용되는 시점을 기준으로 사용자에게 기본 값을 제공할 수 있도록 시스템을 구성하고자 진행하게 되었습니다.</p>
<blockquote>
<p><strong>카운터 매트릭 AOP 적용</strong></p>
</blockquote>
<pre><code class="language-java">@Aspect
@Component
@RequiredArgsConstructor
public class CreateStaccatoMetricsAspect {

    // MeterRegistry를 주입받아 애플리케이션 메트릭을 기록하는 데 사용합니다.
    private final MeterRegistry meterRegistry;

    @Pointcut(&quot;execution(public * com.staccato.staccato.service.StaccatoService.createStaccato(..)) &amp;&amp; args(staccatoRequest, member)&quot;)
    public void createStaccatoPointcut(StaccatoRequest staccatoRequest, Member member) {
    }

    @AfterReturning(pointcut = &quot;createStaccatoPointcut(staccatoRequest, member)&quot;, returning = &quot;result&quot;)
    public void afterSuccessfulCreateStaccato(StaccatoRequest staccatoRequest, Member member, Object result) {
        // staccatoRequest의 visitedAt 필드를 LocalDate로 변환하여 방문 날짜를 구합니다.
        LocalDate visitedAt = staccatoRequest.visitedAt().toLocalDate();
        LocalDate now = LocalDate.now();

        // 방문 날짜가 현재 날짜보다 과거인 경우 &quot;past&quot; 태그로 카운터를 기록합니다.
        if (isPastDate(visitedAt, now)) {
            recordCounter(&quot;past&quot;);
            return;
        }
        // 방문 날짜가 현재 날짜보다 미래인 경우 &quot;future&quot; 태그로 카운터를 기록합니다.
        if (isFutureDate(visitedAt, now)) {
            recordCounter(&quot;future&quot;);
            return;
        }
        // 방문 날짜가 현재 날짜와 동일한 경우 &quot;now&quot; 태그로 카운터를 기록합니다.
        recordCounter(&quot;now&quot;);
    }

    // 주어진 viewPoint 태그를 가진 카운터를 생성(또는 등록)하고, 해당 카운터의 값을 증가시킵니다.
    // MeterRegistry를 통해 메트릭이 기록됩니다.
    private void recordCounter(String viewPoint) {
        Counter.builder(&quot;staccato_record_viewpoint&quot;)
                .tag(&quot;class&quot;, StaccatoService.class.getName())
                .tag(&quot;method&quot;, &quot;createStaccato&quot;)
                .tag(&quot;viewPoint&quot;, viewPoint)
                .description(&quot;counts different view points for Staccato Record&quot;)
                .register(meterRegistry)
                .increment();
    }
    ...
}
</code></pre>
<blockquote>
<p><strong>카운터 매트릭 Dynamic Test</strong></p>
</blockquote>
<pre><code class="language-java">@DisplayName(&quot;기록 상의 날짜를 현재를 기준으로 과거 혹은 미래 인지 매트릭을 통해 표현 할 수 있습니다.&quot;)
@TestFactory
List&lt;DynamicTest&gt; createStaccatoMetricsAspect() {
    Member member = saveMember();
    Category category = saveCategory(member);
    LocalDateTime now = LocalDateTime.now();

    return List.of(
            dynamicTest(&quot;기록 상의 날짜가 과거인 기록과 미래인 기록을 매트릭에 등록합니다.&quot;, () -&gt; {
                // given
                StaccatoRequest pastRequest = createRequest(category.getId(), now.minusDays(2));
                StaccatoRequest futureRequest = createRequest(category.getId(), now.plusDays(2));

                //when
                staccatoService.createStaccato(pastRequest, member);
                staccatoService.createStaccato(futureRequest, member);

                //then
                assertAll(
                        () -&gt; assertThat(getPastCount()).isEqualTo(1.0),
                        () -&gt; assertThat(getFutureCount()).isEqualTo(1.0)
                );
            }),
            dynamicTest(&quot;기록 상의 날짜가 과거인 기록 작성 요청 → 누적: past:2.0, future:1.0&quot;, () -&gt; {
                // given
                StaccatoRequest staccatoRequest = createRequest(category.getId(), now.minusDays(3));

                // when
                staccatoService.createStaccato(staccatoRequest, member);

                // then
                assertAll(
                        () -&gt; assertThat(getPastCount()).isEqualTo(2.0),
                        () -&gt; assertThat(getFutureCount()).isEqualTo(1.0)
                );
            })
    );
}</code></pre>
<p>요구사항에 따라 매트릭을 관리하는 로직을 추가해야 했으며, 주목해야 하는 부분은 크게 네 가지로 정리할 수 있습니다.</p>
<p><strong>첫째</strong>, 기록 시점(과거, 현재, 미래)에 따른 매트릭 분기 처리가 필요했습니다.</p>
<ul>
<li>일반적으로 제공되는 <code>@Counted</code>와 같은 기본 어노테이션으로는 요구사항에 맞게 세밀한 분기가 어려웠기 때문에, 커스텀 로직을 구현하여 각 시점에 따른 빈도 체크 및 기록을 수행하였습니다.</li>
</ul>
<p><strong>둘째</strong>, 성공 응답만을 필터링해야 하는 제약 조건이 있었기 때문에, 매트릭 로직을 Service 계층에 적용하는 것으로 결정했습니다. </p>
<ul>
<li>이를 통해 불필요한 데이터 집계를 방지하고, 실제로 사용자 경험과 직결되는 성공적인 서비스 응답만을 정확하게 기록할 수 있도록 하였습니다.</li>
</ul>
<p><strong>셋째</strong>, 매트릭 관리 로직이 핵심 비즈니스 로직(Service)에 직접 침투하는 문제를 해결하기 위해 AOP(관점 지향 프로그래밍)를 적용했습니다.</p>
<ul>
<li>이를 통해 서비스 코드와 매트릭 수집 및 기록 로직을 분리함으로써, 핵심 기능의 가독성과 유지보수성을 높이고, 매트릭 관리에 따른 부수적인 영향도를 최소화할 수 있었습니다.</li>
</ul>
<p><strong>넷째</strong>, 매트릭이 동적으로 등록되고 추가되는 것을 확인하기 위해 <code>Dynamic test</code>를 통해 검증을 진행하였습니다.</p>
<h3 id="비즈니스-매트릭-시각화">[비즈니스 매트릭 시각화]</h3>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/a0aee357-e30c-4ad1-a16f-c5befbe9de9d/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/ho-tea/post/e168fe39-ef60-42fe-a207-f198518eb802/image.png" alt=""></th>
</tr>
</thead>
</table>
<p><strong>이와 같이 비즈니스 매트릭을 등록하고 시각화하는 과정을 통해, 서비스의 사용자 경험을 개선할 수 있는 유의미한 인사이트를 확보하고자 노력하였습니다.</strong></p>
<hr>
<h1 id="결론"><strong>[결론]</strong></h1>
<p>개발 환경에서 발생했던 디스크 용량 초과 문제와 그로 인한 즉각적인 에러 대응의 어려움을 체감하였고, 이를 해결하기 위해 모니터링 범위 산정과 알람 시스템 구축의 중요성을 재확인하였습니다.</p>
<p><strong>애플리케이션, 인프라, 그리고 요청/응답에 대한 종합적인 모니터링 체계</strong>를 도입하고, NodeExporter와 Grafana를 활용하여 EC2 인스턴스의 상태를 실시간으로 감시함으로써, 장애 발생 시 신속한 대응이 가능하도록 하였습니다. 또한, <code>Alert Rule</code>을 통한 Slack 알람 시스템 구축으로 관리자가 문제 상황을 즉시 인지할 수 있게 되었으며, AOP 기반의 비즈니스 매트릭 로직을 통해 사용자 경험과 직결되는 핵심 지표를 체계적으로 관리할 수 있음을 확인하였습니다.</p>
<p>이러한 기술적 접근은 단순히 문제를 해결하는 데 그치지 않고, 서비스의 안정성과 사용자 만족도를 극대화할 수 있는 인사이트를 제공하였으며, 향후 운영 환경에서도 신속하고 효과적인 문제 대응 및 개선을 위한 기반이 될 수 있음을 보여주었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티쓰레드 환경 사용자 식별성 개선 - MDC]]></title>
            <link>https://velog.io/@ho-tea/%EB%A9%80%ED%8B%B0%EC%93%B0%EB%A0%88%EB%93%9C-%ED%99%98%EA%B2%BD-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%8B%9D%EB%B3%84%EC%84%B1-%EA%B0%9C%EC%84%A0-MDC</link>
            <guid>https://velog.io/@ho-tea/%EB%A9%80%ED%8B%B0%EC%93%B0%EB%A0%88%EB%93%9C-%ED%99%98%EA%B2%BD-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%8B%9D%EB%B3%84%EC%84%B1-%EA%B0%9C%EC%84%A0-MDC</guid>
            <pubDate>Fri, 18 Apr 2025 10:55:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.</strong></p>
</blockquote>
<p>해당 글은 기록 프로젝트를 진행하면서 사용자 로그가 순차적으로 기록되지 않아 겪었던 사용자 식별에 대한 어려움을 해결하고자 선택한 기술적 접근 방식에 대해 설명합니다. </p>
<hr>
<h1 id="요약">[요약]</h1>
<p>여러 사용자가 동시에 애플리케이션에 접근하면 각기 다른 쓰레드를 사용하기 때문에 동일한 요청에 대한 로그가 순차적으로 쌓이는 것이 아닌 순서없이 쌓이게 되는 문제를 <code>MDC</code> 적용과 <code>ArgumentResolver</code> 사용자 식별 로깅을 통해 해결하였습니다.</p>
<hr>
<h1 id="문제-상황">[문제 상황]</h1>
<p>멀티 쓰레드 환경에서는 요청이 동시에 처리되는데, 여러 사용자가 동시에 애플리케이션에 접근하면 각기 다른 쓰레드를 사용하기 때문에 동일한 요청에 대한 로그가 순차적으로 쌓이는 것이 아닌 순서없이 쌓이게 됩니다.</p>
<p>따라서 사용자별 로그가 순차적으로 기록되지 않는 문제와 어떠한 사용자가 요청을 한것인지 식별하기 어려운 문제로 인해 요청별 로그 추적에 어려움을 겪었습니다.</p>
<blockquote>
<p>아래와 같이 테스트를 수행하였을 때에도 <code>문제 상황</code>을 명확히 인지할 수 있습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/a6bc5134-f470-4500-b55f-636dfb40e308/image.png" alt=""></p>
<ul>
<li>사용자 별 로그가 순차적으로 기록되지 않아 요청 별 로그를 추적하기 쉽지 않습니다. </li>
<li><em>→ <code>MDC</code> 적용으로 해결 시도*</em></li>
<li>어떠한 사용자가 어느 시점에 로그인을 시도한지 식별할 수 없습니다. </li>
<li><em>→ <code>Argument Resolver</code>에서 사용자 식별 로깅 추가로 해결 시도*</em></li>
</ul>
<hr>
<h1 id="해결-방안">[해결 방안]</h1>
<h2 id="✅-mdc-적용">✅ [MDC 적용]</h2>
<blockquote>
<p><strong>LoggingFilter.java</strong></p>
</blockquote>
<pre><code class="language-java">@Slf4j
@Component
public class LoggingFilter extends OncePerRequestFilter {

    // 로그 메시지에 사용할 고정 키, MDC(Mapped Diagnostic Context)에 저장됩니다.
    private static final String IDENTIFIER = &quot;request_id&quot;;

    // 필터에서 제외할 URL 패턴들을 정의한 화이트리스트입니다.
    private static final List&lt;String&gt; WHITE_LIST = List.of(
            &quot;/h2-console/**&quot;,
            &quot;/favicon/**&quot;,
            &quot;/swagger-ui/**&quot;,
            &quot;/v3/api-docs/**&quot;,
            &quot;/metrics&quot;,
            &quot;/actuator/**&quot;);

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 요청 처리 시간 측정을 위해 StopWatch를 시작합니다.
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // 각 요청에 대해 고유한 식별자(UUID)를 생성하여 MDC에 저장합니다.
        MDC.put(IDENTIFIER, UUID.randomUUID().toString());

        // 요청 헤더에서 Authorization 토큰을 가져옵니다.
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);

        try {
            // 다음 필터 혹은 최종 리소스로 요청을 전달합니다.
            filterChain.doFilter(request, response);
        } finally {
            // 요청 처리가 끝난 후 StopWatch를 중지합니다.
            stopWatch.stop();

            // 요청 로그를 기록합니다.
            // 로그에 응답 상태 코드, HTTP 메서드, 요청 URI, 토큰 존재 여부, 처리 시간(ms)을 포함합니다.
            log.info(LogForm.REQUEST_LOGGING_FORM,
                    response.getStatus(),
                    request.getMethod(),
                    request.getRequestURI(),
                    tokenExists(token),
                    stopWatch.getTotalTimeMillis());

            // MDC의 내용을 초기화하여, 다른 요청에 영향을 주지 않도록 합니다.
            MDC.clear();
        }
    }
    ...
}</code></pre>
<blockquote>
<p><strong>xxx-appender.xml</strong></p>
</blockquote>
<pre><code class="language-java">&lt;encoder&gt;
    &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level - %msg%n&lt;/pattern&gt;
&lt;/encoder&gt;</code></pre>
<blockquote>
<p><strong>예시 로그</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/34cbc620-1877-4789-8c72-8813e67d946f/image.png" alt=""></p>
<p>이와 같이 <strong><code>MDC</code></strong>를 적용하면서 사용자별 로그를 구분해 각 사용자의 행동을 추적할 수 있으며,</p>
<p>다중 사용자가 동시에 요청을 보낼 때 각 사용자의 컨텍스트가 분리되어 로그가 혼합되지 않는다는 장점을 경험할 수 있었습니다.</p>
<hr>
<h2 id="✅-argument-resolver-사용자-식별">✅ [Argument Resolver 사용자 식별]</h2>
<blockquote>
<p><strong>예시 로그</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/0e3c62a1-9ab7-4cf4-bf30-fc4062712ec0/image.png" alt=""></p>
<p><strong><code>MDC</code></strong>와 더불어 <strong><code>Argument Resolver에서 사용자를 식별</code></strong>하고 로깅을 수행하게 되면,</p>
<p>사용자 요청이 들어올 때마다 Argument Resolver가 먼저 실행되어 각 요청이 어느 사용자인지 명확히 식별할 수 있습니다.</p>
<p>(이때 요청의 고유 ID가 MDC를 통해 저장되므로, 특정 사용자의 요청을 명확하게 추적할 수 있습니다.)</p>
<hr>
<h1 id="결론">[결론]</h1>
<p>실제 <code>Grafana</code>를 통해 로깅을 모니터링 했을 때 아래와 같이 <strong><code>MDC</code></strong>와 <strong><code>Argument Resolver</code></strong> 로깅을 통한 사용자 식별이 적용된 것을 확인해 볼 수 있습니다.</p>
<p>이러한 로그 데이터를 분석함으로써 특정 사용자가 자주 요청하는 API 엔드포인트, 로그인 시도 실패 기록과 패턴, 특정 사용자에게서 발생하는 예외 상황, 사용자별 응답 시간 및 성능 문제 등을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/bb8bab3b-b25b-44d6-8c2f-886a6a39f531/image.png" alt=""></p>
<blockquote>
<p><strong>동일한 쓰레드를 사용하더라도, request_id가 다르므로 서로 다른 요청</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/ee2a624f-3658-4667-8a8d-f441ff49037d/image.png" alt=""></p>
<blockquote>
<p><strong>로그 상세 추적을 위한 Panel</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/736a515a-8f0f-4a05-b18c-da3c09582dee/image.png" alt=""></p>
<p>이와 같이 동일한 <strong><code>request_id</code></strong>를 가진 요청들을 별도로 분리하여 로그를 상세하게 추적할 수 있는 대시보드를 구성하였습니다.</p>
<p>이를 통해 사용자의 행동을 분석하여 서비스 최적화 및 개인화된 서비스 제공에 필요한 데이터로 활용할 수도 있습니다. 또한, 보안 측면에서 의심스러운 요청을 특정 사용자와 연결하여 신속하게 탐지할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Named Lock Connection Pool 분리 전략으로 동시성 문제 해결]]></title>
            <link>https://velog.io/@ho-tea/Named-Lock-Connection-Pool-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@ho-tea/Named-Lock-Connection-Pool-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Fri, 18 Apr 2025 10:44:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 토스 페이먼츠 결제 API를 활용한 방탈출 예약 서비스입니다.</strong></p>
</blockquote>
<p>해당 글은 외부 결제 API를 통한 데이터 일관성 보장을 위해 도입한 트랜잭션 분리 전략 과정에서 예약 관련 사전 정보 저장 시 발생한 동시성 문제를 다룹니다. 이를 해결하기 위해 애플리케이션, 데이터베이스, 인프라 각 수준에서 적용할 동시성 제어 기법을 검토하고 선택한 기술적 접근 방식에 대해 설명합니다.</p>
<hr>
<h1 id="요약">[요약]</h1>
<p>동시성 문제 해결을 위해 <strong>애플리케이션, 데이터베이스, 인프라 각 수준에서 동시성 제어 기법</strong>을 직접 경험하고 검토한 결과, <code>MySQL Named Lock</code>을 활용한 분산 락 구현과 별도의 <code>Connection Pool</code> 분리를 도입하여 DB 부하를 최소화하는 전략을 선택하였습니다.</p>
<hr>
<h1 id="문제-상황">[문제 상황]</h1>
<blockquote>
<p>외부 결제 API를 사용할 때의 데이터 일관성을 보장하기 위해 <strong>파사드 패턴을 활용한 트랜잭션 분리 전략</strong>을 도입해 구현했었습니다. <strong>→ <a href="https://velog.io/@ho-tea/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B0%95%ED%95%9C%EC%B5%9C%EC%A2%85-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%B3%B4%EC%9E%A5">🔗 결제 API - 트랜잭션 분리 전략으로 강한/최종 일관성 보장</a></strong></p>
</blockquote>
<p>그 과정에서, 예약 관련 사전 정보(<code>AdvanceReservation</code>)를 저장하는 과정에서 동시성 문제가 발생하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/023fdb68-9f34-4b95-b784-9ec6e6ea1ee5/image.png" alt=""></p>
<hr>
<h2 id="문제-상황-시뮬레이션">[문제 상황 시뮬레이션]</h2>
<p>직접 작성한 테스트 코드로 동시에 100개의 요청이 들어오는 상황을 시뮬레이션한 결과, 예약은 최종적으로 <code>한 개만</code> 생성되어야 함에도 불구하고 <strong>10개의 예약이 생성되었습니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/992a0e49-f711-4372-8502-b88ff3dceccb/image.png" alt=""></p>
<h2 id="문제-상황-분석">[문제 상황 분석]</h2>
<p>상황은 다음과 같았습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/f0c8ee1e-9e4b-43ca-a4dc-e49c56f1a2f6/image.png" alt=""></p>
<p><strong>하나의 <code>Reservation</code>에 대해 한 트랜잭션이 커밋되기 전에, 다른 트랜잭션이 동시에 <code>insert</code>를 수행하여 예약이 중복 생성되는 문제가 발생하였습니다.</strong></p>
<p>물론 <code>외부 결제 API</code> 측에서 별도의 처리를 해놓는다면, 뒤늦게 따라온 트랜잭션은 실패 처리될 수 있습니다. 하지만 <strong>외부 API의 응답 결과에만 의존하여 코드를 작성하는 것</strong>보다, <code>애플리케이션 레벨</code>, <code>데이터베이스 레벨</code>, <code>인프라 레벨</code>에서의 락이나 고유 제약 조건 등을 활용해 중복 예약이 발생하지 않도록 하는 것이 보다 안전한 설계라고 생각되었습니다.</p>
<hr>
<h1 id="해결-방안">[해결 방안]</h1>
<h2 id="❌-해결-방안-1--애플리케이션-수준에서의-동시성-제어">❌ [해결 방안 1 : 애플리케이션 수준에서의 동시성 제어]</h2>
<h3 id="synchronized-사용">[synchronized 사용]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/1a6e61fd-84ff-4e51-bff1-5c4d626792fe/image.png" alt=""></p>
<h3 id="reentrantlock-사용">[ReentrantLock 사용]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/cdd67678-a546-4261-9033-853d04b6114e/image.png" alt=""></p>
<p>위의 그림에서 보듯이, <code>synchronized</code>와 <code>ReentrantLock</code>을 사용한 경우 테스트가 성공적으로 통과하는 것을 확인할 수 있습니다.</p>
<p>이를 통해 애플리케이션 수준에서 동시성 제어가 수행되었음을 입증할 수 있었습니다.</p>
<hr>
<h3 id="애플리케이션-수준에서의-동시성-제어-한계점">[애플리케이션 수준에서의 동시성 제어 한계점]</h3>
<p><code>synchronized</code>에는 락 획득 대기 시 타임아웃 설정이나 공정성 정책을 지정할 수 없으며, 스레드가 락을 획득하지 못할 경우 무한정 대기하여 중단이나 타임아웃 제어가 어렵다는 단점이 있습니다.</p>
<p>이러한 문제점을 보완하기 위해 <code>ReentrantLock</code>을 사용하였습니다. <code>ReentrantLock</code>은 생성자에서 공정성 옵션을 지정할 수 있어 락 획득 대기 순서를 제어할 수 있으며, <code>tryLock()</code> 메서드를 사용해 지정된 시간 동안만 락 획득을 시도할 수 있어 락 획득 실패 시 대체 작업을 수행할 수 있는 장점을 제공합니다.</p>
<p><strong>하지만, 애플리케이션 레벨에서의 락은 다중 서버 환경에서는 적용하기 어려운 <code>치명적인 한계</code>가 있으므로, 데이터베이스에 락을 걸어 동시성 제어를 시도하였습니다.</strong></p>
<hr>
<h2 id="해결-방안-2--데이터베이스-수준에서의-동시성-제어">[해결 방안 2 : 데이터베이스 수준에서의 동시성 제어]</h2>
<h3 id="낙관적-락-vs-비관적-락">[낙관적 락 vs 비관적 락]</h3>
<p>낙관적 락은 데이터베이스에 이미 영속화되어 버전 필드(<code>@Version</code>)가 존재하는 엔티티에서 동시성 충돌을 감지하는 데 사용되므로, 새로운 엔티티 생성 시점에서는 적용할 수 없었습니다. 이러한 이유로, 낙관적 락 대신 비관적 락을 선택하여 구현하였습니다.</p>
<hr>
<h3 id="❌-비관적-락-사용">❌ [비관적 락 사용]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/314c9146-ede3-42ad-a566-b9e1f7f2716a/image.png" alt=""></p>
<p><strong>위 그림에서 보듯이, 비관적 락을 사용한 경우 또한 테스트는 정상적으로 통과했습니다.</strong></p>
<p>그러나 특정 레코드의 존재 여부를 확인하기 위해 전체 테이블을 조회하는 쿼리에 비관적 락을 적용하게되면, 해당 테이블의 <strong>모든 레코드에 락이 걸려 다른 트랜잭션들이 읽기나 쓰기 작업을 수행할 수 없게 됩니다.</strong></p>
<p><strong>이는 동시성과 성능에 심각한 영향을 미칠 수 있다고 판단하였습니다.</strong></p>
<p>(많은 사용자가 동시에 접근하는 환경에서 한 트랜잭션이 전체 테이블에 락을 걸고 있는 동안, 다른 트랜잭션들은 락 해제가 될 때까지 대기 상태에 빠져 응답 지연이나 병목 현상이 발생합니다.)</p>
<hr>
<h3 id="❌-유니크-제약조건으로-동시성-제어">❌ [유니크 제약조건으로 동시성 제어]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/d28e2c71-4e63-40d9-b900-915aa9468e5e/image.png" alt=""></p>
<p>락은 어떻게 됐든 병목지점을 만드는 것이기에 트레이드 오프라고 생각되었습니다. 따라서 DB 부하를 최소화하고 다른 트랜잭션에 미치는 영향을 줄일 수 있는 방법으로 <strong>유니크 제약 조건</strong>을 통해 동시성 제어를 시도하였습니다.</p>
<p>데이터베이스 엔진은 유니크 인덱스를 통해 중복 검사를 효율적으로 처리하므로, 애플리케이션에서 비관적 락이나 분산 락을 직접 구현하는 것보다 오버헤드가 적고 성능에 미치는 영향이 작다고 판단했습니다.</p>
<p>유니크 제약 조건은 데이터베이스 자체에서 처리되므로 여러 애플리케이션 인스턴스가 동일한 데이터베이스를 사용할 때도 일관된 데이터 무결성을 유지할 수 있었습니다. </p>
<h3 id="유니크-인덱스-데드락-발생-가능성">[유니크 인덱스 데드락 발생 가능성]</h3>
<blockquote>
<p><strong>ReservationService.java</strong></p>
</blockquote>
<pre><code class="language-java">@Transactional
public Reservation saveAdvanceReservationPayment(
        LoginMember loginMember,
        ReservationRequest reservationRequest,
        PaymentRequest paymentRequest
) {
    Reservation reservation = getReservation(loginMember.getId(), reservationRequest, ReservationStatus.ADVANCE_BOOKED);

    Reservations reservations = new Reservations(reservationRepository.findAll());
    if (reservations.hasSameReservation(reservation)) {
        throw new RoomescapeException(
                DUPLICATE_RESERVATION,
                reservationRequest.date(),
                reservationRequest.themeId(),
                reservationRequest.timeId());
    }
    reservationRepository.save(reservation);
    paymentRepository.save(new Payment(reservation, paymentRequest.paymentKey(), paymentRequest.amount()));
    return reservation;
}</code></pre>
<p>하지만 <code>saveAdvanceReservationPayment()</code>가 호출된 이후 아래와 같이 동일한 트랜잭션 내에서 <code>외부 결제 API</code>를 호출하는 형식으로 구성되어 있기에, </p>
<p><strong>외부 결제 API 호출이 실패하게 된다면 데드락의 발생 가능성이 높아집니다.</strong></p>
<blockquote>
<p><strong>ReservationApplicationService.java</strong></p>
</blockquote>
<pre><code class="language-java">@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 12)
public ReservationPaymentResult saveAdvanceReservationPayment(LoginMember loginMember, ReservationPaymentRequest reservationPaymentRequest) {
    Reservation reservation = reservationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), reservationPaymentRequest.toPaymentRequest());
    **PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest()); // 외부 결제 API 호출**
    return new ReservationPaymentResult(reservation, paymentResult);
}</code></pre>
<h3 id="유니크-인덱스-데드락-발생-상황">[유니크 인덱스 데드락 발생 상황]</h3>
<blockquote>
<p><strong>Transaction1이 insert를 넣고 Transaction2가 insert를 넣고 Transaction3이 insert를 넣은 상황</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/89dbb94d-bfe7-421e-a717-39d18b4872c1/image.png" alt=""></p>
<blockquote>
<p><strong>해당 상황에서 트랜잭션 1번을 ROLLBACK 할 경우 데드락이 발생</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/b6641b24-d524-49d0-9ef4-ec9cb7fa2a43/image.png" alt=""></p>
<p><strong>외부 결제 API의 상태는 저희가 제어할 수 없는 영역이며, 그로 인해 응답의 성공과 실패를 항상 보장할 수 없기에 위와 같은 데드락의 발생 가능성이 충분히 존재한다고 생각되었습니다.</strong></p>
<p><strong>따라서, <code>레코드에 락을 것</code>과 <code>고유 제약 조건을 거는 것</code> 대신 MySQL에서 지원하는 메타데이터 기반의 네임드 락(named lock)을 사용하여 동시성 제어를 시도하였습니다.</strong></p>
<hr>
<h3 id="✅-mysql-분산락---named-lock-사용">✅ [MySQL 분산락 - Named Lock 사용]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/04545c07-494b-4af4-8b5c-761e8d4343da/image.png" alt=""></p>
<p><strong>네임드 락을 사용한 경우에도 테스트가 정상적으로 통과함을 확인했습니다.</strong></p>
<blockquote>
<p>아직 데이터베이스에 저장되지 않은 <code>Reservation</code> 엔티티는 식별할 고유 키가 없으므로, 예약 날짜, 테마 ID, 그리고 타임 ID를 조합하여 고유한 키를 생성하였습니다.</p>
</blockquote>
<p>서비스 계층의 비즈니스 로직에서는 새로운 트랜잭션을 위해 <code>REQUIRES_NEW</code>옵션을 적용하고 있는 상태에서 비즈니스 로직에서 사용하는 <code>Connection Pool</code>과 동일한 <code>Connection Pool</code>을 <code>Lock</code> 점유를 위해 사용하게 된다면 <code>HikariCP Maximum Pool Size</code> 로 인해 <strong>커넥션 고갈 문제</strong>가 발생하게 됩니다.</p>
<hr>
<h3 id="✅-mysql-분산락---named-lock-connection-pool-분리">✅ [MySQL 분산락 - Named Lock Connection Pool 분리]</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/a7219a7b-c6dc-44d5-8c04-e7901e90c9af/image.png" alt=""></p>
<p><strong>따라서 Lock 로직이 사용하는 Connection Pool 과 비즈니스 로직이 사용하는 Connection Pool 을 분리하여 구성하였습니다.</strong> </p>
<p><strong>락과 관련된 작업이 별도의 Connection을 통해 독립적으로 처리되어, 락 획득이나 해제와 같은 작업으로 인해 비즈니스 로직에서 사용하는 Connection Pool이 소진되거나 지연되는 상황을 방지할 수 있었습니다.</strong></p>
<hr>
<h2 id="❌-해결-방안-3--인프라-수준에서의-동시성-제어">❌ [해결 방안 3 : 인프라 수준에서의 동시성 제어]</h2>
<h3 id="redis-분산락">[Redis 분산락]</h3>
<p><strong>네임드 락을 사용하면, 데이터베이스에 일시적인 락 정보가 저장되고, 락을 획득하고 해제하는 쿼리가 매번 발생하여 불필요한 DB 부하가 초래될 수 있습니다.</strong> 이에 <code>Redis</code>를 활용한 분산 락 방식도 고려해 보았습니다. </p>
<p><code>Redis</code>는 메모리를 사용하기 때문에 락을 빠르게 획득하고 해제할 수 있으며, 휘발성 특성 덕분에 네임드 락이 가지는 문제를 일부 해결할 수 있습니다. 하지만, <strong><code>Redis</code>를 이용하여 분산락을 구현하게 되면 인프라 구축에 대한 비용이 발생할 뿐만 아니라 인프라 구축 후 유지보수에 대한 비용 또한 발생하게 된다고 생각합니다.</strong> </p>
<p><code>MySQL</code>은 프로젝트 초기부터 <code>RDBMS</code>로 사용해오고 있었기 때문에 인프라 구축 및 유지보수에 대한 추가 비용이 들지 않았고, 분산락의 사용량이 추가적인 비용을 들일 만큼 많지 않았기 때문에 <code>MySQL</code> 을 이용하게 되었습니다. </p>
<p><strong>동시성 문제가 정말 빈번하게 발생하는지, 그리고 <code>Redis</code>를 도입할 충분한 이유가 있는지 고민한 결과, 그 필요성이 크지 않다고 판단했습니다.</strong></p>
<h1 id="결론">[결론]</h1>
<p>본 프로젝트에서는 토스 페이먼츠 결제 API를 활용한 방탈출 예약 서비스 구현 과정에서, 예약 관련 사전 정보 저장 시 발생한 동시성 문제를 해결하기 위해 여러 수준(애플리케이션, 데이터베이스, 인프라)의 동시성 제어 기법을 검토하였습니다. 초기에는 애플리케이션 수준에서의 synchronized 및 ReentrantLock을 통한 동시성 제어를 시도했으나, <strong>다중 서버 환경에서의 한계로 인해 데이터베이스 수준에서의 접근 방식으로 전환하였습니다.</strong></p>
<p>데이터베이스에서는 비관적 락, 유니크 제약 조건 등 여러 기법을 비교했으며, 특히 유니크 제약 조건은 성능에 미치는 부하를 최소화할 수 있는 장점이 있으나, 외부 결제 API 호출과 결합된 복합 로직에서 <strong>데드락 발생 가능성이 존재함을 확인했습니다.</strong> 이러한 문제를 해결하기 위해 MySQL의 <code>Named Lock</code> 기능을 활용하여 분산 락을 구현하고, 이와 동시에 비즈니스 로직과 락 로직이 사용하는 <code>Connection Pool</code>을 분리하여 <strong>커넥션 고갈 문제를 방지하였습니다.</strong></p>
<p><strong>최종적으로, 여러 동시성 제어 방안 중 인프라에 추가 비용을 들이지 않고 기존 RDBMS 환경 내에서 효율적으로 문제를 해결할 수 있는 Named Lock 기반의 분산 락을 선택하였습니다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Connection Pool Starvation 방지 전략]]></title>
            <link>https://velog.io/@ho-tea/Connection-Pool-Starvation-%EB%B0%A9%EC%A7%80-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@ho-tea/Connection-Pool-Starvation-%EB%B0%A9%EC%A7%80-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Fri, 18 Apr 2025 10:32:17 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 토스 페이먼츠 결제 API를 활용한 방탈출 예약 서비스입니다.</strong></p>
</blockquote>
<p>해당 글은 아래의 <code>3가지 조건</code>에 기반하여 결제 API 연동 과정에서 트랜잭션 관리, 원자성 보장, 데이터 무결성 확보 등을 위해 선택한 기술적 접근 방식에 대해 설명합니다. </p>
<ul>
<li><input checked="" disabled="" type="checkbox"> <strong>조건 1 : 결제 도메인의 경우, 돈이 오가는 민감한 트랜잭션을 처리하므로 데이터의 정확성과 일관성이 가장 중요하다.</strong></li>
</ul>
<p><strong>→ <code>트랜잭션 분리 전략</code> = <a href="https://velog.io/@ho-tea/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B0%95%ED%95%9C%EC%B5%9C%EC%A2%85-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%B3%B4%EC%9E%A5">🔗 결제 API - 트랜잭션 분리 전략으로 강한/최종 일관성 보장</a></strong></p>
<ul>
<li><input checked="" disabled="" type="checkbox"> <strong>조건 2 : 방탈출을 예약하는 과정에서 사용자는 출금 결과와 예약 결과를 알고 넘어가야하기 때문에 동기로 구성해야 한다.</strong></li>
</ul>
<p><strong>→ <code>모든 작업을 동기 방식으로 구성</code>  = <a href="https://velog.io/@ho-tea/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B0%95%ED%95%9C%EC%B5%9C%EC%A2%85-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%B3%B4%EC%9E%A5">🔗 결제 API - 트랜잭션 분리 전략으로 강한/최종 일관성 보장</a></strong></p>
<ul>
<li><strong>조건 3 : 사용자는 예약이 즉시 완료되기를 기대하므로 Time Out을 설정해야 한다.</strong></li>
</ul>
<hr>
<h1 id="요약">[요약]</h1>
<blockquote>
<p>해당 글에서는 <code>조건 3</code>을 충족하는 과정을 담고 있으며, <code>조건 1</code>과 <code>조건 2</code>를 충족하는 과정은 
<a href="https://velog.io/@ho-tea/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B0%95%ED%95%9C%EC%B5%9C%EC%A2%85-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%B3%B4%EC%9E%A5">🔗 결제 API - 트랜잭션 분리 전략으로 강한/최종 일관성 보장</a>에서 확인 가능합니다.</p>
</blockquote>
<p><strong>조건 3 : 사용자는 예약이 즉시 완료되기를 기대하므로 Time Out을 설정해야 한다.</strong></p>
<p><strong>결론적으로 <code>Transaction timeout</code>과 <code>Http timeout</code>을 설정하는것으로 해결하였습니다.</strong></p>
<hr>
<h1 id="문제-상황">[문제 상황]</h1>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/d3ea0e28-0959-4fbc-a411-cb7a8c9a302b/image.png" alt=""></p>
<p><strong>외부 결제 API와 예약/결제 사전 정보를 DB에 저장하는 작업</strong>을 하나의 트랜잭션으로 묶게 되면, 외부 API 호출에 소요되는 지연 시간 동안 내부 DB 커넥션이 계속해서 점유되므로 커넥션 고갈 문제가 발생할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/3fedc6bc-798d-474f-9b0a-2f4c578306e5/image.png" alt=""></p>
<p>이러한 상황은 내부 DB 접근에 제약을 주어 <strong>전체 시스템의 성능 저하 및 장애로 이어질 위험</strong>이 있다고 판단하였습니다.</p>
<p>또한 <strong>사용자는 예약이 즉시 완료되기를 기대하지만,</strong> 지연 시간 동안 사용자 경험이 저하되어 불만이 쌓이고, 이는 궁극적으로 고객 이탈로 이어질 수 있다고 판단하였습니다.</p>
<hr>
<h1 id="해결-방안">[해결 방안]</h1>
<ul>
<li><strong>Transaction timeout</strong> : 내부 DB 작업의 시간 제한을 통해 데이터 무결성을 보장</li>
<li><strong>Http timeout :</strong> 외부 시스템과의 통신에서 응답 지연을 방지</li>
</ul>
<h2 id="✅-transaction-timeout">✅ [Transaction Timeout]</h2>
<blockquote>
<p>외부 결제 API 호출 전에 <strong>DB에 예약/결제 사전 정보를 저장하는 작업과 외부 결제 API 호출은 하나의 트랜잭션</strong>으로 처리하고, 그 후 <strong>예약/결제 상세 정보를 저장하는 작업</strong>은 <code>별도의 트랜잭션으로 분리</code></p>
</blockquote>
<pre><code class="language-java">// ReservationFacade.java

public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    ReservationPaymentResult reservationPaymentResult = reservationApplicationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest);
    try {
        return reservationApplicationService.saveDetailedReservationPayment(reservationPaymentResult.reservation(), reservationPaymentResult.paymentResult());
    } catch (Exception e) {
        return new ReservationPaymentResponse(
                ReservationResponse.from(reservationPaymentResult.reservation()),
                PaymentResponse.from(reservationPaymentResult.paymentResult()));
    }
}

// ReservationApplicationService.java

**// [사전 예약 정보 &amp; 결제 정보 DB 저장]**
**@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 6)**
public ReservationPaymentResult saveAdvanceReservationPayment(LoginMember loginMember, ReservationPaymentRequest reservationPaymentRequest) {
    Reservation reservation = reservationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), reservationPaymentRequest.toPaymentRequest());
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    return new ReservationPaymentResult(reservation, paymentResult);
}

**// [상세 예약 정보 &amp; 결제 정보 DB 저장]**
**@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 1)**
public ReservationPaymentResponse saveDetailedReservationPayment(
        Reservation reservation,
        PaymentResult paymentResult
) {
    return reservationService.confirmReservationPayment(reservation, paymentResult);
}</code></pre>
<p>각 트랜잭션에 <code>timeout</code>을 설정함으로써 비정상적인 지연 상황에서 자원 점유를 제한하고, 시스템 전체의 <strong>응답성</strong>을 유지할 수 있습니다.</p>
<blockquote>
<p>데이터베이스에 여러 레코드를 삽입하고 외부 결제 시스템과 통신하는 등 상대적으로 시간이 소요될 수 있는 작업 → <code>timeout 6초</code>
응답 지연 없이 빠르게 작업이 완료되어야 하는 작업 → <code>timeout 1초</code></p>
</blockquote>
<h2 id="✅-http-timeout">✅ [Http Timeout]</h2>
<pre><code class="language-java">// RestClientConfig.java

// 커넥션 풀 매니저 생성 메서드
private PoolingHttpClientConnectionManager getPoolingHttpClientConnectionManager() {
    // 커넥션 구성: **커넥션 생성 및 소켓 타임아웃을 설정**
    ConnectionConfig connectionConfig = ConnectionConfig.custom()
            .setConnectTimeout(connectionTimeOut)
            .setSocketTimeout(socketTimeOut)
            .build();

    return PoolingHttpClientConnectionManagerBuilder.create()
            .setDefaultConnectionConfig(connectionConfig)
            .build();
}

// 요청 타임아웃 설정 메서드
private RequestConfig getRequestConfig() {
    **// 응답 수신 시간 제한(read timeout)을 설정**
    return RequestConfig.custom()
            .setResponseTimeout(readTimeOut)
            .build();
}

// HttpClient 생성 메서드
private CloseableHttpClient getHttpClient(PoolingHttpClientConnectionManager connManager, RequestConfig requestConfig) {
    return HttpClients.custom()
            .setConnectionManager(connManager) // 커넥션 풀 매니저를 사용하여 커넥션 재사용
            .setDefaultRequestConfig(requestConfig) // 요청 관련 타임아웃 설정 적용
            **// 재시도 전략 설정: 최대 3회 재시도, 1초 간격으로 재시도**
            .setRetryStrategy(new DefaultHttpRequestRetryStrategy(3, Timeout.ofSeconds(1)))
            .build();
}</code></pre>
<p>지연시간을 제한하기 위해 <code>Connection Timeout</code>, <code>Socket Timeout</code>, <code>Read Timeout</code>을 설정하여 정해진 응답 시간 내에 응답이 오지 않으면 호출이 실패처리하는 것으로 구성하였습니다. 또한, 일시적인 네트워크 오류나 서버 과부하로 인해 외부 API 호출이 실패할 경우를 대비하여, 지정된 횟수만큼 재시도하는 <code>Retry</code> 로직을 추가로 구성하였습니다.</p>
<blockquote>
<p><code>ConnectionTimeOut</code> : 3초</p>
<ul>
<li>대부분의 시스템에서 <code>InitRTO</code>의 값이 1초로 설정되어 있다고 판단 (<a href="https://datatracker.ietf.org/doc/html/rfc6298">RFC 6298</a>)</li>
<li><code>Syn</code> 패킷 유실, <code>Syn + Ack</code> 패킷 유실, <code>Ack</code> 패킷 유실 과 같이 연결이 지연되어 실패하는 경우를 3가지로 판단하여 3초로 설정</li>
</ul>
<p><code>ReadTimeOut</code> : 30초</p>
<ul>
<li>해당 부분은 <a href="https://docs.tosspayments.com/resources/glossary/timeout">토스 페이먼츠</a>에 기술되어있는 것을 확인하여 30초로 구성</li>
</ul>
<p><code>SocketTimeOut</code> : 2초</p>
<ul>
<li>패킷 하나가 유실되고 다시 재전송되기까지의 시간을 고려하여 구성</li>
</ul>
</blockquote>
<hr>
<h1 id="결론">[결론]</h1>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/5f0b41fa-0991-4be1-bd66-1cb57943cf0b/image.png" alt=""></p>
<p>외부 API와 내부 DB 작업을 하나의 트랜잭션으로 묶게 되면, 외부 API 호출에 소요되는 지연 시간 동안 내부 DB 커넥션이 계속 점유되어 커넥션 고갈 문제가 발생할 위험이 있습니다. 또한, <strong>사용자는 예약이 즉시 완료되기를 기대하지만</strong>, 이러한 지연으로 인해 응답 시간이 길어지면 사용자 경험이 크게 저하될 수 있습니다.</p>
<p>이를 방지하기 위해, </p>
<ul>
<li>각 트랜잭션에 <code>timeout</code>을 설정함으로써 비정상적인 지연 상황에서 자원 점유를 제한하고, 시스템 전체의 <strong>응답성</strong>을 유지할 수 있습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/fcb8c6b0-f923-4152-9daa-af3610674a51/image.png" alt=""></p>
<ul>
<li><code>Connection Timeout</code>, <code>Socket Timeout</code>, <code>Read Timeout</code>을 설정하여 각 단계의 지연 시간을 제한했습니다. 또한, 일시적인 네트워크 오류나 서버 과부하로 인해 외부 API 호출이 실패할 경우를 대비하여, 지정된 횟수만큼 재시도하는 <code>Retry</code> 로직을 추가로 구성하였습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[트랜잭션 분리 전략으로 강한/최종 일관성 보장]]></title>
            <link>https://velog.io/@ho-tea/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B0%95%ED%95%9C%EC%B5%9C%EC%A2%85-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%B3%B4%EC%9E%A5</link>
            <guid>https://velog.io/@ho-tea/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B0%95%ED%95%9C%EC%B5%9C%EC%A2%85-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%B3%B4%EC%9E%A5</guid>
            <pubDate>Fri, 18 Apr 2025 10:20:14 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>본 프로젝트는 토스 페이먼츠 결제 API를 활용한 방탈출 예약 서비스입니다.</strong></p>
</blockquote>
<p>해당 글은 아래의 <code>3가지 조건</code>에 기반하여 결제 API 연동 과정에서 트랜잭션 관리, 원자성 보장, 데이터 무결성 확보 등을 위해 선택한 기술적 접근 방식에 대해 설명합니다. </p>
<ul>
<li><strong>조건 1 : 결제 도메인의 경우, 돈이 오가는 민감한 트랜잭션을 처리하므로 데이터의 정확성과 일관성이 가장 중요하다.</strong></li>
<li><strong>조건 2 : 방탈출을 예약하는 과정에서 사용자는 출금 결과와 예약 결과를 알고 넘어가야하기 때문에 동기로 구성해야 한다.</strong></li>
<li><strong>조건 3 : 사용자는 예약이 즉시 완료되기를 기대하므로 Time Out을 설정해야 한다.</strong></li>
</ul>
<hr>
<h1 id="요약">[요약]</h1>
<blockquote>
<p>해당 글에서는 <code>조건 1</code>과 <code>조건 2</code>를 충족하는 과정을 담고 있으며, <code>조건 3</code>을 충족하는 과정은 
<a href="https://velog.io/@ho-tea/Connection-Pool-Starvation-%EB%B0%A9%EC%A7%80-%EC%A0%84%EB%9E%B5">🔗 결제 API - Connection Pool Starvation 방지 전략</a>에서 확인 가능합니다.</p>
</blockquote>
<p><strong>결론적으로 <code>[해결 방안 3 : 새로운 구조에 트랜잭션 분리 전략 도입]</code>을 선택하는 것으로 2가지 조건을 모두 충족할 수 있었습니다.</strong></p>
<hr>
<h1 id="문제-상황">[문제 상황]</h1>
<p>결제 API를 사용하는 과정에서 <strong>외부 결제 API 호출</strong>과 <strong>예약 정보 &amp; 결제 정보 DB 저장</strong> 로직을 하나의 트랜잭션으로 관리하여 데이터 일관성을 보장해야 한다고 판단했습니다.
그러나 하나의 트랜잭션 내에서 <strong>외부 결제 API 호출 후 예약 정보 &amp; 결제 정보 DB 저장 로직을 수행하는 과정</strong>에서 <strong><code>문제</code></strong>가 발생하였습니다.</p>
<p>(1. 외부 결제 API 호출 → 2. 예약 정보 &amp; 결제 정보 DB 저장 로직 실행 순서)</p>
<pre><code class="language-java">// ReservationApplicationService.java

@Transactional
public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
        PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest()); // 외부 결제 API 호출
    return reservationService.saveReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), paymentResult); // 내부 DB 저장 로직
}
</code></pre>
<p>외부 API 호출에 실패하면 <strong>예약 정보 &amp; 결제 정보 DB 저장</strong> 로직이 실행되지 않아 전체 트랜잭션이 롤백되므로 문제가 발생하지 않습니다.</p>
<p><strong>그러나 외부 API 호출은 성공했지만, 예약 정보 &amp; 결제 정보 DB 저장이 실패</strong>하면 <strong><code>데이터 일관성</code></strong>이 보장되지 않는 문제가 발생하게 됩니다.</p>
<hr>
<h1 id="해결방안">[해결방안]</h1>
<h2 id="❌-해결-방안-1--결제-취소-api-호출-로직을-구현한다">❌ [해결 방안 1 : 결제 취소 API 호출 로직을 구현한다.]</h2>
<h3 id="해결-방안-1-1--try-catch-로-구현">[해결 방안 1-1 : <code>try-catch</code> 로 구현]</h3>
<pre><code class="language-java">@Transactional
public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    try {
        // 예약 정보 &amp; 결제 정보 DB 저장
        return reservationService.saveReservationPayment(
                loginMember,
                reservationPaymentRequest.toReservationRequest(),
                paymentResult
        );
    } catch (Exception e) {
        // 예약 정보 &amp; 결제 정보 DB 저장에 실패한 경우, 결제 취소 요청을 보냄
        paymentClient.cancel(reservationPaymentRequest.paymentKey(), new CancelReason(&quot;관리자 권한 취소&quot;));
        throw e;
    }
}</code></pre>
<h3 id="해결-방안-1-2--트랜잭션-동기화를-통한-보상-처리">[해결 방안 1-2 : 트랜잭션 동기화를 통한 보상 처리]</h3>
<pre><code class="language-java">@Transactional
public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    // 트랜잭션 동기화 콜백을 등록하여, 트랜잭션이 롤백될 경우 보상 처리(결제 취소)를 수행함
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
        @Override
        public void afterCompletion(int status) {
            if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
                // 트랜잭션 롤백 시 결제 취소 요청 수행
                try {
                    paymentClient.cancel(reservationPaymentRequest.paymentKey(), new CancelReason(&quot;관리자 권한 취소&quot;));
                } catch (Exception ex) {
                    // 보상 처리 실패 시 로그 등을 통해 모니터링
                    System.err.println(&quot;결제 취소 보상 처리 실패: &quot; + ex.getMessage());
                }
            }
        }
    });
    return reservationService.saveReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), paymentResult);
}</code></pre>
<blockquote>
<p><strong>보상 트랜잭션 Test code</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/f708fab2-b269-4c94-bbd4-20ff79f6e5c4/image.png" alt=""></p>
<h3 id="해결방안-1-1-1-2-로-해결이-가능한가">[해결방안 1-1, 1-2 로 해결이 가능한가?]</h3>
<ul>
<li><strong>해결방안 1-1 : <code>try-catch</code></strong>를 통해 예약 정보 &amp; 결제 정보 DB 저장에 실패한 경우 결제 취소 API를 호출하는 방식</li>
<li><strong>해결방안 1-2 : <code>TransactionSynchronizationManager</code></strong>를 통해 트랜잭션 롤백 시 결제 취소 API를 호출하는 콜백을 등록하는 방식</li>
</ul>
<p>2가지 방식 모두 <strong>외부 API의 결제 취소 로직이 실패할 가능성</strong>이 있기 때문에 이 방법은 완벽한 해결책이 아니라고 판단하였습니다.</p>
<hr>
<h2 id="❌-해결방안-2--구조-개선">❌ [해결방안 2 : 구조 개선]</h2>
<blockquote>
<p><strong>[새로운 구조]</strong> 
<strong>1. 사전</strong> <strong>예약 정보 &amp; 결제 정보</strong> <strong>DB 저장
2. 외부 결제 API 호출
3. 상세</strong> <strong>예약 정보 &amp; 결제 정보</strong> <strong>DB 저장</strong></p>
</blockquote>
<pre><code class="language-java">public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
        **// 1. 사전 예약 정보 &amp; 결제 정보 DB 저장**
    Reservation reservation = reservationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest);

        **// 2. 외부 결제 API 호출**
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());

    **// 3. 상세 예약 정보 &amp; 결제 정보 DB 저장**
    return reservationService.saveDetailedReservationPayment(reservation, paymentResult);
}</code></pre>
<p>DB에 외부 API 호출에 필요한 사전 정보를 저장한 후 API를 호출하고, 그 결과로 응답 데이터를 DB에 업데이트하는 방식으로 처리할 수 있으며, DB 저장에 실패하면 바로 리턴하도록 구현할 수 있습니다. 이 방식의 장점은 구현이 간단하다는 점입니다.</p>
<p>그러나 이 방식에서 <strong>사전 예약 정보 &amp; 결제 정보 DB 저장</strong>이 성공하고 외부 API 호출도 성공한 후, 마지막 <strong>상세 예약 정보 &amp; 결제 정보 DB 저장</strong>에 실패하는 경우를 생각해 보면, </p>
<p><strong>세 작업을 모두 하나의 트랜잭션으로 묶어두면</strong> <strong>마지막 DB 호출 실패 시 외부 API 결제를 롤백하기 위한 API를 호출해야 하는 문제가 발생하게 됩니다.</strong></p>
<p><strong><code>이는 해결방안 1에서 발생하는 문제와 동일해집니다</code></strong>.</p>
<p><strong>따라서, 서로 다른 트랜잭션으로 처리하는 방안을 고려해 볼 필요가 있습니다.</strong></p>
<hr>
<h2 id="✅-해결-방안-3--새로운-구조에-트랜잭션-분리-전략-도입">✅ [해결 방안 3 : 새로운 구조에 트랜잭션 분리 전략 도입]</h2>
<p>외부 결제 API 호출 전에 <strong>DB에 예약/결제 사전 정보를 저장하는 작업과 외부 결제 API 호출은 하나의 트랜잭션</strong>으로 처리하고, 그 후 <strong>예약/결제 상세 정보를 저장하는 작업</strong>은 <code>별도의 트랜잭션으로 분리</code>하는 방식을 선택하였습니다.</p>
<p>(이 경우, 만약 예약/결제 상세 정보 저장에 실패하더라도 해당 트랜잭션만 롤백되고, 예약/결제 사전 정보는 이미 커밋되어 남게 됩니다.)</p>
<pre><code class="language-java">// ReservationFacade.java

public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    ReservationPaymentResult reservationPaymentResult = reservationApplicationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest);
    try {
        return reservationApplicationService.saveDetailedReservationPayment(reservationPaymentResult.reservation(), reservationPaymentResult.paymentResult());
    } catch (Exception e) {
        return new ReservationPaymentResponse(
                ReservationResponse.from(reservationPaymentResult.reservation()),
                PaymentResponse.from(reservationPaymentResult.paymentResult()));
    }
}

// ReservationApplicationService.java

**// [사전 예약 정보 &amp; 결제 정보 DB 저장]**
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ReservationPaymentResult saveAdvanceReservationPayment(LoginMember loginMember, ReservationPaymentRequest reservationPaymentRequest) {
    Reservation reservation = reservationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), reservationPaymentRequest.toPaymentRequest());
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    return new ReservationPaymentResult(reservation, paymentResult);
}

**// [상세 예약 정보 &amp; 결제 정보 DB 저장]**
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ReservationPaymentResponse saveDetailedReservationPayment(
        Reservation reservation,
        PaymentResult paymentResult
) {
    return reservationService.confirmReservationPayment(reservation, paymentResult);
}</code></pre>
<p>이처럼 서로 다른 트랜잭션으로 묶이게 된다면, 마지막으로 <strong>상세 예약 정보 &amp; 결제 정보 DB 저장</strong> 로직이 실패하더라도 회원의 예약 내역이 이미 저장되어 있어 당장 서비스 제공에는 문제가 발생하지 않습니다.</p>
<ul>
<li><input checked="" disabled="" type="checkbox"> <strong>결제 도메인의 경우, 돈이 오가는 민감한 트랜잭션을 처리하므로 데이터의 정확성과 일관성이 가장 중요하다.</strong></li>
</ul>
<p><strong>→ <code>트랜잭션 분리 전략</code></strong></p>
<ul>
<li><input checked="" disabled="" type="checkbox"> <strong>방탈출을 예약하는 과정에서 사용자는 출금 결과와 예약 결과를 알고 넘어가야하기 때문에 동기로 구성해야 한다.</strong></li>
</ul>
<p><strong>→ <code>모든 작업을 동기 방식으로 구성</code></strong></p>
<p>이와 같은 상황에서는 외부 결제 DB와 내부 DB 간의 상태를 정기적으로 비교하여, 상세 정보가 올바르게 저장되지 않아 불일치가 발생할 경우 이를 신속하게 감지하고 해결할 수 있는 <strong>상태 확인 및 복구 메커니즘</strong>을 마련하는 것이 중요하다고 판단하였습니다. </p>
<p>또한, 외부 API를 사용할 때 예상한 응답값을 기반으로 정상적으로 로직이 수행되는지 테스트를 진행하는 것이 필수적이라고 판단하였습니다.</p>
<hr>
<h3 id="✅-상태-확인-및-복구-매커니즘---scheduler">✅ [상태 확인 및 복구 매커니즘 - Scheduler]</h3>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 3 * * *&quot;)
public void checkPaymentConsistency() {
    log.info(&quot;결제 일관성 검사 시작 시각: {}&quot;, LocalDateTime.now());
    List&lt;Reservation&gt; pendingReservations = reservationService.findPendingDetailedPayments();

    pendingReservations.forEach(
            reservation -&gt; {
                try {
                    Payment payment = paymentRepository.findByReservationId(reservation.getId())
                            .orElseThrow(() -&gt; new RoomescapeException(NOT_FOUND_RESERVATION_PAYMENT, reservation.getId()));
                    PaymentResult paymentResult = paymentClient.lookup(payment.getPaymentKey());
                    if (paymentResult.paymentKey().equals(payment.getPaymentKey())) {
                        reservationService.confirmReservationPayment(reservation, paymentResult);
                    }
                } catch (Exception ex) {
                    log.error(&quot;예약번호 {} 결제 일관성 검사 중 오류 발생: {}&quot;, reservation.getId(), ex.getMessage(), ex);
                }
            }
    );
    log.info(&quot;결제 일관성 검사 완료 시각: {}&quot;, LocalDateTime.now());
}</code></pre>
<ul>
<li>상태 확인 및 복구 메커니즘은 스케줄러를 통해 구현하였습니다. 사람이 가장 적게 이용하는 새벽 3시에 일관성 보장 작업이 수행되도록 구성하였고, 마지막으로 내부 DB 정보 업데이트 로직이 실패한 시점을 추적할 수 있도록 관련 로그를 기록하고 있습니다.</li>
</ul>
<hr>
<h3 id="✅-외부-결제-api-테스트">✅ [외부 결제 API 테스트]</h3>
<pre><code class="language-java">@DisplayName(&quot;적합한 인자를 통한 결제 요청 시 성공한다.&quot;)
@Test
void purchase() throws JsonProcessingException {
    String endPoint = &quot;/v1/payments/confirm&quot;;
    mockServer
            .expect(requestTo(url + endPoint))
            .andExpect(content().json(objectMapper.writeValueAsString(PAYMENT_REQUEST)))
            .andExpect(method(HttpMethod.POST))
            .andRespond(withSuccess(objectMapper.writeValueAsString(PAYMENT_INFO), MediaType.APPLICATION_JSON));

    assertThat(tossPaymentClient.purchase(PAYMENT_REQUEST)).isEqualTo(PAYMENT_INFO);
    mockServer.verify();
}</code></pre>
<ul>
<li><code>MockRestServiceServer</code>를 활용해 외부 API 호출을 모킹하였습니다. 이를 통해 실제 외부 API에 의존하지 않고도 예상한 응답을 기반으로 테스트 환경을 구축할 수 있었습니다.</li>
</ul>
<hr>
<h1 id="결론">[결론]</h1>
<p>결론적으로, 본 프로젝트에서는 결제 도메인에서 요구되는 <strong>데이터 정확성과 강한 일관성</strong>을 보장하기 위해 기존의 단일 트랜잭션 방식 대신 <strong>트랜잭션 분리 전략</strong>을 도입하였습니다. 이를 통해 외부 결제 API 호출과 예약,결제 정보의 상세 저장 작업을 별도의 트랜잭션으로 분리함으로써, 외부 API 호출 후 DB 저장 과정에서 발생할 수 있는 오류로 인한 데이터 불일치를 효과적으로 방지할 수 있었습니다.</p>
<p>또한, 상태 확인 및 복구 메커니즘을 스케줄러를 통해 주기적으로 실행함으로써, 내부 DB와 외부 결제 상태의 불일치 상황을 감지하고 보완할 수 있도록 하였습니다.</p>
<p>이러한 기술적 접근 방식은 결제, 예약 등 민감한 트랜잭션이 포함된 도메인에서 사용자에게 신뢰받는 서비스를 제공하기 위한 필수 요소로, 올바른 서비스 제공과 데이터 무결성을 동시에 확보할 수 있는 해결책이라고 생각되었습니다.</p>
<p>사용자 관점에서는 <strong><code>강한 일관성</code></strong>을 제공하면서 내부적으로는 독립적인 트랜잭션과 후속 보정 과정을 통해 <strong><code>최종 일관성</code></strong> 달성</p>
<p>강한 일관성을 매 순간 보장하려면 시스템의 <strong>확장성이나 가용성이 크게 저해될 수 있기 때문에</strong>, 일부 경우에는 <code>약간의 지연 후에</code> 모든 데이터가 일관된 상태에 도달하는 <strong>최종 일관성</strong>을 선택하는 것이 현실적인 타협점이라고 생각됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TransactionAutoConfiguration 그 후로... ]]></title>
            <link>https://velog.io/@ho-tea/TransactionAutoConfiguration-%EA%B7%B8-%ED%9B%84%EB%A1%9C</link>
            <guid>https://velog.io/@ho-tea/TransactionAutoConfiguration-%EA%B7%B8-%ED%9B%84%EB%A1%9C</guid>
            <pubDate>Wed, 16 Apr 2025 19:31:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h3 id="오늘의-목표">오늘의 목표</h3>
<p>스프링에서는 어떻게 <code>@Transactional</code> 하나로 트랜잭션이 적용될 수 있는지 전반적인 과정에 대해 알아보겠습니다.</p>
</blockquote>
<p><a href="https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8">자동구성..근데-이제-Data-JPA를-곁들인</a> 을 통해 <code>Data JPA</code>를 사용하게 되었을 때 어떠한 자동구성이 일어나게 되는지를 확인하면서 마지막에 <code>TransactionAutoConfiguration</code>을 잠깐 다루었습니다. 
<code>TransactionAutoConfiguration</code>을 통해 트랜잭션 관리를 위한 AOP 설정 및 인프라가 구성이 되는데, 해당 글은 <code>TransactionAutoConfiguration</code>에서부터 시작해 스프링에서 어떻게 <code>@Transactional</code> 하나로 트랜잭션이 적용될 수 있는지 전반적인 과정에 대해 알아보겠습니다.</p>
<h3 id="transactionautoconfiguration">TransactionAutoConfiguration</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/88c9af17-f637-46e9-a520-d17c1bad5d3c/image.png" alt=""></p>
<p><code>TransactionAutoConfiguration</code>의 내부 코드를 자세히 살펴보면 아래와 같이 구성되어 있습니다.</p>
<pre><code class="language-java">// 트랜잭션 자동 구성 클래스
@AutoConfiguration // Spring Boot의 자동 구성 클래스임을 선언
@ConditionalOnClass(PlatformTransactionManager.class) // Classpath에 트랜잭션 관련 클래스가 있을 경우에만 활성화
public class TransactionAutoConfiguration {

    /**
     * 리액티브 트랜잭션 처리용 빈 등록
     * - ReactiveTransactionManager가 하나만 있을 경우, TransactionalOperator 빈을 자동 등록
     */
    @Bean
    @ConditionalOnMissingBean // 이미 동일한 타입의 빈이 없는 경우에만 등록
    @ConditionalOnSingleCandidate(ReactiveTransactionManager.class) // 후보가 하나일 경우에만 등록
    public TransactionalOperator transactionalOperator(ReactiveTransactionManager transactionManager) {
        return TransactionalOperator.create(transactionManager);
    }

    /**
     * 일반 트랜잭션을 위한 TransactionTemplate 구성
     * - PlatformTransactionManager가 단일 후보로 있을 경우 적용
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnSingleCandidate(PlatformTransactionManager.class)
    public static class TransactionTemplateConfiguration {

        /**
         * TransactionOperations 빈이 없는 경우 TransactionTemplate 등록
         * - 명시적 트랜잭션 처리 시 사용됨
         */
        @Bean
        @ConditionalOnMissingBean(TransactionOperations.class)
        public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
            return new TransactionTemplate(transactionManager);
        }

    }

    /**
     * 트랜잭션 AOP (프록시 기반 @Transactional) 활성화 구성
     * - 트랜잭션 매니저가 있고, 수동으로 @EnableTransactionManagement를 안 붙였을 경우에만 적용
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnBean(TransactionManager.class) // 트랜잭션 매니저가 있을 때만 적용
    @ConditionalOnMissingBean(AbstractTransactionManagementConfiguration.class) // 개발자가 수동 설정하지 않았을 경우
    public static class EnableTransactionManagementConfiguration {

        /**
         * JDK 동적 프록시 방식 (@EnableTransactionManagement(proxyTargetClass = false))
         * - 인터페이스 기반 프록시
         * - spring.aop.proxy-target-class=false일 경우 적용
         */
        @Configuration(proxyBeanMethods = false)
        @EnableTransactionManagement(proxyTargetClass = false)
        @ConditionalOnProperty(prefix = &quot;spring.aop&quot;, name = &quot;proxy-target-class&quot;, havingValue = &quot;false&quot;)
        public static class JdkDynamicAutoProxyConfiguration {
            // 설정용 빈만 존재, 실제 로직은 없음
        }

        /**
         * CGLIB 기반 클래스 프록시 (@EnableTransactionManagement(proxyTargetClass = true))
         * - 클래스 기반 프록시
         * - 기본값 (matchIfMissing = true)
         */
        @Configuration(proxyBeanMethods = false)
        @EnableTransactionManagement(proxyTargetClass = true)
        @ConditionalOnProperty(prefix = &quot;spring.aop&quot;, name = &quot;proxy-target-class&quot;, havingValue = &quot;true&quot;,
                matchIfMissing = true)
        public static class CglibAutoProxyConfiguration {
            // 설정용 빈만 존재, 실제 로직은 없음
        }

    }

    /**
     * AspectJ 트랜잭션 처리 지원
     * - @Transactional을 AspectJ 방식으로 사용하는 경우
     * - AbstractTransactionAspect 빈이 존재해야 적용됨
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnBean(AbstractTransactionAspect.class)
    static class AspectJTransactionManagementConfiguration {

        /**
         * AbstractTransactionAspect는 지연 초기화 대상에서 제외해야 함 (즉시 초기화 필요)
         */
        @Bean
        static LazyInitializationExcludeFilter eagerTransactionAspect() {
            return LazyInitializationExcludeFilter.forBeanTypes(AbstractTransactionAspect.class);
        }

    }

}
</code></pre>
<p><code>TransactionAutoConfiguration</code>은 Spring Boot에서 <code>@EnableTransactionManagement</code>를 자동으로 등록해주는 역할을 합니다.
즉, 스프링 부트 환경이 아니면 개발자가 직접 <code>@EnableTransactionManagement</code>를 붙여야 합니다.</p>
<p><strong>TransactionAutoConfiguration은 자동 등록의 트리거일 뿐</strong>
여기서 주의깊게 보아야할 부분은 <strong><code>TransactionTemplateConfiguration</code></strong> 과 <strong><code>EnableTransactionManagementConfiguration</code></strong> 입니다.</p>
<h3 id="transactiontemplateconfiguration">TransactionTemplateConfiguration</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/739d070c-3c43-43f0-95c5-00051fb27437/image.png" alt=""></p>
<p><code>TransactionTemplate</code>은 Spring에서 명시적 프로그래밍 방식으로 트랜잭션을 관리할 수 있게 해주는 템플릿 클래스입니다. 보통 <code>@Transactional</code>은 선언적 방식인데, 이건 프로그래밍 방식으로 트랜잭션을 제어하고자 할 때 사용합니다.</p>
<pre><code class="language-java">// 사용 예시
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
String result = txTemplate.execute(status -&gt; {
    // 트랜잭션 안에서 실행할 코드
    someRepository.save(...);
    return &quot;성공&quot;;
});
</code></pre>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/abed530f-af90-4977-a5c3-8f6a6806a822/image.png" alt=""> </p>
<blockquote>
<p>초기에는 트랜잭션을 사용하기 위해 <code>PlatformTransactionManager</code>를 직접 호출해 트랜잭션을 시작하고, 성공 시 커밋하거나 예외 발생 시 롤백하는 코드를 작성해야 했습니다. 이로 인해 동일한 트랜잭션 제어 로직이 여러 클래스에서 반복적으로 사용되는 문제가 발생했습니다.</p>
</blockquote>
<p>이를 해결하기 위해 <strong><code>TransactionTemplate</code></strong>이 도입되었습니다. <code>TransactionTemplate</code>을 사용하면 템플릿 콜백 패턴을 통해 트랜잭션 처리 코드를 일관되게 작성할 수 있고, 트랜잭션 시작, 커밋, 롤백 로직을 템플릿이 대신 처리해줍니다. 이 덕분에 반복되는 트랜잭션 제어 코드를 제거할 수 있게 되었습니다.</p>
<p>하지만 여전히 문제는 남아 있었습니다. <code>TransactionTemplate</code>을 사용하는 방식에서도 <strong>비즈니스 로직과 트랜잭션 처리 로직</strong>이 같은 클래스 안에 섞여 있어 두 관심사를 하나의 클래스에서 처리하게 되므로 관심사 분리가 어렵고 유지보수가 불편하다는 점입니다.</p>
<p>이 문제를 해결하기 위해 스프링은 AOP 기반의 선언적 트랜잭션 처리 방식을 제공합니다. <code>@Transactional</code> 어노테이션을 사용하면, 트랜잭션 경계 설정을 <strong>AOP 프록시가 대신 처리</strong>해주기 때문에, 개발자는 오직 비즈니스 로직에만 집중할 수 있습니다. 트랜잭션의 시작과 종료, 롤백 여부 등은 AOP 프레임워크가 자동으로 처리합니다.</p>
<h4 id="q-만약-transactional을-사용한다면-transactiontemplate을-사용하지-않는거네">Q. 만약 @Transactional을 사용한다면 TransactionTemplate을 사용하지 않는거네?</h4>
<p>맞습니다. <code>@Transactional</code> 을 사용하게 된다면 AOP 기반의 트랜잭션 처리를 이용하는 것으로  <code>TransactionTemplate</code>은 전혀 등장하지 않습니다.
대신 <strong><code>TransactionInterceptor</code></strong> 가 메서드 호출을 가로채서 기존에 <code>TransactionTemplate</code>이 수행했던 트랜잭션 시작, 커밋, 롤백 로직을 대신 수행해주게 됩니다.</p>
<h3 id="enabletransactionmanagementconfiguration">EnableTransactionManagementConfiguration</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/4c36bb9f-a69c-4ae1-a22f-9abb8ab44d5d/image.png" alt=""></p>
<p>해당 클래스는 Spring Boot에서 <code>@EnableTransactionManagement</code>를 자동 설정하는 <strong>자동 구성 클래스</strong>입니다. 구체적으로는, AOP 기반 트랜잭션 처리에서 JDK 동적 프록시(JDK Proxy)와 CGLIB 프록시 중 어떤 것을 사용할지 자동으로 설정해주는 역할을 합니다.</p>
<h4 id="enabletransactionmanagement">@EnableTransactionManagement...?</h4>
<p><code>@EnableTransactionManagement</code>는 Spring에서 <code>@Transactional</code>이 실제로 동작하도록 활성화해주는 어노테이션입니다. -&gt; <strong>트랜잭션 AOP 설정을 활성화하는 역할 (환경 세팅)</strong></p>
<ul>
<li><code>@EnableTransactionManagement</code> 없으면 <code>@Transactional</code>은 무시됨 (프록시가 안 만들어짐)</li>
<li><code>@EnableTransactionManagement</code>만 있고 <code>@Transactional</code>이 없으면? → 아무 효과 없음</li>
</ul>
<p><strong>둘 다 있어야 AOP 기반 트랜잭션 처리가 정상적으로 작동</strong></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/5900ac0f-5b06-4bdd-96aa-dd1889f4781c/image.png" alt="">이 어노테이션이 적용되면 Spring은 <code>TransactionManagementConfigurationSelector</code>를 통해 다음 <strong>두 Bean을 등록합니다</strong></p>
<ul>
<li><code>AutoProxyRegistrar</code>: AOP 프록시 인프라 구성</li>
<li><code>ProxyTransactionManagementConfiguration</code>: <strong>Transaction Advisor와 Interceptor 등록</strong></li>
</ul>
<h4 id="autoproxyregistrar---빈-후처리기를-컨테이너에-등록하는-역할">AutoProxyRegistrar...? -&gt; <strong>빈 후처리기를 컨테이너에 등록하는 역할</strong></h4>
<p>이 클래스는 <code>InfrastructureAdvisorAutoProxyCreator</code>를 빈으로 등록합니다. 이 빈(<code>InfrastructureAdvisorAutoProxyCreator</code>)은 <code>@Transactional</code>이 붙은 메서드를 자동으로 프록시로 감싸줍니다 (즉, 동작을 가로채기 위한 프록시 생성)
즉, <code>AutoProxyRegistrar</code> 클래스는 <code>@EnableTransactionManagement</code>, <code>@EnableAsync</code>, <code>@EnableAspectJAutoProxy</code> 등
<code>AOP</code> 기반 기능을 활성화하는 어노테이션들에 의해 등록되는 클래스입니다.</p>
<p>이 클래스의 역할은 <code>AOP</code> 기능을 수행할 <code>AutoProxyCreator</code> 빈을 등록하는 것입니다. 
즉, 실제 AOP를 수행하는 객체는 아니고, 프록시를 생성해줄 객체(<strong>AutoProxyCreator</strong>)를 스프링 컨테이너에 등록해주는 역할을 합니다. </p>
<p><strong>내부에서는..</strong></p>
<ul>
<li><code>@EnableTransactionManagement</code> → ImportSelector로 <code>AutoProxyRegistrar</code>를 import</li>
<li><code>AutoProxyRegistrar.registerBeanDefinitions()</code>에서 <code>InfrastructureAdvisorAutoProxyCreator(=AutoProxyCreator)</code> 같은 프록시 생성기 빈을 BeanDefinitionRegistry에 등록 (아래 사진 참고)</li>
</ul>
<p>이후 Spring이 빈을 생성할 때 이 <code>AutoProxyCreator</code>가 개입해서 프록시 객체(AOP 대상)를 감싸도록 합니다.</p>
<p><strong>그럼 진짜 프록시를 만드는 주체는?</strong></p>
<ul>
<li><code>AutoProxyRegistrar</code>가 아니라 <code>AutoProxyCreator</code>들이 실제 프록시를 생성합니다.</li>
</ul>
<p><strong>대표적인 AutoProxyCreator</strong></p>
<ul>
<li><code>InfrastructureAdvisorAutoProxyCreator</code> : <strong>@Transactional, @Async 등 인프라 수준의 AOP용</strong></li>
<li><code>AnnotationAwareAspectJAutoProxyCreator</code> : <strong>@Aspect 기반 AOP용</strong></li>
<li><code>BeanNameAutoProxyCreator</code> : <strong>빈 이름으로 지정된 AOP 대상 프록시 생성</strong></li>
</ul>
<blockquote>
<p><code>AnnotationAwareAspectJAutoProxyCreator</code>는 @Aspect 기반 AOP를 처리하기 위한 <strong>빈 후처리기 (BeanPostProcessor)</strong>.
그리고 이건 우리가 흔히 말하는 커스텀 AOP (예: 로깅, 인증 체크, 성능 측정 등) 에서 동작하는 핵심 컴포넌트.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/2915c712-4843-4ea5-9d14-6e06b661cc8b/image.png" alt=""></p>
<p>결국은 <code>AutoProxyRegistrar</code>을 통해 <strong>AOP 프록시 생성기</strong>가 생성된다는 것을 알았습니다. 
정리하자면 </p>
<ul>
<li><code>AutoProxyRegistrar</code> 클래스는 <code>InfrastructureAdvisorAutoProxyCreator</code>를 빈으로 등록 </li>
<li><code>InfrastructureAdvisorAutoProxyCreator</code> 은 <code>@Transactional</code>이 붙은 메서드를 자동으로 프록시로 감싸줌</li>
</ul>
<h4 id="proxytransactionmanagementconfiguration">ProxyTransactionManagementConfiguration...?</h4>
<p><code>ProxyTransactionManagementConfiguration</code>는 트랜잭션을 위한 <code>Advisor</code> 및 <code>Interceptor</code> 등록하는 역할을 수행합니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/a7cbfe9d-2193-4836-9cbf-297a1ef247bf/image.png" alt=""></p>
<ul>
<li><code>TransactionInterceptor</code> (트랜잭션 로직을 수행)</li>
<li><code>TransactionAttributeSourceAdvisor</code> (Advisor이며 Pointcut + Advice 형태)</li>
<li><em>이 Advisor는 @Transactional 어노테이션을 인식*</em></li>
</ul>
<p>최종적으로 <code>@EnableTransactionManagement</code>에 의해 등록된 <code>AutoProxyRegistrar</code>와 <code>ProxyTransactionManagementConfiguration</code>을 통해 <code>@Transactional</code>이 붙은 메서드는 프록시로 감싸지게 됩니다.
이 프록시는 내부적으로 <code>TransactionInterceptor</code>를 호출합니다.</p>
<h3 id="transactionaspectsupport-transactioninterceptor의-부모-클래스">TransactionAspectSupport (=TransactionInterceptor의 부모 클래스)</h3>
<p><code>TransactionInterceptor</code>의 <code>invoke</code> 메서드를 호출하게 되는데,
<img src="https://velog.velcdn.com/images/ho-tea/post/e5208025-fab8-4ffb-bc9b-0f34dd21bab9/image.png" alt=""></p>
<p><code>TransactionInterceptor</code>는 상속받은 <code>TransactionAspectSupport</code>의 <code>invokeWithinTransaction()</code> 메서드를 통해 트랜잭션을 시작하고, 커밋하거나 롤백하는 과정을 수행합니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/24e67ef1-b002-43ea-a1fd-ba86e34941a5/image.png" alt=""></p>
<p>Spring에서 <code>@Transactional</code>이 붙은 메서드가 실행될 때, 트랜잭션이 실제로 필요한 경우 <strong>트랜잭션을 생성하고 정보 객체를 구성하는 핵심 메서드</strong>가 바로 <code>TransactionAspectSupport</code>의 <code>createTransactionIfNecessary()</code>입니다.</p>
<p>이 메서드는 트랜잭션 처리 흐름의 진입부인 <code>invokeWithinTransaction()</code> 내부에서 호출됩니다.</p>
<pre><code class="language-java">
    protected TransactionInfo createTransactionIfNecessary(@Nullable PlatformTransactionManager tm, @Nullable TransactionAttribute txAttr, final String joinpointIdentification) {
        if (txAttr != null &amp;&amp; ((TransactionAttribute)txAttr).getName() == null) {
            txAttr = new DelegatingTransactionAttribute((TransactionAttribute)txAttr) {
                public String getName() {
                    return joinpointIdentification;
                }
            };
        }

        TransactionStatus status = null;
        if (txAttr != null) {
            if (tm != null) {
                status = tm.getTransaction((TransactionDefinition)txAttr);
            } else if (this.logger.isDebugEnabled()) {
                this.logger.debug(&quot;Skipping transactional joinpoint [&quot; + joinpointIdentification + &quot;] because no transaction manager has been configured&quot;);
            }
        }

        return this.prepareTransactionInfo(tm, (TransactionAttribute)txAttr, joinpointIdentification, status);
    }</code></pre>
<p>큰 흐름을 정리하면 아래와 같습니다.</p>
<h3 id="스프링-선언적-트랜잭션transactional의-큰-흐름">스프링 선언적 트랜잭션(<code>@Transactional</code>)의 큰 흐름</h3>
<pre><code class="language-java">
@EnableTransactionManagement (자동 적용됨)
    ↓
TransactionManagementConfigurationSelector
    ↓
 ┌────────────────────────┬────────────────────────────┐
 │ AutoProxyRegistrar     │ ProxyTransactionConfig     │
 │ → AOP Creator 등록     │ → TransactionInterceptor   │
 │                        │ → TransactionAdvisor       │
 └────────────────────────┴────────────────────────────┘
    ↓
@MyService 등록됨 → 이때 @Transactional 프록시로 감쌈
    ↓
MyService.method() 호출 → 프록시의 invoke()
    ↓
TransactionInterceptor → invokeWithinTransaction()
    ↓
PlatformTransactionManager → getTransaction() / commit() / rollback()

</code></pre>
<p>이렇게 해서 <code>TransactionAutoConfiguration</code> 부터 시작해서 <code>TransactionInterceptor</code>까지의 과정을 통해 스프링 내부에서 선언적 트랜잭션이 적용되는 전반적인 과정을 알 수 있었습니다. 다음에는 <code>PlatformTransactionManager</code>의 동작 방식에 대해 알아보겠습니다.</p>
<blockquote>
<p>아래는 <a href="https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8">자동구성..근데-이제-Data-JPA를-곁들인</a> 부터 시작해서 지금까지의 흐름을 정리한 표입니다.</p>
</blockquote>
<h3 id="🔄-spring-boot-컨테이너-라이프사이클--transactional-적용-흐름-정리">🔄 Spring Boot 컨테이너 라이프사이클 + @Transactional 적용 흐름 정리</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
<th>관련 클래스 또는 어노테이션</th>
<th>생명주기 타이밍</th>
</tr>
</thead>
<tbody><tr>
<td>0️⃣</td>
<td>애플리케이션 시작, <code>SpringApplication.run()</code> 호출</td>
<td><code>SpringApplication</code></td>
<td>애플리케이션 부트</td>
</tr>
<tr>
<td>1️⃣</td>
<td>사용자 정의 <code>@Component</code>, <code>@Service</code> 등의 스캔 시작</td>
<td><code>@ComponentScan</code>, <code>ConfigurationClassPostProcessor</code></td>
<td>빈 정의 등록 시작 전</td>
</tr>
<tr>
<td>2️⃣</td>
<td>사용자 정의 빈 구성 클래스(<code>@Configuration</code> 등) 먼저 처리됨</td>
<td><code>@Configuration</code>, <code>@Bean</code></td>
<td>등록 우선순위 ↑</td>
</tr>
<tr>
<td>3️⃣</td>
<td><code>@EnableAutoConfiguration</code>에 따라 AutoConfig 클래스 로딩</td>
<td><code>spring.factories</code> → <code>META-INF</code></td>
<td>자동 구성 클래스 탐색 시점</td>
</tr>
<tr>
<td>4️⃣</td>
<td><code>DataSourceAutoConfiguration</code> 실행 → HikariDataSource 등록</td>
<td><code>DataSourceAutoConfiguration</code></td>
<td>DB 연결 구성</td>
</tr>
<tr>
<td>5️⃣</td>
<td><code>HibernateJpaAutoConfiguration</code> 실행</td>
<td><code>JpaVendorAdapter</code>, <code>EntityManagerFactoryBuilder</code></td>
<td>DataSource 이후</td>
</tr>
<tr>
<td>→</td>
<td><code>HibernateJpaConfiguration</code> 로드</td>
<td><code>LocalContainerEntityManagerFactoryBean</code></td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>JpaTransactionManager</code>, <code>EntityManagerFactory</code> 등록</td>
<td><code>JpaBaseConfiguration</code> 상속</td>
<td></td>
</tr>
<tr>
<td>6️⃣</td>
<td><code>JpaRepositoriesAutoConfiguration</code> 실행</td>
<td>Repository 스캔</td>
<td>Hibernate 이후</td>
</tr>
<tr>
<td>7️⃣</td>
<td><code>TransactionAutoConfiguration</code> 실행</td>
<td>트랜잭션 관련 설정</td>
<td>DataSource, JPA 이후</td>
</tr>
<tr>
<td>→</td>
<td><code>EnableTransactionManagementConfiguration</code> 내부에서</td>
<td></td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>@EnableTransactionManagement</code> 자동 적용</td>
<td><strong>중요 트리거</strong></td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>TransactionManagementConfigurationSelector</code> → Import 두 개</td>
<td></td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>AutoProxyRegistrar</code> → <code>InfrastructureAdvisorAutoProxyCreator</code> 등록</td>
<td>AOP 프록시 생성기</td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>ProxyTransactionManagementConfiguration</code> → <code>TransactionInterceptor</code>, <code>TransactionAdvisor</code> 등록</td>
<td>어드바이저 구성 완료</td>
<td></td>
</tr>
<tr>
<td>8️⃣</td>
<td>이 시점에 <code>@Service</code>, <code>@Component</code> 등 사용자 정의 빈 등록 시작</td>
<td>일반적인 빈 생성 시점</td>
<td></td>
</tr>
<tr>
<td>9️⃣</td>
<td>등록된 <code>BeanPostProcessor</code> 동작 시작</td>
<td><code>InfrastructureAdvisorAutoProxyCreator</code> 등</td>
<td></td>
</tr>
<tr>
<td>→</td>
<td>Advisor 조건 만족 시 프록시 감싸짐 (<code>@Transactional</code> 등)</td>
<td>✅ <strong>프록시 객체 생성</strong></td>
<td></td>
</tr>
<tr>
<td>🔟</td>
<td>사용자 코드에서 메서드 호출 시 → 프록시 객체가 가로채기</td>
<td></td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>TransactionInterceptor.invoke()</code> 실행됨</td>
<td></td>
<td></td>
</tr>
<tr>
<td>→</td>
<td>내부적으로 <code>invokeWithinTransaction()</code> 호출</td>
<td>트랜잭션 시작, 커밋, 롤백 처리</td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>PlatformTransactionManager.getTransaction()</code> 호출</td>
<td>트랜잭션 생성 여부 판단</td>
<td></td>
</tr>
<tr>
<td>→</td>
<td><code>invocation.proceedWithInvocation()</code></td>
<td>실제 비즈니스 로직 실행</td>
<td></td>
</tr>
<tr>
<td>→</td>
<td>정상 종료 시 <code>commitTransactionAfterReturning()</code> 실행</td>
<td>→ <code>PlatformTransactionManager.commit()</code> 호출</td>
<td></td>
</tr>
<tr>
<td>→</td>
<td>예외 발생 시 <code>completeTransactionAfterThrowing()</code> 실행</td>
<td>→ <code>PlatformTransactionManager.rollback()</code> 호출</td>
<td></td>
</tr>
</tbody></table>
<h3 id="🔄-spring-bean-lifecycle--transactional-적용-지점-정리">🔄 Spring Bean Lifecycle + @Transactional 적용 지점 정리</h3>
<table>
<thead>
<tr>
<th>라이프사이클 단계</th>
<th>설명</th>
<th>기존 흐름에서 해당하는 단계</th>
</tr>
</thead>
<tbody><tr>
<td>1️⃣ 빈 정의 등록 (Definition 등록)</td>
<td>어떤 빈이 존재할 것인지 스프링이 &quot;정의&quot;만 먼저 등록하는 단계</td>
<td>① ~ ⑦ 전체<br>- <code>@ComponentScan</code><br>- <code>@EnableAutoConfiguration</code><br>- <code>AutoProxyRegistrar</code>, <code>TransactionInterceptor</code> 등도 이 시점 등록</td>
</tr>
<tr>
<td>2️⃣ 빈 인스턴스 생성</td>
<td>정의된 빈을 바탕으로 객체를 <code>new</code>해서 인스턴스를 만드는 단계</td>
<td>⑧ 사용자 정의 빈 생성 시점<br>예: <code>@Service</code>, <code>@Component</code> 클래스</td>
</tr>
<tr>
<td>3️⃣ 의존성 주입</td>
<td>생성된 빈에 필요한 의존 객체를 <code>@Autowired</code>, 생성자 등으로 주입</td>
<td>⑧과 함께 진행</td>
</tr>
<tr>
<td>4️⃣ 초기화 (PostProcessor 포함)</td>
<td><code>InitializingBean.afterPropertiesSet()</code>, <code>@PostConstruct</code><br>+ <code>BeanPostProcessor</code> 적용<br>→ 이 시점에 프록시 감싸짐</td>
<td>⑨<br>- <code>InfrastructureAdvisorAutoProxyCreator</code> 작동<br>- <code>@Transactional</code> 조건 만족 시 프록시로 감쌈</td>
</tr>
<tr>
<td>5️⃣ 사용</td>
<td>사용자가 메서드 호출 → 프록시가 가로채서 트랜잭션 처리 시작</td>
<td>🔟 이후<br>- <code>TransactionInterceptor.invoke()</code><br>- <code>invokeWithinTransaction()</code> 내부에서 트랜잭션 시작, 커밋, 롤백</td>
</tr>
<tr>
<td>6️⃣ 소멸</td>
<td>빈이 컨테이너에서 제거될 때 실행<br><code>@PreDestroy</code>, <code>DisposableBean.destroy()</code> 등</td>
<td>트랜잭션과는 직접적인 관련 없음</td>
</tr>
</tbody></table>
<ul>
<li><code>@Transactional</code>의 실제 적용 타이밍은 빈 초기화 직전인 <code>BeanPostProcessor</code> 단계에서 발생.</li>
<li>즉, 빈이 생성된 후에 프록시로 감싸질 수 있는지 조건 판단 → 감싸기가 이뤄짐.</li>
<li>감싸진 후, 메서드가 호출될 때 트랜잭션이 동작하고, 이건 Bean 사용 단계에 해당.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[DataSource가 2개 이상일 때의 Auto-Configure]]></title>
            <link>https://velog.io/@ho-tea/Replication-%ED%99%98%EA%B2%BD%EC%9D%BC-%EB%95%8C%EC%9D%98-Auto-Configure</link>
            <guid>https://velog.io/@ho-tea/Replication-%ED%99%98%EA%B2%BD%EC%9D%BC-%EB%95%8C%EC%9D%98-Auto-Configure</guid>
            <pubDate>Mon, 17 Mar 2025 18:16:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h3 id="오늘의-목표">오늘의 목표</h3>
<p><code>DataSource</code>가 2개 이상일때 자동구성 과정 알아보기</p>
</blockquote>
<p>일전에 <a href="https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8">Data JPA를 사용한 환경에서의 자동구성 과정</a> 를 통해 자동 구성에서 호출되는 클래스와 등록되는 빈들을 살펴보았습니다. 실제 프로젝트에서는 단일 데이터베이스 대신 리플리케이션 등 다중화 작업을 진행하는 경우가 많다고 생각합니다. 그래서 이번에는 하나의 <code>DataSource</code>가 아닌, 여러 <code>DataSource</code>가 등록된 환경에서 자동 구성이 어떻게 이루어지는지 알아보겠습니다. <strong>(짧음 주의)</strong></p>
<h3 id="리플리케이션-환경-구성">리플리케이션 환경 구성</h3>
<p><strong>yml 파일</strong></p>
<p>우선 여러개의 <code>DataSource</code>를 등록해놓아야 하는 상태이므로 아래와 같이 <code>yml</code> 파일을 설정해 주었습니다. 
(간단하게 <code>Reader DB</code>와 <code>Writer DB</code>로 구성해놓았습니다.)</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/bda8e0db-87d5-4c1b-af27-5aa65847e36c/image.png" alt=""></p>
<p><strong>production code</strong></p>
<ul>
<li>여러 <code>DataSource</code>(읽기와 쓰기)를 동적으로 라우팅하고, <code>LazyConnectionDataSourceProxy</code>를 통해 실제 연결 시점을 늦춤으로써, 트랜잭션이나 데이터베이스 작업이 실제로 필요할 때까지 연결을 열지 않도록 합니다.</li>
<li>이 방식은 런타임에 어떤 데이터 소스를 사용할지 결정할 수 있게 해주며, 예를 들어 쓰기 작업은 <code>writer</code>, 읽기 작업은 <code>reader</code>로 분리할 수 있습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/600cec6e-ef36-4e68-b4df-29ff63fe20ce/image.png" alt=""></li>
</ul>
<p>이와 같이 설정한 후 테스트를 수행해보면 정상적으로 테스트가 통과되는 것을 알 수 있습니다.
또한, 아래 콘솔에 출력된 <code>Bean</code>을 확인해보니 <code>LazyConnectionDataSourceProxy</code> 가 반환된 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/8fc99a6a-d45f-4760-963c-57f5c71987c2/image.png" alt=""></p>
<h3 id="datasource-bean을-따로-생성하지-않는다면">DataSource Bean을 따로 생성하지 않는다면?</h3>
<p><code>production code</code>를 모두 지우고 다시 테스트를 수행하면 <code>DataSource</code>를 특정지을 수 없다는 에러 메세지가 나오게 되는데요. 
<img src="https://velog.velcdn.com/images/ho-tea/post/4ed1b1fc-475d-4f97-82f1-b7fc22446ba8/image.png" alt=""></p>
<blockquote>
<p>Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name &#39;dataSourceScriptDatabaseInitializer&#39; defined in class path resource [org/springframework/boot/autoconfigure/sql/init/DataSourceInitializationConfiguration.class]: Unsatisfied dependency expressed through method &#39;dataSourceScriptDatabaseInitializer&#39; parameter 0: Error creating bean with name &#39;dataSource&#39; defined in class path resource [org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration$Hikari.class]: Failed to instantiate [com.zaxxer.hikari.HikariDataSource]: Factory method &#39;dataSource&#39; threw exception with message: Failed to determine a suitable driver class</p>
</blockquote>
<p>단일 <code>DataSource</code> 설정이 있으면 <code>Spring Boot</code>의 자동 구성에 의해 기본 <code>DataSource</code> 빈이 생성되지만, 두 개 이상의 <code>DataSource</code>가 존재하면 어떤 <code>DataSource</code>를 기본으로 사용할지 결정할 수 없어서 &quot;DataSource를 특정지을 수 없습니다&quot;라는 에러가 발생하였습니다. 
이를 해결하려면, 여러 <code>DataSource</code> 중 하나(아까의 <code>LazyConnectionDataSourceProxy</code>)를 <code>@Primary</code>로 지정하거나, 명시적으로 기본 <code>DataSource</code>를 선택할 수 있는 추가 구성을 해야 합니다.</p>
<p>예외 메세지를 더 살펴보자면,</p>
<ul>
<li>빨간색 박스를 자세히 보면 <code>dataSourceInitializationConfiguration</code>의 <code>dataSourceScriptDatabaseInitializer()</code> 메서드의 첫번재 인자로 <code>DataSource</code>가 들어와야 하는데 해당 <code>DataSource</code>가 정상적으로 생성이 되지 않았다는 예외 메세지가 존재합니다.</li>
<li>예외를 따라가다보면 <code>DataSourceConfiguration</code> 클래스가 보이게 됩니다. <a href="https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8">Data JPA를 사용한 환경에서의 자동구성 과정</a> 에서도 다뤘다시피 <code>DataSourceConfiguration</code>는 <code>DataSource</code>를 생성하는 곳이지만 생성이 이루어지지 않았습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/56dc6f7d-b617-4e0f-a030-fe3319778da5/image.png" alt=""></li>
<li>애초에 <code>DataSourceProperties</code> 에 내부 값들이 <code>null</code>로 입력된 것을 알 수 있습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/29075d36-37f8-4cf9-8284-46faee23f8f0/image.png" alt=""><blockquote>
<p><code>@ConfigurationProperties(prefix = &quot;spring.datasource&quot;)</code> 어노테이션은 Spring Boot에서 외부 설정 파일(예: application.yml 또는 application.properties)에 정의된 <code>spring.datasource</code>로 시작하는 속성들을 Java 객체의 필드에 바인딩하기 위해 사용되지만,
현재는 <code>DataSource</code>를 2개 정의하는 바람에 해당 속성을 사용하지 못하였기에 자동으로 <code>DataSource</code>를 설정할 수 없었습니다.</p>
</blockquote>
</li>
</ul>
<h3 id="datasource를-2개-이상-자동구성-할-수-있게-스프링이-지원해줄수는-없나">DataSource를 2개 이상 자동구성 할 수 있게 스프링이 지원해줄수는 없나?</h3>
<p><code>Spring</code>의 <code>GitHub</code> 이슈에서는 현재까지도 다중 <code>DataSource</code> 자동 구성에 대한 논의가 활발히 이어지고 있음을 확인할 수 있습니다.</p>
<blockquote>
<p><a href="https://github.com/spring-projects/spring-boot/issues/15732">스프링 이슈 바로가기</a></p>
</blockquote>
<h3 id="어떻게-해결하지">어떻게 해결하지?</h3>
<p>제가 선택한 해결 방법은 단순합니다. 
기존에 자동구성으로 <code>DataSource</code>가 생성되고, <code>EntityManagerFactory</code>가 생성되고, <code>JpaTransactionManager</code>가 생성이 되었기에, <code>DataSource</code>만 따로 정의한 빈이 등록되게 하고, 나머지 <code>JpaTransactionManager</code>와 <code>EntityManagerFactory</code> 등 <code>JPA</code> 관련 빈들은 <code>Spring Boot</code>가 생성되게 구성하는 것으로 해결하고자 하였습니다. 따라서 여러 <code>DataSource</code>를 빈으로 생성한 후 <code>@Primary</code>를 통해 빈에 우선권을 주었습니다.</p>
<blockquote>
<p>개발자가 정의한 <code>Component</code>들이 먼저 스캔 돼서 <code>Bean</code>으로 등록된 후에, <code>AutoConfiguration</code>의 <code>Bean</code>들이 등록되기 때문에 <code>@ConditionalOnSingleCandidate(DataSource.class)</code> 을 사용하는 (예시:<code>HibernateJpaConfiguration</code>) 클래스들의 조건만 만족시켜주면 스프링 부트의 자동구성을 이용할 수 있습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[자동구성..근데 이제 Data JPA를 곁들인]]></title>
            <link>https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8</link>
            <guid>https://velog.io/@ho-tea/%EC%9E%90%EB%8F%99%EA%B5%AC%EC%84%B1..%EA%B7%BC%EB%8D%B0-%EC%9D%B4%EC%A0%9C-Data-JPA%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8</guid>
            <pubDate>Mon, 17 Mar 2025 11:47:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h3 id="오늘의-목표">오늘의 목표</h3>
<p><strong>TransactionManager</strong>, <strong>EntityManagerFactory</strong>, <strong>Datasource</strong>가 언제 <strong>자동구성</strong>되는지 이해하기</p>
</blockquote>
<p><code>Spring</code> 과 <code>Spring Boot</code>의 여러 차이점 중 자동구성이 존재합니다.
이 글에서는 <code>Data JPA</code>를 사용하는 환경에서는 어떠한 과정을 거쳐 <strong>자동구성</strong>이 설정되는지 알아보고자 합니다. </p>
<h3 id="spring-vs-spring-boot">Spring vs Spring Boot</h3>
<blockquote>
<p>Spring은 스프링 프레임워크의 핵심 모듈을 모아서 만든 프레임워크입니다. Spring에서는 개발자가 직접 설정 파일을 작성하여 스프링 컨테이너를 구성하고, 필요한 빈 객체를 등록하고, 빈 객체 간의 의존성을 설정해야 합니다. Spring은 특정한 구성을 위해 추가적인 라이브러리와 설정이 필요합니다.
반면, Spring Boot는 스프링 프레임워크를 보다 쉽게 사용할 수 있도록 만든 프레임워크입니다. Spring Boot에서는 개발자가 설정 파일을 작성할 필요 없이, <strong>프로젝트의 설정과 라이브러리 의존성을 자동으로 처리해주는 기능</strong>을 제공합니다.</p>
</blockquote>
<p>우선 프로젝트 설정은 아래와 같습니다.</p>
<ul>
<li><code>Spring Boot 3.2.4</code></li>
<li><code>dependencies</code>
<img src="https://velog.velcdn.com/images/ho-tea/post/78bb14b2-b8a6-4d10-80b1-a95abc817227/image.png" alt=""></li>
<li><code>application.properties</code>
<img src="https://velog.velcdn.com/images/ho-tea/post/ebb294dd-8060-4cc2-ad82-9516ef06fd55/image.png" alt=""></li>
</ul>
<hr>
<h3 id="라이브러리-훑어보기">라이브러리 훑어보기</h3>
<p><code>spring-boot-starter</code>를 디펜던시로 설정해놓으면 아래와 같이 자동으로 <code>autoconfigure</code> 기능이 추가되는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/5c75cb56-e579-4332-ac73-fc7f2587c773/image.png" alt=""></p>
<p><code>spring-boot-starter</code> 를 통해 추가된 <code>autoconfigure</code> 안에서 
<code>DataSourceAutoConfiguration</code>를 확인할 수 있는데, <code>DataSourceAutoConfiguration</code>는 <code>Datasource</code>를 자동으로 등록해주는 클래스입니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/b74ff2bc-51dd-4e35-8ab3-606bc20e58b3/image.png" alt=""></p>
<blockquote>
<p>만약 <code>org.springframework.boot:spring-boot-starter-data-jpa</code> 혹은 <code>org.springframework.boot:spring-boot-starter-jdbc</code>를 의존성에 추가하지 않았다면 아래와 같이 <code>org.springframework.jdbc</code> 를 인식할 수 없어 <code>DataSource</code>를 자동으로 등록하는 자동구성을 이용할 수 없습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/ea5045e4-741a-47d2-aac7-7da1f6fdea47/image.png" alt=""><img src="https://velog.velcdn.com/images/ho-tea/post/abb2ec50-5a41-4549-8a4d-e16e822b3240/image.png" alt=""></p>
</blockquote>
<hr>
<h3 id="datasourceautoconfiguration">DataSourceAutoConfiguration</h3>
<p>이 클래스는 애플리케이션에서 데이터베이스 연결을 위한 <code>DataSource</code> 빈을 자동으로 생성하고 구성하는 역할을 담당합니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/eb781f69-3344-4b34-8f26-14ce16fbacb6/image.png" alt=""></p>
<p>여기서는 크게 3가지 설정들을 살펴보겠습니다.</p>
<ul>
<li><p>*<em>EnableConfigurationProperties({DataSourceProperties.class}) : *</em>외부 설정 파일의 <code>DataSource</code> 관련 프로퍼티를 바인딩합니다.</p>
</li>
<li><p><strong>EmbeddedDatabaseCondition :</strong> 이 조건은 <code>spring.datasource.url</code> 속성이 존재하지 않거나 비어있을 경우, 내장형 데이터베이스 구성을 활성화하기 위한 조건입니다.</p>
</li>
<li><p><strong>@Import</strong>를 통해 <code>DataSourceAutoConfiguration</code>는 데이터베이스 연결을 위한 <code>DataSource</code> 빈을 자동으로 생성하기 위한 여러 구현체의 구성을 가져옵니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/c556e544-4435-4d97-a046-381937a079b7/image.png" alt=""></p>
<blockquote>
<p>스프링 부트에서는 데이터베이스 종류와 상관없이 기본 <code>DataSource</code> 구현체로 <code>HikariCP</code>를 사용하기에 아래 <code>Hikari DataSource</code>가 등록됩니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/59f5b638-faad-4962-bed5-8cf72ca9639f/image.png" alt=""></p>
</blockquote>
</li>
</ul>
<p>현재 상황은 <code>application.yml</code>(<code>application.properties</code> 등)에서 하나의 <code>DataSource</code>만 설정했을 때의 예시이고, 이 경우 <code>Spring Boot</code>의 <code>DataSourceAutoConfiguration</code>이 조건에 맞춰 자동으로 <code>DataSource</code> 빈(예: <code>HikariDataSource</code> 등)을 생성하고 구성합니다.</p>
<blockquote>
<p>만약에 <code>DataSource</code>를 2개 이상 설정하게 된다면? -&gt; <a href="https://velog.io/@ho-tea/Replication-%ED%99%98%EA%B2%BD%EC%9D%BC-%EB%95%8C%EC%9D%98-Auto-Configure">해당 블로그 글 참고</a></p>
</blockquote>
<p>다시 본론으로 돌아와서 <code>DataSourceAutoConfiguration</code>을 통해 자동으로 <code>Datasource를</code> 등록해주는 것을 알 수 있었습니다.
이와 같이 <code>Spring Data JPA</code>를 사용하면 <code>HibernateJpaAutoConfiguration</code>와 <code>JpaRepositoriesAutoConfiguration</code>가 함께 작동하여 <code>TransactionManager</code>, <code>EntityManagerFactory</code> 등 <code>JPA</code>를 사용하기 위한 기본 빈들을 자동으로 생성·등록합니다.</p>
<blockquote>
<ul>
<li><strong>HibernateJpaAutoConfiguration:</strong> 이 클래스는 EntityManagerFactory, PlatformTransactionManager 등 JPA 환경에 필요한 핵심 빈들을 자동으로 등록합니다.</li>
</ul>
</blockquote>
<ul>
<li><strong>JpaRepositoriesAutoConfiguration:</strong> 이 클래스는 Repository 인터페이스를 자동으로 스캔하고 등록하여, 별도의 구현체 없이도 CRUD 및 커스텀 메서드를 사용할 수 있도록 도와줍니다.</li>
</ul>
<p>이렇게 두 자동 구성 클래스가 함께 동작함으로써, 개발자는 복잡한 설정 없이 <code>Spring Data JPA</code> 기능을 손쉽게 사용할 수 있습니다.</p>
<hr>
<h3 id="jparepositoriesautoconfiguration">JpaRepositoriesAutoConfiguration</h3>
<p>우선 <strong>JpaRepositoriesAutoConfiguration 를 간략히 살펴보자면,</strong>
이 클래스는 <code>Spring Boot</code>에서 <code>JPA</code> 기반의 <code>Repository</code>를 자동 구성하기 위한 <code>auto-configuration</code> 클래스로 아래와 같은 애노테이션들이 설정되어 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/79ee070e-9454-4b6c-8a0e-10ff7b2d496b/image.png" alt=""></p>
<ul>
<li><p><strong>@AutoConfiguration(after = {HibernateJpaAutoConfiguration.class, TaskExecutionAutoConfiguration.class}) :</strong> 이 설정은 <code>Hibernate JPA</code>와 <code>Task Execution</code> 관련 자동 구성들이 먼저 완료된 후에 이 설정이 적용되도록 순서를 보장합니다.</p>
</li>
<li><p><strong>@ConditionalOnBean({DataSource.class}) :</strong> <code>DataSource</code> 빈이 존재해야 이 <code>auto-configuration</code>이 활성화됩니다. 즉, 데이터베이스 연결 설정이 되어 있어야 합니다.</p>
</li>
<li><p><strong>@ConditionalOnClass({JpaRepository.class}) :</strong> 클래스패스에 <code>JpaRepository</code> 클래스가 있어야 JPA 리포지토리 기능이 활성화됩니다.    </p>
</li>
</ul>
<blockquote>
<p><code>Data JPA</code>가 아닌 <code>JPA</code>를 사용하게 된다면 <code>HibernateJpaAutoConfiguration</code> 클래스만을 사용하고, <code>JpaRepositoriesAutoConfiguration</code> 클래스는 사용하지 않습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/08549e09-9ec0-4bf5-880a-89e28193377e/image.png" alt=""></p>
</blockquote>
<h4 id="data-jpa가-아닌-jpa를-사용하려면">Data JPA가 아닌 JPA를 사용하려면?</h4>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/3b49f2e6-2601-4a5f-b676-f355b5e32a81/image.png" alt=""> <code>Data JPA</code>가 아닌 <code>JPA</code>를 사용하려면 이와 같이 의존성을 추가해야 하며 기존에 <code>Data JPA</code>가 제공하는 기능 5가지 중 <code>JpaRepository</code> 를 사용하지 못합니다.</p>
<ul>
<li><input checked="" disabled="" type="checkbox"> JPA 사용 (@Entity, EntityManager) -&gt; <code>jakarta.persistence-api 덕분에 JPA 어노테이션과 인터페이스 사용 가능</code></li>
<li><input checked="" disabled="" type="checkbox"> Hibernate를 통한 ORM 매핑 -&gt; <code>hibernate-core가 JPA 구현체 역할</code></li>
<li><input disabled="" type="checkbox"> <del>Spring Data의 JpaRepository 자동 구현</del></li>
<li><input checked="" disabled="" type="checkbox"> 트랜잭션 처리 (@Transactional) -&gt; <code>spring-boot-starter에 포함된 spring-tx 덕분에 트랜잭션 처리 가능</code></li>
<li><input checked="" disabled="" type="checkbox"> 기본 JDBC 지원 및 설정 -&gt; <code>spring-boot-starter-jdbc가 DataSource, HikariCP 설정 지원</code></li>
</ul>
<hr>
<h3 id="hibernatejpaautoconfiguration">HibernateJpaAutoConfiguration</h3>
<p>해당 클래스에서는 <code>EntityManagerFactory</code>, <code>PlatformTransactionManager</code> 등 <code>JPA</code> 환경에 필요한 핵심 빈들을 자동으로 등록하는 과정이 이루어집니다.
해당 클래스의 애노테이션들을 확인하게 되면 조금은 익숙한(?) 클래스명이 보입니다.</p>
<blockquote>
<p><code>DataSourceAutoConfiguration</code> 을 <code>after</code>에 지정하는 코드가 보이는데, 해당 <code>DataSourceAutoConfiguration</code>는 초반에 언급했던 <code>DataSource</code>를 자동구성하는 클래스입니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/6edc02f4-8793-48e5-b24e-afc868c86e5f/image.png" alt=""></p>
<pre><code class="language-java">// code
package org.springframework.boot.autoconfigure.orm.jpa;

import jakarta.persistence.EntityManager;
import org.hibernate.engine.spi.SessionImplementor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Import;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;

@AutoConfiguration(
    after = {DataSourceAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class},
    before = {TransactionAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}
)
@ConditionalOnClass({LocalContainerEntityManagerFactoryBean.class, EntityManager.class, SessionImplementor.class})
@EnableConfigurationProperties({JpaProperties.class})
@Import({HibernateJpaConfiguration.class})
public class HibernateJpaAutoConfiguration {
    public HibernateJpaAutoConfiguration() {
    }
}
</code></pre>
<p>해당 애노테이션에 구성된 설정 값 대해서도 간략히 알고 넘어가겠습니다.</p>
<pre><code class="language-java">@AutoConfiguration(
    after = {DataSourceAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class}, 
    before = {TransactionAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})</code></pre>
<ul>
<li><strong>after:</strong> 데이터 소스 및 트랜잭션 매니저 관련 설정이 먼저 완료된 후에 이 구성 클래스가 적용됩니다.</li>
<li><strong>before:</strong> 이후에 실행되는 트랜잭션 관련 자동 구성보다 먼저 이 구성이 처리되어야 함을 의미합니다.</li>
</ul>
<p>해당 애노테이션은 <code>HibernateJpaAutoConfiguration</code>가 실행되는 시점을 제어하는데, 이는 <code>JpaTransactionManager</code> 구성과 밀접하게 관련됩니다. 조금 더 자세히 살펴보겠습니다.</p>
<ul>
<li><p><strong>after 속성:</strong> <code>DataSourceAutoConfiguration</code>와 <code>TransactionManagerCustomizationAutoConfiguration</code>가 먼저 실행되어, 데이터소스와 트랜잭션 매니저에 대한 기본 설정 및 추가 커스터마이징이 완료된 상태여야 합니다.</p>
<ul>
<li><p>여기서 주의해야 할 부분은 <code>TransactionManagerCustomizationAutoConfiguration</code>는 <code>JpaTransactionManager</code>를 <strong>직접</strong> 생성하는 곳이 아닙니다.</p>
</li>
<li><p><code>JpaTransactionManager</code>는 주로 <code>JpaBaseConfiguration</code>(그리고 이를 상속받은 <code>HibernateJpaConfiguration</code>)에서 <code>EntityManagerFactory</code>를 기반으로 생성됩니다.</p>
</li>
<li><p><code>TransactionManagerCustomizationAutoConfiguration</code>는 이미 생성된 <code>TransactionManager</code>에 대해 추가적인 커스터마이징을 적용할 수 있도록 돕는 역할을 합니다. <code>JpaTransactionManager</code>의 기본 생성은 <code>HibernateJpaConfiguration</code>에서 진행되고(<code>HibernateJpaAutoConfiguration X</code>), 그 이후에 <code>TransactionManagerCustomizationAutoConfiguration</code>가 존재하면, 이를 이용해 추가적인 설정이나 수정을 적용합니다. </p>
</li>
</ul>
</li>
</ul>
<blockquote>
<p>방금 다룬 부분은 밑에서 한번 더 다루니 지금은 그냥 
<code>&quot;JpaBaseConfiguration가 JpaTransactionManager를 생성하는구나!&quot;</code> 정도로만 이해하셔도 괜찮습니다.</p>
</blockquote>
<ul>
<li><strong>before 속성:</strong> <code>TransactionAutoConfiguration</code>와 <code>DataSourceTransactionManagerAutoConfiguration</code>보다 먼저 실행되어, <code>JPA</code> 관련 빈들(예: <code>EntityManagerFactory</code>, <code>JpaTransactionManager</code> 등)이 등록되어야 합니다.</li>
</ul>
<p>이러한 순서 덕분에 <code>HibernateJpaAutoConfiguration</code>는 필요한 빈들이 이미 구성된 상태에서 실행되며, 그 후에 전체 트랜잭션 관련 자동 구성이 이루어지도록 보장됩니다.</p>
<hr>
<pre><code class="language-java">@ConditionalOnClass({
        LocalContainerEntityManagerFactoryBean.class, 
         EntityManager.class, 
        SessionImplementor.class})</code></pre>
<ul>
<li>이 조건은 다음과 같은 클래스들이 클래스패스에 존재할 때만 Hibernate 기반의 JPA 자동 구성이 활성화된다는 것을 의미합니다.<ul>
<li><code>LocalContainerEntityManagerFactoryBean</code>: EntityManagerFactory를 생성하는 Spring ORM의 핵심 클래스</li>
<li><code>EntityManager</code>: JPA 표준 인터페이스</li>
<li><code>SessionImplementor</code>: Hibernate의 내부 세션 인터페이스</li>
</ul>
</li>
</ul>
<p>즉, JPA와 Hibernate 관련 라이브러리가 존재해야 이 자동 구성이 적용됩니다.</p>
<hr>
<pre><code class="language-java">@EnableConfigurationProperties({JpaProperties.class})</code></pre>
<ul>
<li><code>JpaProperties</code> 클래스에 정의된 외부 설정(예: <code>application.properties</code> 또는 <code>application.yml</code> 파일의 <code>spring.jpa.*</code> 관련 설정)이 자동으로 바인딩되어, 개발자가 설정한 값을 기반으로 Hibernate 관련 빈들이 구성됩니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/17149899-7eda-493b-bfa6-6ab1b0580807/image.png" alt=""></li>
</ul>
<hr>
<pre><code class="language-java">@Import({HibernateJpaConfiguration.class})</code></pre>
<ul>
<li>실제 Hibernate JPA 설정을 담당하는 <code>HibernateJpaConfiguration</code> 클래스를 가져옵니다.</li>
<li>이 클래스에서는 <code>EntityManagerFactory</code>, <code>EntityManager</code> 및 기타 <code>Hibernate</code> 관련 빈들을 등록하는 작업이 이루어집니다.</li>
</ul>
<h3 id="hibernatejpaconfiguration">HibernateJpaConfiguration</h3>
<p>스프링에서 자바 설정을 추가할 때 사용하는 <code>Import</code>를 타고 들어가게 된다면 <code>실제 Hibernate JPA 설정</code>을 담당하는 <code>HibernateJpaConfiguration</code> 클래스가 나오게 되는데 여기서 주의해야 할 부분은 <strong>@ConditionalOnSingleCandidate(DataSource.class) 입니다.</strong> </p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/e8968d59-703d-439a-b89e-9a26b70a52b7/image.png" alt=""></p>
<p>이 클래스는 <strong>@ConditionalOnSingleCandidate(DataSource.class)</strong> 조건이 붙어 있어서, <code>DataSource</code> 빈이 단 하나일 때만 자동 구성됩니다.</p>
<p><strong>즉, 여러 개의 DataSource가 등록되어 있으면 이 조건이 충족되지 않아 HibernateJpaConfiguration이 자동으로 동작하지 않습니다.</strong></p>
<ul>
<li><strong>HibernateJpaConfiguration</strong>이 동작하지 않는다면 자동으로 <strong>JpaTransactionManager</strong>나 <strong>EntityManagerFactory</strong>와 같은 JPA 관련 핵심 빈들이 생성되지 않게 됩니다. 따라서 수동으로 해당 설정을 진행해주어야합니다.</li>
</ul>
<blockquote>
<p>수동으로 등록하는 방법 외에도 여러 개의 <code>DataSource</code>가 등록되어 있을 때, 그 중 하나를 <code>@Primary</code>로 지정하면 해당 <code>DataSource</code>가 단일 후보로 간주됩니다. 그러면 <code>@ConditionalOnSingleCandidate(DataSource.class)</code> 조건을 충족하여 <code>HibernateJpaConfiguration</code>가 자동으로 동작하게 됩니다.</p>
</blockquote>
<h3 id="jpabaseconfiguration">JpaBaseConfiguration</h3>
<p>해당 클래스(<code>HibernateJpaConfiguration</code>)는  <code>JpaBaseConfiguration</code> 를 상속받고 있습니다. <code>JpaBaseConfiguration</code> 에서 중점적으로 볼 부분은 크게 두가지입니다.</p>
<ul>
<li><strong>트랜잭션 매니저 빈 등록</strong></li>
<li><strong>EntityManagerFactory 빈 등록</strong></li>
</ul>
<p><strong>트랜잭션 매니저 빈 등록</strong></p>
<ul>
<li><code>transactionManager</code> 메서드는 <code>JpaTransactionManager</code>를 생성하고, 추가적인 커스터마이징을 적용합니다. 만약 <code>TransactionManager</code> 빈이 없을 경우에만 생성하도록 조건부(<code>@ConditionalOnMissingBean</code>)로 설정되어 있습니다.
  <img src="https://velog.velcdn.com/images/ho-tea/post/48bc5438-69d9-4aaa-ad84-5cd4d36bfeed/image.png" alt=""></li>
</ul>
<p><strong>EntityManagerFactory 빈 등록</strong></p>
<ul>
<li><code>entityManagerFactory</code> 메서드에서는 <code>entityManagerFactoryBuilder()</code>를 이용해 <code>LocalContainerEntityManagerFactoryBean</code>을 생성합니다. 
이 빈은 실제로 <code>JPA</code>의 <code>EntityManagerFactory</code>를 구성하며, 여기서 데이터 소스, 관리 대상 클래스, 추가적인 <code>JPA</code> 속성들이 적용됩니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/b7cedc46-7cad-4541-8f75-419a27e6bb65/image.png" alt=""><blockquote>
<p><code>LocalContainerEntityManagerFactoryBean</code>는 <code>Spring Framework</code>에서 제공하는 <code>FactoryBean</code>으로, <code>JPA</code>의 <code>EntityManagerFactory</code>를 생성하고 구성하는 데 사용됩니다. 
이 클래스를 사용하면 스프링 컨테이너와 통합된 방식으로 <code>JPA</code> 설정을 손쉽게 관리할 수 있으며, <code>데이터 소스</code>, <code>JPA 벤더 어댑터</code>, <code>엔티티 패키지 스캔</code>, <code>추가 JPA 속성</code> 등을 한 곳에서 설정할 수 있습니다.
<img src="https://velog.velcdn.com/images/ho-tea/post/918f9cb0-4f66-4b86-a088-eaa528c3d18a/image.png" alt=""></p>
</blockquote>
</li>
</ul>
<hr>
<p><code>HibernateJpaConfiguration</code>와 <code>JpaBaseConfiguration</code>을 통해 우리가 흔히 접하는 <code>JpaTransactionManager</code>와 <code>EntityManagerFactory</code>가 자동으로 생성된다는 것을 알 수 있었습니다. 물론 이렇게 내부 흐름을 자세히 알지 않더라도 <code>Spring Data JPA</code>를 사용하는 데 큰 문제는 없지만, 편리하게 사용하는 <code>Spring Boot</code> 내부에는 수많은 자동 구성 메커니즘과 정교한 설계가 숨어 있다는 것을 알 수 있었습니다.</p>
<h3 id="component-vs-autoconfiguration-등록-순서는">@Component vs @AutoConfiguration 등록 순서는?</h3>
<blockquote>
<h4 id="개발자가-정의한-component들이-먼저-스캔-돼서-bean으로-등록된-후에-autoconfiguration의-bean들이-등록됩니다"><del><code>개발자가 정의한 Component들이 먼저 스캔 돼서 Bean으로 등록된 후에, AutoConfiguration의 Bean들이 등록됩니다</code></del></h4>
<p><code>BeanDefinition</code>이 등록되는 순서는 개발자가 정의한 <code>Component</code>들이 맞지만, 의존성과 <code>BeanFactory</code> 내부 순서에 따라 동시에 생성이 진행됩니다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>순서</th>
<th>설명</th>
<th>구성 요소</th>
</tr>
</thead>
<tbody><tr>
<td>1️⃣</td>
<td><code>@ComponentScan</code> + <code>@Configuration</code> 처리 시작</td>
<td>개발자 정의 컴포넌트 클래스들이 스캔되어 <strong>BeanDefinition</strong>으로 등록됨 (이 시점엔 인스턴스 아님)</td>
</tr>
<tr>
<td>2️⃣</td>
<td><code>@EnableAutoConfiguration</code> 처리</td>
<td><code>spring.factories</code>에 정의된 <code>@AutoConfiguration</code> 클래스들도 <strong>BeanDefinition</strong>으로 등록됨</td>
</tr>
<tr>
<td>✅</td>
<td>여기까지는 &quot;<strong>정의 등록</strong>&quot;만 완료된 시점 (BeanDefinition Registry 단계)</td>
<td>→ 즉, 개발자 정의 빈과 AutoConfig 빈은 정의만 먼저 됨</td>
</tr>
<tr>
<td>3️⃣</td>
<td>Bean 인스턴스 생성 시작</td>
<td>이 시점부터 <code>@Component</code>, <code>@Service</code>, AutoConfig 클래스 순서에 따라 실제 객체로 생성됨 (빈 생성 및 주입)</td>
</tr>
<tr>
<td>⚠️</td>
<td>Bean 생성 순서는 의존성에 따라 달라짐</td>
<td>우선순위 지정이 없으면 의존성 그래프 기준으로 생성됨</td>
</tr>
</tbody></table>
<ul>
<li><strong>정의 등록 순서</strong>: 개발자 정의 클래스 (<code>@Component</code> 등) → <code>@AutoConfiguration</code></li>
<li><strong>실제 인스턴스 생성 시점</strong>: 의존성과 BeanFactory 내부 순서에 따라 <strong>동시에 생성 진행됨</strong></li>
<li><strong>&quot;무조건 개발자 정의 빈이 먼저 생성된다&quot;</strong>는 말은 <strong>정확하지 않음</strong></li>
</ul>
<blockquote>
<p><strong>Spring에서는 같은 타입의 BeanDefinition이 여러 개 등록되어 있을 경우, 기본적으로 사용자 정의 빈이 우선됩니다. 이건 자동 구성(AutoConfiguration) 설계 원칙 중 하나입니다.</strong></p>
</blockquote>
<ul>
<li><code>@ConditionalOnMissingBean</code> : 대부분의 자동 구성 클래스는 이 조건이 붙어 있음. 즉, 동일 타입의 빈이 이미 존재하면 자동 구성은 생략됨</li>
<li><code>BeanDefinition 우선순위</code> : Spring은 BeanDefinition을 만들 때, 사용자가 등록한 Bean을 자동 구성보다 우선시함</li>
<li><code>명시적 정의 &gt; 자동 등록</code> : 개발자가 명시적으로 등록한 빈이 우선 → Spring Boot의 핵심 철학인 <strong>&quot;자동이지만 필요하면 덮어써라&quot;</strong>를 반영한 설계</li>
</ul>
<hr>
<h3 id="💡-그래서-순서를-정리하자면">💡 그래서 순서를 정리하자면</h3>
<ul>
<li><strong>DataSourceAutoConfiguration</strong><ul>
<li>애플리케이션의 <code>DataSource</code> 빈을 생성합니다. (데이터베이스 연결, 커넥션 풀 등)</li>
</ul>
</li>
<li><strong>TransactionManagerCustomizationAutoConfiguration</strong><ul>
<li>기본 트랜잭션 매니저가 생성되기 전에, 필요한 커스터마이징을 적용합니다.</li>
</ul>
</li>
<li><strong>HibernateJpaAutoConfiguration</strong><ul>
<li>위의 두 단계(<code>DataSource</code>와 트랜잭션 매니저 커스터마이징)가 완료된 후 실행됩니다.</li>
<li>이 단계에서 HibernateJpaAutoConfiguration이 <strong>HibernateJpaConfiguration</strong>을 임포트합니다.</li>
</ul>
</li>
<li><strong>HibernateJpaConfiguration (JpaBaseConfiguration 기반)</strong><ul>
<li><strong>JpaVendorAdapter</strong>를 생성 및 등록하여, Hibernate 등 JPA 공급자 특화 설정을 적용합니다.</li>
<li><strong>EntityManagerFactoryBuilder</strong>를 생성합니다.</li>
<li><strong>LocalContainerEntityManagerFactoryBean</strong>를 등록하여 실제 JPA의 <code>EntityManagerFactory</code>를 생성하며, 이 빈은 <code>@Primary</code>로 등록됩니다.</li>
<li><strong>PlatformTransactionManager</strong> (<code>JpaTransactionManager</code>)를 생성 및 등록합니다.</li>
</ul>
</li>
<li><strong>JpaRepositoriesAutoConfiguration</strong><ul>
<li>Spring Data JPA의 Repository 인터페이스들을 스캔하여, JPA Repository 빈들을 자동으로 등록합니다.</li>
</ul>
</li>
<li><strong>TransactionAutoConfiguration</strong><ul>
<li>위에서 등록한 트랜잭션 매니저를 기반으로, @Transactional 등의 선언적 트랜잭션 관리를 위한 AOP 설정 및 인프라를 구성합니다.</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[우아한테크코스] OOP]]></title>
            <link>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-OOP</link>
            <guid>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-OOP</guid>
            <pubDate>Thu, 18 Jan 2024 09:11:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>우아한테크코스 6기 최종 코딩테스트</strong>를 준비하면서 작성된 글입니다.</p>
</blockquote>
<p>아래의 우아한테크코스의 프리코스 과제를 수행해 오면서 정리한 내용들로 이루어져 있습니다.</p>
<ul>
<li><code>oncall</code> - 최종 코딩 테스트!</li>
<li><code>subway-path</code></li>
<li><code>pairmatching-precourse</code></li>
<li><code>bridge</code></li>
<li><code>baseball</code></li>
<li><code>menu</code></li>
<li><code>christmas</code></li>
<li><code>lotto</code></li>
<li><code>racingcar</code></li>
<li><code>vendingmachine</code></li>
<li><code>onboarding</code></li>
</ul>
<hr>
<h1 id="oop">OOP</h1>
<h2 id="현실에-객체지향-대입">현실에 객체지향 대입</h2>
<ul>
<li><p><strong>객체는 항상 생성부터 소멸 시점까지 유효한 데이터를 가져야 한다.</strong>
키가 <code>99999</code> 인 사람은 존재하지 않기 때문에 비현실적이다.
이를 막기 위해서 생성 시부터 제약을 가해야 한다.</p>
<blockquote>
<p><strong>객체 지향에서의 객체는 생명체던 비생명체던 모두 자아를 가지고 스스로 동작할 수 있는 존재라고 생각하자.</strong></p>
</blockquote>
</li>
<li><p><strong>추상화</strong></p>
<ul>
<li><strong>현실에 존재하는 사물이 가진 속성 중에서 필요한 것만 추출하여 코드로 옮기는 것</strong></li>
<li><strong>객체를 의인화 하고 동작(메소드)을 추상화하여 가독성을 크게 높일 수 있다.</strong></li>
</ul>
</li>
<li><p><strong>캡슐화</strong></p>
<ul>
<li><strong>속성과 동작을 클래스로 묶어서 만드는 것이 바로 캡슐화다.</strong></li>
<li><strong>추상화</strong>를 통해 현실에 존재하는 사물의 속성과 동작 중에서 <strong>필요한 것만 추출했고,</strong>
이를 <strong>클래스로 옮기면 추상화와 캡슐화를 한 것이다.</strong></li>
</ul>
</li>
<li><p><strong>상속, 다형성</strong></p>
</li>
</ul>
<blockquote>
<p><strong>처음부터 객체지향을 준수하여 프로그래밍하기는 어려운것이 사실이다.</strong>
따라서 <code>소트웍스 앤솔러지</code>라는 책에서 제시하는 객체지향 프로그래밍을 잘 하기 위한 9가지 원칙을 먼저 정리하므로써 객체지향에 대한 기본 틀과 구성 방식에 대해 알아보자.</p>
</blockquote>
<hr>
<h2 id="객체지향-생활-체조-원칙">객체지향 생활 체조 원칙</h2>
<h3 id="1-한-함수메서드에-최소한의-들여쓰기indent만-허용했는가-br-최대-depth--2까지만-허용">1. 한 함수(메서드)에 최소한의 들여쓰기(indent)만 허용했는가? <br> (최대 depth : 2까지만 허용)</h3>
<ul>
<li>들여쓰기를 줄이는 가장 좋은 방법은 함수를 분리하는 것이다.</li>
</ul>
<h3 id="2-else-예약어를-사용하지-않았는가">2. else 예약어를 사용하지 않았는가?</h3>
<ul>
<li><code>else</code>와 같이 조건없이 모든 경우를 열어주는 코드는 큰 버그를 초래할 수 있기 때문에 지양하는 것이 좋다.</li>
</ul>
<h3 id="3-모든-원시값과-문자열을-포장했는가">3. 모든 원시값과 문자열을 포장했는가?</h3>
<ul>
<li><p>변수를 선언하는 방법에는 두가지가 존재하는데, <strong>객체로 포장해라</strong>
(<code>Collection</code>으로 선언한 변수도 포장한다.) -&gt; <code>일급 컬렉션</code></p>
<pre><code class="language-java">int age = 20; // 원시타입의 변수
Age age = new Age(20) // 원시 타입의 변수를 객체로 포장한 변수</code></pre>
</li>
<li><p>아래의 경우를 살펴보면, <code>User</code> 클래스의 필드는 단 2개만 존재하지만,
해당 <code>User</code>클래스가 해야할 일은 굉장히 많다</p>
<pre><code class="language-java">public class User {
  private String name;
  private int age;

  public User(String nameValue, String ageValue) {
      int age = Integer.parseInt(ageValue);
      validateAge(age);
      validateName(nameValue);
      this.name = nameValue;
      this.age = age;
  }

  private void validateName(String name) {
      if (name.length() &lt; 2) {
          throw new RuntimeException(&quot;이름은 두 글자 이상이어야 합니다.&quot;);
      }
  }

  private void validateAge(int age) {
      if (age &lt; 0) {
          throw new RuntimeException(&quot;나이는 0살부터 시작합니다.&quot;);
      }
  }
}</code></pre>
</li>
<li><p>아래와 같이 원시타입 객체를 포장하여 <code>User</code>가 해야할 일을 덜어 줄 수있다</p>
<pre><code class="language-java">public class User {
  private Name name;
  private Age age;

  public User(String name, String age) {
      this.name = new Name(name);
      this.age = new Age(age);
  }
}
</code></pre>
</li>
</ul>
<p>public class Name {
    private String name;</p>
<pre><code>public Name(String name) {
    if (name.length() &lt; 2) {
        throw new RuntimeException(&quot;이름은 두 글자 이상이어야 합니다.&quot;);
    }
    this.name = name;
}</code></pre><p>}</p>
<p>public class Age() {
    private int age;</p>
<pre><code>public Age(String input) {
    int age = Integer.parseInt(input);
    if(age &lt; 0) {
        throw new RuntimeException(&quot;나이는 0살부터 시작합니다.&quot;);
    }
}</code></pre><p>}</p>
<pre><code>이 말은 박싱된 기본타입을 쓰라는 말이랑은 다르다!
`(EffectiveJava: 박싱된 기본 타입보다는 기본 타입을 사용하라)`

위의 예시를 들어 설명하자면, `Age`부분에서 원시타입 객체를 쓴것을 볼 수 있다.
거의 모든 경우에 박싱된 기본타입(`Integer`)과 같은 경우 보다는 기본 타입(`int`)을 사용해야 하고, 무조건 박싱된 기본타입을 써야하는 경우는 아래와 같다.
&gt;**1. 제네릭**
**2. 리플렉션**
**3. DTO (null을 허용하기 때문에)**

정리하자면,
하나의 객체 내부에 필드가 존재하는데, **필드 하나가 아닌 필드가 2개이상인 경우에**
해당 필드에 **유효성검증과 같은 검증 로직**이 들어가야 한다면,
해당 필드를 원시타입으로 유지하지 말고, **포장하자!**

### 4. 컬렉션에 대해 일급 컬렉션을 적용했는가?
- **일반적인 컬렉션**
``` java
Map&lt;String, String&gt; map = new HashMap&lt;&gt;();
map.put(&quot;1&quot;, &quot;A&quot;);
map.put(&quot;2&quot;, &quot;B&quot;);
map.put(&quot;3&quot;, &quot;C&quot;);</code></pre><ul>
<li><p><strong>일급 컬렉션</strong> (아래와 같이 <code>Wrapping</code>하는 것) → 하나의 자료구조가 된다.</p>
</li>
<li><p><code>Collection</code>을 <code>Wrapping</code>하면서, 그 외 다른 멤버변수가 없는 상태를 <strong>일급컬렉션</strong>이라 한다.</p>
<pre><code class="language-java">public class GameRanking {
  private Map&lt;String, String&gt; ranks;

  public GameRanking(Map&lt;String, String&gt; ranks) {
      this.ranks = ranks;
  }
}</code></pre>
</li>
</ul>
<h3 id="5-3개-이상의-인스턴스-변수를-가진-클래스를-구현하지-않았는가">5. 3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않았는가?</h3>
<ul>
<li>인스턴스 변수가 많아질수록 클래스의 응집도는 낮아진다</li>
<li><em>(응집도는 높을수록 좋다)*</em></li>
<li>마틴 파울러는 <strong>대부분의 클래스가 인스턴스 변수 하나만으로 일</strong>을 하는 것이 적합하다고 말했다</li>
<li><strong>따라서, 최대한 클래스를 많이 분리하게끔 강제하여 높은 응집도를 유지하자!</strong></li>
</ul>
<pre><code class="language-java">public class Car {
    private String brand;
    private String model;  // 인스턴스 변수 2개

    public Car(String brand, String model) {
        this.brand = brand;
        this.model = model;
    }

    public String getBrand() {
        return brand;
    }

    public String getModel() {
        return model;
    }
}

public class Engine {};
public class Light {};
public class Wiper {};
public class Brake {};
// ..</code></pre>
<ul>
<li><code>CleanCode</code>에서는 인스턴스 변수 뿐 아니라 메서드의 가장 이상적인 파라미터 개수는 0개라고 한다.</li>
<li><em>즉, 인스턴스 필드나 메서드 파라미터를 최대한 적게 유지해서 응집도를 높여야 한다!*</em></li>
</ul>
<h3 id="6-핵심-로직을-구현하는-도메인-객체에-gettersetter를-사용하지-않고-구현했는가-단-dto는-허용한다">6. 핵심 로직을 구현하는 도메인 객체에 getter/setter를 사용하지 않고 구현했는가? (단, DTO는 허용한다!)</h3>
<ul>
<li><p><code>getter</code>로 객체 내부의 상태를 꺼내와 외부에서 상태를 바꾸는 것은, <strong>객체의 상태값을 바꾼다는 판단</strong>을 외부에 위임한 것이다!</p>
</li>
<li><p><em>객체의 상태값을 바꾼다는 판단을 외부에 맡기지 말자! 
이는 곧 <code>독립적인 객체 설계</code>에 위배되는 행위이다.*</em></p>
</li>
<li><p><strong>즉, 객체의 상태가 변경되는 것은 객체 스스로의 행동에 의해야 한다.</strong>
자율적인 객체가 되고 외부의 영향을 받지 않음으로써 느슨한 결합과 유연한 협력을 이룰 수 있는 것이다.</p>
</li>
<li><p><code>getter</code>와 <code>setter</code>는 자신의 상태 정보를 외부에 노출하는 격이 되고 이것은 외부의 영향으로 상태 정보가 변할 수 있는 가능성을 열어두게된다.</p>
</li>
<li><p><strong>따라서, <code>getter</code>/<code>setter</code>의 사용은 지양하자</strong></p>
<ul>
<li><p>데이터의 이동은 <strong>DTO</strong>를 이용하자!</p>
</li>
<li><p><code>View</code>에서 단지 출력용도로 사용하기 위해서는 <strong><code>getter</code></strong>를 쓸 수도 있다.</p>
<ul>
<li>이때 <strong>방어적 복사</strong>를 통해 외부에서의 데이터 변경이 내부까지 영향이 가지 않도록 구성하자!</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-java">public class DefensiveCopyingExample {
    private List&lt;String&gt; internalList;

    public DefensiveCopyingExample(List&lt;String&gt; originalList) {
        // 방어적 복사를 통해 원본 리스트를 보호
        this.internalList = new ArrayList&lt;&gt;(originalList);
    }

    public List&lt;String&gt; getInternalList() {
        // 방어적 복사를 통해 내부 리스트를 외부로 노출하지 않음
        return new ArrayList&lt;&gt;(internalList);
    }

    public static void main(String[] args) {
        List&lt;String&gt; originalList = new ArrayList&lt;&gt;();
        originalList.add(&quot;Item 1&quot;);
        originalList.add(&quot;Item 2&quot;);

        DefensiveCopyingExample example = new DefensiveCopyingExample(originalList);

        // 외부에서 원본 리스트에 접근
        List&lt;String&gt; externalList = example.getInternalList();

        // 외부에서 리스트에 아이템 추가
        externalList.add(&quot;Item 3&quot;);

        // 원본 리스트에 변화 없음을 확인
        System.out.println(&quot;Original List: &quot; + originalList);  // [Item 1, Item 2]
        System.out.println(&quot;External List: &quot; + externalList);  // [Item 1, Item 2, Item 3]
    }
}    </code></pre>
<h3 id="7-코드-한줄에-점을-하나만-허용했는가">7. 코드 한줄에 점(.)을 하나만 허용했는가?</h3>
<ul>
<li><p><code>단순히 라인에 존재하는 점의 개수를 수치적으로 줄이라는 의미</code>보다는
<code>필드</code>나 <code>메서드</code>를 통해 <code>인스턴스에 접근하는 방식 자체</code>를 재고해보라는 뜻이다.</p>
</li>
<li><p><strong>점의 개수가 많다는 것은 대상 객체의 내부에 깊이 접근하겠다는 의도를 나타낸다.</strong></p>
</li>
<li><p>이는 일반적으로 <strong>호출자</strong>와 <strong>피호출자</strong> 사이의 <strong><code>강한 결합도</code></strong>를 바탕으로 메서드의 응집력을 떨어뜨리고 있을 확률이 높기 때문이다.</p>
<blockquote>
<p>🧨 <strong>디미터의 법칙(&quot;친구하고만 대화하라&quot;)</strong>
자기 소유의 장난감, 자기가 만든 장난감, 그리고 누군가 자기에게 준 장난감하고만 놀 수 있다.</p>
</blockquote>
</li>
<li><p><em>절대 장난감의 장난감과 놀면 안된다.*</em> <br></p>
</li>
<li><p><em>즉, 객체 간의 관계에서 이웃하지 않는 낯선 객체와 메세지를 보내는 설계는 피하라는 것이다.*</em></p>
</li>
<li><p>물론 가독성 측면에서도 문제가 있다.</p>
<pre><code class="language-java">String result = someObject.getA().getB().getC().calculate();</code></pre>
<pre><code class="language-java">A a = someObject.getA();
B b = a.getB();
C c = b.getC();
String result = c.calculate();</code></pre>
</li>
</ul>
<p><code>점의 개수가 많다면</code>
<strong>1. 대상 객체의 내부에 깊이 접근한것은 아닌지,</strong>
<strong>2. 디미터의 법칙을 위배한 것은 아닌지 경계하고,</strong>
<strong>3. 가독성 측면에서도 문제가 있으니 수정을 요한다!</strong></p>
<h3 id="8-메서드의-인자-수를-3개-이하로-제한했는가">8. 메서드의 인자 수를 3개 이하로 제한했는가?</h3>
<p><strong>(3개를 초과하는 인자는 허용하지 않으며, 3개도 가능하면 줄이기 위해 노력해 본다)</strong></p>
<ul>
<li>메서드에 많은 인자가 있다는 것은 해당 메서드의 <strong>역할</strong>이 많다는 것으로도 해석된다.</li>
</ul>
<h3 id="9-메서드가-한가지-일만-담당하도록-구현했는가">9. 메서드가 한가지 일만 담당하도록 구현했는가?</h3>
<p>하나의 메서드가 <strong>여러 책임을 담당</strong>한다면 코드의 길이가 늘어나게 되며,
다른 개발자가 <strong>해당 메서드의 역할을 이해하기 어렵게 된다.</strong> 
(메서드 이름도 애매해진다)</p>
<h3 id="10-클래스를-작게-유지하기-위해-노력했는가">10. 클래스를 작게 유지하기 위해 노력했는가?</h3>
<p><strong>(메서드당 line을 10까지만 허용하며 길이가 길어지면 <code>메서드로 분리</code>시킨다.)</strong></p>
<ul>
<li><strong>하나의 목적을 염두하고 설계하라는 의미이다.</strong>
50줄 이상의 객체는 한 가지 이상의 일을 하고 있을 확률이 높다.</li>
<li><em>그러니 작게 유지해야 한다.*</em></li>
</ul>
<h3 id="11-매직-리터럴매직-넘버-사용을-자제하고-상수를-사용하자">11. 매직 리터럴/매직 넘버 사용을 자제하고 상수를 사용하자</h3>
<ul>
<li>프로그래밍에서 <code>상수 (static final)</code>로 선언하지 않은 <code>숫자를 
매직 넘버, 문자열을 매직 리터럴</code>이라 한다.</li>
<li><em>이를 정적(static)이고 변경 불가능(final)한 상수로 선언하여 사용하자.*</em></li>
<li>코드에서 상수로 선언되어 있지 않은 숫자, 문자열은 무엇을 의미하는지 확신할 수 없다.
이를 상수로 선언하게 됨으로써 불분명한 값들은 이름을 가지게 된다.
이름을 가지게 된 값은 <strong>그 이름만으로도 어떠한 역할을 하는지 알 수 있게 된다.</strong></li>
<li><strong>하지만 숫자 1을 <code>ONE</code>으로 이름을 짓는 것과 같은 의미없은 상수 변환은 피하도록 하자.</strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[우아한테크코스] Unit Test]]></title>
            <link>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C-%ED%85%8C%ED%81%AC-%EC%BD%94%EC%8A%A4-%EC%B5%9C%EC%A2%85-%EC%A0%84%EB%9E%B5-Unit-Test</link>
            <guid>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C-%ED%85%8C%ED%81%AC-%EC%BD%94%EC%8A%A4-%EC%B5%9C%EC%A2%85-%EC%A0%84%EB%9E%B5-Unit-Test</guid>
            <pubDate>Fri, 12 Jan 2024 09:35:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>우아한테크코스 6기 최종 코딩테스트</strong>를 준비하면서 작성된 글입니다.</p>
</blockquote>
<p>아래의 우아한테크코스의 프리코스 과제를 수행해 오면서 정리한 내용들로 이루어져 있습니다.</p>
<ul>
<li><code>oncall</code> - 최종 코딩 테스트!</li>
<li><code>subway-path</code></li>
<li><code>pairmatching-precourse</code></li>
<li><code>bridge</code></li>
<li><code>baseball</code></li>
<li><code>menu</code></li>
<li><code>christmas</code></li>
<li><code>lotto</code></li>
<li><code>racingcar</code></li>
<li><code>vendingmachine</code></li>
<li><code>onboarding</code></li>
</ul>
<hr>
<blockquote>
<p><strong>구현 전략</strong>
<code>Docs</code>, <code>Feat</code>, <code>Test</code> 모두 한번에 <code>Commit</code> 하는 방식으로 진행하였다.
(= 기능단위로 커밋)</p>
</blockquote>
<hr>
<h2 id="unit-test">Unit Test</h2>
<blockquote>
<p><strong>도메인의 비즈니스로직을 단위테스트하는 것을 중심으로 작성되었다.</strong></p>
</blockquote>
<p>우선 <code>AssertJ</code>를 <code>import</code>한다.</p>
<pre><code class="language-java">import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;</code></pre>
<hr>
<h3 id="선택한-test-code-format">선택한 Test Code Format</h3>
<pre><code class="language-java">@Test
@DisplayName(&quot;1에서 9까지의 숫자가 아니라면 예외가 발생한다.&quot;)
void validateRange() { // 사용되어 지는 메서드 명
        assertThatThrownBy(() -&gt; new GameNumber(List.of(0, 2, 3)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(&quot;숫자는 1에서 9까지의 수로 이루어져야 합니다.&quot;);

}</code></pre>
<hr>
<h3 id="테스트-커버리지-확인하는-방법"><a href="https://velog.io/@pgmjun/Java-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BB%A4%EB%B2%84%EB%A6%AC%EC%A7%80-%ED%99%95%EC%9D%B8%EB%B2%95">테스트 커버리지 확인하는 방법</a></h3>
<hr>
<h3 id="테스트-라이브러리-활용">테스트 라이브러리 활용</h3>
<p><a href="https://jaehoney.tistory.com/210"><code>@ParameterizedTest</code></a> 을 <code>@Test</code> 애노테이션 대신 사용하면 여러 개의 파라미터 값에 대해 각각 테스트를 수행하는 코드를 간편하게 작성할 수 있다.</p>
<ul>
<li><p><code>@ValueSource(strings = {&quot;&quot;, &quot;  &quot;, &quot;  &quot;})</code></p>
</li>
<li><p><code>@CsvSource(value = {&quot;1:2&quot;, &quot;2:4&quot;, &quot;3:6&quot;}, delimiter = &#39;:&#39;)</code></p>
</li>
<li><p><code>@NullAndEmptySource</code></p>
</li>
<li><p><code>@EnumSource(Week.class)</code></p>
</li>
<li><p><code>@MethodSource(&quot;paramsForIsBlank&quot;)</code></p>
<pre><code class="language-java">  @ParameterizedTest
  @MethodSource(&quot;matchData&quot;)
  @DisplayName(&quot;다른 숫자와 비교해 같은 자리에 같은 수가 몇개 있는지 알 수 있다.&quot;)
  void matchCount(BaseballNumber computerNumber, BaseballNumber userNumber, long expected){
      assertThat(computerNumber.matchCount(userNumber)).isEqualTo(expected);
  }

  static Stream&lt;Arguments&gt; matchData() {
      BaseballNumber computerNumber = new BaseballNumber(List.of(4, 2, 3));
      return Stream.of(
              Arguments.of(computerNumber, new BaseballNumber(List.of(4, 2, 3)), 3L),
              Arguments.of(computerNumber, new BaseballNumber(List.of(1, 2, 3)), 2L),
              Arguments.of(computerNumber, new BaseballNumber(List.of(4, 3, 2)), 1L),
              Arguments.of(computerNumber, new BaseballNumber(List.of(3, 4, 5)), 0L)
      );
  }</code></pre>
</li>
</ul>
<p><a href="https://www.baeldung.com/assertj-exception-assertion"><code>AssertJ Exception Assertions</code></a></p>
<ul>
<li><code>assertThatIllegalArgumentException()</code>와 같이 예외를 특정해서 테스트할 수 있는 장점이 있지만, 아래와 같이 예외 처리하는 것이 더 간편한 것 같다.<pre><code class="language-java">assertThatThrownBy(() -&gt; new OrderSheets(orderDuplicate))
              .isInstanceOf(IllegalArgumentException.class)
              .hasMessageContaining(&quot;[ERROR] 유효하지 않은 주문입니다. 다시 입력해 주세요.&quot;);</code></pre>
</li>
</ul>
<p><a href="https://www.baeldung.com/junit5-assertall-vs-multiple-assertions"><code>assertAll</code></a></p>
<pre><code class="language-java">@DisplayName(&quot;특정 메뉴 그룹에 속하는 메뉴가 몇 개 있는지 알 수 있다.&quot;)
    @Test
    void getNumberOf() {
        OrderSheets orderSheets = new OrderSheets(List.of(&quot;초코케이크-2&quot;, &quot;제로콜라-1&quot;, &quot;시저샐러드-1&quot;));
        Assertions.assertAll(
                () -&gt; assertThat(orderSheets.getNumberOf(MenuGroup.DESSERT)).isEqualTo(2),
                () -&gt; assertThat(orderSheets.getNumberOf(MenuGroup.APPETIZER)).isEqualTo(1),
                () -&gt; assertThat(orderSheets.getNumberOf(MenuGroup.BEVERAGE)).isEqualTo(1)
        );
    }
</code></pre>
<hr>
<h3 id="테스트-코드-명-작성법">테스트 코드 명 작성법</h3>
<ul>
<li>테스트 코드 명을 작성할 때 <code>&quot;스트라이크 테스트&quot;</code> 보다는 <code>&quot;같은 자리에 같은 숫자가 존재하면, 스트라이크이다.&quot;</code>처럼 명사 나열보다는 <strong>문장 형식</strong>이 낫다.<ul>
<li><del>메서드 명을 한글로 쓰고, 상단에 <code>**@DisplayNameGeneration**</code> 라는 애너테이션을 쓰면, 매번 메서드마다 <code>@DisplayName</code> 을 사용하지 않아도 된다</del>  </li>
<li><em>→ 가독성을 더 헤치는 느낌을 받아 <code>@DisplayNameGeneration</code>을 사용하지 않기로 한다.*</em></li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/cd312cee-d72d-4a6b-8fdb-486285a78e0e/image.png" alt=""></p>
<hr>
<h3 id="메서드-시그니처를-수정하여-테스트하기-좋은-메서드로-만들기"><a href="https://tecoble.techcourse.co.kr/post/2020-05-07-appropriate_method_for_test_by_parameter/">메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기</a></h3>
<pre><code class="language-java">// 테스트하기 어려운 메서드
public void move() {
        final int number = random.nextInt(RANDOM_NUMBER_UPPER_BOUND);

        if (number &gt;= MOVABLE_LOWER_BOUND) {
            position++;
        }
 }

// 테스트하기 좋은 메서드
public void move(int number) {
    if (number &gt;= MOVABLE_LOWER_BOUND) {
        position++;
    }
}</code></pre>
<hr>
<h3 id="getter-와-같은-단순한-읽기-기능의-목적인-메서드들은-단위테스트에-의미가-없다">Getter 와 같은 단순한 읽기 기능의 목적인 메서드들은 단위테스트에 의미가 없다.</h3>
<pre><code class="language-java">public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

@Test
public void testGetName() {
    // Given
    Person person = new Person(&quot;John&quot;);

    // When
    String name = person.getName();

    // Then
    assertEquals(&quot;John&quot;, name);
}

// 의미가 없다!</code></pre>
<p>하지만, 계산된 값이 있는 경우나 로직이 내장된 경우와 같이 <code>Getter</code>가 내부적으로 어떤 로직을 수행하거나 특별한 경우를 처리하는 등의 추가적인 로직이 있는 경우에는 해당 로직에 대한 테스트를 추가하는 것이 좋다.</p>
<pre><code class="language-java">public class Person {
    private String name;
        private int age;

    public Person(String name, int age) {
        this.name = name;
                this.age = age;
    }

    public String getName() {
        if (age &lt; 18) {
            return &quot;Child &quot; + name;
        } else {
            return name;
        }
    }

@Test
public void testGetName() {
    // Given
    Person childPerson = new Person(&quot;Alice&quot;, 15);
    Person adultPerson = new Person(&quot;Bob&quot;, 25);

    // When
    String childName = childPerson.getName();
    String adultName = adultPerson.getName();

    // Then
    assertEquals(&quot;Child Alice&quot;, childName);
    assertEquals(&quot;Bob&quot;, adultName);
}</code></pre>
<p><strong>이경우에는 솔직히 <code>Getter</code>라는 이름보다는 다른 이름을 쓰는 것이 나은 판단 같다.</strong></p>
<p><strong>Q. 어떻게 캡슐화를 코드에 스며들게 할까?</strong>
<strong>A.</strong> 객체를 만들고 객체 안에서 메서드( 기능 )을 구현할 때 이름을 <strong>추상적이게 명명해야 한다.</strong></p>
<p><strong>Q. 그럼 추상적이게 명명한다는 것이 무엇일까?</strong>
<strong>A1.</strong> 먼저 내가 이 메서드를 왜 사용해야 하는지를 먼저 생각해보자
<code>Ex) 성인 콘텐츠와 미성년자 콘텐츠를 구분하여야 한다.</code>
<strong>A2.</strong> 은닉화 되어있는 <code>age</code>의 <code>getter</code>를 내가 가져와야 하는 이유로 명명하자.
<code>Ex) getAge → checkAdult</code>
<strong>A3.</strong> 위와 같이 작성했을 경우 외부에서 이 메서드를 사용하는 사용자는 <code>&#39;성인 여부를 확인하는 메서드이구나&#39;</code>라고 생각하고 사용하게 될 것이다. </p>
<blockquote>
<p><strong>그렇게 된다면,
이 안에서 어떤 속성을 썼는지 내부 로직을 예측할 수 없게 된다. -&gt; <code>추상적</code></strong> 
<strong>이렇게 사용자가 기능만 알고 사용하게 된다면 캡슐화는 잘 적용된 것일 것이다.</strong></p>
</blockquote>
<blockquote>
<p>메서드의 이름이 추상적이다 라는 것은 약간 오해의 소지가 있지만,
<strong><code>”추상적이다 = 구체적이지 않게 작성하라는 뜻”</code></strong>이 아니다
 애매하게 쓰이는 이름보다는 구체적인게 오히려 더 낫다
 <code>getAge</code>를 통해 나이를 알아오는 메서드이름을 추상화해서 표현하여
 <strong>사용자가 내부의 특정 속성, 특정 로직을 예측하지 못하게 구성하고,</strong>
 사용자가 어떤 기능인지만 알고 수행하게끔 <code>checkAdult</code>와 같이 추상화해서 표현하라는 뜻이다.</p>
</blockquote>
<hr>
<h3 id="검증된-메서드를-활용하는-메서드에-대한-테스트">검증된 메서드를 활용하는 메서드에 대한 테스트</h3>
<p> 내부적으로 검증된 메서드들을 사용하더라도 <strong>메서드 시그니처</strong>가 검증된 <strong>메서드들의 시그니처</strong>와 다를 시 테스트 하자.
아래의 경우 <code>finish()</code>에 대한 테스트는 굳이 진행하지 않아도 되겠다.</p>
<pre><code class="language-java">// BridgeGame
public boolean finish() {
        return bridge.end();
}
// Bridge
public boolean end() {
        return unit.size() == index;
}</code></pre>
<pre><code class="language-java">/**
 * @param size 다리의 길이
 * @return 입력받은 길이에 해당하는 다리 모양. 위 칸이면 &quot;U&quot;, 아래 칸이면 &quot;D&quot;로 표현해야 한다.
     */
 public List&lt;String&gt; makeBridge(int size) {
    validateSize(size);
    List&lt;String&gt; bridge = new ArrayList&lt;&gt;();
    for(int i = 0; i &lt; size; i++){
        bridge.add(BridgeUnit.of(bridgeNumberGenerator.generate()).getSignatureLetter());
    }
    return bridge;
}
</code></pre>
<p>위와 같이 이루어져 있는 경우에 <code>makeBridge(int size)</code>로 생성되는 <code>List&lt;String&gt;</code>형의 문자들은 정확히 어떤 문자들이 들어가는지 예측할 수가 없다.
<code>(내부적으로 랜덤한 값을 생성해 문자로 변환하기 때문에)</code></p>
<p>하지만, 랜덤한 값을 생성하는 <code>bridgeNumberGenerator</code>가 정상적으로 동작하는지에 대해 검증했고, <code>BridgeUnit.of()</code> 또한 정상작동하는 것을 검증했다면,
정확히 <code>List&lt;String&gt;</code>에 어떤 순서로 값이 들어가는 지는 별로 중요하지 않고,</p>
<p>특정 문자 <code>U</code>와 <code>D</code>만 정상적으로 들어갔는지 검사하는 것으로 테스트를 할 수 있겠다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;무작위 값을 이용해 다리를 생성할 수 있다.&quot;)
void makeBridge() {
    BridgeMaker bridgeMaker = new BridgeMaker(new BridgeRandomNumberGenerator());
    assertAll(
            () -&gt; assertThat(bridgeMaker.makeBridge(3).size()).isEqualTo(3),
            () -&gt; assertThat(bridgeMaker.makeBridge(3)).containsAnyElementsOf(List.of(CrossingDirection.TOP.getSignatureLetter(), CrossingDirection.BOTTOM.getSignatureLetter()))
    );
}</code></pre>
<hr>
<h3 id="입력-예외-검증-테스트">입력 예외 검증 테스트</h3>
<p>아래와 같이 입력에 대한 예외를 검증하는 부분에 대해서는 테스트 코드를 작성하지 않는다.</p>
<pre><code class="language-java">      private void validateNullAndEmpty(String input) {
        if (Objects.isNull(input) || input.isEmpty()) {
            throw new IllegalArgumentException(&quot;null 이거나 길이가 없는 문자열 입니다.&quot;);
        }
    }

    private void validateNumeric(String input) {
        if (!NUMERIC_PATTERN.matcher(input).matches()) {
            throw new IllegalArgumentException(&quot;문자열이 숫자 1부터 9까지로 이루어져 있지 않습니다.&quot;);
        }
    }
    private void validateSingleLetter(String input) {
        if (input.length() != 1) {
            throw new IllegalArgumentException(&quot;문자열의 크기는 한개로 이루어져야 합니다.&quot;);
        }
    }</code></pre>
<p><code>InputView</code>에서 아래 부분의 <code>throw~</code> 로 예외처리되는 부분이 검증이 되지않아 테스트를 진행하지 않아 테스트 코드 커버리지가 떨어지는 걸 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/9fb488ce-fdd0-4fce-b43d-ea23f135d261/image.png" alt=""></p>
<p><code>util</code>로 따로 생성해 테스트를 해줄까도 생각해 보았지만 그렇게 하지 않았다.
테스트 코드를 꼼꼼히 작성해 테스트 코드 커버리지가 높아 지는 것은 좋은 일이지만,
<strong>코드 커버리지를 높이기 위한 테스트코드 작성은 주객이 전도된 것이다.</strong></p>
<p>테스트는 결함 검출용으로 사용하고, <code>Code Coverage</code>에는 집착하지 마라
결함 검출을 어느 수준까지 할것인지는 본인의 판단이며,
위와 같은 상황에서 아래와 같다면
<code>input 예외처리 테스트 코드 작성의 비용</code> &gt; <code>입력 검증 Test 결함으로 생기는 SideEffect</code>
테스트 코드를 굳이 작성하지 않고, <code>input</code> 검증에 관한 부분은 결함이 없다고 생각하고 진행하는 것이 맞다</p>
<blockquote>
<p>입력 검증 <code>Test</code> 결함으로 생기는 <code>SideEffect</code>는 거의 없다고 생각되며, 비즈니스 로직과 연관되는 예외처리도 아니므로 그렇게 <code>Critical</code>한 예외 상황도 없을 것이다.</p>
</blockquote>
<hr>
<h3 id="주어진-라이브러리에-대한-테스트">주어진 라이브러리에 대한 테스트</h3>
<p>라이브러리로 주어지는 <code>import camp.nextstep.edu.missionutils.Randoms;</code>과 같은 것을
테스트해야 하는지, 또 테스트를 해야한다면 어떻게 구성해야하는지에 대해 알아보자</p>
<p><strong>테스트를 해야 하는가? -&gt; ⭕️</strong>
<code>숫자를 몇개를 만드는지</code>, <code>범위는 어디서부터 어디까지인지</code> 외부에서 모르기 때문에 <code>generate</code>메서드에 대한 테스트를 만들어야 한다.
또한 <code>Util</code>성을 가진 클래스라도 테스트는 해야하는 것이 맞다.</p>
<pre><code class="language-java">public class RandomNumGenerator {
    public static List&lt;Integer&gt; generate(){
        List&lt;Integer&gt; numbers = new ArrayList&lt;&gt;();
        while (numbers.size() &lt; 3) {
            int randomNumber = Randoms.pickNumberInRange(1, 9);
            if (!numbers.contains(randomNumber)) {
                numbers.add(randomNumber);
            }
        }
        return numbers;
    }
}</code></pre>
<p><strong>그렇다면 어떻게 테스트를 해야하는가?</strong>
<code>Randoms.picikNumberInRange</code>의 경우 내부적으로 어떤 구현이 이루어졌는지 메서드 명만으로는 알 수 없으며, 구현내부를 알더라도 관련된 메서드는 <code>private</code>으로 접근제한을 걸어놓아 항상 <code>1부터 9사이의 수</code>를 반환하는지 검증하기 어렵고, 테스트하기 힘들다.</p>
<p>덧붙이자면, 만약 구현 내부(<code>pickNumberInRange() 내부</code>)의 메서드들을 모두 검증할 수 있다면 <code>항상 1부터 9사이의 수를 반환하는 것</code>을 입증할 수 있지만,
현재는 그렇게 하지 못하므로 <code>항상 1부터 9사이의 수를 반환</code>하는지 검증하기 어렵다는 것이다.</p>
<p><strong>따라서, 충분히 큰 수만큼 테스트를 돌렸을 때 정상적으로 동작한다는 것은 메서드가 정상적으로 동작한다는 것으로 생각하자!</strong></p>
<p>(라이브러리가 정상적으로 동작하는 지에 대한 검증이 필요없을 수 있지만,
해당 라이브러리는 내가 직접 구현한 것이 아니므로 추가적인 검증을 진행한 것이다)</p>
<p><strong>테스트를 10000번 돌려도 되고(<code>라이브러리에 대한 추가 검증</code>),
1번만 돌려도(<code>&quot;라이브러리에 대한 검증&quot;이 됐다라고 판단한 후 진행하는 것</code>) 된다.</strong></p>
<pre><code class="language-java">class RandomNumGeneratorTest {

    public static final int ENOUGH_BIG_NUMBER = 10000;

    @Test
    @DisplayName(&quot;1에서 9까지 서로 다른 임의의 수 3개를 생성한다.&quot;)
    void generate() {
        for (int i = 0; i &lt; ENOUGH_BIG_NUMBER; i++) {
            List&lt;Integer&gt; randomNums = RandomNumGenerator.generate();
            assertAll(
                    () -&gt; assertThat(randomNums.stream().allMatch(num -&gt; num &gt;= 1 &amp;&amp; num &lt;= 9)).isTrue(),
                    () -&gt; assertThat(randomNums.stream().distinct().toList().size()).isEqualTo(3),
                    () -&gt; assertThat(randomNums.size()).isEqualTo(3)
            );
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[우아한테크코스] Convention & Docs]]></title>
            <link>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C-%ED%85%8C%ED%81%AC-%EC%BD%94%EC%8A%A4-%EC%B5%9C%EC%A2%85-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C-%ED%85%8C%ED%81%AC-%EC%BD%94%EC%8A%A4-%EC%B5%9C%EC%A2%85-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Thu, 11 Jan 2024 15:32:30 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>우아한테크코스 6기 최종 코딩테스트</strong>를 준비하면서 작성된 글입니다.</p>
</blockquote>
<p>아래의 우아한테크코스의 프리코스 과제를 수행해 오면서 정리한 내용들로 이루어져 있습니다.</p>
<ul>
<li><code>oncall</code> - 최종 코딩 테스트!</li>
<li><code>subway-path</code></li>
<li><code>pairmatching-precourse</code></li>
<li><code>bridge</code></li>
<li><code>baseball</code></li>
<li><code>menu</code></li>
<li><code>christmas</code></li>
<li><code>lotto</code></li>
<li><code>racingcar</code></li>
<li><code>vendingmachine</code></li>
<li><code>onboarding</code></li>
</ul>
<hr>
<h2 id="convention">Convention</h2>
<h3 id="angularjs-commit-convention">AngularJS commit convention</h3>
<pre><code>docs(README): 기능 목록 정리</code></pre><h3 id="google-java-style-guide-code-convention"><a href="https://github.com/woowacourse/woowacourse-docs/tree/main/styleguide/java">Google Java Style Guide (Code convention)</a></h3>
<ul>
<li><a href="https://vince-kim.tistory.com/28">Intellij Google JAVA Style Guide 설정</a></li>
<li><a href="https://velog.io/@pgmjun/IntelliJ-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%84-%EC%84%A4%EC%A0%95%ED%95%B4%EB%B3%B4%EC%9E%90-feat.%EC%9A%B0%ED%85%8C%EC%BD%94">Intellij WootecoStyle 설정</a></li>
</ul>
<hr>
<h2 id="docs">Docs</h2>
<h3 id="기능-요구-사항-프로그래밍-요구-사항-과제-진행-요구-사항-을-만족하기-위해-노력한다">기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항 을 만족하기 위해 노력한다.</h3>
<p>특히 기능 요구 사항을 읽어가면서, 처음에는 <code>README</code>에 아래의 내용을 작성해 나아간다. 기능 요구 사항을 읽어도 눈에 잘 안들어 올 수 있다.
따라서 <code>README</code>에 중요하다고 생각되는 부분 먼저 작성하고,</p>
<ul>
<li><strong>Q. 해당 도메인이 이런 책임을 가져도 되는지 애매하다면?
A. 우선 그 도메인에 해당 기능을 구현하고 추후에 분리하자!</strong></li>
</ul>
<p><strong>해당 도메인을 구현하면서 분리가 가능하다고 생각되면 분리를 진행하자.</strong></p>
<blockquote>
<p><strong>초기 README</strong></p>
</blockquote>
<ul>
<li>전체적인 게임 규칙</li>
<li>전체적인 게임 흐름</li>
<li>Domain</li>
<li>Input/Output</li>
<li>Controller</li>
</ul>
<h3 id="💡-도메인과-입력이-중복되는-부분이-반드시-존재한다---중복-제거-❌">💡 도메인과 입력이 중복되는 부분이 반드시 존재한다. - 중복 제거 ❌</h3>
<p><strong>다리</strong> : <code>다리의 길이는 3 이상 20 이하로 만들어져야 한다.</code></p>
<p><strong>입력</strong> : <code>자동으로 생성할 다리 길이를 입력 받는다. 3 이상 20 이하의 숫자를 입력할 수 있다</code></p>
<p>이와 같은 부분은 도메인의 <strong>비즈니스 로직 예외 검증 부분</strong>과, 
입력의 <strong>단순 입력 예외 검증 부분</strong>을 구분하는 용도로 중복을 제거하지 말고, 그대로 유지한다.</p>
<h3 id="💡-중복되는-기능-요구사항들을-합치자---중복-제거-⭕️">💡 중복되는 기능 요구사항들을 합치자. - 중복 제거 ⭕️</h3>
<p>하나의 도메인<code>(ex: 입력, 출력, 다리 등)</code>에서 중복되는 요구사항들이 존재한다면 다른 도메인으로 전파 시키지 말고 그 도메인 내에서 중복을 제거하자.</p>
<h3 id="💡-단순-생성자만-존재해도-readme에-추가하자">💡 단순 생성자만 존재해도 README에 추가하자</h3>
<p>단순히 값을 표현하는 <code>Enum</code>객체에서 <code>생성자</code>와 <code>Getter</code>만 존재하더라도 기능 목록에 작성해 주고, 필드<code>(생성자만 있는 클래스, 열거형)</code>만 가지고 있는 경우 
<code>README</code>작성 시 <code>- [x]</code>로 <strong>체크박스화</strong> 하지말고 작성하자!
정의할 수 있다 보다는 <code>구성된다</code> 가 더 눈에 잘들어오는 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/e227c29d-299b-4811-affc-122cb7e9fa51/image.png" alt=""></p>
<blockquote>
<p><strong>최종 README</strong></p>
</blockquote>
<ul>
<li><strong>Domain</strong></li>
<li><strong>Input/Output</strong></li>
<li><strong>Controller</strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[우아한테크코스]  다리 건너기 (최종 코테 준비)]]></title>
            <link>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-%EB%8B%A4%EB%A6%AC-%EA%B1%B4%EB%84%88%EA%B8%B0</link>
            <guid>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-%EB%8B%A4%EB%A6%AC-%EA%B1%B4%EB%84%88%EA%B8%B0</guid>
            <pubDate>Sun, 10 Dec 2023 18:55:39 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h3 id="다리-건너기-회고기록">다리 건너기 회고기록</h3>
<p><code>고민 1</code> : 검증된 메서드를 활용하는 메서드에 대한 테스트?
<code>고민 2</code> : 클래스를 작게 쪼개고, <code>Controller</code>는 비즈니스로직을 가지지 말자.
<code>&lt;고민 아닌 고민&gt;</code>
        - <code>@FunctionalInterface</code></p>
</blockquote>
<blockquote>
<p><a href="https://github.com/Ho-Tea/java-bridge/tree/Ho-Tea2">GitHub Code</a></p>
</blockquote>
<h2 id="convention">Convention</h2>
<h3 id="angularjs-commit-conventions"><strong>AngularJS commit conventions</strong></h3>
<pre><code>docs(README): 기능목록 재정리</code></pre><h3 id="google-java-style-guide"><strong>Google Java Style Guide</strong></h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/bb62c26e-4371-4197-9f54-d69880deca66/image.png" alt=""></p>
<pre><code>기존의 Google Java Style Guide와 비교했을 때 
크게 달라진 점은 블럭 들여쓰기 2 -&gt; 4로 변경된 것 뿐이다.</code></pre><blockquote>
<p><code>Enable google-java-format</code>을 설정 시 <code>Google Java Style Guide</code>로 설정.
   <img src="https://velog.velcdn.com/images/ho-tea/post/917db337-fcfd-4298-a1ab-72caa2b9703e/image.png" alt=""></p>
</blockquote>
<blockquote>
<p><code>Default</code>는 <code>WootecoStyle</code>로 설정되어 있다.
<img src="https://velog.velcdn.com/images/ho-tea/post/25b0af60-6266-4f2e-b964-a441ac5856d9/image.png" alt=""></p>
</blockquote>
<hr>
<h2 id="docs">Docs</h2>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/81c23441-3b9c-4094-a01f-06967da3230d/image.png" alt=""></p>
<p>이렇게 기능 요구사항이 주어진다면 처음 부터 아래와 같이 작성하기는 어렵다. </p>
<blockquote>
<h3 id="1-기능-요구-사항을-읽어가면서-아래-사항들을-정리">1. 기능 요구 사항을 읽어가면서 아래 사항들을 정리</h3>
</blockquote>
<ul>
<li>전체적인 게임 규칙</li>
<li>Domain(컴퓨터, 플레이어)</li>
<li>System 유의사항</li>
<li>Exception Handling</li>
<li>Input</li>
<li>Output</li>
<li>사용해야 할 라이브러리</li>
</ul>
<p>따라서 위의 <code>1번</code>을 수행하기전에 아래의 <strong>빨간 밑줄그어진 부분</strong>을 우선적으로 정리해 <code>전체적인 흐름</code>을 알아 내도록 하자
<img src="https://velog.velcdn.com/images/ho-tea/post/5bf8a733-f9ad-4adc-a2ce-3c9f0a40476b/image.png" alt=""></p>
<blockquote>
<h3 id="0-전체적인-흐름-정리">0. 전체적인 흐름 정리</h3>
</blockquote>
<ul>
<li>전체적인 게임 규칙</li>
<li>전체적인 게임 흐름
<img src="https://velog.velcdn.com/images/ho-tea/post/2d33bb9e-a07b-492b-a582-6eee66fb937d/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/54908247-6b0f-4c6c-9d46-1c731b4726e9/image.png" alt=""></p>
<blockquote>
<p>위의 파란색 박스에 속해있는 부분들을 참고하여 아래 사항들을 정리해 나간다.
<strong>정리해 나가면서 <code>입력</code>과 <code>출력</code>에 관한 많은 부분은 <code>입출력 요구 사항</code>을 참고한다.</strong></p>
</blockquote>
<h3 id="1-기능-요구-사항을-읽어가면서-아래-사항들을-정리-1">1. 기능 요구 사항을 읽어가면서 아래 사항들을 정리</h3>
<ul>
<li>전체적인 게임 규칙</li>
<li>전체적인 게임 흐름</li>
<li>Domain(다리, 플레이어)</li>
<li>System 유의사항</li>
<li>Exception Handling</li>
<li>Input</li>
<li>Output</li>
<li>사용해야 할 라이브러리</li>
</ul>
<pre><code class="language-markdown"># 게임 규칙
위아래 둘 중 하나의 칸만 건널 수 있는 다리를 끝까지 건너가는 게임이다.

# 게임 흐름
- 위아래 두 칸으로 이루어진 다리를 건너야 한다.
- 다리의 길이를 숫자로 입력받고 생성한다.
- 다리가 생성되면 플레이어가 이동할 칸을 선택한다.
- 다리를 끝까지 건너면 게임이 종료된다.
- 다리를 건너다 실패하면 게임을 재시작하거나 종료할 수 있다.

## 다리
- 다리의 길이는 3 이상 20 이하로 만들어져야 한다.
  - 올바른 값이 아니면 예외 처리한다.
- 다리를 생성할 때 위 칸과 아래 칸 중 건널 수 있는 칸은 0과 1 중 무작위 값을 이용해서 정한다. 
&lt;br&gt; 무작위 값이 0인 경우 아래 칸, 1인 경우 위 칸이 건널 수 있는 칸이 된다.

## 플레이어
- 다리는 왼쪽에서 오른쪽으로 건너야 한다.
- 위아래 둘 중 하나의 칸만 건널 수 있다. &lt;br&gt; 위 칸을 건널 수 있는 경우 U, 아래 칸을 건널 수 있는 경우 D값으로 나타낸다.

## 입력
- 자동으로 생성할 다리 길이를 입력 받는다. 3 이상 20 이하의 숫자를 입력할 수 있다
  - 올바른 값이 아니면 예외 처리한다.
- 이동할 때 위 칸은 대문자 U, 아래 칸은 대문자 D를 입력한다.
  - 올바른 값이 아니면 예외 처리한다.
- 게임 재시작/종료 여부를 입력 받는다. R(재시작)과 Q(종료) 중 하나의 문자를 입력할 수 있다
  - 올바른 값이 아니면 예외 처리한다.

## 출력
- 게임 시작 문구를 출력한다.
- 게임 종료 문구를 출력한다.
- 사용자가 이동할 때마다 다리 건너기 결과의 출력 (이동한 칸을 건널 수 있다면 O로 표시한다. 건널 수 없다면 X로 표시한다.) 

## System 유의사항
- 재시작해도 처음에 만든 다리로 재사용한다.
- 게임 결과의 총 시도한 횟수는 첫 시도를 포함해 게임을 종료할 때까지 시도한 횟수를 나타낸다.

## Exception Handling
- 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, &quot;[ERROR]&quot;로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

## 사용해야 할 라이브러리
- Random 값 추출은 제공된 bridge.BridgeRandomNumberGenerator의 generate()를 활용한다.
- camp.nextstep.edu.missionutils에서 제공하는 Console API를 사용하여 구현해야 한다.</code></pre>
<blockquote>
<h3 id="2-도메인과-입력이-중복되는-부분이-반드시-존재한다---중복-제거-❌">2. 도메인과 입력이 중복되는 부분이 반드시 존재한다. - 중복 제거 ❌</h3>
<p>다리 : 다리의 길이는 3 이상 20 이하로 만들어져야 한다. | 입력 : 자동으로 생성할 다리 길이를 입력 받는다. 3 이상 20 이하의 숫자를 입력할 수 있다
이와 같은 부분은 도메인의 <strong>비즈니스 로직 예외 검증 부분</strong>과, 입력의 <strong>단순 입력 예외 검증 부분</strong>을 구분하는 용도로 중복을 제거하지 말고, 그대로 유지한다.</p>
</blockquote>
<blockquote>
<h3 id="3-중복되는-기능-요구사항들을-합치자---중복-제거-⭕️">3. 중복되는 기능 요구사항들을 합치자. - 중복 제거 ⭕️</h3>
<p>하나의 도메인(ex: 입력, 출력, 다리 등)에서 중복되는 요구사항들이 존재한다면 다른 도메인으로 전파 시키지 말고 그 도메인 내에서 중복을 제거하자</p>
</blockquote>
<blockquote>
<h3 id="4-단순-생성자만-존재해도-readme에-추가하자">4. 단순 생성자만 존재해도 README에 추가하자</h3>
<p>단순히 값을 표현하는 <code>Enum</code>객체에서 <code>생성자</code>와 <code>Getter</code>만 존재하더라도 기능 목록에 작성해 주고, 표현의 방식을 끝에 <code>~ 정의할 수 있다</code>와 같이 표현해서 해당 도메인이 하는 역할을 특정해 주도록 하자.
<img src="https://velog.velcdn.com/images/ho-tea/post/fc97f3c8-eeb8-4744-8ba2-56bec84e55f4/image.png" alt=""></p>
</blockquote>
<hr>
<h2 id="feat">Feat</h2>
<h3 id="구현-순서">구현 순서</h3>
<blockquote>
<p><code>Docs</code>를 살아있는 문서로 만들면서 진행</p>
</blockquote>
<ul>
<li><code>Domain</code></li>
<li><code>Controller</code> &lt;-&gt; <code>View</code></li>
</ul>
<hr>
<h3 id="고민-1">&lt;고민 1&gt;</h3>
<blockquote>
<p>내부적으로 검증된 메서드들을 사용하더라도 <code>메서드 시그니처</code>가 <code>검증된 메서드들의 시그니처</code>와 다를 시 테스트 하자.
아래의 경우 <code>finish()</code>에 대한 테스트는 굳이 진행하지 않아도 되겠다.</p>
</blockquote>
<pre><code class="language-java">// BridgeGame
public boolean finish() {
        return bridge.end();
}
// Bridge
public boolean end() {
        return unit.size() == index;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/43b31de0-3132-4dd3-a85e-501e5cce0fbe/image.png" alt=""></p>
<pre><code class="language-java">    /**
     * @param size 다리의 길이
     * @return 입력받은 길이에 해당하는 다리 모양. 위 칸이면 &quot;U&quot;, 아래 칸이면 &quot;D&quot;로 표현해야 한다.
     */
    public List&lt;String&gt; makeBridge(int size) {
        validateSize(size);
        List&lt;String&gt; bridge = new ArrayList&lt;&gt;();
        for(int i = 0; i &lt; size; i++){
            bridge.add(BridgeUnit.of(bridgeNumberGenerator.generate()).getSignatureLetter());
        }
        return bridge;
    }</code></pre>
<p>위와 같이 이루어져 있는 경우에 <code>makeBridge(int size)</code>로 생성되는 <code>List&lt;String&gt;</code>형의 문자들은 정확히 어떤 문자들이 들어가는지 예측할 수가 없다.
<code>(내부적으로 랜덤한 값을 생성해 문자로 변환하기 때문에)</code></p>
<p>하지만, <code>랜덤한 값을 생성하는 bridgeNumberGenerator</code>가 정상적으로 동작하는지에 대해 검증했고, <code>BridgeUnit.of</code> 또한 정상작동하는 것을 검증했다면,
정확히 <code>List&lt;String&gt;</code>에 어떤 순서로 값이 들어가는 지는 별로 중요하지 않고, </p>
<blockquote>
<p>특정 문자 <code>U</code>와 <code>D</code>만 정상적으로 들어갔는지 검사하는 것으로 테스트를 할 수 있겠다.</p>
</blockquote>
<pre><code class="language-java">    @Test
    @DisplayName(&quot;무작위 값을 이용해 다리를 생성할 수 있다.&quot;)
    void makeBridge() {
        BridgeMaker bridgeMaker = new BridgeMaker(new BridgeRandomNumberGenerator());
        assertAll(
                () -&gt; assertThat(bridgeMaker.makeBridge(3).size()).isEqualTo(3),
                () -&gt; assertThat(bridgeMaker.makeBridge(3)).containsAnyElementsOf(List.of(CrossingDirection.TOP.getSignatureLetter(), CrossingDirection.BOTTOM.getSignatureLetter()))
        );
    }</code></pre>
<hr>
<h3 id="고민-2">&lt;고민 2&gt;</h3>
<blockquote>
<p><strong>클래스를 잘게 쪼개야 한다, 
또한 Controller가 비즈니스로직을 가지지 않게끔 구성해야 한다.</strong>
<code>Bridge</code> - 다리
<code>BridgeMaker</code> - 다리 생성
<code>BridgeGame</code> - 다리 게임</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/1564e4e7-df89-4793-be7f-cef8649f1bbd/image.png" alt=""></p>
<p>요구사항이 위처럼 주어졌는데 이 요구사항의 목적은 <code>Bridge를 생성하는 클래스를 따로 두어 관리</code>하라는 의미로 다가온다.
<img src="https://velog.velcdn.com/images/ho-tea/post/f5ee6ce1-f105-46e4-9a97-f527b2d296b6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/f174fef8-a8fb-4a10-8857-b2021a73853e/image.png" alt=""></p>
<p>또한, <code>BrdigeGame</code>에 대한 요구사항이 위처럼 주어졌다는 것은 <code>BridgeGame 클래스에서 InputView, OutputView를 사용하지 않는다</code> = <code>BridgeGame</code>을 <code>Controller</code>로 사용하지 말라는 뜻이다.</p>
<p>이걸 확장해서 생각해보면 아래와 같이 볼 수 있다.</p>
<blockquote>
<p>&quot;<code>Controller</code>가 <code>비즈니스로직</code>을 가지지 않게끔 <code>Controller</code>와 비슷한 성격을 가진 <code>Domain</code> 객체를 만들어 해당 <code>Domain</code>객체가 <code>비즈니스로직</code>을 가지도록 구성하라&quot;</p>
</blockquote>
<hr>
<h3 id="고민-아닌-고민---functionalinterface">&lt;고민 아닌 고민&gt; - <code>@FunctionalInterface</code></h3>
<pre><code class="language-java">@FunctionalInterface
public interface BridgeNumberGenerator {

    int generate();
}
</code></pre>
<pre><code class="language-java">public class BridgeRandomNumberGenerator implements BridgeNumberGenerator {

    private static final int RANDOM_LOWER_INCLUSIVE = 0;
    private static final int RANDOM_UPPER_INCLUSIVE = 1;

    @Override
    public int generate() {
        return Randoms.pickNumberInRange(RANDOM_LOWER_INCLUSIVE, RANDOM_UPPER_INCLUSIVE);
    }
}</code></pre>
<p>랜덤값을 생성하는 <code>클래스</code>와 <code>인터페이스</code>가 주어졌는데,</p>
<blockquote>
<p>앞으로 해당하는 라이브러리(<code>Random</code>)를 활용해야 할 때 위와 같이 <code>함수형 인터페이스</code>를 생성해 
<code>@FunctionalInteface</code>를 붙여 컴파일러에게 해당 인터페이스가 함수형 인터페이스임을 명시적으로 알려주자
이 어노테이션을 사용하면 컴파일러가 해당 인터페이스가 함수형 인터페이스의 규칙을 따르고 있는지를 검사하고, 그렇지 않은 경우 컴파일 오류를 발생시킨다.</p>
</blockquote>
<hr>
<h2 id="test">Test</h2>
<blockquote>
<p><code>ChatGpt</code>를 활용해 <code>CodeReview</code>를 받으려 했지만 OpenAI의 API를 활용하려면 일정 금액을 지불해야 했다.
금액을 지불해서 개선점이나 결함을 급히 찾아야 하는 프로젝트가 아니었기에 진행하지 않도록 했다.
<img src="https://velog.velcdn.com/images/ho-tea/post/04fe2a3c-5bf2-4959-b423-6d1913d9c0ab/image.png" alt=""></p>
</blockquote>
<hr>
<h2 id="refactor">Refactor</h2>
<ul>
<li>값을 하드코딩하지 않고 상수화 하였는가 
<code>(0을 ZERO로 표현하는 것은 안하느니 못하다)</code></li>
<li>네이밍 규칙을 잘 따랐는가</li>
<li>패키지 구분을 가독성있게 했는가</li>
<li>접근제한자를 적절하게 사용했는가</li>
<li>출력결과의 순서와 동일한가</li>
<li>처리하지 않은 예외 검사가 있는가</li>
<li>객체지향 생활 체조 원칙을 준수했는가</li>
</ul>
<hr>
<h3 id="code-coverage">Code Coverage</h3>
<blockquote>
<p><code>Code Coverage</code>에 연연하지 말고, 실제 <code>비즈니스 로직</code>에 대한 크리티컬한 테스트가 진행되지 않은 부분이 존재하는지만 판단하도록 하자.
<strong>&quot;테스트는 결함 검출용으로 사용하고, 코드 커버리지에는 집착하지 마라&quot;</strong>
<img src="https://velog.velcdn.com/images/ho-tea/post/f503a79d-0187-4159-8c77-d462e4ad6bc8/image.png" alt=""></p>
</blockquote>
<hr>
<h2 id="참고-블로그">참고 블로그</h2>
<ul>
<li><strong>Google Java Style Guide 설정</strong>
<a href="https://vince-kim.tistory.com/28">https://vince-kim.tistory.com/28</a></li>
<li><strong>WootecoStyle 설정</strong>
<a href="https://velog.io/@pgmjun/IntelliJ-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%84-%EC%84%A4%EC%A0%95%ED%95%B4%EB%B3%B4%EC%9E%90-feat.%EC%9A%B0%ED%85%8C%EC%BD%94">https://velog.io/@pgmjun/IntelliJ-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%84-%EC%84%A4%EC%A0%95%ED%95%B4%EB%B3%B4%EC%9E%90-feat.%EC%9A%B0%ED%85%8C%EC%BD%94</a></li>
<li><strong>ChatGpt CodeReview Setting</strong>
<a href="https://ohwhatisthis.tistory.com/26">https://ohwhatisthis.tistory.com/26</a>
<a href="https://itchipmunk.tistory.com/592">https://itchipmunk.tistory.com/592</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[우아한테크코스] 숫자 야구 게임 (최종 코테 준비)]]></title>
            <link>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-%EC%88%AB%EC%9E%90-%EC%95%BC%EA%B5%AC-%EA%B2%8C%EC%9E%84</link>
            <guid>https://velog.io/@ho-tea/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-%EC%88%AB%EC%9E%90-%EC%95%BC%EA%B5%AC-%EA%B2%8C%EC%9E%84</guid>
            <pubDate>Mon, 04 Dec 2023 14:38:49 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h3 id="숫자-야구-게임-회고기록">숫자 야구 게임 회고기록</h3>
<p><code>고민 1</code> : <strong>Test Code Format</strong>
<code>고민 2</code> : <strong>방어적 복사</strong>
<code>고민 3</code> : <strong>라이브러리에 대한 Test</strong>
<code>&lt;고민 아닌 고민&gt;</code>
        - <code>input 예외 검증 Test</code>
        - <code>정규식 검사</code>
        - <code>단일책임원칙?</code></p>
</blockquote>
<blockquote>
<p><a href="https://github.com/Ho-Tea/java-baseball-6/tree/Ho-Tea2">GitHub Code</a></p>
</blockquote>
<h2 id="convention">Convention</h2>
<h3 id="angularjs-commit-conventions"><strong>AngularJS commit conventions</strong></h3>
<pre><code>docs(README): 기능목록 재정리</code></pre><h3 id="google-java-style-guide"><strong>Google Java Style Guide</strong></h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/bb62c26e-4371-4197-9f54-d69880deca66/image.png" alt=""></p>
<pre><code>기존의 Google Java Style Guide와 비교했을 때 
크게 달라진 점은 블럭 들여쓰기 2 -&gt; 4로 변경된 것 뿐이다.</code></pre><blockquote>
<p><code>Enable google-java-format</code>을 설정 시 <code>Google Java Style Guide</code>로 설정.
   <img src="https://velog.velcdn.com/images/ho-tea/post/917db337-fcfd-4298-a1ab-72caa2b9703e/image.png" alt=""></p>
</blockquote>
<blockquote>
<p><code>Default</code>는 <code>WootecoStyle</code>로 설정되어 있다.
<img src="https://velog.velcdn.com/images/ho-tea/post/25b0af60-6266-4f2e-b964-a441ac5856d9/image.png" alt=""></p>
</blockquote>
<hr>
<h2 id="docs">Docs</h2>
<blockquote>
<h3 id="1-기능-요구-사항을-읽어가면서-아래-사항들을-정리">1. 기능 요구 사항을 읽어가면서 아래 사항들을 정리</h3>
</blockquote>
<ul>
<li>전체적인 게임 규칙</li>
<li>Domain(컴퓨터, 플레이어)</li>
<li>System 유의사항</li>
<li>Exception Handling</li>
<li>Input</li>
<li>Output</li>
<li>사용해야 할 라이브러리</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/75fddfe0-070b-4103-aa3d-1255b6a20ceb/image.png" alt=""></p>
<blockquote>
<h3 id="2-도메인과-입력이-중복되는-부분이-반드시-존재한다">2. 도메인과 입력이 중복되는 부분이 반드시 존재한다.</h3>
<p><code>플레이어 : 서로 다른 3개의 숫자를 입력 | 입력 : 서로 다른 3자리의 수</code>
이 경우 도메인의 <strong>비즈니스 로직 예외 검증 부분</strong>과, 입력의 <strong>단순 입력 예외 검증 부분</strong>을 구분하자</p>
</blockquote>
<blockquote>
<h3 id="3-중복되는-기능-요구사항들을-합치자">3. 중복되는 기능 요구사항들을 합치자.</h3>
<p>현재의 경우에는 출력 부분에 <code>입력한 수에 대한 결과를 볼, 스트라이크 개수로 표시</code>는 게임 결과를 출력한다는 의미로, <code>하나도 없는 경우</code>와 동일한 의미이므로 하나로 합칠 수 있다.
컴퓨터의 <code>플레이어가 입력한 숫자에 대한 결과를 출력한다.</code>와 출력에서 다루는 <code>게임결과 출력</code>은 같은 의미를 내포해 중복을 제거할 수 있지만,
<code>도메인이 어떤 역할을 가지고 있는지</code> 특정하기 위해 합치지 않는다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/4d29f1dd-1585-4c62-be52-ae8b7937c55b/image.png" alt=""></p>
<hr>
<h2 id="feat">Feat</h2>
<h3 id="구현-순서">구현 순서</h3>
<blockquote>
<p><code>Docs</code>를 살아있는 문서로 만들면서 진행</p>
</blockquote>
<ul>
<li><code>Domain</code></li>
<li><code>Controller</code> &lt;-&gt; <code>View</code></li>
</ul>
<hr>
<h3 id="고민-2">&lt;고민 2&gt;</h3>
<p>객체(일급 컬렉션 등)를 생성할 때 무심코 아래와 같이 구성했다.</p>
<pre><code class="language-java">public class GameNumber {
    private final List&lt;Integer&gt; numbers;

    public GameNumber(List&lt;Integer&gt; numbers) {
        this.numbers = numbers;
    }
}</code></pre>
<p>이렇게 구성하게 되면 같은 주소값을 전달받는 것으로 외부에서 내부 변경이 가능해진다.
<img src="https://velog.velcdn.com/images/ho-tea/post/1977ec20-4bdf-4eac-996a-f4e4af6d4e53/image.png" alt=""></p>
<blockquote>
<p>따라서, 외부에서 내부의 값을 변경할 수 없게끔 <strong>방어적 복사</strong>를 수행하자!</p>
</blockquote>
<pre><code class="language-java">public class GameNumber {
    private final List&lt;Integer&gt; numbers;
    public GameNumber(List&lt;Integer&gt; numbers) {
        this.numbers = new ArrayList&lt;&gt;(numbers);
    }
}</code></pre>
<blockquote>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/868bf8f6-eab5-431c-a1c2-378d918980e7/image.png" alt=""></p>
</blockquote>
<hr>
<h3 id="고민-3">&lt;고민 3&gt;</h3>
<p>라이브러리로 주어지는 <code>import camp.nextstep.edu.missionutils.Randoms;</code>과 같은 것을 
<strong>테스트해야 하는지, 또 테스트를 해야한다면 어떻게 구성해야하는지에 대해 알아보자</strong></p>
<ul>
<li><p><strong>테스트를 해야 하는가?</strong> -&gt; ⭕️</p>
</li>
<li><p><em><code>숫자를 몇개를 만드는지</code>, <code>범위는 어디서부터 어디까지인지</code> 외부에서 모르기 때문에 <code>generate</code>메서드에 대한 테스트를 만들어야 한다.*</em></p>
</li>
<li><p><em>또한 Util성을 가진 클래스라도 테스트는 해야하는 것이 맞다.*</em></p>
<pre><code class="language-java">public class RandomNumGenerator {
   public static List&lt;Integer&gt; generate(){
       List&lt;Integer&gt; numbers = new ArrayList&lt;&gt;();
       while (numbers.size() &lt; 3) {
           int randomNumber = Randoms.pickNumberInRange(1, 9);
           if (!numbers.contains(randomNumber)) {
               numbers.add(randomNumber);
           }
       }
       return numbers;
   }
}</code></pre>
</li>
<li><p><strong>그렇다면 어떻게 테스트를 해야하는가?</strong>
<code>Randoms.picikNumberInRange</code>의 경우 내부적으로 어떤 구현이 이루어졌는지 <code>메서드 명</code>만으로는 알 수 없으며, 구현내부를 알더라도 관련된 메서드는 <code>private으로 접근제한</code>을 걸어놓아 <strong>항상 1부터 9사이의 수를 반환</strong>하는지 검증하기 어렵고, 테스트하기 힘들다.</p>
</li>
</ul>
<p>덧붙이자면, 만약 구현 내부(<code>pickNumberInRange() 내부</code>)의 메서드들을 모두 검증할 수 있다면 항상 1부터 9사이의 수를 반환하는 것을 입증할 수 있지만, 현재는 그렇게 하지 못하므로 <strong>항상 1부터 9사이의 수를 반환</strong>하는지 검증하기 어렵다는 것이다.</p>
<blockquote>
<p>따라서, <strong>충분히 큰 수만큼 테스트를 돌렸을 때 정상적으로 동작한다는 것</strong>은 <strong>메서드가 정상적으로 동작한다는 것으로 생각하자!</strong>
(라이브러리가 정상적으로 동작하는 지에 대한 검증이 필요없을 수 있지만, 해당 라이브러리는 내가 직접 구현한 것이 아니므로 추가적인 검증을 진행한 것이다)</p>
</blockquote>
<blockquote>
<p>테스트를 <code>10000</code>번 돌려도 되고(라이브러리에 대한 추가 검증), 
<code>1</code>번만 돌려도(&quot;라이브러리에 대한 검증&quot;이 됐다라고 판단한 후 진행하는 것) 된다.</p>
</blockquote>
<pre><code class="language-java">class RandomNumGeneratorTest {

    public static final int ENOUGH_BIG_NUMBER = 10000;

    @Test
    @DisplayName(&quot;1에서 9까지 서로 다른 임의의 수 3개를 생성한다.&quot;)
    void generate() {
        for (int i = 0; i &lt; ENOUGH_BIG_NUMBER; i++) {
            List&lt;Integer&gt; randomNums = RandomNumGenerator.generate();
            assertAll(
                    () -&gt; assertThat(randomNums.stream().allMatch(num -&gt; num &gt;= 1 &amp;&amp; num &lt;= 9)).isTrue(),
                    () -&gt; assertThat(randomNums.stream().distinct().toList().size()).isEqualTo(3),
                    () -&gt; assertThat(randomNums.size()).isEqualTo(3)
            );
        }
    }
}</code></pre>
<hr>
<h3 id="고민-아닌-고민---단일책임원칙">&lt;고민 아닌 고민&gt; - <code>단일책임원칙?</code></h3>
<p><code>Computer</code>가 어떠한 책임을 가지고 있는지 생각해보면,
<code>컴퓨터는 자신의 숫자와 플레이어가 입력한 숫자에 대한 비교 결과를 알 수 있다.</code>는 것이다.
아래와 같이 두가지의 방식을 고민했었는데,</p>
<blockquote>
<p>단일책임 원칙에 대해 깊게 고민하기 보다는 해당하는 
<strong>객체(도메인)이 어떠한 책임을 가지고있는지에 더 집중하는 게 좋을 것 같다.</strong></p>
</blockquote>
<blockquote>
<p><code>Computer</code> <strong>아래와 같이 2가지의 책임을 가져도 무방하다.</strong></p>
</blockquote>
<ul>
<li><input checked="" disabled="" type="checkbox"> 컴퓨터는 자신의 숫자와 플레이어가 입력한 숫자에 대한 비교 결과를 알 수 있다.<ul>
<li>&lt;비교 기준&gt; 같은 수가 같은 자리에 있으면 스트라이크</li>
<li>&lt;비교 기준&gt; 다른 자리에 있으면 볼</li>
</ul>
</li>
<li><input checked="" disabled="" type="checkbox"> 플레이어가 컴퓨터의 수를 모두 맞추면 플레이어가 승리한다.</li>
</ul>
<pre><code class="language-java">// 1번째 방식
public Map&lt;GameResult, Long&gt; compare(Numbers userNumbers) {
        long totalCount = numbers.countContains(userNumbers);
        long strikeCount = numbers.countMatching(userNumbers);
        long ballCount = Math.abs(strikeCount - totalCount);
        if (strikeCount == 0 &amp;&amp; ballCount == 0) {
            return Map.of(GameResult.NOTHING, totalCount);
        }
        return Map.of(GameResult.STRIKE, strikeCount, GameResult.BALL, ballCount);

    }</code></pre>
<pre><code class="language-java">// 2번째 방식 
public class Computer {
    private final BaseballNumber baseballNumber;

    public Computer(BaseballNumber baseballNumber) {
        this.baseballNumber = baseballNumber;
    }

    public Map&lt;GameHint, Integer&gt; compare(BaseballNumber userBaseballNumber){
        int strikeCount = baseballNumber.matchCount(userBaseballNumber);
        int ballCount = baseballNumber.containsCount(userBaseballNumber) - strikeCount;
        return GameHint.of(strikeCount, ballCount);
    }
}

public enum GameHint {
    STRIKE(&quot;스트라이크&quot;),
    BALL(&quot;볼&quot;),
    NOTHING(&quot;낫싱&quot;);

    private String korean;


    GameHint(String korean) {
        this.korean = korean;
    }

    public static Map&lt;GameHint, Integer&gt; of(int strikeCount, int ballCount) {
        if (strikeCount == 0 &amp;&amp; ballCount == 0) {
            return new EnumMap&lt;&gt;(Map.of(NOTHING, 0));
        }
        return new EnumMap&lt;&gt;(Map.of(BALL, ballCount, STRIKE, strikeCount));
    }
}</code></pre>
<blockquote>
<p>즉 두가지 방식 모두 사용될 수 있는 구조이고,
<strong>굳이 꼽자면 2번째 구조로 구성 할 수 있겠다.</strong></p>
</blockquote>
<hr>
<h3 id="고민-아닌-고민---정규식-검사">&lt;고민 아닌 고민&gt; - <code>정규식 검사</code></h3>
<p>아래와 같은 정규식으로 숫자 1부터 9까지 해당하는지 검사할 수 있다.</p>
<pre><code class="language-java">private static final Pattern NUMERIC_PATTERN = Pattern.compile(&quot;^[1-9]*$&quot;);</code></pre>
<p>하지만, 위와 같은 정규식도 빈 문자열에 대해서는 맞다고 판단하니 해당 부분에 대해서는 </p>
<p><strong>(추가적인 <code>validate</code>가 필요하다)</strong></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/f5d4c410-3573-4bda-94fc-7ddd41d85697/image.png" alt=""></p>
<blockquote>
<p>Option + Enter를 누르면 해당 사진처럼 검사가 가능하다.</p>
</blockquote>
<p><strong><code>isEmpty()</code> 메서드:</strong></p>
<ul>
<li><strong><code>isEmpty()</code></strong> 메서드는 문자열이 길이가 0인지 확인합니다.</li>
<li>즉, 문자열이 아무 문자도 포함하지 않으면 <strong><code>true</code></strong>를 반환하고, 그렇지 않으면 <strong><code>false</code></strong>를 반환합니다.</li>
<li>예를 들어, <strong><code>&quot;&quot;</code></strong> 또는 <strong><code>new String()</code></strong>과 같은 빈 문자열일 때 <strong><code>true</code></strong>를 반환합니다.</li>
</ul>
<pre><code class="language-java">String emptyString = &quot;&quot;;
boolean isEmpty = emptyString.isEmpty(); // true
</code></pre>
<p><strong><code>isBlank()</code> 메서드:</strong></p>
<ul>
<li><strong><code>isBlank()</code></strong> 메서드는 Java 11부터 제공되는 메서드로, 문자열이 비어 있거나(길이가 0) 공백 문자만 포함되어 있는지를 확인합니다.</li>
<li>공백 문자는 일반 공백(whitespace) 문자뿐만 아니라 탭(\t)이나 줄바꿈(\n)과 같은 공백 문자들도 포함합니다.</li>
<li>예를 들어, <strong><code>&quot;&quot;</code></strong> 또는 <strong><code>&quot; &quot;</code></strong>과 같이 공백 문자만 포함되어 있을 때 <strong><code>true</code></strong>를 반환합니다.</li>
</ul>
<pre><code class="language-java">String blankString = &quot;   &quot;;
boolean isBlank = blankString.isBlank(); // true
</code></pre>
<p>따라서, <strong><code>isEmpty()</code></strong>는 정확히 길이가 0일 때만 참이 되고, <strong><code>isBlank()</code></strong>는 길이가 0이거나 공백 문자만 포함되어 있을 때 참이 됩니다. 선택은 사용하고자 하는 상황과 요구사항에 따라 달라집니다. Java 11 이상을 사용하는 경우, 보다 유연하게 문자열이 비어있거나 공백 문자만 포함되어 있는지를 확인하려면 <strong><code>isBlank()</code></strong>를 사용하는 것이 좋습니다.</p>
<p><strong>결국 둘다 null 체크는 못해주므로 아래와 같이 구성해야 한다.</strong></p>
<pre><code class="language-java">public class InputView {
    private static final Pattern NUMERIC_PATTERN = Pattern.compile(&quot;^[1-9]*$&quot;);
    private static final String SEPARATOR = &quot;&quot;;

    public List&lt;Integer&gt; inputNumbers() {
        System.out.println(&quot;숫자를 입력해주세요 : &quot;);
        String numbers = Console.readLine();
        validateNullAndEmpty(numbers);
        validateNumeric(numbers);
        return Arrays.stream(numbers.split(SEPARATOR))
                .map(Integer::valueOf)
                .collect(Collectors.toUnmodifiableList());
    }

    private void validateNullAndEmpty(String input) {
        if (Objects.isNull(input) || input.isEmpty()) {
            throw new IllegalArgumentException(&quot;null 이거나 길이가 없는 문자열 입니다.&quot;);
        }
    }

    private void validateNumeric(String input) {
        if (!NUMERIC_PATTERN.matcher(input).matches()) {
            throw new IllegalArgumentException(&quot;문자열이 숫자 1부터 9까지로 이루어져 있지 않습니다.&quot;);
        }
    }
}</code></pre>
<hr>
<h2 id="test">Test</h2>
<h3 id="고민-1">&lt;고민 1&gt;</h3>
<p>Test코드의 가독성을 향상시키기 위해 <code>@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)</code>
를 사용하려 했으나, 더 가독성이 떨어지는 느낌을 받았다.
<img src="https://velog.velcdn.com/images/ho-tea/post/2dbff4b3-a7d3-4b19-9027-707c2e72bcb2/image.png" alt=""></p>
<blockquote>
<p>따라서, 아래와 같이 구성하기로 결정!</p>
</blockquote>
<pre><code class="language-java">@Test
@DisplayName(&quot;1에서 9까지의 숫자가 아니라면 예외가 발생한다.&quot;)
void validateRange() {
    assertThatThrownBy(() -&gt; new GameNumber(List.of(0, 2, 3)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(&quot;숫자는 1에서 9까지의 수로 이루어져야 합니다.&quot;);
}</code></pre>
<hr>
<h3 id="고민-아닌-고민---input-예외-검증-test">&lt;고민 아닌 고민&gt; - <code>input 예외 검증 Test</code></h3>
<p><code>InputView</code>에서 아래의 부분의 <code>throw ~</code>로 예외처리되는 부분이 검증이 되지않아 테스트를 진행하지 않아 테스트 코드 커버리지가 떨어지는 걸 볼 수 있다.
 <del>생각을 해보니 해당 예외에 대해 어떤식으로 처리되는지를 검사해야 한다고 생각해 해당 단순 입력 검증에 관한 예외 검증 메서드 들은 util로 따로 빼서 활용할 예정이다.</del>
<img src="https://velog.velcdn.com/images/ho-tea/post/2861744f-ee12-44ba-b118-c9419f0a03c8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/82800bfd-0605-4afe-b908-ffebdcb3f9f9/image.png" alt=""></p>
<p><strong><code>util</code>로 따로 생성해 테스트를 해줄까도 생각해 보았지만 그렇게 하지 않았다.</strong>
테스트 코드를 꼼꼼히 작성해 테스트 코드 커버리지가 높아 지는 것은 좋은 일이지만,
코드 커버리지를 높이기 위한 테스트코드 작성은 <strong>주객이 전도된 것이다.</strong></p>
<blockquote>
<p>테스트는 <strong>결함 검출용</strong>으로 사용하고, <code>Code Coverage</code>에는 집착하지 마라
<strong>결함 검출을 어느 수준까지 할것인지는 본인의 판단이며,</strong>
위와 같은 상황에서 아래와 같다면
<code>input 예외처리 테스트 코드 작성의 비용</code> &gt; <code>입력 검증 Test 결함으로 생기는 SideEffect</code>
테스트 코드를 굳이 작성하지 않고, <code>input</code> 검증에 관한 부분은 결함이 없다고 생각하고 진행하는 것이 맞다</p>
</blockquote>
<blockquote>
<p>+<code>입력 검증 Test 결함으로 생기는 SideEffect</code>는 거의 없다고 생각되며, 비즈니스 로직과 연관되는 예외처리도 아니므로 그렇게 <code>Critical</code>한 예외 상황도 없을 것이다.</p>
</blockquote>
<hr>
<h2 id="refactor">Refactor</h2>
<ul>
<li>값을 하드코딩하지 않고 상수화 하였는가 
<code>(0을 ZERO로 표현하는 것은 안하느니 못하다)</code></li>
<li>네이밍 규칙을 잘 따랐는가</li>
<li>패키지 구분을 가독성있게 했는가</li>
<li>접근제한자를 적절하게 사용했는가</li>
<li>출력결과의 순서와 동일한가</li>
<li>처리하지 않은 예외 검사가 있는가</li>
<li>객체지향 생활 체조 원칙을 준수했는가</li>
</ul>
<hr>
<h3 id="code-coverage">Code Coverage</h3>
<p><img src="https://velog.velcdn.com/images/ho-tea/post/82800bfd-0605-4afe-b908-ffebdcb3f9f9/image.png" alt=""></p>
<hr>
<h2 id="참고-블로그">참고 블로그</h2>
<ul>
<li><strong>Google Java Style Guide 설정</strong>
<a href="https://vince-kim.tistory.com/28">https://vince-kim.tistory.com/28</a></li>
<li><strong>WootecoStyle 설정</strong>
<a href="https://velog.io/@pgmjun/IntelliJ-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%84-%EC%84%A4%EC%A0%95%ED%95%B4%EB%B3%B4%EC%9E%90-feat.%EC%9A%B0%ED%85%8C%EC%BD%94">https://velog.io/@pgmjun/IntelliJ-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%84-%EC%84%A4%EC%A0%95%ED%95%B4%EB%B3%B4%EC%9E%90-feat.%EC%9A%B0%ED%85%8C%EC%BD%94</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>