<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Finders Tech Blog]]></title><description><![CDATA[Finders Tech Blog]]></description><link>https://blog.finders.it.kr</link><generator>RSS for Node</generator><lastBuildDate>Mon, 27 Apr 2026 22:11:34 GMT</lastBuildDate><atom:link href="https://blog.finders.it.kr/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[[Elasticsearch] Elasticsearch 기본용어와 CRUD 명령어]]></title><description><![CDATA[-elastic
▶ 들어가며
이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 기본 용어를 정리하고,직접 코드를 쳐가며 CRUD(Create / Read / Update / Delete) 명령어에 익숙해지는 시간을 가져보려고 한다.
Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다.
둘 다 데이터베이스라는 큰 틀 안에서

데...]]></description><link>https://blog.finders.it.kr/elasticsearch-elasticsearch-crud</link><guid isPermaLink="true">https://blog.finders.it.kr/elasticsearch-elasticsearch-crud</guid><dc:creator><![CDATA[주보경]]></dc:creator><pubDate>Tue, 17 Feb 2026 08:36:13 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://blog.kakaocdn.net/dna/byZh0q/dJMcah4r0Wi/AAAAAAAAAAAAAAAAAAAAAEaZOs2uJIkaXNIZwffG9FziQ6DPh2uxpknHI6847OhS/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=nn9jYCpE%2Byrq4jT%2Fp%2FzBCYN8eqw%3D" alt /></p>
<p>-elastic</p>
<h2 id="heading-4pa2ioutpoywtoqwgoupsa">▶ 들어가며</h2>
<p>이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 <strong>기본 용어</strong>를 정리하고,<br />직접 코드를 쳐가며 <strong>CRUD(Create / Read / Update / Delete)</strong> 명령어에 익숙해지는 시간을 가져보려고 한다.</p>
<p>Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,<br />사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다.</p>
<p>둘 다 데이터베이스라는 큰 틀 안에서</p>
<ul>
<li><p>데이터를 삽입하고(Create)</p>
</li>
<li><p>조회하고(Read)</p>
</li>
<li><p>수정하고(Update)</p>
</li>
<li><p>삭제(Delete)</p>
</li>
</ul>
<p>하는 흐름은 동일하다.</p>
<p>그래서 이번 글에서는<br />👉 MySQL과 비교하면서 Elasticsearch를 이해하는 방식으로 정리해보려고 한다!!</p>
<hr />
<h2 id="heading-elasticsearch">▶ Elasticsearch 기본 용어 정리</h2>
<p>아래는 가장 기본적인 MySQL 과의 대응 관계이다.</p>
<p><img src="https://blog.kakaocdn.net/dna/73BoU/dJMcacB4Kl8/AAAAAAAAAAAAAAAAAAAAANmFOi6ii-LtayHddIcHT3Kvy7kl6fFSsqoJUAzXQu-e/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=ixeT5lpm2QlHmlS6wW5YGqB2VzY%3D" alt /></p>
<p>-기본 용어 정리</p>
<p>MySQL:</p>
<blockquote>
<p><strong>table을 만들고 → column의 schema를 정의하고 → row(record)를 저장하고 → 데이터를 조회/수정/삭제한다</strong></p>
</blockquote>
<p>⭐Elasticsearch:</p>
<blockquote>
<p><strong>인덱스를 만들고 → field의 mapping을 정의하며 → document를 저장하고 → 데이터들을 검색/수정/삭제한다</strong></p>
</blockquote>
<p>라는 구조로 동작하는 것이다.</p>
<hr />
<h2 id="heading-crud">▶ CRUD 명령어 정리</h2>
<p>이제부터는 Elasticsearch에서 실제로 사용하는 CRUD 명령어를 직접 쳐보면서 익혀보자!!<br />이번 글에서는 앞으로 계속 사용할 기본 <strong>인덱스명을 "users"</strong>로 고정해서 진행한다.</p>
<h4 id="heading-index">- Index 생성하기</h4>
<p>MySQL에서 CREATE TABLE을 하듯이, Elasticsearch에서는 데이터를 저장하기 위한 <strong>Index</strong>를 생성한다.</p>
<p>아래 명령어로 users 인덱스를 생성해 보자.</p>
<pre><code class="lang-plaintext">PUT /users
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/NYhMq/dJMcaajX4L6/AAAAAAAAAAAAAAAAAAAAAHwI6Zixd-1q7q06_Mmx2-Y8RWX11cTqrZciTtnMHGBQ/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=UQQDDKQvQlXLgebSAfzfwi9m57w%3D" alt /></p>
<hr />
<h4 id="heading-index-1">- Index 조회하기</h4>
<p>생성한 인덱스가 존재하는지 확인하는 명령어이다.</p>
<pre><code class="lang-plaintext">GET /users
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/cEhmUK/dJMcaiPMalC/AAAAAAAAAAAAAAAAAAAAAP5egshosvacbmpOPVc-vXWPVeEmu2RvNMW0WTaKobN4/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=I5Wdoz%2BprkkwriJKkfEBJ%2FuJmeA%3D" alt /></p>
<hr />
<h4 id="heading-index-2">- Index 삭제하기</h4>
<p>이번에는 인덱스를 삭제해보자.</p>
<pre><code class="lang-plaintext">DELETE /users
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/czQPN3/dJMcadt91xp/AAAAAAAAAAAAAAAAAAAAAH-iAcXFgzH2IsvL4RAiIsl-qjl_fb7MqW58qfeHD-sS/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=bzOPHT72d9VZKt%2BDKh11cygrmLU%3D" alt /></p>
<p>이 상태에서 다시 인덱스를 조회해보면</p>
<p><img src="https://blog.kakaocdn.net/dna/bct2Jk/dJMcafS6P3I/AAAAAAAAAAAAAAAAAAAAALynFs2moDhz4LHbvxLWVvZVG41zL7tU9i-C32H_7klL/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=8T%2BFjjRyQL7Gh3HwpvXziNcyImM%3D" alt /></p>
<p>"index_not_found_exception" 에러가 발생한다</p>
<p>👉 즉, 인덱스가 삭제되었기 때문에 <strong>더 이상 존재하지 않는 상태</strong>임을 확인할 수 있다!</p>
<hr />
<h4 id="heading-mapping">- Mapping 정의하기</h4>
<p>MySQL에서 테이블을 만들 때 schema를 정의하듯이, Elasticsearch에서도 index 내부에 어떤 타입의 데이터를 저장할지 정의할 수 있다. 이를 Elasticsearch에서는 <strong>Mapping</strong>이라고 부른다.</p>
<p>아래 명령어로 users 인덱스에 매핑을 정의해보자.</p>
<pre><code class="lang-plaintext">PUT /users/_mapping 
{    
    "properties": 
        { 
            "name": { "type": "keyword" }, 
            "age": { "type": "integer" }, 
            "is_active": { "type": "boolean" } 
        } 
}
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/5nM0w/dJMcad1Z8Qx/AAAAAAAAAAAAAAAAAAAAAKbo8GNVJ6uI4C-lXWje6krZ7O0PLWJlRYKNnCdjOLvn/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=R7fNYfScMb%2FtCey96b2N8ALbnmQ%3D" alt /></p>
<p>여기서 핵심은 다음과 같다.</p>
<ul>
<li><p>properties 안에서 field들을 정의한다.</p>
</li>
<li><p>각 field는 type을 가진다.</p>
</li>
<li><p><strong>keyword</strong>는 MySQL에서 VARCHAR(문자열) 같은 느낌이라고 생각하면 된다.</p>
</li>
<li><p>integer, boolean도 그대로 이해하면 된다.</p>
</li>
</ul>
<p>👉 즉, Mapping은 MySQL에서 schema를 정의하는 과정과 거의 유사하다.</p>
<p>매핑이 잘 정의되었는지 확인하고 싶다면 조회 명령어로 확인할 수 있다.</p>
<p><img src="https://blog.kakaocdn.net/dna/kGR7t/dJMcaflgzBZ/AAAAAAAAAAAAAAAAAAAAAPf-eA7H6VJ6DSD_O7FC_IMINX7aM-60Y8nTeeC1T1Y3/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=IgOL5BrozG691ymnS%2FXaKxSqAW0%3D" alt /></p>
<p>- GET /users</p>
<hr />
<h4 id="heading-document">- Document 삽입하기</h4>
<p>이제 <strong>실제 데이터를 넣어보자!</strong></p>
<p>MySQL에서 테이블에 record(row)를 삽입하는 과정과 동일하게, Elasticsearch에서는 index에 <strong>document</strong>를 삽입한다.</p>
<pre><code class="lang-plaintext">POST /users/_doc 
{
    "name": "Bo", 
    "age": 26, 
    "is_active": true 
}
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/E14Nj/dJMcahwDsZ9/AAAAAAAAAAAAAAAAAAAAADbeqY_7z3icSL1XvYhLDEe_dKg9le5Thrnfh-Eh1OIf/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=%2BdFeswmZ4L6PNcZ8FJrEk%2BUEyyY%3D" alt /></p>
<p>이렇게 하면 Elasticsearch가 <strong>자동으로 랜덤한 _id 값을 생성해서</strong> document를 저장한다.</p>
<hr />
<h4 id="heading-document-1">- Document 조회하기</h4>
<p>삽입된 document를 조회하려면 아래 명령어를 사용한다.</p>
<pre><code class="lang-plaintext">GET /users/_search
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/da18LM/dJMcag5yKt6/AAAAAAAAAAAAAAAAAAAAAA5ZBzr6gCtUO3lZXblyHwBZ6ZN5hu6dE54miV6V0Sdp/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=29%2BnJL2dIlxo0s%2BKSOKaQfwtzoA%3D" alt /></p>
<p>결과를 확인해보면 hits 안에 document가 들어가 있을 것이고,<br />각 document마다 Elasticsearch가 자동으로 생성한 랜덤 _id 값이 붙어있는 것도 확인할 수 있다.</p>
<hr />
<h4 id="heading-id-document">- ID를 지정해서 Document 삽입하기</h4>
<p>document를 저장할 때 자동 ID가 아니라, 내가 직접 ID를 지정해서 저장하는 것도 가능하다.</p>
<pre><code class="lang-plaintext">POST /users/_create/1
{
    "name": "Boo", 
    "age": 27, 
    "is_active": true 
}
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/cOtKA6/dJMcafyOZp0/AAAAAAAAAAAAAAAAAAAAAKTfBrBD40Q3ITBXpCPn5PZibPwkMXFCd0h8ZKy8-gX5/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=AMKf5HpFCTQRQ5%2F%2BFMKXbd2dO1k%3D" alt /></p>
<p>이렇게 하면 <strong>_id = 1로 document가 저장된다.</strong></p>
<p>여기서 중요한 포인트는,</p>
<p>👉 _create 방식은 <strong>같은 ID가 이미 존재하면 저장이 실패한다는 점이다.</strong><br />즉, 중복 저장이 불가능하다. <s>고유 id를 알아서 처리하는..</s></p>
<p><img src="https://blog.kakaocdn.net/dna/bof7mL/dJMcachMz50/AAAAAAAAAAAAAAAAAAAAALQxXHAowAIzvdkm7sI3PoB3KXG3raDu1n4wDfZTUMEY/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=en0TL0wRh0h5t3%2BamAny156QdZk%3D" alt /></p>
<hr />
<h4 id="heading-id-document-1">- 특정 ID로 Document 조회하기</h4>
<p>특정 document 하나만 조회하고 싶다면 아래처럼 조회할 수 있다. </p>
<pre><code class="lang-plaintext">GET /users/_doc/2
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/K4tQv/dJMcai95osJ/AAAAAAAAAAAAAAAAAAAAABH7P6wP8YLw52XKg4mi_Hv5Jj7VZrEG48j_UrA3GWX_/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=DPWva6%2Fkmf07%2BhiG4v2HF39dFHY%3D" alt /></p>
<p>👉 여기서 마지막 숫자 2는 document의 <strong>"id"</strong> 값이다.</p>
<hr />
<h4 id="heading-id">- 같은 ID여도 덮어씌우기(업데이트) 하고 싶다면?</h4>
<p>위에서 확인한 것처럼 이미 존재하는 id값을 POST 하는 경우 ERROR가 발생했지만, MySQL에서 upsert처럼 동작하게 하려면  아래처럼 PUT을 사용하면 된다. 이미 존재하는 id=1 document에 새로운 값을 가지는 데이터를 PUT 하는 경우</p>
<pre><code class="lang-plaintext">PUT /users/_doc/1 
{ 
    "name": "Boo", 
    "age": 28, 
    "is_active": true 
}
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/cwPyW7/dJMcabwn5ct/AAAAAAAAAAAAAAAAAAAAACSH-27A60u66nE_67_FHRbnybeohVe6cjnkysYl0CHT/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=J7jfRhYD4N3zc54OWHrVkOFGwj8%3D" alt /></p>
<p>데이터 삽입이 성공하는 모습을 확인할 수 있고,  이후 다시 조회해보면 기존 document가 덮어씌워진 것을 확인할 수 있다.</p>
<p><img src="https://blog.kakaocdn.net/dna/4pP9D/dJMcaaxvijr/AAAAAAAAAAAAAAAAAAAAAKdKy9gkyumXX9pYrWesOidJZ9IcXXzKD2vgEix5sMpG/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=uNoNns5cAuzixPDX0E6uI41dZig%3D" alt /></p>
<hr />
<h4 id="heading-document-update">- Document 수정하기 (Update)</h4>
<p>사실 위에서 사용했던 PUT /users/_doc/1 자체가<br />👉 특정 ID document를 <strong>수정하는 방식</strong>이라고 생각해도된다. <s>같은 의미지 사실상.</s></p>
<p>즉, Elasticsearch에서는 PUT 요청이</p>
<ul>
<li><p>없으면 생성</p>
</li>
<li><p>있으면 덮어쓰기(update)</p>
</li>
</ul>
<p>방식으로 동작한다.</p>
<hr />
<h4 id="heading-xc0g7yq57kcvio2vhoutnounjcdsijjsojxtlzjqula">- 특정 필드만 수정하기</h4>
<p>특정 인물의 나이만 변경해야 하는 경우라면?  모든 필드가 아니라, 특정 필드만 수정하고 싶을 수도 있다. </p>
<p>이 경우에는 _update를 사용한다.</p>
<pre><code class="lang-plaintext">POST /users/_update/1 
{ 
    "doc": { "age": 29 } 
}
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/ODBxF/dJMb996qzWL/AAAAAAAAAAAAAAAAAAAAAIfmIkI0XDhCczMfQMnna78I_3zCeTDn5yjtZFH6tn5l/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=WC5dN7zyHj1pOOLFCkgQaBIdW6I%3D" alt /></p>
<p>👉 doc 안에 수정할 필드만 넣어주면 된다.</p>
<p>이 방식은 MySQL의 UPDATE users SET age=29 WHERE id=1 같은 느낌이다.</p>
<hr />
<h4 id="heading-document-delete">- Document 삭제하기 (Delete)</h4>
<p>마지막으로 특정 ID document를 삭제해보자.</p>
<pre><code class="lang-plaintext">DELETE /users/_doc/1
</code></pre>
<p><img src="https://blog.kakaocdn.net/dna/be3FbK/dJMcajuoWhK/AAAAAAAAAAAAAAAAAAAAANeDKQkd6WFTrg4nqhkeuiGqB22wN-5RdlyWHtc7QFHQ/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=NB15eG1PBCUzbzBrTBhbdgz6z8c%3D" alt /></p>
<p>이후 index에서 해당 id의 document를 조회해보면 document가 삭제된 것을 확인할 수 있다.</p>
<p><img src="https://blog.kakaocdn.net/dna/bM2nhY/dJMcacvkI9I/AAAAAAAAAAAAAAAAAAAAANW-UWQMViwAULeKGlOSpgpcKjYuhPErrPxa0UuHhSvj/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=SasMSJT00OkGkDEGyZsJekSViAI%3D" alt /></p>
<hr />
<h2 id="heading-4pa2iounioustoumra">▶ 마무리</h2>
<p>이번 글에서는 Elasticsearch에서 가장 기본이 되는 용어와 CRUD 명령어를 정리해 봤다. 정리해보면 Elasticsearch는 처음엔 생소했지만, 동작 방식 자체는 MySQL과 굉장히 유사하다는 것을 알 수 있었다. 자주 사용하는 코드들을 자주 쳐보면서 채화시키는 것이 중요할 것 같다.</p>
<ul>
<li><p>인덱스를 만들고</p>
</li>
<li><p>document를 삽입하고</p>
</li>
<li><p>검색하고</p>
</li>
<li><p>수정하고</p>
</li>
<li><p>삭제한다</p>
</li>
</ul>
<p>이 흐름만 익히면 막막했던 Elasticsearch와 조금 친해지는 느낌이 든다. 👍👍👍</p>
]]></content:encoded></item><item><title><![CDATA[Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기]]></title><description><![CDATA[코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정


1. 시작 — "일단 돌아가게 만들자"
Finders 프로젝트에서 1:1 문의(Inquiry) API를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다.

"JPA 쓰면 쿼리 안 짜도 되는 거 아니야?"

솔직히 처음엔 그렇게 생각했다. JpaRepository에 findAll, findById 쓰면 끝이니까.
// 첫 번째 버전의 목록 조회 (QueryDS...]]></description><link>https://blog.finders.it.kr/jpa-n1-11-api</link><guid isPermaLink="true">https://blog.finders.it.kr/jpa-n1-11-api</guid><dc:creator><![CDATA[IISweetHeartII]]></dc:creator><pubDate>Wed, 11 Feb 2026 08:50:31 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정</p>
</blockquote>
<hr />
<h2 id="heading-1">1. 시작 — "일단 돌아가게 만들자"</h2>
<p>Finders 프로젝트에서 <strong>1:1 문의(Inquiry) API</strong>를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다.</p>
<blockquote>
<p>"JPA 쓰면 쿼리 안 짜도 되는 거 아니야?"</p>
</blockquote>
<p>솔직히 처음엔 그렇게 생각했다. <code>JpaRepository</code>에 <code>findAll</code>, <code>findById</code> 쓰면 끝이니까.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 첫 번째 버전의 목록 조회 (QueryDSL)</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> List&lt;Inquiry&gt; <span class="hljs-title">findByMemberId</span><span class="hljs-params">(Long memberId, <span class="hljs-keyword">int</span> page, <span class="hljs-keyword">int</span> size)</span> </span>{
    <span class="hljs-keyword">return</span> queryFactory
            .selectFrom(inquiry)
            .leftJoin(inquiry.photoLab).fetchJoin()
            .leftJoin(inquiry.replies, inquiryReply).fetchJoin()
            .where(inquiry.member.id.eq(memberId))
            .orderBy(inquiry.createdAt.desc())
            .offset((<span class="hljs-keyword">long</span>) page * size)
            .limit(size)
            .fetch();
}
</code></pre>
<p>문의 목록을 가져올 때, 현상소 이름도 보여줘야 하고 답변이 있는지도 보여줘야 해서 <code>fetchJoin</code>을 걸었다. 잘 돌아갔다. <strong>돌아가기는 했다.</strong></p>
<p>(<a target="_blank" href="https://github.com/Finders-Official/BE/issues/137">#137</a>, <a target="_blank" href="https://github.com/Finders-Official/BE/pull/138">PR #138</a>)</p>
<hr />
<h2 id="heading-2-n1">2. 잠깐, N+1이 뭔데?</h2>
<p>블로그를 쓰는 김에, N+1 문제가 뭔지 한번 쉽게 설명해보겠다.</p>
<h3 id="heading-7iob7zmpioyepoyglq">상황 설정</h3>
<p>문의 10개를 목록 조회한다고 하자. 각 문의에는 <strong>현상소(PhotoLab)</strong> 정보가 필요하다.</p>
<h3 id="heading-n1">😱 N+1이 터지면</h3>
<pre><code class="lang-plaintext">[쿼리 1] SELECT * FROM inquiry WHERE member_id = 1 LIMIT 10    ← 문의 10개 가져옴
[쿼리 2] SELECT * FROM photo_lab WHERE id = 3                   ← 1번 문의의 현상소
[쿼리 3] SELECT * FROM photo_lab WHERE id = 7                   ← 2번 문의의 현상소
[쿼리 4] SELECT * FROM photo_lab WHERE id = 3                   ← 3번 문의의 현상소 (중복!)
  ...
[쿼리 11] SELECT * FROM photo_lab WHERE id = 12                 ← 10번 문의의 현상소
</code></pre>
<p><strong>1번의 쿼리</strong>로 문의 목록을 가져오고, 각 문의마다 현상소를 <strong>N번 추가 조회</strong>한다. 그래서 <strong>1 + N = N+1</strong> 문제다.</p>
<p>문의가 10개면 11번, 100개면 101번, 1000개면 1001번. 데이터가 늘수록 쿼리가 <strong>선형으로 증가</strong>한다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant App as Spring 서버
    participant DB as MySQL

    Note over App,DB: 😱 N+1 발생 시

    App-&gt;&gt;DB: SELECT * FROM inquiry LIMIT 10
    DB--&gt;&gt;App: 문의 10건

    loop 문의 1건마다
        App-&gt;&gt;DB: SELECT * FROM photo_lab WHERE id = ?
        DB--&gt;&gt;App: 현상소 1건
    end

    Note over App,DB: 총 11번 쿼리 실행!
</code></pre>
<h3 id="heading-fetchjoin">😎 fetchJoin으로 해결하면</h3>
<pre><code class="lang-plaintext">[쿼리 1] SELECT i.*, pl.*
         FROM inquiry i
         LEFT JOIN photo_lab pl ON i.photo_lab_id = pl.id
         WHERE i.member_id = 1
         LIMIT 10
</code></pre>
<p><strong>딱 1번</strong>. 끝.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant App as Spring 서버
    participant DB as MySQL

    Note over App,DB: 😎 fetchJoin 적용 후

    App-&gt;&gt;DB: SELECT i.*, pl.* FROM inquiry i LEFT JOIN photo_lab pl ...
    DB--&gt;&gt;App: 문의 10건 + 현상소 정보 한 번에!

    Note over App,DB: 총 1번 쿼리 실행!
</code></pre>
<h3 id="heading-7jmcioydtoughqyjcdrkjjripqg6rg46rmmpw">왜 이렇게 되는 걸까?</h3>
<p>JPA는 기본적으로 <strong>지연 로딩(Lazy Loading)</strong>을 쓴다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@ManyToOne(fetch = FetchType.LAZY)</span>  <span class="hljs-comment">// ← 이게 기본</span>
<span class="hljs-meta">@JoinColumn(name = "photo_lab_id")</span>
<span class="hljs-keyword">private</span> PhotoLab photoLab;
</code></pre>
<p><code>LAZY</code>는 <code>inquiry.getPhotoLab().getName()</code>처럼 <strong>실제로 접근하는 순간</strong>에 쿼리를 날린다. 문의 1건만 볼 때는 괜찮지만, <strong>목록으로 10건을 루프 돌면</strong> 10번 추가 쿼리가 나간다.</p>
<blockquote>
<p>LAZY 자체가 나쁜 게 아니다. <strong>목록 조회에서 연관 엔티티에 접근할 때</strong> 문제가 된다.</p>
</blockquote>
<hr />
<h2 id="heading-3-fetchjoin">3. 첫 번째 발견: fetchJoin + 페이징 = 💥</h2>
<p>돌아가는 코드를 올렸더니, <strong>코드 리뷰</strong>에서 이런 피드백이 왔다.</p>
<blockquote>
<p>"fetchJoin이랑 offset/limit 같이 쓰면 Hibernate가 메모리에서 페이징해요. 데이터 많아지면 터집니다."</p>
</blockquote>
<p>무슨 소리지? 싶었는데, 실제로 로그를 보니:</p>
<pre><code class="lang-plaintext">WARN  HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
</code></pre>
<p>⚠️ <strong>Hibernate가 경고를 보내고 있었다.</strong></p>
<h3 id="heading-7jmcioydtoufscdsnbzsnbq">왜 이런 일이?</h3>
<p><code>fetchJoin</code>은 SQL의 <code>JOIN</code>으로 바뀌는데, 컬렉션(replies 같은 <code>@OneToMany</code>)을 fetchJoin하면 <strong>한 문의에 답변이 3개일 때 결과 행이 3배</strong>로 뻥튀기된다.</p>
<pre><code class="lang-plaintext">문의1 - 답변A
문의1 - 답변B
문의1 - 답변C  ← 같은 문의인데 행이 3개!
문의2 - 답변D
</code></pre>
<p>이 상태에서 <code>LIMIT 10</code>을 걸면? <strong>문의 10개가 아니라 행 10개</strong>를 자른다. 그래서 Hibernate는 <strong>전체 데이터를 메모리에 올린 다음</strong> 애플리케이션에서 페이징한다.</p>
<pre><code class="lang-mermaid">flowchart LR
    A["fetchJoin + 페이징"] --&gt; B{"컬렉션 JOIN?"}
    B --&gt;|YES| C["❌ Hibernate가\n전체 데이터 메모리 로드\n→ OOM 위험"]
    B --&gt;|NO| D["✅ 정상 작동\n(단건 연관관계는 OK)"]

    style C fill:#fee,stroke:#c33
    style D fill:#efe,stroke:#3a3
</code></pre>
<h3 id="heading-2">해결: 2단계 쿼리 패턴</h3>
<p>코드 리뷰 피드백을 반영해서 <strong>ID를 먼저 뽑고, 그 ID로 데이터를 가져오는</strong> 2단계 구조로 바꿨다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// ✅ 개선된 버전 — 2단계 쿼리</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> List&lt;Inquiry&gt; <span class="hljs-title">findByMemberId</span><span class="hljs-params">(Long memberId, <span class="hljs-keyword">int</span> page, <span class="hljs-keyword">int</span> size)</span> </span>{

    <span class="hljs-comment">// 1단계: ID만 페이징해서 가져온다 (가볍다)</span>
    List&lt;Long&gt; ids = queryFactory
            .select(inquiry.id)
            .from(inquiry)
            .where(inquiry.member.id.eq(memberId))
            .orderBy(inquiry.createdAt.desc())
            .offset((<span class="hljs-keyword">long</span>) page * size)
            .limit(size)
            .fetch();

    <span class="hljs-keyword">if</span> (ids.isEmpty()) {
        <span class="hljs-keyword">return</span> Collections.emptyList();
    }

    <span class="hljs-comment">// 2단계: ID 목록으로 fetchJoin (페이징 없이!)</span>
    <span class="hljs-keyword">return</span> queryFactory
            .selectFrom(inquiry)
            .leftJoin(inquiry.photoLab).fetchJoin()
            .leftJoin(inquiry.replies, inquiryReply).fetchJoin()
            .where(inquiry.id.in(ids))
            .orderBy(inquiry.createdAt.desc())
            .fetch();
}
</code></pre>
<p><strong>왜 이게 되는 걸까?</strong></p>
<ul>
<li><p>1단계에서 <code>LIMIT</code>으로 <strong>정확히 10개의 문의 ID</strong>를 가져온다</p>
</li>
<li><p>2단계에서는 <code>WHERE id IN (1, 2, 3, ...)</code> 으로 <strong>딱 그 문의들만</strong> fetchJoin한다</p>
</li>
<li><p>페이징은 1단계에서 끝났으니, 2단계에서 Hibernate 경고 안 뜬다 ✅</p>
</li>
</ul>
<p>(<a target="_blank" href="https://github.com/Finders-Official/BE/commit/82b42c72">커밋 <code>82b42c7</code></a>)</p>
<hr />
<h2 id="heading-4">4. 두 번째 발견: "그 데이터, 진짜 필요해?"</h2>
<p>2단계 쿼리로 바꾸고 나서 다시 코드를 봤다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 2단계에서 replies를 fetchJoin하고 있었다</span>
.leftJoin(inquiry.replies, inquiryReply).fetchJoin()
</code></pre>
<p>문의 <strong>목록</strong> 화면에서 답변 전체 내용이 필요할까?</p>
<blockquote>
<p>사실 목록에서는 <strong>"답변이 있는지 없는지"</strong>만 보여주면 됐다.</p>
</blockquote>
<p>그런데 기존 코드는 이렇게 하고 있었다:</p>
<pre><code class="lang-java"><span class="hljs-comment">// ❌ Before: replies를 전부 로드해서 비었는지 체크</span>
.hasReply(!inquiry.getReplies().isEmpty())
.latestReply(latestReply != <span class="hljs-keyword">null</span> ? ReplyPreviewDTO.from(latestReply) : <span class="hljs-keyword">null</span>)
</code></pre>
<p><code>getReplies()</code>를 호출하는 순간, JPA는 replies 전체를 DB에서 가져온다. 문의 1건에 답변이 20개면? <strong>20건의 데이터가 메모리에 올라간다.</strong> 목록에서 문의 10개를 보여줄 때, <strong>답변 200개가 같이 딸려온다.</strong></p>
<h3 id="heading-status">해결: status로 판단하기</h3>
<p>문의 엔티티에는 이미 <code>status</code> 필드가 있었다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">enum</span> <span class="hljs-title">InquiryStatus</span> </span>{
    PENDING,    <span class="hljs-comment">// 답변 대기</span>
    ANSWERED,   <span class="hljs-comment">// 답변 완료</span>
    CLOSED      <span class="hljs-comment">// 종료</span>
}
</code></pre>
<p>답변이 등록되면 <code>ANSWERED</code>로 바뀌니까, <strong>replies를 안 가져와도</strong> 답변 여부를 알 수 있다!</p>
<pre><code class="lang-java"><span class="hljs-comment">// ✅ After: status만으로 판단, replies 전혀 안 건드림</span>
.hasReply(inquiry.getStatus() != InquiryStatus.PENDING)
</code></pre>
<p>QueryDSL 쿼리에서도 <strong>replies fetchJoin을 통째로 삭제</strong>했다:</p>
<pre><code class="lang-java"><span class="hljs-comment">// ✅ After: replies JOIN 자체를 제거</span>
<span class="hljs-keyword">return</span> queryFactory
        .selectFrom(inquiry)
        .leftJoin(inquiry.photoLab).fetchJoin()  <span class="hljs-comment">// 현상소 이름은 필요하니까 유지</span>
        <span class="hljs-comment">// .leftJoin(inquiry.replies, inquiryReply).fetchJoin()  ← 삭제!</span>
        .where(inquiry.id.in(ids))
        .orderBy(inquiry.createdAt.desc())
        .fetch();
</code></pre>
<p>DTO에서도 <code>latestReply</code> 필드를 아예 제거했다:</p>
<pre><code class="lang-java"><span class="hljs-comment">// ❌ Before</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">InquiryItemDTO</span><span class="hljs-params">(
    Long id, String title, InquiryStatus status,
    String photoLabName, LocalDateTime createdAt,
    <span class="hljs-keyword">boolean</span> hasReply,
    ReplyPreviewDTO latestReply  // ← 이것 때문에 replies 전체를 로드
)</span> </span>{ }

<span class="hljs-comment">// ✅ After</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">InquiryItemDTO</span><span class="hljs-params">(
    Long id, String title, String content, InquiryStatus status,
    String photoLabName, LocalDateTime createdAt,
    <span class="hljs-keyword">boolean</span> hasReply              // ← status로 판단, latestReply 삭제
)</span> </span>{ }
</code></pre>
<blockquote>
<p><strong>불필요한 데이터를 안 가져오는 것이 최고의 최적화다.</strong></p>
</blockquote>
<p>(<a target="_blank" href="https://github.com/Finders-Official/BE/commit/a996e610">커밋 <code>a996e61</code></a>)</p>
<hr />
<h2 id="heading-5-n1">5. 세 번째 발견: 상세 조회도 N+1이었다</h2>
<p>문의 <strong>상세</strong> 조회에서는 답변 내용, 작성자 이름, 현상소 정보가 전부 필요하다.</p>
<p>처음에 이렇게 짰다:</p>
<pre><code class="lang-java"><span class="hljs-comment">// ❌ Before: member를 안 가져옴</span>
<span class="hljs-meta">@Query("SELECT i FROM Inquiry i " +
       "LEFT JOIN FETCH i.photoLab " +
       "LEFT JOIN FETCH i.replies r " +
       "LEFT JOIN FETCH r.replier " +
       "WHERE i.id = :id")</span>
<span class="hljs-function">Optional&lt;Inquiry&gt; <span class="hljs-title">findByIdWithDetails</span><span class="hljs-params">(<span class="hljs-meta">@Param("id")</span> Long id)</span></span>;
</code></pre>
<p>근데 DTO 변환할 때 <code>inquiry.getMember().getName()</code>을 호출하고 있었다. member를 fetchJoin하지 않았으니, <strong>여기서 추가 쿼리 1개가 나간다.</strong></p>
<pre><code class="lang-plaintext">[쿼리 1] SELECT i.*, pl.*, r.*, rp.*     ← 문의 + 현상소 + 답변 + 답변자
         FROM inquiry i
         LEFT JOIN photo_lab pl ...
         LEFT JOIN inquiry_reply r ...
         LEFT JOIN member rp ...
         WHERE i.id = 1

[쿼리 2] SELECT * FROM member WHERE id = 5  ← 😱 문의 작성자 (N+1!)
</code></pre>
<h3 id="heading-member-fetchjoin">해결: member도 fetchJoin에 추가</h3>
<pre><code class="lang-java"><span class="hljs-comment">// ✅ After: member 추가</span>
<span class="hljs-meta">@Query("SELECT i FROM Inquiry i " +
       "LEFT JOIN FETCH i.member " +       // ← 추가!
       "LEFT JOIN FETCH i.photoLab " +
       "LEFT JOIN FETCH i.replies r " +
       "LEFT JOIN FETCH r.replier " +
       "WHERE i.id = :id")</span>
<span class="hljs-function">Optional&lt;Inquiry&gt; <span class="hljs-title">findByIdWithDetails</span><span class="hljs-params">(<span class="hljs-meta">@Param("id")</span> Long id)</span></span>;
</code></pre>
<p>이제 상세 조회도 <strong>딱 1번 쿼리</strong>로 전부 가져온다. ✅</p>
<p>(<a target="_blank" href="https://github.com/Finders-Official/BE/commit/25c991b6">커밋 <code>25c991b</code></a>)</p>
<hr />
<h2 id="heading-6">6. 방어선 만들기 — 놓쳐도 터지지 않게</h2>
<p>모든 쿼리를 완벽하게 최적화할 수는 없다. 그래서 <strong>안전망</strong>을 깔아뒀다.</p>
<h3 id="heading-defaultbatchfetchsize"><code>default_batch_fetch_size</code></h3>
<pre><code class="lang-yaml"><span class="hljs-comment"># application.yml</span>
<span class="hljs-attr">spring:</span>
  <span class="hljs-attr">jpa:</span>
    <span class="hljs-attr">properties:</span>
      <span class="hljs-attr">hibernate:</span>
        <span class="hljs-attr">default_batch_fetch_size:</span> <span class="hljs-number">100</span>
</code></pre>
<p>이 설정 하나면, fetchJoin을 깜빡 잊어도 <strong>N+1이 N/100+1로 줄어든다.</strong></p>
<p>예를 들어 문의 100개를 조회하는데 현상소를 fetchJoin하지 않았다면:</p>
<ul>
<li><p>설정 없이: 100번 추가 쿼리 (N+1)</p>
</li>
<li><p><code>batch_size: 100</code>: <strong>1번</strong> 추가 쿼리 (<code>WHERE id IN (1,2,3,...100)</code>)</p>
</li>
</ul>
<h3 id="heading-batchsize"><code>@BatchSize</code> — 특정 컬렉션만 배치 로딩</h3>
<pre><code class="lang-java"><span class="hljs-meta">@OneToMany(mappedBy = "inquiry", cascade = CascadeType.ALL, orphanRemoval = true)</span>
<span class="hljs-meta">@OrderBy("displayOrder ASC")</span>
<span class="hljs-meta">@BatchSize(size = 10)</span>  <span class="hljs-comment">// ← 이미지는 한 번에 10개씩 배치 로딩</span>
<span class="hljs-keyword">private</span> List&lt;InquiryImage&gt; images = <span class="hljs-keyword">new</span> ArrayList&lt;&gt;();
</code></pre>
<h3 id="heading-vs-dto">목록 vs 상세: DTO 분리</h3>
<pre><code class="lang-mermaid">flowchart TB
    subgraph 목록 조회
        A["InquiryItemDTO"] --&gt; B["id, title, status\nphotoLabName, hasReply"]
        A --&gt; C["❌ replies 안 가져옴\n❌ images 안 가져옴\n❌ member 안 가져옴"]
    end

    subgraph 상세 조회
        D["InquiryDetailDTO"] --&gt; E["id, title, content, status\nphotoLab, member\nimages, replies"]
        D --&gt; F["✅ fetchJoin 한 방 쿼리\n✅ 모든 연관 데이터 포함"]
    end

    style C fill:#fee,stroke:#c33
    style F fill:#efe,stroke:#3a3
</code></pre>
<p><strong>같은 엔티티라도 화면마다 필요한 데이터가 다르다.</strong> 목록에서는 가볍게, 상세에서는 한 방에.</p>
<hr />
<h2 id="heading-7-before-vs-after">7. Before vs After</h2>
<h3 id="heading-10">문의 목록 10건 조회 시</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td></td><td>Before</td><td>After</td></tr>
</thead>
<tbody>
<tr>
<td><strong>쿼리 수</strong></td><td>1 (메모리 페이징⚠️)</td><td>2 (ID 페이징 + fetchJoin)</td></tr>
<tr>
<td><strong>메모리 로드</strong></td><td>전체 replies 포함</td><td>replies 제외</td></tr>
<tr>
<td><strong>Hibernate 경고</strong></td><td><code>HHH90003004</code> ⚠️</td><td>없음 ✅</td></tr>
<tr>
<td><strong>불필요한 데이터</strong></td><td><code>latestReply</code> DTO 포함</td><td><code>hasReply</code>만 (status 기반)</td></tr>
</tbody>
</table>
</div><h3 id="heading-1-1">문의 상세 1건 조회 시</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td></td><td>Before</td><td>After</td></tr>
</thead>
<tbody>
<tr>
<td><strong>쿼리 수</strong></td><td>2 (member 추가 쿼리)</td><td>1 (한 방 fetchJoin)</td></tr>
<tr>
<td><strong>JOIN 대상</strong></td><td>photoLab, replies, replier</td><td><strong>member</strong>, photoLab, replies, replier</td></tr>
</tbody>
</table>
</div><h3 id="heading-7jwi7kce66ed">안전망</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>설정</td><td>효과</td></tr>
</thead>
<tbody>
<tr>
<td><code>default_batch_fetch_size: 100</code></td><td>깜빡 잊어도 N+1 → 최대 2번</td></tr>
<tr>
<td><code>@BatchSize(size = 10)</code></td><td>images 컬렉션 배치 로딩</td></tr>
<tr>
<td>DTO 분리 (목록/상세)</td><td>화면별 최소 데이터만 로드</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-8">8. 정리 — 우리가 배운 것들</h2>
<pre><code class="lang-mermaid">timeline
    title Inquiry 쿼리 최적화 타임라인
    1월 16일 : 1:1 문의 API 첫 구현
            : fetchJoin + 페이징 조합
    1월 17일 : 코드 리뷰에서 문제 발견
            : 2단계 쿼리 패턴 도입
    1월 17일 : 목록 조회 replies 제거
            : status 기반 hasReply 판단
    1월 17일 : 상세 조회 member N+1 수정
            : 보안 어노테이션 추가
    1월 28일 : 중복 로직 헬퍼 메서드 추출
            : 코드 정리 완료
</code></pre>
<p>4가지 교훈:</p>
<h3 id="heading-1-fetchjoin">1. fetchJoin + 페이징 = 위험</h3>
<blockquote>
<p>컬렉션(<code>@OneToMany</code>)을 fetchJoin하면서 페이징하면 <strong>메모리 페이징</strong>이 발생한다. 2단계 쿼리(ID 먼저 → fetchJoin 나중)로 해결.</p>
</blockquote>
<h3 id="heading-2-1">2. 안 가져오는 게 최고의 최적화</h3>
<blockquote>
<p>목록에서 <code>replies</code>를 fetchJoin하던 걸 <strong>통째로 삭제</strong>했다. 이미 있는 <code>status</code> 필드로 대체.</p>
</blockquote>
<h3 id="heading-3-n1">3. N+1은 한 곳만 있지 않다</h3>
<blockquote>
<p>목록 조회를 고쳤더니, 상세 조회에서 또 N+1이 있었다. <strong>모든 조회 경로를 의심</strong>해야 한다.</p>
</blockquote>
<h3 id="heading-4-1">4. 안전망을 깔아두자</h3>
<blockquote>
<p><code>default_batch_fetch_size</code>와 <code>@BatchSize</code>는 fetchJoin을 깜빡해도 <strong>N+1을 N/100+1로</strong> 줄여준다. 보험이다.</p>
</blockquote>
<hr />
<h2 id="heading-7lc46rog">참고</h2>
<ul>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/137">Finders 1:1 문의 API 구현 — Issue #137</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/commit/82b42c72">fetchJoin + 페이징 수정 — 커밋 82b42c7</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/commit/a996e610">replies 로딩 최적화 — 커밋 a996e61</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/commit/25c991b6">상세 조회 N+1 수정 — 커밋 25c991b</a></p>
</li>
<li><p><a target="_blank" href="https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#fetching">Hibernate ORM User Guide — Fetching</a></p>
</li>
<li><p><a target="_blank" href="https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html">Spring Data JPA Reference — Query Methods</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[모니터링] Sentry 도입부터 Discord 에러 알림까지 — 서버 감시 시스템 구축기]]></title><description><![CDATA["서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지


1. 모니터링을 시작한 계기

프론트엔드: "API 안 되는데요?"백엔드: "엥? 언제부터요?"프론트엔드: "...2시간 전부터요?"백엔드: 😱

어느 날 서버가 죽어있었는데 아무도 몰랐다. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue #102)

2. 모니터링 도구 비교: 뭘 쓸까?
처음에는 여러 도구를 비교했다.




도구무료 티어장점단점



Sent...]]></description><link>https://blog.finders.it.kr/sentry-discord</link><guid isPermaLink="true">https://blog.finders.it.kr/sentry-discord</guid><dc:creator><![CDATA[IISweetHeartII]]></dc:creator><pubDate>Wed, 11 Feb 2026 08:08:41 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>"서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지</p>
</blockquote>
<hr />
<h2 id="heading-1">1. 모니터링을 시작한 계기</h2>
<blockquote>
<p>프론트엔드: "API 안 되는데요?"<br />백엔드: "엥? 언제부터요?"<br />프론트엔드: "...2시간 전부터요?"<br />백엔드: 😱</p>
</blockquote>
<p>어느 날 서버가 죽어있었는데 <strong>아무도 몰랐다</strong>. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue <a target="_blank" href="https://github.com/Finders-Official/BE/issues/102">#102</a>)</p>
<hr />
<h2 id="heading-2">2. 모니터링 도구 비교: 뭘 쓸까?</h2>
<p>처음에는 여러 도구를 비교했다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>도구</td><td>무료 티어</td><td>장점</td><td>단점</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Sentry</strong></td><td>5,000 에러/월, 1유저</td><td>에러 추적 특화, Spring Boot 연동 쉬움</td><td>Discord 연동은 Team 플랜($26/월) 필요</td></tr>
<tr>
<td><strong>Datadog</strong></td><td>14일 체험</td><td>올인원 (메트릭+로그+APM)</td><td>무료 없음. 비쌈 ($15~/호스트/월)</td></tr>
<tr>
<td><strong>New Relic</strong></td><td>100GB/월 무료</td><td>무료 티어 넉넉</td><td>설정 복잡, 학습 곡선 높음</td></tr>
<tr>
<td><strong>ELK Stack</strong></td><td>셀프호스팅 무료</td><td>로그 검색 강력</td><td><strong>직접 운영</strong>해야 함. 리소스 많이 먹음</td></tr>
<tr>
<td><strong>GCP Cloud Monitoring</strong></td><td>GCP 자원 무료</td><td>GCP 인프라 지표 자동 수집</td><td><strong>앱 레벨 에러 추적은 못 함</strong></td></tr>
</tbody>
</table>
</div><h3 id="heading-7jqw66as7j2yioyeoo2dnq">우리의 선택</h3>
<pre><code class="lang-plaintext">앱 에러 추적    → Sentry (무료 Developer 플랜)
인프라 지표     → GCP Cloud Monitoring (무료)
실시간 알림     → Discord Webhook (직접 구현, 무료)
로그 중앙화     → GCP Cloud Logging (Docker gcplogs 드라이버)
</code></pre>
<p><strong>핵심 전략: "무료로 최대한 커버하고, 부족한 건 직접 만들자"</strong></p>
<hr />
<h2 id="heading-3-sentry">3. Sentry 도입하기</h2>
<h3 id="heading-sentry">왜 Sentry인가?</h3>
<p>Spring Boot에서 에러 모니터링을 시작하기 가장 쉬운 도구였다.</p>
<p><strong>의존성 하나 추가:</strong></p>
<pre><code class="lang-plaintext">// build.gradle
implementation 'io.sentry:sentry-spring-boot-starter-jakarta:8.28.0'
</code></pre>
<p><strong>설정 추가:</strong></p>
<pre><code class="lang-yaml"><span class="hljs-comment"># application.yml</span>
<span class="hljs-attr">sentry:</span>
  <span class="hljs-attr">dsn:</span> <span class="hljs-string">${SENTRY_DSN}</span>              <span class="hljs-comment"># Sentry 프로젝트 DSN</span>
  <span class="hljs-attr">environment:</span> <span class="hljs-string">${SPRING_PROFILES_ACTIVE:local}</span>
  <span class="hljs-attr">traces-sample-rate:</span> <span class="hljs-number">1.0</span>          <span class="hljs-comment"># 성능 추적 비율</span>
  <span class="hljs-attr">send-default-pii:</span> <span class="hljs-literal">false</span>          <span class="hljs-comment"># 개인정보 전송 안 함</span>
  <span class="hljs-attr">in-app-includes:</span> <span class="hljs-string">com.finders</span>     <span class="hljs-comment"># 우리 코드만 하이라이트</span>
  <span class="hljs-attr">logging:</span>
    <span class="hljs-attr">minimum-event-level:</span> <span class="hljs-string">warn</span>      <span class="hljs-comment"># WARN 이상만 Sentry로</span>
    <span class="hljs-attr">minimum-breadcrumb-level:</span> <span class="hljs-string">info</span> <span class="hljs-comment"># Breadcrumb은 INFO부터</span>
</code></pre>
<p>이것만으로 서버에서 발생하는 <strong>모든 Exception이 자동으로 Sentry에 기록</strong>된다.</p>
<p><img src="https://sentry.io/static/javascript-fd40b2ef06c89b843f16055734aa2eed.jpg" alt="Sentry 에러 모니터링 대시보드" /></p>
<p><em>Sentry 대시보드에서 에러 목록과 발생 빈도를 한눈에 볼 수 있다</em></p>
<h3 id="heading-7zmy6rk967oeioydmo2ujoungsdsskjsnbq">환경별 샘플링 차이</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>환경</td><td>샘플링 비율</td><td>이유</td></tr>
</thead>
<tbody>
<tr>
<td>Dev</td><td><strong>100%</strong></td><td>모든 에러 다 봐야 디버깅 가능</td></tr>
<tr>
<td>Prod</td><td><strong>30%</strong></td><td>비용 절감. 패턴만 파악하면 충분</td></tr>
</tbody>
</table>
</div><blockquote>
<p>무료 플랜은 월 5,000 에러까지라서, prod에서 100% 캡처하면 한도를 금방 넘긴다.</p>
</blockquote>
<h3 id="heading-sentry-exceptionhandler">Sentry 초기 삽질: @ExceptionHandler와의 충돌</h3>
<p>처음에 Sentry를 붙이고 나서, <strong>에러가 캡처가 안 되는</strong> 문제가 있었다.</p>
<p>원인: Spring의 <code>@ExceptionHandler</code>가 에러를 처리해버리면, Sentry 입장에서는 "에러가 아닌 정상 응답"으로 보이는 것이었다!</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># 해결: exception-resolver-order를 최우선으로 설정</span>
<span class="hljs-attr">sentry:</span>
  <span class="hljs-attr">exception-resolver-order:</span> <span class="hljs-number">-2147483648</span>  <span class="hljs-comment"># HIGHEST_PRECEDENCE</span>
  <span class="hljs-comment"># → @ExceptionHandler보다 먼저 예외를 캡처!</span>
</code></pre>
<p>이 설정 하나로 해결되었지만, 찾는 데 한참 걸렸다... (Issue <a target="_blank" href="https://github.com/Finders-Official/BE/issues/177">#177</a>)</p>
<hr />
<h2 id="heading-4-sentry-discord">4. Sentry Discord 알림 — 왜 포기했나</h2>
<p>Sentry에서 에러가 기록되는 건 좋은데, <strong>대시보드를 열어봐야 알 수 있다</strong>는 게 문제였다.</p>
<blockquote>
<p>"에러 났을 때 바로 알림 받고 싶은데..."</p>
</blockquote>
<h3 id="heading-sentry-1">Sentry의 알림 옵션</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>방법</td><td>비용</td><td>비고</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Email 알림</strong></td><td><strong>무료</strong> ✅</td><td>Developer 플랜에서 사용 가능</td></tr>
<tr>
<td><strong>Slack 연동</strong></td><td>$26/월 (Team 플랜)</td><td>Third-party integration 필요</td></tr>
<tr>
<td><strong>Discord 연동</strong></td><td>$26/월 (Team 플랜)</td><td>Third-party integration 필요</td></tr>
</tbody>
</table>
</div><p><strong>Discord 연동을 하려면 Team 플랜($26/월)이 필요</strong>했다! 대학생 팀에게 매달 $26은... 🥲</p>
<p>그래서 결론:</p>
<ul>
<li><p>Sentry는 <strong>Email 알림만</strong> 사용 (무료)</p>
</li>
<li><p>Discord 알림은 <strong>직접 구현</strong>하기로!</p>
</li>
</ul>
<hr />
<h2 id="heading-5-discord-webhook">5. Discord Webhook 직접 만들기</h2>
<h3 id="heading-discord">Discord 설정: 이게 제일 헷갈렸다</h3>
<p>Discord에서 Webhook URL을 만드는 과정이 <strong>의외로 복잡</strong>했다.</p>
<blockquote>
<p>💡 <strong>Discord 웹후크 설정 경로</strong>: 서버 설정(⚙️) → 앱 → 연동 → 웹후크 → "새 웹후크" → URL 복사</p>
</blockquote>
<p><strong>자주 헷갈리는 것들 정리:</strong></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>이름</td><td>뭔가</td><td>어디서 찾나</td></tr>
</thead>
<tbody>
<tr>
<td><strong>서버 ID</strong></td><td>Discord 서버의 고유 번호</td><td>서버 아이콘 우클릭 → "서버 ID 복사"</td></tr>
<tr>
<td><strong>채널 ID</strong></td><td>텍스트 채널의 고유 번호</td><td>채널 이름 우클릭 → "채널 ID 복사"</td></tr>
<tr>
<td><strong>Webhook URL</strong></td><td>메시지를 보낼 수 있는 URL</td><td>서버 설정 → 연동 → 웹후크 → URL 복사</td></tr>
</tbody>
</table>
</div><blockquote>
<p><strong>주의!</strong> "채널 ID"와 "Webhook URL"은 <strong>완전히 다른 것</strong>이다.<br />Sentry 같은 서비스는 채널 ID를 요구하고, 직접 구현할 때는 Webhook URL을 쓴다.</p>
</blockquote>
<h3 id="heading-webhook-url">Webhook URL 만드는 법</h3>
<pre><code class="lang-plaintext">1. Discord 서버 설정 (⚙️) 클릭
2. 앱 &gt; 연동 &gt; 웹후크 클릭
3. "새 웹후크" 클릭
4. 이름 설정 (예: "Finders 에러 알림")
5. 채널 선택 (에러 알림을 받을 채널)
6. "웹후크 URL 복사" 클릭
</code></pre>
<blockquote>
<p><strong>⚠️ 개발자 모드가 필요할 수 있다!</strong> Discord 사용자 설정 → 고급 → <strong>개발자 모드 켜기</strong> 이게 꺼져있으면 "ID 복사" 메뉴가 안 보인다. 찾기 어려워서 한참 헤맸다...</p>
<p>💡 <strong>개발자 모드 경로</strong>: Discord 사용자 설정(⚙️) → 앱 설정 → 고급 → 개발자 모드 토글 ON</p>
</blockquote>
<hr />
<h2 id="heading-6">6. 코드 구조: 어떻게 만들었나</h2>
<h3 id="heading-7kce7lk0ioq1royhsa">전체 구조</h3>
<pre><code class="lang-plaintext">src/main/java/com/finders/api/
├── infra/discord/
│   ├── DiscordProperties.java       # 설정 (webhook-url, enabled)
│   ├── DiscordWebhookService.java   # 알림 전송 로직
│   └── dto/
│       └── DiscordMessage.java      # Discord Embed 메시지 포맷
└── global/exception/
    └── GlobalExceptionHandler.java  # 여기서 Discord 호출!
</code></pre>
<h3 id="heading-64z7j6rio2dkoumha">동작 흐름</h3>
<pre><code class="lang-mermaid">graph TD
    A["🚨 서버 500 에러"] --&gt; B["GlobalExceptionHandler"]
    B --&gt; C["DiscordWebhookService"]
    C --&gt; D["💬 Discord 채널 알림"]
    B --&gt; E["📊 Sentry 자동 기록"]
    B --&gt; F["👤 사용자에게 에러 응답"]
</code></pre>
<pre><code class="lang-plaintext">1. 사용자 요청 → 서버에서 에러 발생 (500)
2. GlobalExceptionHandler가 catch
3. DiscordWebhookService.sendErrorNotification() 호출
4. Discord 채널에 Embed 메시지 전송 🔔
5. (동시에) Sentry에도 자동 기록
6. (동시에) 사용자에게는 깔끔한 에러 응답 반환
</code></pre>
<h3 id="heading-1-globalexceptionhandler">핵심 코드 1: GlobalExceptionHandler</h3>
<pre><code class="lang-java"><span class="hljs-meta">@ExceptionHandler(Exception.class)</span>
<span class="hljs-keyword">public</span> ResponseEntity&lt;ApiResponse&lt;Void&gt;&gt; handleException(
        Exception e, HttpServletRequest request) {

    log.error(<span class="hljs-string">"[UnhandledException] {} - {}"</span>, 
              request.getRequestURI(), e.getMessage(), e);

    <span class="hljs-keyword">try</span> {
        <span class="hljs-comment">// 💡 에러 발생 즉시 Discord로 알림!</span>
        discordWebhookService.sendErrorNotification(
            e, request.getMethod(), request.getRequestURI()
        );
    } <span class="hljs-keyword">catch</span> (Exception ignored) {
        <span class="hljs-comment">// 알림 실패가 사용자 응답에 영향 주면 안 됨</span>
    }

    <span class="hljs-keyword">return</span> ResponseEntity.status(<span class="hljs-number">500</span>)
        .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR));
}
</code></pre>
<p><strong>핵심 설계:</strong></p>
<ul>
<li><p>Discord 알림이 실패해도 <strong>사용자 응답에 영향 없음</strong> (try-catch로 감쌈)</p>
</li>
<li><p><code>500 에러만</code> 알림 (400대 에러는 클라이언트 실수이니 알림 불필요)</p>
</li>
</ul>
<h3 id="heading-2-1">핵심 코드 2: 중복 알림 방지</h3>
<p>같은 에러가 1초에 100번 나면 Discord도 100번 울릴까? <strong>아니다!</strong></p>
<pre><code class="lang-java"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">long</span> DEDUPE_WINDOW_MS = <span class="hljs-number">60_000</span>; <span class="hljs-comment">// 60초</span>

<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">boolean</span> <span class="hljs-title">isDuplicate</span><span class="hljs-params">(String errorKey)</span> </span>{
    <span class="hljs-keyword">long</span> now = System.currentTimeMillis();
    Long lastSeen = recentErrors.putIfAbsent(errorKey, now);

    <span class="hljs-keyword">if</span> (lastSeen != <span class="hljs-keyword">null</span> &amp;&amp; now - lastSeen &lt; DEDUPE_WINDOW_MS) {
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;  <span class="hljs-comment">// 60초 내 같은 에러 → 스킵!</span>
    }
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
}
</code></pre>
<ul>
<li><p><strong>60초 내 같은 에러</strong> (같은 Exception + 같은 URL)는 <strong>한 번만</strong> 알림</p>
</li>
<li><p>Discord Webhook Rate Limit(30요청/분)도 넘기지 않음</p>
</li>
</ul>
<h3 id="heading-3">핵심 코드 3: 민감정보 마스킹</h3>
<p>스택트레이스에 비밀번호나 토큰이 포함될 수 있다. 자동으로 걸러낸다:</p>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">private</span> String <span class="hljs-title">filterSensitiveInfo</span><span class="hljs-params">(String stackTrace)</span> </span>{
    <span class="hljs-keyword">return</span> stackTrace
        .replaceAll(<span class="hljs-string">"(?i)password[=:]\\s*\\S+"</span>, <span class="hljs-string">"password=***"</span>)
        .replaceAll(<span class="hljs-string">"(?i)token[=:]\\s*\\S+"</span>, <span class="hljs-string">"token=***"</span>)
        .replaceAll(<span class="hljs-string">"(?i)secret[=:]\\s*\\S+"</span>, <span class="hljs-string">"secret=***"</span>)
        .replaceAll(<span class="hljs-string">"(?i)api[_-]?key[=:]\\s*\\S+"</span>, <span class="hljs-string">"api_key=***"</span>);
}
</code></pre>
<h3 id="heading-4-discord-embed">핵심 코드 4: Discord Embed 메시지</h3>
<p>Discord에는 단순 텍스트보다 <strong>Embed 메시지</strong>가 훨씬 보기 좋다:</p>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">DiscordMessage</span><span class="hljs-params">(List&lt;Embed&gt; embeds)</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">Embed</span><span class="hljs-params">(
        String title,       // <span class="hljs-string">"Server Error"</span>
        String description,
        <span class="hljs-keyword">int</span> color,          // <span class="hljs-number">0xE74C3C</span> (빨간색)</span>
        List&lt;Field&gt; fields, <span class="hljs-comment">// Exception, Method, URL, Message, Stack Trace</span>
        Instant timestamp
    ) </span>{}
}
</code></pre>
<p>실제로 받는 알림은 이런 느낌이다:</p>
<pre><code class="lang-plaintext">🚨 Server Error
━━━━━━━━━━━━━━━━━━━
Exception    NullPointerException
Method       POST
URL          /api/v1/reservations
Message      Cannot invoke method on null
Stack Trace  (첫 5줄만)
━━━━━━━━━━━━━━━━━━━
</code></pre>
<h3 id="heading-7zmy6rk967oeioyepoyglq">환경별 설정</h3>
<pre><code class="lang-yaml"><span class="hljs-comment"># application.yml (공통 - 기본 비활성화)</span>
<span class="hljs-attr">discord:</span>
  <span class="hljs-attr">webhook-url:</span> <span class="hljs-string">${DISCORD_WEBHOOK_URL:}</span>
  <span class="hljs-attr">enabled:</span> <span class="hljs-literal">false</span>

<span class="hljs-comment"># application-prod.yml (운영에서만 활성화)</span>
<span class="hljs-attr">discord:</span>
  <span class="hljs-attr">enabled:</span> <span class="hljs-literal">true</span>
  <span class="hljs-attr">webhook-url:</span> <span class="hljs-string">${DISCORD_WEBHOOK_URL}</span>
</code></pre>
<p>local이나 dev에서 에러 날 때마다 디코에 알림이 오면 <strong>스팸</strong>이 되니까, <strong>prod에서만</strong> 활성화했다.</p>
<hr />
<h2 id="heading-7">7. 기술 선택 이유: 왜 직접 만들었나?</h2>
<p>Issue <a target="_blank" href="https://github.com/Finders-Official/BE/issues/211">#211</a>에서 여러 방안을 검토했다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>방안</td><td>비용</td><td>판단</td></tr>
</thead>
<tbody>
<tr>
<td>Sentry Team + Discord 연동</td><td><strong>$26/월</strong></td><td>대학생팀엔 부담 ❌</td></tr>
<tr>
<td>discord-webhooks 라이브러리</td><td>무료</td><td>OkHttp 기반, 기존 WebClient 패턴과 불일치 ❌</td></tr>
<tr>
<td><strong>WebClient로 직접 구현</strong></td><td><strong>무료</strong></td><td>기존 프로젝트 패턴 유지, 의존성 최소화 ✅</td></tr>
</tbody>
</table>
</div><p><strong>직접 구현 시 고려한 것들:</strong></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>고려사항</td><td>해결책</td></tr>
</thead>
<tbody>
<tr>
<td>알림 실패 시 서비스 영향?</td><td>비동기 전송 + try-catch</td></tr>
<tr>
<td>스택트레이스에 비밀번호?</td><td>정규식으로 자동 마스킹</td></tr>
<tr>
<td>같은 에러 반복 시 스팸?</td><td>60초 중복 제거 (deduplication)</td></tr>
<tr>
<td>local에서도 알림?</td><td>enabled: false로 환경별 분리</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-8">8. 최종 모니터링 구조</h2>
<pre><code class="lang-plaintext">[Spring Boot 서버]
│
├── 에러 발생 시
│   ├── Sentry ──→ 대시보드에서 상세 분석 📊
│   │              (스택트레이스, 빈도, 환경 정보)
│   │              이메일 알림 (무료)
│   │
│   └── Discord Webhook ──→ 실시간 알림 🔔
│                            (에러 요약 Embed 메시지)
│
├── 인프라 지표
│   └── GCP Cloud Monitoring ──→ 대시보드 📈
│       (CPU, 메모리, 디스크, 네트워크, 에러 로그 수)
│       Terraform으로 대시보드 코드 관리
│
└── 로그
    └── Docker gcplogs ──→ GCP Cloud Logging 📝
        (컨테이너 로그 자동 수집, 라벨로 필터링)
</code></pre>
<hr />
<h2 id="heading-9">9. 마치며</h2>
<h3 id="heading-67me7jqpioygleumra">비용 정리</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>도구</td><td>비용</td><td>역할</td></tr>
</thead>
<tbody>
<tr>
<td>Sentry Developer</td><td><strong>무료</strong></td><td>에러 추적 + 이메일 알림</td></tr>
<tr>
<td>Discord Webhook</td><td><strong>무료</strong> (직접 구현)</td><td>실시간 에러 알림</td></tr>
<tr>
<td>GCP Cloud Monitoring</td><td><strong>무료</strong></td><td>인프라 지표 대시보드</td></tr>
<tr>
<td>GCP Cloud Logging</td><td><strong>무료</strong></td><td>로그 중앙화</td></tr>
<tr>
<td><strong>총 비용</strong></td><td><strong>$0/월</strong></td></tr>
</tbody>
</table>
</div><p>$26/월짜리 Sentry Team 플랜 대신, 직접 구현해서 <strong>동일한 효과를 무료로</strong> 얻었다.</p>
<h3 id="heading-67cw7jq0ioqygutpa">배운 것들</h3>
<ol>
<li><p><strong>Discord Webhook URL 만들기가 의외로 어렵다</strong> — 개발자 모드, 채널 ID vs 서버 ID vs Webhook URL 구분이 처음엔 많이 헷갈린다</p>
</li>
<li><p><strong>Sentry의 무료 플랜은 생각보다 쓸 만하다</strong> — 에러 추적 자체는 무료로 충분. 알림 연동만 유료</p>
</li>
<li><p><strong>직접 만들면 우리 상황에 딱 맞출 수 있다</strong> — 중복 제거, 민감정보 필터링, 환경별 분리 등 세밀한 제어 가능</p>
</li>
<li><p><strong>모니터링은 빨리 도입할수록 좋다</strong> — "나중에 하자"가 아니라, 서버가 죽은 걸 모르는 것보다 빨리 하는 게 낫다</p>
</li>
</ol>
<p>에러가 나면:</p>
<ol>
<li><p><strong>Discord로 즉시 알림</strong> → 1분 내 인지</p>
</li>
<li><p><strong>Sentry에서 상세 분석</strong> → 원인 파악</p>
</li>
<li><p><strong>GCP Cloud Logging에서 로그 추적</strong> → 재현 &amp; 디버깅</p>
</li>
</ol>
<p>"서버 죽었는데 몰랐다"는 이제 <strong>옛날 얘기</strong>가 되었다. 😄</p>
<hr />
<h3 id="heading-8jtjidssljqs6ag7j207iqi">📎 참고 이슈</h3>
<ul>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/102">#102 Sentry 모니터링 시스템 구축</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/177">#177 Sentry가 @ExceptionHandler 예외를 캡처하도록 설정</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/211">#211 Discord Webhook을 통한 서버 에러 알림 구현</a></p>
</li>
<li><p><a target="_blank" href="https://sentry.io/pricing/">Sentry 공식 가격표</a></p>
</li>
<li><p><a target="_blank" href="https://discord.com/developers/docs/resources/webhook">Discord Webhook 가이드</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[CI/CD] 수동 태그에서 자동 릴리즈까지 — Git Flow와 Auto Release]]></title><description><![CDATA[🚀 우리 auto-release.yml 바로 보러가기 →
이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다!
main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다


1. 우리의 Git 전략: Git Flow (경량 버전)
Finders 프로젝트는 Git Flow 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다.
main        ← 운영 서버 (prod) 배포 브...]]></description><link>https://blog.finders.it.kr/cicd-git-flow-auto-release</link><guid isPermaLink="true">https://blog.finders.it.kr/cicd-git-flow-auto-release</guid><dc:creator><![CDATA[IISweetHeartII]]></dc:creator><pubDate>Wed, 11 Feb 2026 08:03:30 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>🚀 <a target="_blank" href="https://github.com/Finders-Official/BE/blob/develop/.github/workflows/auto-release.yml"><strong>우리 auto-release.yml 바로 보러가기 →</strong></a></p>
<p>이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다!</p>
<p>main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다</p>
</blockquote>
<hr />
<h2 id="heading-1-git-git-flow">1. 우리의 Git 전략: Git Flow (경량 버전)</h2>
<p>Finders 프로젝트는 <strong>Git Flow</strong> 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다.</p>
<pre><code class="lang-plaintext">main        ← 운영 서버 (prod) 배포 브랜치
develop     ← 개발 서버 (dev) 배포 브랜치
feature/*   ← 기능 개발 브랜치 (이슈 기반)
fix/*       ← 버그 수정 브랜치
</code></pre>
<p><img src="https://nvie.com/img/git-model@2x.png" alt="Git Flow 브랜치 전략" /></p>
<p><em>출처:</em> <a target="_blank" href="https://nvie.com/posts/a-successful-git-branching-model/"><em>A successful Git branching model</em></a> <em>— Vincent Driessen</em></p>
<h3 id="heading-7z2q66ae">흐름</h3>
<ol>
<li><p><strong>이슈 생성</strong> → 브랜치 생성 (<code>feat/signup-api-#14</code>)</p>
</li>
<li><p>개발 완료 → <strong>develop으로 PR</strong> → CI 통과 + 코드 리뷰 → <strong>머지</strong></p>
</li>
<li><p>develop 머지 → <strong>Dev 서버 자동 배포</strong> (프론트와 연동 테스트)</p>
</li>
<li><p>테스트 완료 → <strong>develop → main PR</strong> → 승인 → <strong>머지</strong></p>
</li>
<li><p>main 머지 → <strong>Prod 서버 자동 배포</strong> + <strong>릴리즈 자동 생성</strong> ← 여기!</p>
</li>
</ol>
<hr />
<h2 id="heading-2">2. 처음에는 수동이었다</h2>
<p>처음에는 릴리즈를 <strong>전부 수동</strong>으로 만들었다.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># 1. main으로 이동</span>
git checkout main
git pull origin main

<span class="hljs-comment"># 2. 태그 생성</span>
git tag v0.7.0

<span class="hljs-comment"># 3. 태그 푸시</span>
git push origin v0.7.0
</code></pre>
<p>그러면 <code>.github/release.yml</code> 설정에 의해 GitHub Release가 생성되었다.</p>
<h3 id="heading-662q6rcaiousuoygnoyygoydhoq5jd8">뭐가 문제였을까?</h3>
<ul>
<li><p><strong>매번 수동</strong>으로 해야 함 → 까먹기 쉬움</p>
</li>
<li><p>버전 번호를 <strong>직접 정해야</strong> 함 → "이번에 뭐였지... v0.7? v0.8?"</p>
</li>
<li><p>릴리즈 노트를 <strong>직접 작성</strong>해야 함 → 귀찮아서 대충 쓰게 됨</p>
</li>
<li><p>main 머지 후 태그 푸시를 <strong>깜빡하면</strong> 릴리즈가 안 생김</p>
</li>
</ul>
<p>결국 issue <a target="_blank" href="https://github.com/Finders-Official/BE/issues/344">#344</a>를 만들었다:</p>
<blockquote>
<p>"main 머지만 하면 태그 + 릴리즈가 자동 생성되도록 워크플로우를 개선합니다."</p>
</blockquote>
<hr />
<h2 id="heading-3-auto-release">3. Auto Release 워크플로우 만들기</h2>
<h3 id="heading-7kce7lk0io2dkoumha">전체 흐름</h3>
<pre><code class="lang-mermaid">graph TD
    A["main에 PR 머지"] --&gt; B["auto-release.yml 실행"]
    B --&gt; C{"PR 제목에 버전이 있나?"}
    C --&gt;|"[RELEASE] v0.9.2 배포"| D["v0.9.2 사용"]
    C --&gt;|"버전 없음"| E["PATCH 자동 증가&lt;br/&gt;v0.9.1 → v0.9.2"]
    D --&gt; F["태그 생성 &amp; 푸시"]
    E --&gt; F
    F --&gt; G["커밋 로그 기반&lt;br/&gt;릴리즈 노트 생성"]
    G --&gt; H["GitHub Release 발행"]
</code></pre>
<h3 id="heading-7zw17iusioy9loutna">핵심 코드</h3>
<p><strong>1단계: 버전 결정</strong></p>
<pre><code class="lang-yaml"><span class="hljs-comment"># PR 제목에서 버전 추출 시도</span>
<span class="hljs-string">if</span> [[ <span class="hljs-string">"$COMMIT_MSG"</span> <span class="hljs-string">=~</span> <span class="hljs-string">v(</span>[<span class="hljs-number">0</span><span class="hljs-number">-9</span>]<span class="hljs-string">+\.</span>[<span class="hljs-number">0</span><span class="hljs-number">-9</span>]<span class="hljs-string">+\.</span>[<span class="hljs-number">0</span><span class="hljs-number">-9</span>]<span class="hljs-string">+)</span> ]]<span class="hljs-string">;</span> <span class="hljs-string">then</span>
  <span class="hljs-string">VERSION="v${BASH_REMATCH[1]}"</span>
  <span class="hljs-comment"># → PR 제목이 "[RELEASE] v0.9.2 배포" 이면 v0.9.2 사용</span>
<span class="hljs-string">else</span>
  <span class="hljs-comment"># 버전 없으면 PATCH 자동 증가</span>
  <span class="hljs-string">IFS='.'</span> <span class="hljs-string">read</span> <span class="hljs-string">-ra</span> <span class="hljs-string">PARTS</span> <span class="hljs-string">&lt;&lt;&lt;</span> <span class="hljs-string">"${LAST_TAG//v/}"</span>
  <span class="hljs-string">PATCH=$((PARTS[2]</span> <span class="hljs-string">+</span> <span class="hljs-number">1</span><span class="hljs-string">))</span>
  <span class="hljs-string">VERSION="v${PARTS[0]}.${PARTS[1]}.${PATCH}"</span>
  <span class="hljs-comment"># → v0.9.1 이 마지막이면 v0.9.2 자동 생성</span>
<span class="hljs-string">fi</span>
</code></pre>
<p><strong>2단계: 릴리즈 노트 자동 생성</strong></p>
<p>커밋 메시지의 <strong>prefix(feat, fix, docs...)</strong>를 분석해서 자동 분류한다:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># feat 커밋만 모아서 Features 섹션</span>
<span class="hljs-string">FEATS=$(git</span> <span class="hljs-string">log</span> <span class="hljs-string">"${LAST_TAG}..HEAD"</span> <span class="hljs-string">--pretty=format:"-</span> <span class="hljs-string">%s</span> <span class="hljs-string">(%h)"</span> <span class="hljs-string">--grep="^feat")</span>

<span class="hljs-comment"># fix 커밋만 모아서 Bug Fixes 섹션</span>
<span class="hljs-string">FIXES=$(git</span> <span class="hljs-string">log</span> <span class="hljs-string">"${LAST_TAG}..HEAD"</span> <span class="hljs-string">--pretty=format:"-</span> <span class="hljs-string">%s</span> <span class="hljs-string">(%h)"</span> <span class="hljs-string">--grep="^fix")</span>

<span class="hljs-comment"># docs, refactor, test도 각각 분류</span>
</code></pre>
<p><strong>3단계: GitHub Release 생성</strong></p>
<pre><code class="lang-yaml"><span class="hljs-string">gh</span> <span class="hljs-string">release</span> <span class="hljs-string">create</span> <span class="hljs-string">"$VERSION"</span> <span class="hljs-string">\</span>
  <span class="hljs-string">--title</span> <span class="hljs-string">"Release ${VERSION}"</span> <span class="hljs-string">\</span>
  <span class="hljs-string">--notes-file</span> <span class="hljs-string">RELEASE_NOTES.md</span> <span class="hljs-string">\</span>
  <span class="hljs-string">--latest</span>
</code></pre>
<hr />
<h2 id="heading-4">4. 실제 릴리즈는 이렇게 생긴다</h2>
<p>📎 <strong>실제 릴리즈 목록</strong>: <a target="_blank" href="https://github.com/Finders-Official/BE/releases">Finders BE Releases →</a></p>
<p>예를 들어 <strong>Release v0.9.5</strong>의 릴리즈 노트는 이렇게 자동 생성되었다:</p>
<pre><code class="lang-markdown"><span class="hljs-section"># Release v0.9.5</span>

<span class="hljs-section">## Changes since v0.9.4</span>

<span class="hljs-section">### ✨ Features</span>
<span class="hljs-bullet">-</span> feat: 전용 terraform-ci SA 추가 및 Compute SA 역할 분리 (#396)
<span class="hljs-bullet">-</span> feat: 공지 api 수정

<span class="hljs-section">### 🐛 Bug Fixes</span>
<span class="hljs-bullet">-</span> fix: swagger 문서 수정
<span class="hljs-bullet">-</span> fix: 위치 미 동의시 현상소 조회의 경우 distanceKm가 반환되지 않게 수정
<span class="hljs-bullet">-</span> fix: 배포 스크립트 서버 경로 통일 (#391)

<span class="hljs-section">### 📚 Documentation</span>
<span class="hljs-bullet">-</span> docs: 아키텍처 문서 업데이트 (#385)

<span class="hljs-section">### ♻️ Refactoring</span>
<span class="hljs-bullet">-</span> refactor: outputs.tf를 단일 jsonencode deploy<span class="hljs-emphasis">_config로 통합 (#393)</span>
</code></pre>
<p><strong>사람이 한 일: PR 머지 버튼 클릭. 끝.</strong></p>
<hr />
<h2 id="heading-5">5. 두 가지 배포 시나리오</h2>
<h3 id="heading-a-patch">시나리오 A: 일반 배포 (PATCH 자동 증가)</h3>
<pre><code class="lang-plaintext">develop → main PR
제목: "[FIX] 이미지 업로드 버그 수정 (#123)"

→ 마지막 태그 v0.9.4에서 자동으로 v0.9.5 생성
</code></pre>
<h3 id="heading-b">시나리오 B: 메이저/마이너 릴리즈 (버전 명시)</h3>
<pre><code class="lang-plaintext">develop → main PR
제목: "[RELEASE] v1.0.0 배포"

→ PR 제목에서 v1.0.0 추출해서 그 버전으로 태그 생성
</code></pre>
<p><strong>포인트:</strong> 특별한 릴리즈(메이저, 마이너 버전 업)일 때만 PR 제목에 버전을 적으면 되고, 일반적인 패치는 알아서 버전이 올라간다!</p>
<hr />
<h2 id="heading-6-semantic-versioning">6. 버전 관리 규칙 (Semantic Versioning)</h2>
<pre><code class="lang-plaintext">v MAJOR . MINOR . PATCH
  ↓       ↓       ↓
  1   .   2   .   3
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>버전</td><td>올리는 시점</td><td>예시</td></tr>
</thead>
<tbody>
<tr>
<td><strong>MAJOR</strong></td><td>호환 안 되는 큰 변경</td><td><code>v1.0.0</code> → <code>v2.0.0</code></td></tr>
<tr>
<td><strong>MINOR</strong></td><td>새 기능 추가</td><td><code>v1.0.0</code> → <code>v1.1.0</code></td></tr>
<tr>
<td><strong>PATCH</strong></td><td>버그 수정</td><td><code>v1.0.0</code> → <code>v1.0.1</code></td></tr>
</tbody>
</table>
</div><p>현재 Finders는 아직 <code>v0.x.x</code> 대로, 빠르게 기능을 추가하는 단계다. <code>v1.0.0</code>은 정식 서비스 출시 시점에 찍을 예정!</p>
<hr />
<h2 id="heading-7-before-vs-after">7. Before vs After</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td></td><td>Before (수동)</td><td>After (자동)</td></tr>
</thead>
<tbody>
<tr>
<td><strong>태그 생성</strong></td><td><code>git tag v0.x.x</code> 직접 입력</td><td>PR 머지 시 자동</td></tr>
<tr>
<td><strong>버전 결정</strong></td><td>"이번 버전 뭐였지?"</td><td>PATCH 자동 증가</td></tr>
<tr>
<td><strong>릴리즈 노트</strong></td><td>대충 적거나 안 적음</td><td>커밋 기반 자동 분류</td></tr>
<tr>
<td><strong>GitHub Release</strong></td><td>수동 생성 or 까먹음</td><td><strong>100% 자동</strong></td></tr>
<tr>
<td><strong>실수 가능성</strong></td><td>태그 안 찍음, 중복 태그</td><td>중복 체크 내장</td></tr>
</tbody>
</table>
</div><pre><code class="lang-mermaid">graph LR
    subgraph Before["❌ Before — 수동"]
        B1["main 머지"] --&gt; B2["git tag 직접"] --&gt; B3["git push 태그"] --&gt; B4["릴리즈 수동 작성"]
    end
    subgraph After["✅ After — 자동"]
        A1["main 머지"] --&gt; A2["전부 자동! 🎉"]
    end
</code></pre>
<hr />
<h2 id="heading-8">8. 마치며</h2>
<p>릴리즈 자동화는 "편해지자"가 아니라 <strong>"실수를 없애자"</strong>에 가깝다.</p>
<p>수동으로 하면:</p>
<ul>
<li><p>까먹는다 (태그 안 찍는 건 일상)</p>
</li>
<li><p>대충 한다 (릴리즈 노트 "잡다한 수정" 한 줄)</p>
</li>
<li><p>틀린다 (중복 태그, 잘못된 버전)</p>
</li>
</ul>
<p>자동으로 하면:</p>
<ul>
<li><p><strong>머지만 하면 끝</strong> → 깜빡할 수가 없음</p>
</li>
<li><p><strong>커밋 메시지가 곧 릴리즈 노트</strong> → feat/fix 잘 쓰는 습관이 중요해짐</p>
</li>
<li><p><strong>중복 체크 내장</strong> → 같은 태그 두 번 생성 불가</p>
</li>
</ul>
<p>결국 <strong>좋은 커밋 메시지 컨벤션 + Auto Release = 자동으로 정리되는 프로젝트 히스토리</strong>라는 걸 깨달았다. 커밋 메시지를 대충 쓰면 릴리즈 노트도 대충 나오니까, 팀원들의 커밋 습관도 자연스럽게 좋아졌다. 👍</p>
<hr />
<h3 id="heading-8jtjidssljqs6a">📎 참고</h3>
<ul>
<li><p>🚀 <a target="_blank" href="https://github.com/Finders-Official/BE/blob/develop/.github/workflows/auto-release.yml">auto-release.yml 전체 코드 보기</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/344">Issue #344: main 머지 시 GitHub Release 자동 생성 워크플로우 추가</a></p>
</li>
<li><p><a target="_blank" href="https://semver.org/lang/ko/">Semantic Versioning 공식 사이트</a></p>
</li>
<li><p><a target="_blank" href="https://cli.github.com/manual/gh_release_create">GitHub Actions - gh release create 문서</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[ci/cd] 대학생 팀의 배포 파이프라인 진화기]]></title><description><![CDATA[PR 하나면 끝나는 무중단 배포까지, 삽질의 기록


1. 시작은 단순했다

"서버에 올려야 하는데... 어떻게 하지?"

Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다.
1. SSH로 서버 접속
2. git pull
3. ./gradlew build
4. java -jar app.jar

당연히 문제가 생겼다.

빌드하는 동안 서버가 꺼져있음 (프론트: "API 왜 안 돼요?" 🔥)

빌...]]></description><link>https://blog.finders.it.kr/cicd</link><guid isPermaLink="true">https://blog.finders.it.kr/cicd</guid><dc:creator><![CDATA[IISweetHeartII]]></dc:creator><pubDate>Wed, 11 Feb 2026 08:02:39 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>PR 하나면 끝나는 무중단 배포까지, 삽질의 기록</p>
</blockquote>
<hr />
<h2 id="heading-1">1. 시작은 단순했다</h2>
<blockquote>
<p>"서버에 올려야 하는데... 어떻게 하지?"</p>
</blockquote>
<p>Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다.</p>
<pre><code class="lang-plaintext">1. SSH로 서버 접속
2. git pull
3. ./gradlew build
4. java -jar app.jar
</code></pre>
<p>당연히 문제가 생겼다.</p>
<ul>
<li><p>빌드하는 동안 <strong>서버가 꺼져있음</strong> (프론트: "API 왜 안 돼요?" 🔥)</p>
</li>
<li><p>빌드가 실패하면 <strong>이전 버전도 날아감</strong></p>
</li>
<li><p>누가 마지막에 배포했는지 <strong>아무도 모름</strong></p>
</li>
</ul>
<p>그래서 GitHub Actions로 자동화를 시작했다. (Issue <a target="_blank" href="https://github.com/Finders-Official/BE/issues/3">#3</a>)</p>
<hr />
<h2 id="heading-2-1-ci-cd">2. 1단계: CI + CD 기본기 만들기</h2>
<h3 id="heading-ci">CI — "최소한 빌드는 되는 코드만 머지하자"</h3>
<p>가장 먼저 만든 건 CI(Continuous Integration) 워크플로우였다.</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># .github/workflows/ci.yml</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">Finders</span> <span class="hljs-string">CI</span> <span class="hljs-string">(Build</span> <span class="hljs-string">&amp;</span> <span class="hljs-string">Test)</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">branches:</span> [ <span class="hljs-string">"develop"</span>, <span class="hljs-string">"main"</span> ]

<span class="hljs-attr">steps:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">Code</span>
    <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">JDK</span> <span class="hljs-number">21</span>
    <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-java@v4</span>
    <span class="hljs-attr">with:</span>
      <span class="hljs-attr">java-version:</span> <span class="hljs-string">'21'</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">Gradle</span> <span class="hljs-string">packages</span>  <span class="hljs-comment"># ⭐ 빌드 속도 최적화</span>
    <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v4</span>
    <span class="hljs-attr">with:</span>
      <span class="hljs-attr">path:</span> <span class="hljs-string">|
        ~/.gradle/caches
        ~/.gradle/wrapper
</span>      <span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-gradle-${{</span> <span class="hljs-string">hashFiles('**/*.gradle*')</span> <span class="hljs-string">}}</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Test</span> <span class="hljs-string">&amp;</span> <span class="hljs-string">Build</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">./gradlew</span> <span class="hljs-string">clean</span> <span class="hljs-string">build</span>
</code></pre>
<p><strong>핵심 포인트:</strong></p>
<ul>
<li><p>PR을 올리면 <strong>자동으로 빌드 + 테스트</strong> 실행</p>
</li>
<li><p>실패하면 <strong>머지 불가</strong> (Branch Protection Rule)</p>
</li>
<li><p><strong>Gradle 캐시</strong>로 의존성 다운로드 시간 절약 (체감 40~50% 단축)</p>
</li>
</ul>
<p><img src="https://docs.github.com/assets/cb-345/images/social-cards/actions.png" alt="GitHub Actions CI 체크" /></p>
<p><em>GitHub Actions가 PR마다 자동으로 빌드/테스트를 실행한다</em></p>
<h3 id="heading-cd">CD — "머지하면 알아서 배포되게"</h3>
<p>CI가 돌아가니, 이제 <strong>배포도 자동화</strong>하고 싶었다. 처음에는 <code>deploy.yml</code> 하나와 <a target="_blank" href="http://docker-compose.prod"><code>docker-compose.prod</code></a><code>.yml</code> 하나만 있었다.</p>
<pre><code class="lang-plaintext">main에 push → Docker 이미지 빌드 → GCE 서버에 배포
</code></pre>
<p>이때의 구조는 아주 단순했다:</p>
<pre><code class="lang-plaintext">.github/workflows/
├── ci.yml          ← PR 빌드/테스트
└── deploy.yml      ← main push 시 배포

docker-compose.prod.yml  ← 서버에서 Docker로 실행
Dockerfile               ← 멀티스테이지 빌드
</code></pre>
<pre><code class="lang-mermaid">graph LR
    A["🧑‍💻 코드 Push"] --&gt; B["⚙️ GitHub Actions"]
    B --&gt; C["🐳 Docker 이미지 빌드"]
    C --&gt; D["🚀 서버 배포"]
</code></pre>
<h3 id="heading-docker">Docker 멀티스테이지 빌드</h3>
<p>Dockerfile도 <strong>2단계</strong>로 나눠서 최적화했다:</p>
<pre><code class="lang-dockerfile"><span class="hljs-comment"># Stage 1: 빌드 (JDK 포함 — 무거움)</span>
<span class="hljs-keyword">FROM</span> eclipse-temurin:<span class="hljs-number">21</span>-jdk AS builder
<span class="hljs-keyword">COPY</span><span class="bash"> . .</span>
<span class="hljs-keyword">RUN</span><span class="bash"> ./gradlew build -x <span class="hljs-built_in">test</span> --no-daemon</span>

<span class="hljs-comment"># Stage 2: 실행 (JRE만 — 가벼움)</span>
<span class="hljs-keyword">FROM</span> eclipse-temurin:<span class="hljs-number">21</span>-jre
<span class="hljs-keyword">COPY</span><span class="bash"> --from=builder /app/build/libs/*.jar app.jar</span>
<span class="hljs-keyword">ENTRYPOINT</span><span class="bash"> [<span class="hljs-string">"java"</span>, <span class="hljs-string">"-jar"</span>, <span class="hljs-string">"app.jar"</span>]</span>
</code></pre>
<p><strong>왜 이렇게 할까?</strong></p>
<ul>
<li><p>빌드에 필요한 JDK, Gradle, 소스코드는 <strong>런타임에 필요 없음</strong></p>
</li>
<li><p>최종 이미지 크기가 확 줄어듦 → 배포 속도 향상</p>
</li>
</ul>
<hr />
<h2 id="heading-3-2-prod-dev">3. 2단계: "prod랑 dev를 분리해야겠다"</h2>
<p>처음엔 main 브랜치 하나에 prod 환경만 있었다. 그런데 프론트엔드 팀과 <strong>API 연동</strong>을 시작하면서 문제가 터졌다.</p>
<blockquote>
<p>프론트엔드: "이 API 아직도 안 돼요...?"<br />백엔드: "아직요... main에 머지가 안 됐어서..."<br />프론트엔드: "그게 언제 될까요?"<br />백엔드: "승인 받아야 하는데..." 😰</p>
</blockquote>
<p>main 브랜치에 머지하려면 팀원 <strong>최소 1명의 approve</strong>가 필요했다. 코드 리뷰는 꼭 필요한 과정이지만, API 연동 초기에는 매번 작은 수정마다 main PR → 승인 대기 → 머지 → 배포 사이클을 돌려야 했고, 프론트엔드 팀은 계속 기다려야 했다.</p>
<p>이게 반복되면서 결국 깨달았다: <strong>개발용 서버가 따로 있어야 한다.</strong></p>
<ul>
<li><p><code>develop</code> 브랜치에 머지하면 → <strong>dev 서버에 바로 배포</strong> (연동 테스트용)</p>
</li>
<li><p><code>main</code> 브랜치에 머지하면 → <strong>prod 서버에 배포</strong> (코드 리뷰 후)</p>
</li>
</ul>
<p>이렇게 하면 프론트엔드 팀은 dev 서버에서 자유롭게 연동 테스트를 하고, prod에는 검증된 코드만 올라가게 된다.</p>
<h3 id="heading-applicationyml">application.yml 분리</h3>
<pre><code class="lang-plaintext">src/main/resources/
├── application.yml        ← 공통 설정
├── application-local.yml  ← 로컬 개발 (Docker MySQL)
├── application-dev.yml    ← 개발 서버 (GCE)
└── application-prod.yml   ← 운영 서버 (GCE)
</code></pre>
<p><strong>환경별로 뭐가 다를까?</strong></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>설정</td><td>Dev</td><td>Prod</td></tr>
</thead>
<tbody>
<tr>
<td>DB DDL</td><td><code>update</code> (스키마 자동 수정)</td><td><code>validate</code> (검증만)</td></tr>
<tr>
<td>로그 레벨</td><td><code>DEBUG</code></td><td><code>WARN</code></td></tr>
<tr>
<td>JWT 만료</td><td>24시간 (테스트 편의)</td><td>30분 (보안)</td></tr>
<tr>
<td>Swagger</td><td>활성화</td><td><strong>비활성화</strong></td></tr>
<tr>
<td>Sentry 샘플링</td><td>100%</td><td>30%</td></tr>
</tbody>
</table>
</div><h3 id="heading-docker-compose-amp-cd">Docker Compose &amp; CD 워크플로우 분리</h3>
<pre><code class="lang-plaintext">.github/workflows/
├── ci.yml
├── deploy.yml      ← main → prod 배포
└── deploy-dev.yml  ← develop → dev 배포  🆕

docker-compose.infra.yml  ← 공통 인프라 (Traefik, Cloudflared)
docker-compose.prod.yml   ← prod 앱 설정
docker-compose.dev.yml    ← dev 앱 설정  🆕
</code></pre>
<p>이제 <strong>develop 브랜치에 머지하면 dev 서버</strong>, <strong>main 브랜치에 머지하면 prod 서버</strong>에 자동 배포된다!</p>
<pre><code class="lang-mermaid">graph TB
    F["feature/*"] --&gt;|"PR + 코드리뷰"| D["develop"]
    D --&gt;|"자동 배포"| DEV["🖥️ Dev 서버"]
    D --&gt;|"PR + approve"| M["main"]
    M --&gt;|"자동 배포"| PROD["🖥️ Prod 서버"]
</code></pre>
<hr />
<h2 id="heading-4-3">4. 3단계: "배포할 때 서버가 죽어요" → 무중단 배포</h2>
<p>환경 분리까지는 좋았다. 그런데 배포할 때마다 <strong>서버가 죽는 문제</strong>가 있었다.</p>
<p>배포 과정이 이랬다:</p>
<pre><code class="lang-plaintext">1. docker compose down  ← 여기서 서버 사망 💀
2. 새 이미지 Pull
3. docker compose up
4. Spring Boot 기동 대기...
</code></pre>
<p><code>docker compose down</code>을 하는 순간부터 서버가 내려간다. 새 컨테이너가 뜨고 Spring Boot가 완전히 기동될 때까지 <strong>짧으면 3분, 길면 5분</strong>. 그 사이에 들어오는 모든 요청은 <strong>503 에러</strong>.</p>
<p>팀원들이 API를 테스트하다가 갑자기 503이 뜨기 시작하면...</p>
<blockquote>
<p>"서버 왜 안 돼요?"<br />"아 배포 중이에요... 5분만 기다려주세요..."<br />"..." 😐</p>
</blockquote>
<p>특히 데모 준비할 때 배포하면 대참사였다. 🤦</p>
<h3 id="heading-nginx-vs-traefik">Nginx vs Traefik, 뭘 쓸까?</h3>
<p>무중단 배포를 위해 <strong>리버스 프록시</strong>가 필요했다. 두 가지를 비교했다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td></td><td>Nginx</td><td>Traefik</td></tr>
</thead>
<tbody>
<tr>
<td>설정 방식</td><td>정적 설정 파일 (nginx.conf)</td><td><strong>Docker 라벨로 자동 감지</strong> ⭐</td></tr>
<tr>
<td>새 서비스 추가 시</td><td>설정 파일 수정 + reload 필요</td><td><strong>라벨만 붙이면 자동 인식</strong></td></tr>
<tr>
<td>학습 곡선</td><td>자료 많음</td><td>상대적으로 적음</td></tr>
<tr>
<td>Blue-Green 적합도</td><td>스크립트로 직접 구현 필요</td><td>프로필 전환만으로 가능</td></tr>
</tbody>
</table>
</div><p><strong>Traefik을 선택한 이유:</strong> Docker Compose의 <code>labels</code>만으로 라우팅이 설정되니까, Blue-Green 배포가 훨씬 간단했다!</p>
<h3 id="heading-blue-green">Blue-Green 배포란?</h3>
<p><img src="https://martinfowler.com/bliki/images/blueGreenDeployment/blue_green_deployments.png" alt="Blue-Green 배포 개념도" /></p>
<p><em>출처:</em> <a target="_blank" href="https://martinfowler.com/bliki/BlueGreenDeployment.html"><em>BlueGreenDeployment</em></a> <em>— Martin Fowler</em></p>
<p>서버를 <strong>2개 슬롯</strong>(Blue, Green)으로 운영하는 방식이다:</p>
<pre><code class="lang-plaintext">[사용자] → [Traefik] → [🔵 Blue 슬롯] ← 현재 트래픽 처리 중
                        [🟢 Green 슬롯] ← 대기 중 (비어있음)
</code></pre>
<p>배포할 때:</p>
<ol>
<li><p>Green에 새 버전 올리기 (같은 Traefik 라벨 등록)</p>
</li>
<li><p>Green 헬스체크 통과 확인</p>
</li>
<li><p>Traefik이 새로 뜬 Green 컨테이너를 <strong>자동 감지</strong>해서 라우팅</p>
</li>
<li><p>Blue 컨테이너 제거 → Green만 남음</p>
</li>
</ol>
<p><strong>핵심: 항상 1대만 트래픽을 받되, 전환 사이에 다운타임이 0!</strong></p>
<blockquote>
<p>참고: 로드밸런서처럼 2대가 동시에 트래픽을 나누는 게 아니다. Traefik은 <strong>리버스 프록시</strong>로, Docker 컨테이너가 뜨고 내려가는 것을 감지해서 라우팅 대상을 자동 전환해주는 역할이다.</p>
</blockquote>
<h3 id="heading-docker-compose-blue-green">Docker Compose로 구현한 Blue-Green</h3>
<pre><code class="lang-yaml"><span class="hljs-comment"># docker-compose.prod.yml (핵심만)</span>
<span class="hljs-attr">services:</span>
  <span class="hljs-attr">prod-blue:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">${DOCKER_IMAGE}:latest</span>
    <span class="hljs-attr">labels:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"traefik.enable=true"</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"traefik.http.routers.prod.rule=Host(`api.finders.it.kr`)"</span>
    <span class="hljs-attr">profiles:</span> [<span class="hljs-string">blue</span>]  <span class="hljs-comment"># ← 프로필로 선택적 실행</span>

  <span class="hljs-attr">prod-green:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">${DOCKER_IMAGE}:latest</span>
    <span class="hljs-attr">labels:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"traefik.enable=true"</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"traefik.http.routers.prod.rule=Host(`api.finders.it.kr`)"</span>
    <span class="hljs-attr">profiles:</span> [<span class="hljs-string">green</span>]  <span class="hljs-comment"># ← 프로필로 선택적 실행</span>
</code></pre>
<p><strong>Traefik의 핵심</strong>: Docker 컨테이너에 같은 라우팅 라벨(<code>Host</code>)이 붙어있으면, Traefik이 자동으로 감지한다. Green이 올라오면 Traefik이 Green을 인식하고, Blue를 내리면 자연스럽게 Green만 남아서 라우팅된다. 별도의 설정 변경이나 reload 없이!</p>
<h3 id="heading-health-check">Health Check: "진짜 살아있어?"</h3>
<p>Green 컨테이너가 올라왔다고 바로 Blue를 내리면 안 된다. Spring Boot가 완전히 기동될 때까지 기다려야 한다.</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># docker-compose.prod.yml</span>
<span class="hljs-attr">healthcheck:</span>
  <span class="hljs-attr">test:</span> [<span class="hljs-string">"CMD"</span>, <span class="hljs-string">"wget"</span>, <span class="hljs-string">"--spider"</span>, <span class="hljs-string">"-q"</span>,
         <span class="hljs-string">"http://localhost:8080/api/actuator/health"</span>]
  <span class="hljs-attr">interval:</span> <span class="hljs-string">20s</span>
  <span class="hljs-attr">timeout:</span> <span class="hljs-string">5s</span>
  <span class="hljs-attr">retries:</span> <span class="hljs-number">5</span>
  <span class="hljs-attr">start_period:</span> <span class="hljs-string">60s</span>  <span class="hljs-comment"># 기동 대기 시간</span>
</code></pre>
<p>배포 스크립트에서 최대 <strong>180초</strong> 동안 체크하고, 실패하면 <strong>자동 롤백</strong>:</p>
<pre><code class="lang-bash"><span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> {1..36}; <span class="hljs-keyword">do</span>
  STATUS=$(docker inspect --format=<span class="hljs-string">'{{.State.Health.Status}}'</span> <span class="hljs-variable">$GREEN</span>)

  <span class="hljs-keyword">if</span> [[ <span class="hljs-string">"<span class="hljs-variable">$STATUS</span>"</span> == <span class="hljs-string">'healthy'</span> ]]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"✅ 정상! 기존 슬롯 제거"</span>
    <span class="hljs-built_in">break</span>
  <span class="hljs-keyword">fi</span>

  <span class="hljs-keyword">if</span> [[ <span class="hljs-string">"<span class="hljs-variable">$STATUS</span>"</span> == <span class="hljs-string">'unhealthy'</span> ]]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"❌ 비정상! 롤백"</span>
    docker stop <span class="hljs-variable">$GREEN</span> &amp;&amp; docker rm -f <span class="hljs-variable">$GREEN</span>
    <span class="hljs-built_in">exit</span> 1  <span class="hljs-comment"># GitHub Actions 실패 → 팀에게 알림</span>
  <span class="hljs-keyword">fi</span>

  sleep 5
<span class="hljs-keyword">done</span>
</code></pre>
<hr />
<h2 id="heading-5-gcp">5. 보너스: GCP 인증은 비밀번호 없이</h2>
<p>GitHub Actions에서 GCP에 접근하려면 인증이 필요하다. 보통은 <strong>서비스 계정 키</strong>(JSON 파일)를 쓰는데, 이건 유출 위험이 있다.</p>
<p>우리는 <strong>Workload Identity Federation(WIF)</strong>을 사용했다:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># 키 파일 없이 인증!</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">google-github-actions/auth@v2</span>
  <span class="hljs-attr">with:</span>
    <span class="hljs-attr">workload_identity_provider:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.WIF_PROVIDER</span> <span class="hljs-string">}}</span>
    <span class="hljs-attr">service_account:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.WIF_SERVICE_ACCOUNT</span> <span class="hljs-string">}}</span>
</code></pre>
<pre><code class="lang-mermaid">graph LR
    GH["GitHub Actions"] --&gt;|"OIDC 토큰 발급"| STS["GCP STS"]
    STS --&gt;|"임시 토큰 교환"| SA["Service Account"]
    SA --&gt;|"GCP 리소스 접근"| GCE["GCE / Artifact Registry"]
</code></pre>
<p><em>키 파일 없이 GitHub → GCP 인증이 가능한 원리 (</em><a target="_blank" href="https://cloud.google.com/iam/docs/workload-identity-federation"><em>GCP 공식 문서</em></a><em>)</em></p>
<p><strong>원리 (한 줄 요약):</strong></p>
<blockquote>
<p>GitHub Actions가 "나 이 리포에서 온 워크플로우야"라고 증명하면, GCP가 임시 권한을 줌. 키 파일 불필요!</p>
</blockquote>
<hr />
<h2 id="heading-6">6. 최종 모습: 전체 파이프라인</h2>
<pre><code class="lang-mermaid">graph LR
    A["🧑‍💻 코드 작성"] --&gt; B["📋 PR 생성"]
    B --&gt; C["✅ CI: 빌드+테스트"]
    C --&gt; D{"코드 리뷰 &amp; 승인"}
    D --&gt;|develop merge| E["🚀 Dev Blue-Green 배포"]
    D --&gt;|main merge| F["🚀 Prod Blue-Green 배포"]
    F --&gt; G["🏷️ 자동 릴리즈"]
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>워크플로우</td><td>파일</td><td>트리거</td><td>역할</td></tr>
</thead>
<tbody>
<tr>
<td>CI</td><td><code>ci.yml</code></td><td>PR → develop/main</td><td>빌드 + 테스트</td></tr>
<tr>
<td>CD (Dev)</td><td><code>deploy-dev.yml</code></td><td>develop push</td><td>Dev Blue-Green 배포</td></tr>
<tr>
<td>CD (Prod)</td><td><code>deploy.yml</code></td><td>main push</td><td>Prod Blue-Green 배포</td></tr>
<tr>
<td>IaC</td><td><code>terraform.yml</code></td><td>infra/ 변경</td><td>Terraform Plan/Apply</td></tr>
<tr>
<td>Release</td><td><code>auto-release.yml</code></td><td>main push</td><td>자동 버전 태깅</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-7">7. 마치며: 삽질의 연대기</h2>
<pre><code class="lang-mermaid">graph LR
    S0["🔧 수동 배포"] --&gt; S1["⚡ CI + CD"]
    S1 --&gt; S2["🔀 환경 분리"]
    S2 --&gt; S3["🔄 Blue-Green"]
</code></pre>
<p>돌아보면 이런 순서로 진화했다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>단계</td><td>뭘 했나</td><td>왜 했나</td></tr>
</thead>
<tbody>
<tr>
<td><strong>0단계</strong></td><td>SSH + 수동 빌드</td><td>"일단 돌아가게만..."</td></tr>
<tr>
<td><strong>1단계</strong></td><td>CI + CD (GitHub Actions)</td><td>"수동 배포 지겨워..."</td></tr>
<tr>
<td><strong>2단계</strong></td><td>prod/dev 환경 분리</td><td>"프론트가 테스트할 서버가 필요해..."</td></tr>
<tr>
<td><strong>3단계</strong></td><td>Traefik + Blue-Green</td><td>"배포할 때 서버 죽는 거 못 참겠어..."</td></tr>
</tbody>
</table>
</div><p>매번 "이 정도면 됐지" 싶었는데, 실제로 운영하다 보면 <strong>다음 문제가 찾아왔다</strong>. 결국 CI/CD는 한 번에 완성되는 게 아니라, <strong>필요에 따라 점진적으로 발전</strong>시키는 거라는 걸 배웠다.</p>
<p>지금은 개발자가 <strong>PR만 올리면</strong> 나머지는 전부 자동이다. "서버 배포해주세요"라는 말이 사라졌다. 😄</p>
<hr />
<h3 id="heading-8jtjidssljqs6ag7j207iqi">📎 참고 이슈</h3>
<ul>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/3">#3 CI/CD 파이프라인 구축</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/101">#101 CI/CD 무중단 배포 구현</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/Finders-Official/BE/issues/194">#194 Cloudflare Tunnel 도입 및 WIF 기반 CI/CD 고도화</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[비관적 락과 낙관적 락, 그리고

예약 시스템에서의 동시성 문제 해결 전략]]></title><description><![CDATA[ReservationSlot 단위 비관적 락을 통한 정원 초과 및 중복 생성 방지


1. 예약 시스템에서 실제로 어떤 동시성 문제가 발생했는가?
이 예약 시스템은 다음 조건을 가진다.

같은 사진관

같은 날짜

같은 시간대


에 대해 여러 사용자가 동시에 예약 요청을 보낼 수 있다.
이때 락이 없다면 다음 문제가 발생할 수 있다.
1️⃣ 정원 초과 문제
정원 5명
A 요청: 현재 예약 4명 → OK
B 요청: 현재 예약 4명 → OK
→ ...]]></description><link>https://blog.finders.it.kr/67me6rsa7kcbioudveqzvcdrgpnqtidsoieg6529lcdqt7jrpqzqs6akcuyyioyvvsdsi5zsiqtthzzsl5dshjzsnzgg64z7iuc7isxiousuoygncdtlbtqsrag7kce6561</link><guid isPermaLink="true">https://blog.finders.it.kr/67me6rsa7kcbioudveqzvcdrgpnqtidsoieg6529lcdqt7jrpqzqs6akcuyyioyvvsdsi5zsiqtthzzsl5dshjzsnzgg64z7iuc7isxiousuoygncdtlbtqsrag7kce6561</guid><category><![CDATA[동시성문제]]></category><category><![CDATA[비관적 락]]></category><dc:creator><![CDATA[이승주]]></dc:creator><pubDate>Tue, 10 Feb 2026 07:09:24 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>ReservationSlot 단위 비관적 락을 통한 정원 초과 및 중복 생성 방지</p>
</blockquote>
<hr />
<h2 id="heading-1">1. 예약 시스템에서 실제로 어떤 동시성 문제가 발생했는가?</h2>
<p>이 예약 시스템은 다음 조건을 가진다.</p>
<ul>
<li><p>같은 사진관</p>
</li>
<li><p>같은 날짜</p>
</li>
<li><p>같은 시간대</p>
</li>
</ul>
<p>에 대해 <strong>여러 사용자가 동시에 예약 요청</strong>을 보낼 수 있다.</p>
<p>이때 락이 없다면 다음 문제가 발생할 수 있다.</p>
<h3 id="heading-1-1">1️⃣ 정원 초과 문제</h3>
<pre><code class="lang-plaintext">정원 5명
A 요청: 현재 예약 4명 → OK
B 요청: 현재 예약 4명 → OK
→ 결과: 예약 6명 (초과)
</code></pre>
<h3 id="heading-2">2️⃣ 슬롯 중복 생성 문제</h3>
<p>슬롯이 아직 생성되지 않은 상태에서:</p>
<pre><code class="lang-plaintext">A 요청: 슬롯 없음 → 생성
B 요청: 슬롯 없음 → 생성
→ 동일한 (photoLab, date, time) 슬롯 2개 생성 시도
</code></pre>
<h3 id="heading-3">3️⃣ 취소 시 카운트 불일치</h3>
<p>동시에 취소 요청이 들어오면:</p>
<ul>
<li><p>reservedCount가 중복 감소</p>
</li>
<li><p>음수로 떨어질 가능성</p>
</li>
</ul>
<p>👉 <strong>결론</strong><br />이 시스템의 동시성 문제는</p>
<blockquote>
<p>“같은 시간 슬롯을 기준으로 한 경쟁 조건”<br />에서 발생한다.</p>
</blockquote>
<hr />
<h2 id="heading-2-1">2. 왜 이 문제는 애플리케이션 락이나 낙관적 락으로 해결하기 어려웠는가?</h2>
<h3 id="heading-4p2mioyvoo2ujoumroy8goydtoyfmcdrnb3snzgg7zwc6roe">❌ 애플리케이션 락의 한계</h3>
<ul>
<li><p>서버가 여러 대면 보장되지 않음</p>
</li>
<li><p>트랜잭션과 결합이 어려움</p>
</li>
</ul>
<h3 id="heading-4p2mioucmeq0goyggsdrnb3snzgg7zwc6roe">❌ 낙관적 락의 한계</h3>
<ul>
<li><p>충돌 시 예외 발생</p>
</li>
<li><p>사용자에게 재시도 요구</p>
</li>
<li><p>예약 도메인 특성상 UX가 나빠짐</p>
</li>
</ul>
<p>예약은:</p>
<ul>
<li><p><strong>정확성 &gt; 성능</strong></p>
</li>
<li><p>“다시 시도하세요”가 허용되기 어려움</p>
<p>  → 그래서 <strong>비관적 락</strong>을 선택했다.</p>
</li>
</ul>
<hr />
<h2 id="heading-3-reservationslot">3. 해결 전략: ReservationSlot 단위 비관적 락</h2>
<p>이 시스템에서 핵심 리소스는 <code>ReservationSlot</code>이다.</p>
<blockquote>
<p>같은 슬롯에 대한 요청은<br /><strong>항상 직렬로 처리되어야 한다</strong></p>
</blockquote>
<p>그래서 전략은 다음과 같다.</p>
<ul>
<li><p>슬롯 row를 <code>SELECT … FOR UPDATE</code>로 잠근다</p>
</li>
<li><p>락을 잡은 상태에서만 정원 증가/감소를 수행한다</p>
</li>
<li><p>슬롯이 없을 경우에도 <strong>중복 생성이 발생하지 않도록</strong> 처리한다</p>
</li>
</ul>
<hr />
<h2 id="heading-4">4. 예약 생성 시 동시성 해결 흐름</h2>
<h3 id="heading-4-1">4-1. 슬롯 조회 + 락 획득</h3>
<pre><code class="lang-plaintext">reservationSlotRepository
    .findByPhotoLabIdAndReservationDateAndReservationTimeForUpdate(
        photoLab.getId(), date, time
    )
</code></pre>
<p>이 쿼리는 내부적으로 다음과 같다.</p>
<pre><code class="lang-plaintext">SELECT *
FROM reservation_slot
WHERE photo_lab_id = ?
  AND reservation_date = ?
  AND reservation_time = ?
FOR UPDATE;
</code></pre>
<p>👉 이 순간, <strong>같은 슬롯에 대한 다른 트랜잭션은 대기 상태</strong>가 된다.</p>
<hr />
<h3 id="heading-4-2">4-2. 슬롯이 없는 경우: 중복 생성 방지</h3>
<p>동시 요청이 들어오면 슬롯 생성도 경쟁 상태가 된다.<br />이를 위해 DB에 다음 전제를 둔다.</p>
<ul>
<li><code>(photo_lab_id, reservation_date, reservation_time)</code> 유니크 제약</li>
</ul>
<p>그리고 코드에서는:</p>
<pre><code class="lang-plaintext">try {
    reservationSlotRepository.save(slot);
} catch (DataIntegrityViolationException ignored) {
    // 다른 트랜잭션이 먼저 생성한 경우
}
</code></pre>
<p>이후 <strong>반드시 다시 FOR UPDATE로 조회</strong>한다.</p>
<pre><code class="lang-plaintext">return reservationSlotRepository
    .findByPhotoLabIdAndReservationDateAndReservationTimeForUpdate(...)
</code></pre>
<p>👉 결과적으로:</p>
<ul>
<li><p>슬롯은 하나만 생성되고</p>
</li>
<li><p>모든 요청은 동일 슬롯 row에 대해 직렬화된다</p>
</li>
</ul>
<hr />
<h3 id="heading-4-3">4-3. 정원 초과 방지</h3>
<pre><code class="lang-plaintext">slot.increaseReservedCountOrThrow();
</code></pre>
<p>이 로직은:</p>
<ul>
<li><p>슬롯 row가 락 잡힌 상태에서 실행됨</p>
</li>
<li><p>동시에 두 요청이 “자리 있음”이라고 판단할 수 없음</p>
</li>
</ul>
<p>따라서 <strong>정원 초과는 구조적으로 발생하지 않는다</strong>.</p>
<hr />
<h2 id="heading-5">5. 예약 취소 시 동시성 해결 흐름</h2>
<p>취소도 동일한 문제가 발생할 수 있기 때문에 락을 사용한다.</p>
<h3 id="heading-5-1">5-1. 예약 자체를 잠금</h3>
<pre><code class="lang-plaintext">reservationRepository
    .findByIdAndPhotoLabIdAndUserIdForUpdate(...)
</code></pre>
<ul>
<li><p>중복 취소 방지</p>
</li>
<li><p>상태 변경 경쟁 방지</p>
</li>
</ul>
<h3 id="heading-5-2">5-2. 슬롯 잠금 후 카운트 감소</h3>
<pre><code class="lang-plaintext">ReservationSlot lockedSlot =
    reservationSlotRepository.findByIdForUpdate(slotId);

lockedSlot.decreaseReservedCountSafely();
</code></pre>
<p>👉 취소 역시 <strong>슬롯 기준으로 직렬 처리</strong>된다.</p>
<hr />
<h2 id="heading-6">6. 이 구조가 안전한 이유 요약</h2>
<ul>
<li><p>슬롯 단위로 경쟁 범위를 최소화</p>
</li>
<li><p>DB 트랜잭션 + 비관적 락으로 서버 수와 무관하게 일관성 보장</p>
</li>
<li><p>재시도 로직 없이도 정합성 유지</p>
</li>
<li><p>예약/취소 흐름 모두 동일한 락 기준 사용</p>
</li>
</ul>
<hr />
<h2 id="heading-7">7. 정리</h2>
<p>이 예약 시스템의 동시성 문제는<br />“같은 시간 슬롯을 동시에 수정하는 경쟁 조건”에서 발생했다.</p>
<p>이를 해결하기 위해:</p>
<ul>
<li><p><code>ReservationSlot</code>을 동시성의 기준 단위로 삼고</p>
</li>
<li><p>비관적 락을 통해 요청을 직렬화했다</p>
</li>
</ul>
<p>그 결과:</p>
<ul>
<li><p>정원 초과</p>
</li>
<li><p>슬롯 중복 생성</p>
</li>
<li><p>취소 시 카운트 불일치</p>
</li>
</ul>
<p>를 모두 구조적으로 방지할 수 있었다.</p>
<hr />
<h3 id="heading-7zwcioykhcdqsrdroaa">한 줄 결론</h3>
<blockquote>
<p>이 예약 시스템에서는 성능보다 정합성이 중요했기 때문에, 슬롯 단위 비관적 락을 선택해 동시성 문제를 해결했다.</p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[단위 테스트 코드 작성 방법]]></title><description><![CDATA[💡
이 글은 다음 블로그를 참고하여 작성된 글입니다. https://mangkyu.tistory.com/143 https://tech.kakaopay.com/post/given-test-code-2/


단위 테스트란?
단위 테스트(Unit Test)는하나의 모듈을 기준으로 독립적으로 수행되는 가장 작은 단위의 테스트이다.
여기서 말하는 모듈이란,애플리케이션에서 동작하는 하나의 기능 또는 하나의 메서드를 의미한다.

단위 테스트의 어려움
일반...]]></description><link>https://blog.finders.it.kr/64uo7jyeio2fjoykpo2kucdsvztrk5wg7j6r7isxiouwqeuylq</link><guid isPermaLink="true">https://blog.finders.it.kr/64uo7jyeio2fjoykpo2kucdsvztrk5wg7j6r7isxiouwqeuylq</guid><category><![CDATA[testcode]]></category><dc:creator><![CDATA[이승주]]></dc:creator><pubDate>Mon, 09 Feb 2026 09:30:21 GMT</pubDate><content:encoded><![CDATA[<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">이 글은 다음 블로그를 참고하여 작성된 글입니다. <a target="_self" href="https://mangkyu.tistory.com/143">https://mangkyu.tistory.com/143</a> <a target="_self" href="https://tech.kakaopay.com/post/given-test-code-2/">https://tech.kakaopay.com/post/given-test-code-2/</a></div>
</div>

<h2 id="heading-64uo7jyeio2fjoykpo2kuouegd8">단위 테스트란?</h2>
<p>단위 테스트(Unit Test)는<br /><strong><mark>하나의 모듈을 기준으로 독립적으로 수행되는 가장 작은 단위의 테스트</mark></strong>이다.</p>
<p>여기서 말하는 <em>모듈</em>이란,<br />애플리케이션에서 동작하는 <strong><mark>하나의 기능 또는 하나의 메서드</mark></strong>를 의미한다.</p>
<hr />
<h2 id="heading-64uo7jyeio2fjoykpo2kuoydmcdslrtroktsm4a">단위 테스트의 어려움</h2>
<p>일반적인 애플리케이션에서는<br />하나의 기능을 처리하기 위해 여러 객체와 메시지를 주고받는다.</p>
<p>하지만 단위 테스트는 특정 모듈을 <strong>독립적으로 검증</strong>하는 것이 목적이기 때문에,<br />다른 객체와의 협력이 포함되면 테스트가 어려워진다.</p>
<p>이 문제를 해결하기 위해,<br />단위 테스트에서는 실제 객체 대신 <strong><mark>가짜 객체(Mock Object)</mark></strong>를 주입하고<br />“어떤 상황에서 어떤 결과를 반환할지”를 미리 정의한다.</p>
<p>이렇게 미리 정해진 응답을 준비하는 과정을 <strong>Stub</strong>이라고 한다.</p>
<hr />
<h2 id="heading-7kkl7j2aioulqoychcdthyzsiqttirjsnzgg7yq57kev">좋은 단위 테스트의 특징</h2>
<p>좋은 단위 테스트는 다음 원칙을 따른다.</p>
<ul>
<li><p>하나의 테스트 함수는 <strong>하나의 개념만 검증한다</strong></p>
</li>
<li><p>테스트 함수 내에서 <strong>assert는 최소화한다</strong></p>
</li>
</ul>
<p>또한, 좋은 테스트 코드는 흔히 <strong>FIRST 원칙</strong>을 만족해야 한다.</p>
<ul>
<li><p><strong>Fast</strong><br />  테스트는 빠르게 실행되어 자주 돌릴 수 있어야 한다.</p>
</li>
<li><p><strong>Independent</strong><br />  각 테스트는 서로 의존하지 않고 독립적으로 실행되어야 한다.</p>
</li>
<li><p><strong>Repeatable</strong><br />  어떤 환경에서도 동일한 결과를 보장해야 한다.</p>
</li>
<li><p><strong>Self-Validating</strong><br />  성공 또는 실패가 명확해야 하며, 추가 해석이 필요 없어야 한다.</p>
</li>
<li><p><strong>Timely</strong><br />  실제 코드를 작성하기 직전 또는 동시에 작성되어야 한다.</p>
</li>
</ul>
<hr />
<h2 id="heading-given-when-then">Given / When / Then 패턴</h2>
<p>단위 테스트에서는<br /><strong>테스트의 의도를 코드만 보고도 이해할 수 있도록</strong> 만드는 것이 중요하다.</p>
<p>이를 위해 가장 널리 사용되는 구조가<br /><strong>Given / When / Then 패턴</strong>이다.</p>
<ul>
<li><p><strong>Given</strong>: 테스트를 위한 사전 조건을 준비한다.</p>
</li>
<li><p><strong>When</strong>: 테스트 대상의 동작을 실행한다.</p>
</li>
<li><p><strong>Then</strong>: 실행 결과를 검증한다.</p>
</li>
</ul>
<p>이 패턴의 목적은</p>
<blockquote>
<p><em>“이 테스트가 무엇을 검증하는지”</em><br />를 코드 구조만으로 드러내는 데 있다.</p>
</blockquote>
<hr />
<h2 id="heading-7jm467aaioydmoyhtoyeseydtcdsl4bripqg6rk97jqwoidsijzsijgg6rcd7lk0ioq4souwmcdthyzsiqttirg">외부 의존성이 없는 경우: 순수 객체 기반 테스트</h2>
<p>로또 번호 생성기 예제처럼<br />외부 의존성이 전혀 없는 경우에는 매우 단순한 테스트 구조를 유지할 수 있다.</p>
<pre><code class="lang-plaintext">// given
final LottoNumberGenerator lottoNumberGenerator = new LottoNumberGenerator();
final int price = 1000;

// when
final List&lt;Integer&gt; lotto = lottoNumberGenerator.generate(price);

// then
assertThat(lotto).hasSize(6);
</code></pre>
<p>이 경우 테스트 대상은 오직 하나의 객체이며,<br /><code>new</code> 키워드만으로 충분히 단위 테스트를 구성할 수 있다.</p>
<hr />
<h2 id="heading-7jmcioyyioyvvsdrj4trqztsnbjsl5dshjzripqg6rcz7j2aiouwqeylneydtcdslrtroktsmrjquyw">왜 예약 도메인에서는 같은 방식이 어려울까?</h2>
<p>예약 도메인의 서비스 로직은<br />로또 예제와 구조적으로 다르다.</p>
<p>하나의 기능을 수행하기 위해<br />여러 협력 객체와 메시지를 주고받기 때문이다.</p>
<pre><code class="lang-plaintext">photoLabRepository.findById(...)
memberUserRepository.findById(...)
reservationSlotRepository.findByPhotoLabIdAndReservationDateAndReservationTimeForUpdate(...)
reservationRepository.save(...)
</code></pre>
<p>이 서비스는 다음과 같은 특징을 가진다.</p>
<ul>
<li><p>Repository를 통해 DB에 접근한다.</p>
</li>
<li><p>트랜잭션과 락 조회가 포함된다.</p>
</li>
<li><p>여러 협력 객체의 상태에 따라 분기 로직이 달라진다.</p>
</li>
</ul>
<p>이런 구조에서는 단순히 객체를 <code>new</code> 해서 테스트를 실행할 수 없다.<br />테스트가 DB 상태, 트랜잭션 환경, 외부 설정에 영향을 받게 되기 때문이다.</p>
<p>즉, <strong>Mock 없이 실제 협력 객체만으로는</strong><br />순수 객체 기반 단위 테스트를 구성하기 어렵다.</p>
<hr />
<h2 id="heading-mock">그래서 단위 테스트에서는 Mock을 사용한다</h2>
<p>이러한 문제를 해결하기 위해,<br />단위 테스트에서는 실제 객체 대신 <strong>행동이 고정된 가짜 객체(Mock)</strong>를 주입한다.</p>
<p>중요한 점은,<br />Mock을 사용하더라도 <strong>테스트의 초점은 여전히 Service에 있다는 것</strong>이다.</p>
<hr />
<h2 id="heading-mock-service">Mock을 사용했지만, 테스트의 초점은 Service에 있다</h2>
<p>아래 테스트는<br /><code>ReservationCommandService</code> <strong>하나의 개념만</strong>을 검증한다.</p>
<pre><code class="lang-plaintext">@ExtendWith(MockitoExtension.class)
class ReservationCommandServiceImplTest {

    @Mock PhotoLabRepository photoLabRepository;
    @Mock MemberUserRepository memberUserRepository;
    @Mock ReservationRepository reservationRepository;
    @Mock ReservationSlotRepository reservationSlotRepository;

    @InjectMocks
    ReservationCommandServiceImpl service;
}
</code></pre>
<p>모든 Repository는 Mock으로 대체되었고,<br />각각의 반환 값은 <strong>Given 단계에서 명확하게 정의</strong>된다.</p>
<pre><code class="lang-plaintext">given(photoLabRepository.findById(labId))
        .willReturn(Optional.of(lab));

given(memberUserRepository.findById(memberId))
        .willReturn(Optional.of(user));
</code></pre>
<p>이를 통해 이 테스트는</p>
<ul>
<li><p>DB 동작을 검증하지 않고</p>
</li>
<li><p>트랜잭션 구현을 신경 쓰지 않으며</p>
</li>
<li><p>오직<br />  **“이 서비스가 주어진 상황에서 올바른 결정을 내리는지”**만 확인한다.</p>
</li>
</ul>
<hr />
<h2 id="heading-given">Given 지옥을 피하기 위해 적용한 방법</h2>
<p>협력 객체가 많은 서비스 테스트에서는<br />Given 코드가 길어지기 쉽다.</p>
<p>이를 방치하면 테스트의 의도가 흐려진다.</p>
<h3 id="heading-1-fixture">1. Fixture로 객체 생성 책임을 분리했다</h3>
<pre><code class="lang-plaintext">var lab = ReservationDomainFixture.photoLab(labId);
var user = ReservationDomainFixture.memberUser(memberId);
var slot = ReservationDomainFixture.slot(lab, 100L, date, time);
</code></pre>
<p>테스트에서는<br /><strong>무엇을 검증하는지만 드러나야 한다.</strong></p>
<p>객체 생성 방식은 Fixture로 숨긴다.</p>
<hr />
<h3 id="heading-2">2. 결과 중심으로 검증했다</h3>
<pre><code class="lang-plaintext">assertThat(response.reservationId()).isEqualTo(777L);
</code></pre>
<p>내부 구현이 아니라<br /><strong>최종 결과와 핵심 부작용만 검증</strong>한다.</p>
<hr />
<h3 id="heading-3">3. 한 테스트는 하나의 개념만 검증했다</h3>
<pre><code class="lang-plaintext">void createReservation_정상_슬롯없으면_생성후_예약저장()
</code></pre>
<ul>
<li><p>메서드 이름 자체가 시나리오</p>
</li>
<li><p>실패 원인이 즉시 드러남</p>
</li>
<li><p>불필요한 assert 제거</p>
</li>
</ul>
<hr />
<h2 id="heading-spring-context">Spring Context를 띄우지 않은 이유</h2>
<p>이 테스트는 <code>@SpringBootTest</code>를 사용하지 않는다.<br />즉, <strong>Spring Application Context를 띄우지 않는다.</strong></p>
<p>그 결과 다음과 같은 장점이 있다.</p>
<ul>
<li><p>테스트 실행 속도가 빠르다 (<strong>Fast</strong>)</p>
</li>
<li><p>테스트 간 상태 공유가 없다 (<strong>Independent</strong>)</p>
</li>
<li><p>환경에 관계없이 반복 가능하다 (<strong>Repeatable</strong>)</p>
</li>
</ul>
<p>이는 FIRST 원칙을 충족하는<br /><strong>단위 테스트에 가까운 구조</strong>이다.</p>
<hr />
<h2 id="heading-7kcv66as">정리</h2>
<ul>
<li><p>단위 테스트는 “Mock이 없는 테스트”가 아니다.</p>
</li>
<li><p>단위 테스트의 핵심은<br />  <strong>하나의 개념을 외부 영향 없이 검증하는 것</strong>이다.</p>
</li>
<li><p>외부 환경에 의해 결과가 달라질 수 있는 의존성만 <mark>Mock으로 대체</mark>한다.</p>
</li>
<li><p>Given / When / Then 구조를 통해 테스트의 의도를 드러낸다.</p>
</li>
<li><p>Fixture와 명확한 네이밍으로 Given 지옥을 줄인다.</p>
</li>
</ul>
<p>이러한 기준을 지키면,<br />협력 객체가 많은 서비스 로직에서도<br /><strong>읽기 쉽고 신뢰할 수 있는 단위 테스트</strong>를 작성할 수 있다.</p>
]]></content:encoded></item><item><title><![CDATA[[Redis] Redis 캐시 방어 로직 추가 및 Upstash 연결하기]]></title><description><![CDATA[드디어 Redis가 죽으면 어떻게 할지 방어 로직을 추가했다.

1. 조회 캐시 vs 인증/토큰
먼저, Redis가 다운되는 것뿐만 아니라 지연되는 것도 생각해야 된다. 그래서 redis가 다운/지연된다면 DB로 fallback 하는 로직을 추가하게 되었다. 하지만 지금 redis를 사용하는 부분은 인기글 조회, 인기 현상소 조회와 인증/토큰 부분이었다.
Redis -> 실패/타임아웃 -> DB 조회

조회 캐시는 이런 흐름으로 방어 로직을 넣...]]></description><link>https://blog.finders.it.kr/redis-redis-upstash</link><guid isPermaLink="true">https://blog.finders.it.kr/redis-redis-upstash</guid><dc:creator><![CDATA[이지영]]></dc:creator><pubDate>Mon, 09 Feb 2026 06:17:44 GMT</pubDate><content:encoded><![CDATA[<p>드디어 Redis가 죽으면 어떻게 할지 방어 로직을 추가했다.</p>
<hr />
<h4 id="heading-1-vs">1. 조회 캐시 vs 인증/토큰</h4>
<p>먼저, Redis가 다운되는 것뿐만 아니라 지연되는 것도 생각해야 된다. 그래서 redis가 다운/지연된다면 DB로 fallback 하는 로직을 추가하게 되었다. 하지만 지금 redis를 사용하는 부분은 인기글 조회, 인기 현상소 조회와 인증/토큰 부분이었다.</p>
<pre><code class="lang-plaintext">Redis -&gt; 실패/타임아웃 -&gt; DB 조회
</code></pre>
<p>조회 캐시는 이런 흐름으로 방어 로직을 넣었다.</p>
<p>하지만 인증/토큰 부분은 얘기가 다르다. Redis가 안 된다고 DB로 넘겨 버리면 인증 번호 재사용 위험, 만료 처리 꼬임 등 여러 문제가 생긴다고 생각되었다. 그래서 인증 실패 응답을 띄우는 게 더 나을 것 같다고 생각했다. 그래서 조회 캐시 부분만 방어 로직을 적용했다.</p>
<hr />
<h4 id="heading-2-upstash">2. Upstash 연결</h4>
<p>기존에는 Redis를 도커에 붙여서 사용했는데 Upstash로 연결하면 관리하기 더 편하다는 얘기를 들었다. 그래서 무료 플랜이 있길래 사용해 보기로 했다.</p>
<p><img src="https://blog.kakaocdn.net/dna/tV4oF/dJMcacvgBdX/AAAAAAAAAAAAAAAAAAAAAKiE2qp16gS-RewEv0vV4ZY9VL1kEtuTFPW9B6WWQOJG/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=GwSe%2BLejtZnb%2Bt9NFj9C%2F26Jl9c%3D" alt /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770617368975/88656b76-5bc3-428b-b5fc-5c4520183866.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770617347508/c0b79187-775f-4c95-a32e-81db06c34624.png" alt class="image--center mx-auto" /></p>
<p>사진처럼 설정해서 사용했다. 지역에는 한국이 없어서 일본으로 설정하고 넘어갔다. 무료 플랜으로 설정하면 카드 입력도 안 해도 돼서 혹시 모를 불안감이 안 생기고 좋은 것 같다.</p>
<p>첫 번째 사진에 있는 Read Regions는 유료 플랜만 가능한 기능이다. 읽기 전용 복제본을 다른 지역에 두는 기능으로 쓰기(write)를 한국에서 하면, 읽기(read)를 미국으로 설정할 수 있다. 그러면 전 세계 유저가 빠르게 조회 가능하다고 한다. 하지만 쓰기 성능에는 영향이 없고 읽기 트래픽이 많을 때 의미가 있다고 한다.</p>
<p>그리고 Eviction은 저장 공간이 꽉 찼을 때, 기존 데이터를 자동으로 지울지 말지를 정하는 옵션이다. 우리 프로젝트는 인증/토큰뿐만 아니라 조회 캐시에서도 Redis를 사용하고 있어, 초기에는 예상치 못한 에러를 피하기 위해 일단 off로 설정하고 시작했다.</p>
<p><img src="https://blog.kakaocdn.net/dna/bMkkrE/dJMcaa5gjJA/AAAAAAAAAAAAAAAAAAAAAFFLP6tVcXpZvM4dQbPKn6Ig2wfe2qlBOuKJVBeDdnj1/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=jhizwZU21BWFJdO4v7SUcz1zUr8%3D" alt /></p>
<p><img src="https://blog.kakaocdn.net/dna/bPN5Yo/dJMcahXCIUc/AAAAAAAAAAAAAAAAAAAAANYvNiNqqQXQUw-Y4RJRz1TsuWHP5P69Yzxg_EQ7g56D/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=fhLjTmIIuytPcPM%2BAE3LSkx7BtU%3D" alt /></p>
<p>Upstash를 연결하면 사진처럼 commands가 늘어나는 걸 볼 수 있다. 그리고 사이트 내 CLI를 이용해서 캐시 초기화도 한 번에 가능하다. 원래라면 도커로 들어가고, 날리고, 다시 나오고... 번거로움이 있었지만 편하게 날릴 수 있다.</p>
<p>Upstash를 연결하고 connect-timeout과 timeout을 각각 1s, 2s로 수정했다. 원래는 10ms, 20ms였는데 그러면 네트워크가 왕복하기에 너무 촉박하기 때문이다. Upstash는 인터넷 너머에 있는 Redis이기 때문에 원래 설정했던 값들이랑은 문제가 있었다. 그리고 docker-compose-dev에서도 Redis 관련 설정을 제거했다. 다른 docker 파일은 추후 인증/토큰 부분도 방어 로직이 추가된다면 제거할 예정이다.</p>
<hr />
<p><strong><em>오늘의 요약</em></strong></p>
<ul>
<li><p>Redis는 타임아웃 설정이 핵심이다.</p>
</li>
<li><p>조회 캐시와 인증/토큰은 성격이 다르다.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[Redis] Redis 캐시 설정 수정 - DefaultTyping.EVERYTHING 삭제]]></title><description><![CDATA[지난번에 설정한 DefaultTyping.EVERYTHING에서 보안 문제가 생길 수 있다는 이슈를 들었다. 그래서 이번엔 그 부분을 제거하는 방향으로 수정하게 되었다.

1. DefaultTyping.EVERYTHING의 문제점
처음 설계는 Redis용 ObjectMapper에 DefaultTyping.EVERYTHING를 써서 모든 캐시 데이터에 강제로 자바 클래스 정보(@class)를 다 넣는 구조였다. 하지만 이러면 몇 가지 문제가 생기...]]></description><link>https://blog.finders.it.kr/redis-redis-defaulttypingeverything</link><guid isPermaLink="true">https://blog.finders.it.kr/redis-redis-defaulttypingeverything</guid><dc:creator><![CDATA[이지영]]></dc:creator><pubDate>Mon, 09 Feb 2026 06:06:46 GMT</pubDate><content:encoded><![CDATA[<p>지난번에 설정한 DefaultTyping.EVERYTHING에서 보안 문제가 생길 수 있다는 이슈를 들었다. 그래서 이번엔 그 부분을 제거하는 방향으로 수정하게 되었다.</p>
<hr />
<h4 id="heading-1-defaulttypingeverything">1. DefaultTyping.EVERYTHING의 문제점</h4>
<p>처음 설계는 Redis용 ObjectMapper에 DefaultTyping.EVERYTHING를 써서 모든 캐시 데이터에 강제로 자바 클래스 정보(@class)를 다 넣는 구조였다. 하지만 이러면 몇 가지 문제가 생기게 된다. 첫 번째, 내 설정 하나 때문에 다른 팀원이 만든 캐시 데이터 포맷까지 강제로 바뀔 수 있다. 두 번째, 모든 클래스를 다 받아 주는 구조라 외부에서 이상한 클래스 정보가 섞여 들어오면 역직렬화 과정에서 RCE 공격 위험이 생길 수 있다. 그래서 보안적으로도, 팀 단위 코드 기준으로도 사용하지 않는 게 좋다.</p>
<hr />
<h4 id="heading-2">2. 해결 과정</h4>
<p>수정 전 구조에서는 Redis에 Java 객체를 그대로 저장한 게 아니었다. 나는 그냥 객체로 들어갔다가 객체로 나오는 줄 알고 쓴 거고,</p>
<pre><code class="lang-plaintext">PostCacheDTO -&gt; JSON -&gt; Redis -&gt; LinkedHashMap
</code></pre>
<p>실제로는 이 흐름인 것이다.</p>
<p>그래서 생각한 해결 방법은 캐시에서 꺼낸 Map을 우리가 직접 DTO로 변환해 주자였고,</p>
<pre><code class="lang-plaintext">LinkedHashMap -&gt; PostCacheDTO
</code></pre>
<p>그 결과 이렇게 바꿔 주는 코드가 추가되었다.</p>
<p>그리고 인기글 캐시는 단순한 목록 캐시가 아니라 로그인/비로그인, 좋아요 여부, 이미지 URL 가공 로직까지 섞이기 때문에 결과를 무조건 캐시하게 되면 안 된다. 어디까지 캐시할지 결정하는 로직이 필요하다고 생각되어서</p>
<p>1. DB에서 가져온 공통 데이터만 캐시</p>
<p>2. 좋아요 여부, 이미지 URL 같은 건 요청 시점에 조립</p>
<p>이 구조를 만들기 위해 PopularPostCacheService 파일을 따로 생성하였다. 그리고 @Cacheable을 기존 Service나 Repository에 붙이지 않고 PopularPostCacheService로 분리했다. 이렇게 하니 캐시 정책이 한곳에 모이고, 요청자 기준 분기 로직도 훨씬 관리하기 쉬워졌다.</p>
<p>objectMapper 함수는 JacksonConfig 파일에 존재하기 때문에 중복 코드는 지우고 끌어와서 쓰게 되었다.</p>
<hr />
<h4 id="heading-3">3. 해결 결과</h4>
<p>Redis에서 캐시를 꺼낸다고 해서 항상 PostCacheDTO가 그대로 나오는 것은 아니었다. 스프링 캐시의 내부 동작 때문에 최초로 DB에서 조회하여 캐시에 저장하는 시점에는 메서드가 반환한 PostCacheDTO가 메모리에 남아 있어 그대로 반환한다. 하지만 캐시가 이미 존재하여 Redis에서 데이터를 읽어오는 시점에는 타입 정보(@class)가 없으므로 Jackson이 이를 LinkedHashMap으로 읽어오게 된다.</p>
<ul>
<li><p>첫 번째 호출 (DB 조회) -&gt; PostCacheDTO</p>
</li>
<li><p>두 번째 이후 호출 (캐시 히트) -&gt; LinkedHashMap</p>
</li>
</ul>
<p>최종적으로는 이런 흐름으로 진행하게 된다. </p>
<hr />
<h4 id="heading-cacheable">+. @Cacheable에 관하여</h4>
<p>@Cacheable을 남발하면 좋지 않다는 얘기를 들었다. 그래서 조사해 보았더니 이 부분은 상황마다 다른 것 같다. 데이터가 자주 바뀌지 않거나, 조회가 매우 잦거나, DB 조인 쿼리가 무거울 때 쓰면 효과가 좋다고 한다. 내가 맡은 인기글도 조회가 잦은 홈페이지에서 사용되니 어노테이션을 사용해도 괜찮은 것이다. 반대로 주식처럼 실시간으로 계속 바뀌는 데이터나 어쩌다 한 번만 조회되는 데이터, DB 조회가 아주 단순하고 빠른 경우엔 오히려 손해라 안 쓰는 게 좋다고 한다.</p>
<hr />
<p><strong><em>오늘의 요약</em></strong></p>
<ul>
<li>Redis에 대해 제대로 이해하지 않고 진행하다 보니 역직렬화에서 계속 부딪히게 됐다. 꼭 이해하고 넘어가자.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[Redis] Redis 캐시 적용하기 - 인기글 조회 적용]]></title><description><![CDATA[지난번 진행했던 Redis 초기 세팅을 바탕으로 오늘은 홈페이지의 인기 게시물 조회 API에 캐시를 적용했다.
인기 게시물은 좋아요 수 기준으로 상위 10개를 불러온다. 실시간으로 데이터가 급격히 변하지 않지만 메인 페이지의 특성상 호출 빈도가 매우 높다. 매번 DB를 조회하는 대신 Redis 캐시를 사용해 성능을 개선했다.

1. RedisConfig 설정
@Configuration
@EnableCaching
public class Redis...]]></description><link>https://blog.finders.it.kr/redis-redis</link><guid isPermaLink="true">https://blog.finders.it.kr/redis-redis</guid><dc:creator><![CDATA[이지영]]></dc:creator><pubDate>Mon, 09 Feb 2026 06:05:40 GMT</pubDate><content:encoded><![CDATA[<p>지난번 진행했던 Redis 초기 세팅을 바탕으로 오늘은 홈페이지의 인기 게시물 조회 API에 캐시를 적용했다.</p>
<p>인기 게시물은 좋아요 수 기준으로 상위 10개를 불러온다. 실시간으로 데이터가 급격히 변하지 않지만 메인 페이지의 특성상 호출 빈도가 매우 높다. 매번 DB를 조회하는 대신 Redis 캐시를 사용해 성능을 개선했다.</p>
<hr />
<h4 id="heading-1-redisconfig">1. RedisConfig 설정</h4>
<pre><code class="lang-plaintext">@Configuration
@EnableCaching
public class RedisConfig {

    ...
    public static final String POPULAR_POSTS_CACHE = "popularPosts"; // 홈페이지 사진 수다 미리 보기

    ...
    private static final long POPULAR_POSTS_TTL_MINUTES = 10L;

    @Bean
    public RedisTemplate&lt;String, Object&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate&lt;String, Object&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper()));
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(DEFAULT_CACHE_TTL_MINUTES))
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper())));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                ...
                .withCacheConfiguration(POPULAR_POSTS_CACHE, config.entryTtl(Duration.ofMinutes(POPULAR_POSTS_TTL_MINUTES)))
                .build();
    }

    private ObjectMapper redisObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                .allowIfBaseType(Object.class)
                .allowIfSubType("com.finders.api")
                .build();
        objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.EVERYTHING);
        return objectMapper;
    }
}
</code></pre>
<ul>
<li><p>인기 게시물 전용 캐시 이름과 TTL 10분 설정을 추가했다.</p>
</li>
<li><p>EVERYTHING 설정을 사용하되, allowIfSubType("com.finders.api")를 통해 우리 패키지 하위 클래스만 타입 정보를 유지하도록 화이트리스트를 적용했다.</p>
</li>
<li><p>코드 리뷰 중에 EVERYTHING 대신 NON_FINAL을 사용해 구성을 더 단순하게 유지하는 것을 고려해 보라는 의견이 있었지만, 아래 트러블 슈팅에서 언급할 record 이슈로 인해 현재 설정을 유지하기로 했다.</p>
</li>
</ul>
<hr />
<h4 id="heading-2-postcachedto">2. PostCacheDTO</h4>
<pre><code class="lang-plaintext">@Builder
public record PostCacheDTO(
        Long id,
        String title,
        Integer likeCount,
        Integer commentCount,
        String objectPath
) implements Serializable {
}
</code></pre>
<ul>
<li><p>Post 엔티티가 PostImage를 참조하고, PostImage가 다시 Post를 참조하는 등 순환 참조 문제가 발생할 수 있고, 캐시에 불필요한 정보까지 저장되는 것을 방지하기 위해 전용 DTO를 생성했다.</p>
</li>
<li><p>Entity를 직접 캐싱하지 않고, 필요한 데이터만 담은 PostCacheDTO를 생성했다.</p>
</li>
</ul>
<hr />
<h4 id="heading-3-postresponse">3. PostResponse</h4>
<pre><code class="lang-plaintext"> // PostResponse
 public static PostPreviewDTO fromCache(PostCacheDTO cache, boolean isLiked, String fullImageUrl) {
            return PostPreviewDTO.builder()
                    .postId(cache.id())
                    .title(cache.title())
                    .likeCount(cache.likeCount())
                    .commentCount(cache.commentCount())
                    .isLiked(isLiked)
                    .image(new PostImageResDTO(fullImageUrl, null, null))
                    .build();
        }
</code></pre>
<ul>
<li>캐시 DTO를 응답 DTO로 변환했다.</li>
</ul>
<hr />
<h4 id="heading-4-postrepository">4. PostRepository</h4>
<pre><code class="lang-plaintext">// PostRepository
@Cacheable(value = "popularPosts", key = "'home_top10'")
    public List&lt;PostCacheDTO&gt; findTop10PopularPosts() {
        List&lt;Long&gt; ids = queryFactory
                .select(post.id)
                .from(post)
                .where(post.status.eq(CommunityStatus.ACTIVE))
                .orderBy(post.likeCount.desc(), post.createdAt.desc()) // 좋아요 순, 같으면 최신순
                .limit(POPULAR_POSTS_LIMIT)
                .fetch();

        if (ids.isEmpty()) return List.of();

        List&lt;Post&gt; posts = queryFactory
                .selectFrom(post)
                .leftJoin(post.postImageList).fetchJoin()
                .where(post.id.in(ids))
                .orderBy(post.likeCount.desc(), post.createdAt.desc())
                .fetch();

        return posts.stream()
                .map(p -&gt; PostCacheDTO.builder()
                        .id(p.getId())
                        .title(p.getTitle())
                        .likeCount(p.getLikeCount())
                        .commentCount(p.getCommentCount())
                        .objectPath(p.getPostImageList().isEmpty() ? null : p.getPostImageList().get(0).getObjectPath())
                        .build())
                .toList();
    }
</code></pre>
<ul>
<li><p>@Cacheable을 적용하여 Redis에 데이터가 있으면 메서드 로직을 타지 않고 바로 반환한다.</p>
</li>
<li><p>쿼리 튜닝을 위해 ID 목록을 먼저 뽑고 IN 절로 데이터를 갖고 오는 방식을 사용했다.</p>
</li>
<li><p>조회 성능 개선이 목적이므로, 서비스가 아닌 조회 전용 Repository 메서드에 캐시를 적용했다. 해당 메서드는 상태 변경 로직이 없고, 캐시 무효화 시점도 명확해 안전하다고 판단했다.</p>
</li>
<li><p>인기 게시물은 모든 사용자에게 동일한 결과를 반환하므로 사용자 ID를 포함하지 않은 고정 캐시 키(home_top10)를 사용했다.</p>
</li>
</ul>
<hr />
<h4 id="heading-5-postqueryserviceimpl">5. PostQueryServiceImpl</h4>
<pre><code class="lang-plaintext">// PostQueryServiceImpl
@Override
    public PostResponse.PostPreviewListDTO getPopularPosts(Long memberId) {
        List&lt;PostCacheDTO&gt; cachedPosts = postQueryRepository.findTop10PopularPosts();

        Set&lt;Long&gt; likedPostIds;
        if (memberId != null &amp;&amp; !cachedPosts.isEmpty()) {
            List&lt;Long&gt; postIds = cachedPosts.stream().map(PostCacheDTO::id).toList();
            likedPostIds = postLikeRepository.findLikedPostIdsByMemberAndPostIds(memberId, postIds);
        } else {
            likedPostIds = java.util.Collections.emptySet();
        }

        List&lt;PostResponse.PostPreviewDTO&gt; previews = cachedPosts.stream()
                .map(dto -&gt; {
                    boolean isLiked = likedPostIds.contains(dto.id());

                    String fullImageUrl = (dto.objectPath() != null)
                            ? storageService.getPublicUrl(dto.objectPath())
                            : null;

                    return PostResponse.PostPreviewDTO.fromCache(dto, isLiked, fullImageUrl);
                })
                .toList();
        return PostResponse.PostPreviewListDTO.from(previews);
    }
</code></pre>
<ul>
<li>전체 리스트는 캐시에서 가져오되, 내가 이 글에 좋아요를 눌렀는가와 같은 개인화된 정보는 서비스 레이어에서 동적으로 결합하도록 설계했다. 캐시의 효율성과 개인화 정보를 동시에 잡은 방식이다.</li>
</ul>
<hr />
<h4 id="heading-6">6. 트러블 슈팅</h4>
<p>오늘 가장 시간을 많이 썼던 부분은 record 타입의 직렬화 충돌 문제였다.</p>
<pre><code class="lang-plaintext">Could not read JSON:Unexpected token (START_ARRAY) // 에러 발생
</code></pre>
<ul>
<li><p>코드 리뷰 과정에서 EVERYTHING 대신 NON_FINAL을 사용해 구성을 단순화하자는 의견이 있었다. 그러나 Java의 record 타입은 모든 필드가 final이기 때문에 NON_FINAL 설정에서는 타입 정보가 누락되어 역직렬화 오류가 발생했다. 해당 오류로 인해 EVERYTHING은 유지하되, allowIfSubType("com.finders.api")를 통해 패키지 화이트리스트를 적용했다.</p>
</li>
<li><p>설정을 변경한 후에는 반드시 <strong><em>docker exec -it [컨테이너명] redis-cli flushall</em></strong> 을 통해 기존에 잘못된 데이터를 날려 줘야 한다. 설정 변경 후 기존에 잘못 직렬화된 데이터가 남아 있어 로컬 환경에서만 flushall로 캐시를 초기화했다.</p>
</li>
</ul>
<hr />
<h4 id="heading-kioq7jik64qy7j2yioyaloyvvsoqkg"><strong><em>오늘의 요약</em></strong></h4>
<ul>
<li><p>캐시용 DTO를 별도로 설계하여 필요한 정보만 효율적으로 저장했다.</p>
</li>
<li><p>설정 변경 후에는 기존 캐시 데이터를 꼭 날리자.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[Cloudflared] Cloud Tunnel 도입기]]></title><description><![CDATA[1. 시작은 단순한 요구였다

외부에서 접근만 되면 되잖아?

프로젝트를 진행하면서 외부에서 접근 가능한 API 엔드포인트가 필요했다. 하지만 서버 환경은 꽤 제한적이었다.

내부 네트워크 또는 로컬 환경

인바운드 포트 개방 불가

공인 IP 사용 지양

VPN은 과한 해결책


이 조건들을 모두 만족시키면서 서버를 외부에 노출할 방법이 필요했고, 이때 선택한 것이 Cloudflare Tunnel이었다.
포트 포워딩 없이도 로컬 서버를 외부로...]]></description><link>https://blog.finders.it.kr/cloudflared-cloud-tunnel</link><guid isPermaLink="true">https://blog.finders.it.kr/cloudflared-cloud-tunnel</guid><category><![CDATA[infrastructure]]></category><category><![CDATA[backend]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[장지요]]></dc:creator><pubDate>Sat, 07 Feb 2026 11:50:38 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-1">1. 시작은 단순한 요구였다</h2>
<blockquote>
<p>외부에서 접근만 되면 되잖아?</p>
</blockquote>
<p>프로젝트를 진행하면서 외부에서 접근 가능한 API 엔드포인트가 필요했다. 하지만 서버 환경은 꽤 제한적이었다.</p>
<ul>
<li><p>내부 네트워크 또는 로컬 환경</p>
</li>
<li><p>인바운드 포트 개방 불가</p>
</li>
<li><p>공인 IP 사용 지양</p>
</li>
<li><p>VPN은 과한 해결책</p>
</li>
</ul>
<p>이 조건들을 모두 만족시키면서 서버를 외부에 노출할 방법이 필요했고, 이때 선택한 것이 <strong><mark>Cloudflare Tunnel</mark></strong>이었다.</p>
<p>포트 포워딩 없이도 로컬 서버를 외부로 노출할 수 있다는 점이 좋아 보였다.<br />하지만 이 편리함이 첫 번째 오해였다..</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770455642280/6e44c007-07ba-4ebc-877c-90519baac47c.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-2-cloud-tunnel">2. 처음엔 Cloud Tunnel을 너무 쉽게 봤다</h2>
<p>Cloud Tunnel을 처음 접했을 때의 인식은 이랬다.</p>
<blockquote>
<p>로컬 서버를 외부에 잠깐 열어주는 도구겠지</p>
</blockquote>
<p>그래서 구조에 대한 깊은 이해 없이 바로 설정부터 시작했다. 처음 Cloud Tunnel을 도입했을 때의 전제는 단순했다.</p>
<ul>
<li><p>하나의 인스턴스에 올라와 있는 dev, prod 서버</p>
</li>
<li><p>둘 다 외부에서 접근 가능해야 함</p>
</li>
<li><p><strong>Cloud Tunnel은 어차피 연결만 해주는 역할</strong></p>
</li>
</ul>
<p>그래서 자연스럽게 <strong>“인스턴스는 하나니깐 Tunnel도 하나 만들어서 dev 도메인, prod 도메인 둘 다 연결하면 되는 거 아닌가?”</strong></p>
<p>이 판단은 틀리지는 않았지만, 불완전했다… 🥹</p>
<hr />
<h2 id="heading-3-tunnel-hostman">3. 최초 구조: 하나의 Tunnel + 두 개의 Hostman</h2>
<p>초기 구성은 대략 이런 형태였다.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">tunnel:</span> <span class="hljs-string">finders-tunnel</span>
<span class="hljs-attr">ingress:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">hostname:</span> <span class="hljs-string">dev-api.finders.it.kr</span>
      <span class="hljs-attr">service:</span> <span class="hljs-string">http://localhost:8081</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">hostname:</span> <span class="hljs-string">api.finders.it.kr</span>
      <span class="hljs-attr">service:</span> <span class="hljs-string">http://localhost:8080</span>
</code></pre>
<p>DNS 역시 다음처럼 설정했다.</p>
<ul>
<li><p><code>dev-api.finders.it.kr</code> → Cloud Tunnel</p>
</li>
<li><p><code>api.finders.it.kr</code> → Cloud Tunnel</p>
</li>
</ul>
<p>이론적으로는 완벽해 보였다.</p>
<ul>
<li><p>hostname으로 환경 구분</p>
</li>
<li><p>포트로 서비스 구분</p>
</li>
<li><p>Tunnel은 공통</p>
</li>
</ul>
<p>hostname으로 환경을 구분하고 포트로 서비스를 나누니, 이론적으로는 완벽해 보였다.<br />실제로 <strong>dev 환경 배포 직후에는 모든 것이 잘 동작했다</strong>!! 새로운 기술을 적용했기에 기뻐했는데,,</p>
<p>진짜 문제는 prod 서버를 올리면서 시작되었다..</p>
<hr />
<h2 id="heading-4-200-502-cors">4. 첫 번째 이상 징후: 200 → 502 → CORS 에러?</h2>
<p>main 브랜치를 배포하고 나서 갑자기 API 응답이 요동치기 시작했다. 어떨 때는 200 OK가 뜨다가, 갑자기 <strong>502 Bad Gateway</strong>와 함께 <strong>CORS 에러</strong>가 터져 나왔다!!!!!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770462698353/78bfedbc-72ec-4b07-8c3d-fea6e566b7c6.png" alt class="image--center mx-auto" /></p>
<p>CORS 설정은 완벽했기에 대체 왜 이런 일이 발생했는지 의문이었다.<br />대체 뭐가 문제인가 싶어서 확인했더니 Cloud Tunnel 문제였다.👿</p>
<h4 id="heading-64k06rcaio2wiounmcdssknqsie">내가 했던 착각</h4>
<blockquote>
<p>hostname이 다르면 Cloudflare가 알아서 완벽하게 분리해 주겠지?</p>
</blockquote>
<p>하지만 정말 착각이었따..</p>
<p>터널의 기준은 hostname이 아니라 <strong>Tunnel Demon(Cloudflared)</strong> 그 자체였다. 하나의 터널 프로세스가 여러 호스트네임의 트래픽을 동시에 처리하다 보니, <strong><mark>Connection Pool의 혼선이나 라우팅 우선순위 문제</mark></strong>가 발생했던 것이다.<br />Cloudflare Edge 서버 입장에서는 같은 터널로 들어오는 요청이 꼬이면서 응답을 제대로 받지 못해 502를 던졌고, 이 과정에서 필수 헤더가 누락되어 브라우저가 이를 CORS 에러로 오인한 것이었다.</p>
<hr />
<h2 id="heading-5-1">5. 해결책 1: 터널 분리</h2>
<blockquote>
<p>hostname 기준으로 다 나뉠 줄 알았다.. (그렇게 해도 된다며..!!!)</p>
</blockquote>
<p>결국 터널을 물리적으로 두 개로 분리하기로 했다. prod용 터널과 dev용 터널을 각각 생성하여 각자의 설정 파일과 프로세스를 가지게 했다.</p>
<p>이렇게 하면 장애 전파 범위를 완벽히 격리할 수 있었다. dev 터널에 과부하가 걸리거나 설정 오류가 나더라도 prod 터널은 물리적으로 분리된 통로를 사용하므로 영향을 받지 않게 되는 것이다!</p>
<p>→ 이것이 바로 격리(Isolation)이었다.</p>
<hr />
<h2 id="heading-6-zombie">6. 두 번째 문제: 사라지지 않는 유령(Zombie) 설정</h2>
<blockquote>
<p>dev를 고쳐놨더니 prod가 말썽이네?</p>
</blockquote>
<p>dev가 너무 정상적으로 실행되었고, 에러가 하나도 나지 않았기 때문에 prod도 문제 없을 거라 생각했다.</p>
<p>하지만 prod 환경이 여전히 불안정했다. 설정은 완벽한데 왜일까?</p>
<p>Tunnel을 분리함으로써 각각의 역할을 명시해주었고, 그에 따라 동일한 설정을 가지고 있음에도 dev는 멀쩡했는데 prod만 문제가 발생하니 어떻게 해야할지가 막막했다..</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770463421150/f7bea587-4e25-4384-b8d4-1925fdc04748.png" alt class="image--center mx-auto" /></p>
<p>답은 라우팅 전파(Propagation)의 지연에 있었다..</p>
<p>찾아보니 기존 터널 정보를 지우고 새 터널을 연결해도, 퍼져 있는 Cloudflare의 Edge 노드들에는 이전의 라우팅 캐시나 ‘좀비 커넥션’이 남아있을 수도 있다고 했다.</p>
<p>그래서 바로 완전히 새로 터널을 생성하여서 ID 자체를 갱신하였다.</p>
<p>두근.. 두근..!!!</p>
<p>결론적으로는 그 이후로는 아주 안정화되었다!!! 아마도 이전 설정 기록들이 어딘가에 남아있었고, 그게 계속 문제를 일으키고 있었던 것 같다.</p>
<hr />
<h2 id="heading-7">7. 마치며</h2>
<blockquote>
<p>이제는 왜 두 개의 Tunnel을 쓰는지 설명할 수 있다</p>
</blockquote>
<p>이번 경험을 통해 단순한 네트워크 도구라도 운영 환경에서는 <strong>책임과 경계</strong>를 명확히 해야 함을 배웠다.</p>
<ol>
<li><p><strong>터널은 단순한 통로가 아니다</strong>: 터널은 하나의 리소스 단위이며 장애 전파의 단위였다.</p>
</li>
<li><p><strong>논리적 분리보다 물리적 분리</strong>: hostname(논리적) 구분보다 Tunnel Process(물리적) 분리가 훨씬 안전하다.</p>
</li>
<li><p><strong>인프라 설정은 때로 재생성이 답이다</strong>: 때로는 수정보다 ‘재생성’이 더 빠르고 확실한 해결책이 될 수 있다.</p>
</li>
</ol>
<p>처음엔 Tunnel 하나로 dev / prod를 동시에 운영하려 했다. 그리고 실제로 한동안은 잘 되는 것처럼 보였다. 하지만 dev와 prod의 분리는 필요하였고, 이는 편의가 아니라 안정성과 책임의 문제였다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770463710356/4cd96240-f124-4631-9d6c-280fd56ef983.png" alt class="image--center mx-auto" /></p>
<p>바이요</p>
]]></content:encoded></item><item><title><![CDATA[[Elasticsearch] ELK stack에 관하여]]></title><description><![CDATA[-elastic
▶ 들어가며
이번에는 ELK Stack에 관해서 찍먹을 해보자.전체적인 플로우를 먼저 잡아두면, 이 기술을 왜 사용하는지, 어떤 구조로 동작하는지 이해하기가 훨씬 쉬워진다.
그래서 이번 글에서는 ELK Stack을 구성하는 요소들이 각각 어떤 역할을 하는지,그리고 전체 흐름이 어떻게 연결되는지를 가볍게 정리해보려고 한다.

▶ ELK Stack 전체 구조

-ELK stack
위 사진은 ELK Stack의 전체적인 구조를 보여주...]]></description><link>https://blog.finders.it.kr/elasticsearch-elk-stack</link><guid isPermaLink="true">https://blog.finders.it.kr/elasticsearch-elk-stack</guid><dc:creator><![CDATA[주보경]]></dc:creator><pubDate>Thu, 05 Feb 2026 08:58:31 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://blog.kakaocdn.net/dna/bqNCco/dJMcaf6x0LY/AAAAAAAAAAAAAAAAAAAAAGaBav2I4H3jF135drV3qnANvTP_PcbtcMdzaG_dfiW4/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=XuW%2BLCTf4LwJevhEZhIuLL6K7ik%3D" alt /></p>
<p>-elastic</p>
<h2 id="heading-4pa2ioutpoywtoqwgoupsa">▶ 들어가며</h2>
<p>이번에는 <strong>ELK Stack</strong>에 관해서 찍먹을 해보자.<br />전체적인 플로우를 먼저 잡아두면, 이 기술을 <strong>왜 사용하는지</strong>, 어떤 구조로 동작하는지 이해하기가 훨씬 쉬워진다.</p>
<p>그래서 이번 글에서는 ELK Stack을 구성하는 요소들이 각각 어떤 역할을 하는지,<br />그리고 전체 흐름이 어떻게 연결되는지를 가볍게 정리해보려고 한다.</p>
<hr />
<h2 id="heading-elk-stack">▶ ELK Stack 전체 구조</h2>
<p><img src="https://blog.kakaocdn.net/dna/ZUMNU/dJMcahDg6BU/AAAAAAAAAAAAAAAAAAAAAIuXQNNiOOm18DvbkJ0hD29tTDXUtQJysUyeTOGMXWWo/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=bYukdDqpTRMY5CJNjokKkMQbwTM%3D" alt /></p>
<p>-ELK stack</p>
<p>위 사진은 ELK Stack의 전체적인 구조를 보여주는 대표적인 그림이다. ELK Stack의 핵심 요소는 이름 그대로 아래 3가지이다.</p>
<p><s>로고가 아주 찰떡이야</s></p>
<ul>
<li><p><strong>Elasticsearch</strong></p>
</li>
<li><p><strong>Logstash</strong></p>
</li>
<li><p><strong>Kibana</strong></p>
</li>
</ul>
<p>그리고 가장 왼쪽에 있는 data 구간은 <strong>Beats</strong>라는 별도의 구성요소로 구분된다.</p>
<p>그럼 이제 각 단계가 어떤 역할을 하는지 하나씩 살펴보자.</p>
<hr />
<h2 id="heading-beats">▶ Beats</h2>
<p><img src="https://blog.kakaocdn.net/dna/clJE90/dJMcaac6UVS/AAAAAAAAAAAAAAAAAAAAACMdh1csrQSeqDwFhRO36Qpxo9gx2f1wcAGGRdDq77c4/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=cQzxd4JXycJB8duMGl6McwhvNLM%3D" alt /></p>
<p>-Beats</p>
<p>사진에서 가장 왼쪽에 있는 요소들이 Beats이다.<br />Beats는 “가벼운 수집 프로그램”들이고, 서버에 설치해서 데이터를 긁어 <strong>Elasticsearch 또는 Logstash로 보내는 데이터 수집기 역할</strong>을 한다.</p>
<p>즉 Beats는 서버에 설치해서 백그라운드에서 동작하는<br />👉 <strong>경량 수집 agent 프로그램</strong>이라고 이해하면 된다.</p>
<p>Beats는 종류가 여러 가지가 있는데 대표적으로 아래와 같다.</p>
<ul>
<li><p><strong>Filebeat</strong> : 서버의 로그 파일(log file)을 수집</p>
</li>
<li><p><strong>Metricbeat</strong> : CPU, RAM, Disk 사용량 같은 서버 metric 정보를 수집</p>
</li>
<li><p><strong>Packetbeat</strong> : 네트워크 패킷(wire data)을 분석하여 트래픽 정보를 수집</p>
</li>
<li><p><strong>Winlogbeat</strong> : Windows 이벤트 로그를 수집</p>
</li>
</ul>
<p>!! Beats가 하는 일을 정리하면 이런 느낌이다!!</p>
<ul>
<li><p>웹서버, 백엔드 서버, DB 서버, 운영 서버 등에 설치</p>
</li>
<li><p>로그 파일을 읽거나, metric을 수집하거나, 네트워크 패킷을 분석해서</p>
</li>
<li><p>Elasticsearch 또는 Logstash로 전송</p>
</li>
</ul>
<p>즉, 빅데이터 관점에서 보면 Beats는<br />👉 <strong>Ingestion(수집) 단계</strong>에 해당한다고 보면 된다.</p>
<hr />
<h2 id="heading-logstash">▶ Logstash</h2>
<p><img src="https://blog.kakaocdn.net/dna/bM5rOo/dJMcagYGeSA/AAAAAAAAAAAAAAAAAAAAADuokSEXRs8TSUgLeaiw6-1irvyPJGRFqqxXw53BO3Do/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=%2F%2FtwVySfl57Ht9wPGbbf5gz%2FF3Y%3D" alt /></p>
<p>-logstash</p>
<p>Logstash는 ELK에서 Beats로부터 로그를 받아서<br />👉 <strong>가공한 뒤 목적지로 보내는 중간 처리 서버</strong> 역할을 한다.</p>
<p>Logstash는 단순 전달이 아니라, 로그 데이터를 <strong>검색 가능한 형태로 바꿔주는 파이프라인 엔진</strong>이라고 볼 수 있다.</p>
<p>Logstash는 크게 아래 3단계 구조로 동작한다.</p>
<ul>
<li><p><strong>Input</strong> : 데이터를 입력받음</p>
</li>
<li><p><strong>Filter</strong> : 데이터를 파싱하고 변환함</p>
</li>
<li><p><strong>Output</strong> : 데이터를 Elasticsearch 등 목적지로 보냄</p>
</li>
</ul>
<p>예를 들어 앱 서버가 아래처럼 단순 텍스트 로그를 보낸다고 해보자.</p>
<pre><code class="lang-plaintext">user=kim age=26
</code></pre>
<p>Logstash는 이를 입력으로 받은 뒤, filter 단계에서 파싱 해서 아래처럼 JSON 형태로 구조화할 수 있다.</p>
<pre><code class="lang-plaintext">{ "user": "kim", "age": 26 }
</code></pre>
<p>이렇게 구조화된 데이터는 Elasticsearch에서 검색하기 훨씬 쉬워진다.<br />즉 Logstash는<br />👉 <strong>로그를 분석 가능한 형태로 바꾸는 전처리(ETL) 단계</strong>라고 볼 수 있다.</p>
<p>-Logstash 설정 예시</p>
<p>Logstash는 <strong>logstash.conf</strong> 같은 설정 파일을 코드처럼 작성해두고, 이 설정대로 파싱 및 변환 작업을 수행한다.</p>
<p>예시 설정은 다음과 같다.</p>
<pre><code class="lang-plaintext">input {
  http {
    port =&gt; 8080
  }
}

filter {
  json {
    source =&gt; "message"
  }

  mutate {
    convert =&gt; { "age" =&gt; "integer" }
  }
}

output {
  elasticsearch {
    hosts =&gt; ["http://localhost:9200"]
    index =&gt; "users"
  }

  stdout { codec =&gt; rubydebug }
}
</code></pre>
<p>위 설정을 보면,</p>
<ul>
<li><p>HTTP로 들어오는 요청을 받아서</p>
</li>
<li><p>JSON 파싱 후</p>
</li>
<li><p>age를 integer로 변환하고</p>
</li>
<li><p>Elasticsearch에 저장하는 구조이다.</p>
</li>
</ul>
<p>그리고 중요한 점은 Logstash는 <strong>필수는 아니다.</strong></p>
<p>Beats가 바로 Elasticsearch로 보내는 구조도 가능하지만,<br />로그가 너무 지저분하거나 여러 시스템의 로그를 하나의 형태로 통합해야 하는 경우 Logstash가 필요해진다.</p>
<p>즉 Logstash는 빅데이터 관점에서 보면<br />👉 <strong>Streaming ETL / Transform 단계</strong>라고 이해하면 된다.</p>
<hr />
<h2 id="heading-elasticsearch">▶ Elasticsearch</h2>
<p><img src="https://blog.kakaocdn.net/dna/u8qBi/dJMcahcdehm/AAAAAAAAAAAAAAAAAAAAAH_Nq9Q1SczyGaCNs3tK_fi7p8rod0kbPm3u6B90gUGP/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=oChioMa0VMlOlJDtTXrX5EAc7wo%3D" alt /></p>
<p>-elasticsearch</p>
<p>Elasticsearch는 전처리된 로그/데이터를 저장하면서 동시에<br />👉 <strong>검색 가능한 구조(index)로</strong> 만들어주는 단계이다.</p>
<p>즉, 단순 저장소(DB)가 아니라 검색/분석이 가능하도록 인덱스를 만들어서 저장하는 저장소 검색엔진이라고 이해하면 된다.</p>
<p>Elasticsearch는 데이터를 SQL 테이블 row 형태로 저장하는 것이 아니라,<br />JSON 형태의 <strong>Document</strong>로 저장한다.</p>
<p>예를 들어 아래 데이터는 Elasticsearch에서 document 한 개가 된다.</p>
<pre><code class="lang-plaintext">{ "user": "kim", "name": "bo", "age": 26 }
</code></pre>
<p>이 document는 특정 index(예: users)에 저장된다. </p>
<p>그리고 Elasticsearch가 단순 DB가 아니라 검색엔진이라고 불리는 이유는, 저장 과정에서 검색을 위한 구조를 만들어두기 때문이다. 대표적으로 Elasticsearch는<br />👉 <strong>Inverted Index(역색인)</strong> 구조를 사용한다.</p>
<p>이 덕분에 특정 단어가 포함된 document를 빠르게 찾을 수 있고, 검색 성능이 매우 뛰어나다.</p>
<p><s>자세한 내용은 다음 포스팅에서...</s></p>
<p>-Aggregation(집계) 기능</p>
<p>Elasticsearch는 단순히 검색만 하는 것이 아니라, Aggregation(집계) 기능도 제공한다.</p>
<p>예를 들어 다음과 같은 분석이 가능하다.</p>
<ul>
<li><p>특정 시간대별 로그 개수</p>
</li>
<li><p>status code 별 요청 수</p>
</li>
<li><p>가장 많이 등장한 user top 10</p>
</li>
<li><p>특정 키워드 검색 결과 집계</p>
</li>
</ul>
<p>즉, Elasticsearch는 빅데이터 관점에서 보면<br />👉 <strong>저장 + 검색 + 집계 분석</strong>을 동시에 수행할 수 있는 핵심 저장소 역할을 한다.</p>
<hr />
<h2 id="heading-kibana">▶ Kibana</h2>
<p><img src="https://blog.kakaocdn.net/dna/GQcQ5/dJMcaaRJxZr/AAAAAAAAAAAAAAAAAAAAAPJ8Q36h7nEG8qWm3fXzYOL-dUA5blaDqlDD0MgZN8LM/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=XPtWKiPtf%2Bru%2FLcUHqeXfb%2BBlfo%3D" alt /></p>
<p>-kibana</p>
<p>이전에도 설명했지만, Kibana는 Elasticsearch에 저장된 데이터를 시각화해 주는 GUI 단계이다. 즉 Kibana는 ELK Stack에서<br />👉 <strong>프론트엔드 역할</strong>을 한다고 보면 된다.</p>
<p>Kibana를 사용하면 아래와 같은 작업이 가능하다.</p>
<ul>
<li><p>Elasticsearch index에 저장된 로그 검색</p>
</li>
<li><p>필터 조건을 걸어 원하는 로그만 확인</p>
</li>
<li><p>시간대별 트래픽 변화 그래프 확인</p>
</li>
<li><p>대시보드(Dashboard)를 구성하여 모니터링 화면 제작</p>
</li>
</ul>
<p>즉 Kibana는 단순히 데이터를 보여주는 툴이 아니라,<br />운영 환경에서 로그 분석과 모니터링을 할 수 있도록 도와주는 도구이다.</p>
<hr />
<h2 id="heading-elk-stack-1">▶ ELK Stack 전체 과정 정리</h2>
<p>전체 흐름을 정리하면 다음과 같다.</p>
<ol>
<li><p>서버에서 로그/metric 데이터가 생성됨</p>
</li>
<li><p>Beats가 해당 데이터를 수집해서 전송함</p>
</li>
<li><p>Logstash가 있다면 데이터를 파싱/변환/정제함</p>
</li>
<li><p>Elasticsearch가 데이터를 저장하고 검색 가능한 구조로 인덱싱함</p>
</li>
<li><p>Kibana가 Elasticsearch 데이터를 시각화하여 보여줌  </p>
</li>
</ol>
<p><img src="https://blog.kakaocdn.net/dna/wahlK/dJMcajujmAG/AAAAAAAAAAAAAAAAAAAAAAK9IoCo-ROGsBi8trumNEp-NKnbbSA24NtmGtC_rV-H/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=0t%2FDs5wrHAbrv2aWx3M%2B3sgqLIA%3D" alt /></p>
<p>EndFragment</p>
]]></content:encoded></item><item><title><![CDATA[[Elasticsearch] Elasticsearch 아키텍처 + GUI(Kibana) 세팅]]></title><description><![CDATA[-elastic
▶ 들어가며
이번 글에서는 이전 포스팅에 이어서, Elasticsearch를 MySQL과 비교하며 아키텍처 관점에서 어떻게 다른지 살펴보고,직접 Kibana를 사용한 개발 환경 세팅까지 진행해보려고 한다.
단순히 개념으로만 이해하는 것이 아니라,

요청이 어떤 방식으로 전달되는지

실제로 Elasticsearch가 잘 동작하는지


까지 확인하는 것을 목표로 한다.

▶ Elasticsearch 아키텍처
Elasticsearch...]]></description><link>https://blog.finders.it.kr/elasticsearch-elasticsearch-guikibana</link><guid isPermaLink="true">https://blog.finders.it.kr/elasticsearch-elasticsearch-guikibana</guid><dc:creator><![CDATA[주보경]]></dc:creator><pubDate>Tue, 03 Feb 2026 18:51:19 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://blog.kakaocdn.net/dna/bCcD5O/dJMcaaEapRe/AAAAAAAAAAAAAAAAAAAAAPTcYBJzowfH4AVUfzS2x91Ro8008aMYcIsOGOXrqVE_/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=Cj03tKK80nb%2BXjYc7ixHWJvNWlE%3D" alt /></p>
<p>-elastic</p>
<h2 id="heading-4pa2ioutpoywtoqwgoupsa">▶ 들어가며</h2>
<p>이번 글에서는 이전 포스팅에 이어서, <strong>Elasticsearch를 MySQL과 비교하며 아키텍처 관점에서 어떻게 다른지</strong> 살펴보고,<br />직접 <strong>Kibana를 사용한 개발 환경 세팅</strong>까지 진행해보려고 한다.</p>
<p>단순히 개념으로만 이해하는 것이 아니라,</p>
<ul>
<li><p>요청이 어떤 방식으로 전달되는지</p>
</li>
<li><p>실제로 Elasticsearch가 잘 동작하는지</p>
</li>
</ul>
<p>까지 확인하는 것을 목표로 한다.</p>
<hr />
<h2 id="heading-elasticsearch">▶ Elasticsearch 아키텍처</h2>
<p>Elasticsearch의 기본적인 동작 흐름은 다음과 같다.</p>
<p>-MySQL의 요청 흐름</p>
<p><img src="https://blog.kakaocdn.net/dna/eTtgXO/dJMcahccwok/AAAAAAAAAAAAAAAAAAAAAIIaJ8q_9SS2stLT7GJDH5PdXTsl_9pEVrifc20DZAQc/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=ID%2B1%2FBFJfmJwx6ATsGRez8o0q1w%3D" alt /></p>
<p>-Elasticsearch의 요청 흐름</p>
<p><img src="https://blog.kakaocdn.net/dna/dm8b5k/dJMcabC40H3/AAAAAAAAAAAAAAAAAAAAACzXvZNs0_3nMHrbv8PHNVxjcRMBIzspegUtDkspddJx/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=6UCay3VMyf7ddpGzXzPaZK7f6vY%3D" alt /></p>
<p>여기서 가장 중요한 점은<br />👉 <strong>REST API 기반으로 동작한다는 점</strong>이다.</p>
<p>자체 문법을 실행하는 방식이 아니라 우리에게 익숙한 HTTP 요청을 통해 데이터를 삽입하고 조회한다.</p>
<p>예를 들어 데이터를 삽입한다고 가정하면,</p>
<ul>
<li>MySQL</li>
</ul>
<pre><code class="lang-plaintext">INSERT INTO user (name, age)
VALUES ('bo', 26);
</code></pre>
<ul>
<li>Elasticsearch</li>
</ul>
<pre><code class="lang-plaintext">curl -X POST "http://localhost:9200/user/_doc" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "bo",
    "age": 26
  }'
</code></pre>
<p>조회 역시 마찬가지로, SQL 대신 <strong>HTTP 기반 요청</strong>으로 처리된다.</p>
<p>이 점 덕분에 Elasticsearch는 웹 서비스와의 연동, 검색 기능 구현에 특히 잘 어울린다.</p>
<hr />
<h2 id="heading-elasticsearch-gui-kibana">▶ Elasticsearch의 GUI, Kibana</h2>
<p>위에서 본 것처럼 cli를 매번 사용하여 요청을 보내고 테이블을 일일이 확인하는것은 여간 번거러운 일이 아님을 알고있을 것이다. MySQL을 공부하면서 이러한 번거로음을 줄이기 위해서</p>
<ul>
<li><p>MySQL Workbench</p>
</li>
<li><p>DataGrip</p>
</li>
</ul>
<p>같은 GUI 도구를 사용하듯이,<br />Elasticsearch 역시 <strong>매번 Postman이나 curl로 요청을 보내는 것은 번거롭다.</strong></p>
<p>그래서 Elasticsearch에서는<br />👉 <strong>Kibana</strong>라는 GUI 도구를 함께 사용한다.</p>
<p>Kibana를 사용하면 데이터 조회, 인덱스 관리, 쿼리 테스트 를 훨씬 직관적으로 할 수 있다.</p>
<p>-Kibana 세팅 구조</p>
<p>전체 흐름을 정리하면 다음과 같다.</p>
<p><img src="https://blog.kakaocdn.net/dna/sZPa4/dJMcacaVouN/AAAAAAAAAAAAAAAAAAAAAHuCgSE7OznedzPQWIDnW0PgUm4CS8AE-0-PbvkDLuad/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=mjrpUk3cul19XVlWXTAg5fBKXys%3D" alt /></p>
<ul>
<li><p><strong>5601 포트</strong>: Kibana</p>
</li>
<li><p><strong>9200 포트</strong>: Elasticsearch REST API</p>
</li>
</ul>
<hr />
<h2 id="heading-4pa2ioqwnouwncdtmzjqsr0g7is47yyf">▶ 개발 환경 세팅</h2>
<p>서로 다른 개발환경을 통일시킬 수 있는 <strong>Docker</strong>를 사용하여 Docker위헤 Elastic과 Kibana를 띄우는 방식으로 세팅을 해볼 것이다.</p>
<p><s>도커 관련 지식이나 막혔을때는 인터넷을 찾아서 하는것이 훨씬 빠를것이다.</s></p>
<ul>
<li><p>docker-compose.yml 파일을 작성/수정</p>
</li>
<li><p>Elasticsearch + Kibana를 동시에 실행</p>
</li>
</ul>
<pre><code class="lang-plaintext">version: "3.8"

services:
  elastic:
    image: docker.elastic.co/elasticsearch/elasticsearch:9.2.4
    ports:
      - "9200:9200"
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - xpack.security.http.ssl.enabled=false

  kibana:
    image: docker.elastic.co/kibana/kibana:9.2.4
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elastic:9200
    depends_on:
      - elastic
</code></pre>
<p>- elastic과 kibana 버전이 반드시 맞아야 함. 다르니까 안됐음.</p>
<p>이후 postman으로 <a target="_blank" href="http://localhost:9200">localhost:9200</a>의 GET요청과 브라우저에서 <a target="_blank" href="http://localhost:5601">http://localhost:5601</a> 주소로 접속했을때 응답이 200OK로 오거나 접속창이 로딩되면 성공!.</p>
<p><img src="https://blog.kakaocdn.net/dna/bUhMDS/dJMcaaqC6J6/AAAAAAAAAAAAAAAAAAAAAB2o3pba99bekMV8Z4vJBV-6fwLhvNtlDLO8-TXNPvDb/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=p9yTIpQfEh%2Bug7I%2FtuXCdZa9UyY%3D" alt /></p>
<p>-postman 응답 화면</p>
<p><img src="https://blog.kakaocdn.net/dna/mPMhi/dJMcaiCacUO/AAAAAAAAAAAAAAAAAAAAALDWNu3nT9ywb8H3l4ergv4M5wqBY0yGRDAmpSDNkpMo/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=kLhuT5sLPSOhy%2Fk%2FEPZn8%2FmhPHM%3D" alt /></p>
<p>-<a target="_blank" href="http://localhost:5601">localhost:5601</a> Kibana화면</p>
<ul>
<li>모두 정상적으로 뜬다면<br />  👉 <strong>Elasticsearch와 Kibana가 잘 실행 중</strong>이라는 의미다.</li>
</ul>
<p>햄버거 바에서 "<strong>Dev Tools"</strong>를 찾아서 GUI로 편하게 요청을 보낼 수있게 된 나. 기존에 Postman이나 curl로 직접 보내던 REST API 요청을</p>
<pre><code class="lang-plaintext">GET /
</code></pre>
<p>과 같은 요청으로 Kibana 내부에서 바로 실행하며 결과를 확인할 수 있다.</p>
<p><img src="https://blog.kakaocdn.net/dna/ci8vU0/dJMcacaVnon/AAAAAAAAAAAAAAAAAAAAAIo78uWlk3kzJOwcHG86uyUW_4LMS1dWLY69W6SkmjKu/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=ot6uxKUuiegS66GJ%2FYmQHh8NMTg%3D" alt /></p>
<p>👉 훨씬 간편하게 실행할 수 있다. 따봉!</p>
]]></content:encoded></item><item><title><![CDATA[[Elasticsearch] Elasticsearch 입문하기]]></title><description><![CDATA[-elastic
▶ 들어가며
이번 프로젝트에서 조회 및 검색 기능 구현을 맡아 개발을 진행했다.Elasticsearch 없이 검색어 자동완성과 검색/조회 기능을 직접 구현하면서, 전체 검색 흐름과 동작 방식에 대해 많이 고민해볼 수 있었다. 이 과정에서 Elasticsearch라는 도구를 접하게 되었는데, 살펴보니 검색, 정렬, 필터링, 자동완성 등주요 기능들이 내가 구현해야 했던 기능들과 거의 정확히 일치했다.
단순히 “검색 엔진이다”라는 인...]]></description><link>https://blog.finders.it.kr/elasticsearch-elasticsearch</link><guid isPermaLink="true">https://blog.finders.it.kr/elasticsearch-elasticsearch</guid><dc:creator><![CDATA[주보경]]></dc:creator><pubDate>Mon, 02 Feb 2026 15:26:56 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://blog.kakaocdn.net/dna/c1Uczp/dJMcabXmiz5/AAAAAAAAAAAAAAAAAAAAAG8dLWTSRtM_WRlDpZFGEcu7FRBdGeon7RihMOKlPMm0/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=%2FaS47K5CWXgbaVFEcpedbEvbCIo%3D" alt /></p>
<p>-elastic</p>
<h2 id="heading-4pa2ioutpoywtoqwgoupsa">▶ 들어가며</h2>
<p>이번 프로젝트에서 <strong>조회 및 검색 기능 구현을 맡아</strong> 개발을 진행했다.<br />Elasticsearch 없이 검색어 자동완성과 검색/조회 기능을 직접 구현하면서, 전체 검색 흐름과 동작 방식에 대해 많이 고민해볼 수 있었다. 이 과정에서 <strong>Elasticsearch</strong>라는 도구를 접하게 되었는데, 살펴보니 검색, 정렬, 필터링, 자동완성 등<br /><strong>주요 기능들이 내가 구현해야 했던 기능들과 거의 정확히 일치</strong>했다.</p>
<p>단순히 “검색 엔진이다”라는 인상을 넘어서,<br />이를 도입하면 <strong>검색 기능 개선 측면에서도 충분히 이야기해볼 만한 포인트가 많겠다는 생각</strong>이 들었고,<br />자연스럽게 Elasticsearch를 제대로 공부해보고 싶어졌다.</p>
<p>이번 카테고리는<br />👉 Elasticsearch를 처음 접하는 입장에서 <strong>개념을 최대한 쉽게 정리하고,</strong><br />👉 이후에는 실제로 어떻게 활용할 수 있는지까지 <strong>딥다이브</strong>하는 것을 목표로 한다.</p>
<hr />
<h2 id="heading-elasticsearch">▶ Elasticsearch가 할 수 있는 일들</h2>
<p>Elasticsearch가 제공하는 대표적인 기능들을 정리해보면 다음과 같다.</p>
<ul>
<li><p><strong>조회 기능 향상</strong></p>
</li>
<li><p>정렬 기능 향상</p>
</li>
<li><p><strong>필터링 서비스에 최적화</strong></p>
</li>
<li><p><strong>검색어 자동완성</strong></p>
</li>
<li><p>동의어 기반 검색</p>
</li>
<li><p><strong>위치(Geo) 검색에 최적화</strong></p>
</li>
<li><p>데이터 양이 많아도 빠른 검색 성능</p>
</li>
<li><p>로그 및 기록 관리에 유용</p>
</li>
</ul>
<p>정리해보니,<br />👉 <strong>내가 구현해야 했던 기능들과 거의 정확히 겹친다.</strong></p>
<p>많은 우리나라 대기업에서 (쿠팡이나 배민과 같은) 실제로 Elasticsearch를 사용하여 대규모 데이터들을 관리하고 있기때문에 이번 기회에 제대로 공부하면 좋을듯..</p>
<hr />
<h2 id="heading-elasticsearch-1">▶ Elasticsearch가 뭔데 그래서?</h2>
<p>처음 감을 잡기 위해 <strong>아주 단순하게 한 줄로 정리하면,</strong></p>
<blockquote>
<p>Elasticsearch는 검색과 데이터 분석을 아주 잘하는 데이터베이스다.</p>
</blockquote>
<p>물론 엄밀하게 말하면 전통적인 의미의 DB(RDBMS)와는 다르다!!</p>
<p>그래도 <strong>처음 접근할 때는 DB라고 생각하는 게 가장 편하다</strong>고 느꼈다. <s>(기능이 아주 많은 도구??)</s></p>
<p>우리가 많이 사용하는 RDBMS인 <strong>MySQL</strong>과 비슷한 점도 많고, 차이점도 명확하다.</p>
<p>핵심만 정리하면</p>
<ul>
<li><p>Elasticsearch는</p>
<ul>
<li><p><strong>검색(Search), 조회(Query)</strong></p>
</li>
<li><p><strong>데이터 분석(Analytics)</strong></p>
</li>
</ul>
</li>
</ul>
<p>    이 2가지에 특화되어 기능을 구현시켜주는 도구이다.</p>
<hr />
<h2 id="heading-elasticsearch-2">▶ Elasticsearch는 어떻게 다루나?</h2>
<p>MySQL을 사용할 때를 떠올려보면,</p>
<ul>
<li><p>DataGrip</p>
</li>
<li><p>MySQL Workbench</p>
</li>
</ul>
<p>같은 <strong>GUI 툴</strong>을 사용해서 직접 데이터 삽입, 삭제, 조회를 보다 가독성있게 관리한다.</p>
<p>Elasticsearch도 마찬가지다.</p>
<p><img src="https://blog.kakaocdn.net/dna/cDoeMd/dJMcaivnCRX/AAAAAAAAAAAAAAAAAAAAANv5PB_IdY55YJt_d2IOJq5h2qukll5aIhrhggPj1gbe/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;expires=1772290799&amp;allow_ip=&amp;allow_referer=&amp;signature=Q4oz8idKumsL09F1UOmiKpYYKbk%3D" alt /></p>
<p>-kibana</p>
<p>Elasticsearch는 <strong>Kibana</strong>라는 <strong>GUI 도구</strong>를 함께 사용한다.</p>
<ul>
<li><p>Elasticsearch 데이터 조회</p>
</li>
<li><p>쿼리 테스트</p>
</li>
<li><p>로그 시각화</p>
</li>
<li><p>인덱스 관리</p>
</li>
</ul>
<p>등을 훨씬 편하게 할 수 있다. 나는 <strong>Kibana 기준으로 명령어와 쿼리를 공부할 예정</strong>이다.  </p>
<p>(MySQL에서 터미널을 사용하듯이 curl 같은 명령어를 사용해서 직접 요청을 보내는 것이 기본.)</p>
]]></content:encoded></item><item><title><![CDATA[[gcp] 외부 Ip 없는 안전한 서버 구축기]]></title><description><![CDATA[Finders 프로젝트의 백엔드 인프라를 설계하며, Zero-Trust 아키텍처를 구축한 과정을 공유합니다.
단순히 서버를 띄우는 것을 넘어, 외부 노출을 최소화하고 권한을 체계적으로 관리하는 법을 고민했습니다..

1. 왜 VPC와 Subnet을 설계해야 하는가?
인프라의 가장 밑단인 VPC(Virtual Private Cloud)는 구글 클라우드에서 만드는 우리만의 사유지입니다. 이는 외부와 완전히 격리되어 있으며, 우리는 이 내부를 보안 ...]]></description><link>https://blog.finders.it.kr/gcp-ip</link><guid isPermaLink="true">https://blog.finders.it.kr/gcp-ip</guid><category><![CDATA[GCP]]></category><category><![CDATA[Security]]></category><category><![CDATA[infrastructure]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[장지요]]></dc:creator><pubDate>Tue, 27 Jan 2026 08:18:15 GMT</pubDate><content:encoded><![CDATA[<p>Finders 프로젝트의 백엔드 인프라를 설계하며, <strong>Zero-Trust 아키텍처</strong>를 구축한 과정을 공유합니다.</p>
<p>단순히 서버를 띄우는 것을 넘어, 외부 노출을 최소화하고 권한을 체계적으로 관리하는 법을 고민했습니다..</p>
<hr />
<h2 id="heading-1-vpc-subnet">1. 왜 VPC와 Subnet을 설계해야 하는가?</h2>
<p>인프라의 가장 밑단인 <strong>VPC(Virtual Private Cloud)</strong>는 구글 클라우드에서 만드는 우리만의 사유지입니다. 이는 외부와 완전히 격리되어 있으며, 우리는 이 내부를 보안 등급에 따라 <strong>Subnet</strong>이라는 논리적 구역으로 쪼개 관리합니다.</p>
<ul>
<li><p><strong>App Subnet (Private)</strong>: 백엔드 서버(GCE)가 있는 공간입니다. 외부 IP가 없어 인터넷에서 직접 접근할 수 없습니다.</p>
</li>
<li><p><strong>DB Subnet (Private/Logical)</strong>: 데이터 관련 자원을 위해 비워둔 예비 공간입니다.</p>
</li>
</ul>
<blockquote>
<p>💡 <mark>외부 IP(Public IP)를 모두 제거</mark>함으로써, 외부의 Brute-force 공격이나 포트 스캐닝 가능성을 물리적으로 차단했습니다!</p>
</blockquote>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769447773936/30f05302-8224-4ac0-afbe-84a24a292f5b.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-2-db-psa">2. 서버와 DB의 통신: 비공개 서비스 액세스(PSA)의 원리</h2>
<p>설계 과정에서 깨달은 점은 Cloud SQL은우리 VPC 안에 직접 설치되는 것이 아니라, 구글이 관리하는 별도의 ‘구글 서비스 VPC’에 존재한다는 점이었습니다. 그런데 어떻게 우리는 <strong><em>내부 IP로 DB에 접속할 수 있는 걸까요?</em></strong></p>
<p><strong>VPC 피어링과 PSA (Private Service Access)</strong></p>
<p>격리된 두 동네(우리 VPC와 구글 VPC)가 통신하기 위해 <strong>비공개 서비스 엑세스(PSA)</strong>라는 전용 통로를 구축했습니다.</p>
<ol>
<li><p><strong>VPC 피어링(Peering)</strong>: 우리 VPC와 구글 SQL VPC 사이에 거대한 전용 고속도로를 뚫습니다.</p>
</li>
<li><p><strong>내부 IP 할당</strong>: 우리 VPC의 남는 IP 대역을 DB용으로 정해두면, 구글 DB가 그 주소를 달고 우리 내부망인 것처럼 행동합니다</p>
</li>
<li><p><strong>결과</strong>: 우리 서버는 인터넷을 거치지 않고, 구글 클라우드 내부 망을 통해 DB 주소로 직접 패킷을 보냅니다.</p>
</li>
</ol>
<hr />
<h2 id="heading-3">3. 외부와의 소통은 어떻게 할까?</h2>
<p>외부 IP가 없는데 사용자는 어떻게 접속하고, 서버는 어떻게 외부 API를 호출할까요? 여기서 입구(Inbound)와 출구(Outbound)의 분리가 일어납니다.</p>
<p><strong>📥 입구: Cloudflare Tunnel (Inbound)</strong><br />사용자의 요청은 서버로 직접 오지 않습니다. <strong>Cloudflare Tunnel</strong>이라는 가상의 보안 터널을 통해 들어옵니다. 서버는 밖으로 문을 열어두지 않아도, 이 터널을 통해 안전하게 요청만 전달받습니다.</p>
<p><strong>📥 출구: Cloud NAT (Outbound)</strong><br />서버가 샌드온(Sendon) 같은 외부 API에 요청을 보내야 할 때, <strong>Cloud NAT</strong>이 신분증 역할을 해줍니다.</p>
<ul>
<li><p><strong>IP Masquerading</strong>: 서버의 비공개 IP를 감추고, Cloud NAT의 고정된 외부 IP를 달고 밖으로 나갑니다.</p>
</li>
<li><p><strong>Whitelisting</strong>: 서버가 10대든 100대든, 샌드온에는 Cloud NAT의 IP만 등록하면 모든 서버가 통신할 수 있습니다.</p>
</li>
</ul>
<hr />
<h2 id="heading-4-redis">4. 마치며: 비어있는 서브넷과 Redis</h2>
<p>결과적으로 제가 직접 만든 DB Subnet은 현재 비어있는 상태입니다. 향후 Redis 캐시 서버를 구축하거나 별도의 Proxy 서버를 둘 때, 이 구역을 이용할 수 있습니다.</p>
<p>현재 Redis 캐시 서버는 앱 서버 내에서 Docker 컨테이너로 운영 중이며, 논리적으로는 App Subnet에 위치합니다. 이를 별도 인스턴스로 분리한다면, DB Subnet으로 넣을 수 있겠죠?☺️</p>
]]></content:encoded></item><item><title><![CDATA[[왜?] 안녕하세요 김덕환입니다.]]></title><description><![CDATA[예,,, 저는 Finders 팀의 백엔드 개발자입니다.
저를 소개하자면,
Log8 - 김덕환 개인 블로그
이런 사람입니다.
저희끼리 블로그를 작성해보기 위해서 hashnode를 만들게 되었습니다!
다들 인사 한번씩 해주세요!!
조만간 hashnode 만드는 법부터 시작해서 블로그 글을 올려보겠습니다!]]></description><link>https://blog.finders.it.kr/wyznd9dioyvioufle2vmoyeuoyalcdquydrjzxtmzjsnoxri4jri6qu</link><guid isPermaLink="true">https://blog.finders.it.kr/wyznd9dioyvioufle2vmoyeuoyalcdquydrjzxtmzjsnoxri4jri6qu</guid><dc:creator><![CDATA[IISweetHeartII]]></dc:creator><pubDate>Sun, 18 Jan 2026 15:02:59 GMT</pubDate><content:encoded><![CDATA[<p>예,,, 저는 Finders 팀의 백엔드 개발자입니다.</p>
<p>저를 소개하자면,</p>
<p><a target="_blank" href="https://log8.kr">Log8 - 김덕환 개인 블로그</a></p>
<p>이런 사람입니다.</p>
<p>저희끼리 블로그를 작성해보기 위해서 hashnode를 만들게 되었습니다!</p>
<p>다들 인사 한번씩 해주세요!!</p>
<p>조만간 hashnode 만드는 법부터 시작해서 블로그 글을 올려보겠습니다!</p>
]]></content:encoded></item></channel></rss>