<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>개굴 레몬</title>
        <link>https://velog.io/</link>
        <description>도전하며 굴러가는 돌멩이, 인생 마라톤 중 😎</description>
        <lastBuildDate>Sun, 29 Mar 2026 15:29:45 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>개굴 레몬</title>
            <url>https://velog.velcdn.com/images/sunset_1839/profile/6e7e37a1-9b62-4c57-b194-0f8d139c3d13/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 개굴 레몬. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sunset_1839" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Hadoop + Spark 맛보기]]></title>
            <link>https://velog.io/@sunset_1839/Hadoop-Spark-%EB%A7%9B%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/Hadoop-Spark-%EB%A7%9B%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sun, 29 Mar 2026 15:29:45 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p>팀 내부에서 그레이존 스터디를 통해 프로젝트를 진행하며 처음 접하거나, 모르는 부분을 탐구하고자 하였다. 첫번째 스터디에서는 신입으로 입사한 나를 위해 광고 도메인의 RTB를 이해하는 시간을 가졌고, 두번째 스터디에서는 스파크, 세번째는 하둡 + 스파크를 공부해보았고, 이 글은 세번째 스터디를 준비하며 공부한 내용을 기록한 글이다.</p>
<hr>
<h1 id="하둡을-왜사용할까">하둡을 왜사용할까</h1>
<ol>
<li><p>분산 저장이 가능하다
데이터를 한 서버에 몰아넣는 게 아니라, 수백~수천 대 서버에 쪼개서 저장한다. 한 서버가 죽어도 다른 서버에 복제본이 있으니까 데이터가 안 날라간다.</p>
</li>
<li><p>대용량 배치 처리에 최적화됐다
MapReduce 방식으로 데이터를 병렬 처리하니까, 혼자 하면 며칠 걸릴 연산도 수백 대가 나눠서 빠르게 끝낼 수 있다.</p>
</li>
<li><p>확장이 쉽다
서버 더 추가하면 그냥 성능이 늘어난다. 수평 확장(Scale-out)이 자연스럽게 된다.</p>
</li>
<li><p>다양한 데이터 형식을 다 받는다
정형 데이터뿐 아니라 로그, 이미지, 텍스트 같은 비정형 데이터도 그냥 저장해두고 나중에 필요할 때 처리하면 된다.</p>
</li>
</ol>
<p>예를들면 이해가 쉽다. 로그가 하루에 몇억건 이상 쌓이는 서비스가 있다고 가정해보자. 서버 하나에 해당 로그를 다 넣을 수도 있겠지만, 시간이 지나 로그가 쌓인다면 쿼리 성능은 처참할 것이다. 또한 DB서버가 다운될 수도 있다. 이전에 배웠던 인덱스 튜닝, 파티셔닝등을 해도 한계가 명확하다.</p>
<p>고성능 서버 한대로 업그레이드 하면 되지 않을까? 
그래도 1년 뒤에는 데이터가 넘칠게 뻔하고, 서버 하나가 죽으면 전체적인 장애가 발생할 것이다.</p>
<p>그렇다면 하둡을 도입해보자. 일반 서버 20대에 하둡을 구성하면 어떻게 될까?</p>
<p>데이터는 20대에 나눠서 저장되고, 각 데이터는 복제된다. 서버 한두 대가 죽어도 데이터는 안전하다. 쿼리를 날리면 20대가 동시에 나눠서 처리하니까 속도도 압도적으로 빠르다. 비용도 고성능 서버 한 대보다 일반 서버 20대가 더 쌀 수 있다. 나중에 데이터가 더 늘어나면 서버 몇 대만 추가하면 그만이다.
로그뿐만 아니라 이미지, 텍스트, JSON 등 형식이 제각각인 데이터도 일단 하둡에 다 던져놓고 나중에 필요할 때 꺼내서 분석하면 된다. 기존 RDB였다면 스키마를 미리 정의해야 했겠지만, 하둡은 그런 제약이 없다.
결국 하둡은 &quot;데이터가 너무 많아서 기존 방식으로는 감당이 안 될 때&quot; 꺼내드는 도구다. 모든 서비스에 필요한 건 아니지만, 데이터가 폭발적으로 쌓이는 순간 하둡 같은 분산 시스템은 선택이 아니라 필수가 된다.</p>
<hr>
<h1 id="목표">목표</h1>
<ol>
<li>하둡이 무엇인가?왜 사용하는지 알아본다.</li>
<li>하둡을 내부 HFDS에서 CSV파일을 읽는다.</li>
<li>도커를 통해 마스터1, 워커4개를 띄워 각각 Spark 설정을 한다.</li>
<li>워커 1개, 2개, 4개를 띄워서 분산처리를 하였을떄 속도 비교를 한다.</li>
</ol>
<hr>
<h2 id="아키텍처">아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/48971457-7540-436f-a1dc-cb236bcb4e86/image.png" alt=""></p>
<pre><code>Mac (로컬)                         Docker (spark-net)
──────────────────                 ──────────────────────────────
Hadoop HDFS                        spark-driver
  NameNode                           └── benchmark.py 실행
  DataNode                               ↓ job 제출
  /data/amazon_big.csv           spark-master
  (포트 9870, 9000)                   └── task 배분
                                  spark-worker-1  (2core 2GB)
                                  spark-worker-2  (2core 2GB)
                                  spark-worker-3  (2core 2GB)
                                  spark-worker-4  (2core 2GB)
                                       ↓ HDFS 읽기
                                  192.168.x.x:9000 (Mac 로컬 IP)</code></pre><h3 id="왜-이-구조인가">왜 이 구조인가</h3>
<p>처음에는 Docker 안에 Hadoop까지 포함하려 했지만, Mac M1 (ARM64) 환경에서
Hadoop 이미지 호환 문제가 계속 발생했다. 결론적으로 Hadoop은 로컬 Mac에 직접
설치하고, Spark만 Docker로 구성하는 방식을 택했다.</p>
<p>Docker 컨테이너가 로컬 HDFS에 접근할 때는 localhost를 쓸 수 없다. localhost는
각 컨테이너 자신을 가리키기 때문이다. 대신 docker-compose.yml의 extra_hosts에
host.docker.internal:host-gateway를 설정하면, 컨테이너 안에서 host.docker.internal
이라는 주소로 Mac에 접근할 수 있다. Docker가 내부적으로 host-gateway를 Mac의 실제
IP로 변환해준다.</p>
<h3 id="spark-dirver가-로컬의-benchmarkpy를-어떻게-실행해주는-원리">spark-dirver가 로컬의 benchmark.py를 어떻게 실행해주는 원리</h3>
<p><code>docker-compose.yml</code>에 이 설정이 있다:</p>
<pre><code class="language-jsx">spark-driver:
  volumes:
    - ../benchmark.py:/app/benchmark.py  

    이 설정이 하는 일:
로컬 Mac                        Docker spark-driver 컨테이너
─────────────────               ──────────────────────────
~/JupyterProject/               /app/
  benchmark.py       ←마운트→     benchmark.py</code></pre>
<p>마운트는 복사가 아니라 연결이다. 로컬 파일과 컨테이너 안의 파일이 같은 파일을 가리킨다. 로컬에서 수정하면 컨테이너 안에서도 즉시 반영된다.</p>
<pre><code>
&lt;실행 흐름&gt;

```jsx
docker exec spark-driver python3 /app/benchmark.py
    ↓
1. spark-driver 컨테이너 안에서 python3 실행
2. /app/benchmark.py 읽음 (= 로컬 benchmark.py)
3. SparkSession 생성 → spark-master:7077 연결
4. HDFS에서 데이터 읽기 → 192.168.x.x:9000
5. Worker들한테 task 분배
6. 결과를 /tmp에 저장</code></pre><hr>
<h2 id="환경-구성">환경 구성</h2>
<h3 id="로컬-환경">로컬 환경</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>버전</th>
</tr>
</thead>
<tbody><tr>
<td>OS</td>
<td>macOS (Apple Silicon M1)</td>
</tr>
<tr>
<td>Hadoop</td>
<td>3.4.3 (Homebrew)</td>
</tr>
<tr>
<td>Java</td>
<td>OpenJDK 11</td>
</tr>
<tr>
<td>Python</td>
<td>3.12 (uv 가상환경)</td>
</tr>
</tbody></table>
<h3 id="docker-환경">Docker 환경</h3>
<table>
<thead>
<tr>
<th>컨테이너</th>
<th>이미지</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>spark-master</td>
<td>custom-spark:3.5.0</td>
<td>Spark Master</td>
</tr>
<tr>
<td>spark-worker-1~4</td>
<td>custom-spark:3.5.0</td>
<td>Spark Worker (2core, 2GB)</td>
</tr>
<tr>
<td>spark-driver</td>
<td>custom-spark:3.5.0</td>
<td>benchmark.py 실행</td>
</tr>
</tbody></table>
<p><code>custom-spark:3.5.0</code>은 <code>apache/spark:3.5.0</code> 기반으로 <code>pandas</code>, <code>matplotlib</code>, <code>pyspark</code>를 추가 설치한 커스텀 이미지다.</p>
<h2 id="왜-driver를-로컬에서-실행안하고-도커-내부로-두었는지">왜 driver를 로컬에서 실행안하고 도커 내부로 두었는지</h2>
<h2 id="문제-상황">문제 상황</h2>
<p>처음에는 benchmark.py를 로컬 PyCharm에서 직접 실행했다. count, GroupBy는 성공했지만 Sort, Join 같은 shuffle이 필요한 작업에서 계속 실패했다.</p>
<pre><code>Failed to connect to /172.20.0.4:45565
Operation timed out</code></pre><hr>
<h2 id="shuffle이-뭔가">shuffle이 뭔가</h2>
<p>Sort나 Join 같은 작업은 여러 Worker에 흩어진 데이터를 재분배해야 한다.</p>
<pre><code>Worker-1: [5, 2, 8]
Worker-2: [1, 9, 3]
          ↓ 전체 정렬하려면 데이터를 섞어야 함
Worker-1: [1, 2, 3]  ← Worker-2 데이터 일부를 받아옴
Worker-2: [5, 8, 9]</code></pre><p>이 데이터 재분배 과정을 shuffle이라고 한다. shuffle이 끝나면 Worker들은 결과를 Driver에게 돌려줘야 한다.</p>
<hr>
<h2 id="driver가-docker-밖로컬-mac에-있을-때-생기는-문제">Driver가 Docker 밖(로컬 Mac)에 있을 때 생기는 문제</h2>
<pre><code>로컬 Mac (Driver)              Docker 내부 네트워크
──────────────────             ──────────────────────
192.168.219.103                172.20.0.4 (worker-1)
                               172.20.0.5 (worker-2)
                               172.20.0.6 (worker-3)
                               172.20.0.7 (worker-4)</code></pre><p>Worker들이 shuffle 결과를 자신의 내부 IP에 올려놓는다.</p>
<pre><code>Worker-1 → &quot;결과를 172.20.0.4:45565 에 올려뒀어&quot;
Driver   → 172.20.0.4:45565 접근 시도
         → Connection timed out ❌</code></pre><p><code>172.20.0.x</code>는 Docker 내부 네트워크 주소다. 로컬 Mac에서는 이 주소로 직접 접근이 안 된다. Docker가 포트 포워딩(8081, 8082 등)으로 일부 포트를 열어두긴 하지만, shuffle에서 사용하는 포트는 랜덤으로 생성되기 때문에 포워딩이 안 돼있다.</p>
<p>결국 Driver가 Worker의 shuffle 결과를 가져올 수 없어서 작업이 실패한다.</p>
<hr>
<h2 id="driver를-docker-안으로-이동하면-해결되는-이유">Driver를 Docker 안으로 이동하면 해결되는 이유</h2>
<p>spark-driver 컨테이너를 추가해서 Driver도 같은 Docker 네트워크 안에 넣었다.</p>
<pre><code>Docker spark-net (172.20.0.x 대역)
──────────────────────────────────
spark-driver   172.20.0.2  ← Driver
spark-master   172.20.0.3
spark-worker-1 172.20.0.4
spark-worker-2 172.20.0.5
spark-worker-3 172.20.0.6
spark-worker-4 172.20.0.7</code></pre><p>이제 Driver, Master, Worker가 전부 같은 <code>172.20.0.x</code> 대역 안에 있다.</p>
<pre><code>Worker-1 → &quot;결과를 172.20.0.4:45565 에 올려뒀어&quot;
Driver   → 172.20.0.4:45565 접근 시도
         → 같은 네트워크라 바로 접근 ✅</code></pre><p>별도의 포트 포워딩 없이도 컨테이너끼리 내부 IP로 자유롭게 통신할 수 있다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Driver 로컬 Mac</th>
<th>Driver Docker 안</th>
</tr>
</thead>
<tbody><tr>
<td>Driver IP</td>
<td>192.168.219.103</td>
<td>172.20.0.2</td>
</tr>
<tr>
<td>Worker IP</td>
<td>172.20.0.x</td>
<td>172.20.0.x</td>
</tr>
<tr>
<td>같은 네트워크?</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>shuffle 통신</td>
<td>❌ 실패</td>
<td>✅ 성공</td>
</tr>
<tr>
<td>count, GroupBy</td>
<td>✅ 성공</td>
<td>✅ 성공</td>
</tr>
<tr>
<td>Sort, Join</td>
<td>❌ 실패</td>
<td>✅ 성공</td>
</tr>
</tbody></table>
<p>count와 GroupBy가 성공한 이유는 shuffle이 없거나 적어서 Worker → Driver 단방향 통신만으로 끝나기 때문이다. Sort와 Join은 Worker들끼리 + Driver와 양방향 통신이 필요해서 같은 네트워크에 있어야 한다.</p>
<hr>
<h2 id="폴더-구조">폴더 구조</h2>
<pre><code>JupyterProject/
  ├── docker/
  │     ├── docker-compose.yml
  │     └── Dockerfile
  ├── env/
  │     └── hadoop.env
  ├── resource/
  │     ├── amazon.csv          (원본 1,465행)
  │     └── amazon_big.csv      (500배 복제 732,500행)
  ├── benchmark.py
  ├── benchmark_results.csv     (결과)
  └── benchmark_result.png      (차트)</code></pre><hr>
<h2 id="실행-방법">실행 방법</h2>
<h3 id="1-로컬-hdfs-시작">1. 로컬 HDFS 시작</h3>
<pre><code class="language-bash"># HDFS 시작
start-dfs.sh

# 프로세스 확인
jps
# NameNode, DataNode, SecondaryNameNode 세 개가 보여야 함</code></pre>
<h3 id="2-hdfs에-데이터-업로드-최초-1회">2. HDFS에 데이터 업로드 (최초 1회)</h3>
<pre><code class="language-bash">cd ~/PycharmProjects/JupyterProject

# 테스트 데이터 생성 (500배 복제)
python3 -c &quot;
import pandas as pd
df = pd.read_csv(&#39;./resource/amazon.csv&#39;)
big_df = pd.concat([df] * 500, ignore_index=True)
big_df.to_csv(&#39;./resource/amazon_big.csv&#39;, index=False)
print(f&#39;생성 완료: {len(big_df):,}행&#39;)
&quot;

# HDFS 업로드
hdfs dfs -mkdir -p /data
hdfs dfs -put ./resource/amazon_big.csv /data/amazon_big.csv
hdfs dfs -ls /data</code></pre>
<h3 id="3-docker-spark-클러스터-시작">3. Docker Spark 클러스터 시작</h3>
<pre><code class="language-bash">cd docker
docker-compose up -d
docker logs spark-master | tail -10</code></pre>
<h3 id="4-벤치마크-실행">4. 벤치마크 실행</h3>
<pre><code class="language-bash">docker exec spark-driver python3 /app/benchmark.py</code></pre>
<p>한 번 실행으로 워커 1개 → 2개 → 4개 순서로 자동 테스트된다.</p>
<h3 id="5-결과-복사">5. 결과 복사</h3>
<pre><code class="language-bash">docker cp spark-driver:/tmp/benchmark_results.csv ~/PycharmProjects/JupyterProject/
docker cp spark-driver:/tmp/benchmark_result.png ~/PycharmProjects/JupyterProject/</code></pre>
<hr>
<h2 id="벤치마크-태스크-설명">벤치마크 태스크 설명</h2>
<h3 id="1-hdfs-읽기--count">1. HDFS 읽기 + count</h3>
<p>HDFS에서 CSV를 읽고 전체 행 수를 세는 가장 기본적인 작업이다.</p>
<pre><code class="language-python">df.count()</code></pre>
<p>Spark는 lazy evaluation 방식이라 <code>.count()</code> 같은 액션이 호출돼야 실제로 데이터를 읽는다. 워커 수가 많을수록 파일을 병렬로 읽어 빠르다.</p>
<h3 id="2-groupby-집계">2. GroupBy 집계</h3>
<p>첫 번째 컬럼(<code>product_id</code>) 기준으로 그룹화해서 각 그룹의 행 수를 세는 작업이다.</p>
<pre><code class="language-python">df.groupBy(first_col).count().collect()</code></pre>
<p>같은 키를 가진 데이터가 여러 워커에 흩어져 있으면 한 곳으로 모아야 집계할 수 있다. 워커가 많을수록 병렬로 처리해 빠르다.</p>
<h3 id="3-sort-top-1000">3. Sort Top 1000</h3>
<p>숫자형 컬럼 기준으로 내림차순 정렬 후 상위 1000개를 가져오는 작업이다.</p>
<pre><code class="language-python">df.orderBy(F.col(sort_col).desc()).limit(1000).collect()</code></pre>
<p>전체 데이터를 다 봐야 정렬할 수 있어서 데이터 이동(shuffle)이 발생한다. 분산 처리 효과가 잘 드러나는 작업이다.</p>
<h3 id="4-self-join-50만-rows">4. Self Join (50만 rows)</h3>
<p>같은 데이터를 두 개로 나눠 서로 Join하는 작업이다.</p>
<pre><code class="language-python">df1 = df.limit(500_000).withColumn(&quot;_key&quot;, F.monotonically_increasing_id())
df2 = df.limit(500_000).withColumn(&quot;_key&quot;, F.monotonically_increasing_id())
df1.join(df2, &quot;_key&quot;).count()</code></pre>
<p>Join은 같은 키를 가진 데이터를 한 워커로 모아야 해서 shuffle이 가장 많이 발생한다. 워커 수 차이가 가장 크게 나타나는 작업이다.</p>
<h3 id="5-hdfs-parquet-쓰기">5. HDFS Parquet 쓰기</h3>
<p>처리 결과를 다시 HDFS에 Parquet 형식으로 저장하는 작업이다.</p>
<pre><code class="language-python">df.limit(100_000).write.mode(&quot;overwrite&quot;).parquet(out_path)</code></pre>
<p>Parquet은 컬럼 기반 저장 포맷으로 CSV보다 빠르고 용량이 작다. HDFS 쓰기 속도는 디스크 I/O에 영향을 받아 워커 수 효과가 상대적으로 작다.</p>
<hr>
<h2 id="벤치마크-결과">벤치마크 결과</h2>
<p>데이터: 732,500행 (amazon_big.csv)</p>
<table>
<thead>
<tr>
<th>태스크</th>
<th>1개</th>
<th>2개</th>
<th>4개</th>
<th>개선율</th>
</tr>
</thead>
<tbody><tr>
<td>HDFS 읽기 + count</td>
<td>0.76초</td>
<td>0.56초</td>
<td>0.29초</td>
<td>2.6배</td>
</tr>
<tr>
<td>GroupBy 집계</td>
<td>1.38초</td>
<td>1.16초</td>
<td>0.81초</td>
<td>1.7배</td>
</tr>
<tr>
<td>Sort Top 1000</td>
<td>1.48초</td>
<td>0.98초</td>
<td>0.80초</td>
<td>1.85배</td>
</tr>
<tr>
<td>Self Join (50만)</td>
<td>2.09초</td>
<td>1.21초</td>
<td>0.97초</td>
<td>2.15배</td>
</tr>
<tr>
<td>Parquet 쓰기</td>
<td>8.44초</td>
<td>6.80초</td>
<td>5.48초</td>
<td>1.54배</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/763bdb38-9255-42e7-b153-604fe30a611f/image.png" alt=""></p>
<h3 id="결과-분석">결과 분석</h3>
<p>워커 4개가 1개보다 평균 2배 빠르다. 이론상 4배여야 하는데 2배 정도인 이유는 모두 같은 물리 머신(Mac)에서 CPU를 나눠 쓰기 때문이다. 실제 물리적으로 다른 서버에서 실행하면 더 큰 차이가 난다.</p>
<p>Self Join이 가장 큰 효과(2.15배)를 보이는 이유는 shuffle이 가장 많이 발생하는 작업이라 워커가 많을수록 병렬 처리 효과가 크기 때문이다. Parquet 쓰기가 가장 작은 효과(1.54배)를 보이는 이유는 디스크 I/O가 병목이라 워커 수보다 저장 속도가 더 영향을 주기 때문이다.</p>
<hr>
<h2 id="그-외-알아본-것">그 외 알아본 것</h2>
<h3 id="start-dfssh--hdfs-시작-스크립트">start-dfs.sh — HDFS 시작 스크립트</h3>
<p>HDFS를 구성하는 프로세스들을 한 번에 시작하는 스크립트다</p>
<p>내부적으로 이 세 가지를 순서대로 실행한다..</p>
<pre><code class="language-jsx">NameNode 시작 → DataNode 시작 → SecondaryNameNode 시작</code></pre>
<table>
<thead>
<tr>
<th>프로세스</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>NameNode</td>
<td>파일이 어느 DataNode에 있는지 기록하는 &quot;지도&quot; 역할</td>
</tr>
<tr>
<td>DataNode</td>
<td>실제 파일 데이터를 블록으로 쪼개서 저장하는 창고</td>
</tr>
<tr>
<td>SecondaryNameNode</td>
<td>NameNode의 메타데이터를 주기적으로 백업</td>
</tr>
</tbody></table>
<pre><code class="language-jsx">ash

`start-dfs.sh   # 시작
stop-dfs.sh    # 종료`</code></pre>
<hr>
<h2 id="네임노드--데이터노드">네임노드 &amp; 데이터노드</h2>
<p>HDFS는 파일을 저장할 때 두 가지 역할로 나눠서 관리한다.</p>
<h3 id="네임노드-namenode">네임노드 (NameNode)</h3>
<p><strong>파일의 위치 지도를 관리하는 관리자</strong>다.</p>
<p>실제 데이터를 저장하지는 않고, &quot;어떤 파일이 어느 데이터노드에 있는지&quot;만 기록한다. 도서관으로 치면 책 자체가 아니라 <strong>책의 목차와 위치를 적어둔 안내 데스크</strong>다.</p>
<p>네임노드가 기억하는 것들:</p>
<ul>
<li>파일 이름, 크기, 권한</li>
<li>파일이 몇 개의 블록으로 쪼개졌는지</li>
<li>각 블록이 어느 데이터노드에 저장됐는지</li>
</ul>
<pre><code>/data/amazon.csv
  └── 블록1 → 데이터노드 3번, 7번, 15번
  └── 블록2 → 데이터노드 2번, 9번, 11번</code></pre><p>네임노드는 <strong>단 한 대</strong>만 존재한다. 그래서 네임노드가 죽으면 전체 HDFS가 멈춘다. 이걸 단일 장애점(SPOF)이라고 하는데, 이를 해결하려고 고가용성(HA) 설정에서는 네임노드를 두 대 띄워서 하나가 죽으면 다른 하나가 이어받는 구조를 쓴다.</p>
<hr>
<h3 id="데이터노드-datanode">데이터노드 (DataNode)</h3>
<p><strong>실제 파일 데이터를 블록 단위로 저장하는 일꾼</strong>이다.</p>
<p>네임노드의 지시에 따라 블록을 저장하고, 읽고, 복제한다. 수백~수천 대가 붙을 수 있고, 대수가 늘수록 저장 용량과 처리 속도가 함께 늘어난다.</p>
<p>데이터노드가 하는 일:</p>
<ul>
<li>파일 블록을 로컬 디스크에 저장</li>
<li>주기적으로 네임노드에 &quot;나 살아있어요&quot; 신호(Heartbeat) 전송</li>
<li>복제본이 부족하면 다른 데이터노드로 블록 복사</li>
</ul>
<pre><code>Heartbeat가 끊기면?
  네임노드가 해당 데이터노드를 Dead로 간주
  → 그 노드에 있던 블록을 다른 노드에 자동 복제
  → 복제 수(dfs.replication) 맞출 때까지 계속</code></pre><hr>
<h3 id="둘의-관계-요약">둘의 관계 요약</h3>
<p>파일을 읽을 때 흐름을 보면 역할이 명확해진다.</p>
<pre><code>클라이언트 → 네임노드에 물어봄 &quot;amazon.csv 어디 있어?&quot;
네임노드   → &quot;블록1은 3번 노드, 블록2는 9번 노드에 있어&quot;
클라이언트 → 데이터노드 3번, 9번에 직접 접근해서 데이터 읽음</code></pre><p>네임노드는 <strong>지도</strong>, 데이터노드는 <strong>창고</strong>다. 지도 없이는 창고를 찾을 수 없고, 창고 없이는 지도가 의미가 없다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[S3 이미지 업로드, 직접 업로드에서 Presigned URL로 바꾸기까지]]></title>
            <link>https://velog.io/@sunset_1839/S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%A7%81%EC%A0%91-%EC%97%85%EB%A1%9C%EB%93%9C%EC%97%90%EC%84%9C-Presigned-URL%EB%A1%9C-%EB%B0%94%EA%BE%B8%EA%B8%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@sunset_1839/S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%A7%81%EC%A0%91-%EC%97%85%EB%A1%9C%EB%93%9C%EC%97%90%EC%84%9C-Presigned-URL%EB%A1%9C-%EB%B0%94%EA%BE%B8%EA%B8%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 06 Jan 2026 14:01:48 GMT</pubDate>
            <description><![CDATA[<p>헬시플레이트의프로필 이미지를 <strong>Amazon S3에 업로드하는 기능</strong>을 구현하면서
<code>직접 업로드 방식</code>과 <code>Presigned URL 방식</code>을 모두 경험해보았다.</p>
<p>처음에는 S3 업로드에 대한 지식이 부족했기 때문에,
아무 고민 없이 가장 흔하고 직관적인 방식인 <strong>서버 직접 업로드</strong>를 선택했다.</p>
<blockquote>
<p>MultipartFile을 받아
<strong>클라이언트 → 서버 → S3</strong> 로 업로드하는 구조</p>
</blockquote>
<p>문제없이 잘 동작했고, 구현도 어렵지 않았다.
하지만 프로젝트를 진행하던 중, 프론트엔드 팀원이자 동네 친구에게 이런 제안을 받았다.</p>
<blockquote>
<p>💬 “Presigned URL 방식으로 바꾸는 게 어때?”</p>
</blockquote>
<p>솔직히 처음에는 <strong>“뭐가 그렇게 다른데?”</strong> 라는 생각이 먼저 들었다.</p>
<p>하지만 <strong>장점이 명확하다면 바꿀 이유는 충분하다고 생각했고</strong>, 공부 + 실제 구현을 해보면서
 <span style="background-color:#e6f4ea; padding:2px 6px; border-radius:4px;"><em>왜 많은 서비스들이 Presigned URL 방식을 선택하는지</em> </span> 몸소 이해하게 되었다.</p>
<p>이 글은 
<strong>직접 업로드 → Presigned URL 방식으로 전환한 이유와 구현 과정</strong>,
그리고 사용하면서 느낀 <strong>장단점과 고민 포인트</strong>를 정리한 기록이다.</p>
<hr>
<h2 id="왜-직접-업로드에서-presigned-url로-바꾸었을까">왜 직접 업로드에서 Presigned URL로 바꾸었을까?</h2>
<p>이 질문에 대한 답은 <strong>두 방식의 구조 차이</strong>를 보면 바로 이해된다.</p>
<h3 id="직접-업로드-방식">직접 업로드 방식</h3>
<pre><code>클라이언트 → 서버(MultipartFile) → S3</code></pre><ul>
<li>서버가 파일을 직접 받아서</li>
<li>다시 S3로 업로드</li>
</ul>
<h3 id="presigned-url-방식">Presigned URL 방식</h3>
<pre><code>클라이언트 → S3 (직접 업로드)</code></pre><ul>
<li>서버는 <strong>업로드 URL만 발급</strong></li>
<li>실제 파일 전송은 클라이언트가 S3와 직접 수행</li>
</ul>
<p>우리 서비스는 <strong>블로그형 서비스</strong>다. 즉, 이미지 업로드가 굉장히 잦다. 이 상황에서 모든 이미지가 서버를 거쳐 간다면?</p>
<ul>
<li>서버 IO 증가</li>
<li>네트워크 대역폭 사용 증가</li>
<li>트래픽 증가 시 병목 발생 가능성이 높아진다.</li>
</ul>
<p>👉 <strong>“굳이 서버가 파일을 중계해야 할까?”</strong>
이 질문에 대한 답이 Presigned URL이었다.</p>
<hr>
<h2 id="presigned-url이란-무엇일까">Presigned URL이란 무엇일까?</h2>
<p>Presigned URL은 <strong>AWS S3에 특정 작업을 허용해주는 임시 URL</strong>이다.</p>
<p>조금 풀어서 말하면,</p>
<blockquote>
<p>“이 URL을 가진 사람은
이 파일을,
이 시간 동안만,
이 권한으로 S3에 업로드(또는 다운로드)할 수 있다”
라는 정보를 <strong>서명(Sign)</strong> 해서 만든 URL이다.</p>
</blockquote>
<p>즉, Presigned URL에는 이미 다음 정보들이 포함되어 있다.</p>
<ul>
<li>어떤 <strong>S3 버킷</strong></li>
<li>어떤 <strong>객체(Key)</strong></li>
<li>어떤 <strong>행위 (PUT / GET)</strong></li>
<li><strong>유효 시간</strong></li>
<li>서버의 <strong>AWS 자격 증명으로 서명된 권한</strong></li>
</ul>
<p>그래서 클라이언트는 AWS Access Key를 전혀 몰라도 <span style="background-color:#e6f4ea; padding:2px 6px; border-radius:4px;">Presigned URL 하나만으로 S3에 직접 접근 </span>할 수 있다.</p>
<hr>
<h3 id="📌-presigned-url의-특징">📌 Presigned URL의 특징</h3>
<h4 id="✔️-권한-위임이다">✔️ 권한 위임이다</h4>
<ul>
<li>서버가 가진 S3 접근 권한을</li>
<li><strong>아주 제한적으로, 잠시 동안만</strong> 클라이언트에게 위임</li>
</ul>
<h4 id="✔️-임시적이다">✔️ 임시적이다</h4>
<ul>
<li>보통 <strong>몇 분 ~ 몇 십 분 후 자동 만료</strong></li>
<li>만료되면 URL은 즉시 무효</li>
</ul>
<h4 id="✔️-안전하다">✔️ 안전하다</h4>
<ul>
<li>AWS 자격 증명을 노출하지 않는다</li>
<li>서버가 허용한 작업만 가능하다</li>
</ul>
<h3 id="🔄-한-줄로-정리하면">🔄 한 줄로 정리하면</h3>
<blockquote>
<p>Presigned URL은
<strong>“서버가 S3 업로드 권한을 잠깐 빌려주는 링크”</strong>다.</p>
</blockquote>
<p>이 개념을 이해하고 나면,
왜 서버를 거치지 않고도 S3 업로드가 가능한지,
그리고 왜 많은 서비스들이 이 방식을 선택하는지가 자연스럽게 이어진다.</p>
<hr>
<h2 id="presigned-url의-치명적인-단점-고아-파일-문제">Presigned URL의 치명적인 단점: 고아 파일 문제</h2>
<p>Presigned URL을 적용하면서 <strong>가장 크게 체감한 단점</strong>이 바로 이 부분이었다.</p>
<p><strong>현재 플로우</strong></p>
<pre><code class="language-js">// 1. Presigned URL 요청
const { presignedUrl, fileUrl } = await getPresignedUrl();

// 2. S3 업로드
await uploadToS3(presignedUrl, imageFile);

// 3. 회원가입
await register({ profileImageUrl: fileUrl });</code></pre>
<p>❌ <strong>3번에서 실패 or 사용자가 회원가입 프로세스에서 이미지 등록 후 페이지를 나간다면?</strong>
S3에는 파일이 있을것이다. 하지만 DB에는 해당 fileUrl이 존재하지 않을 것이다. 이렇게 되면 S3에만 올라가있고 DB에서 조회할 수 없는 <strong>고아파일</strong>이 발생하는 것이다.</p>
<hr>
<h2 id="🛠️-해결-전략">🛠️ 해결 전략</h2>
<h3 id="1️⃣-파일-존재-검증">1️⃣ 파일 존재 검증</h3>
<pre><code class="language-java">if (!s3Service.fileExists(request.profileImageUrl())) {
    throw new IllegalArgumentException(&quot;이미지가 존재하지 않습니다.&quot;);
}</code></pre>
<h3 id="2️⃣-주기적인-cleanup-배치">2️⃣ 주기적인 Cleanup 배치</h3>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 2 * * *&quot;)
public void cleanupOrphanFiles() {
    // DB에 없는 S3 파일 삭제
}</code></pre>
<hr>
<h2 id="📊-직접-업로드-vs-presigned-url-정리">📊 직접 업로드 vs Presigned URL 정리</h2>
<h3 id="직접-업로드">직접 업로드</h3>
<p><strong>장점</strong></p>
<ul>
<li>트랜잭션 안정성</li>
<li>고아 파일 없음</li>
<li>검증 쉬움</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>서버 부하 큼</li>
<li>업로드 느림</li>
<li>대역폭 비용 증가</li>
</ul>
<hr>
<h3 id="presigned-url">Presigned URL</h3>
<p><strong>장점</strong></p>
<ul>
<li>서버 부하 거의 0</li>
<li>빠른 업로드</li>
<li>확장성 우수</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>고아 파일 관리 필요</li>
<li>클라이언트 로직 복잡</li>
<li>검증 제한적</li>
</ul>
<hr>
<h2 id="✍️-마무리하며">✍️ 마무리하며</h2>
<p>Presigned URL은 <strong>“서버를 가볍게 만들고 싶은 서비스”</strong>라면 거의 필수에 가까운 선택지였다.</p>
<p>다만, 무조건 좋은 방식은 아니고 <strong>서비스 특성</strong>과 <strong>트랜잭션 요구사항</strong>에 따라 충분한 고민이 필요하다는 것도 느꼈다.</p>
<blockquote>
<p> <em>이번 경험을 통해 “왜 이렇게 설계했는지”를 이해하게 된 것이 가장 큰 수확이었다.</em></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DDD에서 JPA사용의 딜레마 ]]></title>
            <link>https://velog.io/@sunset_1839/DDD%EA%B0%9C%EB%B0%9C%EB%A1%A0%EC%97%90%EC%84%9C-JPA%EC%82%AC%EC%9A%A9%EC%9D%98-%EB%94%9C%EB%A0%88%EB%A7%88</link>
            <guid>https://velog.io/@sunset_1839/DDD%EA%B0%9C%EB%B0%9C%EB%A1%A0%EC%97%90%EC%84%9C-JPA%EC%82%AC%EC%9A%A9%EC%9D%98-%EB%94%9C%EB%A0%88%EB%A7%88</guid>
            <pubDate>Sun, 04 Jan 2026 02:00:14 GMT</pubDate>
            <description><![CDATA[<p>오랜만에 쓰는 글이다. 우아한테크코스를 수료하고 계속 취업준비를 하며 최근 프론트엔드인 동네 친구와 함께 요리사들 위한 블로그형 커뮤니티인 &#39;헬시플레이트&#39;를 개발하고있다. 내가 이전부터 만들어보고 싶었던 프로젝트라 그런지 하루하루 재미있게 개발중에 있다. 프론트 개발자 1명, 백엔드 개발자 1명으로 이루어졌다보니 각자가 프로젝트 리더고 시니어의 책임감을 가지고 임하고 있다.</p>
<p>이번 프로젝트에서는 아키텍처를 직접 설계하며 다양한 고민들을 마주했는데 그 중하나가 DDD와 JPA에 대한 충돌점이다.</p>
<h2 id="ddd를-왜-선택하셨나요">DDD를 왜 선택하셨나요?</h2>
<p>헬시플레이트에는 인증, 유저, 식재료, 레시피등 다양한 논리적 도메인들이 존재한다. 또한 장기적인 프로젝트로서 추가적인 도메인이 늘어날 가능성이 높다라는 전제를 가지고 시작했다. 이러한 경우 일반적인 레이어드 아키텍처를 사용한다면 확장성과 유지보수성이 낮다고 판단했고 이를 잘 분리하고 관리를 하기위해 DDD의 아키텍처를 따라가기로했다.</p>
<h4 id="단점">단점</h4>
<p>내가 느꼈던 DDD의 단점은 숙련도가 낮을 수록 개발속도가 느려지는것과 코드의 복잡도가 증가하는 것이다.</p>
<h4 id="장점">장점</h4>
<p>물론 단점도 존재했지만 그보다 장점도 크게 느껴졌다.</p>
<p> 우선, 코드를 작성할 때 한 번 더 고민하고 생각하며 나의 철학에 맞는 코드를 작성하고 있는지 한 번 더 확인할 수 있었다. &quot;올바른 모듈에 위치하는지&quot;, &quot;도메인 로직이 응용 계층에 섞여 있지는 않은지&quot;, &quot;이 객체가 진짜 필요한 책임인지&quot; 등을 끊임없이 질문하게 된다.</p>
<p> 우아한테크코스를 수료한 지금, 요즘 들어 시간에 대한 조바심이 생기기 시작했고, 내가 1년동안 배운 우아한테크코스의 철학이 흔들릴 때도 생기는 것 같다. 빠르게 기능을 만들어내는 것도 중요하지만, 제대로 된 설계와 구조를 고민하는 시간이 결국 기술 부채를 줄이고 추후 기능 확장을 위한 리팩토링 영역을 줄이는 등 장기적으로는 더 빠른 개발을 가능하게 한다는 걸 깨달았다. DDD는 그런 고민을 강제하는 아키텍처였다.</p>
<hr>
<h2 id="ddd와-jpa의-충돌점">DDD와 JPA의 충돌점</h2>
<p>하지만 DDD를 실제로 적용하다 보니 JPA와의 충돌점들을 마주하게 되었다. 이론적으로 완벽해 보이는 두 기술이지만, 함께 사용하려니 여러 난관이 있었다.</p>
<h3 id="1value-object의-불변성-vs-jpa의-기본-생성자-요구사항">1.Value Object의 불변성 vs JPA의 기본 생성자 요구사항</h3>
<p>DDD의 Value Object는 불변 객체여야 한다. 하지만 JPA는 엔티티와 임베디드 객체에 기본 생성자를 요구한다.
Ingredient(식재료)는 Aggreate Root이고 IngredientName(식재료명)은 그안에 필드로 존재한다.</p>
<pre><code class="language-java">  @Embeddable
  @Getter
  @EqualsAndHashCode
  @NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA를 위한 타협
  public class IngredientName {

      @Column(name = &quot;name&quot;, length = 100, nullable = false)
      private String value;

      private IngredientName(final String value) {
          this.value = value;
      }

      public static IngredientName of(final String value) {
          validateName(value);
          return new IngredientName(value);
      }

      private static void validateName(final String value) {
          if (value == null || value.isBlank()) {
              throw new IllegalArgumentException(&quot;식재료명은 공백일 수 없습니다.&quot;);
          }
          if (value.length() &gt; 100) {
              throw new IllegalArgumentException(&quot;식재료명은 100자 이하여야 합니다.&quot;);
          }
      }
  }</code></pre>
<p>  해결 방법으로는  </p>
<ul>
<li>@NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용해서 JPA의 요구사항을 만족하면서도 외부에서의 무분별한 생성은 막는다.</li>
<li>정적 팩토리 메서드를 통해서만 객체를 생성하도록 강제하여 유효성 검증을 보장한다.</li>
</ul>
<br>

<h3 id="2-aggregate-root와-연관관계-관리">2. Aggregate Root와 연관관계 관리</h3>
<p>DDD에서는 Aggregate 간의 참조는 ID로 해야한다고 권장하지만, JPA의 연관관계 매핑은 객체 참조를 사용한다.
이 부분이 가장 애매하다고 느껴졌다. DDD관점에서 다른 Aggregate와의 관계를 간접참조로 구성한다면 JPA의 객체참조로부터 오는 <code>dirty checking</code>, <code>fetch=Lazy</code>, <code>엔티티 그래프</code> 장점들을 포기해야하는것이 아닌가?</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;ingredient&quot;)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Ingredient extends BaseEntity {

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

    //등록한 회원
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;registered_by&quot;)
    private User registeredBy;</code></pre>
<p>위의 코드는 식재료를 등록한 사용자를 판별하기위해 User 객체를 @ManyToOne으로 연관관계를 맺었었다. 하지만 DDD관점에서 아래와 같은 문제점들이 있다.</p>
<ul>
<li>Ingredient Aggregate가 User Aggregate를 객체로 소유</li>
<li>updateIngredient() 안에서 User 접근이 기술적으로 가능</li>
<li>Lazy라도 탐색 자체가 허용됨</li>
</ul>
<p>DDD에서는 애그리거트간 독립성과 트랜잭션 일관성이 매우 중요하다.Ingredient가 저장될때 User또한 같이 변경된다면 사이드 이펙트가 발생할 것이다.</p>
<hr>
<h2 id="고민과-해결책-모색">고민과 해결책 모색</h2>
<p>Aggregate Root간 연관관계 문제를 해결하기 위해 두 가지 방향을 고민했다.</p>
<h3 id="방향-1-순수-ddd-방식---id-참조만-사용">방향 1. 순수 DDD 방식 - ID 참조만 사용</h3>
<pre><code class="language-java">@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RegisterId {

    @Column(name = &quot;registeredId&quot;)
    private Long value;

    private RegisterId(final Long value) {
        this.value = value;
    }

    public static RegisterId of(final Long value) {
        return new RegisterId(value);
    }
}</code></pre>
<hr>
<pre><code class="language-java">public class Ingredient{

    @Embedded
    private RegisterId registeredBy;  // Long userId를 감싼 Value Object

}
</code></pre>
<p>전형적인 DDD의 ID 간접 참조 방식이다. 사용시 따라오는 장점과 고민되는점은 아래와 같았다.</p>
<h4 id="장점-1">장점</h4>
<ul>
<li>Aggregate 간 의존성 제거</li>
<li>도메인 모델의 순수성 유지</li>
<li>의도하지 않은 User 변경 원천 차단<h4 id="고민되는-점">고민되는 점</h4>
</li>
<li>User 정보가 필요한 조회 시 JOIN 쿼리를 직접 작성해야 함</li>
<li>&quot;내가 등록한 식재료 목록 + 등록자 이름&quot; 같은 조회는 도메인 모델만으로는 표현하기 어려움</li>
<li>조회 책임이 개발자에게 넘어오기 때문에 비효율적인 조회 로직을 만들 가능성이 있다.</li>
</ul>
<h3 id="방향-2-실용적-jpa-방식---객체-참조-사용">방향 2. 실용적 JPA 방식 - 객체 참조 사용</h3>
<pre><code class="language-java">@ManyToOne(fetch = FetchType.LAZY)
private User registeredBy;</code></pre>
<h4 id="장점-2">장점</h4>
<ul>
<li>객체 그래프 탐색 가능</li>
<li>간단한 조회 코드 작성 가능</li>
<li>JPA의 편의성 극대화</li>
</ul>
<h4 id="문제점">문제점</h4>
<ul>
<li>Aggregate 간 직접 참조로 인해 독립성 훼손</li>
<li>도메인 로직 내에서 User 엔티티 접근 가능</li>
<li>의도하지 않은 상태 변경 가능성</li>
<li>DDD의 Aggregate 경계 원칙과 충돌</li>
</ul>
<hr>
<blockquote>
<p>어느 한쪽을 선택하면 다른 한쪽의 장점을 포기해야하는 딜레마에 빠지게 된다.</p>
</blockquote>
<hr>
<h2 id="cqrs로-해결하기">CQRS로 해결하기</h2>
<h3 id="cqrs란">CQRS란?</h3>
<p><strong>CQRS</strong>(Command Query Responsibility Segregation)는 <strong>쓰기</strong>(Command)와 <strong>읽기</strong>(Query)의 책임을 분리하는 아키텍처 패턴이다.</p>
<ul>
<li><strong>Command (쓰기)</strong><ul>
<li>데이터를 변경</li>
<li>비즈니스 규칙과 도메인 무결성에 집중</li>
</ul>
</li>
<li><strong>Query (읽기)</strong><ul>
<li>데이터를 조회</li>
<li>성능과 조회 편의성에 집중</li>
</ul>
</li>
</ul>
<p>쓰기 모델은 DDD 원칙을 엄격히 준수하고,<br>읽기 모델은 JOIN, DTO 프로젝션 등 조회 최적화를 적극 활용한다.</p>
<p>즉,  </p>
<ul>
<li><strong>Command 모델</strong>에서는 Aggregate 독립성과 도메인 규칙을 지키는 것이 가장 중요하고  </li>
<li><strong>Query 모델</strong>에서는 객체 그래프보다는 SQL 관점에서 “어떻게 빠르게 조회할 것인가”에 집중한다.</li>
</ul>
<hr>
<h3 id="조회가-필요한-경우-식재료-정보--등록한-사용자-정보">조회가 필요한 경우: 식재료 정보 + 등록한 사용자 정보</h3>
<p>예를 들어<br><strong>“내가 등록한 식재료 목록과 함께 등록한 사용자 정보(닉네임 등)를 조회”</strong>해야 한다면<br>Command 모델의 <code>Ingredient</code> 엔티티를 그대로 사용하는 것은 적합하지 않다.</p>
<p>이 경우 Query 전용 DTO와 JOIN 쿼리를 활용한다.</p>
<hr>
<h3 id="query-모델--읽기-전용-dto">Query 모델 – 읽기 전용 DTO</h3>
<pre><code class="language-java">package com.healthy_plate.ingredient.application.dto;

/**
 * 읽기 전용 DTO - 식재료 정보 + 등록자 정보
 * CQRS Query 모델
 */
public record IngredientWithUserDto(
    Long ingredientId,
    String ingredientName,
    String ingredientNameEn,
    Integer calorie,
    Double servingSize,
    String unit,
    String registrationType,
    Boolean isVerified,
    Long registeredUserId,
    String registeredUserNickname
) {

    public static IngredientWithUserDto of(
        final Long ingredientId,
        final String ingredientName,
        final String ingredientNameEn,
        final Integer calorie,
        final Double servingSize,
        final String unit,
        final String registrationType,
        final Boolean isVerified,
        final Long registeredUserId,
        final String registeredUserNickname
    ) {
        return new IngredientWithUserDto(
            ingredientId,
            ingredientName,
            ingredientNameEn,
            calorie,
            servingSize,
            unit,
            registrationType,
            isVerified,
            registeredUserId,
            registeredUserNickname
        );
    }
}</code></pre>
<p>이 DTO는 <strong>조회 전용(Query Model)</strong> 이며,</p>
<ul>
<li>Entity가 아니다</li>
<li>변경 로직이 없다</li>
<li>화면/API에 필요한 데이터만 포함한다</li>
</ul>
<hr>
<h3 id="query-repository--join-기반-조회">Query Repository – JOIN 기반 조회</h3>
<pre><code class="language-java">@Override
@Query(&quot;&quot;&quot;
    SELECT new com.healthy_plate.ingredient.application.dto.IngredientWithUserDto(
        i.id,
        i.name.value,
        i.nameEn,
        i.calorie.value,
        i.servingSize.value,
        CAST(i.unit AS string),
        CAST(i.registrationType AS string),
        i.isVerified,
        u.id,
        u.profile.nickname
    )
    FROM Ingredient i
    JOIN User u ON i.registerId.value = u.id
    WHERE u.id = :userId
&quot;&quot;&quot;)
List&lt;IngredientWithUserDto&gt; findByUserIdWithUserInfo(
    @Param(&quot;userId&quot;) Long userId
);</code></pre>
<h4 id="특징">특징</h4>
<ul>
<li>User 엔티티를 <strong>Command 모델에서 직접 참조하지 않음</strong></li>
<li>조회 시에만 명시적으로 JOIN 수행</li>
<li>DTO 프로젝션으로 필요한 데이터만 조회</li>
<li>N+1 문제 없이 <strong>쿼리 1번으로 조회 완료</strong></li>
</ul>
<hr>
<h3 id="정리">정리</h3>
<ul>
<li><p><strong>쓰기(Command)</strong></p>
<ul>
<li><code>Ingredient</code>는 <code>User</code>를 객체로 참조하지 않고 ID(Value Object)만 보관</li>
<li>Aggregate 독립성 및 도메인 무결성 유지</li>
</ul>
</li>
<li><p><strong>읽기(Query)</strong></p>
<ul>
<li>JOIN + DTO를 활용해 조회 성능 최적화</li>
<li>화면/API 요구사항에 맞춘 유연한 조회 가능</li>
</ul>
</li>
</ul>
<p>CQRS를 통해
<strong>DDD의 원칙과 JPA의 실용성 사이의 충돌을 자연스럽게 해소할 수 있었다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인생 첫 컨퍼런스. 2025 우아콘 후기]]></title>
            <link>https://velog.io/@sunset_1839/%EC%9D%B8%EC%83%9D-%EC%B2%AB-%EC%BD%98%ED%8D%BC%EB%9F%AC%EC%8A%A4.-2025-%EC%9A%B0%EC%95%84%EC%BD%98-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/%EC%9D%B8%EC%83%9D-%EC%B2%AB-%EC%BD%98%ED%8D%BC%EB%9F%AC%EC%8A%A4.-2025-%EC%9A%B0%EC%95%84%EC%BD%98-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Wed, 29 Oct 2025 15:29:10 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sunset_1839/post/90611d32-914e-4b94-8c12-4ba6a0412d20/image.jpg" alt=""></p>
<p>2025년 10월 28일에 개최된 배달의 민족 기술 공유 행사인 &#39;우아한테크컨퍼런스&#39;에 다녀왔다. 
개발자로 전향하고 한번쯤은 개발자 컨퍼런스를 경험해보고 싶었고, 타 컨퍼런스에 몇번 신청하긴 했지만 선정되진 않았었다.
이번에도 우테코 및 공식 홈페이지 추첨에서 떨어졌으나 3기 선배 &#39;티케&#39;의 배려로 참여할 수 있었다 (다시 한번 감사합니다😊) .</p>
<p>우아한테크코스의 커리큘럼에 녹아있는 교육 철학처럼 이번 경험을 통해 얻은 지식과 깨달음을 혼자 가져가는 것이 아닌 <strong>모두</strong>와 함께 공유하여 더불어 성장하고자 되도록 꼼꼼히 글을 작성했다.</p>
<p>우아콘은 백엔드, 프론트엔드, 모바일 앱, PM, 인프라, AI, 로봇, 데이터 등  시간대별로 다양한 세션들이 준비되어 있었고 원하는 시간에 맞춰 해당 세션에 참여하여 발표를 들었다.</p>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/7a3a3fa7-0c33-4d4f-88ef-703bf1e9d8c7/image.png" alt=""></p>
<p>이런 행사가 처음이라 들뜬 마음을 감추지 못한채 준비되어 있는 다양한 체험을 먼저 해버렸다. 원래 혼자 사진을 절대 절대 안찍는데 우아네컷의 상품이 너무나 궁금하여 저질러버렸다... (사진도 2장이 나와버려서 매우 곤란했다.)</p>
<p>나는 아래의 5개의 세션을 들었고, 발표 내용을 <strong>클로바 노트</strong>로 기록하였다. 기록한 내용들을 보기 쉽게 요약하여 보여주고, 추가적으로 나의 생각을 써보려고한다.</p>
<blockquote>
<p>💡 들었던 세션 목록 <br></p>
</blockquote>
<ol>
<li>AI 네이티브 회사를 향항 새로운 항해</li>
<li>글로벌 타킷팅 서비스, 수억 명의 고객을 향한 도전</li>
<li>더 나은 이상 탐지를 위한 여정: 서버부터 서비스, 그리고 문화 까지</li>
<li>ServerSentEvents로 실시간 알림 전송하기</li>
<li>BROS 레거시 스파게티 코드  개선</li>
<li>RAG, 들어는 봤는데... 내 서비스엔 어떻게 쓰지? </li>
</ol>
<hr>
<h2 id="ai-네이티브-회사를-향항-새로운-항해">AI 네이티브 회사를 향항 새로운 항해</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/cbb6a853-fd68-4512-9a87-22b9aa128a35/image.jpg" alt=""></p>
<p>LLM, AI Agent, MCP...
요즘 개발자라면 대부분 어떤식으로든 AI를 활용한다고 생각한다. 
이번 세션에서는 배달의 민족에서 AI를 도입했던 경험을 다룬다.</p>
<h3 id="1️⃣-ai-생산성-기대와-초기-충격">1️⃣ AI 생산성 기대와 초기 충격</h3>
<p>AI 기술이 본격적으로 확산되면서, AI를 잘 활용하는 방법을 다룬 콘텐츠들도 쏟아졌고, 이를 보며 “곧 AI 시대가 온다”는 기대감과 동시에
“이 변화 속에서 뒤처지지 않으려면 어떻게 생산성을 높여야 할까” 하는 고민이 함께 생겼다.</p>
<p>이 주제는 단지 최근의 화두가 아니라, 오래전부터 사람들이 꾸준히 고민해온 생산성의 본질적 문제라고 생각한다.</p>
<h3 id="2️⃣-우아한형제들의-빠른-ai-도입과-변화">2️⃣ 우아한형제들의 빠른 AI 도입과 변화</h3>
<p>이런 상황 속에서 우아한형제들은 비교적 이른 시기에 AI 솔루션을 도입했다.
특히 <code>GitHub Copilot</code>을 빠르게 적용하면서, 개발자들이 “코딩이 더 편해졌다”, “생산성이 확실히 올라갔다”는 긍정적인 피드백을 많이 남겼다.</p>
<p>그럼에도 불구하고 코드리뷰, 자동화, 테스트등에서 불편한 점들이 존재했고, AI의 발전에도 해당 문제들은 해결되지 않았다.</p>
<h3 id="3️⃣-한계와-현실적-문제-인식">3️⃣ 한계와 현실적 문제 인식</h3>
<p>AI 도입 이후에도 활용 편차가 매우 컸다.
잘 쓰는 사람과 그렇지 않은 사람의 격차가 뚜렷했고,
일부는 “AI를 안 쓰면 뒤처진다”는 압박감 속에서 억지로 사용하는 모습도 보였다.</p>
<p>결국 내부적으로는 “해외에서는 AI가 생산성을 폭발적으로 높였다는데,
왜 우리는 그렇지 못한가?”라는 의문이 제기되었다.</p>
<h3 id="4️⃣-개선-방향-검증과-반복의-루프">4️⃣ 개선 방향: 검증과 반복의 루프</h3>
<p>이런 문제를 단순히 “AI는 효과가 없다”로 결론내릴 수는 없었다.
그래서 AI 도입을 검증하고 개선하는 구조적 접근이 필요하다고 판단했다.</p>
<p>우리는** “리뷰 → PoC → 플랜”**의 짧은 루프를 반복하는 전략을 세웠다.</p>
<ul>
<li><p>리뷰(Review): 현재 상황과 결과물을 점검하고, 산출물이 실제로 가치가 있는지 평가</p>
</li>
<li><p>PoC(Proof of Concept): 개념 검증을 통해 기술적 가능성과 효용성 확인</p>
</li>
<li><p>플랜(Plan): 검증된 결과를 바탕으로 실행 계획을 구체화</p>
</li>
</ul>
<p>이 과정을 통해 추측이 아닌 데이터 기반의 개선을 추진하기로 했다.</p>
<h3 id="5️⃣-현재-위치와-ai-발전-단계-인식">5️⃣ 현재 위치와 AI 발전 단계 인식</h3>
<p>OpenAI가 제시한 AI 발전 5단계 모델을 기준으로 보면,
현재는 <strong>3단계 ‘에이전트(Agent) 시대’</strong>에 해당한다.</p>
<blockquote>
</blockquote>
<p>1단계: 단순한 대화 수행
2단계: 데이터 기반 조언과 추론 가능
3단계: 사용자를 대신해 독립적으로 작업 수행
4단계: 문제를 스스로 정의하고 혁신적 해결 시도
5단계: 완전한 자율적 지능</p>
<p>현재는 <strong>3단계, 즉 ‘에이전트가 실제 업무를 수행할 수 있는 단계’</strong>에 진입했다고 판단된다.
따라서 앞으로의 과제는 독립적으로 업무를 처리할 수 있는 AI 에이전트를 어떻게 만들 것인가에 집중되어야 한다.</p>
<p>발표자는 이 AI 모델을 2차 산업혁명의 엔진에 비유한다.
무한한 가능성을 지녔지만, 효율·내구성·비용의 균형이 핵심이다.
이 모델을 어떻게 설계하고 적용하느냐에 따라 AI의 실제 생산성 향상 효과가 결정될 것이다.</p>
<h3 id="🧐-세션-후기">🧐 세션 후기</h3>
<p>AI 트렌드인 Agent와 MCP에 관심이 많아 꾸준히 따라가려고 노력중이다. 그러나 학습을 거듭할수록 하나의 의문이 생겼다.
“과연 우리는 AI에게 전적으로 의존할 수 있을까?”</p>
<p>개인적으로 현재의 AI 열풍에는 일정 부분 <strong>‘버블’</strong>이 존재한다고 생각한다.
빅테크 기업들이 가장 먼저 AI를 도입하며 생산성과 편의성을 높이고 있지만, 그만큼 새로운 위험도 함께 커지고 있다.
예를 들어, 잘못된 데이터를 학습시키는 휴먼 에러, 외부에서 의도적으로 조작된 악의적 학습, 그리고 그로 인한 결과 왜곡의 가능성은 여전히 남아 있다.</p>
<p>결국 AI는 스스로 학습하고 판단하는 시스템이기에, 언제나 오판의 위험을 내포한다.
따라서 우리는 “AI의 활용”뿐 아니라 “AI의 한계와 검증”에도 동일한 관심을 가져야 한다고 생각한다.</p>
<hr>
<h2 id="글로벌-타킷팅-서비스-수억-명의-고객을-향한-도전">글로벌 타킷팅 서비스, 수억 명의 고객을 향한 도전</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/2f5e8174-2c0b-4cf1-80a1-8ef554dbdd41/image.png" alt=""></p>
<p>우아한 형제들에서 사용하는 타켓팅 서비스인 <strong>버즈</strong>가 글로벌 서비스로 확장하면서 선택한 기술들과 코드부터 인프라까지 어떤 변경 사항들이 있었는지에 대한 내용으로 진행되었던 세션이다.</p>
<h3 id="1️⃣-타겟팅-서비스란-무엇인가">1️⃣ 타겟팅 서비스란 무엇인가</h3>
<p>‘<strong>버즈(Buzz)</strong>’는 배민의 <code>유저 데이터</code>를 기반으로 맞춤형 마케팅을 지원하는 타겟팅 시스템이다.
이 시스템의 핵심은 모든 유저에게 동일한 메시지를 보내는 대신, 필요한 사람에게 필요한 쿠폰과 정보를 제공하는 것이다.</p>
<p>타겟팅은 ‘세그먼트(Segment)’라는 개념으로 운영된다.
예를 들어, “20대 여성 중 최근 1주일 내 신한카드로 결제한 포항 거주자” 같은 조건이 세그먼트를 구성한다.
이 조건 기반의 고객 그룹을 빠르게 분석하기 위해, <strong>오픈서치</strong>(OpenSearch) 가 핵심 데이터베이스로 활용된다.</p>
<blockquote>
<p>💡오픈서치란? <br>
오픈서치는 빠른 검색·집계, 스키마리스 JSON 저장, 쿼리 DSL 지원, 샤드 기반 확장성 등으로
수억 건의 고객 데이터를 1초 이내로 조회할 수 있게 한다.</p>
</blockquote>
<h3 id="2️⃣-데이터-수집과-세그먼트-활용-구조">2️⃣ 데이터 수집과 세그먼트 활용 구조</h3>
<p>고객 속성 데이터는 매일 파이프라인을 통해 업데이트된다.
예를 들어, 마케터가 “생일 고객에게 쿠폰을 발행하고 싶다”고 요청하면,
데이터 엔지니어는 회원 DB에서 생일 정보를 추출해 <strong>데이터 웨어하우스</strong>로 복제하고,
버즈 시스템이 이를 집계해 오픈서치에 적재한다.</p>
<p>이 데이터는 푸시 액션(쿠폰, 알림)이나 <strong>퍼스널라이제이션</strong>(개인화된 배너 노출)에 활용된다.</p>
<blockquote>
<p>💡 데이터 웨어하우스란? <br>
데이터 웨어하우스는 다양한 소스에서 가져온 데이터를 통합하고 정리하여 저장하는 시스템으로, 비즈니스 인텔리전스, 보고, 분석을 통해 기업의 의사결정을 지원하는 데이터 저장소이다. 현재 운영 데이터와 분리된 독립적인 공간으로, 과거 데이터를 분석하고 추세를 파악하는 데 사용된다.</p>
</blockquote>
<blockquote>
<p>💡퍼스널라이제이션이란? <br>
소비자 개인의 취향, 관심사, 구매이력, 나이등으로 특정 고객에게 정교한 맞춤형으로 제공하는 것</p>
</blockquote>
<h3 id="3️⃣-글로벌-확장과-기술적-도전">3️⃣ 글로벌 확장과 기술적 도전</h3>
<p>버즈는 글로벌 버전인 세그멘텀(Segmentum) 으로 확장되며 70개국, 약 1.5억 명의 고객을 대상으로 서비스가 운영되고 있다.</p>
<p>확장 과정에서 직면한 주요 과제는 네 가지였다:</p>
<ul>
<li>낮은 API 레이턴시 (P95: 60ms, P99: 80ms 이하)</li>
<li>수억 명 단위 데이터 스케일</li>
<li>높은 TPS(초당 요청 처리)</li>
<li>비용 효율성</li>
</ul>
<h3 id="4️⃣-배포-전략-결정-리전-단위-분산">4️⃣ 배포 전략 결정: 리전 단위 분산</h3>
<p>초기에는 <code>중앙집중형</code> 배포(아시아 단일 리전) 방식을 고려했으나, 거리로 인한 레이턴시와 장애 전파 문제가 발생했다.</p>
<p>반면, 70개국 <code>개별 배포</code>는 비용과 운영 복잡도가 너무 높았다.</p>
<p>결론적으로, 4개 주요 리전(한국 포함) 에 <code>리전 단위 배포</code>를 진행하여 성능과 운영 효율의 균형을 맞췄다.</p>
<h3 id="5️⃣-데이터베이스-선택-dynamodb-중심-아키텍처">5️⃣ 데이터베이스 선택: DynamoDB 중심 아키텍처</h3>
<p>1억 명 고객 × 1만 세그먼트 = 1천억 관계 데이터라는 규모에서
데이터 저장 효율성과 비용을 모두 고려해야 했다. 
후보로는 Redis, DynamoDB, Cassandra를 검토했고, 확장성·일관성·운영 효율성을 이유로 DynamoDB를 선택했다. </p>
<p>&lt;비용 최적화 전략&gt;</p>
<ul>
<li><p>세그먼트 CDC를 통한 변경분만 업데이트</p>
</li>
<li><p>DynamoDB TTL로 만료된 데이터 자동 삭제 (비용 청구 없음)</p>
</li>
<li><p>이 방식으로 일일 데이터 업데이트 비용을 대폭 절감했다.</p>
</li>
</ul>
<h3 id="6️⃣-성능-최적화-빠르고-안정적인-응답">6️⃣ 성능 최적화: 빠르고 안정적인 응답</h3>
<p>아래는 버즈 서비스 팀에서 성능 최적화를 방법들이다.</p>
<p><strong>1. 카페인 캐시(Caffeine Cache)</strong></p>
<ul>
<li>메타데이터를 로컬 캐시로 관리해 DB I/O 최소화</li>
</ul>
<p><strong>2. 커넥션 풀 및 Keep-Alive 설정</strong></p>
<ul>
<li>초기 핸드셰이크 비용 제거, 연결 재사용</li>
</ul>
<p><strong>3. Fail-Fast 전략</strong></p>
<ul>
<li>지연이 발생하면 빠르게 실패 후 재시도 (테일 레이턴시 개선)</li>
</ul>
<p><strong>4. Request Hedging</strong></p>
<ul>
<li>동일 요청을 두 번 보내 빠른 응답을 선택해 안정성 확보</li>
</ul>
<p>결과적으로, 글로벌 환경에서 P95: 10ms / P99: 20ms 수준의 성능을 달성했다.</p>
<h3 id="7️⃣-글로벌-운영-후기와-교훈">7️⃣ 글로벌 운영 후기와 교훈</h3>
<p>글로벌 운영을 하면서 버즈 서비스 팀은 여러가지를 배웠다고 한다. 발표만 들었는데도 너무나 방대한 영역이었기에 얼마나 노력하셨는지 감도 오지 않았다.</p>
<h4 id="어려움">어려움</h4>
<ul>
<li><p>기술 부채를 일시적으로 상환해야 하는 고통 (3개월 이상 소요)</p>
</li>
<li><p>버즈/세그멘텀 간 기술 스택 차이로 인한 복잡성</p>
</li>
<li><p>DH는 독일에 있기에 한국과는 시차가 7시간이 난다. 또한 언어·문화적 장벽이 존재하였다.</p>
</li>
</ul>
<h4 id="얻은-교훈">얻은 교훈</h4>
<ul>
<li><p>확장 가능한 아키텍처 설계의 중요성</p>
</li>
<li><p>기술 도입의 명확한 기준 필요성</p>
</li>
<li><p>글로벌 협업에서의 신뢰와 원팀 문화의 가치</p>
</li>
</ul>
<h3 id="🧐-세션-후기-1">🧐 세션 후기</h3>
<p>사용자 규모를 들었을 때 가장 먼저 떠오른 감정은 <strong>“1억 5천만 명이 사용하는 서비스에 대한 경외심”</strong>이었다.</p>
<p>모잇지 서비스를 개발하며 수백 명의 트래픽만으로도 성능과 안정성을 고민했던 입장에서, 이 수치는 단순한 숫자가 아니라 <strong>‘설계 철학의 깊이 차이’</strong>로 느껴졌다. </p>
<p>다만 아직 주니어 개발자 준비생으로서 대규모 트래픽 환경을 직접 경험해보지 못했다는 점에서,
이런 감정은 어쩌면 당연한 것일지도 모른다.</p>
<hr>
<h2 id="더-나은-이상-탐지를-위한-여정-서버부터-서비스-그리고-문화-까지">더 나은 이상 탐지를 위한 여정: 서버부터 서비스, 그리고 문화 까지</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/68f6b2ca-fa36-47c3-bc21-8d99de4c9f49/image.jpg" alt=""></p>
<p>이번 세션을 통해 처음으로 *<em>SRE(Site Reliability Engineering) *</em> 팀의 존재를 알게 되었다.
사실 ‘SRE’라는 용어조차 이번에 처음 들었다. 
하지만 이번 세션을 들으면서 그들이 얼마나 중요한 역할을 하는지, 내가 과거에 경험했던 단순한 로그 수집이나 모니터링 작업이,SRE의 관점에서 체계적으로 확장되면 어떤 수준의 관리로 발전할 수 있는지 구체적으로 이해할 수 있었다.</p>
<h3 id="1️⃣-sre란-무엇인가">1️⃣ SRE란 무엇인가</h3>
<p><strong>SRE(Site Reliability Engineering)</strong>는 시스템 신뢰성을 보장하기 위한 기술·문화적 접근 방식이다. 회사마다 약간씩 SRE에 대한 정의가 다르기에 우아한 형제들을 기준으로 설명하겠다. </p>
<p>우아한형제들에서는 SRE를 다음과 같은 네 가지 영역으로 정의하고 있다:</p>
<p><strong>1. 관찰 가능성 확보</strong> </p>
<ul>
<li>로그·메트릭·트레이싱 등으로 서비스 상태를 실시간 파악</li>
</ul>
<p><strong>2. 이상 탐지 및 경보 체계 구축</strong></p>
<ul>
<li>장애 전조를 조기에 인지</li>
</ul>
<p><strong>3. 장애 대응 정책 및 전파 프로세스 설계</strong></p>
<p><strong>4. 후속 조치 및 재발 방지 관리</strong></p>
<p>이러한 체계를 통해 서비스 신뢰성을 높이고, 장애 발생 시 복구 시간을 최소화하는 것을 목표로 한다.</p>
<h3 id="2️⃣-왜-이상-탐지가-중요한가">2️⃣ 왜 이상 탐지가 중요한가</h3>
<p>배달의민족 서비스에서 장애는 곧 사용자 경험 저하와 매출 손실로 이어진다.
완벽한 장애 예방은 불가능하며, 빠른 감지와 대응 속도가 핵심 경쟁력이다.
따라서 SRE팀은 “장애를 없애는 것보다, 빠르게 복구하는 체계”에 집중하고 있다.</p>
<h3 id="3️⃣-초기-접근-전사-경보-시스템">3️⃣ 초기 접근: 전사 경보 시스템</h3>
<p>과거 배달의 민족에서는 각 팀이 독립적으로 모니터링을 운영했기 때문에 아래와 같은 문제점이 있었다.</p>
<ul>
<li><p>모니터링 사각지대 발생</p>
</li>
<li><p>경보 기준 불일치</p>
</li>
<li><p>담당자 부재 시 대응 지연</p>
</li>
</ul>
<p>이를 해결하기 위해 중앙 통합 경보 시스템을 구축했다.</p>
<ul>
<li><p>핵심 지표(트래픽, 오류율, 응답 시간 등)를 표준화</p>
</li>
<li><p>전사 공통의 알림 기준 수립</p>
</li>
<li><p>운영자 개입 없이 자동화된 경보 발송</p>
</li>
</ul>
<p>이 과정을 통해 <strong>운영 문화의 일원화</strong>와 <strong>그레이존 축소</strong>라는 효과를 얻었다.</p>
<h3 id="4️⃣-서비스-이상-탐지-시스템-기획">4️⃣ 서비스 이상 탐지 시스템 기획</h3>
<p>SRE팀은 단순한 인프라 수준의 모니터링을 넘어, 서비스가 정상적으로 동작하고 있는지를 판단할 수 있는 상위 레벨의 모니터링 시스템이 필요하다고 보았다.</p>
<p>여기서 말하는 ‘<strong>서비스 지표</strong>’는 CPU, 메모리, 네트워크와 같은 시스템 리소스 지표가 아니라
비즈니스 지표, 즉 서비스의 <code>성능</code>과 <code>품질</code>을 나타내는 지표를 의미한다.</p>
<p>예를 들어, 서비스가 정상적으로 작동한다면 특정 주문 건수나 결제 성공률 등이 일정한 패턴을 유지해야 한다.
하지만 서비스의 ‘<strong>정상 상태</strong>’에는 <strong>정답</strong>이 없다.
비즈니스 환경은 끊임없이 변화하며, 외부 요인(이벤트, 날씨, 트래픽 변화 등)에 따라 지표도 달라지기 때문이다.</p>
<h3 id="5️⃣-장애-탐지-및-대응-프로세스">5️⃣ 장애 탐지 및 대응 프로세스</h3>
<p>우아한형제들 SRE팀은 서비스 장애 탐지를 위해 중앙값 기반의 <code>예측값</code>과 실시간 데이터를 비교하여 <code>편차</code>가 <code>임계치</code>에 도달하면 <code>경보</code>를 발생시키는 전략을 도입했다.</p>
<p>이를 통해 불필요한 <strong>오탐(False Positive)</strong>을 최소화하고,
이상 상황을 신속히 감지하여 장애 대응 속도를 대폭 단축할 수 있었다.</p>
<h3 id="6️⃣-서비스-조직과의-협업">6️⃣ 서비스 조직과의 협업</h3>
<p>이 시스템은 SRE팀 단독이 아닌, 서비스 조직과의 공동 설계를 통해 완성되었다.</p>
<h4 id="기존-문제">기존 문제</h4>
<ul>
<li><p>조직별 지표·경보 기준이 상이</p>
</li>
<li><p>전사적 장애 현황 파악 불가</p>
</li>
<li><p>경보 의미 불명확, 대응 절차 비일관적</p>
</li>
</ul>
<h4 id="해결-방향">해결 방향</h4>
<ul>
<li><p>표준화된 서비스 지표 정의</p>
</li>
<li><p>통일된 경보 포맷 및 대응 프로세스 적용</p>
</li>
<li><p>조직별 맞춤 인터뷰 및 협업 설계
→ “획일화가 아닌, 비즈니스에 맞춘 표준화”</p>
</li>
</ul>
<h3 id="7️⃣-성과와-배운-점">7️⃣ 성과와 배운 점</h3>
<ol>
<li><p>완벽보다 실행 우선    빠르게 시도하고 빠르게 실패해야 개선이 가능하다.</p>
</li>
<li><p>유저 중심의 지표 선정    CPU·메모리보다 사용자의 액션을 반영한 지표가 효과적이다.</p>
</li>
<li><p>공동의 목표 설정    SRE와 서비스 조직이 각자의 역할에 집중해야 시너지가 난다.</p>
</li>
</ol>
<h3 id="🧐-세션-후기-2">🧐 세션 후기</h3>
<p>우선 발표자분의 한 말씀이 굉장히 인상적이었다.</p>
<blockquote>
<p>장애를 근본적으로 방지하는 것은 불가능에 가깝다.사실 아무것도 안 하면 장애는 거의 발생하지 않는다. 
근데 이제 회사는 계속 일을 해야 되고 더 나은 서비스를 만들어야 되기 때문에 변화가 계속 있고 이런 변화가 있는 한 장애는 피할 수가 없다.
그래서 이렇게 장애가 필연적이라면은 결국 서비스를 좀 빠르게 복구하는 데 집중을 해야 되겠죠.</p>
</blockquote>
<p>이 말이 이번 세션의 핵심을 가장 잘 설명한다고 느꼈다.
SRE의 본질은 장애 ‘예방’이 아니라 장애 ‘복구 능력’을 체계화하여 장애 복구 시간을 최소화 시키는것이라고 생각하고 우리가 지향해야할 부분이라고 생각한다.</p>
<hr>
<h2 id="serversentevents로-실시간-알림-전송하기">ServerSentEvents로 실시간 알림 전송하기</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/4399f614-8085-4610-9fb4-f4efb5e9d0ac/image.jpg" alt=""></p>
<p>이 세션은 기대를 했고, 열심히 따라가려고 노력했지만 아쉽게도 잘 이해가 되지 않았다... 내가 SSE를 경험해봤다면 도움이 되겠지만, 기본 지식이 없는 상태에서 듣다보니 머릿속에 남은 것이 적었다.😢 </p>
<p>이번 세션은 추후 SSE를 사용하게 될 날을 기약하며 단순히 발표내용을 정리하는것으로 마무리 하려고한다. </p>
<p>💡이 세션은 기록된 내용을 가독성을 향상 시키는데 집중하였습니다. 잘못된 부분이 있다면 댓글에 기재해주면 감사하겠습니다. </p>
<h3 id="들어가며">들어가며</h3>
<p>주문 접수 채널 팀에서는 수십만 파트너들이 주문을 쉽고 빠르게 처리할 수 있도록 PC 앱, 안드로이드 키오스크 등 다양한 프로그램을 개발하고 있다. 식당에서 들리는 주문 알림음, 그 시스템을 어떻게 개선했는지 이야기해보겠다.</p>
<h3 id="1️⃣기존-mqtt-알림-시스템">1️⃣기존 MQTT 알림 시스템</h3>
<p>기존 시스템은 MQTT 브로커를 통해 알림을 전송했다. 클라이언트가 MQTT에 연결하여 토픽을 구독하면, 도메인 서버에서 변경 사항 발생 시 메시지를 발행하고, 클라이언트는 이를 수신해 API로 상세 정보를 조회한 후 알림을 표시했다.</p>
<h3 id="2️⃣기존-시스템의-문제점">2️⃣기존 시스템의 문제점</h3>
<p><strong>제로 페이로드 문제</strong>
레거시 클라이언트에서 긴 페이로드가 잘리는 문제로 인해 변경 ID만 전송하고 클라이언트가 API로 상세 정보를 조회했다. 이로 인해 알림 로직이 클라이언트에 있어 문구나 시점 변경 시 앱 업데이트가 필수였다.</p>
<p><strong>보안 문제</strong>
토픽 구독에 인증이 없어 다른 파트너의 토픽 구독이 가능했다. 다만 제로 페이로드였기에 유의미한 정보는 API 호출로만 얻을 수 있어 큰 문제는 없었다.</p>
<p><strong>네트워크 문제</strong>
MQTT가 사용하는 1883, 8883 포트가 일부 환경에서 방화벽에 차단되어 폴링 로직이 추가로 필요했다.</p>
<p><strong>웹뷰 연결 문제</strong>
웹뷰에서 여러 탭이 동시에 열리는 환경에서 MQTT 인증서 관리의 복잡도가 높았다.</p>
<h3 id="3️⃣-aws-iot-도입">3️⃣ AWS IoT 도입</h3>
<p>AWS IoT를 도입하여 일부 문제를 해결했다.</p>
<p><strong>보안 강화</strong>: x509 인증서 발급 시 자신의 토픽만 구독 가능하도록 권한을 부여했다.</p>
<p><strong>페이로드 정형화</strong>: 보안이 강화되어 페이로드에 유의미한 정보(title, content, eventType, soundType 등)를 포함시켰다. 클라이언트는 수신 즉시 알림을 표시하게 되어 버전 종속성이 사라졌다.</p>
<p>하지만 MQTT를 사용하는 한 네트워크와 웹뷰 문제는 여전했다.</p>
<h3 id="4️⃣-sse-도입">4️⃣ SSE 도입</h3>
<h4 id="왜-sse인가">왜 SSE인가?</h4>
<p>실시간 통신 방식을 검토했다.</p>
<ul>
<li><strong>폴링</strong>: 주기적 요청으로 비효율적</li>
<li><strong>웹소켓</strong>: 양방향 통신이지만 모든 API를 웹소켓으로 재정의해야 하는 오버 엔지니어링</li>
<li><strong>SSE</strong>: 서버에서 클라이언트로 단방향 푸시, 기존 API 구조 유지 가능</li>
</ul>
<p>알림만 필요한 발표자의 상황에 SSE가 적합했다.</p>
<h3 id="5️⃣-메시지-전달-방식-선택">5️⃣ 메시지 전달 방식 선택</h3>
<p>세 가지 방식을 검토했다.</p>
<ol>
<li><strong>세션 연결 서버로 API 호출</strong>: 성능은 좋지만 세션 관리 복잡, 유실 가능성</li>
<li><strong>브로커로 특정 서버에 전송</strong>: 순서 보장되지만 토픽 관리 복잡, 유실 가능성</li>
<li><strong>모든 서버로 브로드캐스트 후 필터링</strong>: 간단하고 순서 보장, 쉬운 스케일 아웃</li>
</ol>
<p>메시지 신뢰성이 가장 중요했기에 3번 방식을 선택했다.</p>
<h3 id="6️⃣-메시지-안정성-확보">6️⃣ 메시지 안정성 확보</h3>
<h4 id="last-event-id-활용">Last-Event-ID 활용</h4>
<p>SSE 표준인 Last-Event-ID 헤더를 활용했다. 클라이언트가 마지막 수신 ID를 헤더로 전송하면, 재연결 시 그 이후 메시지를 조회해 전송하여 순단 중 유실을 방지했다.</p>
<h4 id="커밋-메시지">커밋 메시지</h4>
<p>주문서 출력, 신규 알림 등 중요 메시지는 정확히 한 번만 전달되어야 했다. commitUrl을 포함한 메시지를 전송하고, 클라이언트가 수신 시 이를 호출하면 서버에서 마킹했다. 주기적으로 미커밋 이벤트를 조회해 재발행했다.</p>
<h4 id="보안-강화">보안 강화</h4>
<p>같은 세션의 동시 접속을 차단했다. 세션 매니저로 활성 세션을 관리하고, 메시지 전송 전 세션 유효성을 검증해 비정상 세션은 강제 종료했다.</p>
<h4 id="폴링-제거">폴링 제거</h4>
<p>클라이언트의 다양한 폴링 로직을 서버로 이동시켰다. SSE 연결 시 서버가 각 도메인을 주기적으로 호출하고, 변경 시에만 클라이언트에 전송해 API 호출을 줄였다.</p>
<h3 id="7️⃣-전환-과정의-문제들">7️⃣ 전환 과정의 문제들</h3>
<h4 id="백프레셔-문제">백프레셔 문제</h4>
<p>코루틴 채널을 기본값(capacity=RENDEZVOUS, 즉 0)으로 생성해서 버퍼가 없었다. 메시지 처리가 지연되면 Kafka max.poll.interval이 초과되어 파티션 할당이 해제되었다. 적절한 capacity와 onBufferOverflow 설정으로 해결했다.</p>
<h4 id="배포-시-cpu-스파이크">배포 시 CPU 스파이크</h4>
<p>배포로 연결이 끊기면 모든 클라이언트가 동시에 재연결을 시도했다. 연결 유지 시간이 일정해 주기적으로 CPU가 급증했다. 연결 유지 시간과 재연결에 랜덤 지터를 부여해 해결했다.</p>
<h4 id="불필요한-네트워크">불필요한 네트워크</h4>
<p>앱, PC, 웹뷰 등 프로그램별, 버전별로 처리 가능한 메시지가 달랐지만 모든 메시지를 전송했다. 연결 시 처리 가능한 이벤트 타입을 받아 필터링하여 불필요한 네트워크 I/O를 제거했다.</p>
<h3 id="8️⃣-향후-개선-계획">8️⃣ 향후 개선 계획</h3>
<p><strong>메시지 브로커 변경</strong>: Kafka 대신 Redis Stream이나 NATS 같은 경량 프로토콜을 검토 중이다.</p>
<p><strong>모바일 앱 SSE 도입</strong>: 포그라운드에서는 SSE, 백그라운드에서는 푸시로 전환하여 속도와 비용을 개선할 예정이다.</p>
<h3 id="9️⃣-결론">9️⃣ 결론</h3>
<p>SSE 도입으로 다음과 같은 성과를 얻었다.</p>
<ul>
<li>서버 비용 절감 및 일평균 4천만 건의 안정적 처리</li>
<li>폴링 제거로 네트워크 트래픽 감소</li>
<li>클라이언트 업데이트 없이 알림 제어 가능</li>
<li>코틀린 코드로 작성되어 쉬운 수정 및 반영</li>
<li>웹뷰 지원 및 보안 강화</li>
</ul>
<hr>
<h2 id="bros-레거시-스파게티-코드-개선">BROS 레거시 스파게티 코드 개선</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/04a5f2b6-7ee6-4688-8586-1a6a5540c356/image.jpg" alt=""></p>
<p>이 세션을 들은 이유는 우아한테크코스를 진행하며 프로젝트의 레거시 코드를 리팩토링하는 경험을 해보았기 때문이다. </p>
<p>내 기억상 리팩토링 과정은 힘들었었다. 리팩토링 이전에는 구현 능력이 중요하다고 생각했는데, 리팩토링을 시작하며 설계 능력의 중요성을 체감하였다. 
아직 전체적인 아키텍쳐 설계 능력이 부족했던 나로서는 리팩토링 과정이 가장 고통스럽기도 하였다.😢</p>
<p>이러한 이유로 조언 혹은 배움을 얻을 수 있을까 싶어 해당 세션에 참여하였다.
BROS는 우아한 형제의 수백개의 프로젝트 중에서 10년 동안 유지보수 되어왔던 레거시 코드이며 10손가락 안에드는 코드라인수를 가졌다. 과연 BROS팀은 어떻게 리팩토링을 하였을까? </p>
<p>단계별로 발표내용을 분석해보겠다.</p>
<h3 id="1️⃣-bros의-시스템-구조와-문제점">1️⃣ BROS의 시스템 구조와 문제점</h3>
<h4 id="멀티-모듈-프로젝트-구조">멀티 모듈 프로젝트 구조</h4>
<p>BROS는 여러 서버와 여러 공통 모듈이 존재하는 멀티 모듈 프로젝트로 구성되어 있다. 주요 서버들은 &#39;딜리버리 서비스&#39;라는 공통 모듈을 의존하고 있으며, 이 공통 모듈에 기능을 작성하면 여러 서버에서 사용할 수 있다. 그 결과 쉽게 기능이 추가되어 대부분의 비즈니스 로직을 포함한 매우 거대한 모듈이 되었다.</p>
<p><strong>진짜 문제는</strong> 이 거대한 공통 모듈을 여러 팀이 커밋하고 있다는 점이다.</p>
<h4 id="rr-문제">R&amp;R 문제</h4>
<blockquote>
<p>💡R&amp;R 이란? <br>
R&amp;R은 Role &amp; Responsibilities의 약자로, 조직 내에서 각 구성원이 맡은 역할과 책임을 뜻한다. </p>
</blockquote>
<p>여러 팀이 하나의 공통 모듈을 관리하면서 다음과 같은 문제가 발생했다.</p>
<ul>
<li>브랜치가 너무 다양하고 복잡</li>
<li>원치 않은 레거시 코드와 코딩 스타일 마주침</li>
<li>R&amp;R 논의, 개발 검토, 리뷰, 배포 이후 조치 등 팀과 팀을 넘어 과한 커뮤니케이션 발생</li>
</ul>
<p>결과적으로 역할을 넘어선 <strong>과도한 비용</strong>이 발생했다.</p>
<h4 id="스파게티-코드-문제">스파게티 코드 문제</h4>
<p>딜리버리 서비스 모듈 안에는 배달 관련 기능이 있고, 이 기능이 수행되면 애플리케이션 이벤트를 발행한다. 그러면 이벤트 리스너 또는 트랜잭셔널 이벤트 리스너로 구현된 후처리 리스너에서 나머지 동작을 수행하는 구조였다.</p>
<p><strong>이벤트와 리스너의 복잡한 관계</strong></p>
<ul>
<li>배달 이벤트: 약 39개</li>
<li>후처리 리스너: 19개</li>
</ul>
<p>서비스 A가 기능을 수행하면 애플리케이션 이벤트를 발행하고, 리스너 B, C, D에서 각각 이벤트를 소비한다.
문제는 리스너가 단순히 종료되지 않고, 다시 순환 호출되거나 다른 도메인을 호출하는 경우가 있다는 점이다.
그 서비스에서 또 다른 이벤트를 발행하면 대응되는 리스너들이 또 이를 소비하는 방식으로, 서비스와 리스너가 N:M 관계를 가지고 서로 복잡하게 호출하는 구조였다.</p>
<p><strong>❗결과적으로</strong></p>
<ul>
<li>개발 검토 중 길을 잃어버리기도 함</li>
<li>높은 인지 부하로 위험 요소 증가</li>
<li>영향 범위 파악에 매우 큰 비용 발생</li>
</ul>
<h3 id="2️⃣-해결과정">2️⃣ 해결과정</h3>
<p>이 과제의 핵심은 사실 RNR 정리 과정을 통해 공통 모듈이 엮여있는 역할과 책임을 분리하는 것이었고, 이를 위해 선행 업무로 코드 청소를 먼저 해야 되고 그다음에 또 성능 개선까지 같이 챙겨야 하는 미션을 받았다.</p>
<h3 id="1-🧹복잡한-코드-청소-애플리케이션-이벤트-리스너-정리">1. 🧹복잡한 코드 청소: 애플리케이션 이벤트 리스너 정리</h3>
<p>코드 청소를 위해 다음 단계를 이어갔다.</p>
<ol>
<li>일단 먼저 사용 여부와 대체제를 확인한 뒤에 삭제를 먼저 검토했다.</li>
<li>처리 리스너 내에 존재하는 비즈니스 로직들을 각 도메인에 맞게 리팩터링을 진행을 했다.</li>
<li>최종적으로 이제 애플리케이션 이벤트 리스트를 삭제하는 방향으로 가져갔고 이 리팩터링한 기능을 직접 메소드 콜 방식으로 개선했다.</li>
</ol>
<h4 id="결과">결과</h4>
<ul>
<li>리스너가 19개 → 5개로 감소 (14개 제거)</li>
<li>리스너 로직 간소화 및 도메인 응집도와 가시성 향상</li>
<li>불필요하게 긴 트랜잭션을 짧게 축소</li>
</ul>
<h3 id="2-💫-rr-정리">2. 💫 R&amp;R 정리</h3>
<p>R&amp;R 정리는 가장 메인이 되는 과제였다. 공통 모듈 사이의 역할과 책임을 끊어어 내고자 많은 노력을 했음이 보였다.   </p>
<h4 id="1-라이더-서버">1. 라이더 서버</h4>
<p><strong>문제점</strong></p>
<ul>
<li>라이더 서버는 라이더 팀 담당이지만 배달 수행에 직접 트랜잭션을 실행하는 역할이 과도함</li>
<li>공통 모듈을 계속 수정해야 하는 부담</li>
<li>액터(라이더 vs 관리자)에 의존하는 로직이 공통 모듈에 존재</li>
</ul>
<p><strong>해결</strong>
배달 서버를 중간에 투입했다. 네트워크 구간은 증가했지만 배달 수행의 직접 트랜잭션 실행 역할을 배달 서버에 부여하고, 라이더 서버는 배달 서버를 호출하는 방식으로 역할을 축소했다. 액터에 의존한 로직은 배달 서버와 어드민 서버 각각 앞쪽으로 이관하고, 공통 모듈에는 배달 도메인에 집중한 코드만 남겼다.</p>
<h4 id="2-라이더-메시지-도메인">2. 라이더 메시지 도메인</h4>
<p><strong>문제점</strong>
관리자의 주소 변경, 고객/사장님의 주문 취소 시 라이더에게 동선 변경을 알리는 웹소켓 발행 코드가 공통 모듈에 존재했다. 배달팀 입장에서 담당 도메인도 아니고 코드 수정이 어려웠다.</p>
<p><strong>해결</strong>
배달 이벤트를 라이더에게 보낼지, 어떻게 보낼지 결정하는 도메인은 라이더에 더 가깝게 위치해야 한다고 합의했다. 메신저 서버를 활용하여 기존 웹소켓 발행 기능을 모두 이관시켰다. 기존 애플리케이션 이벤트 리스너는 Kafka를 사용해서 서버 분리를 진행했다. 메신저 서버에서 Kafka를 통해 이벤트를 소비하고 여기에서만 웹소켓이 발행되도록 R&amp;R을 정리했다.</p>
<h4 id="3-rr-정리-문의-내역-도메인">3. R&amp;R 정리: 문의 내역 도메인</h4>
<p>라이더와 상담사를 연결하는 도메인이 공통 모듈에 위치했다. 새로운 공통 모듈을 만들고 리팩토링하면서 코드를 이관했다. 소프트한 경계 격리를 의도했다.</p>
<h3 id="3-⭕성능-개선-api-3대장-개선">3. ⭕성능 개선: API 3대장 개선</h3>
<p>라이더 서버의 가장 많이 호출되는 API인 &#39;배달 진행 목록&#39; API를 대상으로 성능 개선을 진행했다.</p>
<p><strong>문제점</strong></p>
<ul>
<li>반복적으로 DB 조회 수행</li>
<li>REST Call이 있음에도 트랜잭션을 길게 사용</li>
<li>방어 로직을 수행하는 Insert 기능을 위해 Writer DB를 오랫동안 점유</li>
</ul>
<p><strong>해결</strong></p>
<ul>
<li>배달 서버를 중간에 두고 DB 사용을 모두 위임</li>
<li>불필요한 단계 제거</li>
<li>중복된 DB 사용을 줄이고 리더 DB 사용</li>
<li>REST Call 시점에 트랜잭션이 포함되지 않도록 처리</li>
<li>방어 로직 수행 시 잠깐만 Writer DB를 사용하도록 트랜잭션 범위 최소화</li>
<li>Response 크기 경량화</li>
</ul>
<h3 id="3️⃣-성과">3️⃣ 성과</h3>
<h3 id="1-⭕-코드-품질-개선">1. ⭕ 코드 품질 개선</h3>
<p><strong>스파게티 코드 제거</strong></p>
<ul>
<li>이벤트 리스너: 19개 → 5개 (14개 제거)</li>
<li>기존 마이너 버그와 취약점 개선</li>
<li>미사용 비즈니스 로직, 테이블 적재, 리소스 제거</li>
</ul>
<p><strong>복잡도 감소</strong></p>
<ul>
<li>프로젝트 전체 코드: 3천 라인 증가 (4개월간 다른 팀 개발 병행)</li>
<li>순환 복잡도와 인지 복잡도 감소 (코드량 증가에도 불구하고)</li>
<li>특히 공통 모듈: 1만 라인 이상 제거, 복잡도 크게 감소</li>
</ul>
<p><strong>테스트 커버리지 향상</strong></p>
<ul>
<li>30% 초반 → 36% 상승</li>
<li>리팩토링한 코드들의 커버리지 대부분 70~80% 이상</li>
</ul>
<h3 id="2-⭕-rr-정리">2. ⭕ R&amp;R 정리</h3>
<ul>
<li><strong>라이더 서버</strong>: 역할과 책임 축소</li>
<li><strong>메신저 서버</strong>: 배차 추천/거절 시점만 사용 → 배달 전반적인 부분으로 확대</li>
<li><strong>배달 서버</strong>: 배달 내역 서빙 역할 → 라이더의 배달 수행 처리로 재정의</li>
</ul>
<h3 id="3-⭕-성능-개선">3. ⭕ 성능 개선</h3>
<p><strong>서버 관점</strong></p>
<ul>
<li>주요 API 레이턴시: 약간 감소 (중간 서버 추가로 네트워크 구간 증가했으나 성능 유지)</li>
<li>리스폰스 사이즈: 최대 44% 감소</li>
</ul>
<p><strong>DB 관점</strong></p>
<ul>
<li>저녁 타임 피크 기준 라이터 DB CPU: 최대 7% 감소 (비싼 머신 사용 중이라 큰 변화 기대 어려웠으나 성과 달성)</li>
<li>데일리 기준 라이터 DB CPU: 5% 이상 감소</li>
<li>리더 DB CPU: 각각 1%, 2.7% 증가 (트랜잭션 분리하여 라이터 DB 사용 줄이고 리더 DB로 전환한 의도대로 작동)</li>
</ul>
<h3 id="4-무장애-달성">4. 무장애 달성</h3>
<p><strong>4개월간 개선 업무로 장애 발생 없음</strong> - 참여자들이 가장 만족하는 성과</p>
<h3 id="🧐-세션-후기-3">🧐 세션 후기</h3>
<p>대규모 프로젝트의 리팩토링 과정을 들으며, 만약 내가 시작한다면 어디서부터 손대야 할지 상상해보았다.</p>
<p>결론적으로, &quot;<strong>네! 10분 동안 멍만 때렸습니다.</strong>&quot;</p>
<p>리팩토링 능력을 키우려면 어떻게 해야 할까? 애초에 모든 것을 고려한 설계란 가능한 것일까?
책을 더 읽고, 다른 사람들의 코드를 관찰하며 학습할 필요가 있겠다.</p>
<hr>
<h2 id="rag-들어는-봤는데-내-서비스엔-어떻게-쓰지">RAG, 들어는 봤는데... 내 서비스엔 어떻게 쓰지?</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/71d7dc77-6e72-4eb5-b047-92f9491674f8/image.png" alt=""></p>
<p>이번 세션은 개인적으로 가장 기대했던 부분이다.
‘모잇지’ 프로젝트에서는 핵심 로직에 AI를 직접 활용해보았고, 앞으로는 단순히 학습 데이터에 기반한 응답이 아닌, <strong>실제 데이터 기반</strong>의 신뢰성 높은 응답으로 서비스를 발전시키고자 했다.</p>
<p>그 과정에서 주목한 개념이 바로 <strong>RAG(Retrieval-Augmented Generation)</strong> 이다.
RAG는 최근 AI 분야에서 가장 주목받는 트렌드 중 하나로, 관련 학습 자료도 다양하게 존재했다.
그러나 여러 자료를 살펴보아도, 우아한테크코스 백엔드 코치 <code>검프</code> 의 설명만큼 명확하게 이해되는 것은 없었다.</p>
<h3 id="1️⃣-rag가-필요한-이유">1️⃣ RAG가 필요한 이유</h3>
<p>LLM을 사용할 때 쿼리를 던지면 때로는 정상 응답이 오고, 때로는 응답이 불가하거나 <strong>환각(Hallucination)</strong> 현상이 발생한다. LLM이 바보라서일까? 아니다.
이런 일이 발생하는 이유는 LLM이 인터넷에 있는 정보와 학습 모델이 가진 자체 데이터로만 학습했기 때문이다. 환각이 발생하는 이유는 학습 데이터에 <strong>없는 것</strong>을 요청했기 때문이다.</p>
<h4 id="▪️예시">▪️예시</h4>
<p>질문: &quot;이번 주 미팅 일정은?&quot;
결과: 환각 발생 (LLM의 학습 데이터에 없는 정보)</p>
<p>당연한 결과이다. 사람의 경우에도 누군가가 대뜸 이런 질문을 하더라도 그 사람의 일정에 대해 모른다면 대답을 할리 만무하다. </p>
<p>해결 방법은 <code>쿼리 + 컨텍스트</code>을 같이 제공한다. 캘린더 파일과 함께 &quot;이번 주 미팅 일정은?&quot;이라고 보내면 LLM이 쿼리와 컨텍스트를 이해하고 정상적인 응답을 할 수 있다.</p>
<h4 id="▪️쿼리-컨텍스트-결합도-증가">▪️쿼리-컨텍스트 결합도 증가</h4>
<p>이때 새로운 문제가 발생한다. 준비해야 할 <code>쿼리-컨텍스트</code>가 많아지면 어떻게 될까? </p>
<p>&quot;이번 주 미팅 일정은?&quot;
&quot;기획서 내용 요약해줘&quot;
&quot;데이터가 존재하는지 확인해줘&quot;</p>
<p>쿼리-컨텍스트 결합도가 증가하면 또 다른 문제가 발생한다. 쿼리로는 &quot;이번 주 미팅 일정은?&quot;이라고 보냈는데 컨텍스트로 파일 시스템 정보를 주면 LLM이 이해하지 못하고 잘못된 응답을 받게 된다.</p>
<h3 id="2️⃣-rag란">2️⃣ RAG란?</h3>
<p>RAG는 이러한 문제를 해결한다. 사용자가 쿼리를 보내면 그 쿼리에 맞는 정보를 <strong>지식 베이스</strong>에서 검색해 온 다음, 검색해 온 내용을 바탕으로 쿼리와 함께 LLM에게 정보를 보내어 정상적인 응답을 받게 하는 것이다.</p>
<blockquote>
<p>💡 한마디로 RAG란 <br>
<strong>LLM이 새로운 정보를 검색하고 통합할 수 있도록 하는 기술</strong></p>
</blockquote>
<h3 id="3️⃣rag의-3가지-핵심-컴포넌트">3️⃣RAG의 3가지 핵심 컴포넌트</h3>
<p><strong>1. 색인(Indexing) 파이프라인</strong>
문서를 검색 가능한 형태로 준비하고 저장하는 단계. 예를 들어 캘린더 파일을 검색해서 가져오기 위해 미리 준비하는 것이다.</p>
<p><strong>2. 생성(Generation) 파이프라인</strong>
질문에 맞는 정보를 검색하고 답변을 생성하는 과정. 색인 파이프라인에서 준비된 파일을 가져와 LLM에게 던지고 정상적인 응답을 받게 하는 과정이다.</p>
<p><strong>3. 평가(Evaluation)</strong>
사용자와 직접 인터랙션하지는 않지만 시스템의 품질을 측정하고 개선하기에 필수적인 요소다.</p>
<h3 id="4️⃣-rag-구현-6단계">4️⃣ RAG 구현 6단계</h3>
<p>(실은 이전에 검프가 교육 운영 RAG를 만들기 위해 <strong>MCP</strong> 를 도입하는 과정이 있었지만 내용이 길어 이번에는 적지 않았다.)</p>
<blockquote>
<p>💡RAG 구현 6단계 요약 <br></p>
</blockquote>
<ol>
<li>사용 사례 식별 및 RAG 필요성 평가: RAG의 필요성 확인</li>
<li>요구 사항 수집 및 분석: RAG 시스템에 필요한 요구 사항 분석</li>
<li>RAG 프레임워크 결정: Spring AI 선택</li>
<li>색인 파이프라인 구현: 로딩, 청킹, 임베딩, 저장소</li>
<li>생성 파이프라인 구현: 검색, 증강, 생성</li>
<li>평가 구현: 컨텍스트 관련성, 답변 충실성, 답변 관련성</li>
</ol>
<p>RAG를 구현하기 위해서는 위의 여섯 단계가 필요했다. 이 여섯 단계가 완료되어야 운영 배포가 완료되었다고 말할 수 있다.</p>
<p>이제부터 단계별로 요약하여 설명하겠다.</p>
<h3 id="1-사용-사례-식별-및-rag-필요성-평가">1. 사용 사례 식별 및 RAG 필요성 평가</h3>
<p>RAG가 듣고보니 정말 좋은데 모든 AI 관련 프로젝트에서 도입하면 좋지 않을까? 
그렇지 않다. RAG를 무조건 써야 한다고 생각하면 배보다 배꼽이 커지는 경우가 발생한다.
한다. 
RAG는 <code>쿼리-컨텍스트</code> <strong>결합도</strong>가 많이 증가할 때가 RAG 도입 시점이다.</p>
<p><strong>RAG가 필요한 경우</strong></p>
<ul>
<li>실시간으로 정보가 업데이트되는 경우</li>
<li>도메인 내 특화 지식이 필요한 경우</li>
</ul>
<p><strong>RAG가 불필요한 경우</strong></p>
<ul>
<li>간단한 질의응답</li>
<li>업데이트되지 않는 정적 데이터</li>
<li>일반적인 대화</li>
</ul>
<h3 id="2--요구-사항-수집-및-분석">2.  요구 사항 수집 및 분석</h3>
<h3 id="3-rag-프레임워크-결정">3. RAG 프레임워크 결정</h3>
<p>어떤 프레임워크를 사용할지 결정하는 단계다. 검프는 RAG 구현시 스프링 AI를 선택했고 이유는 아래와 같다.</p>
<p><strong>Spring AI vs LangChain 비교</strong></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>Spring AI</th>
<th>LangChain</th>
</tr>
</thead>
<tbody><tr>
<td>요구 사항 변경 시 유연한 대응</td>
<td>익숙한 자바 환경, 스프링 생태계 - 가능</td>
<td>파이썬 환경, 추가 학습 필요 - 어려움</td>
</tr>
<tr>
<td>팀원들이 단기간에 습득 가능</td>
<td>팀 내 기술 스택과 동일 - 가능</td>
<td>추가 학습 필요 - 어려움</td>
</tr>
<tr>
<td>배포 가능</td>
<td>기존 배포 플랫폼 지원 - 가능</td>
<td>추가 연동 필요 - 어려움</td>
</tr>
<tr>
<td>로깅 추적 가능</td>
<td>가능</td>
<td>불가능</td>
</tr>
</tbody></table>
<p><strong>유의사항</strong></p>
<ul>
<li><p>프레임워크의 장점보다 <strong>팀이 익숙한 언어 환경이 생산성에 더 큰 영향</strong>을 미침</p>
</li>
<li><p>혼자 개발하거나 배울 수는 있지만 팀 내 코드 리뷰나 코멘트를 받으려면 팀원들이 익숙한 언어가 훨씬 생산성이 좋음</p>
</li>
<li><p>모든 항목에서 가능한 것은 없음 - 우리 상황에서 중요한 것의 우선순위 결정 필요</p>
</li>
<li><p>&quot;완벽함보다 빠른 검증&quot; 슬로건으로 빠른 빌드, 배포, 검증이 가능한 Spring AI 선택</p>
</li>
</ul>
<h3 id="4-색인-파이프라인-구현">4. 색인 파이프라인 구현</h3>
<p>문서를 검색 가능한 형태로 준비하고 저장하는 단계다. 핵심 요소는 <strong>로딩, 청킹, 임베딩, 저장소</strong> 네 가지다.</p>
<h4 id="4-1-로딩-loading">4-1. 로딩 (Loading)</h4>
<p>데이터 소스를 연결하고 수집하는 요소다. 캘린더 API를 통해 캘린더 파일을 가져온다고 생각하면 된다.</p>
<p><strong>고려 사항</strong></p>
<ul>
<li><p>어떤 데이터 연결: DB, 구글 드라이브, 캘린더</p>
</li>
<li><p>어떤 포맷 파싱: JSON, 텍스트, XML</p>
</li>
<li><p>데이터 정제: 개인정보 마스킹, 중복 제거</p>
</li>
</ul>
<p><strong>유의사항</strong></p>
<ul>
<li><p>텍스트 데이터를 먼저 고려한다. (이미지, 음성, 비디오는 이후 모든 프로세스 변경 필요)</p>
</li>
<li><p>외부 API 호출 제한에 유념한다.</p>
</li>
<li><p>한 번에 모든 데이터를 가져올지, 조금씩 가져올지 확인한다.</p>
</li>
</ul>
<h4 id="4-2-청킹-chunking">4-2. 청킹 (Chunking)</h4>
<p>데이터를 검색 가능한 단위로 분할하는 요소다. 캘린더에서 가져온 파일을 검색 가능한 단위로 잘게 쪼갠다고 생각하면 된다.</p>
<p><strong>고려 사항</strong></p>
<ul>
<li><p>분할 전략: 고정 크기 청킹, 부모-자식 구조</p>
<ul>
<li>고정 크기 청킹: 청크 크기를 미리 결정</li>
<li>부모-자식 구조: 부모 청크에는 전반적인 요약, 자식 청크에는 세부 사항과 부모 청크 정보 또는 시퀀스 번호</li>
</ul>
</li>
<li><p>크기와 중복: 5천 자 (Spring AI 기본값), 중복 없음 (부모-자식 구조)</p>
</li>
<li><p>메타데이터: 출처, 시간, 구조 정보 저장</p>
</li>
</ul>
<p><strong>유의사항</strong></p>
<ul>
<li><p>청크 크기 최적화가 필요하다.</p>
<ul>
<li>작으면: 빠르고 성능 좋지만 문맥적 검색 신뢰도가 떨어진다.</li>
<li>크면: 성능 떨어진다.</li>
<li>도메인 특성에 따라 설정하는 것을 추천한다.</li>
</ul>
</li>
<li><p>청킹 전략에 따라 분할로 인한 의미 손실 발생 - 도메인 고려하여 손실 허용 범위 설정</p>
</li>
<li><p>메타데이터는 필수다. 청크된 데이터의 의미를 정리해 주는 데이터가 있으면 검색 시 더 나은 성능</p>
</li>
</ul>
<h4 id="4-3-임베딩-embedding">4-3. 임베딩 (Embedding)</h4>
<p>분할된 내용을 컴퓨터가 이해하는 n차원 형태로 변환하는 것이다. 배열의 각 요소가 차원이고, 각 차원의 숫자들이 분할한 데이터를 표현한다.</p>
<p><strong>유의사항</strong></p>
<ul>
<li><p>파운데이션 모델로 항상 먼저 검증</p>
</li>
<li><p>도메인 속성에 따라 파인 튜닝할 수 있지만 처음 전체 플로우를 해볼 때까지는 파운데이션 모델 사용</p>
</li>
<li><p>차원 수는 높을수록 성능 좋지만 비용 증가 - 적절한 수 선택</p>
</li>
</ul>
<h4 id="4-4-저장소-storage">4-4. 저장소 (Storage)</h4>
<p>임베딩된 데이터를 저장하고 검색할 서비스다. 컴퓨터가 이해할 수 있는 벡터 형태로 변환된 파일을 나중에 검색하기 위해 저장한다.</p>
<h3 id="5-생성-파이프라인-구현">5. 생성 파이프라인 구현</h3>
<p>질문에 맞는 정보를 검색하고 답변을 생성하는 과정이다. 핵심 요소는 <strong>검색, 증강, 생성</strong> 세 가지다.</p>
<h4 id="5-1-검색-retrieval">5-1. 검색 (Retrieval)</h4>
<p>사용자 질문에 가장 관련성 높은 청크를 추출하는 것이다. 색인 단계에서 저장된 청크들을 검색기에서 가져온다.</p>
<p><strong>고려 사항</strong></p>
<ul>
<li>검색 전략: 저장소 기본 설정인 문맥적 임베딩 사용<ul>
<li>문맥적 임베딩: 문맥 기반 검색 (예: &quot;애플&quot;이 과일인지 회사인지 구분한다)</li>
</ul>
</li>
<li>사용자 질문 최적화: LLM을 이용하여 최적화한다. (불필요한 것은 버리고 필요한 것만 남긴다)</li>
</ul>
<p><strong>유의사항</strong></p>
<ul>
<li><p>메타데이터 필터링으로 검색 효율성 향상: 데이터 세부 내용보다 필터링된 내용 검색이 효율적이다.</p>
</li>
<li><p>청크 개수 설정 필요: 많이 가져올수록 컨텍스트 윈도우가 빠르게 소비 된다.</p>
</li>
<li><p>유사도 점수 기준 필요: 우리 도메인에서 어느 정도 유사도까지를 맞다고 판단할지 설정해야한다.</p>
</li>
<li><p>검색된 데이터의 정밀도는 평가 단계에서 면밀히 검토한다.</p>
</li>
</ul>
<h4 id="5-2-증강-augmentation">5-2. 증강 (Augmentation)</h4>
<p>검색된 청크를 사용자 질문과 결합하여 LLM에게 전달하는 요소다. 사용자 질문과 검색된 데이터를 프롬프트와 함께 던지는 과정이다.</p>
<p><strong>고려 사항</strong></p>
<ul>
<li><p>페르소나를 정의: 
예시) AI가 수행할 역할이나 성격을  &quot;교육 운영 데이터를 검색해 주는 서비스&quot;</p>
</li>
<li><p>컨텍스트 제약: 검색된 내용으로만 답변</p>
</li>
<li><p>프롬프팅 기법: 추론 과정을 응답하도록 요구, 답변 형식을 특정 형식으로 지정</p>
</li>
</ul>
<p><strong>유의사항</strong></p>
<ul>
<li><p>검색된 청크 원본 유지하여 검증: 프롬프트를 수정하다 청크가 너무 커져도 청크 수정보다는 프롬프팅 수정이나 청크 개수를 조정한다.</p>
</li>
<li><p>프롬프트 명확히 제시: &quot;검색된 내용으로만 답변해&quot;, &quot;이런 응답 형식으로 답변해&quot; 등 명확한 제약</p>
</li>
<li><p>프롬프트도 컨텍스트 윈도우 사이즈에 포함 - 간결화가 필요하다.</p>
</li>
</ul>
<h4 id="5-3-생성-generation">5-3. 생성 (Generation)</h4>
<p>증강된 프롬프트를 받아 최종 응답을 생성하는 요소다. 쿼리, 컨텍스트, 프롬프트를 LLM이 받아서 사용자에게 응답하는 과정이다.</p>
<p><strong>유의사항</strong></p>
<ul>
<li><p>파운데이션 모델로 먼저 검증: 오픈소스 모델, 파인 튜닝 모델도 있지만 처음에는 범용 모델로 모든 프로세스를 검증하는 것을 추천한다.</p>
</li>
<li><p>프롬프트의 점 하나로 결과값이 크게 달라진다.(진짜로)</p>
</li>
<li><p>모델 자체가 바뀌면 기존에 잘 나오던 것도 안 나올 수 있다.</p>
</li>
<li><p>모델 크기는 도메인의 가치에 따라 설정한다 : 방대한 데이터 검색이나 의료 정보는 큰 모델, 단순 캘린더 일정은 작은 모델을 선택한다.</p>
</li>
<li><p>성능과 속도 사이의 트레이드 오프 필요가 필요하다: 늦더라도 정확한 답변 vs 빠르지만 부족한 답변 사이의 트레이드 오프가 필요하다.</p>
</li>
<li><p>증강된 데이터의 품질은 평가 단계에서 고려한다.</p>
</li>
</ul>
<h3 id="6-평가-구현">6. 평가 구현</h3>
<p>시스템의 품질을 측정하고 개선하는 과정이다. 세 가지 큰 지표를 측정한다.</p>
<h4 id="6-1-컨텍스트-관련성-context-relevance">6-1. 컨텍스트 관련성 (Context Relevance)</h4>
<p>검색된 정보가 사용자 질문과 얼마나 관련성이 높은지를 평가하는 지표다.</p>
<h4 id="6-2-답변-충실성-answer-faithfulness">6-2. 답변 충실성 (Answer Faithfulness)</h4>
<p>생성된 답변이 검색된 정보에 얼마나 사실적으로 근거하는지 평가하는 지표다.</p>
<h4 id="6-3-답변-관련성-answer-relevance">6-3. 답변 관련성 (Answer Relevance)</h4>
<p>생성된 답변이 사용자 질문과 얼마나 관련성이 높은지 평가하는 지표다.</p>
<hr>
<h2 id="2025-우아콘을-마치며🎉">2025 우아콘을 마치며🎉</h2>
<h3 id="처음의-마음가짐">처음의 마음가짐</h3>
<p>사실 처음 마음가짐은 <strong>&quot;컨퍼런스 현장의 분위기를 느끼자&quot;</strong>였다.</p>
<p>아마도 마음 한편에 발표를 하시는 개발자 분의 내용을 내가 감히 이해할 수 없을 거라는 주눅이 들었었나 보다.</p>
<h3 id="예상과-달랐던-경험">예상과 달랐던 경험</h3>
<p>하지만 막상 발표를 들어보니 <strong>발표 내용이 이해가 되었고, 너무나도 배울 점이 많았다.</strong></p>
<p>우테코에 들어오기 전이라면 하나도 알아듣지 못했을 내용들이 전부는 아니더라도 귀에 들어오고, 발표 내용에 대해서 고민까지 할 수 있게 되었다.</p>
<p>실제로 <strong>슬리도를 통해 발표자 분께 이것저것 질문을 하고 답변을 받는 경험</strong>을 할 수 있었기에, 우아한형제들의 현업 개발자 분들과 커뮤니케이션을 하고 있다는 사실에 더욱 뿌듯했던 것 같다.</p>
<h3 id="성장의-원동력">성장의 원동력</h3>
<p>아마도 이 모든 것은 <strong>우테코 내의 커리큘럼에 녹아있던 교육철학을 자연스레 배운 덕분</strong>이라 생각한다.</p>
<p>다시 한번 이런 소중한 경험을 주신 <strong>코치님들</strong>과 다양한 이야기를 나누었던 <strong>크루들</strong>에게 감사함을 전하고 싶다.</p>
<h3 id="앞으로의-다짐">앞으로의 다짐</h3>
<p>앞으로도 이러한 컨퍼런스에 자주 참여할 것 같다.</p>
<p>동기부여도 되고 다양한 개발자들의 모습을 볼 수 있다는 것은 많은 것을 배우게 해준다.</p>
<hr>
<p><strong>2025 우아콘은 내 인생 첫 컨퍼런스였고, 최고의 경험이었다!</strong> 🎉</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[예외 처리(Exception Handling) Part 3: [커스텀 예외]]]></title>
            <link>https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-3-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%98%88%EC%99%B8%EC%99%80-%EC%98%88%EC%99%B8-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-3-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%98%88%EC%99%B8%EC%99%80-%EC%98%88%EC%99%B8-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Tue, 14 Oct 2025 07:05:57 GMT</pubDate>
            <description><![CDATA[<p>이전 글 <a href="https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-2-throws-try-catch">💡<span style="color:#7CFC00"><strong>throws, try-catch</strong></span></a> 에서는 Java의 기본 예외 처리 메커니즘과 예외 전파 방식에 대해 살펴보았다.</p>
<p>이번 글에서는 한 단계 더 나아가 <strong>커스텀 예외(Custom Exception)를 가 무엇인지, 올바른 커스텀 예외 생성 방법에 대하여 써보았다.</strong></p>
<hr>
<h2 id="커스텀-예외custom-exception">커스텀 예외(Custom Exception)</h2>
<p>예외 처리를 학습하다 보면 한 가지 의문이 생긴다.</p>
<blockquote>
<p>“표준 예외(IllegalArgumentException, IOException 등)만으로 충분할까?”
“우리 서비스의 도메인 로직을 반영한 의미 있는 예외는 어떻게 만들어야 할까?”</p>
</blockquote>
<p>이 질문의 답을 찾는 과정에서 <strong>커스텀 예외(Custom Exception)</strong> 개념이 등장한다. 
<br></p>
<h3 id="커스텀-예외가-필요한-이유">커스텀 예외가 필요한 이유</h3>
<p>표준 예외는 대부분 일반적인 오류 상황을 표현한다.
예를 들어 IllegalArgumentException은 잘못된 인자를 의미하지만 “사용자 포인트가 부족하다”와 같은 도메인 고유의 상황을 담기에는 한계가 있다.</p>
<p>즉, <strong>비즈니스 맥락(Context)</strong>을 명확히 전달하기 어렵다.</p>
<p><strong>예시</strong></p>
<table>
<thead>
<tr>
<th>구분</th>
<th>표준 예외</th>
<th>커스텀 예외</th>
</tr>
</thead>
<tbody><tr>
<td>예외명</td>
<td><code>IllegalArgumentException</code></td>
<td><code>InsufficientPointException</code></td>
</tr>
<tr>
<td>의미</td>
<td>잘못된 인자</td>
<td>포인트 부족으로 인한 결제 실패</td>
</tr>
<tr>
<td>맥락 반영 여부</td>
<td>❌ 일반적</td>
<td>✅ 도메인에 특화됨</td>
</tr>
</tbody></table>
<p>이처럼 커스텀 예외를 통해 “어떤 문제인지”뿐만 아니라
“왜 발생했는지”를 코드 레벨에서 명확히 표현할 수 있다.
<br></p>
<h3 id="커스텀-예외-설계-기본-구조">커스텀 예외 설계 기본 구조</h3>
<p>커스텀 예외는 Throwable와 상속관계에 있는 클래스를 상속받아 사용한다. 일반적으로 RuntimeException 을 상속받아 정의한다.
이는 명시적인 try-catch 없이도 전파된다.</p>
<pre><code class="language-java">pblic class ExternalApiException extends RuntimeException {

    public ExternalApiException(String message) {
        super(message);
    }

}</code></pre>
<br>

<hr>
<h3 id="커스텀-예외-best-practice">커스텀 예외 Best Practice</h3>
<p>필요한 예외를 직접 정의하는 것은 좋은 접근이다.
하지만 만든 예외의 이름이 <strong>&quot;NoData&quot;, &quot;GoodException&quot;, &quot;BadSituation&quot;</strong> 과 같다면 어떨까?</p>
<p>이런 이름의 예외가 애플리케이션 실행 중 발생한다면 문제가 ‘무엇 때문인지’ 파악하기가 매우 어려울 것이다. 
예외 메시지를 보고도 원인을 유추할 수 없다면  그 예외는 사실상 <strong>의미 없는 신호</strong>에 불과하다.</p>
<p>단독으로 개발하는 상황이라면 그나마 감내할 수 있겠지만 프로젝트가 팀 단위로 진행되는 협업 환경이라면 이야기가 달라진다.
코드 리뷰나 디버깅 과정에서
“이 예외가 정확히 어떤 상황을 의미하는가?”라는 질문이 쏟아질 것이고 팀원들 사이에서 좋지 않은 인상을 줄 가능성이 높다.</p>
<p>즉, 예외 이름은 예외의 본질을 드러내야 한다.
“무엇이 잘못되었는가?”를 명확히 표현하지 못하는 예외는 오히려 문제 해결을 더디게 만든다.</p>
<p>이러한 상황을 방지하고 좋은 예외를 만드는 4가지 Best Practice를 살펴보자.</p>
<h4 id="1-always-provide-a-benefit">1. Always Provide a Benefit</h4>
<blockquote>
<p>항상 “이 예외를 통해 얻을 수 있는 정보”를 제공하자.</p>
</blockquote>
<p>커스텀 예외는 단순히 오류를 알리는 용도가 아니다.
개발자에게 문제의 원인을 명확히 설명하고, 해결 방향을 제시해야 한다.</p>
<p>즉, “이 예외를 던짐으로써 팀원이 얻을 수 있는 <strong>이점(benefit)</strong>”이 있어야 한다.</p>
<p>아무런 이점도 제공할 수 없다면 <code>UnsupportedOperationException</code>  이나 <code>IllegalArgumentException</code> 과 같은 표준 예외 중 하나를 사용하는 것이 좋다 . 모든 Java 개발자는 이미 이러한 예외를 알고 있을 것이다. 이렇게 하면 코드와 API를 더 <strong>쉽게</strong> 이해할 수 있다.  </p>
<p><strong>예시</strong></p>
<pre><code class="language-java">// ❌ 나쁜 예시
throw new NoDataException(&quot;데이터 없음&quot;);

// ✅ 좋은 예시
throw new UserNotFoundException(&quot;ID가 42인 사용자를 찾을 수 없습니다.&quot;);</code></pre>
<p>좋은 예외는 에러 로그를 읽는 순간 무엇이, 왜, 어디서 발생했는지 즉시 이해할 수 있어야한다. 이는 <strong>디버깅 속도</strong>를 높이고  <strong>장애</strong>에 대응하는 시간을 단축시킨다.</p>
<br>

<h4 id="2-follow-the-naming-convention">2. Follow the Naming Convention</h4>
<blockquote>
<p>예외 클래스는 반드시 Exception으로 끝나야 한다.</p>
</blockquote>
<p>명명 규칙을 통일하면 프로젝트 전체에서 예외를 <strong>식별</strong>하기 쉬워진다.
또한 IDE나 검색 도구를 사용할 때, <code>Exception</code> 접미사만으로 관련 클래스를 빠르게 찾을 수 있다.</p>
<p>실제로 Java에서 기본적으로 구현한 예외들을 살펴보면 모두 <code>Exception</code>으로 끝나는걸 확인할 수 있다. </p>
<table>
<thead>
<tr>
<th>❌ 잘못된 이름</th>
<th>✅ 올바른 이름</th>
</tr>
</thead>
<tbody><tr>
<td><code>UserNotFound</code></td>
<td><code>UserNotFoundException</code></td>
</tr>
<tr>
<td><code>ApiError</code></td>
<td><code>ExternalApiException</code></td>
</tr>
<tr>
<td><code>PointLack</code></td>
<td><code>InsufficientPointException</code></td>
</tr>
</tbody></table>
<br>

<h4 id="3-provide-javadoc-comments-for-your-exception-class">3. Provide Javadoc Comments for Your Exception Class</h4>
<blockquote>
<p>예외의 목적과 사용 의도를 명확히 문서화하자.</p>
</blockquote>
<p>커스텀 예외 클래스는 <strong>재사용</strong>될 가능성이 높다.
따라서 예외가 언제 발생하는지, 어떤 상황에서 던져지는지를 Javadoc 주석으로 명확히 기술하는 것이 중요하다.
이를 통해 팀원이나 후속 개발자가 예외의 용도를 이해하기 쉽다.</p>
<p>실제로 모잇지 서비스를 구현하면서 커스텀 예외 문서화의 중요성을 크게 느꼈다.
프론트파트 개발시 간헐적 API 에러가 발생했는데 Swagger를 활용하여 다양한 커스텀 예외를 문서화하여 프론트엔드와 협업할 때 예외 발생 상황과 의미를 명확히 전달할 수 있었고 </p>
<p>그 결과 장애 대응을 빠르게 할 수 있었다.</p>
<pre><code class="language-java">/**
 * 외부 API 호출 중 예기치 않은 오류가 발생했을 때 던져진다.
 * 주로 네트워크 지연, 응답 파싱 실패, 인증 오류 등의 상황에서 사용된다.
 */
public class ExternalApiException extends RuntimeException {
    public ExternalApiException(String message) {
        super(message);
    }
}
</code></pre>
<br>

<h4 id="4-provider-constructor-that-sets-the-cause">4. Provider Constructor That Sets the Cause</h4>
<blockquote>
<p>원인 예외(cause)를 함께 전달하는 생성자를 제공하자.</p>
</blockquote>
<p>커스텀 예외가 감싸는 <code>내부 예외</code>(예: IOException, SQLException)를 명시적으로 연결하면 전체 예외 흐름을 추적하기가 훨씬 쉬워진다.</p>
<pre><code class="language-java">public class ExternalApiException extends RuntimeException {

    public ExternalApiException(String message) {
        super(message);
    }

    public ExternalApiException(String message, Throwable cause) {
        super(message, cause);
    }
}</code></pre>
<p>이 패턴을 사용하면 예외 스택 트레이스에 원인(cause) 이 함께 포함되어 로그 분석 및 장애 추적이 용이해진다.</p>
<h4 id="커스텀-예외-체크리스트">커스텀 예외 체크리스트</h4>
<table>
<thead>
<tr>
<th>항목</th>
<th>체크</th>
</tr>
</thead>
<tbody><tr>
<td>예외 이름이 Exception으로 끝나는가?</td>
<td>☐</td>
</tr>
<tr>
<td>예외 이름이 발생 원인을 명확히 표현하는가?</td>
<td>☐</td>
</tr>
<tr>
<td>Javadoc 주석으로 예외의 목적을 설명했는가?</td>
<td>☐</td>
</tr>
<tr>
<td>원인 예외를 전달하는 생성자를 제공하는가?</td>
<td>☐</td>
</tr>
<tr>
<td>표준 예외로 충분한지 검토했는가?</td>
<td>☐</td>
</tr>
<tr>
<td>비즈니스 맥락을 반영하고 있는가?</td>
<td>☐</td>
</tr>
</tbody></table>
<p>이 표는 커스텀 예외를 설계할 때 확인해야 할 체크리스트이다. 각 항목을 검토하면서 좋은 커스텀 예외를 만들 수 있다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>커스텀 예외가 무엇인지, 어떻게 사용하는지 알아보았다. 
가장 중요한 것은 커스텀예외를 통해 <strong>무엇을</strong> 전달할 것인지 명확히 해야한다. </p>
<p>예외 처리는 비용이 크다. 그렇기에 예외를 남발하기 보다는 꼭 필요한 부분에 구현하고 문제 상황에 따른 적절한 에외를 발생하는게 중요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[예외 처리(Exception Handling) Part 2: [throws, try-catch]]]></title>
            <link>https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-2-throws-try-catch</link>
            <guid>https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-2-throws-try-catch</guid>
            <pubDate>Tue, 14 Oct 2025 02:17:58 GMT</pubDate>
            <description><![CDATA[<p>이전에 썼던<a href="https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-1-%EC%98%88%EC%99%B8%EC%9D%98-%EA%B0%9C%EB%85%90">💡<span style="color:#7CFC00"><strong>예외의 개념과 종류</strong></span></a> 에서는 예외란 무엇인지, Checked 예외와 Unchecked 예외에 대해서 알아보았다. 이번에는 본격적인 예외 처리 방법에 대해 다루고자 한다. 특히 <strong><code>throws</code></strong>와 <strong><code>try-catch</code></strong> 구문을 중심으로 각각의 역할과 사용 시점을 설명할 것이다.</p>
<hr>
<h2 id="1-throws">1. Throws</h2>
<p>Java에서 메서드가 호출 중에 예외를 발생시킬 가능성이 있는 경우 그 예외를 메서드 선언부에 명시하는 것이 바로 <code>throws</code> 키워드다.</p>
<p>특히 Checked 예외는 컴파일러가 예외 처리를 <strong>강제</strong>하기 때문에 메서드 내부에서 직접 처리하지 않고 상위 호출자에게 전달하려면 반드시 throws를 사용해야 한다.</p>
<pre><code class="language-java">public void readFile(String path) throws IOException {
    FileReader reader = new FileReader(path);
    // 파일 읽기 로직
}</code></pre>
<p>위 코드에서 readFile 메서드는 <code>IOException</code>을 처리하지 않고 호출자에게 전달한다.
따라서 호출하는 쪽에서 <code>try-catch</code> 로 예외를 처리하거나 다시 <code>throws</code>로 던져야 한다.</p>
<blockquote>
<p>💡개발자는 throws로 던져진 예외를 어느 <code>계층</code>에서 처리할지 명확히 결정해야 한다.
예를 들어 Controller–Service–Repository 구조에서는, Repository에서 발생한 예외를 어디까지 전파할지가 중요한 설계 포인트다.
이에 대한 구체적인 논의는 다음 글에서 다루겠다.</p>
</blockquote>
<hr>
<h2 id="2-try-catch">2. try-catch</h2>
<p>예외가 발생하면 기본적으로 try-catch를 사용하여 예외를 처리할 수 있다. </p>
<pre><code class="language-java">try{
    //예외가 발생할 가능성이 있는 로직
}catch(Excetpion e){
    //예외가 발생시 실행할 로직
}</code></pre>
<p>기본적인 형태는 위와 같이 <code>try</code> 구간과 <code>catch</code> 구간으로 나뉘어져 있다. </p>
<h3 id="2-1-try">2-1. try</h3>
<ul>
<li>예외가 발생할 가능성이 있는 로직이 위치한다.</li>
</ul>
<h3 id="2-2-catch">2-2. catch</h3>
<ul>
<li>예외가 발생하면 catch 블록이 실행된다.  </li>
<li>catch()의 파라미터에는 처리할 <strong>예외 클래스를 지정</strong>한다.  </li>
<li>예를 들어 <code>catch(IOException e)</code>는 IO 관련 예외만 처리한다.  </li>
</ul>
<h4 id="주의점">주의점</h4>
<ul>
<li><code>catch(Exception e)</code>는 모든 예외를 포괄하기 때문에 예외별 맞춤 처리 불가능하다. </li>
<li>일반적으로 <strong>구체적 예외 → 상위 예외 순서</strong>로 catch를 작성하는 것이 좋다.</li>
</ul>
<h4 id="예제">예제</h4>
<pre><code class="language-java">try {
    FileReader reader = new FileReader(&quot;data.txt&quot;);
} catch (FileNotFoundException e) {
    System.out.println(&quot;파일이 존재하지 않습니다: &quot; + e.getMessage());
} catch (IOException e) {
    System.out.println(&quot;입출력 오류 발생: &quot; + e.getMessage());
} catch(Exception e){
    System.out.println(&quot;Exception 예외까지 도달: &quot; +  e.getMessage());
}</code></pre>
<p>위의 코드를 보자. </p>
<ol>
<li>&#39;data.txt&#39; 파일이 없으면 → FileNotFoundException 발생</li>
<li>파일 읽는 도중 다른 IO 문제가 생기면 → IOException 발생</li>
<li>위 두 경우에 해당하지 않는 기타 문제 발생 시 → Exception 발생</li>
</ol>
<hr>
<h2 id="3-multi-catch">3. multi-catch</h2>
<p>만약 하나의 catch 문에 <code>복수</code>의 예외를 잡고 싶다면 아래와 같이 <code>multi-catch</code>로 구현할 수 있다.(Java 7 이상에서 사용 가능)</p>
<ul>
<li><p>처리 로직이 동일한 경우만 사용 가능</p>
</li>
<li><p>multi-catch 내부에서 e 변수는 final 취급 → 재할당 불가</p>
<pre><code class="language-java">try {
  // 파일 읽기 또는 DB 처리
} catch (IOException | SQLException e) {
  e.printStackTrace(); // 두 예외를 동일하게 처리
}</code></pre>
</li>
</ul>
<hr>
<h2 id="4-finally">4. finally</h2>
<p>만약 try-catch로 감싼 로직이 정상 동작하든, 예외가 발생하든 반드시 실행시키고 싶은 로직이 있다면 <strong><code>finally</code></strong> 를 이용하면 된다.
finally가 중요하게 쓰이는 대표적인 경우는 <strong>파일, DB, 소켓</strong> 등과 같은 자원 관리이다.
예외 발생 시 자원을 닫지 않으면 <strong>메모리 누수 / 리소스 누수</strong>가 발생할 가능성이 있기 때문이다.</p>
<pre><code class="language-java">public void readFileWithoutFinally() {
    FileReader reader = null;
    try {
        reader = new FileReader(&quot;data.txt&quot;);
        int data = reader.read();
        // 파일 읽기 로직 수행
    } catch (IOException e) {
        e.printStackTrace();
    }
    // reader.close()가 예외 발생 시 호출되지 않아 자원 누수 가능
}</code></pre>
<p>위 코드는 예외가 발생하면 <code>reader.close()</code>가 호출되지 않아 자원 누수 위험이 존재한다. 이를 보완한 코드를 살펴보자. 
예외 발생 여부와 관계없이 finally 블록 내부 코드는 <strong>항상</strong> 실행되기 때문에 자원 해제를 <strong>강제</strong>하여 메모리/리소스 누수를 방지한다.
<br>
&lt;<strong>finally로 보완한 안전한 코드</strong>&gt;</p>
<pre><code class="language-java">public void readFileWithFinally() {
    FileReader reader = null;
    try {
        reader = new FileReader(&quot;data.txt&quot;);
        int data = reader.read();
        // 파일 읽기 로직 수행
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            if (reader != null) {
                reader.close(); // 예외 여부와 관계없이 항상 호출
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}</code></pre>
<p>그렇다면 개발자는 <strong>파일, DB, 소켓</strong>등을 사용할 때 매번 직접 finally를 붙여야 할까? 물론 메모리 / 리소스 누수를 방지하기 위해서 반드시 작성해야할 것이다. 
하지만 문제점이 있다. 그것은 개발자가 <code>finally</code>를 실수로 안붙이는 <strong>휴먼에러</strong>의 가능성이 존재한다.
이를 보완해줄 방법이 존재한다.</p>
<hr>
<h2 id="5-try-with-resources">5. try-with-resources</h2>
<p>Java 7 이상에서는 파일, DB, 소켓 등 자원을 안전하게 자동으로 해제하기 위해 <strong><code>try-with-resources</code></strong> 구문을 사용할 수 있다.
이 구문을 사용하면 finally 블록을 직접 작성하지 않아도 자원을 자동으로 닫을 수 있어 코드가 간결해지고 예외 안전성이 높아진다.</p>
<h3 id="특징">특징</h3>
<ol>
<li><p><strong>자동 자원 관리</strong></p>
<ul>
<li>try 괄호 안에서 선언한 자원(AutoCloseable 구현 클래스)은 try 블록 종료 시 자동으로 close() 호출</li>
</ul>
</li>
<li><p><strong>예외 발생 여부와 상관없이 실행</strong></p>
<ul>
<li>Checked/Unchecked 예외 모두 처리 가능</li>
</ul>
</li>
<li><p><strong>코드가 간결해짐</strong></p>
<ul>
<li>finally 블록에서 자원을 닫는 반복적인 코드를 제거</li>
</ul>
</li>
</ol>
<br>

<h3 id="기본-문법">기본 문법</h3>
<pre><code class="language-java">try (자원 선언) {
    // 자원을 사용하는 로직
} catch (예외 e) {
    // 예외 처리
}</code></pre>
<p><code>try(자원 선언)</code> 의 괄호 안에는 FileReader, BufferedReader, Connection, PreparedStatement 과 같은 <strong>AutoCloseable</strong> 인터페이스를 구현한 객체를 선언할 수 있다.</p>
<p>또한 여러 자원도 <code>;</code>로 구분하여 동시에 선언 가능하다.
<br></p>
<h3 id="예제-jdbc-사용">예제: JDBC 사용</h3>
<p>아래 코드는 필자가 우테코 미션에서 구현한 <strong>JdbcTemplate</strong> 예제이다.
<code>try-with-resources</code>를 사용하여 Connection, PreparedStatement, ResultSet 객체를 자동으로 <code>close</code>하고 있다.</p>
<pre><code class="language-java">    public &lt;T&gt;List&lt;T&gt; query(String sql, RowMapper&lt;T&gt; rowMapper) {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            log.debug(&quot;query : {}&quot;, sql);

            List&lt;T&gt; results = new ArrayList&lt;&gt;();
            while (rs.next()) {
                results.add(rowMapper.mapRow(rs));
            }
            return results;
        } catch (SQLException e) {
            log.error(e.getMessage(), e);
            throw new DataAccessException(e);
        }
    }</code></pre>
<hr>
<h2 id="마무리">마무리</h2>
<p>예외 처리는 코드의 품질을 결정짓는 중요한 요소다.
throws로 예외를 넘길지, try-catch로 직접 처리할지, 혹은 try-with-resources로 안전성을 확보할지는 모두 의도적인 설계 선택이다.</p>
<p>결국 예외 처리의 목표는 “<strong>모든 예외를 잡는 것</strong>”이 아니라, <strong>올바른 위치</strong>에서 책임 있게 다루는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[예외 처리(Exception Handling) Part 1: [예외의 개념과 종류]]]></title>
            <link>https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-1-%EC%98%88%EC%99%B8%EC%9D%98-%EA%B0%9C%EB%85%90</link>
            <guid>https://velog.io/@sunset_1839/%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%ACException-Handling-Part-1-%EC%98%88%EC%99%B8%EC%9D%98-%EA%B0%9C%EB%85%90</guid>
            <pubDate>Mon, 13 Oct 2025 07:03:39 GMT</pubDate>
            <description><![CDATA[<h2 id="1-서론">1. 서론</h2>
<p>이 글은 우아한테크코스의 테크니컬 라이팅을 목적으로, 프로그래밍을 어느 정도 경험했지만 <strong>예외(Exception)</strong> 에 대한 이해가 부족하거나 궁금한 사람들을 위해 작성되었다.<br>관련 코드는 <strong>Java</strong> 로 작성되었으며, 자바와 스프링 영역에서의 예외의 개념부터 시작하여 다양한 예외 처리 전략 까지 단계적으로 살펴본다.  </p>
<p>대부분의 사람들은 프로그래밍을 하다 보면 다음과 같은 문제 상황을 한 번쯤 경험하게 된다.</p>
<ul>
<li>코드에 오타가 났다던가  </li>
<li>함수에 잘못된 값을 전달했다던가  </li>
<li>무한 루프를 만들고 실행했다던가  </li>
</ul>
<p>이러한 문제들은 프로그램의 정상적인 실행을 방해한다.<br>그렇다면 <strong>프로그래머들은 이런 상황을 어떻게 해결할까?</strong><br>이 질문에 대한 답이 바로 <strong>예외 처리(Exception Handling)</strong> 이다.  </p>
<hr>
<h2 id="2-예외와-에러의-차이">2. 예외와 에러의 차이</h2>
<p>예외(Exception)를 이해하기 위해서는 먼저 <strong>에러(Error)</strong> 와의 차이를 명확히 구분해야 한다. 결론부터 말하자면 차이점은 아래와 같다.</p>
<blockquote>
<p><strong>에러(Error)</strong> : 시스템의 고장으로 인한 복구 <strong>불가능한</strong> 문제
<strong>예외(Exception)</strong> : 프로그램 실행 중 발생하는 복구 <strong>가능한</strong> 문제</p>
</blockquote>
<blockquote>
<p>추가적으로 오류와 에러를 혼동하시는 분들이 있는데 프로그래밍 세계에서 일반적으로 <strong>‘에러’와 ‘오류’는 같은 의미</strong>로 사용된다.<br>즉, 일상적으로 “오류가 났다”라고 말하는 것은 대부분 <strong>시스템 수준의 에러(Error)</strong> 를 의미한다.</p>
</blockquote>
<h3 id="2-1-자바의-예외-계층-구조">2-1. 자바의 예외 계층 구조</h3>
<p><strong>자바</strong>에서는 에러와 예외를 Throwable 클래스를 최상위로 두고 시스템 수준의 에러는 Error,
프로그램 실행 중 발생하는 문제는 Exception으로 나누어 관리하고있다.</p>
<pre><code>Throwable
├── Error
└── Exception
     ├── RuntimeException (Unchecked)
     └── Checked Exception</code></pre><p>다음으로는 에러와 예외에 대한 개념을 알아보자.</p>
<h3 id="2-2-에러error란-무엇인가">2-2. 에러(Error)란 무엇인가</h3>
<p><strong>오류(Error)</strong> 는 <strong>프로그램이 제어할 수 없는 심각한 문제 상황</strong>을 뜻한다.<br>주로 <strong>시스템 자원 부족</strong>, <strong>가상머신(VM)의 비정상 동작</strong>, <strong>하드웨어 장애</strong> 등의 원인으로 발생하며, 다음과 같이 세 가지 유형으로 나눌 수 있다.  </p>
<h4 id="🔹-컴파일-에러-compile-time-error">🔹 컴파일 에러 (Compile-time Error)</h4>
<p>코드의 문법이나 구조가 언어의 규칙(Java, Python 등)에 맞지 않아 <strong>프로그램이 실행조차 되지 않는 오류</strong>이다.<br>이러한 에러는 <strong>컴파일러나 IDE에서 자동으로 탐지</strong>되며, 메시지를 통해 비교적 쉽게 해결할 수 있다.  </p>
<p><strong>예시:</strong>  </p>
<ul>
<li>문법 에러(Syntax Error)  </li>
<li>타입 불일치(Type Error)  </li>
</ul>
<hr>
<h4 id="🔹-런타임-에러-runtime-error">🔹 런타임 에러 (Runtime Error)</h4>
<p>프로그램 실행 도중 발생하는 예외적 상황으로, <strong>프로그램이 중단되거나 비정상 동작</strong>하게 된다.<br>이번 글의 주제인 <strong>예외(Exception)</strong> 는 바로 이 <strong>런타임 시점에서 발생</strong>한다.<br>이러한 에러는 <strong>로그 분석</strong>이나 <strong>스택 트레이스(Stack Trace)</strong> 를 통해 원인을 파악할 수 있으며, <strong>예외 처리 전략</strong>으로 대응이 가능하다.  </p>
<p><strong>예시:</strong>  </p>
<ul>
<li>0으로 나누기  </li>
<li>Null 참조  </li>
<li>파일 없음  </li>
</ul>
<hr>
<h4 id="🔹-논리-에러-logical-error">🔹 논리 에러 (Logical Error)</h4>
<p>문법적으로나 실행적으로는 문제가 없지만 <strong>프로그램의 결과가 의도와 다르게 나오는 오류</strong>이다.<br>예를 들어, <strong>잔고가 0원인데 결제가 되는 상황</strong>을 생각할 수 있다.<br>이런 오류는 <strong>테스트와 디버깅</strong>을 통해서만 탐지할 수 있다.  </p>
<p><strong>예시:</strong>  </p>
<ul>
<li>잘못된 알고리즘  </li>
<li>조건식 오류  </li>
<li>계산 실수  </li>
</ul>
<hr>
<h3 id="2-3-예외exception란-무엇인가">2-3. 예외(Exception)란 무엇인가</h3>
<p>사전적으로 <strong>예외</strong>는 어떤 일에서 제외되거나 특별한 경우를 의미하는 단어이다. 그러나 프로그래밍 세계에서의 <strong>예외(Exception)</strong>는 다른 의미를 가진다.</p>
<p>프로그래밍에서의 예외는 <strong>프로그램 실행 중(런타임)</strong> 발생하는 오류 상황을 말한다. <strong>예외 처리는</strong> 이러한 오류가 프로그램의 비정상 종료로 이어지지 않도록 오류를 감지하고, 이에 대한 적절한 대응 로직을 수행하는 과정이다.
즉, 예외는 “<strong>복구 가능한 오류</strong>”이며, 코드 레벨에서 처리할 수 있는 문제이다.</p>
<h4 id="예외-처리의-예시">예외 처리의 예시</h4>
<p>예를 들어, 파일을 열 때 해당 파일이 존재하지 않는다면 예외가 발생한다.<br>이 상황에서 예외를 처리하지 않으면 프로그램이 즉시 종료된다.<br>하지만 예외 처리를 통해 다음과 같이 대응할 수 있다.</p>
<pre><code class="language-java">try {
    FileReader reader = new FileReader(&quot;data.txt&quot;);
} catch (FileNotFoundException e) {
    System.out.println(&quot;파일이 없습니다. 기본 설정으로 진행합니다.&quot;);
}</code></pre>
<hr>
<p>위의 설명으로 사전 설명은 끝났다고 생각한다. 이제 본격적으로 예외에 대해 알아보자.</p>
<h2 id="exception-클래스">Exception 클래스</h2>
<p>자바 프로그래밍을 경험한 사람이라면 <code>Exception</code> 클래스를 한 번쯤은 마주쳤을 것이다.<br><code>Exception</code> 클래스가 <strong>어떻게 구성되어 있는지</strong>, 그리고 <strong>어떤 역할을 하는지</strong>를 자세히 살펴보자.</p>
<p>💡<a href="https://docs.oracle.com/javase/8/docs/api/java/lang/Exception.html"><strong>Exception.class docs</strong></a> 공식문서를 보는 것도 추천한다.</p>
<h3 id="1-exception-클래스의-정의">1. Exception 클래스의 정의</h3>
<p><code>Exception</code>은 <code>java.lang</code> 패키지에 포함된 클래스이며,<code>Throwable</code> 클래스를 상속 받고 있고, 자바의 모든 <strong>체크 예외(Checked Exception)</strong> 의 기본 클래스이다.</p>
<pre><code class="language-java">package java.lang;

public class Exception extends Throwable {
    ...
}</code></pre>
<h3 id="2-exception-클래스의-주요-생성자">2. Exception 클래스의 주요 생성자</h3>
<p>Exception 클래스는 다양한 상황에서 예외를 생성할 수 있도록 여러 생성자 오버로딩을 제공한다.</p>
<table>
<thead>
<tr>
<th>생성자</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>Exception()</code></td>
<td>기본 생성자. 메시지와 원인 없음</td>
</tr>
<tr>
<td><code>Exception(String message)</code></td>
<td>예외 메시지 지정</td>
</tr>
<tr>
<td><code>Exception(String message, Throwable cause)</code></td>
<td>메시지와 원인 예외를 함께 지정</td>
</tr>
<tr>
<td><code>Exception(Throwable cause)</code></td>
<td>원인 예외만 지정 (메시지는 <code>cause.toString()</code>으로 설정됨)</td>
</tr>
<tr>
<td><code>Exception(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)</code></td>
<td>예외 억제 및 스택 트레이스 기록 여부 제어 (Java 7+)</td>
</tr>
</tbody></table>
<h3 id="3-checked-예외-unchecked-예외">3. Checked 예외 ,Unchecked 예외</h3>
<p>위에서 <strong>Checked 예외</strong>를 언급했다. 모두가 예상했겠지만 <strong>Unchekced 예외</strong> 또한 존재하고, 두 예외의 차이점을 알아보자.</p>
<h4 id="3-1-checked-예외">3-1. checked 예외</h4>
<ul>
<li><p>컴파일러가 명시적 처리(try-catch 또는 throws 선언) 를 강제하는 예외이다.</p>
</li>
<li><p>Exception의 하위 클래스 중 RuntimeException을 상속하지 않은 것들이 이에 해당한다.</p>
</li>
<li><p>예시) IOException, SQLException, FileNotFoundException</p>
</li>
</ul>
<pre><code class="language-java">public void readFile() throws IOException {
    FileReader reader = new FileReader(&quot;data.txt&quot;); // FileReader 생성 시 IOException 발생 가능
    // 반드시 예외 선언 필요: checked 예외는 메서드에서 직접 처리하거나 선언해야 함
}</code></pre>
<p>위 코드에서 FileReader 생성 시, 생성자의 매개변수로 지정된 &quot;data.txt&quot; 파일이 존재하지 않거나 경로가 잘못되었다면
<code>IOException</code>(checked 예외)이 발생한다. 이러한 경우를 대비하기 위해 checked 예외는 메서드 내부에서 반드시 처리되어야 한다.<br>처리 방법은 두 가지가 있다:</p>
<ol>
<li>메서드 내부에서 try-catch 블록으로 직접 처리</li>
<li>메서드 선언부에 <code>throws IOException</code>을 선언하여 상위 호출자에게 전달</li>
</ol>
<p>이 선언이나 처리가 이루어지지 않으면 <strong>컴파일 오류</strong>가 발생한다.<br>즉, Checked 예외는 코드 어디선가 반드시 해결해야 하며, 처리하지 않고 넘어갈 수 없다.</p>
<h4 id="3-1-unchecked-예외">3-1. Unchecked 예외</h4>
<ul>
<li><p>RuntimeException을 상속하는 예외이다.</p>
</li>
<li><p>명시적 예외 처리 없이 발생 가능하다 (컴파일러가 강제하지 않음)</p>
</li>
<li><p>주로 프로그래밍 오류로 발생한다.</p>
</li>
<li><p>예시) NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException</p>
</li>
</ul>
<pre><code class="language-java">public void divide(int a, int b) {
    // b가 0이면 ArithmeticException 발생 (Unchecked 예외)
    int result = a / b;
    System.out.println(&quot;결과: &quot; + result);
}</code></pre>
<p>위 코드에서 정수를 0으로 나누면 <code>ArithmeticException</code>이 발생한다.<br>하지만 예외가 발생해도 애플리케이션이 자동으로 종료되지는 않으며, 컴파일러도 이를 체크하지 않기 때문에 <strong>try-catch 선언이 필수는 아니다</strong>.  </p>
<p>즉, Unchecked 예외는 발생 가능성이 있어도 <strong>처리해야 한다는 강제성은 없다</strong>.  
다만 필요하다면 try-catch로 처리할 수 있으며, 코드 설계 시 예외가 발생하지 않도록 <strong>사전에 오류를 방지하는 것이 권장</strong>된다.</p>
<h4 id="❓왜-예외를-두가지-상황으로-나누었을까">❓왜 예외를 두가지 상황으로 나누었을까?</h4>
<p>그에 대한 해답은 자바의 아버지라 불리는 James Gosling 과의 대화에서 알 수 있다.</p>
<p>💡<a href="https://www.artima.com/articles/failure-and-exceptions?utm_source=chatgpt.com"><em><strong>제임스 고슬링과의 인터뷰</strong></em></a></p>
<p>James Gosling이 체크 예외 설계 의도를 설명한 인터뷰가 있으며, 그 중 일부가 다음과 같다:</p>
<blockquote>
<p>“In Java you can ignore exceptions, but you have to willfully do it. You can’t accidentally say, ‘I don’t care.’ You have to explicitly say, ‘I don’t care.’”
— Artima, “Failure and Exceptions — A Conversation with James Gosling, Part II” 
artima.com</p>
</blockquote>
<p>이 발언은 “예외 처리를 무시하려면 <strong>명시적으로</strong> 그렇게 해야 한다”는 의미다.<br>즉, 자바는 <strong>컴파일러 수준에서 예외 처리를 강제</strong>함으로써, 개발자가 단순히 “귀찮아서” 예외를 무시하는 일을 방지한다.<br>이것이 바로 Gosling이 추구한 <strong>안정성과 명시성 중심의 설계 철학</strong>이다.</p>
<p>또한, 같은 인터뷰에서 Gosling은 C 언어에서 오류 코드를 무시하는 관습을 지적하면서 Java가 예외 처리를 강제한 이유를 설명한다. </p>
<p>결국 자바는 이러한 철학을 기반으로,예외를 두 가지로 구분했다.
<strong>컴파일러가 강제하는 “체크 예외(Checked Exception)”</strong> 와<br><strong>개발자 선택에 맡기는 “언체크 예외(Unchecked Exception)”</strong> 로 말이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2에서 Spring Boot 실행 트러블슈팅 경험 공유]]></title>
            <link>https://velog.io/@sunset_1839/EC2%EC%97%90%EC%84%9C-Spring-Boot-%EC%8B%A4%ED%96%89-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EA%B2%BD%ED%97%98-%EA%B3%B5%EC%9C%A0</link>
            <guid>https://velog.io/@sunset_1839/EC2%EC%97%90%EC%84%9C-Spring-Boot-%EC%8B%A4%ED%96%89-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EA%B2%BD%ED%97%98-%EA%B3%B5%EC%9C%A0</guid>
            <pubDate>Fri, 10 Oct 2025 02:27:57 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 EC2에서 Spring Boot 애플리케이션을 실행할 때 겪었던 문제와 해결 과정을 정리한다. 실제 사례를 중심으로 문제 원인 분석과 해결 방법을 단계별로 기록했다.</p>
<hr>
<h2 id="1-프로젝트-위치-확인">1. 프로젝트 위치 확인</h2>
<p>SSM(Session Manager)로 EC2에 접속 후 프로젝트가 어디에 있는지부터 확인했다.</p>
<p><code>/home/ubuntu/actions-runner/_work/2025-moitz/2025-moitz/backend</code> 경로에서 프로젝트를 찾았다.</p>
<pre><code class="language-bash">cd ~/actions-runner/_work/2025-moitz/2025-moitz/backend
ls
</code></pre>
<p><code>build/libs</code> 안에 빌드된 JAR 파일이 존재하는 것을 확인했다.</p>
<hr>
<h2 id="2-애플리케이션-백그라운드-실행-시도">2. 애플리케이션 백그라운드 실행 시도</h2>
<p><code>nohup</code>을 사용하여 JAR 파일을 백그라운드로 실행했다.</p>
<pre><code class="language-bash">nohup java -jar build/libs/moitz-0.0.1-SNAPSHOT.jar &gt; app.log 2&gt;&amp;1 &amp;
</code></pre>
<p>하지만 실행 직후 프로세스가 종료되었다.</p>
<p><code>ps -ef | grep java</code>를 통해 확인해도 JAR 프로세스가 남아있지 않았다.</p>
<hr>
<h2 id="3-mongodb와-redis-서버-상태-확인">3. MongoDB와 Redis 서버 상태 확인</h2>
<p>Spring Boot 설정을 보면 MongoDB와 Redis에 연결하도록 되어 있었다.</p>
<p>먼저 MongoDB가 실행 중인지 확인했다.</p>
<pre><code class="language-bash">ps -ef | grep mongod
systemctl status mongod
</code></pre>
<ul>
<li>MongoDB 프로세스가 정상적으로 실행 중임을 확인했다.</li>
<li>PID 3031에서 8시간 이상 가동 중이었다.</li>
</ul>
<p>다음으로 Redis 상태를 확인했다.</p>
<pre><code class="language-bash">ps -ef | grep redis
</code></pre>
<ul>
<li>Redis도 정상적으로 127.0.0.1:6379에서 실행 중이었다.</li>
</ul>
<p>즉, Spring Boot 종료 원인은 DB나 Redis 부재 문제가 아니었다.</p>
<hr>
<h2 id="4-로그-디렉토리-및-권한-확인">4. 로그 디렉토리 및 권한 확인</h2>
<p>애플리케이션이 로그를 기록하는 디렉토리 권한을 확인했다.</p>
<pre><code class="language-bash">ls -ld ~/actions-runner/_work/2025-moitz/2025-moitz/backend/logs
</code></pre>
<ul>
<li>로그 디렉토리는 존재했고, <code>ubuntu</code> 계정으로 읽기/쓰기/실행 권한이 있었다.</li>
<li>따라서 로그 권한 문제로 종료된 것은 아니었다.</li>
</ul>
<hr>
<h2 id="5-jar-직접-실행-및-로그-확인">5. JAR 직접 실행 및 로그 확인</h2>
<p><code>run.log</code> 파일로 표준 출력과 오류 출력을 기록하며 애플리케이션을 실행했다.</p>
<pre><code class="language-bash">java -jar build/libs/moitz-0.0.1-SNAPSHOT.jar &gt; run.log 2&gt;&amp;1
tail -n 50 run.log
</code></pre>
<p>로그를 확인한 결과, 초기 WARN 메시지 외에는 fatal error가 없었지만 애플리케이션은 바로 종료되었다.</p>
<hr>
<h2 id="6-애플리케이션-종료-원인-파악">6. 애플리케이션 종료 원인 파악</h2>
<p>추가 로그 확인 결과, 애플리케이션 시작 시 외부 OPEN API를 호출하는 과정에서 에러가 발생했다.</p>
<pre><code>ExternalApiException: OPEN API 응답이 정상적으로 생성되지 않았습니다.
at com.f12.moitz.infrastructure.client.open.OpenApiClient.searchRoute(OpenApiClient.java:96)
</code></pre><ul>
<li>애플리케이션 시작시 SubwayEdges 초기화를 위한 <code>Setup</code>과정에서 API 호출을 시도했다.</li>
<li>외부 API 관련 예외가 전파되었고, Spring Boot 컨텍스트가 종료되었다.</li>
<li>결과적으로 서버가 시작 직후 종료된 것이다.</li>
</ul>
<hr>
<h2 id="원인-요약">원인 요약</h2>
<ul>
<li>MongoDB와 Redis 서버 모두 정상</li>
<li>로그 디렉토리 권한 문제 없음</li>
<li>JAR 파일 자체는 정상 실행 가능</li>
<li><strong>애플리케이션이 바로 종료된 이유</strong>: 외부 OPEN API 호출 실패로 ApplicationContext 초기화 실패</li>
</ul>
<hr>
<h2 id="해결-전략">해결 전략</h2>
<p>아직 이부분에 대해서는 고민을 하고 있다. 외부 API가 애플리케이션 시작때 관여를 하고 있다면 외부 API의 서버에 문제가 생긴다면 (ex) 대한민국 전산망 화재 ) 우리의 서비스는 배포 조차 할 수 없는 것이다. 현재로서는 아래와 같은 방법이 생각이 난다.</p>
<p> <strong>로컬 더미 데이터 활용</strong></p>
<p><code>개발 환경에서는 OPEN API 대신 로컬 JSON/CSV 데이터를 사용하여 초기화한다.</code></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 경험을 통해 EC2에서 Spring Boot 실행 시 흔히 겪는 문제들을 확인하고, 외부 API 호출과 관련된 초기화 과정이 애플리케이션 종료의 주요 원인임을 파악했다.</p>
<p>개발 환경에서는 예외 처리와 API 호출 지연을 통해 서버를 안전하게 실행할 수 있다는 것을 배웠다.</p>
<p>예전에는 개발을 하면서 데이터의 중요성을 크게 못느꼈던 것 같다. 하지만 성장하면서 발로 뛰어 모은 데이터, 다양한 로그 데이터등이 서비스를 성장시킬 수 있는 가치있는 보물임을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MongoDB 서비스 시작 실패 문제 해결기]]></title>
            <link>https://velog.io/@sunset_1839/MongoDB-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%8B%9C%EC%9E%91-%EC%8B%A4%ED%8C%A8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/MongoDB-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%8B%9C%EC%9E%91-%EC%8B%A4%ED%8C%A8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Fri, 10 Oct 2025 02:25:17 GMT</pubDate>
            <description><![CDATA[<p>최근 모잇지 서비스 운영중 우분투 서버에서 MongoDB 서비스를 재시작하려 했으나, 아래와 같이 실패하는 상황을 겪었다.</p>
<pre><code>$ sudo systemctl status mongod
× mongod.service - MongoDB Database Server
     Loaded: loaded (/usr/lib/systemd/system/mongod.service; disabled; preset: enabled)
     Active: failed (Result: exit-code) since Fri 2025-09-19 04:28:34 UTC; 16s ago
   ...
Sep 19 04:28:34 ip-10-0-0-68 mongod[1252]: {&quot;msg&quot;:&quot;Failed to unlink socket file&quot;,&quot;attr&quot;:{&quot;path&quot;:&quot;/tmp/mongodb-27017.sock&quot;,&quot;error&quot;:&quot;Operation not permitted&quot;}}
</code></pre><p>로그를 확인해보니 MongoDB가 <code>/tmp/mongodb-27017.sock</code> 파일을 삭제하지 못해 시작이 실패했다. 이는 주로 <strong>파일 권한 문제</strong> 또는 <strong>이전에 종료되지 않은 MongoDB 프로세스</strong> 때문에 발생하는 문제였다.</p>
<hr>
<h2 id="문제-원인-분석">문제 원인 분석</h2>
<ol>
<li><code>mongod.service</code> 상태 확인:</li>
</ol>
<pre><code class="language-bash">sudo systemctl status mongod
</code></pre>
<p>결과에서 <code>Failed with result &#39;exit-code&#39;</code>와 함께 <code>Failed to unlink socket file</code> 에러가 발생했다.</p>
<ol>
<li>로그 확인:</li>
</ol>
<pre><code class="language-bash">sudo tail -50 /var/log/mongodb/mongod.log
</code></pre>
<ul>
<li><code>Failed to unlink socket file</code></li>
<li><code>Fatal assertion</code></li>
</ul>
<p>위 로그를 통해 <strong>MongoDB 소켓 파일 삭제 실패</strong>가 직접적인 원인임을 확인했다.</p>
<hr>
<h2 id="문제-해결-과정">문제 해결 과정</h2>
<h3 id="1-소켓-파일-확인">1. 소켓 파일 확인</h3>
<pre><code class="language-bash">ls -l /tmp/mongodb-27017.sock
</code></pre>
<p>파일이 존재하고 소유권 또는 권한 문제로 MongoDB가 접근하지 못함을 확인했다.</p>
<h3 id="2-소켓-파일-삭제">2. 소켓 파일 삭제</h3>
<pre><code class="language-bash">sudo rm /tmp/mongodb-27017.sock
</code></pre>
<blockquote>
<p>삭제 후 MongoDB가 새 소켓 파일을 생성할 수 있게 되었다.</p>
</blockquote>
<h3 id="3-mongodb-재시작">3. MongoDB 재시작</h3>
<pre><code class="language-bash">sudo systemctl start mongod
sudo systemctl status mongod
</code></pre>
<ul>
<li>정상적으로 서비스가 시작됨을 확인했다.</li>
<li>추가적으로 <code>/tmp</code> 폴더 권한이 올바른지 확인하였다.</li>
</ul>
<pre><code class="language-bash">ls -ld /tmp
# 권한: drwxrwxrwt
</code></pre>
<hr>
<h2 id="결론">결론</h2>
<p>이번 문제는 <strong>MongoDB가 소켓 파일을 삭제하지 못해 발생한 서비스 시작 실패</strong>였다.</p>
<p>핵심 해결 방법은 다음과 같다.</p>
<ol>
<li><code>/tmp/mongodb-27017.sock</code> 파일 삭제</li>
<li>MongoDB 서비스 재시작</li>
<li><code>/tmp</code> 권한 확인 (필요 시 <code>chmod 1777 /tmp</code>)</li>
</ol>
<p>이 과정을 통해 MongoDB를 정상적으로 기동시킬 수 있었다.</p>
<hr>
<p>이번 사례를 통해, MongoDB 서비스 문제는 <strong>권한 및 소켓 파일 문제</strong>에서 시작되는 경우가 많음을 알 수 있었다.</p>
<p>운영 환경에서 MongoDB가 갑자기 시작되지 않을 때, 먼저 <code>/tmp</code>와 소켓 파일을 확인하는 습관이 필요하다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모잇지 프로젝트 스프링 빈 충돌 에러 트러블슈팅]]></title>
            <link>https://velog.io/@sunset_1839/%EB%AA%A8%EC%9E%87%EC%A7%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B9%88%EC%B6%A9%EB%8F%8C-%EC%97%90%EB%9F%AC-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@sunset_1839/%EB%AA%A8%EC%9E%87%EC%A7%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B9%88%EC%B6%A9%EB%8F%8C-%EC%97%90%EB%9F%AC-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 16 Sep 2025 02:00:46 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>미션과 퀘스트를 병행하면서 모잇지 프로젝트의 유지보수를 하기위해 오랜만에 개발 브랜치를 가져와 로컬에서 여러 테스트를 하려고 하였다. 그동안 내가 확인하지 못했던 코드들이 반영되었기에 여러 문제 상황을 마주했는데 그중 하나인 빈충돌 트러블 슈팅에 대해 공유하려고 한다.</p>
<h2 id="문제-상황">문제 상황</h2>
<p><code>Spring Boot</code> 프로젝트를 실행했을 때, 애플리케이션 컨텍스트 초기화 단계에서 아래와 같은 에러가 발생했다.</p>
<pre><code>UnsatisfiedDependencyException: Error creating bean with name &#39;openApiClient&#39;
No qualifying bean of type &#39;org.springframework.web.client.RestClient&#39; available:
expected single matching bean but found 3: kakaoRestClient, odsayRestClient, openRestClient
</code></pre><p>처음에는 단순히 <strong>의존성 주입 실패</strong> 정도로만 보였지만, 로그를 따라가면서 원인을 추적해보았다.</p>
<hr>
<h2 id="기존-코드">기존 코드</h2>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
@Component
public class OdsayClient {

    private static final String ODSAY_ROUTE_SEARCH_URL = &quot;/searchPubTransPathT?SX=%f&amp;SY=%f&amp;EX=%f&amp;EY=%f&amp;apiKey=%s&amp;searchPathType=%d&quot;;
    private static final int SEARCH_PATH_TYPE = 1;

    private final RestClient odsayRestClient;
    private final ObjectMapper objectMapper;

    @Value(&quot;${odsay.api.key}&quot;)
    private String odsayApiKey;</code></pre>
<p>기존 코드에서는 <code>생성자 파라미터</code>이름 매칭을 사용하여 빈을 찾아서 등록해주었다.</p>
<p>이때 <code>RestClient</code> 타입의  여러빈들이 등록되어 있다면, 스프링은 파라미터 이름  <code>odsayRestClient</code> 과 동일한 이름을 가진 빈을 우선적으로 주입한다.</p>
<p>따라서 <strong>빈 이름과 생성자 파라미터 이름이 정확히 일치해야</strong> 원하는 빈이 주입된다.</p>
<p><strong>여기서 궁금증이 생겼다.</strong> </p>
<p>해당 코드는 운영환경에서도 배포가 되어 있었고, 서비스가 정상적으로 운영되고 있었다.</p>
<p>하지만 나의 로컬 환경에서는 빈충돌 에러가 생기는 이유가 뭘까?</p>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<p>문제의 핵심은 <code>RestClient</code> 타입의 빈이 여러 개 등록되어 있었던 점이었다.</p>
<p><code>ClientConfig</code> 클래스에서 다음과 같이 세 개의 빈을 등록했다.</p>
<pre><code class="language-java">@Bean
public RestClient kakaoRestClient() {
    return RestClient.builder()
            .baseUrl(&quot;https://dapi.kakao.com/v2/local/search&quot;)
            .build();
}

@Bean
public RestClient odsayRestClient() {
    return RestClient.builder()
            .baseUrl(&quot;https://api.odsay.com/v1/api&quot;)
            .build();
}

@Bean
public RestClient openRestClient() {
    return RestClient.builder()
            .baseUrl(&quot;https://apis.data.go.kr/B553766/path&quot;)
            .build();
}
</code></pre>
<p>각각 카카오 API, Odsay API, 공공 데이터 API 호출을 위한 클라이언트였다.</p>
<p>그러나 <code>OdsayClient</code>, <code>KakaoMapClient</code>, <code>OpenApiClient</code> 같은 클래스에서 단순히 <code>RestClient</code> 타입만 의존성 주입을 시도했기 때문에, 스프링은 <strong>어떤 빈을 넣어야 하는지 판단할 수 없어 에러를 발생</strong>시켰다.</p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>빈 충돌 문제를 해결하기 위해 선택할 수 있는 방법은 크게 세 가지였다.</p>
<h3 id="1-qualifier-사용-권장">1. <code>@Qualifier</code> 사용 (권장)</h3>
<p>빈 주입 시 어떤 빈을 사용할지 명확히 지정한다.</p>
<pre><code class="language-java">@Component
public class OdsayClient {

    private final RestClient odsayRestClient;
    private final ObjectMapper objectMapper;

    public OdsayClient(@Qualifier(&quot;odsayRestClient&quot;) RestClient odsayRestClient,
                       ObjectMapper objectMapper) {
        this.odsayRestClient = odsayRestClient;
        this.objectMapper = objectMapper;
    }
}
</code></pre>
<p>이 방식이 가장 안전하며, 이름과 빈이 매칭되기 때문에 의도한 대로 주입이 이루어진다.</p>
<p>➡️ 가장 명확한 방법.</p>
<p>➡️ 빈 이름은  <strong>`@Bean 메서드명(odsayRestClient</strong>)`과 동일해야 한다.</p>
<hr>
<h3 id="2-primary-지정">2. <code>@Primary</code> 지정</h3>
<p>여러 개의 빈 중 하나를 <strong>기본값</strong>으로 지정할 수 있다.</p>
<pre><code class="language-java">@Bean
@Primary
public RestClient openRestClient() {
    return RestClient.builder()
            .baseUrl(&quot;https://apis.data.go.kr/B553766/path&quot;)
            .build();
}
</code></pre>
<p>그러면 <code>@Qualifier</code>가 없는 경우 자동으로 <code>openRestClient</code>가 주입된다.</p>
<p>하지만 이번 경우처럼 <strong>빈마다 목적이 다른 상황</strong>에서는 적합하지 않았다.</p>
<hr>
<h3 id="3-생성자-파라미터이름-매칭-기존-코드⭐⭐⭐⭐⭐">3. <code>생성자 파라미터</code>이름 매칭 (기존 코드)⭐⭐⭐⭐⭐</h3>
<p>스프링은 생성자 파라미터 이름과 빈 이름이 일치하면 자동으로 매칭을 시도한다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class OdsayClient {
    private final RestClient odsayRestClient; // 필드명 == 빈 이름
    private final ObjectMapper objectMapper;
}
</code></pre>
<p>기존 코드에 적용했던 방법이다. 이 방법을 도입하였기에 이번 빈충돌 에러를 겪었다..ㅠㅠ </p>
<hr>
<h2 id="왜-운영에선-되고-로컬에선-안-됐을까">왜 운영에선 되고 로컬에선 안 됐을까?</h2>
<p>운영 서버에서는 Gradle을 사용하여 빌드/배포가 이루어진다.</p>
<p>이때 Gradle은 기본적으로 <strong>파라미터 이름을 클래스 파일에 유지</strong>하도록 컴파일 옵션을 적용하기 때문에,</p>
<p>스프링이 생성자 파라미터 이름(<code>odsayRestClient</code>)을 정확히 읽어낼 수 있었다.</p>
<p>하지만 내 로컬 환경(IntelliJ)에서는 빌드 도구 설정이 <strong>Gradle</strong>이 아니라 <strong>IntelliJ IDEA</strong>로 되어 있었다.</p>
<p>IntelliJ 기본 컴파일러는 파라미터 이름 정보를 class 파일에 포함하지 않는다.</p>
<p>즉, 런타임에서 스프링이 파라미터 이름을 읽지 못하고 단순히 타입만 가지고 주입하려고 하다 보니,</p>
<p>동일한 타입(<code>RestClient</code>)의 여러 빈이 등록된 상황에서 <strong>빈 충돌 오류</strong>가 발생했던 것이다.</p>
<h3 id="정리하면">정리하면</h3>
<ul>
<li><strong>운영 (Gradle 빌드)</strong> → 파라미터 이름 정보 유지 → 이름 기반 매칭 정상 동작 ✅</li>
<li><strong>로컬 (IntelliJ 빌드)</strong> → 파라미터 이름 정보 누락 → 타입만 보고 주입 시도 → 다중 빈 충돌 발생 ❌</li>
</ul>
<hr>
<h2 id="최종-선택">최종 선택</h2>
<p>이번 프로젝트에서는 <strong>생성자를 직접 정의하고 <code>@Qualifier</code>를 명시적으로 붙이는 방식</strong>을 선택했다.</p>
<p>이렇게 했을 때:</p>
<ul>
<li>어떤 빈이 주입되는지 명확히 알 수 있었고</li>
<li>Lombok이 <code>@Qualifier</code>를 복사하지 않는 문제도 피할 수 있었으며</li>
<li>코드 가독성 측면에서도 의도 전달이 분명해졌다.</li>
<li>가장 중요한건 로컬 환경 세팅에 영향을 받지 않는다는 것이다.</li>
</ul>
<hr>
<h2 id="배운-점">배운 점</h2>
<ol>
<li><strong>동일한 타입의 빈이 여러 개 등록되면 충돌이 발생한다.</strong></li>
<li><code>@Qualifier</code>를 사용하여 명확하게 주입 대상을 지정할 수 있다.</li>
<li><code>@Primary</code>, 파라미터 이름 매칭 등 다른 방법도 있지만, 가장 안정적인 방법은 <code>@Qualifier</code>를 활용하는 것이다.</li>
<li>Lombok의 <code>@RequiredArgsConstructor</code>는 <code>@Qualifier</code>를 복사하지 않으므로, 이 경우에는 생성자를 직접 작성하는 편이 안전하다. </li>
<li>로컬 환경 세팅에 따라 다른 결과가 나올 수 있으므로 팀 컨벤션을 확실히 하자.</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 트러블슈팅을 통해 <strong>스프링 빈 주입 과정에서 모호성이 생기면 반드시 해결해야 한다는 점</strong>을 다시 한번 깨달았다.</p>
<p>같은 코드라도 각자의 로컬 환경 세팅에 따라 다른 결과가 나오기 때문에 협업시 팀 컨벤션을 정하는 것이 이러한 문제를 안겪는 핵심이라 생각한다.</p>
<p>앞으로 비슷한 상황이 발생을 때는 로컬 환경 세팅에 영향을 받지 않는 <code>@Qualifier</code>를 적극적으로 활용할 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링에서 다중 사용자 요청을 처리하는 톰캣 설정 분석]]></title>
            <link>https://velog.io/@sunset_1839/%EC%8A%A4%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C-%EB%8B%A4%EC%A4%91-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9A%94%EC%B2%AD%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%ED%86%B0%EC%BA%A3-%EC%84%A4%EC%A0%95-%EB%B6%84%EC%84%9D</link>
            <guid>https://velog.io/@sunset_1839/%EC%8A%A4%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C-%EB%8B%A4%EC%A4%91-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9A%94%EC%B2%AD%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%ED%86%B0%EC%BA%A3-%EC%84%A4%EC%A0%95-%EB%B6%84%EC%84%9D</guid>
            <pubDate>Fri, 12 Sep 2025 08:45:13 GMT</pubDate>
            <description><![CDATA[<h2 id="스프링과-톰캣">스프링과 톰캣</h2>
<p>우아한 테크코스 스레드 퀘스트를 수행하다보니 궁금한 점이 생겼다.
웹 애플리케이션에서 수많은 사용자가 동시에 우리 서버에 요청을 보낼 때, 스프링은 어떻게 이 모든 요청을 효율적으로 처리하는 걸까? 그 비밀은 바로 <strong>톰캣(Tomcat)의 스레드 풀과 연결 관리 설정</strong>에 있다.</p>
<p>오늘은 <code>application.yml</code> 파일에서 설정할 수 있는 톰캣의 핵심 설정 세 가지를 톰캣 공식 문서를 바탕으로 분석한 결과를 써보려고 한다.</p>
<h2 id="🔍-톰캣-설정-파일-위치와-구조">🔍 톰캣 설정 파일 위치와 구조</h2>
<p>먼저 스프링 부트 프로젝트의 <code>study/src/main/resources/application.yml</code> 파일을 찾아보자. 이 파일에서 톰캣의 성능과 안정성을 결정하는 중요한 설정들을 확인할 수 있다.</p>
<pre><code class="language-yaml">server:
  tomcat:
    accept-count: 1
    max-connections: 1
    threads:
      max: 2
</code></pre>
<h2 id="📋-톰캣-핵심-설정-3가지">📋 톰캣 핵심 설정 3가지</h2>
<h3 id="1-acceptcount---대기열의-크기를-결정한다">1. acceptCount - 대기열의 크기를 결정한다</h3>
<p><strong>톰캣 공식 문서 원문:</strong></p>
<blockquote>
<p>The maximum length of the operating system provided queue for incoming connection requests when maxConnections has been reached. The operating system may ignore this setting and use a different size for the queue. When this queue is full, the operating system may actively refuse additional connections or those connections may time out. The default value is 100</p>
</blockquote>
<p><strong>한글 번역 및 분석:</strong><code>acceptCount</code>는 <code>maxConnections</code>에 도달했을 때 들어오는 연결 요청을 위한 운영체제 제공 대기열의 최대 길이를 의미한다. 운영체제는 이 설정을 무시하고 대기열에 다른 크기를 사용할 수도 있다. 이 대기열이 가득 차면 운영체제는 추가 연결을 적극적으로 거부하거나 해당 연결이 타임아웃될 수 있다. 기본값은 100이다.</p>
<p><strong>의미:</strong></p>
<ul>
<li>서버가 최대 연결 수에 도달했을 때의 &quot;버퍼 역할&quot;을 한다</li>
<li>갑작스러운 트래픽 급증 시 연결을 완전히 거부하지 않고 잠시 대기시킨다</li>
<li>너무 큰 값으로 설정하면 메모리 사용량이 증가할 수 있다</li>
</ul>
<h3 id="2-maxconnections---동시-연결의-한계를-정하다">2. maxConnections - 동시 연결의 한계를 정하다</h3>
<p><strong>톰캣 공식 문서 원문:</strong></p>
<blockquote>
<p>The maximum number of connections that the server will accept and process at any given time. When this number has been reached, the server will accept, but not process, one further connection. This additional connection be blocked until the number of connections being processed falls below maxConnections at which point the server will start accepting and processing new connections again. Note that once the limit has been reached, the operating system may still accept connections based on the acceptCount setting. The default value is 8192.</p>
<p>For NIO/NIO2 only, setting the value to -1, will disable the maxConnections feature and connections will not be counted</p>
</blockquote>
<p><strong>한글 번역 및 분석:</strong>
서버가 주어진 시간에 수락하고 처리할 수 있는 최대 연결 수를 의미한다. 이 숫자에 도달하면 서버는 추가 연결 하나를 더 수락하지만 처리하지는 않는다. 이 추가 연결은 처리 중인 연결 수가 <code>maxConnections</code> 아래로 떨어질 때까지 차단되며, 그 시점에서 서버는 새로운 연결을 다시 수락하고 처리하기 시작한다. 한계에 도달하면 운영체제는 여전히 <code>acceptCount</code> 설정에 따라 연결을 수락할 수 있다는 점을 주의해야 한다. 기본값은 8192이다.</p>
<p>NIO/NIO2에서만 값을 -1로 설정하면 <code>maxConnections</code> 기능이 비활성화되고 연결이 카운트되지 않는다.</p>
<p><strong>의미:</strong></p>
<ul>
<li>TCP 연결 자체의 최대 개수를 제한한다</li>
<li>Keep-Alive 연결도 이 수치에 포함된다</li>
<li>메모리와 파일 디스크립터 사용량을 제어하는 핵심 설정이다</li>
</ul>
<h3 id="3-maxthreads---요청-처리의-핵심-동력을-관리하다">3. maxThreads - 요청 처리의 핵심 동력을 관리하다</h3>
<p><strong>톰캣 공식 문서 원문:</strong></p>
<blockquote>
<p>The maximum number of request processing threads to be created by this Connector, which therefore determines the maximum number of simultaneous requests that can be handled. If not specified, this attribute is set to 200. If an executor is associated with this connector, this attribute is ignored as the connector will execute tasks using the executor rather than an internal thread pool. Note that if an executor is configured any value set for this attribute will be recorded correctly but it will be reported (e.g. via JMX) as -1 to make clear that it is not used.</p>
</blockquote>
<p><strong>한글 번역 및 분석:</strong>
이 커넥터가 생성할 요청 처리 스레드의 최대 개수로, 따라서 동시에 처리할 수 있는 최대 요청 수를 결정한다. 지정되지 않으면 이 속성은 200으로 설정된다. 이 커넥터에 실행자(executor)가 연결되어 있으면 커넥터가 내부 스레드 풀이 아닌 실행자를 사용하여 작업을 실행하므로 이 속성은 무시된다. 실행자가 구성되어 있으면 이 속성에 설정된 값은 정확히 기록되지만 사용되지 않는다는 것을 명확히 하기 위해 (예: JMX를 통해) -1로 보고된다는 점을 주의해야 한다.</p>
<p><strong>의미:</strong></p>
<ul>
<li>실제로 요청을 처리하는 워커 스레드의 개수를 결정한다</li>
<li>CPU 집약적인 작업이 많다면 CPU 코어 수와 비슷하게 설정한다</li>
<li>I/O 대기가 많은 작업이라면 더 많은 스레드를 할당할 수 있다</li>
</ul>
<br>
💡정리하면 각각은 아래와 같이 해석된다.

<blockquote>
<ul>
<li><code>threads.max</code>는 <strong>&quot;얼마나 많은 스레드로 동시에 처리할 수 있냐&quot;</strong></li>
</ul>
</blockquote>
<ul>
<li><code>max-connections</code>는 <strong>&quot;동시에 유지할 수 있는 연결 수&quot;</strong></li>
<li><code>accept-count</code>는 <strong>&quot;스레드 다 차면 몇 개까지 대기열에 둘 수 있냐&quot;</strong></li>
</ul>
<h2 id="🔄-톰캣-다중-사용자-요청-처리-흐름">🔄 톰캣 다중 사용자 요청 처리 흐름</h2>
<p>이제 이 세 설정이 어떻게 협력하여 다중 사용자 요청을 처리하는지 살펴보자.</p>
<p><strong>1단계: 연결 수락</strong></p>
<ul>
<li>클라이언트가 서버에 TCP 연결을 요청한다</li>
<li><code>maxConnections</code> 한계 내에서 연결을 수락한다</li>
</ul>
<p><strong>2단계: 대기열 관리</strong></p>
<ul>
<li><code>maxConnections</code>에 도달하면 <code>acceptCount</code> 크기만큼 대기열에서 대기한다</li>
<li>대기열마저 가득 차면 새로운 연결을 거부한다</li>
</ul>
<p><strong>3단계: 요청 처리</strong></p>
<ul>
<li>수락된 연결에서 HTTP 요청이 들어오면 <code>maxThreads</code> 범위 내에서 스레드를 할당한다</li>
<li>할당된 스레드가 실제 비즈니스 로직을 처리한다</li>
</ul>
<p><strong>4단계: 자원 반환</strong></p>
<ul>
<li>요청 처리가 완료되면 스레드를 스레드 풀로 반환한다</li>
</ul>
<hr>
<p>퀘스트의 전달한 핵심 가치가 마지막에 적혀져 있었다. </p>
<blockquote>
<p><strong>📚 이것이 WAS 스레드풀에서 의미하는 것</strong><br></p>
</blockquote>
<ul>
<li>WAS는 무제한으로 요청을 받아들이지 않는다.</li>
<li>WAS는 정해진 규칙에 따라 자원을 할당한다.</li>
<li>이러한 설정은 서버가 과부하로 인해 죽지 않도록 보호하는 핵심 메커니즘이다. </li>
<li>실무에서는 대용량 트래픽 상황에서 이 설정들이 사용자 경험과 서비스 안정성을 좌우하는 중요한 요소가 된다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot에서 개발, 운영 환경에 따른 Logback 설정]]></title>
            <link>https://velog.io/@sunset_1839/Spring-Boot%EC%97%90%EC%84%9C-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD%EC%97%90-%EB%94%B0%EB%A5%B8-Logback-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@sunset_1839/Spring-Boot%EC%97%90%EC%84%9C-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD%EC%97%90-%EB%94%B0%EB%A5%B8-Logback-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 09 Sep 2025 16:40:42 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-boot에서-개발-운영-환경에-따른-logback-설정">Spring Boot에서 개발, 운영 환경에 따른 Logback 설정</h1>
<p>모잇지 프로젝트를 진행하며 개발 환경과 운영 환경에서 서로 다른 로깅 전략이 필요했다. 그 이유는 개발 환경에서 직접 보는 것이 효과적인 로그들이 있었고, 운영 환경에서는 모니터링을 목적으로 남기는 로그들이 필요했다. 이번에 로그를 담당하며 이를 효과적으로 관리하는 방법을 알아보았다.</p>
<hr>
<h2 id="🔧-1-전역-변수-관리---logback-variableproperties">🔧 1. 전역 변수 관리 - logback-variable.properties</h2>
<p>로깅 설정에서 사용할 변수들을 중앙에서 관리한다다.</p>
<pre><code class="language-properties"># 로그 파일 위치
# 로그 파일 경로 backend/logs/info 또는 backend/logs/error
LOG_PATH=./logs
DEBUG_PATH=debug
WARN_PATH=warn
INFO_PATH=info
ERROR_PATH=error

# 로그 출력 포맷 
# CONSOLE_PATTERN : 개발 환경(콘솔에 출력)
# ROLLING_PATTERN : 운영 환경(파일로 저장) 
CONSOLE_PATTERN=%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{36} - %replace(%msg){&#39;\n&#39;, &#39; &#39;} %n 
ROLLING_PATTERN=%d{yyyy-MM-dd HH:mm:ss.SSS}  %logger{5} - %msg %n

# 로그 파일 정책
MAX_FILE_SIZE=10MB
TOTAL_SIZE=100MB
MAX_HISTORY=10</code></pre>
<h3 id="변수-설명">변수 설명</h3>
<ul>
<li><strong>LOG_PATH</strong>: 로그 파일이 저장될 기본 경로</li>
<li><strong>DEBUG_PATH, INFO_PATH, ERROR_PATH, WARN_PATH</strong>: 각각 일반 로그와 에러 로그의 하위 디렉토리</li>
<li><strong>CONSOLE_PATTERN</strong>: 개발 환경에서 콘솔에 출력될 로그 포맷 (컬러 포함)</li>
<li><strong>ROLLING_PATTERN</strong>: 운영 환경에서 파일에 저장될 로그 포맷 (심플)</li>
<li><strong>MAX_FILE_SIZE</strong>: 개별 로그 파일의 최대 크기</li>
<li><strong>TOTAL_SIZE</strong>: 전체 로그 파일들의 최대 크기</li>
<li><strong>MAX_HISTORY</strong>: 보관할 로그 파일의 최대 개수</li>
</ul>
<h2 id="⚙️-2-applicationproperties-환경별-설정">⚙️ 2. application.properties 환경별 설정</h2>
<h3 id="개발-환경-설정">개발 환경 설정</h3>
<pre><code class="language-properties"># application-dev.properties
# 개발 환경에서는 주로 콘솔 로깅 사용
spring.profiles.active=dev</code></pre>
<h3 id="운영-환경-설정">운영 환경 설정</h3>
<pre><code class="language-properties"># application-prod.properties  
# 운영 환경에서는 파일 로깅 사용
spring.profiles.active=prod
logging.file.path=./logs</code></pre>
<h2 id="📊-3-로그-출력-패턴-분석">📊 3. 로그 출력 패턴 분석</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>의미</th>
<th>예시 출력</th>
</tr>
</thead>
<tbody><tr>
<td><code>%d{yyyy-MM-dd HH:mm:ss.SSS}</code></td>
<td>로그 발생 시각 (형식 지정 가능)</td>
<td><code>2025-08-04 10:15:31.420</code></td>
</tr>
<tr>
<td><code>%thread</code></td>
<td>로그를 발생시킨 스레드명</td>
<td><code>main</code>, <code>http-nio-8080-exec-1</code></td>
</tr>
<tr>
<td><code>%magenta(...)</code></td>
<td>해당 항목을 콘솔에서 자홍색으로 출력 (색상 강조)</td>
<td><code>[main]</code></td>
</tr>
<tr>
<td><code>%highlight(...)</code></td>
<td>로그 레벨별 색상 지정 (<code>INFO</code>, <code>ERROR</code> 등)</td>
<td><code>[INF]</code>, <code>[ERR]</code></td>
</tr>
<tr>
<td><code>%-3level</code></td>
<td>로그 레벨 (좌측 정렬, 최소 너비 3칸)</td>
<td><code>INF</code>, <code>WRN</code>, <code>DBG</code></td>
</tr>
<tr>
<td><code>%logger{36}</code></td>
<td>Logger 이름 (FQCN 기준 최대 36자)</td>
<td><code>com.f12.moitz.LoggingTest</code></td>
</tr>
<tr>
<td><code>%logger{5}</code></td>
<td>Logger 이름 (마지막 5단어 또는 문자 기준으로 축약)</td>
<td><code>moitz.LoggingTest</code></td>
</tr>
<tr>
<td><code>%replace(%msg){&#39;\n&#39;, &#39; &#39;}</code></td>
<td>메시지에서 줄바꿈(<code>\n</code>)을 공백으로 치환 (단일라인 유지용)</td>
<td><code>&quot;line1\nline2&quot;</code> → <code>&quot;line1 line2&quot;</code></td>
</tr>
<tr>
<td><code>%msg</code></td>
<td>로그 메시지 본문</td>
<td><code>&quot;사용자 정보 조회 완료&quot;</code></td>
</tr>
</tbody></table>
<h3 id="개발-환경-로그-예시">개발 환경 로그 예시</h3>
<pre><code>2025-08-04 10:15:31.420 [main] [DEBUG] com.f12.moitz.service.UserService - 사용자 정보 조회 시작
2025-08-04 10:15:31.425 [http-nio-8080-exec-1] [INFO] com.f12.moitz.controller.UserController - GET /api/users 요청 수신</code></pre><h3 id="📡-모니터링-로그-쿼리를-위한-logstash">📡 모니터링 로그 쿼리를 위한 Logstash</h3>
<p>운영 환경에서는 단순히 로그를 파일에 저장하는 것만으로는 부족하다.
ElasticSearch + Logstash 또는 CloudWatch, OpenSearch 같은 로그 수집 시스템으로 로그를 전달하고, 이를 분석할 수 있어야 한다.</p>
<p>이를 위해 logback-spring.xml에서는 LogstashEncoder를 사용해 로그를 JSON 포맷으로 변환했다.
이를 위한 설정은 아래와 같다.</p>
<pre><code>&lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;/&gt;</code></pre><h2 id="📄-4-logback-springxml-전체-구조">📄 4. logback-spring.xml 전체 구조</h2>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;configuration&gt;

    &lt;!-- 외부 프로퍼티 로딩 --&gt;
    &lt;property resource=&quot;logback-variable.properties&quot; /&gt;

    &lt;!-- Spring 환경 변수 (application.properties 에서 주입됨) --&gt;
    &lt;springProperty scope=&quot;context&quot; name=&quot;DEBUG_LOG_PATH&quot; source=&quot;logging.file.debug.path&quot;/&gt;
    &lt;springProperty scope=&quot;context&quot; name=&quot;INFO_LOG_PATH&quot; source=&quot;logging.file.info.path&quot;/&gt;
    &lt;springProperty scope=&quot;context&quot; name=&quot;WARN_LOG_PATH&quot; source=&quot;logging.file.warn.path&quot;/&gt;
    &lt;springProperty scope=&quot;context&quot; name=&quot;ERROR_LOG_PATH&quot; source=&quot;logging.file.error.path&quot;/&gt;

    &lt;!-- 개발 환경용 설정 --&gt;
    &lt;springProfile name=&quot;dev&quot;&gt;
        &lt;!-- 콘솔 전용 Appender --&gt;
        &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
            &lt;encoder&gt;
                &lt;pattern&gt;${CONSOLE_PATTERN}&lt;/pattern&gt;
            &lt;/encoder&gt;
        &lt;/appender&gt;
        &lt;!-- DEBUG 전용 Appender --&gt;
        &lt;appender name=&quot;FILE_DEBUG&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&gt;
            &lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;/&gt;
            &lt;file&gt;${LOG_PATH}/${DEBUG_PATH}/application.log&lt;/file&gt;
            &lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
                &lt;fileNamePattern&gt;${DEBUG_LOG_PATH}/application-info-%d{yyyy-MM-dd}.%i.log.zip&lt;/fileNamePattern&gt;
                &lt;maxHistory&gt;${MAX_HISTORY}&lt;/maxHistory&gt;
                &lt;maxFileSize&gt;${MAX_FILE_SIZE}&lt;/maxFileSize&gt;
                &lt;totalSizeCap&gt;${TOTAL_SIZE}&lt;/totalSizeCap&gt;
            &lt;/rollingPolicy&gt;
        &lt;/appender&gt;

        &lt;root level=&quot;DEBUG&quot;&gt;
            &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
            &lt;appender-ref ref=&quot;FILE_DEBUG&quot;/&gt;
        &lt;/root&gt;
        &lt;!-- 프레임워크 기본 로거 수준 설정 --&gt;
        &lt;logger name=&quot;org.springframework&quot; level=&quot;INFO&quot;/&gt;
        &lt;logger name=&quot;org.springframework.boot&quot; level=&quot;INFO&quot;/&gt;
        &lt;logger name=&quot;org.hibernate&quot; level=&quot;WARN&quot;/&gt;
        &lt;logger name=&quot;org.apache.catalina&quot; level=&quot;INFO&quot;/&gt;
        &lt;logger name=&quot;com.zaxxer.hikari&quot; level=&quot;WARN&quot;/&gt;

        &lt;!--mongoDB--&gt;
        &lt;logger name=&quot;data.mongodb&quot; level=&quot;WARN&quot;/&gt;
        &lt;logger name=&quot;org.mongodb.driver&quot; level=&quot;WARN&quot;/&gt;

        &lt;!--Web Flux--&gt;
        &lt;logger name=&quot;io.netty&quot; level=&quot;WARN&quot;/&gt;
        &lt;logger name=&quot;reactor.netty&quot; level=&quot;WARN&quot;/&gt;

        &lt;!-- CloudWatch 메트릭 관련 로거 비활성화 --&gt;
        &lt;logger name=&quot;io.micrometer.cloudwatch2&quot; level=&quot;OFF&quot;/&gt;
        &lt;logger name=&quot;software.amazon.awssdk&quot; level=&quot;OFF&quot;/&gt;
        &lt;logger name=&quot;io.micrometer.core.instrument.push.PushMeterRegistry&quot; level=&quot;OFF&quot;/&gt;

        &lt;!-- AWS SDK 관련 로거 비활성화 --&gt;
        &lt;logger name=&quot;software.amazon.awssdk.auth.credentials&quot; level=&quot;OFF&quot;/&gt;
        &lt;logger name=&quot;software.amazon.awssdk.core.interceptor&quot; level=&quot;OFF&quot;/&gt;
        &lt;logger name=&quot;software.amazon.awssdk.services.cloudwatch&quot; level=&quot;OFF&quot;/&gt;
        &lt;logger name=&quot;software.amazon.awssdk.utils.cache&quot; level=&quot;OFF&quot;/&gt;
        &lt;logger name=&quot;software.amazon.awssdk.core.internal&quot; level=&quot;OFF&quot;/&gt;

    &lt;/springProfile&gt;

    &lt;!-- ========================================================= --&gt;

    &lt;!-- 운영 환경용 설정 --&gt;
    &lt;springProfile name=&quot;prod&quot;&gt;

        &lt;!-- INFO 전용 Appender --&gt;
        &lt;appender name=&quot;FILE_INFO&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&gt;
            &lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;/&gt;

            &lt;file&gt;${LOG_PATH}/${INFO_PATH}/application-info.log&lt;/file&gt;

            &lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
                &lt;fileNamePattern&gt;${INFO_LOG_PATH}/application-info-%d{yyyy-MM-dd}.%i.log.zip&lt;/fileNamePattern&gt;
                &lt;maxHistory&gt;${MAX_HISTORY}&lt;/maxHistory&gt;
                &lt;maxFileSize&gt;${MAX_FILE_SIZE}&lt;/maxFileSize&gt;
                &lt;totalSizeCap&gt;${TOTAL_SIZE}&lt;/totalSizeCap&gt;
            &lt;/rollingPolicy&gt;

            &lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
                &lt;level&gt;INFO&lt;/level&gt;
                &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
                &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
            &lt;/filter&gt;
        &lt;/appender&gt;

        &lt;!-- WARN 전용 Appender --&gt;
        &lt;appender name=&quot;FILE_WARN&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&gt;
            &lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;/&gt;

            &lt;file&gt;${LOG_PATH}/${WARN_PATH}/application-warn.log&lt;/file&gt;

            &lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
                &lt;fileNamePattern&gt;${WARN_LOG_PATH}/application-warn-%d{yyyy-MM-dd}.%i.log.zip&lt;/fileNamePattern&gt;
                &lt;maxHistory&gt;${MAX_HISTORY}&lt;/maxHistory&gt;
                &lt;maxFileSize&gt;${MAX_FILE_SIZE}&lt;/maxFileSize&gt;
                &lt;totalSizeCap&gt;${TOTAL_SIZE}&lt;/totalSizeCap&gt;
            &lt;/rollingPolicy&gt;

            &lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
                &lt;level&gt;WARN&lt;/level&gt;
                &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
                &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
            &lt;/filter&gt;
        &lt;/appender&gt;

        &lt;!-- ERROR 전용 Appender --&gt;
        &lt;appender name=&quot;FILE_ERROR&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&gt;
            &lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;/&gt;

            &lt;file&gt;${LOG_PATH}/${ERROR_PATH}/application-error.log&lt;/file&gt;

            &lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
                &lt;fileNamePattern&gt;${ERROR_LOG_PATH}/application-error-%d{yyyy-MM-dd}.%i.log.zip&lt;/fileNamePattern&gt;
                &lt;maxHistory&gt;${MAX_HISTORY}&lt;/maxHistory&gt;
                &lt;maxFileSize&gt;${MAX_FILE_SIZE}&lt;/maxFileSize&gt;
                &lt;totalSizeCap&gt;${TOTAL_SIZE}&lt;/totalSizeCap&gt;
            &lt;/rollingPolicy&gt;

            &lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
                &lt;level&gt;ERROR&lt;/level&gt;
                &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
                &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
            &lt;/filter&gt;
        &lt;/appender&gt;

        &lt;root level=&quot;INFO&quot;&gt;
            &lt;appender-ref ref=&quot;FILE_INFO&quot;/&gt;
            &lt;appender-ref ref=&quot;FILE_WARN&quot;/&gt;
            &lt;appender-ref ref=&quot;FILE_ERROR&quot;/&gt;
        &lt;/root&gt;
    &lt;/springProfile&gt;

&lt;/configuration&gt;</code></pre>
<h2 id="🔍-5-로깅-설정-파일-상세-분석">🔍 5. 로깅 설정 파일 상세 분석</h2>
<h3 id="외부-프로퍼티-로딩">외부 프로퍼티 로딩</h3>
<pre><code class="language-xml">&lt;property resource=&quot;logback-variable.properties&quot; /&gt;</code></pre>
<p>logback-variable.properties에 정의된 전역 변수들을 logback 설정에서 사용할 수 있게 한다.</p>
<h3 id="spring-환경-변수-연동">Spring 환경 변수 연동</h3>
<pre><code class="language-xml">&lt;springProperty scope=&quot;context&quot; name=&quot;DEBUG_LOG_PATH&quot; source=&quot;logging.file.debug.path&quot; defaultValue=&quot;${LOG_PATH}/${DEBUG_PATH}&quot; /&gt;
&lt;springProperty scope=&quot;context&quot; name=&quot;INFO_LOG_PATH&quot;  source=&quot;logging.file.info.path&quot;  defaultValue=&quot;${LOG_PATH}/${INFO_PATH}&quot; /&gt;
&lt;springProperty scope=&quot;context&quot; name=&quot;WARN_LOG_PATH&quot;  source=&quot;logging.file.warn.path&quot;  defaultValue=&quot;${LOG_PATH}/${WARN_PATH}&quot; /&gt;
&lt;springProperty scope=&quot;context&quot; name=&quot;ERROR_LOG_PATH&quot; source=&quot;logging.file.error.path&quot; defaultValue=&quot;${LOG_PATH}/${ERROR_PATH}&quot; /&gt;</code></pre>
<ul>
<li><strong>scope=&quot;context&quot;</strong>: Spring ApplicationContext가 초기화될 때 값을 가져옴</li>
<li><strong>name</strong>: logback 내에서 참조할 이름 (ex. <code>${INFO_LOG_PATH}</code>)</li>
<li><strong>source</strong>: Spring 설정 파일(application.properties)에서 가져올 key 값</li>
<li><strong>defaultValue</strong>: source에서 값을 찾지 못할 경우 사용할 기본값</li>
</ul>
<h3 id="개발-환경-설정-dev-profile">개발 환경 설정 (dev profile)</h3>
<pre><code class="language-xml">&lt;springProfile name=&quot;dev&quot;&gt;
    &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
        &lt;encoder&gt;
            &lt;pattern&gt;${CONSOLE_PATTERN}&lt;/pattern&gt;
        &lt;/encoder&gt;
    &lt;/appender&gt;

    &lt;!-- 프레임워크 로거 레벨 설정 --&gt;
    &lt;logger name=&quot;org.springframework&quot; level=&quot;INFO&quot;/&gt;
    &lt;logger name=&quot;org.springframework.boot&quot; level=&quot;INFO&quot;/&gt;
    &lt;logger name=&quot;org.hibernate&quot; level=&quot;WARN&quot;/&gt;
    &lt;logger name=&quot;org.apache.catalina&quot; level=&quot;INFO&quot;/&gt;
    &lt;logger name=&quot;com.zaxxer.hikari&quot; level=&quot;WARN&quot;/&gt;
    &lt;!-- 그외에도 DB, CloudWatch, AWS 등의 로거 레벨 설정 가능--&gt;
    &lt;root level=&quot;DEBUG&quot;&gt;
        &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
    &lt;/root&gt;
&lt;/springProfile&gt;</code></pre>
<p><strong>주요 구성 요소:</strong></p>
<ul>
<li><strong>springProfile</strong>: Spring 프로파일이 <code>dev</code>일 때만 활성화</li>
<li><strong>appender</strong>: 로그 출력 방식을 정의 (콘솔 출력용)</li>
<li><strong>encoder</strong>: 로그 메시지의 출력 포맷 정의</li>
<li><strong>logger</strong>: 특정 패키지/클래스의 로그 레벨 설정</li>
<li><strong>root level</strong>: 전체 애플리케이션의 기본 로그 레벨</li>
</ul>
<h3 id="운영-환경-설정-prod-profile">운영 환경 설정 (prod profile)</h3>
<h4 id="info-전용-appender">INFO 전용 Appender</h4>
<pre><code class="language-xml">&lt;appender name=&quot;FILE_INFO&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&gt;
    &lt;file&gt;${LOG_PATH}/${INFO_PATH}/application-info.log&lt;/file&gt;

    &lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
        &lt;fileNamePattern&gt;${INFO_LOG_PATH}/application-info-%d{yyyy-MM-dd}.%i.log.zip&lt;/fileNamePattern&gt;
        &lt;maxHistory&gt;${MAX_HISTORY}&lt;/maxHistory&gt;
        &lt;maxFileSize&gt;${MAX_FILE_SIZE}&lt;/maxFileSize&gt;
        &lt;totalSizeCap&gt;${TOTAL_SIZE}&lt;/totalSizeCap&gt;
    &lt;/rollingPolicy&gt;

    &lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
        &lt;level&gt;INFO&lt;/level&gt;
    &lt;/filter&gt;

    &lt;encoder&gt;
      &lt;pattern&gt;${ROLLING_PATTERN}&lt;/pattern&gt;
    &lt;/encoder&gt;
&lt;/appender&gt;</code></pre>
<h4 id="error-전용-appender">ERROR 전용 Appender</h4>
<pre><code class="language-xml">&lt;appender name=&quot;FILE_ERROR&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&gt;
    &lt;file&gt;${LOG_PATH}/${ERROR_PATH}/application-error.log&lt;/file&gt;

    &lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
        &lt;fileNamePattern&gt;${ERROR_LOG_PATH}/application-error-%d{yyyy-MM-dd}.%i.log.zip&lt;/fileNamePattern&gt;
        &lt;maxHistory&gt;${MAX_HISTORY}&lt;/maxHistory&gt;
        &lt;maxFileSize&gt;${MAX_FILE_SIZE}&lt;/maxFileSize&gt;
        &lt;totalSizeCap&gt;${TOTAL_SIZE}&lt;/totalSizeCap&gt;
    &lt;/rollingPolicy&gt;

    &lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
        &lt;level&gt;ERROR&lt;/level&gt;
    &lt;/filter&gt;

    &lt;encoder&gt;
        &lt;pattern&gt;${ROLLING_PATTERN}&lt;/pattern&gt;
    &lt;/encoder&gt;
&lt;/appender&gt;</code></pre>
<h4 id="warndebug-전용-appender는-이하-생략하겠다">WARN,DEBUG 전용 Appender는 이하 생략하겠다...</h4>
<p><strong>주요 구성 요소:</strong></p>
<ul>
<li><strong>RollingFileAppender</strong>: 파일 크기/시간 기준으로 로그 파일을 분할</li>
<li><strong>rollingPolicy</strong>: 파일 롤링 정책 설정<ul>
<li><strong>fileNamePattern</strong>: 롤링된 파일의 이름 패턴 및 압축 설정</li>
<li><strong>maxFileSize</strong>: 단일 로그 파일의 최대 크기 (예: 10MB)</li>
<li><strong>maxHistory</strong>: 최대 보관 일수 또는 개수 (예: 10일)</li>
<li><strong>totalSizeCap</strong>: 전체 로그 디렉토리의 최대 용량 제한 (예: 50MB)</li>
</ul>
</li>
<li><strong>filter</strong>: 특정 로그 레벨 필터링<ul>
<li><strong>ThresholdFilter</strong>: 설정된 레벨 이상만 출력 (INFO 설정 시 DEBUG 무시)</li>
</ul>
</li>
</ul>
<h2 id="🚀-6-실행-방법">🚀 6. 실행 방법</h2>
<h3 id="개발-환경-실행">개발 환경 실행</h3>
<pre><code class="language-bash"># IDE에서 실행 시
-Dspring.profiles.active=dev

# JAR 실행 시
java -jar -Dspring.profiles.active=dev your-application.jar</code></pre>
<h3 id="운영-환경-실행">운영 환경 실행</h3>
<pre><code class="language-bash"># JAR 실행 시
java -jar -Dspring.profiles.active=prod your-application.jar</code></pre>
<h2 id="📂-7-생성되는-폴더-구조">📂 7. 생성되는 폴더 구조</h2>
<p>운영 환경에서는 다음과 같은 폴더 구조가 생성된다.</p>
<pre><code>logs/
├── info/
│   ├── application-info.log
│   ├── application-info-2025-08-04.0.log.zip
│   └── application-info-2025-08-04.1.log.zip
└── error/
    ├── application-error.log
    ├── application-error-2025-08-04.0.log.zip
    └── application-error-2025-08-04.1.log.zip
└── warn/
    ├── application-warn.log
    ├── application-warn-2025-08-04.0.log.zip
    └── application-warn-2025-08-04.1.log.zip
</code></pre><h2 id="💡-8-주요-장점">💡 8. 주요 장점</h2>
<h3 id="환경별-최적화">환경별 최적화</h3>
<ul>
<li><strong>개발 환경</strong>: 콘솔 출력, 컬러 하이라이트, 상세한 디버그 정보</li>
<li><strong>운영 환경</strong>: 파일 저장, 레벨별 분리, 압축 및 롤링</li>
</ul>
<h3 id="자동-로그-관리">자동 로그 관리</h3>
<ul>
<li>파일 크기 기반 자동 롤링</li>
<li>날짜별 파일 분할</li>
<li>오래된 로그 자동 삭제</li>
<li>ZIP 압축으로 저장 공간 절약</li>
</ul>
<h3 id="성능-최적화">성능 최적화</h3>
<ul>
<li>운영 환경에서 불필요한 로그 레벨 제한</li>
<li>프레임워크 로거 레벨 조정으로 노이즈 감소</li>
<li>효율적인 파일 I/O</li>
</ul>
<p>```</p>
<p>이 설정을 통해 개발과 운영 환경에서 각각 최적화된 로깅 전략을 구현할 수 있었다. 개발 시에는 상세한 디버그 정보를, 운영 시에는 효율적이고 관리하기 쉬운 로그를 얻을 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS Cloud Watch로 모니터링 연결하기]]></title>
            <link>https://velog.io/@sunset_1839/AWS-Cloud-Watch%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/AWS-Cloud-Watch%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 03 Sep 2025 15:29:08 GMT</pubDate>
            <description><![CDATA[<p>AWS EC2 인스턴스에 CloudWatch 에이전트를 설치하여 시스템 메트릭(CPU, 메모리, 디스크)를 모니터링하는 방법을 정리했습니다.
다음에는 Spring 로그 지표를 모니터링하는 방법을 쓰려고합니다.</p>
<h2 id="모니터링이란">모니터링이란?</h2>
<p>시스템의 상태와 성능을 지속적으로 관찰하고 측정하여 문제를 사전에 감지하고 대응하는 활동입니다.
제가 모니터링에 대해 공부하면서 생각한 주요한 핵심요소는 3가지 입니다.</p>
<p>** 1. 수집 **</p>
<ul>
<li>지표(매트릭) 수집 : CPU, 메모리, 디스크, 사용률등 수치 데이터 수집.      </li>
<li>로그 수집 : 로그를 레벨에 따라 나누고(Info, Warn, Error 등) 대쉬보드에서 모니터링을 하여 운영 상황에서 발생한 문제 상황 인식.</li>
<li>트레이스 : 요청의 전체 처리 과정을 추적.</li>
</ul>
<p>** 2. 시각화 **</p>
<ul>
<li>그래프 및 차트로 데이터를 이해하기 쉽게 표현</li>
<li>대쉬보드를 통한 실시간 상태 확인</li>
</ul>
<p>** 3. 알림 **</p>
<ul>
<li>임계값 초과 시 자동 알림 발송</li>
<li>문제 상황을 즉시 인지하고 대응</li>
</ul>
<hr>
<h2 id="사전-준비사항">사전 준비사항</h2>
<h3 id="iam-역할-설정">IAM 역할 설정</h3>
<p>EC2 인스턴스에 CloudWatch 에이전트 사용을 위한 IAM 역할을 설정했습니다. 다음 정책이 필요합니다:</p>
<ul>
<li><code>CloudWatchAgentServerPolicy</code> (CloudWatch 에이전트용)</li>
</ul>
<p>IAM 역할 설정 방법:</p>
<ol>
<li>AWS EC2 콘솔에서 해당 인스턴스 선택</li>
<li><strong>작업</strong> → <strong>보안</strong> → <strong>IAM 역할 수정</strong></li>
<li>위 정책들이 포함된 역할을 선택하여 적용</li>
</ol>
<p>역할 생성에 대한 내용은 여러 글들이 있으니 참고하면 금방 할 것 같습니다.</p>
<hr>
<h2 id="cloudwatch-에이전트-설치">CloudWatch 에이전트 설치</h2>
<p>모든 과정은 EC2 인스턴스 안에서 진행했습니다.</p>
<h3 id="1-패키지-다운로드">1. 패키지 다운로드</h3>
<p>먼저 CloudWatch 에이전트 패키지를 다운로드했습니다:</p>
<pre><code class="language-bash"># Ubuntu/Debian 계열
wget https://s3.amazonaws.com/amazoncloudwatch-agent/ubuntu/amd64/latest/amazon-cloudwatch-agent.deb</code></pre>
<h3 id="2-패키지-설치">2. 패키지 설치</h3>
<p>다운로드한 패키지를 설치했습니다:</p>
<pre><code class="language-bash">sudo dpkg -i amazon-cloudwatch-agent.deb</code></pre>
<p>설치가 완료되면 <code>/opt/aws/amazon-cloudwatch-agent/</code> 디렉토리가 생성됩니다.</p>
<hr>
<h2 id="구성-파일-생성">구성 파일 생성</h2>
<h3 id="1-구성-파일-디렉토리-이동">1. 구성 파일 디렉토리 이동</h3>
<p>CloudWatch 에이전트 구성 파일을 생성하기 위해 해당 디렉토리로 이동했습니다:</p>
<pre><code class="language-bash">cd /opt/aws/amazon-cloudwatch-agent/bin/</code></pre>
<h3 id="2-구성-파일-생성">2. 구성 파일 생성</h3>
<p>종합적인 모니터링을 위한 구성 파일을 생성했습니다:</p>
<pre><code>sudo vi config.json
</code></pre><p>vi 에디터로 <code>amazon-cloudwatch-agent.json</code> 파일을 생성 후 아래 내용을 넣습니다.</p>
<pre><code class="language-bash"> {
  &quot;agent&quot;: {
    &quot;metrics_collection_interval&quot;: 60,
    &quot;run_as_user&quot;: &quot;cwagent&quot;
  },
  &quot;metrics&quot;: {
    &quot;namespace&quot;: &quot;CWAgent&quot;,
   &quot;append_dimensions&quot;: {
      &quot;InstanceId&quot;: &quot;${aws:InstanceId}&quot;
    },
    &quot;metrics_collected&quot;: {
      &quot;cpu&quot;: {
        &quot;measurement&quot;: [&quot;cpu_usage_user&quot;, &quot;cpu_usage_system&quot;, &quot;cpu_usage_idle&quot;, &quot;cpu_usage_iowait&quot;],
        &quot;metrics_collection_interval&quot;: 60,
        &quot;totalcpu&quot;: true
      },
      &quot;disk&quot;: {
        &quot;measurement&quot;: [&quot;used_percent&quot;],
        &quot;metrics_collection_interval&quot;: 60,
        &quot;resources&quot;: [&quot;*&quot;]
      },
      &quot;mem&quot;: {
        &quot;measurement&quot;: [&quot;mem_used_percent&quot;],
        &quot;metrics_collection_interval&quot;: 60
      }
    }
  },
  &quot;logs&quot;: {
    &quot;logs_collected&quot;: {
      &quot;files&quot;: {
        &quot;collect_list&quot;: [
          {
            &quot;file_path&quot;: &quot;/var/log/messages&quot;,
            &quot;log_group_name&quot;: &quot;lifelog-dev-system-logs&quot;,
            &quot;log_stream_name&quot;: &quot;{instance_id}-messages&quot;,
            &quot;timezone&quot;: &quot;Local&quot;
          }
        ]
      }
    }
  }
}</code></pre>
<hr>
<h2 id="cloudwatch-에이전트-실행">CloudWatch 에이전트 실행</h2>
<h3 id="1-에이전트-시작">1. 에이전트 시작</h3>
<p>config.json파일을 실행하기 위한 권한을 주었습니다.</p>
<pre><code class="language-bash">chmod 755 config.json</code></pre>
<p>그리고 생성한 <code>config.json</code> 파일을 참조하여 CloudWatch 에이전트를 시작했습니다</p>
<pre><code class="language-bash">sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:config.json -s</code></pre>
<br>

<h3 id="2-에이전트-상태-확인">2. 에이전트 상태 확인</h3>
<p>정상적으로 실행되고 있는지 상태를 확인했습니다:</p>
<pre><code class="language-bash">sudo systemctl status amazon-cloudwatch-agent</code></pre>
<br>

<p>&lt;결과&gt;
<img src="https://velog.velcdn.com/images/sunset_1839/post/f9ca0d79-4e04-4458-9db2-251b8efdef20/image.png" alt=""></p>
<h3 id="3-시스템-서비스-관리">3. 시스템 서비스 관리</h3>
<p>에이전트가 시스템 서비스로 등록되었으므로, 이후에는 <code>systemctl</code>로도 관리할 수 있습니다:</p>
<pre><code class="language-bash"># 상태 확인
sudo systemctl status amazon-cloudwatch-agent

# 재시작
sudo systemctl restart amazon-cloudwatch-agent

# 자동 시작 설정 (인스턴스 재부팅 시 자동 시작)
sudo systemctl enable amazon-cloudwatch-agent</code></pre>
<hr>
<h2 id="모니터링-결과-확인">모니터링 결과 확인</h2>
<h3 id="cloudwatch-대시보드에서-시각화하기">CloudWatch 대시보드에서 시각화하기</h3>
<p>모니터링 설정이 완료되었다면, CloudWatch 대시보드를 통해 수집된 메트릭을 시각화해보겠습니다.</p>
<h4 id="대시보드-생성-과정">대시보드 생성 과정</h4>
<ol>
<li><strong>AWS 콘솔</strong> → <strong>CloudWatch</strong> → <strong>대시보드</strong> → <strong>대시보드 생성</strong></li>
<li><strong>위젯 추가</strong> → <strong>선 그래프</strong> 선택</li>
<li><strong>지표</strong> 탭 → <strong>모든 지표</strong> → <strong>CWAgent</strong> 네임스페이스 선택</li>
</ol>
<h4 id="수집되는-주요-메트릭">수집되는 주요 메트릭</h4>
<p>CloudWatch Agent를 통해 다음과 같은 시스템 메트릭들이 1분 간격으로 수집됩니다:</p>
<ul>
<li><p><strong>CPU 사용률</strong></p>
<ul>
<li><code>cpu_usage_idle</code>: 유휴 상태 CPU 비율</li>
<li><code>cpu_usage_iowait</code>: I/O 대기 시간</li>
<li><code>cpu_usage_user</code>: 사용자 프로세스 CPU 사용률</li>
<li><code>cpu_usage_system</code>: 시스템 프로세스 CPU 사용률</li>
</ul>
</li>
<li><p><strong>디스크 사용률</strong></p>
<ul>
<li><code>disk_used_percent</code>: 디스크 사용률 (%)</li>
</ul>
</li>
<li><p><strong>메모리 사용률</strong></p>
<ul>
<li><code>mem_used_percent</code>: 메모리 사용률 (%)</li>
</ul>
</li>
</ul>
<h4 id="대시보드-결과-예시">대시보드 결과 예시</h4>
<p>설정이 완료되면 아래와 같이 실시간 모니터링 대시보드를 확인할 수 있습니다:</p>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/c0904bbc-7987-4530-92ee-dda2edb4990d/image.png" alt="CloudWatch 대시보드 모니터링 결과"></p>
<blockquote>
<p><strong>참고</strong>: 메트릭이 대시보드에 나타나기까지 5-10분 정도 소요될 수 있습니다. Agent 설정 적용 후 잠시 기다린 다음 확인해보세요.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[불변 객체를 만드는 네 가지 습관]]></title>
            <link>https://velog.io/@sunset_1839/%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-%EB%84%A4-%EA%B0%80%EC%A7%80-%EC%8A%B5%EA%B4%80-f6ht76hh</link>
            <guid>https://velog.io/@sunset_1839/%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-%EB%84%A4-%EA%B0%80%EC%A7%80-%EC%8A%B5%EA%B4%80-f6ht76hh</guid>
            <pubDate>Mon, 07 Apr 2025 06:51:47 GMT</pubDate>
            <description><![CDATA[<h3 id="불변객체라는-말을-우아한테크코스에서-처음-듣게-되었다">불변객체라는 말을 우아한테크코스에서 처음 듣게 되었다</h3>
<p>처음엔 &quot;도대체 그게 왜 중요한 거지?🙄&quot;라는 생각이 들었다.
왜 객체의 상태를 바꾸면 안 되는 걸까?
직접 코드를 구현하고, 다양한 상황을 실험해보면서, 불변 객체가 왜 필요하고 어떤 문제를 막아주는지 조금씩 체감할 수 있었다.
이 글에서는 그 과정을 통해 내가 깨달은 불변 객체의 필요성에 대해 이야기해보려 한다.</p>
<hr>
<h3 id="💡불변-객체란">💡불변 객체란?</h3>
<p><strong>불변 객체(Immutable Object)</strong> 란 한 번 생성되면 내부 상태가 절대 변하지 않는 객체를 말한다.
즉, 객체가 생성된 이후에는 어떤 방식으로도 그 속성 값을 바꿀 수 없다.</p>
<p>이러한 특성 덕분에, 불변 객체는 사용하는 입장에서 믿고 쓸 수 있는 신뢰성 있는 객체가 된다.
상태가 바뀌지 않기 때문에 언제, 어디서, 어떤 상황에서 사용해도 예측 가능한 동일한 동작을 보장한다.</p>
<blockquote>
<p>여기서 &quot;예측 가능한 동작&quot;이란
이 객체는 어떤 코드에서 사용하든 항상 같은 값을 리턴하고, 갑작스러운 변경이 발생하지 않는다는 의미다..</p>
</blockquote>
<p>예를 들어, 아래와 같은 코드가 있다고 해보자.</p>
<pre><code class="language-java">Crew meringue = new Crew(&quot;머랭&quot;, &quot;WANNI&quot;); 
System.out.println(meringue.getName()); </code></pre>
<p>만약 위의 Crew 객체가 <code>불변 객체</code>라면,
어떤 메서드, 어떤 클래스, 어떤 스레드에서 호출하더라도 <code>meringue.getName()</code>은 항상 &quot;머랭&quot;을 반환한다.</p>
<p>하지만 Crew 객체가 <code>가변 객체</code>라면? 
외부에서 객체의 상태를 바꿀 수 있게 되면, 협업 중 누군가 실수로 값을 바꾸거나, 멀티스레드 환경에서 예상치 못한 변경이 일어나 버그의 원인이 될 수도 있다.</p>
<hr>
<h3 id="💡불변-객체를-만드는-4가지-원칙">💡불변 객체를 만드는 4가지 원칙</h3>
<p>불변 객체를 만들기 위해선 몇 가지 원칙을 지켜야 한다.
이 네가지 원칙이 잘 어우러질때 비로소 완전한 불변 객체가 완성된다. </p>
<p>각 원칙은 다음과 같다.
1️⃣ 필드는 <strong>private으로</strong> 선언해 외부 접근을 막자.
2️⃣ <strong>setter는</strong> 지양하자.
3️⃣ 값의 <strong>재할당을</strong> 막기 위해 <strong>final을</strong> 붙이자.
4️⃣ 컬렉션이나 참조 타입은 <strong>방어적 복사</strong>를 활용하자.</p>
<p>아래 코드는 불변 객체를 만들기 위한 4가지 원칙을 <strong>전혀</strong> 지키지 않은 사례다.
이 코드가 어떤 문제를 만들 수 있는지 각 단계별로 살펴보고, 어떻게 개선할 수 있는지도 함께 알아보자.</p>
<ul>
<li><p>Crew 클래스: 크루원들의 이름과 그룹을 저장하는 클래스.</p>
</li>
<li><p>Team 클래스: 크루들을 관리하는 일급 컬렉션</p>
<pre><code class="language-java">public class Crew {
     public String name;
  public String group;

  public Crew(String name,String group) {
      this.name = name;
      this.group = group;
  }

  public String getName() {
      return name;
  }

  public String getGroup() {
      return group;
  }

  public void setName(String name) {
      this.name = name;
  }

  public void setGroup(String group) {
      this.group = group;
  }
}</code></pre>
</li>
</ul>
<pre><code class="language-java">public class Team {
    public List&lt;Crew&gt; crews;

    public Team(List&lt;Crew&gt; crews) {
        this.crews = crews;
    }

    public List&lt;Crew&gt; getCrews() {
        return crews;
    }
}
</code></pre>
<hr>
<h3 id="🔻필드는-private으로-선언해-외부-접근을-막자">🔻필드는 private으로 선언해 외부 접근을 막자.</h3>
<p>만약 각 필드가 private으로 선언되지 않는다면 무슨 문제가 생길 수 있을까?
객체지향적 관점에서 보면, <strong>캡슐화(encapsulation)</strong> 원칙이 깨진다는 문제가 있다.</p>
<p>위키백과에서는 캡슐화를 다음과 같이 정의한다:</p>
<blockquote>
</blockquote>
<p>객체의 속성(data fields)과 행위(methods)를 하나로 묶고, 실제 구현 내용 일부를 외부에 감추어 은닉한다.</p>
<p><code>private</code> 접근 지정자는 클래스 내부에서만 접근이 가능하다.
반면 <code>protected</code>나 <code>public</code>은 클래스 외부에서도 필드에 접근할 수 있기 때문에, 내부 구현이 <strong>노출</strong>되고 데이터가 외부에서 쉽게 <strong>변경</strong>될 수 있다.</p>
<p>아래 예제를 통해 문제 상황을 확인해보자.</p>
<pre><code class="language-java">    @DisplayName(&quot;크루원의 이름을 확인한다.&quot;)
    @Test
    void test() {
        Crew moco = new Crew(&quot;모코&quot;, &quot;WANNI&quot;);
        moco.name = &quot;레몬&quot;;
        moco.group = &quot;NEO&quot;;

        SoftAssertions.assertSoftly(softly -&gt; {
            softly.assertThat(moco.name).isEqualTo(&quot;레몬&quot;);
            softly.assertThat(moco.group).isEqualTo(&quot;NEO&quot;);
        });
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/21d91305-b21d-4600-b480-08bdaf5b992c/image.png" alt=""></p>
<p>Crew 클래스의 필드가 public으로 선언되어 있기 때문에,
외부 코드에서 name과 group을 마음대로 바꿀 수 있다.
이로 인해 객체의 <strong>신뢰성</strong>과 <strong>일관성</strong>이 무너진다.</p>
<h4 id="✅-해결-방법은-간단하다">✅ 해결 방법은 간단하다.</h4>
<p>필드를 <strong>private</strong>으로 선언하자.
이렇게 하면 객체의 내부 상태가 외부에서 직접 보이거나 변경되는 일을 막을 수 있고, 객체지향의 핵심 원칙인 캡슐화를 자연스럽게 지킬 수 있다.</p>
<pre><code class="language-java">private String name;
private String group;
</code></pre>
<p><em>애초에 컴파일 단계에서 에러가 발생하여 잘못된 접근을 사전에 차단할 수 있다.</em>
<img src="https://velog.velcdn.com/images/sunset_1839/post/f90663d4-179b-4a72-87f2-17c57c5790b1/image.png" alt=""></p>
<hr>
<h3 id="🔻setter는-지양하자">🔻Setter는 지양하자.</h3>
<p>앞서 필드를 <code>private</code>으로 선언했으니, 더 이상 값을 수정할 수 없을까?
그렇지 않다. <code>setter 메서드</code>가 존재한다면, 외부에서는 여전히 객체의 내부 상태를 마음대로 바꿀 수 있다.</p>
<pre><code class="language-java">    @DisplayName(&quot;setter로 크루원의 정보를 바꾼다.&quot;)
    @Test
    void test() {
        Crew moco = new Crew(&quot;모코&quot;, &quot;WANNI&quot;);
        moco.setName(&quot;레몬&quot;);
        moco.setGroup(&quot;NEO&quot;);

        SoftAssertions.assertSoftly(softly -&gt; {
            softly.assertThat(moco.getName()).isEqualTo(&quot;레몬&quot;);
            softly.assertThat(moco.getGroup()).isEqualTo(&quot;NEO&quot;);
        });
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/9f6e5369-e092-4f51-8b53-0e9cf2a850ab/image.png" alt=""></p>
<p>가장 중요한 문제는, 필드를 <code>protected</code>나 <code>public</code>으로 선언했을 때와 마찬가지로,
객체가 생성된 이후에도 마음만 먹으면 내부 상태를 얼마든지 <strong>바꿀 수 있다</strong>는 점이다.</p>
<p>이 문제를 해결하려면,
모든 상태를 생성자에서 한 번만 초기화하고, 이후에는 절대 수정할 수 없도록 <code>setter</code> 자체를 <strong>만들지 말자</strong>.</p>
<p>이렇게 하면 객체는 항상 일정한 상태를 유지하며, 예측 가능하고 안전한 구조를 가질 수 있다.</p>
<pre><code class="language-java">public class Crew {
    private String name;
    private String group;

    public Crew(String name,String group) {
        this.name = name;
        this.group = group;
    }

    public String getName() {
        return name;
    }

    public String getGroup() {
        return group;
    }
}</code></pre>
<hr>
<h3 id="🔻값의-재할당을-막기-위해-final을-붙이자">🔻값의 <strong>재할당을</strong> 막기 위해 <strong>final을</strong> 붙이자.</h3>
<p>그렇다면 <code>final</code>의 역할은 무엇일까?
가장 핵심적인 기능은 한 번 할당된 값을 다시는 바꿀 수 없게 만드는 것, 즉 <strong>재할당을 막는 것</strong>이다
때문에 변경되지 않을 거란걸 명시적으로 표현할떄, 특히 협업 때 쓰는게 좋다</p>
<p>예를들어 아래와 같이 number라는 변수에 값을 할당하고 final을 붙이면</p>
<pre><code class="language-java">final int number = 1;
</code></pre>
<p>이제 이 변수에는 다시 값을 넣을 수 없다.
즉, 다음과 같은 코드는 컴파일 오류를 발생시킨다:</p>
<pre><code class="language-java">number = 2; //컴파일 에러</code></pre>
<p>이처럼 final을 사용하면 변수나 필드가 다른 값으로 바뀌는 실수를 <strong>방지</strong>할 수 있다.
클래스의 필드에 final을 붙이는 이유도 같은 맥락으로, 객체의 상태를 의도하지 않은 변경으로부터 보호하기 위함이다.</p>
<h4 id="💡그렇다면-객체-앞에-final이-붙는다면">💡그렇다면 객체 앞에 final이 붙는다면?</h4>
<pre><code class="language-java"> final List&lt;Crew&gt; crews = new ArrayList&lt;&gt;();
 final Crew crew = new Crew(&quot;레몬&quot;,&quot;WANNI&quot;);</code></pre>
<p>약 crews 리스트에  crews.add()를 하면 값이 들어갈까? final이라 명시되었기에 약간의 고민이 생길것이다.
또한 player안의 필드값을 변경할 수 있을까?</p>
<p>결론부터 말하자면, <strong>가능</strong>하다.</p>
<p>그 이유는 final의 역할이 <code>&quot;재할당을 막는 것&quot;</code> 이기 때문이다.
즉, crews라는 변수에 다른 리스트를 새로 할당하는 건 <strong>불가능</strong>하지만,
리스트 내부의 요소를 <strong>추가</strong>하거나 <strong>수정</strong>하는 건 <strong>가능</strong>하다.</p>
<p>위에서 말한 것처럼  </p>
<pre><code class="language-java">crews = new ArrayList&lt;&gt;()
crew = new Crew(&quot;머랭&quot;,&quot;NEO&quot;)</code></pre>
<p>위의 코드와 처럼 재할당을 하려고 하는것을 방지하기 위해 <code>명시적</code>으로 쓰인다.</p>
<hr>
<h3 id="🔻컬렉션이나-참조-타입은-방어적-복사를-활용하자">🔻컬렉션이나 참조 타입은 <strong>방어적 복사</strong>를 활용하자.</h3>
<pre><code class="language-java">public class Team {
    private final List&lt;Crew&gt; crews;

    public Team(final List&lt;Crew&gt; crews) {
        this.crews = crews; // 외부 리스트를 그대로 참조
    }

    public List&lt;Crew&gt; getCrews() {
        return crews; // 내부 리스트를 그대로 노출
    }
}</code></pre>
<p>이 코드는 우리가 흔히 볼 수 있는 아주 평범한 구조다.
겉보기엔<code>private</code> 필드에 <code>setter</code>도 없고, 나름 <strong>캡슐화</strong>가 잘 되어 있는 것처럼 보인다.
그래서 방어적 복사의 개념을 처음 접하기 전에는 이 코드의 문제점을 쉽게 눈치채기 어렵다.</p>
<p>하지만 아래 예시를 보면서 왜 방어적 복사가 필요한지 알아보자.</p>
<pre><code class="language-java">List&lt;Crew&gt; crewList = new ArrayList&lt;&gt;();
crewList.add(new Crew(&quot;레몬&quot;, &quot;WANNI&quot;));

Team team = new Team(crewList);
</code></pre>
<p>이 코드는 <code>Crew</code> 객체를 생성해서 <code>리스트</code>에 담고, 그 리스트를 이용해 <code>Team</code> 객체를 만든다.
여기까지는 전혀 문제 없어 보인다. 충분히 자연스럽고, 흔히 사용하는 방식이다.</p>
<p>⚠️그런데 여기서 문제가 발생할 수 있다.</p>
<pre><code class="language-java">// 외부에서 원본 리스트 수정
crewList.add(new Crew(&quot;브리&quot;, &quot;NEO&quot;));

// 내부 리스트에 직접 접근하여 조작
team.getCrews().add(new Crew(&quot;솔라&quot;, &quot;NEO&quot;));

//출력
System.out.println(team.getCrews());
/*
[
예상 출력 값: 
  Crew{name=&#39;레몬&#39;, team=&#39;WANNI&#39;},
  Crew{name=&#39;브리&#39;, team=&#39;NEO&#39;},
  Crew{name=&#39;솔라&#39;, team=&#39;NEO&#39;}
]
*/</code></pre>
<p>위 코드에서는 Team 객체가 생성된 이후에도 외부에서 얼마든지 내부 상태를 바꿀 수 있는 문제가 발생한다.</p>
<ul>
<li><p>첫 번째 줄에서는 Team이 생성된 이후, 원래 넘겼던 crewList를 <strong>직접 수정함</strong>으로써 내부 데이터에 영향을 준다.</p>
</li>
<li><p>두 번째 줄에서는 getCrews()를 통해 <strong>내부 리스트에 접근</strong>하고, 그대로 .add()를 호출해서 새로운 객체를 추가하고 있다.</p>
</li>
</ul>
<p>이 두 줄만으로 Team의 내부 상태는 언제든지 변경 가능해졌고, 이는 불변성을 완전히 깨트리는 심각한 설계적 결함이다.</p>
<p>더욱 심각한 상황도 있다. 만약 실수로 데이터를 삭제한다면 어떻게 될 것인가.</p>
<pre><code class="language-java">crewList.clear(); // 외부에서 리스트를 삭제

team.getCrews().clear(); // 내부 리스트를 직접 삭제

System.out.println(team.getCrews()); // []</code></pre>
<p>아무리 Team 객체 내부에서 crews를 private final로 선언했다고 해도, 내부 리스트가 외부에 노출되어 있거나 방어적 복사가 되지 않았다면, 이렇게 외부에서 .clear()를 호출함으로써 내부 데이터를 완전히 지워버릴 수 있다.</p>
<p>🚨이러한 문제를 방지하기 위해서는 다음과 같은 <code>방어적 복사</code> 전략이 <strong>반드시</strong> 필요하다.</p>
<p>[수정된 Team 클래스]</p>
<pre><code class="language-java">public class Team {
    private final List&lt;Crew&gt; crews;

    public Team(final List&lt;Crew&gt; crews) {
        this.crews = new ArrayList&lt;&gt;(crews); // 방어적 복사
    }

    public List&lt;Crew&gt; getCrews() {
        return Collections.unmodifiableList(crews); // 불변 리스트로 노출
    }
}</code></pre>
<h4 id="⭕방어적-복사의-장점">⭕방어적 복사의 장점</h4>
<ol>
<li><p>Team객체를 만들 때 생성자를 통해 외부에서 받은 리스트를 직접 필드로 저장하지 않고, 리스트 복사본을 만들어 참조를 끝는다.
외부에서 <code>원본 리스트</code>를 건드려도 Team 객체 <code>내부 리스트</code>가 <strong>영향을 받지 않음</strong> → <strong>내부 데이터 보호</strong>.</p>
</li>
<li><p>필드의 crews를 반환시 Collections의 <code>unmodifiableList</code>를 사용하여 불변 리스트로 반환한다. 
외부에서 <code>getCrews()</code>로 받은 리스트를 조작하려고 해도 <code>UnsupportedOperationException</code> 발생 → <strong>불변성 유지</strong>.</p>
</li>
</ol>
<h4 id="❌방어적-복사의-단점">❌방어적 복사의 단점</h4>
<p>매번 복사본을 만들기 때문에 성능 이슈가 발생 가능하다. (특히 리스트가 클때)</p>
<hr>
<h3 id="✅-마무리하며">✅ 마무리하며...</h3>
<p>불변 객체는 처음엔 낯설 수 있지만, 객체의 상태가 예측 가능하고 안정적이라는 점에서 큰 장점을 가진다. 특히 협업이 잦고, 사이드 이펙트에 민감한 환경일수록 불변 객체는 강력한 무기가 된다.</p>
<p><code>방어적 복사</code>와 <code>final</code>, <code>private</code>, <code>setter 지양</code>과 같은 원칙을 차근차근 적용하다 보면 어느새 견고하고 유지보수하기 쉬운 코드를 만들 수 있게 될 것이다!</p>
<blockquote>
</blockquote>
<p>&quot;불변 객체는 객체를 믿고 쓸 수 있게 만드는 가장 단단한 약속이다.&quot;</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Static(스태틱)을 나는 왜 쓰고 있을까?]]></title>
            <link>https://velog.io/@sunset_1839/Static%EC%8A%A4%ED%83%9C%ED%8B%B1%EC%9D%84-%EB%82%98%EB%8A%94-%EC%99%9C-%EC%93%B0%EA%B3%A0-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@sunset_1839/Static%EC%8A%A4%ED%83%9C%ED%8B%B1%EC%9D%84-%EB%82%98%EB%8A%94-%EC%99%9C-%EC%93%B0%EA%B3%A0-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Sun, 06 Apr 2025 11:32:47 GMT</pubDate>
            <description><![CDATA[<h3 id="나는-static을-얕게-알고-있었다-😵">나는 Static을 얕게 알고 있었다! 😵</h3>
<p>우아한테크코스 레벨1 인터뷰를 통해, Static에 대한 나의 기준이 책이나 주변 크루와 같은 <strong>외부적인 요인</strong>에 의해 결정되어 왔고, 내가 이를 얕게 공부해왔다는 사실을 깨달았다. 이번 기회를 통해 Static에 대해 깊이 있게 공부하고, 언제, 왜 사용할지에 대한 나만의 기준을 세워보고자 한다.</p>
<p>나는 프리코스를 거치며 <code>Parser</code>, <code>Validator</code>와 같은 유틸리티 클래스를 사용하면서 Static의 편리함을 직접 경험했다. 입력값을 검증하거나 다른 형태로 변환하는 등, 간단하면서도 여러 곳에서 사용할 수 있다고 판단되는 경우 Static을 사용했다.</p>
<p>하지만, 이 기준은 굉장히 모호하지 않은가? &#39;<strong>간단함</strong>&#39;과 &#39;<strong>여러 곳에서 사용됨</strong>&#39;이라는 기준이 구체적으로 무엇을 의미하는지 누군가 묻는다면 제대로 대답할 수 있을까?</p>
<p>이처럼 안일하고 성의 없는 기준은 나의 성장을 방해하는 큰 요인 중 하나였다. 이번 기회를 통해, 얕게 알고 있던 나 자신을 되돌아보고, Static에 대해 제대로 공부해보자!</p>
<h3 id="static이란">Static이란?</h3>
<p>스태틱은 크게 <strong>4가지</strong>로 분류된다. </p>
<ol>
<li><strong>static 변수</strong> :     모든 객체가 공유</li>
<li><strong>static 메서드</strong> : 객체 없이 호출 가능</li>
<li><strong>static 블록</strong> : 클래스 로드 시 한 번 실행</li>
<li><strong>static 내부 클래스</strong> :    외부 클래스 없이 사용 가능</li>
</ol>
<hr>
<h3 id="🔻static-변수">🔻static 변수</h3>
<blockquote>
<ul>
<li>클래스가 메모리에 로드될 때 한 번만 생성됨.</li>
</ul>
</blockquote>
<ul>
<li>모든 객체가 동일한 값을 공유함.</li>
<li>객체가 없어도 클래스명.변수명으로 접근 가능.</li>
</ul>
<pre><code class="language-java">class Example {
    static int count = 0; // 클래스 변수 (공유됨)

    Example() {
        count++; // 객체가 생성될 때마다 증가
    }

    void showCount() {
        System.out.println(&quot;Count: &quot; + count);
    }
}

public class Main {
    public static void main(String[] args) {
        Example e1 = new Example();
        Example e2 = new Example();
        e1.showCount(); // Count: 2
        e2.showCount(); // Count: 2
    }
}</code></pre>
<hr>
<h3 id="🔻static-메서드">🔻static 메서드</h3>
<blockquote>
<ul>
<li>객체 없이 호출 가능 (클래스명.메서드명()).</li>
</ul>
</blockquote>
<ul>
<li>static 변수만 접근 가능 (인스턴스 변수는 사용 불가).</li>
</ul>
<p>내가 위에서 말했던 유틸리티 클래스를 만들어 사용한 사례이다.</p>
<ul>
<li><code>validateInputNullOrEmpty</code>는 입력을 하지 않았거나 빈 값을 입력했는지를 검증한다.</li>
<li><code>parseStringToIntegerList</code>는 문자열을 &quot;,&quot; 기준으로 정수형 리스트로 변환한다.</li>
</ul>
<p>이 원래 이 메서드들을 사용하려면 <code>Validator</code> 나 <code>Parser</code> 클래스를 인스턴스화 했어야 했지만, <code>static 메서드</code>로 선언 하면 클래스명.메서드명() 형태로 간편하게 사용할 수 있다.</p>
<pre><code class="language-java">import java.util.List;
import java.util.ArrayList;

class Validator {
    public static void validateInputNullOrEmpty(String input) {
        if (input == null || input.trim().isEmpty()) {
            throw new IllegalArgumentException(&quot;입력은 필수 입니다.&quot;);
        }
    }
}

class Parser {
    public static List&lt;Integer&gt; parseStringToIntegerList(String input) {
        List&lt;Integer&gt; result = new ArrayList&lt;&gt;();

        String[] parts = input.split(&quot;,&quot;);
        for (String part : parts) {
            try {
                result.add(Integer.parseInt(part.trim()));
            } catch (NumberFormatException e) {
                throw new IllegalArgumentException(&quot;숫자를 입력해 주세요.&quot;);
            }
        }
        return result;
    }
}

public class Main {
    public static void main(String[] args) {
        String input = &quot;1, 2, 3, 4, 5&quot;;

        Validator.validateInputNullOrEmpty(input); // 입력값 검증

        List&lt;Integer&gt; numbers = Parser.parseStringToIntegerList(input); //문자열 -&gt; 정수형 리스트로 변환

        System.out.println(&quot;Parsed numbers: &quot; + numbers); // 출력: [1, 2, 3, 4, 5]
    }
}

</code></pre>
<hr>
<h3 id="🔻static-블록">🔻static 블록</h3>
<blockquote>
<ul>
<li>클래스가 로드될 때 한 번 실행됨.</li>
</ul>
</blockquote>
<ul>
<li>주로 초기화 작업에 사용됨. </li>
<li>한 클래스 안에 여러개의 static 블록을 사용할 수 있다.</li>
<li>static 블록의 순서대로 로딩한다.</li>
</ul>
<pre><code class="language-java">class Example {
    static String value;

    static {
        value = &quot;첫번째 스태틱 블록&quot;;
           System.out.println(value);
    }

    static {
        value = &quot;두번째 스태틱 블록&quot;;
        System.out.println(value);

    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Example.value);
    }
}

/*
&lt;출력결과&gt;
첫번째 스태틱 블록
두번째 스태틱 블록
두번째 스태틱 블록
*/
</code></pre>
<br>

<p><strong>💡만약 클래스 내부에 <code>스태틱 블록</code>, <code>인스턴스 블록</code>, <code>생성자</code>가 있다면 출력 순서는 어떻게 될까?</strong></p>
<pre><code class="language-java">public class Example {

    static {
        System.out.println(&quot;1. static 블록 실행!&quot;);
    }

    {
        System.out.println(&quot;2. 인스턴스 블록 실행!&quot;);
    }

    public Example() {
        System.out.println(&quot;3. 생성자 실행!&quot;);
    }

    public static void main(String[] args) {
        System.out.println(&quot;메인 메서드 시작!&quot;);
        new Example();
        System.out.println(&quot;-----------&quot;);
        new Example();
    }
}</code></pre>
<pre><code>1. static 블록 실행!
메인 메서드 시작!
2. 인스턴스 블록 실행!
3. 생성자 실행!
-----------
2. 인스턴스 블록 실행!
3. 생성자 실행!</code></pre><p>🔢** 실행 순서**</p>
<ol>
<li><p><strong>static 블록</strong> → 클래스가 메모리에 로드될 때 단 한 번 실행</p>
</li>
<li><p>** 인스턴스 블록** → 객체가 생성될 때마다 생성자보다 먼저 실행</p>
</li>
<li><p><strong>생성자</strong> → 인스턴스 블록 이후 실행됨</p>
</li>
</ol>
<hr>
<h3 id="🔻static-내부-클래스">🔻static 내부 클래스</h3>
<blockquote>
<ul>
<li>외부 클래스의 인스턴스 없이 사용 가능.</li>
</ul>
</blockquote>
<pre><code class="language-java">class Example {
    static class Inner {
        void display() {
            System.out.println(&quot;예제의 내부 클래스에서 출력합니다!&quot;);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Example.Inner obj = new Example.Inner();
        obj.display();  // 예제의 내부 클래스에서 출력합니다!
    }
}

</code></pre>
<hr>
<h3 id="⭕다양한-스태틱-사용-사례">⭕다양한 스태틱 사용 사례</h3>
<p>평소에 우리는 Staitc을 알게 모르게 자주 사용하고 있었다. 어느 부분에서 사용하고 있었을지 알아보자.</p>
<br>

<h4 id="🔻상수-선언">🔻상수 선언</h4>
<ul>
<li>공통적으로 쓰이는 값들을 전역으로 공유하고 싶을 때 사용한다.</li>
<li><code>클래스명.상수명</code> 으로 접근 가능하다.</li>
</ul>
<p>우아한테크코스의 미션을 진행하면서 공통적으로 쓰이는 예외메시지를 한곳에서 관리하고 싶을때 사용했었다.</p>
<pre><code class="language-java">public class ErrorMessage {
    public static final String NULL_POINTER = &quot;NULL 참조가 발생했습니다.&quot;;
    public static final String INVALID_INPUT = &quot;잘못된 입력입니다.&quot;;
    public static final String USER_NOT_FOUND = &quot;사용자를 찾을 수 없습니다.&quot;;
    public static final String IMPOSSIBLE_POSITION = &quot;이동 불가능한 위치입니다.&quot;;
}</code></pre>
<hr>
<h4 id="🔻enum-클래스">🔻Enum 클래스</h4>
<p><strong>Enum(열거형)</strong>은 자바에서 열거된 상수들을 다룰 수 있게 해주는 특별한 클래스이다. 내부적으로 Enum은 여러 개의 static final 필드를 통해 상수들을 정의하며, 이들은 클래스가 로드될 때 static 블록을 통해 초기화된다.</p>
<pre><code class="language-java">public enum Day {
    MONDAY, TUESDAY, WEDNESDAY;
}</code></pre>
<p>위의 Day 클래스는 Enum클래스로 선언되어 있다. 
겉으로는 왜 <strong>static</strong>으로 구성돼 있는지 잘 드러나지 않지만,
<strong>디컴파일</strong>을 통해 내부 구조를 보면 다음과 같다:</p>
<p>[디컴파일된 Day.class]</p>
<pre><code class="language-java">public final class Day extends Enum&lt;Day&gt; {

    public static final Day MONDAY;
    public static final Day TUESDAY;
    public static final Day WEDNESDAY;
    private static final Day[] VALUES;

    static {
        MONDAY = new Day(&quot;MONDAY&quot;, 0);
        TUESDAY = new Day(&quot;TUESDAY&quot;, 1);
        WEDNESDAY = new Day(&quot;WEDNESDAY&quot;, 2);
        VALUES = new Day[] { MONDAY, TUESDAY, WEDNESDAY };
    }

    public static Day[] values() {
        return VALUES.clone();
    }

    private Day(String name, int ordinal) {
        super(name, ordinal);
    }
}</code></pre>
<p>Enum 클래스의 필드를 보면 모두 <strong>static 변수</strong>로 선언되어 있다. 그리고 static 블록에서 초기화를 하고 있다. 그렇기에 <code>클래스명.변수명</code> 으로 사용이 가능하다.</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        Day today = Day.MONDAY;
        System.out.println(&quot;오늘은 &quot; + today + &quot;입니다.&quot;);
    }

    //출력: 오늘은 MONDAY입니다.

}</code></pre>
<hr>
<h4 id="🔻math-클래스">🔻Math 클래스</h4>
<ul>
<li>Math는 객체를 생성하지 않고 바로 사용하는 순수 <strong>유틸리티 클래스</strong>.</li>
<li>내부 메서드는 모두 static으로 선언되어 있다.</li>
</ul>
<p>아래는 Math클래스의 사용 예시이다. <code>Math.메서드명()</code> 으로 사용가능하다. </p>
<pre><code class="language-java">public class MathPractice {
    public static void main(String[] args) {
        int absValue = Math.abs(-1);
        System.out.println(absValue);
    }
    //출력: 1
}</code></pre>
<p>Math 클래스 내부의 메서드들을 살펴보면 전부 Static으로 선언되있다. 아래는 몇가지 Math의 메서드을 가져와 봤다.</p>
<pre><code class="language-java">   @IntrinsicCandidate
    public static int abs(int a) {
        return (a &lt; 0) ? -a : a;
    }

   @IntrinsicCandidate
    public static int min(int a, int b) {
        return (a &lt;= b) ? a : b;
    }

   @IntrinsicCandidate
    public static double max(double a, double b) {
        if (a != a)
            return a;   // a is NaN
        if ((a == 0.0d) &amp;&amp;
            (b == 0.0d) &amp;&amp;
            (Double.doubleToRawLongBits(a) == negativeZeroDoubleBits)) {
            // Raw conversion ok since NaN can&#39;t map to -0.0.
            return b;
        }
        return (a &gt;= b) ? a : b;
    }</code></pre>
<br>

<hr>
<h4 id="🔻collections--arrays-클래스">🔻Collections / Arrays 클래스</h4>
<p>Java에서 <strong>Collections</strong>와 <strong>Arrays</strong> 클래스는 유용한 정적(static) 메서드들을 제공하여
컬렉션 및 배열을 쉽게 조작할 수 있도록 도와준다.</p>
<pre><code class="language-java">public class CollectionPractice {
    public static void main(String[] args) {
        // Arrays.asList 는 static 메서드
        List&lt;Integer&gt; numberList = Arrays.asList(1, 3, 2, 9, 4, 5);

        // Collections.sort 도 static 메서드
        Collections.sort(numberList);

        System.out.println(&quot;정렬된 리스트: &quot; + numberList);
    }
    // 출력: 정렬된 리스트: [1, 2, 3, 4, 5, 9]
}</code></pre>
<p>예시를 위해 몇가지 메서드들을 가져와봤다. </p>
<pre><code class="language-java">// 배열을 리스트로 변환
 public static &lt;T&gt; List&lt;T&gt; asList(T... a) {
        return new ArrayList&lt;&gt;(a);
    }
 //정렬 메서드
 public static &lt;T extends Comparable&lt;? super T&gt;&gt; void sort(List&lt;T&gt; list) {
        list.sort(null);
    }
//리스트 랜덤 메서드
 public static void shuffle(List&lt;?&gt; list) {
        Random rnd = r;
        if (rnd == null)
            r = rnd = new Random(); // harmless race.
        shuffle(list, rnd);
    }</code></pre>
<br>

<hr>
<h4 id="🔻singleton싱글톤-패턴">🔻Singleton(싱글톤) 패턴</h4>
<ul>
<li><strong>싱글톤(Singleton) 패턴</strong>은 객체를 오직 하나만 생성하도록 보장하는 디자인 패턴이다.</li>
<li>객체를 생성하지 않아도 <code>메서드명.getInstance()</code>로 호출 가능하고, 어디서든 <strong>동일한</strong> 인스턴스에 접근할 수 있다.</li>
</ul>
<pre><code class="language-java">public class Singleton {
    // static으로 유일한 인스턴스를 선언
    private static Singleton instance = new Singleton();

    private Singleton() {}

    // static 메서드를 통해 인스턴스를 제공
    public static Singleton getInstance() {
        return instance;
    }
}</code></pre>
<p><strong>[사용예시]</strong></p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();

        s1.doSomething();

        System.out.println(s1 == s2);  // true (동일 인스턴스(객체))
    }
}</code></pre>
<hr>
<h3 id="💡나의-static-사용-기준-정해보기">💡나의 Static 사용 기준 정해보기</h3>
<ul>
<li><p><strong>인스턴스 필드</strong>나 <strong>상태</strong>를 가지지 않고, 범용적인 기능 제공<strong>(유틸리티성</strong>)을 목적으로 하는 클래스는<br>객체 생성 없이 사용될 수 있도록 <strong>static 메서드</strong>로 구성한다.</p>
</li>
<li><p><strong>단</strong>! <code>캐싱</code>이나 <code>설정값</code>처럼 전역적으로 공유해야 하는 데이터는 <code>static 필드</code>를 써야한다.</p>
</li>
<li><p>즉, <strong>불변 유틸리티</strong> + <strong>공유 자원</strong>이 static을 사용하는 핵심 영역이다.</p>
</li>
</ul>
<hr>
<h3 id="🔻반례-객체의-상태를-다루는-경우">🔻반례: 객체의 상태를 다루는 경우</h3>
<ul>
<li><p>객체의 상태를 다루는 클래스, 즉 내부에 필드가 있고 그 값이 바뀌는 경우에는
static 보다는 <strong>인스턴스 메서드</strong>를 사용하는 것이 적절하다.</p>
<pre><code class="language-java">public class Counter {
  private static int count = 0;

  public static void increment() {
      count++;
  }

  public static int getCount() {
      return count;
  }
}</code></pre>
</li>
</ul>
<p>위의 메서드를 static으로 선언하게 되면, 모든 <code>Counter</code> 인스턴스는 동일한 값을 공유하게 된다. 따라서 서로 다른 객체에서 <code>increment()</code>를 호출해도 <code>count</code> 값은 공통으로 증가하게 된다.</p>
<h5 id="🔻나의-static-사용-기준">🔻나의 static 사용 기준</h5>
<table>
<thead>
<tr>
<th>기준</th>
<th><code>static</code> 사용 ✅</th>
<th><code>static</code> 사용 ❌</th>
</tr>
</thead>
<tbody><tr>
<td>상태(필드)가 필요한가?</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>객체 생성이 필요한가?</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>재사용 가능한 기능인가?</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td>전역적으로 공유되는 값인가?</td>
<td>✅   (예: 캐시, 설정값)</td>
<td>❌</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[나만의 스프링 시큐리티 정리하기]]></title>
            <link>https://velog.io/@sunset_1839/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 22 Jan 2025 14:09:22 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>스프링 시큐리티를 처음으로 공부하고 프로젝트에 적용하게 되면서 배운 내용들과 생각을 정리한 글이다.😊</p>
<h3 id="💡스프링-시큐리티spring-security-란">💡스프링 시큐리티(Spring Security) 란?</h3>
<p>공식문서에서는 아래와 같이 정의하고 있다.</p>
<blockquote>
<p>Spring Security는 인증(Authentication), 권한 부여(Authorization), 그리고 일반적인 공격으로부터의 보호를 제공하는 프레임워크이다.
명령형(imperative)과 리액티브(reactive) 애플리케이션 보안을 모두 완벽히 지원하며, Spring 기반 애플리케이션 보안을 위한 사실상의 표준(de-facto standard)으로 자리 잡고 있다.</p>
</blockquote>
<hr>
<h2 id="스프링-시큐리티-아키텍쳐">스프링 시큐리티 아키텍쳐</h2>
<p>먼저 공식 문서를 통해 <strong>스프링 시큐리티 아키텍쳐</strong> 에 대해 공부를 하였다. 공식문서에서 서블릿에 대한 얘기가 나오기에 예전에 공부한 서블릿을 복습하였다. 글에서는 단순히 공식 문서 이해를 위해서 단순 요약하여 정리하였다.</p>
<h4 id="스프링-시큐리티와-서블릿의-관계">스프링 시큐리티와 서블릿의 관계</h4>
<p><strong>1. 서블릿 이란?</strong></p>
<blockquote>
<p>서블릿은 웹 애플리케이션에서 HTTP 요청을 처리하는 표준 방식으로, <strong>Java EE(Enterprise Edition)</strong> 의 일부로 등장했다. 서블릿은 HTTP 요청을 받아 요청 데이터를 처리하고, 동적으로 생성한 응답 데이터를 클라이언트에 전달하는 역할을 수행한다. 주로 웹 서버와 클라이언트 간의 데이터 교환을 중재하는 핵심 요소로 사용된다.</p>
</blockquote>
<p><strong>2.스프링과 서블릿의 관계</strong></p>
<blockquote>
<p><strong>스프링</strong>은 Java EE의 복잡성을 해결하고, 더 효율적인 애플리케이션 개발을 지원하기 위해 개발된 프레임워크다. 스프링은 웹 애플리케이션을 처리하기 위해 서블릿을 기반으로 하는 스프링 MVC라는 웹 프레임워크를 제공하게 되었고, 이 과정에서 서블릿은 중요한 역할을 한다. 그 중 DispatcherServlet은 스프링 MVC 애플리케이션의 핵심 서블릿으로, 모든 HTTP 요청을 처리하는 주요 역할을 담당한다.</p>
</blockquote>
<p><strong>3. 스프링 시큐리티와 서블릿의 연관성</strong></p>
<blockquote>
<p>스프링 시큐리티는 <strong>서블릿 컨테이너</strong>를 기반으로 동작하며, 서블릿과 밀접하게 연관되어 있다.
스프링 시큐리티는 <strong>서블릿 필터(Servlet Filter)</strong> 를 활용하여 요청과 응답에 대한 보안 처리를 한다. 서블릿 필터는 서블릿 요청과 응답을 가로채어 중간에서 작업을 수행할 수 있게 해주는 기술이다. 스프링 시큐리티는 서블릿 필터를 통해 인증 및 권한 부여 로직을 처리하며, 웹 애플리케이션에서 발생하는 보안 관련 작업을 제어한다.</p>
</blockquote>
<p>이제 공식 문서를 번역하며 이해를 해보려고한다.</p>
<hr>
<h2 id="공식문서">공식문서</h2>
<ul>
<li><a href="https://docs.spring.io/spring-security/site/docs/5.4.6/reference/html5/#servlet-architecture">https://docs.spring.io/spring-security/site/docs/5.4.6/reference/html5/#servlet-architecture</a><h3 id="1-filterchain">1. FilterChain</h3>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/844bb7e2-a842-4f4f-8fb4-28d5b0d988ff/image.png" alt=""></p>
<p> 클라이언트가 요청을 보내면 컨테이너는 요청 URI 경로에 따라 처리해야 할 <code>필터(Filter)</code>와 <code>서블릿(Servlet)</code>을 <strong>포함</strong>하는 <code>FilterChain</code>을 생성한다.</p>
<p>스프링 MVC 애플리케이션에서는 이 서블릿이 주로 <code>DispatcherServlet</code> 이다.
<strong>하나의 요청(HttpServletRequest, HttpServletResponse)</strong>에 대해 <strong>하나의 Servlet</strong>만이 직접 처리할 수 있다는 제약이 있지만, 여러 개의 <code>Filter</code>는 함께 사용될 수 있다.</p>
<p> <code>FilterChain</code>은 요청 흐름에서 다음 필터나 서블릿을 호출하며, 요청 처리의 순서를 제어한다. 이를 통해 필터는 요청이 처리되기 전 또는 후에 원하는 작업을 삽입할 수 있다.</p>
<hr>
<br>

<h3 id="2-delegatingfilterproxy">2. DelegatingFilterProxy</h3>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/7bfa1c32-c07b-4f64-b096-50464c10e945/image.png" alt=""></p>
<p>스프링은 필터를 구현한 <code>DelegatingFilterProxy</code> 를 제공하고,
<code>DelegatingFilterProxy</code>는 Spring과 서블릿 컨테이너를 연결해준다.
서블릿 컨테이너가 직접 <code>Spring Bean</code>을 알지 못하므로, <code>DelegatingFilterProxy</code>가 대신 <code>Spring Bean</code>으로 등록된 <code>필터(Filter)</code>를 찾아 실행한다.</p>
<blockquote>
<p>위의 그림은 DelegatingFilterProxy가 필터와 FilterChain에서 어떻게 작동하는지 보여주는 그림이다.
DelegatingFilterProxy는 ApplicationContext에서 Bean Filter0을 찾아 실행한다. </p>
</blockquote>
<hr>
<br>

<h3 id="3-filterchainproxy">3. FilterChainProxy</h3>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/4ae5afb9-9cdc-487a-b622-2572f502e4ee/image.png" alt=""></p>
<p><code>FilterChainProxy</code>는 Spring Security에서 중요한 역할을 하는 필터로, 여러 보안 필터를 관리하고 순차적으로 실행하는 데 사용된다. <code>SecurityFilterChain</code>을 통해 필터를 처리하고, 이를 <code>DelegatingFilterProxy</code>가 위임하여 실행하도록 한다.</p>
<hr>
<br>

<h3 id="4-securityfilterchain">4. SecurityFilterChain</h3>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/b87a1c26-9d69-49e8-aff8-f7f3f6990347/image.png" alt=""></p>
<p><code>SecurityFilterChain</code>은 <code>FilterChainProxy</code>에 의해 사용되어, 요청에 대해 호출해야 할 Spring Security 필터를 결정한다.
요청되는 URL에 따라 알맞는 <strong>SecurityFilterChain(0~n)</strong> 이 실행된다.</p>
<hr>
<br>

<h3 id="5handling-security-exceptions">5.Handling Security Exceptions</h3>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/c82644b1-83fb-4382-9d6f-543668b78135/image.png" alt=""></p>
<p><code>ExceptionTranslationFilter</code>는 <code>FilterChainProxy</code> 내의 <code>보안 필터(Security Filters)</code> 중 하나이며, 요청 중 발생하는 <strong>인증</strong> 및 <strong>권한</strong> 문제를 처리하고 적절한 <strong>응답</strong>을 반환하는 역할을 하는 필터이다. </p>
<h4 id="exceptiontranslationfilter의-동작">ExceptionTranslationFilter의 동작</h4>
<ol>
<li><p>요청 처리 시작</p>
<ul>
<li>요청(로그인)의 결과로 인증 실패나 권한 거부등이 발생할 수 있다.<br>
</li>
</ul>
</li>
<li><p>인증되지 않았거나 AuthenticationException이 발생한 경우</p>
<ul>
<li>이 단계는 사용자가 <strong>로그인하지 않았거나, 인증 정보가 잘못된 경우</strong>에 해당한다.<ul>
<li><code>AuthenticationEntryPoint</code> 호출하여 로그인 페이지로 리디렉션 하여 인증 정보가 필         요하다는 것을 클라이언트에게 알린다.<br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>AccessDeniedException이 발생한 경우</p>
<ul>
<li><p>이 단계는 사용자가 로그인은 되어 있지만, 해당 요청에 필요한 <strong>권한이 부족한</strong> 경우에 해당한다.</p>
</li>
<li><p><code>AccessDeniedHandler</code> 호출하여 접근 권한이 없다는 메시지를 클라이언트에게 보냅니        다.</p>
</li>
<li><p>웹페이지는 403, REST API에서는 403 응답 코드를 반환한다.</p>
<br>

</li>
</ul>
<ol start="4">
<li>예외가 없는 경우<ul>
<li>요청 처리 과정에서 <code>AccessDeniedException</code>이나 <code>AuthenticationException</code>이 발생하지 않으면, ExceptionTranslationFilter는 아무 작업도 하지 않고 요청 처리를 정상적으로 마친다..</li>
</ul>
</li>
</ol>
</li>
</ol>
<blockquote>
<p>쉽게 말하자면 <code>ExceptionTranslationFilter</code>를 <strong>보안 수문장</strong>이라고 생각할 수 있다:</p>
</blockquote>
<ul>
<li>수문장은 요청을 그대로 전달한다.(정상적인 경우).</li>
<li>만약 인증되지 않았거나 잘못된 인증 정보가 있다면, <strong>&quot;로그인하세요!&quot;</strong>라고 알려준다.</li>
<li>인증은 되었지만 권한이 부족한 경우, <strong>&quot;여기는 들어오면 안 돼요!&quot;</strong>라고 거절한다.</li>
<li>문제가 없으면 그냥 지나가게 한다.</li>
</ul>
<br>
---

<h2 id="spring-security-시작하기">Spring Security 시작하기</h2>
<h3 id="빈에-등록하기">빈에 등록하기</h3>
<p>스프링 시큐리티는 <strong>Gradle</strong> 기준으로 build.gradle에 아래와 같이 설정하면 스프링 부트가 자동으로 빈으로 등록하여 사용할 수 있다.</p>
<pre><code class="language-java">dependencies{
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
}</code></pre>
<hr>
<h2 id="securityconfig--커스텀하기">SecurityConfig  커스텀하기</h2>
<h3 id="💡securityconfig란">💡SecurityConfig란?</h3>
<blockquote>
<p>Spring Security 환경 설정을 위한 클래스이다.</p>
</blockquote>
<p>SecurityConfig를 구성하기 위해서는 두 가지 <strong>애너테이션</strong>이 필요하다. 이를 통해 이 클래스가 Spring Security의 보안 설정을 정의한 클래스임을 나타낼 수 있다.</p>
<ul>
<li><p><code>@EnableWebSecurity :</code> 
SpringConfig 클래스에 <code>@EnableWebSecurity</code> 애너테이션을 사용하면 해당 클래스가 Spring Security의 보안 설정을 담당하는 설정 클래스로 인식된다. 이 애너테이션을 사용함으로써 Spring Security의 보안 관련 설정을 활성화할 수 있다.</p>
</li>
<li><p><code>@Configruation</code> : </p>
</li>
<li><p><em>Spring 설정 클래스*</em>를 <strong>정의</strong>할 때 사용된다. 이 애너테이션을 붙인 클래스는 Spring 컨테이너의 <strong>빈(Bean)</strong> 으로 등록되며, 이 클래스에서 정의된 <strong>@Bean</strong> 메서드들을 <strong>Spring 컨테이너</strong>에 등록할 수 있다.
즉, 보안 설정 클래스의 필터 체인, 인증 매니저(AuthenticationManager), CORS 설정 등과 같은 모든 보안 관련 설정을 Spring 컨테이너가 관리할 수 있도록 등록한다.</p>
</li>
</ul>
<pre><code class="language-java">@Configuration        // Spring 설정 클래스를 정의 
@EnableWebSecurity  // Spring Security 설정 활성화
public class SecurityConfig
</code></pre>
<br>

<h3 id="커스텀하는-두가지-방법">커스텀하는 두가지 방법</h3>
<p>스프링 시큐리티에서는 보안을 구성하기 위한 두가지 주요 방법이 존재한다.바로</p>
<ol>
<li><strong>WebSecurityConfigurerAdapter</strong> 상속받기</li>
<li><strong>SecurityFilterChain</strong>을 이용한 <strong>Bean 방식</strong>이다.</li>
</ol>
<p>두 방식 모두 보안 설정을 정의하는 데 사용되지만, 방식과 구현 방법에 차이가 있다.</p>
<h3 id="🔻websecurityconfigureradapter">🔻WebSecurityConfigurerAdapter</h3>
<ul>
<li><p>5.7 버전 이전에 스프링 시큐리티에서 사용되던 기존의 보안 설정 방식이다. </p>
</li>
<li><p>개발자는 <code>WebSecurityConfigurerAdapter</code>를 <strong>상속</strong>받고 클래스에서 제공하는 여러 메서드 (예: configure(HttpSecurity http))를 <strong>@Override</strong>하는 방식으로 다양한 보안 설정을 커스텀 구성한다.</p>
</li>
<li><p>상속 구조에 의존하기 때문에 확장이 제한적일 수 있다.</p>
</li>
</ul>
<p><strong>예시 코드</strong></p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers(&quot;/public/**&quot;).permitAll()
                .anyRequest().authenticated()
            .and()
            .formLogin();
    }
}</code></pre>
<br>

<h3 id="🔻securityfilterchain권장">🔻SecurityFilterChain(권장)</h3>
<p>Spring Security 5.7 이상에서는 WebSecurityConfigurerAdapter을 지양하고 <strong>SecurityFilterChain</strong>을 사용하는 방식이 <strong>권장</strong>된다.
이 방식은 <code>@Bean</code>을 이용하여 보안 설정을 선언적으로 정의한다.</p>
<h4 id="bean방식의-장점">@Bean방식의 장점</h4>
<ul>
<li><p>유연성 :  여러 구성 요소를 조합하거나 조건에 따라 설정할 수 있다.</p>
</li>
<li><p>상속의 종속성 제거 : 기존에 <code>WebSecurityConfigurerAdapter</code> 를 반드시 상속해야 했기 때문에 상당히 의존적이었다. 하지만 <code>@Bean</code>을 통해 클래스를 독립적으로 관리할 수 있다.</p>
</li>
</ul>
<ul>
<li><p>Spring과 자연스럽게 통합 :   @Bean으로 정의된 보안 구성 요소들은  스프링의 IoC 컨테이너에서 관리를 하기 때문에 스프링의 설정 관행에 맞게 자연스럽게 통합됩니다.</p>
</li>
<li><p>테스트 용이성 : 설정이 각 메서드에 분리되어 작성되므로, 특정 설정을 테스트하거나 수정하기가 간단하다.</p>
</li>
</ul>
<p><strong>예시 코드</strong></p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -&gt; auth
                .requestMatchers(&quot;/public/**&quot;).permitAll()
                .anyRequest().authenticated())
            .formLogin();
        return http.build();
    }
}</code></pre>
<br>

<h4 id="✨컴포넌트-기반-아키텍처로의-발전">✨컴포넌트 기반 아키텍처로의 발전</h4>
<p>Spring은 <strong>의존성 주입(Dependency Injection)</strong> 과 <strong>컴포넌트 기반 개발</strong>을 핵심 철학으로 삼고 있기 때문에 Spring Security 또한 점점 컴포넌트 기반 아키텍처를 강조하며 발전했다.</p>
<p>기존 <code>WebSecurityConfigurerAdapter</code> 방식은 클래스 상속에 의존했기 때문에 보안 설정을 커스터마이징하기 위해 추상 메서드를 오버라이드해야 했다. 그리고 상속 구조 때문에 다중 상속이 불가능했다. 
예를 들어 클래스가 이미 다른 클래스를 상속받고 있다면<code>WebSecurityConfigurerAdapter</code>를 상속할 수 없는 한계가 있었다. 또한 보안 설정이 다른 컴포넌트와 강하게 결합되거나 재사용이 어려웠다.
<br>반면, <code>SecurityFilterChain</code>은 상속 대신 구성(Composition) 방식을 사용하여 보안 설정을 유연하게 정의한다.
컴포넌트 기반으로 보안 설정을 관리하고, 각 보안 설정이 독립적인 빈(Bean)으로 관리되므로 다중 필터 체인 정의나 재사용이 훨씬 용이하다. </p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS] AWS 가입하기]]></title>
            <link>https://velog.io/@sunset_1839/AWS-AWS-%EA%B0%80%EC%9E%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/AWS-AWS-%EA%B0%80%EC%9E%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 10 Jan 2025 06:48:28 GMT</pubDate>
            <description><![CDATA[<h3 id="서론💡">서론💡</h3>
<p>프로젝트에서 배포와 CI/CD를 담당하기로 하였다. CD는 경험이 없고,배포와 CI는 교육에서 빠르게 맛만 본 느낌이라 가입부터 배포, CI/CD까지 공부한 내용을 글로 정리하여 공부하고자 한다.</p>
<h5 id="강의-구입">강의 구입</h5>
<p>처음 교육에서 AWS를 설명 들었을 때에는 쉽게 이해가 되지 않았고, 스스로 구글링을 하며 배우기에는 자신이 없었다. 때문에 인프런에서 블랙프라이데이 행사때 눈여겨 보던 박재성님의 AWS와 CI/CD 강의를 구입하였다.</p>
<ul>
<li><a href="https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-%EC%9D%B4%ED%95%B4%ED%95%A0%EC%88%98%EC%9E%88%EB%8A%94-aws-%EC%9E%85%EB%AC%B8%EC%8B%A4%EC%A0%84/dashboard">비전공자도 이해할 수 있는 AWS 입문/실전</a></li>
<li><a href="https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-ci-cd-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84/dashboard">비전공자도 이해할 수 있는 CI/CD 입문·실전</a> </li>
</ul>
<p>앞으로 강의 내용과 서적, 구글링을 통해 배운내용을 토대로 진행하려고한다.</p>
<hr>
<h3 id="사전-준비">사전 준비</h3>
<ul>
<li>이메일 </li>
<li>신용카드</li>
<li>전화번호</li>
</ul>
<hr>
<h2 id="프리티어-계정-만들기">프리티어 계정 만들기</h2>
<h4 id="프리티어-계정-무한-생성하기">프리티어 계정 무한 생성하기</h4>
<p>AWS에 가입하는 새로운 계정은 1년간 프리티어 혜택을 받을 수 있다.
하지만 나는 전에 쓰던 이메일 계정은 탈퇴를 하였고, 탈퇴한 계정은 다시 재가입이 안된다는 것을 알았다.
이때 새로운 계정을 계속해서 생성하여 프리티어 혜택을 받을 수 있는 방법을 찾았다.
AWS는 <strong>메일 계정</strong>을 기준으로 계정이 생성된다. 때문에 카드나 전화번호가 동일해도 이메일만 다르다면 무제한 무료 사용이 가능하다.
이때 Gmail을 통해 계정을 만드는 글을 발견했다.</p>
<ul>
<li><a href="https://montreeho.com/entry/%EB%94%B1-5%EB%B6%84%EB%A7%8C%EC%97%90-%EC%95%84%EB%A7%88%EC%A1%B4-aws-%ED%8F%89%EC%83%9D-%EB%AC%B4%EB%A3%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0">딱 5분만에 아마존 aws평생 사용하기</a></li>
</ul>
<hr>
<h4 id="상세-정보-입력">상세 정보 입력</h4>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/c0aa4632-4ffa-470f-a3fe-e09c59d398f7/image.png" alt=""></p>
<p>이 단계에서 중요한 부분은</p>
<ul>
<li><strong>이름</strong> : 본인의 이름을 영문으로 작성</li>
<li><strong>국가코드</strong> : 국가코드(82:대한민국) 포함한 전화번호를 입력합니다.</li>
<li><strong>국가 또는 리전</strong> : 국가를 선택합니다. 순서는 섞여있어 찾기는 어렵지만 중간쯤에 케냐,쿠웨이트 사이에 <strong>대한민국</strong>이 있습니다.</li>
<li><strong>주소</strong>: 영문 주소를 입력합니다.
<a href="https://www.juso.go.kr/openIndexPage.do">한글 주소 영문 변환 사이트</a></li>
</ul>
<hr>
<h4 id="결제-정보-입력">결제 정보 입력</h4>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/ef04a1d4-cc69-4dcf-b8af-9da97f2a6fc6/image.png" alt=""></p>
<p>프리티어로 EC2,RDS,S3등 주요 서비스들을 무료로 사용할 수 있다. 하지만, <strong>프리티어를 초과</strong>하는 부분에 대해서는 서비스 마다 다른 요금이 청구되기 때문에 유의해야한다.</p>
<hr>
<h4 id="sms전화-인증">SMS/전화 인증</h4>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/ea323ea8-0860-4b82-b817-fdfee2a8d44b/image.png" alt=""></p>
<p>이번에도 국가는 대한민국으로, 전화번호는 010으로 시작하여 입력하면 된다.</p>
<hr>
<h4 id="support-플랜-선택">Support 플랜 선택</h4>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/0c0a9213-b3b0-4a0d-8bac-0ee46df23b40/image.png" alt=""></p>
<p>AWS는 사용자들을 위해 다양한 지원을 하고 있으며, 지원들 마다 가격이 다르다. 나는 프리티어를 사용 할려고 하고, 나중에 변경할 수도 있기 때문에 기본 지원을 선택하면 된다.</p>
<hr>
<h4 id="로그인">로그인</h4>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/fd06f7d5-2ad7-4e8c-b086-0a410a501011/image.png" alt=""></p>
<p>처음 로그인을 시도하면 두가지 로그인 방식이 존재한다.</p>
<h4 id="1--aws-루트-사용자">1.  AWS 루트 사용자</h4>
<p><strong>AWS 계정을 처음 생성할 때 사용된 이메일 주소와 비밀번호로 로그인하는 사용자이다.</strong></p>
<p>&lt;특징&gt;</p>
<ul>
<li>AWS 계정의 모든 권한을 가지고 있다.  </li>
<li>IAM 정책이나 권한 제약 없이 AWS 리소스에 접근 가능하다.</li>
<li>매우 강력하기 때문에 보안 위험이 크다.</li>
</ul>
<h4 id="2-iam-사용자">2. IAM 사용자</h4>
<p><strong>AWS 루트 사용자가 생성한 개별 사용자 계정으로, 특정 권한을 가진 계정입니다.</strong></p>
<p>&lt;특징&gt;</p>
<ul>
<li>IAM(Identity and Access Management)을 통해 만들어진 사용자.</li>
<li>루트 사용자와 달리 필요한 권한만 부여받아 사용합니다.</li>
<li>IAM 정책을 통해 접근할 수 있는 AWS 서비스와 리소스를 제한할 수 있습니다.</li>
<li>보안 그룹 작업, 특정 서비스 이용 등 제한된 범위에서 사용.</li>
</ul>
<p>AWS의 권고사항을 보면, 루트 사용자를 일반적으로 사용하지 말 것을 강력하게 권고하고 있다
왜냐하면 root계정은 모든 권한을 가지고 있기 때문에 해킹시 금전적으로 막대한 손해를 입을 가능성이 높다. 
즉, <strong>루트 계정을 만들고 IAM 관리자 계정을 생성한 후 IAM 사용자로 이용하는 것을 추천한다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 2667 문제 <단지번호붙이기>]]></title>
            <link>https://velog.io/@sunset_1839/%EB%B0%B1%EC%A4%80-2667-%EB%AC%B8%EC%A0%9C-%EB%8B%A8%EC%A7%80%EB%B2%88%ED%98%B8%EB%B6%99%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sunset_1839/%EB%B0%B1%EC%A4%80-2667-%EB%AC%B8%EC%A0%9C-%EB%8B%A8%EC%A7%80%EB%B2%88%ED%98%B8%EB%B6%99%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Thu, 09 Jan 2025 16:08:59 GMT</pubDate>
            <description><![CDATA[<h2 id="서론💡">서론💡</h2>
<p>우테코 선발과정이 끝나고 오랜만에 다시 알고리즘을 시작하였다. 알고리즘 스터디를 만들었고, 하루에 최소 한문제씩 문제를 해결하는 것이 목표이다.
DFS와 BFS를 공부하다가 알고리즘을 중단하였기에 블로그에 정리해둔 글과 강의를 통하여 잃어버린 감을 다시 찾으려고 노력하였다.
하지만 <code>백준 2667</code> 번 문제는 내가 처음 겪은 형식이였고, 많은 고민 끝에 문제를 해결하지 못하고 강의를 통해 해결하였다.</p>
<p><a href="https://youtu.be/SLZ3Tqvgurg?si=X8Klfh1AYsWtLjW6">유튜브 강의 : 단지 번호 붙이기_자바 (백준 2667)</a></p>
<p>다른 사람들의 해설과 강의를 통해 약간의 힌트를 얻고 나머지는 스스로 해결하고자 했지만 생각보다 쉽지 않았다.😅 </p>
<p>총 두가지 방식의 코드로 해결하였고, 처음 코드는 내가 힌트를 얻고 최초로 해결한 코드이며, 두번쨰 코드는 복습을 위해 다른 방식으로 해결하였다.</p>
<hr>
<h3 id="문제">문제</h3>
<blockquote>
<p>&lt;그림 1&gt;과 같이 정사각형 모양의 지도가 있다. 1은 집이 있는 곳을, 0은 집이 없는 곳을 나타낸다. 철수는 이 지도를 가지고 연결된 집의 모임인 단지를 정의하고, 단지에 번호를 붙이려 한다. 여기서 연결되었다는 것은 어떤 집이 좌우, 혹은 아래위로 다른 집이 있는 경우를 말한다. 대각선상에 집이 있는 경우는 연결된 것이 아니다. &lt;그림 2&gt;는 &lt;그림 1&gt;을 단지별로 번호를 붙인 것이다. 지도를 입력하여 단지수를 출력하고, 각 단지에 속하는 집의 수를 오름차순으로 정렬하여 출력하는 프로그램을 작성하시오.
<img src="https://velog.velcdn.com/images/sunset_1839/post/c0c0b474-2675-4b13-8bcd-570169f8acfa/image.png" alt=""></p>
</blockquote>
<br>

<h3 id="입력">입력</h3>
<blockquote>
<p>첫 번째 줄에는 지도의 크기 N(정사각형이므로 가로와 세로의 크기는 같으며 5≤N≤25)이 입력되고, 그 다음 N줄에는 각각 N개의 자료(0혹은 1)가 입력된다.</p>
</blockquote>
<h3 id="출력">출력</h3>
<blockquote>
<p>첫 번째 줄에는 총 단지수를 출력하시오. 그리고 각 단지내 집의 수를 오름차순으로 정렬하여 한 줄에 하나씩 출력하시오.</p>
</blockquote>
<h3 id="예제-입력">예제 입력</h3>
<blockquote>
<p>7
0110100
0110101
1110101
0000111
0100000
0111110
0111000</p>
</blockquote>
<h3 id="예제-출력">예제 출력</h3>
<blockquote>
<p>3
7
8
9</p>
</blockquote>
<hr>
<h2 id="풀이🔻">풀이🔻</h2>
<p>문제에서 가장 중요한 포인트를 생각해보자</p>
<ul>
<li>1은 집이 있는 곳을, 0은 집이 없는 곳을 나타낸다.</li>
<li>연결되었다는 것은 어떤 집이 <strong>좌우, 혹은 아래위</strong>로 다른 집이 있는 경우를 말한다. 대각선상에 집이 있는 경우는 연결된 것이 아니다. </li>
</ul>
<p>처음 문제를 보았을 때 2차원 배열을 활용해서 각 배열의 값이 <strong>1</strong>이냐 <strong>0</strong>이냐를 판단하고자 했다.</p>
<pre><code class="language-java">    static int[][] complex; //1과 0을 저장할 이차원배열


    complex = new int[N][N]; //지도 초기화

    //단지 입력
    String[] line = new String[N];
        for (int i = 0; i &lt; N; i++) {
            String str = br.readLine();
            for (int j = 0; j &lt; N; j++) {
                //한줄씩 입력받아 1 혹은 0을 넣는다
                complex[i][j] = Character.getNumericValue(str.charAt(j)); 
            }
        }</code></pre>
<pre><code class="language-java"></code></pre>
<p>그리고 만약 값이 <strong>1</strong> 이라면 주변의(상하좌우) 값이 1인지, 0인지 확인해야 함을 인지했다.
<strong>좌우, 혹은 아래위</strong>를 이동하기 위해 X좌표 배열과 Y좌표 배열을 사용하였다.</p>
<pre><code class="language-java">    static int[] x = {0, 0, 1, -1};
    static int[] y = {1, -1, 0, 0};</code></pre>
<p>만약 0이라면 다음 배열 위치로 넘어가고, 주변에 1이 있다면 그 위치에 <strong>DFS</strong>를 통하여 진입하고, 또 그 위치에서 주변에 1이 있는지 판단하여 <strong>재귀적</strong>으로 DFS를 호출한다.</p>
<pre><code class="language-java">         //각 단지의 개수를 저장하기 위한 ArrayList를 하나 생성
        ArrayList&lt;Integer&gt; countList = new ArrayList&lt;&gt; ();
        for (int i = 0; i &lt; N; i++) {
            for (int j = 0; j &lt; N; j++) {
                if (complex[i][j] == 1) { //만약 현재 위치의 값이 1이라면
                    countDanji = 0; 
                    dfs(i, j); //dfs시작
                    countList.add(countDanji); //dfs종료후 리스트에 추가
                }
            }
        }
</code></pre>
<br>

<p>가장 핵심 로직인 <code>DFS메서드</code> 이다.</p>
<ol>
<li>현재위치를 <strong>방문</strong>했다는 것을 기록하기 위해 현재 배열값을 <strong>1 -&gt; 0</strong>으로 바꾸어준다<ol start="2">
<li>단지의 개수를 1증가 시킨다.</li>
<li>상하좌우가 1인지 0인지 판단하기 위해 현재 배열위치에서 </li>
</ol>
</li>
</ol>
<p> <strong>(-1,0),(1,0),(0,-1),(0,1)</strong> 해주며 만약 조건에 맞는다면 DFS 메서드를 <strong>재귀호출</strong>한다.</p>
<pre><code>  java
   static void dfs(int i, int j) {
        complex[i][j] = 0;
        countDanji++;
        for (int k = 0; k &lt; 4; k++) {
            int newX = i + x[k];
            int newY = j + y[k];
            // 유효성 검사: 범위를 벗어나지 않고 값이 1인지 확인
            if (newX &gt;= 0 &amp;&amp; newX &lt; complex.length &amp;&amp; newY &gt;= 0 &amp;&amp; newY &lt; complex[0].length &amp;&amp; complex[newX][newY] == 1) {
                dfs(newX, newY);
            }
        }
    }</code></pre><hr>
<h3 id="전체코드">전체코드</h3>
<h4 id="1번-코드">1번 코드</h4>
<ul>
<li>단지 배열을 <strong>1과 0으로 채움</strong></li>
<li>방문 기록을 단지 배열의 값을 <strong>1 -&gt; 0</strong>로 바꾸는 것으로 <strong>판단</strong><ul>
<li>BufferedWriter를 사용 </li>
</ul>
</li>
</ul>
<pre><code class="language-java">import java.io.*;
import java.util.ArrayList;
import java.util.Collections;

public class B_2667 {

    static int[][] complex;
    static int countDanji;
    static int[] x = {0, 0, 1, -1};
    static int[] y = {1, -1, 0, 0};

    public static void main(String[] args) throws IOException{

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

        //지도 크기 입력
        int N = Integer.parseInt(br.readLine());

        //지도 초기화
        complex = new int[N][N];
        //visit 초기화

        //지도 입력
        String[] line = new String[N];
        for (int i = 0; i &lt; N; i++) {
            String str = br.readLine();
            for (int j = 0; j &lt; N; j++) {
                complex[i][j] = Character.getNumericValue(str.charAt(j));
            }
        }

        //돌면서 dfs;
        ArrayList&lt;Integer&gt; countList = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt; N; i++) {
            for (int j = 0; j &lt; N; j++) {
                if (complex[i][j] == 1) {
                    countDanji = 0;
                    dfs(i, j);
                    countList.add(countDanji);
                }
            }
        }

        //출력
        bw.write(countList.size()+&quot;\n&quot;);
        Collections.sort(countList);

        for (int i = 0; i &lt; countList.size(); i++) {
            bw.write(countList.get(i) +&quot;\n&quot;);
        }

        //bw,br 종료
        br.close();
        bw.flush();
        bw.close();
    }

    static void dfs(int i, int j) {
        complex[i][j] = 0;
        countDanji++;
        for (int k = 0; k &lt; 4; k++) {
            int newX = i + x[k];
            int newY = j + y[k];
            // 유효성 검사: 범위를 벗어나지 않고 값이 1인지 확인
            if (newX &gt;= 0 &amp;&amp; newX &lt; complex.length &amp;&amp; newY &gt;= 0 &amp;&amp; newY &lt; complex[0].length &amp;&amp; complex[newX][newY] == 1) {
                dfs(newX, newY);
            }
        }
    }

}</code></pre>
<hr>
<br>

<h4 id="2번-코드">2번 코드</h4>
<ul>
<li>단지 배열을 <strong>boolean형으로 true와 false</strong>로 채움</li>
<li>방문 기록을 <strong>boolean형 visited배열을</strong> 통해 true와 false로 구분함 </li>
<li>단지 배열과 visited배열을 초기화 할 때 공간을 크게 할당하여 <code>Index out of bounds for length</code> 에러를 예방함<ul>
<li>System.out 사용 </li>
</ul>
</li>
</ul>
<pre><code class="language-java">import java.io.*;
import java.util.ArrayList;
import java.util.Collections;


public class B_2667_v2 {

    static boolean[][] visited;
    static boolean[][] complex;
    static int countPerDanji;
    static int[] dirX = {-1, 1, 0, 0};
    static int[] dirY = {0, 0, -1, 1};

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

        //지도 크기 입력
        int N = Integer.parseInt(br.readLine());

        //초기화
        visited = new boolean[N + 10][N + 10];
        complex = new boolean[N + 10][N + 10];

        //지도(complex) 초기값 설정
        for (int i = 1; i &lt;= N; i++) {
            //한줄씩 입력
            String str = br.readLine();
            for (int j = 1; j &lt;= N; j++) {
                if (str.charAt(j-1) == &#39;1&#39;) {
                    complex[i][j] = true;
                }
            }
        }
        br.close();

        ArrayList&lt;Integer&gt; danji = new ArrayList&lt;&gt;();
        for (int i = 1; i &lt;= N; i++) {
            for (int j = 1; j &lt;= N; j++) {
                if (complex[i][j] &amp;&amp; !visited[i][j]) {
                    countPerDanji = 0;
                    dfs(i, j);
                    danji.add(countPerDanji);
                }
            }
        }

        System.out.println(danji.size());
        Collections.sort(danji);
        for (int i = 0; i &lt; danji.size(); i++) {
            System.out.println(danji.get(i));
        }
    }

    //dfs 구현
    static void dfs(int x, int y) {
        visited[x][y] = true;
        countPerDanji++;

        for (int i = 0; i &lt; 4; i++) {
            int positionX = dirX[i] + x;
            int positionY = dirY[i] + y;
            if (!visited[positionX][positionY] &amp;&amp; complex[positionX][positionY]) {
                dfs(positionX, positionY);
            }
        }
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[OAuth2.0] 자동 회원가입 구현 시 발생한 문제 트러블슈팅]]></title>
            <link>https://velog.io/@sunset_1839/OAuth2.0-%EC%9E%90%EB%8F%99-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B5%AC%ED%98%84-%EC%8B%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-%EB%AC%B8%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@sunset_1839/OAuth2.0-%EC%9E%90%EB%8F%99-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B5%AC%ED%98%84-%EC%8B%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-%EB%AC%B8%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Thu, 02 Jan 2025 12:13:32 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>SpringSecurity 및 JWT 강의를 보며 소셜로그인을 구현중이였다. 
우선 강의 내용을 실습을 통해 구현해보고, 현재 진행 중이던 팀프로젝트에 적용하면서 생겼던 문제들을 분석하고 해결했던 과정을 기록하고자 한다.</p>
<hr>
<h3 id="민감정보가-담긴-yml-파일-관리">민감정보가 담긴 yml 파일 관리</h3>
<h4 id="이슈">이슈:</h4>
<p>구글,네이버,카카오등 클라이언트 정보등이 담긴 민감정보를 깃에 안올리면서 팀원들과 공유하는 방법이 필요</p>
<h4 id="해결-방안">해결 방안:</h4>
<ul>
<li><p>gitignore 파일에 yml 파일을 추가하여 Git에 포함되지 않도록 설정.</p>
</li>
<li><p>팀원 간 수동으로 yml 파일을 공유하고 로컬 환경에서 배포.</p>
<blockquote>
<p>매번 수동으로 yml파일을 관리하는 것은 비효율 적으로 생각했고,우리 팀은 서브모듈을 활용하여 yml파일을 관리하는 private repository를 하나 개설하였다. 이 외에도 환경변수로 파일을 관리하는 방법등이 있다고 들어 추후 공부할 예정이다.</p>
</blockquote>
</li>
</ul>
<hr>
<h3 id="oauth20-client_id공유로-인한-에러">OAuth2.0 Client_id공유로 인한 에러</h3>
<h4 id="이슈-1">이슈:</h4>
<ul>
<li>실습에 썼던 구글,네이버,카카오의 애플리케이션의 Client_Id와 Client_Secret 설정을 프로젝트에도 동일하게 사용하였더니 에러가 발생했다. 이유를 몰라 많이 곤란한 문제였다. </li>
<li>소셜로그인 창으로 이동하지 않는다.</li>
<li>콘솔에도 에러 원인이 나오지 않는다. </li>
</ul>
<h4 id="해결방안">해결방안:</h4>
<ul>
<li>프로젝트마다 각 소셜 플랫폼의 애플리케이션을 새로 설정해준다.</li>
</ul>
<h4 id="💡궁금점">💡궁금점:</h4>
<p>2개의 프로젝트에서 동일한 리디렉션 url을 설정 한다면, OAuth에서는 동일한 프로젝트로 인식하고 처리해주지 않을까 싶었던 것이 나의 생각이였다. 하지만 생각과 달리 에러가 발생하였고, 
해당 이유를 추후에 공부하여 정리해야겠다.</p>
<hr>
<h3 id="스프링-시큐리티-설정-에러">스프링 시큐리티 설정 에러</h3>
<h4 id="이슈-2">이슈:</h4>
<ul>
<li>구글,네이버,카카오로 나뉜 로그인 설정을 구현하기 위해 <code>.oauth2Login()</code>을 여러번 사용하여 해결하고자 했으나 에러가 발생하였다. </li>
<li>404에러, 회원가입이 되지 않는등 다양한 에러가 발생하였다.</li>
<li>콘솔 로그에서도 나오지 명확한 에러 원인을 찾지 못했었다.</li>
</ul>
<p>[기존코드]</p>
<pre><code>.oauth2Login(oauth -&gt; oauth
                        .loginProcessingUrl(&quot;/login/oauth2/code/google&quot;)  // 구글 로그인 URL 처리
                        .userInfoEndpoint(userinfo -&gt; userinfo
                                .userService(principalOauth2UserService)))

 .oauth2Login(oauth -&gt; oauth
                        .loginProcessingUrl(&quot;/login/oauth2/code/naver&quot;)  // 네이버 로그인 URL 처리
                        .userInfoEndpoint(userinfo -&gt; userinfo
                                .userService(principalOauth2UserService)))

.oauth2Login(oauth -&gt; oauth
                        .loginProcessingUrl(&quot;/login/oauth2/code/kakao&quot;)  // 카카오 로그인 URL 처리
                        .userInfoEndpoint(userinfo -&gt; userinfo
                                .userService(principalOauth2UserService)))</code></pre><h3 id="해결방안-1">해결방안:</h3>
<ul>
<li><p>핵심은 <code>.oauth2Login()</code> 메서드가 중복 호출될 때 발생하는 구성 충돌이라고 한다. Spring Security에서 .oauth2Login()은 OAuth2 로그인 흐름을 정의하는 메서드이다. 그러나 이 메서드는 <strong>한 번만</strong> 호출되어야 하고, 모든 OAuth2 프로바이더를 통합적으로 처리해야 한다.</p>
</li>
<li><p>loginProcessingUrl 설정 통합: 
각 소셜 로그인별 loginProcessingUrl을 하나로 통합하고, <code>{registrationId}</code>를 사용하여 각 소셜 로그인에 맞게 처리하도록 했다. Spring Security는 <code>{registrationId}</code> 값을 <strong>자동</strong>으로 채워주기 때문에, 개별 로그인 경로를 지정할 필요는 없다.</p>
<pre><code>      .oauth2Login(oauth -&gt; oauth
                      .userInfoEndpoint(userinfo -&gt; userinfo
                              .userService(principalOauth2UserService))  // PrincipalOauth2UserService 사용

                      // 각 소셜 로그인 경로에 대해 공통으로 처리
                      .loginProcessingUrl(&quot;/login/oauth2/code/{registrationId}&quot;)  // Spring Security가 자동으로 {registrationId}에 해당하는 로그인 URL 처리
                      .defaultSuccessUrl(&quot;/home&quot;, true)  // 로그인 후 리디렉션되는 페이지

              )</code></pre></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT - Session]]></title>
            <link>https://velog.io/@sunset_1839/JWT-Session</link>
            <guid>https://velog.io/@sunset_1839/JWT-Session</guid>
            <pubDate>Wed, 01 Jan 2025 06:43:12 GMT</pubDate>
            <description><![CDATA[<h2 id="세션이란">세션이란?</h2>
<p><strong>세션(Session)</strong>은 클라이언트와 서버 간의 상태를 유지하기 위한 기술이다. 클라이언트가 서버에 접속해서 통신을 시작한 시점부터 종료할 때까지의 기간 또는 그 기간 동안 서버에서 클라이언트를 식별하고 상태를 유지하는 방식이다.</p>
<br>

<hr>
<h2 id="세션의-주요-특징">세션의 주요 특징</h2>
<p><strong>1. 상태 유지</strong></p>
<ul>
<li>HTTP는 무상태(Stateless) 프로토콜이기 때문에, 기본적으로 클라이언트와 서버 간의 이전 요청 상태를 기억하지 않는다.</li>
<li>세션은 클라이언트를 식별해 서버가 클라이언트 상태를 유지하도록 도와준다.</li>
</ul>
<p><strong>2. 서버에 저장</strong></p>
<ul>
<li>세션 정보는 보통 서버의 메모리나 데이터베이스에 저장된다.</li>
<li>클라이언트는 세션 ID를 쿠키나 URL 파라미터 등을 통해 서버로 전송한다.</li>
</ul>
<p><strong>3. 유효 기간</strong></p>
<ul>
<li>세션은 기본적으로 유효 기간(예: 30분)이 있으며, 유효 기간 동안 클라이언트와 서버의 상태를 유지한다.</li>
<li>유효 기간이 지나면 세션 정보는 삭제된다.</li>
</ul>
<hr>
<h2 id="세션의-구성-요소">세션의 구성 요소</h2>
<p><strong>1. 세션 ID</strong></p>
<ul>
<li>세션을 식별하는 고유한 키.</li>
<li>보통 랜덤 문자열로 생성되어 클라이언트에 전달된다.</li>
</ul>
<p><strong>2. 세션 저장소</strong></p>
<ul>
<li>서버에서 세션 데이터를 저장하는 공간.</li>
<li>메모리, 데이터베이스, 파일 시스템 등을 사용한다.</li>
</ul>
<p><strong>3. 쿠키</strong></p>
<ul>
<li>세션 ID를 저장하고 클라이언트가 서버에 요청할 때마다 이를 전송하는 역할.</li>
</ul>
<hr>
<h2 id="세션의-동작-원리">세션의 동작 원리</h2>
<p>[로그인을 통한 세션 동작 구조]
<img src="https://velog.velcdn.com/images/sunset_1839/post/05051c1a-c096-4993-b7c0-dd2ed009702a/image.png" alt=""></p>
<p><strong>1. 클라이언트가 서버로 요청을 보낸다</strong></p>
<ul>
<li>주소, 로그인등을 클라이언트가 서버로 요청한다.<br>

</li>
</ul>
<p><strong>2. 서버는 해당 요청이 최초인지 판단한다.</strong></p>
<ul>
<li>해댕 요청에 대한 응답하는 메서드가 존재하는지 확인한다.</li>
<li>최초 요청이면 세션ID를 생성하고 메모리나 데이터베이스에 저장한다.</li>
<li>최초 요청이 아니라면 Client에게 전달받은 SessionId를 메모리나 데이터베이스에서 찾는다.<br>

</li>
</ul>
<p><strong>3. 클라이언트한테 세션ID를 전달한다.</strong>
<img src="https://velog.velcdn.com/images/sunset_1839/post/b3a20ec7-a366-4b58-b77b-5a7acbcfa998/image.png" alt=""></p>
<ul>
<li><p>서버는 HTTP Header 안에 쿠키 안에 세션 ID를 담아 클라이언트에 전달한다. </p>
</li>
<li><p>세션 ID는 일반적으로 쿠키를 통해 클라이언트의 브라우저에 저장된다.</p>
<br>

</li>
</ul>
<p><strong>4. 다음 요청 부터 Client는 Header안 쿠키에 세션ID를 들고 간다.</strong></p>
<ul>
<li>클라이언트는 이후 요청마다 세션 ID를 서버로 전송한다.</li>
<li>서버는 세션 ID를 확인하여 저장된 세션 정보를 로드한다.<br>

</li>
</ul>
<p><strong>5. 세션 종료</strong></p>
<ul>
<li>세션의 유효 기간이 만료, 사용자의 로그아웃, 사용자 브라우저 종료등을 하면 세션이 종료되고, 서버는 해당 세션 데이터를 삭제한다.</li>
</ul>
<hr>
<h2 id="세션과-로드밸런싱">세션과 로드밸런싱</h2>
<p><img src="https://velog.velcdn.com/images/sunset_1839/post/e27e6108-729b-4f5b-93b3-21d70f548f97/image.png" alt=""></p>
<p>만약 <code>서버1</code>이 동시 요청을 100개까지 정상적으로 처리한다고 했을때 101번째 부터는 요청을 처리하는 속도가 느려질 것이다. 그때 101번째 요청부터 <code>서버2</code>로 요청을 넘겨 분담하는 것이 로드 밸런싱이다.</p>
<h3 id="궁금증">궁금증</h3>
<ol>
<li>Client가 <code>서버1</code>에 요청을 보낸다면 <code>서버1</code> 안에서 세션ID를 생성,저장 후에 Client한테 header안의 쿠키를 통하여 세션ID를 담아 보낼 것이다.</li>
<li>만약 Client가 요청을 보냈는데 로드 밸런싱으로 인해 <code>서버2</code>로 세션ID를 보내면 <code>서버2</code>는 해당 세션ID를 저장하고 있지 않기 때문에 최초의 요청으로 받아들일것이다.</li>
</ol>
<p>이 문제를 어떻게 해결할까?</p>
<hr>
<h3 id="해결방안">해결방안</h3>
<h3 id="1-sticky-session-사용">1. Sticky Session 사용</h3>
<p>로드밸런서가 세션 기간 동안 동일한 클라이언트의 request를 항상 <strong>동일한 서버</strong>로 라우팅 해주는 기능이다.</p>
<p>예를 들어, Client가 1번부터 3번까지의 서버 중 1번 서버에 세션을 생성하였다면, 이후에 Client가 보내는 모든 요청은 1번 서버로만 보내지게 된다. 즉, Load Balancer는 User가 첫 번째 세션을 생성한 서버로 모든 요청을 리다이렉트 하여 고정된 세션만 사용하게 한다.</p>
<p><strong>장점</strong></p>
<ul>
<li>서버들간의 세션 데이터 교환을 할 필요가 없다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>특정 서버에 과부하가 발생할 수 있다.</li>
<li>트래픽이 균등하게 배분될 수 없다.</li>
<li>특정 서버의 사람들만 활발히 활동할 경우 해당 서버만 과부하가 걸린다.</li>
</ul>
<hr>
<h3 id="2-session-storage-사용">2. Session Storage 사용</h3>
<p>세션을 저장하는 공간을 의미한다. 각 서버는 세션 스토리지를 공유하여 사용한다.
많은 사람들이 이 방식을 채택한다고 한다.</p>
<p><strong>장점</strong></p>
<ul>
<li>Sticky Session처럼 트래픽이 몰리는 현상을 방지할 수 있다.</li>
<li>서버가 증설되더라도 각 서버에 세션 스토리지의 정보만을 입력하면 사용가능하다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>세션 스토리지에 문제가 생기면 모든 세션이 이용이 불가하다.</li>
</ul>
<hr>
<h3 id="3-세션-복제">3. 세션 복제</h3>
<p>하나의 세션 저장소에서 변경이 발생하면 변경 사항이 모든 다른 세션에 복제를 하는 것이다.</p>
<p><strong>장점</strong></p>
<ul>
<li>세션을 복제한다면 클라이언트가 이후에 어떤 서버에 접속하더라도 로그인 정보가 세션에서 복제되어 있으므로 세션을 사용 가능하다.</li>
<li>장애가 발생하더라도 서비스가 중단 되지 않는다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>모든 서버가 동일한 세션 객체를 가져야 하기 때문에 많은 메모리가 필요하다.</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>