<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>꺄악 운석이다</title>
        <link>https://velog.io/</link>
        <description>멸종은 면하자</description>
        <lastBuildDate>Sun, 29 Jun 2025 08:02:35 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>꺄악 운석이다</title>
            <url>https://velog.velcdn.com/images/penrose_15/profile/e8606eb0-6ef5-413d-9308-95525978dd0d/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 꺄악 운석이다. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/penrose_15" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[2025 스프링 캠프(KSUG) 갔다 옴]]></title>
            <link>https://velog.io/@penrose_15/2025-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BA%A0%ED%94%84KSUG-%EA%B0%94%EB%8B%A4-%EC%98%A8-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/2025-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BA%A0%ED%94%84KSUG-%EA%B0%94%EB%8B%A4-%EC%98%A8-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Sun, 29 Jun 2025 08:02:35 GMT</pubDate>
            <description><![CDATA[<p><a href="https://springcamp.ksug.org/2025/">https://springcamp.ksug.org/2025/</a></p>
<p>이번년도에도 돌아온 스프링 캠프를 갔다 왔습니다.</p>
<p>그래서 작성하는 정리</p>
<h3 id="난-spring에서-ml서빙을-해봤어요">난 Spring에서 ML서빙을 해봤어요</h3>
<p>파이썬이 ML에 특화되어 있으나 파이썬 특성상 GIL으로 인해 병렬처리가 안된다는 단점이 있고 라이브러리 호환성 맞추기가 생각보다 어렵다는 문제점으로 JVM을 활용해 ML을 서빙했다는 내용이었다. </p>
<p>JVM에도 ONNX, TensorFlow, DJL... 등 사용할 수 있는 ML 라이브러리를 통해 구현을 했는데 </p>
<p>DJL은 ML에 대해 지식이 없어도 활용이 쉽고, 허깅 페이스를 통해 다양한 모델들을 선택할 수 있으나, 하드웨어를 극한까지 끌어써야 하는 ML 에서 튜닝이 어렵다는 단점이 있어 TensorFlow를 활용했다고 한다.</p>
<p>지금까지 ML하면 파이썬으로만 가능할 줄 알았는데 JVM에서 ML을 사용할 수 있다는 사실을 처음 알았다. 
오히려 JVM으로 하면 병렬 처리 같은 이점이 있다는 사실이 신기했었고, 생각보다 간단하게(DJL) 적용이 가능해서 사이드 프로젝트할 때 시도해볼 법하다는 생각이 들었다.</p>
<h3 id="올리브영-물류-시스템-개선기">올리브영 물류 시스템 개선기</h3>
<p>올리브영에서 물류 시스템 개선을 어떻게 했는지에 대한 내용이었다. </p>
<p>기존에는 입출고 주문이 들어오면 배치를 통해 물류센터로 입고 주문을 날리는 형식이었으나 배치 특성상 일정 주기마다 작동되는 방식이라 시간이 늦어져 발생하는 문제가 있었다 했다. (시간 지연으로 입고 포기, 실시간으로 데이터 동기화가 안되는 등...)</p>
<p>이를 해결하기 위해 입고 주문 발생 시 카프카를 활용하여 실시간성을 확보하고 만약 카프카에서 예외가 발생하면 DLQ를 구현하여 재처리 프로세스를 구축했다고 했다.</p>
<p>두번째로 오프라인 재고 조회 개선기에 대한 내용이었는데</p>
<p>기존에는 오라클 DB 하나만을 여러 서비스가 바라보고 있어 부하가 발생했다고 한다.
이를 해결하기 위해 Redis를 통해 읽기 DB를 분리하여 부하를 분산 시켰고 Redis를 활용하여 발생한 문제를 어떻게 해결했는지에 대해서도 설명을 해주었다.</p>
<p>Redis를 활용하면서 발생한 문제</p>
<ol>
<li>재고 조회/수정 시 동시성 이슈<ul>
<li>Redisson으로 분산락 처리</li>
</ul>
</li>
<li>조회 성능 이슈<ul>
<li>단건 조회의 경우 Redis가 빠르나 여러건 조회의 경우 Redis SCAN기반 패턴 탐색으로 속도가 느림</li>
<li>이를 해결하기 위해 레디스 데이터 셋을 분리하여 단건 조회용/여러 건 조회용/역인덱스 조회용 으로 분리했다고 한다. </li>
</ul>
</li>
<li>Redis 사망<ul>
<li>서킷 브레이커를 통해 레디스 오류 시 오라클DB를 바라보도록 설계</li>
</ul>
</li>
</ol>
<p>카프카를 사용했다는 점에서 신기함 + 그렇다면 정확히 어떻게 사용을 했는가? 에 대해 의문이 들었다. (카프카 토픽은 어떻게 정의를 했고(지점마다 하나의 토픽을 쓴건지 아니면 지점명 + 물품명으로 토픽을 지정했는지) 순서 보장은 어떻게 했는지 등...) </p>
<p>그리고 레디스면 메모리 기반이라 단순히 빠를 줄만 알았는데 여러 건 조회 시에는 오히려 불리할 수 있겠구나라는 사실을 알게 되었다.</p>
<h3 id="실전-msa-트랜잭션-개발-가이드">실전! MSA 트랜잭션 개발 가이드</h3>
<h4 id="msa">MSA</h4>
<ul>
<li>시스템을 여러개로 독립적으로 분할</li>
<li>이유는 여러 팀이 독립적으로 일하도록 분리</li>
</ul>
<p>가장 중요한거는 다른 테이블에 액서스 하면 안된다.(테이블 변경 시 파급력이 매우 커진다.)</p>
<h4 id="서비스간-트랜잭션-보장은-어떻게">서비스간 트랜잭션 보장은 어떻게?</h4>
<ul>
<li>2PC(Two Phase Commit) 는 사용 안하는게 낫다</li>
<li>여러 서비스에 걸친 쓰기는 트랜잭션 보장이 안됨 <ul>
<li>DB 롤백, Uncommited read 로 독립성이 떨어짐</li>
</ul>
</li>
</ul>
<h4 id="데이터-오너십-원칙">데이터 오너십 원칙</h4>
<ul>
<li>테이블 직접 조인 &lt; DAO 호출 &lt; 서비스 호출 순으로 모듈화 수준이 높아진다.</li>
<li>오너십을 가진 서비스만 데이터를 써야 한다. 여러 서비스가 접근하면 안된다.<ul>
<li>A서비스에서 A데이터를 변경할 수 있는 서비스는 A서비스가 유일해야 한다.</li>
</ul>
</li>
<li>데이터의 주인(?)을 나누는 방법 : 테이블을 나눌 때 속성으로 나누거나 레코드로 나누거나</li>
</ul>
<h4 id="실패한-트랜잭션-처리-방법">실패한 트랜잭션 처리 방법</h4>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/3e4e890e-c93e-4daf-9da8-de27f2d36bbb/image.png" alt=""></p>
<p>만약 B에서 오류나면 완벽하게 롤백이 안된다.</p>
<ul>
<li>여러개의 트랜젹션의 경우 보상 트랜잭션/재시도를 구현해야 한다.</li>
</ul>
<p><strong>Pivot Transaction</strong></p>
<ul>
<li>트랜잭션 중 에러가 발생했을 때 재시도를 취소할지 말지를 결정</li>
<li>Pivot를 기준으로 성공/실패를 가른다.<ul>
<li>만약 WriteB에서 예외가 발생하면 재시도, WriteA에서 예외가 발생하면 롤백</li>
</ul>
</li>
</ul>
<h4 id="트랜잭션-격리성-보완">트랜잭션 격리성 보완</h4>
<ul>
<li>MSA를 하면 Read Uncommited로 수준이 떨어짐<ul>
<li>변경 중인 데이터를 다른 트랜잭션이 보게 된다.</li>
<li>변경중인 데이터를 후행 트랜잭션이 고칠 수 있게 된다.</li>
</ul>
</li>
</ul>
<p>보통은 문제가 잘 안되지만 변경중인 데이터를 다른 트랜잭션이 덮어쓰면 문제가 커진다.</p>
<h4 id="제어-방법">제어 방법</h4>
<ul>
<li>semantic lock : 업무적인 의미를 추가(예매 중인 좌석에 대해 표현 가능하도록 한다든가)</li>
<li>tcc</li>
<li>offline lock</li>
<li>select for update 를 통해 select 를 할 때 락을 잡음<ul>
<li>커밋 되면 락 해제</li>
<li>신뢰할 수 있는 락 해제</li>
</ul>
</li>
<li>트랜잭션 아웃박스 패턴<ul>
<li>주의점 : 멱등성 주의</li>
</ul>
</li>
</ul>
<h3 id="talk3---기술-talk">Talk3 - 기술 Talk</h3>
<p>여러 연사 분들이 나누어서 여러 기술적인 질문들에 대해 이야기를 나누는 시간이었다.</p>
<p>그 중 가장 인상깊었던 것은 JPA엔티티와 도메인 객체를 나누는 것이 좋은지에 대한 이야기였다.</p>
<p>이 질문에 대해 연사 분들은 설계에는 좋고 나쁨이 없다. 상황에 따라 나누면 좋다 이야기를 해주셨다.(DB 스키마가 자주 바뀌거나 레거시한 경우, DB스키마와 도메인 객체 변경 타이밍이 다른 경우...에는 나누는 것이 좋다 예시를 들어주셨다.) 
다만 프로젝트 초기부터 나누지 말고 상황을 봐서 나누는 것이 좋다 이야기를 해주셨다. </p>
<p>이에 더불어 확장성과 오버엔지니어링의 경계에 대해서도 이야기가 나왔다.
확장성 있는 설계를 하는 이유는 변경을 최소화 하기 위해 하는 것인데, 너무 미래를 예언해서 설계를 하기보단 현재 상황의 요구사항에 집중해서 설계를 하는게 좋고, 변화에 열려있는 태도를 가지는게 좋다고 해주셨다.</p>
<p>개발 경력이 길지는 않으나, 프로젝트를 진행하면서 프로젝트의 설계를 좋게 하고 싶은 마음 vs 너무 과한거 아닌가(+ 귀찮음) 이 두 개의 생각을 자주 했었다. 
그리고 지금까지의 프로젝트를 생각해보면 프로젝트의 방향이 내 예상에 적중한 적이 없었다(...) </p>
<p>그래서 패널톡에서 한 이야기처럼 현재의 요구사항에 맞추어 설계를 하되, 앞으로의 변화를 적극적으로 반영하는 태도를 가지는게 좋은 균형점이라는 생각을 가지게 되었다. (+ 변화가 필요할 때 빠르게 결단을 내려야 한다는 것도)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github Actions + Docker 로 CICD 구축하기]]></title>
            <link>https://velog.io/@penrose_15/Github-Actions-Docker-%EB%A1%9C-CICD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/Github-Actions-Docker-%EB%A1%9C-CICD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 26 Nov 2024 07:59:33 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트의 CICD를 Github 와의 연동이 편하고 무료인 github actions로 구축하기로 하였습니다. </p>
<h3 id="대략적인-flow">대략적인 Flow</h3>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/86247036-8a7c-4621-818b-5a48ed9c912c/image.png" alt=""></p>
<ol>
<li>Dockerfile 기반으로 이미지를 생성한 다음, Docker Hub로 Push</li>
<li>배포를 해주는 deploy.sh를 EC2로 전송 후, EC2에서 deploy.sh를 실행
2-1. deploy.sh에서 Docker Image Pull 받은 후, Docker 컨테이너 실행</li>
</ol>
<h3 id="0-setting">0. Setting</h3>
<p>Github Actions 스크립트는 프로젝트의 루트 폴더의 <code>.github &gt; workflows &gt; gradle.yml</code> 에 작성하면 됩니다. </p>
<p>깃허브에서 제공하는 템플릿을 사용해도 됩니다. 
<img src="https://velog.velcdn.com/images/penrose_15/post/3d0208f2-e9a2-4419-8e3f-105fe83614ea/image.png" alt=""></p>
<h3 id="1-ci">1. CI</h3>
<p>Github Actions 스크립트는 CI, CD 2단계로 나누어 진행을 하였습니다. </p>
<p>아래는 CI 단계 전문입니다. </p>
<pre><code class="language-yml">name: CICD with github action and EC2 using docker

on:
  push:
    branches:
      - &#39;main&#39; # main 브랜치로 푸시 이벤트 발생 시 스크립트가 실행됩니다.

env: # Github에 미리 설정해둔 Secrets 환경 변수를 가져옵니다. 이 단계는 굳이 설정하지 않고 ${{ secrets.*** }}로 secrets 환경변수를 꺼내 써도 됩니다.
  DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_USERNAME }}
  DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
  DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }}
  EC2_HOST: ${{ secrets.HOST }}
  EC2_SSH_USER: ${{ secrets.SSH_USER }}
  PRIVATE_KEY: ${{ secrets.SSH }}

permissions:
  id-token: write

jobs: # 1
  CI:
    runs-on: ubuntu-latest # 2
    permissions: # 3
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4 # 4

      - name: Login to Docker Hub # 5
        uses: docker/login-action@v3
        with:
          username: ${{ env.DOCKER_HUB_USERNAME }}
          password: ${{ env.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Set up Docker Buildx # 6
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          file: ./docker/Dockerfile
          push: true
          tags: ${{env.DOCKER_HUB_USERNAME}}/${{ env.DOCKER_IMAGE_NAME }}:latest
</code></pre>
<ol>
<li><p><code>Jobs: CI:</code> workflow 단계를 지정합니다. </p>
</li>
<li><p><code>runs-on</code> 으로 빌드에 사용될 ubuntu를 지정해줍니다. </p>
</li>
<li><p><code>permissions: id-token: write</code> 는 Github OIDC의 접근 허용을 위해 추가하였습니다. </p>
</li>
</ol>
<p>steps에는 각 단계별 사용할 동작을 지정합니다. </p>
<ol start="4">
<li><p><code>uses : actions/checkout@v4</code> : 지정한 브랜치의 코드를 내려받습니다.</p>
</li>
<li><p>```yml</p>
</li>
</ol>
<ul>
<li>name: Login to Docker Hub # 5
uses: docker/login-action@v3
with:
  username: ${{ env.DOCKER_HUB_USERNAME }}
  password: ${{ env.DOCKER_HUB_ACCESS_TOKEN }}<pre><code></code></pre></li>
</ul>
<p>Docker Hub로 이미지를 푸시하기 위해 로그인하는 과정입니다. </p>
<ol start="6">
<li>```yml</li>
</ol>
<ul>
<li><p>name: Set up Docker Buildx # 6
uses: docker/setup-buildx-action@v3</p>
</li>
<li><p>name: Build and push
uses: docker/build-push-action@v6
with:
  file: ./docker/Dockerfile
  push: true
  tags: ${{env.DOCKER_HUB_USERNAME}}/${{ env.DOCKER_IMAGE_NAME }}:latest</p>
<pre><code></code></pre></li>
</ul>
<p><code>docker/setup-buildx-action@v3</code> : 멀티 플랫폼 이미지를 빌드하기 위한 필요한 Buildx를 설치합니다. </p>
<p>이후 <code>docker/build-push-action@v6</code> 로 도커 이미지를 빌드한 후, 이전에 로그인했던 Dockerhub로 Push 합니다. </p>
<p>아래는 도커 이미지 빌드에 사용된 Dockerfile입니다.</p>
<pre><code class="language-dockerfile"># 1. Build Image
FROM amazoncorretto:21-alpine-jdk AS builder

WORKDIR /sources

COPY . .

RUN chmod u+w ./api-module/src/main/resources &amp;&amp; \
    chmod +x gradlew &amp;&amp;  \
    ./gradlew clean &amp;&amp;  \
    ./gradlew :api-module:build

# ------------------------------
# 2. Production Image
FROM optimoz/openjre-21.0.3:0.4

WORKDIR /app

COPY --from=builder /sources/api-module/build/libs/api-module-0.0.1-SNAPSHOT.jar /app/quiz.jar

CMD [&quot;java&quot;, &quot;-Dspring.profiles.active=prod&quot;, &quot;-jar&quot;, &quot;quiz.jar&quot;]</code></pre>
<p>빌드를 위해 사용된 라이브러리가 최종 컨테이너 실행 시 필요없을 수 있습니다. 이런 라이브러리는 쓸모없이 공간 낭비를 합니다. </p>
<p>멀티스테이지 빌드를 사용하면 컨테이너 실행 시에 필요없는 빌드에 사용된 라이브러리가 모두 삭제된 상태로 컨테이너를 실행 시킬 수 있습니다. 이로 인해 좀 더 가벼운 컨테이너를 사용할 수 있습니다. </p>
<pre><code class="language-dockerfile">
FROM amazoncorretto:21-alpine-jdk AS builder

WORKDIR /sources

COPY . .

RUN chmod u+w ./api-module/src/main/resources &amp;&amp; \
    chmod +x gradlew &amp;&amp;  \
    ./gradlew clean &amp;&amp;  \
    ./gradlew :api-module:build</code></pre>
<p>빌드 단계입니다. </p>
<p><code>amazoncorretto:21-alpine-jdk</code>를 베이스 이미지로 지정하고 <code>builder</code> 라는 이름을 지정합니다. </p>
<p>이후 빌드를 실행할 위치를 <code>WORKDIR /sources</code> 로 지정해주고 gradle build 를 실행해줍니다. </p>
<pre><code class="language-dockerfile">FROM optimoz/openjre-21.0.3:0.4

WORKDIR /app

COPY --from=builder /sources/api-module/build/libs/api-module-0.0.1-SNAPSHOT.jar /app/quiz.jar

CMD [&quot;java&quot;, &quot;-Dspring.profiles.active=prod&quot;, &quot;-jar&quot;, &quot;quiz.jar&quot;]</code></pre>
<p>컨테이너 실행 단계입니다. </p>
<p>실행 환경용 Docker 의 기본 이미지는 <code>optimoz/openjre-21</code>를 사용해주었습니다. (JRE)</p>
<p>이후 빌드한 이미지 파일을 COPY 명령어로 이동 시켜줍니다. 여기서 <code>--from</code> 옵션을 통해 <code>builder</code>라는 이미지로부터 복사를 해줍니다. </p>
<h3 id="2-cd">2. CD</h3>
<p>빌드가 끝났으면 이제 배포를 할 차례입니다. </p>
<p>아래는 CD 스크립트 전문입니다. </p>
<pre><code class="language-yml">  CD:
    needs: [CI] # 1
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials # 2
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Get Github Action IP # 3
        id: ip
        uses: haythem/public-ip@v1.3

      - name: Add Github Actions IP to SG # 4
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32

      - name: Send deploy.sh to EC2 # 5
        uses: appleboy/scp-action@master
        with:
          host: ${{ env.EC2_HOST }}
          username: ${{ env.EC2_SSH_USER }}
          key: ${{ env.PRIVATE_KEY }}
          source: &quot;deploy/deploy.sh&quot;
          target: &quot;/home/ec2-user&quot;
          strip_components: 1
          overwrite: true


      - name: Docker Image Pull and Container run # 6
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.EC2_HOST }}
          username: ${{ env.EC2_SSH_USER }}
          key: ${{ env.PRIVATE_KEY }}
          script: |
            chmod +x deploy.sh
            ./deploy.sh

      - name: Remove Github Action IP from SG # 7
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32</code></pre>
<ol>
<li>CD 단계가 실행되기 위해 CI 단계가 성공해야 하기 때문에 <code>needs: [CI]</code> 를 추가해줍니다. </li>
</ol>
<p>2.</p>
<pre><code class="language-yml">- name: Configure AWS credentials # 2
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
    aws-region: ${{ secrets.AWS_REGION }}</code></pre>
<p> AWS 에 접근하기 위해 추가해준 단계입니다. </p>
<p>이 단계는 보안 그룹의 인바운드 설정을 해주기 위해 추가해주었습니다. </p>
<p>배포 스크립트 <code>deploy.sh</code> 를 EC2 로 전달해 주기 위해서는 scp 명령어를 통해 EC2 로 복사를 해주어야 합니다. </p>
<p>문제는 scp 명령어는 ssh 원격 접속 프로토콜을 기반으로 하기 때문에 EC2의 보안 그룹에서 22번 포트를 열어주어야 합니다.</p>
<p>그런데 22번 포트를 활짝 열어놓는것은 너무 찝찝합니다. (집 들어가기 편하려고 집 현관문 열어 놓는 느낌)</p>
<p>이를 위해 배포 전에 github actions IP만 한정적으로 허용해주고 배포가 끝나면 github actions IP를 삭제하는 방식으로 진행하기로 했습니다. </p>
<p>추가적으로 accessKey, secretKey로 접근하는 방법 대신 <a href="https://velog.io/@penrose_15/Github-Action%EC%97%90%EC%84%9C-CICD%EA%B5%AC%EC%B6%95-%EC%8B%9C-AWS%EC%97%90-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%91%EA%B7%BC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95">Github OIDC를 활용하여 접근하는 방식</a>을 활용하였습니다. </p>
<ol start="3">
<li>```yml</li>
</ol>
<ul>
<li>name: Get Github Action IP # 3
id: ip
uses: haythem/public-ip@v1.3<pre><code></code></pre></li>
</ul>
<p>AWS 보안그룹에 github actions IP가 22번 포트 접근을 허용해주기 위해 github actions IP를 구하는 스크립트 입니다. </p>
<ol start="4">
<li><p>```yml</p>
</li>
</ol>
<ul>
<li>name: Add Github Actions IP to SG # 4
run: | <pre><code>  aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32</code></pre><pre><code></code></pre></li>
</ul>
<p>AWS 보안 그룹에 위에서 구한 github actions IP를 대상으로 22번 포트를 허용해주는 명령어입니다. </p>
<p><code>${{ secrets.AWS_SG_ID }}</code> 는 보안 그룹 ID를 뜻하며(github 시크릿 변수에 미리 설정) <code>{{ steps.ip.outputs.ipv4 }}/32</code>는 위에서 구한 github actions IP 입니다.</p>
<ol start="5">
<li><p>```yml</p>
</li>
</ol>
<ul>
<li>name: Send deploy.sh to EC2 # 5
uses: appleboy/scp-action@master
with:
  host: ${{ env.EC2_HOST }}
  username: ${{ env.EC2_SSH_USER }}
  key: ${{ env.PRIVATE_KEY }}
  source: &quot;deploy/deploy.sh&quot;
  target: &quot;/home/ec2-user&quot;
  strip_components: 1
  overwrite: true<pre><code></code></pre></li>
</ul>
<p>배포 스크립트 <code>deploy.sh</code>를 EC2 로 전송해줍니다. 이를 위해 <code>appleboy/scp-action</code>를 활용했습니다. (appleboy 감사합니다...)</p>
<p><code>source: &quot;deploy/deploy.sh&quot;</code> 는 배포 스크립트의 위치,
<code>target: &quot;/home/ec2-user&quot;</code> 는 원격 서버에 배포 스크립트가 위치할 디렉토리를 뜻합니다.
<code>strip_components: 1</code> 는 숫자 만큼<code>source</code> 경로의 선행 경로를 제거해줍니다. (<code>strip_components: 1</code> 이므로 <code>deploy/deploy.sh</code> 에서 선행 디렉토리인 <code>deploy</code> 제거 -&gt; <code>/deploy.sh</code>)
<code>overwrite: true</code>는 원격 서버에 <code>deploy.sh</code>가 이미 존재하면 덮어쓰기를 해줍니다.</p>
<p>6.</p>
<pre><code class="language-yml">- name: Docker Image Pull and Container run # 6
  uses: appleboy/ssh-action@master
  with:
    host: ${{ env.EC2_HOST }}
    username: ${{ env.EC2_SSH_USER }}
    key: ${{ env.PRIVATE_KEY }}
    script: |
            chmod +x deploy.sh
            ./deploy.sh</code></pre>
<p>scp 로 원격 서버로 전송한 배포 스크립트를 실행합니다. </p>
<ol start="7">
<li><pre><code class="language-yml">run: |
  aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32</code></pre>
</li>
</ol>
<p>배포 전 AWS 보안 그룹에 추가했던 Github actions IP를 제거 합니다. </p>
<p>최종 Github Actions 스크립트</p>
<pre><code class="language-yml">name: CICD with github action and EC2 using docker

on:
  push:
    branches:
      - &#39;main&#39;

env:
  DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_USERNAME }}
  DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
  DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }}
  EC2_HOST: ${{ secrets.HOST }}
  EC2_SSH_USER: ${{ secrets.SSH_USER }}
  PRIVATE_KEY: ${{ secrets.SSH }}

permissions:
  id-token: write

jobs:
  CI:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ env.DOCKER_HUB_USERNAME }}
          password: ${{ env.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          file: ./docker/Dockerfile
          push: true
          tags: ${{env.DOCKER_HUB_USERNAME}}/${{ env.DOCKER_IMAGE_NAME }}:latest

  CD:
    needs: [CI]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Get Github Action IP
        id: ip
        uses: haythem/public-ip@v1.3

      - name: Add Github Actions IP to SG
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32

      - name: Send deploy.sh to EC2
        uses: appleboy/scp-action@master
        with:
          host: ${{ env.EC2_HOST }}
          username: ${{ env.EC2_SSH_USER }}
          key: ${{ env.PRIVATE_KEY }}
          source: &quot;deploy/deploy.sh&quot;
          target: &quot;/home/ec2-user&quot;
          strip_components: 1
          overwrite: true


      - name: Docker Image Pull and Container run
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.EC2_HOST }}
          username: ${{ env.EC2_SSH_USER }}
          key: ${{ env.PRIVATE_KEY }}
          script: |
            chmod +x deploy.sh
            ./deploy.sh

      - name: Remove Github Action IP from SG
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32</code></pre>
<hr>
<p>+) 
추가적으로 <code>Send deploy.sh to EC2</code> 단계는 굳이 없어도 됩니다.
대신 <code>Docker Image Pull and Container run</code> 에서 script 부분에 배포 스크립트를 작성해주어야 합니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github Actions에서 OIDC로 AWS에 안전하게 접근하기]]></title>
            <link>https://velog.io/@penrose_15/Github-Action%EC%97%90%EC%84%9C-CICD%EA%B5%AC%EC%B6%95-%EC%8B%9C-AWS%EC%97%90-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%91%EA%B7%BC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@penrose_15/Github-Action%EC%97%90%EC%84%9C-CICD%EA%B5%AC%EC%B6%95-%EC%8B%9C-AWS%EC%97%90-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%91%EA%B7%BC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Fri, 22 Nov 2024 14:42:37 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>해당 글은 Github OIDC 적용 방법에 대해서만 중점적으로 작성하였습니다. </p>
</blockquote>
<p>Github Actions에서 서비스 배포를 위해 AWS에 접근 시 access-key, secret-key로 인증을 하는 경우가 있습니다.</p>
<pre><code class="language-yml">      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with: 
          with:
            aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
            aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            aws-region: ${{ secrets.AWS_REGION }}</code></pre>
<p>그러나 access-key, secret-key를 github secrets변수로 저장해서 안전하게 보관하고 IAM 권한 범위를 적절히 지정하더라도 한 번쯤 찜찜하다고 생각한 적이 있을 것입니다.<del>(Github 가 털린다거나 Github 가 털린다거나Github 가 털린다거나)</del></p>
<p>애초에 AWS 에서도 access key, secret key 사용을 지양하라고 하고 있습니다. (access key를 잃어먹는다거나, 털린다거나...)</p>
<p>그래서 이번에는 access Key 대신 Github OIDC를 활용하여 AWS 에 접근하기로 하였습니다. </p>
<h2 id="github-oidc">Github OIDC</h2>
<p> OIDC를 통해 장기적인 자격 증명 대신, 수명이 짧은 액서스 토큰을 발급받는 방식으로 진행됩니다. </p>
<p><strong>작동 순서</strong></p>
<p> <img src="https://velog.velcdn.com/images/penrose_15/post/804fc0ed-a149-40bc-9443-cafbe7732bd9/image.png" alt="">
출처 : <a href="https://docs.github.com/ko/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect">https://docs.github.com/ko/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect</a></p>
<ol>
<li>클라우드 공급자(AWS)에서 클라우드에 액서스 해야 하는 역할(AWS Role)과 Github Workflow 간에 OIDC Trust를 만듭니다. </li>
<li>작업이 생성될 때마다 Github의 OIDC 공급자는 OIDC 토큰을 생성합니다. </li>
<li>Github의 OIDC 공급자에서 클라우드 공급자(AWS)에게 토큰을 전달합니다.</li>
<li>클라우드 공급자가 토큰에 제공된 클레임의 유효성을 성공적으로 검사하면 작업 기간동안 사용 가능한 수명이 짧은 클라우드 액서스 토큰을 제공합니다.</li>
</ol>
<h2 id="적용-방법">적용 방법</h2>
<h3 id="1-aws-role-생성">1. AWS Role 생성</h3>
<p>AWS IAM -&gt; 역할 -&gt; 역할 생성 으로 들어갑니다.</p>
<p>웹 자격 증명을 선택한 후, 자격증명 공급자에 <code>token.actions.githubusercontent.com</code>을 입력, Audience에 <code>sts.amazonaws.com</code> 입력, Github 조직 및 repository에 해당하는 organization 과 repository 명을 적으면 됩니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/54c0cef6-a32f-41c8-bed0-7e5025741e7f/image.png" alt=""></p>
<blockquote>
<p>2025.07
<img src="https://velog.velcdn.com/images/penrose_15/post/3f44bb87-9794-4929-bce3-db98943918d5/image.png" alt="">
웹 자격 증명 선택 후 ID 제공업체에서 새로 생성을 통해 ID 제공업체를 설정해주셔야 합니다. 
<img src="https://velog.velcdn.com/images/penrose_15/post/159615f6-406c-4d80-a347-c03c5448a017/image.png" alt="">
OpenId Connect 선택 후 공급자 URL에 <code>token.actions.githubusercontent.com</code>을 입력, 대상에 <code>sts.amazonaws.com</code> 입력 후 공급자 추가를 해주셔야 합니다.</p>
</blockquote>
<p>이후 해당 계정에 사용할 권한을 추가하거나 Role 생성 후 다시 추가해도 됩니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/c9aa734f-0413-415b-aaaa-a61d7963080c/image.png" alt=""></p>
<p>저같은 경우 EC2로 특정 파일을 전송하기 위해(scp) 보안그룹에 22번 포트 Github Actions IP를 추가하고 작업이 끝나면 삭제하기 위해 Role 생성 이후 권한 추가를 통해 권한을 연결해주었습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/9a142ec4-e8c8-4937-9b17-d022e044df3f/image.png" alt=""></p>
<p>이후 만든 Role의 ARN 을 따로 기억해둡니다. </p>
<h3 id="2-github-actions-secrets-변수-추가">2. Github Actions Secrets 변수 추가</h3>
<p>Github Actions를 사용할 레포지토리에 들어가 Settings -&gt; Secrets and variables -&gt; Actions 로 들어갑니다. </p>
<p>이후 이전에 기억해두었던 ARN을 저장합니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/16dddd53-4099-47cf-9d80-040d840c4439/image.png" alt=""></p>
<h3 id="3-gradleyml에-작성">3. gradle.yml에 작성</h3>
<p>이제 AWS에 인증 과정을 gradle.yml에 작성하면 됩니다. </p>
<pre><code class="language-yml">...
  CD:
    needs: [CI]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ secrets.AWS_REGION }}
</code></pre>
<p>이전에 저장해둔 ARN을 role-to-assume에 입력하고 aws region도 작성하면 됩니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fetch Join과 Limit를 같이 적용하여 발생한 성능 저하 이슈와 해결 방법]]></title>
            <link>https://velog.io/@penrose_15/Fetch-Join%EA%B3%BC-Limit%EB%A5%BC-%EA%B0%99%EC%9D%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%84%B1%EB%8A%A5-%EC%A0%80%ED%95%98-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@penrose_15/Fetch-Join%EA%B3%BC-Limit%EB%A5%BC-%EA%B0%99%EC%9D%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%84%B1%EB%8A%A5-%EC%A0%80%ED%95%98-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 07 Nov 2024 06:47:58 GMT</pubDate>
            <description><![CDATA[<h3 id="배경">배경</h3>
<p>디프만에서 진행한 프로젝트에서 팔로우 한 사람들의 소식을 타임라인 형식으로 보여주는 API 가 있습니다.</p>
<p>해당 API 가 상당히 느리다는 제보를 받아 이를 수정하기로 결정했습니다.</p>
<h3 id="원인">원인</h3>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/49a3e77f-179c-462e-b1fc-20f0cd4b1075/image.png" alt=""></p>
<p>Memory에 Stroke, Image가 1:N 관계를 가진 상태입니다.</p>
<pre><code class="language-java">@Override
    public List&lt;FollowingMemoryLog&gt; findLogsByMemberIdAndCursorId(Long memberId, Long cursorId) {
        QFriendEntity friend = QFriendEntity.friendEntity;

        List&lt;FollowingMemoryLogEntity&gt; contents =
                queryFactory
                        .selectFrom(followingMemoryLog)
                        .join(followingMemoryLog.memory, memoryEntity)
                        .fetchJoin()
                        .join(followingMemoryLog.memory.member, memberEntity)
                        .fetchJoin()
                        .leftJoin(followingMemoryLog.memory.memoryDetail)
                        .fetchJoin()
                        .leftJoin(followingMemoryLog.memory.strokes, strokeEntity)
                        .fetchJoin()
                        .join(friend)
                        .on(friend.following.eq(memberEntity))
                        .fetchJoin()
                        .where(friend.member.id.eq(memberId), cursorIdLt(cursorId))
                        .limit(11)
                        .orderBy(followingMemoryLog.id.desc())
                        .fetch();
        return contents.stream().map(FollowingMemoryLogEntity::toModel).toList();
    }</code></pre>
<p>위의 쿼리를 통해 Memory와 1:N 관계인 데이터를 Fetch Join으로 가져오고 있었고, 페이지네이션을 적용하였습니다.</p>
<p>서버 로그를 확인해 본 결과, 아래와 같은 로그를 발견할 수 있었습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/04ee335c-582b-4d0f-bee1-f587d10a7d51/image.png" alt="">
해당 로그는 Hibernate가 모든 레코드를 메모리로 가져온 다음, 첫 번째/최대 결과 제한을 두려고 한다는 뜻입니다. </p>
<p>그리고 실행된 쿼리 로그를 살펴본 결과, 페이지네이션을 적용했음에도 불구하고 Limit 명령어가 적용되지 않았음을 확인할 수 있었습니다.</p>
<p>원인은 OneToMany 관계에서 Fetch Join을 적용한 상태에서 Pagination을 적용했기 때문이었습니다. </p>
<p>OneToMany 관계에서 Fetch Join으로 데이터를 한번에 다 가져오면서 하나의 Entity를 조회할 때와 달리 레코드의 개수가 변하게 됩니다. (ex - Memory : Stroke 가 1 : N 관계를 가진 경우, Memory 3개, Memory 1개 당 Stroke 3개인 경우 총 9 개의 레코드를 조회하게 됩니다.)</p>
<p>이로 인해 JPA는 어떤 데이터를 기준으로 Paging을 수행해야 하는 지 알 수 없게 되어 모든 레코드를 한번에 Memory에 올려둔 다음, 메모리에서 Pagination 을 수행하는 방식으로 진행하게 됩니다. </p>
<p>해당 방법은 Memory를 많이 사용하는 만큼, 성능 이슈를 불러올 수 있었습니다. </p>
<h3 id="해결-방법">해결 방법</h3>
<p>해결 방법은 생각보다 간단했습니다.</p>
<p>바로 Fetch Join을 해제해서 1:N 관계의 데이터들을 한번에 조회하지 않고, 쿼리를 나누어 조회하면 해결됩니다.</p>
<p>하지만 Fetch Join을 무작정 해제하면 N+1 문제가 발생할 수 있습니다.</p>
<p>이를 위해 어플리케이션 단에서 쿼리를 분리하거나, Batch Size를 조정하면 됩니다.</p>
<p>저는 Batch Size를 조절하여 N+1 문제를 해결할 수 있었습니다.</p>
<h3 id="성능-비교">성능 비교</h3>
<p>성능 비교를 위해 성능 테스트 툴인 k6을 통해 http_req_duration(원격 서버가 요청을 받고 처리하고 응답할 때까지 걸린 시간) 을 비교해봤습니다.</p>
<p>개선 전
<img src="https://velog.velcdn.com/images/penrose_15/post/ec028ec2-c69c-4abe-9e4d-e12661b8e7a3/image.png" alt=""></p>
<p>개선 후
<img src="https://velog.velcdn.com/images/penrose_15/post/abc3e7d1-bb8c-4b13-9f22-07878ddf9da2/image.png" alt=""></p>
<p>속도가 7.38sec -&gt; 22.27ms 로 대폭 상승했음을 확인할 수 있었습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Batch 로 데이터 일괄 처리하기]]></title>
            <link>https://velog.io/@penrose_15/Spring-Batch-%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%84-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/Spring-Batch-%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%84-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 10 Oct 2024 10:24:31 GMT</pubDate>
            <description><![CDATA[<p>디프만에서 진행한 Swimie 서비스에는 본인이 팔로우 한 사람의 소식을 조회 할 수 있는 기능이 있습니다.</p>
<p>정책 상, 팔로우 소식 데이터는 현재부터 100일 간의 데이터만 보여주도록 되어 있습니다. 팔로우 소식 관련 메타 데이터를 따로 MySQL에 저장해두고 있어 100일 이전의 데이터는 모두 지워줘야 했습니다.</p>
<h2 id="spring-batch">Spring Batch?</h2>
<p>일반적으로 배치 프로그은 사용자와의 상호작용 없이 여러 작업들을 미리 정해진 일련의 순서에 따라 일괄적으로 처리하는 것을 뜻합니다.</p>
<p>배치 프로그램의 필수 요소는 아래와 같습니다.</p>
<ul>
<li>대용량 데이터를 처리할 수 있어야 한다.</li>
<li>심각한 오류 상황 이외에는 사용자의 개입 없이 동작해야 한다.</li>
<li>유효하지 않은 데이터의 경우도 처리하여 비정상적인 동작 중단이 발생하지 않아야 한다.</li>
<li>어떤 문제가 생겼는지, 언제 발생했는지 등을 추적할 수 있어야 한다.</li>
<li>주어진 시간 내에 처리를 완료할 수 있어야 하고, 동시에 동작하고 있는 다른 애플리케이션을 방해하지 말아야 한다.</li>
</ul>
<p>Spring Batch 의 경우 Spring Framework의 특성 기반으로 하고, 로깅 및 추적, 트랜잭션 관리, 재시도 및 재처리, 병렬 처리 등 대량의 데이터를 처리하는데 팔요한 기능들을 제공합니다.</p>
<h2 id="spring-batch-vs-scheduler">Spring Batch VS Scheduler</h2>
<p>종종 Batch와 Scheduler 가 비교되곤 합니다. 하지만 둘의 역할은 완전히 다릅니다.</p>
<p>Scheduler는 특정 비즈니스 작업을 일정 시간 동안 반복할 때 사용됩니다. 반면 배치는 대용량 데이터를 처리할 때 사용됩니다.
이로 인해 Batch에서 일정 시간마다 배치 작업을 실행해야 할 때 Scheduler를 같이 사용하고는 합니다.</p>
<h2 id="spring-batch-구조">Spring Batch 구조</h2>
<p>Spring Batch는 아래와 같은 구조로 설계되었습니다.
<img src="https://velog.velcdn.com/images/penrose_15/post/d2f067ca-2971-471d-99d7-bb8c1a46caf9/image.png" alt="스프링배치 아키텍처"></p>
<ul>
<li><p>Application : 개발자가 작성한 모든 배치 작업과 사용자 정의 코드</p>
</li>
<li><p>Batch Core : 배치 작업을 시작하고 필요한 핵심 런타임 클래스(JobLauncher, Job, Step)</p>
</li>
<li><p>Batch Infrastructure : 개발자와 어플리케이션이 사용하는 Reader, Writer, RetryTemplate</p>
</li>
</ul>
<p>이러한 Spring Batch 구조로 인해 개발자는 Application 계층의 비즈니스 로직에 더 집중할 수 있게 되었고, Batch Core 의 클래스들로 배치의 동작을 제어할 수 있게 되었습니다.</p>
<h2 id="spring-batch-용어">Spring Batch 용어</h2>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/3851cc81-2f04-4eca-bacf-13594fbb5826/image.png" alt=""></p>
<p>Spring Batch에서 자주 사용하는 용어는 아래와 같습니다.</p>
<h3 id="job">Job</h3>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/371fe639-5189-4d10-827b-f73255acc3df/image.png" alt=""></p>
<p>전체 배치 프로세스를 캡슐화한 엔티티로 Step 인스턴스를를 위한 컨테이너입니다. 여러 Step를 가질 수 있습니다. Java 혹은 XML 파일로 설정할 수 있습니다.</p>
<p>Job 구성 요소</p>
<ul>
<li><p>Job 고유의 이름</p>
</li>
<li><p>Step 정의 및 순서 설정</p>
</li>
<li><p>Job 재시작 유무</p>
</li>
</ul>
<h3 id="job-instance">Job Instance</h3>
<p>배치 처리에서 Job 이 실행될 때 하나의 논리적 작업 실행 단위 입니다. 만약 하루에 한번 실행되어야 하는 Job가 있다면 Job Instance는 하루에 1개씩 생성됩니다. (1월 1일 JobInstance, 1월 2일 JobInstance, …)</p>
<p>한번 생성된 JobInstance는 해당 날짜의 데이터를 처리하는데 사용되며, 만약 실행이 실패한 경우, 같은 JobInstance를 다시 실행하여 작업을 완료할 수 있습니다.</p>
<h3 id="job-parameter">Job Parameter</h3>
<p>Job Instance를 구분하기 위해 사용되는 파라미터입니다. 매일 8시에 실행되는 배치에 2024/10/01 에 실행된 Job Instance의 파라미터는 startDate=2024/10/01 이 됩니다.</p>
<h3 id="jobexecution">JobExecution</h3>
<p>JobInstance의 1회 시행 시도를 뜻합니다. 만약 1번 배치 실행이 실패하면 동일한 JobInstance가 실행되고, 다른 JobExecution이 생성됩니다.</p>
<h3 id="step">Step</h3>
<p>Job의 하위 단계로 실제 배치 작업이 이루어지는 단위입니다. 1개 이상의 Step로 Job가 구성되며, 각 Step는 순차적으로 일어납니다.</p>
<h3 id="tasklet-vs-chunk">Tasklet vs Chunk</h3>
<p>Tasklet는 단순히 하나의 Step에서 단일 작업을 수행하도록 설계되었습니다. 복잡한 로직이 필요 없는 간단한 작업에 적합합니다.</p>
<p>Chunk는 대규모 데이터를 효율적으로 처리하기 위한 작업으로 데이터를 작은 Chunk단위로 나누어 처리하고, 트랜잭션도 Chunk 단위로 이루어집니다.</p>
<p>또한 Chunk 방식은 ItemReader, ItemProcessor, ItemWriter로 구성되어 있습니다.</p>
<h3 id="itemreader">ItemReader</h3>
<p>배치 작업에서 처리할 아이템을 읽어옵니다. 여러 형식의 데이터 소스로부터 데이터를 읽어오는 다양한 ItemReader 구현체가 제공됩니다.</p>
<h3 id="itemprocessor">ItemProcessor</h3>
<p>ItemReader에서 읽어온 아이템을 변환시킬 수 있습니다. 경우에 따라서는 사용하지 않아도 됩니다.</p>
<h3 id="itemwriter">ItemWriter</h3>
<p>ItemProcessor에서 처리한 데이터에 대해 최종적으로 기록하는 역할을 합니다.</p>
<h3 id="jobrepository">JobRepository</h3>
<p>배치 작업 관련 모든 정보를 저장하는 메커니즘입니다. Job Launcher, Job, Step 구현을 위한 CRUD 연산을 제공합니다. Job 실행과정에서 생성된 StepExecution, JobExecution은 저장소로 저장되고, 이를 통해 실행 상태를 추적합니다.</p>
<h3 id="joblauncher">JobLauncher</h3>
<p>작업의 시작과 실제 실행을 관리하는 인터페이스로, Job와 Job Parameter를 받아 Job를 실행시킵니다.</p>
<h3 id="작성한-코드">작성한 코드</h3>
<p>해당 코드는 Spring Boot 3 기준으로 작성되었습니다.</p>
<p><strong>build.gradle</strong></p>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-batch&#39;</code></pre><p><strong>application.yml</strong></p>
<pre><code>spring:
  datasource:
    url: ${MYSQL_URL}
    username: ${MYSQL_USERNAME}
    password: ${MYSQL_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        format_sql: true
    show-sql: true
  batch:
    job:
      enabled: true # default 값은 true, 만약 false인 경우 스프링이 자동으로 job를 샐행하는 것을 막습니다. 
      name: ${JOB_NAME:NONE} # 지정한 배치 Job 만 실행되도록 Job 이름 설정, :NONE 은 환경변수로 JOB_NAME이 넘어오지 않으면 배치가 실행되지 않도록 한다. 
    jdbc:
      initialize-schema: always # always로 설정 시 어플리케이션이 실행될 때마다 배치 메타테이터 테이블을 자동 생성해준다.
logging:
  level:
    org:
        orm:
          jpa: INFO</code></pre><p><a href="https://github.com/spring-projects/spring-boot/issues/25373">추가적으로 spring.batch.job.name에서 여러 job 이름을 , 로 명시해서 실행하는 기능이 제거</a>되었습니다.</p>
<p><strong>BatchConfig</strong></p>
<p>만약 커스텀한 JobLauncherApplicationRunner이 필요하면 커스텀한 BatchConfig를 생성해주면 됩니다.</p>
<pre><code>@Configuration
@EnableConfigurationProperties(BatchProperties.class) // (1)
public class BatchConfig {
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(
            prefix = &quot;spring.batch.job&quot;,
            name = &quot;enabled&quot;,
            havingValue = &quot;true&quot;,
            matchIfMissing = true) // (2)
    public JobLauncherApplicationRunner jobLauncherApplicationRunner(
            JobLauncher jobLauncher,
            JobExplorer jobExplorer,
            JobRepository jobRepository,
            BatchProperties properties) {
        JobLauncherApplicationRunner runner =
                new JobLauncherApplicationRunner(jobLauncher, jobExplorer, jobRepository); // (3)
        String jobName = properties.getJob().getName();
        if (StringUtils.hasText(jobName)) {
            runner.setJobName(jobName); // (4)
        }
        return runner;
    }
}</code></pre><p><code>@EnableConfigurationProperties(BatchProperties.class)</code> : BatchProperties의 설정 값을 가져옵니다.</p>
<p><code>@ConditionalOnMissingBean</code> : 프로젝트에 동명의 Bean이 정의되었을 경우 해당 Bean을 사용하지 않고, 동명의 Bean이 존재하지 않으면 현재 등록된 Bean을 쓰게끔 유도합니다. 해당 어노테이션으로 Spring Boot에서 자동으로 추가되는 해주는 JobLauncherApplicationRunner가 사용자가 설정한 JobLauncherApplicationRunner 빈을 덮어 쓰지 않게 해줍니다.</p>
<p><code>@ConditionalOnProperty</code> : Property(application.yml)에 해당하는 값이 특정 조건을 만족할 때만 Bean을 생성해줍니다. 해당 코드에는 spring.batch.job.enabled 값이 true인 경우에만 빈을생성하도록 되어 있다. matchIfMissing 은 매칭되는 것이 없으면 빈을 생성할지 말지 결정하는 속성으로 만약 application.yml에 spring.batch.job.enabled 속성이 누락되어도 빈을 생성할 수 있도록 true로 설정해주었습니다.</p>
<p><code>StringUtils.hasText(jobName)</code> : BatchProperties에서 설정된 job.name을 가져와 빈 문자열이 아니라면 runner에 설정을 해줍니다. 이 설정을 통해 실행 될 Batch Job를 지정해줄 수 있습니다.</p>
<p>(사실 이 클래스는 기존 스프링 부트의 BatchAutoConfiguration과 설정이 똑같아서 굳이 추가를 해주지 않아도 되지만 만약 JobLauncherApplicationRunner를 커스터마이징 해주어야 하는 상황이라면 위와 같은 설정에 부가적인 설정을 해주어야 합니다.)</p>
<p><strong>JobConfig</strong></p>
<p>실행 시킬 Job를 설정해주는 클래스입니다.</p>
<pre><code>@Configuration
@RequiredArgsConstructor
public class FollowingLogDeleteJobConfig {
    private final PlatformTransactionManager transactionManager;
    private final FollowingLogDeleteJobExecutionListener listener;
    private final FollowingLogItemReader itemReader;
    private final FollowingLogItemWriter itemWriter;

    @Bean
    public Job followingLogDeleteJob(JobRepository jobRepository) {
        return new JobBuilder(&quot;followingLogDeleteJob&quot;, jobRepository) // (1)
                .listener(listener)
                .start(followingLogDeleteStep(jobRepository)) // (2)
                .build();
    }

    @Bean
    @JobScope // (3)
    public Step followingLogDeleteStep(JobRepository jobRepository) {
        return new StepBuilder(&quot;followingLogDeleteStep&quot;, jobRepository)
                .&lt;FollowingMemoryLogEntity, FollowingMemoryLogEntity&gt;chunk(10, transactionManager)
                .reader(itemReader) // (4)
                .writer(itemWriter) // (5)
                .allowStartIfComplete(true)
                .build();
    }
}</code></pre><pre><code>@Bean
    public Job followingLogDeleteJob(JobRepository jobRepository) {
        return new JobBuilder(&quot;followingLogDeleteJob&quot;, jobRepository) // (1)
                .listener(listener)
                .start(followingLogDeleteStep(jobRepository)) // (2)
                .build();
    }</code></pre><p>(1)
Job 이름을 설정해줍니다.
(2)
Job 실행 전후 로그 생성을 위해 listener를 추가해주었습니다.
추가할 listener가 없다면 삭제해도 좋습니다.</p>
<p><code>FollowingLogDeleteJobExecutionListener</code></p>
<pre><code>@Slf4j
@Component
@RequiredArgsConstructor
public class FollowingLogDeleteJobExecutionListener implements JobExecutionListener {
    @Override
    public void beforeJob(@NonNull JobExecution jobExecution) {
        log.info(&quot;100일 이후의 FollowingMemoryLog 삭제 Batch Job 시작&quot;);
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
            log.info(&quot;100일 이후의 FollowingMemoryLog 삭제 Batch Job이 완료되었습니다&quot;);
        } else if (jobExecution.getStatus() == BatchStatus.FAILED) {
            log.error(&quot;100일 이후의 FollowingMemoryLog 삭제 Batch Job이 실패하였습니다&quot;);
        } else {
            log.info(
                    &quot;100일 이후의 FollowingMemoryLog 삭제 Batch Job 종료 Status : {}&quot;,
                    jobExecution.getStatus());
        }
    }
}</code></pre><p><code>JobExecutionListener</code> 는 Job 성공/실패와 상관없이 실행되는 이벤트 리스너입니다. 실행 전/후 로그를 남기기 위해 beforeJob, afterJob 를 Override 했으며, afterJob 에서 배치 성공/실패에 따라 로그를 다르게 남기기 위해 Batch Status에 따라 로그를 다르게 남기도록 분기처리를 했습니다.</p>
<pre><code>@Bean
@JobScope // (3)
public Step followingLogDeleteStep(JobRepository jobRepository) {
    return new StepBuilder(&quot;followingLogDeleteStep&quot;, jobRepository)
            .&lt;FollowingMemoryLogEntity, FollowingMemoryLogEntity&gt;chunk(10, transactionManager) // (4)
            .reader(itemReader) // (5)
            .writer(itemWriter) // (6)
            .allowStartIfComplete(true)
            .build();
}</code></pre><p>(3)
Job 내부의 Step를 선언해줍니다.</p>
<p>@JobScope는 Job Parameter를 사용하기 위해 선언해주어야 합니다.</p>
<p>@JobScope는 Spring Batch 실행 시 Job 실행 시점에 Bean을 생성하게 해줍니다.</p>
<p>(4)
chunk는 각 커밋 사이에 처리되는 row 수를 뜻합니다.</p>
<p>한번에 하나씩 데이터를 읽어 Chunk 라는 덩어리를 만들고, Chunk 단위로 트랜잭션을 다룹니다.</p>
<p>만약 도중 Batch가 실패할 경우 Chunk 만큼 롤백이 되고 이전까지의 커밋은 트랜잭션이 반영됩니다.</p>
<p>(5)
데이터를 읽어오기 위해 ItemReader를 설정해줍니다.</p>
<p>Spring 같은 경우 DB, XML, JSON, File 등의 데이터를 배치로 읽어올 수 있는데 여기서는 DB의 데이터를 처리합니다.</p>
<p>ItemReader의 경우 대표적인 구현체로 JdbcCursorItemReader, JdbcPagingItemReader, JpaPagingItemReader 가 존재하여 해당 구현체를 활용하여도 되나 ItemReader 인터페이스를 커스텀하게 구현하여 사용해도 됩니다.</p>
<p>저희 팀 같은 경우 ItemReader를 커스텀하게 구현하는 방법을 사용했습니다.</p>
<pre><code>@StepScope
@Component
public class FollowingLogItemReader implements ItemReader&lt;FollowingMemoryLogEntity&gt; {
    @PersistenceContext private EntityManager em;
    private static final int PAGE_SIZE = 10;
    private int currentIndex = 0;
    private List&lt;FollowingMemoryLogEntity&gt; followingMemoryLogEntities;

    @Override
    public FollowingMemoryLogEntity read() {
        if (followingMemoryLogEntities == null
                || currentIndex &gt;= followingMemoryLogEntities.size()) {
            fetchNextPage();
        }
        if (followingMemoryLogEntities != null
                &amp;&amp; currentIndex &lt; followingMemoryLogEntities.size()) {
            return followingMemoryLogEntities.get(currentIndex++);
        }
        return null;
    }

    private void fetchNextPage() {
        currentIndex = 0;
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime before100Days = now.minusDays(100);

        TypedQuery&lt;FollowingMemoryLogEntity&gt; query =
                em.createQuery(
                        &quot;SELECT f FROM FollowingMemoryLogEntity f WHERE f.createdAt &lt; :date&quot;,
                        FollowingMemoryLogEntity.class);
        query.setParameter(&quot;date&quot;, before100Days);
        query.setFirstResult(currentIndex);
        query.setMaxResults(PAGE_SIZE);
        followingMemoryLogEntities = query.getResultList();
    }
}</code></pre><p>위의 코드는 만약 최초로 데이터를 읽어올 때 (followingMemoryLogEntities 가 null 인경우) fetchNextPage() 메서드로 한번에 데이터를 10개씩 가져옵니다.</p>
<p>이후 데이터를 해당 데이터를 하나씩 ItemWriter로 전달합니다.</p>
<p>fetchNextPage()로 읽어온 데이터를을 ItemWriter로 다 전달하면(pageSize 만큼) 다음 데이터로 이동합니다. 만약 더이상 읽어올 데이터가 없다면 null을 리턴하여 ItemReader를 종료시킵니다.</p>
<p><code>@PersistenceContext private EntityManager em;</code>은 EntityManager를 빈으로 주입할 때 사용하는 어노테이션으로 EntityManagerFactory에서 새로운 EntityManager를 생성하거나 혹은 Transaction에 의해 기존의 EntityManager를 반환해줍니다.</p>
<p>일반적으로 스프링은 싱글톤 기반으로 동작하기 떄문에 EntityManager를 여러 스레드에서 공유를 하며 사용하는데, <a href="https://batory.tistory.com/497">@PersistenceContext 를 추가해주어 동시성 문제가 발생하지 않습니다.</a></p>
<p>(6)</p>
<p>ItemReader에서 읽어온 데이터를 ItemWriter에서 처리를 해줍니다.</p>
<p>중간에 데이터를 가공하기 위해 ItemReader와 ItemWriter 사이 ItemProcessor 단계를 추가해주어도 되나 해당 코드에는 필요가 없어 추가하지 않았습니다.</p>
<p>데이터가 chunk 만큼 쌓인 뒤에 ItemWriter로 전달이 되어 chunk 단위로 작업이 실행됩니다.</p>
<p>ItemWriter 에도 대표적으로 JdbcBatchItemWriter, HibernateItemWriter, JpaItemWriter 가 있고, ItemWriter 인터페이스를 직접 구현해도 됩니다.</p>
<p>저희 팀 같은 경우 ItemWriter 역시 커스텀하게 만들어 구현하였습니다.</p>
<pre><code>@StepScope
@Component
@RequiredArgsConstructor
public class FollowingLogItemWriter implements ItemWriter&lt;FollowingMemoryLogEntity&gt; {
    @PersistenceContext EntityManager em;

    @Override
    public void write(Chunk&lt;? extends FollowingMemoryLogEntity&gt; chunk) throws Exception {
        List&lt;? extends FollowingMemoryLogEntity&gt; followingMemoryLogEntities = chunk.getItems();
        List&lt;Long&gt; followingMemoryLogIds =
                followingMemoryLogEntities.stream().map(FollowingMemoryLogEntity::getId).toList();

        if (!followingMemoryLogIds.isEmpty()) {
            em.createQuery(&quot;DELETE FROM FollowingMemoryLogEntity f WHERE f.id IN :ids&quot;)
                    .setParameter(&quot;ids&quot;, followingMemoryLogIds)
                    .executeUpdate();
            em.flush();
        }
    }
}</code></pre><p>ItemReader에서 데이터를 읽어와 chunk 단위 만큼 데이터가 쌓이면 chunk 단위만큼 트랜잭션이 작동이 됩니다.</p>
<p>주의해야 할 점은 delete 를 하기 위해 벌크 연산을 사용했는데, 벌크 연산의 경우 영속성 콘텍스트를 무시하고 데이터베이스에 직접 쿼리를 하기 때문에 영속성 콘텍스트와 DB 의 데이터가 불일치하게 되는 문제가 발생합니다.</p>
<p>이를 해결하기 위해 마지막에 em.flush()를 추가해줍니다.</p>
<h3 id="마무리">마무리</h3>
<p>이후 완성된 배치를 실행 시킬 스케쥴링이 필요합니다.
저희 팀 같은 경우 기존에 Jenkins를 활용하고 있었고, 멀티모듈로 Batch 서버를 기존 서버와 분리해두었으며, 특정 시간대에만 Batch를 실행 시키기만 하면 되어서 Jenkins로 특정 시간에 Batch를 실행 시키도록 설정을 해주었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티모듈 + 헥사고날 아키텍처 프로젝트에 적용하기]]></title>
            <link>https://velog.io/@penrose_15/%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 10 Oct 2024 09:47:09 GMT</pubDate>
            <description><![CDATA[<p>디프만에서 진행하는 팀 프로젝트에서 멀티모듈과 헥사고날 아키텍처를 적용하기로 하였습니다.</p>
<p>단일 모듈 + 레이어드 아키텍처에서 멀티모듈 + 헥사고날 아키텍처로 전환하면서 겪었던 과정에 대해 공유하도록 하겠습니다.</p>
<h2 id="1-개선된-레이어드-아키텍처">1. 개선된 레이어드 아키텍처</h2>
<p>우리가 보통 사용하는 아키텍처는 아래와 같은 레이어드 아키텍처 일 것입니다.
<img src="https://velog.velcdn.com/images/penrose_15/post/cadcff97-ed14-4abc-b1fb-80410168db3b/image.png" alt=""></p>
<p>간단하게 레이어드 아키텍처를 설명하자면 소프트웨어 시스템을 관심사 별로 여러 계층으로 분리한 아키텍처를 뜻합니다.</p>
<ul>
<li>Presentation 계층(Controller) : 클라이언트와 직접적으로 연결되는 부분으로 사용자의 요청/응답을 처리합니다.</li>
<li>Application 계층(Service) : 비즈니스 로직을 구현하는 부분입니다.</li>
<li>Infrastructure 계층(Repository) : 데이터베이스와 상호작용을 하는 부분입니다.</li>
</ul>
<p>레이어드 아키텍처는 적용이 쉽다는 장점이 있으나 아래와 같은 단점이 있습니다.</p>
<ul>
<li><p>Persistence 계층이 최상위 의존성을 가지고 있어 데이터베이스 설계 위주로 개발을 하게 됩니다.</p>
</li>
<li><p>이로 인해 Presentation 계층의 의존도가 높아지게 되고 해당 계층의 변화는 전체 어플리케이션에 영향을 주게 됩니다. (가령 JPA -&gt; JDBC로 바꾸게 된다면 Entity 부터 Service 까지 많은 곳에 영향을 주게 될 것입니다. )</p>
</li>
<li><p>비즈니스 로직을 주로 서비스에서 다루게 되어 서비스의 책임이 커지게 됩니다.
너무 많은 의존성으로 인해 Unit 테스트를 하기 어려워집니다.</p>
</li>
</ul>
<p>이러한 문제점이 있다는 사실을 팀 내에서 알고 있어 기존의 레이어드 아키텍처를 적용하기 보단 헥사고날 아키텍처를 도입하자는 의견이 나왔으나 러닝커브를 고려하여 점진적으로 헥사고날 아키텍처를 적용하자는 의견이 나왔습니다.</p>
<blockquote>
<p><a href="https://www.inflearn.com/course/%EC%9E%90%EB%B0%94-%EC%8A%A4%ED%94%84%EB%A7%81-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EC%98%A4%EB%8B%B5%EB%85%B8%ED%8A%B8">Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트</a> 를 참고하였습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/ded6c2ac-d8b4-42b7-9ac7-83f2d4dc363f/image.png" alt=""></p>
<p>개선된 레이어드 아키텍처는 기존의 레이어드 아키텍처와 비슷하나 차이점이 있습니다.</p>
<ol>
<li>영속성 객체에서 도메인을 분리하였습니다.</li>
<li>Service가 구체화 된 Repository에 직접 의존하는 대신, 추상화 된 Repository Interface에 의존하도록 설계하였습니다. (의존성 역전)</li>
</ol>
<pre><code>public interface MemberRepository { // 추상화 된 Repository
    Member save(Member member);
    ...
}</code></pre><pre><code>@Repository
@RequiredArgsConstructor // 추상화 된 Repository 구체화
public class MemberRepositoryImpl implements MemberRepository {
 private final MemberJpaRepository memberJpaRepository;

 @Override
 public Member save(Member member) {
  return memberJpaRepository.save(MemberEntity.from(member)).toModel();
 }
  ...
}</code></pre><pre><code>@RequiredArgsConstructor
@Transactional
@Service
public class MemberServiceImpl implements MemberService {
 private final MemberRepository memberRepository; // 추상화 된 Repository에 의존

 @Override
 public Member save(MemberCreateDto memberCreate) {
  ...
 }
}</code></pre><ol start="3">
<li><p>Controller 도 마찬가지로 구체화 된 Service에 의존하지 않고 추상화된 Service Interface에 의존하도록 설계하였습니다.</p>
<pre><code>public interface MemberService {
 Member save(MemberCreateDto memberCreate);
 ...
}
@Tag(name = &quot;사용자(members)&quot;)
@RestController
@RequestMapping(&quot;/member&quot;)
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;

@PostMapping
public void save(@RequestBody MemberCreateDto memberCreate) {
return memberService.save(memberCreate);
}
}</code></pre><p>개선된 레이어드 아키텍처 덕분에 아래와 같은 장점을 얻게 되었습니다.</p>
</li>
</ol>
<ul>
<li><p>영속성 객체와 도메인을 분리시킬 수 있게 되어 도메인 객체가 비즈니스 로직에만 집중할 수 있게 되었고 ORM 종속성을 최소화 할 수 있게 되었습니다.</p>
</li>
<li><p>서비스에 많은 책임이 쏠리는 것을 방지하게 될 수 있었습니다.</p>
</li>
<li><p>데이터베이스 연결 없이도 테스트를 할 수 있게 되어 테스트 작성이 쉬워졌습니다.</p>
</li>
</ul>
<h2 id="2-멀티모듈-도입">2. 멀티모듈 도입</h2>
<p>이후 저희는 멀티모듈을 도입하기로 하였습니다.</p>
<p>기능이 추가될 수록 동일한 코드가 필요해질 때가 발생 (ex : Batch) 했기 때문입니다.</p>
<p>결국 상의 결과 아래와 같은 구조를 가지기로 하였습니다.</p>
<pre><code>├── module-presentation  # API 게이트웨이 서버
├── module-batch  # 배치 서버
├── module-independent  # 독립 모듈
├── module-domain  # 도메인 모듈
└── module-infrastructure  # 외부 모듈
      └── persistence-database # 데이터베이스 모듈</code></pre><p>build.gradle</p>
<pre><code>plugins {
    id &#39;java&#39;
    id &#39;org.springframework.boot&#39; version &#39;3.3.1&#39;
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.5&#39;
}

allprojects {
    apply plugin: &#39;java&#39;

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply plugin: &#39;org.springframework.boot&#39;
    apply plugin: &#39;io.spring.dependency-management&#39;

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    dependencies {
        // 필요한 의존성 추가
    }

    tasks.named(&#39;test&#39;) {
        useJUnitPlatform()
    }
    ...
}</code></pre><p>module-domain의 build.gradle</p>
<pre><code>bootJar { enabled = false }
jar { enabled = true }

dependencies {
    implementation project(&#39;:module-independent&#39;)
    ... // 필요한 의존성 추가
}</code></pre><p>module-presentation의 build.gradle</p>
<pre><code>jar { enabled = false }

dependencies {
    implementation project(&#39;:module-domain&#39;)
    implementation project(&#39;:module-independent&#39;)
    implementation project(&#39;:module-infrastructure:persistence-database&#39;)
    ... // 필요한 의존성 추가
}</code></pre><p>여기서 module-presentation, module-batch에서는 <code>jar { enabled = false }</code>를 적용하고 이외의 모듈에는 <code>bootJar { enabled = false }</code> <code>jar { enabled = true }</code>를 적용해주었습니다.</p>
<ul>
<li><p>bootJar : java -jar로 실행될 수 있는 Executable Archive (어플리케이션 실행에 필요한 모든 의존성 포함)</p>
</li>
<li><p>jar : 실행되지 않는 Plain Archive (어플리케이션 실행에 필요한 의존성을 제외한 리소스 파일과 빌드된 소스코드의 클래스 파일)</p>
</li>
</ul>
<p>이로 인해 중복되는 공통 코드를 없앨 수 있었고, 빌드 시간을 감소시킬 수 있습니다.</p>
<p>(추후 이로 인해 Test 작성에 문제점이 발생하게 되는데 해결 방법은 나중에 올리도록 하겠습니다.)</p>
<h2 id="3-헥사고날-아키텍처로-전환">3. 헥사고날 아키텍처로 전환</h2>
<p>이후 저희 서버 파트는 한 발 더 나아가 헥사고날 아키텍처로 전환하기로 결정을 하였습니다.</p>
<p>헥사고날 아키텍처에 대해 설명하자면, 여러 소프트웨어 환경에 쉽게 연결할 수 있도록, 느슨하게 결합된 어플리케이션 구성 요소를 만드는 것을 목표로 하는 아키텍처입니다.</p>
<p>도메인과 비즈니스 로직을 외부로부터 분리하고, 포트와 어댑터로 외부와 소통하여 포트&amp;어댑터 아키텍처라고도 합니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/48990e00-b04b-48e5-a8e3-f799092bd6b4/image.png" alt=""></p>
<p><a href="https://reflectoring.io/spring-hexagonal/">https://reflectoring.io/spring-hexagonal/</a></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/ad7093df-a4c4-499b-b490-c2e8eea0c527/image.png" alt=""></p>
<p>Port : 어플리케이션 코어와 외부 세계(Adapter)를 연결하는 역할을 합니다. (Usecase 포함)</p>
<p>Port에는 2가지 유형이 있습니다다.</p>
<ol>
<li><p>Input Port : 외부 요청이 어플리케이션 코어로 들어오는 경로(ex : Controller - Service 사이의 인터페이스 - Usecase)</p>
</li>
<li><p>Output Port : 어플리케이션 코어가 외부 세계로 서비스를 제공하는 경로 (ex : Service와 Repository 사이의 인터페이스)</p>
</li>
</ol>
<p>Adapter : 외부 기술, 프레임워크에 의존하는 로직을 담당하며, Port를 통해 어플리케이션 코어와 통신합니다.</p>
<p>Adapter에는 2가지의 유형이 있습니다.</p>
<ol>
<li><p>Primary Adapter(Driving Adapter) : 외부 시스템으로부터 들어오는 요청을 Input Port를 통해 어플리케이션 코어로 전달 (ex : Controller)</p>
</li>
<li><p>Secondary Adapter(Driven Adapter) : 어플리케이션 코어에서 Output Port를 통해 외부로 데이터를 전달 (ex : Repository)</p>
</li>
</ol>
<p>근데 얼핏 보기에 기존에 저희가 처음 도입한 개선된 레이어드 아키텍처와 많이 비슷하다는 것을 알 수 있을 것입니다.</p>
<p>개선된 아키텍처에서 클래스 이름 변경해주고 다이어그램만 살짝 이동 시켜주면 바로 헥사고날 아키텍처로 전환할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/2716d3ac-dc04-4085-92bd-aab9113b9e4d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/1859520d-33cc-46ea-8f19-920af7e9374a/image.png" alt=""></p>
<p>덕분에 생각했던 것 보다 더 빠르게 헥사고날 아키텍처로 전환을 할 수 있게 되었습니다.</p>
<p><strong>Controller</strong></p>
<pre><code>@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/memory&quot;)
public class MemoryController implements MemoryApi {
    private final MemoryFacade memoryFacade;

    @PostMapping
    @Logging(item = &quot;Memory&quot;, action = &quot;POST&quot;)
    public ApiResponse&lt;MemoryCreateResponse&gt; create(
            @LoginMember Long memberId,
            @Valid @RequestBody MemoryCreateRequest memoryCreateRequest) {
        MemoryCreateResponse response = memoryFacade.create(memberId, memoryCreateRequest);
        return ApiResponse.success(MemorySuccessType.POST_RESULT_SUCCESS, response);
    }
}</code></pre><p><strong>Facade</strong></p>
<p>이 포스팅에서는 설명하지 않았으나 저희는 퍼사드 패턴도 적용을 하였습니다.</p>
<pre><code>@RequiredArgsConstructor
@Transactional
@Service
public class MemoryFacade {
    private final CreateMemoryUseCase createMemoryUseCase;
    ...

    @Transactional
    public MemoryCreateResponse create(Long memberId, MemoryCreateRequest request) {
        ...
        Memory newMemory = createMemoryUseCase.save(writer, MemoryMapper.toCommand(request));
        Long memoryId = newMemory.getId();
        ...
        return MemoryCreateResponse.of(month, rank, memoryId);
    }
}</code></pre><p>Facade 계층에서 UseCase(Input Port)를 통해 비즈니스 로직과 소통하게 됩니다.</p>
<p><strong>UseCase &amp; Service</strong></p>
<pre><code>public interface CreateMemoryUseCase {
    Memory save(Member member, CreateMemoryCommand command);
}
@Service
@RequiredArgsConstructor
public class MemoryService
        implements CreateMemoryUseCase, ... {
    private final MemoryPersistencePort memoryPersistencePort;
    ...

    @Transactional
    public Memory save(Member writer, CreateMemoryCommand command) {
        ...
        return memoryPersistencePort.save(memory);
    }
}</code></pre><p>Usecase로부터 요청을 전달 받은 Service는 PrsistencePort(Output Port)를 통해 Repository와 소통하게 됩니다.</p>
<p><strong>PersistencePort &amp; Repository</strong></p>
<pre><code>public interface MemoryPersistencePort {
    Memory save(Memory memory);
    ...
}
@Repository
@RequiredArgsConstructor
public class MemoryRepository implements MemoryPersistencePort {
    private final MemoryJpaRepository memoryJpaRepository;
    ...

    @Override
    public Memory save(Memory memory) {
        return memoryJpaRepository.save(MemoryEntity.from(memory)).toModel();
    }
    ...
}</code></pre><p><strong>Domain &amp; Entity</strong></p>
<pre><code>@Getter
public class Memory {
    private Long id;
    ...

    @Builder
    public Memory(
            Long id,
            ...) {
        this.id = id;
        ...
    }

    public Memory update(Memory updateMemory) {
        ...
    }
}</code></pre><pre><code>@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table
public class MemoryEntity {
    @Id
    @Column(name = &quot;memory_id&quot;)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...
}</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>헥사고날 아키텍처를 적용하면서 아래와 같은 장단점을 경험할 수 있게 되었습니다.</p>
<p><strong>장점</strong></p>
<p>모듈간 결합도가 낮아져 구성 요소를 쉽게 교체할 수 있게 되었습니다.</p>
<p>관심사의 분리로 코드의 이해가 쉬워지고 유지 보수성이 올라갔습니다.</p>
<p>목업 객체를 더 쉽게 만들 수 있게 되어 테스트를 더 안정적으로 할 수 있게 되었습니다.</p>
<p><strong>단점</strong></p>
<p>코드량이 많아졌습니다(…)</p>
<p>디프만에서 프로젝트를 진행하면서 기능이 추가됨에 따라 헥사고날 아키텍처의 장점이 빛을 발하게 되었습니다. 무엇보다도 좋았던 것은 테스트 코드 작성이 더 간편해졌다는 것이었습니다.</p>
<p>기존의 레이어드 아키텍처에서 서비스 계층을 테스트 하려면 H2를 작동 시키거나 Mockito를 활용하여 가짜 Mock 객체를 주입하고 서비스 내부의 Repository 로직들도 구현을 해야 했으나 PersistencePort 인터페이스를 상속받은 FakeRepository를 구현하여 더욱 쉽게 테스트 코드를 작성할 수 있게 되었습니다.</p>
<p>그러나 이전보다는 더 많은 코드량을 요구하게 되었고, 만약 저희가 개선된 레이어드 아키텍처가 아닌 기존의 레이어드 아키텍처에서 전환을 시도했으면 더 많은 시간을 투자해야 했을것입니다.</p>
<p>따라서 개인적으로 헥사고날 아키텍처 사용에 매우 만족하였으나 각자의 상황에 맞게 아키텍처를 도입하는 것이 좋다는 것을 깨닫게 되었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MySQL RDS Slow Query Slack으로 알람 보내기]]></title>
            <link>https://velog.io/@penrose_15/MySQL-RDS-Slow-Query-Slack%EC%9C%BC%EB%A1%9C-%EC%95%8C%EB%9E%8C-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/MySQL-RDS-Slow-Query-Slack%EC%9C%BC%EB%A1%9C-%EC%95%8C%EB%9E%8C-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Mon, 09 Sep 2024 08:23:57 GMT</pubDate>
            <description><![CDATA[<p>디프만에서 진행 중인 팀 프로젝트의 런칭을 앞두고, 서비스에서 발생할 수 있는 이슈를 파악하기 위해 문제가 될 수 있는 쿼리를 추적해야 한다는 의견이 나왔습니다.</p>
<p>로그를 일일이 확인하는 대신, 더 효율적으로 문제가 되는 쿼리만을 찾아낼 수 있는 방법을 모색한 결과, AWS Lambda를 이용해 자동으로 Slack으로 알림을 받을 수 있는 방법을 구현하기로 결정했습니다.</p>
<blockquote>
<p>해당 글은 MySQL RDS 인스턴스를 미리 생성하였다는 가정 하에 작성되었습니다.</p>
</blockquote>
<h3 id="1-rds-파라미터-그룹-설정">1. RDS 파라미터 그룹 설정</h3>
<p>AWS RDS에서 Slow Query 로그 활성화를 하기 위해 파라미터 그룹을 설정 해주어야 합니다. </p>
<p>AWS RDS -&gt; 파라미터 그룹을 선택</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/419eb326-1909-46ac-9306-83b5254cc50d/image.png" alt=""></p>
<p>파라미터 그룹 생성 클릭</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/2094ea34-560b-4e79-b6a2-826d6d021a90/image.png" alt=""></p>
<p>이후 파라미터 그룹을 생성해주고 파라미터 그룹을 수정해주어야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/a98d5e06-3a51-48cb-928a-b2ec40806df0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/dcc36e2e-9aa7-4457-b99c-752f5906814f/image.png" alt=""></p>
<p>이때 수정해줘야 할 파라미터는 아래와 같습니다.</p>
<blockquote>
</blockquote>
<p>slow_query_log=1 (slow query 발생 시 로그 생성, Default=0)
long_query_time=4 (4초 넘게 실행되는 쿼리 대상으로 로그 생성)
log_output=FILE (Cloud Watch로 확인 시 FILE로 적용)</p>
<h3 id="2-rds-인스턴스-설정">2. RDS 인스턴스 설정</h3>
<p>이제 미리 생성해두었던 RDS 데이터베이스로 돌아가 </p>
<p>데이터베이스 수정 -&gt; 추가 구성 -&gt; 데이터베이스 옵션 에서 생성해 두었던 파라미터 그룹을 지정해줍니다</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/2522e4db-7d59-496c-a22e-b1c8e29716c7/image.png" alt=""></p>
<p>더 내려가 로그 내보내기에서 느린 쿼리 로그를 체크해주고 수정 사항을 저장해줍니다. </p>
<p>그리고 RDS 인스턴스를 재부팅 해줍니다.
<img src="https://velog.velcdn.com/images/penrose_15/post/25831757-009d-4965-bebb-ff4d555bb914/image.png" alt=""></p>
<p>RDS 인스턴스가 재부팅 되었다면 테스트를 위해 <code>SELECT SLEEP(5)</code> 쿼리를 실행 시킨 후 Cloud Watch의 로그 그룹으로 가서 확인하시면 Slow Query 가 저장 된 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/4e77cd04-4957-466a-897a-7484306b7388/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/305b6d64-1c6b-47df-b019-dda1f134d318/image.png" alt=""></p>
<h3 id="3-slack-webhook-설정">3. Slack Webhook 설정</h3>
<p>이제 Slow Query를 슬랙에서 알림 받기 위해서 Slack Webhook URL을 생성해주어야 합니다.</p>
<p>slack webhook 설정에는 2가지 방법이 있습니다. </p>
<ul>
<li><p>앱 생성 : <a href="https://api.slack.com/">https://api.slack.com/</a> -&gt; Your apps 에서 Create New App 버튼 클릭</p>
</li>
<li><p>앱 추가 : 해당 채널 -&gt; 앱 추가 -&gt; Incoming WebHooks 검색 후 추가</p>
</li>
</ul>
<p>앱 추가 방식은 만약 생성자가 채널에서 탈퇴를 하는 경우 webHooks도 비활성 되기 때문에 비추천 한다고 합니다. </p>
<p>해당 포스트에서는 앱 생성 방식으로 설명하도록 하겠습니다.</p>
<p><a href="https://api.slack.com/">https://api.slack.com/</a> -&gt; Your apps -&gt; Create New App 버튼 클릭 후 From scratch를 선택합니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/64dc1e1f-a738-434b-abb7-29a155e16fc3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/13adc6e2-df12-491d-add7-8fe44994b89f/image.png" alt=""></p>
<p>Create App 클릭 후, 먼저 Settings -&gt; Collaborators에서 동료를 추가해주어야 합니다. (본인이 채널에서 나가더라고 유지가 됩니다)</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/dc113853-88e5-44c0-81c1-1387e7a6bca2/image.png" alt=""></p>
<p>검색을 통해 추가하고 싶은 동료를 추가해주면 됩니다.</p>
<p>이제 Features -&gt; Incoming Webhooks -&gt; Activate Incoming Webhooks On -&gt; Add New Webhook to Workspace 에서 채널 선택 후 Allow를 누르면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/d048f841-cff4-4459-b5b9-2960793b01fb/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/8a253f52-9d49-4056-8012-a0703ce51bd4/image.png" alt=""></p>
<p>이후 Incoming Webhooks에서 Webhook URL 값을 기억해놓으시면 됩니다.</p>
<h3 id="4-lambda-생성">4. Lambda 생성</h3>
<p>Lambda함수를 통해 RDS에서 Slow Query 가 생성되면 이를 Cloud Watch에 저장하고 이를 슬랙으로 보내줄 겁니다.</p>
<p>AWS Lambda -&gt; 함수 생성을 클릭해주고 아래와 같이 작성해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/61ca151d-bb7c-455c-9117-a7d31436f304/image.png" alt=""></p>
<p>Lambda는 Python, Node.js, Ruby, Java 등 여러 언어를 지원해줍니다.</p>
<p>저 같은 경우  Node.js를 선택하였습니다. </p>
<blockquote>
<p><a href="https://theburningmonk.com/2017/06/aws-lambda-compare-coldstart-time-with-different-languages-memory-and-code-sizes/">Java의 경우 Lambda 실행 시 ColdStart에 걸리는 시간이 다른 언어보다 오래 걸리기 때문</a>에 그 다음으로 잘 하는 언어인 Javascript를 사용하게 되었습니다. (ColdStart는 람다를 처음 시작할 때 컨테이너가 실행되면서 걸리는 지연 시간을 뜻합니다.)</p>
</blockquote>
<p>이후 함수 코드를 작성해주시면 됩니다.</p>
<pre><code class="language-javascript">import https from &#39;https&#39;;
import zlib from &#39;zlib&#39;;

const SLOW_TIME_LIMIT = 4; // Slow Query 시간 기준
const SLOW_QUERY_SLACK_URL = `${위에서_만들어놓은_SLACK_WEBHOOK}`;

export const handler = (input, context) =&gt; {
    // (1) 
    const payload = Buffer.from(input.awslogs.data, &#39;base64&#39;);

    zlib.gunzip(payload, async (err, result) =&gt; {
        if (err) {
            return context.fail(err);
        }
    // (2) 
        const resultString = result.toString(&#39;utf8&#39;);
        let resultJson;

        try {
            resultJson = JSON.parse(resultString);
        } catch (parseErr) {
            console.error(parseErr.message);
            console.error(`[알람발송실패] JSON.parse(result.toString(&#39;utf8&#39;)) Fail, resultUTF8= ${resultString}`);
            return context.fail(parseErr);
        }

        console.log(`result json = ${resultString}`);

        for (const logEvent of resultJson.logEvents) {
            const logJson = toJson(logEvent, resultJson.logStream); // (3)
            try {
                if (logJson.queryTime &gt; SLOW_TIME_LIMIT) {
                    console.log(&#39;slow query message&#39;);
                    const message = slackMessage(logJson);  // (4)
                    await postSlack(message, SLOW_QUERY_SLACK_URL);   // (5)
                } 
            } catch (slackErr) {
                console.error(slackErr.message);
                console.error(`slack message fail= ${JSON.stringify(logJson)}`);
            }
        }
    });
};

const toJson = (logEvent, logLocation) =&gt; {
    const { message, timestamp } = logEvent;

    const splitMessages = message.split(&#39;#&#39;);
    const userInfos = splitMessages[2].split(&#39; &#39;);
    const queryInfos = splitMessages[3];
    const queryTime = queryInfos.split(&#39; &#39;)[2];
    const currentTime = toYyyymmddhhmmss(timestamp);
    const queryMessages = queryInfos.split(&#39;;&#39;);
    const queryMessage = queryMessages[queryMessages.length - 2];

    return {
        currentTime,
        logLocation,
        userIp: userInfos[5],
        user: userInfos[2],
        pid: userInfos[8],
        queryTime,
        query: queryMessage,
    };
}

const toYyyymmddhhmmss = (timestamp) =&gt; {
    if (!timestamp) {
        return &#39;&#39;;
    }

    const pad2 = (n) =&gt; (n &lt; 10 ? `0${n}` : n);

    const kstDate = new Date(timestamp + 32400000);
    return `${kstDate.getFullYear()}-${pad2(kstDate.getMonth() + 1)}-${pad2(kstDate.getDate())} ${pad2(kstDate.getHours())}:${pad2(kstDate.getMinutes())}:${pad2(kstDate.getSeconds())}.${pad2(kstDate.getMilliseconds().toPrecision(3))}`;
};


const slackMessage = (messageJson) =&gt; ({
    text: &#39;Slow Query 발생!&#39;,
    attachments: [
        {
            color: &#39;#ff7f00&#39;,
            title: `${messageJson.currentTime} 발생 Slow Query`,
            fields: [
                {
                    title: &#39;Query&#39;,
                    value: messageJson.query,
                    short: false,
                },
                {
                    title: &#39;Query Time&#39;,
                    value: messageJson.queryTime,
                    short: false,
                },
                {
                    title: &#39;Log Location&#39;,
                    value: messageJson.logLocation,
                    short: false,
                },
                {
                    title: &#39;Request User&#39;,
                    value: messageJson.user,
                    short: false,
                },
                {
                    title: &#39;Request IP&#39;,
                    value: messageJson.userIp,
                    short: false,
                },
            ],
        },
    ],
});

export const postSlack = async (message, slackUrl) =&gt; {
    const options = createRequestOptions(slackUrl);
    return request(options, message);
};

export const createRequestOptions = (slackUrl) =&gt; {
    const { host, pathname } = new URL(slackUrl);
    return {
        hostname: host,
        path: pathname,
        method: &#39;POST&#39;,
        headers: {
            &#39;Content-Type&#39;: &#39;application/json&#39;,
        },
    };
};

const request = (options, data) =&gt; new Promise((resolve, reject) =&gt; {
    const req = https.request(options, (res) =&gt; {
        res.setEncoding(&#39;utf8&#39;);
        let responseBody = &#39;&#39;;

        res.on(&#39;data&#39;, (chunk) =&gt; {
            responseBody += chunk;
        });

        res.on(&#39;end&#39;, () =&gt; {
            resolve(responseBody);
        });
    });

    req.on(&#39;error&#39;, (err) =&gt; {
        console.error(err);
        reject(err);
    });

    req.write(JSON.stringify(data));
    req.end();
});</code></pre>
<p>코드가 긴데 하나하나 설명해보자면</p>
<h4 id="1">(1)</h4>
<pre><code class="language-javascript">const payload = Buffer.from(input.awslogs.data, &#39;base64&#39;);

zlib.gunzip(payload, async (err, result) =&gt; { ... }</code></pre>
<p>Cloud Watch에서 전송되는 코드는 Base64로 인코딩 되어 있고, gzip 형식으로 압축되어 있습니다. 이를 압축 해제하고 Base64 디코딩을 해야 합니다. </p>
<h4 id="2">(2)</h4>
<pre><code class="language-javascript">const resultString = result.toString(&#39;utf8&#39;);
        let resultJson;

try {
    resultJson = JSON.parse(resultString);
} catch (parseErr) {
    console.error(parseErr.message);
    console.error(`[알람발송실패] JSON.parse(result.toString(&#39;utf8&#39;)) Fail, resultString= ${resultString}`);
    return context.fail(parseErr);
}</code></pre>
<p>(1)에서 생성한 Buffer 객체를 String 문자열로 변환해주고, JSON으로 직렬화 하고 있습니다. 
<code>result.toString(&#39;utf8&#39;)</code> 로 인코딩을 해주고 있는데 만약 <code>&#39;ascii&#39;</code>로 인코딩을 해주면 한글이 깨질 수 있습니다.</p>
<h4 id="3">(3)</h4>
<pre><code class="language-javascript">const logJson = toJson(logEvent, resultJson.logStream); // (3)

...

const toJson = (logEvent, logLocation) =&gt; {
    const { message, timestamp } = logEvent;

    const splitMessages = message.split(&#39;#&#39;);
    const userInfos = splitMessages[2].split(&#39; &#39;);
    const queryInfos = splitMessages[3];
    const queryTime = queryInfos.split(&#39; &#39;)[2];
    const currentTime = toYyyymmddhhmmss(timestamp);
    const queryMessages = queryInfos.split(&#39;;&#39;);
    const queryMessage = queryMessages[queryMessages.length - 2];

    return {
        currentTime,
        logLocation,
        userIp: userInfos[5],
        user: userInfos[2],
        pid: userInfos[8],
        queryTime,
        query: queryMessage,
    };
}</code></pre>
<p>json 형식의 데이터를 슬랙 메시지로 보내기 쉽게 가공하는 과정입니다.</p>
<p><code>toYyyymmddhhmmss(timestamp)</code>는 UTC 시간 데이터를 식별 가능한 KST로 변환해줍니다.</p>
<h4 id="4">(4)</h4>
<pre><code>const message = slackMessage(logJson);</code></pre><p>toJson을 통해 받은 JSON 데이터를 슬랙 메시지로 변환합니다. </p>
<h4 id="5">(5)</h4>
<pre><code>await postSlack(message, SLOW_QUERY_SLACK_URL);  </code></pre><p>슬랙 URL로 가공한 메시지를 전달합니다. 이때 비동기로 전송하기 위해 Promise 객체로 만들어줍니다. </p>
<p>이제 테스트를 해볼 차례입니다.</p>
<p>AWS Lambda &gt; 테스트로 들어가면 람다로 테스트 메시지를 보낼 수 있는데 </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/b5398837-25b0-47ff-b25a-3806ef5987a8/image.png" alt=""></p>
<p>메시지 형식은 아래와 같이 보내야 합니다. </p>
<pre><code>{
  &quot;awslogs&quot;: {
    &quot;data&quot;: &quot;gzip로 압축된 데이터&quot;
  }
}
</code></pre><p>여기서 data 내부의 내용에 MySQL 쿼리 로그를 입력해야 하는데 이때 gzip로 압축을 해줘야 합니다. </p>
<p>압축 전 데이터는 아래와 같습니다.</p>
<pre><code>{
  &quot;messageType&quot;: &quot;DATA_MESSAGE&quot;,
  &quot;owner&quot;: &quot;123456789123&quot;,
  &quot;logGroup&quot;: &quot;testLogGroup&quot;,
  &quot;logStream&quot;: &quot;testLogStream&quot;,
  &quot;subscriptionFilters&quot;: [
    &quot;testFilter&quot;
  ],
  &quot;logEvents&quot;: [
    {
      &quot;id&quot;: &quot;eventId1&quot;,
      &quot;timestamp&quot;: 1440442987000,
      &quot;message&quot;: &quot;# Time: 2024-08-17T02:56:39.392215Z
# User@Host: user[user] @  [123.456.789.10]  Id: 11111 # Query_time: 5.000313  Lock_time: 0.000000 Rows_sent: 1  Rows_examined: 1 use dbname;
SET timestamp=1723863394; select sleep(5) LIMIT 0, 1000;&quot;
    }
  ]
}</code></pre><p>이 JSON 데이터를 <a href="https://www.multiutil.com/text-to-gzip-compress/">압축 사이트</a> 에서 압축하시면 테스트 데이터를 얻을 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/e47a3ab8-3702-45b8-999b-f356fa12d9e6/image.png" alt=""></p>
<p>이제 테스트를 해보시면 슬랙 메시지가 오는 것을 확인할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/571a87a5-9290-4d30-b96c-cd42a2b1de96/image.png" alt=""></p>
<h3 id="5-cloudwatch--lambda-연동">5. CloudWatch &amp; Lambda 연동</h3>
<p>Cloud Watch 로 들어가 해당 로그 그룹 선택 -&gt; 작업 -&gt; 구독필터 -&gt; Lambda 구독 필터 생성을 차례로 선택합니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/06ea512a-05e1-4703-b970-04f15930e7f6/image.png" alt=""></p>
<p>구독 필터 생성 화면에 들어가면 생성한 람다 함수를 등록합니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/893ab20b-cd7f-4be3-a084-258cc049f9f1/image.png" alt=""></p>
<p>아래로 내려가보시면 로그 형식 및 필터 구성이 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/192158df-0d7e-45d2-bcca-f75ab0d5075c/image.png" alt=""></p>
<p>여기서 구독 필터 이름을 지정해주시고 구독 필터 패턴 같은 경우 필요한 형태에 따라 적어주시면 됩니다. (ex : 조회만 필터링 하고 싶다면 <code>SELECT</code> 를 적어 놓으시면 됩니다.)</p>
<p>아래로 내려가면 필터링 테스트를 할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/0040b5f1-4370-4c38-a103-cbffc76337c9/image.png" alt=""></p>
<p>스트리밍 시작을 누르면 더이상 수정이 어려우니 여기서 테스트를 하는 것을 추천합니다.</p>
<p>이제 모든 설정이 끝났으므로 본인의 DB 에 테스트를 해보면</p>
<p><code>SELECT SLEEP(5);</code></p>
<p>그럼 아래와 같이 슬랙 알림이 오는 것을 확인할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/63e586d5-1200-49eb-b2bb-dfea2750ac72/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis SortedSet + Websocket 활용한 대기열 서비스 구현(Spring Boot)]]></title>
            <link>https://velog.io/@penrose_15/Redis-Websocket-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8C%80%EA%B8%B0%EC%97%B4-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B5%AC%ED%98%84Spring-Boot</link>
            <guid>https://velog.io/@penrose_15/Redis-Websocket-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8C%80%EA%B8%B0%EC%97%B4-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B5%AC%ED%98%84Spring-Boot</guid>
            <pubDate>Tue, 09 Jul 2024 04:42:49 GMT</pubDate>
            <description><![CDATA[<h2 id="계기">계기</h2>
<p>개인 프로젝트로 진행중인 선착순 퀴즈 서비스에 대기열 서비스를 추가하기로 했습니다. </p>
<p>기존 선착순 입장 기능을 구현할 때, 동시성 이슈를 해결하기 위해 Redisson을 활용하였습니다.</p>
<p>하지만 Redisson 자체로는 사용자의 순서를 보장해주지 못합니다.</p>
<p>이를 해결하기 위해 Redis Sorted Set를 활용하여 순서를 보장해주고, 클라이언트와 본인의 대기 순서를 실시간으로 알 수 있도록 WebSocket를 활용하였습니다.</p>
<h2 id="redis-sorted-set">Redis Sorted Set</h2>
<p>Redis Sorted Set는 Java의 Set 처럼 고유의 값을 가지는 컬렉션입니다.</p>
<p>다만 차이점이라면 Sorted Set 내부의 값들은 score 라는 값을 기준으로 정렬됩니다.(스코어가 낮은 순에서 높은 순으로 정렬)</p>
<p>여기서 score를 날짜-시간으로 지정해두면 사용자를 선착순으로 정렬 할 수 있습니다.</p>
<h2 id="대기열-서비스-요구사항">대기열 서비스 요구사항</h2>
<ul>
<li>들어온 순서대로 입장이 가능해야 한다(순서 보장).</li>
<li>대기열에서 본인의 앞에 몇 명 있는지 알 수 있어야 한다.</li>
<li>유저마다 성공/혹은 실패인지 알 수 있어야 한다. </li>
</ul>
<h2 id="flow">FLOW</h2>
<ol>
<li>사용자가 퀴즈 서비스에 접근하기 위해 REST API로 요청을 보냅니다.  (GET<code>/wait/{endpoint}</code>)</li>
<li>사용자를 Waiting Queue(Redis SortedSet)에 추가합니다.</li>
<li>QueueScheduler는 n초에 1번씩 Queue 내부의 모든 사용자에게 본인의 앞에 몇 명 있는지 정보 전송합니다.(websocket)</li>
<li>앞에서 10명씩 queue에서 poll 한 후, 퀴즈 페이지로 이동시킵니다.</li>
<li>1-4번 까지 반복합니다.</li>
</ol>
<h2 id="구현">구현</h2>
<h3 id="1-participantinfocontroller">1. ParticipantInfoController</h3>
<pre><code class="language-java">@GetMapping(&quot;/wait/{endpoint}&quot;)
    public ResponseEntity&lt;ResponseDto&lt;?&gt;&gt; saveParticipant(@PathVariable(&quot;endpoint&quot;) String endpoint,
                                                          @AuthenticationPrincipal UserAccount user) {
        // queue 에 참가자 추가
        Users users = usersService.findByEmail(user.getUsername());

        Long quizId = participantInfoFacade.getQuizIdByEndpoint(endpoint);
        Long rank = participantInfoQueueService.addQueue(quizId, users.getId());
        return ResponseEntity.ok(ResponseDto.success(new ParticipantQueueResponseDto(quizId, users.getId(),rank)));
    }</code></pre>
<p>사용자가 API에 접근하면 Waiting Queue(Redis SortedSet)에 추가합니다.</p>
<h3 id="2-queuescheduler">2. QueueScheduler</h3>
<pre><code class="language-java">@Slf4j
@EnableScheduling
@Component
@RequiredArgsConstructor
public class QueueScheduler {
    private final ParticipantInfoQueueRepository participantInfoQueueRepository;
    private final ApplicationEventPublisher eventPublisher;
    private final SimpMessagingTemplate messagingTemplate;

    @Async
    @Transactional(value = &quot;redisTx&quot;)
    @Scheduled(fixedDelay = 2000)
    public void showRankAndPollUser() {
        Set&lt;Long&gt; quizIdSet = participantInfoQueueRepository.getQuizIdSet();

        for (Long quizId : quizIdSet) {
            Set&lt;Long&gt; allUsersInQuiz = participantInfoQueueRepository.getAllUsers(quizId);
            Set&lt;Long&gt; tenUsersInQuiz = participantInfoQueueRepository.get10Users(quizId);
            for (Long userId : allUsersInQuiz) {
                Long rank = participantInfoQueueRepository.getRank(quizId, userId);
                rank = rank == null ? 0 : rank;
                log.info(&quot;userId = {}, rank = {}&quot;, userId, rank);
                String endpoint = String.format(&quot;?quiz-id=%d&quot;, quizId);

                messagingTemplate.convertAndSend(&quot;/topic/rank&quot; + endpoint, rank);

                if (tenUsersInQuiz.contains(userId)) {
                    Integer capacity = participantInfoQueueRepository.getParticipantNumber(quizId);
                    if (capacity &gt; 0) {
                        log.info(&quot;capacity = {}&quot;, capacity);
                        boolean isUserTurn = rank &lt; 10L;
                        eventPublisher.publishEvent(new ParticipantQueueInfoDto(quizId, userId, rank, true, isUserTurn));
                    } else {
                        eventPublisher.publishEvent(new ParticipantQueueInfoDto(quizId, userId, rank, false, false));
                    }
                }
                participantInfoQueueRepository.delete10Users(quizId, (long) tenUsersInQuiz.size());
            }

        }
    }
}</code></pre>
<p>1초에 1번씩 queue 내부의 유저 정보들을 loop 타면서 유저 순서 정보를 전송합니다. 
만약 유저가 Sorted Set의 상위 10명 내에 포함되면 유저 순서 정보와 함께 유저가 선착순 안에 들었는지, 실패했는지에 대해 전달하는 이벤트를 발생시킵니다.</p>
<h3 id="3-customeventlistener">3. CustomEventListener</h3>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
@Component
public class CustomEventListener {
    private final ResponsesFacade responsesFacade;
    private final ParticipantInfoFacade participantInfoFacade;
    private final SimpMessagingTemplate messagingTemplate;


    @Async
    @Transactional(value = &quot;mongoTx&quot;,propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendMessage(ParticipantQueueInfoDto participantQueueInfoDto) {
        Long quizId = participantQueueInfoDto.quizId();
        Long userId = participantQueueInfoDto.userId();

        log.info(&quot;quizId = {}, userId = {}&quot;, quizId, userId);
        String endpoint = String.format(&quot;?quiz-id=%d&amp;user-id=%d&quot;,quizId, userId);
        messagingTemplate.convertAndSend(&quot;/topic/participant&quot; + endpoint, participantQueueInfoDto);
        //참여 가능 시
        if(participantQueueInfoDto.isCapacityLeft()) {
            log.info(&quot;save user start&quot;);
            participantInfoFacade.saveParticipants(quizId, userId);
        }

    }
}</code></pre>
<p>QueueScheduler에서 이벤트가 발생되면 eventListener에서 websocket을 통해 사용자에게 유저 순서 정보와 함께 유저가 선착순 안에 들었는지, 실패했는지에 대한 정보를 전달합니다. </p>
<p>이후 순서에 들어간 유저들에게 퀴즈 화면을 보여줍니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[디프만 15기 합격후기]]></title>
            <link>https://velog.io/@penrose_15/%EB%94%94%ED%94%84%EB%A7%8C-15%EA%B8%B0-%ED%95%A9%EA%B2%A9%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/%EB%94%94%ED%94%84%EB%A7%8C-15%EA%B8%B0-%ED%95%A9%EA%B2%A9%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Tue, 11 Jun 2024 11:17:21 GMT</pubDate>
            <description><![CDATA[<p>디프만 15기 붙었다 
아싸</p>
<p>암튼 시작하는 디프만 15기 합격 후기</p>
<h3 id="지원-계기">지원 계기</h3>
<p>필자는 현재 SI 재직중인 백엔드 개발자이다.</p>
<p>그러다보니 서비스 회사로 이직하고 싶다고 항상 노래를 부르지만</p>
<p>요즘 시장이 꽁꽁 얼어붙다보니 이력서를 써도 답이 없다.</p>
<p>그래서 이직 준비 겸 공부를 위해 사이드 플젝을 준비하려다가</p>
<p>오픈 채팅방에서 디프만 모집한다길래 혼자 독수공방 하기보단 다같이 공부하고 역할 나누어서 사이드 프로젝트 하는게 여러모로 도움되겠다 싶어 지원하게 되었다.</p>
<h3 id="서류">서류</h3>
<p>디프만은 서류에서 자소서를 작성해야 하는데</p>
<p>항목이 무려 8개(!) 나 되었다.</p>
<p>여기서 살짝 빤스런 칠까 생각했다가 지원 안하면 후회할 것 같아 정신줄 잡고 작성하였다. </p>
<h4 id="1-지원동기">1. 지원동기</h4>
<p>이전에 한 팀 프로젝트 경험을 통해 성장한 경험을 배경으로 혼자 하기보단 다른 사람들과 프로젝트를 통해 성장하고 싶어서 지원했다고 작성했다.</p>
<h4 id="2-가장-관심을-가지고-몰입했던-경험을-소개하고-해당-경험이-본인에게-어떠한-영향을-주었는지-설명해-주세요">2. 가장 관심을 가지고 몰입했던 경험을 소개하고, 해당 경험이 본인에게 어떠한 영향을 주었는지 설명해 주세요.</h4>
<p>개발자 되기 전 물리치료사로 일하면서 혼자서 자바, 스프링을 공부하고 블로그 프로젝트를 완성한 경험을 녹여서 작성했다.</p>
<h4 id="3-프로젝트에서-실패했던-경험-혹은-성공적으로-마무리한-경험이-있다면-알려주세요-실패-혹은-성공-이유가-무엇이라고-생각하는지-작성해주세요">3. 프로젝트에서 실패했던 경험 혹은 성공적으로 마무리한 경험이 있다면 알려주세요. 실패 혹은 성공 이유가 무엇이라고 생각하는지 작성해주세요.</h4>
<p>팀 프로젝트를 하면서 프로젝트 초기에 만든 팀 룰을 통해 프로젝트를 성공으로 이끌어냈다고 작성했다.</p>
<h4 id="4-팀-프로젝트에서-기존에-알고-있거나-새롭게-알게된-지식을-팀원들에게-공유한-경험을-소개해-주세요-공유를-받은-경험을-작성해주셔도-좋습니다">4. 팀 프로젝트에서 기존에 알고 있거나, 새롭게 알게된 지식을 팀원들에게 공유한 경험을 소개해 주세요. 공유를 받은 경험을 작성해주셔도 좋습니다</h4>
<p>이전에 팀 프로젝트에서 AWS S3를 활용하여 이미지 업로드/조회 기능을 구현하고 원리와 사용법을 팀원들에게 공유한 경험을 작성했다.</p>
<h4 id="5-팀-프로젝트-혹은-업무를-진행하면서-팀원과의-의견-충돌을-어떻게-해결했는지에-대한-경험을-소개해-주세요-그-상황을-어떻게-해결하려-노력했고-극복했는지에-대해-설명해-주세요">5. 팀 프로젝트 혹은 업무를 진행하면서 팀원과의 의견 충돌을 어떻게 해결했는지에 대한 경험을 소개해 주세요. 그 상황을 어떻게 해결하려 노력했고 극복했는지에 대해 설명해 주세요.</h4>
<p>팀 프로젝트를 하면서 데모데이 2일전에 기능 하나 추가해달라고 요청한 프론트엔드 개발자분을 어떻게 설득하고 합의점을 냈는지에 대해 작성</p>
<h4 id="6-주로-사용하는-언어-프레임워크">6. 주로 사용하는 언어 프레임워크</h4>
<p>SpringBoot와 Node.js에 대해 작성하고 각각의 장점과 어떤 상황에 쓰면 좋은지에 대해 작성했다.</p>
<h4 id="7-프로젝트-배포운영-경험">7. 프로젝트 배포/운영 경험</h4>
<p>회사에서 진행한 프로젝트를 AWS CodeDeploy 를 통해 배포하고 트러블 슈팅한 경험, 운영하면서 이슈 사항을 Jira로 공유한 경험을 작성하였다.</p>
<h4 id="8-본인의-깃허브나-포트폴리오-등-본인을-나타낼-수-있는-것">8. 본인의 깃허브나 포트폴리오 등 본인을 나타낼 수 있는 것</h4>
<p>그동안 진행한 프로젝트, 블로그, 스터디에 대해 간략하게 작성하였다.</p>
<h3 id="서합과-면접-준비">서합과 면접 준비</h3>
<p>그렇게 서류를 작성하고 5/15일에 결과가 발표되었다.</p>
<p>결과는 서류합격</p>
<p>이제 면접을 봐야 하는데 zep로 화상면접을 본다 했다.</p>
<p>면접 준비는 저번 기수에 나왔던 질문과 작성한 자소서를 기반으로 준비를 했으나...</p>
<h3 id="화상-면접">화상 면접</h3>
<p>면접은 Zep에서 각 파트별로 진행되었고 다대다 면접으로 진행되었다.(면접관 2, 지원자 2)</p>
<p>면접은 약 25분간 진행되었다.</p>
<p>참고로 면접을 코코볼 마냥 말아먹었다.
아까 위에서 면접 준비를 <code>저번 기수에 나왔던 질문</code> 로 준비를 했다 했는데</p>
<p>면접 질문이 저번 기수와는 전혀 다르게 나왔다.</p>
<p>사실 마인드 컨트롤이라도 잘 하고 면접에 임했어야 했는데 방심하다 허찔려서 횡설수설 하고 나왔다.</p>
<p>아래는 필자가 받은 면접 질문이다.</p>
<p><strong>인성면접</strong></p>
<ol>
<li>본인 자기소개</li>
<li>팀에 어떻게 기여할 것인가?</li>
</ol>
<p><strong>기술면접</strong></p>
<ol>
<li>Nodejs의 싱글스레드 장단점</li>
<li>DB에서 인덱스 구조를 왜 HashTable로 사용하지않고 왜 B* Tree 구조을 사용하는가?</li>
</ol>
<p><strong>기타</strong></p>
<ol>
<li>본인은 리더인가 아니면 팔로워인가?</li>
</ol>
<p>면접 준비는 이전 기수 면접 질문은 참고만 하고 할 수 있다는 암시를 걸어주는게 좋다. (추가적으로 자소서에 작성한 프로젝트의 기술에 대한 답변은 숙지해놓아야 한다)</p>
<p>필자는 그렇지 않아서 말아먹었기 때문이다.</p>
<p>그렇게 디프만 불합격 통보를 받게 되었는데...</p>
<p>추합되었다</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/d733284e-5258-4654-99b3-8ccd28549832/image.jpg" alt=""></p>
<p>?
?
??
????</p>
<p>이에에에에에에ㅔㅔㅔ!!!!</p>
<p>그렇게 16주 동안 디프만 15기 활동을 하게 되었다</p>
<p>회사생활이랑 병행해야해서 당분간 겁나 바빠지겠지만... 그만큼 얻는것도 많을 것 같다 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024 스프링 캠프 갔다온 후기]]></title>
            <link>https://velog.io/@penrose_15/2024-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BA%A0%ED%94%84-%EA%B0%94%EB%8B%A4%EC%98%A8-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/2024-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BA%A0%ED%94%84-%EA%B0%94%EB%8B%A4%EC%98%A8-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Sun, 26 May 2024 08:05:43 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/penrose_15/post/e0978184-0d62-4280-910d-43f4c3ee8a25/image.png" alt=""></p>
<h2 id="티켓팅">티켓팅</h2>
<p>스프링 캠프 티켓 오픈한다길래 락덕후 5년 경력을 살려 티켓팅을 한 결과 티켓을 구매할 수 있었습니다.(1분만에 매진...)</p>
<h2 id="세션-내용-정리">세션 내용 정리</h2>
<h3 id="동시성의-미래---코루틴과-버츄얼-스레드">동시성의 미래 - 코루틴과 버츄얼 스레드</h3>
<p><strong>전통적인 웹 동작 방식</strong></p>
<p>1개의 요청당 1개의 스레드를 사용하는 Thread Per Request 모델</p>
<p>요청을 처리할 때까지 해당 스레드는 블로킹 됩니다. </p>
<p>Spring MVC 패턴을 예시로 들 수 있습니다.</p>
<blockquote>
<p>플랫폼 스레드 문제점
커널 스레드를 사용하므로 무한정 스레드 생성이 불가능하다. 또한 컨텍스트 스위칭이 발생한다. </p>
</blockquote>
<p>Thread Per Request 모델의 문제점을 해결하기 위해 스레드 풀을 통해 미리 스레드를 만들어 놓은 후 가져다 쓰는 방식으로 사용했으나 이러한 방식도 결국 스레드 풀에 생성된 스레드 개수만큼만 자원이 한정되었습니다.</p>
<p>비동기-논블로킹 방식은 그래도 자원문제를 해결할 수 있었으나 가독성(콜백 헬)과 유지보수성이 감소하는 문제가 있었습니다.</p>
<p><strong>리엑티브 프로그래밍</strong></p>
<p>비동기-논블로킹 방식으로 기존 방식의 콜백 헬 문제를 해결할 수 있습니다. (ex: ProjectReactor, RxJava, Webflux)</p>
<p>실시간 데이터 처리에 용이하고 높은 트래픽을 감당할 수 있게 되었으나 러닝커브가 있다는 단점이 있습니다.</p>
<p><strong>코틀린 코루틴</strong></p>
<p>비동기 프로그래밍을 손쉽게 사용 가능한 확장형 라이브러리입니다. </p>
<p>비동기-논블로킹 방식임에도 전통적인 방식처럼 코드 작성이 가능합니다.</p>
<p>전통적인 플랫폼 스레드 위에 여러 코루틴(경랑 스레드)이 띄워져 있는 방식으로 스레드 블로킹을 막을 수 있습니다.</p>
<p><strong>가상 스레드</strong></p>
<p>JDK21에서 나온 기술로 다수의 경량 스레드가 소수의 캐리어 스레드와 매핑되는 방식으로 동작합니다. </p>
<p>가상 스레드의 이점은 기존의 코드에서 가상 스레드 설정만 해주면(스프링 3.2이전에는 빈 설정, 3.2이상에서는 yml에서 설정) 기존의 코드 그대로 사용해도 됩니다.(기존의 Thread.sleep()을 사용해도 스레드가 블로킹되지 않음)</p>
<p><strong>성능 테스트 결과</strong></p>
<p>백엔드에서 플랫폼 스레드, 코루틴 + 블로킹, 가상 스레드, spring webflux + 코루틴 4가지 방식으로 각각 성능 테스트를 해본 결과 </p>
<ul>
<li><p>플랫폼 스레드 : 지연 발생(8초), 성공률 스레드 수에 비례</p>
</li>
<li><p>코루틴 + 블로킹 : 지연 발생(8초), 성공률 스레드 수에 비례, 코루틴 + 블로킹은 성능에 전혀 도움이 되지 못함</p>
</li>
<li><p>가상 스레드 : 지연 발생 없음, 성공률 100퍼</p>
</li>
<li><p>spring webflux + 코루틴 : 지연 발생 없음, 성공률 100퍼</p>
</li>
</ul>
<p><strong>코루틴 + 가상 스레드 통합</strong></p>
<p>코루틴과 버츄얼 스레드를 같이 사용할 수 있다 합니다.</p>
<p>이러한 경우 코루틴이 가상 스레드 위에서 동작합니다. </p>
<p>이를 통해 블로킹이 필요한 경우에는 가상스레드를 활용하고 가상 스레드에서 지원하지 않는 기능은 코루틴의 고급 라이브러리 기능을 활용하여 구현할 수 있었다고 합니다. </p>
<h3 id="llm에도-봄이-찾아오다">LLM에도 봄이 찾아오다</h3>
<p>생성형 AI에 대한 설명과 스프링 AI의 동작 원리에 대한 내용이었습니다. </p>
<p>AI에 대해 사전 지식이 없어 많은 내용을 이해하지 못했으나 요약을 하자면</p>
<ul>
<li><p>기존의 AI(다양한 형태의 인풋 -&gt; 텍스트 형태의 아웃풋)와 달리 생성형 AI는 텍스트 인풋으로 이미지, 오디오 등 다양한 형태의 아웃풋을 얻을 수 있습니다.</p>
</li>
<li><p>생성형 AI의 성능은 파라미터 개수와 비례한다고 합니다. </p>
</li>
<li><p>스프링에서도 LLM을 활용할 수 있다고 합니다. 여러 AI 모델의 Provider를 통합하였고, 주요 벡터 DB또한 활용할 수 있다고 합니다. 무엇보다도 Spring 답게 POJO를 지원해주어 편리하게 개발할 수 있다고 합니다. (다만 아직 실무에 쓰기에는 아직 이르다고 한다.)</p>
</li>
<li><p>Spring AI 구성 요소 플로우는 아래와 같이 진행됩니다. </p>
<ol>
<li>채팅옵션 초기값으로 client 생성</li>
<li>텍스트 오디오 비디오 등 처리에 필요한 데이터를 포함한 지시사항이 생성</li>
<li>실행 시점에 사용할 수 있는 채팅 옵션 제공</li>
<li>지시사항이 AI 모텔이 이해할 수 있는 네이티브 요청으로 변환</li>
<li>클라이언트가 런타임 채팅 옵션으로 오버라이드</li>
<li>네이티브 요청이 API 호출을 통해 AI 모델로 전달</li>
<li>AI 모델이 요청 처리</li>
</ol>
</li>
</ul>
<h3 id="왜-나는-테스트를-작성하기-싫은가">왜 나는 테스트를 작성하기 싫은가</h3>
<p>네이버 개발자분이 네이버 페이 개발을 하는동안 테스트를 작성하면서 겪은 여러 애로사항과 이를 해결하는 과정, Fixture monkey 오픈소스에 대한 내용이었습니다.</p>
<p>테스트 작성은 미래의 유지보수 비용을 줄여주고 사전에 오류를 잡아줄 수 있는 이득이 있으나 테스트 작성으로 인해 발생하는 비용(테스트 유지보수 비용) 또한 고려를 해야 한다는 내용이었습니다. </p>
<h3 id="실전-msa-개발-가이드">실전 MSA 개발 가이드</h3>
<p>이 강의는 MSA를 적용하면서 발생하는 트랜잭션, DB 분리 문제를 주로 다루었습니다.</p>
<p><strong>DB 분리</strong></p>
<p>MSA를 위해 DB를 분리하면 다른 도메인의 데이터를 가져다 써야 할 때 조회속도가 느려질 수 있습니다. </p>
<p>이를 해결할 방법으로는 아래와 같은 방법이 있다 합니다.</p>
<ol>
<li>DB 복제 : 다른 DB에서 필요한 속성만 따로 복제하여 저장</li>
<li>모델링 변경 : 공통 속성은 각 서비스의 DB에 저장해놓고 오너십을 가진 서비스로 데이터 이전</li>
<li>API로 전송 : 일괄 전송 방식으로 데이터를 전달하는 방식이 가장 낫고 병렬조회는 오히려 서버 부하를 늘릴 수 있어 비추</li>
<li>로컬 캐시 : 로컬 캐시를 활용하여 조회속도 향상</li>
</ol>
<p><strong>트랜잭션</strong></p>
<p>MSA로 인해 트랜잭션의 원자성, 독립성 원칙에 문제가 생길 수 있다합니다.</p>
<ol>
<li>원자성 보안</li>
</ol>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/8e69b76f-27e0-46a1-9a25-d67734a89062/image.jpg" alt=""></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/8a0d8739-439b-4e5f-9220-cb50f71e0114/image.jpg" alt=""></p>
<p>A에서 작업 후, B에서 작업할 때 오류가 발생하여 롤백이 된다면 A에서도 똑같이 롤백을 해야 합니다. </p>
<p>그러나 만약 B에서 A로 요청을 할 때 오류가 나서 롤백 요청을 전달하지 못한다면 A와 B의 정보가 일치하지 않게 되는 문제가 발생합니다. </p>
<p>이는 이벤트를 활용하여 재시도를 하는 방식으로 해결할 수 있습니다.</p>
<p>혹은 트랜잭션을 굳이 같이 묶지 않아도 되는 경우(실패해도 되는 기능이라면) 트랜잭션을 분리해도 됩니다.</p>
<ol start="2">
<li>일관성 보장</li>
</ol>
<p>MSA의 경우 서비스 간 트랜잭션이 Read Uncommited 수준으로 떨어집니다. </p>
<p>A서비스와 B서비스에서 연계를 진행하는 동안 트랜잭션이 완료되지 않은 상태에서 사용자가 서비스를 조회하면 Dirty Read가 발생할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/99c2f225-f0bb-412d-b8b0-b38591750d39/image.jpg" alt=""></p>
<p>이를 해결하려면 Application 레벨에서 lock를 걸어야 합니다.</p>
<h3 id="구해줘-홈즈-은행에서-3천만-트래픽의-홈-서비스-새로-만들기">구해줘 홈즈: 은행에서 3천만 트래픽의 홈 서비스 새로 만들기</h3>
<p>이 강의에서는 카카오뱅크에서 서비스를 분리하는 여정을 설명하였다.</p>
<p>의존관계가 복잡하게 꼬인 문제를 해결하기 위해 헥사고날 아키텍처를 도입하고, 성능적 문제를 해결하기 위해 코루틴을 활용하여 동시성을 적용하는 등 서비스를 분리하면서 마주친 문제와 이를 해결하기 위해 고민한 흔적들을 공유해주셨습니다.</p>
<p>서비스 이관을 위해 기존의 서버와 새로운 서버의 응답을 표본 비교를 해보고, A/B 테스트를 통해 기존 서비스에서 새로운 서비스로 트래픽을 조절하는 방식을 활용했다고 합니다. </p>
<hr>
<h2 id="스프링-캠프를-다녀오고">스프링 캠프를 다녀오고</h2>
<p>스프링 캠프 강의 전반적으로 퀄리티가 좋아 얻어간 지식과 시야를 넓힐 수 있는 기회가 되었습니다. </p>
<p>가장 인상깊었던 강의는 자바 가상 스레드와 코루틴 강의였는데 가상 스레드와 코루틴에 대한 설명과 이를 동시에 적용하여 서로 부족한 부분을 보완했다는 점이 인상 갚었습니다.</p>
<p>스프링 캠프 이후 스프링 캠프 참여한 다른 분들과 저녁식사를 하면서 강의를 들으면서 느낀점이나 감상들을 공유할 수 있었고, 이후 카페에 가서 코드리뷰도 받아 볼 수 있었습니다. </p>
<p>개발자 컨퍼런스의 참여를 하면 좋은 점은 지식 뿐만이 아니라 다양한 사람들을 만날 수 있는 기회도 된다는 것을 이번 기회로 알게 되었습니다.</p>
<hr>
<p>+) 여담으로 유쾌한 스프링방(유스방) 오픈채팅방에서 스프링 캠프를 진행하는 SETEC 건물 내에 곳곳에 붙어있는 불꽃 그림 종이를 찾으면 <code>인텔리제이 IntelliJ IDEA 자바 프로그래밍 필수 도구</code> 책을 준다해서 커피 브레이크 시간에 건물 내를 둘러봤는데 종이를 찾을 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/33d80efe-c800-471c-80d8-566b53ef7995/image.jpg" alt=""></p>
<p>덕분에 책도 받고 저자 분도 뵐 수 있었습니다. (책 감사합니다)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 개발자의 개인 플젝 프론트엔드 작업기]]></title>
            <link>https://velog.io/@penrose_15/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8A%94-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%ED%99%94%EB%A9%B4%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@penrose_15/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8A%94-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%ED%99%94%EB%A9%B4%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 17 May 2024 13:32:13 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트를 한다면 왠만한 경우 프론트엔드, 백엔드를 둘다 손대야 할 것이다. </p>
<p>풀스택 개발자라면 하던대로 둘다 작업할 수 있겠으나 필자는 디자인 감각 제로 백엔드 개발자여서 프론트엔드 작업은 개인 프로젝트에서 해결해야 할 산 중 하나였다.</p>
<h2 id="프론트엔드-작업을-하기-앞서">프론트엔드 작업을 하기 앞서</h2>
<p>프론트엔드 작업을 하기 전 필자가 중요하게 생각한 항목은 아래 세 가지였다.</p>
<ol>
<li>CSR vs SSR</li>
<li>채택할 프레임워크</li>
<li>디자인은 어떻게 할 것인가</li>
</ol>
<h3 id="csr-vs-ssr">CSR vs SSR</h3>
<p>먼저 프로젝트가 CSR에 적합할지, SSR에 적합할지 고려를 해야 했다.</p>
<p>만약 프로젝트가 트래픽이 많이 몰리고 사용자와의 상호작용이 많다면 CSR을 선택해야 할 것이고, 검색엔진에 노출이 많이 되어야 한다면 SSR을 선택해야 했다.</p>
<p>만약 둘다 사용을 해야 한다면? Universal Rendering을 선택해야 했다</p>
<p>CSR의 경우 React, Vue, Angular 를 사용하면 되고
SSR의 경우 Thymleaf, Next.js, Nuxt.js를 사용하면 된다.</p>
<p>SSR은 그래도 Spring 진영 개발자라면 Thymleaf같은 선택지가 있어서 진입장벽이 낮으나 (Next.js를 안 쓴다는 가정하에...)</p>
<p>CSR같은 경우 프레임워크(혹은 라이브러리) 학습이 필요해서 진입장벽이 높다는 단점이 있었다.</p>
<p>필자의 경우 선착순 서비스를 만들고 싶어 높은 트래픽을 가정하고 만든 서비스이다 보니 CSR을 선택하게 되었다.</p>
<h3 id="활용할-프레임워크-선택">활용할 프레임워크 선택</h3>
<p>이제 CSR 혹은 SSR 둘 중 하나를 선택했다면 이제 이 중 어떤 프레임워크를 사용할지 선택해야 했다. </p>
<p>필자는 CSR을 채택하다보니 대충 선택지가 Vue.js나 React 로 좁혀졌는데</p>
<p>필자는 이전에 React를 잠깐 만져본 경험이 있어 React를 선택하게 되었으나</p>
<p>보통의 경우 진입장벽이 낮은 Vue를 추천한다. </p>
<p>백엔드 개발자로서 화면에 힘 쏟을 시간에 백엔드 개발에 정성을 더 추가하는게 이득일테니깐</p>
<h3 id="와이어-프레임-설계">와이어 프레임 설계</h3>
<p>이제 렌더링 방식과 프레임워크를 선택했으니 화면을 뽑아내기 전에 와이어 프레임을 설계해야 했다. </p>
<p>개인 프로젝트를 기획하면서 만든 요구사항 정의서와 벤치마킹한 사이트를 기반으로 와이어 프레임을 만들었다.</p>
<p>필자의 경우 퀴즈 사이트가 프로젝트 주제이다보니 google form을 벤치마킹하였다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/937f62c1-4769-49b1-8e35-7728d9330df9/image.png" alt=""></p>
<p>추가적으로 화면 흐름이 어떻게 흘러갈 것인지 고려하면서 와이어프레임을 만들어야 했다. </p>
<p>또한 어떤 컴포넌트(버튼, 네비 바, 드롭박스, 페이지네이션 등등...) 들이 필요할지, 아니면 대체가 가능할지 대략적으로 생각을 해두었다.(너무 매몰되지 않는 선에서)</p>
<h4 id="와이어-프레임-툴">와이어 프레임 툴</h4>
<ol>
<li>Miro
필자가 개인 프로젝트에 사용한 툴이다. 개인적으로 가장 세련되고 사용하기 편했던 툴이었다. 
<img src="https://velog.velcdn.com/images/penrose_15/post/4bc3a32f-c01e-47dc-8985-dea10810189c/image.png" alt=""></li>
</ol>
<ol start="2">
<li><del>카카오 오븐</del>
<del><a href="https://ovenapp.io/">https://ovenapp.io/</a></del></li>
</ol>
<p><del>이전에 몇번 사용해 본 툴 중 하나이다.
제공되는 컴포넌트(검색창, 네비게이션 바, 테이블...)가 다양하다는 장점이 있다.</del></p>
<p>서비스 종료 되었다고 한다</p>
<h3 id="템플릿-선택">템플릿 선택</h3>
<p>이제 본격적으로 화면을 만들면 되는데
직접 HTML, CSS 부터 하나하나 디자인 하는 방법도 있겠으나</p>
<p>디자인 감각 제로에 HTML, CSS 잼병인 내가 이런 것들을 잘 할 리가 없었다.</p>
<p>결국 무료 템플릿의 힘을 빌리기로 했다.</p>
<h4 id="템플릿-선정-기준">템플릿 선정 기준</h4>
<ul>
<li>공짜인가?(저작권법에 저촉되지 않는가?)</li>
<li>사용할 수 있는 컴포넌트가 다양한가? (화면에 필요한 요소들 - 페이지네이션, 셀렉트 박스, 체크박스 등등... 이 존재하는가?)</li>
<li>Docs의 설명이 상세한가?</li>
<li>사용하기 편한가(사용 난이도가 괜찮은가)?</li>
</ul>
<p>위의 기준에 부합하는 템플릿을 계속 검색해 본 결과, 아래의 템플릿 사이트를 채택하게 되었다.
<a href="https://coreui.io/react/">https://coreui.io/react/</a></p>
<h3 id="화면-제작">화면 제작</h3>
<p>이제 사용할 프레임워크(혹은 라이브러리), 템플릿을 다 정했으니 이제 화면을 만들면 되었다.</p>
<p>이를 위해 어느정도 JS 문법과 React기초 공부를 해야 했다. </p>
<p>JS는 어느정도 할 줄 알아서 JS 공부는 따로 하지 않았고 async, Promise 개념, 전역 변수 개념 헷갈릴 때 구글링 하였고(+ <a href="https://poiemaweb.com/">https://poiemaweb.com/</a> 이 사이트 참고를 하였다.) React 의 경우 <a href="https://www.w3schools.com/REACT/DEFAULT.ASP">https://www.w3schools.com/REACT/DEFAULT.ASP</a> 이 사이트 참고를 많이 했다</p>
<p>그리고 완벽하게 완성하겠다는 욕심을 버렸다. 프론트엔드는 거의 문외한인 만큼 처음부터 잘하겠다는 욕심은 버리고 특정 기능을 구현 못하겠다면 이를 대체할 수 있는 더 쉬운 방법을 찾아서 구현을 하였다.</p>
<p>그렇게 2-3주 만에 프론트엔드를 완성할 수 있었다.</p>
<h3 id="결론">결론</h3>
<p>화면 만들고 api 연결까지 7일이면 될 줄 알았는데 생각보다 오래 시간이 걸렸다. 아무리 화면을 간단히 만들고 템플릿의 힘을 빌렸어도 프론트엔드와 백엔드간의 긴밀한 통신으로 사용자의 경험을 향상 시키는 것은 생각보다 어려운 문제였다. 특정 기능을 실행할 때 백엔드와 프론트엔드 사이에 플로우가 어떻게 흘러가야 할지, 어떤 데이터를 주고받아야 할지 고민을 많이 했다. (특히 Google Oauth2 인증과 대기열을 구현할 때 머리 좀 싸맸던 것 같다) 그리고 그 과정에서 백엔드의 수정도 불가피했다. </p>
<p>그래도 프론트엔드를 직접 해보면서 백엔드와 프론트엔드 사이의 협업이 상당히 중요하다는 것을 깨닫게 되었다. 과거에는 그냥 <code>&quot;응답값만 잘 전달해주면 되겄지~&quot;</code> 라는 생각만 했다면 이번 기회를 통해서 백엔드와 프론트엔드간의 커뮤니케이션의 중요성을 절실히 깨닫게 되었달까. </p>
<p>결론적으로 프론트엔드 작업을 통해 프론트엔드와 백엔드 서로를 이해하는데 많은 도움이 되었던 것 같다. 한번 정도는 프론트엔드 개발자의 힘을 빌리지 않고 혼자서 백엔드 + 프론트엔드 작업을 해보는 것도 좋은 경험인 것 같다.</p>
<hr>
<p>+) 2025.12.26</p>
<p>요즘은 AI 툴이 매우 좋아져 바이브 코딩을 통해 풀스택이 가능하다!
물론 백엔드 개발자로서 프론트를 한번쯤 손대보는걸 추천하지만
우리의 시간은 유한하므로 AI한테 프론트 개발을 넘기고 백엔드에 투자하는 것을 추천한다. <del>(인간 시대의 종말이 도래했다)</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SpringBoot + React 
환경에서 Google Oauth2 flow]]></title>
            <link>https://velog.io/@penrose_15/SpringBoot-React-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Google-Oauth2-flow</link>
            <guid>https://velog.io/@penrose_15/SpringBoot-React-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Google-Oauth2-flow</guid>
            <pubDate>Sun, 28 Apr 2024 05:46:29 GMT</pubDate>
            <description><![CDATA[<p>이 글은 OAuth2에 대해 어느정도 알고 있다는 전재 하에 작성되었습니다. </p>
<p><strong>사전지식</strong></p>
<ul>
<li>OAuth2 정의</li>
<li>OAuth2 타입(Implicit Grant, Authorization Code)</li>
<li><a href="https://console.cloud.google.com">Google Console api</a> 사이트에서 google oauth2 설정 방법</li>
</ul>
<p>참고로 Authorization Code 방식으로 진행하였습니다.</p>
<h2 id="oauth2-인증은-프론트에서-일어나야-하는가-백엔드에서-일어나야-하는가">OAuth2 인증은 프론트에서 일어나야 하는가 백엔드에서 일어나야 하는가?</h2>
<p>처음에는 프론트에서 처리할지 백엔드에서 처리할지에 대해 고민은 전혀 하지 않고 백엔드에서 다 처리하고 토큰(혹은 세션) 만 잘 전달해주면 되겠지 하고 그냥 백엔드에서 전부 처리하였다.</p>
<p>그러나 백엔드에서 모든 과정을 처리하려 하니 프론트엔드로 JWT를 전달하는데 어려움을 겪었고 다른 방법을 찾기로 하였다. </p>
<h3 id="1-프론트엔드에서-전부-처리">1. 프론트엔드에서 전부 처리</h3>
<p>말 그대로 프론트에서 모든 과정을 처리하고 사용자 정보만 백엔드로 넘기는 방법이다. (이후 백엔드에서 JWT를 만들어 프론트로 전달해주면 된다.)</p>
<p>필자는 백엔드이므로 패스하였다. </p>
<h3 id="2-백엔드에서-전부-처리">2. 백엔드에서 전부 처리</h3>
<p>처음에 진행했던 방법으로 백엔드에서 Spring Security와 OAuth2를 활용한 방식이다. </p>
<p>진행 Flow는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/7b05c63e-57e2-442d-93de-ffad953f3ea7/image.png" alt=""></p>
<ol>
<li>사용자가 로그인 버튼 클릭</li>
<li>버튼을 누르면 프론트엔드는 백엔드로 로그인 요청</li>
<li>백엔드는 ClientId, redirectURL을 포함하여 인증 서버로 전송</li>
<li>인증 서버는 이를 확인하고 로그인 페이지를 사용자에게 전송</li>
<li>사용자가 로그인 진행</li>
<li>로그인 진행 후, 인증 서버는 이를 확인하여 Auth Code를 백엔드 Redirect URL로 전달</li>
<li>백엔드는 AuthCode를 가지고 다시 인증 서버로 사용자 정보 요청</li>
<li>인증 서버는 백엔드의 Redirect URL을 통해 사용자 정보를 전달</li>
<li>백엔드는 사용자 정보를 통해 세션 혹은 토큰을 프론트엔드로 성공 처리하는 URL로 Redirect</li>
</ol>
<p>위의 과정에서 백엔드 -&gt; 프론트엔드로 JWT를 보낼 수 있는 적절한 방법을 찾지 못했고 결국 다른 방법으로 구현하기로 하였다.</p>
<p>+) 24/07/18
나중에 추가적으로 공부를 한 사실인데, 쿠키에 담으면 JWT전달을 할 수 있다. </p>
<p>문제는... CORS 인데 https + isSecure true, sameSite=NONE(혹은 프론트와 도메인이 같게하고 sameSite=STRICT로 하거나), httpOnly=true로 하면 된다. (프론트에서는 요청 보낼 때 isCredential=true 설정을 해주어야 한다.) </p>
<p>추가적으로 SecurityConfig의 CORS 설정시 OriginAllow에 정확한 도메인 지정과 AllowMethod에 정확한 메서드 지정(GET,POST,PATCH,OPTIONS...)이 필요하다</p>
<h3 id="3-프론트엔드--백엔드">3. 프론트엔드 + 백엔드</h3>
<p>프론트엔드에서 인증 서버와의 통신으로 Auth Code 까지 받은 후, 이를 백엔드로 전달하여 백엔드에서 나머지 인증을 진행한 후, Response Header에 토큰(혹은 세션)을 붙여 이를 프론트엔드로 응답을 보내는 방법으로 진행되었다.</p>
<p>Flow는 아래와 같이 이루어졌다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/85189f2b-636a-462f-a35e-a330434df841/image.png" alt=""></p>
<ol>
<li>사용자가 프론트엔드로 로그인 요청</li>
<li>프론트엔드는 인증서버로 ClientId, Redirect URL을 가지고 로그인 페이지를 요청한다.</li>
<li>인증 서버는 사용자에게 로그인 페이지를 제공한다.</li>
<li>사용자가 로그인한다.</li>
<li>인증 서버는 사용자 정보를 확인하고 Auth Code를 프론트엔드에 전달한다.</li>
<li>프론트엔드는 이 Auth Code를 백엔드로 전달한다.</li>
<li>백엔드는 AuthCode를 인증서버로 전달하여 사용자 정보를 요청한다.</li>
<li>인증서버는 사용자 정보를 백엔드로 전송한다.</li>
<li>백엔드는 사용자 정보로 토큰(혹은 세션)을 만들고 프론트에서 요청한 URL 응답 헤더에 토큰을 붙여 전달한다.</li>
</ol>
<p>중간에 프론트에서 백엔드로 AuthCode를 전달하는 과정 때문에 프론트엔드와 백엔드가 책임을 나눠가지는 방식은 추천하는 방식은 아니지만 구현이 쉽다는 장점이 있어 이 방법을 채택하기로 하였다. </p>
<h2 id="react-코드">React 코드</h2>
<h3 id="googleloginjs">GoogleLogin.js</h3>
<pre><code class="language-js">import { useEffect, useRef } from &#39;react&#39;

export default function GoogleLogin({ onGoogleSignIn = () =&gt; {}, text = &#39;signin_with&#39; }) {
  const googleSignInButton = useRef(null)

  useScript(&#39;https://accounts.google.com/gsi/client&#39;, () =&gt; {
    window.google.accounts.id.initialize({
      client_id: import.meta.env.VITE_CLIENT_ID,
      callback: onGoogleSignIn,
    })
    window.google.accounts.id.renderButton(googleSignInButton.current, {
      theme: &#39;filled_black&#39;,
      size: &#39;large&#39;,
      text,
      width: &#39;250&#39;,
    })
  })

  return &lt;div ref={googleSignInButton}&gt;&lt;/div&gt;
}

const useScript = (url, onload) =&gt; {
  useEffect(() =&gt; {
    const script = document.createElement(&#39;script&#39;)

    script.src = url
    script.onload = onload

    document.head.appendChild(script)

    return () =&gt; {
      document.head.removeChild(script)
    }
  }, [url, onload])
}</code></pre>
<p>이때 Client_id는 .env에 저장한 후, .gitignore에 .env를 추가해야 한다.</p>
<h3 id="loginjs">Login.js</h3>
<pre><code class="language-js">import React, { useEffect } from &#39;react&#39;
import { Link } from &#39;react-router-dom&#39;
import GoogleLogin from &#39;./GoogleLogin&#39;
import { setCookies, getCookies, setTokenAtCookies } from &#39;../../../cookie/Cookie&#39;
import axios from &#39;axios&#39;

const Login = () =&gt; {
  const onGoogleSignIn = async (res) =&gt; {
    const { credential } = res
    const result = await axios.post(
      &#39;http://localhost:8080/api/googleLogin&#39;,
      JSON.stringify({ code: credential }),
      {
        headers: {
          Accept: &#39;application/json&#39;,
          &#39;Content-Type&#39;: &#39;application/json&#39;,
        },
      },
    )
    const status = result.status
    if (status !== 200) console.error(&#39;login failed&#39;)
    console.log(result.headers)
    const accessToken = result.headers.authorization
    const refreshToken = result.headers.refresh

    setTokenAtCookies(accessToken, refreshToken)
  }

  return (
    &lt;div className=&quot;bg-body-tertiary min-vh-100 d-flex flex-row align-items-center&quot;&gt;
      &lt;CContainer&gt;
        &lt;CRow className=&quot;justify-content-center&quot;&gt;
          &lt;CCol md={8}&gt;
            &lt;CCardGroup&gt;
              &lt;CCard className=&quot;p-4&quot;&gt;
                &lt;CCardBody&gt;
                  &lt;CForm&gt;
                    &lt;h1&gt;Login&lt;/h1&gt;
                    &lt;p className=&quot;text-body-secondary&quot;&gt;Sign In to your account&lt;/p&gt;
                    &lt;GoogleLogin onGoogleSignIn={onGoogleSignIn} text=&quot;Google login&quot; /&gt;
                  &lt;/CForm&gt;
                &lt;/CCardBody&gt;
              &lt;/CCard&gt;
            &lt;/CCardGroup&gt;
          &lt;/CCol&gt;
        &lt;/CRow&gt;
      &lt;/CContainer&gt;
    &lt;/div&gt;
  )
}

export default Login</code></pre>
<h2 id="spring-boot-코드">Spring Boot 코드</h2>
<p>백엔드에서 OAuth2 인증 과정을 전부 진행했을 때와 달리 <code>&#39;org.springframework.boot:spring-boot-starter-oauth2-client&#39;</code> dependency 대신 <code>&#39;com.google.api-client:google-api-client:2.4.0&#39;</code> 를 추가해야 했다. 또한 Security filter chain에 oauth2를 빼야 했다.</p>
<pre><code>dependencies {
  ...
  implementation &#39;com.google.api-client:google-api-client:2.4.0&#39;
  implementation &#39;com.auth0:java-jwt:4.4.0&#39;
}</code></pre><h3 id="authcontroller">AuthController</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
public class AuthController {
    private final AuthService authService;

    @PostMapping(&quot;/api/googleLogin&quot;)
    public ResponseEntity&lt;?&gt; googleAuthLogin(@RequestBody IdToken request, HttpServletResponse response) {
        TokenDto tokenDto = authService.login(request.code());
        response.addHeader(&quot;Authorization&quot;, tokenDto.accessToken());
        response.addHeader(&quot;Refresh&quot;, tokenDto.refreshToken());

        return ResponseEntity.ok().build();
    }
}</code></pre>
<h3 id="authservice">AuthService</h3>
<pre><code class="language-java">@Slf4j
@Transactional
@Service
public class AuthService {
    private final GoogleIdTokenVerifier verifier;
    private final JwtTokenizer jwtTokenizer;
    private final UsersService usersService;

    public AuthService( @Value(&quot;${spring.security.oauth2.client.registration.google.client-id}&quot;)String clientId, JwtTokenizer jwtTokenizer, UsersService usersService) {
        this.jwtTokenizer = jwtTokenizer;
        this.usersService = usersService;
        NetHttpTransport transport = new NetHttpTransport();
        JsonFactory jsonFactory = new GsonFactory();
        this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
                .setAudience(Collections.singleton(clientId))
                .build();
    }

    public TokenDto login(String code) {
        try {
            GoogleIdToken idToken = verifier.verify(code);

            if(idToken == null) {
                log.info(&quot;idToken is null&quot;);
                return null;
            }
            GoogleIdToken.Payload payload = idToken.getPayload();
            String email = payload.getEmail();
            String firstName = (String) payload.get(&quot;given_name&quot;);
            String lastName = (String) payload.get(&quot;family_name&quot;);

            UsersRequestDto dto = UsersRequestDto.builder()
                    .email(email)
                    .name(firstName + lastName)
                    .provider(&quot;google&quot;)
                    .build();
            Users users = usersService.findOrCreateUsers(dto);

            String accessToken = &quot;Bearer &quot; + jwtTokenizer.createAccessToken(email);
            String refreshToken = &quot;Bearer &quot; + jwtTokenizer.createRefreshToken(users.getId());
            log.info(&quot;access token = {}&quot;, accessToken);
            saveAuthentication(users);

            return TokenDto.builder()
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .build();
        } catch (Exception e) {
            log.error(&quot;error : &quot;, e);
            throw new AuthException(AuthErrorCode.LOGIN_FAILED);
        }
    }

    public void saveAuthentication(Users users) {
        UserDetails userDetails = new UserAccount(users);

        List&lt;GrantedAuthority&gt; roles = new ArrayList&lt;&gt;();
        roles.add(new SimpleGrantedAuthority(users.getRole()));

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, roles);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}</code></pre>
<h3 id="jwttokenizer">JWTTokenizer</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenizer {
    @Value(&quot;${jwt.secretKey}&quot;)
    private String secretKey;

    @Value(&quot;${jwt.access.expiration}&quot;)
    private Long accessTokenExpirationPeriod;

    @Value(&quot;${jwt.refresh.expiration}&quot;)
    private Long refreshTokenExpirationPeriod;

    @Value(&quot;${jwt.access.header}&quot;)
    private String accessHeader;

    @Value(&quot;${jwt.refresh.header}&quot;)
    private String refreshHeader;

    private Algorithm jwtAlgorithm;

    private final Redis2Utils redisUtils;

    @PostConstruct
    public void setJwtAlgorithm() {
        this.jwtAlgorithm = Algorithm.HMAC512(secretKey);
    }

    private static final String ACCESS_TOKEN_SUBJECT = &quot;AccessToken&quot;;
    private static final String REFRESH_TOKEN_SUBJECT = &quot;RefreshToken&quot;;
    private static final String EMAIL_CLAIM = &quot;email&quot;;
    private static final String BEARER = &quot;Bearer &quot;;

    public String createAccessToken(String email) {
        Date now = new Date();

        return JWT.create()
                .withSubject(ACCESS_TOKEN_SUBJECT) //jwt subject 지정
                .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) //토큰 만료
                .withClaim(EMAIL_CLAIM, email) //payload
                .sign(jwtAlgorithm); //algorithm
    }

    public String createRefreshToken(Long userId) {
        Date now = new Date();

        // 기존 refresh token 삭제
        redisUtils.deleteObject(userId);

        String refreshToken = JWT.create()
                .withSubject(REFRESH_TOKEN_SUBJECT)
                .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
                .sign(jwtAlgorithm);
        redisUtils.addObject(userId, refreshToken, refreshTokenExpirationPeriod);

        return refreshToken;
    }

    public void sendAccessToken(HttpServletResponse response, String accessToken) {
        response.setHeader(accessHeader, accessToken);
        log.info(&quot;set accessToken to header&quot;);
    }

    public void sendRefreshToken(HttpServletResponse response, String refreshToken) {
        response.setHeader(refreshHeader, refreshToken);
        log.info(&quot;set refreshToken to header&quot;);
    }

    public Optional&lt;String&gt; extractEmail(HttpServletRequest request) {
        Optional&lt;String&gt; accessToken = extractAccessToken(request);
        try {
            if (accessToken.isPresent()) {
                String token = accessToken.get();
                String optionalEmail = JWT.require(Algorithm.HMAC512(secretKey))
                        .build()
                        .verify(token)
                        .getClaim(EMAIL_CLAIM)
                        .asString();
                return Optional.of(optionalEmail);
            }
            return Optional.empty();
        } catch (Exception e) {
            log.error(&quot;jwt not valid&quot;, e);
            throw new IllegalArgumentException(e);
        }

    }

    public String getEmail(String accessToken) {
        DecodedJWT jwt = JWT.decode(accessToken);
        return jwt.getClaim(EMAIL_CLAIM).asString();
    }

    public Optional&lt;String&gt; extractAccessToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(accessHeader))
                .filter(at -&gt; at.startsWith(BEARER))
                .map(at -&gt; at.replace(BEARER, &quot;&quot;));
    }

    public Optional&lt;String&gt; extractRefreshToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(refreshHeader))
                .filter(at -&gt; at.startsWith(BEARER))
                .map(at -&gt; at.replace(BEARER, &quot;&quot;));
    }

    public boolean isTokenValid(String token) {
        try {
            JWT.require(jwtAlgorithm)
                    .build()
                    .verify(token);
            return true;
        } catch (TokenExpiredException e) {
            log.error(&quot;token expired : {}&quot;, e.getMessage());
            return false;
        } catch (Exception e) {
            log.error(&quot;jwt error : {}&quot;, e.getMessage());
            return false;
        }
    }

    public boolean isTokenExpired(String token) {
        DecodedJWT jwt = JWT.decode(token);
        Date expDate = jwt.getExpiresAt();
        Date now = new Date();

        return now.after(expDate);
    }
}</code></pre>
<h3 id="securityconfig">SecurityConfig</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
    private final JwtAuthorizationProcessingFilter jwtAuthorizationProcessingFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(FormLoginConfigurer::disable)
                .csrf(CsrfConfigurer::disable)
                .cors(cors -&gt; cors.configurationSource(corsConfigurationSource()))
                .sessionManagement(session -&gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -&gt; auth
                        .requestMatchers(new AntPathRequestMatcher(&quot;/h2/**&quot;)).permitAll()

                        .requestMatchers(new AntPathRequestMatcher(&quot;/api/googleLogin&quot;), new AntPathRequestMatcher(&quot;/error&quot;), new AntPathRequestMatcher(&quot;/index.html&quot;)).permitAll()
                        .requestMatchers(new AntPathRequestMatcher(&quot;/swagger-ui/**&quot;), new AntPathRequestMatcher(&quot;/v3/**&quot;), new AntPathRequestMatcher(&quot;/swagger-ui.html&quot;)).permitAll()
                        .requestMatchers(new AntPathRequestMatcher(&quot;/app/**&quot;), new AntPathRequestMatcher(&quot;/topic/**&quot;), new AntPathRequestMatcher(&quot;/web-socket-connection/**&quot;)).permitAll()
                        .anyRequest().authenticated());
        http
                .addFilterBefore(jwtAuthorizationProcessingFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();

    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of(&quot;http://localhost:3000&quot;));
        configuration.setAllowedMethods(List.of(&quot;*&quot;));
        configuration.setAllowedHeaders(List.of(&quot;Authorization&quot;, &quot;Refresh&quot;, &quot;Content-type&quot;, &quot;Origin&quot;, &quot;Accept&quot;, &quot;Access-Control-Allow-Origin&quot;, &quot;Access-Control-Allow-Headers&quot;, &quot;Access-Control-Allow-Methods&quot;));
        configuration.setExposedHeaders(List.of(&quot;Authorization&quot;, &quot;Refresh&quot;));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration(&quot;/**&quot;, configuration);

        return source;
    }


}</code></pre>
<h3 id="jwtauthorizationprocessfilter">JwtAuthorizationProcessFilter</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationProcessingFilter extends OncePerRequestFilter {
    private static final String[] AUTHORIZATION_NOT_REQUIRED = new String[]{&quot;/login&quot;, &quot;/h2&quot;, &quot;/web-socket-connection&quot;,&quot;/swagger-ui&quot;,&quot;/v3/api-docs&quot;,&quot;/topic/participant&quot;,&quot;/api/googleLogin&quot;};
    private final JwtTokenizer jwtTokenizer;
    private final UsersRepository usersRepository;
    private final Redis2Utils redisUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info(&quot;JwtAuthorizationProcessingFilter start&quot;);
        log.info(&quot;request.getRequestURI() = {}&quot;, request.getRequestURI());
        if (StringUtils.startsWithAny(request.getRequestURI(), AUTHORIZATION_NOT_REQUIRED)) {
            filterChain.doFilter(request, response);
            log.info(&quot;AUTHORIZATION_NOT_REQUIRED&quot;);
            return;
        }
        //accessToken 확인
        Optional&lt;String&gt; accessToken = jwtTokenizer.extractAccessToken(request);
        if (accessToken.isPresent()) {
            // 만약 accessToken 존재시 return;
            log.info(&quot;accessToken exist&quot;);
            boolean isAccessTokenValid = jwtTokenizer.isTokenValid(accessToken.get());
            if (isAccessTokenValid) {
                log.info(&quot;accessToken valid&quot;);
                setAuthentication(accessToken.get());
            } else {
                if(jwtTokenizer.isTokenExpired(accessToken.get())) {
                    log.info(&quot;access token expired&quot;);
                    Optional&lt;String&gt; refreshToken = jwtTokenizer.extractRefreshToken(request);
                    //refresh token 존재시 accessToken reissue 후 return;
                    if (refreshToken.isPresent()) {
                        //refreshToken valid check
                        checkRefreshToken(response, refreshToken, accessToken);
                    } else {
                        log.info(&quot;refresh token not exist&quot;);
                        throw new AuthException(REFRESH_TOKEN_NOT_EXIST);
                    }
                } else {
                    log.info(&quot;access token not valid&quot;);
                    throw new AuthException(JWT_NOT_VALID);
                }
            }
        } else {
            throw new AuthException(ACCESS_TOKEN_NOT_EXIST);
        }
        filterChain.doFilter(request, response);
    }

    private void checkRefreshToken(HttpServletResponse response, Optional&lt;String&gt; refreshToken, Optional&lt;String&gt; accessToken) {
        if (!jwtTokenizer.isTokenValid(refreshToken.get())) {
            log.info(&quot;refresh token not valid&quot;);
            throw new AuthException(JWT_NOT_VALID);
        }
        Users users = getUsers(accessToken.get());
        Optional&lt;String&gt; optionalRt = redisUtils.getObject(users.getId());
        if(optionalRt.isPresent()) {
            String rt = optionalRt.get();
            if(!rt.equals(refreshToken.get())) {
                throw new AuthException(JWT_NOT_VALID);
            }
        }
        reIssueToken(users, response);
    }

    private void reIssueToken(Users users, HttpServletResponse response) {
        log.info(&quot;checkRefreshTokenAndReIssueAccessToken start&quot;);

        String token = jwtTokenizer.createRefreshToken(users.getId());
        String accessToken = jwtTokenizer.createAccessToken(users.getEmail());

        //securityContext에 저장
        saveAuthentication(users);
        //response에 저장
        jwtTokenizer.sendAccessToken(response, accessToken);
        jwtTokenizer.sendRefreshToken(response, token);
        log.info(&quot;checkRefreshTokenAndReIssueAccessToken end&quot;);
    }

    private void setAuthentication(String accessToken) {
        Users users = getUsers(accessToken);
        saveAuthentication(users);
    }

    private Users getUsers(String accessToken) {
        String email = jwtTokenizer.getEmail(accessToken);
        return usersRepository.findByEmail(email)
                .orElseThrow(() -&gt; new UserException(USER_NOT_FOUND));
    }

    private void saveAuthentication(Users users) {
        UserDetails userDetails = new UserAccount(users);

        List&lt;GrantedAuthority&gt; roles = new ArrayList&lt;&gt;();
        roles.add(new SimpleGrantedAuthority(users.getRole()));

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, roles);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        log.info(&quot;should not filter = {}&quot;, request.getRequestURI());
        boolean result =  StringUtils.startsWithAny(request.getRequestURI(), AUTHORIZATION_NOT_REQUIRED);
        log.info(&quot;should not filter = {}&quot;, result);

        return result;
    }
}</code></pre>
<h3 id="idtoken">IdToken</h3>
<pre><code class="language-java">public record IdToken(String code) {
} // Authorization Code 전달</code></pre>
<h3 id="tokendto">TokenDto</h3>
<p>jwt 토큰 전달을 위해 사용</p>
<pre><code class="language-java">public record TokenDto(String accessToken, String refreshToken) {

    @Builder
    public TokenDto {
    }
}</code></pre>
<hr>
<hr>
<p>Reference</p>
<p><a href="https://blog.thelumayi.com/92">https://blog.thelumayi.com/92</a>
<a href="https://hudi.blog/oauth-2.0/">https://hudi.blog/oauth-2.0/</a>
<a href="https://ttl-blog.tistory.com/1434#%F0%9F%A7%90%20%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1">https://ttl-blog.tistory.com/1434#%F0%9F%A7%90%20%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1</a></p>
<p><a href="https://www.youtube.com/playlist?list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB">https://www.youtube.com/playlist?list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[세션 동작 원리]]></title>
            <link>https://velog.io/@penrose_15/%EC%84%B8%EC%85%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC</link>
            <guid>https://velog.io/@penrose_15/%EC%84%B8%EC%85%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC</guid>
            <pubDate>Wed, 24 Apr 2024 11:38:27 GMT</pubDate>
            <description><![CDATA[<p>회사에서 인프라 엔지니어 직원분이 본인이 설정한 네트워크 Timeout 시간보다 로그인 유지 시간이 짧아 도움을 요청하셨습니다.</p>
<h3 id="원인">원인</h3>
<p>원인은 간단했습니다.</p>
<p>세션을 찾는데 사용하는 sessionID는 웹 브라우저의 쿠키에 저장이 되어있으므로</p>
<p>네트워크 타임아웃과 관련없이 쿠키 내부의 session ID 의 유효기간이나 쿠키의 시간이 끝나버리면 네트워크가 얼마나 길게 설정되든 세션을 찾지 못하니깐 로그인 유지가 되지 않았던 것이었습니다.</p>
<p>(사실 누구나 바로 쿠키나 세션 문제를 먼저 떠올렸겠지만 간과하신 부분인것 같습니다)</p>
<h3 id="세션-동작-원리">세션 동작 원리</h3>
<p>그렇다면 세션은 어떤 방식으로 사용자를 기억하는 것일까요?</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/b87d3779-8dbd-4954-88f0-40f5887ec42d/image.png" alt=""></p>
<ol>
<li>클라이언트가 처음으로 서버로 접속을 한다.</li>
<li>서버는 클라이언트를 인증하고 세션을 만든다.</li>
<li>서버는 세션ID를 쿠키에 넣어서 클라이언트에게 전달한다.</li>
</ol>
<p>여기서 전달하는 쿠키는 우리가 쿠키 창에서 자주 보는 jsessionId를 예시로 들 수 있습니다.</p>
<p>사실 쿠키에 세션ID를 전달하는 방법은 서버가 클라이언트ID를 추적하는 방법 중 하나입니다. 이외에도 hidden form fields 모드, SSL모드, URL Rewriting 모드 등이 있습니다</p>
<blockquote>
<p>hidden form fields : html 폼 형식에 세션ID를 <code>&lt;hidden&gt;</code> 태그에 숨겨 전달 
URL Rewriting 모드: GET 방식으로 URL 뒤에 정보를 붙여 전송
SSL 모드 : SSL로 통신을 암호화 하는 방법</p>
</blockquote>
<p>4, 5. 클라이언트가 세션ID가 담긴 쿠키를 가지고 접근하면 서버는 이를 확인하고 접근을 허용합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring에서 Websocket 사용하기]]></title>
            <link>https://velog.io/@penrose_15/Websocket-%EB%B0%8F-Websocket-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@penrose_15/Websocket-%EB%B0%8F-Websocket-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 08 Apr 2024 07:25:30 GMT</pubDate>
            <description><![CDATA[<h2 id="websocket">Websocket</h2>
<p>HTML5 표준 기술로 HTTP 환경에서 클라이언트 - 서버 간 하나의 TCP 연결을 통해 실시간으로 <strong>전이중 통신</strong>을 가능하게 하는 프로토콜</p>
<p>전이중 통신은 양방향으로 송수신이 가능한 것을 뜻한다.</p>
<h3 id="websocket-이전에-활용한-방법들">websocket 이전에 활용한 방법들</h3>
<h4 id="http-polling">HTTP Polling</h4>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/f528b2d1-616a-41fc-b772-d801d3cf3350/image.png" alt=""></p>
<p>주기적으로 클라이언트가 서버로 HTTP 요청을 보내면, 즉시 응답을 받는 방식
클라이언트가 많아지면 서버의 부담이 급증하고, HTTP 오버헤드(Http Header가 커지는 문제)가 발생한다.</p>
<blockquote>
<p>원하는 대상을 찾기 위해 헤더에 정보를 추가하면 정보 전송에 신뢰성은 높아지나 TPS도 같이 늘어남</p>
</blockquote>
<h4 id="http-long-polling">HTTP Long-Polling</h4>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/ec07b1b7-09d3-45ea-9640-59057893678d/image.png" alt=""></p>
<p>HTTP Polling과 비슷하나 즉시 응답이 돌아오는 방식이 아닌 클라이언트가 요청을 보내면 서버는 대기하다가 서버에서 클라이언트에게 전달할 이벤트가 있으면 그 순간 요청을 전달하고 연결을 종료하는 방식이다. 클라이언트는 다시 요청을 전송하여 서버의 다음 이벤트를 기다린다.</p>
<p>일반 Polling 보단 서버의 부담은 줄어드나, 이벤트 간의 간격이 좁으면 Polling과 별 차이가 없어진다.</p>
<h4 id="http-streaming">HTTP Streaming</h4>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/8e007c93-81f5-4c13-80d4-e576714d9ede/image.png" alt=""></p>
<p>서버가 요청을 보내고 HTTP 연결을 끊지 않고 서버로부터 데이터를 수신하는 방법이다.</p>
<h3 id="websocket-진행-과정">Websocket 진행 과정</h3>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/405fb45e-c6b4-4c24-85f1-3ff4da3c2bd9/image.png" alt=""></p>
<p>최초 연결 요청 시 클라이언트에서 HTTP를 통해 서버에게 연결 요청(Handshake)을 한다.</p>
<p>Request Header</p>
<pre><code>GET ws://localhost:8080/ws/init HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Upgrade: websocket
Origin: https://localhost:9000
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: YTwYIE6qJlfakye/Q62cVQ==</code></pre><ul>
<li><code>Connection: Upgrade</code> : 클라이언트 측에서 프로토콜을 바꾸고 싶다는 의미</li>
<li><code>Upgrade: websocket</code> : 클라이언트 측에서 요청한 프로토콜이 websocket라는 뜻</li>
<li><code>Origin</code> : 클라이언트 오리진을 나타낸다. 서버는 Origin 헤더를 보고 어떤 웹 사이트와 통신을 하는지 결정하기 때문에 매우 종요한 역할을 한다. (CORS 정책으로 만들어진 헤더)</li>
<li><code>Sec-Websocket-Version</code> : 웹 소켓 프로토콜 버전</li>
<li><code>Sec-WebSocket-Key</code> : 보안을 위해 브라우저에서 생성한 키, 서버가 웹 소켓 프로토콜을 지원하는지 확인하는데 사용된다.</li>
</ul>
<p>서버는 아래와 같이 응답한다.</p>
<pre><code>HTTP/1.1 101
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: 7orViGJ4sANNTn3FujUUrQKRlkA=\
Date: Mon, 08 Apr 2024 05:15:18 GMT</code></pre><ul>
<li><code>HTTP/1.1 101</code> : http에서 ws로 프로토콜 전환이 승인되었음을 뜻한다.</li>
<li><code>Sec-WebSocket-Accept</code> : 요청 헤더의 Sec-WebSocket-Key에 특정 값을 추가한 후, SHA-1로 해싱한 후 base64로 인코딩한 결과, 이 값으로 클라이언트는 정상적인 핸드쉐이크 과정을 검증한다.</li>
</ul>
<p>위의 과정이 끝나면 그때서야 본격적인 데이터 통신이 시작된다. 이때 데이터는 프레임(Frame) 단위로 이루어진다.</p>
<p>또한 서버와 클라이언트는 상대방에게 ping 패킷을 보내고, ping을 수신한 측은 상대방에게 빨리 pong 패킷을 전송하여 생존신고를 주기적으로 한다. 이를 Heartbeat라고 한다.</p>
<p>이제 연결을 종료 하려 하면 클라이언트/서버 누구든 연결을 종료할 수 있다. 연결 종료를 원하는 측은 Close Frame을 상대한테 전달한다.</p>
<h3 id="stomp">Stomp</h3>
<p>Simple Text Oriented Messaging Protocol의 약자로 텍스트 기반 메시지 프로토콜이다.</p>
<p>websocket 자체는 메시지를 주고받는 형식이 정해져 있지 않다.</p>
<p>그래서 서브 프로토콜을 통해 메시지의 형태를 사용하는데 이때 사용되는것이 Stomp이다.</p>
<p>Stomp는 Command, Header, Body로 구성되며 아래와 같은 구조를 가진다. </p>
<pre><code>COMMAND
header1:value1
header2:value2

Body^@</code></pre><ul>
<li><code>COMMAND</code> : COMMAND/SEND/SUBSCRIBE 등의 명령을 통해 메시지의 동작을 정의</li>
<li><code>Header</code> : 메시지의 수신 대상과 메시지에 대한 정보를 설명</li>
<li><code>Body</code> : 데이터(payload) 가 포함된다.</li>
</ul>
<p>Stomp는 pub/sub 방식으로 작동한다.
<img src="https://velog.velcdn.com/images/penrose_15/post/f0d23622-3d74-458e-928e-6ef7d51a0fec/image.png" alt=""></p>
<ol>
<li>url로 topic 지정하여 메시지 전송(Publishing prefix를 /app로, Subscription prefix를 /topic로 사용)</li>
<li>해당 메시지가 message broker(broker channel)로 도달</li>
<li>message broker는 해당 토픽에 대응하는 response channel로 route</li>
<li>receiver가 메시지 수신</li>
</ol>
<h3 id="spring에서의-websocket-활용법">Spring에서의 Websocket 활용법</h3>
<ol>
<li><p>gradle</p>
<pre><code class="language-gradle">implementation &#39;org.springframework.boot:spring-boot-starter-websocket&#39;</code></pre>
</li>
<li><p>websocket config</p>
<pre><code class="language-java">@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
 @Override
 public void registerStompEndpoints(StompEndpointRegistry registry) {
     registry.addEndpoint(&quot;/ws/init&quot;) //web socket connection이 최초로 이루어지는 곳(handshake)
             .setAllowedOriginPatterns(&quot;*&quot;)
             .withSockJS(); 
 }

 @Override
 public void configureMessageBroker(MessageBrokerRegistry registry) {
     // @Controller 객체의 @MessageMapping 메서드로 라우팅, 클라이언트가 서버로 메시지 보낼 URL 접두사(pub)
     registry.setApplicationDestinationPrefixes(&quot;/app&quot;);
     // /topic 로 클라이언트로 메시지 전달(sub)
     registry.enableSimpleBroker(&quot;/topic&quot;);
 }
}</code></pre>
</li>
<li><p>controller</p>
</li>
</ol>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
@Controller
public class StompController {

    // test connection

    @MessageMapping(&quot;/example&quot;) // 클라이언트가 /app/example로 메시지 전송
    @SendTo(&quot;/topic/messages&quot;)  // 서버가 /topic/messages로 메시지 전달
    public String example(String str) {
        return &quot;success&quot;;
    }
}</code></pre>
<p><del>Websocket Test</del></p>
<p><del><a href="https://apic.app/online/#/tester">https://apic.app/online/#/tester</a> 로 웹 소켓 테스트를 할 수 있다.</del></p>
<p><del>여러모로 불편한 점이 많았으나 subscribe, send 테스트가 가능한 테스트 툴은 내가 찾은 것 중에서 유일했다. 
(postman은 connection test 만 가능)</del></p>
<p><del>참고로 WebSocketConfig.java에서 <code>.withSockJS();</code> 를 제외해야만 테스트가 작동하였다.(...)</del></p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/b7b357f9-ff7a-4224-9334-55de008a34f4/image.png" alt=""></p>
<p>+) 2024.10.31</p>
<p>현재 사이트 동작이 되지 않는다고 한다 <del>AI 한테 통산 테스트 하게 프론트 구현해달라 하면 인수 테스트가 가능하다</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Refresh Token Rotation]]></title>
            <link>https://velog.io/@penrose_15/Refresh-Token-Rotation</link>
            <guid>https://velog.io/@penrose_15/Refresh-Token-Rotation</guid>
            <pubDate>Sat, 06 Apr 2024 08:50:50 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 들어가기 전
이 글은 JWT 토큰을 알고 있다는 전제 하에 작성되었습니다.</p>
</blockquote>
<h2 id="accesstoken만-사용시-발생하는-문제점">AccessToken만 사용시 발생하는 문제점</h2>
<p>AccessToken은 유효기간이 짧다. 이로 인해 잦은 로그인을 요구하여 사용자에게 불편함을 안겨 줄 수 있다.</p>
<p>그렇다고 유효기간을 길게 잡으면 만약 해커한테 털려도 유효기간이 만료될 때까지 손가락만 빨고 있어야 한다.</p>
<p>JWT의 stateless 특성상 서버가 상태를 보관하지 않고, 한번 만든 토큰에 대해 제어권을 가지고 있지 않기 때문이다.</p>
<h2 id="accesstoken--refresh-token-매커니즘">AccessToken + Refresh Token 매커니즘</h2>
<p>그래서 Refresh Token이라는 것을 같이 사용한다.
Refresh Token은 AccessToken에 비해 긴 유효기간을 가진다.</p>
<p>아래는 AccessToken과 RefreshToken을 활용한 Access Token 만료시 Refresh Token을 통해 재발급 받는 과정이다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/27524f1a-1e96-43a8-a99e-1ab5561c11df/image.png" alt=""></p>
<ol>
<li>Client 가 서버로 로그인</li>
<li>Server에서 AccessToken, RefreshToken 생성, 이때 RefreshToken은 DB에 저장</li>
<li>Client에게 AccessToken, RefreshToken 전송</li>
<li>시간이 지나 AccessToken이 만료된 상태로 Client가 Server로 접근한다.</li>
<li>AccessToken이 만료된 것을 확인한 서버는 Client가 같이 가져온 RefreshToken을 서버에 저장된 RefreshToken과 비교 후, AccessToken을 재발급해준다.</li>
</ol>
<p>위의 방식으로 AccessToken의 유효기간을 짧게 가져도 RefreshToken을 통해 보안성을 높일 수 있다.</p>
<p>그러나 위의 방식에도 문제는 있다.</p>
<p>만약 RefreshToken이 털리면(!) 탈취된 RefreshToken으로 계속 AccessToken을 발급받을 수 있다는 문제점이 있다.</p>
<h2 id="rtr-refresh-token-rotation">RTR (Refresh Token Rotation)</h2>
<p>RTR(Refresh Token Rotation) 은 위의 문제를 어느정도 막아줄 수 있다.</p>
<p>아래는 RTR의 플로우이다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/5ae0526c-d611-404f-a1d3-a5ee77fb9157/image.png" alt=""></p>
<ol>
<li>Client 가 서버로 로그인</li>
<li>Server에서 AccessToken, RefreshToken 생성, 이때 RefreshToken은 DB에 저장</li>
<li>Client에게 AccessToken, RefreshToken 전송</li>
<li>시간이 지나 AccessToken이 만료된 상태로 Client가 Server로 접근한다.</li>
<li>AccessToken이 만료된 것을 확인한 서버는 Client의  RefreshToken을 DB의 RefreshToken과 비교 후, 새로운 AccessToken과 RefreshToken을 생성한다.</li>
<li>새로 생성된 AccessToken과 RefreshToken을 Client에게 전달한다.</li>
</ol>
<p>간단히 말해서 RefreshToken을 AccessToken이 만료되어 서버에게 요청하는 경우마다 새로 갱신해주는 방식이다.</p>
<p>이러한 경우 아래와 같은 상황을 막아줄 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/79e6181c-e86f-44f4-a420-b03663b0bac6/image.png" alt=""></p>
<h2 id="rtr의-한계">RTR의 한계</h2>
<p>그러나 만약 Hacker가 정상 Client 보다 먼저 접근해서 AccessToken과 RefreshToken을 갱신받는다면 어떻게 될까? </p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/480ea1c3-3a10-44df-befc-a21c2f4a4ada/image.png" alt=""></p>
<ol>
<li>Client 가 서버로 로그인</li>
<li>Server에서 AccessToken, RefreshToken 생성, 이때 RefreshToken은 DB에 저장</li>
<li>Client에게 AccessToken, RefreshToken 전송</li>
<li>Hacker가 RefreshToken 탈취</li>
<li>Hacker가 먼저 RefreshToken을 가지고 Server로 접근</li>
<li>Server는 RefreshToken을 DB에 저장된 RefreshToken과 비교 후, AccessToken과 RefreshToken을 새로 생성한다.</li>
<li>Server는 AccessToken과 RefreshToken을 Hacker에게 전달한다.</li>
<li>AccessToken이 만료된 Client가 서버로 접근한다.</li>
<li>Server는 RefreshToken의 재사용을 감지하여 RefreshToken을 모두 무효화 한 후, Client에게 AccessDenied 응답한다.</li>
<li>Hacker가 다시 만료된 AccessToken을 가지고 접근한다.</li>
<li>모든 RefreshToken이 무효화되었으므로 서버는 AccessDenied를 응답한다.</li>
</ol>
<p>Hacker의 접근을 막을 수 있었으나 한번은 Hacker의 접근을 허용하게 된다.</p>
<p>결국은 이러나저러나 RefreshToken이 탈취될 위험성이 존재한다.</p>
<p>+) HTTP stateless 방식에 어긋나기도 한다</p>
<h2 id="결론">결론</h2>
<p>실버불렛은 없다
여러 방법들을 적절히 사용해서 보완하는 수 밖에 없다.</p>
<ol>
<li>xss, csrf 방어</li>
<li>AccessToken 유효기간 짧게하기</li>
<li>AccessToken에 중요한 정보 넣지 않기</li>
<li>RefreshToken은 HttpOnly, Secure한 쿠키에 저장하기</li>
</ol>
<p>등등을 활용하면 어느정도는 안전한 보안정책을 만들 수 있을 것이다</p>
<p>끝</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redisson을 활용한 분산락으로 동시성 이슈 해결하기]]></title>
            <link>https://velog.io/@penrose_15/Redisson%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/Redisson%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 04 Apr 2024 04:50:11 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트 중 동시성 이슈를 해결하기 위해 Redisson을 도입하게 되었습니다.</p>
<h3 id="동시성-이슈">동시성 이슈</h3>
<p>100명까지 참여 가능한 서비스에 99명까지 참여했다고 가정을 해봅시다. 마지막 한 사람만이 참여 가능한 상황에서
동시에 두 명이 접근하는 경우 동시성 이슈가 발생할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/65daad61-9722-4014-aff9-7aa8194e9325/image.png" alt=""></p>
<p>위 그림에 대해 설명하자면</p>
<p>UserA가 서비스 참여 가능 인원(cnt) 조회 (cnt = 1)</p>
<p>UserB가 서비스 참여 가능 인원(cnt) 조회 (cnt = 1)</p>
<p>UserA가 서비스에 참여하여 서비스 참여 가능 인원(cnt) -1 업데이트 (cnt = 0)</p>
<p>UserB도 동시에 서비스에 참여하여 서비스 참여 가능 인원(cnt) -1 업데이트 (cnt = 0)</p>
<p>예상으로는 한 사람만이 참여 가능한데 두 명이 지원을 했으면 한 사람은 무조건 참여를 못해야 하지만 각 유저가 cnt 조회시 1이 나왔기 때문에 결국 두 명 다 참여할 수 있게 되는 동시성 문제가 발생합니다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p>동시성을 제어하는 방법으로는 락을 거는 방법이 있습니다. 락을 거는 방법에는 MySQL 비관적 락, 낙관적 락, Redis 스핀락 등 여러 방법이 있으나 이 프로젝트에서는 Redisson 분산락을 사용하기로 하였습니다. </p>
<h3 id="redisson-분산락">Redisson 분산락</h3>
<ul>
<li>분산락?</li>
</ul>
<p>분산락이란 여러의 서버가 하나의 자원에 동시에 접근하려는 것을 막고 한번에 하나의 서버만 작업할 수 있도록 해주는 동기화 매커니즘 입니다. 이를 통해 데이터 동시변경을 막고 시스템 전체의 데이터 일관성을 보장합니다.</p>
<ul>
<li>Redisson 원리</li>
</ul>
<p>Redisson은 아래와 같은 프로세스로 락을 획득합니다.</p>
<ol>
<li>대기가 없는 경우 락 획득 후, true를 반환합니다.</li>
<li>이미 누군가 락을 획득한 경우, pub/sub에서 메시지가 올 때까지 대기하다가, 락이 해제되었다고 메시지가 오면 대기를 해제하고 락 획득을 시도합니다. 만약 락 획득에 실패하면 락 해제 메시지를 기다리고 타임아웃까지 기다립니다.</li>
<li>타임아웃이 지나며 최종적으로 false를 반환하고 락 획득을 실패했다고 합니다.</li>
</ol>
<p>(자세한 내용은 <a href="https://velog.io/@penrose_15/Redisson-tryLock-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95">여기로</a>)</p>
<h3 id="redisson-분산락-구성하기">Redisson 분산락 구성하기</h3>
<ol>
<li>gradle dependency</li>
</ol>
<pre><code>implementation &#39;org.redisson:redisson:3.27.1&#39;</code></pre><ol start="2">
<li>Lock 로직 구성</li>
</ol>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLock {
    private static final String REDISSON_LOCK_KEY = &quot;LOCK:&quot;;

    private final RedissonClient redissonClient;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void lock() throws Throwable {
        RLock rLock = redissonClient.getLock(REDISSON_LOCK_KEY);  // 락 생성
        try {
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeunit()); //락 획득 시도

            if(!available) {
                log.error(&quot;lock timeout&quot;);
                return false;
            }
            // (대충 실행하고자 하는 로직)
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                rLock.unlock(); //락 해제
            } catch (IllegalMonitorStateException e) {
                log.info(&quot;Redisson Lock Already Unlock, Key = {}&quot;,  REDISSON_LOCK_KEY);
            }
        }
    }
}
</code></pre>
<p>이제 위의 redisson lock 로직 사이에 실행하고자 하는 로직을 넣으면 되나</p>
<p>이런식으로 하면 Lock 로직과 비즈니스 로직의 분리가 되지 않습니다.</p>
<p>이를 해결하기 위해 <a href="https://helloworld.kurly.com/blog/distributed-redisson-lock/">마켓컬리의 기술블로그</a>를 참고하여 AOP를 활용하여 분산락 로직과 비즈니스 로직을 분리하였습니다.</p>
<ol start="3">
<li><p>DistributedLock</p>
<pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
 //락 이름
 String key();

 TimeUnit timeunit() default TimeUnit.SECONDS;

 // 락을 얻기 위해 기다릴 수 있는 시간
 long waitTime() default 5L;

 // 락 획득 후 임대할 수 있는 시간    
 long leaseTime() default 3L;
}</code></pre>
</li>
<li><p>DistributionLockAop</p>
</li>
</ol>
<pre><code class="language-java">@Slf4j
public class DistributedLockAop {
    private static final String REDISSON_LOCK_KEY = &quot;LOCK:&quot;;

    private final RedissonClient redissonClient;
    private final AopTransaction aopTransaction;

    @Around(&quot;@annotation(com.quiz.global.lock.DistributedLock)&quot;)
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String key = REDISSON_LOCK_KEY + CustomKeyParser.getKeyNameSuffix(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); // 키 이름 생성
        RLock rLock = redissonClient.getLock(key);
        try {
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeunit());

            if (!available) {
                log.error(&quot;lock timeout&quot;);
                return false;
            }
            return aopTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info(&quot;Redisson Lock Already Unlock serviceName = {}, Key = {}&quot;, method.getName(), REDISSON_LOCK_KEY);
            }
        }
    }
}</code></pre>
<p>여기서 각각의 메서드와 파라미터에 따라 키를 다르게 하기 위해</p>
<pre><code class="language-java">String key = REDISSON_LOCK_KEY + CustomKeyParser.getKeyNameSuffix(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());</code></pre>
<p>SpelExpressionParser를 활용하여 키 이름을 생성하였습니다.</p>
<pre><code class="language-java">public class CustomKeyParser {
    //parameterNames : 파라미터 이름
    //args: 파라미터 값
    public static Object getKeyNameSuffix(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i &lt; parameterNames.length; i++) { //파라미터 이름과 값들을 합쳐 키 이름 생성
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}</code></pre>
<p>추가적으로 AOPTransaction을 통해 비즈니스 로직이 실행시 부모의 트랜잭션과 상관없이 트랜잭션을 새로 생성하였고, 반드시 비즈니스 로직의 트랜잭션이 커밋된 이후 락을 해제하였습니다.</p>
<pre><code class="language-java">@Component
public class AopTransaction {

    //트랜잭션 커밋보다 락의 해제가 뒤에서 일어나야 한다
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}</code></pre>
<p>만약 락의 해제 시점이 비즈니스 로직 트랜잭션 커밋 이전에 발생하면</p>
<p>비즈니스 로직 트랜잭션이 커밋되기 이전에 다른 스레드에서 락을 얻어 비즈니스 로직을 수행할 수 있기 때문입니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/5c44022a-6db6-42ff-9d7b-61f98f220d4f/image.png" alt=""></p>
<p>이로 인해 정합성이 깨질 수 있습니다.</p>
<p>반면에 비즈니스 로직의 트랜잭션 커밋 후 락 해제시 이러한 문제는 사라집니다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/617f782f-9ac9-4df0-9daf-0f5684524188/image.png" alt=""></p>
<h3 id="테스트">테스트</h3>
<pre><code class="language-java">@Slf4j
@SpringBootTest
public class ParticipantInfoServiceTest {
    @Autowired
    ParticipantInfoService participantInfoService;

    Long quizId = 1L;
    int capacity = 90;

    @AfterEach
    void clear() {
        participantInfoService.deleteAll();
    }

    // 동시성 테스트

    // 90명 선착순
    // 100 명의 참가자
    // 10명은 반드시 참여 불가해야 함

    @Test
    void saveFcfsTest() throws InterruptedException {
        int threadCnt = 100;
        AtomicInteger cnt = new AtomicInteger();
        CountDownLatch countDownLatch;
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            countDownLatch = new CountDownLatch(threadCnt);

            IntStream.range(0, threadCnt).forEach(e -&gt; executor.execute(() -&gt; {
                try {
                    participantInfoService.saveFcfs(quizId, (long) (e + 1), capacity);
                } catch (Exception ex) {
                    cnt.getAndIncrement();
                } finally {
                    countDownLatch.countDown();
                }
            }));
            countDownLatch.await();
        }


        int participantCnt = participantInfoService.countParticipantInfoCntByQuizId(quizId);
        List&lt;ParticipantInfo&gt; participantInfoList = participantInfoService.findParticipantInfoByQuizId(quizId);
        for (ParticipantInfo participantInfo : participantInfoList) {
            log.info(&quot;_id : {}&quot;, participantInfo.getId());
            log.info(&quot;userId : {}&quot;, participantInfo.getUserId());
        }

        log.info(&quot;participantCnt = {}&quot;, participantCnt);
        assertThat(participantCnt)
                .isEqualTo(90);
        assertThat(cnt.get())
                .isEqualTo(10);

    }

}
</code></pre>
<p><code>@DistributedLock</code> 적용 전 테스트
<img src="https://velog.velcdn.com/images/penrose_15/post/02f0e1d0-650d-4d5f-871a-d4aaafaeca03/image.png" alt=""></p>
<p><code>@DistributedLock</code> 적용 후 테스트
<img src="https://velog.velcdn.com/images/penrose_15/post/c98b8a87-f748-4a52-a50e-9d9a7fced5f2/image.png" alt=""></p>
<hr>
<p>+) 이후 Redis에 장애가 발생할 경우에 대해서도 조사를 해봤습니다.</p>
<p>Redis가 1대일 경우 Redis 장애 발생 시 무조건 락이 유실됨과 동시에 뒤에 진행되어야 할 락에도 영향을 미칠 것이고</p>
<p>설령 Master Replica로 Redis Cluster를 구성한다 해도 Master 에 장애가 발생하면 Master에 걸린 락은 유실된다는 문제점이 있었습니다.</p>
<p>더 찾아보니 RedLock이라는게 있다는 사실을 알게 되었는데</p>
<p>설명하자면, n개의 Redis가 lock 획득을 시도하여 과반수의 Redis에서 잠금이 획득되면, Lock이 획득된 것으로 간주하고 아니라면 전부 잠금을 해제한다고 합니다.(이로 인해 성능은 떨어진다고 합니다) </p>
<p><a href="https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers#84-redlock">참고로 Redisson의 RedLock은 Deprecated 되었다... </a></p>
<p>Redisson 같은 경우 RLock나 RFencedLock를 대신 사용하라는데 FencedLock는 락 획득시 추가적으로 토큰도 같이 얻어 락 소유권을 확인하는 방식이라 Master-Replica 에서 락이 유실될 수 있는 문제는 해결하지 못했습니다.</p>
<p>결국 결함 허용성을 높이려면 zookeeper를 활용하는게 맞지만 현재 프로젝트에 zookeeper는 좀 오버스펙이라는 생각이 들었습니다.</p>
<p>결론은 Single Redis를 활용한 Lock를 구현하였으나 이 방법이 늘 최적의 방법은 아니라는 것을 알게 되었고 만약 안정성, 가용성이 더 중요한 상황에는 다른 기술을 사용해야 한다는 것을 알게 되었습니다. </p>
<hr>
<p>Reference</p>
<p><a href="https://helloworld.kurly.com/blog/distributed-redisson-lock/">https://helloworld.kurly.com/blog/distributed-redisson-lock/</a>
<a href="https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers#84-redlock">https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers#84-redlock</a>
<a href="https://channel.io/ko/blog/distributedlock_2022_backend">https://channel.io/ko/blog/distributedlock_2022_backend</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redisson tryLock 동작 과정]]></title>
            <link>https://velog.io/@penrose_15/Redisson-tryLock-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@penrose_15/Redisson-tryLock-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Thu, 04 Apr 2024 02:01:09 GMT</pubDate>
            <description><![CDATA[<h3 id="redisson-trylock-동작-과정">Redisson tryLock 동작 과정</h3>
<pre><code class="language-java">public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); //(1)
        if (ttl == null) { // (2)
            return true;
        } else {
            time -= System.currentTimeMillis() - current;
            if (time &lt;= 0L) { 
                this.acquireFailed(waitTime, unit, threadId);
                return false;
            } else {
                current = System.currentTimeMillis();
                CompletableFuture&lt;RedissonLockEntry&gt; subscribeFuture = this.subscribe(threadId);

                try { // (3)
                    subscribeFuture.get(time, TimeUnit.MILLISECONDS);
                } catch (TimeoutException var21) {
                    if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(&quot;Unable to acquire subscription lock after &quot; + time + &quot;ms. Try to increase &#39;subscriptionsPerConnection&#39; and/or &#39;subscriptionConnectionPoolSize&#39; parameters.&quot;))) {
                        subscribeFuture.whenComplete((res, ex) -&gt; {
                            if (ex == null) {
                                this.unsubscribe(res, threadId);
                            }

                        });
                    }

                    this.acquireFailed(waitTime, unit, threadId);
                    return false;
                } catch (ExecutionException var22) {
                    this.acquireFailed(waitTime, unit, threadId);
                    return false;
                }

                try {
                    time -= System.currentTimeMillis() - current;
                    if (time &lt;= 0L) {
                        this.acquireFailed(waitTime, unit, threadId);
                        boolean var24 = false;
                        return var24;
                    } else {
                        boolean var16;
                        do { // (4)
                            long currentTime = System.currentTimeMillis();
                            ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
                            if (ttl == null) {
                                var16 = true;
                                return var16;
                            }

                            time -= System.currentTimeMillis() - currentTime;
                            if (time &lt;= 0L) {
                                this.acquireFailed(waitTime, unit, threadId);
                                var16 = false;
                                return var16;
                            }

                            currentTime = System.currentTimeMillis();
                            if (ttl &gt;= 0L &amp;&amp; ttl &lt; time) {
                                ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                            } else {
                                ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                            }

                            time -= System.currentTimeMillis() - currentTime;
                        } while(time &gt; 0L);

                        this.acquireFailed(waitTime, unit, threadId);
                        var16 = false;
                        return var16;
                    }
                } finally {
                    this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
                }
            }
        }
    }</code></pre>
<p>(1) </p>
<pre><code class="language-java">Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);</code></pre>
<p>tryAcquire() 내부 로직을 보면 아래와 같이 lua 스크립트를 사용하는 것을 알 수 있습니다. 이로 인해 속도에 이점을 얻을 수 있습니다.</p>
<pre><code class="language-java">&lt;T&gt; RFuture&lt;T&gt; tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand&lt;T&gt; command) {
        return this.evalWriteSyncedAsync(this.getRawName(), LongCodec.INSTANCE, command, &quot;if ((redis.call(&#39;exists&#39;, KEYS[1]) == 0) or (redis.call(&#39;hexists&#39;, KEYS[1], ARGV[2]) == 1)) then redis.call(&#39;hincrby&#39;, KEYS[1], ARGV[2], 1); redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]); return nil; end; return redis.call(&#39;pttl&#39;, KEYS[1]);&quot;, Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
    }</code></pre>
<pre><code>&quot;if ((redis.call(&#39;exists&#39;, KEYS[1]) == 0) // LOCK KEY 가 존재하는지 확인(없으면 0, 있으면 1)
or (redis.call(&#39;hexists&#39;, KEYS[1], ARGV[2]) == 1)) // 해시맵 기반으로 LOCK KEY와 스테드 아이디로 존재는지 확인(있으면 0, 존재하지 않으면 저장 후 1리턴)
then redis.call(&#39;hincrby&#39;, KEYS[1], ARGV[2], 1);  // LOCK KEY 가 존재하지 않으면 LOCK KEY와 쓰레드 아이디 기반으로 값 1증가.
redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]); // LOCK KEY에 유효시간을 설정한다.
return nil; // null 반환.
end; 
return redis.call(&#39;pttl&#39;, KEYS[1]);&quot; // 만약 위의 조건들이 모두 false라면 LOCK KEY TTL 시간 리턴.</code></pre><p>위의 과정을 통해 만약 대기가 없는 경우 바로 락을 획득 후 null을 리턴하고 아니라면 LOCK KEY의 TTL 시간을 리턴합니다.</p>
<p>(2) </p>
<pre><code class="language-java">if (ttl == null) {
            return true;
        } else {
            time -= System.currentTimeMillis() - current;
            if (time &lt;= 0L) {
                this.acquireFailed(waitTime, unit, threadId);
                return false;
            }
            ...</code></pre>
<p>만약 ttl 이 null이라면 대기가 없다는 뜻이므로 락을 얻고 true 를 리턴하고,
아니라면 waitingTime을 초과했는지 한 후, 초과했다면 false 를 리턴합니다.</p>
<p>(3) </p>
<pre><code class="language-java">current = System.currentTimeMillis();
                CompletableFuture&lt;RedissonLockEntry&gt; subscribeFuture = this.subscribe(threadId); // (3-1)

                try {
                    subscribeFuture.get(time, TimeUnit.MILLISECONDS);
                } catch (TimeoutException var21) {
                    if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(&quot;Unable to acquire subscription lock after &quot; + time + &quot;ms. Try to increase &#39;subscriptionsPerConnection&#39; and/or &#39;subscriptionConnectionPoolSize&#39; parameters.&quot;))) {
                        subscribeFuture.whenComplete((res, ex) -&gt; {
                            if (ex == null) {
                                this.unsubscribe(res, threadId);
                            }

                        });
                    }

                    this.acquireFailed(waitTime, unit, threadId);
                    return false;
                } catch (ExecutionException var22) {
                    this.acquireFailed(waitTime, unit, threadId);
                    return false;
                }</code></pre>
<p>subscribe 메서드를 통해 threadId를 채널로 구독하고, CompletableFuture get() 메서드를 통해 락 획득이 가능할 때까지 대기합니다.</p>
<pre><code class="language-java">// RedissonLock.class
protected CompletableFuture&lt;RedissonLockEntry&gt; subscribe(long threadId) {
        return this.pubSub.subscribe(this.getEntryName(), this.getChannelName());
    }

// PublishSubscribe.class

public CompletableFuture&lt;E&gt; subscribe(String entryName, String channelName) {
        AsyncSemaphore semaphore = this.service.getSemaphore(new ChannelName(channelName));
        CompletableFuture&lt;E&gt; newPromise = new CompletableFuture();
        semaphore.acquire().thenAccept((c) -&gt; {
            if (newPromise.isDone()) {
                semaphore.release();
            } else {
            ...
            }</code></pre>
<p>더 깊게 확인해보면</p>
<p>semaphore를 활용하는 것을 볼 수 있습니다.
semaphore를 통해 하나의 스레드가 락을 획득하면 다른 스레드들이 접근하지 못하도록 제어합니다.</p>
<p>(4)
이후 다시 (2)에서 했던 것처럼 시간 초과가 되었는지 확인한 후</p>
<pre><code class="language-java">do {
    long currentTime = System.currentTimeMillis();
    ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) { // (4-1)
      var16 = true;
      return var16;

    }

    time -= System.currentTimeMillis() - currentTime;
    if (time &lt;= 0L) {
      this.acquireFailed(waitTime, unit, threadId);
      var16 = false;
      return var16;
    }

    currentTime = System.currentTimeMillis();
    if (ttl &gt;= 0L &amp;&amp; ttl &lt; time) { // (4-2)
      ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
    } else {
      ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
    }
    time -= System.currentTimeMillis() - currentTime;
  } while(time &gt; 0L);</code></pre>
<p>(4-1)
락을 획득을 다시 시도하여 성공했으면 true를 리턴하고</p>
<p>(4-2)
threadId로 구독한 객체로 유효시간동안 lock이 가능한지 확인합니다.</p>
<p>위의 과정을 do ~ while 문으로 시간이 다 될 때까지 루프를 탑니다.</p>
<h3 id="정리">정리</h3>
<p>정리하자면</p>
<ol>
<li>tryLock()로 락 획득을 시도</li>
<li>락을 획득했으면 true 리턴</li>
<li>락 획득 실패시 waitingTime 확인 후 시간이 남았으면 pubsub 구독 후 다시 락 획득이 가능하면 다시 시도</li>
<li>1 - 3 과정을 waitingTime이 다 될 때까지 무한반복</li>
</ol>
<p>의 과정을 거치게 됩니다. </p>
<p>pub/sub를 활용하여 부하가 스핀락보다는 부하가 덜 든다고 하여 구조가 아예 다른 줄 알았는데, 스핀락의 원리를 어느정도 활용한 것을 확인할 수 있었습니다. </p>
<p>다만 스핀락처럼 계속 확인하는 것이 아닌, 락이 풀렸다는 알림이 올 때 락 획득을 시도한다는 점에서 부하 감소가 발생하는 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MongoDB]]></title>
            <link>https://velog.io/@penrose_15/MongoDB</link>
            <guid>https://velog.io/@penrose_15/MongoDB</guid>
            <pubDate>Wed, 20 Mar 2024 04:18:24 GMT</pubDate>
            <description><![CDATA[<p>MongoDB는 데이터 레코드를 BSON(JSON의 이진표현) 형식으로 저장한다.</p>
<pre><code>{
               _id: ObjectId(&quot;5099803df3f4948bd2f98391&quot;),
               name: { first: &quot;Alan&quot;, last: &quot;Turing&quot; },
               birth: new Date(&#39;Jun 23, 1912&#39;),
               death: new Date(&#39;Jun 07, 1954&#39;),
               contribs: [ &quot;Turing machine&quot;, &quot;Turing test&quot;, &quot;Turingery&quot; ],
               views : NumberLong(1250000)
            }</code></pre><p>Document, Collection</p>
<p>Document : RDBMS의 row</p>
<p>Collection: Document의 그룹, RDBMS의 table</p>
<h3 id="bson">BSON</h3>
<p>JSON의 이진 직렬화 형식으로 분산 시스템에서 데이터를 저장하고 전송하기 위해 만들어짐
이진 형식이라 텍스트 형식보다 데이터 크기가 작고 처리 속도가 빠르다.</p>
<h3 id="embedded-vs-references">Embedded vs References</h3>
<p>embedded
<img src="https://velog.velcdn.com/images/penrose_15/post/88da5e6c-6956-4fc5-9c28-a242ebdc264d/image.png" alt=""></p>
<p>embedded 가 사용되는 경우</p>
<ul>
<li>1:1 인 경우</li>
<li>데이터가 작은 경우</li>
</ul>
<p>references
<img src="https://velog.velcdn.com/images/penrose_15/post/75be7ed7-6b2f-4eb8-a269-eaf428368dcc/image.png" alt=""></p>
<p>References 가 사용되는 경우</p>
<ul>
<li>데이터 변경이 자주 일어나거나 </li>
<li>중복이 많이 일어나거나</li>
<li>1:N , N:M인 경우 </li>
</ul>
<p>1:1의 경우 Embedded로 참조를 한다.</p>
<p>1:N인 경우 Embedded vs Link 둘 중 하나를 선택하면 되는데 아래와 같은 기준에 따라 선택하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/44ba27eb-ec99-4ca7-9699-8d587828fbf9/image.png" alt=""></p>
<h3 id="join-성능">Join 성능</h3>
<p>Mongodb에는 RDBMS 와 달리 JOIN 대신 lookup를 사용한다.</p>
<p>Mongodb의 Join은 RDBMS의 JOIN 보다 성능이 느리다.</p>
<p>이유는 RDBMS의 JOIN은 병합 조인, 해시 조인 등 여러 조인 전략 중 옵티마이저를 통해 최적의 전략을 선택하나 MongoDB는 단일 조인 전략만 사용한다고 한다. </p>
<p><a href="https://www.enterprisedb.com/blog/comparison-joins-mongodb-vs-postgresql">출처</a></p>
<p>그래서 JOIN 전 $match나 $project 등을 통해 최대한 JOIN 대상 데이터를 줄이라고 한다.</p>
<h3 id="shading">Shading</h3>
<p>MongoDB는 샤딩이라는 방법으로 수평 확장을 구현한다.</p>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/549531cc-c13c-4906-9216-75a60020368a/image.png" alt=""></p>
<p>이러한 방법으로 시스템의 내결함성과 가용성을 향상 시킬 수 있으나 데이터가 균등하게 저장이 되는지, 중복이 발생하는지에 대해 확인해야 하므로 복잡성이 증가할 수 있다.</p>
<h3 id="replication">Replication</h3>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/2b3478bf-f51a-4ea0-8948-69a72c270c47/image.png" alt=""></p>
<p>하나의 데이터베이스 (Primary) 가 Read/Write를 진행하고 다른 데이터베이스에게 데이터를 복제하는 방식</p>
<p>Primary DB가 중단된 경우 Secondary 중 하나를 Primary로 승격시킬 수 있다.</p>
<p>시스템 가용성과 내결함성이 크게 향상된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MongoDB 트랜잭션 활성화를 위한 Replica Set 구성 (feat: Docker)]]></title>
            <link>https://velog.io/@penrose_15/Docker-MongoDB-replicaSet-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@penrose_15/Docker-MongoDB-replicaSet-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 19 Mar 2024 08:59:46 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트 중 Mongodb로 트랜잭션을 사용하려면 Replica Set를 구성해야 한다고 해서 작성하게 되었다.</p>
<h3 id="굳이-mongodb에-트랜잭션을-도입한-이유">굳이 MongoDB에 트랜잭션을 도입한 이유</h3>
<p>선착순 퀴즈 참여 서비스를 주제로 하다보니 여러 유형의 문제(객관식, 단답형, 주관식...) 를 만들 수 있어야 했고 나중에 유형이 더 추가될 수도 있었다. 그렇기 때문에 스키마 변경이 자유로운 MongoDB를 채택하게 되었다.</p>
<p>하지만 프로젝트가 복잡해지면서 단일 문서에 대한 작업보단 여러 문서에 걸친 작업이 많아져 트랜잭션을 도입하게 되었다. (MongoDB의 경우 기본적으로 단일 문서에 대한 작업은 원자적으로 이루어지지만 여러 문서에서는 보장되지 않는다.) </p>
<h3 id="replica-set">Replica Set</h3>
<p>Mongodb Replica Set는 동일한 데이터 세트를 유지관리하는 mongodb 프로세스 그룹으로 중복성과 고가용성을 제공한다.</p>
<p>DB에 장애가 발생하는 경우 빠르게 복구할 수 있는 장점이 있다.</p>
<h3 id="mongodb에-트랜잭션-사용시-replica-set가-필요한-이유">MongoDB에 트랜잭션 사용시 Replica Set가 필요한 이유</h3>
<p>공식 답변에 따르면 트랜잭션은 논리적 세션의 개념으로 만들어졌기 때문에, 레플리카 셋 환경에서만 가능한 oplog 와 같은 기술이 필요하다고 한다.</p>
<p>여기서 oplog는 레플리카 셋의 데이터 동기화를 위해 내부에서 발생한 로그를 기록한 것을 뜻한다.</p>
<p><a href="https://www.mongodb.com/community/forums/t/why-replica-set-is-mandatory-for-transactions-in-mongodb/9533">출처</a></p>
<h3 id="replica-set-구성">Replica Set 구성</h3>
<p>레플리카 셋은 세가지 역할로 나눌 수 있다.</p>
<ul>
<li>Primary : 클라이언트에서 DB Read/Write</li>
<li>Secondary : Primary로부터 동기화를 한다. oplog를 복제하여 데이터를 동기화 한다.</li>
<li>Arbiter(Optional) : 데이터를 동기화하지는 않으나 Primary 장애시 Secondary 중 누구를 Primary로 선출할지 결정하는 투표권자</li>
</ul>
<p><img src="https://velog.velcdn.com/images/penrose_15/post/98ef369e-d1d5-4498-a1a1-d027330b1045/image.png" alt=""></p>
<p>여기서 HeartBeat는 Replica Set내의 node 간 정해진 초마다 서로에게 ping를 보낸다. 만약 Heartbeat가 특정 초마다 수신되지 않으면 해당 DB 가 죽었다 판단하고 다른 node끼리 Election을 준비한다.</p>
<p>Replica Set의 구성에는 두가지의 방법이 있다</p>
<ul>
<li>PSS(Primary + Secondary + Secondary)</li>
<li>PSA(Primary + Secondary + Arbiter)</li>
</ul>
<p>PSS는 하나의 Primary + 2개의 Secondary 로 구성되어 있으며, Secondary가 2개나 있어 높은 안정성을 보장한다.</p>
<p>PSA는 Primary, Secondary, Arbiter 이 각각 하나씩 있는 구성이다.
Arbiter는 데이터를 저장하지는 않고 Primary 장애시 Secondary 중 누구를 Primary로 대체할지 Election 하는 기능을 가지고 있다.
PSS 보단 안정성이 낮으나 서버 리소스를 덜 잡아 먹는 장점이 있다.</p>
<h3 id="docker--mongodb-replicaset-구성방법">Docker + MongoDB ReplicaSet 구성방법</h3>
<ol>
<li>docker-compose.yml</li>
</ol>
<p>먼저 도커 컨테이너 끼리 통신할 외부 네트워크를 생성한다.</p>
<pre><code class="language-shell">&gt; docker network create mongoCluster</code></pre>
<p>docker-compose.yml</p>
<pre><code class="language-yml">services:
  mongodb1:
    image: mongo
    hostname: mongodb1
    container_name: mongodb1
    restart: always
    environment:
      MONGO_INITDB_ROOT_USERNAME: &lt;username&gt;
      MONGO_INITDB_ROOT_PASSWORD: &lt;password&gt;
    volumes:
      - ./mongo/mongo1/mongod.conf:/etc/mongod.conf
      - ./key/mongodb.key:/etc/mongodb.key
      - ./data/mongodb1:/data/db
    command: mongod --replSet rs0 --port 27017 --keyFile /etc/mongodb.key --bind_ip_all
    ports:
      - 27017:27017
    networks:
      - mongoCluster
  mongodb2:
    image: mongo
    hostname: mongodb2
    container_name: mongodb2
    restart: always
    environment:
      MONGO_INITDB_ROOT_USERNAME: &lt;username&gt;
      MONGO_INITDB_ROOT_PASSWORD: &lt;password&gt;
    volumes:
      - ./mongo/mongo2/mongod.conf:/etc/mongod.conf
      - ./key/mongodb.key:/etc/mongodb.key
      - ./data/mongodb2:/data/db
    command: mongod --replSet rs0 --port 27018  --keyFile /etc/mongodb.key --bind_ip_all
    ports:
      - 27018:27018
    networks:
      - mongoCluster
    depends_on:
      - mongodb1
  mongodb3:
    image: mongo
    hostname: mongodb3
    container_name: mongodb3
    restart: always
    environment:
      MONGO_INITDB_ROOT_USERNAME: &lt;username&gt;
      MONGO_INITDB_ROOT_PASSWORD: &lt;password&gt;
    volumes:
      - ./mongo/mongo3/mongod.conf:/etc/mongod.conf
      - ./key/mongodb.key:/etc/mongodb.key
      - ./data/mongodb3:/data/db
    command: mongod --replSet rs0 --port 27019  --keyFile /etc/mongodb.key --bind_ip_all
    ports:
      - 27019:27019
    networks:
      - mongoCluster
    depends_on:
      - mongodb1



networks:
  mongoCluster:
    external: true</code></pre>
<ol start="2">
<li>config 파일 작성</li>
</ol>
<p>MongoDB의 conf 파일은 <code>etc/mongod.conf</code>에 저장된다.</p>
<p>mongoDB가 총 3개이고 port 가 각기 다르므로 3개를 만들어야 했다.</p>
<pre><code class="language-conf"># mongod.conf

# Where and how to store data.
storage:
  dbPath: /var/lib/mongodb

# where to write logging data.
systemLog:
  destination: file
  logAppend: true
  path: /var/log/mongodb/mongod.log

# network interfaces
net:
  port: 27017
  bindIp: 127.0.0.1


# how the process runs
processManagement:
  timeZoneInfo: /usr/share/zoneinfo

# Keyfile 위치 설정
security:
  authorization: enabled
  clusterAuthMode: keyFile
  keyFile: /etc/mongodb.key

# Replica Set 이름 설정
replication:
  replSetName: rs0</code></pre>
<ol start="3">
<li>키 설정</li>
</ol>
<pre><code class="language-shell">mkdir key

cd key

openssl rand -base64 756 &gt; mongodb.key
chmod 400 mongodb.key
chown 999:999 mongodb.key
</code></pre>
<p>mac 유저라면 위와 같이 간단히 키 설정을 할 수 있으나
윈도우 유저라면 위와 같은 방식으로는 키 설정이 되지 않을 것이다.(아마도)</p>
<p>필자 노트북은 Windows라 위의 방법을 사용하지 못했다. <del>(맥북 갖고 싶다)</del></p>
<p>결국 <a href="https://amkorousagi-money.tistory.com/entry/mongod-keyFile-%EC%8B%9C-permissions-on-etcmongodbkey-are-too-open-%EB%98%90%EB%8A%94-etcmongodbkey-bad-file">이 블로그</a> 글을 통해 Docker의 volume mount 기능을 활용하여 권한 설정을 할 수 있었다.</p>
<p>3-1. nginx 생성을 위한 docker-compose.yml 생성</p>
<pre><code>cd nginx

vim docker-compose.yml</code></pre><p>docker-compose.yml</p>
<pre><code>version: &quot;3&quot;

services: 
  nginx: 
    image: nginx
    container_name: nginx
    ports: 
      - &quot;3001:80&quot;
    volumes:
      - ./key:/key</code></pre><p>3-2. nginx 실행</p>
<pre><code>docker-compose up -d</code></pre><p>3-3. docker desktop나 <code>docker exec -it nginx bash</code>로 컨테이너 접속 후 키 파일 생성</p>
<pre><code class="language-shell">cd /key
openssl rand -base64 756 &gt; mongodb.key
chmod 400 mongodb.key
chown 999:999 mongodb.key</code></pre>
<p>이러면 volume 마운트로 인해 권한 설정이 된 키 파일이 로컬에도 생성 될 것이다.</p>
<p>(참고로 docker-compose는 해당 파일 기준으로 파일위치를 설정해줘야 한다. 만약 docker-compose.yml이 <code>/filename</code>에 위치하고 key file이 <code>/filename/key</code>에 위치한다면 volume에는 키 위치를 <code>./key/mongodb.key</code>로 설정해주어야 한다. )</p>
<ol start="4">
<li>mongodb docker-compose.yml 실행<pre><code>docker-compose up -d</code></pre></li>
</ol>
<p>이후 docker desktop의 mongodb1의 exec에 들어가서 replication set 초기화를 해주면 된다.</p>
<pre><code class="language-shell"># mongosh 접속
mongosh -u &lt;username&gt; -p &lt;password&gt;

# 사용자 전환
test&gt; use admin

# 초기화
admin&gt; rs.initiate({_id:&quot;rs0&quot;, members:[{_id: 0, host:&quot;mongodb1:27017&quot;},{_id: 1, host: &quot;mongodb2:27018&quot;},{_id: 2, host: &quot;mongodb3:27019&quot;}]})
</code></pre>
<ol start="5">
<li>확인</li>
</ol>
<p>rs.status() 로 확인
<img src="https://velog.velcdn.com/images/penrose_15/post/0b4b0f66-9e93-45f8-8990-dacef6b8d4fa/image.png" alt=""></p>
<p>Primary DB shell에 테스트로 DB와 collection 을 생성 한 후 데이터를 insert 한 후, Secondary에서 확인해보자</p>
<pre><code class="language-shell"># Primary DB exec
rs0 [direct: primary] admin&gt; use testdb
switched to db testdb
rs0 [direct: primary] testdb&gt; db.createCollection(&quot;testCollection&quot;)
{ ok: 1 }
rs0 [direct: primary] testdb&gt; show dbs
admin   140.00 KiB
config  192.00 KiB
local   452.00 KiB
testdb    8.00 KiB
rs0 [direct: primary] testdb&gt; db.testcollection.insert({&quot;name&quot;: &quot;test&quot;})
DeprecationWarning: Collection.insert() is deprecated. Use insertOne, insertMany, or bulkWrite.
{
  acknowledged: true,
  insertedIds: { &#39;0&#39;: ObjectId(&#39;65fa2e223456c76da7db83b0&#39;) }
}
rs0 [direct: primary] testdb&gt; db.testcollection.find()
[ { _id: ObjectId(&#39;65fa2e223456c76da7db83b0&#39;), name: &#39;test&#39; } ]

############# Secondary DB exec ###############
rs0 [direct: secondary] test&gt; rs.secondaryOk()

rs0 [direct: secondary] test&gt; use admin
switched to db admin
rs0 [direct: secondary] admin&gt; db.auth(&quot;root&quot;, &quot;password1!&quot;)
{ ok: 1 }

rs0 [direct: secondary] admin&gt; show dbs
admin   140.00 KiB
config  244.00 KiB
local   468.00 KiB
testdb   48.00 KiB

rs0 [direct: secondary] admin&gt; use testdb
switched to db testdb

rs0 [direct: secondary] testdb&gt; db.testcollection.find()
[ { _id: ObjectId(&#39;65fa2e223456c76da7db83b0&#39;), name: &#39;test&#39; } ]
</code></pre>
<p>보다시피 Primary에서 insert한 데이터가 Secondary에도 저장되있는 것을 확인할 수 있다.</p>
<hr>
<p>+) 추가적으로 windows에서 <code>C:\Windows\System32\drivers\etc\hosts</code> 에 아래와 같은 내용을 추가하여 도커의 호스트 이름과 로컬 IP 주소를 매핑 시켜줘야 한다.</p>
<p>127.0.0.1   mongodb1
127.0.0.1   mongodb2
127.0.0.1   mongodb3</p>
<p>(mac의 경우 <code>/etc/hosts</code>에 추가)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[선착순 퀴즈 프로젝트에 MongoDB 적용기]]></title>
            <link>https://velog.io/@penrose_15/%EC%84%A0%EC%B0%A9%EC%88%9C-%ED%80%B4%EC%A6%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-MongoDB-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@penrose_15/%EC%84%A0%EC%B0%A9%EC%88%9C-%ED%80%B4%EC%A6%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-MongoDB-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Mon, 18 Mar 2024 14:51:04 GMT</pubDate>
            <description><![CDATA[<p>최근 개인 프로젝트로 선착순 퀴즈 프로젝트에 MongoDB를 사용하게 되었다.</p>
<p>그래서 시작하는 MongoDB 적용기.</p>
<h3 id="mongodb란">MongoDB란</h3>
<p>간단하게 설명하자면 BSON(JSON의 이진 형식)  형식으로 데이터를 저장하는 NoSQL 이다.</p>
<p>스키마로 데이터 유형을 표준화 하는 RDBMS와 달리 MongoDB는 유연한 스키마로 비정형 데이터를 저장하는데 사용된다.</p>
<h3 id="mongodb를-사용하게-된-이유">MongoDB를 사용하게 된 이유</h3>
<p>처음에는 사용자가 퀴즈에 답변을 제출하다 오류가 나면 롤백을 해야 한다는 생각에 Mysql만을 사용하기로 하였다.</p>
<p>그러나 퀴즈의 유형에는 단답형/객관식/서술형/True-False 등등 여러 유형의 문제가 있는데 이를 RDBMS에 저장하기에는 제약이 있어 MongoDB를 사용하기로 결정하였다.</p>
<h3 id="springboot에-mongodb-설정">SpringBoot에 MongoDB 설정</h3>
<ol>
<li>dependency</li>
</ol>
<pre><code>dependencies {
    implementation &quot;org.springframework.boot:spring-boot-starter-data-mongodb&quot;

}</code></pre><ol start="2">
<li><p>application.yml</p>
<pre><code class="language-yml">spring:
data:
 mongodb:
   url: mongodb://admin:password@localhost:27018?authSource=admin
   host: localhost
   port: 27018
   database: dbname
   username: admin
   password: password
   replica-set: rs0 //transaction 을 사용했기 때문에 추가
   authentication-database: admin</code></pre>
</li>
<li><p>MongoDBConfig</p>
</li>
</ol>
<pre><code class="language-java">@Slf4j
@Configuration
@EnableMongoRepositories(
        basePackages = {&quot;com.quiz.domain.*.mongo&quot;}) // (1) MongoDB만 사용한다면 basePackages를 명시하지 않아도 된다.
@EnableTransactionManagement // (2) MongoDB 트랜잭션
public class MongoDBConfig extends AbstractMongoClientConfiguration {
    @Value(&quot;${spring.data.mongodb.url}&quot;)
    private String connectionString;

    @Value(&quot;${spring.data.mongodb.database}&quot;)
    private String databaseName;

    @Bean // (3) 컨테이너에 트랜잭션을 직접 등록시켜줘야 함
    public MongoTransactionManager transactionManager(MongoDatabaseFactory mongoDatabaseFactory) {
        return new MongoTransactionManager(mongoDatabaseFactory);
    }

    @Override
    public MongoClient mongoClient() {
        String url = this.connectionString;
        ConnectionString connectionString = new ConnectionString(this.connectionString);
        MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
                .applyToConnectionPoolSettings(builder -&gt; builder.maxConnectionIdleTime(10, TimeUnit.SECONDS)) //최대 유휴 시간
                .applyConnectionString(connectionString)
                .build();

        return MongoClients.create(mongoClientSettings);
    }

    @Override
    protected String getDatabaseName() {
        return databaseName;
    }


    @Bean //MongoTemplate 설정
    public MongoOperations mongoTemplate() {
        return new MongoTemplate(mongoClient(), databaseName);
    }
}
</code></pre>
<ul>
<li><code>(1)</code> : basePackages를 명시한 이유는 현재 프로젝트에서 Mysql도 같이 사용하고 있어 MongoDB 를 사용하고 있는 Repository를 명시해주어야 했기 때문이다. MongoDB만 사용 중이라면 basePackages를 명시하지 않아도 된다.</li>
</ul>
<ul>
<li><code>(2), (3)</code> : MongoDB 트랜잭션을 사용하기 위해 추가하였다. </li>
</ul>
<p>MongoDB는 버전 4.0 부터 트랜잭션 적용이 가능해졌다.</p>
<p>SpringBoot는 자동으로 등록된 라이브러리를 보고 해당하는 트랜잭션(JDBCTransactionManager, JPATransactionManager...)을 컨테이너에 자동으로 등록해준다. </p>
<p>그러나 MongoDB는 트랜잭션이 선택이라 우리가 직접적으로 등록해주어야 한다.</p>
<p>또한 트랜잭션을 사용하려면 추가적으로 MongoDB에 Replica set를 설정해주어야 한다. <del>(점점 일이 커진다)</del></p>
<p>(설정 방법은 추후에 포스팅하겠다.)</p>
<ol start="4">
<li>QuestionMongoTemplate</li>
</ol>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class QuestionsMongoTemplate {

    private final MongoTemplate mongoTemplate;

    ...

}</code></pre>
<p>MongoTemplate는 세밀한 쿼리문을 작성할 때 사용하면 편하다.</p>
<ol start="5">
<li>MongoRepository<pre><code class="language-java">public interface QuestionsRepository extends MongoRepository&lt;Questions, String&gt; {
 ...
}</code></pre>
MongoRepository는 단순한 CRUD 사용시 편리하게 사용시 적합하다.</li>
</ol>
]]></description>
        </item>
    </channel>
</rss>