<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>junyoung-choe.log</title>
        <link>https://velog.io/</link>
        <description>라곰</description>
        <lastBuildDate>Mon, 30 Mar 2026 01:10:20 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>junyoung-choe.log</title>
            <url>https://velog.velcdn.com/images/junyoung-choe/profile/e8d7ca03-cf72-4c3a-99db-c1670f633b74/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. junyoung-choe.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/junyoung-choe" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[ Qwen 모델 구축, 삽질의 연속]]></title>
            <link>https://velog.io/@junyoung-choe/Qwen-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%EC%B6%95-%EC%82%BD%EC%A7%88%EC%9D%98-%EC%97%B0%EC%86%8D</link>
            <guid>https://velog.io/@junyoung-choe/Qwen-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%EC%B6%95-%EC%82%BD%EC%A7%88%EC%9D%98-%EC%97%B0%EC%86%8D</guid>
            <pubDate>Mon, 30 Mar 2026 01:10:20 GMT</pubDate>
            <description><![CDATA[<p>3편에서 AWS 환경 세팅을 마쳤다.</p>
<p>이제 Qwen-Image-Edit-2511 모델을 실제로 올려보자.</p>
<p>생각보다 순탄하지 않았다.</p>
<hr>
<h2 id="모델-다운로드">모델 다운로드</h2>
<p>작업 폴더를 만들고 모델을 내려받는다.</p>
<p>모델 크기가 약 40GB라 시간이 좀 걸린다.</p>
<pre><code class="language-bash">mkdir -p ~/qwen-project
cd ~/qwen-project</code></pre>
<pre><code class="language-python">from huggingface_hub import snapshot_download

snapshot_download(
    repo_id=&quot;Qwen/Qwen-Image-Edit-2511&quot;,
    local_dir=&quot;./model&quot;,
    local_dir_use_symlinks=False,
    resume_download=True
)</code></pre>
<p><code>resume_download=True</code> 덕분에 중간에 끊겨도 이어받을 수 있다 !</p>
<hr>
<h2 id="1차-시도--device_mapcuda">1차 시도 — device_map=&quot;cuda&quot;</h2>
<p>모델을 GPU에 전부 올리는 방식으로 시작했다.</p>
<pre><code class="language-python">pipeline = QwenImageEditPlusPipeline.from_pretrained(
    &quot;Qwen/Qwen-Image-Edit-2511&quot;,
    torch_dtype=torch.float16,
    device_map=&quot;cuda&quot;,
    low_cpu_mem_usage=True
)</code></pre>
<p>바로 에러가 떴다.</p>
<pre><code>torch.OutOfMemoryError: CUDA out of memory.
Tried to allocate 72.00 MiB.
GPU 0 has a total capacity of 21.99 GiB of which 69.38 MiB is free.
Of the allocated memory 21.48 GiB is allocated by PyTorch</code></pre><p>VRAM 상태를 보면 이렇다.</p>
<pre><code>Total VRAM : 21.99 GiB
사용 중    : 21.91 GiB (99.6%)
 ├─ PyTorch : 21.48 GiB
 └─ 기타    : 0.43 GiB
남은 공간  : 0.069 GiB (69MB)</code></pre><p>72MB가 필요한데 69MB밖에 없었다.</p>
<p>모델이 VRAM을 꽉 채워버린 것이다.</p>
<hr>
<h2 id="2차-시도--device_mapbalanced">2차 시도 — device_map=&quot;balanced&quot;</h2>
<p>GPU 메모리가 부족하면 자동으로 CPU로 offload하는 방식으로 바꿨다.</p>
<pre><code class="language-python">pipeline = QwenImageEditPlusPipeline.from_pretrained(
    &quot;Qwen/Qwen-Image-Edit-2511&quot;,
    torch_dtype=torch.float16,
    device_map=&quot;balanced&quot;,
    low_cpu_mem_usage=True
)</code></pre>
<p>실행은 됐다.</p>
<p>근데 문제가 생겼다.</p>
<pre><code>40%|████████| 12/30 [4:09:33&lt;6:09:14, 1230.82s/it]</code></pre><p>30 스텝 중 12번째(40%)에 4시간 9분이 걸렸다.</p>
<p>스텝당 약 20분, 총 예상 시간이 10시간이 넘었다.</p>
<p>GPU와 CPU를 계속 왔다갔다하면서 속도가 처참하게 느려진 것이다.</p>
<p>결국 <code>KeyboardInterrupt</code> 로 중단했다.</p>
<hr>
<h2 id="3차-시도--fp-조절">3차 시도 — FP 조절</h2>
<p>VRAM 사용량을 줄이려면 데이터 타입을 낮추면 된다.</p>
<pre><code class="language-python"># 기존
torch_dtype=torch.float16

# 변경 시도
torch_dtype=torch.float8</code></pre>
<p>바로 막혔다.</p>
<p>PyTorch에는 <code>torch.float8</code> 이 없다 !</p>
<pre><code>AttributeError: module &#39;torch&#39; has no attribute &#39;float8&#39;</code></pre><p>float16보다 더 낮은 타입을 쓰려면 다른 방법이 필요했다.</p>
<hr>
<h2 id="해결--서버-교체--sequential-offload--bfloat16-조합">해결 — 서버 교체 + sequential offload + bfloat16 조합</h2>
<p>원래 쓰던 서버는 VRAM 8GB였다.</p>
<p>모델 자체가 너무 커서 8GB로는 아예 올라가지도 않았다.</p>
<p>서버를 교체했지만 모델 전체를 다 올릴 수 있는 사양으로 바꾼 건 아니었다.</p>
<p>그래서 두 가지를 함께 적용했다.</p>
<p><strong>sequential CPU offload</strong> — 레이어 단위로 필요할 때만 GPU에 올리고 나머지는 CPU에 둔다.</p>
<p>model 방식보다 느리지만 메모리를 훨씬 적게 쓴다.</p>
<pre><code class="language-python">pipeline.enable_sequential_cpu_offload()</code></pre>
<p><strong>bfloat16으로 타입 변경</strong> — float16 대비 수치 안정성이 높고 메모리 사용량은 동일하다.</p>
<pre><code class="language-python">torch_dtype=torch.bfloat16</code></pre>
<p>이 두 가지 조합으로 정상적으로 동작했다 !</p>
<hr>
<h2 id="결과">결과</h2>
<p>서버를 바꾼 뒤 정상적으로 동작했다.</p>
<p>GPU 상태도 안정적이었다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/08f92c5e-0e0d-4efd-9b51-88f54c1aa879/image.png" alt=""></p>
<pre><code>GPU 사용률 : 89%
온도       : 73°C
전력       : 58W / 72W
메모리 사용 : 890 MiB / 23034 MiB (약 3.9%)
여유 메모리 : 약 21.6 GB</code></pre><h3 id="강아지-사진-테스트">강아지 사진 테스트</h3>
<p>원본 사진에 다양한 각도와 스타일 변환을 적용해봤다.</p>
<ul>
<li><p>원본 강아지 사진
<img src="https://velog.velcdn.com/images/junyoung-choe/post/c1c808d7-f7e4-4816-9e12-c1d60efb84f9/image.png" alt=""></p>
</li>
<li><p>right side view high-angle shot close-up
<img src="https://velog.velcdn.com/images/junyoung-choe/post/e8a71c1a-e955-424f-8e57-579a7b79acff/image.webp" alt=""></p>
</li>
<li><p>back side view high-angle shot close-up
<img src="https://velog.velcdn.com/images/junyoung-choe/post/1be9ccce-22bd-4cf2-af18-8e41d5e0fbc4/image.webp" alt=""></p>
</li>
<li><p>left side view high-angle shot close-up</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/b4b57297-c9d5-484b-8c9d-4844b73641e1/image.webp" alt=""></p>
<ul>
<li>지브리 스타일 변환 결과
<img src="https://velog.velcdn.com/images/junyoung-choe/post/38d027b5-0bfb-45ed-a435-6447c0512468/image.webp" alt=""></li>
</ul>
<p>지브리 스타일 변환에 사용한 프롬프트다.</p>
<pre><code>Transform the actual photo provided into an anime-style landscape scene.
Keep the core composition and elements from the original photo,
but render everything in vibrant anime aesthetics with exaggerated colors,
dynamic lighting, soft gradients, and cel-shaded outlines typical of
Studio Ghibli-inspired landscapes.</code></pre><h3 id="사람-사진-테스트">사람 사진 테스트</h3>
<ul>
<li>원본 사람 사진</li>
</ul>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/afd795ca-dfb9-4cd3-9337-83836daf374e/image.png" alt=""></p>
<ul>
<li>right side view high-angle shot close-up 결과</li>
</ul>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/aae40d0e-a7d6-4624-a312-e28d7f24ddf4/image.png" alt=""></p>
<hr>
<h2 id="lora--gradio-서버">LoRA + Gradio 서버</h2>
<p>LoRA까지 붙이고 외부에서 접근할 수 있는 Gradio 서버를 구축했다.
<img src="https://velog.velcdn.com/images/junyoung-choe/post/aa650fd4-db0c-41f3-8323-afafc8449f80/image.png" alt="">
단일 편집과 배치 편집(다중 각도 한 번에 생성) 두 가지 탭으로 구성했다.</p>
<pre><code class="language-python">import gradio as gr
import torch
from PIL import Image
from diffusers import QwenImageEditPlusPipeline
import gc
import os

os.environ[&#39;PYTORCH_CUDA_ALLOC_CONF&#39;] = &#39;expandable_segments:True&#39;

pipeline = QwenImageEditPlusPipeline.from_pretrained(
    &quot;./model&quot;,
    torch_dtype=torch.bfloat16
)
pipeline.enable_sequential_cpu_offload()

pipeline.load_lora_weights(
    &quot;fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA&quot;,
    adapter_name=&quot;multi_angle&quot;
)
pipeline.set_adapters([&quot;multi_angle&quot;], adapter_weights=[0.9])</code></pre>
<p>서버는 <code>0.0.0.0:7860</code> 으로 띄운다.</p>
<pre><code class="language-python">demo.launch(
    server_name=&quot;0.0.0.0&quot;,
    server_port=7860,
    share=False,
    debug=True
)</code></pre>
<ul>
<li>결과화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/d7e357a5-bdbe-434d-8035-3c8fa2e184de/image.png" alt=""></li>
</ul>
<hr>
<h2 id="qwen-모델-핵심-파라미터">Qwen 모델 핵심 파라미터</h2>
<p>일반적인 Diffusion 모델과 파라미터가 다르다.</p>
<p>공식 문서 기준으로 아래 값을 써야 제대로 동작한다.</p>
<pre><code class="language-python">output = pipeline(
    image=[img],           # 리스트로 전달
    prompt=prompt,
    negative_prompt=&quot; &quot;,   # 빈 문자열이 아닌 공백 하나
    true_cfg_scale=4.0,    # Qwen 필수 파라미터
    guidance_scale=1.0,    # Qwen 기본값
    num_inference_steps=20,
)</code></pre>
<p><code>negative_prompt=&quot; &quot;</code> 에서 <code>&quot;&quot;</code> 이 아니라 <code>&quot; &quot;</code> (공백 하나)를 써야 한다.</p>
<p>처음엔 이걸 몰라서 한참 헤맸다 !</p>
<hr>
<h2 id="메모리-최적화-정리">메모리 최적화 정리</h2>
<p>T4에서 삽질하면서 배운 최적화 포인트 4가지다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>변경 전</th>
<th>변경 후</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td>CPU Offload</td>
<td><code>model</code></td>
<td><code>sequential</code></td>
<td>메모리 절약</td>
</tr>
<tr>
<td>환경 변수</td>
<td>없음</td>
<td><code>expandable_segments</code></td>
<td>단편화 방지</td>
</tr>
<tr>
<td>이미지 크기</td>
<td>원본 그대로</td>
<td>512px</td>
<td>메모리 1/4 감소</td>
</tr>
<tr>
<td>Steps</td>
<td>40</td>
<td>20</td>
<td>속도 2배 향상</td>
</tr>
</tbody></table>
<p>1024x1024 → 512x512로 줄이는 것만으로 메모리 사용량이 1/4로 줄어든다 !</p>
<hr>
<p>처음엔 단순히 모델을 올리는 거라 쉽게 생각했는데, VRAM 용량과 속도 사이에서 꽤 고생했다.</p>
<p>이번에 배운 건 하나다.</p>
<p>VRAM이 모델 크기를 전부 수용할 수 있으면 제일 좋다.</p>
<p>하지만 그렇지 않더라도 방법이 있다.</p>
<ul>
<li><strong>sequential CPU offload</strong> — 레이어 단위로 쪼개서 필요한 부분만 GPU에 올린다</li>
<li><strong>bfloat16 / float16</strong> — 데이터 타입을 낮춰서 메모리 사용량을 줄인다</li>
<li><strong>이미지 크기 축소</strong> — 입력 해상도를 낮추면 메모리가 확 줄어든다</li>
</ul>
<p>서버 사양이 부족하다고 바로 포기할 필요가 없다는 걸 직접 경험했다.</p>
<p>물론 속도 트레이드오프는 있다. 하지만 테스트 목적이라면 충분히 쓸 만하다 !</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS 환경 세팅, Conda부터 PyTorch까지]]></title>
            <link>https://velog.io/@junyoung-choe/AWS-%ED%99%98%EA%B2%BD-%EC%84%B8%ED%8C%85-Conda%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@junyoung-choe/AWS-%ED%99%98%EA%B2%BD-%EC%84%B8%ED%8C%85-Conda%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 30 Mar 2026 00:49:43 GMT</pubDate>
            <description><![CDATA[<p>앞서 실 서비스들을 직접 써보면서 Out Painting이 어떤 건지 감을 잡았다.</p>
<p>이제 직접 모델을 구축해서 테스트해볼 차례다.</p>
<hr>
<h2 id="왜-aws인가">왜 AWS인가</h2>
<p>회사에서 실제로 사용 중인 GPU 서버에 바로 올리기엔 부담이 있었다.</p>
<p>검증되지 않은 모델을 프로덕션 서버에 올리는 건 리스크가 크니까.</p>
<p>AWS에서 먼저 테스트하고, 결과가 괜찮으면 실 서버에 올리는 방식으로 접근했다.</p>
<hr>
<h2 id="인스턴스-선택">인스턴스 선택</h2>
<p>테스트 목적이니 비용 대비 효율을 따졌다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>인스턴스</td>
<td>g4dn.xlarge</td>
</tr>
<tr>
<td>GPU</td>
<td>NVIDIA T4</td>
</tr>
<tr>
<td>VRAM</td>
<td>16GB</td>
</tr>
</tbody></table>
<p>T4는 추론 최적화 GPU라 딥러닝 모델 테스트 용도로 충분하다.</p>
<hr>
<h2 id="환경-세팅">환경 세팅</h2>
<p>모델을 올리기 전에 환경부터 제대로 잡아야 한다.</p>
<p>GPU 인스턴스 세팅부터 Python 환경 구축까지 순서대로 정리한다.</p>
<hr>
<h2 id="ami-선택">AMI 선택</h2>
<p>AWS에서 인스턴스를 생성할 때 AMI 선택이 중요하다.</p>
<p>직접 드라이버, CUDA, PyTorch를 하나씩 설치하면 버전 충돌로 삽질할 가능성이 크다.</p>
<p>아래 AMI를 선택하면 전부 세팅된 상태로 시작할 수 있다.</p>
<pre><code>Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.9 (Ubuntu 24.04)</code></pre><p>이 AMI 하나로 끝난다.</p>
<ul>
<li>NVIDIA 드라이버 ✅</li>
<li>CUDA Toolkit ✅</li>
<li>cuDNN ✅</li>
<li>PyTorch 2.9 ✅</li>
<li>기타 딥러닝 라이브러리들 ✅</li>
</ul>
<hr>
<h2 id="1-시스템-확인-및-업데이트">1. 시스템 확인 및 업데이트</h2>
<p>인스턴스에 접속하면 가장 먼저 시스템 상태와 GPU를 확인한다.</p>
<pre><code class="language-bash"># 시스템 정보
cat /etc/os-release | grep PRETTY_NAME
uname -r

# GPU 확인
nvidia-smi

# 업데이트
sudo apt update &amp;&amp; sudo apt upgrade -y

# 기본 도구 설치
sudo apt install -y build-essential wget curl git vim htop</code></pre>
<p><code>nvidia-smi</code> 에서 GPU 정보가 정상적으로 출력되면 드라이버는 잡힌 거다.</p>
<hr>
<h2 id="2-nvidia-드라이버-설치-ami-미사용-시">2. NVIDIA 드라이버 설치 (AMI 미사용 시)</h2>
<p>딥러닝 AMI를 쓰지 않는 경우라면 드라이버를 직접 설치해야 한다.</p>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt upgrade -y

# ubuntu-drivers 설치
sudo apt install -y ubuntu-drivers-common

# 추천 드라이버 확인
sudo ubuntu-drivers devices

# 자동 설치
sudo ubuntu-drivers autoinstall

# 재부팅
sudo reboot</code></pre>
<hr>
<h2 id="3-conda-설치">3. Conda 설치</h2>
<p>패키지 버전 관리를 위해 Miniconda를 설치한다.</p>
<pre><code class="language-bash"># Miniconda 다운로드
cd ~
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh

# 설치
bash Miniconda3-latest-Linux-x86_64.sh -b -p $HOME/miniconda3

# Conda 초기화
$HOME/miniconda3/bin/conda init bash

# 설정 적용
source ~/.bashrc

# 확인 — (base) 표시가 나와야 함
conda --version</code></pre>
<hr>
<h2 id="4-python-환경-생성">4. Python 환경 생성</h2>
<p>모델별로 환경을 분리해두는 게 좋다.</p>
<pre><code class="language-bash"># Python 3.10 환경 생성
conda create -n qwen python=3.10 -y -c conda-forge

# 환경 활성화
conda activate qwen

# 확인 — (qwen) 표시가 나와야 함
python --version</code></pre>
<p>에러가 발생하면 <code>conda update -n base -c defaults conda</code> 로 먼저 conda 자체를 업데이트해보자.</p>
<hr>
<h2 id="5-pytorch-설치">5. PyTorch 설치</h2>
<p>환경 안에서 CUDA 호환 PyTorch를 설치한다.</p>
<pre><code class="language-bash">pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121</code></pre>
<p>설치 후 GPU 연결 여부를 확인한다.</p>
<pre><code class="language-python">import torch

print(f&quot;PyTorch 버전: {torch.__version__}&quot;)
print(f&quot;CUDA 사용 가능: {torch.cuda.is_available()}&quot;)
print(f&quot;CUDA 버전: {torch.version.cuda}&quot;)
print(f&quot;GPU 이름: {torch.cuda.get_device_name(0)}&quot;)
print(f&quot;GPU 메모리: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB&quot;)</code></pre>
<p><code>CUDA 사용 가능: True</code> 가 나오면 성공이다 !</p>
<hr>
<h2 id="6-diffusers-및-의존성-설치">6. Diffusers 및 의존성 설치</h2>
<p>Qwen 모델을 쓰려면 HuggingFace 생태계 라이브러리들이 필요하다.</p>
<pre><code class="language-bash">pip install diffusers transformers accelerate safetensors pillow huggingface_hub</code></pre>
<hr>
<h2 id="7-huggingface-로그인">7. HuggingFace 로그인</h2>
<p>모델을 다운로드하려면 HuggingFace 계정 인증이 필요하다.</p>
<pre><code class="language-bash">huggingface-cli login</code></pre>
<p>토큰을 입력하면 인증이 완료된다.</p>
<hr>
<h2 id="gpu-실행-옵션-정리">GPU 실행 옵션 정리</h2>
<p>모델 로드 시 <code>device_map</code> 옵션에 따라 동작 방식이 달라진다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>의미</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>&quot;cuda&quot;</code></td>
<td>GPU에 전체 로드</td>
<td>추천 (VRAM 충분할 때)</td>
</tr>
<tr>
<td><code>&quot;balanced&quot;</code></td>
<td>부족하면 CPU 사용</td>
<td>VRAM 부족 시 대안</td>
</tr>
<tr>
<td><code>&quot;auto&quot;</code></td>
<td>자동 선택</td>
<td>Qwen에서 지원 안 됨</td>
</tr>
</tbody></table>
<p>VRAM이 충분하다면 <code>&quot;cuda&quot;</code> 로 전체 로드하는 게 속도 면에서 가장 좋다.</p>
<hr>
<p>이제 환경이 다 갖춰졌다.</p>
<p>다음 편에서는 Qwen 모델을 실제로 내려받고 Gradio 서버까지 구축한 과정을 정리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Out Painting 개념 + 상용 서비스 직접 써봤다]]></title>
            <link>https://velog.io/@junyoung-choe/Out-Painting-%EA%B0%9C%EB%85%90-%EC%83%81%EC%9A%A9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A7%81%EC%A0%91-%EC%8D%A8%EB%B4%A4%EB%8B%A4</link>
            <guid>https://velog.io/@junyoung-choe/Out-Painting-%EA%B0%9C%EB%85%90-%EC%83%81%EC%9A%A9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A7%81%EC%A0%91-%EC%8D%A8%EB%B4%A4%EB%8B%A4</guid>
            <pubDate>Mon, 30 Mar 2026 00:33:16 GMT</pubDate>
            <description><![CDATA[<p>Out Painting이라는 개념을 처음 접했을 때 꽤 흥미로웠다.</p>
<p>이미지를 잘라내거나 비율을 바꿔야 할 때 배경을 AI가 자연스럽게 채워준다는 거니까.</p>
<p>직접 써보기 전에 개념부터 잡고, 상용 서비스들을 하나씩 테스트해봤다.</p>
<hr>
<h2 id="out-painting이란">Out Painting이란</h2>
<p>원본 이미지의 사이즈를 변경해야 할 때, 없는 배경을 자연스럽게 생성해주는 기술이다.</p>
<p>예를 들어 16:9 이미지를 9:16으로 바꿔야 할 때, 원본 바깥 영역을 AI가 추론해서 채워준다.</p>
<hr>
<h2 id="직접-테스트해봤다">직접 테스트해봤다</h2>
<p>원본 사진에서 일부를 잘라낸 뒤, 잘린 이미지로 원본을 복원할 수 있는지 여러 서비스에서 테스트했다.</p>
<ul>
<li>원본 사진
<img src="https://velog.velcdn.com/images/junyoung-choe/post/26ecf708-d56b-49ee-8bc4-750cd7ec25c5/image.png" alt=""></li>
</ul>
<ul>
<li>원본 사진 일부 추출 (캡처)
<img src="https://velog.velcdn.com/images/junyoung-choe/post/77c97e68-138b-48f7-b674-b7893a42de0d/image.png" alt=""></li>
</ul>
<hr>
<h3 id="promeai">PromeAI</h3>
<p><a href="https://www.promeai.pro/ko">PromeAI 바로가기</a></p>
<ul>
<li>PromeAI 결과 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/8a2b2d47-9ada-4ba9-ac1f-776a17ddb5b1/image.png" alt=""></li>
</ul>
<hr>
<h3 id="pixelcut">Pixelcut</h3>
<p><a href="https://www.pixelcut.ai/uncrop/ai-outpainting">Pixelcut 바로가기</a></p>
<ul>
<li>Pixelcut 결과 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/f9d51282-674f-4319-b1b7-20e1ff982ac0/image.png" alt=""></li>
</ul>
<hr>
<h3 id="capcut">CapCut</h3>
<p><a href="https://dreamina.capcut.com/ko-kr/resource/ai-outpainting">CapCut 바로가기</a></p>
<ul>
<li>CapCut 프롬프트 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/53cc05fe-1f01-490a-ae42-7c05b82ab543/image.png" alt=""></li>
</ul>
<ul>
<li>CapCut 프롬프트 입력 후 결과 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/dafba751-50b7-4d49-9eb5-2db4f2bae246/image.png" alt=""></li>
</ul>
<p>세 서비스 모두 사진이 완벽하게 복구되진 않았고, 퀄리티가 조금씩 아쉬웠다.</p>
<p>그 중 CapCut은 프롬프트를 함께 입력할 수 있어서 다른 서비스보다 조금 더 나은 결과를 받을 수 있었다.</p>
<hr>
<h3 id="gemini">Gemini</h3>
<ul>
<li><p>Gemini Out Painting 프롬프트 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/e46a5030-bef9-4605-bb1e-6c3412cee98a/image.png" alt=""></p>
</li>
<li><p>Gemini 생성 이미지
<img src="https://velog.velcdn.com/images/junyoung-choe/post/c52375e9-b94f-4c3c-8252-ad3a07cfebbc/image.png" alt=""></p>
</li>
</ul>
<hr>
<h2 id="stable-diffusion으로도-해봤다">Stable Diffusion으로도 해봤다</h2>
<p>상용 서비스 외에 Stable Diffusion으로도 직접 돌려봤다.</p>
<ul>
<li><p>원본 산 풍경 사진
<img src="https://velog.velcdn.com/images/junyoung-choe/post/31f676bd-dc3e-46cc-a0fd-cdb35c31e05f/image.jpg" alt=""></p>
</li>
<li><p>Stable Diffusion Out Painting 설정 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/95873f7c-2c8c-4756-897b-a2d3851980b1/image.png" alt=""></p>
</li>
<li><p>Stable Diffusion Out Painting 결과 
<img src="https://velog.velcdn.com/images/junyoung-choe/post/3a91ab5c-45a1-4c8c-8430-100a8d6e577c/image.png" alt=""></p>
</li>
</ul>
<p>완벽하게 모방하진 못하지만 어느 정도의 자연스러움은 유지하면서 확장됐다.</p>
<hr>
<h2 id="문제는-속도였다">문제는 속도였다</h2>
<p>이미지 한 장 확장에 CPU 기준으로 약 3분이 걸렸다.</p>
<p>영상으로 확장하면 어떻게 될까 계산해봤다.</p>
<table>
<thead>
<tr>
<th>fps</th>
<th>1분 영상 프레임 수</th>
</tr>
</thead>
<tbody><tr>
<td>24fps (영화)</td>
<td>1,440개</td>
</tr>
<tr>
<td>30fps (일반 영상)</td>
<td>1,800개</td>
</tr>
<tr>
<td>60fps (고화질)</td>
<td>3,600개</td>
</tr>
</tbody></table>
<p>24fps 1분짜리 클립을 Out Painting 한다고 하면:</p>
<pre><code>1,440 프레임 × 3분 = 4,320분</code></pre><p>72시간이 넘는다 !</p>
<p>GPU로 시도해봐야 한다는 결론이 나왔다.</p>
<p>과연 영상으로 이 과정을 처리할 수 있을까 ?</p>
<hr>
<p>다음 편에서는 Video Out Painting을 다룬 논문을 분석한다.</p>
<p>세로 영상을 가로로 변환하는 문제를 어떻게 접근했는지, 핵심 아이디어가 뭔지 정리해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GPU 기초, 드라이버부터 PyTorch까지]]></title>
            <link>https://velog.io/@junyoung-choe/GPU-%EA%B8%B0%EC%B4%88-%EB%93%9C%EB%9D%BC%EC%9D%B4%EB%B2%84%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@junyoung-choe/GPU-%EA%B8%B0%EC%B4%88-%EB%93%9C%EB%9D%BC%EC%9D%B4%EB%B2%84%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 30 Mar 2026 00:24:15 GMT</pubDate>
            <description><![CDATA[<p>어느 날 팀원 중 한 명이 다음과 같은 말을 했다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/84d8c32d-92cc-469b-b52c-9083bfe59625/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/cdd2f0c4-375d-418c-87fb-30c0affa9fda/image.png" alt=""></p>
<p><a href="https://www.threads.com/@choi.openai/post/DSnK8HRjy8f?hl=ko">choi.openai 님 Threads 포스트 (2025-12-24)</a></p>
<p>4개월 만에 상용 모델을 따라잡는 오픈소스라니, 직접 돌려보고 싶다는 생각이 바로 들었다.</p>
<p>그냥 API 쓰는 게 아니라, 모델이 내부적으로 어떻게 돌아가는지 직접 부딪혀보기로 했다.</p>
<p>시작하기 전에 GPU 관련 개념부터 제대로 정리해두기로 했다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/0d115428-0d34-4e85-95fe-71d47d306568/image.png" alt=""></p>
<h2 id="레이어-구조">레이어 구조</h2>
<p>GPU로 AI를 돌리려면 세 가지 계층이 맞춰져 있어야 한다.</p>
<pre><code>PyTorch
   ↑
CUDA
   ↑
NVIDIA 드라이버
   ↑
GPU 하드웨어</code></pre><p>위로 올라갈수록 추상화가 높아진다.</p>
<p>아래부터 하나씩 보자.</p>
<hr>
<h2 id="nvidia-드라이버">NVIDIA 드라이버</h2>
<p>&quot;운영체제 ↔ GPU 하드웨어를 이어주는 필수 소프트웨어&quot;</p>
<p>OS와 애플리케이션이 GPU에 명령을 보내고 결과를 받게 해주는 가장 낮은 레벨의 소프트웨어 계층이다.</p>
<p>OS 입장에서 이게 없으면 GPU라는 하드웨어 자체를 인식하지 못한다.</p>
<p>드라이버 = GPU 장치 드라이버 (Device Driver)</p>
<hr>
<h2 id="cuda">CUDA</h2>
<p>드라이버 위에서 돌아가는 개발용 플랫폼이다.</p>
<p>&quot;GPU를 써서 연산을 짜고 돌리게 해주는 것&quot;</p>
<p>CUDA = GPU 프로그래밍용 SDK + 런타임</p>
<p>개발자 입장에서 이게 없으면 C/Python 코드로 행렬연산이나 딥러닝 같은 걸 GPU에 던질 수 없다.</p>
<hr>
<h2 id="pytorch">PyTorch</h2>
<p>CUDA 위에서 돌아가는 고수준 프레임워크다.</p>
<p>CPU/GPU에서 텐서 연산, 자동 미분, 딥러닝 모델 구현과 학습을 담당한다.</p>
<p>GPU 가속이 필요할 때 내부적으로 CUDA를 사용한다.</p>
<p>개발자는 <code>torch.cuda</code> 모듈을 통해 CUDA를 직접 다루지 않고도 GPU를 쓸 수 있다.</p>
<hr>
<h2 id="레이어-순서-정리">레이어 순서 정리</h2>
<table>
<thead>
<tr>
<th>레이어</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>NVIDIA 드라이버</td>
<td>GPU 인식·초기화, 커널 모드 명령 처리 (가장 아래층)</td>
</tr>
<tr>
<td>CUDA 런타임 / 드라이버 API</td>
<td>드라이버 위에서 커널 실행, 메모리 할당/복사 등 GPU 연산 제어</td>
</tr>
<tr>
<td>PyTorch</td>
<td>텐서 연산·NN 모듈·옵티마이저 제공, <code>torch.cuda</code>로 CUDA 추상화</td>
</tr>
</tbody></table>
<hr>
<h2 id="서버-ami-설정">서버 AMI 설정</h2>
<p>이번에 AWS에서 아래 AMI를 선택했다.</p>
<pre><code>Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.9 (Ubuntu 24.04)</code></pre><p>이 AMI 하나로 아래가 전부 세팅된 상태로 시작한다.</p>
<ul>
<li>NVIDIA 드라이버 ✅</li>
<li>CUDA Toolkit ✅</li>
<li>cuDNN ✅</li>
<li>PyTorch 2.9 ✅</li>
<li>기타 딥러닝 라이브러리들 ✅</li>
</ul>
<p>직접 하나씩 설치하면 버전 충돌로 삽질할 가능성이 크다.</p>
<p>딥러닝 AMI를 쓰면 이 과정을 통째로 건너뛸 수 있다 !</p>
<hr>
<p>다음 편에서는 Out Painting이 뭔지, 상용 서비스들을 직접 써보면서 비교한 과정을 정리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메일이 왜 이렇게 느려? - @Async로 비동기 처리하기]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%A9%94%EC%9D%BC%EC%9D%B4-%EC%99%9C-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%8A%90%EB%A0%A4-Async%EB%A1%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@junyoung-choe/%EB%A9%94%EC%9D%BC%EC%9D%B4-%EC%99%9C-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%8A%90%EB%A0%A4-Async%EB%A1%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 26 Mar 2026 01:45:10 GMT</pubDate>
            <description><![CDATA[<h2 id="참고한-레퍼런스">참고한 레퍼런스</h2>
<p>비슷한 고민을 한 사람들이 이미 많았다.</p>
<ul>
<li><a href="https://www.baeldung.com/spring-async">How To Do @Async in Spring - Baeldung</a> — Spring <code>@Async</code> 적용 방법 전반을 잘 정리해놓은 글. SMTP 같은 네트워크 I/O 작업에 적용하는 게 대표적인 사례로 나온다.</li>
<li><a href="https://techblog.woowahan.com/23625/">장시간 비동기 작업, Kafka 대신 RDB 기반 Task Queue로 해결하기 - 우아한형제들 기술블로그</a> — 엑셀 파일을 생성해서 이메일로 발송하는 기능에서 처리 시간이 길어 유저가 기다리게 되는 문제를 비동기로 해결한 실제 사례. Virgin Road랑 맥락이 거의 같다.</li>
</ul>
<p>두 글을 보면서 &quot;메일처럼 외부 네트워크를 타는 작업은 유저 응답 흐름 밖으로 빼는 게 맞다&quot;는 확신이 생겼다.</p>
<hr>
<h2 id="문제를-발견한-순간">문제를 발견한 순간</h2>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/1ff1c79a-3821-41a1-bb97-e59d8034820c/image.gif" alt=""></p>
<p>Virgin Road를 직접 써보다가 불편한 게 느껴졌다.</p>
<p>방 만들기 버튼을 누르면 인증 코드 메일이 발송되는데, 버튼을 누르고 나서 화면이 잠깐 멈추는 느낌이 있었다. 처음엔 그냥 넘겼는데, 여러 번 써보니까 계속 신경 쓰였다.</p>
<p>코드를 보니 원인이 바로 보였다.</p>
<hr>
<h2 id="문제의-원인">문제의 원인</h2>
<p>메일 발송이 동기로 실행되고 있었다.</p>
<pre><code>유저가 버튼 클릭
  → Redis에 인증 코드 저장
  → Gmail SMTP 서버에 연결
  → 메일 전송
  → SMTP 응답 대기  ← 여기서 유저가 기다림
  → 그제야 API 응답 반환</code></pre><p>Gmail SMTP는 외부 네트워크를 타는 작업이다. 빠를 때는 괜찮지만 네트워크 상태에 따라 수백 ms에서 길면 수 초까지 걸린다. 유저 입장에서는 버튼 눌렀는데 화면이 굳어버린 것처럼 느껴지는 순간이다.</p>
<p>사실 메일 발송이 끝날 때까지 유저를 기다리게 할 이유가 없다. Redis에 인증 코드 저장이 완료된 순간 이미 할 일은 다 한 거고, 메일은 그냥 알림 수단일 뿐이다.</p>
<hr>
<h2 id="async-적용">@Async 적용</h2>
<p>메일 발송을 별도 스레드에 위임하도록 비동기로 바꿨다.</p>
<p><strong>먼저 AsyncConfig.java를 따로 만들었다.</strong></p>
<p><code>@EnableScheduling</code>이 있는 <code>SchedulingConfig.java</code>에 같이 넣을 수도 있었지만, 스케줄링이랑 비동기는 역할이 다르다. 나중에 코드 볼 때 &quot;여기서 왜 @EnableAsync가 나와?&quot; 싶은 상황을 만들고 싶지 않았다.</p>
<pre><code class="language-java">@Configuration
@EnableAsync
public class AsyncConfig {
}</code></pre>
<p><strong>유저 요청 흐름에 있는 메서드에만 @Async를 붙였다.</strong></p>
<pre><code class="language-java">@Async
public void sendVerificationCode(String email, String code) { ... }

@Async
public void sendRoomCreated(String email, String groomName, String brideName, String shareUrl) { ... }</code></pre>
<p>스케줄러에서 호출하는 <code>sendLettersPdf</code>, <code>sendSchedulerReport</code>는 건드리지 않았다. 어차피 유저가 기다리는 흐름이 아니니까.</p>
<p>적용하고 나면 흐름이 이렇게 바뀐다.</p>
<pre><code>유저가 버튼 클릭
  → Redis에 인증 코드 저장
  → 메일 발송을 별도 스레드에 위임
  → API 응답 즉시 반환  ← 유저는 바로 다음 화면으로

  (백그라운드에서)
  → Gmail SMTP 연결
  → 메일 전송</code></pre><hr>
<h2 id="적용-후-ui">적용 후 UI</h2>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/ea040777-75c2-4f07-a13e-c67238f8dbd0/image.gif" alt=""></p>
<hr>
<h2 id="한-가지-고민">한 가지 고민</h2>
<p><code>@Async</code>를 붙이면 메일 발송 실패가 유저한테 전달되지 않는다. SMTP가 터져도 API는 성공 응답을 내려준다. 즉 유저는 &quot;인증 코드 발송됨&quot; 화면을 봤는데 메일이 안 온 상황이 생길 수 있다.</p>
<p>처음엔 이게 좀 찜찜했는데, 생각해보니 이미 해결책이 있었다. 인증 화면에 <strong>재발송 버튼</strong>이 있다. 메일이 안 왔으면 다시 받으면 되고, 이게 사실 어느 서비스든 이메일 인증에서 다 쓰는 패턴이다.</p>
<p>메일 발송 실패 하나 때문에 전체 응답을 느리게 만드는 건 그냥 손해다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th></th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>메일 발송 방식</td>
<td>동기 (SMTP 응답 대기)</td>
<td>비동기 (별도 스레드)</td>
</tr>
<tr>
<td>API 응답 시점</td>
<td>SMTP 완료 후</td>
<td>즉시</td>
</tr>
<tr>
<td>발송 실패 영향</td>
<td>API 에러 응답</td>
<td>유저 모름 (재발송으로 커버)</td>
</tr>
</tbody></table>
<p>외부 네트워크를 타는 작업은 유저 응답 흐름 밖으로 빼는 게 맞다. 메일뿐 아니라 Slack 알림, 푸시 알림 같은 것들도 동일하게 적용할 수 있는 패턴이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PDF 서버를 Node.js로 분리한 이유]]></title>
            <link>https://velog.io/@junyoung-choe/PDF-%EC%84%9C%EB%B2%84%EB%A5%BC-Node.js%EB%A1%9C-%EB%B6%84%EB%A6%AC%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@junyoung-choe/PDF-%EC%84%9C%EB%B2%84%EB%A5%BC-Node.js%EB%A1%9C-%EB%B6%84%EB%A6%AC%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 24 Mar 2026 02:27:17 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/b6e225df-a88e-47ba-8b85-a6c17f4f0d2a/image.png" alt=""></p>
<h2 id="배경">배경</h2>
<p>Virgin Road는 결혼식 당일 하객들이 쓴 편지를 PDF로 만들어 신랑/신부에게 이메일로 보낸다. 스케줄러가 매일 19시에 그날 결혼식인 방을 조회해서 PDF를 생성하고 발송한다.</p>
<p>PDF 생성은 처음부터 Java 안에서 구현하려고 했다. Spring Boot 안에서 처리하면 별도 서버 없이 깔끔하니까.</p>
<hr>
<h2 id="flying-saucer로-처음-구현">Flying Saucer로 처음 구현</h2>
<p>Java에서 PDF 생성 라이브러리를 검토했다.</p>
<ul>
<li><strong>iText</strong> - AGPL 라이선스라 상용 서비스엔 사실상 유료</li>
<li><strong>Apache PDFBox</strong> - 저수준 API, 좌표 직접 계산해야 함</li>
<li><strong>Flying Saucer</strong> - HTML을 CSS로 스타일링해서 PDF로 변환</li>
</ul>
<p>Flying Saucer를 선택했다. HTML + CSS로 레이아웃을 짜면 디자인하기 편할 것 같았다.</p>
<p>실제로 써보니 문제가 한두 가지가 아니었다.</p>
<p><strong>한글 폰트 처리가 까다로웠다.</strong> Flying Saucer는 폰트를 직접 등록해야 하는데, 한글 폰트 경로 설정과 CSS <code>font-family</code> 연동이 생각처럼 되지 않았다.</p>
<p><strong>CSS 지원 수준이 낮았다.</strong> Flying Saucer가 지원하는 CSS는 굉장히 제한적이다. 현대적인 레이아웃(flexbox 등)은 쓸 수 없고, 오래된 CSS 박스 모델만 지원한다. 편지 카드를 예쁘게 배치하려는데 레이아웃이 계속 틀어졌다.</p>
<p>결국 Java 안에서 PDF 생성하는 걸 포기하고 다른 방법을 찾았다.</p>
<hr>
<h2 id="react-pdf-발견">react-pdf 발견</h2>
<p><code>@react-pdf/renderer</code>는 JSX로 PDF 레이아웃을 작성할 수 있는 Node.js 라이브러리다. Flexbox 기반 레이아웃을 지원하고, 폰트 등록도 간단하다.</p>
<pre><code class="language-jsx">Font.register({
  family: &#39;NanumGothicCoding&#39;,
  fonts: [
    { src: fonts(&#39;NanumGothicCoding.ttf&#39;), fontWeight: &#39;normal&#39; },
    { src: fonts(&#39;NanumGothicCoding-Bold.ttf&#39;), fontWeight: &#39;bold&#39; },
  ],
})</code></pre>
<p>레이아웃은 React 컴포넌트 짜듯이 만든다.</p>
<pre><code class="language-jsx">&lt;Document&gt;
  &lt;Page style={styles.page}&gt;
    &lt;CoverPage groomName={groomName} brideName={brideName} weddingDate={weddingDate} /&gt;
    &lt;AuthorsPage authors={authors} /&gt;
    {letterChunks.map((chunk, i) =&gt; (
      &lt;LettersPage key={i} letters={chunk} /&gt;
    ))}
  &lt;/Page&gt;
&lt;/Document&gt;</code></pre>
<p>Flying Saucer로 씨름하던 한글 폰트도, 편지 카드 레이아웃도 훨씬 수월하게 해결됐다. 문제는 이게 JavaScript 라이브러리라는 것이다.</p>
<hr>
<h2 id="그냥-spring-안에-넣으면-안-됐나">그냥 Spring 안에 넣으면 안 됐나</h2>
<p>react-pdf가 Node.js 라이브러리라서 어쩔 수 없이 분리한 것도 있지만, 사실 억지로 JVM 위에서 돌릴 방법이 없는 건 아니다. GraalVM으로 JS를 실행하거나, Kotlin/JS를 쓰거나. 그런데 굳이 그렇게 할 이유가 없었다.</p>
<p><strong>PDF 디자인은 자주 바뀐다.</strong> 편지 카드 레이아웃, 폰트, 배경 이미지, 페이지 구성 등은 실제 결혼식 PDF를 보면서 계속 다듬을 수밖에 없다. 만약 PDF 코드가 Spring 프로젝트 안에 있었다면 디자인 수정할 때마다 Spring 서버를 빌드하고 재배포해야 한다.</p>
<p>Spring 서버 재배포는 리스크가 있다. Gradle 빌드 시간도 있고, 재시작 중 서비스가 잠깐 끊기는 구간도 생긴다. PDF 레이아웃 조금 바꾸자고 API 서버 전체를 내렸다 올리는 건 낭비다.</p>
<p>분리하면 PDF 서버만 독립적으로 배포할 수 있다. Spring 서버는 건드리지 않고 <code>npm start</code> 하나로 PDF 서버만 교체된다. API 서버 입장에서는 PDF 서버가 어떻게 생겼는지 알 필요가 없다. HTTP 요청 보내고 바이트 받으면 끝이다.</p>
<hr>
<h2 id="express-서버로-분리">Express 서버로 분리</h2>
<p>역할은 단순하게 잡았다. JSON 받아서 PDF 바이트 반환.</p>
<pre><code class="language-jsx">// server.jsx
app.post(&#39;/pdf/letters&#39;, async (req, res) =&gt; {
  const { groomName, brideName, weddingDate, letters } = req.body

  const buffer = await renderToBuffer(
    &lt;WeddingPDF
      groomName={groomName}
      brideName={brideName}
      weddingDate={weddingDate}
      letters={letters}
    /&gt;
  )

  res.set(&#39;Content-Type&#39;, &#39;application/pdf&#39;)
  res.send(buffer)
})

app.listen(3001)</code></pre>
<p>Spring 백엔드에서는 <code>RestClient</code>로 이 서버를 호출한다.</p>
<pre><code class="language-java">// PdfClient.java
public byte[] generatePdf(PdfRequest request) {
    return restClient.post()
            .uri(&quot;/pdf/letters&quot;)
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(byte[].class);
}</code></pre>
<p>Spring 입장에서 PDF 서버는 그냥 HTTP API 하나다. 편지 데이터를 조합해서 보내고, 바이트 배열로 받아서 이메일에 첨부한다.</p>
<hr>
<h2 id="인프라-구성">인프라 구성</h2>
<p>이 서비스는 클라우드 서버 없이 Mac Mini 홈 서버 위에서 돌아간다. 홈 서버 구축 과정은 <a href="https://velog.io/@junyoung-choe/series/home-server">홈 서버 구축 일지 시리즈</a>에 따로 정리해뒀다.</p>
<p>PDF 서버도 동일한 홈 서버 위에 Docker 컨테이너로 띄웠다. Spring Boot 백엔드, MySQL, Redis와 같은 Docker 네트워크 안에서 통신한다.</p>
<pre><code>back/   (Spring Boot, :8080)         ─┐
pdf/    (Express + react-pdf, :3001)  ─┤ 같은 Docker 네트워크 (Mac Mini 홈 서버)
mysql/  (:3306)                      ─┤
redis/  (:6379)                      ─┘</code></pre><p>PDF 서버는 외부에 노출하지 않는다. Caddy 리버스 프록시를 통해 Spring 백엔드만 외부로 열려 있고, PDF 서버는 내부 네트워크에서만 접근 가능하다.</p>
<p>배포는 GitHub Actions로 자동화했다. main 브랜치에 푸시하면 Tailscale SSH를 통해 홈 서버에 접속해서 컨테이너를 교체한다. PDF 서버도 별도 워크플로우로 동일하게 배포된다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>처음엔 Java 안에서 해결하려고 했는데 Flying Saucer의 한계로 포기했다. react-pdf는 JSX로 레이아웃을 짤 수 있어서 디자인하기가 압도적으로 편했다. 서버를 하나 더 띄우는 게 복잡해 보이지만, 역할이 명확히 분리되어 오히려 관리하기 쉬웠다. Spring은 비즈니스 로직, PDF 서버는 렌더링만 담당한다.</p>
<p>홈 서버 위에서 운영하는 구체적인 인프라 구성은 <a href="https://velog.io/@junyoung-choe/series/home-server">홈 서버 구축 일지</a>를 참고.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[방 생성 플로우 설계 - INACTIVE → ACTIVE]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%A9-%EC%83%9D%EC%84%B1-%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%84%A4%EA%B3%84-INACTIVE-ACTIVE</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%A9-%EC%83%9D%EC%84%B1-%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%84%A4%EA%B3%84-INACTIVE-ACTIVE</guid>
            <pubDate>Tue, 24 Mar 2026 02:24:44 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/d39d6bd8-a473-44db-80a5-09c1e0c7ee62/image.png" alt=""></p>
<h2 id="배경">배경</h2>
<p>Virgin Road는 신랑/신부가 방을 만들면 하객들이 편지를 남기는 서비스다. 방을 만들 때 이름, 결혼 날짜, 이메일을 입력한다. 이 이메일이 나중에 PDF 발송 대상이 된다.</p>
<p>초기 구현은 단순했다. 방 생성 요청이 오면 바로 DB에 INSERT하고 완료. 그런데 서비스를 조금 더 생각해보니 문제가 보였다.</p>
<hr>
<h2 id="이메일-인증-없이-바로-만들면-생기는-문제">이메일 인증 없이 바로 만들면 생기는 문제</h2>
<p>이메일 소유 검증이 없으면 <strong>아무나 타인의 이메일로 방을 만들 수 있다.</strong></p>
<p>실제로 이메일 주인이 나중에 방을 만들려고 하면 &quot;이미 존재합니다&quot; 에러만 뜬다. 본인이 만든 것도 아닌데 서비스를 쓸 수 없게 된다. 결혼식 당일 PDF도 엉뚱한 이메일로 발송된다.</p>
<p>이메일 인증을 추가하기로 했다.</p>
<hr>
<h2 id="처음-구현한-인증-플로우의-문제">처음 구현한 인증 플로우의 문제</h2>
<p>처음엔 방을 먼저 만들고(ACTIVE), 이후에 인증하는 구조로 짰다. 그런데 이러면 인증 전에 이미 방이 활성화된 상태라 하객들이 편지를 쓸 수 있었다. 인증을 안 해도 방이 동작하는 것이다.</p>
<p>방 생성과 인증 완료를 묶어야 했다.</p>
<hr>
<h2 id="inactive-→-active-2단계-플로우">INACTIVE → ACTIVE 2단계 플로우</h2>
<p>방 생성을 2단계로 나눴다.</p>
<pre><code>POST /rooms         →  INACTIVE 방 생성 + 인증 코드 이메일 발송
POST /rooms/verify  →  인증 코드 확인 + ACTIVE 활성화</code></pre><p><strong>1단계</strong>: DB에 방을 <code>INACTIVE</code> 상태로만 저장한다. 아직 인증이 안 된 미완성 방이다.
인증 코드를 생성해서 입력한 이메일로 발송한다.</p>
<p><strong>2단계</strong>: 인증 코드가 맞으면 방을 <code>ACTIVE</code>로 전환한다. 이 시점부터 하객들이 편지를 쓸 수 있다. 개설 완료 안내 메일도 함께 발송한다.</p>
<pre><code class="language-java">@Transactional
public RoomCreatedResponse verifyAndActivate(VerifyCodeRequest request) {
    emailVerificationService.verify(request.getRoomCode(), request.getAuthCode());

    Room room = roomRepository.findByRoomCode(request.getRoomCode())
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.ROOM_NOT_FOUND));

    room.activate(); // INACTIVE → ACTIVE

    String shareUrl = frontendUrl + &quot;/room/&quot; + room.getRoomCode();
    mailService.sendRoomCreated(room.getEmail(), groomName, brideName, shareUrl);
    ...
}</code></pre>
<hr>
<h2 id="이메일-중복-처리">이메일 중복 처리</h2>
<p>같은 이메일로 방을 여러 번 만들려는 케이스를 처리해야 했다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>처리</th>
</tr>
</thead>
<tbody><tr>
<td>ACTIVE 방이 이미 있음</td>
<td>거부 (이미 운영 중인 방)</td>
</tr>
<tr>
<td>INACTIVE 방이 있음</td>
<td>재사용 (정보 업데이트 + 인증 코드 재발송)</td>
</tr>
<tr>
<td>없음</td>
<td>새로 생성</td>
</tr>
</tbody></table>
<p>INACTIVE를 재사용하는 이유는, 인증 도중 이탈했다가 다시 시도하는 케이스 때문이다. 매번 새로 INSERT하면 INACTIVE 레코드가 계속 쌓인다. 같은 이메일로 만든 INACTIVE 방은 어차피 동일 인물이므로 덮어쓰는 게 맞다.</p>
<pre><code class="language-java">Room room = roomRepository.findByEmailAndStatus(request.getEmail(), Room.RoomStatus.INACTIVE)
        .map(existing -&gt; {
            existing.updateInfo(
                    request.getGroomFirstName(),
                    request.getGroomLastName(),
                    request.getBrideFirstName(),
                    request.getBrideLastName(),
                    request.getWeddingDate()
            );
            return existing; // roomCode, email은 유지
        })
        .orElseGet(() -&gt; {
            Room newRoom = Room.builder()
                    .roomCode(generateUniqueRoomCode())
                    ...
                    .build();
            return roomRepository.save(newRoom);
        });</code></pre>
<hr>
<h2 id="상태-정의">상태 정의</h2>
<pre><code class="language-java">public enum RoomStatus {
    INACTIVE, // 이메일 인증 대기 중
    ACTIVE,   // 정상 운영 중 (하객 편지 가능)
    CLOSED    // 결혼식 종료 후 (스케줄러가 전환)
}</code></pre>
<p><code>CLOSED</code>는 결혼식 당일 스케줄러가 PDF 발송 후 자동으로 전환한다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>인증 없이 바로 방을 만드는 건 빠르게 구현할 수 있지만, 이메일 소유 검증이 빠져서 실제 서비스에서 문제가 생긴다. INACTIVE 상태를 두고 인증 완료 시 ACTIVE로 전환하는 방식으로 해결했다. 덤으로 INACTIVE 재사용 로직 덕분에 중복 레코드 쌓임도 막을 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이메일 인증을 DB에서 Redis로 바꾼 이유]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D%EC%9D%84-DB%EC%97%90%EC%84%9C-Redis%EB%A1%9C-%EB%B0%94%EA%BE%BC-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D%EC%9D%84-DB%EC%97%90%EC%84%9C-Redis%EB%A1%9C-%EB%B0%94%EA%BE%BC-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 24 Mar 2026 02:22:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/c4129c61-ae25-407b-8098-6b5467d5abd7/image.png" alt=""></p>
<h2 id="배경">배경</h2>
<p>Virgin Road는 신랑/신부가 방을 만들 때 이메일 인증을 거친다. 인증 코드를 이메일로 보내고, 5분 안에 입력하면 방이 활성화되는 구조다.</p>
<p>처음 구현할 때 인증 코드를 어디에 저장할지 고민했다. 가장 빠른 선택은 DB 테이블이었다. 이미 MySQL을 쓰고 있으니까 테이블 하나 추가하면 끝이었다.</p>
<hr>
<h2 id="db로-처음-구현했을-때">DB로 처음 구현했을 때</h2>
<p><code>email_verification</code> 테이블을 만들었다.</p>
<pre><code class="language-sql">CREATE TABLE email_verification (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    room_id BIGINT NOT NULL,
    auth_code VARCHAR(6) NOT NULL,
    attempts INT DEFAULT 0,
    created_at DATETIME NOT NULL
);</code></pre>
<p>인증 코드 발급 시 INSERT, 인증 완료 시 DELETE. 동작은 했다.</p>
<p>근데 만료 처리가 문제였다. DB에는 TTL 개념이 없으니 직접 구현해야 했다. 조회할 때마다 <code>created_at + 5분 &lt; now()</code> 조건을 달거나, 별도 스케줄러로 주기적으로 만료된 레코드를 지워야 했다.</p>
<p>5분짜리 임시 데이터를 영구 저장소에 넣고, 그걸 지우기 위해 스케줄러까지 돌려야 하는 구조가 점점 이상하게 느껴졌다.</p>
<hr>
<h2 id="redis로-전환">Redis로 전환</h2>
<p>Redis는 TTL을 네이티브로 지원한다. 키를 저장할 때 만료 시간을 같이 설정하면 알아서 사라진다. 스케줄러도 필요 없고, 만료 로직도 없다.</p>
<p><code>email_verification</code> 테이블과 엔티티를 아예 날리고 Redis로 전환했다. Flyway 마이그레이션으로 테이블도 DROP했다.</p>
<pre><code class="language-sql">-- V7__drop_email_verification_table.sql
DROP TABLE IF EXISTS email_verification;</code></pre>
<p>Redis 키 구조는 Hash로 잡았다.</p>
<pre><code>key   : verification:{roomCode}
field : authCode  → &quot;382910&quot;
        attempts  → &quot;0&quot;
TTL   : 5분</code></pre><p>이메일이 아닌 <code>roomCode</code>를 키로 쓴 이유는, 같은 이메일로 방을 여러 번 만드는 케이스에서 키 충돌이 생기기 때문이다. 방마다 고유한 roomCode가 있으니 이걸 키로 쓰는 게 더 자연스럽다.</p>
<pre><code class="language-java">public String createVerification(String roomCode) {
    String authCode = generateAuthCode();
    String key = KEY_PREFIX + roomCode;

    redisTemplate.opsForHash().putAll(key, Map.of(
            FIELD_AUTH_CODE, authCode,
            FIELD_ATTEMPTS, &quot;0&quot;
    ));
    redisTemplate.expire(key, EXPIRY); // 5분 TTL

    return authCode;
}</code></pre>
<hr>
<h2 id="시도-횟수-제한도-자연스럽게">시도 횟수 제한도 자연스럽게</h2>
<p>5회 이상 틀리면 더 이상 시도 못하게 막는다. Redis Hash의 <code>increment</code>로 처리한다.</p>
<pre><code class="language-java">public void verify(String roomCode, String authCode) {
    String key = KEY_PREFIX + roomCode;
    Map&lt;Object, Object&gt; data = redisTemplate.opsForHash().entries(key);

    if (data.isEmpty()) {
        throw new BusinessException(ErrorCode.VERIFICATION_NOT_FOUND); // 만료 또는 미존재
    }

    int attempts = Integer.parseInt((String) data.get(FIELD_ATTEMPTS));
    if (attempts &gt;= MAX_ATTEMPTS) {
        throw new BusinessException(ErrorCode.VERIFICATION_MAX_ATTEMPTS);
    }

    redisTemplate.opsForHash().increment(key, FIELD_ATTEMPTS, 1);

    String storedAuthCode = (String) data.get(FIELD_AUTH_CODE);
    if (!storedAuthCode.equals(authCode)) {
        throw new BusinessException(ErrorCode.VERIFICATION_INVALID_CODE);
    }

    redisTemplate.delete(key); // 인증 성공 시 즉시 삭제
}</code></pre>
<p>TTL이 지나면 키 자체가 사라지기 때문에 <code>data.isEmpty()</code> 하나로 &quot;만료됨&quot;과 &quot;존재하지 않음&quot;을 동시에 처리할 수 있다. DB였으면 조건이 두 개였을 것이다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th></th>
<th>DB</th>
<th>Redis</th>
</tr>
</thead>
<tbody><tr>
<td>TTL 관리</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>는 Redis가 맞다. DB로 구현하면 TTL을 흉내 내는 코드가 생기고, 그걸 관리하기 위한 코드가 또 생긴다. 전환하고 나서 <code>EmailVerification</code> 엔티티, 레포지토리, 스케줄러 관련 코드가 전부 사라졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Virgin Road - 결혼식 축하 편지 타임캡슐 서비스를 만들었다]]></title>
            <link>https://velog.io/@junyoung-choe/Virgin-Road-%EA%B2%B0%ED%98%BC%EC%8B%9D-%EC%B6%95%ED%95%98-%ED%8E%B8%EC%A7%80-%ED%83%80%EC%9E%84%EC%BA%A1%EC%8A%90-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@junyoung-choe/Virgin-Road-%EA%B2%B0%ED%98%BC%EC%8B%9D-%EC%B6%95%ED%95%98-%ED%8E%B8%EC%A7%80-%ED%83%80%EC%9E%84%EC%BA%A1%EC%8A%90-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Tue, 24 Mar 2026 02:19:40 GMT</pubDate>
            <description><![CDATA[<h2 id="어떤-서비스인가">어떤 서비스인가</h2>
<p><strong>Virgin Road</strong>는 결혼식 축하 편지 타임캡슐 서비스다.</p>
<p>신랑/신부가 링크를 만들어 지인들에게 공유하면, 하객들이 편지를 남긴다. 편지는 결혼식 당일 밤까지 잠겨 있다가 그날 한꺼번에 공개된다. 편지들은 PDF 레터북으로도 받을 수 있다.</p>
<ul>
<li>서비스 주소: <a href="https://virginroad.store">https://virginroad.store</a></li>
<li>인스타그램: <a href="https://www.instagram.com/virginroad_official">https://www.instagram.com/virginroad_official</a></li>
<li>노션 페이지: <a href="https://wooden-route-526.notion.site/Virgin-road-30f1aa61352680759139fd71233b8ce1?source=copy_link">https://wooden-route-526.notion.site/Virgin-road-30f1aa61352680759139fd71233b8ce1?source=copy_link</a></li>
</ul>
<hr>
<h2 id="왜-만들었나">왜 만들었나</h2>
<p>결혼식에 가본 사람이라면 알겠지만, 모바일 청첩장 댓글은 대부분 이렇다.</p>
<blockquote>
<p>&quot;축하합니다&quot;
&quot;잘 사세요&quot;
&quot;행복하세요&quot;</p>
</blockquote>
<p>누구나 볼 수 있는 공개된 공간이니까 자연스럽게 형식적인 말들만 남게 된다. 정작 친한 친구, 오래 알고 지낸 사람들도 다를 바 없다.</p>
<p>그리고 결혼식 당일은 정신이 없다. 하객 한 명 한 명의 축하를 제대로 받지 못하고 지나치는 경우가 많다. 그날 받은 축하를 나중에 차분하게 돌아볼 방법도 없다.</p>
<p>이 두 가지가 아이디어의 출발이었다.</p>
<p>신랑/신부만 볼 수 있는 공간이라면 사람들이 더 진심을 담아 쓰지 않을까. 결혼식 당일 밤, 정신없는 하루가 끝나고 나서 차분하게 편지들을 열어보면 어떨까.</p>
<hr>
<h2 id="기능-정리">기능 정리</h2>
<p><strong>방 만들기</strong></p>
<ul>
<li>이름, 결혼 날짜, 이메일을 입력하면 고유한 공유 링크가 생성된다.</li>
<li>이메일 인증으로 본인 확인을 한다.</li>
</ul>
<p><strong>편지 받기</strong></p>
<ul>
<li>링크를 공유받은 하객들이 편지를 작성한다.</li>
<li>작성자 이름은 보이지만, 편지 내용은 결혼식 당일까지 잠겨 있다.</li>
</ul>
<p><strong>결혼식 당일</strong></p>
<ul>
<li>스케줄러가 그날 결혼식인 방을 조회해서 편지들을 PDF로 만든다.</li>
<li>신랑/신부 이메일로 PDF 레터북을 발송한다.</li>
</ul>
<hr>
<h2 id="아키텍처">아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/33870532-d868-4a73-a84d-eb68827edb05/image.jpg" alt=""></p>
<h2 id="기술-스택">기술 스택</h2>
<table>
<thead>
<tr>
<th>영역</th>
<th>스택</th>
</tr>
</thead>
<tbody><tr>
<td>백엔드</td>
<td>Spring Boot 3.4, Java 17, MySQL, Redis</td>
</tr>
<tr>
<td>PDF 생성</td>
<td>Node.js (Express + react-pdf)</td>
</tr>
<tr>
<td>인프라</td>
<td>Docker, Caddy, Cloudflare Tunnel, GitHub Actions</td>
</tr>
<tr>
<td>배포</td>
<td>Mac Mini 홈 서버</td>
</tr>
</tbody></table>
<p>백엔드는 Spring Boot로, PDF 생성은 별도 Node.js 서버로 분리했다. 인프라는 클라우드 서버 없이 Mac Mini 홈 서버로 운영하고 있다.</p>
<hr>
<h2 id="앞으로">앞으로</h2>
<p>처음부터 완벽한 건 아니고 써보면서 피드백을 받아 계속 개선하고 있다. 현재는 완전 무료 서비스다.</p>
<p>다음 글들에서 개발하면서 고민했던 부분들을 정리해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Caddy 리로드 실패 - inode 교체 문제 분석]]></title>
            <link>https://velog.io/@junyoung-choe/Caddy-%EB%A6%AC%EB%A1%9C%EB%93%9C-%EC%8B%A4%ED%8C%A8-inode-%EA%B5%90%EC%B2%B4-%EB%AC%B8%EC%A0%9C-%EB%B6%84%EC%84%9D</link>
            <guid>https://velog.io/@junyoung-choe/Caddy-%EB%A6%AC%EB%A1%9C%EB%93%9C-%EC%8B%A4%ED%8C%A8-inode-%EA%B5%90%EC%B2%B4-%EB%AC%B8%EC%A0%9C-%EB%B6%84%EC%84%9D</guid>
            <pubDate>Fri, 20 Mar 2026 07:57:06 GMT</pubDate>
            <description><![CDATA[<p>홈 서버 구축 시리즈를 진행하면서 만난 문제를 따로 정리했다.
시리즈가 궁금하다면 → <a href="https://velog.io/@junyoung-choe/series/home-server">홈 서버 구축 일지</a></p>
<h2 id="증상">증상</h2>
<pre><code>Error: reading config from file: open /etc/caddy/Caddyfile: no such file or directory</code></pre><ul>
<li>헬스체크는 통과</li>
<li>Caddy 리로드에서 실패</li>
<li>롤백에서는 동일한 명령이 성공</li>
<li>백엔드 배포는 됐는데 프론트엔드 배포는 안 됨</li>
</ul>
<p>증상이 일관성이 없어서 처음엔 원인을 찾기 어려웠다.</p>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<h3 id="1단계---git-reset---hard">1단계 - git reset --hard</h3>
<p>GitHub Actions의 Sync server 단계에서 시작된다.</p>
<pre><code class="language-bash">git reset --hard origin/main</code></pre>
<p><code>git reset --hard</code>는 파일을 새로 작성하면서 Caddyfile의 inode(파일 고유번호)를 교체한다.</p>
<hr>
<h3 id="2단계---docker-bind-mount-구조">2단계 - Docker bind mount 구조</h3>
<pre><code class="language-yaml"># static/docker-compose.yml
volumes:
  - ./caddy/:/etc/caddy/:ro</code></pre>
<pre><code>Caddy 컨테이너
→ 호스트의 ./caddy/ 디렉토리를 /etc/caddy/에 bind mount
→ Docker bind mount는 inode를 기준으로 파일 추적</code></pre><hr>
<h3 id="3단계---inode-교체-→-컨테이너에서-파일이-사라짐">3단계 - inode 교체 → 컨테이너에서 파일이 사라짐</h3>
<pre><code>git reset --hard 실행 전
→ Caddyfile inode #12345

git reset --hard 실행 후
→ Caddyfile inode #99999 (새 파일 생성)

컨테이너 내부
→ inode #12345를 참조하고 있었는데 사라짐
→ /etc/caddy/Caddyfile: no such file or directory</code></pre><p>VirtioFS(OrbStack의 파일시스템 공유 방식)에서 새 inode가 컨테이너에 propagation되는 데 수초의 지연이 발생한다.</p>
<hr>
<h3 id="4단계---왜-백엔드는-됐고-프론트엔드는-안-됐나">4단계 - 왜 백엔드는 됐고 프론트엔드는 안 됐나?</h3>
<table>
<thead>
<tr>
<th></th>
<th>백엔드</th>
<th>프론트엔드</th>
</tr>
</thead>
<tbody><tr>
<td>빌드 방식</td>
<td>Gradle (느림)</td>
<td>Vite (빠름, 캐시됨)</td>
</tr>
<tr>
<td>git reset → caddy reload 사이 시간</td>
<td>길다 → propagation 완료</td>
<td>짧다 → propagation 미완료</td>
</tr>
<tr>
<td>결과</td>
<td>우연히 성공</td>
<td>실패</td>
</tr>
</tbody></table>
<p>롤백에서 성공한 이유도 같은 맥락이다.</p>
<p>롤백 실행 시점에는 이미 propagation이 완료돼 있었기 때문이다.</p>
<hr>
<h2 id="해결">해결</h2>
<p>컨테이너 내부 파일 경로에 의존하지 않고, 호스트 파일을 stdin으로 직접 주입하는 방식으로 바꿨다.</p>
<pre><code class="language-bash"># 변경 전 - 컨테이너 내부 파일 경로 참조 (inode 문제 영향 받음)
docker exec virgin-road-caddy caddy reload --config /etc/caddy/Caddyfile

# 변경 후 - 호스트 파일을 stdin으로 직접 전달 (inode 무관)
docker exec -i virgin-road-caddy caddy reload --config - --adapter caddyfile &lt; &quot;$CADDY_FILE&quot;</code></pre>
<p><code>--config -</code> 옵션은 Caddy가 파일 경로 대신 stdin에서 설정을 읽도록 하는 옵션이다.</p>
<p>호스트에서 직접 읽어서 주입하기 때문에 Docker bind mount 상태와 완전히 무관하다.</p>
<p>Linux 환경에서는 bind mount 기반 <code>--config /etc/caddy/Caddyfile</code>이 표준이지만, Mac(OrbStack) 환경에서는 VirtioFS 특성상 이 방식이 잘 안 되는 경우가 있다.</p>
<p>참고로 stdin으로 Caddy 리로드하는 건 공식 지원이다.</p>
<p>Caddy 공식 문서에 <code>--config -</code> (stdin으로 읽기)가 명시되어 있으니 이상한 방법을 쓴 게 아니다 !</p>
<hr>
<h2 id="핵심-교훈">핵심 교훈</h2>
<pre><code>Docker bind mount는 inode 기준으로 파일 추적
→ git reset --hard, sed -i &#39;&#39; 등 inode를 교체하는 명령어 주의

해결 원칙
→ 컨테이너 내부 파일 경로에 의존하지 말 것
→ 호스트에서 직접 stdin으로 주입하는 방식 사용</code></pre><p>증상이 비일관적으로 나타나면 타이밍 이슈를 의심해보자.</p>
<p>이번처럼 빌드 속도 차이 때문에 어떤 배포는 되고 어떤 배포는 안 되는 경우가 생긴다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[홈 서버 구축 일지 (5) - Cloudflare 보안 규칙 설정]]></title>
            <link>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-5-Cloudflare-%EB%B3%B4%EC%95%88-%EA%B7%9C%EC%B9%99-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-5-Cloudflare-%EB%B3%B4%EC%95%88-%EA%B7%9C%EC%B9%99-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Fri, 20 Mar 2026 07:54:32 GMT</pubDate>
            <description><![CDATA[<p>도메인 연결, 자동 배포까지 다 됐으니 마지막으로 보안을 잡았다.</p>
<p>Cloudflare Free 플랜에서 설정할 수 있는 규칙들로 충분히 기본적인 방어가 가능하다.</p>
<p>오늘 적용한 건 세 가지다.</p>
<ul>
<li>악성 봇 / 스캐너 차단</li>
<li>XSS 방어</li>
<li>한국 외 API 요청 차단</li>
<li>스팸 방지 Rate Limiting</li>
</ul>
<hr>
<h2 id="공통-설정-방법">공통 설정 방법</h2>
<p>모든 규칙은 아래 경로에서 만든다.</p>
<pre><code>Cloudflare 대시보드
→ Security → Security rules
→ 규칙 생성
→ 규칙 이름 입력
→ Edit expression → 기존 내용 지우고 표현식 붙여넣기
→ Action → Block
→ Deploy</code></pre><hr>
<h2 id="사용자-지정-규칙-custom-rules">사용자 지정 규칙 (Custom Rules)</h2>
<p>규칙 순서가 중요하다.</p>
<p>반드시 1 → 2 → 3 순서를 유지해야 한다.</p>
<hr>
<h3 id="규칙-1--악성-봇--스캐너-차단">규칙 1 — 악성 봇 / 스캐너 차단</h3>
<p><code>sqlmap</code>, <code>nikto</code>, <code>nmap</code> 같은 공격 도구들은 User-Agent에 자신의 이름을 그대로 노출하는 경우가 많다.</p>
<p>User-Agent가 아예 비어있는 요청도 같이 차단했다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>규칙 이름</td>
<td>악성 봇/스캐너 차단</td>
</tr>
<tr>
<td>액션</td>
<td>Block</td>
</tr>
</tbody></table>
<pre><code>(http.user_agent contains &quot;sqlmap&quot;) or
(http.user_agent contains &quot;nikto&quot;) or
(http.user_agent contains &quot;nmap&quot;) or
(http.user_agent eq &quot;&quot;)</code></pre><hr>
<h3 id="규칙-2--xss-방어">규칙 2 — XSS 방어</h3>
<p>URL 쿼리스트링에 스크립트 관련 문자열이 들어오는 요청을 차단한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>규칙 이름</td>
<td>XSS 방어</td>
</tr>
<tr>
<td>액션</td>
<td>Block</td>
</tr>
</tbody></table>
<pre><code>(http.request.uri.query contains &quot;&lt;script&quot;) or
(http.request.uri.query contains &quot;javascript:&quot;) or
(http.request.uri.query contains &quot;onerror=&quot;)</code></pre><p>Free 플랜은 request body 검사가 불가능하다.</p>
<p>그래서 URL 쿼리스트링 기반의 GET 방식 XSS만 차단된다는 한계가 있다.</p>
<hr>
<h3 id="규칙-3--한국-외-api-차단">규칙 3 — 한국 외 API 차단</h3>
<p>서비스 특성상 한국 사용자만 이용한다.</p>
<p><code>/api</code> 경로로 들어오는 요청 중 한국 이외의 국가 IP는 전부 차단했다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>규칙 이름</td>
<td>한국 외 API 차단</td>
</tr>
<tr>
<td>액션</td>
<td>Block</td>
</tr>
</tbody></table>
<pre><code>(http.request.uri.path contains &quot;/api&quot;) and
(not ip.geoip.country in {&quot;KR&quot;})</code></pre><hr>
<h2 id="속도-제한-규칙-rate-limiting-rules">속도 제한 규칙 (Rate Limiting Rules)</h2>
<p>Security rules 페이지 내 <strong>Rate limiting rules</strong> 탭에서 별도로 만든다.</p>
<hr>
<h3 id="스팸-방지">스팸 방지</h3>
<p>방명록, 편지 작성 같은 POST 요청이 반복적으로 들어오는 걸 막는다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>규칙 이름</td>
<td>스팸 방지</td>
</tr>
<tr>
<td>요청 수</td>
<td>2회</td>
</tr>
<tr>
<td>기간</td>
<td>10초 (Free 플랜 고정값)</td>
</tr>
<tr>
<td>액션</td>
<td>Block</td>
</tr>
<tr>
<td>차단 기간</td>
<td>선택 가능한 가장 긴 시간</td>
</tr>
</tbody></table>
<pre><code>(http.request.method eq &quot;POST&quot;) and
(
  (http.request.uri.path eq &quot;/api/rooms&quot;) or
  (http.request.uri.path eq &quot;/api/rooms/recover&quot;) or
  (http.request.uri.path contains &quot;/letters&quot;)
)</code></pre><p>10초에 2회 제한이라 실수로 버튼을 한 번 더 눌러도 허용된다.</p>
<p>봇처럼 반복적으로 쏘는 요청만 걸린다 !</p>
<hr>
<p>최종 적용한 보안규칙 설정 화면이다.
<img src="https://velog.velcdn.com/images/junyoung-choe/post/6be349b7-86c8-412d-b846-28e5c27f475f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/354e8629-6a6e-4d4d-9e51-9b5a7c9facd6/image.png" alt=""></p>
<hr>
<h2 id="최종-규칙-목록">최종 규칙 목록</h2>
<table>
<thead>
<tr>
<th>순서</th>
<th>규칙 이름</th>
<th>종류</th>
<th>액션</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>악성 봇/스캐너 차단</td>
<td>Custom Rule</td>
<td>Block</td>
</tr>
<tr>
<td>2</td>
<td>XSS 방어</td>
<td>Custom Rule</td>
<td>Block</td>
</tr>
<tr>
<td>3</td>
<td>한국 외 API 차단</td>
<td>Custom Rule</td>
<td>Block</td>
</tr>
<tr>
<td>4</td>
<td>스팸 방지</td>
<td>Rate Limiting</td>
<td>Block</td>
</tr>
</tbody></table>
<hr>
<p>Cloudflare Free 플랜만으로도 기본적인 공격은 충분히 막을 수 있다.</p>
<p>Mac Mini 홈 서버 구축 시리즈는 여기서 마무리다.</p>
<p>도메인 연결부터 자동 배포, 보안까지 생각보다 완성도 있는 서버가 만들어졌다.</p>
<p>집에서 돌아가는 내 서버에 내 서비스가 올라가 있다는 게 아직도 신기하다 !</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[홈 서버 구축 일지 (4) - SSH 키 인증 & 배포 디버깅]]></title>
            <link>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-4-SSH-%ED%82%A4-%EC%9D%B8%EC%A6%9D-%EB%B0%B0%ED%8F%AC-%EB%94%94%EB%B2%84%EA%B9%85-twk6540f</link>
            <guid>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-4-SSH-%ED%82%A4-%EC%9D%B8%EC%A6%9D-%EB%B0%B0%ED%8F%AC-%EB%94%94%EB%B2%84%EA%B9%85-twk6540f</guid>
            <pubDate>Fri, 20 Mar 2026 07:49:56 GMT</pubDate>
            <description><![CDATA[<p>3편에서 Cloudflare Tunnel 연결과 GitHub Actions 자동 배포 기반을 만들었다.</p>
<p>오늘은 실제로 배포를 돌려보면서 만난 문제들을 하나씩 해결한 과정을 정리한다.</p>
<p>SSH 키 인증 설정부터, docker 명령어 못 찾는 문제, Caddy 리로드 실패까지 삽질이 꽤 있었다.</p>
<hr>
<h2 id="ssh-키-인증이-왜-필요한가">SSH 키 인증이 왜 필요한가</h2>
<p>자동 배포를 구성하다 보면 SSH 접속 방식을 다시 생각하게 된다.</p>
<p>평소에 터미널에서 Mac Mini에 접속할 때는 비밀번호로도 충분하다.</p>
<p>Tailscale VPN 안에 있으니 내부망처럼 동작하고, 사람이 직접 입력하면 그만이다.</p>
<p>문제는 GitHub Actions다.</p>
<pre><code>GitHub Actions SSH 접속
→ 완전히 새로운 세션
→ macOS 키체인 접근 불가
→ 저장된 인증정보 못 읽음
→ 비밀번호 방식 불가
→ SSH 키 방식 필수</code></pre><p>그래서 두 가지 키를 만들었다.</p>
<ul>
<li><strong>Mac Mini용 GitHub SSH 키</strong> → Mac Mini가 GitHub에서 코드를 pull 할 때 사용</li>
<li><strong>GitHub Actions용 SSH 키</strong> → GitHub Actions가 Mac Mini에 SSH 접속할 때 사용</li>
</ul>
<hr>
<h2 id="ssh-키-설정-과정">SSH 키 설정 과정</h2>
<h3 id="mac-mini-→-github-키-설정">Mac Mini → GitHub 키 설정</h3>
<pre><code class="language-bash"># 키 생성
ssh-keygen -t ed25519 -C &quot;macmini-github&quot; -f ~/.ssh/github
# 패스프레이즈 없이 엔터 2번

# 공개키 확인
cat ~/.ssh/github.pub</code></pre>
<p>공개키를 GitHub에 등록한다.</p>
<pre><code>github.com → Settings → SSH and GPG keys → New SSH key → 공개키 붙여넣기</code></pre><p>SSH Config도 잡아준다.</p>
<pre><code class="language-bash">cat &gt;&gt; ~/.ssh/config &lt;&lt; &#39;EOF&#39;
Host github.com
    IdentityFile ~/.ssh/github
    User git
EOF</code></pre>
<p>원격 URL을 HTTPS에서 SSH 방식으로 바꾼다.</p>
<pre><code class="language-bash">cd ~/Desktop/git/virgin-road
git remote set-url origin git@github.com:zerogoons/virgin-road.git</code></pre>
<hr>
<h3 id="github-actions-→-mac-mini-키-설정">GitHub Actions → Mac Mini 키 설정</h3>
<pre><code class="language-bash"># 배포 전용 키 생성
ssh-keygen -t ed25519 -C &quot;github-actions&quot; -f ~/.ssh/github_actions
# 패스프레이즈 없이 엔터 2번

# 공개키를 Mac Mini authorized_keys에 등록
cat ~/.ssh/github_actions.pub &gt;&gt; ~/.ssh/authorized_keys

# 개인키 확인 (GitHub Secrets에 등록할 것)
cat ~/.ssh/github_actions</code></pre>
<p>GitHub 레포 Secrets에 등록한다.</p>
<pre><code>GitHub 레포 → Settings → Secrets and variables → Actions

SERVER_IP          →  Tailscale IP
SSH_PRIVATE_KEY    →  github_actions 개인키 전체 내용
TAILSCALE_AUTH_KEY →  Tailscale 인증 키</code></pre><hr>
<h2 id="삽질-기록">삽질 기록</h2>
<p>키 설정을 마치고 실제 배포를 돌렸더니 에러가 줄줄 나왔다.</p>
<hr>
<h3 id="오류-1-github-인증-실패">오류 1. GitHub 인증 실패</h3>
<pre><code>fatal: could not read Username for &#39;https://github.com&#39;: Device not configured</code></pre><p>원인은 간단했다.</p>
<p>기존 원격 URL이 HTTPS 방식이었고, GitHub Actions SSH 세션에서는 인증 정보가 없다.</p>
<pre><code class="language-bash"># SSH 방식으로 변경
git remote set-url origin git@github.com:zerogoons/virgin-road.git</code></pre>
<hr>
<h3 id="오류-2-host-key-verification-failed">오류 2. Host key verification failed</h3>
<pre><code>Host key verification failed.
fatal: Could not read from remote repository.</code></pre><p>Mac Mini가 GitHub 서버에 처음 접속하는 경우, <code>known_hosts</code>에 GitHub 서버 정보가 없어서 생기는 문제다.</p>
<pre><code class="language-bash">ssh-keyscan github.com &gt;&gt; ~/.ssh/known_hosts</code></pre>
<hr>
<h3 id="오류-3-remote-ref-main-못-찾음">오류 3. remote ref main 못 찾음</h3>
<pre><code>fatal: couldn&#39;t find remote ref main</code></pre><p>기본 브랜치가 <code>dev</code>로 설정되어 있어서 <code>origin/main</code> 트래킹이 안 된 상태였다.</p>
<pre><code class="language-bash">git branch --set-upstream-to=origin/main main</code></pre>
<hr>
<h3 id="오류-4-docker-command-not-found">오류 4. docker command not found</h3>
<pre><code>deploy-blue-green.sh: line 9: docker: command not found</code></pre><p>이게 가장 시간을 많이 잡아먹었다.</p>
<p>OrbStack은 docker를 <code>/usr/local/bin/docker</code>에 심볼릭 링크로 설치한다.</p>
<p>터미널에서 직접 접속하면 <code>~/.zshrc</code>가 로드되면서 PATH가 잡히는데, GitHub Actions SSH 세션은 다르다.</p>
<pre><code>터미널 직접 로그인 (Interactive Shell)
→ ~/.zshrc, ~/.zprofile 전부 로드
→ PATH 정상 로드
→ docker 명령어 인식

GitHub Actions SSH (Non-Interactive Shell)
→ ~/.zshrc, ~/.zprofile 로드 안 함
→ PATH 기본값만 로드
→ docker 명령어 인식 불가</code></pre><p>시도해본 방법들이 다 막혔다.</p>
<p><code>.zshrc</code>, <code>.bashrc</code>, <code>.profile</code>에 PATH 추가해봤지만 SSH 비로그인 세션은 이 파일들을 읽지 않는다.</p>
<p><code>/usr/bin/docker</code>에 심볼릭 링크를 만들려고 했더니 macOS SIP(System Integrity Protection)가 막아버린다.</p>
<pre><code class="language-bash">sudo ln -sf /Applications/OrbStack.app/Contents/MacOS/xbin/docker /usr/bin/docker
# ln: /usr/bin/docker: Operation not permitted</code></pre>
<p><code>/usr/bin</code>은 macOS 시스템 전용 디렉토리라 건드리면 안 된다.</p>
<p><code>~/.ssh/environment</code>에 PATH 설정도 해봤지만 macOS SSH 환경에서 신뢰되지 않는다.</p>
<p>결국 가장 단순한 방법이 정답이었다.</p>
<p>스크립트 첫 줄에 PATH를 직접 박아버리는 것이다.</p>
<pre><code class="language-bash">#!/bin/sh
export PATH=&quot;/usr/local/bin:/opt/homebrew/bin:$PATH&quot;
set -e</code></pre>
<p>외부 환경에 의존하지 않으니 어느 세션에서 실행해도 동작한다 !</p>
<hr>
<h3 id="오류-5-caddy-리로드-실패">오류 5. Caddy 리로드 실패</h3>
<pre><code>Error: reading config from file: open /etc/caddy/Caddyfile: no such file or directory</code></pre><p>헬스체크는 통과했는데 Caddy 리로드에서 실패했다.</p>
<p>롤백할 때는 같은 명령이 성공해서 더 헷갈렸다.</p>
<p>원인은 macOS의 <code>sed -i &#39;&#39;</code> 동작 방식이었다.</p>
<pre><code>sed -i &#39;&#39; 실행 시
→ 새 파일 생성 (inode 변경)
→ Docker bind mount는 inode 기준으로 파일 추적
→ 컨테이너 안에서 파일이 잠깐 &quot;없는&quot; 상태
→ VirtioFS propagation 지연 (수십ms ~ 수초)
→ 롤백 때 성공한 이유: 그 사이에 propagation 완료됨</code></pre><p>inode를 유지하면서 파일 내용만 교체하는 방식으로 바꿨다.</p>
<pre><code class="language-bash"># 기존 (inode 교체됨)
sed -i &#39;&#39; &quot;s/virgin-road-back-$CURRENT/virgin-road-back-$NEXT/&quot; &quot;$CADDY_FILE&quot;

# 수정 (inode 유지)
TMP=$(mktemp)
sed &quot;s/virgin-road-back-$CURRENT/virgin-road-back-$NEXT/&quot; &quot;$CADDY_FILE&quot; &gt; &quot;$TMP&quot;
cat &quot;$TMP&quot; &gt; &quot;$CADDY_FILE&quot;
rm -f &quot;$TMP&quot;</code></pre>
<p><code>cat tmp &gt; original</code> 패턴으로 기존 파일에 덮어쓰면 inode가 바뀌지 않는다 !</p>
<hr>
<h2 id="최종-워크플로우">최종 워크플로우</h2>
<pre><code class="language-yaml">name: Deploy Backend

on:
  push:
    branches: [main]
    paths:
      - &#39;back/**&#39;
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Tailscale 연결
        uses: tailscale/github-action@v2
        with:
          authkey: ${{ secrets.TAILSCALE_AUTH_KEY }}

      - name: Sync server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ****
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd ~/Desktop/git/virgin-road
            git fetch origin
            git checkout main
            git reset --hard origin/main

      - name: Deploy Backend
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ****
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: sh ~/Desktop/git/virgin-road/infra/back/deploy-blue-green.sh</code></pre>
<p><code>git pull</code> 대신 <code>git fetch + git reset --hard</code>를 쓰는 이유도 정리해두자.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>사용 상황</th>
</tr>
</thead>
<tbody><tr>
<td><code>git pull</code></td>
<td>로컬 개발, 변경사항 있을 때</td>
</tr>
<tr>
<td><code>git fetch + git reset --hard</code></td>
<td>서버 배포, 항상 원격과 동일하게 맞출 때</td>
</tr>
</tbody></table>
<p>서버는 코드를 직접 수정하지 않으니까 <code>git reset --hard</code>가 표준이다.</p>
<hr>
<h2 id="오늘의-교훈">오늘의 교훈</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>docker not found</td>
<td>SSH 비로그인 세션에 PATH 없음</td>
<td>스크립트 첫 줄에 PATH 직접 설정</td>
</tr>
<tr>
<td>/usr/bin symlink 불가</td>
<td>macOS SIP 시스템 보호</td>
<td>해당 경로 사용하지 않음</td>
</tr>
<tr>
<td>git pull 실패</td>
<td>HTTPS 인증 없음</td>
<td>SSH 방식으로 변경</td>
</tr>
<tr>
<td>Caddy 리로드 실패</td>
<td>sed -i &#39;&#39;로 inode 교체</td>
<td>cat tmp &gt; original로 inode 유지</td>
</tr>
</tbody></table>
<p>SSH 비로그인 세션은 <code>.zshrc</code>를 읽지 않는다는 것, macOS의 <code>sed -i &#39;&#39;</code>는 inode를 교체한다는 것.</p>
<p>이 두 가지가 오늘의 핵심이었다.</p>
<p>이런 걸 직접 부딪혀봐야 경험이 는다는 걸 또 한번 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[홈 서버 구축 일지 (3) - Cloudflare 도메인 연결 & 자동 배포]]></title>
            <link>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-3-Cloudflare-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-3-Cloudflare-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Fri, 20 Mar 2026 01:55:12 GMT</pubDate>
            <description><![CDATA[<p>2편에서 서버 환경 세팅과 프로젝트 배포까지 마쳤다.</p>
<p>오늘은 두 가지를 한 번에 끝냈다.</p>
<ul>
<li>가비아 도메인을 Cloudflare에 연결해서 외부에서 접근 가능하게 만들기</li>
<li>GitHub Actions로 main 브랜치 push 시 자동 배포 연결</li>
</ul>
<hr>
<h2 id="part-1-cloudflare-도메인-연결">Part 1. Cloudflare 도메인 연결</h2>
<h3 id="전체-흐름">전체 흐름</h3>
<p>일반 방식과 Cloudflare Tunnel 방식의 차이부터 보자.</p>
<table>
<thead>
<tr>
<th></th>
<th>일반 방식</th>
<th>Cloudflare Tunnel</th>
</tr>
</thead>
<tbody><tr>
<td>가비아에서 할 일</td>
<td>A 레코드에 집 IP 등록</td>
<td>없음</td>
</tr>
<tr>
<td>Cloudflare에서 할 일</td>
<td>DNS 관리</td>
<td>네임서버만 변경</td>
</tr>
<tr>
<td>집 IP 노출</td>
<td>노출됨</td>
<td>완전 숨김</td>
</tr>
<tr>
<td>포트포워딩</td>
<td>필요</td>
<td>불필요</td>
</tr>
</tbody></table>
<p>Tunnel 방식을 쓰면 집 IP가 외부에 전혀 노출되지 않는다 !</p>
<p>Tunnel 방식의 흐름은 이렇다.</p>
<pre><code>가비아 → 네임서버를 Cloudflare로 변경
  ↓
Cloudflare Tunnel 생성 (cloudflared)
  ↓
Cloudflare가 자동으로 CNAME 등록
(virginroad.store → tunnel-id.cfargotunnel.com)
  ↓
집 IP 노출 없이 연결 완료</code></pre><hr>
<h3 id="1단계-cloudflare에-도메인-등록">1단계: Cloudflare에 도메인 등록</h3>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/90eedfb4-a4cb-425d-a845-a51dcc0cd59c/image.png" alt=""></p>
<ol>
<li><a href="https://cloudflare.com">https://cloudflare.com</a> 로그인</li>
<li><strong>Add a Site</strong> → <code>virginroad.store</code> 입력</li>
<li>Free 플랜 선택</li>
<li>Cloudflare가 <strong>네임서버 2개</strong>를 알려준다 (<code>xxx.ns.cloudflare.com</code> 형태)
<img src="https://velog.velcdn.com/images/junyoung-choe/post/7febaa46-8afb-4508-b89f-f8658f41b939/image.png" alt=""></li>
</ol>
<blockquote>
<p>Cloudflare 대시보드에서 도메인이 추가된 것을 확인하고, 기존 DNS 레코드는 삭제했다.</p>
</blockquote>
<hr>
<h3 id="2단계-가비아-네임서버-변경">2단계: 가비아 네임서버 변경</h3>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/2bb9d9b6-eda9-4b7b-8331-b197d1b406de/image.png" alt=""></p>
<ol>
<li><a href="https://gabia.com">https://gabia.com</a> 로그인</li>
<li>My가비아 → 도메인 관리 → <code>virginroad.store</code></li>
<li><strong>네임서버 변경</strong> → Cloudflare가 준 네임서버로 교체</li>
<li>저장
<img src="https://velog.velcdn.com/images/junyoung-choe/post/0dcb01bb-fdcb-4b0f-b127-e6b78ae2d02c/image.png" alt=""></li>
</ol>
<blockquote>
<p>가비아 기존 DNS 레코드를 삭제하고, Cloudflare 네임서버를 등록했다.</p>
</blockquote>
<hr>
<h3 id="3단계-cloudflare-active-확인">3단계: Cloudflare Active 확인</h3>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/5429e504-fafd-4441-a6b4-2b9d2f461130/image.png" alt=""></p>
<p>네임서버 전파는 보통 5분 ~ 몇 시간 걸린다.</p>
<p>Cloudflare 대시보드에서 <strong>&quot;Active&quot;</strong> 상태가 뜨면 완료다 !</p>
<blockquote>
<p>Cloudflare 네임서버를 가비아에 등록하니 Active 상태로 바뀐 것을 확인했다.</p>
</blockquote>
<hr>
<h3 id="4단계-cloudflare-tunnel-생성">4단계: Cloudflare Tunnel 생성</h3>
<p>이제 Mac Mini 서버에서 터널을 만든다.</p>
<p><strong>로그인</strong></p>
<pre><code class="language-bash">cloudflared tunnel login</code></pre>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/fe4d4ef5-2eb9-4dea-a622-d4e9c51c1baa/image.png" alt=""></p>
<p>브라우저가 열리면서 Cloudflare 계정 인증을 진행한다.
<img src="https://velog.velcdn.com/images/junyoung-choe/post/6b7dfc1f-d16b-4340-8305-7f84e22628cd/image.png" alt="">
<img src="https://velog.velcdn.com/images/junyoung-choe/post/de5c582b-dfc5-4570-9540-50442dcda9d7/image.png" alt=""></p>
<p>인증이 완료되면 Mac Mini의 <code>~/.cloudflared/cert.pem</code> 경로에 인증키가 생성된다.
<img src="blob:https://velog.io/ce8200c9-a54a-4bd2-90a7-4650b6f211e8" alt="업로드중.."></p>
<blockquote>
<p>해당 경로에 Cloudflare 계정 인증키가 생성된 것을 확인했다.</p>
</blockquote>
<hr>
<p><strong>터널 생성</strong></p>
<pre><code class="language-bash">cloudflared tunnel create virgin-road</code></pre>
<p><img src="blob:https://velog.io/07a88108-4445-49ec-a337-40a27f4e97c4" alt="업로드중.."></p>
<blockquote>
<p>터널 ID와 인증 JSON 파일이 생성된다.</p>
</blockquote>
<hr>
<p><strong>DNS 연결</strong></p>
<pre><code class="language-bash">cloudflared tunnel route dns virgin-road virginroad.store</code></pre>
<p><img src="blob:https://velog.io/41f14072-81c2-4121-8a6c-09b0f2079150" alt="업로드중.."></p>
<blockquote>
<p>Cloudflare DNS에 CNAME 레코드가 자동으로 등록된다.</p>
</blockquote>
<hr>
<p><strong>config 파일 생성</strong></p>
<p><code>~/.cloudflared/config.yml</code> 을 아래처럼 작성했다.</p>
<pre><code class="language-yaml">tunnel: ****-****
credentials-file: /Users/****/.cloudflared/****-****.json

ingress:
  - hostname: virginroad.store
    service: http://localhost:80
  - service: http_status:404</code></pre>
<hr>
<p><strong>터널 실행</strong></p>
<pre><code class="language-bash">cloudflared tunnel run virgin-road</code></pre>
<p><img src="blob:https://velog.io/be1d4744-7bab-4039-ba5a-a3ffce8ffdd6" alt="업로드중.."></p>
<p>실행하면 Cloudflare가 안정성을 위해 자동으로 4개 연결을 만든다.</p>
<pre><code>연결 1 → icn05 (서울 데이터센터)
연결 2 → icn06 (서울 데이터센터)
연결 3 → icn06 (서울 데이터센터)
연결 4 → icn01 (서울 데이터센터)</code></pre><p>하나가 끊겨도 나머지 3개로 유지되는 <strong>고가용성</strong> 구조다.</p>
<p>신경 안 써도 된다 !</p>
<hr>
<h3 id="caddy-에러-수정">Caddy 에러 수정</h3>
<p>터널 실행 후 <code>virginroad.store</code> 에 접속했더니 문제가 생겼다.</p>
<p>Caddy가 HTTP → HTTPS 리다이렉트(308)를 하면서 Cloudflare Tunnel이랑 충돌한 것이다.</p>
<p>Cloudflare Tunnel은 내부적으로 HTTP로 통신하는데, Caddy가 강제로 HTTPS로 밀어버리니까 루프가 생긴다.</p>
<p>해결 방법은 Caddy 설정에서 도메인 기반 리스닝을 포트 기반으로 바꾸는 것이다.</p>
<pre><code class="language-diff">- virginroad.store {
+ :80 {
    reverse_proxy /api/* virgin-road-back-blue:8080
    reverse_proxy /pdf/* virgin-road-pdf:3001
    reverse_proxy * virgin-road-front-blue:80</code></pre>
<p><code>:80</code> 으로 바꾸니까 Cloudflare Tunnel이 정상적으로 연결됐다 !</p>
<hr>
<h3 id="터널-자동-실행-등록">터널 자동 실행 등록</h3>
<p>재부팅하면 터널이 꺼진다.</p>
<p>서비스로 등록해서 자동 실행되게 만들었다.</p>
<pre><code class="language-bash">sudo cloudflared service install</code></pre>
<p><img src="blob:https://velog.io/8fd08980-589d-4b6b-a97e-8e00a8054f6f" alt="업로드중.."></p>
<p>시스템 서비스는 root로 실행되기 때문에 <code>/Users/****/</code> 경로에 접근이 안 된다.</p>
<p>그래서 config와 인증 파일을 시스템 경로로 복사했다.</p>
<pre><code class="language-bash">sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/config.yml /etc/cloudflared/config.yml
sudo cp ~/.cloudflared/****-****.json /etc/cloudflared/
sudo cp ~/.cloudflared/cert.pem /etc/cloudflared/</code></pre>
<p>이제 재부팅해도 터널이 자동으로 올라온다 !</p>
<hr>
<h3 id="보안-구조-정리">보안 구조 정리</h3>
<p>현재 포트 접근 구조는 이렇다.</p>
<p><strong>외부에서 접근 가능</strong></p>
<pre><code>:80   → Caddy (Cloudflare Tunnel이 여기로 연결)
:443  → Caddy (집 IP 모르면 사실상 못 씀)</code></pre><p><strong>내부(Docker 네트워크)에서만 접근</strong></p>
<pre><code>:8080 → 백엔드
:3001 → PDF
:3306 → MySQL
:6379 → Redis</code></pre><p>외부에서 서비스 접근은 오직 <code>virginroad.store</code> 를 통해서만 가능하다.</p>
<p>집 IP가 노출되지 않으니까 IP로 직접 들어오는 건 불가능하다.</p>
<table>
<thead>
<tr>
<th>접근 방법</th>
<th>수단</th>
<th>누구나 가능?</th>
</tr>
</thead>
<tbody><tr>
<td>웹 서비스</td>
<td>Cloudflare Tunnel (virginroad.store)</td>
<td>✅ 누구나</td>
</tr>
<tr>
<td>SSH / 원격</td>
<td>Tailscale (100.xx.xx.xxx)</td>
<td>❌ 나만</td>
</tr>
</tbody></table>
<ul>
<li><strong>Cloudflare Tunnel</strong> → 집 IP 완전 숨김, DDoS 방어</li>
<li><strong>Tailscale</strong> → 나만 SSH 접근 가능</li>
<li><strong>DB / Redis</strong> → 외부 노출 전혀 없음</li>
<li><strong>포트포워딩</strong> → 없음</li>
</ul>
<hr>
<h2 id="part-2-github-actions-자동-배포">Part 2. GitHub Actions 자동 배포</h2>
<p>도메인 연결까지 됐으니, 이제 배포도 자동화했다.</p>
<p><code>main</code> 브랜치에 push하면 Tailscale로 Mac Mini에 SSH 접속해서 자동 배포되는 구조다.</p>
<p>기존에 EC2 기반으로 작성된 GitHub Actions 설정을 Mac Mini 기준으로 변경했다.</p>
<hr>
<h3 id="변경-내용-ec2-→-mac-mini">변경 내용 (EC2 → Mac Mini)</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>기존</th>
<th>변경</th>
</tr>
</thead>
<tbody><tr>
<td>host</td>
<td>virginroad.store</td>
<td><code>${{ secrets.SERVER_IP }}</code></td>
</tr>
<tr>
<td>username</td>
<td>ubuntu</td>
<td><code>****</code></td>
</tr>
<tr>
<td>경로</td>
<td>/home/virgin-road</td>
<td>~/Desktop/git/virgin-road</td>
</tr>
<tr>
<td>접속 방식</td>
<td>직접 SSH</td>
<td>Tailscale → SSH</td>
</tr>
</tbody></table>
<hr>
<h3 id="github-secrets-등록">GitHub Secrets 등록</h3>
<table>
<thead>
<tr>
<th>Secret</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>TAILSCALE_AUTH_KEY</td>
<td>Tailscale Reusable + Ephemeral 키</td>
</tr>
<tr>
<td>SSH_PRIVATE_KEY</td>
<td>Mac Mini SSH 개인키</td>
</tr>
<tr>
<td>SERVER_IP</td>
<td>Tailscale IP</td>
</tr>
</tbody></table>
<hr>
<h3 id="추가된-스텝">추가된 스텝</h3>
<p>Tailscale 연결 스텝을 파이프라인 앞에 추가했다.</p>
<pre><code class="language-yaml">- name: Tailscale 연결
  uses: tailscale/github-action@v2
  with:
    authkey: ${{ secrets.TAILSCALE_AUTH_KEY }}</code></pre>
<p>GitHub Actions Runner가 Tailscale 네트워크에 참가한 뒤 SSH로 Mac Mini에 접속하는 방식이다.</p>
<p>EC2처럼 퍼블릭 IP 없이도 배포가 된다 !</p>
<hr>
<h2 id="오늘-완료한-것들">오늘 완료한 것들</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>Cloudflare 도메인 등록</td>
<td>✅</td>
</tr>
<tr>
<td>가비아 네임서버 변경</td>
<td>✅</td>
</tr>
<tr>
<td>Cloudflare Tunnel 생성</td>
<td>✅</td>
</tr>
<tr>
<td>DNS 연결</td>
<td>✅</td>
</tr>
<tr>
<td>Caddy 에러 수정</td>
<td>✅</td>
</tr>
<tr>
<td>터널 자동 실행 등록</td>
<td>✅</td>
</tr>
<tr>
<td>GitHub Actions 자동 배포 연결</td>
<td>✅</td>
</tr>
</tbody></table>
<p>도메인 연결부터 자동 배포까지 하루에 다 끝냈다.</p>
<p>외부에서 <code>virginroad.store</code> 로 접속되는 걸 확인했을 때가 제일 뿌듯했다 !</p>
<p>집 IP 노출 없이, 포트포워딩 없이, 무료로 이 구조가 다 된다는 게 신기하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[홈 서버 구축 일지 (2) - 서버 기본 설정 및 프로젝트 구축]]></title>
            <link>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-2-%EC%84%9C%EB%B2%84-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%A0%95-%EB%B0%8F-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-2-%EC%84%9C%EB%B2%84-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%A0%95-%EB%B0%8F-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Fri, 20 Mar 2026 00:09:10 GMT</pubDate>
            <description><![CDATA[<p>1편에서 Mac Mini 기본 세팅과 Tailscale로 원격 접속까지 마쳤다.</p>
<p>오늘은 본격적으로 서버 환경을 갖추고, 실제 프로젝트까지 올려보려 한다.</p>
<hr>
<h2 id="전체-아키텍처-먼저-보고-가자">전체 아키텍처 먼저 보고 가자</h2>
<p>세팅을 시작하기 전에 전체 구조를 먼저 그려봤다.</p>
<pre><code>+------------------------------------------+
|             External Users               |
|        (API calls, Web access)           |
+-------------------+----------------------+
                    |
                    v
+------------------------------------------+
|               Cloudflare                 |
|  - Domain  : Gabia -&gt; Cloudflare         |
|  - DDoS Protection                       |
|  - Auto HTTPS                            |
|  - Hide Home IP                          |
+-------------------+----------------------+
                    | Cloudflare Tunnel
                    v
+------------------------------------------+
|              Mac Mini M4                 |
|  +--------------------------------------+|
|  |          OrbStack (Docker)           ||
|  |  +-------------+  +-------------+   ||
|  |  |  API Server |  |     DB      |   ||
|  |  |    :3000    |  |    :5432    |   ||
|  |  +-------------+  +-------------+   ||
|  |  +-------------+  +-------------+   ||
|  |  |  Web Server |  |   Others    |   ||
|  |  |    :8080    |  |  Services   |   ||
|  |  +-------------+  +-------------+   ||
|  +--------------------------------------+|
|                                          |
|  cloudflared  (always-on tunnel)         |
+------------------------------------------+
                    ^
+------------------------------------------+
|            Developer (Me)                |
|                                          |
|  Local    -&gt;  vnc://192.168.x.x          |
|  Remote   -&gt;  Tailscale (100.xx.xx.xxx)  |
|  Terminal -&gt;  ssh homeserver             |
|  UI       -&gt;  vnc://100.xx.xx.xxx        |
+------------------------------------------+</code></pre><p>외부 사용자는 Cloudflare를 통해 들어오고,</p>
<p>나는 Tailscale VPN을 통해 직접 Mac Mini에 붙는 구조다.</p>
<p>집 IP는 절대 노출되지 않는다 !</p>
<hr>
<h2 id="1-homebrew-설치">1. Homebrew 설치</h2>
<p>Mac Mini에 처음 접속하면 아무것도 없다.</p>
<p>가장 먼저 패키지 매니저인 Homebrew부터 설치했다.</p>
<pre><code class="language-bash">/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;</code></pre>
<p>이후 모든 설치는 <code>brew</code>로 해결한다.</p>
<hr>
<h2 id="2-cloudflared-설치">2. Cloudflared 설치</h2>
<p>외부에서 서비스에 접근하려면 Cloudflare Tunnel이 필요하다.</p>
<pre><code class="language-bash">brew install cloudflared</code></pre>
<p>Cloudflare Tunnel을 쓰면 포트포워딩 없이 도메인으로 서비스를 공개할 수 있다.</p>
<p>집 IP도 완전히 숨겨지고, HTTPS도 자동이다 !</p>
<hr>
<h2 id="3-orbstack-설치">3. OrbStack 설치</h2>
<p>Docker Desktop 대신 <strong>OrbStack</strong>을 선택했다.</p>
<p>Mac에서 Docker를 쓴다면 OrbStack을 강력 추천한다.</p>
<ul>
<li>도커와 완전히 동일한 명령어 사용 가능</li>
<li>Docker Desktop보다 훨씬 가볍고 빠름</li>
<li>M4 ARM 아키텍처에 최적화</li>
</ul>
<pre><code class="language-bash">brew install --cask orbstack</code></pre>
<p>설치 후 PATH 영구 등록을 해줘야 새 터미널에서 <code>docker</code> 명령어가 자동으로 인식된다.</p>
<pre><code class="language-bash">echo &#39;export PATH=&quot;/Applications/OrbStack.app/Contents/MacOS/xbin:$PATH&quot;&#39; &gt;&gt; ~/.zshrc</code></pre>
<hr>
<h2 id="4-ssh-키-인증--config-등록">4. SSH 키 인증 + Config 등록</h2>
<p>매번 비밀번호 입력하는 건 너무 불편하다.</p>
<p>SSH 키 인증으로 바꾸고, Config도 등록했다.</p>
<p>이제 터미널에서 <code>ssh homeserver</code> 한 줄이면 끝이다.</p>
<pre><code class="language-bash">ssh homeserver</code></pre>
<hr>
<h2 id="5-프로젝트-배포">5. 프로젝트 배포</h2>
<p>환경이 갖춰졌으니 실제 프로젝트를 올려보자.</p>
<h3 id="프로젝트-clone">프로젝트 Clone</h3>
<pre><code class="language-bash">git clone https://github.com/zerogoons/virgin-road</code></pre>
<h3 id="env-파일-생성">.env 파일 생성</h3>
<p>민감한 환경변수들은 <code>.env</code> 파일에 따로 관리한다.</p>
<pre><code>MYSQL_ROOT_PASSWORD=...
DB_USERNAME=root
DB_PASSWORD=...
MAIL_USERNAME=...
MAIL_PASSWORD=...
SWAGGER_SERVER_URL=https://virginroad.store/api
VITE_API_URL=https://virginroad.store/api
ADMIN_EMAIL=...
FRONTEND_URL=https://virginroad.store
PDF_URL=http://virgin-road-pdf:3001</code></pre><h3 id="정적-인프라-시작-mysql--redis--caddy">정적 인프라 시작 (MySQL + Redis + Caddy)</h3>
<pre><code class="language-bash">cd ~/Desktop/git/virgin-road/infra/static

docker compose --env-file ../.env up -d</code></pre>
<p>이 단계에서 Docker 네트워크 <code>virgin-road-net</code>도 자동으로 생성된다.</p>
<h3 id="pdf-서버-배포">PDF 서버 배포</h3>
<pre><code class="language-bash">cd ~/Desktop/git/virgin-road/infra/pdf

bash deploy.sh</code></pre>
<h3 id="백엔드-배포-블루-그린">백엔드 배포 (블루-그린)</h3>
<pre><code class="language-bash">cd ~/Desktop/git/virgin-road/infra/back

sh deploy-blue-green.sh</code></pre>
<h3 id="프론트엔드-배포-블루-그린">프론트엔드 배포 (블루-그린)</h3>
<pre><code class="language-bash">cd ~/Desktop/git/virgin-road/infra/front

sh deploy-blue-green.sh</code></pre>
<p>블루-그린 배포라 무중단으로 배포된다 !</p>
<hr>
<h2 id="오늘-완료한-것들">오늘 완료한 것들</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>Mac Mini 기본 세팅</td>
<td>✅</td>
</tr>
<tr>
<td>WiFi 고정 IP 설정</td>
<td>✅</td>
</tr>
<tr>
<td>SSH 활성화</td>
<td>✅</td>
</tr>
<tr>
<td>화면 공유 활성화</td>
<td>✅</td>
</tr>
<tr>
<td>특정 사용자만 허용</td>
<td>✅</td>
</tr>
<tr>
<td>비밀번호 강화</td>
<td>✅</td>
</tr>
<tr>
<td>Rosetta 2 확인</td>
<td>✅</td>
</tr>
<tr>
<td>Homebrew 설치</td>
<td>✅</td>
</tr>
<tr>
<td>Cloudflared 설치</td>
<td>✅</td>
</tr>
<tr>
<td>OrbStack 설치</td>
<td>✅</td>
</tr>
<tr>
<td>Tailscale 설치 및 연결</td>
<td>✅</td>
</tr>
<tr>
<td>SSH 키 인증 설정</td>
<td>✅</td>
</tr>
<tr>
<td>SSH Config 등록</td>
<td>✅</td>
</tr>
</tbody></table>
<p>생각보다 하루 만에 꽤 많은 걸 해냈다.</p>
<p>아키텍처대로 서비스가 올라가는 걸 보니까 뿌듯했다 !</p>
<p>다음엔 Cloudflare Tunnel 연결해서 실제로 외부 도메인으로 접근하는 것까지 해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[홈 서버 구축 일지 (1) - Mac Mini 첫 세팅]]></title>
            <link>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-1-Mac-Mini-%EC%B2%AB-%EC%84%B8%ED%8C%85</link>
            <guid>https://velog.io/@junyoung-choe/%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-1-Mac-Mini-%EC%B2%AB-%EC%84%B8%ED%8C%85</guid>
            <pubDate>Thu, 19 Mar 2026 07:53:37 GMT</pubDate>
            <description><![CDATA[<p>Mac Mini M4를 구매했다.</p>
<p>회사 서버도 아니고, 클라우드도 아닌, 내 손 안의 서버 !</p>
<p>홈 서버를 구축해보고 싶다는 생각은 오래전부터 했는데, 이번에 Mac Mini M4가 나오면서 결심했다.</p>
<p>시작이 반이라고, 일단 사고 봤다.</p>
<p>오늘은 Mac Mini를 받아서 기본 세팅부터 외부 원격 접속까지 한 번에 정리해보려 한다.</p>
<hr>
<h2 id="📌-구성-환경">📌 구성 환경</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>서버</td>
<td>Mac Mini M4</td>
</tr>
<tr>
<td>공유기</td>
<td>ASUS</td>
</tr>
<tr>
<td>인터넷</td>
<td>LG</td>
</tr>
<tr>
<td>도메인</td>
<td>가비아 (구매 완료)</td>
</tr>
<tr>
<td>네트워크</td>
<td>WiFi (무선)</td>
</tr>
</tbody></table>
<hr>
<h2 id="1-mac-mini-기본-세팅">1. Mac Mini 기본 세팅</h2>
<p>가장 먼저 서버로 쓸 거니까 절대 꺼지면 안 된다.</p>
<p>컴퓨터 이름부터 <code>****</code>로 설정했다.</p>
<p>그리고 절전 관련 설정을 전부 손봤다.</p>
<ul>
<li>저전력 모드 → <strong>끄기</strong></li>
<li>자동으로 잠자기 않기 → <strong>켜기</strong></li>
<li>네트워크 연결 시 깨우기 → <strong>켜기</strong></li>
<li>정전 후 자동 시작 → <strong>켜기</strong></li>
</ul>
<p>서버가 혼자 잠들면 안 되니까 !</p>
<hr>
<h2 id="2-공유-설정">2. 공유 설정</h2>
<p><code>시스템 설정 → 일반 → 공유</code> 에서 두 가지를 켰다.</p>
<ul>
<li><strong>원격 로그인 (SSH)</strong> → 켜기</li>
<li><strong>화면 공유</strong> → 켜기</li>
<li>접근 허용: 특정 사용자만 (<code>****</code>)</li>
</ul>
<p>아무나 접근하면 안 되니까, 특정 사용자만 허용하도록 설정했다.</p>
<hr>
<h2 id="3-tailscale-설치-및-연결">3. Tailscale 설치 및 연결</h2>
<p>외부에서 Mac Mini에 접속하려면 VPN이 필요하다.</p>
<p>여러 선택지가 있었는데, 결국 <strong>Tailscale</strong>을 선택했다.</p>
<p>이유는 간단하다.</p>
<ul>
<li>포트포워딩 불필요 !</li>
<li>공유기 설정 불필요 !</li>
<li>무료 !</li>
<li>설치가 너무 쉽다 !</li>
</ul>
<p>Mac Mini랑 MacBook 둘 다 설치하고, 같은 계정으로 로그인하면 끝이다.</p>
<pre><code class="language-bash"># 설치
brew install --cask tailscale

# 상태 확인
tailscale status

# IP 확인
tailscale ip</code></pre>
<p>Mac Mini의 Tailscale IP가 <code>100.xx.xx.xxx</code> 로 고정되었다.</p>
<p>이게 나의 내부 VPN IP가 된다.</p>
<hr>
<h2 id="4-macbook-→-mac-mini-원격-접속">4. MacBook → Mac Mini 원격 접속</h2>
<p>Tailscale이 연결되면 두 가지 방법으로 원격 접속할 수 있다.</p>
<h3 id="ssh-터미널">SSH (터미널)</h3>
<pre><code class="language-bash">ssh ****@100.xx.xx.xxx</code></pre>
<h3 id="화면-공유-ui">화면 공유 (UI)</h3>
<pre><code>Finder → 이동 → 서버에 연결 (⌘K)
→ vnc://100.xx.xx.xxx
→ Mac Mini 로그인 비밀번호 입력</code></pre><p>터미널로 붙는 것도 좋지만, UI로 화면까지 공유되니까 훨씬 편하다 !</p>
<p>Mac Mini가 옆방에 있어도 MacBook에서 전부 제어 가능하다.</p>
<hr>
<h2 id="5-rosetta-2-확인">5. Rosetta 2 확인</h2>
<p>M4는 Apple Silicon이다.</p>
<p>혹시 Intel 기반 프로그램을 쓸 일이 생기면 Rosetta 2가 필요할 수 있어서 확인해봤다.</p>
<pre><code class="language-bash">softwareupdate --install-rosetta --agree-to-license
# &quot;Rosetta 2 update is not available&quot; → 정상 (이미 설치됨)</code></pre>
<p>이미 설치되어 있었다 !</p>
<p>따로 신경 쓸 필요 없다.</p>
<hr>
<h2 id="🔐-보안-설정-현황">🔐 보안 설정 현황</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>Tailscale VPN</td>
<td>✅ 적용</td>
</tr>
<tr>
<td>화면 공유 특정 사용자만</td>
<td>✅ 적용</td>
</tr>
<tr>
<td>SSH 특정 사용자만</td>
<td>✅ 적용</td>
</tr>
<tr>
<td>강한 비밀번호</td>
<td>🔲 변경 필요</td>
</tr>
<tr>
<td>Tailscale 2FA</td>
<td>🔲 설정 필요</td>
</tr>
<tr>
<td>SSH 키 인증</td>
<td>🔲 다음 편에서 설정</td>
</tr>
</tbody></table>
<p>오늘은 기본 세팅에 집중했고, 보안 강화는 차차 해나갈 예정이다.</p>
<hr>
<h2 id="💡-핵심-정리">💡 핵심 정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>외부 접속 수단</td>
<td>Tailscale (<code>100.xx.xx.xxx</code>)</td>
</tr>
<tr>
<td>서비스 공개 수단</td>
<td>Cloudflare Tunnel (이후 구성)</td>
</tr>
<tr>
<td>집 IP 노출 여부</td>
<td>없음 ✅</td>
</tr>
<tr>
<td>포트포워딩 필요 여부</td>
<td>없음 ✅</td>
</tr>
<tr>
<td>공유기 설정 필요 여부</td>
<td>없음 ✅</td>
</tr>
<tr>
<td>Cloudflare Tunnel 비용</td>
<td>무료 ✅</td>
</tr>
</tbody></table>
<p>생각보다 훨씬 쉽게 첫 세팅을 마쳤다.</p>
<p>Tailscale 덕분에 공유기 건드릴 필요도 없었고, IP 노출도 없다.</p>
<p>다음엔 mac mini 에 기본 소프트웨어 및 컨테이너 구성을 진행해보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 직접 쿼리]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A7%81%EC%A0%91-%EC%BF%BC%EB%A6%AC</link>
            <guid>https://velog.io/@junyoung-choe/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A7%81%EC%A0%91-%EC%BF%BC%EB%A6%AC</guid>
            <pubDate>Sun, 15 Dec 2024 07:41:39 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@junyoung-choe/Redis-%EB%8F%99%EC%8B%9C%EC%84%B1">이전 포스팅</a>
이전에 공유 자원에 대한 동시성을 처리하며, 기존의 값을 조회해온뒤 + 5 해서 Update 플로우를 진행했었다.</p>
<p>최근 한 블로그를 보면서 내가 생각하지 못했던 로직에 대해서 직접 다뤄보려고 한다.
<a href="https://velog.io/@hyeok_1212/%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%A0%95%ED%95%A9%EC%84%B1">조회수 기능 구현 (동시성 이슈)</a></p>
<p>공유자원의 문제를 생각하기 이전에 </p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/6c6bc71b-a40d-40f1-b3f5-ae4b6ecb37eb/image.png" alt="">
&quot;기존 값 + 5&quot; 라는 &quot;Update&quot; 종이를 들고 위 사진과 같이 사람들이 줄서있다고 생각해보자.</p>
<p>이전의 select 이후 update는 &quot;Select&quot; 종이를 먼저 제출하고 다시 맨 뒤에 줄을서 &quot;Update&quot; 종이를 들고 줄을 서있었기 때문에 매표소에서는 해당 자원의 무결성을 보장할수 없었다.</p>
<p>매표소에서 &quot;Update&quot; 종이만 허가한다면 공유 자원의 문제점이 없을 뿐 더러, 줄을 더 빠르게 소진시킬수 있다.</p>
<p>또한, 이는 &quot;Update&quot;, &quot;Select&quot; 종이를 들고있는 사람은 중복될수 없음을 보장하기도 한다.(다른 트랜잭션에서 실행되기 때문)</p>
<p>따라서 해당 로직을 작성해보려고 한다.</p>
<p>기존 프로젝트에 board 도메인을 추가한다.</p>
<p>Controller</p>
<pre><code class="language-java">import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController()
@RequestMapping(&quot;board&quot;)
@RequiredArgsConstructor
public class BoardController {
    private final BoardService boardService;

    @GetMapping()
    public Integer plusViews() {
        return boardService.plusViews(1L);
    }

    @GetMapping(&quot;/{id}&quot;)
    public Integer getViews(@PathVariable Long id) {
        return boardService.getViews(id);
    }
}
</code></pre>
<p>Service</p>
<pre><code class="language-java">import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class BoardService {
    private final BoardRepository boardRepository;

    public Integer plusViews(Long id) {
        return boardRepository.plusBoardView(id);
    }

    public Integer getViews(Long id) {
        return boardRepository.findBoardById(id).getViews();
    }
}
</code></pre>
<p>Repository</p>
<pre><code class="language-java">import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface BoardRepository extends JpaRepository&lt;Board, Long&gt; {

    @Modifying
    @Query(&quot;update Board b set b.views = b.views + 5 where b.id = :id&quot;)
    Integer plusBoardView(Long id);

    Board findBoardById (Long id);
}
</code></pre>
<p>테스트를 위해 Controller 에서 임시로 만들어져 있는 컬럼 id=1 을 이용해서 사용한다.</p>
<p>이후 Jmeter를 활용하여 한번에 1000개의 request를 보내보겠다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/1ad882ce-27da-4b42-86d5-74fea837b18d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/75b4d24b-7f01-4add-85b8-e59d2b15be75/image.png" alt=""></p>
<p>&quot;Update(n + 5)&quot; 종이 5000장을 보낸 셈이다.</p>
<p>일전의 분산락이나 비관 낙관 락을 사용할때 보다 훨신 더 빠른 효율을 확인할 수 있다.</p>
<p>또한 해당 방법은 DB로 request를 여러 서버에서 보내도 하나의 DB에서 처리 되기 때문에 서버 증설의 문제점도 없다. (메모리에 부하만 일어나지 않는다면...)</p>
<p>DB의 트랜잭션 시스템을 통해서 request들이 관리되며 순차적으로 요청을 처리한다.</p>
<hr>
<ul>
<li>Redis write</li>
</ul>
<p>그렇다면 위와 비슷한 방법으로 wirte 로직을 DB에 직접 쓰는게 아닌 인메모리 단계에 존재하는 Redis를 활용한다면 ?</p>
<p>client -&gt; Server : wirte (Update) 로직을 Redis에서 관리하고 </p>
<p>특정 시간이나 trigger에 실제 DB와 동기화 하도록 설계한다면 ?</p>
<p>client는 Read 로직에서 DB를 바라보도록 설정하여 value 조회가 가능하다.</p>
<p>Controller 는 위와 같다.</p>
<p>Service</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Transactional
public class BoardService {
    private final BoardRepository boardRepository;
    private final RedisTemplate&lt;String, Long&gt; redisTemplate;

//    public Integer plusViews(Long id, Long views) { return boardRepository.plusBoardViews(id); }

    public Integer plusViews(Long id) {
        String redisKey = FEED_VIEW_COUNT_PREFIX + id;
        Long increment = redisTemplate.opsForValue().increment(redisKey, 5L);
        if(increment == null) return 0;
        return increment.intValue();
    }
    public Integer getViews(Long id) {
        return boardRepository.findBoardById(id).getViews();
    }


}</code></pre>
<p>위 코드에서 write 발생시 Redis 저장소에 value를 증가시킨다.</p>
<p>스케줄러</p>
<pre><code class="language-java">import com.example.threadsafetest.board.BoardRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.Optional;
import java.util.Set;

import static com.example.threadsafetest.config.redis.RedissonConfig.FEED_VIEW_COUNT_PREFIX;

@Service
@RequiredArgsConstructor
public class ScheduleService {
    private final BoardRepository boardRepository;
    private final RedisTemplate&lt;String, Long&gt; redisTemplate;

    // every 1 minute
    @Scheduled(cron = &quot;0 * * * * *&quot;)
    @Transactional
    public void applyViewsToDb() {
        Set&lt;String&gt; keys = redisTemplate.keys(FEED_VIEW_COUNT_PREFIX + &quot;*&quot;);
        if (keys.isEmpty()) { // redis에 존재하는 모든 조회수를 가져온다.
            return;
        }

        // 가져온 조회수를 DB에 반영 ( redis to DB )
        keys.forEach(redisKey -&gt; {
            Long boardId = Long.parseLong(redisKey.replace(FEED_VIEW_COUNT_PREFIX, &quot;&quot;));
            long ViewsCount = Optional.ofNullable(redisTemplate.opsForValue().get(redisKey))
                    .orElse(0L);
            if (ViewsCount &gt; 0) { // 0 이상의 조회수가 쌓인 경우 동기화
                syncViewCount(redisKey, boardId, ViewsCount);
            }
        });
    }

    // DB 접근
    private void syncViewCount(String redisKey, Long boardId, long ViewsCount) {
        Integer i = boardRepository.plusBoardViews(boardId, ViewsCount);// DB 호출
        redisTemplate.opsForValue().set(redisKey, 0L);
    }
}
</code></pre>
<p>매분 cron을 통한 스케줄러로 Redis에 있는 value를 DB에 Update 해준다.</p>
<p>Repository</p>
<pre><code class="language-java">import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface BoardRepository extends JpaRepository&lt;Board, Long&gt; {

    @Modifying
    @Query(&quot;update Board b set b.views = b.views + :views where b.id = :id&quot;)
    Integer plusBoardViews(Long id, Long views);

    Board findBoardById(Long id);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/3a3e2322-39f2-49f0-bbcc-1da0c57bd9da/image.png" alt=""></p>
<p>동시 1000개의 요청을 write 하는데 단 10ms 의 시간이 소요된다.</p>
<p>이후 스케줄러 가동을 통하여
<img src="https://velog.velcdn.com/images/junyoung-choe/post/698d0bf7-46e0-40c0-803b-0448b8c84b0c/image.png" alt=""></p>
<p>Redis 의 값을 DB와 동기화 시켜준뒤 Redis value를 다시 0으로 변경해준다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/3e41d0e2-5fda-4043-96d2-016ca4a9dcff/image.png" alt="">
이후 조회해보면 정상적으로 5000 value가 반영 되어있는 것을 확인할 수 있다.</p>
<p>기존 DB에 value를 반영하는 로직과 무려 380배 성능 차이를 보인다.</p>
<p>이렇게 DB가 아닌 메모리에 value를 기록해두고 한꺼번에 DB에 반영한다면, 데이터 실시간성은 조금 떨어질수 있다.</p>
<p>하지만, 스케줄러 시간을 조정한다면, </p>
<ul>
<li>DB에 가는 부하를 줄임으로써 얻는 안정성</li>
<li>따로 Lock을 걸지 않아도 나타나지 않는 동시성 문제</li>
<li>DB에 write를 했을때 보다 무려 380 배 빠른 성능</li>
</ul>
<p>훨신 더 큰 trade off 가져온다는 장점이 존재한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NginX 502]]></title>
            <link>https://velog.io/@junyoung-choe/NginX-502</link>
            <guid>https://velog.io/@junyoung-choe/NginX-502</guid>
            <pubDate>Tue, 13 Aug 2024 00:36:56 GMT</pubDate>
            <description><![CDATA[<p>엔진엑스의 default.conf 파일에 리버스 프록시 설정을 추가했지만, proxy_pass 가 적용되지 않는 문제점이 존재했다.</p>
<pre><code class="language-html">server {
    listen 80;
    listen [::]:80;
    server_name dangil.store;
    access_log off;

    location /.well-known/acme-challenge/ {
        allow all;
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

map $http_upgrade $connection_upgrade {
  default upgrade;
  &#39;&#39; close;
}

server {
    listen 443 ssl;
    server_name dangil.store;

    # include /etc/nginx/conf.d/service-url.inc;

    # 서트봇이 볼륨으로 남겨두는 인증서를 가지고 이미지를 만들었고 엔진엑스 컨테이너에서 해당 인증서를 사용한다
    ssl_certificate /etc/letsencrypt/live/dangil.store/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dangil.store/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    ignore_invalid_headers off;

    # 디폴트로 IP는 프록시로 변경되고 난뒤에 (리버스 프록시 기능)
    # 원래의 요청의 헤더에 설정을 수정 및 추가한다
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    location /api {
        proxy_pass http://localhost:8080;
        proxy_set_header X-Forwarded-Host $server_name;
    }
}</code></pre>
<p>api/health url 검색해도 502 에러가 나왔다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/3cebb190-cecc-4a36-b6f8-f73ac444742a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/71444738-19ab-4588-9a63-a040abe477ec/image.png" alt=""></p>
<p><a href="https://nginx.viraptor.info/">nginx.viraptor.info</a></p>
<p>해당 사이트에서 Nginx의 default.conf 파일의 설정과 url 테스트를 통해서 api/ url이 동작하지 않는 것을 확인했다.</p>
<pre><code class="language-java">server {
    listen 80;
    listen [::]:80;
    server_name dangil.store;
    access_log off;

    location /.well-known/acme-challenge/ {
        allow all;
        root /var/www/certbot;
    }

    location / {
        return 301 https://dangil.store$request_uri;
    }
}

map $http_upgrade $connection_upgrade {
  default upgrade;
  &#39;&#39; close;
}

server {
    listen 443 ssl;
    server_name dangil.store;
    # include /etc/nginx/conf.d/service-url.inc;

    # 서트봇이 볼륨으로 남겨두는 인증서를 가지고 이미지를 만들었고 엔진엑스 컨테이너에서 해당 인증서를 사용한다
    ssl_certificate /etc/letsencrypt/live/dangil.store/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dangil.store/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # 부적절한 헤더도 요청을 허용한다.
    ignore_invalid_headers off;

    location /api {
        proxy_pass http://today_back:8080;
        proxy_set_header X-Forwarded-Host $server_name;

        # 디폴트로 IP는 프록시로 변경되고 난뒤에 (리버스 프록시 기능)
        # 원래의 요청의 헤더에 설정을 수정 및 추가한다
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
</code></pre>
<p>proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
해당 코드가 안에 들어가서 정상적으로 작동했다 !</p>
<p>해당 설정이 전역으로 적용이 안되는거 같다...</p>
<p>왜? 그럴까?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis 동시성]]></title>
            <link>https://velog.io/@junyoung-choe/Redis-%EB%8F%99%EC%8B%9C%EC%84%B1</link>
            <guid>https://velog.io/@junyoung-choe/Redis-%EB%8F%99%EC%8B%9C%EC%84%B1</guid>
            <pubDate>Tue, 13 Aug 2024 00:22:27 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@junyoung-choe/DB-%EB%8F%99%EC%8B%9C%EC%84%B1">이전 포스팅</a>
DB 단계에서 Lock 설정을 통한 비관적락을 통해 병렬 요청으로 인한 동시성을 처리했다.</p>
<ol>
<li><p>Lock을 확인하러 가는 과정에 DB에 접근이 불가피하다.</p>
</li>
<li><p>복잡한 서비스에서 컬럼들에 무자비하게 Lock을 건다면 데드락 상황이 발생할수 있다.</p>
</li>
<li><p>컬럼이 아닌 로직상에서 Lock을 설정할 수 없다.</p>
</li>
</ol>
<p>이처럼 낙관적락은 데이터의 정합성을 보장하나, 문제점이 여전히 존재한다.</p>
<p>이러한 문제점을 해결하고자 분산락을 적용 해보고자 한다.</p>
<hr>
<p><code>분산락이란?</code> 
여러 서버에서 공유된 데이터를 효과적으로 제어하기 위해서 사용하는 방법.</p>
<p>즉 데이터의 정합성을 기본적으로 보장하고, 추가적인 로직 설계가 가능하다.</p>
<p>그러면 ? 낙관적락이랑 뭐가 다를까 ?
분산락을 학습하며, 낙관적락이랑 매우 유사하다는 생각이 지속해서 들었다.</p>
<p>개인적인 생각으로는 꼭 컬럼이 아닌 상태나 상황에 Lock 설정이 가능하다.</p>
<p>무엇보다, 여러개의 애플리케이션 서버와 여러개의 DB가 존재할때 발생할수 있는 데드락을 방지할수 있다는 큰 장점이 있다고 생각한다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/19fe8757-c35c-4217-b4ca-5d536ca45590/image.png" alt=""></p>
<p>위 그림처럼 여러개의 서버 &amp; 여러개의 DB가 존재할때 </p>
<p>1번 스프링 서버에서 1번 2번 DB에 순차 접근하고</p>
<p>2번 스프링 서버에서 2번 1번 DB에 순차 접근한다면 ?</p>
<p>같은 member PK 에 연결된 데이터들에 접근한다면?</p>
<p>비관적락의 경우 각자의 트랜잭션에서 
1번 스프링서버는 1번 DB의 Lock을 가지고 2번 DB에 접근
2번 스프링서버는 2번 DB의 Lock을 가지고 1번 DB에 접근
-&gt; 데드락 현상이 발생한다 ! ! !</p>
<hr>
<p>이때 Redis에서 member PK를 가지고 Lock을 관리한다면? </p>
<p>Redis는 싱글 스레드 스택으로 Lock을 관리한다면 1번 스프링 서버의 요청이 끝난뒤에 
2번 스프링서버의 로직이 실행된다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/076ef7f6-3d47-4e2c-91f4-d1ee86a4588a/image.png" alt=""></p>
<hr>
<p><code>Redisson</code></p>
<p>그렇다면 어떤 라이브러리 ?</p>
<ul>
<li><p>Lettuce
공식적으로 분산락 기능을 제공하지 않으므로, 이를 직접 구현해야 합니다. Lettuce의 락 획득 방식은 스핀락(spin lock)으로, 락을 획득하지 못할 경우 Redis에 반복적으로 요청을 보내는 방식입니다. 이로 인해 Redis에 부하가 발생할 수 있는 단점이 있습니다.</p>
</li>
<li><p>Redisson 
락 획득 시 스핀락 대신 pub/sub 방식을 사용합니다. 이 방식에서는 락이 해제될 때, subscribe된 클라이언트에게 락 획득 시도를 알리는 메시지를 보내므로, 락 획득 실패 시 Redis에 지속적인 요청을 보내지 않게 되어 부하를 줄일 수 있습니다. 또한, Redisson은 RLock이라는 인터페이스를 제공해 락을 보다 쉽게 활용할 수 있습니다.</p>
</li>
</ul>
<hr>
<p>RedissonConfig</p>
<pre><code class="language-java">import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    private static final String REDISSON_HOST_PREFIX = &quot;redis://&quot;;

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + &quot;localhost:6379&quot;);
        return Redisson.create(config);
    }
}
</code></pre>
<p>PeopleService</p>
<pre><code class="language-java">import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class PeopleService {
    final private PeopleRepository peopleRepository;
    final private RedissonClient redissonClient;

    long waitTime = 5L;
    long leaseTime = 3L;
    TimeUnit timeUnit = TimeUnit.SECONDS;

    @Transactional
    public int plusNumber() {
        // 락 이름 배정
        String lockName = &quot;jun&quot;;
        RLock rLock = redissonClient.getLock(lockName);

        try {
            boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
            if(!available){
                throw new RuntimeException();
            }
            //=== 락 획득 후 로직 수행 ===
            People people = peopleRepository.findPeopleByName(&quot;jun&quot;);
            people.setCount(people.getCount() + 5);
            return peopleRepository.findPeopleByName(&quot;jun&quot;).getCount();
            // === 로직 수행 완료 ===
        } catch (InterruptedException e) {
            //락을 얻으려고 시도하다가 인터럽트를 받았을 때 발생하는 예외
            throw new RuntimeException(e);
        } finally{
            try{
                rLock.unlock();
            }catch (RuntimeException e){
                //이미 종료된 락일 때 발생하는 예외
                throw new RuntimeException();
            }
        }
    }

    @Transactional(readOnly = true)
    public int getNumber() {
        return peopleRepository.findPeopleByNameWithoutLock(&quot;jun&quot;).getCount();
    }
}</code></pre>
<p>기존 로직에 Lock을 얻어오는 로직과
Lock을 얻지 못 했을때 락 획득을 시도하는 로직을 추가했다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/3971d7ca-1342-4698-bf06-5e0d6933d35e/image.png" alt=""></p>
<h4 id="헐">헐<del>!</del>!<del>!</del>!</h4>
<p>왜? 5000이 아니지?</p>
<p>오류없이 1000개의 요청이 완료되었지만 결과는 /2 밖에 안되는것을 볼 수 있다.</p>
<p>왜? 그 럴 까 ?</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/003b9d55-f92f-4271-aea9-8f68166f8d4c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/4d50b3be-d393-47f9-b246-29ba87102517/image.png" alt=""></p>
<p>연속된 request에서 +5가 되지 않은 상태로 같은 2450의 결과를 응답으로 받은것을 확인할 수 있다.</p>
<hr>
<p>락을 돌려준 후 커밋을 진행하기 때문인가? </p>
<p>커밋을 진행하는 동안 조회를 한다면 ?</p>
<p>그러면 락을 놓기전에 트랜잭션 처리를 반드시 해야한다.</p>
<p>그래야 데이터의 정합성을 지킬수있다.</p>
<pre><code class="language-java">import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Slf4j
public class PeopleService {
    final private PeopleRepository peopleRepository;
    final private RedissonClient redissonClient;

    long waitTime = 5L;
    long leaseTime = 3L;
    TimeUnit timeUnit = TimeUnit.SECONDS;

    @Transactional
    public int plusNumber() {
        // 락 이름 배정
        String lockName = &quot;jun&quot;;
        RLock rLock = redissonClient.getLock(lockName);

        try {
            boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
            if (!available) {
                throw new RuntimeException(&quot;Unable to acquire lock&quot;);
            }

            // 트랜잭션 커밋 후 락 해제를 위해 등록
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCommit() {
                    rLock.unlock();
                    log.info(&quot;락 반납했다 ~ &quot;);
                }

                @Override
                public void afterCompletion(int status) {
                    if (rLock.isLocked() &amp;&amp; rLock.isHeldByCurrentThread()) {
                        rLock.unlock();
                    }
                }
            });
            log.info(&quot;락 획득했다 ~ &quot;);
            // === 락 획득 후 로직 수행 ===
            People people = peopleRepository.findPeopleByName(&quot;jun&quot;);
            people.setCount(people.getCount() + 5);
            return peopleRepository.findPeopleByName(&quot;jun&quot;).getCount();
            // === 로직 수행 완료 ===

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Transactional(readOnly = true)
    public int getNumber() {
        return peopleRepository.findPeopleByNameWithoutLock(&quot;jun&quot;).getCount();
    }
}</code></pre>
<p> &quot;TransactionSynchronizationManager&quot; 를 활용하여, 트랜잭션 commit 이후에 Lock을 반납하도록 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/85db4771-4d8c-4700-8e08-271b49e45ccd/image.png" alt="">
트랜잭션 커밋 이후에 Lock을 반납 하는것을 로그로써 확인했습니다.</p>
<p> <img src="https://velog.velcdn.com/images/junyoung-choe/post/09663aca-e3cc-4870-9d21-c594e9139248/image.png" alt=""></p>
<p>원하던 결과인 5000을 조회해올수 있었습니다.</p>
<p>야호~</p>
<hr>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/7e3dcb87-5b81-47c3-80f6-d58da08afee6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/5898cfec-3378-426a-b594-211c85670303/image.png" alt=""></p>
<p>시간은 약 8000ms가 소요됬다...</p>
<p>앞선 비관적 락의 소요 시간은 약 4000ms였다.</p>
<p>약 2배의 시간 차이를 보인다.</p>
<p>이처럼 시간 소요가 큰 비관적 락보다 더 많은 시간이 소요된다.</p>
<hr>
<p>결론</p>
<p>동시성을 해결하고 데이터의 정합성 보장될수록, 로직에는 더 많은 비용과 시간이 소요된다.</p>
<p>따라서 정답이 존재하기 보단, 상황에 맞는 설계가 정답이라고 생각합니다.</p>
<p>많은 서비스가 클라우드에 배포되고 있다. 서버의 갯수는 scale out 되고, 더 많은 트래픽이 발생하고 있는 만큼 동시성문제는 정말 중요하다고 생각합니다.</p>
<p>참조 - </p>
<p><a href="https://innovation123.tistory.com/185">[Redis] Redisson 분산 락을 간단하게 적용해보기</a></p>
<p><a href="https://velog.io/@dangddoong/learning-solutions-concurrency-issues-e-commerce-inventory-management-logic">쇼핑몰 재고관리 - 동시성 문제 해결방법 탐구: 낙관락&amp;비관락, 분산락(네임드락, Redisson)</a></p>
<p><a href="https://velog.io/@dlswns2480/Redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%EC%A0%9C%EC%96%B4-Lettuce-vs-Redisson">Redis를 활용한 동시성 이슈 제어 : Lettuce  vs Redisson</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DB 동시성]]></title>
            <link>https://velog.io/@junyoung-choe/DB-%EB%8F%99%EC%8B%9C%EC%84%B1</link>
            <guid>https://velog.io/@junyoung-choe/DB-%EB%8F%99%EC%8B%9C%EC%84%B1</guid>
            <pubDate>Mon, 12 Aug 2024 08:43:33 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@junyoung-choe/%EB%8F%99%EC%8B%9C%EC%84%B1">이전 포스팅</a></p>
<p>이전 포스팅에서 코드 레벨에서의 동시성 처리에 대해서 학습했다.</p>
<p>각 서버에서가 아닌? 하나의 DB에 동시에 접근해서 CRUD 한다면? </p>
<p>앞선 동시성 이슈가 DB에서 발생하게 되고, 이는 데이터의 정합성을 보장해줄수 없게된다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/4db07e07-7d73-4abd-a001-0814d56debb0/image.png" alt=""></p>
<hr>
<ol>
<li><code>unlocked DB</code>
실제 테스트를 진행해보자</li>
</ol>
<pre><code class="language-java">package com.example.threadsafetest;

import com.example.threadsafetest.people.People;
import com.example.threadsafetest.people.PeopleRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Transactional
public class HelloService {
    final private PeopleRepository peopleRepository;

    public void plusNumber() {
        People people = peopleRepository.findPeopleByName(&quot;jun&quot;);
        people.setCount(people.getCount() + 1);
    }

    public int getNumber() {
        return peopleRepository.findPeopleByName(&quot;jun&quot;).getCount();
    }

}
</code></pre>
<p>1000째 응답</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/1412a440-805c-4bd0-8b96-68bfcd07f989/image.png" alt=""></p>
<p>DB에 접근하니 더 난리이다</p>
<p>접근해서 값을 가져오고 값을 쓰는데 훨신 더 많은 시간이 소요되기 때문이다 ! </p>
<p>그때 이미 가져온 값을 동시에 쓰게되고 숫자들이 밀리게 되는 현상이 발생한다 ! </p>
<hr>
<ol start="2">
<li><code>unlocked DB with synchronized</code>
이번엔 함수에 를 추가하고 결과를 살펴보자 !</li>
</ol>
<pre><code class="language-java">package com.example.threadsafetest;

import com.example.threadsafetest.people.People;
import com.example.threadsafetest.people.PeopleRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Transactional
public class HelloService {
    final private PeopleRepository peopleRepository;

    public synchronized void plusNumber() {
        People people = peopleRepository.findPeopleByName(&quot;jun&quot;);
        people.setCount(people.getCount() + 1);
    }

    public synchronized int getNumber() {
        return peopleRepository.findPeopleByName(&quot;jun&quot;).getCount();
    }

}
</code></pre>
<p>처리를 해줬음에도 불구하고 500이 조금 넘는 값이 나온것을 볼수있다 </p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/2153cb64-087f-4af9-bf3c-2a41071527d3/image.png" alt=""></p>
<p>Q. 그치만 Transactional을 통해서 트랜잭션이 보장된다면 -&gt; 메소드가 끝나기 전에 트랜잭션도 끝나는게 아닌가?</p>
<p>아니였다. </p>
<p>synchronized plusNumber() 메소드가 실행되고 메소드가 끝날때, @Transactional이 관리된다. 즉 메소드가 끝나면 트랜잭션이 커밋된다.</p>
<p>synchronized -&gt; JVM 영역</p>
<p>@Transactional -&gt; JDBC(JPA) DB 영역</p>
<p>이처럼 메소드 레벨이 끝나고 JDBC의 영역에서 일어나는 DB의 IO는 아예 다른 영역이기 때문이다.</p>
<p>따라서 DB의 Lock과 트랜잭션의 관리가 필요하다 !<del>!</del>!~!</p>
<hr>
<p>먼저 DB 동시성을 위해 간단히 지식 정리를 해보자</p>
<p>DB에서 동시성을 처리하려면 </p>
<ol>
<li>트랜잭션</li>
<li>Lock </li>
</ol>
<p>이 두가지가 정말 중요하다 !</p>
<p><code>트랜잭션</code>은</p>
<p>ex) one 이라는 사람이 two 라는 사람에게 2000원을 입금한다고 했을때 </p>
<p>one 계좌에서 -2000</p>
<p>two 계좌에서 +2000</p>
<p>이 과정이 두개다 성공했을때 Commit</p>
<p>하나라도 실패하면 Rollback을 진행한다.</p>
<p>그렇지 않으면 계좌 시스템에서 큰 에러가 발생한다 ! </p>
<p><code>Lock</code>은</p>
<p>ex) one 이라는 사람, two 라는 사람 각 각 three 에게 1000씩 입금한다고 해보자 ! </p>
<p>one도 기존 0 + 1000 반영 → 총 1000</p>
<p>two도 기존 0 + 1000 반영 → 총 1000</p>
<p>따라서 두명이 입금했음에도 </p>
<p>three의 계좌에는 총 1000이 있는 문제점이 생긴다.</p>
<p>이때 Lock을 걸어 순서를 보장해줄수 있다.</p>
<hr>
<p>DB의 Lock을 거는것은 두가지 방법이 존재한다.</p>
<p>첫번째는 
optimistic lock (낙관적 락)</p>
<pre><code>package com.example.threadsafetest.people;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
public class People {
    @Id
    @GeneratedValue
    @Column(name = &quot;people_id&quot;)
    private Long id;

    private String name;

    private Integer count;

    public People(String name, Integer count) {
        this.name = name;
        this.count = count;
    }

    @Version
    private Long version;
}
</code></pre><p>version을 추가하여 DB의 버전을 관리한다 ! </p>
<p>Entity에 @Version을 추가하여 DB에 Version 컬럼을 넣어준다. </p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/ca142b9a-6e3f-4f25-b567-989be277615e/image.png" alt=""></p>
<p>이후, JPA가 데이터를 CRUD할때 버전이 겹치면 Execption을 던져준다.</p>
<p>Read 로직이 많을때 적합하며, 버전 충돌시 예외 처리를 통해 시스템 안정화를 구현할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/0c6a3794-007a-45f8-8066-2d6f1f84089d/image.png" alt=""></p>
<p>지속해서 발생하는 버전 에러로 요청이 이뤄지지 않는다</p>
<p><code>StaleObjectStateException</code><br><code>트랜잭션 커밋 단계에서 버전이 다를때 발생하는 에러이다.</code>
<img src="https://velog.velcdn.com/images/junyoung-choe/post/ba52c5da-876b-4e27-806a-ac5fdf984ec9/image.png" alt=""></p>
<p>에러 처리를 통해 해당 트랜잭션에 다시 접근해보겠다.</p>
<pre><code class="language-java">@Transactional
    public void plusNumber() {
        boolean updated = false;
        while (!updated) {
            try {
                People people = peopleRepository.findPeopleByName(&quot;jun&quot;);
                people.setCount(people.getCount() + 1);
                peopleRepository.save(people);
                updated = true;
            } catch (OptimisticLockingFailureException e) {
                // 충돌이 발생하면 루프를 돌면서 재시도
                System.out.println(&quot;Optimistic lock exception, retrying...&quot;);
            }
        }
    }</code></pre>
<p>동시성 에러 발생시 DB 접근을 지속적으로 재시도해도 문제는 해결되지 않는다 !</p>
<p>이번에는 두번째 방법인 비관적 락을 시도해보자</p>
<p><code>비관적 락</code>
<img src="https://velog.velcdn.com/images/junyoung-choe/post/95ed2559-00e2-4e8a-be34-2c659097bef4/image.png" alt=""></p>
<p>비관적락은 다음과 같이 
@Lock(LockModeType.PESSIMSTIC_READ) 어노테이션을 통해 락 설정이 가능하다.</p>
<p>참고로 Mode에는 </p>
<ul>
<li><p>PESSIMISTIC_READ
다른 트랜잭션에게 읽기만을 허용</p>
</li>
<li><p>PESSIMISTIC_WRITE
다른 트랜잭션에서 쓰지도 읽지도 X</p>
</li>
<li><p>PESSIMISTIC_FORCE_INCREMENT
잠금을 걺과 동시에 버전을 증가</p>
</li>
</ul>
<p>내 로직에서는 데이터를 write 할때도 read 하면 안되기에 (가장 최근의 commit된 데이터를 조회 해와야 한다) PESSIMISTIC_WRITE을 선택했다.</p>
<pre><code class="language-java">package com.example.threadsafetest;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Scope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController()
@RequestMapping(&quot;hello&quot;)
@RequiredArgsConstructor
public class HelloController {

    final private HelloService helloService;

    @GetMapping()
    public Integer hello() {

        for (int i = 0; i &lt; 5; i++) {
            helloService.plusNumber();
        }


        return helloService.getNumber();
    }
}
</code></pre>
<pre><code class="language-java">package com.example.threadsafetest;

import com.example.threadsafetest.people.People;
import com.example.threadsafetest.people.PeopleRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class HelloService {
    final private PeopleRepository peopleRepository;

    @Transactional
    public void plusNumber() {
        People people = peopleRepository.findPeopleByName(&quot;jun&quot;);
        people.setCount(people.getCount() + 1);
        peopleRepository.save(people);
    }

    @Transactional
    public int getNumber() {
        return peopleRepository.findPeopleByName(&quot;jun&quot;).getCount();
    }

}
</code></pre>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/ce4a81f8-d3a1-4abb-80bc-299d38cb3df6/image.png" alt=""></p>
<p>정상적으로 모든 요청이 처리된것을 확인했다.</p>
<p>&quot;jun&quot;이라는 이름을 가진 컬럼에 Lock을 걸었기 때문에 트랜잭션이 commit or rollback 되기 전에는 해당 컬럼에 접근이 불가능하다. </p>
<p>따라서 비관적락을 사용하면 데이터의 정합성은 보존된다.</p>
<h4 id="다만-속도는-오래-걸린다는-단점이-있다">다만 속도는 오래 걸린다는 단점이 있다.</h4>
<hr>
<p>이전 포스팅에서 진행했던 
시스템 단계의 ConcurrentHahMap의 시간과 DB단계에서 처리한 비관적락의 시간을 비교해보겠다.</p>
<p>시스템 단계의 ConcurrentHahMap</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/a998c859-f2cd-4ef8-afb3-ec7cd467861d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/e9378d4d-e7f4-4472-ba31-0bf703edc702/image.png" alt=""></p>
<p>DB 단계의 Lock</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/dd93ef8b-0fa2-4ddf-bdbd-94b640662630/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/d60ced21-650b-41c6-86b4-4a6377bab35b/image.png" alt=""></p>
<p>총 시간만 확인하더라고 약 500정도 시간 차이가 발생한다.</p>
<p>그만큼 DB 접근 시간 + 비관적락 시간은 훨신 더 긴 시간이 소요된다.</p>
<hr>
<p>결론</p>
<p>트랜잭션이 보장되고 데드락과 성능 저하 부분을 고려해야 하지만 ! </p>
<p>가장 중요한 데이터의 정합성은 유지가 된다.</p>
<p>항상 trade off를 고민하며 학습해야 한다.</p>
<hr>
<p>추가적으로 왜 코드 로직을 최적화 해야하는지 깨달았다</p>
<p> +1을 5번 하는거 보단 +5 하는것이 동시성 관점에서 훨신 안정적이고 빠르다.</p>
<p> 또한 하나의 트랙잭션 내에서 처리 하는것이 좋다.</p>
<p> 예를들어 조회 로직이 이미 있다고 해도, 기존 로직에서 return 값을 바로 넘겨주며 하나의 트랜잭션 단위에서 처리한다면 더 안정적이다.</p>
<p>당연한 이야기지만 직접 로직을 작성하고, Test에 실패하며 실습으로 다뤘기에 몸소 체감이 가능했다.</p>
<p>동시성 로직을 구현할 기회가 생긴다면 꼭 도전해보고싶다.</p>
<p>참조 - 
<a href="https://velog.io/@chullll/JPA-%EC%9E%A0%EA%B8%88%EC%9D%98-%EC%A2%85%EB%A5%98">JPA-잠금의-종류</a></p>
<p><a href="https://devhooney.tistory.com/110">[Spring] 동시성 이슈 해결 방법 (3)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%8F%99%EC%8B%9C%EC%84%B1</link>
            <guid>https://velog.io/@junyoung-choe/%EB%8F%99%EC%8B%9C%EC%84%B1</guid>
            <pubDate>Fri, 09 Aug 2024 01:37:44 GMT</pubDate>
            <description><![CDATA[<p>회원가입시 선착순 100명에게 커피 쿠폰을 제공하는 이벤트가 있다고 가정 해보겠습니다.</p>
<p>이 경우에 일시적으로 1000명의 인원이 동시에 참여 버튼을 눌렀다면?</p>
<p>이 경우에 어떻게 선착순 100명을 지정하고 나머지 900명의 인원에게 &quot;선착순 마감&quot;이라는 메세지를 전해줄수 있을까요?</p>
<p>이러한 궁금증에서 시작했습니다.</p>
<p>1000개의 요청을 동시에 Jmeter를 활용하여 request하고 Java 코드 레벨에서 Test 해보겠습니다.</p>
<hr>
<p><a href="https://velog.io/@junyoung-choe/JVM%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C">이전 포스팅</a>
JAVA 멀티 스레드 환경에서 공유 자원에 대한 궁금증을 해결해보자</p>
<p>이전 포스팅에서 JVM을 공부하며, 공유자원의 중요성을 느꼈고, 예제와 test를 통해서 학습을 진행해보려 합니다.</p>
<hr>
<p><code>공유 자원 테스트</code></p>
<ol>
<li>Controller</li>
</ol>
<pre><code class="language-java">package com.example.threadsafetest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController()
@RequestMapping(&quot;hello&quot;)
public class HelloController {

    @Autowired
    private HelloService helloService;

    @GetMapping()
    public Integer hello() {

        for (int i = 0; i &lt; 5; i++) {
            helloService.plusNumber();
        }


        return helloService.getNumber();
    }
}
</code></pre>
<ol start="2">
<li>Service</li>
</ol>
<p>getNumber
해당 클래스의 count 변수를 get 한다.</p>
<p>plusNumber
해당 클래스의 count 변수를 ++ 해준다.</p>
<pre><code class="language-java">package com.example.threadsafetest;

import org.springframework.stereotype.Service;

@Service
public class HelloService {
    private int number = 0;

    public void plusNumber() {
        number++;
    }

    public int getNumber() {
        return number;
    }

}
</code></pre>
<p>1번째 호출</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/75fd9026-7c7f-42e1-9f6c-b244bb399476/image.png" alt=""></p>
<p>2번째 호출</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/51ee1d9a-a11a-417e-a356-204407e13061/image.png" alt=""></p>
<p>5번째 호출
<img src="https://velog.velcdn.com/images/junyoung-choe/post/968a9704-2ca2-410e-81bf-11a45c0187c8/image.png" alt=""></p>
<p>Spring load시에 </p>
<p>HelloService 객체가 로드되고 해당 객체는 컨텍스트에 존재한다.</p>
<p>따라서 HelloService는 모든 스레드에서 공유 자원으로 사용하고 해당 객체의 클래스 변수의 값 또한 공유된다.</p>
<p>따라서 number 라는 변수는 계속해서 증가한다.</p>
<hr>
<p><code>@Scope(&quot;prototype&quot;)</code>
그러면 스프링의 기본 방식인 싱글톤을 사용하지 않는다면 어떨까?</p>
<pre><code class="language-java">@Scope(&quot;prototype&quot;)</code></pre>
<p>Controller와 Service에 해당 어노테이션을 추가했다.</p>
<p>이후 API요청을 보내게 되면 </p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/ff056018-bfe1-4dd1-a99e-e8b21aaaf158/image.png" alt=""></p>
<p>5라는 숫자가 지속해서 출력된다.</p>
<p>왜 그럴까?</p>
<p>prototype은 요청마다 객체를 새로 제공해주는 설정으로 요청이 들어올때마다 새로운 Controller, Service 객체가 생성된다.</p>
<p>Q. 그럼 Controller, Service 양쪽다 설정이 필요한가 ? -&gt; Yes </p>
<ol>
<li>@Scope(&quot;prototype&quot;) Controller 에만</li>
</ol>
<p>Service는 싱글톤으로 설정되기에 Controller 객체들은 같은 Service 객체를 사용한다.
따라서 -&gt; 요청하면 같은 객체를 랜더링해준다.</p>
<ol start="2">
<li>@Scope(&quot;prototype&quot;) Service 에만</li>
</ol>
<p>이게 조금 헷갈렸다 ! (생각해보니 당연했다..)
컨텍스트에 싱글톤으로 생성된 Controller는 이미 하나의 Service를 가지고있고 따라서 Controller는 하나의 Service만 바라보게 된다(Service가 prototype으로 선언 되어 있어도 !)</p>
<hr>
<p><code>request 1000개</code></p>
<p>그렇다면 다시 양쪽에 싱글톤으로 처리한뒤 JMeter를 활용해 1000개의 요청을 병렬로 처리해보자</p>
<ul>
<li><p>IP, Port, URL 설정
<img src="https://velog.velcdn.com/images/junyoung-choe/post/a622e3ed-e727-43f8-a5b5-39ff216d810b/image.png" alt=""></p>
</li>
<li><p>스레드 갯수, 시간, 반복 설정
<img src="https://velog.velcdn.com/images/junyoung-choe/post/bd9c951b-78a2-4068-909b-d6568e62ca95/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p>요청 성공
<img src="https://velog.velcdn.com/images/junyoung-choe/post/a4ccd3b4-b385-46a0-aa44-6f1975ef20e9/image.png" alt=""></p>
</li>
<li><p>1000번째 응답
<img src="https://velog.velcdn.com/images/junyoung-choe/post/02b83cea-009d-4e20-b796-1acda1828a52/image.png" alt=""></p>
</li>
</ul>
<p>응답 결과를 보면 <code>1000 * 5 = 5000</code>을 기대했지만, 결과는 2999이다 </p>
<h3 id="헉-">헉 <del>!</del>!<del>!</del>!~!</h3>
<p>이게 바로 멀티 스레드 환경에서 발생하는 동시성 문제이다.</p>
<p>병렬로 요청하게 되면 동시에 Plus 요청을 하게 되고 -&gt; 이는 많은 양의 요청을 무시하는 결과를 가져온다.</p>
<hr>
<p><code>synchronized</code> </p>
<p>그러면 어떻게 동시성을 보장 받을수 있을까?</p>
<p>방법은 간단하다 !</p>
<p>병렬로 요청이 동시에 들어오더라도 코드 레벨에서 메소드의 접근을 하나씩만 차례로 받아 드린다면? 이런 문제를 해결할수 있다.</p>
<p>Java에서 그런 역할을 해주는게 바로 &quot;synchronized&quot; </p>
<pre><code class="language-jsx">package com.example.threadsafetest;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

@Service
//@Scope(&quot;prototype&quot;)
public class HelloService {
    private int number = 0;

    public synchronized void plusNumber() {
        number++;
    }

    public synchronized int getNumber() {
        return number;
    }

}
</code></pre>
<p>plusNumber &amp; getNumber 에 synchronized 기능을 추가하여 동시성을 보장시킬수 있다 ! </p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/85e650f3-1c83-4628-9a62-dbc2a1ab65dd/image.png" alt=""></p>
<p>1000번째 요청이 정상적으로 기대한 5000이 나오는것을 알수있다.</p>
<hr>
<p><code>변수가 여러개라면 ?</code></p>
<p>그렇지만 만약에 number 라는 변수말고 다른 변수에도 랜덤하게 접근한다면?</p>
<p>[인기있는 축구 경기(토트넘 VS 뮌헨)] (<a href="https://www.coupangplay.com/promotion/coupangplayseries/">https://www.coupangplay.com/promotion/coupangplayseries/</a>) 많은 관중들이 각자 원하는 자리를 선착순으로 선택한다 !</p>
<p>이때 만약 자리에 상관없이 동시성 처리를 한다면 10000명의 사람이 순서에 따라서 기다려야 한다 !</p>
<p>그치만 만약 각 자리(100좌석 가정)에 동시성이 걸린다면? 그렇다면 평균적으로 각 자리에 100명의 사람만 기다리면 되는 결과가 만들어진다.</p>
<p>이렇게 자리별(버킷 or 인덱스)로 Lock을 걸어주는 컬렉션이 바로 ! </p>
<p><code>&quot;ConcurrentHashMap&quot;</code> </p>
<p>일반 <code>HashMap</code> 은 동기화 처리가 안되있기 때문에 스레드 세이프하지 않다.</p>
<p><code>HashTable</code> 은 모두 synchronized 되어있기 때문에 성능적으로 느리다 ! </p>
<p><code>ConcurrentHashMap</code> 은 선택적으로 동기화 처리가 되어있기 때문에 성능적으로 개선이 된다.</p>
<pre><code class="language-java">package com.example.threadsafetest;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Scope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController()
@RequestMapping(&quot;hello&quot;)
//@Scope(&quot;prototype&quot;)
@RequiredArgsConstructor
public class HelloController {

    final private HelloService helloService;

    @GetMapping()
    public Integer hello() {

        for (int i = 0; i &lt; 5; i++) {
            helloService.plusNumber();
        }

        return helloService.getNumber();
    }
}
</code></pre>
<p>기존의 synchronized를 제거하고 
plusNumber, getNumber 메소드에서 ConcurrentHashMap에 접근해서 Plus, Get 해주고 있다.</p>
<p>이 과정에서 각 버킷에만 Lock이 걸려 동작한다</p>
<pre><code class="language-java">package com.example.threadsafetest;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

import java.util.concurrent.ConcurrentHashMap;

@Service
//@Scope(&quot;prototype&quot;)
public class HelloService {
    ConcurrentHashMap&lt;String, Integer&gt; concurrentHashMap = new ConcurrentHashMap&lt;&gt;();

    // 초기 값 설정
    public HelloService() {
        concurrentHashMap.put(&quot;number&quot;, 0);
    }

    public void plusNumber() {
        concurrentHashMap.computeIfPresent(&quot;number&quot;, (Key, oldValue) -&gt; oldValue + 1);
    }

    public int getNumber() {
        return concurrentHashMap.get(&quot;number&quot;);
    }

}
</code></pre>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/43a61389-4076-469b-b923-0c537150cdf0/image.png" alt=""></p>
<p>결과값으로 5000을 받아볼수 있다.</p>
<hr>
<ul>
<li>결론 </li>
</ul>
<p>IT 시대에서 고트래픽에 대비하지 않는다면 그 서비스는 위험..ㅎㅏ다.....</p>
<p>실서비스 경험과 고트래픽 경험이 적어서 동시성에 대한 고민을 해볼수 있는 기회가 없었다.</p>
<p>Java의 내부 원리를 학습하다 동시성까지 오게되었고 덕분에 동시성처리까지 해볼 수 있었다.</p>
<p>스레드끼리 공유할 수 있는 자원에 대해서 동시성 처리와 + 성능 개선이 중요하다!</p>
<p>+++
추가
Q. 또 하나의 궁금증이 생겼다...</p>
<p>DB에 연동됬을때는 어떻게 처리하고?</p>
<p>또 서버의 갯수가 하나가 아니라면?..</p>
<p>다음 포스팅에서 학습해보겠다.</p>
<p>참고 -
<a href="https://velog.io/@alsgus92/ConcurrentHashMap%EC%9D%98-Thread-safe-%EC%9B%90%EB%A6%AC">[Java] ConcurrentHashMap는 어떻게 Thread-safe 한가?</a></p>
]]></description>
        </item>
    </channel>
</rss>