<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jelog_131.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 24 Feb 2026 07:11:44 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jelog_131.log</title>
            <url>https://velog.velcdn.com/images/jelog_131/profile/ccc57688-f3bb-4395-95ef-5579283cdb84/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jelog_131.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jelog_131" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Thymeleaf에서 React(Vercel)로 프론트 분리하며 겪은 모든 것]]></title>
            <link>https://velog.io/@jelog_131/Thymeleaf%EC%97%90%EC%84%9C-ReactVercel%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8-%EB%B6%84%EB%A6%AC%ED%95%98%EB%A9%B0-%EA%B2%AA%EC%9D%80-%EB%AA%A8%EB%93%A0-%EA%B2%83</link>
            <guid>https://velog.io/@jelog_131/Thymeleaf%EC%97%90%EC%84%9C-ReactVercel%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8-%EB%B6%84%EB%A6%AC%ED%95%98%EB%A9%B0-%EA%B2%AA%EC%9D%80-%EB%AA%A8%EB%93%A0-%EA%B2%83</guid>
            <pubDate>Tue, 24 Feb 2026 07:11:44 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jelog_131/post/e2e29d64-b05c-4769-bb17-dbdc69e99077/image.png" alt=""></p>
<blockquote>
<p>Spring Boot + Thymeleaf 모놀리식 구조에서 React(Vercel) + Spring Boot(Docker) 분리 배포까지, 실전에서 마주한 삽질과 해결 과정을 공유합니다.</p>
</blockquote>
<h2 id="왜-프론트를-분리했는가">왜 프론트를 분리했는가?</h2>
<p>기존 마이니치 니홍고 프로젝트는 Spring Boot + Thymeleaf로 프론트와 백엔드가 하나의 서버에서 동작하는 모놀리식 구조였습니다.</p>
<pre><code class="language-text">[사용자] → [Ubuntu 서버 (Spring Boot + Thymeleaf)]
                    ↕
              [MySQL DB]
</code></pre>
<p>여기에 소셜 로그인(Google, Kakao), JWT 인증, 학습 콘텐츠 리스트 등 프론트 기능이 점점 복잡해지면서 React로 전환하게 되었고, 자연스럽게 프론트/백엔드 분리 배포를 결정했습니다.</p>
<h3 id="목표-아키텍처">목표 아키텍처</h3>
<pre><code class="language-text">[사용자] → [Vercel (React)] ←→ [Ubuntu 서버 (Spring Boot API)]
     mainichi-nihongo.com          api.mainichi-nihongo.com
                                          ↕
                                    [MySQL DB]
                                    [Redis]
</code></pre>
<ul>
<li>프론트엔드: Vercel에 배포 (자동 CI/CD, CDN)</li>
<li>백엔드: Ubuntu 인스턴스에 Docker로 배포 (nginx-proxy + Let&#39;s Encrypt)</li>
<li>도메인 분리: mainichi-nihongo.com (프론트) / api.mainichi-nihongo.com (백엔드)</li>
</ul>
<hr>
<h2 id="1-프론트엔드--환경변수로-api-주소-관리하기">1. 프론트엔드 : 환경변수로 API 주소 관리하기</h2>
<h4 id="문제">문제</h4>
<p>개발 시에는 Vite의 proxy 설정으로 /api 요청을 localhost:8080으로 프록시했지만, 프로덕션에서는 백엔드 서버 주소가 다릅니다.</p>
<pre><code class="language-typescript">// ❌ 하드코딩된 API 주소
const API_BASE_URL = &#39;http://localhost:8080&#39;;</code></pre>
<h4 id="해결">해결</h4>
<p>Vite의 환경변수 시스템을 활용했습니다.</p>
<pre><code class="language-typescript">// ✅ 환경변수로 관리
export const API_BASE_URL =
  import.meta.env.VITE_API_BASE_URL || &#39;http://localhost:8080&#39;;

const api = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
});</code></pre>
<p>Vercel 대시보드에서 VITE_API_BASE_URL을 <a href="https://api.mainichi-nihongo.com">https://api.mainichi-nihongo.com</a> 으로 설정하면 프로덕션에서 자동으로 올바른 주소를 사용합니다.</p>
<blockquote>
<p>⚠️ 주의: OAuth2 로그인 URL도 같은 방식으로 처리해야 합니다. authApi.ts에서 API_BASE_URL을 import해서 사용하세요.</p>
</blockquote>
<hr>
<h2 id="2-백엔드--cors와-도메인-설정">2. 백엔드 : CORS와 도메인 설정</h2>
<h4 id="cors-허용-출처-추가">CORS 허용 출처 추가</h4>
<p>프론트가 다른 도메인에서 API를 호출하므로 CORS 설정이 필수입니다.</p>
<pre><code class="language-yaml"># application.yml
cors:
  allowed-origins:
    - http://localhost:3000          # 로컬 개발
    - https://mainichi-nihongo.com    # 프로덕션
    - https://www.mainichi-nihongo.com
    - https://api.mainichi-nihongo.com</code></pre>
<h4 id="docker-compose에서-api-서브도메인-설정">Docker Compose에서 API 서브도메인 설정</h4>
<p>nginx-proxy + Let&#39;s Encrypt로 SSL 인증서 자동 발급</p>
<pre><code class="language-yaml"># docker-compose.yml
services:
  app:
    environment:
      VIRTUAL_HOST: api.mainichi-nihongo.com
      LETSENCRYPT_HOST: api.mainichi-nihongo.com
      FRONTEND_URL: ${FRONTEND_URL}</code></pre>
<hr>
<h2 id="3-cicd--github-actions로-자동-배포">3. CI/CD : Github Actions로 자동 배포</h2>
<h4 id="환경변수-전달-구조">환경변수 전달 구조</h4>
<p>개발 중 OAuth2, JWT, Redis 등의 기능이 추가되면서 환경변수가 많아졌습니다. Github Secrets -&gt; SSH -&gt; .env 파일로 안전하게 전달합니다.</p>
<pre><code class="language-yaml"># .github/workflows/application-deploy-oracle.yml
- name: Deploy via SSH with environment variables
  env:
    DB_URL: ${{ secrets.DB_URL }}
    JWT_SECRET: ${{ secrets.JWT_SECRET }}
    GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
    KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }}
    REDIS_HOST: ${{ secrets.REDIS_HOST }}
    REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
    # ... 기타 변수들
  run: |
    ssh ubuntu@${{ secrets.ORACLE_HOST }} &lt;&lt; EOF
      echo &quot;DB_URL=${DB_URL}&quot; &gt; .env
      echo &quot;JWT_SECRET=${JWT_SECRET}&quot; &gt;&gt; .env
      # ... 나머지 변수들
      sudo docker compose pull
      sudo docker compose up -d --force-recreate
    EOF</code></pre>
<hr>
<h2 id="4-삽질-모음--프로덕션에서만-터지는-것들">4. 삽질 모음 : 프로덕션에서만 터지는 것들</h2>
<h4 id="삽질-1-docker-이미지가-없다고">삽질 1: Docker 이미지가 없다고?</h4>
<pre><code class="language-text">ERROR: pull access denied, repository does not exist or may require authentication</code></pre>
<p>openjdk:17-jdk-slim 이미지가 Docker Hub에서 deprecated 되어 더 이상 pull이 안 됩니다.</p>
<pre><code class="language-dockerfile"># ❌ deprecated
FROM openjdk:17-jdk-slim

# ✅ Eclipse Temurin으로 교체 (multi-platform 지원)
FROM eclipse-temurin:17-jre-jammy</code></pre>
<h4 id="삽질-2-oauth2-로그인하면-redirect_uri가-http">삽질 2: OAuth2 로그인하면 redirect_uri가 http://?</h4>
<p>카카오 로그인 시 이런 에러가 발생했습니다</p>
<blockquote>
<p>등록하지 않은 리다이렉트 URI를 사용해 인가 코드를 요청했습니다. 사용한 리다이렉트 URI: <a href="http://api.mainichi-nihongo.com/login/oauth2/code/kakao">http://api.mainichi-nihongo.com/login/oauth2/code/kakao</a></p>
</blockquote>
<p>분명 https://로 등록했는데 http://로 요청이 갔습니다.</p>
<p>원인: Spring Boot는 nginx-proxy 뒤에서 8080 포트로 동작하기 때문에, 자신이 http://로 접근되고 있다고 인식합니다. nginx가 HTTPS를 처리하고 내부적으로는 HTTP로 전달하기 때문이죠.</p>
<pre><code class="language-yaml"># application.yml - 한 줄 추가로 해결
server:
  port: 8080
  forward-headers-strategy: native  # ← 이거!</code></pre>
<p>이 설정으로 nginx-proxy가 보내는 X-Forwarded-Proto: https 헤더를 Spring Boot가 인식하게 됩니다.</p>
<blockquote>
<p>Google OAuth2는 프로덕션에서 https:// redirect URI만 허용합니다. http://로 등록하면 작동하지 않아요.</p>
</blockquote>
<h4 id="삽질-3-컨테이너-이름-충돌">삽질 3: 컨테이너 이름 충돌</h4>
<pre><code class="language-text">Error: Conflict. The container name &quot;/mainichi-nihongo&quot; is already in use
by container &quot;334f3290c63f...&quot;</code></pre>
<p>docker compose down은 현재 compose 프로젝트의 컨테이너만 제거합니다. 이전에 별도로 실행했던 같은 이름의 컨테이너가 남아있으면 충돌이 발생합니다.</p>
<pre><code class="language-bash"># 수동으로 제거
sudo docker rm 334f3290c63f
sudo docker compose up -d</code></pre>
<h4 id="삽질-4-로그인하면-login-페이지로">삽질 4: 로그인하면 /login 페이지로?</h4>
<p>OAuth2 로그인이 api.mainichi-nihongo.com/login으로 리다이렉트되는 현상. 로그를 확인해보니</p>
<pre><code class="language-text">Table &#39;mainichi_nihongo.users&#39; doesn&#39;t exist</code></pre>
<p>application.yml에서 ddl-auto: none으로 설정해놓고 프로덕션 DB에 users 테이블을 생성하지 않았던 것이 원인이었습니다. 로컬에서는 이미 만들어둬서 잘 되었는데, 배포 환경에서는 빠뜨린 거죠.</p>
<pre><code class="language-sql">CREATE TABLE users (
    id BIGINT NOT NULL AUTO_INCREMENT,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(100),
    profile_image VARCHAR(500),
    provider VARCHAR(255) NOT NULL,
    provider_id VARCHAR(255) NOT NULL,
    tier VARCHAR(255) NOT NULL,
    created_at DATETIME(6) NOT NULL,
    last_login_at DATETIME(6),
    subscriber_id BIGINT,
    PRIMARY KEY (id),
    UNIQUE KEY uk_users_email (email)
);</code></pre>
<blockquote>
<p>ddl-auto: none 환경에서는 엔티티를 추가할 때마다 프로덕션 DB 마이그레이션을 잊지 말 것! Flyway나 Liquibase 같은 마이그레이션 도구 도입을 고려해보세요.</p>
</blockquote>
<h4 id="삽질-5-vercel에서-직접-url-접속하면-404">삽질 5: Vercel에서 직접 URL 접속하면 404</h4>
<p><a href="https://mainichi-nihongo.com/contents/20260223%EC%9D%84">https://mainichi-nihongo.com/contents/20260223을</a> 직접 주소창에 입력하면</p>
<pre><code class="language-text">404: NOT_FOUND</code></pre>
<p>React는 <strong>SPA(Single Page Application)</strong>이기 때문에 모든 라우팅은 클라이언트(React Router)에서 처리합니다. Vercel에 해당 경로의 실제 파일이 없으므로 404를 반환합니다.</p>
<pre><code class="language-json">// vercel.json
{
  &quot;rewrites&quot;: [
    { &quot;source&quot;: &quot;/(.*)&quot;, &quot;destination&quot;: &quot;/index.html&quot; }
  ]
}</code></pre>
<p>이 설정으로 모든 경로를 index.html로 리라이트하면 React Router가 라우팅을 처리할 수 있습니다.</p>
<h4 id="삽질-6-파비콘이-vercel에서-안-보인다">삽질 6: 파비콘이 Vercel에서 안 보인다?</h4>
<p>public/favicon.ico를 넣었는데 Vercel에서 404. .gitignore를 열어보니</p>
<pre><code class="language-gitignore"># Gatsby files
.cache/
public        # ← 이 놈!!!!</code></pre>
<p>프로젝트 초기에 범용 .gitignore 템플릿을 사용했더니 Gatsby용 설정이 포함되어 있었고, Vite 프로젝트에서 정적 파일을 두는 public/ 폴더 전체가 Git에서 무시되고 있었습니다.</p>
<pre><code class="language-gitignore"># ✅ 해당 라인 삭제 후
git add public/favicon.ico</code></pre>
<blockquote>
<p>.gitignore 템플릿을 그대로 쓰지 말고, 프로젝트에 맞게 검토하세요.</p>
</blockquote>
<hr>
<h2 id="최종-체크리스트">최종 체크리스트</h2>
<p>프론트/백엔드 분리 배포 시 확인해야 할 사항들</p>
<ul>
<li>프론트 API 주소가 환경변수로 관리되는가 ?</li>
<li>백엔드 CORS에 프론트 도메인이 허용되는가?</li>
<li>OAuth2 rediret URI가 HTTPS인가?</li>
<li>forward-headers-strategy가 설정되었는가?</li>
<li>Vercel에 SPA 라우팅 설정 (vercel.json)이 있는가?</li>
<li>.gitignore가 정적 파일을 무시하고 있지 않은가?</li>
<li>프로덕션 DB에 새 테이블이 생성되었는가?</li>
<li>Github Secrets에 모든 환경변수가 등록되었는가?</li>
<li>Docker 이미지가 최신 &amp; multi-platform 지원인가?</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>모놀리식에서 프론트/백엔드 분리는 단순히 &quot;코드를 나누는 것&quot;이 아니라, <strong>인프라, 네트워크, 보안, CI/CD 전반을 재설계하는 작업</strong>이었습니다.</p>
<p>특히 <strong>forward-headers-strategy</strong>나 <strong>.gitignore</strong>의 <strong>public</strong> 같은 문제는 로컬에서는 절대 발견할 수 없는, 프로덕션에서만 터지는 이슈들이어서 더 고생했습니다.</p>
<p>이 글이 비슷한 구조를 도입하려는 분들에게 도움이 되길 바랍니다 ! </p>
<h4 id="마이니치-니홍고-이용해보기">마이니치 니홍고 이용해보기</h4>
<p><a href="https://mainichi-nihongo.com">https://mainichi-nihongo.com</a></p>
<hr>
<blockquote>
<p>기술 스택: React + TypeScript + Vite (Vercel) / Spring Boot 3.4.5 + JPA + Security + OAuth2 (Docker) / MySQL 8.0 / Redis 7 / nginx-proxy + Let&#39;s Encrypt / GitHub Actions</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring Modulith: MSA가 부담스러울 때 선택한 우아한 모놀리스]]></title>
            <link>https://velog.io/@jelog_131/Spring-Spring-Modulith-MSA%EA%B0%80-%EB%B6%80%EB%8B%B4%EC%8A%A4%EB%9F%AC%EC%9A%B8-%EB%95%8C-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9A%B0%EC%95%84%ED%95%9C-%EB%AA%A8%EB%86%80%EB%A6%AC%EC%8A%A4</link>
            <guid>https://velog.io/@jelog_131/Spring-Spring-Modulith-MSA%EA%B0%80-%EB%B6%80%EB%8B%B4%EC%8A%A4%EB%9F%AC%EC%9A%B8-%EB%95%8C-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9A%B0%EC%95%84%ED%95%9C-%EB%AA%A8%EB%86%80%EB%A6%AC%EC%8A%A4</guid>
            <pubDate>Tue, 09 Dec 2025 08:51:14 GMT</pubDate>
            <description><![CDATA[<p>&quot;팀 규모는 작은데 코드는 점점 스파게티가 되어간다...&quot; 🍝</p>
<p>개발하다 보면 누구나 겪는 딜레마. 모놀리스(Monolith)로 빠르게 시작하긴 했는데, 기능이 늘어날수록 의존성이 뒤엉켜서 <strong>&#39;거대한 진흙 덩어리(Big Ball of Mud)&#39;</strong>가 되어버린다. 그렇다고 당장 MSA로 넘어가자니 인프라 비용이랑 관리 포인트가 너무 부담스럽고.</p>
<p>그래서 찾아본 현실적인 대안, Spring Modulith. 스프링 공식 프로젝트라 믿을만하고, <strong>&#39;모듈러 모놀리스(Modular Monolith)&#39;</strong>를 강제해 주는 도구다. 핵심만 빠르게 정리해 둔다.</p>
<hr>
<h2 id="spring-modulith가-뭔데">Spring Modulith가 뭔데?</h2>
<p>쉽게 말해 &quot;패키지 구조가 곧 아키텍처가 되게 만드는&quot; 프레임워크다.</p>
<p>패키지 = 모듈: 최상위 패키지 바로 아래 있는 패키지들을 하나의 &#39;논리적 모듈&#39;로 본다.</p>
<p>경계 강제: 내 모듈의 public 클래스만 남이 쓸 수 있게 하고, 내부는 철저히 숨긴다.</p>
<p>느슨한 결합: 모듈끼리는 웬만하면 이벤트를 던져서 소통한다.</p>
<h2 id="왜-써야-할까-핵심-기능-3가지">왜 써야 할까? (핵심 기능 3가지)</h2>
<p>직접 써보니 좋았던 점은 구조 검증, 이벤트 처리, 문서화 딱 3가지다.</p>
<h3 id="첫째-아키텍처-구조-검증-verification">첫째, 아키텍처 구조 검증 (Verification)</h3>
<p>보통 개발하다 보면 Order에서 Inventory 부르고, Inventory가 다시 Payment 부르고 난리가 난다. Spring Modulith는 테스트 코드 한 줄로 순환 참조나 허용되지 않은 의존성을 잡아낸다.</p>
<p>Java</p>
<pre><code class="language-java">class ApplicationModulesTest {

    @Test
    void verifyModularity() {
        // 전체 모듈 구조가 규칙을 잘 지키는지 검증
        // 순환 참조 있으면 바로 테스트 실패 뜸
        ApplicationModules.of(Application.class).verify();
    }
}</code></pre>
<p>빌드 타임에 아키텍처 깨지는 걸 막을 수 있다는 게 엄청난 장점.</p>
<hr>
<h3 id="둘째-이벤트-기반의-느슨한-결합-event-externalization">둘째, 이벤트 기반의 느슨한 결합 (Event Externalization)</h3>
<p>서비스끼리 메서드 직접 호출(의존)하지 말고, <strong>이벤트(Event)</strong>를 던지라는 거다. 특히 Transactional Outbox Pattern 구현이 진짜 편하다.</p>
<p>상황: 주문 완료되면 -&gt; 재고 줄이기</p>
<p>주문 모듈: 주문 끝나면 OrderPlacedEvent 발행</p>
<p>재고 모듈: 이벤트 리스닝해서 처리</p>
<p>Java</p>
<pre><code class="language-java">// 재고 모듈 (Inventory)
@Component
@RequiredArgsConstructor
class InventoryEventListener {

    private final InventoryService inventoryService;

    // @EventListener 말고 이거 씀
    // 트랜잭션 커밋 된 후에 실행됨을 보장해 줌
    @ApplicationModuleListener 
    void on(OrderPlacedEvent event) {
        inventoryService.decreaseStock(event.orderId());
    }
}</code></pre>
<p>@ApplicationModuleListener: 트랜잭션 성공했을 때만 실행되고, 실패하면 재시도 처리까지 지원함. 비동기 처리가 세상 편해짐.</p>
<hr>
<h3 id="셋째-살아있는-문서화-documentation">셋째, 살아있는 문서화 (Documentation)</h3>
<p>코드 짜기도 바쁜데 문서 업데이트는 언제 함? 얘는 코드를 분석해서 모듈 간 관계도(C4 Model)를 PlantUML로 그려준다.</p>
<p>Java</p>
<pre><code class="language-java">@Test
void writeDocumentationSnippets() {
    new Documenter(ApplicationModules.of(Application.class))
            .writeDocumentation() // build 폴더에 puml 파일 생성됨
            .writeIndividualModulesAsPlantUml();
}</code></pre>
<p>코드가 바뀌면 문서도 알아서 바뀐다. 이게 진짜 문서화지.</p>
<hr>
<h2 id="디렉토리-구조는-이렇게">디렉토리 구조는 이렇게</h2>
<p>패키지 구조가 곧 모듈 정의다.</p>
<pre><code class="language-plaintext">src/main/java
└── com.example.shop
    ├── ShopApplication.java
    ├── inventory            // [Inventory 모듈]
    │   ├── Inventory.java
    │   ├── InventoryService.java (public - 공개 API)
    │   └── internal          // (숨김 - 외부에서 접근 불가!)
    │       └── InventoryRepository.java 
    ├── order                // [Order 모듈]
    │   └── OrderService.java
    └── payment              // [Payment 모듈]</code></pre>
<p>internal 같은 패키지에 넣어둔 건 외부 모듈에서 import 하려고 하면 컴파일러가(혹은 테스트가) 혼낸다. 강제로 캡슐화가 됨.</p>
<hr>
<h2 id="결론-언제-도입할까">결론: 언제 도입할까?</h2>
<p>&quot;지금 당장 MSA 하긴 오버인데, 나중에 쪼갤 준비는 하고 싶을 때&quot;</p>
<p>배포는 하나로 퉁쳐서 운영 편의성은 챙기고, 코드 내부는 MSA처럼 깔끔하게 유지할 수 있다. 나중에 트래픽 터져서 떼어내야 할 때도, 이미 모듈 경계가 확실해서 찢어내기 쉽다.</p>
<p>레거시가 엉망이거나, 신규 프로젝트 깔끔하게 시작하고 싶다면 Spring Modulith, 무조건 찍먹해볼 만하다.</p>
<p>📚 Reference
<a href="https://spring.io/projects/spring-modulith">Spring Modulith Reference Documentation</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mini PC에 Ubuntu를 설치하고 Railway에서 마이그레이션해보며 깨달은 점]]></title>
            <link>https://velog.io/@jelog_131/Mini-PC%EC%97%90-Ubuntu%EB%A5%BC-%EC%84%A4%EC%B9%98%ED%95%98%EA%B3%A0-Railway%EC%97%90%EC%84%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%B4%EB%B3%B4%EB%A9%B0-%EA%B9%A8%EB%8B%AC%EC%9D%80-%EC%A0%90</link>
            <guid>https://velog.io/@jelog_131/Mini-PC%EC%97%90-Ubuntu%EB%A5%BC-%EC%84%A4%EC%B9%98%ED%95%98%EA%B3%A0-Railway%EC%97%90%EC%84%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%B4%EB%B3%B4%EB%A9%B0-%EA%B9%A8%EB%8B%AC%EC%9D%80-%EC%A0%90</guid>
            <pubDate>Mon, 20 Oct 2025 12:07:23 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jelog_131/post/094a0739-0e81-4e6b-bda4-b1d8942a5f5a/image.png" alt=""></p>
<p>최근에 Mini PC를 구매해서 직접 Ubuntu를 설치하고, 기존에 Railway에서 운영하던 서비스를 Mini PC로 마이그레이션하는 작업을 진행했다.
처음엔 단순히 “클라우드 비용을 아껴보자”는 생각이었는데, 실제로 직접 서버를 구성해보니 예상보다 훨씬 많은 걸 배우고 느낄 수 있었다.</p>
<hr>
<h2 id="1-실제-서버-환경-구성--클라우드가-자동으로-해주던-것들">1. 실제 서버 환경 구성 — 클라우드가 자동으로 해주던 것들</h2>
<p>Railway 같은 클라우드 서비스에서는 배포 버튼 몇 번만 누르면 모든 게 자동으로 된다.
하지만 Mini PC 환경에서는 기본적인 네트워크와 서버 설정을 모두 직접 해야 한다.</p>
<h4 id="포트포워딩-설정">포트포워딩 설정</h4>
<p>라우터에서 외부 요청이 Mini PC까지 올 수 있도록 포트포워딩을 수동으로 설정했다.
예를 들어, HTTP(80), HTTPS(443), API용 8080 포트를 열어서 내 내부 IP로 포워딩:</p>
<pre><code class="language-bash"># 예시 (라우터 설정 화면에서)
외부포트: 80  → 내부 IP: 192.168.0.20  내부포트: 80  (TCP)
외부포트: 443 → 내부 IP: 192.168.0.20  내부포트: 443 (TCP)
외부포트: 8080 → 내부 IP: 192.168.0.20 내부포트: 8080 (TCP)</code></pre>
<p>라우터마다 UI가 조금씩 다르긴 하지만, 원리는 동일하다.</p>
<hr>
<h3 id="ufw-방화벽-설정">UFW 방화벽 설정</h3>
<p>Ubuntu 기본 방화벽인 UFW(Uncomplicated Firewall)를 활용해서 불필요한 포트는 차단하고, 필요한 포트만 열었다.</p>
<pre><code class="language-bash"># 방화벽 활성화
sudo ufw enable

# 기본 정책: 모든 외부 접근 차단
sudo ufw default deny incoming
sudo ufw default allow outgoing

# 필요한 포트만 열기
sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP
sudo ufw allow 443/tcp   # HTTPS
sudo ufw allow 8080/tcp  # API 서버</code></pre>
<p>이 과정을 거치니, “클라우드에서 왜 기본적으로 보안 설정이 잘 돼 있는지” 체감할 수 있었다.
직접 하면 생각보다 귀찮고, 설정 실수 하나로 바로 접속이 막히거나 공격 대상이 되기도 한다.</p>
<hr>
<h3 id="도메인--ssl-인증서-적용">도메인 &amp; SSL 인증서 적용</h3>
<p>무료 SSL 인증서를 발급해주는 Let’s Encrypt와 Nginx를 활용해서 HTTPS를 적용했다.</p>
<pre><code class="language-bash">sudo apt update
sudo apt install nginx certbot python3-certbot-nginx

# Nginx 서버블록 설정 (예: /etc/nginx/sites-available/myservice)
server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://localhost:8080;
    }
}

# 심볼릭 링크로 활성화
sudo ln -s /etc/nginx/sites-available/myservice /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

# SSL 인증서 발급
sudo certbot --nginx -d example.com</code></pre>
<p>이제 브라우저에서 <a href="https://example.com%EC%9C%BC%EB%A1%9C">https://example.com으로</a> 접속하면 인증서가 적용된 안전한 서비스에 접근할 수 있게 됐다.</p>
<hr>
<h3 id="서비스-자동-실행-설정-systemd">서비스 자동 실행 설정 (systemd)</h3>
<p>서버가 재부팅되거나 장애가 발생했을 때 자동으로 백엔드 애플리케이션이 재시작되도록 systemd 설정도 추가했다.</p>
<pre><code class="language-bash">sudo nano /etc/systemd/system/myservice.service</code></pre>
<pre><code class="language-ini">[Unit]
Description=My Spring Boot Service
After=network.target

[Service]
User=ubuntu
ExecStart=/usr/bin/java -jar /home/ubuntu/app/myservice.jar
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target</code></pre>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable myservice
sudo systemctl start myservice</code></pre>
<p>이제 서버가 꺼졌다 켜져도 자동으로 애플리케이션이 실행된다.</p>
<hr>
<h2 id="2-클라우드가-그만큼의-비용을-받는-이유를-실감하다">2. 클라우드가 “그만큼의 비용”을 받는 이유를 실감하다</h2>
<p>Railway, Vercel, AWS, GCP 등은 단순히 서버만 빌려주는 게 아니다.
실제로 해보면, 그들이 인프라를 얼마나 자동화하고 안정적으로 제공하는지가 피부로 느껴진다.
    • 자동 백업 / 복구
    • 장애 대비 고가용성 구성 (HA)
    • 로드밸런싱 및 트래픽 분산
    • 모니터링, 로그 수집, 알림 시스템</p>
<p>이런 걸 혼자서 Mini PC 환경에 구성하려면 생각보다 훨씬 많은 시간, 경험, 그리고 리소스가 필요하다.
그래서 규모가 커지면 결국 클라우드 서비스로 이전하는 게 정답이라는 걸 깨달았다.</p>
<hr>
<h2 id="3-mini-pc의-역할--학습용-소규모-서비스에-딱이다">3. Mini PC의 역할 — 학습용, 소규모 서비스에 딱이다</h2>
<p>그렇다고 Mini PC가 의미 없는 건 아니다. 오히려 공부용, 실험용으로는 정말 훌륭하다.
    •    개인 블로그나 소규모 API 서버 운영 가능
    •    Docker, Nginx, CI/CD 등 실습에 최적
    •    인프라 전반을 스스로 설정해보며 실력을 끌어올릴 수 있음
    •    비용 부담 없이 자유롭게 테스트 가능</p>
<p>실제로 이번 마이그레이션 경험은 단순히 서버 이전이 아니라, 인프라의 기본 원리를 체득하는 값진 시간이었다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>👉 정리하자면,
    • 공부/소규모 서비스에는 Mini PC로도 충분하다.
    • 실서비스/대규모 트래픽은 클라우드의 안정성과 자동화가 필요하다.
    • 무엇보다, 실제로 직접 설정해보는 경험이 개발자로서의 역량을 크게 키운다.</p>
<p>이번 경험을 통해 클라우드의 편리함이 단순한 “편의” 수준이 아니라는 걸 확실히 느꼈고, 동시에 Mini PC 환경에서 자유롭게 실험해볼 수 있는 즐거움도 컸다.
앞으로도 이런 식의 실험을 계속해 나갈 생각이다. 💪</p>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/687b3137-a120-46dd-b754-a85f8e05d16d/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 서브넷과 서브넷 마스크 완벽 정리]]></title>
            <link>https://velog.io/@jelog_131/TIL-%EC%84%9C%EB%B8%8C%EB%84%B7%EA%B3%BC-%EC%84%9C%EB%B8%8C%EB%84%B7-%EB%A7%88%EC%8A%A4%ED%81%AC-%EC%99%84%EB%B2%BD-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jelog_131/TIL-%EC%84%9C%EB%B8%8C%EB%84%B7%EA%B3%BC-%EC%84%9C%EB%B8%8C%EB%84%B7-%EB%A7%88%EC%8A%A4%ED%81%AC-%EC%99%84%EB%B2%BD-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 06 Oct 2025 10:57:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>정보처리기사 실기 준비 중 서브넷 개념과 문제 풀이 방법을 정리했습니다.</p>
</blockquote>
<h2 id="🔍-서브넷subnet이란">🔍 서브넷(Subnet)이란?</h2>
<p><strong>서브넷</strong>은 하나의 큰 네트워크를 여러 개의 작은 네트워크로 나누는 기술입니다. 이를 통해 네트워크를 효율적으로 관리하고 보안을 강화할 수 있습니다.</p>
<p><strong>서브넷 마스크</strong>는 IP 주소에서 네트워크 영역과 호스트 영역을 구분하는 데 사용됩니다.</p>
<h2 id="📊-기본-서브넷-마스크">📊 기본 서브넷 마스크</h2>
<table>
<thead>
<tr>
<th>Class</th>
<th>기본 서브넷 마스크</th>
<th>CIDR 표기</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>255.0.0.0</td>
<td>/8</td>
</tr>
<tr>
<td>B</td>
<td>255.255.0.0</td>
<td>/16</td>
</tr>
<tr>
<td>C</td>
<td>255.255.255.0</td>
<td>/24</td>
</tr>
</tbody></table>
<h2 id="💡-핵심-공식">💡 핵심 공식</h2>
<h3 id="1-서브넷-개수-계산">1. 서브넷 개수 계산</h3>
<pre><code>서브넷 개수 = 2^n
(n = 서브넷 비트 수)</code></pre><h3 id="2-호스트-개수-계산">2. 호스트 개수 계산</h3>
<pre><code>호스트 개수 = 2^h - 2
(h = 호스트 비트 수)</code></pre><blockquote>
<p><strong>-2를 하는 이유</strong>: 네트워크 주소와 브로드캐스트 주소는 사용할 수 없기 때문</p>
</blockquote>
<h3 id="3-cidr-표기법-이해">3. CIDR 표기법 이해</h3>
<ul>
<li><code>/24</code> → 255.255.255.0 (호스트 비트 8개)</li>
<li><code>/26</code> → 255.255.255.192 (호스트 비트 6개)</li>
<li><code>/27</code> → 255.255.255.224 (호스트 비트 5개)</li>
</ul>
<p><strong>변환 방법</strong>: 8비트씩 순서대로 1로 채우고, 나머지는 0으로 채웁니다.</p>
<h2 id="📝-자주-나오는-문제-유형">📝 자주 나오는 문제 유형</h2>
<h3 id="유형-1-서브넷-범위-구하기">유형 1: 서브넷 범위 구하기</h3>
<p><strong>예제</strong>: <code>192.168.10.0/25</code>의 서브넷 범위는?</p>
<pre><code>- 네트워크 주소: 192.168.10.0
- 브로드캐스트 주소: 192.168.10.127
- 사용 가능한 IP: 192.168.10.1 ~ 192.168.10.126
- 사용 가능한 호스트 수: 126개</code></pre><p><strong>계산 과정</strong>:</p>
<ul>
<li>/25는 호스트 비트가 7개</li>
<li>2^7 - 2 = 126개의 호스트</li>
</ul>
<h3 id="유형-2-네트워크-분할">유형 2: 네트워크 분할</h3>
<p><strong>예제</strong>: <code>192.168.1.0/24</code>를 4개의 서브넷으로 나누기</p>
<pre><code>필요한 서브넷 비트: 2^2 = 4개 → 2비트 필요
새로운 서브넷 마스크: /26 (255.255.255.192)

각 서브넷:
1번: 192.168.1.0   ~ 192.168.1.63
2번: 192.168.1.64  ~ 192.168.1.127
3번: 192.168.1.128 ~ 192.168.1.191
4번: 192.168.1.192 ~ 192.168.1.255</code></pre><h3 id="유형-3-서로-다른-서브넷으로-분리">유형 3: 서로 다른 서브넷으로 분리</h3>
<p><strong>예제</strong>: <code>192.168.0.1</code>과 <code>192.168.0.65</code>를 서로 다른 서브넷에 배치하려면?</p>
<p><strong>풀이</strong>:</p>
<ol>
<li>두 IP의 차이를 확인 (64)</li>
<li>64는 2^6이므로, 호스트 비트가 6개 이하면 분리 가능</li>
<li><strong>답</strong>: 255.255.255.192 (/26) 사용</li>
</ol>
<h2 id="🎯-문제-풀이-팁">🎯 문제 풀이 팁</h2>
<ol>
<li><strong>2의 제곱수를 외워두기</strong>: 2, 4, 8, 16, 32, 64, 128, 256</li>
<li><strong>비트 계산에 익숙해지기</strong>: 8비트 단위로 생각하기</li>
<li><strong>네트워크/브로드캐스트 주소 제외하기</strong>: 항상 -2 기억</li>
<li><strong>CIDR 표기법 변환 연습</strong>: /24, /25, /26 등을 즉시 변환할 수 있도록</li>
</ol>
<h2 id="🔥-실전-연습-문제">🔥 실전 연습 문제</h2>
<h3 id="q1-255255255192의-호스트-개수는">Q1. 255.255.255.192의 호스트 개수는?</h3>
<details>
<summary>정답 보기</summary>

<pre><code>255.255.255.192는 /26
호스트 비트: 6개
2^6 - 2 = 62개</code></pre></details>

<h3 id="q2-100008-네트워크를-16개의-서브넷으로-나누려면">Q2. 10.0.0.0/8 네트워크를 16개의 서브넷으로 나누려면?</h3>
<details>
<summary>정답 보기</summary>

<pre><code>16 = 2^4 → 4비트 필요
/8 + 4 = /12
서브넷 마스크: 255.240.0.0</code></pre></details>

<h2 id="📌-마무리">📌 마무리</h2>
<p>서브넷 문제는 공식과 계산 방법만 숙지하면 빠르게 풀 수 있는 유형입니다. 특히 실기에서는 직접 계산해야 하므로 2의 제곱수와 CIDR 변환을 확실히 익혀두는 것이 중요합니다!</p>
<hr>
<p><strong>참고</strong>: 이 내용은 정보처리기사 실기 시험 준비를 위해 학습한 내용을 정리한 것입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker Compose 기반 Spring Boot 백엔드, Railway 배포 완전 정복 (Redis/MongoDB/MySQL 포함)]]></title>
            <link>https://velog.io/@jelog_131/Docker-Compose-%EA%B8%B0%EB%B0%98-Spring-Boot-%EB%B0%B1%EC%97%94%EB%93%9C-Railway-%EB%B0%B0%ED%8F%AC-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5-RedisMongoDBMySQL-%ED%8F%AC%ED%95%A8</link>
            <guid>https://velog.io/@jelog_131/Docker-Compose-%EA%B8%B0%EB%B0%98-Spring-Boot-%EB%B0%B1%EC%97%94%EB%93%9C-Railway-%EB%B0%B0%ED%8F%AC-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5-RedisMongoDBMySQL-%ED%8F%AC%ED%95%A8</guid>
            <pubDate>Thu, 18 Sep 2025 15:07:10 GMT</pubDate>
            <description><![CDATA[<h1 id="docker-compose-기반-spring-boot-백엔드-railway-배포-완전-정복-redismongodbmysql-포함">Docker Compose 기반 Spring Boot 백엔드, Railway 배포 완전 정복 (Redis/MongoDB/MySQL 포함)</h1>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/d65dccdb-c0c1-4e58-881f-06c42d9035ee/image.png" alt=""></p>
<h2 id="들어가며">들어가며</h2>
<p>최근 사이드 프로젝트로 Spring Boot 기반의 RESTful API 서버를 개발하고 있었습니다. 로컬 환경에서는 Docker Compose를 활용해 Redis, MongoDB, MySQL을 함께 운영하며 개발을 진행했는데, 배포 단계에서 고민이 많았습니다.</p>
<p>AWS EC2나 GCP Compute Engine을 사용하자니 설정할 것이 너무 많고, Heroku는 2022년 11월부터 무료 플랜이 사라져서 대안을 찾고 있었죠. 그러던 중 개발자 커뮤니티에서 <strong>Railway</strong>라는 서비스를 알게 되었고, 직접 마이그레이션해본 경험을 상세히 공유하고자 합니다.</p>
<h2 id="기존-환경-분석">기존 환경 분석</h2>
<h3 id="로컬-개발-환경">로컬 개발 환경</h3>
<ul>
<li><strong>Framework</strong>: Spring Boot 3.1.5</li>
<li><strong>Java Version</strong>: OpenJDK 17</li>
<li><strong>Build Tool</strong>: Gradle 8.4</li>
<li><strong>Database</strong>: MySQL 8.0 (주 데이터), MongoDB 6.0 (로그 데이터)</li>
<li><strong>Cache</strong>: Redis 7.0</li>
<li><strong>Container</strong>: Docker Compose</li>
</ul>
<h3 id="docker-compose-구성">Docker Compose 구성</h3>
<pre><code class="language-yaml">version: &#39;3.8&#39;

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - &quot;8080:8080&quot;
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - MYSQL_HOST=mysql
      - MYSQL_PORT=3306
      - MYSQL_DATABASE=myapp
      - MYSQL_USERNAME=app_user
      - MYSQL_PASSWORD=secure_password
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - MONGO_HOST=mongo
      - MONGO_PORT=27017
      - MONGO_DATABASE=myapp_logs
    depends_on:
      - mysql
      - redis
      - mongo
    networks:
      - app-network

  mysql:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=myapp
      - MYSQL_USER=app_user
      - MYSQL_PASSWORD=secure_password
      - MYSQL_ROOT_PASSWORD=root_password
    ports:
      - &quot;3306:3306&quot;
    volumes:
      - mysql_data:/var/lib/mysql
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    ports:
      - &quot;6379:6379&quot;
    command: redis-server --requirepass redis_password
    volumes:
      - redis_data:/data
    networks:
      - app-network

  mongo:
    image: mongo:6
    environment:
      - MONGO_INITDB_ROOT_USERNAME=mongo_user
      - MONGO_INITDB_ROOT_PASSWORD=mongo_password
      - MONGO_INITDB_DATABASE=myapp_logs
    ports:
      - &quot;27017:27017&quot;
    volumes:
      - mongo_data:/data/db
    networks:
      - app-network

volumes:
  mysql_data:
  redis_data:
  mongo_data:

networks:
  app-network:
    driver: bridge</code></pre>
<h3 id="dockerfile-구성">Dockerfile 구성</h3>
<pre><code class="language-dockerfile">FROM openjdk:17-jdk-slim

WORKDIR /app

COPY build/libs/*.jar app.jar

EXPOSE 8080

ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;/app/app.jar&quot;]</code></pre>
<h3 id="주요-기능">주요 기능</h3>
<ul>
<li><strong>인증/인가</strong>: JWT 기반 인증 시스템</li>
<li><strong>파일 업로드</strong>: MultipartFile 처리 (이미지, 문서)</li>
<li><strong>실시간 알림</strong>: WebSocket 연결</li>
<li><strong>API 문서화</strong>: Swagger/OpenAPI 3.0</li>
<li><strong>로깅</strong>: 구조화된 로그를 MongoDB에 저장</li>
<li><strong>캐싱</strong>: Redis를 활용한 세션 관리 및 데이터 캐싱</li>
</ul>
<h2 id="railway-마이그레이션-과정">Railway 마이그레이션 과정</h2>
<h3 id="1단계-railway-프로젝트-초기화">1단계: Railway 프로젝트 초기화</h3>
<p>Railway 대시보드에서 새 프로젝트를 생성하는 과정은 놀라울 정도로 간단했습니다.</p>
<ol>
<li><strong>GitHub 연동</strong>: Railway에서 &quot;Deploy from GitHub repo&quot; 선택</li>
<li><strong>Repository 선택</strong>: 배포할 레포지토리를 선택하면 자동으로 Dockerfile을 인식</li>
<li><strong>자동 빌드</strong>: 첫 번째 배포가 자동으로 시작됨</li>
</ol>
<pre><code class="language-bash"># Railway CLI 설치 (선택사항)
npm install -g @railway/cli

# 로컬에서 Railway 프로젝트 연결
railway login
railway link [프로젝트-ID]</code></pre>
<h3 id="2단계-데이터베이스-서비스-추가">2단계: 데이터베이스 서비스 추가</h3>
<p>Railway의 가장 큰 장점 중 하나는 데이터베이스를 별도로 관리할 필요가 없다는 것입니다.</p>
<h4 id="2-1-mysql-서비스-추가">2-1. MySQL 서비스 추가</h4>
<pre><code>Railway Dashboard → Add Service → Database → MySQL</code></pre><p>Railway에서 제공하는 MySQL 인스턴스 정보:</p>
<ul>
<li><strong>Host</strong>: <code>containers-us-west-xxx.railway.app</code></li>
<li><strong>Port</strong>: <code>6543</code></li>
<li><strong>Database</strong>: <code>railway</code></li>
<li><strong>Username</strong>: <code>root</code> </li>
<li><strong>Password</strong>: 자동 생성된 32자리 패스워드</li>
</ul>
<h4 id="2-2-redis-서비스-추가">2-2. Redis 서비스 추가</h4>
<pre><code>Railway Dashboard → Add Service → Database → Redis</code></pre><p>Redis 연결 정보:</p>
<ul>
<li><strong>Host</strong>: <code>redis-xxx.railway.app</code></li>
<li><strong>Port</strong>: <code>6379</code></li>
<li><strong>Password</strong>: 자동 생성</li>
</ul>
<h4 id="2-3-mongodb-서비스-추가">2-3. MongoDB 서비스 추가</h4>
<pre><code>Railway Dashboard → Add Service → Database → MongoDB</code></pre><p>MongoDB 연결 정보:</p>
<ul>
<li><strong>Connection URI</strong>: <code>mongodb://mongo:xxx@containers-us-west-xxx.railway.app:6034/railway</code></li>
</ul>
<h3 id="3단계-환경변수-구성">3단계: 환경변수 구성</h3>
<p>Railway는 각 데이터베이스 서비스마다 연결 정보를 환경변수로 자동 제공합니다. 이를 Spring Boot의 <code>application.yml</code>에 맞게 매핑해야 합니다.</p>
<h4 id="railway-제공-환경변수">Railway 제공 환경변수</h4>
<pre><code class="language-env"># MySQL
MYSQL_URL=mysql://root:xxx@containers-us-west-xxx.railway.app:6543/railway
MYSQLHOST=containers-us-west-xxx.railway.app
MYSQLPORT=6543
MYSQLDATABASE=railway
MYSQLUSER=root
MYSQLPASSWORD=xxx

# Redis
REDIS_URL=redis://:xxx@redis-xxx.railway.app:6379
REDISHOST=redis-xxx.railway.app
REDISPORT=6379
REDISPASSWORD=xxx

# MongoDB  
MONGO_URL=mongodb://mongo:xxx@containers-us-west-xxx.railway.app:6034/railway
MONGOHOST=containers-us-west-xxx.railway.app
MONGOPORT=6034
MONGOUSER=mongo
MONGOPASSWORD=xxx
MONGODATABASE=railway</code></pre>
<h4 id="applicationyml-수정">application.yml 수정</h4>
<pre><code class="language-yaml">spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:prod}

  # MySQL 설정
  datasource:
    url: jdbc:mysql://${MYSQLHOST:localhost}:${MYSQLPORT:3306}/${MYSQLDATABASE:myapp}?useSSL=false&amp;serverTimezone=UTC&amp;allowPublicKeyRetrieval=true
    username: ${MYSQLUSER:root}
    password: ${MYSQLPASSWORD:password}
    driver-class-name: com.mysql.cj.jdbc.Driver

  # JPA 설정
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        format_sql: true

  # MongoDB 설정
  data:
    mongodb:
      host: ${MONGOHOST:localhost}
      port: ${MONGOPORT:27017}
      database: ${MONGODATABASE:myapp_logs}
      username: ${MONGOUSER:}
      password: ${MONGOPASSWORD:}
      authentication-database: admin

  # Redis 설정
  data:
    redis:
      host: ${REDISHOST:localhost}
      port: ${REDISPORT:6379}
      password: ${REDISPASSWORD:}
      timeout: 60000
      jedis:
        pool:
          max-active: 8
          max-wait: -1
          max-idle: 8
          min-idle: 0

# 서버 설정
server:
  port: ${PORT:8080}

# JWT 설정
jwt:
  secret: ${JWT_SECRET:your-secret-key}
  expiration: ${JWT_EXPIRATION:3600000}

# 파일 업로드 설정
file:
  upload:
    path: ${FILE_UPLOAD_PATH:/tmp/uploads}
    max-size: ${FILE_MAX_SIZE:10485760}</code></pre>
<h3 id="4단계-railway-variables-설정">4단계: Railway Variables 설정</h3>
<p>Railway 대시보드에서 Variables 탭으로 이동해 다음 환경변수들을 추가했습니다.</p>
<pre><code class="language-env"># Spring 프로파일
SPRING_PROFILES_ACTIVE=prod

# JWT 설정
JWT_SECRET=your-super-secure-jwt-secret-key-here
JWT_EXPIRATION=3600000

# 파일 업로드 설정
FILE_UPLOAD_PATH=/app/uploads
FILE_MAX_SIZE=10485760

# 로깅 레벨
LOGGING_LEVEL_ROOT=INFO
LOGGING_LEVEL_COM_MYAPP=DEBUG

# Railway 포트 (자동 설정됨)
PORT=${{PORT}}</code></pre>
<h3 id="5단계-배포-및-테스트">5단계: 배포 및 테스트</h3>
<p>코드를 GitHub에 push하면 Railway가 자동으로 배포를 시작합니다.</p>
<pre><code class="language-bash">git add .
git commit -m &quot;Configure Railway deployment&quot;
git push origin main</code></pre>
<p>배포 과정은 Railway 대시보드의 Deployments 탭에서 실시간으로 확인할 수 있습니다.</p>
<ol>
<li><strong>Build Phase</strong>: Dockerfile 기반 이미지 빌드</li>
<li><strong>Deploy Phase</strong>: 컨테이너 실행 및 health check</li>
<li><strong>Live</strong>: 서비스 활성화 완료</li>
</ol>
<p>배포가 완료되면 Railway에서 제공하는 도메인 (예: <code>https://myapp-production-xxxx.up.railway.app</code>)으로 접근할 수 있습니다.</p>
<h2 id="성능-및-모니터링">성능 및 모니터링</h2>
<h3 id="railway-제공-메트릭">Railway 제공 메트릭</h3>
<p>Railway 대시보드에서 다음과 같은 메트릭을 실시간으로 확인할 수 있습니다.</p>
<ul>
<li><strong>CPU 사용량</strong>: 평균 15-20% (유휴 상태 기준)</li>
<li><strong>메모리 사용량</strong>: 약 300MB (Spring Boot JVM 기본 설정)</li>
<li><strong>네트워크 I/O</strong>: 인바운드/아웃바운드 트래픽</li>
<li><strong>응답 시간</strong>: 평균 200ms 이하</li>
</ul>
<h3 id="로그-모니터링">로그 모니터링</h3>
<p>Railway는 실시간 로그 스트리밍을 제공합니다.</p>
<pre><code class="language-bash"># Railway CLI로 로그 확인
railway logs --follow

# 특정 서비스 로그만 확인
railway logs --service mysql --follow</code></pre>
<h3 id="health-check-구성">Health Check 구성</h3>
<p>Spring Boot Actuator를 활용해 health check 엔드포인트를 구성했습니다.</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/actuator&quot;)
public class HealthController {

    @Autowired
    private RedisTemplate&lt;String, String&gt; redisTemplate;

    @Autowired
    private DataSource dataSource;

    @GetMapping(&quot;/health&quot;)
    public ResponseEntity&lt;Map&lt;String, String&gt;&gt; health() {
        Map&lt;String, String&gt; status = new HashMap&lt;&gt;();

        try {
            // MySQL 연결 확인
            try (Connection connection = dataSource.getConnection()) {
                status.put(&quot;mysql&quot;, &quot;UP&quot;);
            }

            // Redis 연결 확인
            redisTemplate.opsForValue().set(&quot;health-check&quot;, &quot;OK&quot;);
            status.put(&quot;redis&quot;, &quot;UP&quot;);

            status.put(&quot;status&quot;, &quot;UP&quot;);
            return ResponseEntity.ok(status);

        } catch (Exception e) {
            status.put(&quot;status&quot;, &quot;DOWN&quot;);
            status.put(&quot;error&quot;, e.getMessage());
            return ResponseEntity.status(503).body(status);
        }
    }
}</code></pre>
<h2 id="비용-분석">비용 분석</h2>
<h3 id="railway-요금-체계">Railway 요금 체계</h3>
<p>Railway는 사용량 기반 과금 모델을 사용합니다.</p>
<ul>
<li><strong>Compute</strong>: $0.000463/vCPU/minute</li>
<li><strong>Memory</strong>: $0.000231/GB/minute</li>
<li><strong>Egress</strong>: $0.10/GB</li>
<li><strong>Storage</strong>: 데이터베이스별 별도 과금</li>
</ul>
<h3 id="실제-사용-비용-월간">실제 사용 비용 (월간)</h3>
<ul>
<li><strong>Spring Boot App</strong>: CPU 0.5, Memory 512MB → 약 $8/월</li>
<li><strong>MySQL</strong>: 약 $5/월</li>
<li><strong>Redis</strong>: 약 $3/월</li>
<li><strong>MongoDB</strong>: 약 $4/월</li>
<li><strong>Total</strong>: 약 $20/월</li>
</ul>
<p>$20 무료 크레딧으로 약 1개월간 무료 사용이 가능합니다.</p>
<h3 id="기존-솔루션과-비교">기존 솔루션과 비교</h3>
<ul>
<li><strong>AWS EC2 t3.micro</strong>: $8.5/월 + RDS $15/월 + ElastiCache $13/월 = $36.5/월</li>
<li><strong>Heroku</strong>: Dyno $7/월 + PostgreSQL $9/월 + Redis $15/월 = $31/월</li>
<li><strong>Railway</strong>: 약 $20/월 (올인원)</li>
</ul>
<h2 id="최적화-및-팁">최적화 및 팁</h2>
<h3 id="1-빌드-시간-최적화">1. 빌드 시간 최적화</h3>
<pre><code class="language-dockerfile"># Multi-stage build로 빌드 시간 단축
FROM gradle:7.6-jdk17 AS build
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle build --no-daemon

FROM openjdk:17-jdk-slim
COPY --from=build /home/gradle/src/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;/app.jar&quot;]</code></pre>
<h3 id="2-환경별-배포-설정">2. 환경별 배포 설정</h3>
<p>Railway에서는 브랜치별로 다른 환경을 구성할 수 있습니다.</p>
<ul>
<li><strong>main</strong> 브랜치 → Production 환경</li>
<li><strong>develop</strong> 브랜치 → Staging 환경</li>
</ul>
<h3 id="3-자동-ssl-및-도메인">3. 자동 SSL 및 도메인</h3>
<p>Railway는 자동으로 HTTPS를 적용하고, 커스텀 도메인도 쉽게 연결할 수 있습니다.</p>
<pre><code>Settings → Environment → Custom Domain → Add Domain</code></pre><h3 id="4-백업-설정">4. 백업 설정</h3>
<p>각 데이터베이스는 자동 백업이 활성화되어 있지만, 중요한 데이터는 별도 백업 전략을 수립하는 것이 좋습니다.</p>
<h2 id="🚨-주의사항-및-한계점">🚨 주의사항 및 한계점</h2>
<h3 id="장점">장점</h3>
<ul>
<li><strong>간편한 배포</strong>: Dockerfile만 있으면 즉시 배포 가능</li>
<li><strong>통합 관리</strong>: DB까지 한 곳에서 관리</li>
<li><strong>자동 스케일링</strong>: 트래픽에 따른 자동 확장</li>
<li><strong>실시간 모니터링</strong>: 로그, 메트릭 실시간 확인</li>
<li><strong>합리적인 가격</strong>: 소규모 프로젝트에 적합</li>
</ul>
<h3 id="한계점">한계점</h3>
<ul>
<li><strong>지역 제한</strong>: 아직 아시아 리전 없음 (레이턴시 약 150-200ms)</li>
<li><strong>커스터마이징 제약</strong>: 고도의 인프라 커스터마이징이 어려움</li>
<li><strong>대용량 트래픽</strong>: 대규모 서비스에는 부적합할 수 있음</li>
<li><strong>한국어 지원</strong>: 공식 한국어 문서 부재</li>
</ul>
<h3 id="추천-대상">추천 대상</h3>
<ul>
<li>사이드 프로젝트 및 MVP 개발</li>
<li>스타트업 초기 단계</li>
<li>개인 포트폴리오 프로젝트</li>
<li>프로토타입 개발 및 테스트</li>
</ul>
<h2 id="향후-계획">향후 계획</h2>
<h3 id="1-cicd-파이프라인-구성">1. CI/CD 파이프라인 구성</h3>
<p>GitHub Actions와 Railway를 연동한 자동 배포 파이프라인을 구축할 예정입니다.</p>
<pre><code class="language-yaml"># .github/workflows/deploy.yml
name: Deploy to Railway

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js
        uses: actions/setup-node@v3
        with:
          node-version: &#39;18&#39;
      - name: Install Railway CLI
        run: npm install -g @railway/cli
      - name: Deploy to Railway
        run: railway deploy
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}</code></pre>
<h3 id="2-모니터링-강화">2. 모니터링 강화</h3>
<p>Prometheus + Grafana를 통한 커스텀 메트릭 수집과 New Relic 연동을 검토하고 있습니다.</p>
<h3 id="3-로드-테스트">3. 로드 테스트</h3>
<p>Apache JMeter를 활용해 Railway 환경에서의 성능 한계를 측정하고, 최적화 포인트를 찾을 계획입니다.</p>
<h2 id="결론">결론</h2>
<p>Docker Compose에서 Railway로의 마이그레이션은 예상보다 훨씬 수월했습니다. 특히 데이터베이스 관리의 복잡성이 크게 줄어들었고, 배포 과정도 자동화되어 개발에만 집중할 수 있게 되었습니다.</p>
<p>Railway는 다음과 같은 경우에 특히 추천합니다.</p>
<ol>
<li><strong>빠른 MVP 배포</strong>가 필요한 경우</li>
<li><strong>인프라 관리</strong>보다 <strong>개발에 집중</strong>하고 싶은 경우  </li>
<li><strong>합리적인 비용</strong>으로 풀스택 환경을 구축하고 싶은 경우</li>
</ol>
<p>물론 대규모 트래픽을 처리해야 하거나, 특수한 인프라 요구사항이 있다면 AWS나 GCP를 고려하는 것이 좋습니다. 하지만 개인 프로젝트나 스타트업 초기 단계라면 Railway가 매우 실용적인 선택지라고 생각합니다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://docs.railway.app/">Railway 공식 문서</a></li>
<li><a href="https://spring.io/projects/spring-boot">Spring Boot 공식 문서</a></li>
<li><a href="https://docs.docker.com/compose/">Docker Compose 공식 문서</a></li>
</ul>
<p><strong>💡 Railway $20 크레딧 받기</strong>: <a href="https://railway.com?referralCode=qZFbvo">여기서 가입</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Ninety 프로젝트에서 게임 리소스 관리 방식 고민하기]]></title>
            <link>https://velog.io/@jelog_131/Ninety-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%EA%B2%8C%EC%9E%84-%EB%A6%AC%EC%86%8C%EC%8A%A4-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EC%8B%9D-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jelog_131/Ninety-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%EA%B2%8C%EC%9E%84-%EB%A6%AC%EC%86%8C%EC%8A%A4-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EC%8B%9D-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 03 Aug 2025 11:23:08 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-소개">프로젝트 소개</h2>
<p>최근에 Ninety라는 습관 트래킹 앱에 간단한 게임 요소를 추가하는 작업을 했다. 방 꾸미기와 펫 수집/육성 기능을 넣어서 사용자들이 습관을 달성할 때마다 재미를 느낄 수 있도록 만들었다.</p>
<div style="display: flex; gap: 7px;">
  <img src="https://velog.velcdn.com/images/jelog_131/post/47a5bbdc-b750-49d1-a28b-95cc2e5efd92/image.png" width="200">
  <img src="https://velog.velcdn.com/images/jelog_131/post/2385b1e0-bb44-4bf2-b057-11b016d0f529/image.png" width="300">
  <img src="https://velog.velcdn.com/images/jelog_131/post/cfaf0b46-28bd-4ccc-babd-7651af6107c0/image.png" width="300">
</div>

<p>처음에는 단순하게 시작했지만, 게임 요소가 들어가면서 여러 가지 기술적 고민이 생겼다. 특히 게임 리소스를 어떻게 관리할지에 대한 부분이 가장 고민스러웠다.</p>
<h2 id="백오피스-분리에-대한-고민">백오피스 분리에 대한 고민</h2>
<p>현재 상점 관련 API들(방 꾸미기 아이템, 캐릭터 관련)이 모두 Ninety 메인 프로젝트에 포함되어 있다. 개발 초기에는 빠르게 구현하기 위해 이렇게 했지만, 점점 불편함을 느끼기 시작했다.</p>
<p>상점 아이템을 추가하거나 수정할 때마다 메인 앱의 코드를 건드려야 하는 상황이 발생했다. 예를 들어 새로운 가구 아이템을 추가하려면 API 코드를 수정하고, 배포까지 해야 하는 번거로움이 있었다.</p>
<p>그래서 백오피스 기능을 별도 프로젝트로 분리하는 것을 고려하게 되었다. 전용 웹사이트나 관리 툴을 만들어서 게임 컨텐츠를 관리할 수 있도록 말이다 ! </p>
<h3 id="분리했을-때의-장점들">분리했을 때의 장점들</h3>
<ul>
<li>책임 분리 : 게임 컨텐츠 관리와 메인 앱 로직을 완전히 분리할 수 있다</li>
<li>개발 효율성 : 컨텐츠 업데이트를 위해 메인 앱을 건드릴 필요가 없다</li>
<li>확장성 : 나중에 더 복잡한 게임 요소가 추가되어도 유연하게 대응할 수 있다</li>
<li>협업 : 기획자나 다른 팀원들이 직접 컨텐츠를 관리할 수 있다</li>
</ul>
<p>결국 별도 관리 시스템을 구축하는 것이 장기적으로 더 좋겠다는 결론을 내렸다.</p>
<h2 id="게임-리소스-저장-방식-고민">게임 리소스 저장 방식 고민</h2>
<p>다음으로 고민한 것은 게임 리소스(이미지 파일들)를 어떻게 관리할지였다.</p>
<h3 id="프론트엔드-번들링-vs-외부-저장소">프론트엔드 번들링 vs 외부 저장소</h3>
<p>처음에는 픽셀 아트로 만든 이미지들이 용량이 크지 않아서 그냥 프론트엔드 프로젝트에 포함시키면 어떨까 생각했다. 이렇게 하면 ?</p>
<ul>
<li>별도 서버 없이도 이미지를 표시할 수 있다</li>
<li>네트워크 요청 없이 즉시 로딩된다</li>
<li>관리가 단순해진다</li>
</ul>
<p>하지만 곰곰히 생각해보니 여러 문제점들이 보였다.</p>
<ul>
<li>새로운 아이템이 추가될 때마다 앱을 새로 배포해야 한다</li>
<li>앱 용량이 계속 늘어날 수 있다</li>
<li>이미지 최적화나 버전 관리가 어렵다</li>
<li>백오피스에서 동적으로 컨텐츠를 관리하기 힘들다</li>
</ul>
<h3 id="aws-s3--백오피스-조합">AWS S3 + 백오피스 조합</h3>
<p>결국 AWS S3에 리소스를 저장하고, 백오피스 툴에서 API를 통해 상점 아이템들을 생성 / 관리하는 방식을 선택했다.</p>
<p>이 방식의 장점은 ? </p>
<ul>
<li>동적 관리 : 앱 배포 없이도 새로운 아이템을 추가할 수 있다</li>
<li>성능 : CDN을 통한 빠른 이미지 로딩</li>
<li>확장성 : 나중에 더 많은 리소스가 생겨도 대응 가능</li>
<li>관리 편의성 : 백오피스에서 이미지 업로드부터 상품 등록까지 한 번에 처리</li>
</ul>
<h2 id="배운-점들">배운 점들</h2>
<p><strong>초기 편의성 vs 장기 확장성</strong>
처음에는 간단하게 구현하는 것이 좋지만, 어느 정도 규모가 커지면 구조를 제대로 잡는 것이 중요하다. 당장은 번거로워 보여도 나중을 생각하면 투자할 가치가 있다.</p>
<p><strong>컨텐츠 관리의 중요성</strong>
게임 요소가 들어가면 컨텐츠 업데이트가 빈번해진다. 이를 위한 시스템을 미리 준비해두지 않으면 나중에 발목을 잡힌다..</p>
<p><strong>단계적 접근</strong>
처음부터 완벽한 시스템을 만들려고 하지 말고, 문제가 생겼을 때 하나씩 개선해나가는 것도 좋은 방법이다. 다만 너무 늦기 전에 리팩토링을 시작해야 한다.</p>
<hr>
<p>작은 프로젝트지만 이런 고민들을 통해 시스템 설계에 대해 더 깊이 생각해볼 수 있었다. 무엇보다 사용자가 재미있게 습관을 만들어갈 수 있는 서비스를 만들어가는 과정이 즐겁다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA JOIN FETCH 와 Optional 조합 시 발생하는 NonUniqueResult 오류 해결]]></title>
            <link>https://velog.io/@jelog_131/JPA-JOIN-FETCH-%EC%99%80-Optional-%EC%A1%B0%ED%95%A9-%EC%8B%9C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-NonUniqueResult-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@jelog_131/JPA-JOIN-FETCH-%EC%99%80-Optional-%EC%A1%B0%ED%95%A9-%EC%8B%9C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-NonUniqueResult-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Sat, 05 Jul 2025 07:47:54 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>Spring Boot 프로젝트에서 날짜별 콘텐츠를 조회하는 기능을 구현하던 중, 다음과 같은 오류가 발생했다.</p>
<pre><code class="language-text">Query did not return a unique result: 2 results were returned</code></pre>
<p>처음에는 동일한 날짜에 여러 개의 레코드가 존재하는 것으로 생각했다. 하지만 MySQL에서 직접 쿼리를 실행해보니 예상과 달랐다.</p>
<h3 id="문제가-된-코드">문제가 된 코드</h3>
<h4 id="repository">Repository</h4>
<pre><code class="language-java">@Query(&quot;SELECT e FROM EmailContent e JOIN FETCH e.theme WHERE e.createdAt BETWEEN ?1 AND ?2&quot;)
Optional&lt;EmailContent&gt; findByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);</code></pre>
<h4 id="controller">Controller</h4>
<pre><code class="language-java">LocalDate targetDate = LocalDate.parse(date, DateTimeFormatter.ofPattern(&quot;yyyyMMdd&quot;));
LocalDateTime startOfDay = targetDate.atStartOfDay();        // 2025-07-05T00:00:00
LocalDateTime endOfDay = targetDate.atTime(23, 59, 59);     // 2025-07-05T23:59:59

Optional&lt;EmailContent&gt; content = emailContentRepository.findByCreatedDateBetween(startOfDay, endOfDay);</code></pre>
<h3 id="mysql-직접-확인-결과">MySQL 직접 확인 결과</h3>
<pre><code class="language-sql">-- 날짜별 레코드 수 확인
SELECT DATE(created_at) as date, COUNT(*) as count 
FROM email_content 
GROUP BY DATE(created_at) 
HAVING count &gt; 1;
-- 결과: Empty set

-- 해당 날짜의 데이터 확인
SELECT count(1) 
FROM email_content e 
JOIN content_theme t ON t.id = e.theme_id 
WHERE e.created_at BETWEEN &#39;2025-07-05 00:00:00&#39; AND &#39;2025-07-05 23:59:59&#39;;
-- 결과: 1 (정상)</code></pre>
<p>MySQL에서는 1개의 결과만 나오는데, 왜 JPA에서는 2개 결과가 반환된다고 하지?</p>
<h2 id="원인-분석">원인 분석</h2>
<h3 id="jpa-join-fetch의-특성">JPA JOIN FETCH의 특성</h3>
<p>문제는 JPA의 <strong>JOIN FETCH와 Optional 조합</strong>에서 발생했다.</p>
<ul>
<li><strong>JOIN FETCH</strong>는 연관된 엔티티를 즉시 로딩하기 위한 JPA 최적화 기법</li>
<li>하지만 <strong>Optional과</strong> 함께 사용할 때, JPA 구현체 (Hibernate)의 내부 처리 과정에서 중복 결과로 인식하는 경우가 있음</li>
<li><strong>실제 DB에서는 1개 행 이지만, JPA 레벨에서는 2개로 인식</strong>하는 상황</li>
</ul>
<h3 id="hibernate-로그-분석">Hibernate 로그 분석</h3>
<pre><code class="language-sql">Hibernate: 
    select
        ec1_0.id,
        ec1_0.created_at,
        ec1_0.detailed_content,
        ec1_0.html_content,
        ec1_0.sent,
        ec1_0.sent_at,
        t1_0.id,
        t1_0.jlptlevel,
        t1_0.topic,
        t1_0.used,
        t1_0.used_at 
    from
        email_content ec1_0 
    join
        content_theme t1_0 
            on t1_0.id=ec1_0.theme_id 
    where
        ec1_0.created_at between ? and ?</code></pre>
<p>SQL 자체는 정상적이지만, JPA가 결과를 Optional로 변환하는 과정에서 문제가 발생한다.</p>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1-distinct-추가">1. DISTINCT 추가</h3>
<pre><code class="language-java">@Query(&quot;SELECT DISTINCT e FROM EmailContent e JOIN FETCH e.theme WHERE e.createdAt BETWEEN ?1 AND ?2&quot;)
Optional&lt;EmailContent&gt; findByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);</code></pre>
<h4 id="왜-distinct가-해결책">왜 DISTINCT가 해결책?</h4>
<ul>
<li>JPA에서 JOIN FETCH를 사용할 때 권장되는 패턴</li>
<li>중복 결과 제거로 Optional 안전하게 동작</li>
</ul>
<h3 id="2-반환-타입-변경">2. 반환 타입 변경</h3>
<pre><code class="language-java">@Query(&quot;SELECT e FROM EmailContent e JOIN FETCH e.theme WHERE e.createdAt BETWEEN ?1 AND ?2&quot;)
List&lt;EmailContent&gt; findByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);

// Controller에서 사용
List&lt;EmailContent&gt; contents = emailContentRepository.findByCreatedDateBetween(startOfDay, endOfDay);
Optional&lt;EmailContent&gt; content = contents.isEmpty() ? Optional.empty() : Optional.of(contents.get(0));</code></pre>
<h3 id="3-join-fetch-제거">3. JOIN FETCH 제거</h3>
<pre><code class="language-java">Optional&lt;EmailContent&gt; findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);</code></pre>
<p>연관된 엔티티는 지연 로딩으로 처리하고, 필요시 별도 조회</p>
<hr>
<h2 id="til">TIL</h2>
<ol>
<li><strong>JPA와 실제 DB 동작의 차이</strong> : MySQL에서 정상 동작하는 쿼리라도 JPA 레벨에서는 다른 결과가 나올 수 있다.</li>
<li><strong>JOIN FETCH + Optional 조합 주의</strong> : 이 조합은 NonUniqueResult 오류를 발생시킬 수 있으므로 DISTINCT를 함께 사용하는 것이 안전하다.</li>
<li><strong>디버깅 접근법</strong> :<ul>
<li>먼저 DB에서 직접 쿼리 실행</li>
<li>Hibernate 로그 확인</li>
<li>JPA 내부 동작 원리 이해</li>
</ul>
</li>
<li><strong>Best Practice</strong> : JPA에서 JOIN FETCH를 사용할 때는 항상 DISTINCT를 고려하자.</li>
</ol>
<p>단순해 보이는 조회 기능이지만, JPA의 내부 동작을 이해하지 못하면 예상치 못한 오류에 직면할 수 있다.
이번 경험을 통해 <strong>ORM의 추상화 뒤에 숨겨진 복잡성</strong>을 다시 한번 깨달았다.</p>
<ul>
<li>참고 자료<ul>
<li><a href="https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#hql-explicit-join-fetch">Hibernate Documemtation - JOIN FETCH</a></li>
<li><a href="https://docs.oracle.com/javaee/7/tutorial/persistence-querylanguage.htm">JPA Query 최적화 가이드</a></li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[대규모 이메일 발송 테스트 중 겪은 커넥션 누수 문제 해결하기]]></title>
            <link>https://velog.io/@jelog_131/%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EB%B0%9C%EC%86%A1-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%EA%B2%AA%EC%9D%80-%EC%BB%A4%EB%84%A5%EC%85%98-%EB%88%84%EC%88%98-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jelog_131/%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EB%B0%9C%EC%86%A1-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%EA%B2%AA%EC%9D%80-%EC%BB%A4%EB%84%A5%EC%85%98-%EB%88%84%EC%88%98-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Jun 2025 10:34:20 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jelog_131/post/2ff9a26d-04f2-4537-9ae5-a1d609d7e747/image.png" alt=""></p>
<p>Spring Boot로 운영 중인 프로젝트에서 대규모 이메일 발송 성능 테스트를 수행하다가, 두 가지 문제를 겪고 원인 분석 및 해결까지 진행한 경험을 공유합니다.</p>
<h3 id="배경">배경</h3>
<p>프로젝트에 구독자에게 <strong>일본어 학습 이메일을 매일 발송하는 기능</strong>이 있습니다.
실제로 수천 명의 유저에게 동시에 이메일을 보낼 수 있도록 하기 위해, 단일 스레드 기반으로 성능 테스트 코드를 작성했습니다.</p>
<pre><code class="language-java">public PerformanceResult testSingleThreadPerformance(int targetCount) {
    List&lt;Subscriber&gt; testSubscribers = createTestSubscribers(targetCount);
    String subject = &quot;마이니치 니홍고 - 성능 테스트&quot;;
    String content = contentService.generateDailyContent(); // 매일 학습 콘텐츠 생성

    for (Subscriber subscriber : testSubscribers) {
        emailService.sendEmail(subscriber.getEmail(), subject, content);
        ...
    }
}</code></pre>
<p><strong>generateDailyContent()</strong>는 오늘 날짜 기준의 콘텐츠를 생성하고 저장하며, emailService.sendEmail()은 JavaMailSender를 통해 실제 메일을 발송합니다.</p>
<p>하지만 1만명 단위의 구독자 테스트를 수행하던 중, 아래와 같은 <strong>경고 로그</strong>가 계속 출력되었습니다.</p>
<hr>
<h3 id="문제-1--커넥션-누수-connection-leak-경고-발생">문제 1 : 커넥션 누수 (Connection Leak) 경고 발생</h3>
<h4 id="문제-발생">문제 발생</h4>
<pre><code class="language-text">HikariPool-1 - Connection leak detection triggered for ... </code></pre>
<p>테스트 중 일정 수 이상의 구독자에게 메일을 발송하면 위와 같은 커넥션 누수 경고가 반복적으로 출력되고, 전체 테스트 속도도 현저히 느려졌습니다.</p>
<h4 id="원인-추론">원인 추론</h4>
<p>generateDailyContent() 내부 코드를 살펴본 결과, 문제의 원인을 찾을 수 있었습니다.</p>
<pre><code class="language-java">@Transactional
public String generateDailyContent() {
    Optional&lt;EmailContent&gt; todayContent = emailContentRepository.findByCreatedDate(...);
    ...
    String htmlContent = geminiService.generateContent(...); // 외부 API 호출
    ...
    emailContentRepository.save(...);
    ...
}</code></pre>
<p>이 메서드는 @Transactional이 붙어 있기 때문에, 전체 블록이 <strong>하나의 트랜잭션</strong>으로 묶입니다.
즉, 내부에서 사용하는 DB 커넥션은 트랜잭션이 끝날 때까지 반환되지 않습니다.</p>
<p>그런데 문제는 geminiService.generateContent()가 외부 AI API 호출이기 때문에 <strong>지연이 수 초 이상 발생</strong>하는 경우가 잦았습니다. 그 시간 동안 커넥션이 반환되지 않기 때문에, 수천 명에게 메일을 보내면 커넥션 풀을 모두 소진하게 됩니다.</p>
<p>결과적으로 커넥션 누수 경고가 발생한 것입니다.</p>
<h4 id="해결-방법">해결 방법</h4>
<p>핵심은 <strong>외부 API 호출을 트랜잭션 밖으로 분리</strong>하여, DB 커넥션을 점유하지 않도록 하는 것입니다.</p>
<pre><code class="language-java">public String generateDailyContent() {
    Optional&lt;EmailContent&gt; todayContent = emailContentRepository.findByCreatedDate(...);
    if (todayContent.isPresent()) {
        return applyEmailTemplate(todayContent.get().getHtmlContent());
    }

    ContentTheme theme = getOrCreateTheme();
    String htmlContent = geminiService.generateContent(theme.getJLPTLevel(), theme.getTopic());

    return saveContentWithTransaction(theme, htmlContent); // 트랜잭션은 여기서 시작
}

@Transactional
public String saveContentWithTransaction(ContentTheme theme, String htmlContent) {
    EmailContent emailContent = new EmailContent(theme, htmlContent);
    emailContentRepository.save(emailContent);

    theme.markAsUsed();
    contentThemeRepository.save(theme);

    return applyEmailTemplate(htmlContent);
}</code></pre>
<p>이렇게 분리하면 <strong>트랜잭션이 필요한 DB 작업만 최소 범위로 분리</strong>되어, 커넥션 점유 시간이 줄어듭니다.</p>
<hr>
<h3 id="문제-2--gmail-smtp-일일-발송량-초과로-인한-전송-실패">문제 2 : Gmail SMTP 일일 발송량 초과로 인한 전송 실패</h3>
<h4 id="문제-발생-1">문제 발생</h4>
<p>성능 테스트 중 갑자기 콘솔에 다음과 같은 오류가 발생했습니다.</p>
<pre><code class="language-text">org.eclipse.angus.mail.smtp.SMTPSendFailedException: 
550-5.4.5 Daily user sending limit exceeded. 
For more information on Gmail 550-5.4.5 sending limits go to ...

이메일 전송 실패: Failed messages: org.eclipse.angus.mail.smtp.SMTPSendFailedException: 550-5.4.5 Daily user sending limit exceeded.</code></pre>
<h4 id="원인-분석">원인 분석</h4>
<p>이는 JavaMailSender나 SMTP 설정의 문제가 아니라, *<em>Gmail SMTP *</em>서버가 제공하는 일일 발송량 한도를 초과했기 때문입니다.</p>
<ul>
<li>Gmail SMTP는 일반 계정 기준으로 하루에 최대 <strong>500명에게만 메일 전송 가능</strong></li>
<li>테스트 중 1만명 이상의 구독자에게 메일을 전송하려 했기 때문에, Gmail 서버 측에서 이를 차단한 것</li>
</ul>
<p>Gmail 공식 가이드에 따르면, SMTP를 통한 대량 발송은 제한되며, 비즈니스용 Google Workspace에서도 하루 2,000건 내외로 제한됩니다.</p>
<h4 id="해결-방법-1">해결 방법</h4>
<p>이번 테스트는 개발 환경에서 Gmail SMTP로 메일 발송을 설정한 상태였기 때문에, <strong>실제 대량 발송에 적합한 메일 서비스</strong>로 전환이 필요했습니다.</p>
<p>다음과 같은 방법을 고려하고 있습니다.</p>
<ol>
<li><p>Amazon SES, Mailgun, Sendgrid 등 외부 이메일 서비스 사용</p>
<ul>
<li>Amazon SES : 저렴하고 AWS 기반이라 확장성도 우수</li>
<li>Mailgun/SendGrid : REST API 또는 SMTP를 통해 대량 발송 지원</li>
<li>대부분 SPF, DKIM 설정을 통해 발송 신뢰도도 높일 수 있음</li>
</ul>
</li>
<li><p>메일 발송 큐 구성</p>
<ul>
<li>실제 서비스에서는 Kafka나 RabbitMQ 기반의 비동기 메일 큐 구성을 통해 메일 발송 속도를 제어</li>
<li>일정 시간당 발송량을 제한하여 발송 실패율을 줄일 수 있음</li>
</ul>
<hr>
<h3 id="마무리">마무리</h3>
</li>
</ol>
<ul>
<li>Gmail SMTP 제한을 인지하고.. 메일을 500개 정도로 줄여서 테스트 진행</li>
<li>이후 메일 발송 성공률이 100%로 회복되었고, 장기적으로는 Amazon SES로 전환을 계획 중</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - Spring Boot에서 동시성 제어 : 언제 무엇을 써야 할까?]]></title>
            <link>https://velog.io/@jelog_131/TIL-Spring-Boot%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%96%B8%EC%A0%9C-%EB%AC%B4%EC%97%87%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jelog_131/TIL-Spring-Boot%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%96%B8%EC%A0%9C-%EB%AC%B4%EC%97%87%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 30 May 2025 15:20:33 GMT</pubDate>
            <description><![CDATA[<p>예전에 Lock에 대해 공부하면서 동시성 제어라는 말을 접했었다. 하지만 동시성 제어를 할 수 있는 방법으로는 많은 방법이 있고 어떤 상황에 어떻게 대처해야 될지 의문점이 생겨 공부한 내용을 정리해 보려고한다.</p>
<p>동시성 문제는 실제 서비스에서 생각보다 흔하게 발생한다. 특히 재고 감소, 결제, 좋아요 등의 기능은 수많은 요청이 동시에 들어올 수 있기 때문에 신중한 동시성 제어가 필요하다.</p>
<h3 id="동시성-문제란">동시성 문제란?</h3>
<p>동시에 여러 사용자가 같은 자원에 접근할 때, 예상치 못한 상태 충돌이나 데이터 불일치가 발생하는 문제이다.
예를 들어 A와 B가 동시에 같은 상품의 재고를 감소시키면, 잘못된 재고 값이 저장될 수 있다.</p>
<h3 id="주요-동시성-제어-방법">주요 동시성 제어 방법</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>범위</th>
<th>장점</th>
<th>단점</th>
<th>추천 상황</th>
</tr>
</thead>
<tbody><tr>
<td><code>synchronized</code></td>
<td>JVM 내 단일 인스턴스</td>
<td>구현 매우 쉬움</td>
<td>분산 환경에서는 무용지물</td>
<td>단일 서버에서 간단한 테스트나 개발용 동기화</td>
</tr>
<tr>
<td><code>@Transactional + Pessimistic Lock</code></td>
<td>DB</td>
<td>충돌 방지 확실</td>
<td>성능 저하, 데드락 가능</td>
<td>충돌이 자주 발생하는 환경 (ex. 재고, 결제)</td>
</tr>
<tr>
<td><code>@Transactional + Optimistic Lock</code></td>
<td>DB</td>
<td>락 없이 동시성 제어, 성능 우수</td>
<td>충돌 시 예외 처리 및 재시도 로직 필요</td>
<td>충돌이 드문 상황 (ex. 게시글 좋아요)</td>
</tr>
<tr>
<td><strong>Redis 분산 락 (Redisson)</strong></td>
<td>분산 서버 전체</td>
<td>서버 여러 대에서 일관성 유지 가능</td>
<td>Redis 장애 시 전체 시스템 영향</td>
<td>분산 환경에서 강한 락 필요 (ex. 결제, 이벤트 선착순 등)</td>
</tr>
<tr>
<td>Kafka / MQ 직렬 처리</td>
<td>분산 서버 전체</td>
<td>완전한 순차 처리 가능</td>
<td>실시간성 떨어짐, 큐 지연 발생 가능</td>
<td>순서가 중요한 작업 (ex. 포인트 적립, 거래 내역 저장)</td>
</tr>
</tbody></table>
<h3 id="왜-한-가지-방식으로-통일하지-않을까">왜 한 가지 방식으로 통일하지 않을까?</h3>
<p>&quot;Redisson이 분산 락도 되는데 이걸로 다 하면 되지 않을까?&quot; 라는 생각이 들 수 있지만, 실제로는 <strong>성능, 안정성, 구현 난이도</strong>를 고려해 <strong>상황에 맞는 전략</strong>을 선택하는 것이 훨씬 중요하다.</p>
<h4 id="redisson만으로는-부족하거나-과한-경우">Redisson만으로는 부족하거나 과한 경우</h4>
<ul>
<li>단순한 동기화인데 Redis 락을 사용하면 오히려 성능 낭비</li>
<li>충돌이 거의 없는데 굳이 락을 걸 필요는 없다</li>
<li>외부 장애 (예 : Redis 다운)로 전체 시스템이 영향을 받을 수 있다</li>
</ul>
<h3 id="결론">결론</h3>
<p>모든 락은 비용이 따르고, 트레이드오프가 존재한다.
따라서 <strong>서비스의 특성과 요구 사항에 따라 가장 효율적인 전략</strong>을 선택해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2 환경에서 Spring Boot 크론잡(@Scheduled)이 실행되지 않을 때 체크할 것!]]></title>
            <link>https://velog.io/@jelog_131/EC2-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Spring-Boot-%ED%81%AC%EB%A1%A0%EC%9E%A1Scheduled%EC%9D%B4-%EC%8B%A4%ED%96%89%EB%90%98%EC%A7%80-%EC%95%8A%EC%9D%84-%EB%95%8C-%EC%B2%B4%ED%81%AC%ED%95%A0-%EA%B2%83</link>
            <guid>https://velog.io/@jelog_131/EC2-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Spring-Boot-%ED%81%AC%EB%A1%A0%EC%9E%A1Scheduled%EC%9D%B4-%EC%8B%A4%ED%96%89%EB%90%98%EC%A7%80-%EC%95%8A%EC%9D%84-%EB%95%8C-%EC%B2%B4%ED%81%AC%ED%95%A0-%EA%B2%83</guid>
            <pubDate>Fri, 30 May 2025 08:41:58 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-발생">문제 발생</h3>
<p>로컬에서는 정상 작동하던 크론잡이 EC2 서버에서는 정상적으로 실행되지 않았다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>크론잡(@Scheduled)은 <strong>서버 시간대(Timezone)를 기준으로</strong> 작동한다.
로컬은 &#39;Asia/Seoul&#39;, EC2는 &#39;UTC&#39; 시간대를 사용하고 있었다. 즉, 크론 표현식 기준 시간이 달라져 실행되지 않은 것처럼 보인 것이다.</p>
<h3 id="해결-방법">해결 방법</h3>
<ol>
<li><p><strong>EC2 인스턴스의 시간대 설정</strong>
EC2 인스턴스에 접속하여 시간대 설정이 어떻게 되어있는지 확인해본다. (&#39;<strong>timedatectl</strong>&#39; 명령어로 가능)
<img src="https://velog.velcdn.com/images/jelog_131/post/50cf6a9f-705c-4d54-885c-7900e5fb6862/image.png" alt="">
현재 &#39;UTC&#39; 시간대로 설정되어있는 모습을 확인할 수 있었다.
&#39;<strong>sudo timedatectl set-timezone Asia/Seoul</strong>&#39; 명령어로 시간대를 현지 지역과 시간에 맞게 변경해주었다.
<img src="https://velog.velcdn.com/images/jelog_131/post/a7c2fd4e-074d-4471-be00-312d02e69e32/image.png" alt=""></p>
</li>
<li><p><strong>Docker 컨테이너의 시간대를 호스트와 동기화</strong>
docker-compose.yml 스크립트를 수정하여 컨테이너가 EC2의 시간대와 동일하게 작동하도록 설정하였다.</p>
<pre><code class="language-yaml">services:
app:
 ...
 volumes:
   - /etc/localtime:/etc/localtime:ro</code></pre>
</li>
<li><p><strong>크론잡에 시간대 명시</strong>
코드상에 명시적으로 타임존을 지정하므로서 코드만 봐도 유지보수 시 혼란을 줄일 수 있다.</p>
</li>
</ol>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 8 * * ?&quot;, zone = &quot;Asia/Seoul&quot;)
    public void sendDailyEmailToSubscribers() {
        log.info(&quot;일일 이메일 발송 작업 시작: {}&quot;, LocalDateTime.now());

        List&lt;Subscriber&gt; subscribers = subscribeService.getAllSubscribers();

        if (subscribers.isEmpty()) {
            log.info(&quot;구독자가 없습니다. 이메일 발송을 건너뜁니다.&quot;);
            return;
        }

        String subject = &quot;마이니치 니홍고 - 오늘의 일본어 학습&quot;;
        String content = contentService.generateDailyContent();

        int successCount = 0;
        int failCount = 0;

        for (Subscriber subscriber : subscribers) {
            boolean sent = emailService.sendEmail(subscriber.getEmail(), subject, content);

            if (sent) {
                successCount++;
            } else {
                failCount++;
            }
        }

        log.info(&quot;일일 이메일 발송 완료 - 성공: {}, 실패: {}&quot;, successCount, failCount);
    }</code></pre>
<p>결국 이번 문제는 코드상에 문제가 있다기 보다는 <strong>배포 환경의 시간대 설정</strong>이 문제였다.
크론잡을 사용할 때는 항상 <strong>시간대를 명시하거나 서버의 시간대</strong>를 확인하자</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블슈팅 - 크로스 플랫폼 배포 시 HttpOnly RefreshToken이 전달되지 않는 문제]]></title>
            <link>https://velog.io/@jelog_131/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%ED%81%AC%EB%A1%9C%EC%8A%A4-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EB%B0%B0%ED%8F%AC-%EC%8B%9C-HttpOnly-RefreshToken%EC%9D%B4-%EC%A0%84%EB%8B%AC%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@jelog_131/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%ED%81%AC%EB%A1%9C%EC%8A%A4-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EB%B0%B0%ED%8F%AC-%EC%8B%9C-HttpOnly-RefreshToken%EC%9D%B4-%EC%A0%84%EB%8B%AC%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sat, 17 May 2025 06:33:52 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p>현재 GigSync라는 인디밴드 커뮤니티 웹 어플리케이션을 개발하고 있다.</p>
<ul>
<li>프론트엔드 : Vite + React (localhost:5173)</li>
<li>백엔드 : Spring Boot (localhost:8080)</li>
<li>인증 방식 : JWT + Refresh Token (HttpOnly Cookie)</li>
</ul>
<p>로컬 환경에서는 아무 문제 없이 RefreshToken이 쿠키로 잘 전달되고 인증이 매끄럽게 동작했다.
그러나 EC2에 프론트와 백엔드를 각각 배포하면서 문제가 발생했다.</p>
<h3 id="발생한-문제--로그인-직후-로그아웃됨">발생한 문제 : 로그인 직후 로그아웃됨</h3>
<p>프론트 EC2에서는 로그인 요청을 정상적으로 보내고, 백엔드 EC2에서는 RefreshToken을 Set-Cookie 헤더에 담아 응답했음에도 불구하고, 쿠키가 클라이언트에 저장되지 않았다.
결과적으로 인증 상태가 유지되지 않아 로그인 직후 자동 로그아웃되는 현상이 발생했다.</p>
<h3 id="원인분석--크로스-도메인--보안-정책">원인분석 : 크로스 도메인 + 보안 정책</h3>
<ol>
<li><p><strong>Same-Origin Policy와 크로스 도메인</strong>
브라우저는 보안상 <strong>출처(origin)</strong>가 다른 경우 쿠키를 자동으로 저장하거나 전송하지 않는다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>프론트</th>
<th>백엔드</th>
<th>Same-Origin인가?</th>
</tr>
</thead>
<tbody><tr>
<td>로컬</td>
<td><a href="http://localhost:5173">http://localhost:5173</a></td>
<td><a href="http://localhost:8080">http://localhost:8080</a></td>
<td>X (포트 다름)</td>
</tr>
<tr>
<td>EC2 배포</td>
<td><a href="http://FRONT_IP">http://FRONT_IP</a></td>
<td><a href="http://BACK_IP">http://BACK_IP</a></td>
<td>X (도메인 다름)</td>
</tr>
</tbody></table>
<ul>
<li><strong>포트가 다르거나 도메인이 다르면 Same-Origin이 아니다.</strong></li>
</ul>
</li>
<li><p><strong>Set-Cookie의 보안 속성</strong>
백엔드가 보낸 쿠키가 브라우저에 저장되려면 아래 조건을 만족해야 한다.</p>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
<th>테스트 환경에서 주의할 점</th>
</tr>
</thead>
<tbody><tr>
<td>HttpOnly</td>
<td>자바스크립트로 접근 불가</td>
<td>로그인 보안 향상</td>
</tr>
<tr>
<td>Secure</td>
<td>HTTPS에서만 동작</td>
<td><strong>로컬이나 HTTP EC2에서는 작동 안 함</strong></td>
</tr>
<tr>
<td>SameSite=None</td>
<td>크로스 사이트 요청 허용</td>
<td><code>Secure</code>가 반드시 같이 설정되어야 함</td>
</tr>
</tbody></table>
<p>즉, SameSite=None; Secure 조합은 HTTPS가 아니면 무효하다.</p>
</li>
</ol>
<h3 id="해결-방법">해결 방법</h3>
<h4 id="테스트-환경에서의-임시-해결프론트--백엔드를-단일-ec2에-배포">테스트 환경에서의 임시 해결(프론트 + 백엔드를 단일 EC2에 배포)</h4>
<ul>
<li>NGINX 또는 Caddy 등으로 <strong>Reverse Proxy를</strong> 구성하여 하나의 도메인 또는 포트로 동작하게 한다.</li>
<li>로컬처럼 동일 Origin이 되므로 쿠키가 정상 동작함</li>
</ul>
<h4 id="실제-서비스-운영-시-해결-방안">실제 서비스 운영 시 해결 방안</h4>
<ol>
<li><strong>도메인 구입 및 HTTPS 적용</strong><ul>
<li><strong>SSL 인증서</strong>로 HTTPS 적용</li>
<li>Secure 속성이 붙은 쿠키도 정상 작동</li>
</ul>
</li>
<li><strong>백엔드 응답 헤더 설정</strong></li>
</ol>
<pre><code class="language-java">ResponseCookie.from(&quot;refreshToken&quot;, token)
    .httpOnly(true)
    .secure(true) // HTTPS 환경에서만 작동
    .sameSite(&quot;None&quot;) // 크로스 도메인 허용
    .path(&quot;/&quot;)
    .build();</code></pre>
<ol start="3">
<li><strong>프론트엔드 요청 시 설정</strong></li>
</ol>
<pre><code class="language-ts">axios.post(&#39;https://api.gigsync.com/login&#39;, data, {
  withCredentials: true // 반드시 설정
});</code></pre>
<h3 id="쿠키-전달-문제-점검-체크리스트">쿠키 전달 문제 점검 체크리스트</h3>
<table>
<thead>
<tr>
<th>체크 항목</th>
<th>확인 내용</th>
</tr>
</thead>
<tbody><tr>
<td>withCredentials 설정 여부</td>
<td>프론트 axios/fetch에서 반드시 포함</td>
</tr>
<tr>
<td>Set-Cookie에 SameSite 포함 여부</td>
<td>백엔드 쿠키 설정 확인</td>
</tr>
<tr>
<td>HTTPS 환경 여부</td>
<td>Secure 쿠키는 HTTPS에서만 유효</td>
</tr>
<tr>
<td>도메인/서브도메인 설정</td>
<td>크로스 도메인 여부에 따라 SameSite 고려</td>
</tr>
</tbody></table>
<h2 id="끝으로">끝으로..</h2>
<p>테스트 환경에서는 동일 EC2에 프론트와 백엔드를 배치하여 문제를 해결했지만, 서비스 <strong>확장성</strong>과 <strong>보안성</strong>을 고려하면 도메인을 이용한 HTTPS 기반의 분리 배포가 필수이다. HttpOnly Cookie 기반 인증은 보안상 유리하지만, <strong>크로스 도메인 환경에서의 제약</strong>을 반드시 이해하고 맞춰줘야 한다는 걸 알 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kafka와 WebSocket을 이용한 채팅 시스템 구현 & 구현 중 발생한 문제점들]]></title>
            <link>https://velog.io/@jelog_131/Kafka%EC%99%80-WebSocket%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-%EA%B5%AC%ED%98%84-%EC%A4%91-%EB%B0%9C%EC%83%9D%ED%95%9C-%EB%AC%B8%EC%A0%9C%EC%A0%90%EB%93%A4</link>
            <guid>https://velog.io/@jelog_131/Kafka%EC%99%80-WebSocket%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-%EA%B5%AC%ED%98%84-%EC%A4%91-%EB%B0%9C%EC%83%9D%ED%95%9C-%EB%AC%B8%EC%A0%9C%EC%A0%90%EB%93%A4</guid>
            <pubDate>Sat, 03 May 2025 09:41:29 GMT</pubDate>
            <description><![CDATA[<h2 id="kafka란">Kafka란?</h2>
<p>Apache Kafka는 <strong>분산 스트리밍 플랫폼</strong>으로 대용량의 데이터를 빠르고 안정적으로 처리할 수 있는 메시지 브로커 시스템이다. 기본적인 구성 요소는 다음과 같다.</p>
<ul>
<li>Producer : 메시지를 발행하는 주체</li>
<li>Broker : 메시지를 저장하고 관리하는 Kafka 서버</li>
<li>Topic : 메시지를 분류하는 단위</li>
<li>Consumer : 메시지를 구독하고 처리하는 주체</li>
</ul>
<p>Kafka는 일반적인 메시지 큐 시스템보다 <strong>높은 처리량, 내구성, 확정성</strong>을 제공하여 마이크로서비스 간의 통신이나 실시간 데이터 처리에 매우 유용하다.</p>
<h2 id="왜-kafka를-채팅-시스템에-도입">왜 Kafka를 채팅 시스템에 도입?</h2>
<p>기존의 WebSocket 기반 채팅 시스템에서는 <strong>WebSocket 세션 간 직접 메시지를 전달</strong>하는 방식으로 구성하는 경우가 많다. 이 경우 다음과 같은 문제점이 발생할 수 있다.</p>
<ul>
<li><strong>다중 서버 환경에서 세션 공유의 어려움</strong></li>
<li><strong>실시간 처리 외의 기능(메시지 저장, 분석, 알림 발송 등) 확장이 어려움</strong></li>
</ul>
<p>Kafka를 도입하면 메시지를 중간에 Kafka로 보내고 Consumer에서 받아 WebSocket을 통해 다시 전달하는 방식으로 아키텍처가 바뀐다. 이를 통해 다음과 같은 이점을 얻을 수 있다.</p>
<ul>
<li><strong>메시지 발행과 소비를 분리하여 확장성 향상</strong></li>
<li><strong>다양한 서비스가 동일 메시지를 구독 가능 (알림, 저장, 분석)</strong></li>
<li><strong>서버 간 세션 공유가 불필요 (stateless)</strong></li>
</ul>
<h2 id="구현-구조-요약">구현 구조 요약</h2>
<ul>
<li>클라이언트는 WebSocket으로 연결하며 token과 receiverId를 쿼리 파라미터로 보낸다.</li>
<li>서버는 JWT로 인증된 후 세션을 관리하며 과거 메시지를 불러온다.</li>
<li>클라이언트에서 메시지를 보내면 서버를 이를 Kafka Producer를 통해 전송한다.</li>
<li>Kafka Consumer가 메시지를 수신하고 수신자에게 해당 메시지를 WebSocket으로 전송한다.</li>
</ul>
<h4 id="websocket-handler-예제-코드">WebSocket Handler 예제 코드</h4>
<pre><code class="language-java">@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    String senderId = (String) session.getAttributes().get(&quot;userId&quot;);
    String receiverId = (String) session.getAttributes().get(&quot;receiverId&quot;);

    ChatMessageRequestDto requestDto = new ChatMessageRequestDto(message.getPayload());
    ChatMessageResponseDto savedMessage = chatMessageService.saveMessage(
        Long.parseLong(receiverId),
        Long.parseLong(senderId),
        requestDto
    );

    String messageJson = objectMapper.writeValueAsString(savedMessage);
    kafkaProducerService.sendMessage(messageJson); // Kafka로 메시지 전송
}</code></pre>
<h3 id="문제-상황과-해결">문제 상황과 해결</h3>
<h4 id="문제-1--채팅방에-관계없는-메시지가-전달됨">문제 1 : 채팅방에 관계없는 메시지가 전달됨</h4>
<ul>
<li>유저 1과 채팅 중인데 유저 2가 보낸 메시지가 실시간으로 나타남</li>
<li>원인 : 메시지를 수신할 때 receiverId만 비교하고 roomId에 대한 필터링이 없음</li>
<li>해결 : WebSocketSession에 roomId를 저장하고, Kafka Consumer에서 메시지의 roomId와 세션의 roomId를 비교해 일치할 때만 전송하도록 수정</li>
</ul>
<h4 id="문제-2--메시지가-중복-전송됨">문제 2 : 메시지가 중복 전송됨</h4>
<ul>
<li>Kafka에서 메시지를 수신할 때 같은 메시지가 2번 표시되는 현상 발생</li>
<li>원인 : 메시지를 전송하는 로직이 이중으로 실행되거나, WebSocket 세션이 여러 개 생성된 경우</li>
<li>해결 : SessionManager를 통해 중복 세션을 방지하고, 메시지 전송 전에 중복 여부 확인</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블슈팅 - HttpOnly Refresh Token이 로컬 개발 환경에서 쿠키에 안 보이는 문제]]></title>
            <link>https://velog.io/@jelog_131/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-HttpOnly-Refresh-Token%EC%9D%B4-%EB%A1%9C%EC%BB%AC-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%BF%A0%ED%82%A4%EC%97%90-%EC%95%88-%EB%B3%B4%EC%9D%B4%EB%8A%94-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@jelog_131/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-HttpOnly-Refresh-Token%EC%9D%B4-%EB%A1%9C%EC%BB%AC-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%BF%A0%ED%82%A4%EC%97%90-%EC%95%88-%EB%B3%B4%EC%9D%B4%EB%8A%94-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sat, 03 May 2025 09:19:02 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>Spring Boot + JWT 인증 구조에서 로그인 시 refreshToken을 HttpOnly 쿠키에 담아 응답하도록 설정했다.</p>
<pre><code class="language-java">ResponseCookie refreshTokenCookie = ResponseCookie.from(&quot;refreshToken&quot;, refreshToken)
    .httpOnly(true)
    .secure(true)
    .path(&quot;/api/auth/refresh&quot;)
    .maxAge(Duration.ofDays(14))
    .sameSite(&quot;None&quot;)
    .build();

return ResponseEntity.ok()
    .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString())
    .body(...);</code></pre>
<p>그러나 로컬에서 <a href="http://localhost%EB%A1%9C">http://localhost로</a> 서버를 실행했을 때, 브라우저에 쿠키가 저장되지 않았다.</p>
<h2 id="원인-분석">원인 분석</h2>
<p>쿠키 설정에서 아래 두 옵션이 문제였다.</p>
<ul>
<li>secure=true : HTTPS에서만 쿠키 전송을 허용</li>
<li>sameSite=&quot;None&quot; : 크로스 도메인 요청에서 쿠키 전송 허용. 단, secure=true 조건 필수</li>
</ul>
<p>따라서 로컬에서는 쿠키 자체가 전송되지 않는다. -&gt; 브라우저 개발자 도구에서도 보이지 않음</p>
<h2 id="해결-방법">해결 방법</h2>
<p>환경에 따라 쿠키 설정을 분기 처리하도록 수정하였다.</p>
<pre><code class="language-java">boolean isLocal = true; // 개발/운영 환경에 따라 설정

ResponseCookie refreshTokenCookie = ResponseCookie.from(&quot;refreshToken&quot;, refreshToken)
    .httpOnly(true)
    .secure(!isLocal)                          // 운영에서는 secure=true
    .path(&quot;/api/auth/refresh&quot;)
    .sameSite(isLocal ? &quot;Lax&quot; : &quot;None&quot;)        // 로컬에서는 Lax로 설정
    .maxAge(Duration.ofDays(14))
    .build();</code></pre>
<ul>
<li>로컬(localhost)<ul>
<li>secure=false, sameSite=Lax</li>
<li>쿠키 정상 전송됨 (브라우저 Application &gt; Cookies 탭에서 확인 가능)</li>
</ul>
</li>
<li>운영(https)<ul>
<li>secure=true, sameSite=None</li>
<li>크로스 사이트에서도 쿠키 전송 가능 (보안 유지)</li>
</ul>
</li>
</ul>
<h4 id="보안상-중요한-포인트">보안상 중요한 포인트</h4>
<ul>
<li>httpOnly=true 덕분에 JS에서는 document.cookie로 접근이 불가하다. -&gt; XSS 방어</li>
<li>브라우저 콘솔에서 쿠키가 안 보이는 건 정상 동작이다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - JWT + Spring Security + Redis 기반 로그아웃 처리 방식]]></title>
            <link>https://velog.io/@jelog_131/TIL-JWT-Spring-Security-Redis-%EA%B8%B0%EB%B0%98-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@jelog_131/TIL-JWT-Spring-Security-Redis-%EA%B8%B0%EB%B0%98-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Sun, 20 Apr 2025 06:43:34 GMT</pubDate>
            <description><![CDATA[<p>오늘은 Spring Boot에서 JWT 인증 방식을 사용하는 환경에서 <strong>Redis를 활용한 로그아웃 처리</strong> 및 <strong>Refresh Token 관리</strong>에 대해 정리했다. 구현하며 느꼈던 포인트들과 코드 중심으로 정리해본다.</p>
<h2 id="왜-redis를-사용하는가">왜 Redis를 사용하는가?</h2>
<p>JWT는 서버에 세션을 저장하지 않는 Stateless 방식이라 로그아웃 시 토큰을 무효화시키는 것이 쉽지 않다.
이를 해결하기 위해 <strong>Redis를 블랙리스트 저장소</strong>로 활용한다.</p>
<ul>
<li><strong>AccessToken 블랙리스트 저장</strong> : 로그아웃 시 해당 토큰을 Redis에 저장하여 재사용 방지</li>
<li><strong>Refresh Token 저장 및 검증</strong> : 클라이언트가 갱신 요청 시 Redis에 저장된 Refresh Token을 기준으로 검증</li>
</ul>
<h2 id="redis-설정">Redis 설정</h2>
<pre><code class="language-java">@Bean
public RedisTemplate&lt;String, Object&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate&lt;String, Object&gt; template = new RedisTemplate&lt;&gt;();
    template.setConnectionFactory(connectionFactory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(genericJackson2JsonRedisSerializer());
    return template;
}</code></pre>
<h2 id="jwtauthfilter-내부-블랙리스트-검증-코드">JwtAuthFilter 내부 블랙리스트 검증 코드</h2>
<pre><code class="language-java">private void authenticate(HttpServletRequest request) {
    String token = this.getTokenFromHeader(request);
    if (!StringUtils.hasText(token) || !jwtUtil.validateToken(token)) return;

    // 블랙리스트 여부 확인
    String isLogout = (String) redisTemplate.opsForValue().get(token);
    if (&quot;logout&quot;.equals(isLogout)) return;

    String email = jwtUtil.getUserEmail(token);
    UserDetails userDetails = userDetailsService.loadUserByUsername(email);

    UsernamePasswordAuthenticationToken authenticationToken =
        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}</code></pre>
<h2 id="로그아웃-서비스-구현">로그아웃 서비스 구현</h2>
<pre><code class="language-java">@Override
public void logout(String accessToken, Long expirationTime) {
    // 블랙리스트 등록
    redisTemplate.opsForValue().set(accessToken, &quot;logout&quot;, Duration.ofMillis(expirationTime));

    // Refresh Token 삭제
    redisTemplate.delete(&quot;RT:&quot; + getEmailFromToken(accessToken));
}</code></pre>
<h2 id="refresh-token-저장-예시">Refresh Token 저장 예시</h2>
<pre><code class="language-java">@Override
public void saveRefreshToken(String email, String refreshToken, Long ttl) {
    redisTemplate.opsForValue().set(&quot;RT:&quot; + email, refreshToken, Duration.ofMillis(ttl));
}</code></pre>
<h2 id="refresh-token-검증-및-재발급-로직-예시">Refresh Token 검증 및 재발급 로직 예시</h2>
<pre><code class="language-java">public String reissueAccessToken(String email, String providedRefreshToken) {
    String savedRefreshToken = (String) redisTemplate.opsForValue().get(&quot;RT:&quot; + email);
    if (!providedRefreshToken.equals(savedRefreshToken)) {
        throw new CustomException(TokenErrorCode.INVALID_REFRESH_TOKEN);
    }

    return jwtUtil.generateAccessToken(userRepository.findByEmail(email).get());
}</code></pre>
<hr>
<h4 id="유의할-점">유의할 점</h4>
<ul>
<li>토큰 만료 시간과 Redis TTL을 맞춰줘야 불필요한 메모리 낭비를 줄일 수 있다</li>
<li>Filter, JwtUtil, RedisConfig 사이 의존성도 잘 분리해둬야 유지보수가 쉬움</li>
<li>RedisTemplate를 여러 서비스에서 사용할 경우, 유틸 클래스로 추출해 사용하는 것도 고려할 수 있다</li>
<li>Refresh Token의 경우도 Redis에 저장되므로 탈취에 유의하고 HTTPS 등 보안 대책 필수</li>
</ul>
<h4 id="느낀-점">느낀 점</h4>
<p>처음에는 Stateless 인증 구조에서 로그아웃 구현이 어렵게 느껴졌지만, Redis를 활용한 블랙리스트 패턴과 Refresh Token 저장 방식으로 충분히 실용적인 방법이 가능하다는 걸 깨달았다. Spring Security와 잘 연동되도록 설계하는 것이 핵심이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블슈팅 - S3에 업로드한 이미지가 다운로드만 뜨는 문제 해결하기]]></title>
            <link>https://velog.io/@jelog_131/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-S3%EC%97%90-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%EB%A7%8C-%EB%9C%A8%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jelog_131/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-S3%EC%97%90-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%EB%A7%8C-%EB%9C%A8%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 11 Apr 2025 18:07:07 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>Spring Boot에서 게시글 생성 시 파일(이미지, 동영상)을 첨부해 AWS S3에 업로드하도록 구현했다.
하지만 Postman으로 테스트하던 중 다음과 같은 에러가 발생했다.</p>
<pre><code class="language-java">2025-04-12T01:19:02.529+09:00  WARN 47568 --- [gigsync] [nio-8080-exec-7] 
.w.s.m.s.DefaultHandlerExceptionResolver : 
Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: 
Content-Type &#39;application/octet-stream&#39; is not supported]</code></pre>
<p>파일 업로드 자체가 실패했고, 이후 S3 버킷을 확인해도 업로드된 파일은 application/octet-stream 타입으로 설정되어 있었으며 <strong>브라우저에서 바로 열 수 없고 다운로드만 되는 문제</strong>가 발생했다.</p>
<h2 id="원인-분석">원인 분석</h2>
<p>이 문제는 크게 두 가지 원인으로 나눌 수 있었다.</p>
<ol>
<li><strong>Spring이 application/octet-stream 요청을 처리하지 못함</strong></li>
</ol>
<ul>
<li>@RequestPart로 JSON (BoardRequestDto)과 파일(List&lt;<code>MultipartFile</code>&gt;)을 동시에 받는 구조였는데, Postman에서 JSON을 잘못 설정하거나 Content-Type을 생략하면 Spring이 인식하지 못해 위 에러가 발생함</li>
</ul>
<ol start="2">
<li><strong>S3에 잘못된 Content-Type으로 업로드됨</strong></li>
</ol>
<ul>
<li>Multipart 업로드 요청 시 Content-Type이 지정되지 않으면 S3에서 자동으로 application/octet-stream으로 처리됨 -&gt; 이 경우, 이미지도 파일처럼 다운로드되며 웹에서 미리보기가 되지 않음</li>
</ul>
<h2 id="해결-방안">해결 방안</h2>
<p><strong>Postman에서 요청을 multipart/form-data로 정확히 설정</strong>하고 <strong>@RequestPart에는 application/json 형태의 문자열을 전달</strong>하였다. 업로드 파일의 <strong>Content-Type을 MultipartFile.getContentType()으로 명시하고</strong>, 이미지 업로드 시 <strong>허용된 확장자만 가능하도록 설정</strong>했다.</p>
<h4 id="postma-api-요청">Postma API 요청</h4>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/84549a4d-da32-494c-ba5e-a5aff08bdafa/image.png" alt=""></p>
<h4 id="수정된-fileservicejava">수정된 FileService.java</h4>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class FileService {

    private static final List&lt;String&gt; ALLOWED_IMAGE_EXTENSIONS = List.of(&quot;jpg&quot;, &quot;jpeg&quot;, &quot;png&quot;, &quot;gif&quot;);

    private final S3Client s3Client;

    @Value(&quot;${cloud.aws.s3.bucketName}&quot;)
    private String bucketName;

    public List&lt;BoardFile&gt; uploadFiles(List&lt;MultipartFile&gt; files, Board board) {
        List&lt;BoardFile&gt; result = new ArrayList&lt;&gt;();

        for (MultipartFile file : files) {
            String originalFilename = file.getOriginalFilename();
            String extension = getExtension(originalFilename);

            if (isImage(file) &amp;&amp; !ALLOWED_IMAGE_EXTENSIONS.contains(extension.toLowerCase())) {
                throw new IllegalArgumentException(&quot;지원하지 않는 이미지 형식입니다: &quot; + extension);
            }

            String fileName = UUID.randomUUID() + &quot;_&quot; + originalFilename;
            String url = uploadToS3(file, fileName);

            result.add(BoardFile.builder()
                    .fileName(fileName)
                    .fileUrl(url)
                    .board(board)
                    .build()
            );
        }

        return result;
    }

    private String uploadToS3(MultipartFile file, String fileName) {
        try {
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(bucketName)
                    .key(fileName)
                    .acl(&quot;public-read&quot;)
                    .contentType(file.getContentType())
                    .build();

            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
            return &quot;https://&quot; + bucketName + &quot;.s3.amazonaws.com/&quot; + fileName;
        } catch (IOException e) {
            throw new RuntimeException(&quot;파일 업로드 중 오류 발생&quot;, e);
        }
    }

    private boolean isImage(MultipartFile file) {
        String contentType = file.getContentType();
        return contentType != null &amp;&amp; contentType.startsWith(&quot;image&quot;);
    }

    private String getExtension(String filename) {
        if (filename == null || !filename.contains(&quot;.&quot;)) {
            throw new IllegalArgumentException(&quot;파일 이름에 확장자가 없습니다.&quot;);
        }
        return filename.substring(filename.lastIndexOf(&#39;.&#39;) + 1);
    }
}</code></pre>
<h2 id="결과-확인">결과 확인</h2>
<h4 id="브라우저에서-바로-이미지-미리보기-가능">브라우저에서 바로 이미지 미리보기 가능</h4>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/6c22a2b0-7ec0-4c27-802f-e6a1aead0c7d/image.png" alt=""></p>
<h4 id="동영상도-별도-설정-없이-s3-주소에서-재생-가능">동영상도 별도 설정 없이 S3 주소에서 재생 가능</h4>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/bb6fc358-e4c8-45f2-b75c-f675cc64a3de/image.png" alt=""></p>
<p>확장자 검증으로 잘못된 파일 업로드를 방지하고 S3 URL을 게시판 조회 시 바로 사용할 수 있게 되었다.</p>
<p>이번 이슈는 흔하지만 초반에 놓치기 쉬운 Content-Type과 multipart/form-data 처리에 대한 실수였다. <strong>Spring Boot와 S3 연동 시에는 아래 항목들을 꼭 챙기자 !</strong></p>
<ul>
<li>@RequestPart를 사용할 때는 multipart/form-data 필수</li>
<li>파일은 적절한 Content-Type으로 업로드해야 브러우저에서 랜더링됨</li>
<li>이미지 확장자 검증은 보안/UX 차원에서 중요</li>
<li>Postman에서도 요청 설정을 정확히 해야 Spring이 인식함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - Kubernetes 스터디 내용 정리 5 (Pod 오토스케일링, 배포 전략)]]></title>
            <link>https://velog.io/@jelog_131/TIL-Kubernetes-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%82%B4%EC%9A%A9-%EC%A0%95%EB%A6%AC-5-Pod-%EC%98%A4%ED%86%A0%EC%8A%A4%EC%BC%80%EC%9D%BC%EB%A7%81-%EB%B0%B0%ED%8F%AC-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@jelog_131/TIL-Kubernetes-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%82%B4%EC%9A%A9-%EC%A0%95%EB%A6%AC-5-Pod-%EC%98%A4%ED%86%A0%EC%8A%A4%EC%BC%80%EC%9D%BC%EB%A7%81-%EB%B0%B0%ED%8F%AC-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Fri, 11 Apr 2025 07:51:42 GMT</pubDate>
            <description><![CDATA[<h2 id="pod-오토스케일링-hpa-horizontal-pod-autoscaler">Pod 오토스케일링: HPA (Horizontal Pod Autoscaler)</h2>
<p><strong>HPA</strong>는 애플리케이션의 리소스 사용량(CPU, 메모리 등)을 기준으로 <strong>Pod 수를 자동으로 조절하는 오브젝트이다.</strong>
일반적으로 Deployment, ReplicaSet, StatefulSet에 적용 가능하다.
리소스 사용량을 기반으로, 정의된 <strong>임계치를 넘기면 Pod의 수를 늘리고, 사용량이 줄어들면 다시 줄인다.</strong></p>
<h3 id="hpa-예제-코드">HPA 예제 코드</h3>
<pre><code class="language-yaml">apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: example-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50</code></pre>
<ul>
<li>kubectl top pod으로 리소스 사용량 확인 가능</li>
<li>metrics-server 설치가 필요하다 (클러스터에 리소스 사용량 데이터를 제공)</li>
</ul>
<hr>
<h2 id="다양한-배포-전략">다양한 배포 전략</h2>
<ul>
<li><strong>Rolling Update</strong><ul>
<li>쿠버네티스의 기본 배포 전략</li>
<li>점진적으로 새로운 Pod를 생성하고, 이전 버전을 하나씩 종료함</li>
<li>다운타임 없이 배포 가능</li>
</ul>
</li>
<li><strong>Blue/Green Deployment</strong><ul>
<li>기존 버전(Blue)과 새로운 버전(Green)을 동시에 유지하다가, 트래픽을 전환하는 방식</li>
<li>트래픽 전환이 완전히 이뤄진 후, 기존 버전을 제거</li>
<li>롤백이 쉽고 안정성이 높다</li>
<li>쿠버네티스에서는 서비스 객체를 이용해 label selector를 변경하여 트래픽을 전환</li>
</ul>
</li>
<li><strong>Canary Deployment</strong><ul>
<li>새로운 버전을 일부 사용자에게만 점진적으로 배포한 뒤, 이상이 없으면 전체 배포한다</li>
<li>ex) 전체 트래픽의 10%만 Canary 버전에 전달 -&gt; 이상이 없다면 점차 확대한다</li>
<li>Deployment를 복수로 관리하거나, Service 객체를 통해 레이블 조정 + 트래픽 분산을 조절함</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - Kubernetes 스터디 내용 정리 4 (Persistent Volume, StatefulSet, Job/CronJob)]]></title>
            <link>https://velog.io/@jelog_131/TIL-Kubernetes-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%82%B4%EC%9A%A9-%EC%A0%95%EB%A6%AC-4-Persistent-Volume-StatefulSet-JobCronJob</link>
            <guid>https://velog.io/@jelog_131/TIL-Kubernetes-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%82%B4%EC%9A%A9-%EC%A0%95%EB%A6%AC-4-Persistent-Volume-StatefulSet-JobCronJob</guid>
            <pubDate>Wed, 02 Apr 2025 14:06:26 GMT</pubDate>
            <description><![CDATA[<h2 id="pvpersistent-volume와-pvcpersistent-volume-claim">PV(Persistent Volume)와 PVC(Persistent Volume Claim)</h2>
<p>쿠버네티스에서 Persistent Volume(PV)는 클러스터에서 관리하는 스토리지 리소스이고, Persistent Volume Claim(PVC)는 사용자가 해당 스토리지를 요청하는 방식이다.</p>
<ul>
<li>Persistent Volume(PV)<ul>
<li>관리자가 설정한 정적인 스토리지 리소스</li>
<li>다양한 스토리지 백엔드(NFS, AWS EBS, GCE Persistent Disk 등) 지원</li>
<li>수명 주기가 노드의 독립적</li>
</ul>
</li>
<li>Persistent Volume Claim(PVC)<ul>
<li>개발자가 필요한 스토리지를 요청하는 오브젝트</li>
<li>PV가 클레임에 매칭되면 바인딩되어 사용 가능</li>
<li>요청한 용량, 접근 모드(ReadWriteOnce, ReadOnlyMany 등)에 따라 적절한 PV가 할당됨</li>
</ul>
</li>
</ul>
<hr>
<h2 id="statefulset과-stateless-차이">StatefulSet과 Stateless 차이</h2>
<h3 id="stateful-vs-stateless">Stateful vs Stateless</h3>
<ul>
<li>Stateless(무상태 애플리케이션)<ul>
<li>특정 요청에 대한 상태를 저장하지 않음</li>
<li>여러 인스턴스를 동일한 방식으로 생성/삭제 가능 (ex: 웹서버, API 서버)</li>
<li>확장이 용이하며 로드 밸런서를 통해 부하 분산 가능</li>
</ul>
</li>
<li>Stateful(상태 저장 애플리케이션)<ul>
<li>특정 요청에 대한 상태를 유지해야 하는 애플리케이션 (ex: 데이터베이스, 메시지 큐)</li>
<li>각 인스턴스가 고유한 식별자와 데이터를 가지며, 특정 순서로 생성/삭제됨</li>
</ul>
</li>
</ul>
<h3 id="statefulset-이란">StatefulSet 이란?</h3>
<p>StatefulSet은 상태를 가지는 (Stateful) 애플리케이션을 관리하는 쿠버네티스 컨트롤러이다.</p>
<ul>
<li>각 Pod에 고유한 네트워크 ID(Stable Network Identity) 부여</li>
<li>Pod 재시작 또는 재배포 시에도 일관된 시토리지 유지 (PVC와 함께 사용)</li>
<li>Pod 번호 순서 보장 (ex: my-sql-0, my-sql-1)</li>
<li>주로 데이터베이스 (MySQL, PostgreSQL), Kafka, Redis 같은 상태 기반 서비스에서 사용</li>
</ul>
<h4 id="statefulset-기본-구성-예제">StatefulSet 기본 구성 예제</h4>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: my-db
spec:
  serviceName: &quot;my-db&quot;
  replicas: 3
  selector:
    matchLabels:
      app: my-db
  template:
    metadata:
      labels:
        app: my-db
    spec:
      containers:
      - name: my-db
        image: mysql:latest
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [&quot;ReadWriteOnce&quot;]
      resources:
        requests:
          storage: 10Gi</code></pre>
<hr>
<h2 id="batch-프로그램과-쿠버네티스-job">Batch 프로그램과 쿠버네티스 Job</h2>
<h3 id="batch-프로그램이란">Batch 프로그램이란?</h3>
<ul>
<li>일정한 작업을 한 번 또는 주기적으로 실행하는 프로그램</li>
<li>사용자 입력 없이 실행되며 대량의 데이터 처리에 적합</li>
<li>ex: 로그 분석, 백업, 데이터 변환, 배치 처리</li>
</ul>
<h3 id="쿠버네티스-job">쿠버네티스 Job</h3>
<p>Job은 일회성 또는 특정 횟수만큼 실행되는 작업을 관리하는 오브젝트이다.</p>
<ul>
<li>Pod가 성공적으로 완료될 때까지 실행됨</li>
<li>실패한 경우 재시도 가능</li>
<li>completions 및 parallelism 설정을 통해 여러 개의 작업을 병렬로 실행 가능</li>
</ul>
<h4 id="job-예제">Job 예제</h4>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata:
  name: example-job
spec:
  template:
    spec:
      containers:
      - name: batch-job
        image: busybox
        command: [&quot;echo&quot;, &quot;Hello, Kubernetes Batch Job!&quot;]
      restartPolicy: Never
  backoffLimit: 4  # 실패 시 최대 4번 재시도</code></pre>
<h3 id="cronjob-스케줄링-job">CronJob (스케줄링 Job)</h3>
<ul>
<li>Job을 일정한 주기로 실행하는 오브젝트</li>
<li>Cron 표현식(* * * * *)을 사용하여 실행 시간 설정 가능</li>
<li>ex: 매일 자정에 백업 실행, 5분마다 로그 분석 실행</li>
</ul>
<h4 id="cronjob-예제">CronJob 예제</h4>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-backup
spec:
  schedule: &quot;0 0 * * *&quot;  # 매일 자정
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: backup
            image: busybox
            command: [&quot;echo&quot;, &quot;Backing up data...&quot;]
          restartPolicy: OnFailure</code></pre>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>PV/PVC : 쿠버네티스에서 상태를 저장하는 방법 (스토리지)</li>
<li>StatefulSet : 상태를 유지하는 애플리케이션을 위한 관리 방식</li>
<li>Stateless vs Stateful : 상태 저장 여부에 따른 애플리케이션의 특성</li>
<li>Job : 일회성 작업을 수행하는 쿠버네티스 오브젝트</li>
<li>CronJob : 일정 주기로 실행되는 Job</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - Kubernetes 스터디 내용 정리 3 (Service, Ingress, Deployment)]]></title>
            <link>https://velog.io/@jelog_131/TIL-Kubernetes-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%82%B4%EC%9A%A9-%EC%A0%95%EB%A6%AC-3-Service-Ingress-Deployment</link>
            <guid>https://velog.io/@jelog_131/TIL-Kubernetes-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%82%B4%EC%9A%A9-%EC%A0%95%EB%A6%AC-3-Service-Ingress-Deployment</guid>
            <pubDate>Wed, 26 Mar 2025 13:26:19 GMT</pubDate>
            <description><![CDATA[<h2 id="service--pod의-네트워크-접근을-위한-추상화">Service : Pod의 네트워크 접근을 위한 추상화</h2>
<p>kubernetes에서 Pod는 동적으로 생성되고 사라지므로, 특정 Pod의 IP 주소는 고정되지 않는다. 따라서 안정적인 네트워크 통신을 위해 Service가 필요하다.</p>
<h3 id="service의-주요-타입">Service의 주요 타입</h3>
<ol>
<li>ClusterIP (기본값)<ul>
<li>클러스터 내부에서만 접근 가능한 서비스</li>
<li>kubectl expose deployment my-app --type=ClusterIP --port=80</li>
</ul>
</li>
<li>NodePort<ul>
<li>각 Node의 고정된 포트를 개방하여 외부에서 접근 가능</li>
<li>kubectl expose deployment my-app --type=NodePort --port=80</li>
</ul>
</li>
<li>LoadBalancer<ul>
<li>클라우드 제공자의 로드밸런서를 사용하여 외부 접근을 지원</li>
<li>kubectl expose deployment my-app --type=LoadBalancer --port=80</li>
</ul>
</li>
<li>ExternalName<ul>
<li>외부 서비스 도메인과 연결</li>
<li>kubectl apply -f external-service.yaml</li>
</ul>
</li>
</ol>
<hr>
<h2 id="ingress--외부-트래픽을-클러스터-내부로-라우팅">Ingress : 외부 트래픽을 클러스터 내부로 라우팅</h2>
<p>Ingress는 kubernetes에서 HTTP(S) 트래픽을 관리하는 리소스로, 로드밸런서보다 유연한 라우팅 기능을 제공한다.</p>
<h3 id="ingress-주요-개념">Ingress 주요 개념</h3>
<ul>
<li>Path-based Routing : 도메인 + 경로 기반으로 트래픽을 특정 서비스로 전달</li>
<li>Host-based Routing : 여러 도메인을 하나의 Ingress에서 처리 가능</li>
<li>TLS 지원 : HTTPS 트래픽 관리 가능</li>
</ul>
<h3 id="ingress-예제-yaml">Ingress 예제 yaml</h3>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress
spec:
  rules:
  - host: my-app.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-service
            port:
              number: 80</code></pre>
<h4 id="ingress-controller-필요">Ingress Controller 필요</h4>
<p>기본적으로 kubernetes에는 Ingress 기능만 있고, 실제 트래픽을 처리하려면 <strong>Ingress Controller(Nginx, Traefik 등)</strong>를 설치해야 한다.</p>
<hr>
<h2 id="deployment--애플리케이션-배포-및-관리">Deployment : 애플리케이션 배포 및 관리</h2>
<p>Deployment는 Pod를 생성, 업데이트, 롤백, 스케일링할 수 있도록 관리하는 리소스이다.</p>
<h3 id="deployment의-주요-기능">Deployment의 주요 기능</h3>
<ul>
<li>ReplicaSet을 통해 원하는 개수의 Pod 유지</li>
<li>새로운 버전 배포 시 Rolling Update 또는 Recreate 전략 제공</li>
<li>특정 버전으로 되돌리는 Rollback 기능 지원</li>
</ul>
<h3 id="deployment-예제-yaml">Deployment 예제 yaml</h3>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
      - name: my-app
        image: my-app:1.0
        ports:
        - containerPort: 80</code></pre>
<ul>
<li>replicas: 3 -&gt; Pod 3개 유지</li>
<li>image: my-app:1.0 -&gt; 특정 버전 이미지 사용</li>
<li>Rolling Update로 중단 없이 새로운 버전 배포 가능</li>
</ul>
<h4 id="kubectl-명령어-정리">kubectl 명령어 정리</h4>
<ul>
<li>kubectl apply -f deployment.yaml -&gt; Deployment 생성</li>
<li>kubectl rollout status deployment my-app -&gt; 배포 상태 확인</li>
<li>kubectl rollout undo deployment my-app -&gt; 이전 버전으로 롤백</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot JPA에서 LOB필드 조회 시 JpaSystemException 발생 해결]]></title>
            <link>https://velog.io/@jelog_131/Spring-Boot-JPA%EC%97%90%EC%84%9C-LOB%ED%95%84%EB%93%9C-%EC%A1%B0%ED%9A%8C-%EC%8B%9C-JpaSystemException-%EB%B0%9C%EC%83%9D-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@jelog_131/Spring-Boot-JPA%EC%97%90%EC%84%9C-LOB%ED%95%84%EB%93%9C-%EC%A1%B0%ED%9A%8C-%EC%8B%9C-JpaSystemException-%EB%B0%9C%EC%83%9D-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Tue, 25 Mar 2025 08:35:23 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-발생">문제 발생</h2>
<p>Spring Boot + JPA 환경에서 상품 단건 조회 API를 호출했을 때 다음과 같은 예외가 발생했다.</p>
<pre><code>org.springframework.orm.jpa.JpaSystemException: Unable to access lob stream  
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:341)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:241)</code></pre><ul>
<li>문제가 된 코드</li>
</ul>
<pre><code class="language-java">@Override
public ProductResponseDto findProduct(Long productId) {
    Product product = productRepository.findById(productId)
            .orElseThrow(() -&gt; new GlobalException(ProductErrorCode.PRODUCT_NOT_FOUND));
    return ProductResponseDto.toDto(product);
}</code></pre>
<p>Product 엔티티는 @Lob 타입의 description 필드를 포함하고 있음
ProductPhoto 엔티티와 @OneToMany 관계를 맺고 있으며 이를 조회할 때 @EntityGraph를 사용했음</p>
<h2 id="원인-추론">원인 추론</h2>
<ol>
<li>Lazy 로딩으로 인한 트랜잭션 종료 문제</li>
</ol>
<ul>
<li>@Lob 필드는 기본적으로 Lazy 로딩이다.</li>
<li>@EntityGraph(attributePaths = {&quot;productPhotos.photo&quot;})를 설정했지만, @Lob 필드는 즉시 로딩되지 않음.</li>
<li>트랜잭션 종료 후 description 필드에 접근하려다 보니 JpaSystemException이 발생.</li>
</ul>
<ol start="2">
<li>해당 필드가 LOB 타입이라 직접적인 스트림 접근이 필요함.</li>
</ol>
<ul>
<li>Hibernate는 LOB 필드를 Lazy 로딩할 때 스트림을 통해 접근하는데, 트랜잭션이 끝나면 세션이 닫혀서 접근 불가.</li>
</ul>
<h2 id="해결-방안">해결 방안</h2>
<ol>
<li>@EntityGraph를 사용해 연관 데이터를 즉시 로딩
기존에는 productPhotos.photo만 즉시 로딩되었지만, description 필드는 포함되지 않았다.
따라서 @EntityGraph를 유지하면서 트랜잭션 범위를 확장해야 했다.</li>
</ol>
<pre><code class="language-java">@EntityGraph(attributePaths = {&quot;productPhotos.photo&quot;})
Optional&lt;Product&gt; findProductWithPhotoById(Long id);</code></pre>
<ol start="2">
<li>@Transactional(readOnly = true)로 트랜잭션 범위 확장
트랜잭션이 유지되면 Lazy 로딩 필드(description)에도 안전하게 접근할 수 있다.</li>
</ol>
<pre><code class="language-java">@Override
@Transactional(readOnly = true) // 트랜잭션 유지
public ProductResponseDto findProduct(Long productId) {
    Product product = productRepository.findProductWithPhotoById(productId)
            .orElseThrow(() -&gt; new GlobalException(ProductErrorCode.PRODUCT_NOT_FOUND));
    return ProductResponseDto.toDto(product);
}</code></pre>
<h2 id="결과-확인">결과 확인</h2>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/0334e528-c0d6-4f48-86bf-fabef9fa44e4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jelog_131/post/3da5c6ba-cdf7-492b-98dd-4d7c57f5a43d/image.png" alt=""></p>
<ul>
<li>Product를 조회할 때 productPhotos.photo와 함께 description 필드도 정상적으로 로딩됨.</li>
<li>JpaSystemException이 발생하지 않고, 트랜잭션이 유지된 상태에서 @Lob 필드에 접근 가능.</li>
<li>불필요한 추가 쿼리 없이 한 번의 쿼리로 데이터를 조회할 수 있어 성능도 개선됨.</li>
</ul>
<h4 id="key-point">Key Point</h4>
<ul>
<li>@Lob 필드는 기본적으로 Lazy 로딩되므로, 조회 시 트랜잭션을 유지하는 것이 중요 ! </li>
<li>@EntityGraph를 사용하면 특정 필드를 즉시 로딩할 수 있지만, @Lob 필드는 별도로 고려해야 함 !</li>
<li>트랜잭션이 종료되기 전에 데이터를 모두 조회해야 LazyInitializationException을 방지할 수 있음 !</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kubernetes 유용한 명령어 정리]]></title>
            <link>https://velog.io/@jelog_131/Kubernetes-%EC%9C%A0%EC%9A%A9%ED%95%9C-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jelog_131/Kubernetes-%EC%9C%A0%EC%9A%A9%ED%95%9C-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 24 Mar 2025 09:27:09 GMT</pubDate>
            <description><![CDATA[<table>
<thead>
<tr>
<th><strong>명령어</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>aws eks --region &lt;region&gt; update-kubeconfig --name &lt;cluster-name&gt;</code></td>
<td>EKS 클러스터와 로컬 kubectl을 연결</td>
</tr>
<tr>
<td><code>kubectl config delete-context &lt;old-cluster-context&gt;</code></td>
<td>특정 컨텍스트 삭제</td>
</tr>
<tr>
<td><code>kubectl config delete-cluster &lt;old-cluster&gt;</code></td>
<td>특정 클러스터 삭제</td>
</tr>
<tr>
<td><code>kubectl get nodes</code></td>
<td>클러스터의 노드 상태 확인</td>
</tr>
<tr>
<td><code>kubectl get pods --all-namespaces</code></td>
<td>모든 네임스페이스의 파드 상태 확인</td>
</tr>
<tr>
<td><code>kubectl describe pod &lt;pod-name&gt; -n &lt;namespace&gt;</code></td>
<td>특정 파드의 상태 세부 정보 확인</td>
</tr>
<tr>
<td><code>kubectl logs -f &lt;pod-name&gt; -n &lt;namespace&gt;</code></td>
<td>특정 파드의 실시간 로그 확인</td>
</tr>
<tr>
<td><code>kubectl get deployments -n &lt;namespace&gt;</code></td>
<td>특정 네임스페이스의 디플로이먼트 상태 확인</td>
</tr>
<tr>
<td><code>kubectl get svc -n &lt;namespace&gt;</code></td>
<td>특정 네임스페이스의 서비스 상태 확인</td>
</tr>
<tr>
<td><code>kubectl top nodes</code></td>
<td>노드 리소스 사용량 확인</td>
</tr>
<tr>
<td><code>kubectl top pods -n &lt;namespace&gt;</code></td>
<td>파드 리소스 사용량 확인</td>
</tr>
<tr>
<td><code>kubectl run &lt;pod-name&gt; --image=&lt;image-name&gt; -n &lt;namespace&gt;</code></td>
<td>새로운 파드 실행</td>
</tr>
<tr>
<td><code>kubectl set image deployment/&lt;deployment-name&gt; &lt;container-name&gt;=&lt;new-image&gt; -n &lt;namespace&gt;</code></td>
<td>배포된 애플리케이션 이미지 업데이트</td>
</tr>
<tr>
<td><code>kubectl rollout undo deployment/&lt;deployment-name&gt; -n &lt;namespace&gt;</code></td>
<td>배포 롤백</td>
</tr>
<tr>
<td><code>kubectl delete pod &lt;pod-name&gt; -n &lt;namespace&gt;</code></td>
<td>파드 삭제</td>
</tr>
<tr>
<td><code>kubectl delete svc &lt;service-name&gt; -n &lt;namespace&gt;</code></td>
<td>서비스 삭제</td>
</tr>
<tr>
<td><code>kubectl create namespace &lt;namespace-name&gt;</code></td>
<td>새로운 네임스페이스 생성</td>
</tr>
<tr>
<td><code>kubectl get all -n &lt;namespace&gt;</code></td>
<td>특정 네임스페이스에 대한 모든 리소스 상태 확인</td>
</tr>
<tr>
<td><code>kubectl get events -n &lt;namespace&gt;</code></td>
<td>클러스터의 이벤트 확인</td>
</tr>
<tr>
<td><code>source &lt;(kubectl completion bash)</code></td>
<td>kubectl 명령어 자동완성 설정</td>
</tr>
<tr>
<td>`curl <a href="https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3">https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3</a></td>
<td>bash`</td>
</tr>
<tr>
<td><code>helm install &lt;release-name&gt; &lt;chart-name&gt; -n &lt;namespace&gt;</code></td>
<td>Helm 차트를 이용해 애플리케이션 배포</td>
</tr>
<tr>
<td><code>aws eks --region &lt;region&gt; describe-cluster --name &lt;cluster-name&gt; --query &quot;cluster.identity.oidc.issuer&quot;</code></td>
<td>EKS에서 IAM 역할 연결</td>
</tr>
<tr>
<td><code>kubectl get endpoints -n &lt;namespace&gt;</code></td>
<td>서비스 디스커버리 확인</td>
</tr>
<tr>
<td><code>kubectl get svc &lt;service-name&gt; -n &lt;namespace&gt;</code></td>
<td>서비스의 외부 IP 확인</td>
</tr>
<tr>
<td><code>kubectl apply -f &lt;file-name&gt;.yaml</code></td>
<td>YAML 파일로 리소스를 생성하거나 업데이트</td>
</tr>
<tr>
<td><code>kubectl apply -f &lt;dir-path&gt;/</code></td>
<td>여러 YAML 파일을 한 번에 적용</td>
</tr>
<tr>
<td><code>kubectl get -f &lt;file-name&gt;.yaml</code></td>
<td>YAML 파일로 정의된 리소스의 상태 확인</td>
</tr>
<tr>
<td><code>kubectl delete -f &lt;file-name&gt;.yaml</code></td>
<td>YAML 파일로 정의된 리소스 삭제</td>
</tr>
<tr>
<td><code>kubectl apply -f &lt;file-name&gt;.yaml</code></td>
<td>수정된 YAML 파일을 클러스터에 반영</td>
</tr>
<tr>
<td><code>kubectl get &lt;resource-type&gt; &lt;resource-name&gt; -n &lt;namespace&gt; -o yaml</code></td>
<td>배포된 리소스를 YAML 형식으로 출력</td>
</tr>
<tr>
<td><code>kubectl create -f &lt;file-name&gt;.yaml -o yaml</code></td>
<td>리소스 생성 후 YAML 형식으로 출력</td>
</tr>
<tr>
<td><code>kubectl rollout status deployment/&lt;deployment-name&gt; -n &lt;namespace&gt;</code></td>
<td>배포된 리소스의 상태 확인</td>
</tr>
<tr>
<td><code>kubectl apply -f &lt;scaled-deployment&gt;.yaml</code></td>
<td>리소스의 replica 수를 변경</td>
</tr>
<tr>
<td><code>kubectl logs -f &lt;pod-name&gt; -n &lt;namespace&gt;</code></td>
<td>배포된 파드의 로그 확인</td>
</tr>
<tr>
<td><code>kubectl describe -f &lt;file-name&gt;.yaml</code></td>
<td>YAML 파일로 정의된 리소스의 세부 정보 확인</td>
</tr>
<tr>
<td><code>kubectl diff -f &lt;file-name&gt;.yaml</code></td>
<td>두 YAML 파일 간의 차이점 확인</td>
</tr>
<tr>
<td><code>kubectl apply -f &lt;file-name&gt;.yaml --dry-run=client</code></td>
<td>YAML 파일의 유효성 검증 (실제로 리소스를 생성하지 않음)</td>
</tr>
</tbody></table>
]]></description>
        </item>
    </channel>
</rss>