<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Rockernun.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 24 Aug 2026 04:31:11 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Rockernun.log</title>
            <url>https://velog.velcdn.com/images/rocker_nun/profile/f0c0bd50-74e7-4ccf-a1cf-9f8278565deb/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Rockernun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/rocker_nun" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[최종 프로젝트 인프라 2차 구축]]></title>
            <link>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-2%EC%B0%A8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-2%EC%B0%A8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Mon, 24 Aug 2026 04:31:11 GMT</pubDate>
            <description><![CDATA[<h1 id="🔄-dockerfile부터-cd-자동-배포까지">🔄 Dockerfile부터 CD 자동 배포까지</h1>
<p>백엔드를 이미지로 만들어 서버에 자동으로 올립니다. <code>develop</code> 브랜치에 push하면 WAR 빌드 → 이미지 생성 → Docker Hub 업로드 → API, Batch 서버 배포 → 헬스체크까지 자동으로 돌아갑니다.</p>
<h2 id="📄-전체-흐름">📄 전체 흐름</h2>
<pre><code>1. 개발자가 Github에 push
2. Github Actions가 아래 작업을 진행
    a. WAR 파일 빌드(Maven)
    b. Docker 이미지 빌드
    c. Docker Hub에 push
3. (빌드 성공 시) AWS SSM으로 각 API 서버와 Batch 서버에 배포 명령 전송
    a. API 서버: pull 받은 후 docker compose up -d
    b. Batch 서버: pull 받은 후 docker compose up -d
4. 로드 밸런서가 API 서버로 트래픽 전달
5. 헬스체크 통과
6. 배포 완료</code></pre><p>API 서버와 Batch 서버는 완전히 같은 이미지를 사용합니다. 실행할 때 프로필(<code>api</code> / <code>batch</code>)만 다르게 줍니다. 그래서 이미지는 한 번만 만들면 되고, 두 서버의 버전이 어긋날 일이 없습니다.</p>
<p>&nbsp;</p>
<h2 id="🖋️-dockerfile-작성">🖋️ Dockerfile 작성</h2>
<p>가장 먼저 만든 건 <strong>Dockerfile</strong>입니다. 이게 있어야 이미지가 생기고, 이미지가 있어야 서버에 올릴 컨테이너가 뜹니다. 프로젝트 루트(<code>pom.xml</code>과 같은 위치)에 만들었습니다.</p>
<pre><code class="language-docker">FROM tomcat:9-jdk17-temurin

# 기본 웹앱 제거 (manager, examples 등 불필요 + 보안)
RUN rm -rf /usr/local/tomcat/webapps/*

# WAR를 ROOT로 배포 → 컨텍스트 경로가 &quot;/&quot;
COPY target/*.war /usr/local/tomcat/webapps/ROOT.war

ENV TZ=Asia/Seoul
EXPOSE 8080</code></pre>
<ul>
<li><p><code>FROM tomcat:9-jdk17-temurin</code>: 톰캣 9 버전과 JDK 17이 이미 세팅된 이미지에서 출발합니다. 톰캣을 직접 설치할 필요가 없습니다.</p>
</li>
<li><p><code>rm -rf .../webapps/*</code>: 톰캣 기본 웹앱(manager, examples 등)을 통째로 지웁니다. 사용하지 않기도 하고, 이런 기본 앱들이 열려 있으면 보안 취약점이 되기 때문입니다.</p>
</li>
<li><p><code>COPY target/*.war ... /ROOT.war</code>: 빌드된 WAR를 <code>ROOT.war</code>라는 이름으로 넣습니다. 톰캣은 <code>ROOT.war</code>를 컨텍스트 경로 <code>/</code>로 배포합니다. 즉 <code>/api/...</code>로 바로 접근됩니다.</p>
</li>
<li><p><code>ENV TZ=Asia/Seoul</code>: 컨테이너 시간대를 한국으로 설정해서 로그, 스케줄, DB 시각이 어긋나지 않게 합니다.</p>
</li>
<li><p><code>EXPOSE 8080</code>: 톰캣이 8080에서 듣는다는 표시입니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h3 id="🚫-dockerignore-파일-작성">🚫 .dockerignore 파일 작성</h3>
<p>같은 위치에 <code>.dockerignore</code>를 만들어, 이미지에 들어갈 필요 없는 것들을 빌드 컨텍스트에서 제외했습니다.</p>
<pre><code>.git
.github
.idea
*.iml
sql/
docs/
.env
README.md</code></pre><p>특히 <code>.env</code>를 반드시 제외해야 합니다. 실수로 이미지 안에 비밀값이 구워지면 이미지를 받는 누구나 볼 수 있게 됩니다.</p>
<p>&nbsp;</p>
<h2 id="🔍-로컬-검증">🔍 로컬 검증</h2>
<p>여기가 가장 중요한 지점이었습니다. 이미지가 로컬에서 뜨는 걸 확인하지 않고 바로 Actions에 올리면, 실패했을 때 원인 찾기가 훨씬 어렵습니다. 대부분의 문제(WAR 경로, 톰캣 버전, 컨텍스트 경로)가 여기서 드러나기 때문에, 이 단계를 통과하면 나머지가 수월해집니다.</p>
<pre><code class="language-bash"># 1) WAR 빌드
./mvnw clean package -DskipTests
ls -lh target/*.war # WAR가 실제로 만들어졌는지 확인

# 2) 이미지 빌드
docker build -t nagasseum-backend:test .

# 3) 실행 (로컬 DB, Redis가 떠 있어야 함)
docker run --rm -p 8081:8080 \
  -e JAVA_OPTS=&quot;-Dspring.profiles.active=local,api&quot; \
  -e DB_URL=&quot;jdbc:mysql://host.docker.internal:3306/independence?serverTimezone=Asia/Seoul&quot; \
  -e REDIS_HOST=&quot;host.docker.internal&quot; \
  nagasseum-backend:test</code></pre>
<ul>
<li>포트를 <code>8081:8080</code>으로 매핑한 이유: IntelliJ에서 띄우는 톰캣이 8080을 쓰기 때문에, 로컬 테스트 컨테이너는 8081로 빼서 충돌을 피했습니다.</li>
<li><code>host.docker.internal</code>: 컨테이너 안에서 호스트 PC의 DB/Redis에 접근할 때 쓰는 주소입니다.</li>
</ul>
<pre><code class="language-bash">curl http://localhost:8081/api/v1/members/health</code></pre>
<p>요청을 해보면 이미지가 만들어지고 WAR가 <code>/</code>로 배포되고 있는 것을 확인할 수 있습니다.</p>
<p>&nbsp;</p>
<h2 id="📂-서버용-compose-파일-2개">📂 서버용 compose 파일 2개</h2>
<p>로컬에서 이미지가 검증됐으면, 이제 EC2에서 이 이미지를 어떻게 띄울지를 정의합니다. API 서버용과 Batch 서버용을 나눠 두 개를 만들었습니다.</p>
<h3 id="📃-docker-composeyml-api-2a-api-2c에-배치">📃 docker-compose.yml (api-2a, api-2c에 배치)</h3>
<pre><code class="language-yaml">services:
  api-was:
    image: ${DOCKER_USER}/nagasseum-backend:latest
    container_name: api-was
    restart: always
    ports:
      - &quot;8080:8080&quot;  # ALB가 접근할 포트
    environment:
      JAVA_OPTS: &quot;-Dspring.profiles.active=prod,api -Xmx512m&quot;
      DB_URL: ${DB_URL}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: ${REDIS_HOST}
      REDIS_PORT: 6379
      CODEF_CLIENT_ID: ${CODEF_CLIENT_ID}
      CODEF_CLIENT_SECRET: ${CODEF_CLIENT_SECRET}
      CODEF_PUBLIC_KEY: ${CODEF_PUBLIC_KEY}
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;</code></pre>
<p>&nbsp;</p>
<h3 id="📃-docker-composeyml-batch-2a에-배치">📃 docker-compose.yml (batch-2a에 배치)</h3>
<pre><code class="language-yaml">services:
  batch-was:
    image: ${DOCKER_USER}/nagasseum-backend:latest  # API와 완전히 같은 이미지
    container_name: batch-was
    restart: always
    # ports 없음 — 외부에서 접근할 일이 없음
    environment:
      JAVA_OPTS: &quot;-Dspring.profiles.active=prod,batch -Xmx512m&quot;
      DB_URL: ${DB_URL}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: ${REDIS_HOST}
      REDIS_PORT: 6379
      CODEF_CLIENT_ID: ${CODEF_CLIENT_ID}
      CODEF_CLIENT_SECRET: ${CODEF_CLIENT_SECRET}
      CODEF_PUBLIC_KEY: ${CODEF_PUBLIC_KEY}
      SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;</code></pre>
<p>두 파일의 차이는 딱 세 가지입니다.</p>
<ol>
<li><p><strong>프로필</strong>: <code>prod, api</code>과 <code>prod, batch</code>입니다. 같은 코드가 프로필에 따라 API 서버로도, 배치 실행기로도 동작합니다.</p>
</li>
<li><p><strong>포트 노출</strong>: API는 ALB가 붙어야 하니 <code>8080</code>을 열고, Batch는 외부에서 부를 일이 없어 포트를 안 엽니다.</p>
</li>
<li><p><strong>Slack 웹훅</strong>: 배치 작업 결과 알림용이라 Batch에만 있습니다.</p>
</li>
</ol>
<p>&nbsp;</p>
<h3 id="2️⃣-꼭-챙긴-두-가지-설정">2️⃣ 꼭 챙긴 두 가지 설정</h3>
<ul>
<li><p><code>logging</code> (json-file, max-size 10m, max-file 3): 이걸 안 걸면 도커 로그가 무한정 쌓여 몇 주 만에 디스크를 꽉 채웁니다. 실제로 아주 흔한 장애 원인이라, 최대 10MB * 3개로 순환하도록 제한했습니다.</p>
</li>
<li><p><code>Xmx512m</code> (힙 제한): 서버가 t3.small(메모리 2GB)입니다. JVM은 힙을 명시하지 않으면 호스트 메모리의 약 1/4을 기본값으로 잡는데, 그러면 메모리 예측이 어긋납니다. 512MB로 못 박아 안정적으로 만들었습니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="👤-서버의-env">👤 서버의 .env</h2>
<p>compose 파일의 <code>${DB_URL}</code> 같은 변수들은 각 EC2의 <code>~/nagasseum/.env</code>에서 채워집니다. 이 파일은 git에 절대 올리지 않습니다.</p>
<pre><code class="language-bash">DOCKER_USER=&lt;도커허브_아이디&gt;
DB_URL=jdbc:mysql://&lt;RDS_엔드포인트&gt;:3306/independence?serverTimezone=Asia/Seoul&amp;characterEncoding=UTF-8
DB_USERNAME=admin
DB_PASSWORD=&lt;RDS_마스터_비밀번호&gt;
REDIS_HOST=&lt;ElastiCache_Primary_엔드포인트&gt;
REDIS_PORT=6379
CODEF_CLIENT_ID=...
CODEF_CLIENT_SECRET=...
CODEF_PUBLIC_KEY=...
SLACK_WEBHOOK_URL=...</code></pre>
<blockquote>
<p>위 값들은 예시로 마스킹했습니다. 실제 엔드포인트, 비밀번호는 팀 내부 <code>.env</code>(및 GitHub Secrets)에만 존재합니다.</p>
</blockquote>
<p>여기서 주의할 부분이 있습니다.</p>
<ul>
<li><p><code>REDIS_HOST</code>는 반드시 <strong>Primary 엔드포인트</strong>로 해야 합니다. ElastiCache에는 읽기 전용 Reader 엔드포인트(<code>ro</code>가 붙음)가 따로 있는데, 이걸 넣으면 쓰기 작업이 전부 실패합니다.</p>
</li>
<li><p><code>DB_URL</code>에 <strong><code>characterEncoding=UTF-8</code>을 포함</strong>해야 합니다. 로컬에서 겪었던 한글 깨짐이 RDS에서도 재현될 수 있어서 미리 넣었습니다.</p>
</li>
</ul>
<p>파일 권한도 소유자만 읽도록 제한했습니다.</p>
<pre><code class="language-bash">chmod 600 ~/nagasseum/.env</code></pre>
<p>&nbsp;</p>
<h2 id="⛓️-cd-파이프라인">⛓️ CD 파이프라인</h2>
<p>이제 지금까지 만든 것들(이미지, compose, <code>.env</code>)을 하나로 잇습니다. <code>.github/workflows/cd.yml</code>을 만들어 빌드부터 배포, 검증까지 자동화했습니다.</p>
<pre><code class="language-yaml">name: CD - Build, Push &amp; Deploy

on:
  push:
    branches: [ develop ]
  workflow_dispatch:  # 버튼으로 수동 실행도 가능

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: 소스 체크아웃
        uses: actions/checkout@v4

      - name: JDK 17 설정
        uses: actions/setup-java@v4
        with:
          java-version: &#39;17&#39;
          distribution: &#39;temurin&#39;
          cache: maven

      - name: WAR 빌드
        run: ./mvnw clean package -DskipTests

      - name: 빌드 결과 확인
        run: ls -lh target/*.war

      - name: Docker Hub 로그인
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Buildx 설정
        uses: docker/setup-buildx-action@v3

      - name: 이미지 빌드 &amp; push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/nagasseum-backend:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/nagasseum-backend:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: AWS 자격증명 설정
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: API 서버 배포
        run: |
          aws ssm send-command \
            --targets &quot;Key=tag:Role,Values=api&quot; \
            --document-name &quot;AWS-RunShellScript&quot; \
            --comment &quot;deploy ${{ github.sha }}&quot; \
            --parameters &#39;commands=[&quot;cd /home/ssm-user/nagasseum &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f&quot;]&#39;

      - name: Batch 서버 배포
        run: |
          aws ssm send-command \
            --targets &quot;Key=tag:Role,Values=batch&quot; \
            --document-name &quot;AWS-RunShellScript&quot; \
            --parameters &#39;commands=[&quot;cd /home/ssm-user/nagasseum &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f&quot;]&#39;

      - name: 배포 결과 확인
        run: |
          sleep 60
          curl -f https://api.nagasseum.com/api/v1/members/health
          echo &quot;&quot;
          echo &quot;배포 성공&quot;</code></pre>
<p>&nbsp;</p>
<h3 id="🔨-build-and-push-job-이미지-만들어서-올리기">🔨 build-and-push job: 이미지 만들어서 올리기</h3>
<ul>
<li><p><code>setup-java</code> &amp; <code>cache: maven</code>: JDK 17을 깔고 Maven 의존성을 캐시합니다. 두 번째 실행부터 빌드가 빨라집니다.</p>
</li>
<li><p><code>ls -lh target/*.war</code>: WAR가 실제로 나왔는지 로그에 남겨, 문제가 생겼을 때 빌드 단계인지 이미지 단계인지 바로 구분되게 했습니다.</p>
</li>
<li><p><code>platforms: linux/amd64</code>: Actions 러너는 x86이라 대개 amd64가 나오지만, 이렇게 설정하면 ARM 이미지가 섞여 서버에서 실행이 안 되는 사고를 막습니다.</p>
</li>
<li><p>태그 2개 (<code>latest</code> + <code>${{ github.sha }}</code>): <code>latest</code>는 배포 명령을 단순하게 해주고, 커밋 SHA 태그는 해당 이미지가 정확히 어느 커밋인지를 남겨 롤백 및 추적에 씁니다.</p>
</li>
<li><p><code>cache-from/to: gha</code>: 이미지 레이어를 GitHub Actions 캐시에 저장해 빌드 시간을 줄입니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h3 id="🛫-deploy-job-실제-서버에-반영">🛫 deploy job: 실제 서버에 반영</h3>
<ul>
<li><p><code>needs: build-and-push</code>: 빌드/푸시가 성공해야만 배포가 시작됩니다. 이미지가 없거나 깨진 상태로 서버를 건드리는 일을 원천 차단하는 안전장치입니다.</p>
</li>
<li><p>SSH가 아니라 SSM으로 배포: 이 선택이 핵심인데, 이유는 아래에서 따로 설명합니다.</p>
</li>
<li><p>배포 명령 <code>cd ... &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f</code></p>
<ul>
<li><code>pull</code>: 방금 올린 최신 이미지를 서버로 내려받고</li>
<li><code>up -d</code>: 새 이미지로 컨테이너를 교체하고</li>
<li><code>image prune -f</code>: 교체되고 떠도는 옛 이미지를 정리해 디스크를 아낍니다.</li>
</ul>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="🤔-왜-ssh가-아니라-ssm인가">🤔 왜 SSH가 아니라 SSM인가</h2>
<p>배포 방식으로 SSH 대신 <strong>AWS SSM(Systems Manager)</strong> <code>send-command</code>를 골랐습니다. 이유는 세 가지입니다.</p>
<ul>
<li><p><strong>접속 키가 필요 없습니다.</strong> SSH라면 GitHub Actions에 서버 개인키를 넣어둬야 합니다. SSM은 IAM 권한으로 명령을 보내므로 키를 보관 및 유출할 위험이 없고, 서버의 22번 포트를 열어둘 필요도 없습니다.</p>
</li>
<li><p><strong>태그로 대상을 지정할 수 있습니다.</strong> <code>Key=tag:Role,Values=api</code>는 Role 태그가 api인 인스턴스 전부라는 뜻입니다. IP를 하드코딩하지 않으니, 서버를 교체하거나 늘려도 태그만 맞으면 워크플로 수정 없이 배포됩니다. 따라서 API 서버가 <code>api-2a</code>, <code>api-2c</code> 둘인데도 명령 한 번으로 둘 다 처리할 수 있습니다.</p>
</li>
<li><p><strong>실행 기록이 남는다.</strong> 보낸 명령과 그 출력이 Systems Manager 콘솔의 실행 기록(Run Command)에 남아, 서버에 들어가지 않고도 배포 실패 원인을 확인할 수 있습니다.</p>
</li>
</ul>
<p>여기서 또 주의할 점이 있었는데, SSM <code>send-command</code>의 <code>commands</code> 배열은 각 항목이 서로 독립된 셸에서 실행된다는 점이었습니다. 그래서 아래와 같이 나눠 쓰면 안 됩니다.</p>
<pre><code class="language-json">// 잘못된 예 - cd가 다음 줄에 적용되지 않는다
&#39;commands=[
  &quot;cd /home/ssm-user/nagasseum&quot;,
  &quot;docker compose pull&quot;,  // ← 홈 디렉터리에서 실행됨 → compose 파일 못 찾음
  &quot;docker compose up -d&quot;
]&#39;</code></pre>
<p><code>cd</code>를 따로 두면, 다음 명령은 <strong>이동이 사라진 새 셸</strong>에서 실행돼 compose 파일을 못 찾고 배포가 실패합니다. 그래서 <code>&amp;&amp;</code>로 <strong>한 줄에 이어 붙여</strong> 같은 셸에서 순서대로 돌게 했습니다.</p>
<pre><code class="language-json">// 올바른 예 — 하나의 셸에서 디렉터리가 유지된 채 순차 실행
&#39;commands=[&quot;cd /home/ssm-user/nagasseum &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f&quot;]&#39;</code></pre>
<p>덤으로 <code>&amp;&amp;</code>는 앞 명령이 성공했을 때만 다음으로 넘어가므로, <code>pull</code>이 실패하면 <code>up -d</code>까지 가지 않고 멈추는 안전장치도 됩니다.</p>
<p>&nbsp;</p>
<h2 id="💊-헬스체크">💊 헬스체크</h2>
<pre><code class="language-yaml">- name: 배포 결과 확인
  run: |
    sleep 60
    curl -f https://api.nagasseum.com/api/v1/members/health
    echo &quot;&quot;
    echo &quot;배포 성공&quot;</code></pre>
<ul>
<li><p><code>sleep 60</code>: 애플리케이션이 완전히 기동할 시간을 줍니다. 뜨자마자 때리면 아직 준비 안 된 서버를 실패로 오판할 수 있습니다.</p>
</li>
<li><p><code>curl -f</code>: <code>f</code>(fail)가 핵심입니다. HTTP 4xx/5xx가 오면 curl이 실패 코드로 끝나고, 그러면 이 스텝도 실패합니다. 즉 헬스체크가 통과하지 못하면 파이프라인이 초록불이 되지 않습니다. 배포는 됐는데 서버가 죽어 있는 상태를 놓치지 않게 해줍니다.</p>
</li>
</ul>
<p>이 요청은 <code>https://api.nagasseum.com</code>(ALB) → API 서버 컨테이너로 흐르니, 헬스체크가 통과했다는 건 <strong>ALB, 서버, 컨테이너, DB 경로가 다 살아있다</strong>는 뜻이기도 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[최종 프로젝트 인프라 1차 구축]]></title>
            <link>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-1%EC%B0%A8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-1%EC%B0%A8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Wed, 19 Aug 2026 06:16:01 GMT</pubDate>
            <description><![CDATA[<h1 id="🗺️-vpc-생성">🗺️ VPC 생성</h1>
<p><strong>VPC(Virtual Private Cloud)</strong>는 AWS 안에 만드는 논리적으로 격리된 사설 네트워크입니다. 이 안의 리소스들은 하나의 네트워크에 속하고, 밖에서는 우리가 허용한 경로로만 접근할 수 있습니다. EC2나 RDS 같은 리소스는 반드시 어떤 VPC에 속해야 생성됩니다. 그리고 VPC 단위로 IP 대역을 갖기 때문에, 내부 통신이 사설 IP로 이루어져 인터넷을 거치지 않습니다.</p>
<h2 id="⚙️-나갔음청년-인프라-설정">⚙️ 나갔음청년 인프라 설정</h2>
<pre><code>이름: nagasseum-vpc
CIDR: 10.0.0.0/16 → 사용 가능 IP 약 65,536개
IPv6: 없음
테넌시: 기본값
DNS 호스트 이름: 활성화</code></pre><p>CIDR을 <code>/16</code>으로 잡은 이유는 서브넷을 여러 개 나눠야 하고, 나중에 확장할 여지를 두기 위함입니다. <code>/16</code>이면 <code>/24</code> 서브넷을 256개까지 만들 수 있어 충분합니다. 그리고 DNS 호스트 이름을 활성화했습니다. RDS와 ElastiCache는 접속 주소를 도메인 형태로 제공하는데(<code>nagasseum-db.xxxx.rds.amazonaws.com</code>), 이 옵션이 꺼져 있으면 EC2가 그 도메인을 IP로 변환하지 못해 DB 연결이 실패합니다. 나중에 원인을 찾기 어려운 문제라 처음에 켜두었습니다.</p>
<p>&nbsp;</p>
<h1 id="🚧-서브넷-6개-생성">🚧 서브넷 6개 생성</h1>
<p><strong>서브넷(Subnet)</strong>은 VPC의 IP 대역을 쪼갠 구획입니다. 리소스는 서브넷 단위로 배치되고, 서브넷마다 라우팅 규칙을 다르게 줄 수 있습니다. 서브넷을 6개 생성하기로 결정한 이유는 우리 인프라 구조를 3계층, 가용영역 2개로 결정했기 때문입니다. </p>
<p>여기서 계층을 나눈 것은 인터넷에서 직접 닿을 수 있는 범위를 최소화하기 위함입니다. 인프라 멘토링에서 *&quot;서버가 퍼블릭 서브넷에 있을 필요가 없다&quot;, &quot;DB를 서비스와 같이 띄우지 말라&quot;* 는 지적을 받은 데 대한 대응입니다. 가용영역을 2개로 나눈 것은 데이터센터 하나가 통째로 장애가 나도 서비스가 이어지도록 하기 위함입니다. 또한 ALB와 RDS 서브넷 그룹은 최소 2개 AZ를 요구하므로 기술적 필수 요건이기도 합니다.</p>
<h2 id="⚙️-서브넷-설정">⚙️ 서브넷 설정</h2>
<table>
<thead>
<tr>
<th>이름</th>
<th>AZ</th>
<th>CIDR</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>nagasseum-public-2a</td>
<td>2a</td>
<td>10.0.1.0/24</td>
<td>NAT, ALB</td>
</tr>
<tr>
<td>nagasseum-public-2c</td>
<td>2c</td>
<td>10.0.2.0/24</td>
<td>NAT, ALB</td>
</tr>
<tr>
<td>nagasseum-app-2a</td>
<td>2a</td>
<td>10.0.11.0/24</td>
<td>API #1, Batch</td>
</tr>
<tr>
<td>nagasseum-app-2c</td>
<td>2c</td>
<td>10.0.12.0/24</td>
<td>API #2</td>
</tr>
<tr>
<td>nagasseum-data-2a</td>
<td>2a</td>
<td>10.0.21.0/24</td>
<td>RDS, Redis</td>
</tr>
<tr>
<td>nagasseum-data-2c</td>
<td>2c</td>
<td>10.0.22.0/24</td>
<td>RDS 대기, Redis 복제본</td>
</tr>
</tbody></table>
<p>세 번째 옥텟을 1, 11, 21로 나눈 이유는 십의 자리로 계층을 구분하면 IP만 보고 어느 계층인지 즉시 알 수 있습니다. 실제로 검증 단계에서 RDS가 <code>10.0.22.241</code>로 해석되는 것을 보고 Data 계층에 제대로 들어갔다는 것을 바로 확인할 수 있었습니다. 각 계층에 9개까지 여유가 있어 확장에도 대비됩니다.</p>
<p>그리고 NAT 인스턴스가 공인 IP를 받아야 하기 때문에 퍼블릭 서브넷 2개에만 퍼블릭 IPv4 주소 자동 할당을 활성화했습니다. App, Data는 비활성화하여 공인 IP 자체가 없는 상태를 만들었습니다.</p>
<p>엄밀히 말하면 이 시점에는 6개 모두 사실상 프라이빗입니다. 이름만 퍼블릭일 뿐, 라우팅 테이블에 IGW 경로가 들어가야 비로소 퍼블릭이 됩니다.</p>
<p>&nbsp;</p>
<h1 id="📝-internet-gateway--라우팅-테이블-설정">📝 Internet Gateway &amp; 라우팅 테이블 설정</h1>
<p><strong>IGW(Internet Gateway)</strong>는 VPC와 인터넷을 잇는 관문입니다. VPC에 하나만 붙일 수 있고, 이게 없으면 어떤 리소스도 인터넷과 통신할 수 없습니다. <strong>라우팅 테이블(Routing Table)</strong>은 특정 목적지로 가는 트래픽은 어디로 보낼지를 정의한 이정표입니다. 서브넷마다 하나씩 연결되며, 여기 무엇이 적혀 있느냐가 그 서브넷의 성격을 결정합니다.</p>
<h2 id="⚙️-라우팅-테이블-설정">⚙️ 라우팅 테이블 설정</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ca76377b-d588-494b-8baa-67234e21fdd2/image.png" alt=""></p>
<p><strong>퍼블릭 라우팅 테이블 (1개)</strong></p>
<pre><code>라우팅 테이블 이름: nagasseum-routing-table-public
10.0.0.0/16 → local (VPC 내부 통신)
0.0.0.0/0 → IGW ← 이것이 퍼블릭의 정의
연결: public-2a, public-2c</code></pre><p>&nbsp;</p>
<p><strong>프라이빗 라우팅 테이블 (2개, AZ별)</strong></p>
<pre><code>라우팅 테이블 이름: nagasseum-routing-table-private-2a
10.0.0.0/16 → local
0.0.0.0/0 → NAT-2a (아웃바운드 전용)
pl-xxxx(S3) → VPC Endpoint
연결: app-2a, data-2a

라우팅 테이블 이름: nagasseum-routing-table-private-2c
0.0.0.0/0 → NAT-2c
연결: app-2c, data-2c</code></pre><p>&nbsp;</p>
<p>프라이빗을 AZ별로 나눈 이유가 핵심입니다. 하나로 합치면 두 AZ가 같은 NAT를 쓰게 되어 AZ 간 데이터 전송 요금이 발생하고, 그 NAT가 있는 AZ가 죽으면 반대편 AZ까지 인터넷이 끊겨 이중화의 의미가 사라집니다.</p>
<p>검증 결과, api-2a는 공인 IP <code>54.180.xxx.xxx</code>, api-2c는 <code>52.79.xxx.xxx</code>로 나갔습니다. 서로 다른 IP라는 것이 각 AZ가 자기 NAT를 쓰고 있다는 증거입니다.</p>
<p>&nbsp;</p>
<h1 id="🚥-nat-instance-2대-생성">🚥 NAT Instance 2대 생성</h1>
<p><strong>NAT Instance</strong>는 프라이빗 서브넷의 리소스가 인터넷으로 나갈 수 있게 해주는 일종의 중계 장치입니다. 자기 공인 IP를 빌려주고, 응답이 오면 원래 요청자에게 돌려줍니다.</p>
<p>우리 백엔드는 공인 IP가 없어 인터넷과 직접 통신할 수 없습니다. 그런데 밖으로 나갈 요청은 많습니다.</p>
<pre><code>Batch 서버 → CODEF, 국토부 실거래가 API
API 서버 → 카카오 로그인, CODEF 수동 동기화
전체 → SSM 에이전트가 AWS 엔드포인트에 등록</code></pre><p>NAT는 안에서 밖으로 나가는 요청만 처리합니다. 밖에서 안으로 들어오는 것은 불가능합니다. 그래서 외부 API는 호출할 수 있지만, 외부에서 우리 백엔드로 접속할 수는 없기 때문에 보안을 유지될 수 있습니다.</p>
<h2 id="🤔-nat-gateway가-아닌-instance를-택한-이유">🤔 NAT Gateway가 아닌 Instance를 택한 이유</h2>
<table>
<thead>
<tr>
<th></th>
<th>NAT Gateway 2대</th>
<th>NAT Instance 2대</th>
</tr>
</thead>
<tbody><tr>
<td>월 비용</td>
<td>~$90</td>
<td><strong>~$8</strong></td>
</tr>
<tr>
<td>데이터 처리 요금</td>
<td>GB당 $0.059</td>
<td>없음</td>
</tr>
<tr>
<td>관리</td>
<td>완전 관리형</td>
<td>직접 설정, 복구</td>
</tr>
<tr>
<td>자동 복구</td>
<td>있음</td>
<td>없음</td>
</tr>
</tbody></table>
<p>NAT Instance로 전환하면 NAT Gateway를 사용할 때보다 약 월 80달러를 절감할 수 있었습니다. 우리 아웃바운드 트래픽은 배치의 외부 API 호출 정도로 적고, NAT가 죽어도 서비스 전체가 아니라 외부 호출만 막히므로 감수할 만한 트레이드오프라고 판단했습니다.</p>
<h2 id="⚙️-nat-인스턴스-설정">⚙️ NAT 인스턴스 설정</h2>
<pre><code>이름: nagasseum-nat-2a, nagasseum-nat-2c
AMI: Amazon Linux 2023 (ARM64)
인스턴스 유형: t4g.nano
서브넷: 각 AZ의 Public Subnet
퍼블릭 IP: 활성화
보안그룹: 모든 트래픽 ← 10.0.0.0/16 ← VPC 내부 요청을 받아야 함
소스/대상 확인 중지 </code></pre><p>소스/대상 확인 중지가 반드시 필요합니다. EC2는 기본적으로 출발지나 목적지가 자기 IP가 아닌 패킷을 폐기합니다. NAT는 남의 패킷을 대신 전달하는 역할이므로 이 검사를 꺼야 합니다. 이 설정을 빼먹으면 NAT는 동작하지 않습니다.</p>
<p><strong>OS 레벨 설정 2가지</strong></p>
<pre><code class="language-bash"># ① IP 포워딩 — 패킷을 다른 인터페이스로 넘길 수 있게
net.ipv4.ip_forward = 1

# ② MASQUERADE — 출발지 IP를 NAT 자신의 IP로 바꿔치기
iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE</code></pre>
<p>&nbsp;</p>
<h1 id="↩️-vpc-gateway-endpoint-s3">↩️ VPC Gateway Endpoint (S3)</h1>
<p><strong>VPC Gateway Endpoint</strong>는 VPC 안에서 특정 AWS 서비스로 가는 전용 통로를 여는 기능입니다. Gateway 유형은 S3와 DynamoDB만 지원하며 요금이 없습니다.</p>
<p>S3는 우리 VPC 안에 있는 리소스가 아니라 AWS가 운영하는 공용 서비스입니다. 주소도 인터넷 도메인(<code>s3.ap-northeast-2.amazonaws.com</code>)이라, 기본적으로는 NAT를 거쳐 인터넷으로 나가야 접근 가능합니다.</p>
<pre><code>[기존] 백업 서버 → NAT → 인터넷 → S3 (NAT 데이터 처리 요금 발생)
[변경] 백업 서버 → Gateway Endpoint → S3 (무료, AWS 내부망)</code></pre><p>비용 절감보다는 보안 이점이 큽니다. 우리 DB 백업 파일에는 회원 정보와 암호화된 Connected ID가 들어 있는데, 이 트래픽이 공용 인터넷을 거치지 않고 AWS 내부망에만 머뭅니다.</p>
<h2 id="⚙️-gateway-설정">⚙️ Gateway 설정</h2>
<pre><code>서비스: com.amazonaws.ap-northeast-2.s3 (Gateway 유형)
VPC: nagasseum-vpc
라우팅: private-2a, private-2c ← 프라이빗만</code></pre><p>생성하면 해당 라우팅 테이블에 <code>pl-</code>로 시작하는 prefix list 항목이 자동 추가됩니다. AWS가 S3의 IP 대역을 관리해주므로 우리가 IP를 직접 적을 필요가 없습니다.</p>
<p>&nbsp;</p>
<h1 id="🛡️-보안그룹-4종-추가">🛡️ 보안그룹 4종 추가</h1>
<p><strong>보안그룹(Security Group)</strong>은 인스턴스 단위의 가상 방화벽입니다. 어디서 오는 무슨 포트를 허용할지를 정의합니다. 상태 저장(stateful) 방식이라, 아웃바운드를 허용하면 그 응답은 자동으로 들어옵니다.</p>
<h2 id="⚙️-보안그룹-설정">⚙️ 보안그룹 설정</h2>
<pre><code>security-group-alb 인바운드: 80, 443 ← 0.0.0.0/0 (인터넷 전체)
security-group-app 인바운드: 8080 ← security-group-alb (ALB에서만)
security-group-rds 인바운드: 3306 ← security-group-app (App 계층에서만)
security-group-redis 인바운드: 6379 ← security-group-app (App 계층에서만)</code></pre><p>AWS 리소스에 IP 대역이 아니라 <strong>다른 보안그룹 ID</strong>를 지정했습니다. 이 방식의 이점이 세 가지입니다.</p>
<ol>
<li><p><strong>인스턴스 IP가 바뀌어도 규칙이 유효합니다.</strong> EC2를 재생성하거나 오토스케일링으로 늘려도 그 보안그룹에 속하기만 하면 접근이 허용됩니다.</p>
</li>
<li><p><strong>의도가 코드처럼 읽힙니다.</strong> *&quot;10.0.11.0/24에서 3306 허용&quot;* 보다 *&quot;App 계층에서 3306 허용&quot;* 이 명확합니다.</p>
</li>
<li><p><strong>우회가 구조적으로 불가능합니다.</strong> 누군가 프라이빗 서브넷에 인스턴스를 만들어도, <code>sg-app</code>을 붙이지 않으면 DB에 접근할 수 없습니다.</p>
</li>
</ol>
<p>만드는 순서에 주의가 필요했습니다. <code>sg-app</code>이 <code>sg-alb</code>를 참조하려면 <code>sg-alb</code>가 먼저 존재해야 하므로, <strong>빈 보안그룹 4개를 먼저 만들고 규칙을 나중에 채우는</strong> 방식을 썼습니다. 그리고 SSH(22)는 어디에도 열지 않았습니다. SSM Session Manager를 쓰기로 했기 때문입니다. 이는 CIS AWS Foundations Benchmark의 네트워킹 권고와도 일치합니다.</p>
<p>&nbsp;</p>
<h1 id="💾-rds-mysql-생성">💾 RDS (MySQL) 생성</h1>
<p>인프라 리뷰에서 *&quot;DB를 서비스와 같이 띄우지 마라, 장애의 흔한 원인&quot;* 이라는 지적을 받았습니다. RDS를 구축해서 관리형으로 전환한 근거는 세 가지입니다.</p>
<ul>
<li><p><strong>장애 격리:</strong> 백엔드와 같은 인스턴스에 있으면, 백엔드가 메모리를 다 쓸 때 DB도 죽고 재배포로 재시작하면 DB도 함께 내려갑니다. 한쪽 문제가 다른 쪽으로 전파되는 구조를 끊었습니다.</p>
</li>
<li><p><strong>복구 시간:</strong> 직접 운영하면 장애 시 사람이 인스턴스를 살리고 컨테이너를 다시 띄워야 합니다. 새벽에 죽으면 아침까지 서비스가 멈춥니다. Multi-AZ는 1~2분 안에 대기 인스턴스가 자동 승격합니다.</p>
</li>
<li><p><strong>백업과 복구 지점:</strong> 직접 하면 mysqldump 스크립트 / cron / S3 업로드를 다 만들어야 하고, 하루 한 번이면 최대 24시간치를 잃습니다. RDS는 자동 백업에 더해 특정 시점 복구(PITR)를 지원합니다.</p>
</li>
</ul>
<h2 id="⚙️-rds-설정">⚙️ RDS 설정</h2>
<pre><code>엔진: MySQL 8.0
인스턴스: db.t3.micro
가용성: 다중 AZ DB 인스턴스 ← 동기 복제
스토리지: gp3 20GB (자동 조정 비활성화)
서브넷 그룹: data-2a, data-2c
퍼블릭 액세스: 아니요 
보안그룹: security-group-rds
초기 DB 이름: independence
자동 백업: 7일</code></pre><p>퍼블릭 액세스를 *&quot;아니요&quot;* 로 한 것이 프라이빗 배치의 핵심입니다. 이 설정이 *&quot;예&quot;* 면 프라이빗 서브넷에 있어도 인터넷에서 접근 가능한 엔드포인트가 생깁니다.</p>
<p>초기 데이터베이스 이름을 반드시 입<strong>력</strong>해야 합니다. 비워두면 인스턴스만 생기고 데이터베이스가 없어서 나중에 직접 <code>CREATE DATABASE</code>를 해야 합니다.</p>
<pre><code>인스턴스 (Multi-AZ)   ~$36     ← Single-AZ 대비 2배
스토리지 (×2)          ~$5
백업                  ~$2
────────────────────────────
합계                  ~$43/월</code></pre><p>&nbsp;</p>
<h1 id="💽-elasticache-redis-생성">💽 ElastiCache (Redis) 생성</h1>
<p>API 서버가 2대로 늘면서 프로세스 간에 공유해야 하는 상태가 생겼습니다. 자바 메모리는 프로세스별로 격리되어 있어 쓸 수 없습니다.</p>
<ol>
<li><p><strong>CODEF 액세스 토큰:</strong> 액세스 토큰의 ****유효기간은 7일입니다. 서버마다 따로 발급받으면 낭비이고 발급 횟수 제한도 있습니다.</p>
</li>
<li><p><strong>분산 락:</strong> 자산 연동 시 Connected ID 중복 발급을 막습니다. <code>synchronized</code>는 한 JVM 안에서만 유효해 다른 서버의 요청을 막지 못합니다. 대체 불가능한 용도입니다.</p>
</li>
<li><p><strong>비동기 작업 상태:</strong> 자산 동기화를 비동기로 전환하면서, 클라이언트가 폴링으로 진행 상태를 조회합니다. ALB가 요청을 분산하므로 폴링이 다른 서버로 갈 수 있어 공유 저장소가 필수입니다.</p>
</li>
<li><p><strong>집계 캐시:</strong> 또래 비교 집계는 매월 1일 기준으로 고정된 값이라 재사용이 자연스럽습니다.</p>
</li>
</ol>
<h2 id="⚙️-elasticache-설정">⚙️ ElastiCache 설정</h2>
<pre><code>엔진: Redis 7.1
클러스터 모드: 비활성화 ← 단순 구성
노드 유형: cache.t3.micro
복제본: 1개
다중 AZ: 활성화 ← 자동 장애 조치
저장 중 암호화: 활성화 (기본 키)
전송 중 암호화: 비활성화 ← VPC 내부 통신
자동 백업: 비활성화 ← 캐시라 불필요
유지 관리: 일요일 21:00 UTC ← 새벽 배치와 회피</code></pre><p>클러스터 모드를 활성화하면 샤딩 구조가 되어 Lettuce 클라이언트 설정이 복잡해집니다. 우리 용도에는 단일 노드 + 복제본이면 충분합니다.</p>
<p>자동 백업 옵션은 비활성화했습니다. Redis에 저장하는 것은 토큰 / 락 / 캐시 / 작업상태로, 없어져도 다시 만들 수 있는 데이터입니다. 백업 스토리지 요금만 나가고 실익이 없습니다. Redis에서 복제본을 두는 이유는 데이터 보존이 아니라 <strong>가용성</strong>입니다. Redis가 죽으면 데이터를 잃는 게 아니라 기능이 멈춥니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5efa9804-8cba-480d-9c4c-27dc5ac02498/image.png" alt=""></p>
<p>주의할 점은 엔드포인트가 Primary와 Reader 두 개라는 점입니다. 우리 용도는 전부 쓰기 작업이므로 <strong>반드시 Primary</strong>를 써야 합니다. Reader로 설정하면 <code>SET</code>, <code>SETNX</code>가 모두 실패합니다.</p>
<p>&nbsp;</p>
<h1 id="🦾-ec2-3대--iam-역할">🦾 EC2 3대 &amp; IAM 역할</h1>
<h2 id="⚙️-인스턴스-구성">⚙️ 인스턴스 구성</h2>
<table>
<thead>
<tr>
<th>이름</th>
<th>유형</th>
<th>서브넷</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>nagasseum-api-2a</td>
<td>t3.small</td>
<td>app-2a</td>
<td>Tomcat (api 프로필)</td>
</tr>
<tr>
<td>nagasseum-api-2c</td>
<td>t3.small</td>
<td>app-2c</td>
<td>Tomcat (api 프로필)</td>
</tr>
<tr>
<td>nagasseum-batch-2a</td>
<td>t3.micro</td>
<td>app-2a</td>
<td>Tomcat (batch 프로필)</td>
</tr>
</tbody></table>
<p>API가 t3.small인 이유는 3계층 전환으로 MySQL, Redis가 빠져 EC2가 가벼워졌지만, 톰캣 JVM이 550MB 정도를 쓰고 OS 및 Docker를 합치면 830MB입니다. t3.micro(1GB)는 여유가 170MB뿐이라 GC나 요청 집중 시 위험합니다. Batch가 t3.micro인 이유는 사용자 요청을 받지 않고 새벽에만 동작하므로 부하가 낮습니다.</p>
<p>세 대 모두 프라이빗 서브넷에 배치했기 때문에 퍼블릭 IP는 없습니다. </p>
<h2 id="🤔-ssm-session-manager를-택한-이유">🤔 SSM Session Manager를 택한 이유</h2>
<p>Bastion Host 대신 SSM을 사용했을 때의 이점에는 4가지 정도가 있었습니다.</p>
<ol>
<li><strong>SSH 22번을 아예 열지 않아도 됩니다.</strong> 공인 IP를 가진 서버에 SSH 포트가 열려 있으면 무차별 로그인 시도의 표적이 됩니다.</li>
<li><strong>키 관리가 사라집니다.</strong> 개인키 파일을 Bastion Host에 복사하면, Bastion Host 뚫렸을 때 프라이빗 서버까지 함께 뚫립니다.</li>
<li><strong>EC2 한 대 값이 절약됩니다.</strong> 베스천도 인스턴스라 요금이 나가지만 SSM은 무료입니다.</li>
<li><strong>접속 기록이 남습니다.</strong> IAM 권한으로 접근을 통제하고 CloudTrail에 로그가 남습니다.</li>
</ol>
<h2 id="👤-iam-역할">👤 IAM 역할</h2>
<pre><code>역할명: nagasseum-ec2-ssm-role
정책: AmazonSSMManagedInstanceCore (SSM 접속), AmazonS3FullAccess (백업용, 추후 축소 예정)
신뢰 관계: ec2.amazonaws.com</code></pre><p><code>AmazonS3FullAccess</code>는 권한 범위가 넓어 정석은 아닙니다. 백업 버킷만 접근하는 커스텀 정책으로 좁히는 것이 맞으며, 개선 예정입니다.</p>
<p>&nbsp;</p>
<h1 id="💥-트러블슈팅-기록">💥 트러블슈팅 기록</h1>
<h2 id="🚫-ssm-접속-불가-→-iam-역할-미연결">🚫 SSM 접속 불가 → IAM 역할 미연결</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/74306191-efbf-498c-979e-ae2dd08bdbf7/image.png" alt=""></p>
<pre><code>SSM Agent unable to acquire credentials
Systems Manager&#39;s instance management role is not configured</code></pre><p>원인은 인스턴스 생성 시 IAM 인스턴스 프로파일을 지정하지 않아 발생하는 문제였습니다. SSM 에이전트는 IAM 역할로 AWS API에 인증하는데, 역할이 없으니 자격증명을 얻지 못했습니다.</p>
<p>따라서 <code>작업 → 보안 → IAM 역할 수정</code>으로 역할을 연결하고 인스턴스를 재부팅해서 해결했습니다. 에이전트가 자격증명 획득에 실패하면 재시도 간격을 늘리므로, 역할을 나중에 붙이면 즉시 반영되지 않습니다.</p>
<p>&nbsp;</p>
<h2 id="⛓️💥-nat-미동작-→-사용자-데이터-실행-순서-오류">⛓️‍💥 NAT 미동작 → 사용자 데이터 실행 순서 오류</h2>
<p>프라이빗 서브넷의 EC2가 SSM에 등록되지 않고, 외부 인터넷도 되지 않았습니다.</p>
<pre><code>1. SSM 접속 불가 → IAM 역할은 붙어 있음
2. 프라이빗 서버가 AWS 엔드포인트에 닿지 못한다고 추정 → NAT 의심
3. NAT 인스턴스에 접속(퍼블릭이라 접속 가능)해 상태 확인
      cat /proc/sys/net/ipv4/ip_forward → 1 (정상)
      iptables -t nat -L POSTROUTING -n → 비어 있음 (여기가 문제!!!)
4. cloud-init 로그 확인
      /var/log/cloud-init-output.log</code></pre><p>원인은 사용자 데이터 스크립트에서 <code>iptables</code> 명령을 먼저 실행하고 패키지 설치를 나중에 배치해서 발생한 문제였습니다. Amazon Linux 2023에는 <code>iptables</code>가 기본 설치되어 있지 않아 명령이 실패했고, 그 결과 MASQUERADE 규칙이 누락되었습니다. 따라서 IP 포워딩만 켜진 상태였으므로 패킷은 나가지만 출발지 IP가 사설 IP라 응답이 돌아오지 못하는 상황이었습니다.</p>
<pre><code>part-001: line 8: iptables: command not found
part-001: line 9: iptables: command not found</code></pre><p>아래와 같이 명령어를 입력하면 됩니다.</p>
<pre><code class="language-bash">IFACE=$(ip -o -4 route show to default | awk &#39;{print $5}&#39;)
sudo iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
sudo iptables -F FORWARD
sudo service iptables save # 재부팅 후에도 유지
sudo systemctl enable iptables</code></pre>
<p>따라서 사용자 데이터는 의존성 설치를 먼저 배치해야 합니다. 그리고 실패해도 인스턴스는 정상 기동하므로, <code>/var/log/cloud-init-output.log</code><strong>를</strong> 확인하는 습관이 필요합니다. 증상과 원인의 거리가 멀었다는 점도 기록해 둘 만합니다. *&quot;SSM 접속 불가&quot;* 라는 증상에서 *&quot;사용자 데이터의 명령 실행 순서&quot;* 라는 근본 원인까지, 계층을 세 번 내려가야 했습니다.</p>
<p>&nbsp;</p>
<h1 id="🎉-최종-검증-결과">🎉 최종 검증 결과</h1>
<p><code>nagasseum-api-2a</code>에서 SSM으로 접속해 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d582578b-b604-436b-a38b-0b6bc6e95aea/image.png" alt=""></p>
<pre><code class="language-bash">$ curl -I https://oauth.codef.io
HTTP/1.1 200 ...
TRUEIP: 54.180.xxx.xxx ← NAT-2a 경유 확인

$ docker --version
Docker version 29.1.3

$ nc -zv nagasseum-db.xxxx.rds.amazonaws.com 3306
Connection to ... (10.0.22.xxx) 3306 succeeded! ← Data 서브넷

$ nc -zv nagasseum-cache.xxxx.cache.amazonaws.com 6379
Connection to ... (10.0.22.xxx) 6379 succeeded! ← Data 서브넷</code></pre>
<p><code>nagasseum-api-2c</code>에서도 확인했습니다.</p>
<pre><code class="language-bash">$ curl -I https://oauth.codef.io
TRUEIP: 52.79.xxx.xxx ← NAT-2c 경유 (다른 IP)</code></pre>
<p>여기서 중요한 점은 공인 IP가 서로 다르다는 것입니다. ****각 AZ가 자기 NAT를 통해 나가고 있습니다. AZ별 라우팅 테이블 분리가 실제로 동작하며, AZ 간 데이터 전송 요금이 발생하지 않고 한쪽 NAT 장애가 반대편에 영향을 주지 않습니다.</p>
<p>&nbsp;</p>
<h1 id="🏗️-1차-인프라-작업-완료-후-구조">🏗️ 1차 인프라 작업 완료 후 구조</h1>
<pre><code>Region: ap-northeast-2 (서울)
└── VPC: nagasseum-vpc (10.0.0.0/16)
    │
    ├── Public Subnet: 10.0.1.0/24 (2a), 10.0.2.0/24 (2c)
    │     ├── NAT Instance 2대 (t4g.nano)
    │     └── [예정] ALB
    │
    ├── Private App: 10.0.11.0/24 (2a), 10.0.12.0/24 (2c)
    │     ├── nagasseum-api-2a (t3.small)
    │     ├── nagasseum-api-2c (t3.small)
    │     └── nagasseum-batch-2a (t3.micro)
    │
    └── Private Data: 10.0.21.0/24 (2a), 10.0.22.0/24 (2c)
          ├── RDS MySQL 8.0 Multi-AZ → 10.0.22.241
          └── ElastiCache Redis 7.1 복제 → 10.0.22.195</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[몬테카를로로 주택 목표 달성 확률 예측하기]]></title>
            <link>https://velog.io/@rocker_nun/%EB%AA%AC%ED%85%8C%EC%B9%B4%EB%A5%BC%EB%A1%9C%EB%A1%9C-%EC%A3%BC%ED%83%9D-%EB%AA%A9%ED%91%9C-%EB%8B%AC%EC%84%B1-%ED%99%95%EB%A5%A0-%EC%98%88%EC%B8%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/%EB%AA%AC%ED%85%8C%EC%B9%B4%EB%A5%BC%EB%A1%9C%EB%A1%9C-%EC%A3%BC%ED%83%9D-%EB%AA%A9%ED%91%9C-%EB%8B%AC%EC%84%B1-%ED%99%95%EB%A5%A0-%EC%98%88%EC%B8%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 13 Aug 2026 05:49:53 GMT</pubDate>
            <description><![CDATA[<h1 id="🏠-n년-뒤-이-집-내가-살-수-있을까">🏠 n년 뒤 이 집, 내가 살 수 있을까?</h1>
<p>이 질문에 가능 혹은 불가능이 아니라 달성 확률로 답하기까지의 기록입니다. 미래 집값의 불확실성을 어떻게 수치로 다뤘는지, 그리고 그 과정에서 어떤 설계 판단을 했는지 정리했습니다.</p>
<ul>
<li><p><strong>가격 모델(Price Model)</strong>: 과거 실거래 데이터에서 <strong>집값의 추세(μ)</strong>와 <strong>불확실성(σ)</strong> 두 숫자를 뽑아냅니다.</p>
</li>
<li><p><strong>몬테카를로 엔진</strong>: 뽑아낸 집값의 추세와 불확실성으로 목표 시점의 집값을 1만 번 시뮬레이션해 <strong>가격 분포</strong>를 만들고, 사용자의 예상 예산이 그 분포에서 어느 위치에 있는지를 확률로 환산합니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="％-왜-확률이어야-했나">％ 왜 확률이어야 했나?</h2>
<p>우리 서비스는 MZ세대의 주거 독립을 돕는 플랫폼입니다. 사용자는 원하는 지역, 주거 유형, 목표 시점을 입력하고 <em>“내가 이런 곳에 살 수 있을까?”</em> 를 진단받습니다.</p>
<p>초기에는 현재 시세와 현재 자산을 비교해서 <em>“가능”, “불가능”</em> 을 판정하는 방식이었지만 여기에는 아주 큰 허점이 있었습니다. <strong>바로 미래의 집값은 오늘과 다르다는 점</strong>입니다. 사용자가 만약 2년 뒤를 목표로 잡았다면, 비교해야 할 것은 오늘 시세가 아니라 2년 뒤의 시세일 것입니다. 그리고 정확한 미래 시세는 아무도 모르죠.</p>
<p>바로 이 불확실성을 정직하게 다루는 것이 이번 작업의 핵심이었습니다. 그냥 <em>“2년 뒤엔 2.2억이 됩니다.”</em> 라고 말해주는 대신, <strong><em>“2년 뒤엔 중앙값 2.2억, 나쁘면 1.6억, 좋으면 2.9억이고, 사용자 예산으로 살 수 있을 확률은 60%입니다.”</em></strong> 라고 구체적으로 알려주는 겁니다.</p>
<p>&nbsp;</p>
<h3 id="📈-가격-모델-집값의-추세와-변동성을-숫자로">📈 가격 모델: 집값의 추세와 변동성을 숫자로</h3>
<p>우리 서비스는 국토부 실거래 API에서 수집한 <code>rent_transaction</code> 테이블을 사용합니다. 아파트, 연립다세대, 오피스텔, 단독다가구의 전월세 데이터가 모두 들어 있습니다. 여기서 특정 조건(지역, 주거유형, 거래유형, 평수)에 맞는 최근 36개월치 거래를 조회합니다.</p>
<p>36개월로 정한 이유는 너무 짧으면 최근 몇 달의 일시적 변동성에 휘둘리고, 너무 길면 오늘날과는 맞지 않는 환경이 섞입니다. 그래서 단기적인 변동성을 흡수하면서도 현재 시장의 성격을 담기 위해 3년으로 결정했습니다. <del>3년치의 실거래가가 570만건인데, 그 이상 호출하니까 API 일일 호출 가능량을 초과했다...</del></p>
<p>&nbsp;</p>
<p>여기서 한 가지 주의할 점은 <strong>실거래 한 건 한 건은 서로 다른 매물이라는 것</strong>입니다. 롯데 캐슬과 동네 빌라를 비교해서 집값 상승률을 계산하는 것은 말이 안 되죠. 그래서 두 단계로 정규화합니다. </p>
<ol>
<li><p><strong>거래가를 면적으로 나눠서 평당 가격으로 바꿉니다.</strong> 이러면 20평이든 40평이든 같은 기준으로 비교됩니다. </p>
</li>
<li><p><strong>평당 가격을 월별로 묶어 각 달의 중앙값을 취합니다.</strong> 그러면 1월 중앙값, 2월 중앙값, … 처럼 하나의 깔끔한 시계열이 남습니다.</p>
</li>
</ol>
<p>&nbsp;</p>
<p>여기서 평균이 아니라 중앙값을 사용하는 이유는 만약 어느 달에 인접 단지의 특이 거래나 극단 매물이 한두 건 섞여도, 중앙값은 그 이상치에 거의 흔들리지 않습니다. </p>
<pre><code class="language-java">List&lt;Double&gt; monthlyMedians = raw.stream()
    .collect(Collectors.groupingBy(MonthlyPricePoint::getDealYm))
    .entrySet().stream()
    .filter(e -&gt; e.getValue().size() &gt;= MIN_SAMPLES_PER_MONTH)  // 월 5건 미만 제외
    .sorted(Map.Entry.comparingByKey())
    .map(e -&gt; medianPerPyeong(e.getValue()))
    .collect(Collectors.toList());</code></pre>
<p>표본이 적은 달의 중앙값은 신뢰하기 어려울 것입니다. 그래서 거래가 5건 미만인 달은 제외하고, 그렇게 걸러낸 뒤 유효한 달이 5개 미만이면 예외를 던지도록 했습니다. 근거가 빈약한 조건에 대해 그럴듯한 확률을 만들어내는 것보다, 솔직하게 판단할 데이터가 없다고 말하는 편이 사용자에게 더 도움이 될 것이라고 판단했기 때문입니다.</p>
<p>&nbsp;</p>
<p>아무튼 이렇게 월별 중앙값 시계열 <code>[P₁, P₂, …, Pₙ]</code>에서 두 숫자를 추정합니다.</p>
<ul>
<li><p><strong>변동성(σ):</strong> 달마다의 로그 수익률 <code>rₜ = ln(Pₜ / Pₜ₋₁)</code>을 구해 그 표준편차를 σ_월로 씁니다. 로그 수익률은 이번 달 가격이 지난달 대비 몇 % 움직였지 연속 복리로 표현한 값이라, 상승과 하락이 대칭적으로 다뤄지고 뒤의 기하 브라운 운동과 자연스럽게 맞물립니다.</p>
</li>
<li><p><strong>logDrift(b):</strong> 수익률의 추세는 로그 수익률의 평균이 아니라, <code>ln(Pₜ)</code>를 시간 t에 최소제곱(OLS) 회귀한 기울기로 잡습니다. 왜냐하면 로그 수익률의 평균은 <code>(1 / k)·Σ ln(Pₜ / Pₜ₋₁) = (1 / k)·ln(Pₙ / P₁)</code>이므로 결국 첫 달과 마지막 달 두 점에만 의존하기 때문입니다. 두 시점 CAGR과 똑같은 취약함이죠. 반면 로그 가격 회귀는 36개월의 모든 점으로 추세선을 그으므로 엔드포인트 노이즈에 강합니다.</p>
</li>
</ul>
<p>여기서 결정적인 사실은 GBM에서 로그 가격은 <code>ln P(t) = ln P(0) + (μ − σ²/2)·t + σ·W(t)</code>로 움직이므로, 회귀 기울기의 기댓값은 산술 드리프트 μ가 아니라 로그-드리프트 <code>(μ − σ²/2)</code>입니다. 시뮬레이션이 기대하는 산술 드리프트 μ로 넘기려면 <code>σ² / 2</code>를 다시 더해줘야 합니다.</p>
<p>&nbsp;</p>
<p>정리하면 연 단위로:</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/122976f0-1cd7-4036-9da7-d14b37ca4d8e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/067962bb-a1b3-4004-94e2-aec7834e0084/image.png" alt=""></p>
<pre><code>logDrift_연 = (ln(price)~t 회귀 기울기) × 12  // 로그-드리프트 (μ − σ²/2)
σ_연 = σ_월 × √12   // 변동성 (시간의 제곱근에 비례)
μ_연 = logDrift_연 + 0.5 × σ_연²  // 산술 드리프트 → 몬테카를로 입력
CAGR = exp(logDrift_연) − 1   // 표시용 연 상승률 (중앙값 기준)</code></pre><p>σ가 √12로 환산되는 건 무작위 걸음(random walk)에서 불확실성은 시간에 정비례해서가 아니라 시간의 제곱근에 비례해 커지기 때문입니다.</p>
<p>&nbsp;</p>
<h3 id="🎛️-몬테카를로-미래-집값을-1만-번-그려본다">🎛️ 몬테카를로: 미래 집값을 1만 번 그려본다</h3>
<p>집값이 시간에 따라 어떻게 움직이는지를 수학으로 표현할 때 가장 널리 사용하는 모델은 <strong>기하 브라운 운동(Geometric Brownian Motion, GBM)</strong>입니다. 원래 주가 모델링에서 출발했지만, 두 가지 성질 덕분에 부동산에도 합리적입니다.</p>
<ul>
<li><p>항상 양수: 집값은 음수가 될 수 없는데, GBM은 지수함수 형태라 절대 0 밑으로 내려가지 않습니다.</p>
</li>
<li><p>로그정규 분포: 가격이 퍼센트로 변한다는 직관과 맞습니다. (ex. 매달 몇 % 오른다)</p>
</li>
</ul>
<p>&nbsp;</p>
<p>수식은 아래와 같습니다.</p>
<pre><code>P(T) = P(0) × exp((μ − σ²/2)·T + σ·√T·Z)</code></pre><p>여기서 <code>Z</code>는 표준정규분포 <code>N(0, 1)</code>에서 뽑은 난수입니다. 지수 안은 두 부분으로 읽으면 됩니다. <code>(μ − σ² / 2)·T</code>는 <strong>방향(추세)</strong>, <code>σ·√T·Z</code>는 <strong>흔들림(불확실성)</strong>입니다. 매번 다른 <code>Z</code>를 뽑을 때마다 서로 다른 미래 하나가 그려집니다.</p>
<p>&nbsp;</p>
<p>여기서 집중해야 할 부분은 <code>σ²/2</code>, 즉 <strong>Itô 보정</strong>입니다. 왜 필요할까요? </p>
<p>직관적으로는, 드리프트가 μ라면 미래 가격의 기댓값이 <code>P(0) × exp(μT)</code>여야 합니다. 그런데 로그정규분포의 기댓값 공식 때문에, 보정 없이 <code>μT + σ√T·Z</code>를 지수에 그대로 넣으면 기댓값이 <code>P(0) × exp((μ + σ²/2)T)</code>로 나옵니다. <code>σ² / 2</code>만큼 부풀어 오르는 것이죠.</p>
<p>이유는 지수함수의 볼록성에 있습니다. 위로 튄 시나리오(큰 양수 Z)가 아래로 튄 시나리오(같은 크기 음수 Z)보다 지수를 취하면 더 크게 벌어집니다. 그래서 흔들림 자체가 평균을 위로 끌어올립니다. <code>σ² / 2</code>를 빼는 건 이 흔들림이 만드는 공짜 상승분을 정확히 상쇄하기 위한 것입니다. 빠뜨리면 집값 상승을 체계적으로 과대 예측하게 됩니다.</p>
<p>&nbsp;</p>
<p>이제 <code>Z</code>를 1만 번 바꿔 뽑아 1만 개의 미래를 만듭니다.</p>
<pre><code class="language-java">double drift = (annualDrift - 0.5 * annualVol * annualVol) * T;
double diffusion = annualVol * Math.sqrt(T);

Random rng = new Random(seed);  // 재현성을 위해 시드 고정
long[] prices = new long[simulations];

for (int i = 0; i &lt; simulations; i++) {
    double z = rng.nextGaussian();  // Z ~ N(0,1)  (Java는 Marsaglia 극좌표법 사용)
    prices[i] = Math.round(initialPrice * Math.exp(drift + diffusion * z));
}</code></pre>
<p>시드를 고정한 건 재현성 때문입니다. 같은 입력이면 항상 같은 결과가 나와야 캐싱과도 아귀가 맞고, 테스트도 안정적입니다. 시뮬레이션이 1만 개나 되므로 어떤 시드를 쓰든 분위값은 사실상 같은 곳으로 수렴합니다.</p>
<p>&nbsp;</p>
<p>1만 개의 가격을 정렬하면 미래 집값의 분포가 나옵니다. 여기서 세 지점을 뽑는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/168076fd-e2c2-4e4b-8fab-b74537c22ac9/image.png" alt=""></p>
<ul>
<li><strong>P50(중앙값)</strong>: 가장 있을 법한 목표 시점 집값</li>
<li><strong>P5(하위 5%)</strong>: 집값이 크게 떨어진 시나리오</li>
<li><strong>P95(상위 5%)</strong>: 집값이 크게 오른 시나리오</li>
</ul>
<p>그리고 성공 확률은, 사용자의 예상 예산이 이 1만 개 중 몇 개를 넘어서는지를 세면 됩니다.</p>
<pre><code class="language-java">successProbability = (double) count(budgetAtT &gt;= price) / simulations;</code></pre>
<p>즉 성공 확률이란 1만 가지 미래 중 내 예산으로 이 집을 살 수 있는 시나리오의 비율입니다. 60%라고 한다면, 10000개의 멀티버스에서 6000개의 멀티버스에서는 목표를 이룬다는 뜻입니다. </p>
<p>&nbsp;</p>
<p>집값이 확률적이라면, 예산은 결정론적으로 둡니다. <code>BudgetCalculator</code>가 목표 시점의 예상 예산을 계산합니다.</p>
<pre><code>budgetAtT = 예적금 × (1 + 월이율)^T + 기타자산 + 월저축액 × 적립식미래가치계수</code></pre><p>예적금은 복리로 굴리고, 매달 저축액은 적립식으로 쌓아 올린 미래가치를 더합니다. 결국 이 구조는 확률적인 집값과 결정론적인 예산을 고려해서 사용자의 예산선이 집값 분포의 어디를 지나는지로 달성 확률을 읽어내는 것입니다.</p>
<p>&nbsp;</p>
<p>이해를 돕기 위해 또 다른 예시를 들어보겠습니다. 어떤 조건의 현재 시세 중앙값이 <code>P(0) = 2억</code>, 추정된 산술 드리프트 <code>μ = 5%</code>, 변동성 <code>σ = 12%</code>, 목표까지 <code>T = 2년</code>이라고 가정하겠습니다.</p>
<ul>
<li><p>추세 항: <code>(0.05 − 0.5 × 0.12²) × 2 = 0.0428 × 2 = 0.0856</code></p>
</li>
<li><p>흔들림 계수: <code>σ×√T = 0.12 × 1.414 = 0.170</code></p>
</li>
</ul>
<p>&nbsp;</p>
<p>이제 <code>Z</code>에 따라 미래가 갈립니다. </p>
<ul>
<li><p><strong>Z = 0 (평범한 미래)</strong>: <code>2.0억 × exp(0.0856) ≈ 2.18억</code> ← 대략 중앙값</p>
</li>
<li><p><strong>Z ≈ +1.65 (상위 5%)</strong>: <code>2.0억 × exp(0.0856 + 0.170 × 1.65) ≈ 2.88억</code></p>
</li>
<li><p><strong>Z ≈ −1.65 (하위 5%)</strong>: <code>2.0억 × exp(0.0856 − 0.170 × 1.65) ≈ 1.65억</code></p>
</li>
</ul>
<p>이때 만약 사용자의 목표 시점 예산이 2억 3천만원이라면, <strong>2억 3천만원 이하로 끝나는 시나리오</strong>의 비율이 성공 확률입니다. 계산해 보면 약 62%가 나옵니다. 사용자에게는 *&quot;2년 뒤 이 집을 살 수 있을 확률은 62%입니다&quot;* 와 같이 목표를 진단해줄 수 있습니다. </p>
<p>&nbsp;</p>
<h2 id="🤔-설계-과정에서의-판단들">🤔 설계 과정에서의 판단들</h2>
<p>수식만큼이나 중요했던 건, 이 로직을 서비스에 안전하게 작성하는 일이었습니다. 아래는 그 과정 속에서 했던 판단들입니다.</p>
<h3 id="📝-판단1-목표가-저장되기-전에도-시뮬레이션이-돌아야-한다">📝 판단1: 목표가 저장되기 전에도 시뮬레이션이 돌아야 한다</h3>
<p>처음 만든 API는 <code>simulate(memberId, goalId)</code>였습니다. 저장된 목표가 있어야만 목표를 진단할 수 있는 구조였던 것입니다. 하지만 팀에서 추천 알고리즘을 도입하기로 결정됐고 목표가 저장되기 전에도 시뮬레이션을 가동할 수 있어야 했습니다.</p>
<p>추천 알고리즘은 여러 후보 조건(지역, 주거유형, 거래유형, 평수 조합)을 훑으며 각각의 현실성을 평가합니다. 여기에 몬테카를로를 붙이려면 <code>goalId</code> 없이 해당 조건으로 일단 돌릴 수 있어야 했습니다.</p>
<p>그래서 오버로드를 추가했습니다.</p>
<pre><code class="language-java">// 저장된 목표용 — GoalController 엔드포인트
MonteCarloResponse simulate(Long memberId, Long goalId);

// 알고리즘 내부용 — goalId 불필요
MonteCarloEngine.Result simulate(PriceModelRequest housing,
                                 long initialPrice,
                                 long budgetAtT,
                                 int months);</code></pre>
<p>알고리즘이 넘기는 <code>initialPrice (현재 시세 중앙값)</code>와 <code>budgetAtT (BudgetCalculator 결과)</code>는 알고리즘이 이미 알고 있는 값입니다. 추가 조회 없이 그대로 넘기면 되니 낭비도 없습니다.</p>
<p>&nbsp;</p>
<h3 id="🚧-판단2-호출자가-하나뿐이어도-도메인-경계는-지킨다">🚧 판단2: 호출자가 하나뿐이어도 도메인 경계는 지킨다</h3>
<p><code>PriceModelService</code>의 직접 호출자가 <code>MonteCarloServiceImpl</code> 딱 하나여서, *&quot;서비스를 없애고 로직을 안으로 흡수하면 더 단순하지 않을까?&quot;* 를 검토했습니다. 결론은 불가였습니다.</p>
<p><code>PriceModelServiceImpl</code>은 property 도메인의 Mapper(<code>RegionMapper</code>, <code>RentTransactionMapper</code>)를 직접 사용합니다. 이 로직을 goal 도메인인 <code>MonteCarloServiceImpl</code>로 옮기면, goal 도메인 서비스가 property 도메인 Mapper를 직접 부르게 됩니다. 프로젝트 기획 당시 미리 정해놓은 도메인 접근은 반드시 해당 도메인의 서비스 계층을 통해서만 해야 한다는 규칙에 위반되는 행위였습니다.</p>
<p><code>PriceModelService</code>는 단순한 껍데기 추상화가 아니라, goal → property 도메인 경계를 지키는 문지기 역할을 하고 있었습니다. 호출자가 하나뿐이라는 건 경계를 허물 이유가 되지 못합니다.</p>
<p>&nbsp;</p>
<h3 id="🚫-판단3-같은-계산을-매번-반복할-이유가-없다">🚫 판단3: 같은 계산을 매번 반복할 이유가 없다</h3>
<p>처음엔 <code>GET /{goalId}/simulation</code>을 열어두고 사용자가 목표 상세 화면을 열 때마다 계산하게 했습니다. 그러다 한 가지 의문이 들었습니다.</p>
<blockquote>
<p><em>&quot;목표가 확정된 뒤엔, 목표를 다시 설정하지 않는 한 몬테카를로를 돌릴 일이 없는 것 아닌가?&quot;</em></p>
</blockquote>
<p>맞는 말이었습니다. 목표 조건(주거 조건, 목표 시점, 월 저축액)이 바뀌지 않으면 시뮬레이션 입력이 고정됩니다. 그런데 가장 비싼 연산인 <code>PriceModelService.estimate()</code>(36개월 DB 조회 + 회귀 분석)를 매 요청마다 되풀이할 이유가 없었죠.</p>
<p>그래서 Redis 캐싱을 도입하기로 결정했습니다.</p>
<pre><code>Key: goal:simulation:{goalId}
TTL: 30일</code></pre><p>TTL은 어디까지나 보조 안전장치입니다. 목표 수정 및 삭제 시 캐시를 명시적으로 지우므로, 정상 흐름에서는 TTL이 만료되기 전에 무효화됩니다.</p>
<p>&nbsp;</p>
<h3 id="⛓️💥-판단4-순환-의존을-어떻게-끊는가">⛓️‍💥 판단4: 순환 의존을 어떻게 끊는가</h3>
<p>캐시를 지우려면 <code>GoalServiceImpl</code>이 관련 컴포넌트를 주입받아야 합니다. 그런데 <code>MonteCarloServiceImpl</code>은 이미 <code>GoalService</code>를 주입받고 있었습니다. 그대로 <code>GoalServiceImpl</code>에 <code>MonteCarloService</code>를 넣으면 서로를 참조하는 순환이 생깁니다.</p>
<pre><code>MonteCarloServiceImpl → GoalService
GoalServiceImpl → MonteCarloService ← 순환!</code></pre><p>해결책은 <strong>책임을 쪼개는 것</strong>이었습니다. 캐시 삭제만 담당하는 <code>MonteCarloSimulationStore</code>를 별도 컴포넌트로 떼어내고, <code>GoalServiceImpl</code>에는 이 Store만 주입했습니다. 이제 <code>GoalServiceImpl</code>은 <code>MonteCarloService</code>의 존재 자체를 모르게 되어 순환이 사라집니다.</p>
<pre><code>MonteCarloServiceImpl → GoalService
GoalServiceImpl → MonteCarloSimulationStore ← 순환 없음</code></pre><p>&nbsp; </p>
<h2 id="🏰-최종-구조">🏰 최종 구조</h2>
<p>추천 알고리즘이 호출하는 경로 (목표 저장 전):</p>
<pre><code>추천 알고리즘
  ↓ simulate(housing, initialPrice, budgetAtT, months)
MonteCarloService
  ├── PriceModelService.estimate() → μ, σ 산출 (property 도메인)
  └── MonteCarloEngine.simulate() → 10,000 경로 (순수 계산)
  ↓
Result { p5, p50, p95, successProbability }</code></pre><p>&nbsp;</p>
<p>사용자가 목표 상세를 여는 경로 (목표 저장 후):</p>
<pre><code>GoalController  GET /{goalId}/simulation
  ↓ simulate(memberId, goalId)
MonteCarloServiceImpl
  ├── 소유권 확인 (findOwnedGoal)
  ├── 캐시 HIT  → MonteCarloSimulationStore.find() → 즉시 반환
  └── 캐시 MISS → compute() → save() → 반환</code></pre><p>&nbsp;</p>
<p>목표가 바뀌면 캐시를 지우는 경로:</p>
<pre><code>GoalServiceImpl
  updateGoal() → goalMarketTrendCacheStore.delete()
               → monteCarloSimulationStore.delete()
  deleteGoal() → goalMarketTrendCacheStore.delete()
               → monteCarloSimulationStore.delete()</code></pre><p>&nbsp;</p>
<h2 id="😁-결과">😁 결과</h2>
<p><strong>기능 관점</strong>: 추천 알고리즘은 이제 <code>MonteCarloService</code> 하나만 주입받아 시뮬레이션을 부를 수 있습니다. <code>goalId</code>가 없는 목표 저장 전 단계에서도 정상 동작합니다.</p>
<p><strong>성능 관점</strong>: <code>PriceModelService.estimate()</code>는 실행에 약 110~130ms가 걸립니다(JVM 워밍업 후 측정). 캐시 HIT 시 이 비용이 통째로 사라지므로, 목표 조건이 바뀌지 않는 한 사용자는 늘 캐시 응답을 받습니다.</p>
<p><strong>설계 관점</strong>: 도메인 경계(goal → property)를 지키면서, 순환 의존 없이 캐시 무효화 책임을 깔끔하게 분리했습니다. 앞으로 알고리즘이 더 붙더라도 <code>MonteCarloService.simulate(housing, initialPrice, budgetAtT, months)</code> 하나만 부르면 됩니다. 내부 최적화(캐싱, 모델 개선 등)는 호출자가 알 필요가 없습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[하나의 코드베이스, 두 개의 서버: Spring Profile 제대로 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/%ED%95%98%EB%82%98%EC%9D%98-%EC%BD%94%EB%93%9C%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%84%9C%EB%B2%84-Spring-Profile-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/%ED%95%98%EB%82%98%EC%9D%98-%EC%BD%94%EB%93%9C%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%84%9C%EB%B2%84-Spring-Profile-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 12 Aug 2026 01:25:41 GMT</pubDate>
            <description><![CDATA[<p>같은 코드를 API 서버와 배치 서버, 두 가지로 나눠 실행하는 구조를 만들면서 Spring Profile을 깊게 들여다볼 일이 있었습니다. 이 글은 공식 문서를 따라가며 개념을 정리하고, 실제 프로젝트에 적용한 내용을 함께 기록한 글입니다.</p>
<p>먼저 이 글이 도달하려는 목표부터 그림으로 보여드리겠습니다. 똑같은 WAR 파일 하나를, 기동할 때 옵션 하나만 바꿔서 서로 다른 역할로 띄우는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/93653c88-34de-40f6-9aca-17327076a339/image.png" alt=""></p>
<h2 id="🧐-profile이란">🧐 Profile이란</h2>
<blockquote>
<p><strong>*&quot;조건이 맞을 때만 컨테이너에 등록되는, 이름이 붙은 빈 정의(bean definition)의 논리적 그룹&quot;*</strong></p>
</blockquote>
<p>공식 문서의 정의입니다. 문장이 다소 딱딱하니, 처음 이해할 때 도움이 되는 <strong>세 가지 감각</strong>으로 바꿔 보겠습니다.</p>
<ol>
<li><p>Profile은 빈을 <em><strong>&quot;만드는&quot;</strong></em> 도구가 아니라, <em><strong>&quot;등록할지 말지&quot;</strong></em> 를 정하는 스위치입니다. 객체를 어떻게 생성하는지와는 무관합니다. <code>prod</code> 프로필에 속한 빈은 <code>prod</code>가 켜졌을 때만 컨테이너에 올라가고, 아니면 아예 존재하지 않습니다.</p>
</li>
<li><p>판정은 애플리케이션이 뜰 때 딱 한 번 일어납니다. 매 요청마다 돌아가는 <code>if</code> 문이 아닙니다. 기동 시점에 이 빈을 등록할지 말지를 한 번 결정하고 나면, 그 결과는 애플리케이션이 살아있는 내내 고정됩니다.</p>
</li>
<li><p>그래서 프로필 이름은 비즈니스 로직 어디에도 나타나지 않습니다. 어떤 빈을 쓸지는 설정 단계에서 이미 정해지므로, 실제 로직은 현재 무슨 프로필인지 알 필요가 없습니다.</p>
</li>
</ol>
<p>이 세 가지만 기억하고 넘어가면 나머지는 자연스럽게 따라옵니다.</p>
<p>Profile은 Spring 3.1부터 제공되며, <code>Environment</code>라는 인터페이스가 관리합니다. <code>Environment</code>는 뒤에서 자세히 다룰 텐데, 지금은 <strong>*&quot;지금 어떤 프로필이 켜져 있는지 알고 있는 관리자&quot;*</strong> 정도로만 생각하면 됩니다.</p>
<p>&nbsp;</p>
<h2 id="👍-핵심-활용법-역할-축-vs-환경-축">👍 핵심 활용법: 역할 축 vs 환경 축</h2>
<p>Profile의 진짜 힘은 <strong>여러 개를 조합</strong>하는 데 있습니다. 프로필을 두 개의 축으로 나눠서 생각하면 활용법이 한눈에 들어옵니다.</p>
<table>
<thead>
<tr>
<th>축</th>
<th>예시</th>
<th>무엇을 정하나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>역할 축</strong></td>
<td><code>api</code> / <code>batch</code></td>
<td>이 인스턴스가 <strong>무슨 역할로</strong> 실행될지</td>
</tr>
<tr>
<td><strong>환경 축</strong></td>
<td><code>local</code> / <code>prod</code></td>
<td>이 인스턴스가 <strong>어느 환경에서</strong> 실행될지</td>
</tr>
</tbody></table>
<p>그리고 쉼표로 두 축을 동시에 켤 수 있습니다.</p>
<pre><code>-Dspring.profiles.active=local,api    # 로컬에서 + API 역할로
-Dspring.profiles.active=prod,batch   # 운영에서 + 배치 역할로</code></pre><p>이 조합이 앞에서 본 <strong>*&quot;WAR 하나, 두 서버&quot;*</strong> 를 가능하게 하는 핵심입니다. 이 감각을 미리 잡아두면, 아래에 나오는 문법들이 전부 이 그림을 채우기 위한 도구로 읽힙니다.</p>
<p>&nbsp;</p>
<h2 id="3️⃣-profile-사용법-세-가지-레벨">3️⃣ @Profile 사용법: 세 가지 레벨</h2>
<p><code>@Profile</code>은 붙이는 위치에 따라 적용 범위가 달라집니다. 가장 직관적인 것부터 살펴보겠습니다.</p>
<h3 id="🌕-컴포넌트-레벨-구현체를-통째로-교체">🌕 컴포넌트 레벨: 구현체를 통째로 교체</h3>
<p>가장 이해하기 쉬운 사용법입니다. 같은 인터페이스의 구현체를 프로필에 따라 갈아끼웁니다.</p>
<pre><code class="language-java">@Component
@Profile(&quot;local&quot;)
public class CodefMockClient implements CodefClient { }  // local일 때 이 구현체

@Component
@Profile(&quot;prod&quot;)
public class CodefRealClient implements CodefClient { }  // prod일 때 이 구현체</code></pre>
<p>주입받는 쪽은 그냥 <code>CodefClient</code>를 받을 뿐, 자기가 목(mock)을 받는지 실제 클라이언트를 받는지 전혀 몰라도 됩니다. 프로필을 바꾸면 주입되는 실체만 조용히 바뀝니다.</p>
<p>&nbsp;</p>
<h3 id="🌙-메서드-레벨-빈-하나씩-분기">🌙 메서드 레벨: 빈 하나씩 분기</h3>
<p><code>@Bean</code> 메서드에 붙이면 그 빈만 프로필에 종속됩니다. 환경별로 다른 <code>DataSource</code>를 만드는 전형적인 예입니다.</p>
<pre><code class="language-java">@Configuration
public class DataSourceConfig {

    @Bean(&quot;dataSource&quot;)
    @Profile(&quot;local&quot;)
    public DataSource localDataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .build();
    }

    @Bean(&quot;dataSource&quot;)
    @Profile(&quot;prod&quot;)
    public DataSource prodDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv(&quot;DB_URL&quot;));
        return new HikariDataSource(config);
    }
}</code></pre>
<p>두 메서드 모두 <code>@Bean(&quot;dataSource&quot;)</code>로 <strong>같은 빈 이름</strong>을 갖되, <strong>메서드 이름은 다르게</strong> 뒀습니다. 이게 안전한 패턴인 데는 이유가 있습니다.</p>
<blockquote>
<p><strong>주의)</strong> 만약 메서드 이름까지 똑같이 만들어 자바 오버로딩이 되면, <code>@Profile</code> 조건을 <strong>모든 오버로딩 메서드에 일관되게</strong> 선언해야 합니다. 조건이 서로 다르면 첫 번째 선언의 조건만 적용됩니다. 위처럼 메서드 이름을 다르게 두면 애초에 오버로딩이 아니므로 이 함정 자체를 피할 수 있습니다.</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="🌞-클래스-레벨-설정-클래스-전체를-묶기">🌞 클래스 레벨: 설정 클래스 전체를 묶기</h3>
<p><code>@Configuration</code> 클래스에 붙이면 파급력이 가장 큽니다. 조건이 맞지 않으면 <strong>그 안의 모든 것이 통째로 사라집니다.</strong></p>
<pre><code class="language-java">@Configuration
@Profile(&quot;batch&quot;)
@EnableScheduling
@ComponentScan(&quot;com.example.scheduler&quot;)
public class BatchConfig {
// batch 프로필일 때만 이 클래스 전체가 처리됨
}</code></pre>
<p>공식 문서가 강조하는 규칙이 여기 있습니다. <code>@Configuration</code> 클래스에 <code>@Profile</code>이 붙어 조건이 맞지 않으면, 그 클래스의 모든 <code>@Bean</code> 메서드와 <code>@Import</code>, <code>@ComponentScan</code>이 전부 건너뛰어집니다.</p>
<p>이 규칙이 왜 강력한지는 <code>@EnableScheduling</code>을 보면 드러납니다. <code>@EnableScheduling</code>의 실체는 <code>@Import(SchedulingConfiguration.class)</code>입니다. 즉 이것도 <code>@Import</code>의 일종이므로, <code>BatchConfig</code>가 통째로 무시되면 스케줄링 기능 자체가 등록되지 않습니다. <code>@ComponentScan</code>도 실행되지 않으니 해당 패키지를 아예 스캔하지 않습니다.</p>
<p>설정 클래스 하나에 <code>@Profile</code>을 붙이면, 그 안의 모든 기능이 하나의 스위치에 종속되는 것입니다.</p>
<pre><code class="language-java">@Configuration
@Profile(&quot;api&quot;)
public class ApiConfig {
    ...
}

@Configuration
@Profile(&quot;batch&quot;)
@EnableScheduling
@ComponentScan(basePackages = &quot;com.team.independence.scheduler&quot;)
public class BatchConfig {
}</code></pre>
<p>&nbsp;</p>
<h2 id="✍🏻-프로필-표현식">✍🏻 프로필 표현식</h2>
<p>단순한 이름 매칭을 넘어, 논리 연산자를 쓸 수 있습니다.</p>
<pre><code class="language-java">@Profile(&quot;production&quot;)  // production이 활성일 때
@Profile(&quot;!production&quot;)  // NOT — production이 아닐 때
@Profile(&quot;production &amp; us-east&quot;)  // AND — 둘 다 활성일 때
@Profile(&quot;production | eu-central&quot;)  // OR — 하나라도 활성일 때
@Profile(&quot;production &amp; (us-east | eu-central)&quot;)  // 복합 — 괄호 필수</code></pre>
<p>한 가지 규칙만 주의하면 됩니다. <code>&amp;</code>와 <code>|</code>를 괄호 없이 섞으면 유효하지 않은 표현식입니다.</p>
<pre><code class="language-java">@Profile(&quot;production &amp; us-east | eu-central&quot;)   // ❌ 잘못된 표현식
@Profile(&quot;production &amp; (us-east | eu-central)&quot;) // ✅ 괄호로 묶어야 함</code></pre>
<p>한편, 배열로 전달하면 자동으로 OR로 해석됩니다.</p>
<pre><code class="language-java">@Profile({&quot;local&quot;, &quot;dev&quot;})   // local 또는 dev
@Profile({&quot;p1&quot;, &quot;!p2&quot;})      // p1이 활성이거나, p2가 비활성이면 등록</code></pre>
<p>&nbsp;</p>
<h3 id="🥤-메타-애노테이션으로-뽑아내기">🥤 메타 애노테이션으로 뽑아내기</h3>
<p>자주 쓰는 조건은 커스텀 애노테이션으로 만들어두면 가독성이 좋아집니다. <code>@Profile</code>은 메타 애노테이션으로 사용할 수 있습니다.</p>
<pre><code class="language-java">@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Profile(&quot;production&quot;)
public @interface Production { }

// 사용
@Configuration
@Production
public class ProdConfig { }</code></pre>
<p>&nbsp;</p>
<h2 id="🎛️-프로필-활성화-방법">🎛️ 프로필 활성화 방법</h2>
<p>정의한 프로필을 실제로 켜는 방법은 여러 가지입니다.</p>
<ol>
<li><strong>JVM 시스템 프로퍼티 (가장 일반적)</strong></li>
</ol>
<pre><code>-Dspring.profiles.active=local,api</code></pre><p>IntelliJ에서는 Tomcat Run Configuration의 VM options에 입력합니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1ae7b4ee-2973-4860-a7ae-42e6a31d52c9/image.png" alt=""></p>
<ol>
<li><strong>OS 환경변수</strong></li>
</ol>
<pre><code class="language-bash">export SPRING_PROFILES_ACTIVE=prod,batch</code></pre>
<p>&nbsp;</p>
<ol start="2">
<li><strong>web.xml (서블릿 환경)</strong></li>
</ol>
<pre><code class="language-xml">&lt;context-param&gt;
    &lt;param-name&gt;spring.profiles.active&lt;/param-name&gt;
    &lt;param-value&gt;production&lt;/param-value&gt;
&lt;/context-param&gt;</code></pre>
<p>&nbsp;</p>
<ol start="3">
<li><strong>코드로 직접 설정</strong></li>
</ol>
<pre><code class="language-java">AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.getEnvironment().setActiveProfiles(&quot;local&quot;, &quot;api&quot;);
ctx.register(RootConfig.class);
ctx.refresh();</code></pre>
<blockquote>
<p><code>setActiveProfiles()</code>는 <code>String...</code> 가변인자를 받습니다. 그리고 반드시 <code>register()</code> → <code>refresh()</code> <strong>이전에</strong> 호출해야 합니다. 프로필 판정이 컨텍스트 리프레시 시점에 일어나기 때문입니다.</p>
</blockquote>
<p>&nbsp;</p>
<ol start="4">
<li><strong>테스트에서</strong></li>
</ol>
<p><code>spring-test</code>의 <code>@ActiveProfiles</code>를 사용합니다.</p>
<pre><code class="language-java">@SpringJUnitConfig(RootConfig.class)
@ActiveProfiles({&quot;local&quot;, &quot;api&quot;})
class MemberServiceTest { }</code></pre>
<p>어떤 방법을 쓰든, 쉼표로 여러 개를 동시에 켤 수 있다는 점(2장의 역할 축 × 환경 축)이 활용의 핵심입니다.</p>
<p>&nbsp;</p>
<h2 id="💫-기본-프로필">💫 기본 프로필</h2>
<p>활성 프로필이 하나도 없을 때 적용되는 프로필입니다. 이름은 그냥 <code>default</code>입니다.</p>
<pre><code class="language-java">@Configuration
@Profile(&quot;default&quot;)
public class DefaultDataConfig {
    @Bean
    public DataSource dataSource() { ... }
}</code></pre>
<p>아무 프로필도 켜지 않고 기동하면 이 빈이 등록됩니다. 반대로 어떤 프로필이든 하나라도 켜지면 <code>default</code>는 적용되지 않습니다. (<code>local</code> 하나만 켜도 <code>default</code>는 빠집니다.)</p>
<p>기본 프로필의 이름 자체는 <code>setDefaultProfiles()</code>나 <code>spring.profiles.default</code> 프로퍼티로 바꿀 수 있습니다.</p>
<blockquote>
<p><strong>실무 팁)</strong> <code>default</code>에 안전한 기본값을 두면 프로필 지정을 깜빡했을 때의 방어막이 됩니다. 반대로 <strong>프로필 지정을 강제하고 싶다면 일부러 <code>default</code>를 비워두세요.</strong> 그러면 필수 빈이 없어 기동에 실패하므로, 설정 실수를 즉시 알아챌 수 있습니다.</p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🧩-원리-profile은-결국-conditional이다">🧩 원리: @Profile은 결국 @Conditional이다</h2>
<p>지금까지 어떻게 쓰는지를 봤으니, 이제 왜 그렇게 동작하는지를 열어보겠습니다. 먼저 <code>@Profile</code>를 살펴보면 아래와 같습니다.</p>
<pre><code class="language-java">@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional({ProfileCondition.class})  // 핵심
public @interface Profile {
    String[] value();
}</code></pre>
<p>즉 <code>@Profile</code>은 독립적인 기능이 아니라, <code>@Conditional</code>에 <code>ProfileCondition</code>이라는 조건 하나를 끼워 넣은 특수 케이스입니다. 그리고 <code>ProfileCondition</code>이 하는 일은 단순합니다. <code>Environment</code>의 활성 프로필 목록에 그 이름이 들어 있는지 확인하는 것뿐입니다.</p>
<p>여기서 가장 중요한 것은 <strong>판정 시점</strong>입니다.</p>
<pre><code>JVM 기동 (-Dspring.profiles.active=prod,api)
  │
  ├─ Environment 생성 → activeProfiles = [prod, api]
  │
  ├─ ConfigurationClassPostProcessor가 설정 클래스를 파싱
  │     └─ 각 클래스/메서드마다 ConditionEvaluator.shouldSkip() 호출
  │           ├─ 조건 false → 통째로 건너뜀
  │           │              (@Bean, @ComponentScan, @Import 전부 무시)
  │           └─ 조건 true  → 빈 정의 등록
  │
  └─ 등록된 빈 정의로 싱글톤 인스턴스화</code></pre><p>빈이 만들어지기도 전, 설정 클래스를 읽는 단계에서 한 번 판정되고 끝납니다. 이것이 바로 매 요청마다 돌아가는 <code>if</code> 문이 아니라는 말의 의미입니다. 기동이 끝나면 프로필을 바꿀 수 없고, 등록되지 않은 빈이 나중에 되살아나지도 않습니다.</p>
<p>&nbsp;</p>
<h2 id="🏰-environment-인터페이스-프로필과-프로퍼티의-관리자">🏰 Environment 인터페이스: 프로필과 프로퍼티의 관리자</h2>
<p>방금 <code>ProfileCondition</code>이 결국 <code>Environment</code>에게 특정 프로필이 켜져 있는지 확인한다는 것을 봤습니다. 이제 그 <code>Environment</code>가 정확히 무엇인지 볼 차례입니다.</p>
<p>이름 때문에 그냥 환경 설정 객체 정도로 넘기기 쉽지만, <code>Environment</code>는 컨테이너에 통합되어 애플리케이션 환경의 <strong>두 축</strong>을 모델링하는 추상화입니다.</p>
<ul>
<li><strong>Profile:</strong> 지금 어떤 프로필이 활성인지, 기본값은 무엇인지</li>
<li><strong>Property:</strong> properties 파일, 시스템 프로퍼티, 환경변수 등에서 값을 찾아오는 창구</li>
</ul>
<p>&nbsp;</p>
<h3 id="💥-주요-메서드">💥 주요 메서드</h3>
<pre><code class="language-java">package org.springframework.core.env;

public interface Environment extends PropertyResolver {
    String[] getActiveProfiles();  // 활성 프로필 배열
    String[] getDefaultProfiles();  // 기본 프로필 배열

    default boolean matchesProfiles(String... profileExpressions) {
        return this.acceptsProfiles(Profiles.of(profileExpressions));
    }

    /**@deprecated */
    @Deprecated
    boolean acceptsProfiles(String... profiles);  // 표현식 매칭 (구버전)

    boolean acceptsProfiles(Profiles profiles);

// PropertyResolver로부터 상속받는 프로퍼티 조회
// String  getProperty(String key);
// String  getProperty(String key, String defaultValue);
// boolean containsProperty(String key);
}</code></pre>
<blockquote>
<p><code>acceptsProfiles(String...)</code> 형태는 Spring 5.1부터 deprecated입니다. <code>Profiles</code> 객체를 받는 쪽을 쓰면 되고, <code>Profiles.of(&quot;prod &amp; api&quot;)</code>처럼 표현식을 그대로 넘길 수 있습니다.</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="🎭-읽기용과-조작용이-나뉘어-있다">🎭 읽기용과 조작용이 나뉘어 있다</h3>
<p><code>Environment</code>는 읽기 전용 조회만 제공하고, 값을 바꾸는 <strong>조작</strong>은 하위 인터페이스인 <code>ConfigurableEnvironment</code>에 있습니다. 그래서 애플리케이션 코드에서는 보통 <code>Environment</code>만 주입받아 읽기만 하고, 설정을 바꾸는 일은 컨테이너 기동 시점에 프레임워크가 처리합니다.</p>
<p>&nbsp;</p>
<h3 id="🎏-두-가지-구현체">🎏 두 가지 구현체</h3>
<p>어떤 구현체가 쓰이느냐에 따라 기본 프로퍼티 소스가 달라집니다.</p>
<table>
<thead>
<tr>
<th>구현체</th>
<th>사용처</th>
<th>기본 프로퍼티 소스</th>
</tr>
</thead>
<tbody><tr>
<td><code>StandardEnvironment</code></td>
<td>일반 애플리케이션</td>
<td>JVM 시스템 프로퍼티, OS 환경변수</td>
</tr>
<tr>
<td><code>StandardServletEnvironment</code></td>
<td><strong>서블릿(WAR) 환경</strong></td>
<td>JVM 시스템 프로퍼티, OS 환경변수, ServletConfig, ServletContext, JNDI</td>
</tr>
</tbody></table>
<p>WAR로 배포하는 프로젝트라면 자동으로 <code>StandardServletEnvironment</code>가 쓰입니다. 뒤에서 다룰 프로퍼티 우선순위가 다섯 계층이나 되는 이유가 바로 이것입니다. 실제로 이 구현체가 프로퍼티 소스를 추가하는 부분을 보면 계층이 어디서 생기는지 보입니다.</p>
<pre><code class="language-java">public class StandardServletEnvironment extends StandardEnvironment implements ConfigurableWebEnvironment {

    protected void customizePropertySources(MutablePropertySources propertySources) {
        propertySources.addLast(new PropertySource.StubPropertySource(&quot;servletConfigInitParams&quot;));
        propertySources.addLast(new PropertySource.StubPropertySource(&quot;servletContextInitParams&quot;));
        if (jndiPresent &amp;&amp; JndiLocatorDelegate.isDefaultJndiEnvironmentAvailable()) {
            propertySources.addLast(new JndiPropertySource(&quot;jndiProperties&quot;));
        }
        super.customizePropertySources(propertySources);  // 시스템 프로퍼티·환경변수 추가
    }
// ...
}</code></pre>
<p>&nbsp;</p>
<h3 id="🤔-언제-environment를-직접-쓰고-언제-쓰지-말아야-하나">🤔 언제 <code>Environment</code>를 직접 쓰고, 언제 쓰지 말아야 하나</h3>
<p><code>Environment</code>로 프로필을 직접 조회해서 분기하는 코드를 쓸 수도 있습니다.</p>
<pre><code class="language-java">// 이렇게 할 수 있지만...
if (environment.acceptsProfiles(Profiles.of(&quot;prod&quot;))) {
    return new CodefRealClient();
}
return new CodefMockClient();</code></pre>
<p>하지만 권장하는 방식은 아닙니다. 프로필 이름이 비즈니스 로직 안으로 스며들고, 새 환경이 생길 때마다 <code>if</code>가 늘어납니다. 같은 일을 <code>@Profile</code>로 하면 조건이 설정 영역에만 남고 로직은 깨끗해집니다.</p>
<pre><code class="language-java">@Component @Profile(&quot;prod&quot;)  class CodefRealClient implements CodefClient { }
@Component @Profile(&quot;local&quot;) class CodefMockClient implements CodefClient { }</code></pre>
<p>정리하면 역할 분담의 기준은 이렇습니다.</p>
<ul>
<li><strong>*&quot;등록할지 말지&quot;* 는 <code>@Profile</code>에게</strong> 맡긴다.</li>
<li><strong>*&quot;값이 무엇인지&quot;* 는 <code>Environment</code>에게</strong> 묻는다. (프로퍼티 조회, 기동 시 활성 프로필 로깅, 조건부 설정값 계산 등 <strong>값을 읽는</strong> 경우)</li>
</ul>
<p>&nbsp;</p>
<h3 id="👀-활성-프로필-직접-확인하기">👀 활성 프로필 직접 확인하기</h3>
<p>기동 시 어떤 프로필이 켜졌는지 로그로 남기는 것은 <code>Environment</code>를 적절하게 쓰는 대표적인 예입니다.</p>
<pre><code class="language-java">@Configuration
public class RootConfig {

    @Autowired
    private Environment environment;

    @PostConstruct
    public void logActiveProfiles() {
        System.out.println(&quot;활성 프로필: &quot;
                + Arrays.toString(environment.getActiveProfiles()));
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/40baae33-9f2a-4151-a5a9-3dec28e7cb5f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6056ea4e-bf49-4db2-a272-76ff3e6f3128/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="📝-propertysource-우선순위">📝 PropertySource 우선순위</h2>
<p><code>Environment</code>의 또 다른 책임인 프로퍼티를 볼 차례입니다. <code>Environment</code>는 여러 출처에서 값을 찾는데, 계층적으로 탐색하며 먼저 찾은 값이 이깁니다. 값이 병합되는 게 아니라 완전히 덮어써진다는 점이 중요합니다.</p>
<p>서블릿 환경(<code>StandardServletEnvironment</code>)의 전체 우선순위는 다음과 같습니다. 위쪽이 높습니다.</p>
<table>
<thead>
<tr>
<th>순위</th>
<th>출처</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>ServletConfig 파라미터 (DispatcherServlet 컨텍스트)</td>
</tr>
<tr>
<td>2</td>
<td>ServletContext 파라미터 (web.xml의 <code>context-param</code>)</td>
</tr>
<tr>
<td>3</td>
<td>JNDI 환경변수 (<code>java:comp/env/</code>)</td>
</tr>
<tr>
<td>4</td>
<td><strong>JVM 시스템 프로퍼티</strong> (<code>-D</code> 옵션)</td>
</tr>
<tr>
<td>5</td>
<td><strong>OS 환경변수</strong></td>
</tr>
</tbody></table>
<p><code>@PropertySource</code>로 등록한 파일은 기본적으로 이 아래에 놓입니다. 즉 <code>.properties</code>에 값이 있어도 환경변수가 있으면 환경변수가 이깁니다.</p>
<blockquote>
<p>&quot;시스템 프로퍼티 → 환경변수 → properties 파일&quot; 세 단계만 알고 있었다면, 서블릿 환경에서는 그 위에 세 계층이 더 있다는 점을 기억해두면 좋습니다. WAR로 배포하는 프로젝트라면 특히 그렇습니다.</p>
</blockquote>
<pre><code class="language-java">@Configuration
@PropertySource(&quot;classpath:config/app.properties&quot;)
@PropertySource(value = &quot;file:${user.dir}/.env&quot;, ignoreResourceNotFound = true)
public class RootConfig { }</code></pre>
<ul>
<li><code>ignoreResourceNotFound = true</code>를 붙이면 파일이 없어도 무시합니다. 로컬에만 두는 민감한 값(API 키, DB 비밀번호)을 <code>.env</code>로 관리할 때 유용합니다.</li>
<li><code>@PropertySource</code>를 여러 개 선언했을 때 같은 키가 겹치면 나중에 처리된 것이 이깁니다. 위 예시에서는 <code>.env</code>의 값이 <code>app.properties</code>를 덮어씁니다.</li>
</ul>
<p>이 우선순위 덕분에 코드를 고치지 않고 환경만 바꿀 수 있습니다. 로컬에서는 <code>app.properties</code>의 기본값을 쓰고, 서버에서는 환경변수로 주입된 값이 자동으로 이기는 구조입니다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-실전-api-서버--배치-서버-분리">🛠️ 실전: API 서버 / 배치 서버 분리</h2>
<p>이제 처음의 그림으로 돌아옵니다. 같은 WAR를 두 역할로 실행하되, 기동 시 VM options 하나만 바꿉니다.</p>
<pre><code>동일한 WAR
├── -Dspring.profiles.active=local,api → API 서버
└── -Dspring.profiles.active=local,batch → 배치 서버</code></pre><p>&nbsp;</p>
<h3 id="🟣-배치-서버를-결정하는-클래스-하나">🟣 배치 서버를 결정하는 클래스 하나</h3>
<p>클래스 레벨 <code>@Profile</code>을 활용합니다.</p>
<pre><code class="language-java">@Configuration
@Profile(&quot;batch&quot;)
@EnableScheduling
@ComponentScan(&quot;com.example.scheduler&quot;)
public class BatchConfig { }</code></pre>
<p><code>api</code> 프로필로 실행하면 다음 연쇄가 일어납니다.</p>
<ol>
<li><p><code>@Profile(&quot;batch&quot;)</code> 조건 불일치 → BatchConfig 자체가 처리되지 않음</p>
</li>
<li><p><code>@ComponentScan</code>이 실행되지 않음 → scheduler 패키지를 스캔하지 않음</p>
</li>
<li><p><code>AssetSyncScheduler</code>에 <code>@Component</code>가 붙어 있어도 <strong>빈으로 등록되지 않음</strong></p>
</li>
<li><p><code>@EnableScheduling</code>도 없으니 <code>ScheduledAnnotationBeanPostProcessor</code>가 등록되지 않음</p>
</li>
<li><p><code>@Scheduled</code>를 읽어줄 후처리기가 없어 애노테이션이 <strong>그냥 무시됨</strong></p>
</li>
</ol>
<p>즉, 이중 안전장치입니다. 스케줄러 빈이 등록조차 되지 않고, 설령 어쩌다 등록되더라도 그걸 읽어줄 담당자가 없습니다.</p>
<blockquote>
<p>애노테이션은 스스로 아무 일도 하지 않습니다. <code>@Scheduled</code>는 표시일 뿐이고, 그것을 읽어 스케줄러에 등록해주는 후처리기가 있어야 동작합니다. <code>@EnableScheduling</code>이 하는 일이 바로 그 후처리기를 등록하는 것입니다.</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="🧑🏻🔧-스케줄러는-언제-만-알고-무엇을-은-위임한다">🧑🏻‍🔧 스케줄러는 *&quot;언제&quot;* 만 알고 *&quot;무엇을&quot;* 은 위임한다</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class AssetSyncScheduler {

    private final AssetSyncService assetSyncService;

    @Scheduled(cron = &quot;0 0 4 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    public void syncAllMembers() {
        log.info(&quot;[배치] 자산 동기화 시작&quot;);
        assetSyncService.syncAll();
        log.info(&quot;[배치] 자산 동기화 종료&quot;);
    }
}</code></pre>
<p>스케줄러에는 비즈니스 로직이 없습니다. 언제 실행할지만 알고 무엇을 할지는 서비스에 위임합니다. 덕분에 API 서버에서 수동 동기화 엔드포인트를 만들 때 동일한 <code>AssetSyncService</code>를 그대로 재사용할 수 있습니다. Service와 Mapper는 프로필과 무관하게 양쪽 모두 로딩되기 때문입니다.</p>
<p>&nbsp;</p>
<h3 id="🔄-배치-서버의-생애">🔄 배치 서버의 생애</h3>
<pre><code>1. Tomcat 기동 (batch 프로필)
2. Spring 컨텍스트 초기화
3. BatchConfig 로드
4. @EnableScheduling → 스케줄러 스레드 풀 시작
5. AssetSyncScheduler 빈 등록
6. [대기…  매일 04:00]
7. syncAllMembers() → syncAll() 실행</code></pre><p>API 서버가 HTTP 요청을 기다리는 것과 달리, 배치 서버는 이 스레드를 살려두는 것 자체가 목적입니다. 사용자 요청을 기다리는 게 아니라, 자기 안의 시계를 보다가 시간이 되면 스스로 실행하는 구조입니다. 운영 환경에서는 별도 인스턴스에 <code>batch</code> 프로필로만 띄우고, 외부 포트를 열지 않은 채 아웃바운드 호출만 하도록 구성합니다.</p>
<p>&nbsp;</p>
<h3 id="❓-환경-축은-아직-여지로-남겨둔다">❓ 환경 축은 아직 여지로 남겨둔다</h3>
<p>현재 프로젝트에는 <code>@Profile(&quot;local&quot;)</code>이 붙은 빈이 아직 없습니다. 외부 API를 환경별로 분기해야 할 때 아래처럼 활용할 예정입니다.</p>
<pre><code class="language-java">@Component @Profile(&quot;local&quot;) class CodefMockClient implements CodefClient { }
@Component @Profile(&quot;prod&quot;)  class CodefRealClient implements CodefClient { }</code></pre>
<p>시연 중 외부 API 장애가 나도 프로필 전환만으로 더미 응답으로 대체할 수 있다는 점이 실용적입니다.</p>
<hr>
<h2 id="🌈-정리">🌈 정리</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Profile(&quot;x&quot;)</code></td>
<td><code>x</code> 프로필이 활성일 때만 빈 등록</td>
</tr>
<tr>
<td>실체</td>
<td><code>@Conditional(ProfileCondition.class)</code>의 특수 케이스</td>
</tr>
<tr>
<td>판정 시점</td>
<td>설정 클래스 파싱 단계에서 <strong>기동 시 1회</strong> (런타임 <code>if</code> 아님)</td>
</tr>
<tr>
<td>클래스에 붙이면</td>
<td>내부 <code>@Bean</code> · <code>@Import</code> · <code>@ComponentScan</code> 전부 종속</td>
</tr>
<tr>
<td>표현식</td>
<td><code>!</code> <code>&amp;</code> `</td>
</tr>
<tr>
<td>배열 전달</td>
<td>OR 조건 (<code>@Profile({&quot;a&quot;,&quot;b&quot;})</code>)</td>
</tr>
<tr>
<td>기본 프로필</td>
<td>활성 프로필이 하나도 없을 때만 <code>default</code> 적용</td>
</tr>
<tr>
<td>다중 프로필</td>
<td>역할(api/batch) + 환경(local/prod) 조합 가능</td>
</tr>
<tr>
<td><code>Environment</code> 역할 분담</td>
<td>&quot;등록 여부&quot;는 <code>@Profile</code>, &quot;값 조회&quot;는 <code>Environment</code></td>
</tr>
<tr>
<td>프로퍼티 우선순위</td>
<td>서블릿 환경은 5계층, 먼저 찾은 값이 이김(덮어쓰기)</td>
</tr>
<tr>
<td><code>@EnableScheduling</code> 위치</td>
<td>BatchConfig 안에 두면 api 프로필에서 스케줄러 완전 차단</td>
</tr>
</tbody></table>
<p>Spring Profile은 하나의 코드베이스로 여러 실행 형태를 가능하게 하는 도구입니다. 환경 분기와 역할 분기를 조합하면, 복잡한 배포 구조도 코드 변경 없이 VM 옵션 하나로 제어할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[25초를 0.03초(?)로: 자산 동기화를 비동기로 전환]]></title>
            <link>https://velog.io/@rocker_nun/25%EC%B4%88%EB%A5%BC-0.03%EC%B4%88%EB%A1%9C-%EC%9E%90%EC%82%B0-%EB%8F%99%EA%B8%B0%ED%99%94%EB%A5%BC-%EB%B9%84%EB%8F%99%EA%B8%B0%EB%A1%9C-%EC%A0%84%ED%99%98</link>
            <guid>https://velog.io/@rocker_nun/25%EC%B4%88%EB%A5%BC-0.03%EC%B4%88%EB%A1%9C-%EC%9E%90%EC%82%B0-%EB%8F%99%EA%B8%B0%ED%99%94%EB%A5%BC-%EB%B9%84%EB%8F%99%EA%B8%B0%EB%A1%9C-%EC%A0%84%ED%99%98</guid>
            <pubDate>Wed, 05 Aug 2026 06:06:19 GMT</pubDate>
            <description><![CDATA[<p>MZ세대의 독립을 위한 자산 관리 서비스를 만들면서 CODEF API로 사용자의 금융 자산을 연동하는 기능을 맡았습니다. 기능은 동작했지만 응답에 평균 25.87초가 걸리는 동안 사용자가 할 수 있는 부분이 없었고, 이걸 0.029초로 줄인(?) 경험입니다.</p>
<h1 id="🫯-문제-상황-25초-동안의-침묵">🫯 문제 상황: 25초 동안의 침묵</h1>
<p>자산 동기화는 사용자가 *&quot;새로고침&quot;* 버튼을 누르면 연동된 금융기관에서 최신 계좌 정보를 다시 가져오는 기능입니다. 총 11개의 계좌를 연동한 계정으로 아래와 같이 동기화 API에 POST HTTP 요청을 보내고 응답이 오기까지 측정한 결과, 평균 25초 정도가 걸렸습니다.</p>
<pre><code>1. POST /api/v1/assets/sync?memberId=1
2. 평균 25초 대기...
3. 응답 200 OK  { &quot;syncedCount&quot;: ?, &quot;failedCount&quot;: ?, ... }</code></pre><table>
<thead>
<tr>
<th>회차</th>
<th>응답 시간</th>
</tr>
</thead>
<tbody><tr>
<td>첫 번째 동기화 시도</td>
<td>25.10초</td>
</tr>
<tr>
<td>두 번째 동기화 시도</td>
<td>29.40초</td>
</tr>
<tr>
<td>세 번째 동기화 시도</td>
<td>23.11초</td>
</tr>
<tr>
<td><strong>평균</strong></td>
<td><strong>25.87초</strong></td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="😖-왜-이렇게-느린가">😖 왜 이렇게 느린가</h2>
<p>CODEF는 실제 금융기관에 로그인해서 데이터를 긁어오는 서비스입니다. 호출하는 순간 CODEF 서버가 은행 사이트에 접속하고, 인증하고, 계좌 목록을 조회합니다. 어찌보면 오래 걸리는게 당연합니다.</p>
<p>기존 서비스 코드는 연동된 기관을 순차적으로 호출하고 있었습니다.</p>
<pre><code>우리은행 조회 → 삼성증권 조회 → KB증권 조회 = 25초</code></pre><p>평균 25.87초에 달하는 응답 지연은 사용자 경험(UX) 저하에 그치지 않고 백엔드 아키텍처 전체에 걸쳐 복합적인 스레드 고갈 및 네트워크 안전성 문제를 야기할 수 있습니다.</p>
<p>첫째, 서블릿 컨테이너(Apache Tomcat)의 스레드 고갈 현상입니다. Spring MVC 기반의 서블릿 애플리케이션은 요청 하나당 하나의 워커 스레드를 할당하는 Thread-per-Request 모델로 작동하는데, 스레드 풀의 크기가 한정된 상태에서 단일 동기화 요청이 25초 이상 서블릿 스레드를 차단하게 되면, 동시 동기화 요청 수에 비례하여 워커 스레드가 고갈됩니다. 이는 자산 동기화와 무관한 시스템 내 다른 조회 및 저장 API 요청까지 대기 상태로 만드는 파급 효과를 가져올 수 있었습니다.</p>
<p>둘째, 클라이언트 커넥션의 불안정성입니다. 25초 동안 HTTP 소켓 연결이 유지되는 과정에서 모바일 네트워크 환경의 변화나 Wi-Fi 연결이 끊긴다면 클라이언트는 응답을 받지 못하고 요청 실패로 인식합니다. 하지만 백엔드 서버는 클라이언트와의 소켓이 끊어진 후에도 작업을 계속 수행하므로, 작업은 정상 완료되었음에도 사용자는 실패로 오인하여 요청을 재시도하는 상태 불일치가 일어납니다.</p>
<p>셋째, 클라이언트 UI/UX의 전면적인 블로킹입니다. 25초 동안 화면은 로딩 상태에 묶이게 되며, 사용자의 다른 화면 이동이나 애플리케이션 이탈 시 HTTP 요청이 취소될 수 있어 서비스 이탈률을 높이는 원인이 될 수 있습니다.</p>
<p>&nbsp;</p>
<h1 id="👍-기다리게-하지-말고-나중에-확인하게-하자">👍 기다리게 하지 말고, 나중에 확인하게 하자</h1>
<p>25초를 줄이는 접근은 크게 두 가지로 생각할 수 있었습니다.</p>
<ol>
<li><p><strong>작업 자체를 빠르게:</strong> 기관별 호출을 병렬화하면 25초가 10초 정도로 줄어듭니다. 하지만 CODEF가 실제 은행에 접속하는 시간은 우리가 어쩔 수 없습니다. 10초도 여전히 길다고 느낄 사용자가 있을 수 있고 기관이 늘어나면 다시 길어져서 사용자 경험을 저하시킬 것입니다.</p>
</li>
<li><p><strong>기다리지 않게:</strong> 요청을 받자마자 접수했다고 응답하고, 실제 작업은 백그라운드에서 처리합니다. 클라이언트는 나중에 결과를 확인하면 됩니다.</p>
</li>
</ol>
<p>저는 후자를 택했습니다. 근본 원인(외부 API가 느리다는 것)을 없앨 수 없다면, 그 대기를 사용자에게 전가하지 않는 것이 맞다고 판단했기 때문입니다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-기술-선택-왜-async인가">🛠️ 기술 선택: 왜 @Async인가</h2>
<p>비동기 처리에는 여러 선택지가 있었습니다.</p>
<h3 id="1-webclient-→-배제">1. WebClient → 배제</h3>
<p><code>WebClient</code>는 리액티브 환경에서 쓰는 논블로킹 HTTP 클라이언트입니다. 하지만 우리 서비스는 Spring MVC(서블릿 기반) 구조입니다.</p>
<p>서블릿은 요청 하나당 스레드 하나가 할당되는 블로킹 모델이라, <code>WebClient</code>를 써도 결국 <code>.block()</code>으로 결과를 기다려야 합니다. 그러면 <code>RestTemplate</code>과 동작이 같아지고 의존성만 늘어나기 때문에 도입할 이유가 없었습니다.</p>
<p>리액티브의 이점은 전 구간이 논블로킹일 때 나옵니다. 중간에 블로킹이 하나라도 끼면 그 지점에서 스레드가 묶여 이점이 사라집니다.</p>
<h3 id="2-kafka--rabbitmq-→-배제">2. Kafka / RabbitMQ → 배제</h3>
<p>메시지 큐는 서비스 간 비동기 통신이나 대용량 이벤트 처리에 적합한 도구입니다.</p>
<p>우리 서비스는 단일 모놀리식 서버에서 하나의 비동기 작업을 처리하려는 것뿐입니다. 여기에 브로커를 도입하면 토픽 관리, 컨슈머 설정, 브로커 운영이라는 비용이 따라오는데 얻는 이점보다 훨씬 큽니다. 그리고 t3.small 인스턴스에 브로커를 하나 더 올릴 여유도 없었습니다.</p>
<h3 id="3-async--threadpooltaskexecutor-→-채택">3. @Async &amp; ThreadPoolTaskExecutor → 채택!!!</h3>
<p>일단 Spring이 기본 제공하는 기능이라 추가 의존성이 없습니다. 그리고 스레드 풀을 명시적으로 등록해 스레드 수를 제어하고 다른 비동기 작업과 격리할 수 있습니다.</p>
<p>여기서 중요한 점은 Spring의 기본 실행기인 <code>SimpleAsyncTaskExecutor</code>는 요청마다 새 스레드를 만들고 재사용하지 않는다는 것입니다. 그대로 두면 동시 요청이 몰릴 때 스레드가 무한히 생성될 우려가 있어서 전용 풀을 등록하기로 결정했습니다.</p>
<pre><code class="language-java">@Bean(&quot;assetSyncExecutor&quot;)
public Executor assetSyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);  // 상시 대기: 동시 2건은 즉시 처리
    executor.setMaxPoolSize(5);  // 최대: 요청이 몰려도 5건까지
    executor.setQueueCapacity(10);  // 대기열: 풀이 차도 10건까지 대기
    executor.setThreadNamePrefix(&quot;asset-sync-&quot;);
    executor.initialize();
    return executor;
}</code></pre>
<table>
<thead>
<tr>
<th>설정</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>corePoolSize</td>
<td>기본적으로 유지되는 스레드 수</td>
</tr>
<tr>
<td>maxPoolSize</td>
<td>큐가 가득 찼을 때 확장 가능한 최대 스레드 수</td>
</tr>
<tr>
<td>queueCapacity</td>
<td>코어 스레드가 모두 사용 중일 때 작업이 대기하는 큐의 크기</td>
</tr>
<tr>
<td>threadNamePrefix</td>
<td>스레드 이름 접두사 (로그에서 비동기 스레드를 식별할 때 유용)</td>
</tr>
<tr>
<td>keepAliveSeconds</td>
<td>코어 수를 초과한 유휴 스레드가 제거되기까지의 대기 시간</td>
</tr>
<tr>
<td>rejectedExecutionHandler</td>
<td>큐와 스레드 모두 가득 찼을 때의 거부 정책</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="✈️-rest-long-running-task-패턴을-이용한-api-설계">✈️ REST Long-Running Task 패턴을 이용한 API 설계</h2>
<p>HTTP의 <code>202 Accepted</code> 상태 코드가 제가 선택한 방법의 의도와 정확히 일치했습니다.</p>
<blockquote>
<p><code>202 Accepted</code>: 요청은 접수되었으나 처리가 완료되지 않았음. (RFC 9110, HTTP Semantics)</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="⬅️-before">⬅️ Before</h3>
<pre><code>POST /api/v1/assets/sync?memberId=1
    ↓ 25초 대기
200 OK { &quot;syncedCount&quot;: ?, &quot;failedCount&quot;: ?, &quot;institutions&quot;: [...] }</code></pre><p>&nbsp;</p>
<h3 id="➡️-after">➡️ After</h3>
<pre><code>POST /api/v1/assets/sync?memberId=1
    ↓ 즉시
202 Accepted
Location: http://.../api/v1/assets/sync/status/900b55dd-...
{ &quot;success&quot;: true, &quot;data&quot;: { &quot;jobId&quot;: &quot;900b55dd-...&quot; } }</code></pre><pre><code>GET /api/v1/assets/sync/status/900b55dd-...
    ↓ 진행 중
200 OK { &quot;data&quot;: { &quot;status&quot;: &quot;PENDING&quot;, &quot;errorMessage&quot;: null, &quot;resultUrl&quot;: null } }

    ↓ 완료 후
200 OK { &quot;data&quot;: { &quot;status&quot;: &quot;SUCCESS&quot;, &quot;errorMessage&quot;: null, &quot;resultUrl&quot;: &quot;/api/v1/assets/summary/1&quot; } }</code></pre><p><img src="https://velog.velcdn.com/images/rocker_nun/post/52965146-1fc3-4e14-8033-62091db03a63/image.png" alt=""></p>
<h3 id="🎛️-패턴의-구성-요소">🎛️ 패턴의 구성 요소</h3>
<table>
<thead>
<tr>
<th>요소</th>
<th>구현</th>
</tr>
</thead>
<tbody><tr>
<td>즉시 응답</td>
<td><code>ResponseEntity.accepted()</code> → 202</td>
</tr>
<tr>
<td>폴링 URL 안내</td>
<td><code>Location</code> 헤더</td>
</tr>
<tr>
<td>상태 조회</td>
<td><code>GET /assets/sync/status/{jobId}</code></td>
</tr>
<tr>
<td>결과 위치 안내</td>
<td><code>resultUrl</code> 필드</td>
</tr>
<tr>
<td>실패 사유</td>
<td><code>errorMessage</code> 필드</td>
</tr>
</tbody></table>
<p><strong><code>Location</code> 헤더를 넣는 이유가 중요합니다.</strong> 클라이언트가 <code>jobId</code>만 받으면 폴링 URL을 직접 조립해야 합니다. 서버가 URL을 알려주면 클라이언트는 URL 구조를 몰라도 되고, 나중에 경로가 바뀌어도 클라이언트 코드를 고칠 필요가 없습니다.</p>
<p><code>resultUrl</code>도 같은 원리입니다. 완료 후 어디서 결과를 가져올지 서버가 알려주니, API 경로 변경이 클라이언트로 전파되지 않습니다.</p>
<p>&nbsp;</p>
<h3 id="🪄-실제-프론트엔드-측-연동">🪄 실제 프론트엔드 측 연동</h3>
<pre><code class="language-jsx">async function pollAssetSyncStatus(jobId) {
  const deadline = Date.now() + SYNC_POLL_TIMEOUT_MS

  while (Date.now() &lt; deadline) {
    const response = await getAssetSyncStatus(jobId)
    const { status, errorMessage } = response.data

    if (status === SYNC_STATUS.SUCCESS) return
    if (status === SYNC_STATUS.FAILED) {
      throw new Error(errorMessage ?? &#39;자산 동기화에 실패했어요.&#39;)
    }

    await wait(SYNC_POLL_INTERVAL_MS)
  }

  throw new Error(&#39;자산 동기화가 지연되고 있어요. 잠시 후 다시 시도해주세요.&#39;)
}

...

export async function getAssetSyncStatus(jobId) {
  const { data } = await httpClient.get(`/api/v1/assets/sync/status/${jobId}`)
  return data
}</code></pre>
<blockquote>
<p>왜 폴링인가? SSE나 WebSocket으로 서버가 완료를 밀어줄 수도 있습니다. 다만 우리 상황에선 폴링이 나았습니다. 작업이 25초 내외로 끝나 폴링 횟수가 10여 회에 불과하고, 연결을 오래 유지하지 않아 서버가 상태를 들고 있지 않아도 되며(무상태 유지), 프론트 구현도 단순합니다. 실시간성이 중요한 채팅 같은 기능이라면 반대로 선택했을 겁니다.</p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🫢-트러블슈팅-async가-동작하지-않는다">🫢 트러블슈팅: @Async가 동작하지 않는다?</h2>
<p>가장 많이 시간을 쓴 부분입니다. <code>@Async</code>를 붙였는데 비동기로 동작하지 않았습니다. 여전히 25초를 기다렸습니다.</p>
<pre><code class="language-java">// ❌ 동작하지 않는 구조
@Service
public class AssetSyncServiceImpl implements AssetSyncService {

    public SyncJobResponse startSync(Long memberId) {
        String jobId = jobStore.createJob();
        this.doSyncAsync(memberId, jobId);  // 자기 호출
        return SyncJobResponse.builder().jobId(jobId).build();
    }

    @Async(&quot;assetSyncExecutor&quot;)  // 무시됨
    public void doSyncAsync(Long memberId, String jobId) {
        syncAccounts(memberId);
    }
}</code></pre>
<p>&nbsp;</p>
<h3 id="🫵🏻-원인-프록시를-거치지-않는-자기-호출">🫵🏻 원인: 프록시를 거치지 않는 자기 호출</h3>
<p>Spring의 <code>@Async</code>는 AOP 프록시로 동작합니다. 스프링은 <code>@Async</code>가 붙은 빈을 감싼 프록시 객체를 만들고, 그 프록시가 메서드 호출을 가로채 스레드 풀에 넘깁니다.</p>
<pre><code>정상 흐름
호출자 → [프록시] → 실제 객체
          ↑ 여기서 스레드 분기

자기 호출
실제 객체 내부에서 this.method()
                      ↑ 프록시를 안 거침 → 그냥 일반 메서드 호출</code></pre><p><code>this.doSyncAsync()</code>는 프록시가 아니라 원본 객체의 메서드를 직접 부릅니다. 프록시가 개입할 틈이 없으니 <code>@Async</code>는 아무 일도 하지 않습니다. 이건 <code>@Transactional</code>에서도 똑같이 발생하는 전형적인 문제입니다. 프록시 기반 AOP는 빈 경계를 넘을 때만 동작한다는 원리를 이해하면 두 경우 모두 설명됩니다.</p>
<p>&nbsp;</p>
<h3 id="🤩-해결-호출-경계를-빈-경계와-일치시키기">🤩 해결: 호출 경계를 빈 경계와 일치시키기</h3>
<p><code>@Async</code>가 반드시 프록시를 통과하도록 역할을 세 개의 빈으로 분리했습니다.</p>
<pre><code>[AssetController]
      ↓
[AssetSyncJobStarter]   jobId 생성 → Runner에 위임 → 즉시 리턴
      ↓  다른 빈 호출 → 프록시 통과
[AssetSyncJobRunner]    @Async 적용. 여기서 스레드가 분기됨
      ↓  다른 빈 호출 → 프록시 통과
[AssetSyncServiceImpl]  기존 @Transactional 동기화 로직 그대로</code></pre><p><code>jobRunner.runAsync()</code>를 호출하는 순간 <code>AssetSyncJobRunner</code>의 프록시가 개입해 <code>assetSyncExecutor</code> 풀에 작업을 넘기고 즉시 리턴합니다. <code>AssetSyncJobStarter.start()</code>는 <code>jobId</code>를 반환하고, 컨트롤러는 202를 바로 응답합니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1d547172-d463-45fc-a01a-97cd726820c0/image.png" alt=""></p>
<p>부수 효과도 좋았습니다. Runner가 Service를 프록시를 통해 호출하므로 기존 <code>@Transactional</code>도 정상 적용됩니다. 트랜잭션 경계를 건드리지 않고 비동기만 얹은 셈입니다.</p>
<p>책임을 나누려고 클래스를 쪼갠 게 아니라 프록시가 개입할 지점을 만들려고 쪼갰습니다. 처음엔 메서드 하나 때문에 클래스를 셋으로 나누는 게 과한가 싶었는데, 결과적으로 역할도 명확해졌습니다.</p>
<ul>
<li><code>Starter</code>: 작업 접수와 식별자 발급</li>
<li><code>Runner</code>: 비동기 실행과 상태 갱신</li>
<li><code>Service</code>: 실제 동기화 로직</li>
</ul>
<p>&nbsp;</p>
<h2 id="💾-작업-상태는-어디에-저장하나">💾 작업 상태는 어디에 저장하나</h2>
<p>비동기로 바꾸면 새로운 요구가 생깁니다. <strong>*&quot;이 작업이 지금 어디까지 진행됐는지&quot;*</strong> 를 어딘가에 저장해야 폴링 요청이 읽을 수 있습니다.</p>
<p>가장 단순한 방법은 그냥 <code>ConcurrentHashMap</code>를 사용하는 것이었습니다. 하지만 우리 인프라에서는 가용 영역을 두 곳을 두고 각각 API 서버를 배치하기 때문에 의미가 없었습니다.</p>
<pre><code>동기화 요청 → ALB → API 서버 #1  (자기 메모리에 상태 저장)
폴링 요청 → ALB → API 서버 #2  (그런 작업 없음)</code></pre><p>로드밸런서가 요청을 분산하니 폴링이 다른 서버로 갈 수 있습니다. 두 서버는 별개의 JVM 프로세스라 당연히 서로의 메모리를 볼 수 없습니다. 서버가 한 대였다면 충분했을 방법이, 이중화하면서 쓸 수 없게 된 것입니다.</p>
<p>&nbsp;</p>
<h3 id="😁-redis를-선택한-이유">😁 Redis를 선택한 이유</h3>
<p>Redis는 애플리케이션 안의 라이브러리가 아니라 <strong>독립적으로 떠 있는 별도 서버</strong>입니다. 세 서버(API 서버 2대, 배치 서버 1대)가 같은 엔드포인트를 바라보니 자연스럽게 공유됩니다. MySQL을 공유하는 것과 같은 원리인데, 메모리 기반이라 훨씬 빠릅니다.</p>
<p>DB 컬럼에 저장할 수도 있었지만 두 가지가 걸렸습니다. 폴링이 2초마다 오는데 매번 DB를 때리는 부담이 있고, 진행 상태처럼 곧 사라질 임시 데이터를 영구 저장소에 두는 것도 과하다고 생각했습니다.</p>
<p>&nbsp;</p>
<h3 id="🗝️-키-설계">🗝️ 키 설계</h3>
<pre><code>asset:sync:job:{jobId}:status → PENDING / SUCCESS / FAILED (TTL 10분)
asset:sync:job:{jobId}:error → 에러 메시지 (FAILED일 때만) (TTL 10분)
asset:sync:job:{jobId}:result → 결과 조회 URL (SUCCESS일 때만) (TTL 10분)</code></pre><p>동기화 완료 후 클라이언트가 상태를 확인할 시간은 충분히 주되, 완료된 작업이 Redis에 무기한 누적되는 건 막아야 했기 때문에 TTL을 10분으로 설정했습니다. </p>
<p>만료된 <code>jobId</code>는 404로 처리했습니다. Redis에 키가 없으면 만료됐거나 존재하지 않는 작업이므로 <code>ASSET_SYNC_JOB_NOT_FOUND</code>를 반환합니다. TTL을 쓰면 *&quot;없음&quot;* 과 *&quot;만료됨&quot;* 이 구분되지 않는데, 클라이언트 입장에선 어차피 결과를 못 받는 건 같으니 하나로 합쳤습니다.</p>
<p>&nbsp;</p>
<h2 id="🎉-결과">🎉 결과</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기존 (동기)</th>
<th>변경 후 (비동기)</th>
</tr>
</thead>
<tbody><tr>
<td>1회차</td>
<td>25.10초</td>
<td>0.029초</td>
</tr>
<tr>
<td>2회차</td>
<td>29.40초</td>
<td>0.031초</td>
</tr>
<tr>
<td>3회차</td>
<td>23.11초</td>
<td>0.028초</td>
</tr>
<tr>
<td><strong>평균</strong></td>
<td><strong>25.87초</strong></td>
<td><strong>0.029초</strong></td>
</tr>
</tbody></table>
<p>응답 시간이 약 890배 빨라졌습니다(?). 다만 정확히 말하면 작업 자체가 빨라진 게 아닙니다. CODEF 호출은 여전히 25초가 걸립니다. 달라진 건 그 25초를 사용자가 기다리지 않게 된 것입니다. 동기화가 도는 동안 다른 화면을 보거나 앱을 닫아도 됩니다. 네트워크가 끊겨도 결과를 확인할 수 있습니다. <code>jobId</code>만 있으면 다시 접속해 상태를 조회하면 됩니다. 이게 사실 응답 속도보다 중요한 개선이라고 생각합니다.</p>
<p>&nbsp;</p>
<h2 id="📝-남은-과제">📝 남은 과제</h2>
<p><strong>중복 요청 처리:</strong> 응답이 즉시 오니 사용자가 버튼을 여러 번 누르기 더 쉬워졌습니다. 회원 단위 분산 락으로 중복 실행을 막고 있지만, 이미 진행 중인 작업이 있으면 새 <code>jobId</code>를 발급하는 대신 기존 <code>jobId</code>를 돌려주는 방식이 더 자연스러울 것 같습니다.</p>
<p><strong>서버 재시작 시 유실:</strong> 백그라운드 작업 도중 서버가 내려가면 그 작업은 사라지고 상태는 <code>PENDING</code>인 채 TTL로 만료됩니다. 클라이언트는 영원히 완료를 못 봅니다. 작업에 시작 시각을 남기고 일정 시간이 지나면 <code>FAILED</code>로 간주하는 처리가 필요합니다.</p>
<p><strong>기관별 병렬 호출:</strong> 지금도 백그라운드에서는 25초가 걸립니다. 기관별 호출을 병렬화하면 10초 정도로 줄어들 텐데, 사용자 체감에는 영향이 없어 우선순위를 낮췄습니다. 다만 CODEF의 호출 제한을 확인한 뒤 적용해볼 만합니다.</p>
<p><strong><code>@Async</code> 예외 처리:</strong> 반환 타입이 <code>void</code>인 <code>@Async</code> 메서드에서 예외가 나면 <strong>호출자에게 전파되지 않고 삼켜집니다.</strong> 지금은 Runner 내부에서 try-catch로 잡아 Redis에 <code>FAILED</code>를 기록하고 있지만, <code>AsyncUncaughtExceptionHandler</code>를 등록해 두는 편이 더 안전합니다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em></p>
<ul>
<li><a href="https://www.rfc-editor.org/rfc/rfc9110#name-202-accepted">RFC 9110 — HTTP Semantics, 15.3.3. 202 Accepted</a></li>
<li><a href="https://restfulapi.net/rest-api-design-for-long-running-tasks/">REST API Design for Long-Running Tasks</a></li>
<li><a href="https://docs.spring.io/spring-framework/reference/integration/scheduling.html">Spring Framework Reference — Task Execution and Scheduling</a></li>
<li><a href="https://devpanpan.tistory.com/106">Long-Running 작업을 다루기 위한 지침들</a></li>
<li><a href="https://dmoritle.tistory.com/197">[Spring] Spring의 비동기 처리 — @Async부터 Virtual Thread 통합까지</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[최소 권한 원칙과 보안 그룹]]></title>
            <link>https://velog.io/@rocker_nun/%EC%B5%9C%EC%86%8C-%EA%B6%8C%ED%95%9C-%EC%9B%90%EC%B9%99%EA%B3%BC-%EB%B3%B4%EC%95%88-%EA%B7%B8%EB%A3%B9</link>
            <guid>https://velog.io/@rocker_nun/%EC%B5%9C%EC%86%8C-%EA%B6%8C%ED%95%9C-%EC%9B%90%EC%B9%99%EA%B3%BC-%EB%B3%B4%EC%95%88-%EA%B7%B8%EB%A3%B9</guid>
            <pubDate>Thu, 30 Jul 2026 04:41:41 GMT</pubDate>
            <description><![CDATA[<h1 id="👁️-최소-권한의-원칙이란">👁️ 최소 권한의 원칙이란?</h1>
<p>보안에서 <strong>최소 권한의 원칙(PoLP, Principle of Least Privilege)</strong>이라는 아주 중요한 법칙이 있다. 이 원칙은 다양한 클라우드 플랫폼에서 필수적인 보안 정책으로 활용되고 있다. 이 원칙은 말 그대로 무언가에 접근할 때 필요한 최소한의 권한만을 가지도록 제한하는 보안 원칙이다.</p>
<p>그럼 지금까지 만들어놓은 인스턴스들이 최소 권한의 원칙을 제대로 지키고 있는지 직접 확인해보도록 하자. Bastion Host의 인바운드 규칙을 살펴보면, 모든 IP에 대해 22번, 80번 포트로 접속할 수 있도록 허용해 둔 상태다. 즉, 어떤 곳에서든 Bastion Host의 SSH로 접속할 수 있도록 권한이 설정되어 있다는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6191c3a2-5139-48cf-8107-9c847269f62f/image.png" alt=""></p>
<p>근데 생각해보면 모든 사용자가 Bastion Host로 접속할 수 있도록 하면 안 된다. Bastion Host를 통해 EC2나 RDS 같은 자원에 접속해야 하는 어쩔 수 없는 상황에서, 관련된 관리자만 접속할 수 있도록 해야 한다. 지금은 최소 권한의 원칙을 어기고 있는 상황인 것이다.</p>
<p>&nbsp;</p>
<h2 id="🔒-보안-그룹이란">🔒 보안 그룹이란?</h2>
<p><strong>보안 그룹(Security Group)</strong>은 AWS 클라우드에서의 네트워크 보안을 뜻한다. AWS 리소스(EC2, RDS, ALB 등)를 집이라고 가정한다면, 보안 그룹은 집 주변에 설치된 울타리와 대문이라고 말할 수 있다. 바깥쪽에서 보안 그룹 안쪽으로 들어갈 때나 보안 그룹에서 바깥쪽으로 나올 때, 대문에서 지나가도 되는 트래픽인지를 검사하는 것이다. </p>
<p>보안 그룹은 보안 그룹의 규칙을 지정할 수 있다. 이 보안 그룹의 규칙에서는 트래픽으로 크게 2가지를 얘기하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f3420530-9252-4785-9863-8c180e55e8c3/image.png" alt=""></p>
<ul>
<li><strong>Inbound Traffic</strong>: 보안 그룹의 외부에서 보안 그룹 안쪽으로 들어가는 트래픽</li>
<li><strong>Outbound Traffic</strong>: 보안 그룹 안쪽에서 보안 그룹의 외부로 빠져나가는 트래픽</li>
</ul>
<p>따라서 각각의 경우에 대해 허용할 IP 범위와 포트를 가지고 설정해야 한다. 보안 그룹은 하나의 AWS 리소스에 대해서도 설정 가능하고, 여러 개의 리소스에 대해서도 보안 그룹을 묶어서 설정할 수도 있고, 심지어는 서브넷이나 가용 영역에 관계없이 크게 묶어서 보안 그룹을 설정할 수도 있다. 이제 기존에 생성해놓은 다른 AWS 리소스들을 하나씩 살펴보면서 최소 권한의 원칙을 지켰는지 살펴보도록 하자. </p>
<p>&nbsp;</p>
<h3 id="🔑-보안-그룹의-두-가지-핵심-특성">🔑 보안 그룹의 두 가지 핵심 특성</h3>
<p>리소스별 규칙을 만지기 전에, 보안 그룹의 중요한 특성 두 가지를 짚고 넘어가자. 이 두 가지를 알면 앞으로 왜 인바운드 규칙만 계속 건드리는지, 왜 IP 대신 다른 보안 그룹을 소스로 지정하는지가 자연스럽게 이해된다.</p>
<p><strong>① 보안 그룹은 Stateful하다.</strong>
인바운드로 허용된 요청에 대한 응답 트래픽은 아웃바운드 규칙과 상관없이 자동으로 허용된다. 예를 들어 ALB가 인바운드로 받은 HTTP 요청을 처리하고 응답을 돌려보낼 때, 그 응답은 아웃바운드 규칙을 따로 열어주지 않아도 나갈 수 있다. 그래서 이 글에서는 대부분 <strong>인바운드 규칙만</strong> 신경 쓰면 된다. (참고로 서브넷 단위로 동작하는 네트워크 ACL은 이와 달리 Stateless해서, 인바운드와 아웃바운드를 모두 명시해야 한다.)</p>
<p><strong>② 소스로 IP 대신 다른 보안 그룹을 지정할 수 있다.</strong>
인바운드 규칙의 소스에 특정 IP 대역이 아니라 다른 보안 그룹을 지정하면, 그 보안 그룹이 적용된 리소스로부터의 요청만 허용하겠다는 의미가 된다. 이 방식이 강력한 이유는, 백엔드 서버의 IP가 바뀌더라도(재배포, 오토스케일링 등) 규칙을 고칠 필요가 없기 때문이다. ALB로부터 오는 트래픽 허용처럼 <strong>관계 자체를 규칙으로 표현</strong>하기 때문에, 최소 권한과 유지보수성을 동시에 잡을 수 있다.</p>
<p>이제 기존에 생성해놓은 AWS 리소스들을 하나씩 살펴보면서 최소 권한의 원칙을 지켰는지 점검해보자.</p>
<p>&nbsp;</p>
<h3 id="🔀-로드-밸런서의-보안-규칙">🔀 로드 밸런서의 보안 규칙</h3>
<p>먼저 로드 밸런서를 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2015ff44-2445-4cf9-92b0-d72b81ddab42/image.png" alt=""></p>
<p>현재 ALB는 외부 사용자가 인터넷을 통해서 보내는 HTTP 요청을 받고 있다. </p>
<p>리스너 및 규칙을 살펴보면 ELB로 들어올 수 있는 요청은 HTTP 프로토콜, 즉 80번 포트 밖에 없기 때문에 보안 그룹의 인바운드 규칙도 HTTP 80번 포트에 대해서만 설정하면 된다. 또한, 로드 밸런서는 모든 사용자로부터 요청을 받을 수 있어야 하기 때문에 모든 IPv4에 대해서 트래픽을 허용하면 된다. 향후 HTTPS 요청을 받을 수 있다는 요구사항이 추가되면 그에 대해서도 보안 규칙을 설정해주면 된다. 지금은 최소 권한의 원칙에 따라 HTTPS에 대한 인바운드 규칙은 제거해주도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/677fb248-95e8-4d01-8eec-703afd390248/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/61deb457-b3f1-455f-93e3-997f35517fbd/image.png" alt=""></p>
<p>보안 그룹 수정 후 로드 밸런서로 요청을 보내보면 응답이 정상적으로 오는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/025dd4f8-55f6-4255-89c6-7ae5a3152337/image.png" alt=""></p>
<p>그럼 혹시 보안 그룹에 포함되지 않은 리소스가 요청을 보내면 어떻게 될까? 인바운드 규칙에서 HTTP에 대한 규칙도 지우고 다시 로드 밸런서에 요청을 보내면 아래와 같이 계속 로딩만 되다가 타임아웃이 발생하는 것을 볼 수 있다. 인프라를 구성하다가 이렇게 응답이 오지 않는다면 보안 규칙을 다시 한번 들여다 볼 필요가 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b574af71-cec5-4269-b6ec-10038709e461/image.png" alt=""></p>
<p><del>확인이 끝났으면 방금 지운 HTTP 80번 인바운드 규칙은 다시 복구해두자. (모든 IPv4 → HTTP 80)</del></p>
<p>&nbsp; </p>
<h3 id="👮🏻-bastion-host의-보안-규칙">👮🏻 Bastion Host의 보안 규칙</h3>
<p>이번에는 Bastion Host의 보안 그룹을 수정해보자. 현재 Bastion Host로 접근하는 주체는 인프라의 리소스들을 간접적으로 접근해서 관리해야 하는 관리자다. 따라서 인바운드 규칙에 개발자나 관리자의 IP 주소에 대해서만 허용해줘야 한다. 그리고 Bastion Host는 SSH로 접속하는 용도로만 쓰이기 때문에 22번 포트에 대해서만 인바운드 규칙을 추가해주도록 하자. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/925b70f1-604a-4887-b82b-eab4526c8038/image.png" alt=""></p>
<p>지금은 내 컴퓨터에서의 접근만 허용해주도록 설정했다. 다른 관리자의 접근도 허용하고 싶다면 그 관리자의 IP 주소도 아래에 추가해주면 된다.</p>
<p>이제 설정한 보안 그룹을 Bastion Host에 적용하고 확인해보면 잘 접속되는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f9ea4b18-cdf6-467e-bb08-bb764a9f1a66/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a484294e-1281-4ca2-8dd8-a4cc24633599/image.png" alt=""></p>
<p>&nbsp;</p>
<h3 id="💻-백엔드-서버의-보안-규칙">💻 백엔드 서버의 보안 규칙</h3>
<p>이제 Private Subnet에 있는 백엔드 서버들의 보안 규칙을 설정해보도록 하자. 일단 백엔드 서버로 접근해오는 경로들을 살펴보면 2가지다.</p>
<ol>
<li>Bastion Host가 EC2 인스턴스로 접근 (SSH 22번)</li>
<li>ALB가 사용자로부터 전달받은 트래픽을 EC2 인스턴스로 전달 (HTTP 80번)</li>
</ol>
<p>SSH로 접근하는 규칙은 앞서 생성해놓은 <strong>Bastion Host의 보안 그룹</strong>을 소스로, HTTP로 접근하는 규칙은 <strong>ALB의 보안 그룹</strong>을 소스로 지정해주면 된다. 앞서 설명한 소스로 다른 보안 그룹 지정 방식을 그대로 활용하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f6fff8fb-a2c3-40bc-b8ff-3c10222b1f2e/image.png" alt=""></p>
<p>이제 생성한 보안 그룹을 각각의 백엔드 서버의 보안 그룹으로 설정하고 요청이 제대로 오는지 직접 확인해보도록 하자. 먼저 ALB에서 요청이 오는 경우에 대해 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/50ce34da-6f59-4b79-854f-eff2b8e5a35c/image.png" alt=""></p>
<p>정상적으로 데이터가 잘 조회되는 것을 볼 수 있다. 다음은 Bastion Host에서 백엔드 서버로 직접 HTTP 요청을 보내봤을 때도 잘 응답하는지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9984046e-7396-43b9-8a8e-0c0e5b4451c5/image.png" alt=""></p>
<p>응답이 오지 않는다. 현재 백엔드 서버의 HTTP 인바운드 규칙은 <strong>ALB 보안 그룹</strong>만 소스로 허용하고 있기 때문에, ALB를 거치지 않고 Bastion Host에서 직접 보낸 HTTP 요청은 차단되는 것이다. 보안 그룹에 막힌 것이 맞는지 확인하기 위해, 백엔드 서버의 HTTP 인바운드 규칙을 잠시 모든 요청으로부터 허용하도록 수정했더니 아래와 같이 응답이 잘 오는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c39384fe-f6b6-4e9d-b458-281a44c1eb21/image.png" alt=""></p>
<p>&nbsp;</p>
<h3 id="💾-rds-인스턴스의-보안-규칙">💾 RDS 인스턴스의 보안 규칙</h3>
<p>현재 RDS로 들어오는 요청들의 경로를 살펴보자.</p>
<ol>
<li>Bastion Host가 접근 (관리, 디버깅 목적)</li>
<li>백엔드 서버가 RDS 인스턴스에 접근 (실제 서비스 쿼리)</li>
</ol>
<p>RDS는 실제 사용자로부터 직접 요청을 받지 않고 백엔드 서버로부터만 요청을 받는다. 따라서 3306번 포트에 대해 허용해야 하는 주체는 <strong>Bastion Host와 백엔드 서버</strong> 둘뿐이다. 여기서도 IP가 아니라 보안 그룹을 소스로 지정한다. Bastion Host의 보안 그룹과 practice-server 보안 그룹을 각각 3306번 포트의 소스로 설정해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2a69fc27-74fb-452c-9840-7c13c776840b/image.png" alt=""></p>
<p>확인해보면 RDS에 정상적으로 접근되는 것을 확인할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8b6cd546-62f0-4749-a2a9-17664fcb1799/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AZ 하나가 통째로 죽어도 살아남는 서비스 만들기]]></title>
            <link>https://velog.io/@rocker_nun/AZ-%ED%95%98%EB%82%98%EA%B0%80-%ED%86%B5%EC%A7%B8%EB%A1%9C-%EC%A3%BD%EC%96%B4%EB%8F%84-%EC%82%B4%EC%95%84%EB%82%A8%EB%8A%94-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/AZ-%ED%95%98%EB%82%98%EA%B0%80-%ED%86%B5%EC%A7%B8%EB%A1%9C-%EC%A3%BD%EC%96%B4%EB%8F%84-%EC%82%B4%EC%95%84%EB%82%A8%EB%8A%94-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 30 Jul 2026 04:19:05 GMT</pubDate>
            <description><![CDATA[<h1 id="🏭-가용성이란">🏭 가용성이란?</h1>
<p><strong>가용성(Availability)</strong>이란 시스템이 서비스를 정상적으로 제공할 수 있는 가능성을 말한다. 가용성은 서비스의 정상 가동률(%)로 표시되는데, 예를 들어, 가용성이 99.99%라면 99.99%의 시간을 정상적으로 이용 가능한 시스템을 말한다. </p>
<p>고가용성 시스템을 만들기 위해서는 시스템을 이중화 시키는 것이 중요하다. 시스템 이중화란 시스템의 일부분을 사용할 수 없게 되어도 다른 시스템을 이용하여 서비스를 계속해서 이용할 수 있도록 만드는 것을 말한다. 고가용성 시스템을 만들기 위해 시스템 이중화뿐만 아니라 가용 영역에 대해서도 신경을 써야 한다. AWS는 자연 재해나 예상치 못한 사고로 데이터 센터가 파괴될 가능성을 고려해서 물리적으로 데이터 센터를 분리해놓았다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7d2c40b8-ae82-4806-a4c1-df4dd0ba37f9/image.png" alt=""></p>
<p>위 그림과 같이 분리된 각각의 데이터 센터를 가용 영역(AZ)이라고 한다. 이 가용 영역을 활용해서 시스템의 가용성을 높여 서비스의 장애 확률을 떨어뜨릴 수 있다. 지금까지 실습을 진행하면서는 가용 영역 1개 안에 모든 인프라를 구성했었다. 이러면 해당 가용 영역에 문제가 생겼을 때 복구되기 전까지는 서비스가 중단되는 치명적인 상황이 벌어질 수 있다. 따라서 안정성이 중요한 기업의 경우, 가용 영역을 2개 이상 활용하여 인프라를 구성한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0bbe46f1-b4a6-4982-9b6f-3471b2d6db98/image.png" alt=""></p>
<p>위와 같이 가용 영역을 2개를 활용한 구조에서 만약 ap-northeast-2a 가용 영역에 장애가 나면 ap-northeast-2b 가용 영역에 있는 백엔드 서버로 요청이 가기 때문에 장애 확률이 떨어지는 것이다. </p>
<p>&nbsp;</p>
<h2 id="🏗️-가용-영역-추가하기">🏗️ 가용 영역 추가하기</h2>
<p>새로운 Public Subnet과 Private Subnet을 추가하고 가용 영역을 ap-northeast-2b로 설정해주자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fcd03ccb-9e5e-466a-9297-20c42e993ad2/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/97bdf802-6908-4ae7-9a9d-de23b8e89cb7/image.png" alt=""></p>
<p>Internet Gateway는 이미 VPC에 붙여 놓았기 때문에 추가로 붙여줄 필요는 없다. 반면, NAT Gateway는 서브넷 단위로 동작한다.</p>
<p>여기서 한 가지 짚고 넘어갈 점이 있다. NAT Gateway는 <strong>가용 영역마다 하나씩</strong> 두는 것이 원칙이다. 만약 ap-northeast-2b의 Private Subnet이 ap-northeast-2a에 있는 NAT Gateway를 함께 사용하도록 구성하면, ap-northeast-2a 가용 영역에 장애가 발생했을 때 ap-northeast-2b의 아웃바운드 통신까지 함께 끊긴다. 애써 가용 영역을 나눈 의미가 사라지는 것이다. 따라서 <strong>AZ 간 장애를 격리하기 위해</strong> 새로운 가용 영역(ap-northeast-2b)에 대한 NAT Gateway를 별도로 생성해준다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/36a9008d-eb0b-4cd0-9a53-2716f36abd05/image.png" alt=""></p>
<p>기존에 생성된 퍼블릭 라우팅 테이블의 라우팅 정책을 별도로 수정할 게 없기 때문에, 새로 생성한 퍼블릭 서브넷은 기존 퍼블릭 라우팅 테이블을 같이 사용해도 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9686fdc9-1064-4c6d-bb5d-7d6818ad18ef/image.png" alt=""></p>
<p>반면, 기존 프라이빗 라우팅 테이블은 ap-northeast-2a의 가용 영역에 있는 NAT 게이트웨이로 라우팅을 하고 있다. 새로 만든 프라이빗 서브넷은 ap-northeast-2b의 가용 영역에 있는 NAT 게이트웨이로 라우팅을 해야하기 때문에 새로운 라우팅 테이블을 만들어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/67c770d9-a5ab-4ea6-aed4-487e7af6ba29/image.png" alt=""></p>
<p>이제 구성이 끝났다. 새로운 가용 영역의 Private Subnet 내부에 EC2 인스턴스(백엔드 서버)를 배치해보도록 하자. 일단 인스턴스를 새로 생성해주자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/01eea679-c420-4cbb-aae4-cc100782cc97/image.png" alt=""></p>
<p>Bastion Host를 통해 가용 영역 ap-northeast-2b의 Private Subnet 내부에 인스턴스에 접근해보면 아래처럼 정상적으로 접근 가능한 것을 확인할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/312dcd9d-437e-4891-8b10-7add6b1df8f1/image.png" alt=""></p>
<p>&nbsp;</p>
<h1 id="💾-private-subnet에-rds-인스턴스-추가">💾 Private Subnet에 RDS 인스턴스 추가</h1>
<p>이번엔 각각의 가용 영역의 Private Subnet 내부에 RDS 인스턴스를 추가해보도록 하자. 이를 위해 RDS 메뉴에 들어가서 서브넷 그룹을 생성해주어야 한다. RDS를 VPC의 서브넷에 배치하기 위해서는 서브넷 그룹 생성이 필수적이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b09d076c-8195-4c30-b93a-8797eafdf297/image.png" alt=""></p>
<p>이제 보안 그룹을 생성해줘야 한다. EC2 페이지로 이동하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/10bc8fb8-20c3-4480-bf4c-cf85ace96cf9/image.png" alt=""></p>
<p>이제 RDS 인스턴스를 생성하면 된다. 여러 설정을 해주면 되는데, 현재 DB를 Private Subnet에 배치할 것이기 때문에 퍼블릭 액세스를 <em>“아니오”</em> 로 설정해주도록 하자. 그리고 추가 구성에서 해당 RDS 인스턴스가 생성되면서 practice 데이터베이스를 자동으로 생성하도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5aa2fd3f-1ba8-46f0-8303-f137492c6bca/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d36bedbb-5424-4334-b364-772698c499c2/image.png" alt=""></p>
<p>이제 생성된 RDS에 접속해보도록 하자. 접속은 MySQL Workbench를 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/efca01ab-8271-45d5-9b90-591b4df946a0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/43825072-0345-410c-95e5-fdce6fd6dee8/image.png" alt=""></p>
<p>근데 연결이 되지 않는다. 당연하게도 RDS 인스턴스가 현재 VPC의 Private Subnet에 구축되어 있기 때문이다. 즉, 외부 인터넷에서 직접 접근할 수 없다는 말이다. 그렇다면 같은 VPC 내부에 있는 Bastion Host를 통해 간접적으로 접근할 수 있지 않을까?</p>
<p>이때 사용하는 방법이 <strong>SSH 터널링(SSH Tunneling)</strong>이다. 내 로컬 컴퓨터에서 RDS로 직접 연결하는 대신, 먼저 Bastion Host에 SSH로 접속하고, 그 연결을 통로 삼아 RDS로 트래픽을 흘려보내는 방식이다. MySQL Workbench의 경우 연결 방식을 <code>Standard TCP/IP over SSH</code>로 설정하면 된다.</p>
<ul>
<li><strong>SSH Hostname</strong>: Bastion Host의 Public IP</li>
<li><strong>SSH Key File</strong>: Bastion Host 접속용 키페어(.pem)</li>
<li><strong>MySQL Hostname</strong>: RDS 엔드포인트 (Bastion 입장에서 접근하는 주소이므로 Private 경로로 해석됨)</li>
</ul>
<p>즉, 외부에서 볼 때는 Bastion Host에만 접속하는 것처럼 보이지만, 실제로는 그 뒤에 숨어 있는 Private Subnet의 RDS까지 안전하게 닿는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b40d25d7-8570-404f-bf5a-3c8d70076f0f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3dbe90bf-fbbe-4448-b928-77a0cbbd2d2d/image.png" alt=""></p>
<p>이제 EC2 인스턴스에 스프링부트 애플리케이션을 배포해보자. Bastion Host에서 Private Subnet에 있는 practice-server-a 인스턴스에 접속해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3963fe32-7066-4e03-929e-1d5a47661d57/image.png" alt=""></p>
<p>이제 JDK 17 버전을 설치해주도록 하자.</p>
<pre><code class="language-bash">sudo apt update
sudo apt install openjdk-17-jdk -y</code></pre>
<p>그리고 Github에서 스프링부트 프로젝트를 클론해와서 <code>application.yml</code> 파일을 아래와 같이 수정 후 빌드를 진행해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2f650c4a-9003-493c-a4b4-ea50a4ab893a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/39da43e4-dae1-4d0d-a841-4ccd6fcfeffa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/92c5ff93-930c-4077-af63-cc9b1c884d0a/image.png" alt=""></p>
<p>빌드 후 백그라운드에서 스프링부트 프로젝트를 실행하고 헬스체크 및 간단한 요청을 보낸 후, DB를 확인해보면 데이터가 정상적으로 들어간 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/eef6fa3c-e4d3-4402-a704-c5390f0786c8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/83919855-299a-4707-80f0-57e739426aa5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/da4317a3-a198-4f0c-9f50-0b82017ebd3a/image.png" alt=""></p>
<p>이로써 가용 영역 ap-northeast-2a의 Private Subnet 내부에 있는 EC2 인스턴스에 백엔드 서비스를 성공적으로 배포한 것이다. 다른 가용 영역 ap-northeast-2b의 Private Subnet 내부의 EC2 인스턴스에도 위와 동일한 과정을 거쳐 배포하면 된다.</p>
<p>근데 생각해보니 일반 사용자가 백엔드 API 서버에 요청을 보내기 위해 Bastion Host를 거치게 만들 수는 없다. 그래서 일반 사용자도 백엔드 서버에 직접 요청을 보낼 수 있게 만들어 주는 것이 중요하다. 이를 위해 로드 밸런서를 도입하도록 하자.</p>
<p>&nbsp;</p>
<h1 id="🚥-elb-도입하기">🚥 ELB 도입하기</h1>
<p>이제 <strong>ELB(Elastic Load Balancer)</strong>를 추가해서 사용자가 외부 인터넷을 통해서 Private Subnet에 있는 백엔드 서버로 요청을 보낼 수 있게 만들고, 두 인스턴스의 트래픽을 골고루 분산시킬 수 있도록 만들어보자.</p>
<p>참고로 ELB는 상위 개념이고, 실제로 생성할 때는 용도에 따라 유형을 골라야 한다. 지금처럼 HTTP 기반의 백엔드 API 요청을 분산하는 경우에는 <strong>ALB(Application Load Balancer)</strong>를 사용한다. (TCP/UDP 레벨의 분산이 필요하면 NLB, 예전 방식은 CLB다.)</p>
<p>그러기 위해서는 먼저 보안 그룹을 만들어줘야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e0482733-e59e-48d0-ab07-bea57d325369/image.png" alt=""></p>
<p>그 후, 로드 밸런서의 기본 옵션을 설정해줘야 한다. 네트워크 매핑 탭에서 practice-vpc, 서브넷을 2개의 Public Subnet으로 설정하고, 대상 그룹도 설정하도록 하자. 로드 밸런서 자체는 Public Subnet에 위치해 외부 요청을 받고, 대상 그룹으로는 Private Subnet에 있는 백엔드 EC2를 지정한다. 이렇게 하면 사용자는 ALB까지만 접근하고, 실제 서버는 여전히 외부에 노출되지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e38c5d5b-e809-4353-89f1-395c853a08ff/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4424a24a-57df-48a2-abe5-f5ad2b4bafda/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6c05829b-c35c-460b-88b1-44f393cfb562/image.png" alt=""></p>
<p>이제 로드 밸런서 생성 페이지로 돌아와서 아래와 같이 생성한 대상 그룹을 추가하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/968722eb-822e-414b-af95-fbb7d826476b/image.png" alt=""></p>
<p>생성된 로드 밸런서가 실제로 2대의 EC2 인스턴스에 트래픽을 분산시킬 수 있는지 직접 Postman으로 확인해보자. ALB의 DNS 주소로 요청을 반복해서 보내니, 요청이 두 인스턴스로 번갈아 전달되며 각각에서 정상적으로 헬스 체크 응답이 오는 것을 볼 수 있었다. (각 응답에 찍힌 서버 식별 정보를 통해 서로 다른 인스턴스가 처리하고 있음을 확인할 수 있다.)</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/121a21ab-c2bc-4589-b13c-76b345487b22/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e8e2a83f-1182-4d67-8f0d-7eb1d82c0994/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9a062595-97e0-4284-8f90-87ed7f928e94/image.png" alt=""></p>
<p>이렇게 가용 영역을 2개로 나누고 로드 밸런서를 통해 트래픽을 분산시켜 가용성을 높일 수 있었다. 하지만 여전히 아쉬운 점은 아래 ap-northeast-2a의 가용 영역이 자연 재해로 인해 고장난다면, RDS 인스턴스가 작동하지 않으면서 서비스의 중단이 일어난다는 점이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/36f355aa-94fb-49bf-b1b9-8d720af782c9/image.png" alt=""></p>
<p>이를 방지하기 위해 EC2 인스턴스(백엔드 서버)를 서로 다른 가용 영역에 배치한 것처럼, RDS 인스턴스(데이터베이스 서버)를 서로 다른 가용 영역에 배치할 수 있다. 이 기능을 보고 다중 AZ 배포라고 한다. </p>
<p>&nbsp;</p>
<h1 id="🗺️-다중-az-배포">🗺️ 다중 AZ 배포</h1>
<p><strong>다중 AZ(Multi AZ) 배포</strong>란 다른 가용 영역에 RDS 인스턴스의 복제본을 생성해두는 걸 의미한다. 두 데이터베이스를 구분하기 위해 원본 DB를 <strong>Primary DB</strong> 또는 <strong>Master DB</strong>라고 부르고, 복제본 DB를 <strong>Standby DB(예비 데이터베이스)</strong>라고 부른다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b57d44b0-d4a2-4797-80ea-f0e737a6d58c/image.png" alt=""></p>
<p>Primary DB에 데이터가 추가됨에 따라 Standby DB에도 똑같이 데이터가 동기화된다. 만약 Primary DB에 장애가 발생할 경우, Standby DB로 자동으로 교체(Failover)해서 사용한다.</p>
<p>여기서 한 가지 오해하기 쉬운 점이 있다. <strong>Standby DB는 평상시에 읽기 요청조차 처리하지 않는다.</strong> 오로지 Primary가 죽었을 때를 대비해 대기만 하는 순수 예비 인스턴스다. 따라서 다중 AZ 배포는 읽기 성능 향상이 아니라 <strong>장애 대비(가용성)</strong>를 위한 기능이다. 읽기 부하를 분산하고 싶다면 그건 Read Replica라는 별도의 기능을 써야 한다.</p>
<p>이제 직접 RDS에 다중 AZ 배포 설정을 해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/25c95e1d-9e1f-4a15-a7be-775ca1becce0/image.png" alt=""></p>
<p>다만, 이 다중 AZ 배포 기능은 기존 RDS를 복제해서 2개의 RDS 인스턴스를 활용하는 기능이기 때문에 비용이 2배 발생한다. 이 점을 주의하도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NAT Gateway와 Bastion Host로 Private Subnet 다루기]]></title>
            <link>https://velog.io/@rocker_nun/NAT-Gateway%EC%99%80-Bastion-Host%EB%A1%9C-Private-Subnet-%EB%8B%A4%EB%A3%A8%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/NAT-Gateway%EC%99%80-Bastion-Host%EB%A1%9C-Private-Subnet-%EB%8B%A4%EB%A3%A8%EA%B8%B0</guid>
            <pubDate>Tue, 28 Jul 2026 00:30:10 GMT</pubDate>
            <description><![CDATA[<h1 id="🪧-nat-gateway란">🪧 NAT Gateway란?</h1>
<p><strong>NAT Gateway</strong>는 외부 인터넷에서 서브넷으로 접근할 수는 없지만 서브넷에서 외부 인터넷으로 접근할 수 있게 해주는 장치다. </p>
<p>인터넷 게이트웨이는 외부 인터넷에서 서브넷의 방향이든 서브넷에서 외부 인터넷의 방향이든 양방향으로 통신이 가능했지만, <strong>NAT Gateway는 서브넷에서 외부 인터넷의 방향으로만 통신이 가능</strong>하다. 즉, 내부에서 외부로만 나갈 수 있는 특수한 출입구라고 생각하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e74e4de0-c70e-44c0-b126-4a89c955d27f/image.png" alt=""></p>
<ul>
<li><p><em>외부 인터넷 → 서브넷 방향의 통신</em></p>
<ul>
<li>서브넷에 포함된 EC2 인스턴스의 백엔드 서버로 API 요청</li>
<li>서브넷에 포함된 EC2 인스턴스에 SSH로 접속</li>
</ul>
</li>
<li><p><em>서브넷 → 외부 인터넷 방향의 통신</em></p>
<ul>
<li>서브넷에 포함된 EC2 인스턴스에서 외부 API(ex. 날씨 API, OpenAI API 등) 호출</li>
<li>서브넷에 포함된 EC2 인스턴스에서 소프트웨어(ex. Nginx, git 등) 설치</li>
</ul>
</li>
</ul>
<p>&nbsp;   </p>
<p>NAT Gateway를 어떻게 추가해야 하는 지 아래 그림으로 살펴보자. 특이하게도 NAT Gateway는 VPC도, Private Subnet도 아닌 <strong>Public Subnet에 연결을 해야 한다.</strong> Private Subnet을 외부 인터넷에 직접적으로 노출시키지 않으면서도, 인터넷에 접근은 할 수 있게 만들어야 해서 나온 구조로 예상된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7d45e94a-bd20-4807-9749-5c303c0a51ed/image.png" alt=""></p>
<p>이제 NAT Gateway를 직접 생성해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3d891cb3-7062-4fbf-a4f9-972e0f4ab085/image.png" alt=""></p>
<p>NAT Gateway가 생성된 이후에는 Private Subnet에 연결시킬 라우팅 테이블을 만들어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/450fb3af-c925-4182-853a-93f7f70c3c85/image.png" alt=""></p>
<p>라우팅 편집에서 VPC 내부에서 보내는 IPv4에 대한 모든 트래픽은 NAT Gateway로 보내는 것으로 설정했다. 이렇게 하면 NAT Gateway를 활용해서 외부 인터넷에서 접근할 수 없는 Private Subnet을 만드는 과정을 모두 완료한 것이다.</p>
<p>이제 Private Subnet에 EC2 인스턴스를 생성해서 실제 외부 인터넷에서 접근할 수 없는지, 인스턴스에서는 외부 인터넷으로 요청을 보낼 수 있는지도 직접 눈으로 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/936ba091-f747-4573-a291-004e3a688609/image.png" alt=""></p>
<p>이제 Private Subnet에 EC2를 배치하고 연결해보면 아래와 같은 경고 팝업이 뜬다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/81f60f9e-2ef1-4a97-88ad-a6c703d2d56a/image.png" alt=""></p>
<p>외부에서 접근을 막기 위해 의도적으로 퍼블릭 IP 주소를 할당하지 않았고, 인스턴스를 Public Subnet에 배치하지 않았기 때문에 해당 EC2 인스턴스에 접근할 수 없다는 경고 메시지다.</p>
<p>근데 이렇게 만든 EC2 인스턴스에 백엔드 프로젝트를 배포하거나 관리 및 디버깅해야 할 때는 EC2 인스턴스로 들어가야 할 텐데, 접속할 수 있는 방법이 없으면 안 되는 것 아닌가 하는 의문점이 생긴다. </p>
<p>&nbsp;</p>
<h2 id="🔐-private-subnet에-있는-ec2에-접근하기">🔐 Private Subnet에 있는 EC2에 접근하기</h2>
<p>생각해보면 같은 VPC 내부에서는 기존에 만들어 놓은 Public Subnet의 리소스와 Private Subnet의 리소스가 자유자재로 통신이 가능하다. 그 점을 활용해서 Private Subnet에 있는 EC2 인스턴스에 접근할 수 있다.</p>
<p>일단 터미널에서 키페어를 활용하여 Public Subnet에 존재하는 EC2 인스턴스에 접속해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/30091850-348f-41f6-8a72-d2375670b022/image.png" alt=""></p>
<p>성공적으로 Public Subnet에 있는 EC2에 접속했다. 이제 여기서 Private Subnet에 있는 EC2 인스턴스에 접속해야 한다. </p>
<p>근데 그러려면 그 인스턴스의 키페어가 있어야 하는데 현재 접속해있는 EC2에는 당연히 키페어 파일이 존재하지 않는다. 따라서 현재 내 컴퓨터에서 web-server 인스턴스로 키페어 파일을 옮겨야 한다. </p>
<p>새로운 터미널에서 아래 명령어로 키페어 파일을 옮기도록 하자.</p>
<pre><code class="language-bash">scp -i web-server.pem practice-server.pem ubuntu@[인스턴스 Public IPv4]:~/</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e54c2b57-2ac9-4cd5-bf2e-5da52bdd7693/image.png" alt=""></p>
<p>이제 Public Subnet에 있는 EC2의 내부에 정상적으로 키페어 파일이 전송된 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5fb11e8f-6561-419a-8bc6-c940b1aa54ed/image.png" alt=""></p>
<p>이제 동일하게 Public Subnet 내부의 EC2 인스턴스에서 Private Subnet에 있는 practice-server에 접속하기 위해 명령어를 입력해주면 정상적으로 접속되는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fabd5aaf-0274-4409-8a24-b8209ee35d79/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="👮🏻-bastion-host란">👮🏻 Bastion Host란?</h2>
<p><strong>Bastion Host</strong>는 외부 인터넷에서 내부 네트워크(VPC)에 있는 리소스에 접근할 때 보안을 위해 가장 먼저 거치는 컴퓨터를 말한다. 근데 Bastion Host가 꼭 필요할까? 만약 Bastion Host가 없는 아키텍처라면 아래와 같은 문제가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3876d11c-c759-4fbe-afbc-2deffe5cf570/image.png" alt=""></p>
<p>보다시피 Public Subnet에 있는 EC2 인스턴스가 외부 인터넷에 노출된다. 해커 입장에서는 Public Subnet에 있는 모든 리소스에 접근할 수 있기 때문에 서비스를 운영하는 입장에서는 공격이 들어올 수 있는 포인트가 많아서 보안적으로 대비가 힘들어진다. 따라서 아래와 같이 Public Subnet에 Bastion Host를 배치해줘야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/36bb6289-bc8d-4b8c-8ea4-66afc4b6b489/image.png" alt=""></p>
<p>Bastion Host를 활용해 외부에서 접근할 수 있는 경로는 딱 하나만 만들어줬다. 외부에서 접근할 수 있는 경로는 단 하나이기 때문에 보안적인 조치를 하기도 쉽고 관리도 쉬워진다. </p>
<p>&nbsp;</p>
<h3 id="⚓️-bastion-host를-public-subnet에-배치하기">⚓️ Bastion Host를 Public Subnet에 배치하기</h3>
<p>먼저 Public Subnet에 있는 EC2에 터미널을 이용해서 접속해보자.</p>
<pre><code class="language-bash">$ cd [키 페어 파일이 존재하는 경로]
$ ssh -i &quot;web-server.pem&quot; ubuntu@[bastion-host 인스턴스의 Public IP]</code></pre>
<p>&nbsp;</p>
<p>접속한 후에 해당 EC2가 Bastion Host라는 것을 쉽게 알아보기 위해 인스턴스의 호스트 이름을 바꿔준 후에 인스턴스를 재시작하도록 하자.</p>
<pre><code class="language-bash">$ sudo vi /etc/hostname
$ sudo reboot # EC2 인스턴스 재시작하기</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/07c1524b-b8ca-4220-a55a-6879739d8aef/image.png" alt=""></p>
<p>다시 Bastion Host에 접속하면 아래와 같이 정상적으로 호스트 이름이 변경되 상태로 접속된 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c62651a5-a70c-4ed1-8d80-fafbd7be8899/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS VPC 완전 정복]]></title>
            <link>https://velog.io/@rocker_nun/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%84%B1-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@rocker_nun/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%84%B1-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Mon, 27 Jul 2026 07:55:50 GMT</pubDate>
            <description><![CDATA[<h1 id="🏝️-vpc란">🏝️ VPC란?</h1>
<p><strong>VPC(Virtual Private Cloud)</strong>의 정의는 <strong>가상의 네트워크 공간(컴퓨터들끼리 서로 통신하는 공간)</strong>이다. VPC를 사용하는 이유를 한 단어로 줄이면 <strong><em>“보안”</em></strong> 때문이다. VPC를 활용하면 외부에서 직접 접근할 수 없는 독립적인 네트워크 환경을 구성할 수 있어서, 보안적으로 안전하게 리소스(EC2, RDS 등)를 사용할 수 있다. </p>
<p>예를 들어, EC2 인스턴스 2대가 있다고 가정하자. 그런데 1대의 인스턴스는 인터넷에 자유롭게 접근하면서 사용하고 싶고, 나머지 1대는 좀 더 안전하고 비공개로 사용하고 싶을 수 있다. 이럴 때 VPC를 활용하면 된다. </p>
<p>VPC를 더 쉽게 이해하자면, 아래 그림과 같이 <strong>격자가 그려진 섬</strong>이라고 생각하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d9321982-c8ca-417c-8492-e0b3b7c90033/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/19698a75-9422-437e-a7f1-6b7b28c8c034/image.png" alt=""></p>
<p>섬의 각 구역에는 그 구역만의 주소(IP 주소)가 존재하고 VPC 안에는 여러 개의 IP 주소를 할당할 수 있다. 이후에 AWS에서 EC2를 생성하든, RDS를 생성하든, ELB를 생성하든 이 VPC 안에 아래와 같이 배치하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/513feb4b-8227-4381-b2db-a4734e700e58/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="📝-cidr-표기법">📝 CIDR 표기법</h2>
<p> VPC의 크기를 정할 때는 IP 주소의 범위로 정한다. 위의 그림같이 크기로 정하려면 VPC의 IP 주소 범위를 <code>10.10.0.0</code> ~ <code>10.10.0.31</code>로 잡겠다고 설정하면 된다. 정확히 말하자면, 설정할 때 <code>10.10.0.0/27</code>의 범위로 VPC를 만들어야 한다. </p>
<p>이러한 표기법을 <strong>CIDR 표기 방식</strong>이라고 하는데, 특정 IP 주소의 범위를 나타내는 표기 방식이다. 실제 AWS에서 VPC를 만들 때는 CIDR 표기 방식을 활용해 입력해야 한다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a188189a-c38c-41fa-958c-16d82c8d3ea3/image.png" alt=""></p>
<p>알다시피 IP 주소는 4개의 숫자로 구성되어 있고, 각 숫자는 <code>0</code> ~ <code>255</code> 범위의 값을 가진다.</p>
<p>ex) <code>127.23.150.11</code>, <code>15.0.255.1</code></p>
<p>&nbsp;</p>
<p>CIDR 표기법으로 표기된 값을 해석하는 방법은 아래 절차대로 진행하면 된다.</p>
<ol>
<li>10진수를 2진수로 바꾼다.</li>
<li>CIDR 슬래시 뒤에 있는 숫자를 활용해 2진수를 자른다. </li>
<li>잘린 2진수의 왼쪽 값은 그대로 고정시키고, 잘린 2진수의 오른쪽 값에서 표현할 수 있는 최솟값과 최댓값을 구한다. </li>
<li>2진수로 표현된 최솟값과 최대값을 10진수로 변환한다.</li>
</ol>
<p>&nbsp;</p>
<p>예를 들어, CIDR 표기법으로 표기된 <code>13.25.82.0/24</code>라는 값이 있다고 해보자.</p>
<ol>
<li>10진수로 표현된 각 숫자들을 2진수로 바꾼다.<ul>
<li><code>00001101.00011001.01010010.00000000</code></li>
</ul>
</li>
<li>CIDR 슬래시 뒤에 있는 숫자를 활용해 2진수를 자른다. <ul>
<li><code>00001101.00011001.01010010</code> / <code>00000000</code></li>
</ul>
</li>
<li>잘린 2진수의 왼쪽 값은 그대로 고정시키고, 잘린 2진수의 오른쪽 값에서 표현할 수 있는 최솟값과 최댓값을 구한다. <ul>
<li>최소값 : <code>00001101.00011001.01010010</code> / <code>00000000</code></li>
<li>최대값 : <code>00001101.00011001.01010010</code> / <code>11111111</code></li>
</ul>
</li>
<li>2진수로 표현된 최솟값과 최대값을 10진수로 변환한다.<ul>
<li>최솟값 : <code>13.25.82.0</code></li>
<li>최댓값 : <code>13.25.82.255</code></li>
</ul>
</li>
</ol>
<p>즉, CIDR로 표기된 <code>13.25.82.0/24</code>의 IP 주소 범위는 <code>13.25.82.0 ~ 13.25.82.255</code>이라는 의미다. 이 과정을 이해하고 스스로 알아낼 수 있어야 한다.</p>
<p>&nbsp;</p>
<h2 id="🆚-public-ip-vs-private-ip">🆚 Public IP vs Private IP</h2>
<p><strong>Public IP(공인 IP)</strong>란 외부 인터넷에서 접근할 수 있는 공식적인 주소를 말한다. 네이버에 <em>“내 컴퓨터 ip 주소 확인”</em> 이라고 검색하면 바로 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a479b100-5652-476e-8bc3-66fe2a9287d2/image.png" alt=""></p>
<p>반면, <strong>Private IP(사설 IP)</strong>는 외부 인터넷과 직접 연결되지 않고, 내부 네트워크에서만 사용되는 주소를 뜻한다. Private IP의 특징은 동일한 네트워크 환경에서만 서로 통신할 수 있다는 것이다. 여기서 <strong>*&quot;동일한 네트워크”*</strong> 라는 뜻은 같은 공유기(와이파이)를 사용하고 있는 경우, 혹은 같은 VPC인 경우라고 생각하면 된다.</p>
<p>전세계에 딱 하나뿐인 Public IP와는 다르게 Private IP는 네트워크 환경마다 독립적으로 사설 IP를 사용할 수 있다. 한 마디로 사설 IP는 각자의 네트워크 환경에서만 사용하는 IP이다. 예를 들어, A라는 네트워크 환경에서 <code>10.0.0.0</code>이라는 사설 IP를 쓰고 있다고 하더라도, B라는 네트워크 환경 에서도 <code>10.0.0.0</code>을 별개로 사용할 수 있다는 말이다.</p>
<p>Private IP의 범위는 IETF라는 국제 인터넷 표준화 기구에서 아래와 같이 정해놓았다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b8f7b58d-bc15-42b9-9228-23d5c84d7950/image.png" alt=""></p>
<p>위의 3가지 범위에 속한 IP 주소로 통신하면 컴퓨터들은 무조건 Private IP로 인식한다. </p>
<p>실습을 진행해보자. EC2 하나를 생성하고 Nginx를 설치해준다. 그리고 EC2가 가지고 있는 Public IP에 접속하면 Nginx가 정상적으로 동작하는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3d28b1d0-ebec-4781-837d-ba7e9896534f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1e361f25-46c4-45a6-ae31-56106ec60bdc/image.png" alt=""></p>
<p>이렇게 Public IP 주소로 응답이 잘 날라왔다는 것은 Public IP 주소를 활용해서 Nginx가 실행되고 있는 컴퓨터 인스턴스에 접근할 수 있다는 의미다.</p>
<p>&nbsp;</p>
<p>이번엔 Private IP 주소로 요청을 보내보자. 어떻게 될까? </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cdc66710-e6ad-445f-8b1f-98b5c64a91b9/image.png" alt=""></p>
<p>보다시피 아무런 반응이 없다. 아까 말했듯이 Private IP는 동일한 네트워크 환경에서만 통신할 수 있는 주소라고 했다. 하지만 지금은 내 로컬 컴퓨터의 네트워크 환경과 AWS의 VPC에서 실행되고 있는 EC2 인스턴스의 네트워크 환경이 서로 다르기 때문에 접속되지 않는 것이다. 그럼 네트워크 환경을 동일하게 맞춰주면 되는거 아닌가? 한번 실험해보자.</p>
<p>먼저 Nginx가 실행되고 있는 EC2가 어떤 VPC(현재는 기본값)를 쓰고 있는지 확인해야 한다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c87c93ce-1dda-4418-a7c6-fb6c4dbd0883/image.png" alt=""></p>
<p>확인 후, 동일한 VPC(기본값)을 사용하도록 하고 보안 규칙을 추가 후 인스턴스를 생성해보자. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0d3431c5-755c-4a0b-b5b4-57d7f2aca385/image.png" alt=""></p>
<p>현재 같은 VPC를 사용 중인 인스턴스 2대가 있는 상황이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/447ba993-22f4-42c6-af88-893bfaa5d157/image.png" alt=""></p>
<p>이제 새로 만든 web-server2에서 Nginx가 실행되고 있는 web-server에 Private IP로 통신을 시도해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ff50ff74-5d0c-42a9-9900-5b51cb7526e8/image.png" alt=""></p>
<p>보다시피 동일한 VPC를 사용하고 있기 때문에 Private IP로 요청을 보내도 정상적으로 응답 받을 수 있는 것을 확인할 수 있다.</p>
<p>이번에는 VPC를 직접 생성해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/72c28158-3cd2-47b4-86ef-03f7ba94dfa8/image.png" alt=""></p>
<p>IP 주소의 범위는 CIDR 표기법으로 <code>10.0.0.0/16</code>으로 설정했다. 이렇게 설정한 이유는 IP 범위를 정할 때 Private IP 범위에 포함되어야 하기 때문이다. 이제 이렇게 직접 설정해서 생성한 VPC에 EC2 인스턴스를 생성해서 배치해보자.</p>
<p>근데 EC2를 생성하려고 보니까 서브넷을 찾을 수 없다는 문구가 나온다. 서브넷에 대해 알아보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cdc67593-52ba-4bfb-8d1e-4bca70e046b7/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d9c2dd64-6e0a-4f42-85e4-c04b23a0978f/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="🗃️-subnet이란">🗃️ Subnet이란?</h2>
<p><strong>서브넷(Subnet)</strong>은 하나의 큰 네트워크를 작은 네트워크 단위로 쪼갠 것을 말한다. 하나의 VPC는 여러 개의 서브넷으로 나뉘어진다. 위에서 접한 그림에서 서브넷을 표현해보자면 아래와 같이 표현할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/31cc96ab-2ea9-4ae5-8e3a-fb3b5d7a0aa7/image.png" alt=""></p>
<p>이렇게 여러 서브넷으로 쪼개는 특별한 이유가 뭘까? 바로 <strong><em>“용도에 따라 네트워크를 분리해서 사용하고 싶기 때문”</em></strong> 이다. 예를 들어, 하나의 VPC에서 데이터베이스끼리만 모아놓은 네트워크와 백엔드 서버끼리만 모아놓은 네트워크를 분리해서 관리하고 싶을 수 있다. 이럴 때 서브넷을 활용하는 것이다.</p>
<p>근데 한 가지 중요한 궁금증이 있다. </p>
<blockquote>
<p><strong><em>“서브넷을 어떤 기준으로 나눠야 하는거지?”</em></strong></p>
</blockquote>
<p>이때 가장 많이 활용하는 기준이 <strong>외부에서 접근 가능한 네트워크(Public Subnet)</strong>로 할지, <strong>외부에서 접근이 불가능한 네트워크(Private Subnet)</strong>로 할지다. 위에서 생성해놓은 VPC의 서브넷을 만들어보자.</p>
<h3 id="👌🏻-public-subnet-생성">👌🏻 Public Subnet 생성</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f8c061c5-6f24-4915-ab0a-8a6d9d30c10d/image.png" alt=""></p>
<h3 id="🚫-private-subnet-생성">🚫 Private Subnet 생성</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f6a753f0-cdcc-4465-88d5-7b291aa84d0e/image.png" alt=""></p>
<p>이제 EC2를 하나 생성해서 외부에서 접근 가능하도록 기존에 만들어놓은 VPC의 Public Subnet에 배치해놓자. HTTP 80번 포트에 대해 모든 IP에서 접속 가능하도록 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c4b12b56-135f-4e1a-8a1b-d2d259707569/image.png" alt=""></p>
<p>그리고 EC2를 연결하려고 했는데…</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0b9c0e9a-7c5b-4bf6-ba17-d3624a26e4ec/image.png" alt=""></p>
<p>인스턴스가 Public Subnet에 없다는 문구와 함께 서브넷을 Public Subnet으로 만들기 위해서는 서브넷 라우팅 테이블의 경로를 인터넷 게이트웨이에 추가해야 한다고 되어 있다. 분명히 Public Subnet으로 설정해줬는데 이게 어떻게 된 일일까?</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/47380cf5-708e-4c11-b436-10baa47fb1de/image.png" alt=""></p>
<p>우려한대로 접속이 되지 않는다. 접속이 안 되는 이유는 VPC의 외부 인터넷과 소통할 수 있는 출입구를 세팅해주지 않았기 때문이다. 이 출입구가 바로 <strong>인터넷 게이트웨이(Internet Gateway)</strong>다. 출입구를 VPC에 달아주도록 하자.</p>
<p>&nbsp;</p>
<h2 id="🚪internet-gateway란">🚪Internet Gateway란?</h2>
<p><strong>인터넷 게이트웨이(Internet Gateway)</strong>란 VPC와 외부 인터넷 간에 통신할 수 있게 해주는 장치를 뜻한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/bce377e6-3e00-4438-8040-fc8314a09a64/image.png" alt=""></p>
<p>이제 인터넷 게이트웨이를 직접 만들고 VPC에 달아주도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a3905130-20dd-4ee9-9912-95814caaa48e/image.png" alt=""></p>
<p>근데 외부 인터넷과 VPC 내부에 있는 EC2 인스턴스랑 통신이 되게 하기 위해서는 한 가지 더 세팅을 해야 한다. 바로 <strong>라우팅 테이블(Routing Table)</strong>이다.</p>
<p>&nbsp;</p>
<h2 id="🔀-routing-table이란">🔀 Routing Table이란?</h2>
<p><strong>라우팅 테이블(Routing Table)</strong>은 트래픽을 어디로 전송해야 하는 지 경로를 알려주는 테이블을 의미한다. 왜 필요할까? </p>
<p>아래 그림을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c50af9f4-6c48-4acf-a783-05d09ddd8d7f/image.png" alt=""></p>
<p>VPC 내부에 EC2 인스턴스가 있고, 이 인스턴스가 VPC 외부에 있는 특정 컴퓨터와 통신을 하고 싶다고 가정해보자. 그럼 EC2 인스턴스 입장에서는 외부 인터넷에 있는 컴퓨터와 통신을 하기 위해 인터넷 게이트웨이를 거쳐가야 하는데 <strong>문제는 인터넷 게이트웨이로 가는 방법 자체를 모른다는 것</strong>이다. </p>
<p>즉, EC2 인스턴스가 특정 컴퓨터로 통신을 보내려고 해도 어떤 방향으로 트래픽을 보내야 하는 지 모른다는 뜻이다. 그래서 EC2 인스턴스가 어떤 경로로 트래픽을 전송해야 되는 지 라우팅 테이블을 통해 알려줘야 하는 것이다.</p>
<p>라우팅 테이블을 만들고, Public Subnet만 연결해놓도록 하자. 그리고 라우팅 편집에서 몇 가지 설정을 해야 하는데,</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a2c759c7-2b96-4877-a212-d9c490c39dda/image.png" alt=""></p>
<p>첫 번째 설정은 VPC 내부에서 <code>10.0.0.0/16</code>으로 보내는 트래픽은 local로 보내라는 뜻이다. 여기서 local로 보낸다는 것은 Private IP를 활용해서 네트워크 내부에서만 통신하라는 의미다. </p>
<p>바로 아래 설정에서 <code>0.0.0.0/0</code>은 모든 IPv4 주소를 의미하는데, VPC 내부에서 모든 IPv4 주소로 보내는 트래픽은 인터넷 게이트웨이로 보내라는 의미다.</p>
<p>근데 뭔가 모순적이지 않나? 만약 <code>10.0.0.5</code> 같은 주소로 보내려면 어떻게 되는걸까? local로 보내야 할까, 인터넷 게이트웨이로 보내야 할까? 다행스럽게도 AWS 공식문서에 이에 대해 IP 주소 범위가 더 구체적인 조건을 우선 적용한다고 나와 있다. 즉, <code>10.0.0.5</code> 트래픽은 local로 보내게 되는 것이다.</p>
<p>여기까지 설정을 마치고, EC2에 접속해보면 정상적으로 접속되는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2f12e03e-5a9b-4bca-b17e-56a286b69aa3/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Batch 개념 정리]]></title>
            <link>https://velog.io/@rocker_nun/Spring-Batch-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@rocker_nun/Spring-Batch-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 26 Jun 2026 08:55:03 GMT</pubDate>
            <description><![CDATA[<h1 id="👨🏻🔧-배치batch란">👨🏻‍🔧 배치(Batch)란?</h1>
<p><strong>배치 처리(Batch Processing)</strong>는 대용량 데이터를 사용자 개입 없이 일괄로 처리하는 방식이다.</p>
<p>조금 더 풀어서 말하면, 배치 처리는 다음과 같은 특징을 가진다.</p>
<ul>
<li>대용량 데이터를 처리한다.</li>
<li>사용자가 응답을 기다리지 않는다.</li>
<li>보통 정해진 시간에 실행된다.</li>
<li>처리 상태를 기록할 수 있어야 한다.</li>
<li>실패 시 재시작할 수 있어야 한다.</li>
<li>데이터를 일정 단위로 나누어 안정적으로 처리해야 한다.</li>
</ul>
<p>배치 처리는 단순히 <code>for</code>문으로 많은 데이터를 반복 처리하는 것이 아니라, 운영 환경에서 대용량 데이터를 안정적으로 처리하기 위한 실행 방식이라고 볼 수 있다.</p>
<p>배치 처리는 일반적인 웹 API 처리와 다르다. 웹 API는 사용자의 요청에 즉시 응답해야 한다. 예를 들어 사용자가 상품 상세 페이지에 들어오면 서버는 상품 정보를 조회해서 빠르게 응답해야 한다. 반면, 배치 처리는 사용자가 직접 기다리는 작업이 아니라 정산, 통계 집계, 만료 쿠폰 삭제, 대량 알림 발송, 데이터 마이그레이션처럼 정해진 시점에 대량의 데이터를 한 번에 처리하는 경우에 이용되는 방식이다.</p>
<p>정리하자면, 웹 API는 <strong><em>“빠른 응답”</em></strong> 이 중요하고, 배치는 <strong><em>“안정적인 대량 처리”</em></strong> 가 중요하다.</p>
<p>&nbsp;</p>
<h1 id="😁-spring-batch">😁 Spring Batch</h1>
<p><strong>Spring Batch</strong>는 스프링 프레임워크 기반의 배치 처리 프레임워크다. 대용량 데이터를 안정적으로 처리하기 위해 필요한 기능들을 제공한다. Spring Batch가 제공하는 대표적인 기능은 다음과 같다.</p>
<ul>
<li><p><code>재시작 기능</code>: 배치 작업은 오래 걸릴 수 있다. 작업 도중 서버가 중단되거나 예외가 발생할 수 있다. Spring Batch는 작업이 어디까지 진행되었는지 데이터베이스에 기록한다. 그래서 작업이 실패하더라도 실행 이력을 바탕으로 다시 실행하거나, 실패 지점을 추적할 수 있다.</p>
</li>
<li><p><code>Chunk 단위 처리</code>: 100만 건의 데이터를 한 번에 메모리에 올리면 위험하다. Spring Batch는 데이터를 일정 개수씩 나누어 읽고 처리할 수 있다. 예를 들어 200만 건을 한 번에 처리하는 것이 아니라, 1000건씩 읽고, 처리하고, 저장하는 식으로 작업할 수 있다. 이렇게 일정 단위로 나누어 처리하는 방식을 Chunk 지향 처리라고 한다.</p>
</li>
<li><p><code>Skip/Retry 처리</code>: 운영 데이터는 항상 완전하지 않다. 특정 데이터 하나 때문에 예외가 발생할 수 있다. Spring Batch는 특정 예외가 발생했을 때 해당 데이터만 건너뛰거나, 일정 횟수만큼 재시도하는 기능을 제공한다. 이를 통해 일부 데이터 오류 때문에 전체 작업이 중단되는 상황을 줄일 수 있다.</p>
</li>
<li><p><code>메타 데이터 관리</code>: Spring Batch는 Job과 Step의 실행 이력을 데이터베이스에 기록한다. 예를 들어, 어떤 Job이 실행되었는지, 어떤 파라미터로 실행됐는지, 언제 시작되고 언제 종료됐는지, 성공했는지 실패했는지, 몇 건을 읽고 쓰고, 몇 건을 건너뛰었는지 등과 같은 정보들을 저장한다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="🏛️-spring-batch의-구조">🏛️ Spring Batch의 구조</h2>
<p>Spring Batch는 크게 3개의 레이어(Application, Batch Core, Batch Infrastructure)로 구성된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2ddbf493-660d-446c-b9d0-a6dd81ddcfa7/image.png" alt=""></p>
<ul>
<li><p><code>Application Layer</code>: 업무 로직이나 서비스, 혹은 잡 구조화와 관련된 내용이 포함된다. 월말 정산 배치를 만든다고 가정해보면 아래와 같은 내용들이 포함될 것이다.</p>
<ul>
<li>정산 Job은 어떤 Step들로 구성되는가?</li>
<li>판매자 데이터를 어디서 읽을 것인가?</li>
<li>정산 금액은 어떻게 계산할 것인가?</li>
<li>계산 결과를 어디에 저장할 것인가?</li>
<li>오류가 발생하면 어떻게 처리할 것인가?</li>
</ul>
<p>즉, 개발자가 주로 집중해야 하는 영역이 Application Layer라고 할 수 있다.</p>
</li>
</ul>
<ul>
<li><p><code>Batch Core Layer</code>: 배치 작업을 실행하고 제어하는 핵심 기능을 제공한다. 대표적으로 JobLauncher, Job, Step, JobRepository가 이 레이어에 존재한다. Application Layer에서 개발자가 Job과 Step을 정의하면, Batch Core Layer가 그 Job과 Step을 실행하고 제어한다.</p>
</li>
<li><p><code>Batch Infrastructure Layer</code>: 배치 처리에 필요한 공통 기반 기능을 제공한다. 예를 들어 CSV 파일을 읽는 FlatFileItemReader, 데이터베이스에서 데이터를 읽는 JdbcPagingItemReader, 데이터를 파일이나 DB에 쓰는 ItemWriter, 재시도 처리를 위한 RetryTemplate, 실행 이력을 저장하는 메타데이터 저장소 등이 이 레이어에 존재한다. 개발자는 Infrastructure Layer에서 제공하는 구현체를 가져와 Application Layer에서 조합하고, Batch Core Layer가 이를 실행하는 구조로 이해할 수 있다.</p>
</li>
</ul>
<h2 id="🔀-spring-batch-작업-흐름">🔀 Spring Batch 작업 흐름</h2>
<p>Spring Batch의 전체 작업 흐름에 대해 살펴보면 아래 그림과 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/acc44305-9761-4234-baf1-a3e66acd0089/image.png" alt=""></p>
<h3 id="🍔-triggering-system">🍔 Triggering System</h3>
<p><code>Triggering System</code>은 Batch Job 실행을 시작시키는 외부 시스템이다. Spring Batch 자체가 특정 시간에 Job을 자동으로 실행해주는 스케줄러는 아니다. 실제로 Job을 언제 실행할지는 외부 트리거가 결정한다.</p>
<p>예를 들면 다음과 같은 것들이 Triggering System이 될 수 있다.</p>
<ul>
<li>Spring Scheduler</li>
<li>Linux Cron</li>
<li>Jenkins</li>
<li>Kubernetes CronJob</li>
<li>운영자가 직접 실행하는 CLI</li>
<li>특정 API 요청</li>
</ul>
<p>예를 들어 매월 말 정산 배치를 실행한다면, 스케줄러가 정해진 시간에 Batch Job을 실행하도록 트리거할 수 있다.</p>
<pre><code>매월 말 00:00
    ↓
정산 Batch Job 실행</code></pre><p>즉, Triggering System은 <strong><em>“언제 배치를 실행할 것인가?”</em></strong> 를 담당한다.</p>
<p>&nbsp;</p>
<h3 id="🧀-joblauncher">🧀 JobLauncher</h3>
<p><code>JobLauncher</code>는 Job을 실행하는 진입점이다. Job을 정의했다고 해서 자동으로 실행되는 것은 아니다. 정의된 Job을 실제로 실행하려면 <code>JobLauncher</code>를 통해 실행 요청을 보내야 한다.</p>
<pre><code class="language-java">jobLauncher.run(job, jobParameters);</code></pre>
<p><code>JobLauncher</code>는 실행할 <code>Job</code>과 해당 Job에 전달할 <code>JobParameters</code>를 받아서 배치 작업을 시작한다. 쉽게 말하면 <code>JobLauncher</code>는 <em><strong>“이 Job을 이 파라미터로 실행해줘”</strong></em> 와 같은 요청을 처리한다.</p>
<p>&nbsp;</p>
<h3 id="🍿-jobparameters">🍿 JobParameters</h3>
<p><code>JobParameters</code>는 Job을 실행할 때 함께 전달하는 파라미터다. 단순히 Job 내부에서 사용할 값이라고만 생각하면 안 된다. Spring Batch에서 <code>JobParameters</code>는 <code>JobInstance</code>를 식별하는 중요한 기준이 된다.</p>
<p>Spring Batch는 <em>“Job 이름 + JobParameters”</em> 조합으로 하나의 <code>JobInstance</code>를 구분한다. 예를 들어 다음과 같은 Job이 있다고 해보자.</p>
<pre><code>Job 이름: settlementJob
JobParameters: settlementMonth=2026-06</code></pre><p>이 경우 Spring Batch는 이것을 <code>2026년 6월 정산 작업</code>이라는 하나의 논리적인 실행 단위로 보는 것이다.</p>
<p>&nbsp;</p>
<h3 id="🍖-job">🍖 Job</h3>
<p><code>Job</code>은 하나의 배치 작업 전체를 의미한다. 예를 들어 다음과 같은 작업은 각각 하나의 Job이 될 수 있다.</p>
<ul>
<li>월말 정산 Job</li>
<li>일별 매출 통계 생성 Job</li>
<li>만료 쿠폰 삭제 Job</li>
<li>휴면 회원 전환 Job</li>
<li>대량 알림 발송 Job</li>
</ul>
<p>즉, Job은 <strong><em>“무엇을 처리할 것인가?”</em></strong> 에 대한 가장 큰 단위다.</p>
<p>&nbsp;</p>
<h3 id="🍗-jobinstance">🍗 JobInstance</h3>
<p><code>JobInstance</code>는 <code>Job</code> 이름과 <code>JobParameters</code> 조합으로 식별되는 논리적인 실행 단위다. 예를 들어 다음과 같이 실행했다고 해보자.</p>
<pre><code>Job 이름: settlementJob
JobParameters: settlementMonth=2026-06</code></pre><p>그러면 Spring Batch는 이 조합을 하나의 <code>JobInstance</code>로 본다.</p>
<pre><code>settlementJob + settlementMonth=2026-06
= 하나의 JobInstance</code></pre><p>이 <code>JobInstance</code>는 <code>2026년 6월 정산 작업</code>이라는 논리적인 배치 작업을 의미한다. 중요한 점은 <strong>이미 성공적으로 완료된 <code>JobInstance</code>는 같은 파라미터로 다시 실행할 수 없다는 것</strong>이다. 왜냐하면 같은 데이터를 같은 조건으로 다시 처리하면 중복 정산, 중복 지급, 중복 알림 같은 문제가 발생할 수 있기 때문이다.</p>
<p>따라서 Spring Batch는 완료된 <code>JobInstance</code>의 동일 파라미터 재실행을 막는다. 새로운 <code>JobInstance</code>를 만들고 싶다면 새로운 파라미터를 사용해야 한다.</p>
<p>&nbsp;</p>
<h3 id="🍕-jobexecution">🍕 JobExecution</h3>
<p><code>JobExecution</code>은 <code>JobInstance</code>를 실제로 실행한 한 번의 시도다. <code>JobInstance</code>가 <strong><em>“어떤 작업인가?”</em></strong> 를 나타낸다면, <code>JobExecution</code>은 <strong><em>“그 작업을 실제로 실행한 한 번의 기록”</em></strong> 이라고 볼 수 있다.</p>
<p>예를 들어 <code>2026년 6월 정산 JobInstance</code>가 있다고 해보자. 이 작업을 실행했는데 중간에 실패했다면, <code>JobInstance</code>는 그대로 유지된다. 하지만 실행 시도인 <code>JobExecution</code>은 하나 생성된다.</p>
<p>이후 같은 <code>JobInstance</code>를 다시 실행하면 어떻게 될까? 논리적인 작업은 여전히 <code>2026년 6월 정산 작업</code>이므로 <code>JobInstance</code>는 기존 것을 재사용한다.</p>
<p>하지만 실행 시도는 새롭게 발생한 것이므로 <code>JobExecution</code>은 새로 생성된다. 정리하면 다음과 같다.</p>
<pre><code>JobInstance: 2026년 6월 정산 작업
JobExecution 1: 첫 번째 실행 시도 - 실패
JobExecution 2: 두 번째 실행 시도 - 성공</code></pre><p>즉, 하나의 <code>JobInstance</code>는 여러 개의 <code>JobExecution</code>을 가질 수 있다. <code>JobExecution</code>에는 보통 다음과 같은 정보가 기록된다.</p>
<ul>
<li>실행 상태</li>
<li>시작 시간</li>
<li>종료 시간</li>
<li>종료 코드</li>
<li>실패 예외</li>
<li>ExecutionContext</li>
</ul>
<p>&nbsp;</p>
<h3 id="🥗-step">🥗 Step</h3>
<p><code>Step</code>은 <code>Job</code>을 구성하는 독립적인 실행 단계다. 하나의 <code>Job</code>은 하나 이상의 <code>Step</code>으로 구성되어 있다.</p>
<p>예를 들어 월말 정산 <code>Job</code>은 다음과 같이 여러 <code>Step</code>으로 나눌 수 있다.</p>
<pre><code>월말 정산 Job
 ├─ Step 1. 정산 대상 판매자 조회
 ├─ Step 2. 판매자별 정산 금액 계산
 ├─ Step 3. 정산 결과 저장
 └─ Step 4. 정산 완료 알림 발송</code></pre><p>각 <code>Step</code>은 독립적인 실행 단위다. 앞의 <code>Step</code>이 성공해야 다음 <code>Step</code>이 실행되도록 구성할 수도 있고, 조건에 따라 다른 <code>Step</code>으로 분기할 수도 있다.</p>
<p>&nbsp;</p>
<h3 id="🍤-stepexecution">🍤 StepExecution</h3>
<p><code>StepExecution</code>은 <code>JobExecution</code> 안에서 각각의 <code>Step</code>이 실행된 정보를 의미한다.</p>
<p><code>JobExecution</code>이 <code>Job</code>의 실행 시도라면, <code>StepExecution</code>은 <code>Step</code>의 실행 시도다. 하나의 <code>Job</code> 안에 <code>Step</code>이 여러 개 있으면, 각 <code>Step</code>마다 <code>StepExecution</code>이 생성된다.</p>
<p>예를 들어 월말 정산 <code>Job</code>에 <code>Step</code>이 3개 있다면 다음과 같이 볼 수 있다.</p>
<pre><code>JobExecution
 ├─ StepExecution 1. 정산 대상 조회 Step 실행 정보
 ├─ StepExecution 2. 정산 금액 계산 Step 실행 정보
 └─ StepExecution 3. 정산 결과 저장 Step 실행 정보</code></pre><p><code>StepExecution</code>에는 해당 <code>Step</code>의 상태와 처리 건수 등이 기록된다.</p>
<p>예를 들면 다음과 같은 정보가 저장될 수 있다.</p>
<ul>
<li>Step 시작 시간</li>
<li>Step 종료 시간</li>
<li>Step 실행 상태</li>
<li>읽은 데이터 수</li>
<li>저장한 데이터 수</li>
<li>커밋 횟수</li>
<li>롤백 횟수</li>
<li>Skip 횟수</li>
</ul>
<p>&nbsp;</p>
<h3 id="🥟-executioncontext">🥟 ExecutionContext</h3>
<p><code>ExecutionContext</code>는 <code>JobExecution</code> 또는 <code>StepExecution</code> 범위에서 사용할 수 있는 key-value 형태의 상태 저장 공간이다.</p>
<p>쉽게 말하면 배치 실행 중 유지해야 하는 상태를 저장하는 공간이다. 예를 들어 다음과 같은 정보를 저장할 수 있다.</p>
<ul>
<li>마지막으로 읽은 파일 위치</li>
<li>마지막으로 처리한 ID</li>
<li>Step 간 공유해야 하는 값</li>
<li>재시작 시 복원해야 하는 상태</li>
</ul>
<p>Spring Batch는 <code>ExecutionContext</code>를 메타데이터 테이블에 저장한다. 그래서 배치 작업이 중간에 실패했다가 재시작될 때, 이전 실행에서 저장해둔 <code>ExecutionContext</code> 값을 다시 읽어와 이어서 처리할 수 있다.</p>
<p>즉, <code>ExecutionContext</code>는 재시작 기능과 밀접하게 연결된 개념이다.</p>
<p>&nbsp;</p>
<h3 id="🍟-itemreader">🍟 ItemReader</h3>
<p><code>ItemReader</code>는 데이터를 읽는 역할을 한다. 읽어오는 대상은 다양할 수 있다.</p>
<ul>
<li>파일</li>
<li>데이터베이스</li>
<li>메시지 큐</li>
<li>외부 API</li>
<li>다른 시스템의 데이터</li>
</ul>
<p><code>ItemReader</code>는 데이터를 한 건씩 읽어서 다음 단계로 넘긴다. 예를 들어 CSV 파일에서 데이터를 읽는다면 <code>FlatFileItemReader</code>를 사용할 수 있고, 데이터베이스에서 페이징 방식으로 데이터를 읽는다면 <code>JdbcPagingItemReader</code>를 사용할 수 있다.</p>
<p><code>ItemReader</code>의 중요한 특징은 읽을 데이터가 더 이상 없을 때 <code>null</code>을 반환한다는 것이다. Spring Batch는 이 <code>null</code> 반환을 보고 더 이상 처리할 데이터가 없다고 판단한다.</p>
<p>&nbsp;</p>
<h3 id="🍭-itemprocessor">🍭 ItemProcessor</h3>
<p><code>ItemProcessor</code>는 <code>ItemReader</code>가 읽은 데이터를 가공하거나 검증하는 역할을 한다. 예를 들어 정산 배치라면 <code>ItemProcessor</code>에서 다음과 같은 작업을 할 수 있다.</p>
<ul>
<li>판매 금액 계산</li>
<li>수수료 계산</li>
<li>환불 금액 차감</li>
<li>정산 대상 검증</li>
<li>잘못된 데이터 필터링</li>
<li>Entity를 저장용 DTO로 변환</li>
</ul>
<p><code>ItemProcessor</code>는 필수는 아니다. 데이터를 읽은 그대로 저장해도 된다면 <code>ItemProcessor</code> 없이 <code>ItemReader</code>와 <code>ItemWriter</code>만으로 <code>Step</code>을 구성할 수 있다.</p>
<p>하지만 실제 비즈니스 로직에서는 데이터를 가공하거나 검증해야 하는 경우가 많기 때문에 자주 사용된다. 또한 <code>ItemProcessor</code>에서 <code>null</code>을 반환하면 해당 아이템은 이후 <code>Writer</code>로 전달되지 않는다.</p>
<p>즉, 특정 조건에 맞지 않는 데이터를 필터링할 때 사용할 수 있다.</p>
<p>&nbsp;</p>
<h3 id="🍫-itemwriter">🍫 ItemWriter</h3>
<p><code>ItemWriter</code>는 처리된 데이터를 저장하는 역할을 한다. <code>ItemProcessor</code>를 거친 데이터는 Chunk 단위로 모이고, 정해진 개수만큼 쌓이면 <code>ItemWriter</code>가 한 번에 저장한다.</p>
<p>저장 대상은 다양할 수 있다.</p>
<ul>
<li>데이터베이스</li>
<li>파일</li>
<li>메시지 큐</li>
<li>외부 시스템</li>
</ul>
<p>예를 들어 정산 배치라면 <code>ItemWriter</code>는 계산된 정산 결과를 정산 테이블에 저장할 수 있다.</p>
<p><code>ItemWriter</code>는 Chunk 단위로 데이터를 전달받기 때문에, 한 건씩 저장하기보다는 여러 건을 모아서 저장하는 구조에 가깝다.</p>
<p>&nbsp;</p>
<h3 id="🍰-data-source">🍰 Data Source</h3>
<p><code>Data Source</code>는 배치 작업이 읽거나 쓰는 외부 데이터 저장소를 의미한다. 대표적으로 다음과 같은 것들이 있다.</p>
<ul>
<li>File System</li>
<li>JMS Queue</li>
<li>Database</li>
<li>외부 API</li>
<li>다른 애플리케이션 시스템</li>
</ul>
<p>Spring Batch의 <code>ItemReader</code>는 Data Source에서 데이터를 읽고, <code>ItemWriter</code>는 처리 결과를 Data Source에 쓴다.</p>
<p>정산 배치를 예로 들면 다음과 같이 볼 수 있다.</p>
<pre><code>Database에서 주문 데이터를 읽는다.
    ↓
정산 금액을 계산한다.
    ↓
Database에 정산 결과를 저장한다.</code></pre><p>즉, Data Source는 배치 작업의 입력이자 출력 대상이 될 수 있다.</p>
<p>&nbsp;</p>
<h3 id="🍜-jobrepository">🍜 JobRepository</h3>
<p><code>JobRepository</code>는 Spring Batch의 실행 메타데이터를 저장하고 관리하는 저장소다. </p>
<p>여기서 중요한 점은 <code>JobRepository</code>가 비즈니스 데이터를 저장하는 저장소가 아니라는 것이다. 예를 들어 정산 결과 자체는 정산 테이블에 저장된다. 반면 <code>JobRepository</code>는 다음과 같은 배치 실행 이력을 저장한다.</p>
<ul>
<li>어떤 Job이 실행되었는지</li>
<li>어떤 파라미터로 실행되었는지</li>
<li>Job이 언제 시작되고 끝났는지</li>
<li>Job이 성공했는지 실패했는지</li>
<li>어떤 Step이 실행되었는지</li>
<li>Step에서 몇 건을 읽었는지</li>
<li>Step에서 몇 건을 썼는지</li>
<li>Step에서 몇 건을 건너뛰었는지</li>
</ul>
<p>Spring Batch는 이 정보를 메타데이터 테이블에 저장한다. 대표적인 테이블은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>테이블</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>BATCH_JOB_INSTANCE</td>
<td>JobInstance 정보 저장</td>
</tr>
<tr>
<td>BATCH_JOB_EXECUTION</td>
<td>JobExecution 정보 저장</td>
</tr>
<tr>
<td>BATCH_JOB_EXECUTION_PARAMS</td>
<td>JobParameters 정보 저장</td>
</tr>
<tr>
<td>BATCH_STEP_EXECUTION</td>
<td>StepExecution 정보 저장</td>
</tr>
<tr>
<td>BATCH_JOB_EXECUTION_CONTEXT</td>
<td>JobExecutionContext 저장</td>
</tr>
<tr>
<td>BATCH_STEP_EXECUTION_CONTEXT</td>
<td>StepExecutionContext 저장</td>
</tr>
</tbody></table>
<p><code>JobRepository</code>가 중요한 이유는 재시작과 추적 때문이다. 배치 작업이 중간에 실패해도, Spring Batch는 <code>JobRepository</code>에 저장된 실행 이력을 바탕으로 어떤 <code>Job</code>이 어떤 상태였는지 확인할 수 있다.</p>
<p><code>JobRepository</code>는 크게 3가지 시점에서 동작한다.</p>
<ol>
<li><p>Job이 시작되기 전: 같은 이름과 파라미터로 조합으로 이전에 실행된 이력이 존재하는지 조회한다.</p>
</li>
<li><p>Job이 실행되는 중: Step이 처리한 건수, 현재 실행 상태 같은 Spring Batch에서 참조할 수 있는 진행 정보를 주기적으로 업데이트한다.</p>
</li>
<li><p>Job이 종료: 최종 상태를 기록한다. (COMPLETED, FAILED 등)</p>
</li>
</ol>
<p>&nbsp;</p>
<h2 id="📝-전체-흐름-요약">📝 전체 흐름 요약</h2>
<p>마지막으로 지금까지 나온 용어를 하나의 흐름으로 정리하면 다음과 같다.</p>
<pre><code>1. Triggering System이 배치 실행을 시작한다.

2. JobLauncher가 Job과 JobParameters를 받아 Job을 실행한다.

3. Spring Batch는 Job 이름과 JobParameters로 JobInstance를 식별한다.

4. JobInstance의 실제 실행 시도로 JobExecution이 생성된다.

5. Job은 하나 이상의 Step을 실행한다.

6. 각 Step 실행 정보는 StepExecution으로 기록된다.

7. Step은 Tasklet 또는 Chunk 방식으로 실행된다.

8. Chunk 방식에서는 ItemReader, ItemProcessor, ItemWriter가 Read → Process → Write 흐름으로 데이터를 처리한다.

9. ItemReader와 ItemWriter는 File, Queue, Database 같은 Data Source와 데이터를 주고받는다.

10. JobRepository는 이 모든 실행 이력을 메타데이터 테이블에 저장한다.</code></pre><hr>
<p><strong>&lt;참고 자료&gt;</strong>
<a href="https://www.toptal.com/developers/spring/spring-batch-tutorial">Spring Batch Tutorial: Batch Processing Made Easy with Spring</a>
<a href="https://gngsn.tistory.com/177#google_vignette">Spring Batch, 제대로 이해하기 (1) - 개념이해</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[eGovFrame VSCode Initializr 오픈소스 기여: txAdvisor 직접 호출 안티패턴 수정]]></title>
            <link>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC-txAdvisor-%EC%A7%81%EC%A0%91-%ED%98%B8%EC%B6%9C-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%EC%88%98%EC%A0%95</link>
            <guid>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC-txAdvisor-%EC%A7%81%EC%A0%91-%ED%98%B8%EC%B6%9C-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%EC%88%98%EC%A0%95</guid>
            <pubDate>Fri, 12 Jun 2026 08:45:01 GMT</pubDate>
            <description><![CDATA[<h1 id="🔍-문제의-발견">🔍 문제의 발견</h1>
<p>eGovFrame VSCode Initializr는 전자정부 표준프레임워크 기반 프로젝트를 VS Code 환경에서 쉽게 생성할 수 있도록 도와주는 도구다. 프로젝트 생성뿐만 아니라 CRUD 코드 생성, 설정 파일 생성 기능도 제공한다.</p>
<p>그중 설정 파일 생성 기능은 Spring Framework 설정 파일을 XML, Java Config, YAML, Properties 형식으로 생성할 수 있게 해준다. 트랜잭션 설정의 경우 <code>templates/templates-context-xml.json</code> 파일에 다음 세 가지 항목으로 등록되어 있었다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a9b546a8-d430-4920-b886-27f45cbebd88/image.png" alt=""></p>
<p>즉, 사용자가 설정 파일 생성 탭에서 트랜잭션 관련 설정을 만들 때 실제로 연결되는 템플릿 위치는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c36acd98-7f5a-490c-b625-295727e9dbf5/image.png" alt=""></p>
<p>여기서 <code>datasource.hbs</code>, <code>jpa.hbs</code>, <code>jta.hbs</code>는 XML 설정 템플릿이고, <code>datasource-java.hbs</code>, <code>jpa-java.hbs</code>, <code>jta-java.hbs</code>는 Java Config 설정 템플릿이다.</p>
<p>문제는 Java Config 템플릿에서 발견됐다. AOP 기반 트랜잭션 설정을 활성화하고 Java Config 파일을 생성하면, <code>txAdvisor</code> Bean 메서드 내부에서 <code>txAdvice(txManager)</code>를 직접 호출하는 코드가 생성되고 있었다.</p>
<p>&nbsp;</p>
<p>수정 전 코드는 다음과 같은 형태였다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression( &quot;execution(* egovframework.example..*Impl.*(..)) &quot; + &quot;|| execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot; ); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager)); 
}</code></pre>
<p>처음에는 이 코드가 크게 이상해 보이지 않을 수 있다. <code>txAdvisor</code>를 만들 때 필요한 <code>txAdvice</code>를 호출해서 넣어주는 것처럼 보이기 때문이다.</p>
<p>하지만 이 코드는 Spring 설정 클래스에서 다른 <code>@Bean</code> 메서드를 직접 호출하는 패턴이다. 그리고 이 패턴은 <code>@Configuration</code>의 동작 방식, 특히 <code>proxyBeanMethods</code> 설정에 따라 미묘하거나 실제적인 문제를 만들 수 있다.</p>
<p>&nbsp;</p>
<h2 id="❓-aop-config-transaction-옵션은-무엇인가">❓ AOP Config Transaction 옵션은 무엇인가?</h2>
<p>먼저 이 문제가 언제 발생하는지부터 정리해봤다.</p>
<p>설정 파일 생성 화면에는 <code>AOP Config Transaction</code>이라는 체크박스가 있다. 이 체크박스를 선택하면 내부 폼 데이터의 <code>chkAopConfigTransaction</code> 값이 <code>true</code>가 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9223e972-d130-41f2-bd3d-2c72ec00ae57/image.png" alt=""></p>
<p>그리고 템플릿 파일에서는 이 값을 조건문으로 사용한다. 즉, 사용자가 AOP 기반 트랜잭션 설정을 체크하면 <code>{{#if chkAopConfigTransaction}} ... {{/if}}</code> 블록 안에 있는 코드가 실제 생성 파일에 포함된다.</p>
<p>Java Config 템플릿에서는 다음과 같은 코드가 조건부로 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/daf74151-4c80-4aa7-93d6-0b15f1bdf82e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8c6fc783-4721-4982-bb50-71dc935342ef/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/26e4733c-974e-4fba-894e-b86cd5988520/image.png" alt=""></p>
<p>정리하면, 아래와 같은 흐름이다. </p>
<ol>
<li>사용자가 AOP Config Transaction 체크 </li>
<li><code>chkAopConfigTransaction</code> 값이 true가 됨</li>
<li>템플릿의 <code>{{#if chkAopConfigTransaction}}</code> 블록 활성화</li>
<li><code>@EnableAspectJAutoProxy(proxyTargetClass = true)</code>, <code>txAdvice</code>, <code>txAdvisor</code> 코드 생성</li>
</ol>
<p>&nbsp;</p>
<p>여기서 중요한 점은 <code>chkAopConfigTransaction</code>이 단순히 <code>@EnableAspectJAutoProxy</code>만 생성하는 옵션은 아니라는 것이다. 이 옵션이 켜지면 AOP 기반 트랜잭션 설정에 필요한 코드가 함께 생성된다. 대표적으로 다음 코드들이 포함된다.</p>
<pre><code class="language-java">@Bean(name = &quot;{{txtAdviceName}}&quot;)
public TransactionInterceptor txAdvice(PlatformTransactionManager txManager) {
    TransactionInterceptor txAdvice = new TransactionInterceptor();
    txAdvice.setTransactionManager(txManager);
    txAdvice.setTransactionAttributeSource(getNameMatchTransactionAttributeSource());
    return txAdvice;
}

@Bean
public Advisor txAdvisor(...) {
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    pointcut.setExpression(&quot;{{txtPointCutExpression}}&quot;);
    return new DefaultPointcutAdvisor(pointcut, txAdvice);
}</code></pre>
<p>즉, <code>chkAopConfigTransaction</code>은 AOP 방식으로 트랜잭션을 적용하는 설정 코드를 생성할지 여부를 나타내는 옵션이다.</p>
<p>&nbsp;</p>
<h3 id="🤔-enableaspectjautoproxy-애노테이션">🤔 @EnableAspectJAutoProxy 애노테이션?</h3>
<p>AOP Config Transaction을 체크하면 Java Config 파일에 다음 애노테이션이 생성된다.</p>
<pre><code class="language-java">@EnableAspectJAutoProxy(proxyTargetClass = true)</code></pre>
<p>이 애노테이션을 한 문장으로 정리하면 다음과 같다.</p>
<blockquote>
<p><strong>Spring에게 AOP 프록시를 자동으로 만들어서, 특정 메서드 호출 앞뒤에 공통 기능을 끼워 넣을 수 있게 해달라고 요청하는 설정이다.</strong></p>
</blockquote>
<p>Spring 공식 문서에서는 <code>@EnableAspectJAutoProxy</code>를 AspectJ의 <code>@Aspect</code> 애노테이션이 붙은 컴포넌트를 처리할 수 있도록 지원을 활성화하는 애노테이션이라고 설명한다. XML 설정의 <code>&lt;aop:aspectj-autoproxy&gt;</code>와 비슷한 기능이며, 보통 <code>@Configuration</code> 클래스에 붙여 사용한다.</p>
<p>&nbsp;</p>
<p>예를 들면 다음과 같은 구조다.</p>
<pre><code class="language-java">@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

    @Bean
    public FooService fooService() {
        return new FooService();
    }

    @Bean
    public MyAspect myAspect() {
        return new MyAspect();
    }
}</code></pre>
<pre><code class="language-java">@Aspect
public class MyAspect {

    @Before(&quot;execution(* FooService+.*(..))&quot;)
    public void advice() {
        // advise FooService methods as appropriate
    }
}</code></pre>
<p>이 경우 Spring은 <code>MyAspect</code>를 AOP 설정으로 인식하고, <code>FooService</code>에 Advice를 적용하기 위해 필요한 프록시를 생성한다.</p>
<p>흐름을 간단히 정리하면 다음과 같다.</p>
<ol>
<li><code>@EnableAspectJAutoProxy</code> 애노테이션을 붙인다.</li>
<li>Spring이 AOP 설정을 읽을 수 있게 된다.</li>
<li>어드바이스를 적용해야 하는 Bean을 찾는다.</li>
<li>그 Bean 앞에 프록시 객체를 세운다.</li>
<li>메서드 호출을 프록시가 가로챈다.</li>
<li>공통 기능 실행 후 실제 메서드를 호출한다.</li>
</ol>
<p>즉, <code>@EnableAspectJAutoProxy</code>는 Spring AOP 프록시 기능을 활성화하는 역할을 한다.</p>
<p>&nbsp;</p>
<p>다만 eGovFrame 템플릿에서는 <code>@Aspect</code> 클래스를 직접 만드는 방식이 아니라, <code>Advisor</code>, <code>Pointcut</code>, <code>TransactionInterceptor</code>를 직접 Bean으로 등록하는 방식이다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txAdvice&quot;) TransactionInterceptor txAdvice) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression(&quot;execution(* egovframework.example..*Impl.*(..))&quot;); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice); 
}</code></pre>
<p>이 구조는 다음과 같이 이해할 수 있다.</p>
<ul>
<li><code>@AspectJExpressionPointcut</code>: 어떤 메서드에 트랜잭션을 적용할지 결정한다.</li>
<li><code>@TransactionInterceptor</code>: 트랜잭션 시작, 커밋, 롤백을 처리한다.</li>
<li><code>@DefaultPointcutAdvisor</code>: Pointcut과 Advice를 묶는다.</li>
<li><code>@EnableAspectJAutoProxy</code>: Advisor를 보고 대상 Bean에 AOP 프록시를 적용할 수 있게 한다.</li>
</ul>
<p>결과적으로 포인트컷 표현식에 매칭되는 메서드가 호출되면, Spring AOP 프록시가 호출을 가로채고 <code>TransactionInterceptor</code>가 트랜잭션 처리를 수행한다.</p>
<p>&nbsp;</p>
<p>실행 흐름은 간단히 정리하면 다음과 같다.</p>
<ol>
<li><code>ServiceImpl</code> 메서드 호출</li>
<li>Spring AOP 프록시가 호출을 가로챔</li>
<li><code>txAdvisor</code>의 포인트컷에 매칭되는지 확인</li>
<li>매칭되면 <code>txAdvice(TransactionInterceptor)</code> 실행</li>
<li><code>PlatformTransactionManager</code>로 트랜잭션 시작</li>
<li>실제 <code>ServiceImpl</code> 메서드 실행</li>
<li>정상 종료면 커밋</li>
<li>예외 발생하면 롤백</li>
</ol>
<p>&nbsp;</p>
<h3 id="🎭--proxytargetclass--true는-무엇인가">🎭  proxyTargetClass = true는 무엇인가?</h3>
<p><code>@EnableAspectJAutoProxy</code>에는 <code>proxyTargetClass</code>라는 속성이 있다. </p>
<p>공식 문서 기준으로 <code>proxyTargetClass</code>의 기본값은 <code>false</code>다. 기본값인 <code>false</code>에서는 가능한 경우 인터페이스 기반 JDK 동적 프록시를 사용한다. 반면 <code>true</code>로 설정하면 CGLIB 스타일의 클래스 기반 프록시를 사용한다. 이처럼 Spring AOP의 프록시 방식은 크게 두 가지로 나눌 수 있다.</p>
<p>&nbsp;</p>
<p>첫 번째는 JDK 동적 프록시다.</p>
<pre><code class="language-java">public interface OrderService { 
    void saveOrder(); 
} 

public class OrderServiceImpl implements OrderService { 
    @Override 
    public void saveOrder() { 
        // 주문 저장 
    } 
}</code></pre>
<p>JDK 동적 프록시는 인터페이스를 기반으로 프록시를 만든다. 즉, <code>OrderService</code> 인터페이스를 구현한 프록시 객체가 생성되고, 실제 호출은 <code>OrderServiceImpl</code>로 위임된다.</p>
<p>&nbsp;</p>
<p>두 번째는 CGLIB 프록시다.</p>
<pre><code class="language-java">public class OrderServiceImpl { 
    public void saveOrder() { 
        // 주문 저장 
    } 
}</code></pre>
<p>CGLIB는 클래스를 상속해서 프록시를 만든다. 개념적으로는 아래 코드와 유사하다.</p>
<pre><code class="language-java">public class OrderServiceImplProxy extends OrderServiceImpl { 
    @Override 
    public void saveOrder() { 
        // 공통 기능 실행 
        super.saveOrder(); 
        // 공통 기능 실행 
    } 
}</code></pre>
<p>물론 실제 코드는 Spring과 CGLIB가 런타임에 생성하지만, 이해는 이런 식으로 하면 된다. eGovFrame 템플릿의 기본 포인트컷 표현식에는 다음과 같이 <code>*Impl.*(..)</code> 패턴이 들어 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b7076ef1-4480-4c9e-a5a9-81731772c2e6/image.png" alt=""></p>
<p>즉, 기본적으로 <code>*Impl</code>로 끝나는 구현 클래스의 메서드를 트랜잭션 적용 대상으로 삼으려는 의도로 볼 수 있다. 그래서 템플릿에서 <code>proxyTargetClass = true</code>를 사용한 것은 구현 클래스 중심의 전통적인 eGovFrame 서비스 구조에서 AOP 적용을 더 명확하게 하기 위한 설정으로 볼 수 있다.</p>
<p>&nbsp;</p>
<p>다만 여기서 주의할 점이 있다. <code>@EnableAspectJAutoProxy(proxyTargetClass = true)</code>는 트랜잭션을 직접 처리하는 애노테이션이 아니다. 이 애노테이션은 AOP 프록시 생성을 활성화하는 역할을 한다. 실제 트랜잭션 처리는 <code>TransactionInterceptor</code>가 담당한다.</p>
<p>정리하면 다음과 같다.</p>
<ol>
<li><code>@EnableAspectJAutoProxy(proxyTargetClass = true)</code></li>
<li>클래스 기반 AOP 프록시 생성 기능 활성화 <code>AspectJExpressionPointcut</code> </li>
<li>어떤 메서드에 적용할지 결정 <code>TransactionInterceptor</code> </li>
<li>트랜잭션 시작, 커밋, 롤백 처리 <code>DefaultPointcutAdvisor</code> </li>
<li>포인트컷과 어드바이스를 하나로 묶음</li>
</ol>
<p>&nbsp;</p>
<h2 id="💥-문제-코드-txadvicetxmanager-직접-호출">💥 문제 코드: txAdvice(txManager) 직접 호출</h2>
<p>이제 다시 문제 코드로 돌아가보자.</p>
<p>AOP 기반 트랜잭션 설정을 Java Config로 생성하면 기존 템플릿에서는 다음과 같은 코드가 만들어졌다.</p>
<pre><code class="language-java">@Bean
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) {
    ...
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));
}</code></pre>
<p>&nbsp;</p>
<p>문제는 마지막 줄이다. <code>txAdvice(txManager)</code>와 같이 설정 클래스 안에 있는 다른 <code>@Bean</code> 메서드를 직접 호출하고 있었다.</p>
<pre><code class="language-java">@Bean(name = &quot;{{txtAdviceName}}&quot;)
public TransactionInterceptor txAdvice(@Qualifier(&quot;{{txtTransactionName}}&quot;) DataSourceTransactionManager transactionManager) {
    TransactionInterceptor txAdvice = new TransactionInterceptor();
    txAdvice.setTransactionManager(transactionManager);
    txAdvice.setTransactionAttributeSource(getNameMatchTransactionAttributeSource());
    return txAdvice;
}</code></pre>
<p>겉으로 보면 단순히 <code>txAdvisor</code>를 만들기 위해 필요한 <code>txAdvice</code>를 생성해서 넣는 것처럼 보인다. 하지만 Spring 설정 클래스에서 <code>@Bean</code> 메서드를 직접 호출하는 것은 일반 Java 메서드 호출과 다르게 해석될 수 있다.</p>
<p>이 문제를 이해하려면 <code>@Configuration</code>과 <code>@Bean</code>의 동작 방식을 다시 살펴봐야 한다.</p>
<p>&nbsp;</p>
<h3 id="🏭-configuration과-bean-메서드-호출">🏭 @Configuration과 @Bean 메서드 호출</h3>
<p>보통 Spring 설정 클래스는 다음과 같이 작성한다.</p>
<pre><code class="language-java">@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(
            memberRepository(),
            discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();  
    }
}</code></pre>
<p>여기서 <code>@Configuration</code>은 이 클래스가 Spring 설정 클래스라는 뜻이고, <code>@Bean</code>은 이 메서드가 반환하는 객체를 Spring 컨테이너에 Bean으로 등록하겠다는 뜻이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/bc67a069-8d0e-4864-993f-840716ae1eab/image.png" alt=""></p>
<p>그런데 중요한 부분은 다음과 같은 코드다.</p>
<pre><code class="language-java">@Bean
public MemberService memberService() {
    return new MemberServiceImpl(memberRepository());
}</code></pre>
<p><code>memberService()</code> 메서드 안에서 <code>memberRepository()</code> 메서드를 직접 호출하고 있다. 또 <code>orderService()</code> 메서드 안에서도 <code>memberRepository()</code>와 <code>discountPolicy()</code>를 직접 호출한다.</p>
<p>순수 Java 코드라면 <code>memberRepository()</code>를 호출할 때마다 매번 새로운 <code>MemoryMemberRepository</code> 인스턴스가 생성될 것이다. 이러면 <code>MemberServiceImpl</code>이 사용하는 <code>MemoryMemberRepository</code>와 <code>OrderServiceImpl</code>이 사용하는 <code>MemoryMemberRepository</code>가 서로 다른 객체가 될 수 있다.</p>
<p>&nbsp;</p>
<p>하지만 <code>@Configuration</code>이 붙은 Spring 설정 클래스에서는 기본적으로 그렇게 동작하지 않는다. Spring은 <code>@Configuration</code> 클래스를 그냥 그대로 사용하지 않고, CGLIB를 이용해 설정 클래스를 상속한 프록시 객체를 만든다. 개념적으로는 다음과 같은 느낌이다.</p>
<pre><code class="language-java">class AppConfigProxy extends AppConfig {

    private final SimpleSpringContainer springContainer = new SimpleSpringContainer();

    @Override
    public MemberService memberService() {
        return springContainer.getBean(&quot;memberService&quot;, () -&gt; super.memberService());
    }

    @Override
    public OrderService orderService() {
        return springContainer.getBean(&quot;orderService&quot;, () -&gt; super.orderService());
    }

    @Override
    public MemberRepository memberRepository() {
        return springContainer.getBean(&quot;memberRepository&quot;, () -&gt; super.memberRepository());
    }

    @Override
    public DiscountPolicy discountPolicy() {
        return springContainer.getBean(&quot;discountPolicy&quot;, () -&gt; super.discountPolicy());
    }
}</code></pre>
<p><code>@Bean</code> 메서드가 호출되면 곧바로 새로운 인스턴스를 생성하는 것이 아니라, 먼저 Spring 컨테이너에 해당 Bean이 이미 존재하는지 확인한다. 이미 존재하면 기존 Bean을 반환하고, 아직 없다면 원본 메서드를 실행해 Bean을 생성한 뒤 컨테이너에 등록한다.</p>
<p>&nbsp;</p>
<h3 id="🎏-full-mode와-lite-mode">🎏 Full mode와 Lite mode</h3>
<p>여기서 중요한 개념이 <strong>full mode</strong>와 <strong>lite mode</strong>다.</p>
<p>일반적으로 다음처럼 <code>@Configuration</code> 애노테이션을 붙여서 작성하면,</p>
<pre><code class="language-java">@Configuration
public class AppConfig {}</code></pre>
<p><code>@Configuration(proxyBeanMethods = true)</code>로 설정된다. 이 경우 Spring은 설정 클래스를 CGLIB 프록시로 감싸고, <code>@Bean</code> 메서드 간 직접 호출을 가로챈다. 이를 보통 full mode라고 이해할 수 있다. </p>
<p>반면 <code>@Configuration(proxyBeanMethods = false)</code> 로 설정하면 Spring은 설정 클래스를 CGLIB 프록시로 감싸지 않는다. 이 경우 <code>@Bean</code> 메서드는 일반 Java 메서드처럼 동작한다. 이를 lite mode라고 볼 수 있다. lite mode에서는 다음 코드가 진짜 일반 Java 메서드 호출이 된다.</p>
<pre><code class="language-java">@Bean 
public MemberService memberService() { 
    return new MemberServiceImpl(memberRepository()); 
}</code></pre>
<p>즉, <code>memberRepository()</code>를 호출할 때마다 새로운 <code>MemoryMemberRepository</code> 인스턴스가 만들어질 수 있다.</p>
<p>그래서 <code>proxyBeanMethods = false</code>를 사용할 때는 보통 다음처럼 작성하는 것이 좋다.</p>
<pre><code class="language-java">@Configuration(proxyBeanMethods = false)
public class AppConfig {

    @Bean
    public MemberService memberService(MemberRepository memberRepository) {
        return new MemberServiceImpl(memberRepository);
    }

    @Bean
    public OrderService orderService(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        return new OrderServiceImpl(memberRepository, discountPolicy);
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();  
    }
}</code></pre>
<p>이 방식은 <code>memberRepository()</code>를 직접 호출하지 않는다. 대신 Spring 컨테이너가 관리하는 <code>MemberRepository</code> Bean을 메서드 파라미터로 주입받는다.</p>
<p>Spring 공식 문서에서도 런타임 프록시가 없는 설정 클래스에서는 <code>@Bean</code> 메서드가 다른 <code>@Bean</code> 메서드를 직접 호출할 필요가 없고, 그런 호출은 팩토리 메서드 인자로 표현할 수 있다고 설명한다.</p>
<p>즉, 핵심을 간단하게 다음과 같다.</p>
<ul>
<li><code>@Bean</code> 메서드 직접 호출<ul>
<li><code>proxyBeanMethods = true</code>에서는 Spring이 가로채므로 겉으로는 동작 가능</li>
<li><code>proxyBeanMethods = false</code>에서는 일반 Java 메서드 호출이 되어 새로운 인스턴스가 생성</li>
</ul>
</li>
<li>파라미터 주입<ul>
<li><code>proxyBeanMethods</code> 설정과 무관하게 Spring 컨테이너가 관리하는 Bean을 사용</li>
</ul>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="⏮️-다시-txadvisor-문제로-돌아가보자">⏮️ 다시 <code>txAdvisor</code> 문제로 돌아가보자</h2>
<p>이제 기존 코드가 왜 문제인지 더 명확해진다. 수정 전 코드는 다음과 같았다.</p>
<pre><code class="language-java">@Bean
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) {
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    pointcut.setExpression(&quot;execution(* egovframework.example..*Impl.*(..)) || execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot;);
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));  // 직접 호출
}</code></pre>
<p>여기서 <code>txAdvice(txManager)</code>는 다른 <code>@Bean</code> 메서드를 직접 호출하는 코드다.</p>
<p>기본 <code>@Configuration(proxyBeanMethods = true)</code> 환경에서는 Spring이 이 호출을 가로챈다. 그래서 겉으로는 문제가 없어 보일 수 있다. Spring은 <code>txAdvice(txManager)</code>를 일반 Java 메서드 호출로 두지 않고, 컨테이너가 관리하는 <code>txAdvice</code> Bean을 반환한다.</p>
<p>&nbsp;</p>
<p>하지만 이 상황에서도 코드의 의미는 어색하다.</p>
<pre><code class="language-java">txAdvice(txManager)</code></pre>
<p>이 코드는 마치 <code>txManager</code>를 직접 넘겨서 <code>txAdvice</code>를 새로 만들거나 구성하는 것처럼 보인다. 하지만 실제 런타임에서는 Spring 컨테이너가 이미 관리 중인 <code>txAdvice</code> Bean을 반환한다. 이때 <code>txAdvice(txManager)</code>에 전달한 <code>txManager</code> 인자는 개발자가 예상하는 방식으로 사용되지 않을 수 있다.</p>
<p>즉, 코드의 겉모습은 <code>txAdvisor</code>를 만들 때 <code>txManager</code>를 넘겨서 <code>txAdvice</code>를 구성하는 것처럼 보이지만, 실제 동작은 Spring 컨테이너에서 이미 관리 중인 <code>txAdvice</code> Bean을 가져오는 것이다. 이렇게 코드의 표현과 실제 동작이 다르면, 나중에 이 코드를 읽는 개발자가 의도를 오해할 수 있다.</p>
<p>&nbsp;</p>
<p>더 큰 문제는 <code>proxyBeanMethods = false</code> 환경에서 발생한다.</p>
<pre><code class="language-java">@Configuration(proxyBeanMethods = false) 
public class TransactionConfig { ... }</code></pre>
<p>이 경우 Spring은 설정 클래스를 CGLIB 프록시로 감싸지 않는다. 따라서 <code>txAdvisor()</code> 안에서 호출한 <code>txAdvice(txManager)</code>는 진짜 일반 Java 메서드 호출이 된다.</p>
<p>&nbsp;</p>
<p>실제로 <code>proxyBeanMethods = false</code>로 설정 후 인스턴스 참조값을 비교해보면 다른 것을 확인할 수 있다.</p>
<pre><code>Spring 관리 txAdvice@1028908791
txAdvisor 내부 advice@ 395686760
→ 동일 인스턴스: false
→ [BUG] 별개 인스턴스 생성됨 — Spring 생명주기 미적용, 트랜잭션 미적용 위험</code></pre><p>그 후, <code>TransactionInterceptor</code>가 새로 생성되어 Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean이 TransactionInterceptor 객체 A를 만들고, <code>txAdvisor</code> 내부에서 직접 호출로 만들어진 <code>txAdvice</code> Bean이 새로운 TransactionInterceptor 객체 B를 만드는 상황이 된다. </p>
<p>&nbsp;</p>
<h2 id="👀-근데-이게-무슨-문제가-될까">👀 근데 이게 무슨 문제가 될까?</h2>
<p><code>txAdvice</code>는 트랜잭션 AOP에서 실제 트랜잭션 처리 로직을 담고 있는 Advice다. 구체적으로는 <code>TransactionInterceptor</code> 객체다. <code>txAdvisor</code>는 어떤 메서드에 이 Advice를 적용할지 결정하는 객체고, 내부적으로 포인트컷과 어드바이스를 함께 가지고 있다.</p>
<p>여기서 중요한 것은 <code>txAdvisor</code>가 참조하는 <code>txAdvice</code>가 Spring 컨테이너가 관리하는 Bean이어야 한다는 점이다. <code>proxyBeanMethods = false</code> 환경에서 직접 호출이 발생하면, Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean과 실제 <code>txAdvisor</code> 내부에 들어간 <code>txAdvice</code> 인스턴스가 달라진다. 겉으로는 트랜잭션이 동작하는 것처럼 보일 수도 있다. 하지만 구조적으로는 위험하다. </p>
<p>왜냐하면 설정, 생명주기, 후처리, 프록시 적용, Bean 관리 관점에서 Spring이 관리하는 객체와 실제 사용 객체가 달라질 수 있기 때문이다. 특히 나중에 <code>txAdvice</code> Bean에 추가 설정이 붙거나, Bean 후처리기가 개입하거나, 특정 스코프나 프록시가 적용되면 더 미묘한 문제가 생길 수 있다.</p>
<p>&nbsp;</p>
<h1 id="👍-해결-직접-호출-대신-파라미터-주입으로-변경">👍 해결: 직접 호출 대신 파라미터 주입으로 변경</h1>
<p>공식 문서가 권장하는 방식도 다른 <code>@Bean</code> 메서드를 직접 호출하지 말고, 필요한 Bean을 메서드 파라미터로 받으라는 것이다.</p>
<p>&nbsp;</p>
<p>수정 전 코드는 다음과 같았다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression( &quot;execution(* egovframework.example..*Impl.*(..)) &quot; + &quot;|| execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot; ); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager)); 
}</code></pre>
<p>&nbsp;</p>
<p>수정 후에는 다음과 같이 변경했다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txAdvice&quot;) TransactionInterceptor txAdvice) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression( &quot;execution(* egovframework.example..*Impl.*(..)) &quot; + &quot;|| execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot; ); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice); 
}</code></pre>
<p>&nbsp;</p>
<p>첫 번째 변경점은 <code>txAdvisor</code>가 더 이상 <code>txManager</code>를 파라미터로 받지 않도록 한 것이다. 대신 <code>TransactionInterceptor</code> 타입의 <code>txAdvice</code> Bean을 직접 주입받는다.</p>
<pre><code class="language-java">@Qualifier(&quot;txAdvice&quot;) TransactionInterceptor txAdvice</code></pre>
<p>&nbsp;</p>
<p>두 번째 변경점은 <code>txAdvice(txManager)</code> 메서드 직접 호출을 제거한 부분이다. </p>
<pre><code class="language-java">// 수정 전
return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));

// 수정 후
return new DefaultPointcutAdvisor(pointcut, txAdvice);</code></pre>
<p>이제 <code>txAdvisor()</code>는 <code>txAdvice()</code> 메서드를 직접 호출하지 않는다. Spring에게 <code>txAdvisor</code> Bean을 만들 때 <code>TransactionInterceptor</code> 타입의 <code>txAdvice</code> Bean을 주입을 요청하는 구조가 된다. 이 방식은 훨씬 명확하다. 결과적으로 <code>proxyBeanMethods</code> 설정과 무관하게 <code>txAdvisor</code>는 항상 Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean을 참조한다.</p>
<p>&nbsp;</p>
<h1 id="🎉-pull-request-결과">🎉 Pull Request 결과</h1>
<p>PR의 핵심 내용은 다음과 같다.</p>
<blockquote>
<p><strong>AOP 기반 트랜잭션 Java Config 템플릿에서 <code>txAdvisor</code> Bean이 <code>txAdvice()</code>  Bean 메서드를 직접 호출하던 패턴을 제거하고, <code>@Qualifier</code>를 사용한 파라미터 주입 방식으로 변경했다.</strong></p>
</blockquote>
<p>변경 전에는 다음 구조였다.</p>
<pre><code class="language-java">return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));</code></pre>
<p>&nbsp;</p>
<p>변경 후에는 다음 구조가 되었다.</p>
<pre><code class="language-java">return new DefaultPointcutAdvisor(pointcut, txAdvice);</code></pre>
<p>이 변경으로 <code>proxyBeanMethods = false</code> 환경에서도 <code>txAdvisor</code>가 Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean을 안정적으로 참조하게 되었다. 그리고 해당 PR은 최종적으로 main 브랜치에 merge되었다.</p>
<p>&nbsp;</p>
<h2 id="😂-이럴거면-왜-proxybeanmethods--false-설정을-제공하는거지">😂 이럴거면 왜 proxyBeanMethods = false 설정을 제공하는거지?</h2>
<p>이렇게 의도치 않은 동작이 발생할 수도 있으니 그냥 항상 true로 두면 안 되는 건지 의문이 들었다. 하지만 proxyBeanMethods = true 는 CGLIB 프록시를 만들어야 한다. 즉, 런타임에 설정 클래스를 상속한 프록시 클래스를 만들고, 메서드 호출을 가로채야 하는데 이건 약간의 비용과 제약이 있다.</p>
<p>특히 Spring Boot의 자동 설정 클래스들에서는 보통 <code>proxyBeanMethods = false</code> 를 많이 사용한다. 왜냐하면 자동 설정 클래스는 대체로 <code>@Bean</code> 메서드끼리 직접 호출하지 않고, 필요한 의존성을 파라미터로 받도록 작성하기 때문이다. 실제로 Spring 5.2 이후부터는 자동 구성 클래스에 <code>proxyBeanMethods = false</code>가 기본값으로 사용되는 경우가 많아졌으며, 프록시를 생성하지 않아도 충분히 동작할 수 있는 경우 이를 활용하도록 공식 문서에서도 권장하고 있다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://github.com/eGovFramework/egovframe-vscode-initializr/issues/11">[Bug] AOP 기반 트랜잭션 설정 생성 시 txAdvisor Bean의 잘못된 직접 메서드 호출 패턴</a>
<a href="https://github.com/eGovFramework/egovframe-vscode-initializr/pull/12">fix: AOP 트랜잭션 설정 템플릿에서 txAdvisor의 잘못된 직접 메서드 호출 수정</a>
<a href="https://velog.io/@rocker_nun/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%B4-%EC%A7%80%EC%9B%90%ED%95%98%EB%8A%94-%ED%94%84%EB%A1%9D%EC%8B%9C">스프링이 지원하는 프록시</a>
<a href="https://docs.spring.io/spring-framework/reference/core/beans/java/basic-concepts.html">Basic Concepts: @Bean and @Configuration</a>
<a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/EnableAspectJAutoProxy.html">Annotation Interface EnableAspectJAutoProxy</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA(Java Persistence API) 들어가기]]></title>
            <link>https://velog.io/@rocker_nun/JPAJava-Persistence-API-%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/JPAJava-Persistence-API-%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0</guid>
            <pubDate>Fri, 12 Jun 2026 00:25:02 GMT</pubDate>
            <description><![CDATA[<h1 id="💥-sql을-직접-다룰-때의-문제점">💥 SQL을 직접 다룰 때의 문제점</h1>
<p>관계형 데이터베이스에서 데이터를 다루고 관리하려면 SQL을 작성해야 한다. JPA를 사용하기 전의 자바 애플리케이션에서는 보통 JDBC API를 사용해서 SQL을 데이터베이스에 전달하는 코드를 직접 작성했다.</p>
<p>이해를 위해 상품을 CRUD하는 기능을 개발한다고 해보자. 먼저 상품 정보를 표현하기 위한 <code>Product</code> 객체를 만들어야 한다.</p>
<pre><code class="language-java">@Getter 
@Setter 
@NoArgsConstructor 
@AllArgsConstructor 
@Builder 
@ToString
public class Product {
    private int productId;        // 상품 번호, PK
    private String productName;   // 상품명
    private int categoryId;       // 카테고리 번호, FK
    private int price;            // 가격
    private int stock;            // 재고
    private String manufacturer;  // 제조사
    private LocalDate createdAt;  // 등록일
}</code></pre>
<p>&nbsp;</p>
<p>이제 상품 데이터를 데이터베이스에 저장하거나 조회하려면 DAO 객체를 만들어야 한다. 일반적으로는 먼저 SQL 문을 정의하고, JDBC API를 사용해서 SQL을 실행한 뒤, 조회 결과를 다시 자바 객체에 매핑하는 방식으로 작업을 진행한다.</p>
<pre><code class="language-java">public class ProductDao {

    // 상품 삽입 기능
    public int insert(Product product) {
        String sql = &quot;&quot;&quot; 
                INSERT product (product_name, category_id, price, stock, manufacturer, created_at) 
                VALUES (?, ?, ?, ?, ?, ?) 
                &quot;&quot;&quot;;

        try (
                Connection connection = DBUtil.getConnection();
                PreparedStatement pstmt = connection.prepareStatement(sql);
                )
        {
            pstmt.setString(1, product.getProductName());
            pstmt.setInt(2, product.getCategoryId());
            pstmt.setInt(3, product.getPrice());
            pstmt.setInt(4, product.getStock());
            pstmt.setString(5, product.getManufacturer());
            pstmt.setDate(6, Date.valueOf(product.getCreatedAt()));

            return pstmt.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        }

        return 0;
    }

    // 상품 리스트 조회 기능
    public List&lt;Product&gt; selectList(int count) {
        List&lt;Product&gt; products = new ArrayList&lt;&gt;();
        String sql = &quot;&quot;&quot; 
                SELECT product_id, product_name, category_id, price, stock, manufacturer, created_at 
                FROM product ORDER BY price DESC LIMIT ? 
                &quot;&quot;&quot;;

        try (
                Connection connection = DBUtil.getConnection();
                PreparedStatement pstmt = connection.prepareStatement(sql);
                )
        {
            pstmt.setInt(1, count);
            try (ResultSet rs = pstmt.executeQuery();) {
                while (rs.next()) {
                    products.add(mpaProduct(rs));
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }

        return products;
    }

    // 상품 단건 조회 기능
    public Product selectOne(int productId) {
        String sql = &quot;&quot;&quot; 
                SELECT product_id, product_name, category_id, price, stock, manufacturer, created_at 
                FROM product WHERE product_id = ? 
                &quot;&quot;&quot;;

        try (
                Connection connection = DBUtil.getConnection();
                PreparedStatement pstmt = connection.prepareStatement(sql);
                )
        {
            pstmt.setInt(1, productId);
            try (ResultSet rs = pstmt.executeQuery();) {
                if (rs.next()) {
                    return mpaProduct(rs);
                }
            } 
        } catch (SQLException e) {
            e.printStackTrace();
        }

        return null;
    }

    // 상품 삭제 기능
    public int delete(int productId) {
        String sql = &quot;&quot;&quot;
            DELETE FROM product
            WHERE product_id = ?
            &quot;&quot;&quot;;

        try (
                Connection connection = DBUtil.getConnection();
                PreparedStatement pstmt = connection.prepareStatement(sql);
        ) {
            pstmt.setInt(1, productId);

            return pstmt.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        }

        return 0;
    }

    private Product mpaProduct(ResultSet rs) throws SQLException {
        return Product.builder()
                .productId(rs.getInt(&quot;product_id&quot;))
                .productName(rs.getString(&quot;product_name&quot;))
                .categoryId(rs.getInt(&quot;category_id&quot;))
                .price(rs.getInt(&quot;price&quot;)) .stock(rs.getInt(&quot;stock&quot;))
                .manufacturer(rs.getString(&quot;manufacturer&quot;))
                .createdAt(rs.getDate(&quot;created_at&quot;).toLocalDate())
                .build();
    }
}</code></pre>
<p>&nbsp;</p>
<p>보다시피 고작 상품 CRUD를 구현하는데도 코드량이 상당하다. 그리고 자세히 보면 반복되는 코드도 많다. 예를 들어 다음과 같은 작업이 거의 모든 메서드에서 반복된다.</p>
<pre><code class="language-java">Connection connection = DBUtil.getConnection(); 
PreparedStatement pstmt = connection.prepareStatement(sql);</code></pre>
<p>&nbsp;</p>
<p>그리고 조회 기능에서는 <code>ResultSet</code>에서 값을 하나씩 꺼내 객체에 직접 넣어주는 작업도 필요하다.</p>
<pre><code class="language-java">.productId(rs.getInt(&quot;product_id&quot;)) 
.productName(rs.getString(&quot;product_name&quot;)) 
.categoryId(rs.getInt(&quot;category_id&quot;)) 
.price(rs.getInt(&quot;price&quot;)) 
.stock(rs.getInt(&quot;stock&quot;)) 
.manufacturer(rs.getString(&quot;manufacturer&quot;)) 
.createdAt(rs.getDate(&quot;created_at&quot;).toLocalDate())</code></pre>
<p>&nbsp;</p>
<p>상품 객체를 데이터베이스가 아니라 아래와 같이 자바 컬렉션에 간편하게 처리할 수 있다면 얼마나 좋을까?</p>
<pre><code class="language-java">list.add(product); 
Product product = list.get(productId);</code></pre>
<p>하지만 현실적으로는 이렇게 할 수 없다. 자바 객체와 관계형 데이터베이스는 서로 비슷해 보이지만, 실제로는 데이터를 표현하는 방식이 다르다. 객체는 필드와 메서드를 가지고 있고, 객체끼리 참조를 통해 연결된다. 반면 관계형 데이터베이스는 데이터를 테이블, 행, 열, 외래 키 중심으로 관리한다. 그래서 JDBC를 사용할 때는 중간에서 SQL과 JDBC API를 이용해 객체와 테이블 사이의 변환 작업을 개발자가 직접 처리해야 한다. </p>
<p>&nbsp;</p>
<p>문제는 테이블이 하나만 있는 것이 아니라는 점이다. 상품 테이블뿐만 아니라 회원, 주문, 카테고리, 재고, 결제 같은 테이블이 계속 늘어난다면 각 테이블마다 비슷한 DAO 코드를 반복해서 작성해야 한다. </p>
<p>더 큰 문제는 변경에 취약하다는 것인데, 예를 들어 <code>Product</code>에 필드 하나가 추가되기만 해도 INSERT 쿼리, SELECT 쿼리, <code>PreparedStatement</code> 파라미터 바인딩, <code>ResultSet</code> 매핑 코드를 모두 수정해야 한다.</p>
<p>또한 JDBC 기반 개발에서는 객체만 보고 해당 객체가 어떤 데이터를 온전히 가지고 있는지 판단하기 어렵다. 어떤 DAO 메서드는 상품 기본 정보만 조회하고, 어떤 DAO 메서드는 카테고리 정보까지 조인해서 조회할 수 있다. 결국 개발자는 객체를 신뢰하기보다 DAO 메서드와 SQL을 직접 열어보고 <em>“이 객체가 어디까지 조회된 객체인지”</em> 확인해야 한다.</p>
<p>&nbsp;</p>
<p>따라서 SQL과 JDBC를 직접 사용해서 코드를 작성했을 때의 문제점은 다음과 같이 정리할 수 있다.</p>
<ul>
<li>반복되는 JDBC 코드가 많다.</li>
<li>SQL 중심으로 개발하게 된다.</li>
<li>필드가 추가되거나 테이블 구조가 바뀌면 수정할 코드가 많다.</li>
<li>객체만 보고 어떤 데이터가 조회되었는지 신뢰하기 어렵다.</li>
<li>진정한 의미에서 객체 중심의 개발을 하기 어렵다.</li>
<li>계층을 분리해도 결국 DAO와 SQL에 강하게 의존하게 된다.</li>
</ul>
<p>결국 JDBC를 직접 사용하는 방식에서는 개발자가 비즈니스 로직에 집중하기보다 SQL 작성, 파라미터 바인딩, 결과 매핑 같은 반복 작업에 많은 시간을 사용하게 된다.</p>
<p>&nbsp;</p>
<h2 id="⚠️-패러다임의-불일치">⚠️ 패러다임의 불일치</h2>
<p>애플리케이션이 발전할수록 내부 복잡성은 점점 증가한다. 이런 복잡성을 관리하기 위해 자바 같은 객체지향 언어를 사용하는 것은 매우 자연스러운 선택이다.</p>
<p>객체지향 언어를 사용하면 비즈니스 요구사항을 객체로 모델링할 수 있다. 예를 들어 상품, 카테고리, 주문, 회원 같은 개념을 각각 객체로 표현하고, 객체들이 서로 협력하도록 만들 수 있다. 문제는 이렇게 만든 객체를 데이터베이스에 저장할 때 발생한다.</p>
<p>&nbsp;</p>
<p>알다시피 객체는 속성과 기능을 가진다. 그리고 하나의 객체는 다른 객체를 참조할 수도 있다. 따라서 객체지향적으로 상품과 카테고리를 모델링한다면 아래와 같은 형태가 더 자연스러울 것이다.</p>
<pre><code class="language-java">public class Product { 
    private int productId; 
    private String productName; 
    private Category category; 
    private int price; 
    private int stock; 
}</code></pre>
<p>상품 입장에서는 <code>categoryId</code>라는 숫자 값만 가지고 있는 것보다 <code>Category</code> 객체를 직접 참조하는 것이 더 객체지향적이다.</p>
<pre><code class="language-java">String categoryName = product.getCategory().getCategoryName();</code></pre>
<p>&nbsp;</p>
<p>하지만 관계형 데이터베이스에서는 객체 참조라는 개념이 없고, 대신 외래 키를 사용한다.</p>
<pre><code class="language-sql">SELECT * FROM product p 
JOIN category c ON p.category_id = c.category_id;</code></pre>
<p>즉, 객체는 참조를 통해 연관된 객체를 탐색하지만, 관계형 데이터베이스는 외래 키와 조인을 통해 연관된 데이터를 조회한다. 이 차이는 단순한 문법 차이가 아니다. 객체지향과 관계형 데이터베이스가 데이터를 바라보는 관점 자체가 다르기 때문에 발생하는 문제다.</p>
<p>&nbsp;</p>
<p>객체지향에서는 추상화, 캡슐화, 상속, 다형성, 참조, 객체 그래프 탐색 같은 개념을 중요하게 생각한다. 반면 관계형 데이터베이스는 데이터를 테이블에 정규화해서 저장하고, SQL을 통해 필요한 데이터를 집합적으로 조회하는 방식에 더 가깝다.</p>
<p>대표적인 차이를 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>객체지향</th>
<th>관계형 데이터베이스</th>
</tr>
</thead>
<tbody><tr>
<td>객체는 참조로 연관 객체를 탐색한다</td>
<td>테이블은 외래 키와 조인으로 연관 데이터를 찾는다</td>
</tr>
<tr>
<td>객체는 상속과 다형성을 사용할 수 있다</td>
<td>테이블에는 상속과 다형성 개념이 직접 존재하지 않는다</td>
</tr>
<tr>
<td>객체는 식별자뿐만 아니라 동일성도 중요하다</td>
<td>데이터베이스는 기본 키를 기준으로 행을 구분한다</td>
</tr>
<tr>
<td>객체 그래프를 자유롭게 탐색할 수 있다</td>
<td>필요한 데이터는 SQL로 명시적으로 조회해야 한다</td>
</tr>
<tr>
<td>객체는 상태와 행위를 함께 가진다</td>
<td>테이블은 주로 데이터 중심으로 구성된다</td>
</tr>
</tbody></table>
<p>이처럼 객체지향 프로그래밍과 관계형 데이터베이스는 지향하는 방향이 다르다. 이 차이를 흔히 <strong>패러다임의 불일치(Impedance Mismatch)</strong>라고 한다.</p>
<p>결국 개발자는 객체지향적으로 코드를 작성하고 싶지만, 데이터를 저장하고 조회하는 순간에는 결국 관계형 데이터베이스 구조에 맞춰 SQL을 작성해야 한다. 이 과정에서 객체 중심의 설계가 테이블 중심의 코드로 흔들리기 쉽다.</p>
<p>&nbsp;</p>
<h1 id="🛠️-jpa의-등장">🛠️ JPA의 등장</h1>
<p>이러한 문제를 해결하기 위해 등장한 기술이 JPA다. <strong>JPA</strong>(<strong>Java Persistence API)</strong>는 자바 진영의 ORM 기술 표준이다. 여기서 <strong>ORM(Object-Relational Mapping)은</strong> 말 그대로 객체와 관계형 데이터베이스를 매핑한다는 뜻이다.</p>
<p>즉, ORM은 객체와 테이블 사이의 차이를 중간에서 해결해주는 기술이다. JPA를 사용하면 개발자는 SQL과 JDBC API를 직접 다루는 대신, 객체를 중심으로 데이터를 저장하고 조회할 수 있다. 마치 자바 컬렉션에 객체를 저장하듯이 엔티티를 데이터베이스에 저장하고, 식별자를 이용해 객체를 조회할 수 있다.</p>
<p>JPA는 애플리케이션과 JDBC 사이에서 동작한다. 개발자가 JPA를 통해 엔티티를 저장하거나 조회하면, JPA가 내부적으로 적절한 SQL을 생성하고 JDBC API를 사용해서 데이터베이스와 통신한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/235f43d8-6e54-4244-a480-0628f7e9e4f5/image.png" alt=""></p>
<p>&nbsp;</p>
<pre><code class="language-java">em.persist(product); 
Product product = em.find(Product.class, productId);</code></pre>
<p>여기서 <strong>em(엔티티 매니저)</strong>는 JPA에서 <strong>엔티티를 저장, 조회, 수정, 삭제할 때 사용하는 핵심 객체</strong>다.</p>
<p>&nbsp;</p>
<p>다만 여기서 한 가지 짚고 넘어갈 부분이 있다. JPA는 구현체가 아니라 자바 진영의 ORM 표준 인터페이스라는 점이다. 실제로 JPA를 구현한 대표적인 구현체로는 Hibernate가 있다. 스프링 부트에서 JPA를 사용한다고 할 때 대부분 내부적으로 Hibernate를 함께 사용한다.</p>
<p>따라서 JPA를 배운다는 것은 단순히 특정 라이브러리 사용법을 배우는 것이 아니라, 자바 객체와 관계형 데이터베이스를 어떻게 매핑하고 관리할 것인지에 대한 표준 방식을 배우는 것이라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🤔-왜-jpa를-사용해야-하는가">🤔 왜 JPA를 사용해야 하는가?</h2>
<h3 id="🏭-생산성">🏭 생산성</h3>
<p>첫 번째 이유는 생산성이다.</p>
<p>JDBC를 직접 사용할 때는 SQL 작성, 파라미터 바인딩, <code>ResultSet</code> 처리, 객체 매핑 코드를 반복해서 작성해야 했다. 반면 JPA를 사용하면 객체를 마치 자바 컬렉션에 저장하듯이 다룰 수 있다.</p>
<pre><code class="language-java">em.persist(product);  // 저장
Product product = em.find(Product.class, productId);  // 조회</code></pre>
<p>상품을 저장할 때 직접 INSERT 쿼리를 작성하지 않아도 된다. 상품을 조회할 때도 직접 SELECT 쿼리를 작성하고 <code>ResultSet</code>을 하나씩 꺼내 객체에 담는 작업을 하지 않아도 된다.</p>
<p>물론 JPA가 SQL을 완전히 몰라도 되게 만들어주는 것은 아니다. 기본적인 CRUD SQL과 반복적인 JDBC 코드를 직접 작성하는 부담을 크게 줄여주는 것이다.</p>
<p>복잡한 조회가 필요하거나 성능 최적화가 필요한 경우에는 JPQL, QueryDSL, Native SQL 등을 사용해야 할 수 있다. 또한 JPA가 실제로 어떤 SQL을 실행하는지 이해하는 능력도 중요하다.</p>
<p>즉, JPA는 SQL 지식을 대체하는 기술이라기보다 반복적인 SQL 작성과 객체 매핑 작업을 줄여주고, 개발자가 객체 중심으로 비즈니스 로직을 작성할 수 있도록 도와주는 기술이다.</p>
<p>&nbsp;</p>
<h3 id="👨🏻🔧-유지보수성">👨🏻‍🔧 유지보수성</h3>
<p>두 번째 이유는 유지보수성이다.</p>
<p>JDBC 방식에서는 필드 하나가 추가되어도 여러 부분을 수정해야 한다. 예를 들어 상품에 <code>description</code>이라는 설명 필드가 추가되었다고 해보자.</p>
<p>그러면 다음과 같은 코드를 모두 수정해야 한다.</p>
<ul>
<li>INSERT 쿼리</li>
<li>SELECT 쿼리</li>
<li><code>PreparedStatement</code> 파라미터 바인딩 코드</li>
<li><code>ResultSet</code> 매핑 코드</li>
<li>관련 DTO 또는 화면 출력 코드</li>
</ul>
<p>물론 JPA를 사용한다고 해서 모든 변경이 자동으로 해결되는 것은 아니다. 엔티티 필드를 추가하고, 필요한 경우 테이블 컬럼도 추가해야 한다. 하지만 JDBC를 직접 사용할 때처럼 모든 CRUD SQL과 매핑 코드를 일일이 수정하는 부담은 크게 줄어든다. 특히 단순한 CRUD에서는 JPA가 변경된 엔티티 구조를 기반으로 SQL을 생성해주기 때문에 반복적인 수정 작업이 줄어든다.</p>
<p>&nbsp;</p>
<h3 id="🎏-패러다임-불일치-해결">🎏 패러다임 불일치 해결</h3>
<p>세 번째 이유는 객체와 관계형 데이터베이스 사이의 패러다임 불일치를 줄여준다는 점이다.</p>
<p>객체는 객체답게 참조를 사용할 수 있고, 데이터베이스는 데이터베이스답게 외래 키를 사용할 수 있다. JPA는 이 둘 사이를 매핑해주기 때문에 개발자는 테이블 구조에만 맞춘 코드가 아니라 객체 관계를 중심으로 코드를 작성할 수 있다. </p>
<p>물론 JPA가 모든 패러다임 불일치를 완벽하게 없애주는 것은 아니다. 객체와 관계형 데이터베이스는 여전히 다른 모델이다. 하지만 JPA는 이 차이를 상당 부분 완화해주고, 개발자가 더 객체지향적인 방식으로 애플리케이션을 설계할 수 있도록 도와준다.</p>
<p>&nbsp;</p>
<h3 id="⚙️-성능-최적화">⚙️ 성능 최적화</h3>
<p>네 번째 이유는 성능 최적화 기능이다.</p>
<p>JPA는 애플리케이션과 데이터베이스 사이에서 다양한 최적화 기능을 제공한다. 대표적인 예가 <strong>1차 캐시</strong>다.</p>
<p>JPA는 엔티티를 영속성 컨텍스트라는 공간에서 관리한다. 같은 영속성 컨텍스트 안에서 동일한 식별자로 엔티티를 다시 조회하면, 데이터베이스를 다시 조회하지 않고 이미 관리 중인 엔티티를 반환한다.</p>
<pre><code class="language-java">int productId = 1; 
Product productA = em.find(Product.class, productId); 
Product productB = em.find(Product.class, productId);</code></pre>
<p>JDBC를 직접 사용했다면 같은 상품을 조회하는 쿼리가 두 번 실행될 수 있다. 하지만 JPA는 첫 번째 조회 결과를 영속성 컨텍스트에 보관하고, 두 번째 조회에서는 이미 관리 중인 객체를 반환할 수 있다. 스프링 환경에서는 같은 트랜잭션 범위 안에서 이러한 효과를 볼 수 있다.</p>
<p>또한 JPA는 변경 감지 기능도 제공한다. 엔티티를 조회한 뒤 값을 변경하면, 트랜잭션이 끝나는 시점에 JPA가 변경된 내용을 감지해서 필요한 UPDATE 쿼리를 실행한다.</p>
<pre><code class="language-java">Product product = em.find(Product.class, productId); 
product.changePrice(30000);</code></pre>
<p>위 코드에서 별도로 UPDATE 쿼리를 직접 작성하지 않아도, JPA는 엔티티의 변경 사항을 감지해서 데이터베이스에 반영할 수 있다.</p>
<p>이 외에도 JPA는 쓰기 지연, 지연 로딩, 즉시 로딩, 플러시 같은 다양한 기능을 제공한다. 이러한 기능들은 편리하지만 잘못 사용하면 성능 문제가 발생할 수도 있기 때문에 JPA의 동작 원리를 이해하는 것이 중요하다.</p>
<p>&nbsp;</p>
<h3 id="👤-데이터-접근-추상화와-벤더-독립성">👤 데이터 접근 추상화와 벤더 독립성</h3>
<p>다섯 번째 이유는 데이터 접근 추상화와 벤더 독립성이다.</p>
<p>관계형 데이터베이스는 MySQL, Oracle, PostgreSQL 등 여러 종류가 있다. 각 데이터베이스는 SQL 문법, 자료형, 함수, 페이징 방식 등이 조금씩 다르다. JDBC를 직접 사용하는 방식에서는 애플리케이션 코드가 특정 데이터베이스에 강하게 의존할 수 있다. 예를 들어 MySQL에 맞춰 작성한 SQL이 Oracle에서는 그대로 동작하지 않을 수 있다. </p>
<p>JPA는 이러한 차이를 줄이기 위해 <strong>Dialect</strong>라는 개념을 사용한다. Dialect는 데이터베이스 방언이라는 뜻으로, JPA 구현체가 특정 데이터베이스에 맞는 SQL을 생성할 수 있도록 도와준다.</p>
<p>예를 들어 사용하는 데이터베이스가 MySQL인지, PostgreSQL인지, Oracle인지 설정하면 JPA는 해당 데이터베이스에 맞는 SQL을 생성하려고 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fb364d4d-7de2-4f00-b8c3-ea4992943f59/image.png" alt=""></p>
<p>물론 데이터베이스를 변경한다고 해서 모든 문제가 자동으로 해결되는 것은 아니다. 데이터베이스마다 지원하는 함수, 자료형, 락 동작, 인덱스 전략 등이 다를 수 있기 때문이다. 하지만 JPA는 데이터베이스별 차이를 상당 부분 추상화해주기 때문에, 순수 JDBC로 모든 SQL을 직접 작성하는 방식보다 특정 벤더에 대한 의존을 줄일 수 있다.</p>
<p>&nbsp;</p>
<h1 id="❓-jpa를-사용한다고-sql을-몰라도-될까">❓ JPA를 사용한다고 SQL을 몰라도 될까?</h1>
<p>JPA를 처음 접하면 <em>“이제 SQL을 몰라도 되는 것 아닌가?”</em> 라고 생각할 수 있다. 하지만 그렇지는 않은 것 같다. 학습하면서 JPA는 SQL을 직접 작성하는 부담을 줄여주는 기술이지, SQL과 데이터베이스 지식을 완전히 대체하는 기술은 아니라는 느낌이 들었다. </p>
<p>따라서 JPA를 제대로 사용하기 위해서는 다음과 같은 내용에 대한 이해가 필수인 것 같다.</p>
<ul>
<li>엔티티와 테이블이 어떻게 매핑되는지</li>
<li>연관관계를 어떻게 매핑하는지</li>
<li>영속성 컨텍스트가 무엇인지</li>
<li>지연 로딩과 즉시 로딩이 어떻게 동작하는지</li>
<li>JPQL이 어떤 SQL로 변환되는지</li>
<li>실제 실행되는 SQL이 성능상 문제가 없는지</li>
<li>트랜잭션 안에서 엔티티가 어떻게 관리되는지</li>
</ul>
<p>특히 성능 문제를 해결하려면 JPA가 내부적으로 실행하는 SQL을 반드시 확인할 수 있어야 한다. JPA를 사용하더라도 최종적으로 데이터베이스와 통신할 때는 SQL이 실행되기 때문이다. JPA를 잘 사용하기 위해 객체지향과 관계형 데이터베이스에 대한 학습도 게을리해서는 안 되겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링이 지원하는 프록시]]></title>
            <link>https://velog.io/@rocker_nun/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%B4-%EC%A7%80%EC%9B%90%ED%95%98%EB%8A%94-%ED%94%84%EB%A1%9D%EC%8B%9C</link>
            <guid>https://velog.io/@rocker_nun/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%B4-%EC%A7%80%EC%9B%90%ED%95%98%EB%8A%94-%ED%94%84%EB%A1%9D%EC%8B%9C</guid>
            <pubDate>Wed, 03 Jun 2026 16:05:28 GMT</pubDate>
            <description><![CDATA[<h1 id="💥-동적-프록시의-문제점">💥 동적 프록시의 문제점</h1>
<p>아래 3가지 질문에 대한 해결책을 생각해보자.</p>
<blockquote>
<p><strong>1번. 인터페이스가</strong> <strong>있는</strong> <strong>경우에는</strong> JDK <strong>동적</strong> <strong>프록시를</strong> <strong>적용하고</strong>, <strong>그렇지</strong> <strong>않은</strong> <strong>경우에는</strong> CGLIB<strong>를</strong> <strong>적용하려면</strong> <strong>어떻게</strong> <strong>해야할까</strong>?</p>
</blockquote>
<p>이전에는 개발자가 상황에 따라서 직접 JDK 동적 프록시를 사용할지 CGLIB를 사용할지 선택해야 했다. 하지만 스프링이 제공하는 프록시 팩토리(<code>ProxyFactory</code>)라는 기능을 사용하면 인터페이스가 있으면 JDK 동적 프록시를, 구체 클래스만 있다면 알아서 CGLIB를 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b1181d62-7acc-44dc-8a76-8f7ae2fec239/image.png" alt=""></p>
<p>클라이언트는 이제 더 이상 JDK 동적 프록시나 CGLIB에 직접 프록시 생성을 요청할 필요가 없다. 그냥 프록시 팩토리에 요청하면 된다. </p>
<p>&nbsp;</p>
<blockquote>
<p><strong>2번. 두 기술을 함께 사용할 때 부가 기능을 적용하기 위해 JDK 동적 프록시가 제공하는 <code>InvocationHandler</code>와 CGLIB가 제공하는 <code>MethodInterceptor</code>를 각각 중복으로 따로 만들어야 할까?</strong></p>
</blockquote>
<p>느낌상 그래도 각각 만들어야 할 것 같지만 스프링은 이 문제를 해결하기 위해 부가기능을 적용할 때 어드바이스(<code>Advice</code>)라는 추상화된 새로운 개념을 도입했다. 이제 개발자는 <code>InvocationHandler</code>든 <code>MethodInterceptor</code>든 신경쓰지 않고 <code>Advice</code>만 만들면 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e51d4c3d-1028-4b46-adb3-03818ab22725/image.png" alt=""></p>
<p>프록시 팩토리에서 생성된 프록시는 JDK 동적 프록시 같은 경우, 내부에 <code>adviceInvocationHandler</code>를 만들어 놓고, CGLIB도 <code>adviceMethodInterceptor</code>를 만들어 놓았다. 클라이언트가 JDK 동적 프록시나 CGLIB로 만들어진 프록시를 호출하면 각 프록시들은 각각 <code>adviceInvocationHandler</code>와 <code>adviceMethodInterceptor</code>를 호출하고, 이 둘은 <code>Advice</code>를 호출하는 것이다. 전체 흐름을 보자면 아래 그림과 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fff0123a-ca02-46c1-ab72-75967c11b253/image.png" alt=""></p>
<p>&nbsp;</p>
<blockquote>
<p><strong>3번. 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면?</strong></p>
</blockquote>
<p>앞서 패턴을 정의해서 메서드 이름이 매칭될 경우에만 부가기능을 사용할 수 있도록 처리했다. 하지만 스프링의 <code>PointCut</code>이라는 개념을 이용한다면 이 문제를 일관성있게 처리할 수 있다.</p>
<p>&nbsp;</p>
<h2 id="📝-예제-코드---advice">📝 예제 코드 - Advice</h2>
<p>일단 <code>Advice</code>를 만들어보자. 만드는 방법에는 여러가지가 있지만 가장 기본적인 방법은 <code>MethodInterceptor</code> 인터페이스를 구현하는 것이다. 기존의 CGLIB를 사용할 때의 <code>MethodInterceptor</code>와는 별개의 패키지에 있는 완전 다른 인터페이스다.</p>
<pre><code class="language-java">package org.aopalliance.intercept;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

@FunctionalInterface
public interface MethodInterceptor extends Interceptor {
    @Nullable
    Object invoke(@Nonnull MethodInvocation invocation) throws Throwable;
}</code></pre>
<p>해당 인터페이스는 <code>Interceptor</code>를 상속 받고 있고, <code>Interceptor</code>는 다시 <code>Advice</code>를 상속 받고 있다. 파라미터로 <code>MethodInvocation</code>만 넘어가고 있는데 이 내부에는 다음 메서드를 호출하는 방법, 현재 프록시 객체 인스턴스, <code>args</code> , 메서드 정보 등이 포함되어 있다. 기존에 파라미터로 제공되는 부분들이 이 안으로 모두 들어갔다고 생각하면 된다.</p>
<p>&nbsp;</p>
<p>이제 실제 <code>Advice</code>를 이용해서 테스트 코드를 작성해보자.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.common.advice;

import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

@Slf4j
public class TimeAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info(&quot;TimeProxy 실행...&quot;);
        long startTime = System.currentTimeMillis();

        Object result = invocation.proceed();

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info(&quot;TimeProxy 종료 resultTime={}&quot;, resultTime);
        return result;
    }
}</code></pre>
<p>위 코드에서 <code>invocation.proceed()</code> 메서드를 호출하면 <code>target</code> 클래스를 호출하고 그 결과를 받을 수 있다. 그런데 <code>MethodInterceptor</code>는 타깃 객체를 직접 필드로 보관하지 않는다. 대신 <code>MethodInvocation</code> 객체 안에 메서드 정보, 인자 정보, 프록시 정보, 타깃 호출 체인 정보가 포함되어 있으며, <code>proceed()</code>를 호출하여 다음 호출 대상으로 위임한다. <code>MethodInterceptor</code>으로 메서드 정보와 함께 타깃 객체가 담긴 <code>MethodInvocation</code> 객체가 전달된다.</p>
<p><code>MethodInvocation</code>은 일종의 콜백 객체로, <code>MethodInvocation.proceed()</code>는 다음 인터셉터(Advice)가 존재하면 해당 인터셉터를 호출하고, 더 이상 인터셉터가 없으면 최종적으로 타깃 메서드를 호출한다. 그러면 <code>MethodInvocation</code>을 구현한 클래스는 일종의 공유 가능한 템플릿처럼 동작한다. 이 부분이 동적 프록시를 직접 만드는 방식보다 프록시 팩토리를 사용하는 방식의 가장 큰 차이점이자 장점이라고 할 수 있다. </p>
<p>&nbsp;</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.proxyfactory;

import com.example.advanced_spring.proxy.common.advice.TimeAdvice;
import com.example.advanced_spring.proxy.common.service.ConcreteService;
import com.example.advanced_spring.proxy.common.service.ServiceImpl;
import com.example.advanced_spring.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;

@Slf4j
public class ProxyFactoryTest {

    @DisplayName(&quot;인터페이스가 있으면 JDK 동적 프록시 사용&quot;)
    @Test
    void interfaceProxy() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new TimeAdvice());
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        log.info(&quot;targetClass={}&quot;, target.getClass());
        log.info(&quot;proxyClass={}&quot;, proxy.getClass());

        proxy.save();

        Assertions.assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        Assertions.assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        Assertions.assertThat(AopUtils.isCglibProxy(proxy)).isFalse();

        /**
         * 13:35:31.170 [Test worker] INFO com.example.advanced_spring.proxy.proxyfactory.ProxyFactoryTest -- targetClass=class com.example.advanced_spring.proxy.common.service.ServiceImpl
         * 13:35:31.172 [Test worker] INFO com.example.advanced_spring.proxy.proxyfactory.ProxyFactoryTest -- proxyClass=class jdk.proxy3.$Proxy13
         * 13:35:31.174 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
         * 13:35:31.174 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
         * 13:35:31.174 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
         */
    }
}
</code></pre>
<p><code>new ProxyFactory(target)</code>에서 프록시 팩토리를 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨주고 있는 것을 볼 수 있다. 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어 내고, 만약 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성하는 것이다. </p>
<p>여기서 호출 대상이 <code>ServiceImpl</code>이고 해당 구현체는 <code>ServiceInterface</code> 인터페이스를 구현하고 있으므로 해당 인터페이스를 기반으로 한 JDK 동적 프록시를 생성한다.</p>
<p>그 후, <code>proxyFactory.addAdvice(new TimeAdvice())</code>를 통해 프록시 팩토리를 통해서 만든 프록시가 사용할 부가기능 로직을 설정한다. 프록시 팩토리에서 <code>MethodInterceptor</code>를 설정해줄 때는 일반적인 DI처럼 세터를 사용하는 대신, 이렇게 <code>addAdvice()</code>라는 메서드를 사용한다. 프록시 팩토리에는 여러 개의 <code>MethodInterceptor</code>를 추가가 가능하고, 이는 프록시 팩토리 하나만으로 여러 개의 부가기능을 제공하는 프록시를 생성할 수 있다는 말이다.</p>
<p>&nbsp;</p>
<p>쉽게 생각하면, JDK 동적 프록시가 제공하는 <code>InvocationHandler</code> 와 CGLIB가 제공하는
<code>MethodInterceptor</code> 의 개념과 유사하다. 이렇게 프록시가 제공하는 부가 기능 로직을 어드바이스(<code>Advice</code>)라 한다. </p>
<p>&nbsp;</p>
<p>이번에는 CGLIB를 적용한 테스트 코드를 작성해보자.</p>
<pre><code class="language-java">@DisplayName(&quot;구체 클래스가 있으면 CGLIB 사용&quot;)
@Test
void concreteProxy() {
    ConcreteService target = new ConcreteService();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new TimeAdvice());
    ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();

    log.info(&quot;targetClass={}&quot;, target.getClass());
    log.info(&quot;proxyClass={}&quot;, proxy.getClass());

    proxy.call();

    Assertions.assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    Assertions.assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    Assertions.assertThat(AopUtils.isCglibProxy(proxy)).isTrue();

    /**
     * 13:35:45.647 [Test worker] INFO com.example.advanced_spring.proxy.proxyfactory.ProxyFactoryTest -- targetClass=class com.example.advanced_spring.proxy.common.service.ConcreteService
     * 13:35:45.648 [Test worker] INFO com.example.advanced_spring.proxy.proxyfactory.ProxyFactoryTest -- proxyClass=class com.example.advanced_spring.proxy.common.service.ConcreteService$$SpringCGLIB$$0
     * 13:35:45.649 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
     * 13:35:45.649 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ConcreteService -- ConcreteService 호출...
     * 13:35:45.649 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
     */
}</code></pre>
<p><code>ConcreteService$$SpringCGLIB$$0</code>를 보면 CGLIB 프록시가 정상적으로 적용된 것을 확인할 수 있다.</p>
<p>&nbsp;</p>
<p>이제 <code>ProxyTargetClass</code> 옵션을 사용해서 인터페이스가 있어도 CGLIB 프록시를 생성하도록 처리해보자. 해당 옵션에 <code>true</code> 값을 넣으면 인터페이스가 있어도 강제로 CGLIB를 사용한다. 그리고 인터페이스가 아닌 클래스 기반의 프록시를 만들어준다.</p>
<pre><code class="language-java">@DisplayName(&quot;ProxyTargetClass 옵션을 이용하면 인터페이스가 있어도 CGLIB를 사용&quot;)
@Test
void proxyTargetClass() {
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(true);  // 옵션 설정
    proxyFactory.addAdvice(new TimeAdvice());
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

    log.info(&quot;targetClass={}&quot;, target.getClass());
    log.info(&quot;proxyClass={}&quot;, proxy.getClass());

    proxy.save();

    Assertions.assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    Assertions.assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    Assertions.assertThat(AopUtils.isCglibProxy(proxy)).isTrue();

    /**
     * 13:36:21.783 [Test worker] INFO com.example.advanced_spring.proxy.proxyfactory.ProxyFactoryTest -- targetClass=class com.example.advanced_spring.proxy.common.service.ServiceImpl
     * 13:36:21.785 [Test worker] INFO com.example.advanced_spring.proxy.proxyfactory.ProxyFactoryTest -- proxyClass=class com.example.advanced_spring.proxy.common.service.ServiceImpl$$SpringCGLIB$$0
     * 13:36:21.786 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
     * 13:36:21.786 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
     * 13:36:21.786 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
     */
}</code></pre>
<p>&nbsp;</p>
<h1 id="🧑🧑🧒-pointcut-advice-advisor">🧑‍🧑‍🧒 PointCut, Advice, Advisor</h1>
<p><strong>어드바이스(Advice)</strong>: 타깃 객체에 적용하는 부가기능을 담은 객체다. 단순하게 프록시 로직이라 생각하면 된다. </p>
<p><strong>포인트컷(PointCut)</strong>: 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링 한다. 이름 그대로 어떤 포인트에 기능을 적용할지 하지 않을지 잘라서 구분하는 것이다.</p>
<p><strong>어드바이저(Advisor)</strong>: 포인트컷 1개와 어드바이스 1개를 합쳐 놓은 것이다. <del>왜 합친거지…?</del></p>
<p>&nbsp;</p>
<p>간단히 정리하면 부가 기능 로직을 적용해야 하는데, 포인트컷으로 어디에 적용할지 선택하고, 어드바이스로 어떤 로직을 적용할지 선택해야 한다. 이 모든 것을 알고 있는 것이 바로 <strong>어드바이저(Advisor)</strong>이다. 조언자(<code>Advisor</code>)는 어디(<code>Pointcut</code>)에 조언(<code>Advice</code>)을 해야할지 알고 있는 것이다.</p>
<p>&nbsp;</p>
<p>이렇게 구분한 것은 역할과 책임을 명확하게 분리한 것이다. 포인트컷은 대상 여부를 확인하는 필터 역할만 담당한다. 어드바이스는 깔끔하게 부가 기능 로직만 담당한다. 둘을 합치면 어드바이저가 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fa4c4991-70bd-4a52-a95b-9366201e6e07/image.png" alt=""></p>
<p>호출 흐름을 간단하게 살펴보자면, 일단 클라이언트가 프록시를 호출하게 되면 프록시는 어드바이저 내부의 포인트컷을 호출한다. 메서드를 호출해도 된다는 확인을 받으면 그때 부가기능을 호출하고 필터링에 실패하면 부가기능을 호출하지 않고 바로 타겟 객체를 호출한다.</p>
<p>&nbsp;</p>
<h2 id="📝-예제-코드---advisor">📝 예제 코드 - Advisor</h2>
<p>어드바이저는 내부에 하나의 포인트컷과 하나의 어드바이스를 가지고 있다고 했다. 프록시 팩토리를 통해 프록시를 생성할 때 어드바이저를 제공하면 어디에 어떤 기능을 제공할지 알 수 있다.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.advisor;

import com.example.advanced_spring.proxy.common.advice.TimeAdvice;
import com.example.advanced_spring.proxy.common.service.ServiceImpl;
import com.example.advanced_spring.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;

@Slf4j
public class AdvisorTest {

    @Test
    void advisorTest1() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
        proxyFactory.addAdvisor(advisor);
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();

        /**
         * 16:41:50.024 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
         * 16:41:50.026 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
         * 16:41:50.026 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
         *
         * 16:41:50.027 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
         * 16:41:50.027 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- find 호출...
         * 16:41:50.027 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
         */
    }
}
</code></pre>
<p>지금은 <code>Pointcut.TRUE</code>를 넘겨줌으로써 포인트컷의 필터링을 무조건 통과하도록 처리했다. 따라서 실행 시간 측정 부가기능을 수행하는 <code>TimeAdvice</code>가 정상적으로 동작하는 것을 확인할 수 있다.</p>
<p>&nbsp;</p>
<h2 id="📝-예제-코드---pointcut">📝 예제 코드 - <strong>PointCut</strong></h2>
<p>이번에는 <code>save()</code> 메서드에는 어드바이스를 호출하고, <code>find()</code> 메서드에는 호출하지 않도록 필터링 하는 포인트컷을 직접 만들어보도록 하자. 다행히도 스프링이 아래와 같이 <code>Pointcut</code> 인터페이스를 제공하고 있어서 깡구현은 하지 않아도 된다. </p>
<pre><code class="language-java">package org.springframework.aop;

public interface Pointcut {
    Pointcut TRUE = TruePointcut.INSTANCE;

    ClassFilter getClassFilter();

    MethodMatcher getMethodMatcher();
}</code></pre>
<p><code>Pointcut</code> 인터페이스는 내부에 <code>ClassFilter</code>와 <code>MethodMatcher</code>로 이루어져 있는데 클래스가 맞는지, 특정 메서드와 매칭이 되는지 확인할 때 사용한다. 이 2개의 관문을 모두 통과해야 어드바이스를 적용할 수 있게 되는 것이다. </p>
<p>&nbsp;</p>
<p><code>Pointcut</code> 인터페이스를 구현한 구현체를 스프링이 제공하지만 연습을 위해 이번만 직접 포인트컷을 만들어보자.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.advisor;

import com.example.advanced_spring.proxy.common.advice.TimeAdvice;
import com.example.advanced_spring.proxy.common.service.ServiceImpl;
import com.example.advanced_spring.proxy.common.service.ServiceInterface;
import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.ClassFilter;
import org.springframework.aop.MethodMatcher;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;

@Slf4j
public class AdvisorTest {

    ...

    @Test
    void advisorTest2() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());
        proxyFactory.addAdvisor(advisor);
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();

        /**
         * 17:01:55.248 [Test worker] INFO com.example.advanced_spring.proxy.advisor.AdvisorTest -- 포인트컷 호출 method=save, targetClass=class com.example.advanced_spring.proxy.common.service.ServiceImpl
         * 17:01:55.251 [Test worker] INFO com.example.advanced_spring.proxy.advisor.AdvisorTest -- 포인트컷 결과 result=true
         * 17:01:55.252 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
         * 17:01:55.253 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
         * 17:01:55.253 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
         * 
         * 17:01:55.253 [Test worker] INFO com.example.advanced_spring.proxy.advisor.AdvisorTest -- 포인트컷 호출 method=find, targetClass=class com.example.advanced_spring.proxy.common.service.ServiceImpl
         * 17:01:55.253 [Test worker] INFO com.example.advanced_spring.proxy.advisor.AdvisorTest -- 포인트컷 결과 result=false
         * 17:01:55.253 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- find 호출...
         */
    }

    static class MyPointcut implements Pointcut {
        @Override
        public ClassFilter getClassFilter() {
            return ClassFilter.TRUE;
        }

        @Override
        public MethodMatcher getMethodMatcher() {
            return new MyMethodMatcher();
        }
    }

    static class MyMethodMatcher implements MethodMatcher {

        private String matchName = &quot;save&quot;;

        @Override
        public boolean matches(Method method, Class&lt;?&gt; targetClass) {
            boolean result = method.getName().equals(matchName);
            log.info(&quot;포인트컷 호출 method={}, targetClass={}&quot;, method.getName(), targetClass);
            log.info(&quot;포인트컷 결과 result={}&quot;, result);
            return result;
        }

        @Override
        public boolean isRuntime() {
            return false;
        }

        @Override
        public boolean matches(Method method, Class&lt;?&gt; targetClass, Object... args) {
            return false;
        }
    }
}</code></pre>
<p>&nbsp;</p>
<h2 id="📝-예제-코드---스프링이-제공하는-포인트컷">📝 예제 코드 - 스프링이 제공하는 포인트컷</h2>
<p>이번에는 스프링이 제공하는 <code>NameMatchMethodPointcut</code> 를 사용해서 구현해보자.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.advisor;

import com.example.advanced_spring.proxy.common.advice.TimeAdvice;
import com.example.advanced_spring.proxy.common.service.ServiceImpl;
import com.example.advanced_spring.proxy.common.service.ServiceInterface;
import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.ClassFilter;
import org.springframework.aop.MethodMatcher;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;

@Slf4j
public class AdvisorTest {

    ...

    @Test
    void advisorTest3() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames(&quot;save&quot;);

        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
        proxyFactory.addAdvisor(advisor);
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();

        /**
         * 17:15:55.486 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 실행...
         * 17:15:55.489 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
         * 17:15:55.489 [Test worker] INFO com.example.advanced_spring.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
         * 
         * 17:15:55.490 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- find 호출...
         */
    }

    ...
}
</code></pre>
<p>보다시피 <code>find()</code> 메서드일 경우에는 어드바이스가 호출되지 않는 것을 확인할 수 있다. 이처럼 스프링은 <code>NameMatchMethodPointcut</code>뿐만 아니라 여러 포인트컷 구현체를 제공하고 있다. 대표적인 구현체는 다음과 같다.</p>
<ul>
<li><p><code>NameMatchMethodPointcut</code> : 메서드 이름을 기반으로 매칭한다. 내부에서는 <code>PatternMatchUtils</code> 를 사용한다. (<code>*xxx*</code> 허용)</p>
</li>
<li><p><code>JdkRegexpMethodPointcut</code> : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.</p>
</li>
<li><p><code>TruePointcut</code> : 항상 참을 반환한다.</p>
</li>
<li><p><code>AnnotationMatchingPointcut</code> : 애노테이션으로 매칭한다.</p>
</li>
<li><p><code>AspectJExpressionPointcut</code> : aspectJ 표현식으로 매칭한다.</p>
</li>
</ul>
<p>실무에서는 사용하기도 편리하고 기능도 가장 많은 aspectJ 표현식을 기반으로 사용하는 <code>AspectJExpressionPointcut</code>을 사용하게 된다.</p>
<p>&nbsp;</p>
<h2 id="📝-예제-코드---여러-어드바이저-적용">📝 예제 코드 - 여러 어드바이저 적용</h2>
<p>이번에는 처리 흐름을 아래 그림과 같이 타깃 객체에 여러 어드바이저를 적용하는 코드를 작성해보자. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/84640caf-f25a-479a-b2a8-60c90fa69d68/image.png" alt=""></p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.advisor;

import com.example.advanced_spring.proxy.common.service.ServiceImpl;
import com.example.advanced_spring.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;

public class MultiAdvisorTest {

    @Test
    void multiAdvisorTest1() {
        // 프록시1 생성
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory1 = new ProxyFactory(target);
        DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
        proxyFactory1.addAdvisor(advisor1);
        ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();

        // 프록시2 생성
        ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
        DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
        proxyFactory1.addAdvisor(advisor2);
        ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();

        // 실행
        proxy2.save();

        /**
         * 23:33:12.266 [Test worker] INFO com.example.advanced_spring.proxy.advisor.MultiAdvisorTest$Advice1 -- advice1 호출...
         * 23:33:12.267 [Test worker] INFO com.example.advanced_spring.proxy.advisor.MultiAdvisorTest$Advice2 -- advice2 호출...
         * 23:33:12.268 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
         */
    }

    @Slf4j
    static class Advice1 implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info(&quot;advice1 호출...&quot;);
            return invocation.proceed();
        }
    }

    @Slf4j
    static class Advice2 implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info(&quot;advice2 호출...&quot;);
            return invocation.proceed();
        }
    }

}
</code></pre>
<p>&nbsp;</p>
<p>근데 위와 같이 여러 부가기능을 수행하기 위해서는 그 부가기능만큼 프록시를 생성하는 코드를 작성해야 하는 문제점이 있다. 다행히도 스프링은 하나의 프록시에 여러 어드바이저를 적용할 수 있도록 되어 있다. 그냥 적용할 어드바이저를 <code>addAdvisor()</code> 메서드를 이용해서 추가하기만 하면 된다.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.advisor;

import com.example.advanced_spring.proxy.common.service.ServiceImpl;
import com.example.advanced_spring.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;

public class MultiAdvisorTest {

    ...

    @Test
    void multiAdvisorTest2() {
        DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
        DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());

        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory1 = new ProxyFactory(target);

        proxyFactory1.addAdvisor(advisor1);
        proxyFactory1.addAdvisor(advisor2);
        ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();

        // 실행
        proxy.save();

        /**
         * 23:40:21.378 [Test worker] INFO com.example.advanced_spring.proxy.advisor.MultiAdvisorTest$Advice1 -- advice1 호출...
         * 23:40:21.380 [Test worker] INFO com.example.advanced_spring.proxy.advisor.MultiAdvisorTest$Advice2 -- advice2 호출...
         * 23:40:21.380 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ServiceImpl -- save 호출...
         */
    }

    ...
}
</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7cb95b6a-8b3a-463f-9df6-d2bf14554ab0/image.png" alt=""></p>
<p>보다시피 <code>multiAdvisorTest1()</code>, <code>multiAdvisorTest2()</code> 모두 여러 개의 어드바이저를 적용할 수 있지만, <code>multiAdvisorTest2()</code>처럼 <code>addAdvisor()</code>를 사용해서 적용할 어드바이저를 추가하는 것이 코드량도 훨씬 적고 성능도 더 좋다.</p>
<p>&nbsp;</p>
<h1 id="🏭-프록시-팩토리-적용">🏭 프록시 팩토리 적용</h1>
<p>이제 프록시 팩토리를 사용해서 애플리케이션에 프록시를 생성해보자.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v3_proxyfactory.advice;

import com.example.advanced_spring.trace.TraceStatus;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import java.lang.reflect.Method;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

public class LogTraceAdvice implements MethodInterceptor {

    private final LogTrace logTrace;

    public LogTraceAdvice(LogTrace logTrace) {
        this.logTrace = logTrace;
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TraceStatus status = null;

        try {
            Method method = invocation.getMethod();
            String message = method.getDeclaringClass().getName() + &quot;.&quot; + method.getName() + &quot;()&quot;;
            status = logTrace.begin(message);

            // 로직 호출
            Object result = invocation.proceed();

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
</code></pre>
<p>&nbsp;</p>
<h3 id="👤-인터페이스가-존재하는-경우">👤 인터페이스가 존재하는 경우</h3>
<pre><code class="language-java">@Slf4j
@Configuration
public class ProxyFactoryConfigV1 {

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
        ProxyFactory factory = new ProxyFactory(orderController);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderControllerV1 proxy = (OrderControllerV1) factory.getProxy();
        log.info(&quot;ProxyFactory proxy={}, target={}&quot;, proxy.getClass(), orderController.getClass());
        return proxy;
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
        ProxyFactory factory = new ProxyFactory(orderService);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderServiceV1 proxy = (OrderServiceV1) factory.getProxy();
        log.info(&quot;ProxyFactory proxy={}, target={}&quot;, proxy.getClass(), orderService.getClass());
        return proxy;
    }
    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
        ProxyFactory factory = new ProxyFactory(orderRepository);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderRepositoryV1 proxy = (OrderRepositoryV1) factory.getProxy();
        log.info(&quot;ProxyFactory proxy={}, target={}&quot;, proxy.getClass(), orderRepository.getClass());
        return proxy;
    }

    private Advisor getAdvisor(LogTrace logTrace) {
        // 포인트컷
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames(&quot;request*&quot;, &quot;order*&quot;, &quot;save*&quot;);

        // 어드바이스
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}
</code></pre>
<p>현재 V1은 인터페이스만 존재하고 있기 때문에 애플리케이션을 실행해보면 프록시 팩토리가 JDK 동적 프록시가 적용되는 것을 확인할 수 있다. </p>
<p>&nbsp;</p>
<h3 id="💎-인터페이스가-없고-구현체만-존재하는-경우">💎 인터페이스가 없고, 구현체만 존재하는 경우</h3>
<pre><code class="language-java">@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {

    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
        OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));
        ProxyFactory factory = new ProxyFactory(orderController);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderControllerV2 proxy = (OrderControllerV2) factory.getProxy();
        log.info(&quot;ProxyFactory proxy={}, target={}&quot;, proxy.getClass(), orderController.getClass());
        return proxy;
    }

    @Bean
    public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
        OrderServiceV2 orderService = new OrderServiceV2(orderRepositoryV2(logTrace));
        ProxyFactory factory = new ProxyFactory(orderService);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderServiceV2 proxy = (OrderServiceV2) factory.getProxy();
        log.info(&quot;ProxyFactory proxy={}, target={}&quot;, proxy.getClass(), orderService.getClass());
        return proxy;
    }
    @Bean
    public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
        OrderRepositoryV2 orderRepository = new OrderRepositoryV2();
        ProxyFactory factory = new ProxyFactory(orderRepository);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderRepositoryV2 proxy = (OrderRepositoryV2) factory.getProxy();
        log.info(&quot;ProxyFactory proxy={}, target={}&quot;, proxy.getClass(), orderRepository.getClass());
        return proxy;
    }

    private Advisor getAdvisor(LogTrace logTrace) {
        // 포인트컷
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames(&quot;request*&quot;, &quot;order*&quot;, &quot;save*&quot;);

        // 어드바이스
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}
</code></pre>
<p>V2는 인터페이스가 없고 구현체만 존재하고 있기 때문에 프록시 팩토리가 CGLIB을 적용하는 것을 확인할 수 있다. 이로써 번거롭게 프록시를 일일이 생성해주지 않고 프록시 팩토리가 상황에 맞게 알맞은 방법으로 프록시를 생성해주도록 위임할 수 있게 되었고, 어드바이저, 어드바이스, 포인트컷 이라는 개념 덕분에 어떤 부가기능을 어디에 적용해야 할지 아주 명확하게 이해할 수 있게 되었다. </p>
<p>하지만 아직 남은 문제들이 몇 가지 남았는데, 위의 코드만 보더라도 각 방식을 적용하기 위해서는 설정 파일들을 일일이 만들어줘야 한다는 것이다. 스프링 빈으로 직접 등록하는 것도 모자라서, 프록시를 적용하는 코드까지 빈 생성 코드에 넣어줘야 한다. 그리고 컴포넌트 스캔을 사용하는 경우 지금까지 알고 있는 방법으로는 프록시 적용이 불가능하다. 왜냐하면 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Maven과 Gradle 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/Maven%EA%B3%BC-Gradle-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/Maven%EA%B3%BC-Gradle-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 16 May 2026 03:53:45 GMT</pubDate>
            <description><![CDATA[<h1 id="🎏-빌드-도구의-개념과-차이">🎏 빌드 도구의 개념과 차이</h1>
<p>eGovFrame VSCode Initializr로 Web 템플릿과 Boot 템플릿을 생성해보면서 자연스럽게 Maven과 Gradle도 함께 살펴보게 되었다.</p>
<p>처음에는 Maven과 Gradle을 단순히 <em>“프로젝트 빌드할 때 쓰는 도구”</em> 정도로만 생각했지만, 학습을 진행하며 둘은 같은 목적을 가지지만 접근 방식이 꽤 다르다는 것을 알 수 있었다.</p>
<p>또 eGovFrame 5.0 기반 프로젝트를 실행하려면 Tomcat 버전도 중요했다. 기존 Java EE의 <code>javax.*</code> 패키지가 Jakarta EE의 <code>jakarta.*</code> 패키지로 바뀌면서, Tomcat 10 이상이 필요해졌기 때문이다.</p>
<p>&nbsp;</p>
<h2 id="❓-빌드-도구가-왜-필요할까">❓ 빌드 도구가 왜 필요할까?</h2>
<p>Java 프로젝트를 만든다고 해서 단순히 <code>.java</code> 파일만 작성하면 끝나는 것은 아니다.</p>
<p>실제 프로젝트에서는 다음과 같은 작업이 필요하다.</p>
<ul>
<li>필요한 외부 라이브러리 다운로드</li>
<li>Java 소스 코드 컴파일</li>
<li>테스트 실행</li>
<li>정적 리소스 포함</li>
<li>JAR 또는 WAR 파일로 패키징</li>
<li>로컬 저장소 또는 원격 저장소에 배포</li>
<li>실행 환경에 맞는 설정 적용</li>
</ul>
<p>이런 많은 작업을 매번 개발자가 직접 하면 매우 번거로울 뿐만 아니라 실수도 많이 발생할 것이다. 그래서 등장한 것이 <strong>빌드 자동화 도구</strong>다.</p>
<p>빌드 자동화 도구는 프로젝트 설정 파일을 읽고, 필요한 라이브러리를 가져오고, 소스 코드를 컴파일하고, 최종 결과물인 JAR 또는 WAR 파일을 만들어준다. 그 대표적인 빌드 도구가 바로 Java 생태계에서는 <strong>Maven</strong>과 <strong>Gradle</strong>인 것이다.</p>
<p>&nbsp;</p>
<h2 id="🤔-maven이란">🤔 Maven이란?</h2>
<p>Maven은 Java 프로젝트를 빌드하고 관리하기 위한 대표적인 빌드 자동화 도구다. Maven은 <code>pom.xml</code> 파일을 중심으로 동작한다.</p>
<p><code>pom.xml</code>은 <strong>Project Object Model</strong>의 약자로, 프로젝트의 정보를 담고 있는 설정 파일이다. 여기에는 다음과 같은 정보가 들어간다.</p>
<ul>
<li>프로젝트 이름</li>
<li>프로젝트 버전</li>
<li>패키징 방식</li>
<li>의존성 목록</li>
<li>저장소 정보</li>
<li>빌드 플러그인</li>
<li>테스트 설정</li>
</ul>
<p>예를 들어 eGovFrame Web 템플릿의 <code>pom.xml</code>에는 다음과 같은 설정이 들어 있다.</p>
<pre><code class="language-xml">&lt;groupId&gt;com.egovframework&lt;/groupId&gt;
&lt;artifactId&gt;egovweb&lt;/artifactId&gt;
&lt;packaging&gt;war&lt;/packaging&gt;
&lt;version&gt;1.0.0&lt;/version&gt;</code></pre>
<p>&nbsp;</p>
<h3 id="🔄️-maven의-라이프사이클">🔄️ Maven의 라이프사이클</h3>
<p>Maven의 핵심은 <strong>정해진 규칙과 라이프사이클</strong>이다. Maven은 프로젝트가 어떤 흐름으로 빌드되어야 하는지 미리 정해두었다. </p>
<p>&nbsp;</p>
<p>대표적인 라이프사이클 단계는 다음과 같다.</p>
<ol>
<li><p><code>validate</code>: 프로젝트가 올바른지 확인한다.</p>
</li>
<li><p><code>compile</code>: Java 소스 코드를 컴파일한다.</p>
</li>
<li><p><code>test</code>: 테스트 코드를 실행한다.</p>
</li>
<li><p><code>package</code>: 컴파일된 결과를 JAR이나 WAR로 패키징한다.</p>
</li>
<li><p><code>verify</code>: 패키징 결과를 검증한다.</p>
</li>
<li><p><code>install</code>: 로컬 Maven 저장소에 설치한다.</p>
</li>
<li><p><code>deploy</code>: 원격 저장소에 배포한다.</p>
</li>
</ol>
<p>&nbsp;</p>
<p>예를 들어 <code>mvn clean package</code> 명령어를 실행하면 Maven은 아래와 같은 작업들을 순서대로 수행한다.</p>
<ol>
<li>clean 단계에서 기존 target 폴더 삭제</li>
<li>필요한 의존성 다운로드</li>
<li>Java 소스 코드 컴파일</li>
<li>테스트 처리</li>
<li>프로젝트를 JAR 또는 WAR로 패키징</li>
</ol>
<p>eGovFrame Web 템플릿에서는 <code>packaging</code>이 <code>war</code>이므로 최종 결과물로 <code>.war</code> 파일이 생성되고, Spring Boot Boot 템플릿에서는 실행 가능한 <code>.jar</code> 파일이 생성된다.</p>
<p>&nbsp;</p>
<h3 id="👪-maven의-부모-pom-이해하기">👪 Maven의 부모 POM 이해하기</h3>
<p>eGovFrame Web 템플릿의 <code>pom.xml</code>에서 중요한 부분은 부모 POM이다.</p>
<pre><code class="language-xml">&lt;parent&gt;
    &lt;groupId&gt;org.egovframe.web&lt;/groupId&gt;
    &lt;artifactId&gt;egovframe-web-config-parent&lt;/artifactId&gt;
    &lt;version&gt;5.0.0&lt;/version&gt;
&lt;/parent&gt;</code></pre>
<p>부모 POM은 여러 라이브러리의 버전을 한 곳에서 관리해준다.</p>
<p>&nbsp;</p>
<p>예를 들어 아래와 같은 의존성이 있다고 해보자.</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.egovframe.rte&lt;/groupId&gt;
    &lt;artifactId&gt;egovframe-rte-ptl-mvc&lt;/artifactId&gt;
&lt;/dependency&gt;</code></pre>
<p>여기에는 <code>&lt;version&gt;</code>이 없다. 그런데도 빌드가 된다. 그 이유는 부모 POM에서 해당 라이브러리의 버전을 관리해주기 때문이다.</p>
<p>이 방식의 장점은 명확하다. 여러 라이브러리의 버전을 프로젝트마다 직접 맞추지 않아도 된다는 것이다. 부모 POM에서 검증된 버전 조합을 관리하면, 하위 프로젝트는 그 조합을 그대로 사용할 수 있다. 이런 방식은 eGovFrame처럼 여러 실행환경 라이브러리와 Spring, MyBatis, Jakarta 관련 라이브러리를 함께 사용하는 프로젝트에서 매우 중요하다.</p>
<p>&nbsp;</p>
<h3 id="⛓️-maven의-scope-이해하기">⛓️ Maven의 scope 이해하기</h3>
<p>Maven 의존성에는 <code>scope</code>라는 개념이 있다. 예를 들어 Web 템플릿의 <code>pom.xml</code>을 보면 다음과 같은 설정이 나온다.</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;jakarta.servlet.jsp&lt;/groupId&gt;
    &lt;artifactId&gt;jakarta.servlet.jsp-api&lt;/artifactId&gt;
    &lt;scope&gt;provided&lt;/scope&gt;
&lt;/dependency&gt;</code></pre>
<p>여기서 <code>provided</code>는 <strong>컴파일할 때는 필요하지만, 실행할 때는 외부 환경이 제공한다</strong>는 뜻이다.</p>
<p>&nbsp;</p>
<p>예를 들어 Servlet API나 JSP API는 코드를 컴파일할 때 필요하다. 하지만 실제 실행 시점에는 Tomcat이 이미 Servlet/JSP 관련 기능을 제공한다. 따라서 이런 라이브러리를 WAR 파일 안에 또 넣으면 충돌이 발생할 수 있다.</p>
<p>정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>scope</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>compile</code></td>
<td>컴파일과 실행 모두 필요. 기본값</td>
</tr>
<tr>
<td><code>provided</code></td>
<td>컴파일할 때만 필요. 실행 환경이 제공</td>
</tr>
<tr>
<td><code>test</code></td>
<td>테스트할 때만 필요</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="🎭-maven의-장점과-단점">🎭 Maven의 장점과 단점</h3>
<ul>
<li><p><strong>장점</strong>: 규칙이 명확하다는 것이다. 프로젝트 구조, 빌드 단계, 의존성 관리 방식이 어느 정도 정해져 있다. 그래서 처음 보는 Maven 프로젝트라도 <code>pom.xml</code>을 보면 프로젝트가 어떤 라이브러리를 사용하고, 어떤 방식으로 빌드되는지 파악하기 쉽다. 또한 eGovFrame처럼 여러 라이브러리 버전을 안정적으로 맞춰야 하는 프로젝트에서는 부모 POM을 통한 버전 관리가 큰 장점이 된다.</p>
</li>
<li><p><strong>단점</strong>: XML 기반이라 설정이 길고 장황해질 수 있다. 또한 정해진 라이프사이클에 맞춰 동작하기 때문에 복잡한 커스텀 빌드 로직을 작성하기에는 유연성이 부족할 수 있다. 즉, Maven은 안정적이고 예측 가능하지만, 복잡한 빌드 자동화를 세밀하게 제어하고 싶을 때는 답답하게 느껴질 수 있다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="🤔-gradle이란">🤔 Gradle이란?</h2>
<p>Gradle도 Maven처럼 Java 프로젝트를 빌드하고 의존성을 관리하는 빌드 자동화 도구다. 하지만 Maven과는 접근 방식이 다르다.</p>
<p>Maven이 <code>pom.xml</code>이라는 XML 파일에 정해진 구조로 빌드를 선언한다면, Gradle은 <code>build.gradle</code> 또는 <code>build.gradle.kts</code> 파일에 Groovy나 Kotlin DSL로 빌드 로직을 작성한다.</p>
<p>&nbsp;</p>
<p>예를 들어 Gradle에서는 의존성을 다음처럼 작성한다.</p>
<pre><code class="language-gradle">dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    testImplementation &#39;org.junit.jupiter:junit-jupiter-api&#39;
}</code></pre>
<p>보다시피 Maven보다 문법이 짧고, 조건문이나 반복문 같은 프로그래밍적 표현도 사용할 수 있다. Gradle의 핵심은 <strong>태스크(Task)</strong>다. Gradle은 빌드를 여러 태스크의 조합으로 바라본다.</p>
<p>&nbsp;</p>
<p>예를 들어 다음과 같은 태스크가 있을 수 있다.</p>
<pre><code class="language-text">compileJava
processResources
test
jar
bootRun
build</code></pre>
<p>Gradle은 이 태스크들 사이의 의존 관계를 분석해서 필요한 작업만 실행한다.</p>
<p>&nbsp;</p>
<h3 id="🗃️-gradle-wrapper란">🗃️ Gradle Wrapper란?</h3>
<p>Gradle 프로젝트를 보면 다음과 같은 파일들을 <strong>Gradle Wrapper</strong>라고 한다.</p>
<pre><code class="language-text">gradlew
gradlew.bat
gradle/wrapper/gradle-wrapper.properties</code></pre>
<p>Gradle Wrapper가 있으면 로컬 PC에 Gradle이 설치되어 있지 않아도 프로젝트에서 지정한 Gradle 버전으로 빌드할 수 있다.</p>
<p>&nbsp;</p>
<p>예를 들어 <code>./gradlew build</code> 명령어를 실행하면 Wrapper가 필요한 Gradle 버전을 확인하고, 없으면 자동으로 다운로드한 뒤 빌드를 수행한다.</p>
<p>이 방식은 팀 프로젝트에서 매우 편리한데, 팀원마다 로컬에 설치된 Gradle 버전이 다르면 빌드 결과가 달라질 수 있는 부분들을 Wrapper를 사용하면 모든 팀원이 프로젝트에서 지정한 동일한 Gradle 버전으로 빌드할 수 있다.</p>
<p>CI/CD 환경에서도 마찬가지다. 빌드 서버에 Gradle을 따로 설치하지 않아도 <code>./gradlew build</code>만 실행하면 프로젝트가 요구하는 버전으로 빌드된다.</p>
<p>&nbsp;</p>
<h3 id="🏃♂️-gradle이-빠르다고-하는-이유">🏃‍♂️ Gradle이 빠르다고 하는 이유</h3>
<p>Gradle은 Maven보다 빌드 성능 측면에서 유리한 기능을 많이 제공한다.</p>
<p>대표적으로 다음과 같은 기능이 있다.</p>
<ul>
<li><p><code>Incremental Build</code>: 이전 빌드 이후 변경된 부분만 다시 빌드하는 방식이다. 예를 들어 소스 코드가 바뀌지 않았다면 굳이 같은 컴파일 작업을 다시 하지 않을 수 있다.</p>
</li>
<li><p><code>Build Cache</code>: 이미 수행한 빌드 결과를 저장해두었다가 같은 입력에 대해 다시 사용할 수 있게 해준다. 동일한 입력으로 동일한 결과가 나오는 작업이라면, Gradle은 매번 새로 작업하지 않고 캐시된 결과를 재사용할 수 있다.</p>
</li>
<li><p><code>Gradle Daemon</code>: Gradle 실행 프로세스를 백그라운드에 유지해서 매번 새로 JVM을 띄우는 비용을 줄여준다. 이런 기능 덕분에 규모가 큰 프로젝트에서는 Gradle이 Maven보다 빠르게 느껴질 수 있다. 다만 Gradle은 자유도가 높은 만큼 처음 배우는 사람에게는 Maven보다 어렵게 느껴질 수 있다. 빌드가 어떻게 동작하는지 이해하려면 태스크, 플러그인, DSL, Wrapper 구조를 함께 이해해야 하기 때문이다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="📝-maven과-gradle의-차이">📝 Maven과 Gradle의 차이</h2>
<p>Maven과 Gradle은 모두 Java 프로젝트를 빌드하고 의존성을 관리하는 도구다. 둘의 목표는 비슷하지만 철학이 다르다.</p>
<p>Maven은 <strong>정해진 규칙을 따르는 선언형 빌드 도구</strong>에 가깝고, Gradle은 <strong>필요한 빌드 흐름을 코드처럼 구성할 수 있는 유연한 빌드 도구</strong>에 가깝다.</p>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>Maven</th>
<th>Gradle</th>
</tr>
</thead>
<tbody><tr>
<td>설정 파일</td>
<td><code>pom.xml</code></td>
<td><code>build.gradle</code>, <code>build.gradle.kts</code></td>
</tr>
<tr>
<td>설정 언어</td>
<td>XML</td>
<td>Groovy DSL 또는 Kotlin DSL</td>
</tr>
<tr>
<td>빌드 철학</td>
<td>정해진 라이프사이클 중심</td>
<td>태스크 그래프 중심</td>
</tr>
<tr>
<td>구조</td>
<td>규칙이 강하고 예측 가능</td>
<td>자유도가 높고 유연함</td>
</tr>
<tr>
<td>의존성 관리</td>
<td>안정적이고 명확함</td>
<td>Maven 저장소 사용 가능, 표현이 간결함</td>
</tr>
<tr>
<td>성능</td>
<td>단순하고 안정적이지만 반복 빌드가 느릴 수 있음</td>
<td>캐시, 증분 빌드, 데몬으로 빠른 빌드 가능</td>
</tr>
<tr>
<td>커스텀 빌드</td>
<td>XML 플러그인 설정이 장황할 수 있음</td>
<td>코드처럼 작성 가능</td>
</tr>
<tr>
<td>학습 난이도</td>
<td>초보자에게 상대적으로 이해하기 쉬움</td>
<td>DSL과 태스크 개념을 익혀야 함</td>
</tr>
<tr>
<td>공공/기업 레거시</td>
<td>많이 사용됨</td>
<td>점점 증가 중</td>
</tr>
<tr>
<td>eGovFrame 공식 구조</td>
<td>Maven 중심으로 이해하기 쉬움</td>
<td>별도 BOM/플러그인 설정 이해 필요</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="👍-maven을-선택하면-좋은-경우">👍 Maven을 선택하면 좋은 경우</h3>
<ul>
<li>eGovFrame 프로젝트를 처음 학습하는 경우</li>
<li>공공/기업 프로젝트의 전통적인 구조를 익히고 싶은 경우</li>
<li>정해진 라이프사이클과 표준 구조를 따르고 싶은 경우</li>
<li><code>pom.xml</code> 기반 의존성 관리와 부모 POM 구조를 이해하고 싶은 경우</li>
</ul>
<p>&nbsp;</p>
<h3 id="👍-gradle을-선택하면-좋은-경우">👍 Gradle을 선택하면 좋은 경우</h3>
<ul>
<li>빌드 속도가 중요한 대규모 프로젝트인 경우</li>
<li>멀티 모듈 프로젝트를 효율적으로 관리하고 싶은 경우</li>
<li>커스텀 빌드 로직이 많은 경우</li>
<li>Spring Boot, Android, Kotlin 기반 프로젝트에 익숙한 경우</li>
<li>Gradle Wrapper로 팀의 빌드 환경을 통일하고 싶은 경우</li>
</ul>
<p>&nbsp;</p>
<h2 id="👀-egovframe-프로젝트에서-maven과-gradle을-어떻게-바라보면-좋을까">👀 eGovFrame 프로젝트에서 Maven과 Gradle을 어떻게 바라보면 좋을까?</h2>
<p>이번 실습을 통해 느낀 점은, eGovFrame 프로젝트를 처음 공부한다면 Maven부터 이해하는 것이 좋다는 것이다.</p>
<p>이유는 간단하다. eGovFrame Web 템플릿의 <code>pom.xml</code>은 부모 POM을 통해 버전을 관리하고, 전자정부 저장소와 실행환경 의존성을 명확하게 보여준다. 따라서 Maven 구조를 이해하면 eGovFrame 프로젝트가 어떤 라이브러리에 의존하고, 어떤 방식으로 빌드되는지 파악하기 쉽다.</p>
<p>Gradle도 충분히 사용할 수 있다. 하지만 Maven의 parent POM 상속 구조를 Gradle에서는 BOM 형태로 따로 설정해야 할 수 있다.</p>
<p>예를 들어 Gradle에서는 다음과 같이 플랫폼 의존성을 명시하는 방식이 필요할 수 있다.</p>
<pre><code class="language-gradle">dependencies {
    implementation platform(&#39;org.egovframe.boot:egovframe-dependencies:5.0.0&#39;)
    implementation &#39;org.egovframe.rte:egovframe-rte-ptl-mvc&#39;
}</code></pre>
<p>즉, Gradle은 유용하지만 eGovFrame 공식 구조를 이해하지 않은 상태에서 바로 변환하면 오히려 더 헷갈릴 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🔥-tomcat-10과-jakarta-전환-이해하기">🔥 Tomcat 10과 Jakarta 전환 이해하기</h2>
<p>eGovFrame 5.0 기반 프로젝트를 실행할 때는 Tomcat 버전을 반드시 확인해야 한다. 결론부터 말하면 Tomcat 10 버전을 사용해야 한다. 왜 그럴까? 그냥 최신 버전이니까? 이유는 Tomcat 10부터 Java 웹 표준 API의 패키지 이름이 <code>javax.*</code>에서 <code>jakarta.*</code>로 바뀌었기 때문이다. </p>
<p>&nbsp;</p>
<p>예전 Java EE 기반 프로젝트에서는 다음과 같은 패키지를 사용했다.</p>
<pre><code class="language-java">import javax.servlet.http.HttpServletRequest;</code></pre>
<p>&nbsp;</p>
<p>하지만 Jakarta EE 기반 프로젝트에서는 패키지명이 다음처럼 바뀌었다.</p>
<pre><code class="language-java">import jakarta.servlet.http.HttpServletRequest;</code></pre>
<p>&nbsp;</p>
<p>즉, 단순히 이름만 바뀐 것이 아니라 Java 웹 생태계의 표준 패키지 네임스페이스가 <code>javax.*</code>에서 <code>jakarta.*</code>로 이동한 것이다. 이 차이 때문에 Tomcat 버전도 중요하다.</p>
<p>Tomcat 9 이하 버전은 기본적으로 <code>javax.*</code> 기반이고, Tomcat 10 이상부터 Jakarta 네임스페이스를 지원한다. eGovFrame 5.0은 Spring Framework 6 / Spring Boot 3 계열 흐름에 맞춰 Jakarta EE 기반으로 전환된 프로젝트이기 때문에 Tomcat 9 이하에서 실행하면 관련 클래스를 찾지 못하는 문제가 발생할 수 있는 것이다.</p>
<p>정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>주요 네임스페이스</th>
<th>대표 Tomcat 버전</th>
</tr>
</thead>
<tbody><tr>
<td>Java EE 기반</td>
<td><code>javax.*</code></td>
<td>Tomcat 9 이하</td>
</tr>
<tr>
<td>Jakarta EE 기반</td>
<td><code>jakarta.*</code></td>
<td>Tomcat 10 이상</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<p>근데 왜 굳이 <code>javax.*</code>에서 <code>jakarta.*</code>로 이동시킨걸까? 이 부분은 프로그래밍 얘기는 아니므로 그냥 그러려니 하고 넘어가자. </p>
<p>원래 처음 Java EE는 Sun Microsystems가 주도했고, 이후 Sun을 Oracle이 인수하면서 Oracle이 관리하게 됐는데 시간이 지나면서 Java EE의 발전 속도가 느려졌고, 오픈소스 커뮤니티 중심으로 더 빠르게 발전시키기 위해 Oracle이 Java EE를 Eclipse Foundation으로 이관했다. 이때 Java EE가 Jakarta EE로 이름이 바뀐 것이다. </p>
<p>자세히 말하자면, Eclipse 재단이 Java EE 기술을 넘겨받았지만, Java라는 이름과 <code>javax.*</code> 네임스페이스에 대한 권리는 Oracle이 가지고 있었기 때문에 Eclipse 재단은 Jakarta EE를 발전시키면서도 기존의 <code>javax.*</code> 패키지 이름을 계속 자유롭게 변경하거나 확장할 수 없었던 것이다. </p>
<p>쉽게 말해, 새 표준은 Eclipse 재단이 관리하지만, 패키지 이름은 Oracle의 Java 상표권과 얽혀 있는 약간 애매한 상황이 돼 버린건데, 그래서 결국 Jakarta EE는 기존 <code>javax.*</code>를 버리고 새로운 네임스페이스를 선택한 것이다.</p>
<p>Spring Framework 6, Spring Boot 3부터는 Jakarta EE 기반으로 전환돼서 Spring Boot 3 기반 프로젝트에서는 더 이상 예전 <code>javax.servlet.*</code>, <code>javax.validation.*</code>, <code>javax.persistence.*</code>를 쓰지 않고, <code>jakarta.*</code>를 사용한다. </p>
<p>eGovFrame 5.0도 이 흐름을 따라가면서 Jakarta 기반으로 전환된 것이다. 그래서 앞서 생성한 eGovFrame 5.0 Web 템플릿에서도 <code>jakarta.servlet</code>, <code>jakarta.validation</code> 같은 의존성이 등장하고, 외부 Tomcat도 Jakarta를 지원하는 Tomcat 10 이상이 필요해진 것이다.</p>
<p>&nbsp;</p>
<h2 id="▶️-tomcat-10-실행하기참고">▶️ Tomcat 10 실행하기(참고)</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/090f48c9-11d9-455c-abd4-c5a8da22c2cf/image.png" alt=""></p>
<p>Tomcat을 실행할 때는 Tomcat 폴더의 <code>bin</code> 디렉터리로 이동한 뒤 <code>startup.sh</code>를 실행하면 된다.</p>
<pre><code class="language-bash">cd ~/dev/apache-tomcat-10.1.47/bin
chmod +x startup.sh
./startup.sh</code></pre>
<p>&nbsp;</p>
<p>정상 실행되면 아래와 같은 로그가 출력된다.</p>
<pre><code class="language-text">byeonguk@bagbyeong-ug-ui-MacBookPro bin % ./startup.sh
Using CATALINA_BASE:   /Users/byeonguk/dev/apache-tomcat-10.1.47
Using CATALINA_HOME:   /Users/byeonguk/dev/apache-tomcat-10.1.47
Using CATALINA_TMPDIR: /Users/byeonguk/dev/apache-tomcat-10.1.47/temp
Using JRE_HOME:        /Users/byeonguk/Library/Java/JavaVirtualMachines/temurin-24.0.2/Contents/Home
Using CLASSPATH:       /Users/byeonguk/dev/apache-tomcat-10.1.47/bin/bootstrap.jar:/Users/byeonguk/dev/apache-tomcat-10.1.47/bin/tomcat-juli.jar
Using CATALINA_OPTS:
Tomcat started.</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[eGovFrame Web 템플릿과 Boot 템플릿의 차이(WAR 방식 & JAR 방식)]]></title>
            <link>https://velog.io/@rocker_nun/Standard-WAR-vs-Spring-Boot-Executable-JAR-%EB%B9%8C%EB%93%9C-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@rocker_nun/Standard-WAR-vs-Spring-Boot-Executable-JAR-%EB%B9%8C%EB%93%9C-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Fri, 15 May 2026 04:18:46 GMT</pubDate>
            <description><![CDATA[<h1 id="🆚-war-vs-jar">🆚 WAR vs JAR</h1>
<p>eGovFrame VSCode Initializr를 사용해보면 프로젝트 생성 단계에서 여러 템플릿을 선택할 수 있다. 그중 가장 먼저 눈에 들어오는 차이는 <strong>Web 템플릿</strong>과 <strong>Boot 템플릿</strong>이다.</p>
<p>Web 템플릿은 전통적인 <strong>WAR 방식</strong>으로 배포하는 프로젝트이고, Boot 템플릿은 Spring Boot 기반의 <strong>JAR 방식</strong>으로 실행하는 프로젝트다.</p>
<p>처음에는 단순히 <code>.war</code>, <code>.jar</code> 와 같이 확장자만 다른 줄 알았는데, 직접 생성하고 실행해보니 두 방식은 단순히 파일 확장자만 다른 것이 아니었다. 결론적으로 핵심적인 차이는 다음 질문으로 정리할 수 있었다.</p>
<blockquote>
<p><strong>1. 애플리케이션을 누가 실행하는가?</strong>
<strong>2. 외부 Tomcat이 실행하는가, 아니면 애플리케이션이 스스로 서버를 포함해서 실행하는가?</strong></p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🤔-war-방식이란">🤔 WAR 방식이란?</h2>
<p>위키백과에서 WAR의 정의는 다음과 같이 설명되어 있다. </p>
<blockquote>
<p><strong>웹 애플리케이션을 배포하기 위해 설계된 특수한 JAR 파일(.war 확장자)이다. 소프트웨어 공학에서 웹 애플리케이션(서블릿, 자바 클래스, JSP, HTML, CSS, JS, XML, 태그 라이브러리 및 기타 자원 등)을 하나의 파일로 패키징해 웹 서버에 배포하는 압축 파일 형식이다. 톰캣, 제티같은 웹 서버나 서블릿 컨테이너에서 실행할 수 있다.</strong></p>
</blockquote>
<p>쉽게 말해, <strong>WAR(Web Application Archive)</strong>는 Java 웹 애플리케이션을 배포하기 위해 필요한 파일들을 하나로 묶은 압축 파일이라고 생각하면 된다. 위의 설명대로 WAR 파일 안에는 보통 다음과 같은 것들이 들어간다.</p>
<ul>
<li>컴파일된 Java 클래스</li>
<li>JSP 파일</li>
<li>CSS, JavaScript, 이미지 같은 정적 리소스</li>
<li><code>web.xml</code> 같은 배포 설정 파일</li>
<li>애플리케이션에서 사용하는 라이브러리 JAR 파일</li>
</ul>
<p>&nbsp;</p>
<p>하지만 여기서 중요하게 생각해야 할 부분은 <strong>WAR 파일은 일반적으로 혼자 실행되지 않는다</strong>는 점이다. WAR 파일은 Tomcat, Jetty, JBoss 같은 <strong>외부 WAS(Web Application Server)</strong> 또는 서블릿 컨테이너에 배포되어야 실행된다. 즉, WAR 방식에서는 애플리케이션이 직접 서버를 실행하는 것이 아니라, 예를 들어 Tomcat이 먼저 실행되고 그 위에 애플리케이션이 올라가는 구조라는 것이다.</p>
<p>&nbsp;</p>
<p>흐름을 간단히 표현하면 다음과 같다.</p>
<ol>
<li>Tomcat 실행</li>
<li><code>webapps</code> 폴더에 WAR 배포</li>
<li>Tomcat이 WAR 압축 해제</li>
<li>web.xml 읽기</li>
<li>DispatcherServlet 등록</li>
<li>Spring MVC 애플리케이션 실행</li>
</ol>
<p>비유하자면 <strong>Tomcat은 건물을 관리하는 운영 시설</strong>이고, <strong>WAR 파일은 그 건물 안에 입주하는 하나의 서비스 공간</strong>이라고 할 수 있다. 서비스 공간은 혼자 존재할 수는 있지만, 실제로 손님을 받으려면 건물의 전기, 수도, 출입구, 관리 시스템이 필요하다. WAR 방식에서 그 역할을 하는 것이 Tomcat 같은 WAS라는 것이다.</p>
<p>&nbsp;</p>
<h2 id="🤔-jar-방식이란">🤔 JAR 방식이란?</h2>
<p>JAR는 다음과 같이 설명되어 있다.</p>
<blockquote>
<p><strong>여러개의 자바 클래스 파일과, 클래스들이 이용하는 관련 리소스(텍스트, 그림 등) 및 메타데이터를 하나의 파일로 모아서 자바 플랫폼에 응용 소프트웨어나 라이브러리를 배포하기 위한 소프트웨어 패키지 파일 포맷이다.</strong></p>
</blockquote>
<p>쉽게 말해, <strong>JAR(Java Archive)</strong>는 Java 클래스와 리소스를 하나로 묶는 일반적인 Java 압축 파일 형식이라는 것이다. 그런데 Spring Boot에서는 이 JAR 파일을 조금 특별하게 사용한다.</p>
<p>Spring Boot의 JAR는 단순한 라이브러리 묶음이 아니라, <strong>실행 가능한 JAR(Executable JAR)</strong>이다. 즉, 다음 명령어 하나로 애플리케이션을 바로 실행할 수 있다는 말이다.</p>
<pre><code class="language-bash">java -jar app.jar</code></pre>
<p>&nbsp;</p>
<p>이게 가능한 이유는 Spring Boot JAR 안에 애플리케이션 코드뿐만 아니라 <strong>내장 Tomcat</strong>도 함께 포함되기 때문이다. WAR 방식이 외부 Tomcat을 실행하고 그 위에서 WAR를 배포하면 애플리케이션이 실행되는 흐름이었다면, JAR 방식은 위의 명령어로 Spring Boot 애플리케이션을 시작하면 내장 Tomcat이 실행되고 애플리케이션이 실행되는 흐름인 것이다.</p>
<p>즉, JAR 방식에서는 실행의 주도권이 외부 Tomcat이 아니라 <strong>애플리케이션 자신</strong>에게 있다는 말이다. 이해를 위해 비유를 하자면 WAR 방식은 <strong><em>“건물에 입주하는 가게”</em></strong> 이고, JAR 방식은 <em><strong>“푸드트럭”</strong></em> 에 가깝다. 푸드트럭은 주방, 전기, 판매 공간을 어느 정도 스스로 갖고 있기 때문에 특정 건물에 입주하지 않아도 바로 영업할 수 있다. </p>
<p>이 구조는 클라우드, Docker, Kubernetes 같은 환경과 잘 어울린다. 서버에 별도로 Tomcat을 설치하고 설정할 필요 없이, JAR 파일 하나와 Java 실행 환경만 있으면 애플리케이션을 실행할 수 있기 때문이다.</p>
<p>&nbsp;</p>
<h2 id="🗂️-egovframe-initializr에서의-web-템플릿과-boot-템플릿">🗂️ eGovFrame Initializr에서의 Web 템플릿과 Boot 템플릿</h2>
<p>eGovFrame VSCode Initializr에서 직접 생성해본 프로젝트는 두 가지다.</p>
<h3 id="🏹-web-템플릿">🏹 Web 템플릿</h3>
<p>Web 템플릿은 전통적인 Java 웹 애플리케이션 방식이다. 빌드 결과물이 <code>.war</code> 파일로 만들어지고, 이 파일을 외부 Tomcat 같은 WAS에 배포해서 실행한다.</p>
<p>Web 템플릿은 다음과 같은 특징을 가진다.</p>
<ul>
<li>외부 Tomcat에 배포한다.</li>
<li><code>src/main/webapp</code> 폴더가 중요하다.</li>
<li><code>WEB-INF/web.xml</code>이 존재한다.</li>
<li><code>dispatcher-servlet.xml</code> 같은 Spring MVC XML 설정이 있다.</li>
<li>화면은 JSP 기반이다.</li>
<li>Maven으로 빌드하면 WAR 파일이 생성된다.</li>
</ul>
<p>&nbsp;</p>
<h3 id="✨-boot-템플릿">✨ Boot 템플릿</h3>
<p>Boot 템플릿은 Spring Boot 기반 프로젝트다. 빌드 결과물이 <code>.jar</code> 파일로 만들어지고, 애플리케이션 내부에 내장 Tomcat이 포함되어 있어 <code>java -jar</code> 명령어만으로 실행할 수 있다.</p>
<p>Boot 템플릿은 다음과 같은 특징을 가진다.</p>
<ul>
<li>외부 Tomcat에 배포하지 않아도 된다.</li>
<li>애플리케이션 내부에 내장 Tomcat이 포함된다.</li>
<li><code>main()</code> 메서드가 애플리케이션의 시작점이다.</li>
<li>XML 설정보다 Java Config와 <code>application.properties</code> 중심이다.</li>
<li>화면은 Thymeleaf 기반이다.</li>
<li>Maven 또는 Gradle로 빌드하면 실행 가능한 JAR 파일이 생성된다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🔍-egovframe-web-템플릿-구조">🔍 eGovFrame Web 템플릿 구조</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/938faf24-b551-46ed-81f5-f1fa8d3821b4/image.png" alt=""></p>
<p>eGovFrame VSCode Initializr에서 Web 템플릿을 선택해 프로젝트를 생성하면 대략 다음과 같은 구조가 만들어진다.</p>
<pre><code class="language-text">egov-web/
├── pom.xml
├── src/main/
│   ├── java/egovframework/example/
│   │   ├── cmmn/
│   │   └── sample/
│   │       ├── service/
│   │       │   ├── EgovSampleService.java
│   │       │   ├── SampleVO.java
│   │       │   ├── SampleDefaultVO.java
│   │       │   └── impl/
│   │       │       ├── EgovSampleServiceImpl.java
│   │       │       └── SampleMapper.java
│   │       └── web/
│   │           └── EgovSampleController.java
│   │
│   ├── resources/
│   │   └── egovframework/
│   │       ├── spring/
│   │       │   ├── context-common.xml
│   │       │   ├── context-datasource.xml
│   │       │   ├── context-mapper.xml
│   │       │   ├── context-transaction.xml
│   │       │   └── context-properties.xml
│   │       └── sqlmap/example/
│   │           └── mappers/
│   │               └── EgovSample_Sample_SQL.xml
│   │
│   └── webapp/
│       ├── WEB-INF/
│       │   ├── web.xml
│       │   ├── config/egovframework/springmvc/
│       │   │   └── dispatcher-servlet.xml
│       │   └── jsp/egovframework/example/sample/
│       │       ├── egovSampleList.jsp
│       │       └── egovSampleRegister.jsp
│       ├── index.jsp
│       ├── css/
│       ├── js/
│       ├── fonts/
│       └── img/
└── target/</code></pre>
<p>&nbsp;</p>
<h3 id="🔀-web-템플릿의-요청-처리-흐름">🔀 Web 템플릿의 요청 처리 흐름</h3>
<p>Web 템플릿에서 사용자가 브라우저로 접근하면 요청은 다음 흐름으로 처리된다.</p>
<ol>
<li>사용자가 브라우저에서 URL 요청</li>
<li>Tomcat이 요청 수신</li>
<li>web.xml의 설정에 따라 DispatcherServlet으로 요청 전달</li>
<li>DispatcherServlet이 Controller 탐색</li>
<li>EgovSampleController 실행</li>
<li>EgovSampleService 호출</li>
<li>EgovSampleServiceImpl 실행</li>
<li>SampleMapper 호출</li>
<li>MyBatis가 EgovSample_Sample_SQL.xml의 SQL 실행</li>
<li>HSQLDB에서 데이터 조회</li>
<li>결과를 Model에 담아 JSP로 전달</li>
<li>JSP가 HTML을 렌더링</li>
<li>브라우저에 응답</li>
</ol>
<p>&nbsp;</p>
<p>이 흐름에서 중요한 점은 <strong>Tomcat이 먼저 요청을 받고, 애플리케이션은 Tomcat 내부에서 실행된다</strong>는 것이다.</p>
<p>&nbsp;</p>
<h3 id="🔨-web-템플릿-빌드와-배포">🔨 Web 템플릿 빌드와 배포</h3>
<p>WAR 방식 프로젝트는 Maven으로 빌드할 수 있다. 프로젝트 루트에서 다음 명령어를 실행한다.</p>
<pre><code class="language-bash">mvn clean package</code></pre>
<p>여기서 <code>clean</code>은 이전 빌드 결과물인 <code>target</code> 폴더를 정리하는 단계이고, <code>package</code>는 프로젝트를 컴파일하고 배포 가능한 파일로 묶는 단계다.</p>
<p>&nbsp;</p>
<p>빌드가 성공하면 <code>target</code> 폴더 아래에 다음과 같은 WAR 파일이 생성된다.</p>
<pre><code class="language-text">target/egovweb-1.0.0.war</code></pre>
<p>이 파일이 바로 Tomcat에 배포할 수 있는 결과물이다.</p>
<p>&nbsp;</p>
<p>Tomcat이 실행 중이라면, 생성된 WAR 파일을 Tomcat의 <code>webapps</code> 폴더에 복사한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/97fdec8b-b95a-4bb7-ac2e-e6375432350b/image.png" alt=""></p>
<p>Tomcat은 <code>webapps</code> 폴더에 들어온 WAR 파일을 감지하고 자동으로 압축을 해제한다. 그리고 WAR 파일 이름을 기준으로 컨텍스트 경로를 만든다.</p>
<p>예를 들어 파일 이름이 <code>egovweb-1.0.0.war</code>이라면 <code>http://localhost:8080/egovweb-1.0.0/</code> 경로로 접속해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2800efdd-cc8e-4995-9afc-ac8a150b92e3/image.png" alt=""></p>
<p>여기서 <code>egovweb-1.0.0</code> 부분을 <strong>컨텍스트 패스(Context Path)</strong>라고 한다. 만약 운영 환경에서 <code>/</code> 경로로 바로 접근하고 싶다면 WAR 파일 이름을 <code>ROOT.war</code>로 변경하거나 Tomcat 설정을 수정할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/888e2bf7-9642-4fe0-bbe6-db269368ea2c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2b32fc80-683c-4ca8-9474-1de2907f5078/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="🔍-egovframe-boot-템플릿-구조">🔍 eGovFrame Boot 템플릿 구조</h2>
<p>Boot 템플릿은 Spring Boot 기반 프로젝트다. 위에서도 말했지만 가장 큰 차이는 <strong>외부 Tomcat에 배포하지 않아도 된다</strong>는 점이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c1a4eb08-d947-4d22-990d-38e60da8d893/image.png" alt=""></p>
<p>Boot 템플릿을 생성하면 대략 다음과 같은 구조를 볼 수 있다.</p>
<pre><code class="language-text">boot-web/
├── pom.xml
├── src/main/
│   ├── java/egovframework/example/
│   │   ├── EgovBootApplication.java
│   │   ├── config/
│   │   │   ├── EgovConfigDatasource.java
│   │   │   ├── EgovConfigMapper.java
│   │   │   ├── EgovConfigTransaction.java
│   │   │   └── EgovConfigWeb.java
│   │   └── sample/
│   │       ├── service/
│   │       └── web/
│   │
│   └── resources/
│       ├── application.properties
│       ├── static/
│       │   ├── css/
│       │   ├── js/
│       │   ├── fonts/
│       │   └── img/
│       └── templates/
│           └── egovframework/example/sample/
│               ├── egovSampleList.html
│               └── egovSampleRegister.html
└── target/</code></pre>
<p>&nbsp;</p>
<p>Web 템플릿과 비교해보니 몇 가지 차이가 바로 보였다.</p>
<ol>
<li><code>src/main/webapp</code>이 중심이 아니다.</li>
<li>정적 리소스는 <code>src/main/resources/static</code>에 있다.</li>
<li>동적 화면 템플릿은 <code>src/main/resources/templates</code>에 있다.</li>
<li><code>web.xml</code>이 없다.</li>
<li>XML 설정보다 Java Config가 많다.</li>
<li>JSP 대신 Thymeleaf를 사용한다.</li>
</ol>
<p>즉, Web 템플릿이 XML 설정 중심이라면, Boot 템플릿은 Java 클래스와 <code>application.properties</code> 중심이다.</p>
<p>&nbsp;</p>
<h3 id="❓-egovbootapplicationjava의-의미">❓ EgovBootApplication.java의 의미</h3>
<p>Boot 템플릿에서 가장 중요한 파일은 <code>EgovBootApplication.java</code>다.</p>
<pre><code class="language-java">@SpringBootApplication
public class EgovBootApplication {

    public static void main(String[] args) {
        SpringApplication.run(EgovBootApplication.class, args);
    }
}</code></pre>
<p>이 파일은 애플리케이션의 시작점이다.</p>
<p>WAR 방식에서는 Tomcat이 먼저 실행되고, Tomcat이 <code>web.xml</code>을 읽어 애플리케이션을 로딩했다. 하지만 Boot 방식에서는 <code>main()</code> 메서드가 직접 실행된다. 그리고 <code>SpringApplication.run()</code>이 Spring 컨텍스트를 만들고 내장 Tomcat을 실행한다.</p>
<p>즉, 실행 주도권이 외부 Tomcat에서 애플리케이션 내부로 이동한다. 이 차이가 WAR와 JAR 방식의 가장 핵심적인 차이다.</p>
<p>&nbsp;</p>
<h3 id="🔀-boot-템플릿의-요청-처리-흐름">🔀 Boot 템플릿의 요청 처리 흐름</h3>
<p>Boot 템플릿에서는 외부 Tomcat이 먼저 요청을 받지 않는다. 애플리케이션 내부에서 내장 Tomcat이 실행되고, 그 내장 Tomcat이 요청을 처리한다.</p>
<p>흐름을 그림처럼 표현하면 다음과 같다.</p>
<pre><code class="language-text">[사용자 브라우저]
      ↓
[Spring Boot JAR 실행 프로세스]
      ↓
[내장 Tomcat]
     ↓
[DispatcherServlet]
      ↓
[Controller]
    ↓
[Service]
    ↓
[Mapper]
    ↓
[Database]
     ↓
[Thymeleaf View]
       ↓
[사용자 브라우저]</code></pre>
<p>WAR 방식과 비교하면 외부 Tomcat이 Spring Boot JAR 실행 프로세스 내부의 내장 Tomcat으로 바뀐 것이라고 생각하면 된다.</p>
<p>&nbsp;</p>
<h3 id="▶️-boot-템플릿-실행하기">▶️ Boot 템플릿 실행하기</h3>
<p>앞서 말했다시피 Boot 템플릿은 외부 Tomcat에 배포할 필요가 없다. 개발 중에는 다음 명령어로 실행할 수 있다.</p>
<pre><code class="language-bash">mvn spring-boot:run</code></pre>
<p>또는 Gradle 프로젝트라면 다음 명령어를 사용할 수 있다.</p>
<pre><code class="language-bash">./gradlew bootRun</code></pre>
<p>&nbsp;</p>
<p>빌드 후 실행하려면 다음 흐름을 사용한다.</p>
<pre><code class="language-bash">mvn clean package
java -jar target/boot-web-1.0.0.jar</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ca91c649-a022-409b-9951-7c065b714791/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cd099eeb-d99b-4c13-bcc3-cea254cb75ce/image.png" alt=""></p>
<p>이 방식은 배포가 단순하다. 서버에 JAR 파일 하나를 올리고 그냥 <code>java -jar</code>로 실행하면 된다. 외부 Tomcat을 따로 설치하고 WAR를 복사하는 등과 같은 복잡한 과정이 필요 없다.</p>
<p>&nbsp;</p>
<h2 id="💥-war-방식과-jar-방식의-핵심-차이">💥 WAR 방식과 JAR 방식의 핵심 차이</h2>
<p>WAR 방식과 JAR 방식의 차이는 단순히 확장자가 다르다는 정도로 이해하면 부족하다.</p>
<p>가장 중요한 차이는 <strong>실행 주체</strong>인데, WAR 방식은 외부 Tomcat이 애플리케이션을 실행하고, JAR 방식은 애플리케이션이 내장 Tomcat을 포함하고 직접 실행된다는 점을 반드시 기억하자.</p>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>WAR 방식</th>
<th>JAR 방식</th>
</tr>
</thead>
<tbody><tr>
<td>파일 의미</td>
<td>Web Application Archive</td>
<td>Java Archive</td>
</tr>
<tr>
<td>주로 사용되는 형태</td>
<td>전통적인 Java 웹 애플리케이션</td>
<td>Spring Boot 실행 애플리케이션</td>
</tr>
<tr>
<td>실행 주체</td>
<td>외부 WAS</td>
<td>애플리케이션 자체</td>
</tr>
<tr>
<td>서버 포함 여부</td>
<td>포함하지 않음</td>
<td>내장 Tomcat 포함</td>
</tr>
<tr>
<td>실행 명령</td>
<td>Tomcat에 배포 후 실행</td>
<td><code>java -jar app.jar</code></td>
</tr>
<tr>
<td>배포 방식</td>
<td>WAR 파일을 <code>webapps</code>에 복사</td>
<td>JAR 파일을 서버에서 직접 실행</td>
</tr>
<tr>
<td>설정 중심</td>
<td><code>web.xml</code>, XML 설정</td>
<td><code>application.properties</code>, Java Config</td>
</tr>
<tr>
<td>화면 기술 예시</td>
<td>JSP</td>
<td>Thymeleaf</td>
</tr>
<tr>
<td>환경 의존성</td>
<td>외부 WAS 설정에 영향 받음</td>
<td>애플리케이션이 실행 환경을 많이 포함</td>
</tr>
<tr>
<td>운영 방식</td>
<td>하나의 WAS에 여러 WAR 배포 가능</td>
<td>하나의 프로세스가 하나의 앱을 실행하는 구조에 적합</td>
</tr>
<tr>
<td>어울리는 환경</td>
<td>전통적인 공공/기업 WAS 운영 환경</td>
<td>클라우드, 컨테이너, MSA, CI/CD 환경</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="👍-war-방식이-어울리는-경우">👍 WAR 방식이 어울리는 경우</h3>
<p>WAR 방식은 다음과 같은 상황에서 자연스럽다.</p>
<ul>
<li>이미 조직에서 표준 Tomcat/WAS를 운영하고 있는 경우</li>
<li>여러 웹 애플리케이션을 하나의 WAS에서 함께 관리하는 경우</li>
<li>기존 공공 SI 또는 레거시 Spring MVC 구조를 유지해야 하는 경우</li>
<li><code>web.xml</code>, JSP, 외부 WAS 배포 방식에 익숙한 조직인 경우</li>
</ul>
<p>&nbsp;</p>
<h3 id="👍-jar-방식이-어울리는-경우">👍 JAR 방식이 어울리는 경우</h3>
<p>JAR 방식은 다음과 같은 상황에서 유리하다.</p>
<ul>
<li>애플리케이션을 독립 실행하고 싶은 경우</li>
<li>Docker 컨테이너로 배포하고 싶은 경우</li>
<li>서버마다 Tomcat을 따로 설치하고 관리하고 싶지 않은 경우</li>
<li>CI/CD 파이프라인에서 빌드 산출물을 단순하게 관리하고 싶은 경우</li>
<li>Spring Boot 자동 설정과 내장 서버 구조를 적극 활용하고 싶은 경우</li>
</ul>
<p>&nbsp;</p>
<h3 id="🎏-jsp와-thymeleaf의-차이참고">🎏 JSP와 Thymeleaf의 차이(참고)</h3>
<p>Web 템플릿과 Boot 템플릿은 화면 기술에서도 차이가 있다. Web 템플릿은 JSP를 사용하고, Boot 템플릿은 Thymeleaf를 사용한다.</p>
<ol>
<li><code>JSP</code>: 오래된 Java 웹 화면 기술이다. JSP 파일은 바로 HTML로 실행되는 것이 아니라, Tomcat 내부에서 Java Servlet 코드로 변환되고 컴파일된 뒤 실행된다. JSP는 전통적인 Java 웹 프로젝트에서 많이 사용되었고, JSTL 같은 태그 라이브러리를 통해 반복문, 조건문, 메시지 출력 등을 처리할 수 있다. 하지만 HTML 안에 서버 전용 문법이 많이 들어가면 프론트엔드 개발자가 단독으로 화면을 확인하기 어려울 수 있다.</li>
</ol>
<p>&nbsp;</p>
<ol start="2">
<li><code>Thymeleaf</code>: Spring Boot에서 많이 사용하는 서버 사이드 템플릿 엔진이다. 가장 큰 특징은 HTML 파일 자체가 자연스럽게 유지된다는 점이다. 예를 들어 <code>&lt;td th:text=&quot;${sample.name}&quot;&gt;샘플 이름&lt;/td&gt;</code>와 같이 작성할 수 있다. 브라우저는 <code>th:text</code>라는 속성을 몰라도 무시하고, 안에 있는 “샘플 이름” 텍스트를 보여준다. 반면 Spring Boot가 실행될 때는 Thymeleaf가 <code>th:text</code>를 해석해서 실제 데이터로 치환한다. 이런 방식을 <strong>Natural Template</strong>이라고 한다. 덕분에 Thymeleaf는 서버를 실행하지 않아도 HTML 구조를 어느 정도 확인할 수 있고, 디자이너나 프론트엔드 담당자와 협업하기 편하다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Bean 순환 참조(Circular Dependency) 문제 해결하기]]></title>
            <link>https://velog.io/@rocker_nun/Spring-Bean-%EC%88%9C%ED%99%98-%EC%B0%B8%EC%A1%B0Circular-Dependency</link>
            <guid>https://velog.io/@rocker_nun/Spring-Bean-%EC%88%9C%ED%99%98-%EC%B0%B8%EC%A1%B0Circular-Dependency</guid>
            <pubDate>Wed, 06 May 2026 02:37:53 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-순환-참조circular-dependency란">🤔 순환 참조(Circular Dependency)란?</h1>
<p>Spring을 공부하다 보면 한 번쯤 이런 에러를 만나게 된다.</p>
<pre><code class="language-text">***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  AService defined in file [/Users/byeonguk/Desktop/egov-vscode2026/circular-reference-demo/target/classes/com/example/circular/bad/AService.class]
↑     ↓
|  BService defined in file [/Users/byeonguk/Desktop/egov-vscode2026/circular-reference-demo/target/classes/com/example/circular/bad/BService.class]
└─────┘

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name &#39;AService&#39;: Requested bean is currently in creation: Is there an unresolvable circular reference?</code></pre>
<p>또는 Spring Boot 2.6 이상 환경에서는 애플리케이션 실행 중 다음과 비슷한 메시지를 볼 수 있다.</p>
<pre><code class="language-text">The dependencies of some of the beans in the application context form a cycle</code></pre>
<p>처음 이 에러를 보면 단순히 <em>“Bean 주입이 꼬였나?”</em> 정도로 생각할 수 있다. 하지만 Spring Bean 순환 참조는 단순한 설정 오류가 아니다. 오히려 이 에러는 애플리케이션 내부의 객체들이 서로 너무 강하게 얽혀 있고, 책임 분리가 제대로 되어 있지 않다는 신호에 가깝다.</p>
<p>이번 글에서는 Spring Bean 순환 참조가 무엇인지, 왜 발생하는지, Spring 컨테이너 내부에서는 어떤 일이 일어나는지, 그리고 실무에서는 어떻게 해결해야 하는지 정리해보려고 한다.</p>
<p>&nbsp;</p>
<h2 id="🫛-spring-bean-이해하기">🫛 Spring Bean 이해하기</h2>
<p>순환 참조를 이해하려면 먼저 <strong>Spring Bean</strong>이 무엇인지부터 정리해야 한다. 알디시피 Spring에서는 개발자가 직접 객체를 생성하고 관리하기보다, Spring 컨테이너가 객체의 생성과 의존성 주입을 대신 관리한다.</p>
<p>예를 들어 다음과 같은 클래스가 있다고 하자.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}</code></pre>
<p><code>OrderService</code>는 직접 <code>new PaymentService()</code>를 호출하지 않는다. 대신 생성자를 통해 <code>PaymentService</code>를 주입받는다. 이때 <code>OrderService</code>와 <code>PaymentService</code> 같은 객체를 Spring 컨테이너가 관리하면 이를 <strong>Bean</strong>이라고 부른다.</p>
<p>Spring 컨테이너는 애플리케이션이 시작될 때 <code>@Component</code>, <code>@Service</code>, <code>@Repository</code>, <code>@Controller</code>, <code>@Configuration</code> 같은 어노테이션이 붙은 클래스를 스캔한다. 그리고 이 클래스들을 기반으로 Bean을 생성하고, 필요한 의존성을 자동으로 연결해준다. </p>
<p>이것이 바로 Spring의 핵심 개념인 <strong>IoC(Inversion of Control, 제어의 역전)</strong>와 <strong>DI(Dependency Injection, 의존성 주입)</strong>이다. 개발자가 객체 생성과 연결을 직접 제어하는 것이 아니라, Spring 컨테이너가 객체의 생명주기와 의존 관계를 관리하는 것이다.</p>
<p>&nbsp;</p>
<h2 id="🔄-순환-참조란-무엇인가">🔄 순환 참조란 무엇인가?</h2>
<p>순환 참조는 이름 그대로 <strong>의존성의 흐름이 원처럼 순환하는 구조</strong>를 말한다.</p>
<p>가장 단순한 예시는 다음과 같다.</p>
<pre><code class="language-text">A → B → A</code></pre>
<p>A는 B가 필요하고, B는 다시 A가 필요한 구조다.</p>
<p>&nbsp;</p>
<p>코드로 보면 다음과 같다.</p>
<pre><code class="language-java">@Service
public class AService {
    private final BService bService;

    public AService(BService bService) {
        this.bService = bService;
    }
}</code></pre>
<pre><code class="language-java">@Service
public class BService {
    private final AService aService;

    public BService(AService aService) {
        this.aService = aService;
    }
}</code></pre>
<p><code>AService</code>를 만들려면 <code>BService</code>가 필요하다. 그런데 <code>BService</code>를 만들려면 다시 <code>AService</code>가 필요하다.</p>
<p>&nbsp;</p>
<p>Spring 컨테이너 입장에서는 이런 상황이 된다.</p>
<ol>
<li>_&quot;AService를 만들려고 했더니 BService가 필요하네?&quot; _ </li>
<li><em>&quot;그럼 BService를 먼저 만들자.&quot;</em></li>
<li>_&quot;그런데 BService를 만들려고 했더니 AService가 필요하네?&quot;  _</li>
<li>_&quot;다시 AService를 만들어야 하나?&quot;  _</li>
<li><em>&quot;그런데 AService를 만들려면 BService가 필요한데?&quot;</em></li>
</ol>
<p>결국 누구를 먼저 만들어야 하는지 결정할 수 없는 상황, 이것이 바로 <strong>Spring Bean 순환 참조</strong>다. <em>“닭이 먼저냐, 달걀이 먼저냐”</em> 문제와 아주 비슷하다. A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하다. 둘 중 하나가 먼저 완성되어야 다른 하나를 만들 수 있는데, 둘 다 서로가 먼저 필요하다고 말하는 상황이다.</p>
<p>&nbsp;</p>
<h2 id="💣-생성자-주입에서-순환-참조가-더-치명적인-이유">💣 생성자 주입에서 순환 참조가 더 치명적인 이유</h2>
<p>Spring에서는 의존성을 주입하는 방식이 여러 가지 있다.</p>
<p>대표적으로 <strong>생성자 주입(Constructor Injection)</strong>, <strong>세터 주입(Setter Injection)</strong>, <strong>필드 주입(Field Injection)</strong>가 있다. 이 중 현대 Spring 개발에서는 생성자 주입이 가장 권장된다. 생성자 주입은 필수 의존성을 객체 생성 시점에 반드시 전달받도록 강제할 수 있다. 또한 필드를 <code>final</code>로 선언할 수 있어 객체의 불변성을 지키기 좋고, 테스트 코드 작성도 쉽다.</p>
<p>&nbsp;</p>
<p>하지만 순환 참조 상황에서는 생성자 주입이 가장 빠르게 문제를 드러낸다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) {
        this.userService = userService;
    }
}</code></pre>
<pre><code class="language-java">@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) {
        this.orderService = orderService;
    }
}</code></pre>
<p><code>OrderService</code> 객체를 생성하려면 생성자 인자로 <code>UserService</code>가 필요하다. 그런데 <code>UserService</code> 객체를 생성하려면 생성자 인자로 <code>OrderService</code>가 필요하다.</p>
<p>생성자 주입에서는 객체가 완성되기 전에 필요한 의존성이 모두 준비되어 있어야 한다. 따라서 서로가 서로의 생성자 인자로 필요한 상황에서는 객체를 하나도 완성할 수 없다.</p>
<p>그래서 생성자 주입 기반의 순환 참조는 Spring 컨테이너가 해결하기 어렵고, 애플리케이션 시작 단계에서 바로 예외가 발생한다. 불편해 보일 수 있지만, 사실 이건 좋은 신호이기도 하다. 생성자 주입은 잘못된 의존 관계를 애플리케이션 실행 초기에 명확히 드러내기 때문이다.</p>
<p>&nbsp;</p>
<h2 id="💉-세터-주입과-필드-주입">💉 세터 주입과 필드 주입</h2>
<p>과거 Spring Boot 2.5 이하 환경에서는 세터 주입이나 필드 주입을 사용하면 순환 참조가 동작하는 경우가 있었다.</p>
<p>예를 들어 다음과 같은 코드다.</p>
<pre><code class="language-java">@Service
public class AService {
    @Autowired
    private BService bService;
}</code></pre>
<pre><code class="language-java">@Service
public class BService {
    @Autowired
    private AService aService;
}</code></pre>
<p>생성자 주입과 달리 필드 주입은 객체를 먼저 만든 뒤 나중에 필드를 채운다.</p>
<p>&nbsp;</p>
<p>즉, Spring 컨테이너는 다음과 같은 방식으로 처리할 수 있다.</p>
<ol>
<li>AService 객체를 일단 만든다.</li>
<li>아직 bService 필드는 비어 있다.</li>
<li>BService 객체를 만든다.</li>
<li>BService에 AService의 미완성 참조를 넣는다.</li>
<li>BService 생성이 끝난다.</li>
<li>다시 AService로 돌아와 bService 필드를 채운다.</li>
</ol>
<p>이런 방식이 가능했던 이유는 Spring 내부에 <strong>3단계 캐시 메커니즘</strong>이 있었기 때문이다.</p>
<p>&nbsp;</p>
<h3 id="🏰-spring의-3단계-캐시-메커니즘">🏰 Spring의 3단계 캐시 메커니즘</h3>
<p>Spring은 싱글톤 Bean을 생성하고 관리하기 위해 내부적으로 여러 캐시를 사용한다. 순환 참조와 관련해서 자주 언급되는 것이 바로 <strong>3단계 캐시</strong>다.</p>
<table>
<thead>
<tr>
<th>캐시 단계</th>
<th>내부 변수명</th>
<th>저장 대상</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>1단계 캐시</td>
<td><code>singletonObjects</code></td>
<td>완전히 생성된 Bean</td>
<td>인스턴스화, 의존성 주입, 초기화까지 끝난 정상 Bean</td>
</tr>
<tr>
<td>2단계 캐시</td>
<td><code>earlySingletonObjects</code></td>
<td>조기 참조 Bean</td>
<td>아직 완전히 초기화되지는 않았지만 참조 가능한 미완성 Bean</td>
</tr>
<tr>
<td>3단계 캐시</td>
<td><code>singletonFactories</code></td>
<td>ObjectFactory</td>
<td>필요할 때 조기 참조 객체를 만들어 반환할 수 있는 팩토리</td>
</tr>
</tbody></table>
<p>이 구조를 아주 단순하게 설명하면 다음과 같다.</p>
<p>Spring은 A Bean을 만들기 시작하면, 아직 완전히 완성되지 않았더라도 “A라는 객체의 참조를 나중에 꺼낼 수 있는 통로”를 3단계 캐시에 등록해둔다.</p>
<p>이후 B Bean을 만들다가 A Bean이 필요해지면, Spring은 완성된 A Bean이 없더라도 3단계 캐시에서 A의 조기 참조를 꺼내 B에 주입한다.</p>
<p>그 후 B가 완성되면 다시 A로 돌아와 B를 주입하고 A도 완성한다.</p>
<p>즉, Spring은 Java 객체가 참조 타입이라는 특성을 이용해서 미완성 객체의 참조를 임시로 넘겨주는 방식으로 일부 순환 참조를 해결해왔다. 하지만 이 방식은 어디까지나 제한적인 우회 방식이다.</p>
<p>&nbsp;</p>
<h3 id="❓-aop가-끼어들면-왜-더-복잡해질까">❓ AOP가 끼어들면 왜 더 복잡해질까?</h3>
<p>Spring 애플리케이션에서는 <code>@Transactional</code>, <code>@Async</code> 같은 기능을 자주 사용한다. 이런 기능은 Spring AOP를 통해 동작한다. Spring은 원본 객체를 그대로 사용하는 것이 아니라, 원본 객체를 감싼 <strong>프록시 객체</strong>를 만들어 사용한다.</p>
<p>예를 들어 <code>OrderService</code>에 <code>@Transactional</code>이 붙어 있다면, Spring 컨테이너가 관리하는 실제 Bean은 순수한 <code>OrderService</code> 객체가 아니라 트랜잭션 기능이 적용된 프록시 객체일 수 있다.</p>
<p>문제는 순환 참조 상황에서 조기 참조로 노출된 객체와 최종적으로 컨테이너에 등록되는 객체가 달라질 수 있다는 점이다.</p>
<p>예를 들어 B Bean이 A Bean의 조기 참조를 주입받았는데, 그 시점의 A는 아직 프록시가 적용되기 전의 원본 객체라고 해보자. 이후 A의 초기화가 끝나면서 Spring이 A를 프록시 객체로 감싼다. 그러면 컨테이너가 관리하는 공식 A Bean은 프록시 객체인데, B 내부에 들어 있는 A는 원본 객체일 수 있다.</p>
<p>이렇게 되면 트랜잭션, 비동기 처리, 보안 같은 AOP 기능이 기대한 대로 동작하지 않을 수 있다. Spring이 내부적으로 이런 문제를 줄이기 위한 복잡한 방어 로직을 갖고 있지만, 모든 상황을 완벽히 안전하게 처리하기는 어렵다. 그래서 순환 참조는 단순히 Spring이 알아서 해결해주면 되는 문제로 보면 안 된다.</p>
<p>&nbsp;</p>
<h2 id="🚫-spring-boot-26부터-순환-참조가-기본적으로-금지된-이유">🚫 Spring Boot 2.6부터 순환 참조가 기본적으로 금지된 이유</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/854b5848-9e6f-4084-9559-6691e6cbb289/image.png" alt=""></p>
<p>Spring Boot 2.6부터는 순환 참조가 기본적으로 금지되었다. 이전에는 Spring이 일부 순환 참조를 내부 캐시를 통해 해결해주었지만, 이제는 애플리케이션 시작 시점에 순환 참조를 발견하면 기본적으로 실패하도록 바뀌었다. 이 정책 변화는 매우 중요하다.</p>
<p>Spring이 순환 참조를 막는 이유는 개발자를 불편하게 만들기 위해서가 아니라 오히려 애플리케이션 구조의 문제를 더 빨리 발견하게 하기 위해서다. 순환 참조는 대부분 다음과 같은 설계 문제를 의미한다.</p>
<ul>
<li>두 객체의 책임이 명확히 분리되어 있지 않다.</li>
<li>서로 다른 도메인이 너무 강하게 결합되어 있다.</li>
<li>한 클래스가 너무 많은 일을 하고 있다.</li>
<li>의존성 방향이 정리되어 있지 않다.</li>
</ul>
<p>&nbsp;</p>
<p>즉, 순환 참조는 단순 에러라기보다 <strong>코드 스멜(Code Smell)</strong>에 가깝다.</p>
<p>Spring Boot 2.6의 기본 차단 정책은 이런 구조적 문제를 애플리케이션 실행 초기에 드러내는 <strong>Fail-fast 전략</strong>이라고 볼 수 있다. Fail-fast 전략이란 문제가 있다면 가능한 한 빨리 실패하게 해서, 더 큰 장애로 번지기 전에 개발자가 문제를 수정하도록 하는 방식을 말한다.</p>
<p>&nbsp;</p>
<h2 id="👀-allow-circular-referencestrue는-해결책일까">👀 allow-circular-references=true는 해결책일까?</h2>
<p>릴리즈 노트를 보면 알 수 있듯이 Spring Boot 2.6 이상에서도 설정을 통해 순환 참조를 다시 허용할 수 있다.</p>
<pre><code class="language-yaml">spring:
  main:
    allow-circular-references: true</code></pre>
<p>또는 <code>application.properties</code>에서는 다음처럼 설정할 수 있다.</p>
<pre><code class="language-properties">spring.main.allow-circular-references=true</code></pre>
<p>이 설정을 켜면 Spring은 예전처럼 순환 참조를 해결하려고 시도한다.</p>
<p>&nbsp;</p>
<p>하지만 이 설정은 근본 해결책이 아니다. 해당 설정은 일단 애플리케이션을 실행시키기 위한 임시방편이다. 레거시 프로젝트를 Spring Boot 2.6 이상으로 마이그레이션하는 상황이라면 일시적으로 사용할 수는 있다. 하지만 장기적으로는 순환 참조 구조를 제거하는 방향으로 리팩토링해야 한다.</p>
<p>이 설정에 계속 의존하면 시스템 내부의 잘못된 의존 관계가 숨겨진 채로 남게 된다. 시간이 지날수록 구조는 더 복잡해지고, 나중에는 작은 변경 하나에도 여러 서비스가 연쇄적으로 영향을 받는 상황이 될 수 있다.</p>
<p>따라서 <code>allow-circular-references=true</code>는 문제를 해결하는 스위치가 아니라, 리팩토링 시간을 벌기 위한 임시 안전장치로만 사용하는 것이 바람직하다.</p>
<p>&nbsp;</p>
<h2 id="📝-순환-참조가-발생하는-대표적인-설계-문제">📝 순환 참조가 발생하는 대표적인 설계 문제</h2>
<p>순환 참조는 우연히 발생하지 않는다. 대부분 객체 간 책임이 명확하지 않거나, 도메인 경계가 흐릿할 때 발생한다.</p>
<p>대표적인 예시를 보자.</p>
<pre><code class="language-java">@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void withdraw(Long userId) {
        if (orderService.hasActiveOrders(userId)) {
            throw new IllegalStateException(&quot;진행 중인 주문이 있어 탈퇴할 수 없습니다.&quot;);
        }
        // 회원 탈퇴 처리
    }
}</code></pre>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) {
        this.userService = userService;
    }

    public void createOrder(Long userId) {
        userService.validateUserStatus(userId);
        // 주문 생성 처리
    }
}</code></pre>
<p>위 구조에서 <code>UserService</code>는 회원 탈퇴를 위해 주문 정보를 확인해야 한다. 반대로 <code>OrderService</code>는 주문 생성을 위해 회원 상태를 검증해야 한다.</p>
<p>말만 들었을 때는 굉장히 자연스러워 보인다. 하지만 구조적으로 들여다 보면 두 서비스가 서로의 도메인 로직에 깊게 관여하고 있다. <code>UserService</code>는 회원 관리에 집중해야 하고, <code>OrderService</code>는 주문 관리에 집중해야 한다. 그런데 서로의 내부 기능을 직접 호출하면서 양방향 의존성이 생긴다. 이런 구조는 시간이 지날수록 유지보수가 어려워진다.</p>
<p>&nbsp;</p>
<h3 id="🚥-해결책-1-lazy-애노테이션-사용">🚥 해결책 1: @Lazy 애노테이션 사용</h3>
<p>가장 간단한 우회 방법 중 하나는 <code>@Lazy</code>를 사용하는 것이다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService;

    public OrderService(@Lazy UserService userService) {
        this.userService = userService;
    }
}</code></pre>
<p><code>@Lazy</code>를 사용하면 Spring은 실제 <code>UserService</code> 객체를 즉시 주입하지 않고, 프록시 객체를 대신 주입한다. 실제 <code>UserService</code>는 해당 메서드가 처음 호출되는 시점에 생성된다. 이렇게 하면 애플리케이션 시작 시점의 순환 참조 문제를 우회할 수 있다.</p>
<p>하지만 <code>@Lazy</code> 애노테이션 역시 근본 해결책은 아니다. 문제 발생 시점이 애플리케이션 시작 단계에서 런타임으로 미뤄질 수 있다. 또한 실제 메서드가 처음 호출되는 순간 객체 초기화가 발생하면서 예상치 못한 지연이 생길 수도 있다.</p>
<p>따라서 <code>@Lazy</code>는 리팩토링이 당장 어려운 레거시 코드에서 제한적으로 사용하는 것이 좋다.</p>
<p>&nbsp;</p>
<h3 id="🚚-해결책-2-objectprovider-사용">🚚 해결책 2: ObjectProvider 사용</h3>
<p><code>ObjectProvider</code>를 사용하면 필요한 Bean을 즉시 주입받는 대신, 필요한 시점에 직접 조회할 수 있다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final ObjectProvider&lt;UserService&gt; userServiceProvider;

    public OrderService(ObjectProvider&lt;UserService&gt; userServiceProvider) {
        this.userServiceProvider = userServiceProvider;
    }

    public void createOrder(Long userId) {
        UserService userService = userServiceProvider.getIfAvailable();
        if (userService == null) {
            throw new IllegalStateException(&quot;UserService를 사용할 수 없습니다.&quot;);
        }

        userService.validateUserStatus(userId);
        // 주문 생성 처리
    }
}</code></pre>
<p>이 방식도 결국 의존성 주입 시점을 늦추는 것이다. <code>OrderService</code>를 만들 때 <code>UserService</code> 자체를 주입받는 것이 아니라, 나중에 <code>UserService</code>를 찾아올 수 있는 공급자 객체를 주입받는다.</p>
<p><code>@Lazy</code>보다 의존성을 가져오는 시점이 코드에 명시적으로 드러난다는 장점이 있지만 이 방식 역시 Spring의 <code>ObjectProvider</code>라는 프레임워크 API에 도메인 코드가 의존하게 된다. 따라서 근본적인 설계 개선보다는 전술적 우회 방법으로 보는 것이 좋다.</p>
<p>&nbsp;</p>
<h3 id="📡-해결책-3-세터-주입-또는-필드-주입으로-변경">📡 해결책 3: 세터 주입 또는 필드 주입으로 변경</h3>
<p>순환 참조를 우회하기 위해 생성자 주입을 세터 주입이나 필드 주입으로 바꾸는 방법도 있다.</p>
<pre><code class="language-java">@Service
public class UserService {
    private OrderService orderService;

    @Autowired
    public void setOrderService(OrderService orderService) {
        this.orderService = orderService;
    }
}</code></pre>
<p>이 방식은 객체를 먼저 생성한 뒤 나중에 의존성을 주입할 수 있기 때문에 일부 상황에서는 순환 참조를 우회할 수 있다.</p>
<p>하지만 이 방법도 권장하기 어렵다. 생성자 주입을 사용하면 필수 의존성을 객체 생성 시점에 강제할 수 있고, 필드를 <code>final</code>로 선언할 수 있다. 반면 세터 주입이나 필드 주입은 객체가 불완전한 상태로 존재할 가능성을 만든다.</p>
<p>또한 테스트 코드에서도 의존성이 명확하게 드러나지 않는다. 따라서 세터 주입이나 필드 주입은 순환 참조를 해결하기 위한 근본적인 방법이 아니라, 오히려 문제를 숨기는 방식에 가깝다.</p>
<p>&nbsp;</p>
<h3 id="👨🏻🔧-해결책-4-공통-책임을-제3의-서비스로-분리권장">👨🏻‍🔧 해결책 4: 공통 책임을 제3의 서비스로 분리(권장)</h3>
<p>순환 참조를 가장 건강하게 해결하는 방법은 <strong>책임을 다시 나누는 것</strong>이다. 앞에서 본 예시에서 <code>UserService</code>와 <code>OrderService</code>가 서로를 참조하는 이유는 아래와 같다.</p>
<ul>
<li>주문 생성 시 회원 상태 검증이 필요하다.</li>
<li>회원 탈퇴 시 진행 중인 주문 여부 확인이 필요하다.</li>
</ul>
<p>이때 두 서비스가 서로를 직접 호출하게 두는 대신, 공통으로 필요한 책임을 별도 서비스로 분리할 수 있다.</p>
<p>&nbsp;</p>
<p>예를 들어 회원 검증 로직을 <code>UserValidator</code>로 분리한다.</p>
<pre><code class="language-java">@Component
public class UserValidator {
    public void validateUserStatus(Long userId) {
        // 회원 상태 검증
    }
}</code></pre>
<p>&nbsp;</p>
<p>그리고 <code>OrderService</code>는 <code>UserService</code>가 아니라 <code>UserValidator</code>에 의존한다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserValidator userValidator;

    public OrderService(UserValidator userValidator) {
        this.userValidator = userValidator;
    }

    public void createOrder(Long userId) {
        userValidator.validateUserStatus(userId);
        // 주문 생성 처리
    }
}</code></pre>
<p>또는 주문 상태 확인 로직을 별도의 <code>OrderReader</code>, <code>OrderValidator</code> 같은 컴포넌트로 분리할 수도 있다.</p>
<pre><code class="language-java">@Component
public class OrderValidator {
    public boolean hasActiveOrders(Long userId) {
        // 진행 중인 주문 여부 확인
        return false;
    }
}</code></pre>
<pre><code class="language-java">@Service
public class UserService {
    private final OrderValidator orderValidator;

    public UserService(OrderValidator orderValidator) {
        this.orderValidator = orderValidator;
    }

    public void withdraw(Long userId) {
        if (orderValidator.hasActiveOrders(userId)) {
            throw new IllegalStateException(&quot;진행 중인 주문이 있어 탈퇴할 수 없습니다.&quot;);
        }
        // 회원 탈퇴 처리
    }
}</code></pre>
<p>&nbsp;</p>
<p>이렇게 하면 <code>UserService</code>와 <code>OrderService</code>가 서로 직접 의존하지 않아도 된다.</p>
<pre><code class="language-text">Before
UserService → OrderService
OrderService → UserService

After
UserService → OrderValidator
OrderService → UserValidator</code></pre>
<p>순환 구조가 단방향 구조로 바뀐다.</p>
<p>&nbsp;</p>
<h3 id="📨-해결책-5-이벤트-기반으로-분리권장">📨 해결책 5: 이벤트 기반으로 분리(권장)</h3>
<p>두 서비스가 반드시 즉시 서로를 호출할 필요가 없다면, 이벤트 기반 구조를 사용할 수도 있다. 예를 들어 주문이 생성된 뒤 회원 관련 후처리가 필요하다고 하자.</p>
<p>이때 <code>OrderService</code>가 <code>UserService</code>를 직접 호출하는 대신, “주문이 생성되었다”는 이벤트를 발행할 수 있다.</p>
<pre><code class="language-java">public record OrderCreatedEvent(Long orderId, Long userId) {
}</code></pre>
<pre><code class="language-java">@Service
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public void createOrder(Long userId) {
        // 주문 생성 처리
        Long orderId = 1L;

        eventPublisher.publishEvent(new OrderCreatedEvent(orderId, userId));
    }
}</code></pre>
<p>&nbsp;</p>
<p>그리고 <code>UserService</code> 또는 별도의 이벤트 핸들러가 해당 이벤트를 구독한다.</p>
<pre><code class="language-java">@Component
public class OrderEventHandler {

    @EventListener
    public void handle(OrderCreatedEvent event) {
        // 주문 생성 이후 필요한 후처리
    }
}</code></pre>
<p>이 구조에서는 <code>OrderService</code>가 <code>UserService</code>를 직접 알 필요가 없다. 단지 이벤트를 발행할 뿐이다. 이벤트를 처리하는 쪽도 <code>OrderService</code>를 직접 호출하지 않는다. 특정 이벤트가 발생하면 반응할 뿐이다.</p>
<p>따라서 두 객체 간의 결합도를 크게 낮출 수 있다. 다만 이벤트 기반 구조는 흐름이 눈에 바로 보이지 않을 수 있다. 메서드 호출처럼 <em>“어디서 어디로 호출되는지”</em> 가 명확하지 않기 때문에, 이벤트 이름과 핸들러 위치를 잘 관리해야 한다.</p>
<p>&nbsp;</p>
<h3 id="🔨-해결책-6-facade-또는-애플리케이션-서비스-도입권장">🔨 해결책 6: Facade 또는 애플리케이션 서비스 도입(권장)</h3>
<p>여러 서비스의 로직을 조합해야 하는 경우, 각 서비스가 서로를 직접 호출하게 만들기보다 상위 계층의 조합 서비스를 둘 수 있다. 예를 들어 회원 탈퇴를 처리할 때 회원 정보도 확인해야 하고, 주문 상태도 확인해야 한다고 하자.</p>
<p>이때 <code>UserService</code>와 <code>OrderService</code>가 서로를 직접 호출하게 만들지 않고, <code>UserWithdrawalFacade</code> 같은 클래스를 둘 수 있다.</p>
<pre><code class="language-java">@Service
public class UserWithdrawalFacade {
    private final UserService userService;
    private final OrderService orderService;

    public UserWithdrawalFacade(UserService userService, OrderService orderService) {
        this.userService = userService;
        this.orderService = orderService;
    }

    public void withdraw(Long userId) {
        if (orderService.hasActiveOrders(userId)) {
            throw new IllegalStateException(&quot;진행 중인 주문이 있어 탈퇴할 수 없습니다.&quot;);
        }

        userService.withdraw(userId);
    }
}</code></pre>
<p>파사드가 여러 서비스를 조합하고, 각 도메인 서비스는 자신의 책임에만 집중한다. 이 방식은 계층 간 의존성 방향을 정리하는 데 특히 유용하다.</p>
<p>&nbsp;</p>
<h2 id="👤-자기-자신을-주입받는-경우도-순환-참조일까">👤 자기 자신을 주입받는 경우도 순환 참조일까?</h2>
<p>특이한 경우로, Bean이 자기 자신을 주입받는 경우도 있다.</p>
<pre><code class="language-java">@Service
public class MyService {
    @Autowired
    private MyService self;

    public void methodA() {
        self.methodB();
    }

    @Transactional
    public void methodB() {
        // 트랜잭션 처리
    }
}</code></pre>
<p>이런 코드는 보통 같은 클래스 내부에서 <code>@Transactional</code> 같은 AOP 기능을 적용하기 위해 사용된다.</p>
<p>Spring AOP는 프록시 기반으로 동작하기 때문에, 같은 클래스 내부에서 <code>this.methodB()</code>를 호출하면 프록시를 거치지 않는다. 그래서 트랜잭션이 적용되지 않을 수 있다. 이를 피하려고 자기 자신의 프록시를 주입받는 방식이 사용되기도 한다.</p>
<p>하지만 이것 역시 좋은 설계라고 보기는 어렵다. 트랜잭션 경계가 달라야 하는 메서드라면 별도의 서비스로 분리하는 것이 더 명확하다.</p>
<pre><code class="language-java">@Service
public class MyService {
    private final MyTransactionService myTransactionService;

    public MyService(MyTransactionService myTransactionService) {
        this.myTransactionService = myTransactionService;
    }

    public void methodA() {
        myTransactionService.methodB();
    }
}</code></pre>
<pre><code class="language-java">@Service
public class MyTransactionService {

    @Transactional
    public void methodB() {
        // 트랜잭션 처리
    }
}</code></pre>
<p>이렇게 분리하면 자기 자신을 주입받지 않아도 되고, 트랜잭션 경계도 더 명확해진다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em></p>
<p><a href="https://www.alibabacloud.com/blog/spring-circular-dependency_600191">Spring Circular Dependency - Alibaba Cloud Community</a>
<a href="https://oneuptime.com/blog/post/2025-12-22-spring-circular-reference-errors/view">How to Handle Circular Reference Errors in Spring</a>
<a href="https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes">Spring Boot 2.6 Release Notes</a>
<a href="https://www.geeksforgeeks.org/java/circular-dependencies-in-spring/">Circular Dependencies in Spring - GeeksforGeeks</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[eGovFrame VSCode Initializr 프로젝트]]></title>
            <link>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8</link>
            <guid>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8</guid>
            <pubDate>Mon, 04 May 2026 07:29:32 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-egovframe-vscode-initializr란">🤔 eGovFrame VSCode Initializr란?</h2>
<p>eGovFrame VSCode Initializr는 <strong>VS Code 환경에서 전자정부 표준 프레임워크 기반 프로젝트를 쉽게 생성하고, 필요한 코드와 설정 파일을 자동으로 만들어주는 개발 도구</strong>다. 조금 더 쉽게 말하면, 전자정부 표준 프레임워크 프로젝트를 시작할 때 필요한 초기 작업을 도와주는 도구다.</p>
<p>개발자는 VSCode 안에서 몇 번의 선택만으로 프로젝트를 생성할 수 있고, 데이터베이스 테이블 정의를 기반으로 CRUD 코드도 자동으로 만들 수 있다. 여기서 <strong><em>“Initializr”</em></strong> 라는 이름이 중요한데, 말 그대로 <strong><em>“초기화 도구”</em></strong> 라는 의미에 가깝다.</p>
<p>Spring 진영에 Spring Initializr가 있는 것처럼, eGovFrame VSCode Initializr는 전자정부 표준 프레임워크 프로젝트를 더 쉽게 시작할 수 있도록 도와주는 역할을 한다.</p>
<p>즉, 이 도구는 개발자가 처음부터 모든 설정과 코드를 직접 작성하지 않고, 표준화된 프로젝트 뼈대를 빠르게 만들 수 있도록 도와준다.</p>
<p>&nbsp;</p>
<h2 id="❓-왜-vscode-initializr가-필요할까">❓ 왜 VSCode Initializr가 필요할까?</h2>
<p>전자정부 표준 프레임워크는 오랫동안 공공 정보시스템 개발에서 중요한 역할을 해왔다. 하지만 과거에는 개발 환경이 주로 Eclipse 중심이었다. Eclipse는 오랜 기간 Java 개발 환경의 대표적인 IDE였고, 전자정부 표준 프레임워크 개발환경도 Eclipse 기반으로 제공되는 경우가 많았다.</p>
<p>하지만 최근 개발 환경은 많이 바뀌었다. 프론트엔드와 백엔드가 분리되고, React, Vue, Node.js, Spring Boot, MSA, 클라우드 네이티브 같은 기술이 함께 사용되면서 개발자는 더 가볍고 유연한 도구를 선호하게 되었다.</p>
<p>그 흐름 속에서 많은 개발자가 VSCode를 사용한다. VSCode는 가볍고 빠르며, 확장 프로그램 생태계가 풍부하다. 프론트엔드 개발뿐만 아니라 백엔드, DevOps, 문서 작성, Git 관리까지 폭넓게 사용할 수 있다. 전자정부 표준 프레임워크도 이런 변화에 맞춰 VSCode 기반 개발 도구가 필요해졌다.</p>
<p>그 결과 등장한 것이 바로 <strong>eGovFrame VSCode Initializr</strong>이다. 이 도구는 전자정부 표준 프레임워크가 과거의 무거운 개발 환경에만 머무르지 않고, 현대적인 개발자 경험으로 확장되고 있다는 것을 보여주는 중요한 프로젝트다.</p>
<p>&nbsp;</p>
<h2 id="📝-이-도구가-해결하는-문제">📝 이 도구가 해결하는 문제</h2>
<p>eGovFrame VSCode Initializr가 해결하려는 문제는 크게 세 가지로 볼 수 있다.</p>
<ol>
<li><p><strong>초기 프로젝트 생성의 복잡함:</strong> 전자정부 표준 프레임워크 기반 프로젝트를 만들려면 정해진 구조와 설정을 따라야 한다. 하지만 초보 개발자나 처음 프로젝트에 참여한 개발자 입장에서는 어떤 구조가 표준인지, 어떤 파일이 필요한지, 어떤 의존성을 추가해야 하는지 헷갈릴 수 있다. VSCode Initializr는 검증된 템플릿을 기반으로 프로젝트 구조를 자동 생성해준다. 덕분에 개발자는 처음부터 구조를 고민하는 데 시간을 많이 쓰지 않아도 된다.</p>
</li>
<li><p><strong>반복적인 코드 작성</strong>: CRUD 기능을 만든다고 생각해보자. 게시글을 등록하고, 조회하고, 수정하고, 삭제하는 기능은 거의 모든 웹 서비스에서 필요하다. 그런데 이 기능을 만들려면 보통 여러 파일을 함께 작성해야 한다. VO, Service, ServiceImpl, Controller, Mapper Interface, Mapper XML, 목록 화면, 등록 화면 등이 필요하다. 이런 파일들은 구조가 반복되는 경우가 많다. 그래서 사람이 직접 작성하면 시간이 오래 걸리고, 오타나 경로 실수도 발생할 수 있다. VSCode Initializr는 DDL을 기반으로 이런 파일들을 자동 생성해준다.</p>
</li>
<li><p><strong>설정 파일 작성의 어려움</strong>: Spring 기반 프로젝트에서는 데이터베이스 설정, 트랜잭션 설정, 로깅 설정, 프로퍼티 설정 등 다양한 설정 파일이 필요하다. 특히 레거시 XML 방식과 최신 Java Config, YAML, Properties 방식이 함께 존재하면 초보자 입장에서는 더 헷갈릴 수 있다. VSCode Initializr는 이런 설정 파일 생성도 도와준다. </p>
</li>
</ol>
<p>즉, 이 도구의 핵심은 단순히 코드를 빨리 만들어주는 것이 아니라 <strong>표준화된 방식으로 프로젝트를 시작하게 해주고, 반복 작업을 줄이며, 사람의 실수를 줄여주는 도구</strong>라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-주요-기능-살펴보기">🛠️ 주요 기능 살펴보기</h2>
<p>eGovFrame VSCode Initializr의 핵심 기능은 크게 세 가지로 정리할 수 있다.</p>
<ol>
<li><p><strong>프로젝트 자동 생성:</strong> 개발자는 VS Code 안에서 원하는 프로젝트 유형과 빌드 도구를 선택할 수 있다. 예를 들어 Maven을 사용할지, Gradle을 사용할지 선택할 수 있고, 프로젝트 템플릿도 선택할 수 있다. </p>
<p> 이 과정은 개발자가 직접 압축 파일을 내려받고, 폴더 구조를 만들고, 설정 파일을 수정하는 과정을 줄여준다. 쉽게 비유하면, 빈 땅에 건물을 지으려고 할 때 기초 공사부터 직접 하는 것이 아니라, 기본 골조가 잡힌 상태에서 시작하는 것과 같다. </p>
<p> 프로젝트 생성 기능은 특히 처음 전자정부 표준 프레임워크를 접하는 개발자에게 도움이 된다. 프로젝트 구조를 잘못 잡으면 이후 개발 과정에서 계속 문제가 생길 수 있다. 하지만 Initializr가 표준 템플릿을 기반으로 프로젝트를 만들어주면, 처음부터 어느 정도 검증된 구조에서 출발할 수 있다.</p>
</li>
</ol>
<ol start="2">
<li><p><strong>DDL 기반 CRUD 코드 자동 생성:</strong>  DDL(Data Definition Language)은 데이터베이스 테이블 구조를 정의하는 SQL 문이다. 예를 들어 다음과 같은 테이블 생성 구문이 있다고 해보자.</p>
<pre><code class="language-sql"> CREATETABLE USER_INFO (
     USER_IDVARCHAR(20) NOTNULL,
     USER_NAMEVARCHAR(50),
     EMAILVARCHAR(100),
 PRIMARYKEY (USER_ID)
 );</code></pre>
<p> 보통 개발자는 이 테이블을 기반으로 여러 계층의 코드를 작성해야 한다. 데이터를 담을 VO 클래스가 필요하고, 비즈니스 로직을 정의할 Service 인터페이스가 필요하고, 실제 로직을 구현할 ServiceImpl 클래스가 필요하고, HTTP 요청을 받을 Controller가 필요하고, DB 접근을 위한 Mapper Interface와 Mapper XML이 필요하고, 화면을 위한 JSP나 Thymeleaf 파일도 필요하다.</p>
<p> 이 작업은 구조가 반복적이지만, 작성해야 할 파일 수가 많다. VSCode Initializr는 DDL을 입력하면 이를 분석해서 필요한 코드를 자동으로 생성해준다.</p>
<p> 자동 생성 대상은 대표적으로 다음과 같다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>생성 파일</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 객체</td>
<td>VO, DefaultVO</td>
</tr>
<tr>
<td>비즈니스 로직</td>
<td>Service, ServiceImpl</td>
</tr>
<tr>
<td>웹 요청 처리</td>
<td>Controller</td>
</tr>
<tr>
<td>데이터 접근</td>
<td>Mapper Interface, Mapper XML</td>
</tr>
<tr>
<td>화면</td>
<td>JSP List, JSP Register</td>
</tr>
<tr>
<td>모던 뷰</td>
<td>Thymeleaf List, Thymeleaf Register</td>
</tr>
</tbody></table>
<p> 즉, 하나의 테이블 정의를 기반으로 백엔드 계층과 화면 계층의 기본 코드를 한 번에 만들 수 있다. 이 기능의 장점은 단순히 코드 작성 시간을 줄이는 것만이 아니다. 반복적인 코드에서 자주 발생하는 변수명 오타, 패키지 경로 실수, 파일명 불일치, Mapper 연결 오류 같은 문제를 줄일 수 있다. 개발자는 자동 생성된 기본 코드를 바탕으로 실제 비즈니스 요구사항에 맞는 로직을 추가하면 된다.</p>
</li>
<li><p><strong>설정 파일 자동 생성:</strong> Spring 기반 프로젝트에서는 설정 파일이 매우 중요하다. 데이터베이스 연결 정보, 트랜잭션 설정, 로깅 설정, 스케줄링 설정, 프로퍼티 설정 등이 잘못되면 애플리케이션이 제대로 실행되지 않는다.</p>
<p> 특히 전자정부 표준 프레임워크는 레거시 프로젝트와 최신 프로젝트가 함께 존재할 수 있기 때문에, XML 설정, Java Config, YAML, Properties 등 다양한 설정 방식에 대한 이해가 필요하다. VSCode Initializr는 이런 설정 파일 생성을 도와준다.</p>
<p> 개발자는 복잡한 설정 문법을 처음부터 모두 외우지 않아도 된다. 도구가 제공하는 흐름에 따라 필요한 값을 입력하면, 설정 파일의 기본 구조를 생성할 수 있다. 이 기능은 초보 개발자에게 특히 유용하다. 설정 파일은 비즈니스 로직보다 눈에 잘 보이지 않지만, 한 글자만 잘못 작성해도 실행 오류가 발생할 수 있다. 따라서 설정 파일 자동 생성 기능은 프로젝트 초기 오류를 줄이는 데 큰 도움이 된다.</p>
</li>
</ol>
<p>&nbsp;    </p>
<h2 id="🔍-내부적으로는-어떻게-동작할까">🔍 내부적으로는 어떻게 동작할까?</h2>
<p>이 프로젝트는 단순히 템플릿 파일을 복사하는 수준의 도구가 아니다. eGovFrame VSCode Initializr는 <strong>TypeScript 기반으로 작성된 VSCode 확장 프로그램</strong>이며, 사용자 인터페이스는 <strong>React와 WebView API</strong>를 활용해 구성되어 있다.</p>
<p>여기서 WebView는 VSCode 확장 프로그램 안에서 웹 화면을 띄울 수 있게 해주는 기능이다. 즉, VSCode 안에 작은 웹 애플리케이션이 들어가 있는 것처럼 동작한다고 이해하면 쉽다.</p>
<p>개발자가 버튼을 클릭하거나 값을 입력하면 React 기반 UI가 상태를 관리하고, VSCode Extension API를 통해 로컬 파일 시스템에 프로젝트 파일이나 코드 파일을 생성한다. 또한 코드 생성에는 Handlebars라는 템플릿 엔진이 사용된다.</p>
<p>Handlebars는 미리 만들어둔 코드 템플릿에 테이블명, 컬럼명, 타입 같은 값을 끼워 넣어 실제 코드 파일을 만들어주는 역할을 한다. 예를 들어 템플릿에 다음과 같은 부분이 있다고 생각해보자.</p>
<pre><code>privateString {{fieldName}};</code></pre><p>DDL에서 <code>USER_NAME</code>이라는 컬럼을 읽어 <code>userName</code>이라는 필드명으로 변환하면, 최종 코드는 다음처럼 생성된다.</p>
<pre><code>privateStringuserName;</code></pre><p>이런 방식으로 DDL에서 추출한 메타데이터를 여러 코드 템플릿에 바인딩하여 VO, Service, Controller, Mapper 등을 생성한다. </p>
<p>또한 SQL 문법 분석에는 <code>monaco-sql-languages</code>가 활용된다. 이 라이브러리는 DDL 입력 내용을 분석하고, SQL 문법 오류를 감지하는 역할을 한다. 덕분에 잘못된 DDL을 기반으로 잘못된 코드가 생성되는 문제를 줄일 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🖼️-왜-react와-webview를-사용할까">🖼️ 왜 React와 WebView를 사용할까?</h2>
<p>VSCode 확장 프로그램은 단순한 명령어 기반 도구로 만들 수도 있다. 하지만 eGovFrame VSCode Initializr는 프로젝트 생성, 코드 생성, 설정 생성처럼 사용자가 선택하고 입력해야 하는 값이 많다. 만약 모든 기능을 명령어 입력 방식으로만 제공한다면 사용성이 떨어질 수 있다.</p>
<p>그래서 React 기반 UI와 WebView를 사용한다. React를 사용하면 복잡한 화면 상태를 컴포넌트 단위로 관리할 수 있다. 사용자가 선택한 빌드 도구, 프로젝트 유형, DDL 입력값, 생성 결과 미리보기 등을 UI에서 효율적으로 다룰 수 있다. 또한 VSCode의 테마와 자연스럽게 어울리는 UI를 제공할 수도 있다.</p>
<p>예를 들어 사용자가 VSCode를 다크 모드로 사용하면 확장 프로그램 화면도 다크 모드에 맞춰 보이고, 라이트 모드로 바꾸면 그에 맞춰 자연스럽게 전환된다. 이런 부분은 단순한 기능 구현을 넘어 개발자 경험을 좋게 만드는 요소다. 개발 도구는 기능만 많다고 좋은 것이 아니다. 사용자가 불편함 없이 자연스럽게 사용할 수 있어야 한다.</p>
<p>&nbsp;</p>
<h2 id="⚙️-코드-생성-성능을-위해-어떤-최적화가-필요할까">⚙️ 코드 생성 성능을 위해 어떤 최적화가 필요할까?</h2>
<p>DDL 기반 CRUD 코드 생성 기능은 매우 편리하지만, 내부적으로는 처리해야 할 일이 많다. DDL을 분석해야 하고, 테이블명과 컬럼명을 추출해야 하고, 타입을 변환해야 하고, 여러 템플릿에 값을 바인딩해야 하고, 생성 결과를 미리보기 화면에 보여줘야 한다.</p>
<p>문제는 생성해야 하는 파일이 하나가 아니라는 점이다. VO, DefaultVO, Service, ServiceImpl, Controller, Mapper Interface, Mapper XML, JSP List, JSP Register, Thymeleaf List, Thymeleaf Register 등 여러 파일이 동시에 생성될 수 있다.</p>
<p>만약 사용자가 DDL을 한 글자 입력할 때마다 모든 템플릿을 순차적으로 다시 렌더링한다면 어떻게 될까? VSCode 화면이 버벅일 수 있다. 사용자는 입력할 때마다 지연을 느끼게 되고, 도구를 사용하는 경험이 나빠진다. 그래서 이 프로젝트에서는 성능 최적화가 중요하다.</p>
<p>자료에서는 선택적 자동 업데이트와 비동기 병렬 렌더링 같은 최적화 전략이 언급된다. 쉽게 말하면, 사용자가 입력할 때마다 무조건 전체 코드를 다시 생성하지 않고, 필요한 시점에 효율적으로 생성하도록 제어하는 것이다. 또한 여러 템플릿은 서로 독립적이기 때문에 순서대로 하나씩 생성하기보다 병렬로 처리할 수 있다.</p>
<p>예를 들어 VO 생성과 Controller 생성은 서로 직접 의존하지 않는다. 그렇다면 두 작업을 동시에 처리하는 것이 더 효율적이다. 이런 방식으로 사용자는 더 빠른 미리보기와 코드 생성 경험을 얻을 수 있다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://github.com/eGovFramework/egovframe-vscode-initializr">egovframe-vscode-initializr 레포지토리</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[전자정부 표준 프레임워크(eGovFrame) 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/eGovFrame-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/eGovFrame-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 04 May 2026 06:34:02 GMT</pubDate>
            <description><![CDATA[<h1 id="🔍-전자정부-표준-프레임워크egovframe">🔍 전자정부 표준 프레임워크(eGovFrame)</h1>
<p>이번 오픈소스 컨트리뷰션 아카데미에서 내가 맡은 프로젝트는 <code>egovframe-vscode-initializr</code>이다. 처음 이 프로젝트를 접했을 때 가장 먼저 든 생각은 이것이었다.</p>
<blockquote>
<p><strong><em>“전자정부 표준 프레임워크가 정확히 뭐지?”</em></strong></p>
</blockquote>
<p>이름만 보면 조금 딱딱하고 어렵게 느껴진다. 전자정부, 표준, 프레임워크라는 단어가 한 번에 붙어 있기 때문이다.</p>
<p>하지만 핵심은 생각보다 단순하다. 전자정부 표준 프레임워크는 <strong>공공기관 정보시스템을 더 빠르고, 안정적이고, 표준화된 방식으로 만들기 위해 제공되는 Java 기반 개발 표준 환경이다.</strong></p>
<p>조금 더 쉽게 말하면, 공공기관 웹 서비스를 만들 때 매번 로그인, 권한 관리, 게시판, 데이터베이스 연결, 예외 처리, 로그 처리 같은 공통 기능을 처음부터 다시 만들지 않도록 미리 준비해둔 개발 기반이다. 집을 지을 때마다 벽돌, 창문, 문, 전기 배선 규격을 매번 새로 만들지 않는 것처럼, 정보시스템을 만들 때도 반복적으로 필요한 기능들을 표준화해서 제공하는 것이다.</p>
<p>&nbsp;</p>
<h2 id="❓-프레임워크는-왜-등장했을까">❓ 프레임워크는 왜 등장했을까?</h2>
<p>전자정부 표준 프레임워크를 이해하기 전에 먼저 프레임워크가 왜 필요한지 이해해야 한다.</p>
<p>소프트웨어 개발 환경은 시대에 따라 계속 변해왔다. 1970년대에는 메인프레임 중심의 중앙 집중형 컴퓨팅 환경이 주로 사용되었다. 하나의 큰 컴퓨터가 데이터 처리, 비즈니스 로직, 화면 처리까지 대부분의 일을 담당했다. 이 방식은 관리와 배포가 편리했지만, 장비 가격이 비싸고 시스템 확장이 어렵다는 단점이 있었다.</p>
<p>이후 1980년대에는 클라이언트-서버 환경이 등장했다. 서버는 데이터베이스를 관리하고, 사용자의 PC가 일부 비즈니스 로직과 화면 처리를 담당하는 방식이었다. 이전보다 비용은 낮아졌고 사용자 인터페이스도 좋아졌지만, 클라이언트 프로그램을 배포하고 관리하는 일이 어려웠다.</p>
<p>1990년대 이후 인터넷과 웹이 보편화되면서 시스템 구조는 다시 크게 바뀌었다. 사용자는 브라우저를 통해 서비스에 접속하고, 서버는 웹 서버, WAS, 데이터베이스 등으로 역할을 나누어 처리하게 되었다. 이 방식은 시스템 간 연동과 확장, 유지보수 측면에서 훨씬 유리했다.</p>
<p>하지만 웹 기반 시스템이 많아지면서 또 다른 문제가 생겼다. 만들어야 할 소프트웨어는 많아졌는데, 매번 비슷한 기능을 반복해서 개발해야 했던 것이다. 예를 들어 어떤 공공기관 시스템을 만든다고 생각해보자. 민원 시스템이든, 복지 시스템이든, 행정 정보 시스템이든 대부분 다음과 같은 기능이 필요하다.</p>
<ul>
<li>회원가입 및 로그인</li>
<li>회원 관리</li>
<li>권한 처리</li>
<li>게시판</li>
<li>파일 업로드</li>
<li>데이터베이스 연결</li>
<li>트랜잭션 처리</li>
<li>로그 기록</li>
<li>예외 처리</li>
<li>보안 처리</li>
</ul>
<p>이 기능들은 프로젝트마다 조금씩 다를 수는 있지만 기본 구조는 비슷하다. 그런데 매번 새 프로젝트를 할 때마다 이 기능을 처음부터 다시 만든다면 개발 비용이 커지고, 품질도 프로젝트마다 달라질 수밖에 없다. 그래서 소프트웨어 개발은 점점 <strong>재사용성</strong>을 높이는 방향으로 발전해왔다.</p>
<p>&nbsp;</p>
<h2 id="🔄-소프트웨어-재사용-방식의-발전">🔄 소프트웨어 재사용 방식의 발전</h2>
<p>소프트웨어를 재사용하는 방식은 여러 단계를 거쳐 발전했다.</p>
<h3 id="🖋️-1단계-소스-코드-재사용">🖋️ 1단계: 소스 코드 재사용</h3>
<p>가장 단순한 방식은 과거에 작성했던 코드를 복사해서 붙여넣는 것이다. </p>
<p>예를 들어 날짜를 문자열로 변환하는 코드가 필요할 때, 이전 프로젝트에서 사용했던 코드를 그대로 복사해오는 식이다.</p>
<p>처음에는 빠르고 간단해 보인다. 하지만 문제가 있다. 같은 코드가 여러 곳에 복사되어 있으면, 나중에 수정이 필요할 때 모든 복사본을 찾아서 고쳐야 한다. 즉, 코드 중복이 늘어나고 유지보수가 어려워진다.</p>
<p>&nbsp;</p>
<h3 id="📖-2단계-메서드-라이브러리-재사용">📖 2단계: 메서드 라이브러리 재사용</h3>
<p>복사 붙여넣기의 문제를 줄이기 위해 자주 사용하는 기능을 메소드나 라이브러리로 분리하기 시작했다. 예를 들어 날짜 변환 기능을 <code>DateUtil</code> 같은 유틸리티 클래스로 만들고, 여러 곳에서 해당 메소드를 호출하는 방식이다.</p>
<p>이렇게 하면 같은 코드를 여러 번 복사하지 않아도 된다. 기능을 수정해야 할 때도 라이브러리 내부만 고치면 된다.</p>
<p>하지만 이 방식도 한계가 있다. 특정 기능 단위의 재사용에는 좋지만, 전체 애플리케이션 구조를 잡아주지는 못한다.</p>
<p>&nbsp;</p>
<h3 id="🧩-3단계-객체지향-재사용">🧩 3단계: 객체지향 재사용</h3>
<p>객체지향 프로그래밍에서는 클래스를 통해 재사용을 할 수 있다.</p>
<p>예를 들어 <code>Person</code>이라는 부모 클래스에 이름, 생년월일, 출력 기능을 정의하고, <code>Client</code>, <code>Employee</code> 같은 자식 클래스가 이를 상속받아 사용할 수 있다. 상속을 활용하면 공통 속성과 기능을 재사용할 수 있다.</p>
<p>하지만 객체지향 재사용도 주로 수직적인 관계에서 효과적이다. 현실의 시스템은 단순히 부모-자식 구조만으로 해결되지 않는다. 데이터 저장 방식, 외부 시스템 연동, 화면 처리 방식처럼 상황에 따라 유연하게 바뀌어야 하는 문제들이 많다.</p>
<p>&nbsp;</p>
<h3 id="🎏-4단계-디자인-패턴-재사용">🎏 4단계: 디자인 패턴 재사용</h3>
<p>디자인 패턴은 자주 발생하는 설계 문제에 대한 검증된 해결 방법이다.</p>
<p>예를 들어 데이터 저장 방식이 관계형 데이터베이스일 수도 있고, 파일일 수도 있고, 외부 API일 수도 있다고 해보자. 이때 Adapter 패턴을 사용하면 서로 다른 저장 방식의 차이를 감추고 일관된 방식으로 사용할 수 있다.</p>
<p>디자인 패턴은 특정 코드 자체를 재사용한다기보다, 문제를 해결하는 구조와 아이디어를 재사용하는 방식이다.</p>
<p>하지만 디자인 패턴도 전체 애플리케이션을 구성하는 완성된 틀은 아니다. 시스템의 부분적인 문제를 해결하는 데는 좋지만, 애플리케이션 전체 구조를 표준화해주지는 못한다.</p>
<p>&nbsp;</p>
<h3 id="🏰-5단계-프레임워크-재사용">🏰 5단계: 프레임워크 재사용</h3>
<p>프레임워크는 앞선 재사용 방식들을 더 큰 단위로 통합한 것이다. 프레임워크는 단순한 코드 모음이 아니다. 애플리케이션을 어떤 구조로 만들지, 각 계층은 어떤 역할을 해야 하는지, 공통 기능은 어떻게 처리해야 하는지에 대한 기본 틀을 제공한다. 쉽게 말해 프레임워크는 <strong>반제품 형태의 소프트웨어</strong>다.</p>
<p>완제품은 아니지만, 기본 골격과 핵심 기능이 이미 준비되어 있다. 개발자는 그 위에 자신이 만들고자 하는 업무 기능을 추가하면 된다. 건축으로 비유하면, 프레임워크는 건물의 기본 설계도와 골조에 가깝다. 개발자는 그 골조 위에 각 건물의 목적에 맞는 방, 인테리어, 세부 기능을 추가하는 것이다.</p>
<p>&nbsp;</p>
<h2 id="⚙️-전자정부-표준-프레임워크가-만들어진-이유">⚙️ 전자정부 표준 프레임워크가 만들어진 이유</h2>
<p>그렇다면 왜 정부는 별도로 전자정부 표준 프레임워크를 만들었을까? 이유는 공공 정보화 사업의 구조적인 문제 때문이다. 과거 공공기관 정보시스템은 특정 대형 SI 업체의 자체 프레임워크를 기반으로 구축되는 경우가 많았다. 예를 들어 삼성 SDS, LG CNS, SK C&amp;C 같은 대형 업체들이 각자 자체 프레임워크를 가지고 있었고, 많은 공공 시스템이 이런 특정 업체의 기술에 의존했다. 이 방식에는 몇 가지 문제가 있었다.</p>
<ol>
<li><p><strong>특정 업체에 대한 종속성</strong>: 어떤 시스템이 특정 업체의 자체 프레임워크로 만들어지면, 나중에 유지보수나 고도화 사업을 할 때 다른 업체가 참여하기 어려워진다. 내부 구조와 기술을 기존 업체만 잘 알고 있기 때문이다.</p>
</li>
<li><p><strong>중소기업의 참여가 어려움</strong>: 공공사업에 참여하려면 해당 업체의 프레임워크를 알아야 하는데, 중소기업 입장에서는 이런 폐쇄적인 프레임워크에 접근하기 어렵다. 결국 대형 업체 중심의 구조가 강화될 수밖에 없다.</p>
</li>
<li><p><strong>비슷한 기능이 계속 중복 개발</strong>: A 기관도 로그인 기능을 만들고, B 기관도 게시판을 만들고, C 기관도 권한 관리 기능을 새로 만든다. 이미 다른 프로젝트에서 만든 기능인데도 매번 다시 개발하는 일이 반복되었다.</p>
</li>
<li><p><strong>시스템 간 연계와 유지보수가 어려움:</strong> 각 기관의 시스템이 서로 다른 프레임워크와 구조로 만들어지면, 나중에 시스템을 통합하거나 연계할 때 많은 비용이 든다.</p>
</li>
</ol>
<p>위와 같은 문제를 해결하기 위해 정부는 2008년부터 전자정부 표준 프레임워크 개발과 보급을 추진했다. 목적은 분명했다. <strong>공공 정보시스템 개발 기반을 표준화하고, 특정 업체 종속을 줄이며, 중복 개발을 방지하고, 중소기업도 공정하게 참여할 수 있는 개발 생태계를 만드는 것</strong>이다.</p>
<p>즉, 전자정부 표준 프레임워크는 단순히 개발자를 편하게 해주는 도구가 아닌, 공공 소프트웨어 생태계의 구조를 개선하기 위해 만들어진 표준 개발 기반이다.</p>
<p>&nbsp;</p>
<h2 id="🤔-전자정부-표준-프레임워크란-무엇인가">🤔 전자정부 표준 프레임워크란 무엇인가?</h2>
<p>전자정부 표준 프레임워크는 공공 정보시스템 개발에 필요한 공통 기반을 표준화해서 제공하는 프레임워크다. 조금 더 쉽게 말하면, 공공기관 웹 서비스를 만들 때 자주 필요한 기능과 구조를 미리 준비해둔 개발 플랫폼이다.</p>
<p>공공기관 시스템에는 보통 아래와 같은 기능이 필요하다고 위에서 언급했다.</p>
<ul>
<li>회원가입 및 로그인</li>
<li>회원 관리</li>
<li>권한 처리</li>
<li>게시판</li>
<li>파일 업로드</li>
<li>데이터베이스 연결</li>
<li>트랜잭션 처리</li>
<li>로그 기록</li>
<li>예외 처리</li>
<li>보안 처리</li>
</ul>
<p>전자정부 표준 프레임워크는 이런 기능들을 공통적으로 사용할 수 있도록 제공한다. 개발자는 매번 바닥부터 구현하지 않고, 표준 프레임워크가 제공하는 구조와 기능을 활용해 실제 업무 로직에 집중할 수 있다. 여기서 중요한 점은 전자정부 표준 프레임워크가 단순히 하나의 라이브러리가 아니라는 것이다.</p>
<p>전자정부 표준 프레임워크는 다음과 같은 요소들을 포함하는 개발 생태계에 가깝다.</p>
<ul>
<li>실행환경</li>
<li>개발환경</li>
<li>관리환경</li>
<li>운영환경</li>
<li>공통컴포넌트</li>
<li>개발 가이드</li>
<li>템플릿</li>
<li>예제 코드</li>
</ul>
<p>즉, 프로젝트를 만들고, 개발하고, 테스트하고, 운영하고, 유지보수하는 전체 과정을 지원하는 표준 기반이라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🎭-전자정부-표준-프레임워크와-spring의-관계">🎭 전자정부 표준 프레임워크와 Spring의 관계</h2>
<p>전자정부 표준 프레임워크를 처음 접하면 이런 의문이 들 수 있다.</p>
<blockquote>
<p><em><strong>“전자정부 표준 프레임워크는 Spring이랑 같은 건가?”</strong></em></p>
</blockquote>
<p>95% 정도만 같다. 전자정부 표준 프레임워크는 Spring을 기반으로 하지만, Spring 그 자체는 아니다. Spring은 Java 애플리케이션 개발을 도와주는 대표적인 프레임워크다. 전자정부 표준 프레임워크는 Spring을 포함한 여러 오픈소스 기술을 공공 정보시스템 개발에 맞게 선별하고, 검증하고, 표준화해서 제공한다. 즉, Spring이 핵심 엔진이라면, 전자정부 표준 프레임워크는 그 엔진 위에 공공 시스템 개발에 필요한 표준 구조, 공통 기능, 개발 도구, 운영 도구, 가이드까지 함께 묶어 제공하는 패키지라고 볼 수 있다.</p>
<p>비유하자면 Spring은 자동차의 엔진이고, 전자정부 표준 프레임워크는 그 엔진을 기반으로 실제 도로에서 안전하게 달릴 수 있도록 차체, 운전석, 안전장치, 내비게이션, 정비 매뉴얼까지 갖춘 완성형 플랫폼에 가깝다.</p>
<p>&nbsp;</p>
<h2 id="🗂️-전자정부-표준-프레임워크의-구성">🗂️ 전자정부 표준 프레임워크의 구성</h2>
<p>전자정부 표준 프레임워크는 크게 실행환경, 개발환경, 관리환경, 운영환경, 공통 컴포넌트로 구성된다.</p>
<h2 id="▶️-실행환경">▶️ 실행환경</h2>
<p>실행환경은 애플리케이션이 실제 서버에서 동작할 때 필요한 기반 기능을 제공한다. 쉽게 말해 시스템이 돌아가기 위한 엔진룸이다. 실행환경에는 다음과 같은 기능들이 포함된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/daa901ff-aa00-4f2c-8ac3-83e170be2670/image.png" alt=""></p>
<ul>
<li><p><strong>화면 처리 영역</strong>: 사용자가 브라우저에서 보는 화면과 서버 간의 요청과 응답을 담당한다.</p>
</li>
<li><p><strong>업무 처리 영역</strong>: 실제 비즈니스 로직이 실행되는 부분이다.</p>
</li>
<li><p><strong>데이터 처리 영역</strong>: 데이터베이스와 연결하고 데이터를 조회하거나 저장하는 역할을 한다.</p>
</li>
<li><p><strong>연계 및 통합 영역</strong>: 외부 시스템이나 다른 기관 시스템과 통신하는 기능을 담당한다.</p>
</li>
<li><p><strong>공통 기반 영역</strong>: 로그, 예외 처리, 메시지 처리 같은 공통 기능을 제공한다.</p>
</li>
<li><p><strong>배치 처리 영역</strong>: 대량의 데이터를 정해진 시간에 일괄 처리하는 기능을 담당한다.</p>
</li>
</ul>
<p>예를 들어 매일 밤 전 국민의 특정 데이터를 정산하거나, 대량의 통계 데이터를 생성하는 작업은 배치 처리 영역에서 담당할 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-개발환경">🛠️ 개발환경</h2>
<p>개발환경은 개발자가 표준 프레임워크 기반 프로젝트를 더 쉽게 만들고 개발할 수 있도록 돕는 도구 모음이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5eb92b6e-8619-4143-afb7-770e9f85f430/image.png" alt=""></p>
<p>과거에는 전자정부 표준 프레임워크 개발환경이 Eclipse 중심으로 제공되었다. 하지만 최근 개발 환경은 많이 바뀌었다. 많은 개발자가 VS Code, IntelliJ, 다양한 프론트엔드 도구를 함께 사용한다.</p>
<p>이 흐름에 맞춰 전자정부 표준 프레임워크도 VS Code 기반 개발 도구를 제공하고 있다. <del>여기서 내가 맡은 <code>egovframe-vscode-initializr</code> 프로젝트가 등장한다.</del></p>
<p>이 도구는 VS Code 안에서 전자정부 표준 프레임워크 기반 프로젝트를 쉽게 생성하고, 설정 파일을 만들고, CRUD 코드까지 자동 생성할 수 있도록 돕는 확장 프로그램이다. 즉, 개발자가 표준 프레임워크 프로젝트를 시작할 때 겪는 복잡한 초기 설정 과정을 줄여주는 도구다.</p>
<p>&nbsp;</p>
<h2 id="👨🏻🔧-관리환경">👨🏻‍🔧 관리환경</h2>
<p>관리환경은 표준 프레임워크 자체를 유지하고 개선하기 위한 체계다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d7d22b2e-4763-4b0f-9b36-25306381a3c3/image.png" alt=""></p>
<p>프레임워크도 한 번 만들고 끝나는 것이 아니다. 오픈소스 라이브러리 버전이 올라가고, 보안 취약점이 발견되고, 새로운 기술이 등장하면 계속 개선되어야 한다. 관리환경은 이런 변경 요청, 오류 수정, 기능 개선, 버전 관리, 표준 관리 등을 처리하는 역할을 한다. 쉽게 말하면 표준 프레임워크가 계속 안정적으로 발전할 수 있도록 관리하는 내부 운영 체계다.</p>
<p>&nbsp;</p>
<h2 id="🏭-운영환경">🏭 운영환경</h2>
<p>운영환경은 실제 서비스가 운영되는 동안 시스템 상태를 확인하고 관리하기 위한 환경이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cfd2ee57-2ef3-44e1-8856-eaceb1ffd073/image.png" alt=""></p>
<p>서비스가 정상적으로 동작하는지, 서버 자원은 충분한지, 응답 시간이 너무 느리지는 않은지, 에러는 발생하지 않는지 등을 모니터링한다. 쉽게 말해 운영환경은 시스템의 관제실이다.</p>
<p>공공기관 서비스는 많은 사용자가 이용하고, 장애가 발생하면 사회적 영향이 클 수 있다. 따라서 시스템이 잘 동작하는지 지속적으로 확인하는 운영환경이 중요하다.</p>
<p>&nbsp;</p>
<h2 id="📦-공통-컴포넌트">📦 공통 컴포넌트</h2>
<p>전자정부 표준 프레임워크에서 가장 실질적으로 개발 생산성을 높여주는 요소가 공통컴포넌트다. 공통 컴포넌트는 여러 정보시스템에서 반복적으로 사용되는 기능을 미리 만들어둔 소프트웨어 부품이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/755b5823-14eb-4c5f-87ca-3d3593efca12/image.png" alt=""></p>
<p>예를 들어 다음과 같은 기능들이 공통 컴포넌트로 제공된다고 한다면,</p>
<ul>
<li>로그인</li>
<li>사용자 관리</li>
<li>권한 관리</li>
<li>게시판</li>
<li>공지사항</li>
<li>주소록</li>
<li>일정 관리</li>
<li>파일 업로드</li>
<li>공통 코드 관리</li>
<li>메뉴 관리</li>
<li>로그 관리</li>
<li>통계 관리</li>
<li>암호화</li>
<li>유효성 검사</li>
</ul>
<p>개발자는 이런 기능을 처음부터 새로 만들 필요 없이, 필요한 공통컴포넌트를 가져와 프로젝트에 적용할 수 있다. 비유하자면 공통 컴포넌트는 레고 블록과 비슷하다. 필요한 블록을 선택해서 조립하면 원하는 구조물을 더 빠르게 만들 수 있다.</p>
<p>물론 공통 컴포넌트를 그대로 사용하는 것만이 정답은 아니다. 기관이나 프로젝트의 요구사항에 맞게 수정해서 사용할 수도 있다. 중요한 것은 완전히 빈 상태에서 시작하지 않아도 된다는 점이다.</p>
<p>&nbsp;</p>
<h2 id="👍-전자정부-표준-프레임워크를-사용하면-좋은-점">👍 전자정부 표준 프레임워크를 사용하면 좋은 점</h2>
<p>전자정부 표준 프레임워크를 사용하면 여러 장점이 있다.</p>
<ol>
<li><p><strong>개발 생산성 향상</strong>: 반복적으로 필요한 기능을 매번 새로 만들 필요가 줄어든다. 개발자는 로그인, 권한, DB 연결, 로그 처리 같은 기반 기능보다 실제 업무 로직에 더 집중할 수 있다.</p>
</li>
<li><p><strong>품질 향상</strong>: 프로젝트마다 제각각 구현하던 공통 기능을 검증된 표준 방식으로 구현할 수 있다. 이는 코드 품질과 시스템 안정성을 높이는 데 도움이 된다.</p>
</li>
<li><p><strong>유지보수성 향상</strong>: 표준화된 구조를 사용하면 새로운 개발자가 프로젝트에 참여했을 때 구조를 이해하기 쉽다. 공공 시스템은 한 번 만들고 끝나는 것이 아니라 오랜 기간 운영되기 때문에 유지보수성이 매우 중요하다.</p>
</li>
<li><p><strong>특정 업체 종속성 완화</strong>: 표준 프레임워크는 공개된 표준과 오픈소스 기반 기술을 활용한다. 따라서 특정 업체의 폐쇄적인 프레임워크에 의존하는 문제를 줄일 수 있다.</p>
</li>
<li><p><strong>중소기업 참여 기회 확대</strong>: 공통된 표준 기반이 있으면 특정 대기업만 시스템을 이해하고 유지보수할 수 있는 구조를 줄일 수 있다. 이는 더 많은 기업이 공정하게 경쟁할 수 있는 환경을 만드는 데 도움이 된다.</p>
</li>
</ol>
<p>&nbsp;</p>
<h2 id="🌊-전자정부-표준-프레임워크의-최신-흐름">🌊 전자정부 표준 프레임워크의 최신 흐름</h2>
<p>전자정부 표준 프레임워크는 과거의 공공 SI 개발 도구에 머무르지 않고 계속 변화하고 있다.</p>
<p>초기 버전은 주로 공공 웹 시스템의 표준 개발 기반을 제공하는 데 초점이 있었다. 이후 모바일 환경 지원, 보안 강화, 오픈소스 라이브러리 업그레이드, Spring 버전 업그레이드 등이 꾸준히 이루어졌다. 최근에는 Spring Boot, MSA, 클라우드 네이티브, OpenSearch, AI 기반 검색, VS Code 개발환경 같은 현대적인 흐름도 반영되고 있다. </p>
<p>특히 Spring Boot 기반으로 전환되면서 과거보다 설정이 간소화되고, 독립 실행 가능한 애플리케이션 구성이 쉬워졌다. 기존에는 복잡한 XML 설정을 많이 작성해야 했지만, Spring Boot 기반에서는 자동 설정을 활용해 초기 설정 부담을 크게 줄일 수 있다.</p>
<p>또한 MSA 환경도 중요해지고 있다. 과거에는 하나의 큰 애플리케이션 안에 모든 기능이 들어 있는 모놀리식 구조가 일반적이었다. 하지만 시스템 규모가 커지고 트래픽이 늘어나면 하나의 거대한 애플리케이션을 관리하기 어려워진다. MSA는 사용자, 게시판, 예약, 인증 같은 기능을 작고 독립적인 서비스로 나누어 운영하는 방식이다. 특정 서비스에 트래픽이 몰리면 해당 서비스만 확장할 수 있고, 일부 서비스에 문제가 생겨도 전체 시스템 장애로 번지는 것을 줄일 수 있다. 전자정부 표준 프레임워크도 이런 클라우드 네이티브 환경에 맞춰 계속 확장되고 있다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://www.egovframe.go.kr/home/main.do">표준 프레임워크 포털 eGovFrame</a>
오픈소스 컨트리뷰션 아카데미 배포 자료</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[애그리거트(Aggregate)]]></title>
            <link>https://velog.io/@rocker_nun/%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B1%B0%ED%8A%B8Aggregate</link>
            <guid>https://velog.io/@rocker_nun/%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B1%B0%ED%8A%B8Aggregate</guid>
            <pubDate>Sun, 03 May 2026 14:43:16 GMT</pubDate>
            <description><![CDATA[<h1 id="🏰-애그리거트">🏰 애그리거트</h1>
<p>온라인 쇼핑몰 시스템을 상위 수준 개념을 이용해서 바라보면 아래와 같이 전체 모델들 간의 관계를 이해할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/07a7f2b9-3e3f-4d8a-a76d-589cd2aaa7f8/image.png" alt=""></p>
<p>위와 같은 상위 수준 모델 간의 관계를 제대로 이해하지 않고 개별 객체들 간의 관계만을 보고 전체 모델의 관계를 파악하기는 매우 어렵고, 코드를 변경하고 확장하는 것이 매우 힘들어진다. 이를 위해 상위 수준에서 모델을 관찰할 수 있게 해주는 <strong>애그리거트(Aggregate)</strong>가 등장한다. 도메인 규칙에 따라 함께 일관성을 유지해야 하는 객체들을 하나의 애그리거트 경계로 묶어서 상위 수준에서 도메인 모델 간의 관계를 파악하는 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7c08746d-bca9-495e-8ed3-44747c5d2e64/image.png" alt=""></p>
<p>위의 그림처럼 애그리거트는 전체 모델 간의 관계를 이해할 수 있도록 도와줄 뿐만 아니라, 일관성을 관리하는 기준도 될 수 있다. 그리고 관련된 모델을 하나로 모았기 때문에 한 애그리거트에 속하는 객체는 유사하거나 동일한 라이프 사이클을 갖는다. 그리고 그림을 보면 알 수 있듯이 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. 경계를 명확히 해서 복잡한 도메인을 단순한 구조로 만드는 것이 애그리거트의 임무이기 때문에 어찌 보면 당연하다. </p>
<p>이 경계를 어떻게 결정하는지에 대한 답은 도메인 규칙과 요구사항으로부터 찾아야 한다. 일단 도메인 규칙에 따라 함께 생성되고 변경되는 구성요소는 한 애그리거트에 속할 가능성이 높다. 그리고 주의할 점은 <em>“A가 B를 갖는다”</em> 와 같은 요구사항을 보고 A와 B는 무조건 하나의 애그리거트에 속할 것이라고 속단하는 것이다. 아래 상품과 리뷰 예시를 보자.</p>
<p>상품 상세 페이지에 들어가면 보통 리뷰가 달려 있으니 상품과 리뷰는 하나의 애그리거트에 포함시키는 것이 이상하다고 느껴지지 않는다. 하지만 분명 상품과 리뷰는 함께 생성되지도, 변경되지도 않는다. 게다가 변경 주체도 상품은 관리자가 변경하고, 리뷰는 고객이 변경 주체이기 때문에 하나의 애그리거트에 포함시키는 것은 무리가 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/78460e31-38da-4795-9ba6-a770c6445d0f/image.png" alt=""></p>
<p>&nbsp;</p>
<h1 id="👨🏻🔧-애그리거트-루트">👨🏻‍🔧 애그리거트 루트</h1>
<p>도메인 규칙을 지키기 위해서는 하나의 애그리거트에 속한 여러 객체들이 모두 정상 상태를 유지해야 한다. 따라서 애그리거트 전체를 컨트롤할 수 있는 관리자가 필요한데, 이 책임을 지는 것이 바로 애그리거트의 <strong>루트 엔티티(Root Entity)</strong>다. </p>
<p>루트 엔티티의 임무는 애그리거트의 일관성이 깨지지 않도록 하는 것이다. 이를 위해 루트 엔티티는 애그리거트가 제공해야 할 도메인 기능을 구현한다. 불필요한 중복을 피하고 루트 엔티티를 통해서만 도메인 로직을 구현하게 만들기 위해서는 <code>setter</code> 메서드를 외부에서 접근할 수 없도록 만들고, 밸류 타입은 불변으로 구현해야 한다. </p>
<p>그리고 루트 엔티티는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다. 아래 <code>Order</code> 루트 엔티티 코드와 <code>Member</code> 루트 엔티티 코드를 살펴보자.</p>
<pre><code class="language-java">public class Order {
    private Money totalAmount;
    private List&lt;OrderLine&gt; orderLines;

    private void calculateTotalAmount() {
        int sum = orderLines.stream()
                .mapToInt(ol -&gt; ol.getPrice() * ol.getQuantity())
                .sum();
        this.totalAmount = new Money(sum);
    }
}</code></pre>
<pre><code class="language-java">public class Member {
    private Password password;

    public void changePassword(Password currentPassword, Password newPassword) {
        if (!password.match(currentPassword)) {
            throw new PasswordNotMatchException();
        }
        this.password = new Password(newPassword);
    }
}</code></pre>
<p><code>Order</code>는 총 주문 금액을 구하기 위해 <code>OrderLine</code> 목록을 사용하고, <code>Member</code>는 비밀번호를 변경하기 위해 <code>Password</code> 밸류 타입에 비밀번호가 일치하는지 확인하고 있다. 추가로 루트 엔티티는 구성요소의 필드만 참조하는 것이 아니라 기능 실행을 위임하기도 한다.</p>
<p>&nbsp;</p>
<h2 id="🚧-트랜잭션-범위">🚧 트랜잭션 범위</h2>
<p>트랜잭션의 범위는 작을수록 좋다. 예를 들어 1개의 테이블을 수정하면 락의 대상이 그 테이블의 1개 행일 뿐이지만, 3개의 테이블을 수정한다면 락의 대상이 많아질 수밖에 없다. 이는 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 의미하고 전체적인 성능을 떨어뜨린다. </p>
<p>이와 마찬가지로 한 번에 수정하는 애그리거트 개수가 많으면 많아질수록 전체 처리량이 떨어지기 때문에 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. 다시 말하자면, 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 되도록 두지 말자는 것이다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class Order {
    private Orderer orderer;

    public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddressAsMemberAddress) {
        verifyNotYetShipped();
        setShippingInfo(newShippingInfo);  // 주문 애그리거트의 배송지 정보를 변경
        if (useNewShippingAddressAsMemberAddress) {
                // 회원의 주소를 변경된 배송지로 설정...
            orderer.getMember().changeAddress(newShippingInfo.getAddress());
        }
    }

    ...
}</code></pre>
<p>위의 코드는 주문 애그리거트에서 회원 애그리거트 내부 상태까지 변경하고 있는 상태다. <code>Order</code> 루트 엔티티나 <code>Member</code> 루트 엔티티는 각각 주문, 회원 애그리거트 내부의 일관성을 책임져야 하는데 <code>Order</code> 루트 엔티티가 회원 애그리거트 내부 일관성을 해치고 있는 것이다. 하지만 상황에 따라 한 트랜잭션으로 2개 이상의 애그리거트를 수정해야 한다면 위의 코드처럼 직접 수정하는 것보다 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 한다.</p>
<pre><code class="language-java">public class ChangeOrderService {
    private OrderRepository orderRepository;
    private MemberRepository memberRepository;

    @Transactional
    public void changeShippingInfo(
            OrderId orderId,
            ShippingInfo newShippingInfo,
            boolean useNewShippingAddressAsMemberAddress
    ) {
        Order order = orderRepository.findById(orderId);
        if (order == null) {
            throw new OrderNotFoundException();
        }
        order.shipTo(newShippingInfo);

        if (useNewShippingAddressAsMemberAddress) {
            Member member = findMember(order.getOrderer());
            member.changeAddress(newShippingInfo.getAddress());
        }
    }

    ...
}</code></pre>
<p>정리하면, 한 트랜잭션에서 한 개의 애그리거트를 변경하는 것이 가장 좋지만, 아래의 경우에는 한 트랜잭션에서 2개 이상의 애그리거트를 변경하는 것을 고려할 수도 있다.</p>
<ul>
<li><strong>팀 표준</strong>: 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우가 있다.</li>
<li><strong>기술 제약</strong>: 기술적으로 이벤트 방식을 도입할 수 없는 경우 한 트랜잭션에서 다수의 애그리거트를 수정해서 일관성을 처리해야 한다.</li>
<li><strong>UI 구현의 편리</strong>: 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶을 것이다. 이 경우 한 트랜잭션에서 여러 주문 애그리거트의 상태를 변경해야 한다.</li>
</ul>
<p>&nbsp;</p>
<h1 id="💾-리포지토리와-애그리거트">💾 리포지토리와 애그리거트</h1>
<p>객체의 영속성을 처리하는 리포지토리도 애그리거트 단위로 존재한다. 새로운 애그리거트를 만들면 저장소에 애그리거트를 영속화하고 애그리거트를 사용하려면 저장소에서 애그리거트를 읽어 와야 하기 때문에 리포지토리는 기본적으로 아래 2개의 메서드를 제공한다.</p>
<ul>
<li><code>save()</code>: 애그리거트를 저장</li>
<li><code>findById()</code>: ID로 애그리거트를 조회</li>
</ul>
<p>알다시피 리포지토리를 구현하는 기술들은 정말 다양하다. 어떤 기술을 채택하느냐에 따라 리포지토리 구현 방식도 달라진다. 그리고 리포지토리 구현체는 애그리거트 루트뿐만 아니라 애그리거트에 속한 구성요소까지 함께 저장하고 조회해야 한다. 만약 <code>Order</code> 애그리거트 루트와 그와 관련된 테이블이 여러 개가 있다면 루트 엔티티뿐만 아니라 나머지 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다. </p>
<pre><code class="language-java">// 애그리거트 전체를 영속화
orderRepository.save(order);

// 완전한 주문 애그리거트를 조회
Order order = orderRepository.findById(orderId);</code></pre>
<p>&nbsp;</p>
<h1 id="🆔-id를-이용한-애그리거트-참조">🆔 ID를 이용한 애그리거트 참조</h1>
<p>아까 봤다시피 하나의 애그리거트도 다른 애그리거트를 참조할 수 있다. 더 정확히 말하면, 다른 애그리거트의 루트 엔티티를 참조하는 것이다. 앞에서 본 예시 코드처럼 애그리거트 간의 참조는 필드를 통해 쉽게 구현할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9266b8b1-1081-4cb8-b6c0-98b3771d206d/image.png" alt=""></p>
<p>이처럼 애그리거트 간의 참조를 구현하는 것은 쉽지만 몇 가지 문제들이 발생할 수 있다는 것을 유념해야 한다.</p>
<ul>
<li>편한 탐색 오용</li>
<li>성능에 대한 고민</li>
<li>확장 어려움</li>
</ul>
<p>&nbsp;</p>
<p>일단 첫 번째로, 한 애그리거트가 관리하는 범위는 자기 자신으로 한정하는 것이 가장 좋다고 했다. 하지만 구현이 워낙 편리하기 때문에 다른 애그리거트를 수정하고자 하는 유혹에 빠지기 쉽다. 또한 애그리거트 간의 결합도가 높아져 애그리거트의 변경을 어렵게 만든다. </p>
<p>두 번째 문제는 성능이다. 애그리거트를 객체로 직접 참조하면 JPA 같은 ORM을 사용할 때 지연 로딩과 즉시 로딩 중 무엇을 선택할지 고민해야 한다. 잘못 선택하면 불필요한 쿼리가 많이 실행되거나, 반대로 필요하지 않은 객체까지 한 번에 조회하는 문제가 생길 수 있다.</p>
<p>마지막으로, 확장에 대한 문제다. 서비스가 성장하고 사용자 수가 늘면 자연스럽게 부하를 분산하기 위해 하위 도메인별로 시스템을 분리해야 할 것이다. 이 과정에서 하위 도메인마다 각기 다른 DBMS를 사용하거나 아예 다른 데이터 저장소를 사용할 수도 있다. 이렇게 되면 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술은 사용할 수 없게 된다.</p>
<p>&nbsp;</p>
<p>위와 같은 문제점들을 한 번에 해결할 수 있는 방법이 바로 ID를 이용해서 다른 애그리거트를 참조하는 방법이다. 아래 다이어그램을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7f7987b6-0296-4be0-96a3-164604474bed/image.png" alt=""></p>
<p>보다시피 ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결되기 때문에 모델의 복잡도를 낮추고 각 애그리거트의 응집도도 높여줄 수 있다. 추가로 구현 복잡도도 낮아진다. 이제 직접적으로 참조하지 않기 때문에 JPA를 예로 들면 애그리거트 간의 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 더 이상 고민하지 않아도 된다. 참조하는 애그리거트가 필요하다면 그냥 응용 서비스에서 ID로 로딩하면 된다.</p>
<pre><code class="language-java">public class ChangeOrderService {

    ...

    @Transactional
    public void changeShippingInfo(
            OrderId orderId,
            ShippingInfo newShippingInfo,
            boolean useNewShippingAddressAsMemberAddress
    ) {
        Order order = orderRepository.findById(orderId);
        if (order == null) {
            throw new OrderNotFoundException();
        }
        order.changeShippingInfo(newShippingInfo);

        if (useNewShippingAddressAsMemberAddress) {
            // ID를 이용해서 참조하는 애그리거트를 구한다.
            Member member = memberRepository.findById(
                    order.getOrderer().getMemberId()
            );
            member.changeAddress(newShippingInfo.getAddress());
        }
    }

    ...
}</code></pre>
<p>&nbsp;</p>
<h2 id="⚙️-조회-성능">⚙️ 조회 성능</h2>
<p>하지만 다른 애그리거트를 ID로 참조하게 되면 여러 애그리거트를 읽을 때 조회 속도가 저하될 수도 있다. 예를 들어 주문 목록을 보여주려면 상품 애그리거트와 회원 애그리거트를 같이 읽어 와야 할 것이다. 아래 코드를 보자.</p>
<pre><code class="language-java">...

Member member = memberRepository.findById(ordererId);

List&lt;Order&gt; orders = orderRepository.findByOrderer(ordererId);
List&lt;OrderView&gt; dtos = orders.stream()
        .map(order -&gt; {
            ProductId productId = order.getOrderLines().get(0).getProductId();

            // 각 주문마다 첫 번째 주문 상품 정보 로딩을 위한 쿼리 실행
            Product product = productRepository.findById(productId);

            return new OrderView(order, member, product);
        })
        .collect(Collectors.toList());

 ...</code></pre>
<p>만약 주문이 10개면 주문을 읽어오기 위한 1번의 쿼리와 주문별로 각 상품을 읽어오기 위한 10번의 쿼리를 실행해야 한다. 이게 그 유명한 <strong>N + 1 조회 문제</strong>다. 이는 더 많은 쿼리를 날리기 때문에 당연히 성능을 저하시킬 수밖에 없다. </p>
<p>이 문제를 해결하려면 조회 목적에 맞는 전용 쿼리를 사용하는 것이 좋다. 객체 참조와 즉시 로딩으로 해결할 수도 있지만, 이는 애그리거트 간 결합도를 높이고 필요하지 않은 데이터까지 함께 조회할 위험이 있다. </p>
<p>따라서 목록 화면처럼 여러 애그리거트의 데이터를 한 번에 보여줘야 하는 경우에는 조회 전용 DAO나 조회 전용 모델을 두고 조인 쿼리로 필요한 데이터만 읽어오는 방식이 더 적합하다. 예를 들어 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 1번의 쿼리로 필요한 데이터를 로딩하면 된다. 아래 특정 사용자의 주문 내역을 보여주는 코드를 보자.</p>
<pre><code class="language-java">@Repository
public class JpaOrderViewDao implements OrderViewDao {
    @PersistenceContext
    private EntityManager em;

    @Override
    public List&lt;OrderView&gt; selectByOrderer(String ordererId) {
        String selectQuery =
                &quot;select new com.myshop.order.application.dto.OrderView(o, m, p) &quot; + 
                        &quot;from Order o join o.orderLines ol, Member m, Product p &quot; +
                        &quot;where o.orderer.memberId.id = :ordererId &quot; +
                        &quot;and o.orderer.memberId = m.id &quot; +
                        &quot;and index(ol) = 0 &quot; +
                        &quot;and ol.productId = p.id &quot; +
                        &quot;order by o.number.number desc&quot;;
        TypedQuery&lt;OrderView&gt; query =
                em.createQuery(selectQuery, OrderView.class);
        query.setParameter(&quot;ordererId&quot;, ordererId);
        return query.getResultList();
    }
}</code></pre>
<p>이 JPQL은 <code>Order</code> 애그리거트와 <code>Member</code> 애그리거트, <code>Product</code> 애그리거트를 조인으로 조회해서 1번의 쿼리로 로딩한다. 따라서 즉시 로딩이든 지연 로딩이든 상관없이 조회 화면에서 필요한 애그리거트 데이터를 1번의 쿼리로 로딩할 수 있는 것이다. </p>
<p>&nbsp;</p>
<h1 id="⛓️-애그리거트-간-집합-연관">⛓️ 애그리거트 간 집합 연관</h1>
<p>이제 애그리거트 간의 일대다 관계, 다대다 관계에 대해 알아보자. 이 두 연관은 <strong>컬렉션(Collection)</strong>을 이용한 연관이다. 일단 애그리거트 간의 일대다 관계로, 하나의 카테고리와 그에 연관된 상품을 값으로 갖는 컬렉션을 필드로 아래와 같이 정의할 수 있다.</p>
<pre><code class="language-java">public class Category {

    private Set&lt;Product&gt; products;

    ...
}</code></pre>
<p>&nbsp;</p>
<p>근데 개념적으로 존재하는 애그리거트 간의 일대다 관계를 실제 구현에 반영하는 것이 요구사항을 충족하는 것과는 상관없을 때가 있다. 특정 카테고리에 속한 상품 목록을 보여주는 요구사항을 생각해보자. 보통 목록과 관련된 요구사항은 한 번에 모든 정보를 보여주기보다는 페이징 기법을 이용해 요소들을 나눠서 보여준다. 이 기능을 카테고리 입장에서 일대다 관계를 이용해서 구현하면 아래와 같이 코드를 작성할 수 있다. </p>
<pre><code class="language-java">public class Category {

    private Set&lt;Product&gt; products;

    public List&lt;Product&gt; getProducts(int page, int size) {
        List&lt;Product&gt; sortedProducts = sortById(products);
        return sortedProducts.subList((page - 1) * size, page * size);
    }

    ...
}</code></pre>
<p>하지만 이 코드를 실제 DB와 연동한다면 카테고리에 있는 모든 상품들이 다 딸려 나온다. 그러면 성능에 아주 심각한 문제가 생긴다. 따라서 개념적으로는 애그리거트 간에 일대다 연관이 있더라도 실제 구현에 반영하지는 않는다. 카테고리에 속한 상품 목록이 필요하다면, 카테고리 애그리거트가 상품 컬렉션을 직접 들고 있기보다 상품 조회용 리포지토리나 조회 전용 DAO를 통해 <code>categoryId</code> 조건으로 페이징 조회하는 편이 더 적절하다. </p>
<p>다대다 연관도 일대다 연관과 마찬가지로, 실제 요구사항을 고려해서 구현에 포함할지를 결정해야 한다. 카테고리와 상품을 예로 들면, 개념적으로는 상품과 카테고리 사이에 양방향 다대다 관계가 존재할 수 있다. 하지만 실제 구현에서는 요구사항에 필요한 방향만 반영하면 된다.</p>
<p>예를 들어 상품 상세 화면에서 상품이 속한 카테고리 정보만 필요하다면, 상품에서 카테고리 ID 목록을 참조하거나 별도 조회 쿼리로 상품과 카테고리 관계를 읽어오는 방식으로 충분할 수 있다. 반대로 특정 카테고리에 속한 상품 목록이 필요하다면, 카테고리가 상품 컬렉션을 직접 들고 있기보다 상품 조회용 DAO나 조회 전용 쿼리를 통해 페이징해서 조회하는 편이 더 적절하다.</p>
<p>&nbsp;</p>
<h1 id="🏭-애그리거트를-팩토리로-사용하기">🏭 애그리거트를 팩토리로 사용하기</h1>
<p>고객이 특정 상점을 신고해서 해당 상점이 더 이상 물건을 등록하지 못하는 상황을 생각해보자. 상품 등록 기능을 구현한 응용 서비스는 상점 계정이 차단 상태가 아닌 경우에만 상품을 등록할 수 있도록 로직을 구현해야 할 것이다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class RegisterProductService {

    ...

    public ProductId registerNewProduct(NewProductRequest request) {
        Store store = storeRepository.findById(request.getStoreId());
        checkNull(store);
        if (store.isBlocked()) {
            throw new StoreBlockedException();
        }
        ProductId productId = productRepository.nextId();
        Product product = new Product(productId, store.getId(), ...);
        productRepository.save(product);
        return productId;
    }

    ...
}</code></pre>
<p>겉으로 보기에는 문제가 없어 보이지만, <code>Store</code>가 상품을 생성할 수 있는지 여부를 검사하고 상품을 생성하는 로직은 분명 논리적으로 하나의 도메인 기능인데 현재 응용 서비스에서 구현하고 있는 것이다. 해당 도메인 기능을 별도의 도메인 서비스나 팩토리 클래스를 만들 수도 있지만 아래와 같이 <code>Store</code> 애그리거트에 구현하는 방법도 있다. </p>
<pre><code class="language-java">public class Store {
    ...

    public Product createProduct(ProductId newProductId, ...) {
        if (isBlocked()) {
            throw new StoreBlockedException();
        }
        return new Product(newProductId, getId(), ...);
    }
}</code></pre>
<p><code>Store</code> 애그리거트의 <code>createProduct()</code> 메서드는 <code>Product</code> 애그리거트를 생성하는 팩토리 역할을 하면서도 중요한 도메인 로직을 구현하고 있다. 이제 응용 서비스에서 해당 팩토리 기능을 사용하기만 하면 된다.</p>
<pre><code class="language-java">public class RegisterProductService {
    ...

    public ProductId registerNewProduct(NewProductRequest request) {
        Store store = storeRepository.findById(request.getStoreId());
        checkNull(store);
        ProductId productId = productRepository.nextId();
        Product product = store.createProduct(productId, ...);
        productRepository.save(product);
        return productId;
    }

    ...
}</code></pre>
<p>이제 상품을 생성할 수 있는지 여부를 검사하는 도메인 로직에 변경이 일어나더라도 응용 서비스는 전혀 영향을 받지 않기 때문에 도메인의 응집도가 높아진 것이다. 이게 바로 애그리거트를 팩토리로 사용할 때의 장점이다. 따라서 앞으로 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 이런 식으로 애그리거트에 팩토리 메서드를 구현하는 것을 고려하도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Stream 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/Java-Stream-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-vm4fqdrr</link>
            <guid>https://velog.io/@rocker_nun/Java-Stream-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-vm4fqdrr</guid>
            <pubDate>Fri, 01 May 2026 08:51:13 GMT</pubDate>
            <description><![CDATA[<h1 id="↩️-외부-반복자에서-내부-반복자로">↩️ 외부 반복자에서 내부 반복자로</h1>
<p>자바에서 컬렉션을 다룰 때 우리는 보통 <code>for</code>, <code>while</code>, <code>Iterator</code>를 사용해왔다. 예를 들어 숫자 리스트에서 짝수만 출력한다고 해보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

for (int number : numbers) {
    if (number % 2 == 0) {
        System.out.println(number);
    }
}</code></pre>
<p>이 코드는 굉장히 익숙하다. 리스트에서 값을 하나씩 꺼내고, 조건을 검사하고, 조건에 맞으면 출력한다.</p>
<pre><code class="language-text">1 꺼냄 → 짝수인지 검사
2 꺼냄 → 짝수인지 검사 → 출력
3 꺼냄 → 짝수인지 검사
4 꺼냄 → 짝수인지 검사 → 출력
5 꺼냄 → 짝수인지 검사</code></pre>
<p>이처럼 개발자가 컬렉션 바깥에서 직접 요소를 꺼내며 반복을 제어하는 방식을 <strong>외부 반복자</strong>라고 한다.</p>
<p>&nbsp;</p>
<p>반면 Java 8부터 자주 사용하게 된 <code>Stream</code>은 조금 다른 방식으로 컬렉션을 처리한다.</p>
<pre><code class="language-java">numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .forEach(number -&gt; System.out.println(number));</code></pre>
<p>이 코드에는 <code>for</code>, <code>while</code>, <code>Iterator</code>가 보이지 않는다. 그렇다고 반복이 사라진 것이 아니라 반복은 여전히 일어난다. 다만 개발자가 직접 반복을 제어하지 않을 뿐이다. 스트림 내부에서 반복이 일어나고, 개발자는 각 요소에 적용할 처리 규칙만 람다식으로 전달한다. 이것이 바로 <strong>내부 반복자</strong> 방식이다.</p>
<p>&nbsp;</p>
<h1 id="💉-스트림을-비유로-이해하기">💉 스트림을 비유로 이해하기</h1>
<p>스트림을 처음 접하면 이런 코드가 낯설게 느껴진다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();</code></pre>
<p><code>stream()</code>은 무엇이고, <code>filter()</code>는 무엇이며, <code>map()</code>은 무엇이고, 마지막에 <code>toList()</code>는 왜 필요한 걸까? 이걸 다음과 같이 비유해서 이해해보자.</p>
<pre><code class="language-text">내 몸 = 컬렉션
주사기 = 스트림
주사하는 것 = 람다식으로 전달하는 처리 규칙
중간 처리기 = filter, map, sorted
추출기 = forEach, sum, collect, toList</code></pre>
<p>여기서 중요한 점은 <strong>스트림이 원본 컬렉션을 직접 바꾸는 도구가 아니라는 것</strong>이다. 스트림은 컬렉션 안의 데이터를 하나씩 흘려보내면서, 중간중간 람다식으로 전달한 처리 규칙을 적용하고, 마지막에 원하는 결과를 꺼내 쓰는 방식이다. 즉, 스트림을 정리하면 다음과 같다.</p>
<blockquote>
<p><em><strong>&quot;스트림은 컬렉션의 원본 데이터를 직접 수정하지 않고, 요소들을 하나씩 흘려보내며 처리한 뒤, 최종 연산을 통해 원하는 결과를 꺼내 쓰는 데이터 처리 방식이다.&quot;</strong></em></p>
</blockquote>
<p>&nbsp;</p>
<h1 id="🎭-컬렉션은-원본-데이터다">🎭 컬렉션은 원본 데이터다</h1>
<p>먼저 컬렉션이 있다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);</code></pre>
<p>위 <code>numbers</code>는 원본 데이터다. 비유하자면 내 몸 안에 있는 데이터라고 볼 수 있다.</p>
<pre><code class="language-text">내 몸 = 컬렉션
몸 안의 요소들 = 컬렉션 안의 데이터</code></pre>
<p>&nbsp;</p>
<p><code>numbers</code> 안에는 1, 2, 3, 4, 5라는 값이 들어 있다. 이제 이 컬렉션을 스트림으로 바꿔보면...</p>
<pre><code class="language-java">Stream&lt;Integer&gt; stream = numbers.stream();</code></pre>
<p>위와 같이 <code>stream()</code>을 호출했다고 해서 원본 리스트가 바뀌는 것은 아니다. <code>numbers</code>가 사라지는 것도 아니고, 새로운 리스트가 바로 만들어지는 것도 아니다. 단지 컬렉션 안의 요소들을 하나씩 처리할 수 있는 <strong>흐름</strong>을 만든 것이다.</p>
<blockquote>
<p><em><strong>&quot;numbers.stream(): numbers 안의 요소들을 하나씩 흘려보낼 수 있는 통로를 만든다 == 빨대를 꽂는다(강사님 피셜)&quot;</strong></em></p>
</blockquote>
<p>&nbsp;</p>
<h1 id="🌊-스트림은-결과물이-아니라-흐름이다">🌊 스트림은 결과물이 아니라 흐름이다</h1>
<p>스트림을 처음 배울 때 가장 헷갈리는 부분이 있었는데, 바로 스트림 자체를 결과물처럼 생각하는 것이다. 예를 들어 다음 코드를 보자.</p>
<pre><code class="language-java">numbers.stream().filter(number -&gt; number % 2 == 0);</code></pre>
<p>이 코드는 짝수만 골라낸 것처럼 보인다. 하지만 이 시점에서 아직 <code>List&lt;Integer&gt;</code> 결과가 만들어진 것은 아니다. 찍어보면 위 코드의 타입은 여전히 <code>Stream&lt;Integer&gt;</code>인 것을 확인할 수 있다. 즉, 결과 리스트가 아니라 스트림이다.</p>
<p>주사기 비유로 마저 설명해보자면 대략 아래와 느낌이다.</p>
<ol>
<li>주사기로 데이터를 뽑았는데,</li>
<li>아직 결과물을 꺼낸 것이 아니라</li>
<li>처리 규칙이 연결된 주사기 상태로 들고 있는 것이다.</li>
</ol>
<p>따라서 <em>&quot;<code>filter()</code> 처리했으니까 필터링된 결과가 리스트로 담겼겠네?&quot;</em> 라는 이해는 완전히 잘못된 것이다. <code>filter</code>, <code>map</code>, <code>sorted</code> 같은 중간 연산은 결과물을 바로 꺼내주지 않는다. 대부분 다시 <code>Stream</code>을 반환한다.</p>
<pre><code class="language-java">numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10);</code></pre>
<p>위 코드도 아직 최종 결과가 아니라 <code>numbers</code>에서 요소를 흘려보내는데, 짝수만 통과시키고, 통과한 값을 10배한 값으로 바꾼 처리 규칙이 명시된 스트림일 뿐이다. 따라서 <strong>결과물을 실제로 꺼내려면 마지막에 최종 연산이 필요</strong>하다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();</code></pre>
<p>위와 같이 작성해야 결과가 리스트로 나온다. 정리하면 중간 연산만 연결했다면 여전히 <code>Stream</code> 타입이고 최종 연산을 해야 비로소 실제 결과물을 얻을 수 있다는 것이다.</p>
<p>&nbsp;</p>
<h1 id="📝-람다식은-스트림에-전달하는-처리-규칙이다">📝 람다식은 스트림에 전달하는 처리 규칙이다</h1>
<p>스트림에서는 람다식이 자주 등장한다. 예를 들어 다음 코드를 보자.</p>
<pre><code class="language-java">.filter(number -&gt; number % 2 == 0)</code></pre>
<p>위 코드에서 람다식은 <code>number -&gt; number % 2 == 0</code> 부분인데, 이 람다식은 <em>&quot;숫자 하나를 받아서 짝수인지 검사하라&quot;</em> 와 같은 의미다.</p>
<p>스트림은 컬렉션의 요소를 하나씩 흘려보내면서 이 람다식을 적용하는 것이다.</p>
<ul>
<li>1 들어옴 → 1 % 2 == 0 → false → 버림</li>
<li>2 들어옴 → 2 % 2 == 0 → true  → 통과</li>
<li>3 들어옴 → 3 % 2 == 0 → false → 버림</li>
<li>4 들어옴 → 4 % 2 == 0 → true  → 통과</li>
<li>5 들어옴 → 5 % 2 == 0 → false → 버림</li>
</ul>
<p>결국 2와 4만 통과하게 되는 것이다.</p>
<p>&nbsp;</p>
<p>이번에는 <code>map</code>을 보자.</p>
<pre><code class="language-java">.map(number -&gt; number * 10)</code></pre>
<p>여기서 람다식은 <code>number -&gt; number * 10</code>인데 숫자 하나를 받아서 10배로 바꾸라는 같은 의미를 갖는다. 따라서 앞에서 <code>filter</code>를 통과한 2, 4는 <code>map</code>을 지나면서 각각 20과 40으로 바뀌는 것이다. 즉, 람다식은 스트림에게 전달하는 처리 규칙이다.</p>
<p>&nbsp;</p>
<ul>
<li><code>filter()</code>에 전달하는 람다식: 어떤 요소를 통과시킬지 판단하는 규칙</li>
<li><code>map()</code>에 전달하는 람다식: 요소를 어떤 값으로 바꿀지 정하는 규칙</li>
<li><code>forEach()</code>에 전달하는 람다식: 요소를 어떻게 소비할지 정하는 규칙</li>
</ul>
<p>&nbsp;</p>
<h1 id="🧩-중간-연산-filter-map-sorted">🧩 중간 연산: filter, map, sorted</h1>
<p>스트림에는 크게 중간 연산, 최종 연산이 있다. 먼저 중간 연산부터 보자.</p>
<p>중간 연산은 스트림을 받아서 다시 스트림을 반환하는 연산이다. 대표적으로 다음과 같은 것들이 있다.</p>
<ul>
<li><code>filter()</code></li>
<li><code>map()</code></li>
<li><code>sorted()</code></li>
<li><code>distinct()</code></li>
<li><code>limit()</code></li>
<li><code>skip()</code></li>
</ul>
<p>&nbsp;</p>
<p>중간 연산의 특징은 다음과 같다.</p>
<ol>
<li>최종 결과를 바로 만들지 않는다.</li>
<li>대부분 <code>Stream</code>을 다시 반환한다.</li>
<li>여러 개를 연결해서 사용할 수 있다.</li>
<li>최종 연산이 호출되기 전까지 실제로 실행되지 않는다.</li>
</ol>
<p>&nbsp;</p>
<h2 id="📌-filter-조건에-맞는-요소만-통과시킨다">📌 filter(): 조건에 맞는 요소만 통과시킨다</h2>
<p><code>filter</code>는 조건에 맞는 요소만 통과시킨다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .toList();

System.out.println(result);

/**
 * [2, 4]
 */</code></pre>
<p><code>filter</code>에 전달된 람다식은 <code>number -&gt; number % 2 == 0</code>로 각 요소에 대해 <code>true</code> 또는 <code>false</code>를 반환하고 <code>true</code>는 통과, <code>false</code>는 제외시킨다. 따라서 <code>filter</code>는 조건문과 비슷한 역할을 한다.</p>
<p>외부 반복자로 작성하면 다음과 같다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = new ArrayList&lt;&gt;();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number);
    }
}</code></pre>
<p>스트림으로 작성하면 다음과 같다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .toList();</code></pre>
<p>&nbsp;</p>
<h2 id="⛏️-map-요소를-다른-값으로-변환한다">⛏️ map(): 요소를 다른 값으로 변환한다</h2>
<p><code>map</code>은 스트림을 지나가는 요소를 다른 값으로 변환한다.</p>
<pre><code class="language-java">List&lt;String&gt; names = List.of(&quot;kim&quot;, &quot;lee&quot;, &quot;park&quot;);

List&lt;String&gt; result = names.stream()
        .map(name -&gt; name.toUpperCase())
        .toList();

System.out.println(result);

/**
 * [KIM, LEE, PARK]
 */</code></pre>
<p>&nbsp;</p>
<p>원본 리스트는 바뀌지 않는다.</p>
<pre><code class="language-java">System.out.println(names);  // [kim, lee, park]</code></pre>
<p>즉, <code>map</code>은 원본 데이터를 직접 수정하는 것이 아니다. 스트림을 지나가는 값을 새로운 값으로 변환해서 다음 단계로 넘긴다.</p>
<pre><code class="language-text">&quot;kim&quot;  → &quot;KIM&quot;
&quot;lee&quot;  → &quot;LEE&quot;
&quot;park&quot; → &quot;PARK&quot;</code></pre>
<p>&nbsp;</p>
<p>객체 리스트에서도 자주 사용한다.</p>
<pre><code class="language-java">List&lt;Student&gt; students = List.of(
        new Student(&quot;Kim&quot;, 80),
        new Student(&quot;Lee&quot;, 90),
        new Student(&quot;Park&quot;, 70)
);

List&lt;String&gt; names = students.stream()
        .map(student -&gt; student.getName())
        .toList();

/**
 * Student(&quot;Kim&quot;, 80)  → &quot;Kim&quot;
 * Student(&quot;Lee&quot;, 90)  → &quot;Lee&quot;
 * Student(&quot;Park&quot;, 70) → &quot;Park&quot;
 */</code></pre>
<p>위와 같이 <code>Student</code> 객체에서 이름만 뽑아 새로운 리스트를 만든다.</p>
<p>&nbsp;</p>
<h2 id="⛓️-sorted-요소를-정렬한다">⛓️ sorted(): 요소를 정렬한다</h2>
<p><code>sorted</code>는 스트림의 요소를 정렬한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(5, 3, 1, 4, 2);

List&lt;Integer&gt; result = numbers.stream()
        .sorted()
        .toList();

System.out.println(numbers);
System.out.println(result);

/**
 * [5, 3, 1, 4, 2]
 * [1, 2, 3, 4, 5]
 */</code></pre>
<p>여기도 마찬가지로 원본 리스트가 바뀌지 않는다. <code>sorted()</code>는 원본 컬렉션을 직접 정렬하는 것이 아니라 스트림을 지나가는 요소들을 정렬된 흐름으로 만들어주고, 최종 연산을 통해 새로운 결과를 꺼내는 것이다.</p>
<p>객체를 정렬할 때는 <code>Comparator</code>를 함께 사용할 수 있다.</p>
<pre><code class="language-java">List&lt;Student&gt; students = List.of(
        new Student(&quot;Kim&quot;, 80),
        new Student(&quot;Lee&quot;, 90),
        new Student(&quot;Park&quot;, 70)
);

List&lt;Student&gt; result = students.stream()
        .sorted(Comparator.comparing(Student::getScore))
        .toList();</code></pre>
<p>이 코드는 학생들을 점수 기준으로 오름차순 정렬한 결과를 새 리스트로 만든다.</p>
<p>&nbsp;</p>
<h1 id="🎊-최종-연산-foreach-sum-collect-tolist">🎊 최종 연산: forEach, sum, collect, toList</h1>
<p>중간 연산만으로는 결과를 꺼낼 수 없다. 스트림에서 결과를 꺼내려면 반드시 최종 연산이 필요하다. 대표적인 최종 연산은 다음과 같다.</p>
<ul>
<li><code>forEach()</code></li>
<li><code>count()</code></li>
<li><code>sum()</code></li>
<li><code>average()</code></li>
<li><code>collect()</code></li>
<li><code>toList()</code></li>
<li><code>anyMatch()</code></li>
<li><code>allMatch()</code></li>
<li><code>findFirst()</code></li>
</ul>
<p>최종 연산의 특징은 다음과 같다.</p>
<ol>
<li>스트림을 실제로 실행시킨다.</li>
<li>결과를 반환하거나 소비한다.</li>
<li>최종 연산 이후 스트림은 다시 사용할 수 없다.</li>
</ol>
<p>&nbsp;</p>
<h2 id="🥪-foreach-요소를-하나씩-소비한다">🥪 forEach(): 요소를 하나씩 소비한다</h2>
<p><code>forEach</code>는 스트림의 요소를 하나씩 꺼내서 소비한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .forEach(number -&gt; System.out.println(number));

/**
 * 2
 * 4
 */</code></pre>
<p><code>forEach</code>는 최종 연산이다. 하지만 결과를 리스트로 모아서 반환하지는 않는다. 각 요소를 하나씩 소비하고 끝낸다. 그래서 <code>forEach</code>는 보통 출력, 로그 확인, 특정 동작 수행 등에 사용된다.</p>
<p>&nbsp;</p>
<h2 id="🗃️-tolist-결과를-리스트로-모은다">🗃️ toList(): 결과를 리스트로 모은다</h2>
<p><code>toList</code>는 스트림의 결과를 리스트로 모은다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();

System.out.println(result);

// [20, 40]</code></pre>
<p>&nbsp;</p>
<p>흐름을 풀어보면 다음과 같다.</p>
<pre><code class="language-text">원본 리스트: [1, 2, 3, 4, 5]

stream()
→ 요소를 하나씩 흘려보낼 준비

filter(number -&gt; number % 2 == 0)
→ 짝수만 통과
→ 2, 4

map(number -&gt; number * 10)
→ 통과한 값을 10배로 변환
→ 20, 40

toList()
→ 결과를 List로 추출
→ [20, 40]</code></pre>
<p>&nbsp;</p>
<h2 id="🔍-collect-원하는-형태로-결과를-수집한다">🔍 collect(): 원하는 형태로 결과를 수집한다</h2>
<p><code>collect</code>는 스트림의 결과를 원하는 자료구조나 형태로 모을 때 사용한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .collect(Collectors.toList());</code></pre>
<p>&nbsp;</p>
<p>Java 16 이후에는 단순히 리스트로 모을 때 <code>toList()</code>를 자주 사용할 수 있다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .toList();</code></pre>
<p>&nbsp; </p>
<p>하지만 <code>collect</code>는 더 다양한 수집 작업을 할 수 있다. 예를 들어 이름들을 하나의 문자열로 합칠 수 있다.</p>
<pre><code class="language-java">List&lt;String&gt; names = List.of(&quot;Kim&quot;, &quot;Lee&quot;, &quot;Park&quot;);

String result = names.stream()
        .collect(Collectors.joining(&quot;, &quot;));

System.out.println(result);  // Kim, Lee, Park</code></pre>
<p>또는 그룹화도 할 수 있다.</p>
<pre><code class="language-java">Map&lt;Integer, List&lt;Student&gt;&gt; result = students.stream()
        .collect(Collectors.groupingBy(Student::getGrade));</code></pre>
<p>즉, <code>collect</code>는 스트림의 결과를 원하는 방식으로 수집하는 강력한 최종 연산이다.</p>
<p>&nbsp;</p>
<h2 id="✚-sum-숫자-값을-합산한다">✚ sum(): 숫자 값을 합산한다</h2>
<p><code>sum</code>은 숫자 스트림에서 합계를 구할 때 사용한다. 일반 <code>Stream&lt;Integer&gt;</code>에서는 바로 <code>sum()</code>을 사용할 수 없다. 보통 <code>mapToInt</code>를 사용해서 <code>IntStream</code>으로 바꾼 뒤 사용한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

int sum = numbers.stream()
        .mapToInt(number -&gt; number)
        .sum();

System.out.println(sum);  // 15</code></pre>
<p>&nbsp;</p>
<p>객체 리스트에서도 자주 사용한다.</p>
<pre><code class="language-java">List&lt;Student&gt; students = List.of(
        new Student(&quot;Kim&quot;, 80),
        new Student(&quot;Lee&quot;, 90),
        new Student(&quot;Park&quot;, 70)
);

int totalScore = students.stream()
        .mapToInt(student -&gt; student.getScore())
        .sum();

System.out.println(totalScore);</code></pre>
<p>흐름은 다음과 같다.</p>
<pre><code class="language-text">Student 객체들이 지나간다.
→ 각 Student에서 score만 뽑는다.
→ int 값들의 흐름이 된다.
→ sum()으로 합산한다.</code></pre>
<p>&nbsp;</p>
<h1 id="🚫-중간-연산은-최종-연산이-호출되기-전까지-실행되지-않는다">🚫 중간 연산은 최종 연산이 호출되기 전까지 실행되지 않는다</h1>
<p>스트림에서 중요한 특징 중 하나는 <strong>지연 실행(Lazy Evaluation)</strong>이다. 중간 연산은 최종 연산이 호출되기 전까지 실제로 실행되지 않는다. 다음 코드를 보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

Stream&lt;Integer&gt; stream = numbers.stream()
        .filter(number -&gt; {
            System.out.println(&quot;filter 실행: &quot; + number);
            return number % 2 == 0;
        });</code></pre>
<p>이 코드를 실행해도 아무것도 출력되지 않는다. 왜냐하면 아직 최종 연산이 없기 때문이다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">stream.toList();</code></pre>
<p>이렇게 최종 연산을 호출해야 그때 실제로 실행된다.</p>
<pre><code class="language-text">filter 실행: 1
filter 실행: 2
filter 실행: 3
filter 실행: 4
filter 실행: 5</code></pre>
<p>&nbsp;</p>
<p>즉, 스트림의 중간 연산은 바로 실행되는 것이 아니라, 최종 연산이 호출될 때 한 번에 동작한다. 비유하자면 다음과 같다.</p>
<pre><code class="language-text">filter, map, sorted를 연결하는 것은
주사기에 처리 장치를 연결해두는 것과 비슷하다.

하지만 아직 실제로 뽑아낸 것은 아니다.

toList, collect, forEach 같은 최종 연산을 해야
비로소 데이터가 흐르면서 처리되고 결과가 나온다.</code></pre>
<p>&nbsp;</p>
<h1 id="😢-스트림은-한-번-사용하면-다시-사용할-수-없다">😢 스트림은 한 번 사용하면 다시 사용할 수 없다</h1>
<p>스트림은 한 번 최종 연산을 수행하면 다시 사용할 수 없다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

Stream&lt;Integer&gt; stream = numbers.stream();

stream.forEach(System.out::println);

stream.forEach(System.out::println);  // 예외 발생</code></pre>
<p>두 번째 <code>forEach</code>에서는 예외가 발생한다. 스트림은 일회용이다. 한 번 최종 연산으로 소비되면 다시 사용할 수 없다. 다시 사용하고 싶다면 원본 컬렉션에서 스트림을 새로 만들어야 한다.</p>
<pre><code class="language-java">numbers.stream().forEach(System.out::println);
numbers.stream().forEach(System.out::println);</code></pre>
<p>&nbsp;</p>
<p>비유하면 다음과 같다.</p>
<pre><code class="language-text">한 번 사용한 주사기는 버리고 새로운 주사기를 사용해야 한다.
다시 처리하고 싶다면 원본 컬렉션에서 새로운 스트림을 만들어야 한다는 말이다.</code></pre>
<p>&nbsp;</p>
<h1 id="🤔-외부-반복자란-무엇인가">🤔 외부 반복자란 무엇인가?</h1>
<p>이제 스트림을 외부 반복자와 내부 반복자 관점에서 다시 살펴보자. 그동안 컬렉션을 다룰 때 <code>for</code>, <code>while</code>, <code>Iterator</code>를 사용해왔다. 이 방식은 외부 반복자 방식이다. 외부 반복자는 개발자가 직접 컬렉션의 요소를 하나씩 꺼내며 반복을 제어하는 방식이다.</p>
<p>예를 들어 다음 코드를 보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = new ArrayList&lt;&gt;();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number * 10);
    }
}

System.out.println(result);</code></pre>
<p>&nbsp;</p>
<p>위 코드에서 개발자는 다음 작업을 직접 수행한다.</p>
<ol>
<li>새로운 리스트를 만든다.</li>
<li>numbers에서 숫자를 하나씩 꺼낸다.</li>
<li>짝수인지 검사한다.</li>
<li>짝수라면 10을 곱한다.</li>
<li>결과 리스트에 추가한다.</li>
<li>반복이 끝나면 결과 리스트를 사용한다.</li>
</ol>
<p>즉, 반복의 흐름을 개발자가 직접 제어한다. <code>Iterator</code>를 사용해도 마찬가지다.</p>
<pre><code class="language-java">Iterator&lt;Integer&gt; iterator = numbers.iterator();

while (iterator.hasNext()) {
    Integer number = iterator.next();

    if (number % 2 == 0) {
        result.add(number * 10);
    }
}</code></pre>
<p>여기서도 개발자는 직접 다음 요소가 있는지 확인하고, 직접 요소를 꺼낸다.</p>
<pre><code class="language-java">iterator.hasNext()
iterator.next()</code></pre>
<p>이처럼 컬렉션 바깥에서 개발자가 반복을 직접 제어하는 방식이 외부 반복자다.</p>
<p>&nbsp;</p>
<h1 id="🤔-내부-반복자란-무엇인가">🤔 내부 반복자란 무엇인가?</h1>
<p>내부 반복자는 반복의 제어권을 개발자가 직접 가지지 않고, 스트림 내부에 맡기는 방식이다. 앞의 코드를 스트림으로 바꿔보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();

System.out.println(result);</code></pre>
<p>&nbsp;</p>
<p>이 코드에서 개발자는 직접 요소를 꺼내지 않는다. 대신 스트림에게 다음과 같이 요청한다.</p>
<ol>
<li>numbers에서 스트림을 만들어줘.</li>
<li>짝수만 통과시켜줘.</li>
<li>통과한 값을 10배로 바꿔줘.</li>
<li>리스트로 모아줘.</li>
</ol>
<p>반복은 스트림 내부에서 일어난다. 개발자는 각 요소에 적용할 처리 규칙만 람다식으로 전달한다. 즉, 내부 반복자는 반복은 스트림 내부에 맡기고, 개발자는 처리 규칙만 전달하는 방식이라고 이해할 수 있다.</p>
<p>&nbsp;</p>
<p>외부 반복자와 내부 반복자의 차이점은 집중하는 관점이 다르다. </p>
<p><em><strong>&lt;외부 반복자&gt;</strong></em></p>
<ul>
<li>어떻게 반복할 것인가?</li>
<li>어떻게 요소를 꺼낼 것인가?</li>
<li>언제 결과 리스트에 추가할 것인가?</li>
</ul>
<p><em><strong>&lt;내부 반복자&gt;</strong></em></p>
<ul>
<li>무엇을 골라낼 것인가?</li>
<li>무엇으로 변환할 것인가?</li>
<li>어떤 결과로 모을 것인가?</li>
</ul>
<p>&nbsp;</p>
<p>따라서 스트림은 단순히 반복문을 짧게 쓰는 문법이 아니다. 스트림은 데이터 처리 과정을 더 선언적으로 표현하게 해주는 도구라고 할 수 있다.</p>
<p>&nbsp;</p>
<h1 id="👍-스트림의-장점">👍 스트림의 장점</h1>
<p>외부 반복자 방식은 반복 로직 때문에 핵심 의도가 묻힐 때가 있다.</p>
<pre><code class="language-java">List&lt;String&gt; result = new ArrayList&lt;&gt;();

for (Student student : students) {
    if (student.getScore() &gt;= 60) {
        result.add(student.getName());
    }
}</code></pre>
<p>이 코드는 다음 과정을 직접 따라가야 한다.</p>
<pre><code class="language-text">학생을 하나씩 꺼낸다.
점수가 60점 이상인지 검사한다.
조건에 맞으면 이름을 결과 리스트에 추가한다.</code></pre>
<p>&nbsp;</p>
<p>반면, 위와 코드를 똑같이 스트림으로 작성하면 의도가 더 직접적으로 드러난다.</p>
<pre><code class="language-java">List&lt;String&gt; result = students.stream()
        .filter(student -&gt; student.getScore() &gt;= 60)
        .map(student -&gt; student.getName())
        .toList();</code></pre>
<p>이 코드는 다음처럼 읽힌다.</p>
<pre><code class="language-text">60점 이상인 학생만 골라서
이름만 뽑고
리스트로 만든다.</code></pre>
<p>&nbsp;</p>
<p>두 번째 장점으로, 스트림은 여러 중간 연산을 연결해서 사용할 수 있다.</p>
<pre><code class="language-java">List&lt;String&gt; result = students.stream()
        .filter(student -&gt; student.getScore() &gt;= 60)
        .map(student -&gt; student.getName())
        .sorted()
        .toList();</code></pre>
<p>이 코드는 다음처럼 읽을 수 있다.</p>
<pre><code class="language-text">60점 이상인 학생만 고른다.
학생 객체에서 이름만 뽑는다.
이름을 정렬한다.
리스트로 만든다.</code></pre>
<p>각 단계가 파이프라인처럼 이어지기 때문에 데이터 처리 흐름을 파악하기 좋다.</p>
<p>&nbsp;</p>
<p>세 번째 장점으로는 스트림은 원본 데이터를 직접 변경하지 않는다는 것이다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(5, 3, 1, 4, 2);

List&lt;Integer&gt; result = numbers.stream()
        .sorted()
        .toList();

System.out.println(numbers);
System.out.println(result);

/*
 * [5, 3, 1, 4, 2]
 * [1, 2, 3, 4, 5]
 */</code></pre>
<p>원본 리스트는 그대로 유지되고, 정렬된 결과가 새롭게 만들어진다. 이 점은 데이터를 안전하게 다루는 데 도움이 된다.</p>
<p>&nbsp;</p>
<p>그리고 스트림은 내부 반복자 방식이라고 했다. 이 말은 반복의 제어권을 개발자가 직접 가지는 것이 아니라 스트림이 가진다는 말이고, 이 구조 덕분에 경우에 따라 병렬 처리를 적용하기 쉽다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.parallelStream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();</code></pre>
<p>물론 <code>parallelStream()</code>이 항상 좋은 것은 아니다. 데이터 양이 적거나, 처리 비용이 작거나, 순서가 중요하거나, 공유 자원을 변경하는 작업이 있다면 오히려 문제가 될 수 있다. 하지만 구조적으로 보면 내부 반복자는 반복 제어권을 라이브러리가 가지고 있기 때문에, 최적화나 병렬 처리에 유리한 여지가 생긴다.</p>
<p>&nbsp;</p>
<h1 id="🫣-스트림이-항상-정답은-아니다">🫣 스트림이 항상 정답은 아니다</h1>
<p>스트림이 편리하다고 해서 항상 스트림을 써야 하는 것은 아니다. 단순한 반복은 오히려 <code>for</code>문이 더 읽기 쉬울 수 있다.</p>
<pre><code class="language-java">for (int i = 0; i &lt; 5; i++) {
    System.out.println(i);
}</code></pre>
<p>이런 코드를 굳이 스트림으로 바꾸면 오히려 어색해질 수 있다.</p>
<p>또한 반복 중간에 복잡한 상태 변경이 필요하거나, <code>break</code>, <code>continue</code>처럼 흐름 제어가 중요한 경우에는 일반 반복문이 더 적합할 수 있다. 예를 들어 특정 학생을 찾으면 즉시 반복을 멈추고 싶다고 해보자.</p>
<pre><code class="language-java">for (Student student : students) {
    if (student.getName().equals(&quot;Kim&quot;)) {
        System.out.println(&quot;찾았다!&quot;);
        break;
    }
}</code></pre>
<p>스트림에도 <code>findFirst</code>, <code>anyMatch</code> 같은 최종 연산이 있지만, 복잡한 흐름 제어가 들어가면 오히려 코드가 읽기 어려워질 수 있다.</p>
<p>&nbsp;</p>
<p>따라서 기준은 다음과 같이 잡으면 좋다.</p>
<ul>
<li>단순 반복, 복잡한 흐름 제어가 필요하다 → for문이 나을 수 있다.</li>
<li>필터링, 변환, 정렬, 집계가 중심이다 → 스트림이 잘 어울린다.</li>
</ul>
<p>&nbsp;</p>
<h1 id="🖋️-정리">🖋️ 정리</h1>
<p>스트림을 처음 배울 때는 다음 세 가지를 반드시 기억해야 한다.</p>
<p>첫 번째, 스트림은 원본 컬렉션을 직접 변경하지 않는다.</p>
<pre><code class="language-text">원본 데이터는 그대로 두고,
처리 결과를 새롭게 만들어서 사용한다.</code></pre>
<p>&nbsp;</p>
<p>두 번째, 중간 연산만으로는 결과가 나오지 않는다.</p>
<pre><code class="language-text">filter, map, sorted는 중간 처리기다.
이 연산들은 대부분 다시 Stream을 반환한다.
따라서 최종 연산을 해야 결과를 꺼낼 수 있다.</code></pre>
<p>&nbsp;</p>
<p>세 번째, 스트림은 내부 반복자를 사용한다.</p>
<pre><code class="language-text">for, while, Iterator는 외부 반복자다.
개발자가 직접 요소를 꺼내며 반복을 제어한다.

Stream은 내부 반복자다.
반복은 스트림 내부에서 일어나고,
개발자는 람다식으로 처리 규칙만 전달한다.</code></pre>
<p>&nbsp;</p>
<p>결국 스트림은 다음과 같이 이해할 수 있다.</p>
<pre><code class="language-text">스트림은 컬렉션의 요소를 직접 꺼내던 외부 반복 방식에서 벗어나,
컬렉션 내부에서 요소를 흘려보내며
람다식으로 전달한 처리 규칙을 적용하고,
최종 연산으로 결과를 꺼내는 내부 반복 기반의 데이터 처리 방식이다.</code></pre>
<p>&nbsp;</p>
<p>처음 비유로 다시 돌아가면 다음과 같다.</p>
<pre><code class="language-text">내 몸 = 컬렉션
주사기 = 스트림
주사하는 것 = 람다식으로 전달하는 처리 규칙
중간 처리기 = filter, map, sorted
추출기 = forEach, sum, collect, toList</code></pre>
<p>&nbsp;</p>
<p>단, 스트림은 결과물이 담긴 주사기 자체가 아니다. 스트림은 데이터를 흘려보내며 처리하는 흐름이다. 따라서 중간 처리기만 연결해두면 아직 결과물이 아니라 <code>Stream</code> 타입일 뿐이다. 결과물을 사용하려면 반드시 <code>forEach</code>, <code>sum</code>, <code>collect</code>, <code>toList</code> 같은 최종 연산으로 꺼내야 한다. 이 관점을 잡으면 스트림 코드는 훨씬 자연스럽게 읽힌다.</p>
<p>스트림은 단순히 반복문을 짧게 쓰기 위한 문법이 아니다. 스트림은 컬렉션 데이터를 더 선언적으로, 더 조합하기 쉽게, 그리고 원본 데이터를 직접 변경하지 않는 방식으로 처리하기 위한 도구다.</p>
]]></description>
        </item>
    </channel>
</rss>