<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>minwoo95 님의 블로그</title>
    <link>https://minwoo95.tistory.com/</link>
    <description>minwoo95 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 9 May 2026 20:13:57 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>minwoo95</managingEditor>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 17</title>
      <link>https://minwoo95.tistory.com/216</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778245183449&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778245190227&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 AI 이미지 생성 SDK 연동과 Kafka 설정이 완료된 상태에서, 빠져있던 Grafana 모니터링 환경을 처음부터 끝까지 구축했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot Actuator + Micrometer Prometheus 의존성 추가 및 application.yml 설정&lt;/li&gt;
&lt;li&gt;docker-compose.yml에 Kafka JMX Exporter 3개 추가 (kafka-1 / kafka-2 / kafka-3)&lt;/li&gt;
&lt;li&gt;Kafka 브로커 3개에 JMX 포트 환경변수 설정 (KAFKA_JMX_PORT: 9999, KAFKA_JMX_HOSTNAME)&lt;/li&gt;
&lt;li&gt;monitoring/kafka-jmx-config.yml 작성 (JMX 메트릭 수집 룰 정의)&lt;/li&gt;
&lt;li&gt;monitoring/prometheus.yml Kafka 스크랩 타겟 3개 추가&lt;/li&gt;
&lt;li&gt;Prometheus &amp;rarr; Grafana 데이터소스 연결 (http://prometheus:9090)&lt;/li&gt;
&lt;li&gt;Grafana 대시보드 Import (Spring Boot 19004, Kafka 7589)&lt;/li&gt;
&lt;li&gt;Prometheus Targets 전체 UP 상태 확인 (novelcraft / kafka-1 / kafka-2 / kafka-3)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 트러블슈팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-1. Spring Boot 3.3.5에서 management.endpoint.prometheus.enabled 빨간불&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml에 아래와 같이 Prometheus 설정을 작성했더니 IDE에서 빨간불이 들어왔다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;management:
  endpoint:
    prometheus:
      enabled: true&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.3.x에서는 enabled 속성이 deprecated 되어 해당 키 자체가 제거됐다. micrometer-registry-prometheus 의존성이 classpath에 있으면 /actuator/prometheus 엔드포인트가 자동으로 활성화되기 때문에 별도로 enabled를 선언할 필요가 없어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;enabled 관련 설정을 전부 제거하고 아래와 같이 정리했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, metrics, info
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: novelcraft&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성만 있으면 자동 활성화된다는 점을 다시 한번 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. GRAFANA_ADMIN_PASSWORD 환경변수 누락으로 docker-compose 실행 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker-compose up -d 실행 시 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;subunit&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;error while interpolating services.grafana.environment.GF_SECURITY_ADMIN_PASSWORD:
required variable GRAFANA_ADMIN_PASSWORD is missing a value: set GRAFANA_ADMIN_PASSWORD&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 docker-compose.yml의 Grafana 설정이 아래와 같이 환경변수를 필수로 강제하는 방식이었다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;avrasm&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에 .env 파일이 없어서 해당 변수를 찾지 못해 실행 자체가 차단됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 루트에 .env 파일을 생성하고 값을 추가했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ini&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env 파일은 민감정보가 포함될 수 있으므로 .gitignore에 등록되어 있는지 반드시 확인해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. Prometheus Targets에서 Kafka 타겟이 보이지 않음&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;http://localhost:9095/targets 에 접속했을 때 novelcraft 타겟만 UP 상태로 보이고 Kafka 타겟은 아예 표시되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prometheus.yml의 Kafka 스크랩 타겟을 아래와 같이 host.docker.internal로 설정했는데, Kafka JMX Exporter는 도커 컨테이너 내부에 떠있기 때문에 host.docker.internal로는 접근이 불가능했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;1c&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# 잘못된 설정
- job_name: 'kafka'
  static_configs:
    - targets: ['host.docker.internal:5556']&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 앱은 도커 외부(로컬)에서 실행되므로 host.docker.internal이 맞지만, JMX Exporter는 도커 네트워크 내부 서비스이므로 컨테이너 서비스명으로 접근해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prometheus.yml을 아래와 같이 컨테이너 서비스명 기준으로 수정하고 Prometheus를 재시작했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;scrape_configs:
  - job_name: 'novelcraft'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']   # 앱은 로컬 실행이므로 host.docker.internal

  - job_name: 'kafka-1'
    static_configs:
      - targets: ['kafka-1-jmx-exporter:5556']   # JMX Exporter는 도커 내부 서비스명

  - job_name: 'kafka-2'
    static_configs:
      - targets: ['kafka-2-jmx-exporter:5556']

  - job_name: 'kafka-3'
    static_configs:
      - targets: ['kafka-3-jmx-exporter:5556']&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;docker-compose restart prometheus&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시작 후 Targets 페이지에서 kafka-1 / kafka-2 / kafka-3 모두 UP 상태를 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 주요 구현 내용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 모니터링 아키텍처&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Spring Boot App (로컬:8080)
    └─ /actuator/prometheus
            &amp;darr;
        Prometheus (도커:9095)
            ├─ novelcraft job   &amp;rarr; host.docker.internal:8080
            ├─ kafka-1 job      &amp;rarr; kafka-1-jmx-exporter:5556
            ├─ kafka-2 job      &amp;rarr; kafka-2-jmx-exporter:5556
            └─ kafka-3 job      &amp;rarr; kafka-3-jmx-exporter:5556
                &amp;darr;
            Grafana (도커:3000)
                ├─ Spring Boot 3.x Statistics (ID: 19004)
                └─ Kafka Exporter Overview    (ID: 7589)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;docker-compose.yml 변경 사항 (팀원 영향 최소화)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조를 최대한 유지하면서 아래 항목만 추가했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;kafka-1 / kafka-2 / kafka-3 환경변수에 KAFKA_JMX_PORT: 9999, KAFKA_JMX_HOSTNAME 2줄 추가&lt;/li&gt;
&lt;li&gt;kafka-1-jmx-exporter, kafka-2-jmx-exporter, kafka-3-jmx-exporter 서비스 3개 신규 추가&lt;/li&gt;
&lt;li&gt;Prometheus depends_on에 JMX Exporter 3개 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;신규 오픈 포트: 5556, 5557, 5558&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;monitoring/ 폴더 구조&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;monitoring/
├── prometheus.yml
└── kafka-jmx-config.yml&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;kafka-jmx-config.yml은 JMX 메트릭 수집 룰을 정의하는 파일로, 이 파일이 없으면 JMX Exporter 컨테이너가 뜨지 않는다. 팀원들이 docker-compose up 할 때 반드시 해당 파일이 있어야 하므로 함께 커밋했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Grafana 대시보드 결과&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Spring Boot 3.x Statistics&lt;/b&gt;: Uptime, Heap Used(2.9%), Non-Heap Used(17.8%), CPU Usage, Load Average 등 실시간 수집 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Kafka Exporter Overview&lt;/b&gt;: 현재 메시지 발행/소비가 없어 No data 상태이며, 실제 트래픽 발생 시 데이터가 채워질 예정&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모니터링 환경 구축은 처음이라 설정 하나하나가 낯설었는데, 막상 Grafana 대시보드에 메트릭이 실시간으로 찍히는 걸 보니 뿌듯했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 헷갈렸던 부분은 도커 네트워크 개념이었다. Spring Boot 앱처럼 도커 외부에서 실행되는 서비스는 host.docker.internal로 접근해야 하고, JMX Exporter처럼 도커 내부 컨테이너는 서비스명으로 접근해야 한다는 점이 처음엔 직관적으로 와닿지 않았다. 타겟이 안 보이는 현상을 보고 직접 원인을 추적하면서 도커 네트워크 구조를 제대로 이해하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에서 docker-compose.yml을 수정할 때는 기존 구조를 얼마나 건드리지 않을 수 있느냐가 중요하다는 것도 느꼈다. 처음에는 과감하게 수정했다가 팀원 영향을 고려해서 최소한의 변경만 남기는 방향으로 다시 정리했는데, 공유 인프라 파일을 다룰 때는 항상 팀원 관점에서 먼저 생각해야 한다는 걸 다시 한번 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계로는 실제로 Kafka 메시지가 발행/소비될 때 Grafana 대시보드에서 Consumer Lag, Message In/Out per second 등이 어떻게 보이는지 확인해볼 예정이다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/216</guid>
      <comments>https://minwoo95.tistory.com/216#entry216comment</comments>
      <pubDate>Fri, 8 May 2026 22:00:21 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 16</title>
      <link>https://minwoo95.tistory.com/215</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778154528074&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cPT8Fe/dJMb9jOq1Q3/k3nqCfozbnD57Nu2PIjVk0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bhvxq8/dJMb9lMfAw4/2NCjGLStKCrKuNA7kKTexk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cPT8Fe/dJMb9jOq1Q3/k3nqCfozbnD57Nu2PIjVk0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bhvxq8/dJMb9lMfAw4/2NCjGLStKCrKuNA7kKTexk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778154531857&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 NovelCraft 프로젝트에서 AI 소설 표지 생성 기능을 동기 방식에서 Kafka 기반 비동기 처리로 고도화하고, 코드래빗 PR 리뷰를 반영하여 방어 로직을 강화했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Google Gemini API 연동 기반 AI 소설 표지 생성 기능 구현&lt;/li&gt;
&lt;li&gt;동기 방식(30초 블로킹) &amp;rarr; Kafka 비동기 처리로 개선 (응답 시간 30초 &amp;rarr; 150ms)&lt;/li&gt;
&lt;li&gt;CoverJob 엔티티 추가 (PENDING &amp;rarr; PROCESSING &amp;rarr; COMPLETED/FAILED 상태 추적)&lt;/li&gt;
&lt;li&gt;CoverGenerationProducer 구현 (Kafka 토픽 발행)&lt;/li&gt;
&lt;li&gt;CoverGenerationConsumer 구현 (Gemini 재시도 3회, S3 재시도 3회, 포인트 환불)&lt;/li&gt;
&lt;li&gt;CoverJobEventRelay 구현 (@TransactionalEventListener(AFTER_COMMIT) 적용)&lt;/li&gt;
&lt;li&gt;표지 생성 상태 조회 API 추가 (GET /api/ai/novels/cover/status/{jobId})&lt;/li&gt;
&lt;li&gt;작가 권한 + 본인 소설 검증 + 300포인트 차감 로직 구현&lt;/li&gt;
&lt;li&gt;코드래빗 리뷰 5개 항목 반영 (중복 이벤트 방어, 포인트 차감 플래그, 상태 전이 가드 등)&lt;/li&gt;
&lt;li&gt;CoverService, CoverGenerationConsumer, CoverJob 단위 테스트 작성 (커버리지 100%)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. AI 소설 표지 생성 기능 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 선택 - Gemini vs DALL-E 3&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 DALL-E 3로 구현했으나 한국어 텍스트 렌더링이 깨지는 문제가 있었다. Google Gemini(gemini-2.5-flash-image)로 교체하자 한국어 제목과 작가명이 이미지에 완벽하게 렌더링됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API는 이미지를 바이트 배열로 반환하는 구조라 DALL-E 3처럼 URL을 바로 반환하지 않는다. 따라서 S3에 직접 업로드하고 영구 URL을 반환하는 방식으로 구현했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프롬프트 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소설 DB에서 제목, 작가 닉네임, 장르, 줄거리를 자동으로 조회해 프롬프트에 주입한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;String.format(
    &quot;Create a professional Korean novel cover image. &quot; +
    &quot;The title '%s' must be written clearly and legibly at the top in large, stylish Korean typography. &quot; +
    &quot;Below the title, write the author name '%s 지음' in smaller Korean text. &quot; +
    &quot;Genre: %s. Story summary: %s. &quot; +
    &quot;Style: cinematic, high quality book cover art.&quot;,
    novel.getTitle(), authorName, novel.getGenre(), novel.getDescription()
)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Kafka 비동기 처리 고도화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 동기 방식이 문제였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API 호출은 평균 20~30초가 소요된다. 동기 방식으로 처리하면 해당 스레드가 30초간 블로킹되어 톰캣 스레드 풀이 고갈될 위험이 있고, 다른 API 요청에도 영향을 미친다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 @Async가 아닌 Kafka인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 비동기 처리라면 @Async로도 가능하지만 Kafka를 선택한 이유는 세 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 메시지 영속성이다. 서버가 재시작되더라도 Kafka 브로커에 메시지가 남아있어 처리가 재개된다. @Async는 서버 재시작 시 처리 중인 작업이 유실된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 프로젝트에 Kafka가 이미 알림 시스템에 도입되어 있어 추가 인프라 없이 일관된 아키텍처를 유지할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, Consumer를 독립적으로 스케일아웃할 수 있는 구조다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 AWS Lambda가 아닌 Kafka인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lambda도 좋은 선택지지만 이 프로젝트에서 Kafka를 선택한 이유는 다음과 같다. Lambda는 Cold Start 문제가 있어 첫 요청 시 수초의 지연이 발생할 수 있는데, Gemini API 자체가 이미 30초가 걸리는 상황에서 추가 지연은 부담이었다. 또한 Lambda + API Gateway + SQS 조합은 설정 복잡도가 높아 팀 프로젝트 일정상 Kafka가 현실적인 선택이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;처리 흐름&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;POST /api/ai/novels/{novelId}/cover
    &amp;darr; 즉시 jobId 반환 (150ms)
CoverService &amp;rarr; CoverJob 저장 &amp;rarr; ApplicationEventPublisher.publishEvent()
    &amp;darr; 트랜잭션 커밋 후
CoverJobEventRelay (@TransactionalEventListener AFTER_COMMIT)
    &amp;darr;
CoverGenerationProducer &amp;rarr; Kafka 토픽 발행
    &amp;darr;
CoverGenerationConsumer
    &amp;rarr; PENDING 상태 가드 (중복 이벤트 방어)
    &amp;rarr; PROCESSING 상태 전이
    &amp;rarr; Gemini 호출 (재시도 3회)
    &amp;rarr; S3 업로드 (재시도 3회)
    &amp;rarr; 포인트 차감 (isDeducted 플래그)
    &amp;rarr; COMPLETED 상태 전이
    &amp;rarr; 실패 시 FAILED + 포인트 환불 (차감된 경우에만)

GET /api/ai/novels/cover/status/{jobId}
    &amp;rarr; PENDING / PROCESSING / COMPLETED / FAILED 조회&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;폴링 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 클라이언트가 주기적으로 상태 조회 API를 호출하는 폴링 방식으로 구현했다. 개선 방향으로는 프로젝트에 이미 WebSocket이 알림 시스템에 도입되어 있으므로 서버에서 완료 시점에 Push하는 방식으로 전환할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 코드래빗 리뷰 반영&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 중복 Kafka 이벤트 방어 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 jobId에 대한 중복 메시지가 도착할 경우 상태 검증 없이 모든 작업을 반복 실행하는 문제가 있었다. 포인트가 여러 번 차감될 수 있는 치명적인 버그다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 수정 전 - 가드 없음
job.processing();

// 수정 후 - PENDING 상태에서만 처리
if (job.getStatus() != CoverJobStatus.PENDING) {
    log.warn(&quot;[Cover] 이미 처리된 Job 스킵 jobId={} status={}&quot;, event.jobId(), job.getStatus());
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 포인트 차감 전 실패 시 환불 방지 (Critical)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 차감은 Gemini/S3 성공 후에 이루어지는데, 기존 catch 블록에서 차감 여부와 무관하게 무조건 charge()를 호출하고 있었다. Gemini/S3 단계에서 실패하면 차감도 안 됐는데 포인트가 그냥 증가하는 버그였다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;boolean isDeducted = false;

pointService.deduct(event.userId(), COVER_COST);
isDeducted = true;

// catch 블록에서
if (isDeducted) {
    pointService.charge(event.userId(), COVER_COST); // 차감된 경우에만 환불
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. complete/fail 시 반대 필드 정리 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;complete() 호출 시 이전에 설정된 errorMessage가 남아있거나, fail() 호출 시 coverImageUrl이 남아있어 조회 응답에 혼합 상태가 노출될 수 있는 문제였다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;public void complete(String coverImageUrl) {
    this.status = CoverJobStatus.COMPLETED;
    this.coverImageUrl = coverImageUrl;
    this.errorMessage = null; // 추가
}

public void fail(String errorMessage) {
    this.status = CoverJobStatus.FAILED;
    this.errorMessage = errorMessage;
    this.coverImageUrl = null; // 추가
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-4. Kafka 발행 실패 시 PENDING 영구 정체 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 발행이 실패하면 Job이 PENDING 상태로 영구히 남는 문제가 있었다. 발행 실패를 상위로 전파하거나 즉시 FAILED로 전환해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cos&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;try {
    eventPublisher.publishEvent(new CoverJobCreatedEvent(jobId, novelId, userId));
} catch (Exception e) {
    job.fail(&quot;Kafka 발행 실패&quot;);
    coverJobRepository.save(job);
    throw e;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-5. 트랜잭션 커밋 전 Kafka 발행 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CoverJob을 저장한 직후 Kafka를 발행하면, Consumer가 메시지를 소비했을 때 트랜잭션이 아직 커밋되지 않아 DB에서 jobId를 찾지 못하는 경우가 발생할 수 있다. 알림 시스템의 NotificationEventRelay 패턴을 참고해 @TransactionalEventListener(AFTER_COMMIT)으로 해결했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCoverJobCreated(CoverJobCreatedEvent event) {
    coverGenerationProducer.publish(
            new CoverGenerationEvent(event.jobId(), event.novelId(), event.userId())
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 트러블슈팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. Gemini 1.5 Flash로 변경했으나 404 오류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini AI에게 &quot;이미지 생성이 가능한 모델을 알려달라&quot;고 물어보니 gemini-1.5-flash를 추천했다. 변경 후 테스트했으나 아래 오류가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;404 Not Found. models/gemini-1.5-flash is not found for API version v1beta, 
or is not supported for generateContent.&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini 1.5 Flash는 텍스트 전용 모델이라 이미지 생성 자체가 불가능하다. 이미지 생성은 gemini-2.5-flash-image 모델을 사용해야 하며, 해당 모델은 무료 API 티어에서는 호출이 불가하다. AI Studio 웹 UI에서는 무료로 사용 가능하지만 API 호출은 별도 과금 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; Google AI Studio에서 결제 등록 후 gemini-2.5-flash-image 모델 사용&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. S3 업로드 후 Access Denied&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;S3 업로드는 성공했으나 이미지 URL 접근 시 Access Denied XML 응답이 반환됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;xml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;&amp;lt;Error&amp;gt;
  &amp;lt;Code&amp;gt;AccessDenied&amp;lt;/Code&amp;gt;
  &amp;lt;Message&amp;gt;Access Denied&amp;lt;/Message&amp;gt;
&amp;lt;/Error&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; 버킷 정책에 퍼블릭 GetObject 허용 설정이 누락됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; S3 버킷 정책에 아래 내용 추가&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;json&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;json&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
        {
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: &quot;*&quot;,
            &quot;Action&quot;: &quot;s3:GetObject&quot;,
            &quot;Resource&quot;: &quot;arn:aws:s3:::hot6-novelcraft-minwoo/*&quot;
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. point_histories.type 컬럼 Data truncated 오류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI_COVER 타입을 PointHistoryType enum에 추가했으나 실제 API 호출 시 500 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;SQL Error: 1265, SQLState: 01000
Data truncated for column 'type' at row 1&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; point_histories.type 컬럼이 VARCHAR(10)으로 정의되어 있어 8자인 AI_COVER가 저장되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;sql&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;ALTER TABLE point_histories MODIFY COLUMN type VARCHAR(20) NOT NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티에도 명시적으로 length 추가&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;@Column(nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
private PointHistoryType type;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-4. 기존 CoverService 코드와 포인트 중복 차감&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consumer에서 포인트 차감을 처리하는데 CoverService에도 포인트 선차감 로직이 남아있어 이중 차감이 발생할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; CoverService에서 포인트 차감 로직 전체 제거. 포인트 처리는 Consumer에서만 담당하도록 단일 책임 원칙을 적용했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-5. CoverServiceTest - ArgumentCaptor가 값을 캡처하지 못하는 문제&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;fortran&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;No argument value was captured!
You might have forgotten to use argument.capture() in verify()&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; CoverService가 CoverGenerationProducer를 직접 호출하는 방식에서 ApplicationEventPublisher를 통한 이벤트 발행 방식으로 변경됐는데, 테스트 코드의 Mock 및 검증 대상이 업데이트되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;// 기존
@Mock private CoverGenerationProducer coverGenerationProducer;
verify(coverGenerationProducer).publish(any(CoverGenerationEvent.class));

// 수정
@Mock private ApplicationEventPublisher eventPublisher;
verify(eventPublisher).publishEvent(any(CoverJobCreatedEvent.class));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 오늘의 회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 하루 동안 단순한 AI API 연동에서 시작해 Kafka 비동기 처리까지 고도화하면서 여러 가지를 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설계 관점에서&lt;/b&gt; 외부 API 호출처럼 시간이 오래 걸리는 작업은 동기 방식으로 처리하면 안 된다는 것을 다시 한번 체감했다. 30초 블로킹이 단순히 사용자 경험의 문제가 아니라 서버 자원 고갈로 이어질 수 있다는 점이 핵심이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Kafka와 @Async의 차이&lt;/b&gt;에 대해서도 명확하게 정리됐다. @Async는 구현이 간단하지만 서버 재시작 시 메시지 유실이 발생한다. Kafka는 복잡도가 높지만 메시지가 브로커에 영속화되어 장애 상황에도 재처리가 가능하다. 어떤 것이 절대적으로 옳은 것이 아니라 상황에 따라 트레이드오프를 이해하고 선택하는 것이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코드래빗 리뷰&lt;/b&gt;에서 다섯 가지 문제를 지적받았는데, 그중 Critical 항목인 &quot;차감 전 실패에도 무조건 환불&quot; 버그는 실제 서비스라면 사용자 포인트가 의도치 않게 증가하는 치명적인 문제였다. 테스트 코드가 이런 엣지 케이스를 잡아낼 수 있도록 시나리오를 촘촘하게 작성하는 것의 중요성을 다시 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오늘의 AI 이미지 생성 연결 결과물!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프롬포트를 작성하는게 아니라, 이미 가지고 있는 소설 정보를 바탕으로 알아서 자동생성해주는 기능으로 구현완료!&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;a88e6a52-3bff-4d99-8356-f8b52896b979.png&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;1200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rvknb/dJMcaaFeXI7/Bm1BVkwx0Gv2VTzyzrS8bk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rvknb/dJMcaaFeXI7/Bm1BVkwx0Gv2VTzyzrS8bk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rvknb/dJMcaaFeXI7/Bm1BVkwx0Gv2VTzyzrS8bk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Frvknb%2FdJMcaaFeXI7%2FBm1BVkwx0Gv2VTzyzrS8bk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;896&quot; height=&quot;1200&quot; data-filename=&quot;a88e6a52-3bff-4d99-8356-f8b52896b979.png&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;1200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/215</guid>
      <comments>https://minwoo95.tistory.com/215#entry215comment</comments>
      <pubDate>Thu, 7 May 2026 20:53:19 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 15</title>
      <link>https://minwoo95.tistory.com/214</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778072596746&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778072602698&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 NovelCraft 프로젝트에서 AI 도메인 중 소설 표지 생성 기능을 담당하여 OpenAI DALL-E 3 API를 Spring Boot에 처음으로 도입했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI 도메인 패키지 구조 설계 (ai/cover 독립 패키지)&lt;/li&gt;
&lt;li&gt;OpenAI Java SDK 의존성 추가 및 API 키 설정&lt;/li&gt;
&lt;li&gt;DallEClient 구현 (DALL-E 3 API 호출 전담)&lt;/li&gt;
&lt;li&gt;CoverService 구현 (소설 정보 DB 조회 &amp;rarr; 프롬프트 자동 생성 &amp;rarr; 이미지 생성)&lt;/li&gt;
&lt;li&gt;CoverController 구현 (POST /api/ai/novels/{novelId}/cover)&lt;/li&gt;
&lt;li&gt;CoverExceptionEnum 구현 (공통 예외 패턴 적용)&lt;/li&gt;
&lt;li&gt;Postman으로 로컬 테스트 완료 (DALL-E 응답 성공 확인)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 트러블슈팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-1. Optional.get()에 인덱스 인자를 넣어 컴파일 에러 발생&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DallEClient에서 DALL-E 응답의 이미지 URL을 꺼내는 코드를 아래와 같이 작성했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;return response.data().get(0).url()
        .orElseThrow(() -&amp;gt; CoverExceptionEnum.IMAGE_GENERATION_FAILED.toException());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴파일 시 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;oxygene&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;method get in class Optional&amp;lt;T&amp;gt; cannot be applied to given types;
required: no arguments
found: int&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI Java SDK에서 response.data()의 반환 타입이 List&amp;lt;Image&amp;gt;가 아닌 Optional&amp;lt;List&amp;lt;Image&amp;gt;&amp;gt;였다. Optional의 get()은 인자를 받지 않는데 인덱스 0을 넣어버리니 타입 불일치 에러가 발생한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;response.data() 자체가 Optional이므로 먼저 orElseThrow()로 꺼낸 뒤 리스트 인덱스로 접근하도록 수정했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;livescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;return response.data()
        .orElseThrow(() -&amp;gt; CoverExceptionEnum.IMAGE_GENERATION_FAILED.toException())
        .get(0)
        .url()
        .orElseThrow(() -&amp;gt; CoverExceptionEnum.IMAGE_GENERATION_FAILED.toException());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK를 처음 사용할 때는 반환 타입을 IDE에서 직접 확인하는 습관이 필요하다는 것을 느꼈다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. S3 업로드 403 에러 (The request signature does not match)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DALL-E 호출은 성공했지만 이후 S3 업로드 단계에서 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;S3Exception: The request signature we calculated does not match the signature you provided.
Status Code: 403&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 테스트 환경에서 S3 액세스 키 권한 문제로 서명 불일치가 발생한 것이었다. DALL-E가 반환하는 임시 URL을 S3에 업로드하는 흐름이었는데, 로컬에서는 S3 연동 자체가 불필요한 상황이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 테스트 단계에서는 S3 업로드 없이 DALL-E가 반환한 임시 URL을 그대로 반환하도록 수정했다. DALL-E URL은 1시간 후 만료되지만 로컬 테스트 목적으로는 충분하다. 실제 배포 시에는 S3 업로드 로직을 복구할 예정이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;public CoverCreateResponse generateCover(Long novelId) {
    Novel novel = novelRepository.findByIdAndIsDeletedFalse(novelId)
            .orElseThrow(() -&amp;gt; CoverExceptionEnum.NOVEL_NOT_FOUND.toException());

    String prompt = buildPrompt(novel);
    String imageUrl = dallEClient.generateImage(prompt);  // DALL-E URL 바로 반환

    return CoverCreateResponse.of(novelId, imageUrl);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 주요 구현 내용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패키지 구조&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;vbscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;ai/cover/
├── controller/  CoverController
├── service/     CoverService
├── client/      DallEClient
├── dto/
│   ├── request/   CoverCreateRequest
│   └── response/  CoverCreateResponse
└── exception/   CoverExceptionEnum&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프롬프트 자동 생성 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 별도 요청 파라미터 없이 novelId만 받아서 DB에서 소설 정보를 조회한 뒤 프롬프트를 자동으로 구성했다. no text, no letters 지시어를 반드시 포함해야 이미지에 깨진 글자가 생성되지 않는다는 점을 알게 됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;private String buildPrompt(Novel novel) {
    return String.format(
            &quot;Create a professional Korean novel cover image. &quot; +
            &quot;Title: %s, Genre: %s. &quot; +
            &quot;Story summary: %s. &quot; +
            &quot;Style: cinematic, high quality book cover art, no text, no letters.&quot;,
            novel.getTitle(),
            novel.getGenre(),
            novel.getDescription()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 엔드포인트&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dts&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;POST /api/ai/novels/{novelId}/cover
Authorization: Bearer {JWT}

&amp;rarr; 응답
{
    &quot;success&quot;: true,
    &quot;status&quot;: &quot;200&quot;,
    &quot;message&quot;: &quot;소설 표지가 성공적으로 생성되었습니다&quot;,
    &quot;data&quot;: {
        &quot;novelId&quot;: 1,
        &quot;coverImageUrl&quot;: &quot;https://...&quot;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI API를 Spring Boot에 처음 도입해봤는데 생각보다 구조 자체는 단순했다. 외부 API 클라이언트를 별도 client 레이어로 분리하니 서비스 코드가 깔끔하게 유지됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DALL-E 3는 응답 시간이 평균 20초 이상 걸린다는 점이 인상적이었다. 실서비스라면 비동기 처리나 로딩 UX가 반드시 필요하다는 걸 체감했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지에 제목이나 작가명을 자연스럽게 넣는 것은 DALL-E만으로는 한계가 있다는 점도 알게 됐다. 실제 서비스에서는 배경 이미지 생성 후 프론트엔드에서 텍스트를 합성하는 방식이 가장 현실적이라는 결론을 팀원들과 공유했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 기능은 구현 자체보다 &lt;b&gt;어떤 프롬프트를 어떻게 구성하느냐&lt;/b&gt;가 품질을 좌우한다는 것을 느꼈다. 앞으로 프롬프트 엔지니어링도 백엔드 개발자가 알아야 할 중요한 역량이 될 것 같다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/214</guid>
      <comments>https://minwoo95.tistory.com/214#entry214comment</comments>
      <pubDate>Wed, 6 May 2026 22:03:57 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 14</title>
      <link>https://minwoo95.tistory.com/213</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778045337212&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778045345451&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;오늘 작업 요약&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NovelCraft 웹소설 창작 플랫폼 포트폴리오 증거 자료 확보를 위해 다음 항목들을 진행했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Prometheus + Grafana 모니터링 구축&lt;/li&gt;
&lt;li&gt;이벤트 참여 동시성 부하테스트 (Redisson 분산락)&lt;/li&gt;
&lt;li&gt;수익 환전 동시성 부하테스트 (Redis SETNX 분산락)&lt;/li&gt;
&lt;li&gt;멘토링 멘티 수락 동시성 테스트 (낙관적 락)&lt;/li&gt;
&lt;li&gt;멘토링 피드백 V1/V2 동시성 비교 (비관적 락)&lt;/li&gt;
&lt;li&gt;revenues 인덱스 성능 비교&lt;/li&gt;
&lt;li&gt;캐시 성능 비교 (이벤트 목록 / 수익 통계 / 도서 검색)&lt;/li&gt;
&lt;li&gt;Redis Record 클래스 역직렬화 버그 수정&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Prometheus + Grafana 모니터링 구축&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 참여 부하 테스트를 돌리면서 Grafana 대시보드로 실시간 지표를 캡처해 포트폴리오 증거 자료로 남기려고 구축했다. &quot;동시 200명 요청, 정합성 100%, 평균 응답 Xms&quot;를 Grafana 대시보드 스크린샷 한 장으로 증명하는 게 말 백 마디보다 강력하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구성도&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Spring Boot (IntelliJ 실행)
    &amp;darr; /actuator/prometheus 노출
Prometheus (Docker, 포트 9095)
    &amp;darr; 15초마다 scrape
Grafana (Docker, 포트 3000)
    &amp;darr; 시각화 대시보드&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;1단계: build.gradle 의존성 추가&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;gradle&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;sml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: application.yml 설정 추가&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, metrics
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: novelcraft&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계: SecurityConfig + JwtFilter 수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Actuator 엔드포인트가 Security 필터와 JwtFilter에 막혀 있어서 두 곳 모두 수정이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityConfig .requestMatchers 부분에 추가:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;1c&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;, &quot;/actuator/**&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtFilter shouldNotFilter 메서드에 추가:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;1c&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;|| path.startsWith(&quot;/actuator&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계: docker-compose.yml에 Prometheus + Grafana 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Kafka 3노드 클러스터가 있는 docker-compose.yml에 두 컨테이너를 추가했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;prometheus:
  image: prom/prometheus:latest
  container_name: prometheus
  ports:
    - &quot;9095:9090&quot;   # Kafka-2가 9093 포트를 사용해 9095로 설정
  volumes:
    - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    - prometheus_data:/prometheus
  networks:
    - kafka-net

grafana:
  image: grafana/grafana:latest
  container_name: grafana
  ports:
    - &quot;3000:3000&quot;
  environment:
    GF_SECURITY_ADMIN_USER: admin
    GF_SECURITY_ADMIN_PASSWORD: admin
  volumes:
    - grafana_data:/var/lib/grafana
  depends_on:
    - prometheus
  networks:
    - kafka-net&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계: prometheus.yml 설정&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'novelcraft'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;host.docker.internal은 Docker 컨테이너에서 Mac 로컬의 Spring Boot 앱에 접근할 수 있게 해주는 주소다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6단계: Grafana 대시보드 구성&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;http://localhost:3000 접속 (admin/admin)&lt;/li&gt;
&lt;li&gt;Connections &amp;rarr; Data Sources &amp;rarr; Prometheus 추가&lt;/li&gt;
&lt;li&gt;URL: http://prometheus:9090 (컨테이너 내부 통신이라 localhost가 아닌 컨테이너 이름 사용)&lt;/li&gt;
&lt;li&gt;Dashboards &amp;rarr; Import &amp;rarr; ID 4701 (Spring Boot 전용 대시보드) 임포트&lt;/li&gt;
&lt;li&gt;Application 변수: Query에 label_values(application) 입력 &amp;rarr; novelcraft 확인&lt;/li&gt;
&lt;li&gt;Instance 변수: Query에 label_values(jvm_info, instance) 입력&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트러블슈팅: Prometheus 포트 충돌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 포트 9090이 Kafka-2 브로커와 충돌할 수 있어 호스트 포트를 9095로 변경했다. Grafana에서 Prometheus 연결 시에는 내부 컨테이너 통신이라 prometheus:9090을 사용한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트러블슈팅: Grafana Application 변수 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4701 대시보드 임포트 후 Application 드롭다운에 아무것도 안 떴다. 변수 설정에서 Target data source가 비어 있어서 발생. Variables &amp;rarr; Application &amp;rarr; Target data source를 Prometheus로 선택하고 Query에 label_values(application) 입력 후 novelcraft가 나타나면 성공이다. Instance 변수도 동일하게 label_values(jvm_info, instance) 입력.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsJ7pt/dJMcadBPtGr/4RcP7hNXGjd9KiN3elUCKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsJ7pt/dJMcadBPtGr/4RcP7hNXGjd9KiN3elUCKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsJ7pt/dJMcadBPtGr/4RcP7hNXGjd9KiN3elUCKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsJ7pt%2FdJMcadBPtGr%2F4RcP7hNXGjd9KiN3elUCKK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2914&quot; height=&quot;1332&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 이벤트 참여 동시성 부하테스트 (Redisson 분산락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;maxParticipants=100인 이벤트에 &lt;b&gt;200명이 동시에&lt;/b&gt; 참여 신청. 정확히 100명만 참여가 성공하는지 데이터 정합성 검증.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 시나리오 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지 시나리오를 동시에 실행했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;export const options = {
    scenarios: {
        // 시나리오 1: 동시 참여 신청 (200명 동시 요청)
        spike_participate: {
            executor: 'shared-iterations',
            vus: 200,        // 동시 가상 유저 200명
            iterations: 200, // 총 200번 요청 (유저당 1번)
            maxDuration: '60s',
            exec: 'participateTest',
        },
        // 시나리오 2: 조회 API 지속 부하 (Grafana 수치 확보용)
        sustained_read: {
            executor: 'constant-vus',
            vus: 50,         // 동시 가상 유저 50명
            duration: '30s', // 30초 동안 지속
            startTime: '0s',
            exec: 'readTest',
        },
    },
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;shared-iterations: 200개의 iteration을 200개의 VU가 나눠서 처리. 각 VU는 로그인 후 이벤트 참여 요청 1회 실행.&lt;/li&gt;
&lt;li&gt;constant-vus: 50명이 30초 동안 계속 이벤트 목록/상세 조회를 반복해 Grafana에 의미 있는 트래픽 생성.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 유저: loadtest1~&lt;a href=&quot;mailto:200@test.com&quot;&gt;200@test.com&lt;/a&gt; (200명, BCrypt 해싱 후 DB에 사전 삽입)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Before (분산락 버그 있는 버전)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt; @Transactional과 Redisson 분산락 해제 순서 버그&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 잘못된 코드: 트랜잭션 커밋 전에 락이 해제됨
@Transactional
public void participate(Long eventId, Long userId) {
    RLock lock = redissonClient.getLock(&quot;event:&quot; + eventId);
    lock.lock();
    try {
        // DB 로직...
    } finally {
        lock.unlock(); // 여기서 락 해제 &amp;rarr; 트랜잭션은 아직 커밋 안 됨
    }
    // 메서드 종료 시 @Transactional이 커밋 &amp;rarr; 이미 락 해제 후라 다른 요청이 진입 가능
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt; 101명 참여 &amp;rarr; 정합성 깨짐&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1682&quot; data-origin-height=&quot;528&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5jeH1/dJMcadolBe7/aNSvOX3DAMk0eEpmoVotXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5jeH1/dJMcadolBe7/aNSvOX3DAMk0eEpmoVotXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5jeH1/dJMcadolBe7/aNSvOX3DAMk0eEpmoVotXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5jeH1%2FdJMcadolBe7%2FaNSvOX3DAMk0eEpmoVotXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1682&quot; height=&quot;528&quot; data-origin-width=&quot;1682&quot; data-origin-height=&quot;528&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDUBmv/dJMcadolBg7/oZRIkOGCQVWifXidYkREW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDUBmv/dJMcadolBg7/oZRIkOGCQVWifXidYkREW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDUBmv/dJMcadolBg7/oZRIkOGCQVWifXidYkREW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDUBmv%2FdJMcadolBg7%2FoZRIkOGCQVWifXidYkREW1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2172&quot; height=&quot;548&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;After (분산락 Fix)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; @Transactional 제거 후 락 안에서 별도 서비스 메서드 호출&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// UserEventService - @Transactional 제거
public void participate(Long eventId, Long userId) {
    RLock lock = redissonClient.getLock(&quot;event:&quot; + eventId);
    // leaseTime을 3초에서 10초로 증가
    boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
    if (!acquired) throw new CustomException(ErrorCode.TOO_MANY_REQUESTS);
    try {
        eventParticipateService.execute(eventId, userId); // 별도 @Transactional 서비스
    } finally {
        lock.unlock(); // 트랜잭션 커밋 완료 후 락 해제
    }
}

// EventParticipateService - @Transactional 적용
@Transactional
public void execute(Long eventId, Long userId) {
    // DB 로직 처리 후 커밋 완료
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2200&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNM90F/dJMcac3260d/QqKwE5JawKxbC2JnBcFGJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNM90F/dJMcac3260d/QqKwE5JawKxbC2JnBcFGJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNM90F/dJMcac3260d/QqKwE5JawKxbC2JnBcFGJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNM90F%2FdJMcac3260d%2FQqKwE5JawKxbC2JnBcFGJk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2200&quot; height=&quot;538&quot; data-origin-width=&quot;2200&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트 결과 (After)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청:    200명
참여 성공:  100명 (✅ maxParticipants와 정확히 일치)
참여 실패:  100명 (maxParticipants 초과)
정합성:     100% PASS&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Grafana 관측 수치 (k6 실행 중)&lt;/h3&gt;
&lt;div&gt;지표수치
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RPS (초당 요청 수)&lt;/td&gt;
&lt;td&gt;약 40 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU 사용률&lt;/td&gt;
&lt;td&gt;74.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JVM 스레드 수&lt;/td&gt;
&lt;td&gt;295개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GC Pause 시간&lt;/td&gt;
&lt;td&gt;1.72ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행전&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;1340&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2s9YE/dJMcad2VIIz/MhwJaKxSnt5rpFWE0N6sGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2s9YE/dJMcad2VIIz/MhwJaKxSnt5rpFWE0N6sGK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2s9YE/dJMcad2VIIz/MhwJaKxSnt5rpFWE0N6sGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2s9YE%2FdJMcad2VIIz%2FMhwJaKxSnt5rpFWE0N6sGK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2796&quot; height=&quot;1340&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;1340&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2810&quot; data-origin-height=&quot;1306&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qikb2/dJMcaiDcSug/fLYdoFCJbv8HbkQO0ek0KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qikb2/dJMcaiDcSug/fLYdoFCJbv8HbkQO0ek0KK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qikb2/dJMcaiDcSug/fLYdoFCJbv8HbkQO0ek0KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqikb2%2FdJMcaiDcSug%2FfLYdoFCJbv8HbkQO0ek0KK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2810&quot; height=&quot;1306&quot; data-origin-width=&quot;2810&quot; data-origin-height=&quot;1306&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rrRZ3/dJMb990Cilz/HjPcIkYdNuxX7itVdlPSp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rrRZ3/dJMb990Cilz/HjPcIkYdNuxX7itVdlPSp1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rrRZ3/dJMb990Cilz/HjPcIkYdNuxX7itVdlPSp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrrRZ3%2FdJMb990Cilz%2FHjPcIkYdNuxX7itVdlPSp1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2914&quot; height=&quot;1332&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2902&quot; data-origin-height=&quot;1336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/51yJN/dJMcaicbhzb/YGvouw7dPwdEf12vAEliKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/51yJN/dJMcaicbhzb/YGvouw7dPwdEf12vAEliKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/51yJN/dJMcaicbhzb/YGvouw7dPwdEf12vAEliKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F51yJN%2FdJMcaicbhzb%2FYGvouw7dPwdEf12vAEliKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2902&quot; height=&quot;1336&quot; data-origin-width=&quot;2902&quot; data-origin-height=&quot;1336&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 수익 환전 동시성 부하테스트 (Redis 분산락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잔액 100만원인 작가가 동시에 10번 전액 환전 요청. 정확히 1건만 성공해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 시나리오 구성&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;spike_withdrawal: {
    executor: 'shared-iterations',
    vus: 10,        // 동시 가상 유저 10명 (같은 유저가 10번)
    iterations: 10,
    maxDuration: '30s',
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방어 메커니즘&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1차 방어: Redis SETNX 분산락&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;String lockKey = &quot;lock:withdrawal:&quot; + authorId;
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 get 후 delete는 비원자적이라 다른 요청의 락을 실수로 해제할 수 있다. Lua Script로 비교와 삭제를 하나의 원자적 연산으로 처리했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;lua&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2차 방어:&lt;/b&gt; PENDING 상태 중복 체크&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청:    10건
환전 성공:  1건 (✅)
환전 실패:  9건 (잔액 부족)
잔액:       0원
정합성:     100% PASS&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1424&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MmhZZ/dJMcadolBMg/X6mTA4tHPefKBQqrt11Du0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MmhZZ/dJMcadolBMg/X6mTA4tHPefKBQqrt11Du0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MmhZZ/dJMcadolBMg/X6mTA4tHPefKBQqrt11Du0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMmhZZ%2FdJMcadolBMg%2FX6mTA4tHPefKBQqrt11Du0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2914&quot; height=&quot;1424&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1424&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KRHSq/dJMcadolBNE/D6hItf6nkrwapKTmDyLw41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KRHSq/dJMcadolBNE/D6hItf6nkrwapKTmDyLw41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KRHSq/dJMcadolBNE/D6hItf6nkrwapKTmDyLw41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKRHSq%2FdJMcadolBNE%2FD6hItf6nkrwapKTmDyLw41%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2912&quot; height=&quot;1414&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1414&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1370&quot; data-origin-height=&quot;442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNNnIO/dJMcadolBOK/LDR0NLhdHjrj3ZbjMlNLCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNNnIO/dJMcadolBOK/LDR0NLhdHjrj3ZbjMlNLCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNNnIO/dJMcadolBOK/LDR0NLhdHjrj3ZbjMlNLCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNNnIO%2FdJMcadolBOK%2FLDR0NLhdHjrj3ZbjMlNLCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1370&quot; height=&quot;442&quot; data-origin-width=&quot;1370&quot; data-origin-height=&quot;442&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 멘토링 멘티 수락 동시성 테스트 (낙관적 락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토 1명(maxMentees=3)에게 PENDING 상태의 멘토십 10개에 대해 동시에 수락 요청.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 설정&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;spike_accept: {
    executor: 'shared-iterations',
    vus: 10,
    iterations: 10,
    maxDuration: '30s',
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 원리 (낙관적 락)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;d&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;@Entity
public class Mentor {
    @Version
    private Integer version;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10건이 동시에 같은 Mentor 엔티티의 version(0)을 읽고, 첫 번째 커밋만 성공하면서 version이 1로 증가한다. 나머지 9건은 version 불일치로 OptimisticLockingFailureException이 발생해 실패한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;ACCEPTED: 1건 (✅)
PENDING:  9건
maxMentees: 2 (1 차감됨)
version: 1 (낙관적 락 버전 증가 확인)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시도 로직이 없어 1건만 성공했지만 슬롯 초과 없이 정합성이 100% 유지됐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSjjGR/dJMcacbV4qd/OEIwji5boRFqQvXu9hcem0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSjjGR/dJMcacbV4qd/OEIwji5boRFqQvXu9hcem0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSjjGR/dJMcacbV4qd/OEIwji5boRFqQvXu9hcem0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSjjGR%2FdJMcacbV4qd%2FOEIwji5boRFqQvXu9hcem0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1586&quot; height=&quot;568&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;포트폴리오 스토리&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 10건 수락 요청에서 낙관적 락(@Version)이 동시성 충돌을 감지하여 1건만 성공. maxMentees 초과 없이 정합성 100% 유지.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 멘토링 피드백 V1/V2 동시성 비교 (비관적 락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토가 ACCEPTED 상태의 멘토십에 동시에 5건 피드백 작성. sessionNumber(회차 번호) 중복 없이 순서대로 저장되는지 확인.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 설정&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;spike_feedback: {
    executor: 'shared-iterations',
    vus: 5,
    iterations: 5,
    maxDuration: '30s',
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;V1 결과 (동시성 보호 없음)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청: 5건
피드백 성공: 1건
피드백 실패: 4건 (유니크 제약 위반으로 400 에러)
오류 메시지: &quot;데이터 저장에 실패하였습니다&quot; (모호한 에러)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt; 5건이 모두 같은 sessionNumber(1)로 저장 시도. DB 유니크 제약에 의해 첫 번째만 통과하고 나머지는 모호한 에러가 반환된다. 사용자 입장에서 왜 실패했는지 알 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;V2 결과 (비관적 락 적용)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청: 5건
피드백 성공: 5건 (✅)
sessionNumber: 1, 2, 3, 4, 5 (중복 없이 순서대로)
totalSessions: 5&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; findByIdWithLock()으로 멘토링 row에 SELECT FOR UPDATE(비관적 락)를 걸어 요청을 직렬화. 각 요청이 이전 요청의 커밋 후 totalSessions를 읽어 다음 sessionNumber를 계산한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Before/After 비교&lt;/h3&gt;
&lt;div&gt;항목V1V2
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;성공 건수&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패 이유&lt;/td&gt;
&lt;td&gt;유니크 제약 위반 &amp;rarr; 모호한 에러&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sessionNumber 처리&lt;/td&gt;
&lt;td&gt;중복 시도 &amp;rarr; DB가 막음&lt;/td&gt;
&lt;td&gt;비관적 락으로 직렬화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용자 경험&lt;/td&gt;
&lt;td&gt;4건 &quot;데이터 저장 실패&quot;&lt;/td&gt;
&lt;td&gt;모두 정상 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Before 결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjeToE/dJMb99M0ZfV/KXwt7f4u7hdczFWMBh0391/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjeToE/dJMb99M0ZfV/KXwt7f4u7hdczFWMBh0391/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjeToE/dJMb99M0ZfV/KXwt7f4u7hdczFWMBh0391/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjeToE%2FdJMb99M0ZfV%2FKXwt7f4u7hdczFWMBh0391%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2720&quot; height=&quot;638&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;638&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2630&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddRVgC/dJMcaa6g3LQ/towHpcLSKyUeUKAnfhyUv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddRVgC/dJMcaa6g3LQ/towHpcLSKyUeUKAnfhyUv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddRVgC/dJMcaa6g3LQ/towHpcLSKyUeUKAnfhyUv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FddRVgC%2FdJMcaa6g3LQ%2FtowHpcLSKyUeUKAnfhyUv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2630&quot; height=&quot;604&quot; data-origin-width=&quot;2630&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;After 결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2660&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EbBqE/dJMb99TN6Au/Ztdnryh9azArGvK7y5ZX3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EbBqE/dJMb99TN6Au/Ztdnryh9azArGvK7y5ZX3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EbBqE/dJMb99TN6Au/Ztdnryh9azArGvK7y5ZX3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEbBqE%2FdJMb99TN6Au%2FZtdnryh9azArGvK7y5ZX3K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2660&quot; height=&quot;780&quot; data-origin-width=&quot;2660&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;684&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2k1az/dJMcaa6g3Nx/kIYk9fw7RK1vNfQ4VMddUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2k1az/dJMcaa6g3Nx/kIYk9fw7RK1vNfQ4VMddUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2k1az/dJMcaa6g3Nx/kIYk9fw7RK1vNfQ4VMddUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2k1az%2FdJMcaa6g3Nx%2FkIYk9fw7RK1vNfQ4VMddUK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2756&quot; height=&quot;684&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;684&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. revenues 인덱스 성능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;목적&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;revenues 테이블에 10만 건 더미 데이터를 삽입하고, 인덱스 추가 전후 쿼리 성능을 EXPLAIN ANALYZE로 비교한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;더미 데이터 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;loadtest 유저 50명 &amp;times; 각 2000건 = 총 10만 건 삽입&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;추가한 인덱스&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;sql&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;n1ql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;CREATE INDEX idx_revenue_author_type ON revenues (author_id, type);
CREATE INDEX idx_revenue_author_type_date ON revenues (author_id, type, created_at);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과 비교&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿼리 1: 수익 현황 집계&lt;/h4&gt;
&lt;div&gt;항목BeforeAfter
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시간&lt;/td&gt;
&lt;td&gt;24.7ms&lt;/td&gt;
&lt;td&gt;1.15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스캔 방식&lt;/td&gt;
&lt;td&gt;Full Table Scan (100,015건)&lt;/td&gt;
&lt;td&gt;Index Lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개선율&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 21배&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿼리 2: 월별 수익 통계&lt;/h4&gt;
&lt;div&gt;항목BeforeAfter
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시간&lt;/td&gt;
&lt;td&gt;16.2ms&lt;/td&gt;
&lt;td&gt;1.29ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스캔 방식&lt;/td&gt;
&lt;td&gt;Full Table Scan&lt;/td&gt;
&lt;td&gt;Index Range Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개선율&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 12배&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿼리 3: 수익 TOP 10 집계 (트레이드오프)&lt;/h4&gt;
&lt;div&gt;항목BeforeAfter
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시간&lt;/td&gt;
&lt;td&gt;32.3ms&lt;/td&gt;
&lt;td&gt;54ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;b&gt;오히려 느려짐&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 테이블을 type 기준으로 집계하는 쿼리는 어차피 모든 행을 읽어야 하기 때문에 인덱스가 도움이 안 된다. 오히려 인덱스 스캔 오버헤드가 추가돼 더 느려졌다. 인덱스는 선택도(selectivity)가 높은 컬럼에 효과적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;적용전&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQS9aV/dJMcaakSHbE/STuyt8PQFM6ITZNXakmwPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQS9aV/dJMcaakSHbE/STuyt8PQFM6ITZNXakmwPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQS9aV/dJMcaakSHbE/STuyt8PQFM6ITZNXakmwPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQS9aV%2FdJMcaakSHbE%2FSTuyt8PQFM6ITZNXakmwPk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2756&quot; height=&quot;654&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;적용후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2734&quot; data-origin-height=&quot;1006&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cROEUz/dJMcagrRz55/7JDTiqNZoZOxTHuUhm2zY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cROEUz/dJMcagrRz55/7JDTiqNZoZOxTHuUhm2zY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cROEUz/dJMcagrRz55/7JDTiqNZoZOxTHuUhm2zY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcROEUz%2FdJMcagrRz55%2F7JDTiqNZoZOxTHuUhm2zY0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2734&quot; height=&quot;1006&quot; data-origin-width=&quot;2734&quot; data-origin-height=&quot;1006&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 캐시 성능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-1. 이벤트 목록 조회 캐시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API: GET /api/events?status=ONGOING&amp;amp;page=0&amp;amp;size=10&lt;br /&gt;캐시: RedisTemplate, TTL 5분&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Cache Miss (DB 조회): 33.5ms
Cache Hit  (Redis):   6.6ms
개선율: 약 5배 (80.3%)
checks: 10/10 성공&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2730&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EUi4V/dJMcabKUkO0/wy7Krcb6DZoemeNUGUkqw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EUi4V/dJMcabKUkO0/wy7Krcb6DZoemeNUGUkqw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EUi4V/dJMcabKUkO0/wy7Krcb6DZoemeNUGUkqw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEUi4V%2FdJMcabKUkO0%2Fwy7Krcb6DZoemeNUGUkqw1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2730&quot; height=&quot;1392&quot; data-origin-width=&quot;2730&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2740&quot; data-origin-height=&quot;1396&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lnT1g/dJMcah5oYeB/PuftOktKxAxZRklJ4mUQWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lnT1g/dJMcah5oYeB/PuftOktKxAxZRklJ4mUQWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lnT1g/dJMcah5oYeB/PuftOktKxAxZRklJ4mUQWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlnT1g%2FdJMcah5oYeB%2FPuftOktKxAxZRklJ4mUQWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2740&quot; height=&quot;1396&quot; data-origin-width=&quot;2740&quot; data-origin-height=&quot;1396&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-2. 수익 통계 조회 캐시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API: GET /api/revenues/me/statistics?period=MONTHLY&amp;amp;year=2026&lt;br /&gt;캐시: RedisTemplate, TTL 1시간&lt;br /&gt;데이터: revenues 10만 건 + GROUP BY 집계 쿼리&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Cache Miss (DB 집계): 181.5ms
Cache Hit  (Redis):    6.1ms
개선율: 약 7.4배 (86.5%)
checks: 10/10 성공&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GROUP BY + 집계 쿼리는 데이터가 많을수록 DB 조회가 느리기 때문에 캐시 효과가 가장 극적으로 나왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2742&quot; data-origin-height=&quot;1410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MQ9F4/dJMcajvlfmT/sV12akGc71VtrVktMKb69k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MQ9F4/dJMcajvlfmT/sV12akGc71VtrVktMKb69k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MQ9F4/dJMcajvlfmT/sV12akGc71VtrVktMKb69k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMQ9F4%2FdJMcajvlfmT%2FsV12akGc71VtrVktMKb69k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2742&quot; height=&quot;1410&quot; data-origin-width=&quot;2742&quot; data-origin-height=&quot;1410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2762&quot; data-origin-height=&quot;1400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZd0YE/dJMcaaSKcP8/j5UvJV7w9oUM9HNKCPNkY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZd0YE/dJMcaaSKcP8/j5UvJV7w9oUM9HNKCPNkY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZd0YE/dJMcaaSKcP8/j5UvJV7w9oUM9HNKCPNkY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZd0YE%2FdJMcaaSKcP8%2Fj5UvJV7w9oUM9HNKCPNkY1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2762&quot; height=&quot;1400&quot; data-origin-width=&quot;2762&quot; data-origin-height=&quot;1400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-3. 도서 검색 캐시 (국립도서관 외부 API)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API: GET /api/v1/national-library/books/search?query=스프링&amp;amp;page=1&amp;amp;size=10&lt;br /&gt;캐시: RedisTemplate, TTL 10분&lt;br /&gt;특징: 국립도서관 외부 API 호출&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Cache Miss (외부 API): 231.9ms
Cache Hit  (Redis):     43.8ms
개선율: 약 2배 (53.1%)
checks: 10/10 성공&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cache Hit이 43ms로 다른 API에 비해 느린 이유는 응답 크기가 27kB로 크기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;캐시 성능 비교 종합&lt;/h3&gt;
&lt;div&gt;APICache MissCache Hit개선율
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 목록 조회&lt;/td&gt;
&lt;td&gt;33.5ms&lt;/td&gt;
&lt;td&gt;6.6ms&lt;/td&gt;
&lt;td&gt;약 5배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수익 통계 조회&lt;/td&gt;
&lt;td&gt;181.5ms&lt;/td&gt;
&lt;td&gt;6.1ms&lt;/td&gt;
&lt;td&gt;약 7.4배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도서 검색&lt;/td&gt;
&lt;td&gt;231.9ms&lt;/td&gt;
&lt;td&gt;43.8ms&lt;/td&gt;
&lt;td&gt;약 2배&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2744&quot; data-origin-height=&quot;1400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsVqhH/dJMcajov6dx/3fV4OlloGmavwVHDBFc2k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsVqhH/dJMcajov6dx/3fV4OlloGmavwVHDBFc2k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsVqhH/dJMcajov6dx/3fV4OlloGmavwVHDBFc2k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsVqhH%2FdJMcajov6dx%2F3fV4OlloGmavwVHDBFc2k0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2744&quot; height=&quot;1400&quot; data-origin-width=&quot;2744&quot; data-origin-height=&quot;1400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2776&quot; data-origin-height=&quot;1410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhJx39/dJMcaiiUfWn/rOeIo1k8IlgEeYYUowaJ81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhJx39/dJMcaiiUfWn/rOeIo1k8IlgEeYYUowaJ81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhJx39/dJMcaiiUfWn/rOeIo1k8IlgEeYYUowaJ81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdhJx39%2FdJMcaiiUfWn%2FrOeIo1k8IlgEeYYUowaJ81%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2776&quot; height=&quot;1410&quot; data-origin-width=&quot;2776&quot; data-origin-height=&quot;1410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. Redis Record 클래스 역직렬화 버그 수정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 발견&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수익 현황 조회 캐시 테스트 중 두 번째 요청부터 500 에러 발생.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;SerializationException: Could not read JSON:
Unexpected token (START_OBJECT), expected START_ARRAY:
need Array value to contain `As.WRAPPER_ARRAY` type information&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GenericJackson2JsonRedisSerializer가 Redis에 저장할 때 타입 정보 없이 순수 JSON으로 저장한다. 꺼낼 때 Jackson이 타입을 알 수 없어 LinkedHashMap으로 역직렬화한다. 이를 RevenueOverviewResponse로 강제 캐스팅하면 ClassCastException이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;activateDefaultTyping(NON_FINAL) 옵션으로 해결하려 했지만 &lt;b&gt;Java Record 클래스는 final로 컴파일&lt;/b&gt;되기 때문에 해당 옵션이 적용되지 않는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ObjectMapper로 직접 직렬화/역직렬화한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dart&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 저장 시 (JSON 문자열로 직렬화)
String json = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, json, CACHE_TTL);

// 조회 시 (JSON 문자열로 역직렬화)
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof String jsonStr) {
    return objectMapper.readValue(jsonStr, RevenueOverviewResponse.class);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RevenueService와 StatisticsService 두 곳에 적용했다. RedisConfig는 팀원 영향 없이 원래대로 복구했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2732&quot; data-origin-height=&quot;1374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEpJK5/dJMcafsWY4N/ENpPtxfVKb0RUoTjgfzWa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEpJK5/dJMcafsWY4N/ENpPtxfVKb0RUoTjgfzWa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEpJK5/dJMcafsWY4N/ENpPtxfVKb0RUoTjgfzWa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEpJK5%2FdJMcafsWY4N%2FENpPtxfVKb0RUoTjgfzWa1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2732&quot; height=&quot;1374&quot; data-origin-width=&quot;2732&quot; data-origin-height=&quot;1374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZI6Nd/dJMcafsWY5v/j0kMcMJTZ9lz9RuMYTXEeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZI6Nd/dJMcafsWY5v/j0kMcMJTZ9lz9RuMYTXEeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZI6Nd/dJMcafsWY5v/j0kMcMJTZ9lz9RuMYTXEeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZI6Nd%2FdJMcafsWY5v%2Fj0kMcMJTZ9lz9RuMYTXEeK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2720&quot; height=&quot;1378&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 교훈&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에 Java Record 클래스를 캐싱할 때는 GenericJackson2JsonRedisSerializer의 자동 역직렬화가 동작하지 않는다. ObjectMapper로 명시적으로 직렬화/역직렬화해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. k6 VUs(Virtual Users) 설정 개념 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;shared-iterations:&lt;/b&gt; 총 N개의 iteration을 M개의 VU가 나눠서 처리. 동시 참여처럼 정확히 N번의 요청을 보내야 할 때 사용.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;constant-vus:&lt;/b&gt; M명의 VU가 duration 동안 계속 반복 요청. 지속적인 부하를 주고 싶을 때 사용. Grafana에 의미 있는 트래픽 패턴을 만들기 위해 사용.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 참여 부하테스트에서 두 시나리오를 동시에 실행한 이유:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;spike_participate(VUs 200, iterations 200): 정합성 검증 목적&lt;/li&gt;
&lt;li&gt;sustained_read(VUs 50, duration 30s): Grafana 수치 확보 목적&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 시나리오가 동시에 실행되어 Grafana에서 RPS 40 req/s, CPU 74.3%, 스레드 295개, GC 1.72ms를 관찰했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 트러블슈팅 모음&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 1: MySQL @변수 세션 문제&lt;/b&gt;&lt;br /&gt;SQL 파일 실행 시 세션 변수가 초기화되지 않아 데이터 미삽입. Subquery 방식으로 변경해 해결.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 2: k6 한글 파라미터 인코딩&lt;/b&gt;&lt;br /&gt;query=스프링 직접 입력 시 인코딩 문제로 checks 0% 발생. URL 인코딩 값 %EC%8A%A4%ED%94%84%EB%A7%81 사용으로 해결.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 3: Redis Record 역직렬화&lt;/b&gt;&lt;br /&gt;섹션 8 참고.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 4: NovelStatus enum 값 불일치&lt;/b&gt;&lt;br /&gt;더미 SQL에서 'SERIALIZING' 사용 &amp;rarr; 실제 enum은 'ONGOING'. SELECT DISTINCT status FROM novels;로 확인 후 수정.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 5: Grafana 변수 Target data source 미설정&lt;/b&gt;&lt;br /&gt;4701 대시보드 임포트 후 Application 드롭다운이 비어 있었음. Variables 편집에서 Target data source를 Prometheus로 수동 선택해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 최종 포트폴리오 증거 자료 정리&lt;/h2&gt;
&lt;div&gt;&lt;br /&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;모니터링 구축&lt;/td&gt;
&lt;td&gt;Prometheus + Grafana 대시보드 캡처&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 참여 동시성 (분산락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB 100건 정확 + Grafana 수치 (RPS 40, CPU 74.3%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수익 환전 동시성 (Redis 분산락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB 1건 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멘토링 수락 동시성 (낙관적 락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB ACCEPTED 1건 + version=1 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;피드백 V1/V2 비교 (비관적 락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB sessionNumber 1~5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;revenues 인덱스 성능&lt;/td&gt;
&lt;td&gt;EXPLAIN ANALYZE Before/After (21배, 12배, 트레이드오프)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 목록 캐시&lt;/td&gt;
&lt;td&gt;k6 결과 (33ms &amp;rarr; 6.6ms, 5배)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수익 통계 캐시&lt;/td&gt;
&lt;td&gt;k6 결과 (181ms &amp;rarr; 6.1ms, 7.4배)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도서 검색 캐시&lt;/td&gt;
&lt;td&gt;k6 결과 (231ms &amp;rarr; 43ms, 2배)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. Kafka 도입 스토리 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도입 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 이벤트 참여, 멘토링 수락 등 여러 도메인에서 알림 발송을 동기로 처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;강결합:&lt;/b&gt; 알림 발송 실패 시 핵심 비즈니스 로직도 함께 실패&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 저하:&lt;/b&gt; 알림 처리 시간이 API 응답 시간에 포함됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OOM 위험:&lt;/b&gt; 이벤트 생성 시 전체 READER 유저를 한 번에 조회하면 수만 명일 경우 OOM 발생 위험&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka 도입으로 해결한 것&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;기존: 이벤트 참여 &amp;rarr; [알림 발송] &amp;rarr; 응답 반환  (동기, 실패 시 롤백)

도입 후: 이벤트 참여 &amp;rarr; 응답 반환 (빠름)
                  &amp;darr; ApplicationEventPublisher
              @TransactionalEventListener(AFTER_COMMIT)
                  &amp;darr; 트랜잭션 커밋 후 Kafka 발행
              알림 Consumer (비동기)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대량 알림 발송 시에는 전체 READER 유저를 1000건씩 페이지 단위로 배치 조회하여 순차적으로 이벤트를 발행한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 @Async가 아닌 Kafka인가?&lt;/h3&gt;
&lt;div&gt;항목@AsyncKafka
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;메시지 유실&lt;/td&gt;
&lt;td&gt;서버 재시작 시 유실&lt;/td&gt;
&lt;td&gt;디스크 영속화로 보존&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재처리&lt;/td&gt;
&lt;td&gt;별도 구현 필요&lt;/td&gt;
&lt;td&gt;Consumer 재시작으로 재처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;확장성&lt;/td&gt;
&lt;td&gt;JVM 내부 스레드 풀 한계&lt;/td&gt;
&lt;td&gt;Consumer Group으로 수평 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모니터링&lt;/td&gt;
&lt;td&gt;어려움&lt;/td&gt;
&lt;td&gt;토픽/파티션/오프셋으로 추적 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 유실 없이 안정적으로 처리해야 하고, 향후 여러 Consumer(이메일, 앱 푸시, SMS 등)로 확장 가능한 구조가 필요했기 때문에 Kafka를 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;2&quot; data-ke-size=&quot;size23&quot;&gt;안정성과 확장성을 위한 고도화 고민&lt;/h3&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;Kafka 도입으로 비동기 처리와 도메인 간 결합도 해제라는 큰 성과를 얻었지만, 시스템의 신뢰도를 100%로 끌어올리기 위해 다음의 고도화 전략들을 고민하고 있다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size20&quot;&gt;1. 데이터 유실 제로를 위한 'Transactional Outbox Pattern'&lt;/h4&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;현재는 AFTER_COMMIT 시점에 이벤트를 발행하여 정합성을 맞추고 있다. 하지만 아주 희박한 확률로 &lt;b data-index-in-node=&quot;59&quot; data-path-to-node=&quot;5&quot;&gt;DB 커밋은 성공했으나 Kafka 발행 직전 네트워크 장애가 발생&lt;/b&gt;할 경우 메시지가 유실될 수 있다. 이를 완벽히 방지하기 위해 비즈니스 데이터와 메시지를 하나의 트랜잭션으로 DB(Outbox Table)에 저장하고, 별도 프로세스가 발행을 보장하는 패턴을 검토 중이다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size20&quot;&gt;2. 예외 상황의 격리와 재처리를 위한 'DLT(Dead Letter Topic)'&lt;/h4&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;비동기 처리 중 Consumer에서 예상치 못한 에러가 발생했을 때, 무한 재시도로 인해 전체 시스템이 지연되는 것을 막아야 한다. 실패한 메시지는 별도의 &lt;b data-index-in-node=&quot;87&quot; data-path-to-node=&quot;7&quot;&gt;Dead Letter Topic&lt;/b&gt;으로 격리하여 장애 원인을 분석하고, 정상화 이후 해당 메시지만 선별적으로 재처리할 수 있는 운영 프로세스를 구상하고 있다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size20&quot;&gt;3. 대규모 트래픽 대비 'Cursor 기반 페이징 &amp;amp; 병렬 처리'&lt;/h4&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;수만 명의 유저에게 알림을 보낼 때, 현재의 페이지 단위 조회를 더욱 최적화하기 위해 &lt;b data-index-in-node=&quot;48&quot; data-path-to-node=&quot;9&quot;&gt;ID 기반 Cursor 페이징&lt;/b&gt;을 적용하여 조회 성능을 유지하고자 한다. 또한, 알림량이 늘어날 경우 Kafka의 Partition을 확장하고 Consumer Group의 인스턴스를 수평 확장(Scale-out)하여 처리량을 극대화하는 설계를 염두에 두고 있다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;10,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0&quot;&gt;마치며&lt;/b&gt; 모든 프로젝트에 이런 고도로 복잡한 패턴이 정답은 아닐 것이다. 하지만 서비스가 성장함에 따라 마주칠 병목 구간을 미리 예측하고, 그에 맞는 아키텍처를 제시할 수 있는 능력이 진정한 기술적 깊이라고 믿는다. 단순히 기술을 '사용'하는 것을 넘어, 시스템의 &lt;b data-index-in-node=&quot;147&quot; data-path-to-node=&quot;10,0&quot;&gt;전체적인 흐름과 운영의 안정성&lt;/b&gt;을 책임지는 개발자로 성장해 나가고자 한다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/213</guid>
      <comments>https://minwoo95.tistory.com/213#entry213comment</comments>
      <pubDate>Mon, 4 May 2026 21:22:39 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 13</title>
      <link>https://minwoo95.tistory.com/212</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;도메인별 적용 기술 정리하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;튜터님 피드백 및 방향성&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[캘린더] &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/calendars/me/records&amp;nbsp;&amp;mdash;&amp;nbsp;독서&amp;nbsp;기록&amp;nbsp;등록 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;CalendarExceptionEnum:&amp;nbsp;PLATFORM&amp;nbsp;출처인데&amp;nbsp;서재&amp;nbsp;미등록&amp;nbsp;시&amp;nbsp;BOOK_NOT_IN_LIBRARY&amp;nbsp;예외&amp;nbsp;발생&amp;nbsp;&amp;rarr;&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;PK&amp;nbsp;직접&amp;nbsp;참조:&amp;nbsp;Library&amp;nbsp;테이블에서&amp;nbsp;userId&amp;nbsp;+&amp;nbsp;novelId로&amp;nbsp;서재&amp;nbsp;등록&amp;nbsp;여부&amp;nbsp;확인,&amp;nbsp;연관관계&amp;nbsp;없이&amp;nbsp;PK로&amp;nbsp;조회 &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/calendars/me/records&amp;nbsp;&amp;mdash;&amp;nbsp;독서&amp;nbsp;기록&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리:&amp;nbsp;date,&amp;nbsp;novelId가&amp;nbsp;선택값이므로&amp;nbsp;BooleanExpression으로&amp;nbsp;null&amp;nbsp;조건&amp;nbsp;자동&amp;nbsp;제외 &lt;br /&gt;PageResponse:&amp;nbsp;페이징&amp;nbsp;처리하여&amp;nbsp;content,&amp;nbsp;totalElements,&amp;nbsp;totalPages&amp;nbsp;등&amp;nbsp;반환 &lt;br /&gt;PK&amp;nbsp;직접&amp;nbsp;참조:&amp;nbsp;novelId,&amp;nbsp;userId를&amp;nbsp;외래키로&amp;nbsp;직접&amp;nbsp;참조해&amp;nbsp;조회 &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/calendars/me&amp;nbsp;&amp;mdash;&amp;nbsp;독서&amp;nbsp;캘린더&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;CalendarExceptionEnum:&amp;nbsp;조회&amp;nbsp;범위&amp;nbsp;1년&amp;nbsp;초과&amp;nbsp;시&amp;nbsp;DATE_RANGE_TOO_LARGE&amp;nbsp;&amp;rarr;&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;GlobalExceptionHandler&amp;nbsp;+&amp;nbsp;MissingServletRequestParameterException&amp;nbsp;핸들러:&amp;nbsp;startDate&amp;nbsp;누락&amp;nbsp;시&amp;nbsp;500&amp;nbsp;대신&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;&lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/calendars/me/statistics&amp;nbsp;&amp;mdash;&amp;nbsp;월간&amp;nbsp;통계&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;CalendarExceptionEnum:&amp;nbsp;미래&amp;nbsp;달&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;INVALID_STAT_DATE&amp;nbsp;&amp;rarr;&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;JPQL&amp;nbsp;@Query:&amp;nbsp;월별&amp;nbsp;집계(총&amp;nbsp;페이지,&amp;nbsp;완독&amp;nbsp;수,&amp;nbsp;독서일&amp;nbsp;수&amp;nbsp;등)&amp;nbsp;단순&amp;nbsp;집계&amp;nbsp;쿼리는&amp;nbsp;JPA로&amp;nbsp;처리 &lt;br /&gt;MonthlyStatResponse&amp;nbsp;Record:&amp;nbsp;집계&amp;nbsp;결과를&amp;nbsp;Record&amp;nbsp;클래스&amp;nbsp;DTO로&amp;nbsp;반환 &lt;br /&gt;&lt;br /&gt;이유 &lt;br /&gt;통계&amp;nbsp;조회는&amp;nbsp;복잡한&amp;nbsp;동적&amp;nbsp;조건보다는&amp;nbsp;month,&amp;nbsp;year가&amp;nbsp;고정된&amp;nbsp;단순&amp;nbsp;집계이므로&amp;nbsp;QueryDSL&amp;nbsp;대신&amp;nbsp;JPQL&amp;nbsp;@Query로&amp;nbsp;처리했습니다.&amp;nbsp;복잡도에&amp;nbsp;맞는&amp;nbsp;기술을&amp;nbsp;선택하는&amp;nbsp;것이&amp;nbsp;팀&amp;nbsp;컨벤션이고,&amp;nbsp;오버엔지니어링을&amp;nbsp;방지합니다.&amp;nbsp;미래&amp;nbsp;달&amp;nbsp;조회&amp;nbsp;제한은&amp;nbsp;존재하지&amp;nbsp;않는&amp;nbsp;데이터에&amp;nbsp;대한&amp;nbsp;불필요한&amp;nbsp;쿼리를&amp;nbsp;사전&amp;nbsp;차단하기&amp;nbsp;위한&amp;nbsp;방어&amp;nbsp;로직입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;N+1,&amp;nbsp;반복&amp;nbsp;쿼리&amp;nbsp;발생을&amp;nbsp;방지,&amp;nbsp;불필요한&amp;nbsp;조인&amp;nbsp;줄이고&amp;nbsp;응답&amp;nbsp;일관성&amp;nbsp;확보,&amp;nbsp;복합&amp;nbsp;유니크&amp;nbsp;제약과&amp;nbsp;어플리케이션&amp;nbsp;중복검사&amp;nbsp;==&amp;gt;&amp;nbsp;중복&amp;nbsp;서재&amp;nbsp;등록&amp;nbsp;방지&lt;/span&gt; &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[내서제] &lt;br /&gt;POST&amp;nbsp;/api/libraries&amp;nbsp;&amp;mdash;&amp;nbsp;내서재&amp;nbsp;담기 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;메타데이터&amp;nbsp;스냅샷&amp;nbsp;저장 &lt;br /&gt;문제:&amp;nbsp;소설&amp;nbsp;정보(제목,&amp;nbsp;작가명,&amp;nbsp;표지)는&amp;nbsp;작가가&amp;nbsp;수정할&amp;nbsp;수&amp;nbsp;있어서&amp;nbsp;담기&amp;nbsp;시점&amp;nbsp;이후에&amp;nbsp;변경될&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;Novel&amp;nbsp;테이블을&amp;nbsp;매번&amp;nbsp;Join하면&amp;nbsp;수정된&amp;nbsp;데이터가&amp;nbsp;담기&amp;nbsp;시점과&amp;nbsp;다르게&amp;nbsp;노출되고&amp;nbsp;불필요한&amp;nbsp;Join&amp;nbsp;비용이&amp;nbsp;발생합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;담기&amp;nbsp;시점의&amp;nbsp;novelTitle,&amp;nbsp;authorNickname,&amp;nbsp;coverImageUrl을&amp;nbsp;Library&amp;nbsp;테이블에&amp;nbsp;직접&amp;nbsp;저장합니다.&amp;nbsp;이후&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;Novel&amp;nbsp;테이블&amp;nbsp;Join&amp;nbsp;없이&amp;nbsp;Library&amp;nbsp;단독으로&amp;nbsp;응답이&amp;nbsp;가능합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;DB&amp;nbsp;유니크&amp;nbsp;제약&amp;nbsp;+&amp;nbsp;애플리케이션&amp;nbsp;중복&amp;nbsp;검사&amp;nbsp;이중&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;애플리케이션&amp;nbsp;레벨의&amp;nbsp;existsByUserIdAndNovelId()&amp;nbsp;단독&amp;nbsp;검사만으로는&amp;nbsp;동시&amp;nbsp;요청&amp;nbsp;시&amp;nbsp;race&amp;nbsp;condition으로&amp;nbsp;중복&amp;nbsp;insert가&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Library&amp;nbsp;테이블에&amp;nbsp;userId&amp;nbsp;+&amp;nbsp;novelId&amp;nbsp;복합&amp;nbsp;유니크&amp;nbsp;인덱스를&amp;nbsp;추가하여&amp;nbsp;DB&amp;nbsp;레벨에서도&amp;nbsp;중복을&amp;nbsp;차단합니다.&amp;nbsp;애플리케이션&amp;nbsp;레벨&amp;nbsp;검사는&amp;nbsp;409&amp;nbsp;응답을&amp;nbsp;위해&amp;nbsp;유지하고&amp;nbsp;DB&amp;nbsp;제약은&amp;nbsp;동시&amp;nbsp;요청&amp;nbsp;방어의&amp;nbsp;최후&amp;nbsp;방어선으로&amp;nbsp;이중&amp;nbsp;적용하였습니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/libraries/me&amp;nbsp;&amp;mdash;&amp;nbsp;내서재&amp;nbsp;목록&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리 &lt;br /&gt;문제:&amp;nbsp;전체/읽는중/완독/찜/구매&amp;nbsp;탭&amp;nbsp;필터와&amp;nbsp;최신순/제목순&amp;nbsp;정렬을&amp;nbsp;JPQL로&amp;nbsp;구현하면&amp;nbsp;조건&amp;nbsp;조합마다&amp;nbsp;별도&amp;nbsp;메서드가&amp;nbsp;필요해&amp;nbsp;코드&amp;nbsp;중복이&amp;nbsp;발생합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL을&amp;nbsp;사용하여&amp;nbsp;libraryType이&amp;nbsp;null이면&amp;nbsp;전체,&amp;nbsp;값이&amp;nbsp;있으면&amp;nbsp;해당&amp;nbsp;타입만&amp;nbsp;필터링하는&amp;nbsp;동적&amp;nbsp;쿼리를&amp;nbsp;단일&amp;nbsp;메서드로&amp;nbsp;처리합니다.&amp;nbsp;정렬&amp;nbsp;조건도&amp;nbsp;resolveOrder()로&amp;nbsp;분기하여&amp;nbsp;확장에&amp;nbsp;유연한&amp;nbsp;구조로&amp;nbsp;설계하였습니다. &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결&amp;nbsp;&amp;mdash;&amp;nbsp;일괄&amp;nbsp;집계&amp;nbsp;쿼리 &lt;br /&gt;&lt;br /&gt;문제:&amp;nbsp;각&amp;nbsp;소설의&amp;nbsp;총&amp;nbsp;회차&amp;nbsp;수를&amp;nbsp;구하기&amp;nbsp;위해&amp;nbsp;항목마다&amp;nbsp;countByNovelId()를&amp;nbsp;호출하면&amp;nbsp;페이지&amp;nbsp;크기만큼&amp;nbsp;추가&amp;nbsp;쿼리가&amp;nbsp;발생하는&amp;nbsp;N+1&amp;nbsp;문제가&amp;nbsp;생깁니다.&amp;nbsp;페이지&amp;nbsp;크기가&amp;nbsp;12라면&amp;nbsp;최대&amp;nbsp;13번의&amp;nbsp;쿼리가&amp;nbsp;실행됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;조회된&amp;nbsp;Library&amp;nbsp;목록에서&amp;nbsp;novelId&amp;nbsp;집합을&amp;nbsp;추출한&amp;nbsp;뒤&amp;nbsp;countByNovelIds()로&amp;nbsp;단일&amp;nbsp;쿼리에서&amp;nbsp;GROUP&amp;nbsp;BY&amp;nbsp;novelId로&amp;nbsp;일괄&amp;nbsp;집계합니다.&amp;nbsp;결과를&amp;nbsp;Map으로&amp;nbsp;변환하여&amp;nbsp;항목별로&amp;nbsp;매핑하므로&amp;nbsp;쿼리가&amp;nbsp;항상&amp;nbsp;1회로&amp;nbsp;고정됩니다.&amp;nbsp;또한&amp;nbsp;isDeleted&amp;nbsp;=&amp;nbsp;false&amp;nbsp;조건으로&amp;nbsp;soft-delete된&amp;nbsp;회차를&amp;nbsp;제외하여&amp;nbsp;실제&amp;nbsp;공개된&amp;nbsp;회차&amp;nbsp;수만&amp;nbsp;집계합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계&amp;nbsp;제거&amp;nbsp;+&amp;nbsp;ID&amp;nbsp;직접&amp;nbsp;참조 &lt;br /&gt;문제:&amp;nbsp;JPA&amp;nbsp;연관관계를&amp;nbsp;사용하면&amp;nbsp;즉시/지연&amp;nbsp;로딩에&amp;nbsp;따른&amp;nbsp;불필요한&amp;nbsp;쿼리가&amp;nbsp;발생하고&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;결합도가&amp;nbsp;높아져&amp;nbsp;변경&amp;nbsp;영향&amp;nbsp;범위가&amp;nbsp;넓어집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Library&amp;nbsp;엔티티가&amp;nbsp;User,&amp;nbsp;Novel을&amp;nbsp;객체로&amp;nbsp;참조하지&amp;nbsp;않고&amp;nbsp;userId,&amp;nbsp;novelId를&amp;nbsp;컬럼으로만&amp;nbsp;보유하도록&amp;nbsp;설계하였습니다.&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;결합을&amp;nbsp;끊고&amp;nbsp;필요한&amp;nbsp;시점에만&amp;nbsp;QueryDSL&amp;nbsp;Join으로&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/libraries/me&amp;nbsp;-&amp;nbsp;내&amp;nbsp;서재&amp;nbsp;통합&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1&amp;nbsp;-&amp;nbsp;단일&amp;nbsp;API&amp;nbsp;통합&amp;nbsp;응답 &lt;br /&gt;문제:&amp;nbsp;소설&amp;nbsp;서재와&amp;nbsp;국립도서관&amp;nbsp;도서&amp;nbsp;서재를&amp;nbsp;별도&amp;nbsp;API로&amp;nbsp;분리하면&amp;nbsp;프론트에서&amp;nbsp;두&amp;nbsp;번&amp;nbsp;호출해야&amp;nbsp;하고&amp;nbsp;로딩&amp;nbsp;타이밍이&amp;nbsp;달라져&amp;nbsp;UX가&amp;nbsp;저하됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;기존&amp;nbsp;GET&amp;nbsp;/api/libraries/me&amp;nbsp;하나에서&amp;nbsp;소설&amp;nbsp;서재(novels)와&amp;nbsp;국립도서관&amp;nbsp;도서&amp;nbsp;서재(nationalLibraryBooks)를&amp;nbsp;MyLibraryResponse&amp;nbsp;하나로&amp;nbsp;통합하여&amp;nbsp;반환하도록&amp;nbsp;설계하였습니다.&amp;nbsp;프론트&amp;nbsp;호출&amp;nbsp;횟수를&amp;nbsp;줄이고&amp;nbsp;단일&amp;nbsp;응답으로&amp;nbsp;렌더링&amp;nbsp;구조를&amp;nbsp;단순화하였습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2&amp;nbsp;-&amp;nbsp;N+1&amp;nbsp;방지를&amp;nbsp;위한&amp;nbsp;일괄&amp;nbsp;조회&amp;nbsp;(findAllById&amp;nbsp;+&amp;nbsp;Map&amp;nbsp;매핑) &lt;br /&gt;문제:&amp;nbsp;연관관계&amp;nbsp;없이&amp;nbsp;ID로만&amp;nbsp;관리하는&amp;nbsp;구조에서&amp;nbsp;UserBook&amp;nbsp;목록을&amp;nbsp;순회하며&amp;nbsp;Book을&amp;nbsp;개별&amp;nbsp;조회하면&amp;nbsp;N+1&amp;nbsp;문제가&amp;nbsp;발생하여&amp;nbsp;쿼리&amp;nbsp;횟수가&amp;nbsp;데이터&amp;nbsp;수에&amp;nbsp;비례하여&amp;nbsp;증가합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;UserBook&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;bookId&amp;nbsp;목록을&amp;nbsp;추출하여&amp;nbsp;findAllById로&amp;nbsp;Book을&amp;nbsp;단&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;일괄&amp;nbsp;조회하고,&amp;nbsp;Map으로&amp;nbsp;변환하여&amp;nbsp;O(1)로&amp;nbsp;매핑하는&amp;nbsp;방식을&amp;nbsp;적용하였습니다.&amp;nbsp;조회&amp;nbsp;쿼리가&amp;nbsp;항상&amp;nbsp;2번으로&amp;nbsp;고정되어&amp;nbsp;데이터&amp;nbsp;수와&amp;nbsp;무관하게&amp;nbsp;일정한&amp;nbsp;성능을&amp;nbsp;유지합니다. &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[국립도서] &lt;br /&gt;구현한&amp;nbsp;API&amp;nbsp;및&amp;nbsp;적용&amp;nbsp;기술&amp;nbsp;정리 &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1/national-library/books/search&amp;nbsp;-&amp;nbsp;도서&amp;nbsp;검색 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;-&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;(RedisTemplate) &lt;br /&gt;문제:&amp;nbsp;국립중앙도서관은&amp;nbsp;외부&amp;nbsp;API라&amp;nbsp;매&amp;nbsp;요청마다&amp;nbsp;호출하면&amp;nbsp;네트워크&amp;nbsp;지연으로&amp;nbsp;응답&amp;nbsp;속도가&amp;nbsp;느려지고&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;횟수&amp;nbsp;제한&amp;nbsp;리스크가&amp;nbsp;있습니다.&amp;nbsp;동일한&amp;nbsp;검색어에&amp;nbsp;대한&amp;nbsp;반복&amp;nbsp;요청이&amp;nbsp;많을수록&amp;nbsp;불필요한&amp;nbsp;외부&amp;nbsp;호출이&amp;nbsp;증가합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;검색어&amp;nbsp;+&amp;nbsp;페이지&amp;nbsp;+&amp;nbsp;사이즈를&amp;nbsp;조합한&amp;nbsp;키로&amp;nbsp;검색&amp;nbsp;결과를&amp;nbsp;Redis에&amp;nbsp;10분간&amp;nbsp;캐싱하여&amp;nbsp;캐시&amp;nbsp;히트&amp;nbsp;시&amp;nbsp;외부&amp;nbsp;API를&amp;nbsp;호출하지&amp;nbsp;않도록&amp;nbsp;하였습니다.&amp;nbsp;이를&amp;nbsp;통해&amp;nbsp;응답&amp;nbsp;속도를&amp;nbsp;개선하고&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;횟수를&amp;nbsp;최소화하였습니다. &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/v1/national-library/books/shelf&amp;nbsp;-&amp;nbsp;내&amp;nbsp;서재&amp;nbsp;도서&amp;nbsp;저장 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1&amp;nbsp;-&amp;nbsp;books&amp;nbsp;테이블&amp;nbsp;upsert&amp;nbsp;패턴 &lt;br /&gt;문제:&amp;nbsp;검색&amp;nbsp;결과에서&amp;nbsp;선택한&amp;nbsp;도서를&amp;nbsp;저장할&amp;nbsp;때&amp;nbsp;동일한&amp;nbsp;ISBN의&amp;nbsp;도서가&amp;nbsp;여러&amp;nbsp;유저에&amp;nbsp;의해&amp;nbsp;중복으로&amp;nbsp;저장될&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;도서&amp;nbsp;데이터가&amp;nbsp;중복&amp;nbsp;적재되면&amp;nbsp;저장&amp;nbsp;공간이&amp;nbsp;낭비되고&amp;nbsp;데이터&amp;nbsp;정합성이&amp;nbsp;깨집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;도서&amp;nbsp;저장&amp;nbsp;요청&amp;nbsp;시&amp;nbsp;ISBN&amp;nbsp;기준으로&amp;nbsp;books&amp;nbsp;테이블에&amp;nbsp;이미&amp;nbsp;존재하는지&amp;nbsp;먼저&amp;nbsp;확인하고,&amp;nbsp;없을&amp;nbsp;때만&amp;nbsp;저장하고&amp;nbsp;있으면&amp;nbsp;기존&amp;nbsp;레코드를&amp;nbsp;재사용하는&amp;nbsp;방식을&amp;nbsp;적용하였습니다.&amp;nbsp;도서&amp;nbsp;데이터&amp;nbsp;중복을&amp;nbsp;방지하고&amp;nbsp;user_books&amp;nbsp;테이블에는&amp;nbsp;유저와&amp;nbsp;도서의&amp;nbsp;관계만&amp;nbsp;저장하도록&amp;nbsp;설계하였습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2&amp;nbsp;-&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계&amp;nbsp;제거&amp;nbsp;+&amp;nbsp;ID&amp;nbsp;직접&amp;nbsp;참조 &lt;br /&gt;문제:&amp;nbsp;JPA&amp;nbsp;연관관계를&amp;nbsp;사용하면&amp;nbsp;즉시/지연&amp;nbsp;로딩에&amp;nbsp;따른&amp;nbsp;불필요한&amp;nbsp;쿼리가&amp;nbsp;발생하고&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;결합도가&amp;nbsp;높아져&amp;nbsp;변경&amp;nbsp;영향&amp;nbsp;범위가&amp;nbsp;넓어집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;UserBook&amp;nbsp;엔티티가&amp;nbsp;User,&amp;nbsp;Book을&amp;nbsp;객체로&amp;nbsp;참조하지&amp;nbsp;않고&amp;nbsp;userId,&amp;nbsp;bookId를&amp;nbsp;컬럼으로만&amp;nbsp;보유하도록&amp;nbsp;설계하였습니다.&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;결합을&amp;nbsp;끊고&amp;nbsp;필요한&amp;nbsp;시점에만&amp;nbsp;ID&amp;nbsp;기반으로&amp;nbsp;일괄&amp;nbsp;조회하여&amp;nbsp;처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;Redis&amp;nbsp;캐시&amp;nbsp;활용&amp;nbsp;네트워크&amp;nbsp;지연율&amp;nbsp;감소,&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;결과&amp;nbsp;저장&amp;nbsp;시&amp;nbsp;upsert&amp;nbsp;중복적재&amp;nbsp;방지,&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;장애나&amp;nbsp;지연&amp;nbsp;가능성을&amp;nbsp;고려&lt;/span&gt; &lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[멘토&amp;nbsp;도메인] &lt;br /&gt;POST&amp;nbsp;/api/mentors&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;신청 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;careerLevel&amp;nbsp;기준&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;처리 &lt;br /&gt;문제:&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;신청&amp;nbsp;시&amp;nbsp;모든&amp;nbsp;신청을&amp;nbsp;관리자가&amp;nbsp;수동으로&amp;nbsp;검토하면&amp;nbsp;처리&amp;nbsp;지연이&amp;nbsp;발생하고&amp;nbsp;관리자&amp;nbsp;부담이&amp;nbsp;커집니다.&amp;nbsp;하지만&amp;nbsp;등급별로&amp;nbsp;이미&amp;nbsp;플랫폼&amp;nbsp;활동&amp;nbsp;조건이&amp;nbsp;정해져&amp;nbsp;있어&amp;nbsp;일정&amp;nbsp;조건을&amp;nbsp;충족한&amp;nbsp;경우는&amp;nbsp;자동으로&amp;nbsp;승인해도&amp;nbsp;무방합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;신청&amp;nbsp;시점에&amp;nbsp;Novel,&amp;nbsp;Episode&amp;nbsp;테이블을&amp;nbsp;조회해&amp;nbsp;PUBLISHED&amp;nbsp;에피소드&amp;nbsp;수와&amp;nbsp;좋아요&amp;nbsp;합산을&amp;nbsp;검증합니다.&amp;nbsp;INTRODUCTION은&amp;nbsp;에피소드&amp;nbsp;50회&amp;nbsp;이상,&amp;nbsp;ELEMENTARY는&amp;nbsp;에피소드&amp;nbsp;50회&amp;nbsp;이상&amp;nbsp;+&amp;nbsp;좋아요&amp;nbsp;50개&amp;nbsp;이상,&amp;nbsp;INTERMEDIATE는&amp;nbsp;에피소드&amp;nbsp;100회&amp;nbsp;이상&amp;nbsp;+&amp;nbsp;좋아요&amp;nbsp;100개&amp;nbsp;이상이면&amp;nbsp;즉시&amp;nbsp;APPROVED로&amp;nbsp;저장합니다.&amp;nbsp;PROFICIENT는&amp;nbsp;수상/출간&amp;nbsp;경력&amp;nbsp;검증이&amp;nbsp;필요하므로&amp;nbsp;관리자&amp;nbsp;수동&amp;nbsp;승인으로&amp;nbsp;PENDING&amp;nbsp;유지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;리스트&amp;nbsp;필드&amp;nbsp;JSON&amp;nbsp;직렬화&amp;nbsp;저장 &lt;br /&gt;문제:&amp;nbsp;mainGenres,&amp;nbsp;specialFields,&amp;nbsp;mentoringStyles는&amp;nbsp;복수의&amp;nbsp;값을&amp;nbsp;가지는&amp;nbsp;필드입니다.&amp;nbsp;별도&amp;nbsp;테이블로&amp;nbsp;분리하면&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;항상&amp;nbsp;Join이&amp;nbsp;필요하고&amp;nbsp;수정&amp;nbsp;시&amp;nbsp;기존&amp;nbsp;데이터를&amp;nbsp;삭제&amp;nbsp;후&amp;nbsp;재삽입하는&amp;nbsp;복잡한&amp;nbsp;로직이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;ObjectMapper.writeValueAsString()으로&amp;nbsp;List를&amp;nbsp;JSON&amp;nbsp;문자열로&amp;nbsp;직렬화해&amp;nbsp;단일&amp;nbsp;컬럼에&amp;nbsp;저장합니다.&amp;nbsp;조회&amp;nbsp;시에는&amp;nbsp;objectMapper.readValue()로&amp;nbsp;역직렬화해&amp;nbsp;List로&amp;nbsp;반환합니다.&amp;nbsp;이&amp;nbsp;필드들은&amp;nbsp;단순&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;외에&amp;nbsp;복잡한&amp;nbsp;검색&amp;nbsp;쿼리가&amp;nbsp;필요&amp;nbsp;없고&amp;nbsp;변경&amp;nbsp;빈도도&amp;nbsp;낮아&amp;nbsp;Join&amp;nbsp;없이&amp;nbsp;처리할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;JSON&amp;nbsp;직렬화&amp;nbsp;방식이&amp;nbsp;적합하다고&amp;nbsp;판단했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;DB&amp;nbsp;유니크&amp;nbsp;제약&amp;nbsp;+&amp;nbsp;DataIntegrityViolationException&amp;nbsp;처리로&amp;nbsp;동시성&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;시&amp;nbsp;PENDING,&amp;nbsp;APPROVED&amp;nbsp;상태를&amp;nbsp;사전&amp;nbsp;조회한&amp;nbsp;후&amp;nbsp;저장하는&amp;nbsp;구조라&amp;nbsp;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;둘&amp;nbsp;다&amp;nbsp;조회를&amp;nbsp;통과해&amp;nbsp;중복&amp;nbsp;저장이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;check-then-act&amp;nbsp;패턴의&amp;nbsp;전형적인&amp;nbsp;race&amp;nbsp;condition입니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor&amp;nbsp;엔티티의&amp;nbsp;userId&amp;nbsp;컬럼에&amp;nbsp;@Column(unique&amp;nbsp;=&amp;nbsp;true)를&amp;nbsp;걸어&amp;nbsp;DB&amp;nbsp;레벨에서&amp;nbsp;중복을&amp;nbsp;막습니다.&amp;nbsp;동시&amp;nbsp;요청으로&amp;nbsp;두&amp;nbsp;건이&amp;nbsp;저장을&amp;nbsp;시도할&amp;nbsp;경우&amp;nbsp;DB에서&amp;nbsp;DataIntegrityViolationException이&amp;nbsp;발생하는데,&amp;nbsp;save&amp;nbsp;호출부를&amp;nbsp;try-catch로&amp;nbsp;감싸&amp;nbsp;해당&amp;nbsp;예외를&amp;nbsp;MENTOR_ALREADY_APPROVED로&amp;nbsp;변환해&amp;nbsp;409로&amp;nbsp;응답합니다.&amp;nbsp;애플리케이션&amp;nbsp;레벨&amp;nbsp;검증만으로는&amp;nbsp;race&amp;nbsp;condition을&amp;nbsp;막을&amp;nbsp;수&amp;nbsp;없어&amp;nbsp;DB&amp;nbsp;제약과&amp;nbsp;예외&amp;nbsp;처리를&amp;nbsp;함께&amp;nbsp;구성했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;4.&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;패턴으로&amp;nbsp;엔티티&amp;nbsp;생성 &lt;br /&gt;문제:&amp;nbsp;생성자를&amp;nbsp;열어두면&amp;nbsp;팀원이&amp;nbsp;엔티티를&amp;nbsp;생성할&amp;nbsp;때&amp;nbsp;필드&amp;nbsp;순서를&amp;nbsp;실수하거나&amp;nbsp;필수&amp;nbsp;초기값&amp;nbsp;설정을&amp;nbsp;빠뜨릴&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;특히&amp;nbsp;Mentor는&amp;nbsp;생성&amp;nbsp;시점에&amp;nbsp;반드시&amp;nbsp;status가&amp;nbsp;결정되어야&amp;nbsp;하는데&amp;nbsp;이를&amp;nbsp;강제할&amp;nbsp;방법이&amp;nbsp;없습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;생성자를&amp;nbsp;private으로&amp;nbsp;막고&amp;nbsp;Mentor.create()라는&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;메서드만&amp;nbsp;열어두었습니다.&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;여부를&amp;nbsp;결정한&amp;nbsp;initialStatus를&amp;nbsp;파라미터로&amp;nbsp;받아&amp;nbsp;생성&amp;nbsp;시점에&amp;nbsp;반드시&amp;nbsp;status가&amp;nbsp;설정되도록&amp;nbsp;강제합니다.&amp;nbsp;객체&amp;nbsp;생성&amp;nbsp;의도를&amp;nbsp;메서드&amp;nbsp;이름으로&amp;nbsp;명확히&amp;nbsp;표현할&amp;nbsp;수&amp;nbsp;있고&amp;nbsp;생성&amp;nbsp;규칙을&amp;nbsp;한&amp;nbsp;곳에서&amp;nbsp;관리할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;PUT&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;정보&amp;nbsp;수정 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;부분&amp;nbsp;업데이트&amp;nbsp;-&amp;nbsp;null&amp;nbsp;필드&amp;nbsp;기존&amp;nbsp;값&amp;nbsp;유지 &lt;br /&gt;문제:&amp;nbsp;수정&amp;nbsp;API에서&amp;nbsp;전체&amp;nbsp;필드를&amp;nbsp;항상&amp;nbsp;보내도록&amp;nbsp;강제하면&amp;nbsp;프론트엔드에서&amp;nbsp;변경하지&amp;nbsp;않는&amp;nbsp;필드까지&amp;nbsp;모두&amp;nbsp;채워서&amp;nbsp;보내야&amp;nbsp;하는&amp;nbsp;부담이&amp;nbsp;생깁니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor.update()&amp;nbsp;메서드에서&amp;nbsp;null&amp;nbsp;필드는&amp;nbsp;기존&amp;nbsp;값을&amp;nbsp;유지하도록&amp;nbsp;구현했습니다.&amp;nbsp;introduction,&amp;nbsp;careerHistory&amp;nbsp;같은&amp;nbsp;단순&amp;nbsp;문자열&amp;nbsp;필드는&amp;nbsp;null&amp;nbsp;여부를&amp;nbsp;체크하고,&amp;nbsp;mainGenres,&amp;nbsp;specialFields,&amp;nbsp;mentoringStyles&amp;nbsp;리스트&amp;nbsp;필드는&amp;nbsp;등록용&amp;nbsp;toJson()과&amp;nbsp;수정용&amp;nbsp;toJsonForUpdate()를&amp;nbsp;분리했습니다.&amp;nbsp;toJsonForUpdate()는&amp;nbsp;null이면&amp;nbsp;null을&amp;nbsp;반환해&amp;nbsp;기존&amp;nbsp;값을&amp;nbsp;유지하고,&amp;nbsp;빈&amp;nbsp;리스트를&amp;nbsp;보내면&amp;nbsp;실제로&amp;nbsp;직렬화해&amp;nbsp;명시적으로&amp;nbsp;비울&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;했습니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘토&amp;nbsp;프로필&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;JSON&amp;nbsp;역직렬화로&amp;nbsp;리스트&amp;nbsp;필드&amp;nbsp;복원 &lt;br /&gt;문제:&amp;nbsp;DB에&amp;nbsp;JSON&amp;nbsp;문자열로&amp;nbsp;저장된&amp;nbsp;mainGenres,&amp;nbsp;specialFields,&amp;nbsp;mentoringStyles를&amp;nbsp;응답&amp;nbsp;시&amp;nbsp;List로&amp;nbsp;변환해야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;MentorProfileResponse의&amp;nbsp;parseJson()&amp;nbsp;메서드에서&amp;nbsp;objectMapper.readValue()로&amp;nbsp;JSON&amp;nbsp;문자열을&amp;nbsp;List로&amp;nbsp;역직렬화합니다.&amp;nbsp;파싱&amp;nbsp;실패&amp;nbsp;시&amp;nbsp;빈&amp;nbsp;리스트를&amp;nbsp;반환하도록&amp;nbsp;예외&amp;nbsp;처리해&amp;nbsp;응답이&amp;nbsp;깨지지&amp;nbsp;않도록&amp;nbsp;했습니다. &lt;br /&gt;&lt;br /&gt;배치&amp;nbsp;스케줄러&amp;nbsp;&amp;mdash;&amp;nbsp;매일&amp;nbsp;자정&amp;nbsp;멘토&amp;nbsp;등급&amp;nbsp;자동&amp;nbsp;조정 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Spring&amp;nbsp;@Scheduled&amp;nbsp;배치 &lt;br /&gt;문제:&amp;nbsp;멘토&amp;nbsp;등급은&amp;nbsp;에피소드&amp;nbsp;회차와&amp;nbsp;좋아요&amp;nbsp;수에&amp;nbsp;따라&amp;nbsp;자동으로&amp;nbsp;올라가야&amp;nbsp;합니다.&amp;nbsp;에피소드&amp;nbsp;발행이나&amp;nbsp;좋아요&amp;nbsp;발생&amp;nbsp;시마다&amp;nbsp;이벤트로&amp;nbsp;처리하면&amp;nbsp;에피소드,&amp;nbsp;좋아요&amp;nbsp;도메인에&amp;nbsp;멘토&amp;nbsp;도메인&amp;nbsp;로직이&amp;nbsp;침투해&amp;nbsp;결합도가&amp;nbsp;높아집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Spring의&amp;nbsp;@Scheduled(cron&amp;nbsp;=&amp;nbsp;&quot;0&amp;nbsp;0&amp;nbsp;0&amp;nbsp;*&amp;nbsp;*&amp;nbsp;*&quot;)로&amp;nbsp;매일&amp;nbsp;자정에&amp;nbsp;한&amp;nbsp;번&amp;nbsp;APPROVED&amp;nbsp;상태&amp;nbsp;멘토&amp;nbsp;전체를&amp;nbsp;순회하며&amp;nbsp;조건을&amp;nbsp;검사합니다.&amp;nbsp;각&amp;nbsp;도메인이&amp;nbsp;서로를&amp;nbsp;몰라도&amp;nbsp;되고&amp;nbsp;구현이&amp;nbsp;단순합니다.&amp;nbsp;현재&amp;nbsp;트래픽&amp;nbsp;규모에서&amp;nbsp;실시간&amp;nbsp;반영이&amp;nbsp;필수가&amp;nbsp;아니라&amp;nbsp;판단해&amp;nbsp;별도&amp;nbsp;인프라&amp;nbsp;없이&amp;nbsp;Spring&amp;nbsp;내장&amp;nbsp;스케줄러로&amp;nbsp;충분하다고&amp;nbsp;결정했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;등급&amp;nbsp;변경&amp;nbsp;이력&amp;nbsp;별도&amp;nbsp;테이블&amp;nbsp;저장 &lt;br /&gt;문제:&amp;nbsp;배치로&amp;nbsp;등급이&amp;nbsp;변경될&amp;nbsp;때&amp;nbsp;Mentor&amp;nbsp;테이블에&amp;nbsp;덮어쓰기만&amp;nbsp;하면&amp;nbsp;언제&amp;nbsp;어떤&amp;nbsp;이유로&amp;nbsp;등급이&amp;nbsp;바뀌었는지&amp;nbsp;추적이&amp;nbsp;불가능합니다.&amp;nbsp;관리자&amp;nbsp;입장에서&amp;nbsp;멘토&amp;nbsp;이의&amp;nbsp;제기&amp;nbsp;시&amp;nbsp;근거&amp;nbsp;자료가&amp;nbsp;없습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;MentorCareerHistory&amp;nbsp;테이블에&amp;nbsp;변경&amp;nbsp;전&amp;nbsp;등급,&amp;nbsp;변경&amp;nbsp;후&amp;nbsp;등급,&amp;nbsp;변경&amp;nbsp;사유,&amp;nbsp;변경&amp;nbsp;시각을&amp;nbsp;함께&amp;nbsp;저장합니다.&amp;nbsp;데이터가&amp;nbsp;누적되는&amp;nbsp;append-only&amp;nbsp;구조라&amp;nbsp;변경&amp;nbsp;흐름&amp;nbsp;전체를&amp;nbsp;추적할&amp;nbsp;수&amp;nbsp;있고&amp;nbsp;삭제나&amp;nbsp;수정이&amp;nbsp;없어&amp;nbsp;관리가&amp;nbsp;단순합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;중복 sessionNumber 발생을 방지하기 위해 비관적 락과 DB 유니크 제약을 함께 적용, 낙관적 락 적용, 상태전이 모델, 성능을 점진적으로 개선 해나 났다 +배치 스케줄러&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;배치&amp;nbsp;스케줄러--&amp;gt;&amp;nbsp;도메인 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;멘토 등급 자동 조정 청크 단위 고민한거--&amp;gt; 메모리 부하, 영속성 컨텍스트 누적 문제 해결, 배치 페이지네이션 pk 정렬 고정&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;--------------------------------------------------------------------------------------------------------&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;[멘토&amp;nbsp;도메인] &lt;br /&gt;1.&amp;nbsp;GET&amp;nbsp;/api/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;페이지네이션&amp;nbsp;(Spring&amp;nbsp;Data&amp;nbsp;Pageable) &lt;br /&gt;문제:&amp;nbsp;멘토에게&amp;nbsp;접수된&amp;nbsp;멘토링&amp;nbsp;신청이&amp;nbsp;많아질수록&amp;nbsp;전체&amp;nbsp;목록을&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;반환하면&amp;nbsp;응답&amp;nbsp;크기가&amp;nbsp;커지고&amp;nbsp;DB&amp;nbsp;부하가&amp;nbsp;증가합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Pageable을&amp;nbsp;적용해&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;조회하고&amp;nbsp;PageResponse로&amp;nbsp;감싸&amp;nbsp;현재&amp;nbsp;페이지,&amp;nbsp;전체&amp;nbsp;페이지&amp;nbsp;수,&amp;nbsp;마지막&amp;nbsp;페이지&amp;nbsp;여부를&amp;nbsp;함께&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;엔티티&amp;nbsp;ID&amp;nbsp;참조&amp;nbsp;방식&amp;nbsp;+&amp;nbsp;개별&amp;nbsp;조회&amp;nbsp;(N+1&amp;nbsp;인지) &lt;br /&gt;문제:&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계를&amp;nbsp;맺지&amp;nbsp;않는&amp;nbsp;설계&amp;nbsp;원칙상&amp;nbsp;Mentorship에서&amp;nbsp;멘티&amp;nbsp;이름(User)과&amp;nbsp;소설&amp;nbsp;제목(Novel)을&amp;nbsp;바로&amp;nbsp;JOIN할&amp;nbsp;수&amp;nbsp;없습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;userRepository.findByIdAndIsDeletedFalse(),&amp;nbsp;novelRepository.findById()로&amp;nbsp;개별&amp;nbsp;조회합니다.&amp;nbsp;현재는&amp;nbsp;N+1이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있으나&amp;nbsp;탈퇴한&amp;nbsp;멘티는&amp;nbsp;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자,&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설로&amp;nbsp;graceful하게&amp;nbsp;처리합니다.&amp;nbsp;고도화&amp;nbsp;시&amp;nbsp;QueryDSL&amp;nbsp;JOIN으로&amp;nbsp;교체할&amp;nbsp;위치를&amp;nbsp;TODO&amp;nbsp;주석으로&amp;nbsp;명시해두었습니다. &lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;PATCH&amp;nbsp;/api/mentorings/{mentoringId}/mentees/{menteeId}/accept&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;수락 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;낙관적&amp;nbsp;락&amp;nbsp;(@Version) &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;여러&amp;nbsp;멘티를&amp;nbsp;동시에&amp;nbsp;수락하는&amp;nbsp;경우&amp;nbsp;슬롯&amp;nbsp;차감(decreaseSlot())이&amp;nbsp;동시에&amp;nbsp;실행되면&amp;nbsp;슬롯이&amp;nbsp;0&amp;nbsp;이하로&amp;nbsp;내려가는&amp;nbsp;race&amp;nbsp;condition이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다 &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor&amp;nbsp;엔티티에&amp;nbsp;@Version을&amp;nbsp;적용해&amp;nbsp;낙관적&amp;nbsp;락을&amp;nbsp;걸었습니다.&amp;nbsp;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;슬롯을&amp;nbsp;차감하려&amp;nbsp;하면&amp;nbsp;나중에&amp;nbsp;커밋된&amp;nbsp;트랜잭션이&amp;nbsp;OptimisticLockException을&amp;nbsp;발생시켜&amp;nbsp;중복&amp;nbsp;차감을&amp;nbsp;방지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(PENDING&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;수락/거절&amp;nbsp;처리된&amp;nbsp;멘토링에&amp;nbsp;중복으로&amp;nbsp;수락&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.PENDING&amp;nbsp;체크로&amp;nbsp;이미&amp;nbsp;처리된&amp;nbsp;요청에는&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;3.&amp;nbsp;PATCH&amp;nbsp;/api/mentorings/{mentoringId}/mentees/{menteeId}/reject&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;거절 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(PENDING&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;수락/거절&amp;nbsp;처리된&amp;nbsp;멘토링에&amp;nbsp;거절&amp;nbsp;요청이&amp;nbsp;중복으로&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;수락&amp;nbsp;API와&amp;nbsp;동일하게&amp;nbsp;PENDING&amp;nbsp;상태&amp;nbsp;체크를&amp;nbsp;통해&amp;nbsp;중복&amp;nbsp;처리를&amp;nbsp;방지하고&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다.&amp;nbsp;수락과&amp;nbsp;달리&amp;nbsp;슬롯&amp;nbsp;차감이&amp;nbsp;없으므로&amp;nbsp;낙관적&amp;nbsp;락은&amp;nbsp;적용하지&amp;nbsp;않았습니다. &lt;br /&gt;&lt;br /&gt;4.&amp;nbsp;GET&amp;nbsp;/api/mentorings/{mentoringId}/documents&amp;nbsp;&amp;mdash;&amp;nbsp;원고&amp;nbsp;다운로드&amp;nbsp;URL&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;다운로드&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;원고를&amp;nbsp;몇&amp;nbsp;번&amp;nbsp;다운로드했는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;URL&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;mentorship.increaseManuscriptDownloadCount()를&amp;nbsp;호출해&amp;nbsp;다운로드&amp;nbsp;횟수를&amp;nbsp;증가시킵니다.&amp;nbsp;조회와&amp;nbsp;카운트&amp;nbsp;증가를&amp;nbsp;하나의&amp;nbsp;트랜잭션으로&amp;nbsp;묶어&amp;nbsp;일관성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;S3&amp;nbsp;Presigned&amp;nbsp;URL&amp;nbsp;연동&amp;nbsp;준비&amp;nbsp;(TODO) &lt;br /&gt;문제:&amp;nbsp;원고&amp;nbsp;파일&amp;nbsp;URL을&amp;nbsp;그대로&amp;nbsp;노출하면&amp;nbsp;인증&amp;nbsp;없이&amp;nbsp;누구나&amp;nbsp;접근할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;보안&amp;nbsp;문제가&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;현재는&amp;nbsp;manuscriptUrl을&amp;nbsp;직접&amp;nbsp;반환하지만&amp;nbsp;S3&amp;nbsp;연동&amp;nbsp;후&amp;nbsp;s3Service.generatePresignedUrl()로&amp;nbsp;교체할&amp;nbsp;위치를&amp;nbsp;TODO&amp;nbsp;주석으로&amp;nbsp;명시해&amp;nbsp;두었습니다. &lt;br /&gt;&lt;br /&gt;5.&amp;nbsp;PATCH&amp;nbsp;/api/mentorings/{mentoringId}/complete&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;종료 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;낙관적&amp;nbsp;락&amp;nbsp;(@Version)&amp;nbsp;+&amp;nbsp;슬롯&amp;nbsp;반환 &lt;br /&gt;문제:&amp;nbsp;멘토링&amp;nbsp;종료&amp;nbsp;시&amp;nbsp;슬롯을&amp;nbsp;반환(increaseSlot())하는데&amp;nbsp;동시에&amp;nbsp;여러&amp;nbsp;멘토링이&amp;nbsp;종료되면&amp;nbsp;슬롯&amp;nbsp;값이&amp;nbsp;꼬일&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor&amp;nbsp;엔티티의&amp;nbsp;@Version으로&amp;nbsp;슬롯&amp;nbsp;반환&amp;nbsp;시에도&amp;nbsp;낙관적&amp;nbsp;락이&amp;nbsp;적용되어&amp;nbsp;동시&amp;nbsp;수정&amp;nbsp;충돌을&amp;nbsp;방지합니다.&amp;nbsp;수락&amp;nbsp;시&amp;nbsp;차감한&amp;nbsp;슬롯을&amp;nbsp;종료&amp;nbsp;시&amp;nbsp;정확히&amp;nbsp;반환해&amp;nbsp;슬롯&amp;nbsp;정합성을&amp;nbsp;유지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;PENDING이나&amp;nbsp;REJECTED&amp;nbsp;상태의&amp;nbsp;멘토링을&amp;nbsp;종료&amp;nbsp;처리하려는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.ACCEPTED&amp;nbsp;체크로&amp;nbsp;진행&amp;nbsp;중인&amp;nbsp;멘토링만&amp;nbsp;종료할&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_NOT_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;6.&amp;nbsp;GET&amp;nbsp;/api/mentorings/{mentoringId}&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;엔티티&amp;nbsp;ID&amp;nbsp;참조&amp;nbsp;방식&amp;nbsp;+&amp;nbsp;Soft&amp;nbsp;Delete&amp;nbsp;고려&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;멘토나&amp;nbsp;멘티가&amp;nbsp;탈퇴한&amp;nbsp;경우에도&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;정보는&amp;nbsp;조회&amp;nbsp;가능해야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;userRepository.findByIdAndIsDeletedFalse()로&amp;nbsp;조회해&amp;nbsp;탈퇴한&amp;nbsp;유저는&amp;nbsp;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자로&amp;nbsp;graceful하게&amp;nbsp;처리합니다.&amp;nbsp;멘토링&amp;nbsp;데이터&amp;nbsp;자체는&amp;nbsp;유지되어&amp;nbsp;피드백&amp;nbsp;이력&amp;nbsp;등을&amp;nbsp;확인할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;피드백&amp;nbsp;목록&amp;nbsp;시간순&amp;nbsp;정렬&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;피드백이&amp;nbsp;여러&amp;nbsp;건일&amp;nbsp;때&amp;nbsp;작성&amp;nbsp;순서대로&amp;nbsp;보여줘야&amp;nbsp;멘토링&amp;nbsp;진행&amp;nbsp;흐름을&amp;nbsp;파악할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorFeedbackRepository.findAllByMentorshipIdOrderByCreatedAtAsc()로&amp;nbsp;피드백을&amp;nbsp;시간&amp;nbsp;오름차순으로&amp;nbsp;조회합니다. &lt;br /&gt;&lt;br /&gt;7.&amp;nbsp;POST&amp;nbsp;/api/mentorings/{mentoringId}/feedbacks&amp;nbsp;&amp;mdash;&amp;nbsp;피드백&amp;nbsp;작성 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;세션&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토링이&amp;nbsp;몇&amp;nbsp;회&amp;nbsp;진행되었는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;피드백&amp;nbsp;저장&amp;nbsp;시&amp;nbsp;mentorship.increaseSession()을&amp;nbsp;함께&amp;nbsp;호출해&amp;nbsp;총&amp;nbsp;세션&amp;nbsp;수를&amp;nbsp;증가시킵니다.&amp;nbsp;피드백&amp;nbsp;저장과&amp;nbsp;세션&amp;nbsp;증가를&amp;nbsp;하나의&amp;nbsp;트랜잭션으로&amp;nbsp;묶어&amp;nbsp;피드백이&amp;nbsp;저장되면&amp;nbsp;반드시&amp;nbsp;세션도&amp;nbsp;증가하도록&amp;nbsp;일관성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;진행&amp;nbsp;중이&amp;nbsp;아닌&amp;nbsp;멘토링(PENDING,&amp;nbsp;REJECTED,&amp;nbsp;COMPLETED)에&amp;nbsp;피드백을&amp;nbsp;작성하는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.ACCEPTED&amp;nbsp;체크로&amp;nbsp;수락된&amp;nbsp;멘토링에만&amp;nbsp;피드백&amp;nbsp;작성이&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_FEEDBACK_ONLY_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[고도화&amp;nbsp;진행한&amp;nbsp;내용] &lt;br /&gt;POST&amp;nbsp;/api/v1/mentorings/{mentoringId}/feedbacks&amp;nbsp;&amp;mdash;&amp;nbsp;피드백&amp;nbsp;작성&amp;nbsp;(V1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;세션&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토링이&amp;nbsp;몇&amp;nbsp;회&amp;nbsp;진행되었는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;피드백&amp;nbsp;저장&amp;nbsp;시&amp;nbsp;mentorship.increaseSession()을&amp;nbsp;함께&amp;nbsp;호출해&amp;nbsp;총&amp;nbsp;세션&amp;nbsp;수를&amp;nbsp;증가시킵니다.&amp;nbsp;피드백&amp;nbsp;저장과&amp;nbsp;세션&amp;nbsp;증가를&amp;nbsp;하나의&amp;nbsp;트랜잭션으로&amp;nbsp;묶어&amp;nbsp;일관성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;진행&amp;nbsp;중이&amp;nbsp;아닌&amp;nbsp;멘토링에&amp;nbsp;피드백을&amp;nbsp;작성하는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.ACCEPTED&amp;nbsp;체크로&amp;nbsp;수락된&amp;nbsp;멘토링에만&amp;nbsp;피드백&amp;nbsp;작성이&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_FEEDBACK_ONLY_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;회차별&amp;nbsp;피드백&amp;nbsp;이력&amp;nbsp;관리 &lt;br /&gt;문제:&amp;nbsp;피드백이&amp;nbsp;몇&amp;nbsp;회차인지,&amp;nbsp;어떤&amp;nbsp;제목의&amp;nbsp;피드백인지&amp;nbsp;구분할&amp;nbsp;방법이&amp;nbsp;없었습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;MentorFeedback에&amp;nbsp;title과&amp;nbsp;sessionNumber를&amp;nbsp;추가해&amp;nbsp;회차별&amp;nbsp;피드백&amp;nbsp;이력을&amp;nbsp;명확히&amp;nbsp;관리합니다.&amp;nbsp;sessionNumber는&amp;nbsp;totalSessions&amp;nbsp;+&amp;nbsp;1로&amp;nbsp;계산해&amp;nbsp;자동&amp;nbsp;부여합니다. &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/v2/mentorings/{mentoringId}/feedbacks&amp;nbsp;&amp;mdash;&amp;nbsp;피드백&amp;nbsp;작성&amp;nbsp;(V2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;비관적&amp;nbsp;락&amp;nbsp;(동시성&amp;nbsp;보호) &lt;br /&gt;문제:&amp;nbsp;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;totalSessions&amp;nbsp;+&amp;nbsp;1을&amp;nbsp;동시에&amp;nbsp;읽어&amp;nbsp;같은&amp;nbsp;sessionNumber가&amp;nbsp;중복&amp;nbsp;저장될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;findByIdWithLock()으로&amp;nbsp;멘토링&amp;nbsp;row에&amp;nbsp;비관적&amp;nbsp;락을&amp;nbsp;걸어&amp;nbsp;동시&amp;nbsp;요청을&amp;nbsp;직렬화합니다.&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;동시에&amp;nbsp;들어와도&amp;nbsp;하나씩&amp;nbsp;순서대로&amp;nbsp;처리됩니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;DB&amp;nbsp;유니크&amp;nbsp;제약&amp;nbsp;(이중&amp;nbsp;방어) &lt;br /&gt;문제:&amp;nbsp;애플리케이션&amp;nbsp;레벨의&amp;nbsp;락만으로는&amp;nbsp;예외&amp;nbsp;상황에서&amp;nbsp;중복&amp;nbsp;저장을&amp;nbsp;완전히&amp;nbsp;막을&amp;nbsp;수&amp;nbsp;없습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship_feedbacks&amp;nbsp;테이블에&amp;nbsp;(mentorship_id,&amp;nbsp;session_number)&amp;nbsp;유니크&amp;nbsp;제약을&amp;nbsp;추가해&amp;nbsp;DB&amp;nbsp;레벨에서도&amp;nbsp;중복을&amp;nbsp;차단합니다.&amp;nbsp;충돌&amp;nbsp;시&amp;nbsp;DataIntegrityViolationException을&amp;nbsp;MENTORING_SESSION_CONFLICT&amp;nbsp;예외로&amp;nbsp;변환해&amp;nbsp;409를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;입력값&amp;nbsp;길이&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;title이&amp;nbsp;200자를&amp;nbsp;초과하면&amp;nbsp;DB&amp;nbsp;예외로&amp;nbsp;500이&amp;nbsp;반환될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;@Size(max&amp;nbsp;=&amp;nbsp;200)을&amp;nbsp;추가해&amp;nbsp;요청&amp;nbsp;단계에서&amp;nbsp;400으로&amp;nbsp;끊어&amp;nbsp;사용자에게&amp;nbsp;명확한&amp;nbsp;오류&amp;nbsp;메시지를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(V1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;페이지네이션 &lt;br /&gt;문제:&amp;nbsp;멘토링&amp;nbsp;신청이&amp;nbsp;많을&amp;nbsp;경우&amp;nbsp;전체&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;성능&amp;nbsp;저하가&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Pageable을&amp;nbsp;적용해&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;조회합니다.&amp;nbsp;@Min,&amp;nbsp;@Max로&amp;nbsp;page,&amp;nbsp;size&amp;nbsp;파라미터를&amp;nbsp;검증해&amp;nbsp;잘못된&amp;nbsp;입력으로&amp;nbsp;인한&amp;nbsp;500&amp;nbsp;에러를&amp;nbsp;방지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;탈퇴&amp;nbsp;회원/삭제&amp;nbsp;소설&amp;nbsp;Fallback&amp;nbsp;처리 &lt;br /&gt;문제:&amp;nbsp;멘티가&amp;nbsp;탈퇴하거나&amp;nbsp;소설이&amp;nbsp;삭제된&amp;nbsp;경우&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;예외가&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Optional의&amp;nbsp;orElse()로&amp;nbsp;탈퇴한&amp;nbsp;멘티는&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;,&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;을&amp;nbsp;반환해&amp;nbsp;안정적으로&amp;nbsp;목록을&amp;nbsp;제공합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(V2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;V1의&amp;nbsp;findById()는&amp;nbsp;soft-delete&amp;nbsp;조건을&amp;nbsp;포함하지&amp;nbsp;않아&amp;nbsp;삭제된&amp;nbsp;소설&amp;nbsp;제목이&amp;nbsp;그대로&amp;nbsp;노출될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;findByIdAndIsDeletedFalse()로&amp;nbsp;교체해&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;로&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1/mentorings/{mentoringId}&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(V1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;권한&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;다른&amp;nbsp;멘토가&amp;nbsp;멘토링&amp;nbsp;상세를&amp;nbsp;조회하는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getMentorId()와&amp;nbsp;로그인한&amp;nbsp;멘토의&amp;nbsp;ID를&amp;nbsp;비교해&amp;nbsp;본인의&amp;nbsp;멘토링만&amp;nbsp;조회&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_UNAUTHORIZED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;회차별&amp;nbsp;피드백&amp;nbsp;이력&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;멘토링&amp;nbsp;상세에서&amp;nbsp;피드백&amp;nbsp;이력을&amp;nbsp;회차&amp;nbsp;순서대로&amp;nbsp;확인해야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;findAllByMentorshipIdOrderByCreatedAtAsc()로&amp;nbsp;피드백을&amp;nbsp;생성&amp;nbsp;순서대로&amp;nbsp;조회하고&amp;nbsp;FeedbackInfo에&amp;nbsp;title,&amp;nbsp;sessionNumber를&amp;nbsp;포함해&amp;nbsp;회차별&amp;nbsp;내용을&amp;nbsp;명확히&amp;nbsp;제공합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentorings/{mentoringId}&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(V2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;V1의&amp;nbsp;findById()는&amp;nbsp;삭제된&amp;nbsp;소설&amp;nbsp;제목이&amp;nbsp;그대로&amp;nbsp;노출될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;findByIdAndIsDeletedFalse()로&amp;nbsp;교체해&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;로&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/mentees/{menteeId}/accept&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;수락 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;슬롯&amp;nbsp;관리 &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;수락할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;멘티&amp;nbsp;수는&amp;nbsp;최대&amp;nbsp;5명으로&amp;nbsp;제한됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;수락&amp;nbsp;시&amp;nbsp;mentor.decreaseSlot()으로&amp;nbsp;잔여&amp;nbsp;슬롯을&amp;nbsp;차감합니다.&amp;nbsp;슬롯&amp;nbsp;차감과&amp;nbsp;멘토링&amp;nbsp;상태&amp;nbsp;변경을&amp;nbsp;하나의&amp;nbsp;트랜잭션으로&amp;nbsp;묶어&amp;nbsp;일관성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;중복&amp;nbsp;처리&amp;nbsp;방지 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;수락/거절된&amp;nbsp;멘토링에&amp;nbsp;다시&amp;nbsp;수락&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.PENDING&amp;nbsp;체크로&amp;nbsp;PENDING&amp;nbsp;상태에서만&amp;nbsp;수락이&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/mentees/{menteeId}/reject&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;거절 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;중복&amp;nbsp;처리&amp;nbsp;방지 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;처리된&amp;nbsp;멘토링에&amp;nbsp;거절&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;PENDING&amp;nbsp;상태에서만&amp;nbsp;거절&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;거절&amp;nbsp;시각&amp;nbsp;기록 &lt;br /&gt;문제:&amp;nbsp;이번&amp;nbsp;달&amp;nbsp;거절&amp;nbsp;건수&amp;nbsp;통계&amp;nbsp;집계를&amp;nbsp;위해&amp;nbsp;거절&amp;nbsp;시각이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.reject()&amp;nbsp;호출&amp;nbsp;시&amp;nbsp;rejectedAt을&amp;nbsp;LocalDateTime.now()로&amp;nbsp;기록합니다.&amp;nbsp;이를&amp;nbsp;기반으로&amp;nbsp;countRejectedThisMonth()&amp;nbsp;쿼리에서&amp;nbsp;월별&amp;nbsp;거절&amp;nbsp;통계를&amp;nbsp;정확히&amp;nbsp;집계합니다. &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/complete&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;종료 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;슬롯&amp;nbsp;반환 &lt;br /&gt;문제:&amp;nbsp;멘토링이&amp;nbsp;종료되면&amp;nbsp;해당&amp;nbsp;슬롯을&amp;nbsp;다시&amp;nbsp;다른&amp;nbsp;멘티에게&amp;nbsp;열어줘야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;종료&amp;nbsp;시&amp;nbsp;mentor.increaseSlot()으로&amp;nbsp;슬롯을&amp;nbsp;반환합니다.&amp;nbsp;슬롯&amp;nbsp;반환과&amp;nbsp;상태&amp;nbsp;변경을&amp;nbsp;하나의&amp;nbsp;트랜잭션으로&amp;nbsp;묶어&amp;nbsp;일관성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;PENDING이나&amp;nbsp;REJECTED&amp;nbsp;상태의&amp;nbsp;멘토링을&amp;nbsp;종료하는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;ACCEPTED&amp;nbsp;상태에서만&amp;nbsp;종료&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_NOT_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/documents&amp;nbsp;&amp;mdash;&amp;nbsp;원고&amp;nbsp;다운로드&amp;nbsp;URL&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;다운로드&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;원고를&amp;nbsp;몇&amp;nbsp;번&amp;nbsp;다운로드했는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;URL&amp;nbsp;반환&amp;nbsp;시&amp;nbsp;mentorship.increaseManuscriptDownloadCount()를&amp;nbsp;함께&amp;nbsp;호출해&amp;nbsp;다운로드&amp;nbsp;횟수를&amp;nbsp;기록합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;원고&amp;nbsp;존재&amp;nbsp;여부&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;원고&amp;nbsp;파일이&amp;nbsp;없는&amp;nbsp;멘토링에&amp;nbsp;다운로드&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;manuscriptUrl이&amp;nbsp;null인&amp;nbsp;경우&amp;nbsp;MENTORING_MANUSCRIPT_NOT_FOUND&amp;nbsp;예외를&amp;nbsp;반환해&amp;nbsp;명확한&amp;nbsp;오류&amp;nbsp;메시지를&amp;nbsp;제공합니다. &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[고도화&amp;nbsp;진행한&amp;nbsp;내용] &lt;br /&gt;GET&amp;nbsp;/api/mentors/me/mentees&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘티&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(v1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;탈퇴/삭제&amp;nbsp;데이터&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;멘티가&amp;nbsp;탈퇴하거나&amp;nbsp;소설이&amp;nbsp;삭제된&amp;nbsp;경우&amp;nbsp;응답에&amp;nbsp;null이&amp;nbsp;들어갈&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;userRepository.findByIdAndIsDeletedFalse()&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;orElse(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;),&amp;nbsp;novelRepository.findById()&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;orElse(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;)로&amp;nbsp;null을&amp;nbsp;방어합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;최근&amp;nbsp;피드백&amp;nbsp;날짜&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;멘티별로&amp;nbsp;마지막&amp;nbsp;피드백&amp;nbsp;날짜를&amp;nbsp;보여줘야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorFeedbackRepository.findTopByMentorshipIdOrderByCreatedAtDesc()로&amp;nbsp;최신&amp;nbsp;1건만&amp;nbsp;조회해서&amp;nbsp;createdAt을&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentors/me/mentees&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘티&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(v2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결 &lt;br /&gt;문제:&amp;nbsp;v1은&amp;nbsp;반복문&amp;nbsp;안에서&amp;nbsp;멘티,&amp;nbsp;소설,&amp;nbsp;피드백을&amp;nbsp;각각&amp;nbsp;개별&amp;nbsp;조회해&amp;nbsp;멘티가&amp;nbsp;N명이면&amp;nbsp;쿼리가&amp;nbsp;1+3N번&amp;nbsp;나가는&amp;nbsp;구조였습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL로&amp;nbsp;mentorship&amp;nbsp;&amp;rarr;&amp;nbsp;menteeUser&amp;nbsp;&amp;rarr;&amp;nbsp;novel&amp;nbsp;&amp;rarr;&amp;nbsp;feedback을&amp;nbsp;단일&amp;nbsp;JOIN&amp;nbsp;쿼리로&amp;nbsp;처리해&amp;nbsp;쿼리를&amp;nbsp;1번으로&amp;nbsp;줄였습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;soft-delete&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;탈퇴한&amp;nbsp;유저나&amp;nbsp;삭제된&amp;nbsp;소설&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;LEFT&amp;nbsp;JOIN&amp;nbsp;결과&amp;nbsp;null이&amp;nbsp;응답에&amp;nbsp;그대로&amp;nbsp;들어갈&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Expressions.cases()로&amp;nbsp;쿼리&amp;nbsp;단에서&amp;nbsp;직접&amp;nbsp;null을&amp;nbsp;방어합니다. &lt;br /&gt;javaExpressions.cases() &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.when(menteeUser.nickname.isNull()) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.then(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.otherwise(menteeUser.nickname) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;v1&amp;nbsp;호환성&amp;nbsp;유지 &lt;br /&gt;문제:&amp;nbsp;기존&amp;nbsp;v1&amp;nbsp;API를&amp;nbsp;바로&amp;nbsp;교체하면&amp;nbsp;롤백이&amp;nbsp;어렵습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;v1&amp;nbsp;엔드포인트는&amp;nbsp;그대로&amp;nbsp;유지하고&amp;nbsp;/api/v2/mentors/me/mentees를&amp;nbsp;신규&amp;nbsp;추가해&amp;nbsp;점진적&amp;nbsp;전환이&amp;nbsp;가능하도록&amp;nbsp;했습니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(v2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결 &lt;br /&gt;문제:&amp;nbsp;v1은&amp;nbsp;멘토십&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;반복문에서&amp;nbsp;멘티&amp;nbsp;정보,&amp;nbsp;소설&amp;nbsp;정보를&amp;nbsp;개별&amp;nbsp;조회하는&amp;nbsp;구조였습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL로&amp;nbsp;mentorship&amp;nbsp;&amp;rarr;&amp;nbsp;menteeUser&amp;nbsp;&amp;rarr;&amp;nbsp;novel을&amp;nbsp;단일&amp;nbsp;LEFT&amp;nbsp;JOIN&amp;nbsp;쿼리로&amp;nbsp;처리했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;탈퇴한&amp;nbsp;멘티나&amp;nbsp;삭제된&amp;nbsp;소설의&amp;nbsp;경우&amp;nbsp;null이&amp;nbsp;응답에&amp;nbsp;들어갈&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;JOIN&amp;nbsp;조건에&amp;nbsp;isDeleted=false를&amp;nbsp;추가하고&amp;nbsp;Expressions.cases()로&amp;nbsp;null&amp;nbsp;fallback을&amp;nbsp;처리했습니다. &lt;br /&gt;java.leftJoin(menteeUser).on(menteeUser.id.eq(mentorship.menteeId) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.and(menteeUser.isDeleted.eq(false))) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;페이지네이션 &lt;br /&gt;문제:&amp;nbsp;접수&amp;nbsp;목록이&amp;nbsp;많아질수록&amp;nbsp;전체&amp;nbsp;조회는&amp;nbsp;성능에&amp;nbsp;부담이&amp;nbsp;됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Pageable을&amp;nbsp;적용해&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;조회하고&amp;nbsp;PageImpl로&amp;nbsp;응답합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/mentorships/v2/me/history&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘토링&amp;nbsp;이력&amp;nbsp;조회&amp;nbsp;(v2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결 &lt;br /&gt;문제:&amp;nbsp;v1은&amp;nbsp;멘토십&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;반복문에서&amp;nbsp;멘토&amp;nbsp;정보를&amp;nbsp;개별&amp;nbsp;조회하는&amp;nbsp;구조였습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL로&amp;nbsp;mentorship&amp;nbsp;&amp;rarr;&amp;nbsp;mentor&amp;nbsp;&amp;rarr;&amp;nbsp;user를&amp;nbsp;단일&amp;nbsp;JOIN&amp;nbsp;쿼리로&amp;nbsp;처리했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;탈퇴한&amp;nbsp;멘토의&amp;nbsp;닉네임이&amp;nbsp;inner&amp;nbsp;join&amp;nbsp;구조에서&amp;nbsp;그대로&amp;nbsp;노출될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;inner&amp;nbsp;join&amp;nbsp;&amp;rarr;&amp;nbsp;leftJoin으로&amp;nbsp;변경하고&amp;nbsp;isDeleted=false&amp;nbsp;조건과&amp;nbsp;null&amp;nbsp;fallback을&amp;nbsp;추가했습니다. &lt;br /&gt;java.leftJoin(user).on(user.id.eq(mentor.userId) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.and(user.isDeleted.eq(false))) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;상태&amp;nbsp;필터링 &lt;br /&gt;문제:&amp;nbsp;전체&amp;nbsp;이력이&amp;nbsp;아닌&amp;nbsp;특정&amp;nbsp;상태(ACCEPTED,&amp;nbsp;COMPLETED&amp;nbsp;등)의&amp;nbsp;이력만&amp;nbsp;조회할&amp;nbsp;수&amp;nbsp;있어야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;BooleanBuilder로&amp;nbsp;status가&amp;nbsp;null이면&amp;nbsp;전체,&amp;nbsp;값이&amp;nbsp;있으면&amp;nbsp;해당&amp;nbsp;상태만&amp;nbsp;필터링합니다. &lt;br /&gt;javaif&amp;nbsp;(status&amp;nbsp;!=&amp;nbsp;null)&amp;nbsp;{ &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;where.and(mentorship.status.eq(status)); &lt;br /&gt;} &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;정보&amp;nbsp;수정 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;부분&amp;nbsp;업데이트&amp;nbsp;(null&amp;nbsp;허용) &lt;br /&gt;문제:&amp;nbsp;수정&amp;nbsp;요청&amp;nbsp;시&amp;nbsp;변경하지&amp;nbsp;않는&amp;nbsp;필드까지&amp;nbsp;전부&amp;nbsp;보내야&amp;nbsp;하면&amp;nbsp;불편합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;MentorUpdateRequest의&amp;nbsp;각&amp;nbsp;필드가&amp;nbsp;null이면&amp;nbsp;기존&amp;nbsp;값을&amp;nbsp;유지하고,&amp;nbsp;값이&amp;nbsp;있을&amp;nbsp;때만&amp;nbsp;업데이트합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;careerHistory&amp;nbsp;빈&amp;nbsp;값&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;careerHistory를&amp;nbsp;빈&amp;nbsp;문자열로&amp;nbsp;보내면&amp;nbsp;의미없는&amp;nbsp;데이터가&amp;nbsp;저장됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;null은&amp;nbsp;기존&amp;nbsp;값&amp;nbsp;유지,&amp;nbsp;빈&amp;nbsp;문자열은&amp;nbsp;MENTOR_CAREER_REQUIRED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;신청 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;등급별&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;로직 &lt;br /&gt;문제:&amp;nbsp;등급마다&amp;nbsp;멘토&amp;nbsp;승인&amp;nbsp;조건이&amp;nbsp;다릅니다.&amp;nbsp;INTRODUCTION은&amp;nbsp;에피소드&amp;nbsp;50개,&amp;nbsp;ELEMENTARY는&amp;nbsp;에피소드&amp;nbsp;50개&amp;nbsp;+&amp;nbsp;좋아요&amp;nbsp;50개,&amp;nbsp;PROFICIENT는&amp;nbsp;항상&amp;nbsp;관리자&amp;nbsp;수동&amp;nbsp;승인입니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;resolveInitialStatus()에서&amp;nbsp;등급별&amp;nbsp;조건을&amp;nbsp;switch로&amp;nbsp;분기해&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;여부를&amp;nbsp;결정합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;동시&amp;nbsp;요청&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;동시에&amp;nbsp;여러&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;중복&amp;nbsp;등록이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;DataIntegrityViolationException&amp;nbsp;발생&amp;nbsp;시&amp;nbsp;MENTOR_ALREADY_APPROVED&amp;nbsp;예외로&amp;nbsp;변환해&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;중복&amp;nbsp;신청&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;PENDING이거나&amp;nbsp;APPROVED&amp;nbsp;상태인&amp;nbsp;경우&amp;nbsp;중복&amp;nbsp;신청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;existsByUserIdAndStatus()로&amp;nbsp;사전&amp;nbsp;체크&amp;nbsp;후&amp;nbsp;이미&amp;nbsp;존재하면&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;스케줄러&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;등급&amp;nbsp;자동&amp;nbsp;조정&amp;nbsp;(매일&amp;nbsp;자정) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;청크&amp;nbsp;단위&amp;nbsp;페이지네이션 &lt;br /&gt;문제:&amp;nbsp;승급&amp;nbsp;대상&amp;nbsp;멘토를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;전부&amp;nbsp;List로&amp;nbsp;가져오면&amp;nbsp;데이터가&amp;nbsp;많을수록&amp;nbsp;메모리&amp;nbsp;부하가&amp;nbsp;커집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;100명씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;나눠서&amp;nbsp;처리합니다. &lt;br /&gt;javaPageRequest.of(pageNumber,&amp;nbsp;CHUNK_SIZE,&amp;nbsp;Sort.by(&quot;id&quot;).ascending()) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;persistence&amp;nbsp;context&amp;nbsp;정리 &lt;br /&gt;문제:&amp;nbsp;@Transactional&amp;nbsp;안에서&amp;nbsp;처리한&amp;nbsp;엔티티가&amp;nbsp;배치&amp;nbsp;종료까지&amp;nbsp;영속성&amp;nbsp;컨텍스트에&amp;nbsp;쌓여&amp;nbsp;메모리가&amp;nbsp;누적됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;청크&amp;nbsp;처리&amp;nbsp;후&amp;nbsp;entityManager.flush()&amp;nbsp;/&amp;nbsp;entityManager.clear()를&amp;nbsp;호출해&amp;nbsp;청크마다&amp;nbsp;메모리를&amp;nbsp;비웁니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;정렬&amp;nbsp;기준&amp;nbsp;고정 &lt;br /&gt;문제:&amp;nbsp;정렬&amp;nbsp;기준이&amp;nbsp;없으면&amp;nbsp;배치&amp;nbsp;실행&amp;nbsp;중&amp;nbsp;다른&amp;nbsp;트랜잭션이&amp;nbsp;데이터를&amp;nbsp;변경할&amp;nbsp;때&amp;nbsp;페이지&amp;nbsp;경계가&amp;nbsp;밀려&amp;nbsp;행&amp;nbsp;누락/중복이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Sort.by(&quot;id&quot;).ascending()으로&amp;nbsp;PK&amp;nbsp;기준&amp;nbsp;정렬을&amp;nbsp;고정합니다. &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[배포] &lt;br /&gt;젠킨스&amp;nbsp;aws &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[수익환전] &lt;br /&gt;계좌&amp;nbsp;등록&amp;nbsp;+&amp;nbsp;1원&amp;nbsp;인증 &lt;br /&gt;POST&amp;nbsp;/api/revenues/me/account/verify&amp;nbsp;&amp;mdash;&amp;nbsp;계좌&amp;nbsp;등록&amp;nbsp;+&amp;nbsp;1원&amp;nbsp;인증&amp;nbsp;코드&amp;nbsp;발송 &lt;br /&gt;POST&amp;nbsp;/api/revenues/me/account/verify/confirm&amp;nbsp;&amp;mdash;&amp;nbsp;인증코드&amp;nbsp;검증 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;AES/GCM&amp;nbsp;계좌번호&amp;nbsp;암호화 &lt;br /&gt;문제:&amp;nbsp;계좌번호를&amp;nbsp;평문으로&amp;nbsp;DB에&amp;nbsp;저장하면&amp;nbsp;DB&amp;nbsp;탈취&amp;nbsp;시&amp;nbsp;금융&amp;nbsp;정보가&amp;nbsp;그대로&amp;nbsp;노출됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;AES/GCM&amp;nbsp;양방향&amp;nbsp;암호화로&amp;nbsp;계좌번호를&amp;nbsp;암호화하여&amp;nbsp;저장하고,&amp;nbsp;응답&amp;nbsp;시에는&amp;nbsp;뒤&amp;nbsp;4자리만&amp;nbsp;노출하는&amp;nbsp;마스킹&amp;nbsp;처리를&amp;nbsp;적용했습니다.&amp;nbsp;복호화는&amp;nbsp;1원&amp;nbsp;인증&amp;nbsp;성공&amp;nbsp;후&amp;nbsp;계좌&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;시에만&amp;nbsp;수행합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;BankVerificationClient&amp;nbsp;인터페이스&amp;nbsp;분리 &lt;br /&gt;문제:&amp;nbsp;실제&amp;nbsp;은행&amp;nbsp;API(useB/CODEF)는&amp;nbsp;금융규제상&amp;nbsp;테스트/개발&amp;nbsp;환경에서&amp;nbsp;연동이&amp;nbsp;불가능합니다.&amp;nbsp;개발&amp;nbsp;중&amp;nbsp;매번&amp;nbsp;실제&amp;nbsp;API를&amp;nbsp;호출하면&amp;nbsp;비용이&amp;nbsp;발생하고&amp;nbsp;환경에&amp;nbsp;따라&amp;nbsp;동작이&amp;nbsp;달라집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;BankVerificationClient&amp;nbsp;인터페이스를&amp;nbsp;분리하고&amp;nbsp;@Profile({&quot;dev&quot;,&amp;nbsp;&quot;local&quot;,&amp;nbsp;&quot;test&quot;})가&amp;nbsp;붙은&amp;nbsp;LocalBankVerificationClient&amp;nbsp;시뮬레이션&amp;nbsp;구현체를&amp;nbsp;별도로&amp;nbsp;만들었습니다.&amp;nbsp;운영&amp;nbsp;환경에서는&amp;nbsp;실제&amp;nbsp;구현체로&amp;nbsp;교체만&amp;nbsp;하면&amp;nbsp;되는&amp;nbsp;구조입니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;인증코드&amp;nbsp;만료/시도&amp;nbsp;횟수&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;인증코드를&amp;nbsp;무제한으로&amp;nbsp;시도하거나&amp;nbsp;만료&amp;nbsp;이후에도&amp;nbsp;사용할&amp;nbsp;수&amp;nbsp;있으면&amp;nbsp;보안상&amp;nbsp;문제가&amp;nbsp;됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;AccountVerification&amp;nbsp;엔티티에&amp;nbsp;expiredAt(5분),&amp;nbsp;attemptCount(최대&amp;nbsp;5회)를&amp;nbsp;관리하여&amp;nbsp;만료&amp;nbsp;시간&amp;nbsp;초과&amp;nbsp;또는&amp;nbsp;시도&amp;nbsp;횟수&amp;nbsp;초과&amp;nbsp;시&amp;nbsp;예외를&amp;nbsp;발생시킵니다. &lt;br /&gt;&lt;br /&gt;수익&amp;nbsp;현황&amp;nbsp;조회 &lt;br /&gt;GET&amp;nbsp;/api/revenues/me&amp;nbsp;&amp;mdash;&amp;nbsp;총&amp;nbsp;누적&amp;nbsp;수익&amp;nbsp;/&amp;nbsp;총&amp;nbsp;환전&amp;nbsp;금액&amp;nbsp;/&amp;nbsp;가용&amp;nbsp;잔액&amp;nbsp;/&amp;nbsp;인증된&amp;nbsp;계좌&amp;nbsp;정보 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;(TTL&amp;nbsp;30분) &lt;br /&gt;문제:&amp;nbsp;수익&amp;nbsp;현황은&amp;nbsp;Revenue&amp;nbsp;테이블&amp;nbsp;전체를&amp;nbsp;집계하는&amp;nbsp;무거운&amp;nbsp;쿼리입니다.&amp;nbsp;작가가&amp;nbsp;대시보드를&amp;nbsp;반복&amp;nbsp;조회할&amp;nbsp;때마다&amp;nbsp;집계&amp;nbsp;쿼리가&amp;nbsp;실행되면&amp;nbsp;DB&amp;nbsp;부하가&amp;nbsp;커집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;첫&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;집계&amp;nbsp;결과를&amp;nbsp;Redis에&amp;nbsp;30분간&amp;nbsp;캐싱합니다.&amp;nbsp;이후&amp;nbsp;동일&amp;nbsp;요청은&amp;nbsp;DB를&amp;nbsp;거치지&amp;nbsp;않고&amp;nbsp;Redis에서&amp;nbsp;바로&amp;nbsp;반환합니다.&amp;nbsp;환전&amp;nbsp;신청&amp;nbsp;또는&amp;nbsp;수익&amp;nbsp;발생&amp;nbsp;시&amp;nbsp;해당&amp;nbsp;캐시를&amp;nbsp;즉시&amp;nbsp;무효화하여&amp;nbsp;데이터&amp;nbsp;정합성을&amp;nbsp;유지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;Revenue&amp;nbsp;타입&amp;nbsp;분리&amp;nbsp;집계 &lt;br /&gt;문제:&amp;nbsp;수익(EPISODE_SALE,&amp;nbsp;SUBSCRIPTION),&amp;nbsp;환전(WITHDRAWAL),&amp;nbsp;환불(REFUND)이&amp;nbsp;하나의&amp;nbsp;Revenue&amp;nbsp;테이블에&amp;nbsp;혼재되어&amp;nbsp;있어&amp;nbsp;가용&amp;nbsp;잔액&amp;nbsp;계산&amp;nbsp;시&amp;nbsp;타입별&amp;nbsp;분리&amp;nbsp;집계가&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;sumAmountByAuthorIdAndTypeIn()으로&amp;nbsp;수익+환불을&amp;nbsp;합산하고&amp;nbsp;sumAmountByAuthorIdAndType()으로&amp;nbsp;환전액을&amp;nbsp;별도&amp;nbsp;집계하여&amp;nbsp;가용&amp;nbsp;잔액&amp;nbsp;=&amp;nbsp;수익합계&amp;nbsp;-&amp;nbsp;환전합계로&amp;nbsp;계산합니다. &lt;br /&gt;&lt;br /&gt;환전&amp;nbsp;신청 &lt;br /&gt;POST&amp;nbsp;/api/revenues/me/exchanges&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;신청 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redis&amp;nbsp;분산락&amp;nbsp;(UUID&amp;nbsp;토큰&amp;nbsp;기반) &lt;br /&gt;문제:&amp;nbsp;동시에&amp;nbsp;여러&amp;nbsp;환전&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;잔액&amp;nbsp;검증을&amp;nbsp;통과한&amp;nbsp;요청이&amp;nbsp;중복으로&amp;nbsp;처리되어&amp;nbsp;잔액보다&amp;nbsp;많은&amp;nbsp;금액이&amp;nbsp;환전될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Redis&amp;nbsp;SETNX로&amp;nbsp;lock:withdrawal:{authorId}&amp;nbsp;키에&amp;nbsp;UUID&amp;nbsp;토큰을&amp;nbsp;5초간&amp;nbsp;점유합니다.&amp;nbsp;락&amp;nbsp;해제&amp;nbsp;시에는&amp;nbsp;get&amp;nbsp;후&amp;nbsp;delete의&amp;nbsp;비원자성&amp;nbsp;문제를&amp;nbsp;방지하기&amp;nbsp;위해&amp;nbsp;Lua&amp;nbsp;Script로&amp;nbsp;비교와&amp;nbsp;삭제를&amp;nbsp;하나의&amp;nbsp;원자적&amp;nbsp;연산으로&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;FeePolicy&amp;nbsp;Enum으로&amp;nbsp;수수료&amp;nbsp;정책&amp;nbsp;분리 &lt;br /&gt;문제:&amp;nbsp;수수료율(3.3%)과&amp;nbsp;최소&amp;nbsp;환전&amp;nbsp;금액(10,000원)을&amp;nbsp;서비스&amp;nbsp;코드에&amp;nbsp;하드코딩하면&amp;nbsp;정책&amp;nbsp;변경&amp;nbsp;시&amp;nbsp;코드&amp;nbsp;전체를&amp;nbsp;수정해야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;FeePolicy&amp;nbsp;Enum에&amp;nbsp;수수료율,&amp;nbsp;최소&amp;nbsp;환전&amp;nbsp;금액,&amp;nbsp;수수료&amp;nbsp;계산,&amp;nbsp;실수령액&amp;nbsp;계산&amp;nbsp;로직을&amp;nbsp;캡슐화했습니다.&amp;nbsp;정책&amp;nbsp;변경&amp;nbsp;시&amp;nbsp;Enum&amp;nbsp;값만&amp;nbsp;수정하면&amp;nbsp;됩니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;Revenue&amp;nbsp;+&amp;nbsp;Withdrawal&amp;nbsp;한&amp;nbsp;트랜잭션&amp;nbsp;처리 &lt;br /&gt;문제:&amp;nbsp;Revenue(WITHDRAWAL)&amp;nbsp;차감&amp;nbsp;기록과&amp;nbsp;Withdrawal&amp;nbsp;생성이&amp;nbsp;별도&amp;nbsp;트랜잭션이면&amp;nbsp;하나만&amp;nbsp;성공하고&amp;nbsp;하나가&amp;nbsp;실패할&amp;nbsp;경우&amp;nbsp;잔액&amp;nbsp;불일치가&amp;nbsp;발생합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;잔액&amp;nbsp;확인&amp;nbsp;&amp;rarr;&amp;nbsp;Revenue(WITHDRAWAL)&amp;nbsp;저장&amp;nbsp;&amp;rarr;&amp;nbsp;Withdrawal&amp;nbsp;저장을&amp;nbsp;하나의&amp;nbsp;@Transactional&amp;nbsp;안에서&amp;nbsp;처리하여&amp;nbsp;원자성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;환전&amp;nbsp;내역&amp;nbsp;조회 &lt;br /&gt;GET&amp;nbsp;/api/revenues/me/exchanges&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;목록&amp;nbsp;(상태/기간&amp;nbsp;필터&amp;nbsp;+&amp;nbsp;페이징) &lt;br /&gt;GET&amp;nbsp;/api/revenues/me/exchanges/{id}&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리 &lt;br /&gt;문제:&amp;nbsp;상태&amp;nbsp;필터,&amp;nbsp;기간&amp;nbsp;필터가&amp;nbsp;선택적으로&amp;nbsp;적용되어야&amp;nbsp;하는데&amp;nbsp;JPQL로&amp;nbsp;작성하면&amp;nbsp;조건&amp;nbsp;조합마다&amp;nbsp;별도&amp;nbsp;쿼리가&amp;nbsp;필요하고&amp;nbsp;null&amp;nbsp;체크&amp;nbsp;분기가&amp;nbsp;복잡해집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL&amp;nbsp;BooleanExpression으로&amp;nbsp;각&amp;nbsp;필터&amp;nbsp;조건을&amp;nbsp;null&amp;nbsp;체크&amp;nbsp;후&amp;nbsp;동적으로&amp;nbsp;조합합니다.&amp;nbsp;또한&amp;nbsp;requestedAt&amp;nbsp;단일&amp;nbsp;정렬&amp;nbsp;시&amp;nbsp;동일&amp;nbsp;시각&amp;nbsp;레코드에서&amp;nbsp;페이지&amp;nbsp;중복/누락이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있어&amp;nbsp;id&amp;nbsp;DESC를&amp;nbsp;tie-breaker로&amp;nbsp;추가했습니다. &lt;br /&gt;&lt;br /&gt;수익&amp;nbsp;분석&amp;nbsp;통계 &lt;br /&gt;GET&amp;nbsp;/api/revenues/me/statistics&amp;nbsp;&amp;mdash;&amp;nbsp;월별/주별&amp;nbsp;수익&amp;nbsp;집계 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;+&amp;nbsp;SCAN&amp;nbsp;기반&amp;nbsp;패턴&amp;nbsp;무효화&amp;nbsp;(TTL&amp;nbsp;1시간) &lt;br /&gt;문제:&amp;nbsp;통계는&amp;nbsp;GROUP&amp;nbsp;BY&amp;nbsp;집계&amp;nbsp;쿼리로&amp;nbsp;부하가&amp;nbsp;크고,&amp;nbsp;월별/주별&amp;nbsp;등&amp;nbsp;기간&amp;nbsp;단위로&amp;nbsp;캐시&amp;nbsp;키가&amp;nbsp;달라집니다.&amp;nbsp;수익&amp;nbsp;발생&amp;nbsp;시&amp;nbsp;해당&amp;nbsp;작가의&amp;nbsp;모든&amp;nbsp;통계&amp;nbsp;캐시를&amp;nbsp;무효화해야&amp;nbsp;하는데&amp;nbsp;키&amp;nbsp;패턴이&amp;nbsp;다양하면&amp;nbsp;일괄&amp;nbsp;삭제가&amp;nbsp;어렵습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;statistics:{authorId}:*&amp;nbsp;패턴으로&amp;nbsp;캐시&amp;nbsp;키를&amp;nbsp;설계하고,&amp;nbsp;무효화&amp;nbsp;시&amp;nbsp;KEYS&amp;nbsp;대신&amp;nbsp;SCAN으로&amp;nbsp;패턴&amp;nbsp;매칭&amp;nbsp;키를&amp;nbsp;순회&amp;nbsp;삭제합니다.&amp;nbsp;KEYS는&amp;nbsp;싱글스레드&amp;nbsp;Redis를&amp;nbsp;블로킹하지만&amp;nbsp;SCAN은&amp;nbsp;커서&amp;nbsp;기반으로&amp;nbsp;점진적으로&amp;nbsp;탐색하여&amp;nbsp;운영&amp;nbsp;환경에서도&amp;nbsp;안전합니다. &lt;br /&gt;&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;승인/거절 &lt;br /&gt;GET&amp;nbsp;/api/admin/exchanges&amp;nbsp;&amp;mdash;&amp;nbsp;전체&amp;nbsp;환전&amp;nbsp;목록&amp;nbsp;조회 &lt;br /&gt;GET&amp;nbsp;/api/admin/exchanges/{id}&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;PUT&amp;nbsp;/api/admin/exchanges/{id}/approve&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;승인 &lt;br /&gt;PUT&amp;nbsp;/api/admin/exchanges/{id}/reject&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;거절 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;WithdrawalStatus&amp;nbsp;상태&amp;nbsp;전이&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;완료(COMPLETED)되거나&amp;nbsp;거절(REJECTED)된&amp;nbsp;환전&amp;nbsp;건을&amp;nbsp;다시&amp;nbsp;승인/거절하면&amp;nbsp;데이터&amp;nbsp;정합성이&amp;nbsp;깨집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;WithdrawalStatus&amp;nbsp;Enum에&amp;nbsp;VALID_TRANSITIONS&amp;nbsp;Map으로&amp;nbsp;허용된&amp;nbsp;전이만&amp;nbsp;정의하고,&amp;nbsp;Withdrawal&amp;nbsp;엔티티의&amp;nbsp;상태&amp;nbsp;변경&amp;nbsp;메서드에서&amp;nbsp;validateTransition()을&amp;nbsp;호출하여&amp;nbsp;허용되지&amp;nbsp;않은&amp;nbsp;전이&amp;nbsp;시&amp;nbsp;IllegalStateException을&amp;nbsp;발생시킵니다.&amp;nbsp;PENDING&amp;nbsp;&amp;rarr;&amp;nbsp;PROCESSING&amp;nbsp;&amp;rarr;&amp;nbsp;COMPLETED,&amp;nbsp;PENDING&amp;nbsp;&amp;rarr;&amp;nbsp;REJECTED만&amp;nbsp;허용됩니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;거절&amp;nbsp;시&amp;nbsp;REFUND&amp;nbsp;타입으로&amp;nbsp;잔액&amp;nbsp;복구 &lt;br /&gt;문제:&amp;nbsp;환전&amp;nbsp;신청&amp;nbsp;시&amp;nbsp;Revenue(WITHDRAWAL)로&amp;nbsp;잔액이&amp;nbsp;차감됩니다.&amp;nbsp;거절&amp;nbsp;시&amp;nbsp;해당&amp;nbsp;WITHDRAWAL&amp;nbsp;레코드를&amp;nbsp;삭제하거나&amp;nbsp;수정하면&amp;nbsp;이력&amp;nbsp;추적이&amp;nbsp;불가능합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;거절&amp;nbsp;시&amp;nbsp;Revenue를&amp;nbsp;삭제하지&amp;nbsp;않고&amp;nbsp;동일&amp;nbsp;금액의&amp;nbsp;Revenue(REFUND)를&amp;nbsp;새로&amp;nbsp;생성합니다.&amp;nbsp;가용&amp;nbsp;잔액&amp;nbsp;계산&amp;nbsp;시&amp;nbsp;typeIn(EPISODE_SALE,&amp;nbsp;SUBSCRIPTION,&amp;nbsp;REFUND)로&amp;nbsp;합산하면&amp;nbsp;자연스럽게&amp;nbsp;복구된&amp;nbsp;금액이&amp;nbsp;반영되고,&amp;nbsp;환전/거절&amp;nbsp;이력이&amp;nbsp;모두&amp;nbsp;Revenue&amp;nbsp;테이블에&amp;nbsp;남습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;@Secured&amp;nbsp;+&amp;nbsp;역할&amp;nbsp;분리 &lt;br /&gt;문제:&amp;nbsp;일반&amp;nbsp;작가나&amp;nbsp;독자가&amp;nbsp;관리자&amp;nbsp;API에&amp;nbsp;접근하면&amp;nbsp;안&amp;nbsp;됩니다. &lt;br /&gt;해결:&amp;nbsp;@Secured({&quot;ADMIN&quot;,&amp;nbsp;&quot;SUPER_ADMIN&quot;})을&amp;nbsp;컨트롤러&amp;nbsp;클래스&amp;nbsp;레벨에&amp;nbsp;적용하여&amp;nbsp;ADMIN과&amp;nbsp;SUPER_ADMIN&amp;nbsp;역할만&amp;nbsp;접근&amp;nbsp;가능하도록&amp;nbsp;제한했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;RedisTTL&amp;nbsp;효과,&amp;nbsp;키&amp;nbsp;설계,&amp;nbsp;Redis&amp;nbsp;기반&amp;nbsp;락,&amp;nbsp;상태전이,&amp;nbsp;민감한&amp;nbsp;금융정보에&amp;nbsp;대한&amp;nbsp;핸들링&lt;/span&gt; &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[이벤트] &lt;br /&gt;1.&amp;nbsp;POST&amp;nbsp;/api/admin/events&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;페이지&amp;nbsp;단위&amp;nbsp;배치&amp;nbsp;조회&amp;nbsp;+&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;비동기&amp;nbsp;알림&amp;nbsp;발송 &lt;br /&gt;문제:&amp;nbsp;이벤트가&amp;nbsp;생성되면&amp;nbsp;전체&amp;nbsp;독자(READER)에게&amp;nbsp;알림을&amp;nbsp;발송해야&amp;nbsp;한다. &lt;br /&gt;단순하게&amp;nbsp;userRepository.findAllByRole(READER)로&amp;nbsp;전체&amp;nbsp;유저를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;조회하면 &lt;br /&gt;유저&amp;nbsp;수가&amp;nbsp;수만&amp;nbsp;명이&amp;nbsp;될&amp;nbsp;경우&amp;nbsp;메모리에&amp;nbsp;전부&amp;nbsp;올라가&amp;nbsp;OOM이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있고, &lt;br /&gt;이벤트&amp;nbsp;생성&amp;nbsp;트랜잭션&amp;nbsp;자체가&amp;nbsp;지연되는&amp;nbsp;문제가&amp;nbsp;생긴다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;전체&amp;nbsp;READER를&amp;nbsp;1000건씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;배치&amp;nbsp;조회하여&amp;nbsp;순차적으로&amp;nbsp;이벤트를&amp;nbsp;발행했다. &lt;br /&gt;알림&amp;nbsp;발송은&amp;nbsp;ApplicationEventPublisher를&amp;nbsp;통해&amp;nbsp;이벤트를&amp;nbsp;등록하고, &lt;br /&gt;@TransactionalEventListener(AFTER_COMMIT)으로&amp;nbsp;트랜잭션&amp;nbsp;커밋&amp;nbsp;이후에&amp;nbsp;Kafka에&amp;nbsp;발행되는&amp;nbsp;구조를&amp;nbsp;활용했다. &lt;br /&gt;이로&amp;nbsp;인해&amp;nbsp;알림&amp;nbsp;발송&amp;nbsp;실패가&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;트랜잭션에&amp;nbsp;영향을&amp;nbsp;주지&amp;nbsp;않고,&amp;nbsp;READER에게만&amp;nbsp;선택적으로&amp;nbsp;발송된다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;Redis&amp;nbsp;캐시&amp;nbsp;Evict &lt;br /&gt;문제:&amp;nbsp;사용자&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회에&amp;nbsp;Redis&amp;nbsp;캐싱이&amp;nbsp;적용되어&amp;nbsp;있기&amp;nbsp;때문에 &lt;br /&gt;새&amp;nbsp;이벤트가&amp;nbsp;생성되어도&amp;nbsp;캐시가&amp;nbsp;살아있으면&amp;nbsp;사용자에게&amp;nbsp;새&amp;nbsp;이벤트가&amp;nbsp;노출되지&amp;nbsp;않는다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;사용자용&amp;nbsp;목록&amp;nbsp;캐시&amp;nbsp;전체를&amp;nbsp;즉시&amp;nbsp;evict한다. &lt;br /&gt;이후&amp;nbsp;사용자가&amp;nbsp;목록을&amp;nbsp;조회하면&amp;nbsp;캐시&amp;nbsp;miss가&amp;nbsp;발생해&amp;nbsp;DB에서&amp;nbsp;최신&amp;nbsp;데이터를&amp;nbsp;읽어온&amp;nbsp;뒤&amp;nbsp;다시&amp;nbsp;캐싱된다. &lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;GET&amp;nbsp;/api/admin/events&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;캐싱&amp;nbsp;미적용&amp;nbsp;-&amp;nbsp;실시간&amp;nbsp;DB&amp;nbsp;직접&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;관리자는&amp;nbsp;이벤트의&amp;nbsp;정확한&amp;nbsp;현재&amp;nbsp;상태(참여자&amp;nbsp;수,&amp;nbsp;진행&amp;nbsp;여부&amp;nbsp;등)를&amp;nbsp;실시간으로&amp;nbsp;파악해야&amp;nbsp;한다. &lt;br /&gt;캐싱을&amp;nbsp;적용하면&amp;nbsp;최신&amp;nbsp;상태가&amp;nbsp;반영되지&amp;nbsp;않아&amp;nbsp;관리&amp;nbsp;판단에&amp;nbsp;오류가&amp;nbsp;생길&amp;nbsp;수&amp;nbsp;있다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;관리자&amp;nbsp;목록&amp;nbsp;조회는&amp;nbsp;캐싱을&amp;nbsp;전혀&amp;nbsp;적용하지&amp;nbsp;않고&amp;nbsp;항상&amp;nbsp;DB를&amp;nbsp;직접&amp;nbsp;조회한다. &lt;br /&gt;관리자는&amp;nbsp;소수&amp;nbsp;인원이라&amp;nbsp;요청&amp;nbsp;빈도가&amp;nbsp;낮으므로&amp;nbsp;DB&amp;nbsp;부하&amp;nbsp;문제가&amp;nbsp;없다. &lt;br /&gt;사용자&amp;nbsp;API와&amp;nbsp;관리자&amp;nbsp;API를&amp;nbsp;별도&amp;nbsp;Service로&amp;nbsp;분리해&amp;nbsp;캐싱&amp;nbsp;전략을&amp;nbsp;독립적으로&amp;nbsp;관리했다. &lt;br /&gt;&lt;br /&gt;3.&amp;nbsp;GET&amp;nbsp;/api/admin/events/{eventId}&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;캐싱&amp;nbsp;미적용&amp;nbsp;-&amp;nbsp;실시간&amp;nbsp;DB&amp;nbsp;직접&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;관리자&amp;nbsp;상세&amp;nbsp;조회는&amp;nbsp;참여자&amp;nbsp;현황,&amp;nbsp;마감&amp;nbsp;여부&amp;nbsp;등&amp;nbsp;실시간&amp;nbsp;정보가&amp;nbsp;중요하다. &lt;br /&gt;&lt;br /&gt;캐싱된&amp;nbsp;데이터를&amp;nbsp;보여주면&amp;nbsp;관리&amp;nbsp;목적으로&amp;nbsp;사용할&amp;nbsp;수&amp;nbsp;없다. &lt;br /&gt;해결:&amp;nbsp;캐싱&amp;nbsp;없이&amp;nbsp;항상&amp;nbsp;DB를&amp;nbsp;직접&amp;nbsp;조회한다. &lt;br /&gt;사용자용&amp;nbsp;상세&amp;nbsp;조회와&amp;nbsp;달리&amp;nbsp;관리자용은&amp;nbsp;별도&amp;nbsp;Service&amp;nbsp;메서드로&amp;nbsp;분리해&amp;nbsp;캐싱&amp;nbsp;로직이&amp;nbsp;섞이지&amp;nbsp;않도록&amp;nbsp;했다. &lt;br /&gt;&lt;br /&gt;4.&amp;nbsp;GET&amp;nbsp;/api/admin/events/{eventId}/participants&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트별&amp;nbsp;참여자&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;연관관계&amp;nbsp;미사용&amp;nbsp;+&amp;nbsp;FK&amp;nbsp;컬럼&amp;nbsp;직접&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;프로젝트&amp;nbsp;컨벤션상&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계(@ManyToOne&amp;nbsp;등)를&amp;nbsp;사용하지&amp;nbsp;않고&amp;nbsp;FK를&amp;nbsp;Long&amp;nbsp;컬럼으로만&amp;nbsp;관리한다. &lt;br /&gt;EventParticipant에서&amp;nbsp;Event나&amp;nbsp;User로의&amp;nbsp;Join이&amp;nbsp;필요한&amp;nbsp;경우&amp;nbsp;연관관계&amp;nbsp;없이&amp;nbsp;처리해야&amp;nbsp;한다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;EventParticipant&amp;nbsp;테이블에&amp;nbsp;event_id,&amp;nbsp;user_id를&amp;nbsp;Long&amp;nbsp;컬럼으로&amp;nbsp;보유하고 &lt;br /&gt;Repository에서&amp;nbsp;findAllByEventId()로&amp;nbsp;페이징&amp;nbsp;조회한다. &lt;br /&gt;추가&amp;nbsp;정보가&amp;nbsp;필요한&amp;nbsp;경우&amp;nbsp;QueryDSL&amp;nbsp;Join으로&amp;nbsp;처리할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;구조로&amp;nbsp;설계했다. &lt;br /&gt;&lt;br /&gt;5.&amp;nbsp;GET&amp;nbsp;/api/events&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(사용자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;RedisTemplate&amp;nbsp;캐싱&amp;nbsp;-&amp;nbsp;TTL&amp;nbsp;5분 &lt;br /&gt;문제:&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회는&amp;nbsp;다수의&amp;nbsp;사용자가&amp;nbsp;반복적으로&amp;nbsp;요청하는&amp;nbsp;API다. &lt;br /&gt;매&amp;nbsp;요청마다&amp;nbsp;DB를&amp;nbsp;조회하면&amp;nbsp;이벤트&amp;nbsp;오픈&amp;nbsp;직후처럼&amp;nbsp;트래픽이&amp;nbsp;몰리는&amp;nbsp;상황에서&amp;nbsp;DB&amp;nbsp;부하가&amp;nbsp;심해진다. &lt;br /&gt;이벤트&amp;nbsp;메타&amp;nbsp;정보(제목,&amp;nbsp;기간,&amp;nbsp;포인트&amp;nbsp;등)는&amp;nbsp;자주&amp;nbsp;변경되지&amp;nbsp;않으므로&amp;nbsp;캐싱이&amp;nbsp;적합하다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;RedisTemplate을&amp;nbsp;사용해&amp;nbsp;status,&amp;nbsp;페이지&amp;nbsp;번호,&amp;nbsp;페이지&amp;nbsp;크기,&amp;nbsp;정렬&amp;nbsp;조건을&amp;nbsp;조합한&amp;nbsp;키로&amp;nbsp;캐싱한다. &lt;br /&gt;TTL은&amp;nbsp;5분으로&amp;nbsp;설정해&amp;nbsp;짧은&amp;nbsp;주기로&amp;nbsp;갱신되게&amp;nbsp;하고,&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;캐시를&amp;nbsp;evict해&amp;nbsp;정합성을&amp;nbsp;유지한다. &lt;br /&gt;캐시&amp;nbsp;역직렬화&amp;nbsp;실패&amp;nbsp;시&amp;nbsp;자동으로&amp;nbsp;DB&amp;nbsp;조회로&amp;nbsp;전환하는&amp;nbsp;장애&amp;nbsp;대응&amp;nbsp;로직도&amp;nbsp;포함했다. &lt;br /&gt;&lt;br /&gt;6.&amp;nbsp;GET&amp;nbsp;/api/events/{eventId}&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(사용자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;이벤트&amp;nbsp;상태별&amp;nbsp;차등&amp;nbsp;캐싱&amp;nbsp;전략 &lt;br /&gt;문제:&amp;nbsp;진행&amp;nbsp;중인&amp;nbsp;이벤트의&amp;nbsp;상세&amp;nbsp;조회는&amp;nbsp;참여&amp;nbsp;가능&amp;nbsp;여부와&amp;nbsp;현재&amp;nbsp;상태가&amp;nbsp;실시간으로&amp;nbsp;중요하다. &lt;br /&gt;반면&amp;nbsp;종료된&amp;nbsp;이벤트는&amp;nbsp;결과가&amp;nbsp;확정되어&amp;nbsp;더&amp;nbsp;이상&amp;nbsp;변경되지&amp;nbsp;않는데도&amp;nbsp;매&amp;nbsp;요청마다&amp;nbsp;DB를&amp;nbsp;조회하면 &lt;br /&gt;이벤트&amp;nbsp;종료&amp;nbsp;직후&amp;nbsp;결과&amp;nbsp;확인&amp;nbsp;트래픽이&amp;nbsp;몰릴&amp;nbsp;때&amp;nbsp;DB에&amp;nbsp;불필요한&amp;nbsp;부하가&amp;nbsp;생긴다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;진행&amp;nbsp;중인&amp;nbsp;이벤트는&amp;nbsp;캐싱&amp;nbsp;없이&amp;nbsp;DB를&amp;nbsp;직접&amp;nbsp;조회해&amp;nbsp;실시간&amp;nbsp;정확성을&amp;nbsp;보장한다. &lt;br /&gt;종료된&amp;nbsp;이벤트는&amp;nbsp;첫&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;Redis에&amp;nbsp;TTL&amp;nbsp;7일로&amp;nbsp;캐싱한다. &lt;br /&gt;이후&amp;nbsp;동일&amp;nbsp;이벤트&amp;nbsp;조회는&amp;nbsp;전부&amp;nbsp;Redis에서&amp;nbsp;응답해&amp;nbsp;DB&amp;nbsp;부하&amp;nbsp;없이&amp;nbsp;처리된다. &lt;br /&gt;&lt;br /&gt;7.&amp;nbsp;POST&amp;nbsp;/api/events/{eventId}/participants&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;참여&amp;nbsp;신청&amp;nbsp;(사용자) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redisson&amp;nbsp;분산락&amp;nbsp;-&amp;nbsp;선착순&amp;nbsp;동시성&amp;nbsp;제어 &lt;br /&gt;문제:&amp;nbsp;이벤트&amp;nbsp;참여&amp;nbsp;신청은&amp;nbsp;짧은&amp;nbsp;시간에&amp;nbsp;수백&amp;nbsp;건의&amp;nbsp;요청이&amp;nbsp;동시에&amp;nbsp;몰린다. &lt;br /&gt;동시성&amp;nbsp;제어&amp;nbsp;없이&amp;nbsp;처리하면&amp;nbsp;maxParticipants를&amp;nbsp;초과해&amp;nbsp;참여자가&amp;nbsp;저장되거나 &lt;br /&gt;같은&amp;nbsp;유저가&amp;nbsp;중복&amp;nbsp;참여하는&amp;nbsp;레이스&amp;nbsp;컨디션이&amp;nbsp;발생한다. &lt;br /&gt;DB&amp;nbsp;비관적&amp;nbsp;락은&amp;nbsp;트래픽&amp;nbsp;집중&amp;nbsp;시&amp;nbsp;DB&amp;nbsp;커넥션을&amp;nbsp;오래&amp;nbsp;점유해&amp;nbsp;병목이&amp;nbsp;생기므로&amp;nbsp;부적합하다. &lt;br /&gt;이미&amp;nbsp;Redis&amp;nbsp;인프라가&amp;nbsp;구성되어&amp;nbsp;있으므로&amp;nbsp;Redisson&amp;nbsp;분산락을&amp;nbsp;선택했다. &lt;br /&gt;TTL(leaseTime)&amp;nbsp;기반으로&amp;nbsp;서버&amp;nbsp;장애&amp;nbsp;시에도&amp;nbsp;데드락이&amp;nbsp;발생하지&amp;nbsp;않고, &lt;br /&gt;멀티&amp;nbsp;인스턴스&amp;nbsp;환경에서도&amp;nbsp;단일&amp;nbsp;락으로&amp;nbsp;동시성을&amp;nbsp;제어할&amp;nbsp;수&amp;nbsp;있다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;이벤트&amp;nbsp;ID&amp;nbsp;단위로&amp;nbsp;Redisson&amp;nbsp;분산락을&amp;nbsp;걸어&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;하나의&amp;nbsp;요청만&amp;nbsp;처리한다. &lt;br /&gt;waitTime&amp;nbsp;5초(최대&amp;nbsp;대기&amp;nbsp;시간),&amp;nbsp;leaseTime&amp;nbsp;3초(자동&amp;nbsp;해제&amp;nbsp;시간)로&amp;nbsp;설정했다. &lt;br /&gt;락&amp;nbsp;획득&amp;nbsp;실패&amp;nbsp;시&amp;nbsp;TOO_MANY_REQUESTS(429)를&amp;nbsp;반환해&amp;nbsp;사용자에게&amp;nbsp;재시도를&amp;nbsp;안내한다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;DB&amp;nbsp;UniqueConstraint&amp;nbsp;-&amp;nbsp;이중&amp;nbsp;동시성&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;Redisson&amp;nbsp;락의&amp;nbsp;leaseTime이&amp;nbsp;초과되는&amp;nbsp;극한&amp;nbsp;상황에서 &lt;br /&gt;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;락을&amp;nbsp;획득해&amp;nbsp;중복&amp;nbsp;참여가&amp;nbsp;저장될&amp;nbsp;가능성이&amp;nbsp;극히&amp;nbsp;낮지만&amp;nbsp;존재한다. &lt;br /&gt;애플리케이션&amp;nbsp;레벨의&amp;nbsp;중복&amp;nbsp;체크만으로는&amp;nbsp;완전한&amp;nbsp;방어가&amp;nbsp;불가능하다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;EventParticipant&amp;nbsp;테이블에&amp;nbsp;(event_id,&amp;nbsp;user_id)&amp;nbsp;복합&amp;nbsp;UniqueConstraint를&amp;nbsp;적용했다. &lt;br /&gt;Redisson&amp;nbsp;락이&amp;nbsp;1차&amp;nbsp;방어,&amp;nbsp;DB&amp;nbsp;제약조건이&amp;nbsp;2차&amp;nbsp;방어로&amp;nbsp;동작하는&amp;nbsp;이중&amp;nbsp;구조로&amp;nbsp;완전한&amp;nbsp;중복&amp;nbsp;참여를&amp;nbsp;차단한다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;포인트&amp;nbsp;즉시&amp;nbsp;지급&amp;nbsp;-&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;서비스&amp;nbsp;직접&amp;nbsp;호출 &lt;br /&gt;문제:&amp;nbsp;선착순&amp;nbsp;인원&amp;nbsp;안에&amp;nbsp;든&amp;nbsp;사용자에게는&amp;nbsp;참여&amp;nbsp;신청&amp;nbsp;즉시&amp;nbsp;포인트가&amp;nbsp;지급되어야&amp;nbsp;한다. &lt;br /&gt;지급이&amp;nbsp;지연되면&amp;nbsp;사용자&amp;nbsp;경험이&amp;nbsp;나빠지고&amp;nbsp;지급&amp;nbsp;누락&amp;nbsp;이슈가&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;참여자&amp;nbsp;저장&amp;nbsp;직후&amp;nbsp;같은&amp;nbsp;트랜잭션&amp;nbsp;내에서&amp;nbsp;PointService.chargeEventReward()를&amp;nbsp;호출한다. &lt;br /&gt;PointHistory에&amp;nbsp;타입을&amp;nbsp;EVENT로&amp;nbsp;기록해&amp;nbsp;이벤트&amp;nbsp;보상&amp;nbsp;이력을&amp;nbsp;명확히&amp;nbsp;남긴다. &lt;br /&gt;같은&amp;nbsp;트랜잭션이므로&amp;nbsp;참여자&amp;nbsp;저장과&amp;nbsp;포인트&amp;nbsp;지급이&amp;nbsp;원자적으로&amp;nbsp;처리되어 &lt;br /&gt;참여는&amp;nbsp;됐는데&amp;nbsp;포인트가&amp;nbsp;안&amp;nbsp;지급되는&amp;nbsp;불일치&amp;nbsp;상황이&amp;nbsp;발생하지&amp;nbsp;않는다. &lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;Redis 캐싱, 분산락, 유니크제약, kafka를 활용한 이벤트 발행, --&amp;gt; 메모리 고민 대량 사용자 알림 발송시&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;포폴을위해&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;1.조회시 성능 지표 가지기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;2.동시성이나 데이터 정합성 -&amp;gt;동시성 데이터 정합성 테스트에 관한 수치 자료&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;3.외부 연동 잘 챙겨가기&lt;/span&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;4.카프카를 통해 대량 알림 전송 관련 -&amp;gt; 왜 카프카로 넘어오게된 스토리가 필요하고 관련 수치 자료가 필요하다&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;-&amp;gt;카프카 실패시에 대한 재시도 고민, 알림중복발송 을 위한 방지를 위한 고민&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;5.부하테스트 해보기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;6.인프라 쪽으로 더해보던지&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;7.카프카쪽으로 고도화 해보던지&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;8.대용량 쿠폰 발행 고민해보기-&amp;gt;굳이 여기까지 갈필요는 없을것같다&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;포인트 -&amp;gt; 증거를 확보하고, 인덱스 설계를 보강하고, 동시성이나 정합성 증거남기기,캐시자체도 내가 어떻게 설계했는데 정리해보기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;증거 잘 정리하면 바로 지원서 넣어도 될정도&lt;/span&gt;&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/212</guid>
      <comments>https://minwoo95.tistory.com/212#entry212comment</comments>
      <pubDate>Fri, 1 May 2026 14:10:29 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 12</title>
      <link>https://minwoo95.tistory.com/211</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777546968662&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bDfBaL/dJMb9eTSXxn/6dl00KbIKkctPIvlnu9Ir1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/GdakC/dJMb9hC4Bil/jpzVKi2KKf4bwtxRmlBO11/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bDfBaL/dJMb9eTSXxn/6dl00KbIKkctPIvlnu9Ir1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/GdakC/dJMb9hC4Bil/jpzVKi2KKf4bwtxRmlBO11/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777546975011&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1.&amp;nbsp;오늘&amp;nbsp;한&amp;nbsp;일&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;NovelCraft&amp;nbsp;프로젝트에서&amp;nbsp;이벤트&amp;nbsp;도메인&amp;nbsp;전체를&amp;nbsp;설계부터&amp;nbsp;구현까지&amp;nbsp;완성했다.&lt;br /&gt;관리자와&amp;nbsp;사용자&amp;nbsp;권한을&amp;nbsp;명확히&amp;nbsp;분리하고,&amp;nbsp;선착순&amp;nbsp;동시성&amp;nbsp;제어와&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;전략을&amp;nbsp;적용했으며,&lt;br /&gt;포인트&amp;nbsp;즉시&amp;nbsp;지급&amp;nbsp;및&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;전체&amp;nbsp;회원&amp;nbsp;알림&amp;nbsp;발송까지&amp;nbsp;연동했다.&lt;br /&gt;마지막으로&amp;nbsp;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;반영&amp;nbsp;후&amp;nbsp;컨트롤러/서비스&amp;nbsp;테스트&amp;nbsp;코드를&amp;nbsp;작성해&amp;nbsp;커버리지&amp;nbsp;100%를&amp;nbsp;달성했다.&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구현&amp;nbsp;API&amp;nbsp;목록&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/admin/events&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;(관리자)&lt;br /&gt;GET&amp;nbsp;/api/admin/events&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자,&amp;nbsp;실시간)&lt;br /&gt;GET&amp;nbsp;/api/admin/events/{eventId}&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(관리자)&lt;br /&gt;GET&amp;nbsp;/api/admin/events/{eventId}/participants&amp;nbsp;-&amp;nbsp;이벤트별&amp;nbsp;참여자&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자)&lt;br /&gt;GET&amp;nbsp;/api/events&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(사용자,&amp;nbsp;Redis&amp;nbsp;캐싱)&lt;br /&gt;GET&amp;nbsp;/api/events/{eventId}&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(사용자,&amp;nbsp;종료&amp;nbsp;이벤트&amp;nbsp;캐싱)&lt;br /&gt;POST&amp;nbsp;/api/events/{eventId}/participants&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;참여&amp;nbsp;신청&amp;nbsp;(사용자,&amp;nbsp;선착순&amp;nbsp;+&amp;nbsp;포인트&amp;nbsp;지급)&lt;br /&gt;&lt;br /&gt;&lt;b&gt;추가&amp;nbsp;작업&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;AdminEventController&amp;nbsp;/&amp;nbsp;UserEventController&amp;nbsp;권한별&amp;nbsp;분리&lt;br /&gt;AdminEventService&amp;nbsp;/&amp;nbsp;UserEventService&amp;nbsp;역할별&amp;nbsp;분리&lt;br /&gt;EventExceptionEnum&amp;nbsp;구현&amp;nbsp;(ErrorCode&amp;nbsp;인터페이스&amp;nbsp;구현)&lt;br /&gt;EventParticipant&amp;nbsp;테이블&amp;nbsp;(event_id,&amp;nbsp;user_id)&amp;nbsp;UniqueConstraint&amp;nbsp;적용&lt;br /&gt;SecurityConfig&amp;nbsp;/api/admin/**&amp;nbsp;SUPER_ADMIN,&amp;nbsp;ADMIN&amp;nbsp;권한&amp;nbsp;체크&amp;nbsp;추가&lt;br /&gt;RedisConfig&amp;nbsp;JavaTimeModule&amp;nbsp;적용&amp;nbsp;(LocalDateTime&amp;nbsp;직렬화&amp;nbsp;지원)&lt;br /&gt;PointService&amp;nbsp;chargeEventReward()&amp;nbsp;메서드&amp;nbsp;추가&lt;br /&gt;NotificationType&amp;nbsp;EVENT_CREATED&amp;nbsp;추가&lt;br /&gt;NotificationEvent&amp;nbsp;eventCreated()&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;메서드&amp;nbsp;추가&lt;br /&gt;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;7건&amp;nbsp;반영&lt;br /&gt;컨트롤러/서비스&amp;nbsp;테스트&amp;nbsp;코드&amp;nbsp;4개&amp;nbsp;파일&amp;nbsp;48개&amp;nbsp;케이스&amp;nbsp;작성&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2.&amp;nbsp;기술&amp;nbsp;선정&amp;nbsp;이유&lt;/b&gt;&lt;br /&gt;&lt;b&gt;2-1.&amp;nbsp;Redisson&amp;nbsp;분산락&amp;nbsp;-&amp;nbsp;선착순&amp;nbsp;동시성&amp;nbsp;제어&lt;/b&gt;&lt;br /&gt;이벤트&amp;nbsp;참여&amp;nbsp;신청은&amp;nbsp;짧은&amp;nbsp;시간에&amp;nbsp;트래픽이&amp;nbsp;폭발적으로&amp;nbsp;몰리는&amp;nbsp;구조라&amp;nbsp;동시성&amp;nbsp;제어가&amp;nbsp;핵심이었다.&lt;br /&gt;세&amp;nbsp;가지&amp;nbsp;방식을&amp;nbsp;비교했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;DB&amp;nbsp;비관적&amp;nbsp;락&lt;/b&gt;은&amp;nbsp;트래픽이&amp;nbsp;집중될&amp;nbsp;때&amp;nbsp;DB&amp;nbsp;커넥션을&amp;nbsp;오래&amp;nbsp;점유하고&amp;nbsp;병목이&amp;nbsp;생긴다.&lt;br /&gt;선착순&amp;nbsp;이벤트처럼&amp;nbsp;순간적으로&amp;nbsp;수백&amp;nbsp;건이&amp;nbsp;몰리는&amp;nbsp;상황에서는&amp;nbsp;적합하지&amp;nbsp;않다고&amp;nbsp;판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;Lua&amp;nbsp;스크립트는&lt;/b&gt;&amp;nbsp;Redis에서&amp;nbsp;원자적으로&amp;nbsp;처리가&amp;nbsp;가능하지만,&lt;br /&gt;카운터&amp;nbsp;관리&amp;nbsp;로직이&amp;nbsp;복잡해지고&amp;nbsp;디버깅이&amp;nbsp;어렵다는&amp;nbsp;단점이&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;Redisson&amp;nbsp;분산락&lt;/b&gt;은&amp;nbsp;이미&amp;nbsp;프로젝트에&amp;nbsp;Redis&amp;nbsp;인프라가&amp;nbsp;구성되어&amp;nbsp;있어&amp;nbsp;추가&amp;nbsp;비용이&amp;nbsp;없고,&lt;br /&gt;TTL&amp;nbsp;기반으로&amp;nbsp;데드락을&amp;nbsp;방지하며&amp;nbsp;멀티&amp;nbsp;인스턴스&amp;nbsp;환경에서도&amp;nbsp;동작한다.&lt;br /&gt;구현도&amp;nbsp;직관적이어서&amp;nbsp;가장&amp;nbsp;적합하다고&amp;nbsp;판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;RLock lock = redissonClient.getLock(&quot;lock:event:participate:&quot; + eventId);&lt;br /&gt;boolean&amp;nbsp;acquired&amp;nbsp;=&amp;nbsp;lock.tryLock(5,&amp;nbsp;3,&amp;nbsp;TimeUnit.SECONDS);&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;waitTime&amp;nbsp;5초는&amp;nbsp;대기&amp;nbsp;허용&amp;nbsp;시간이고,&amp;nbsp;leaseTime&amp;nbsp;3초는&amp;nbsp;락&amp;nbsp;자동&amp;nbsp;해제&amp;nbsp;시간이다.&lt;br /&gt;서버&amp;nbsp;장애로&amp;nbsp;락&amp;nbsp;해제가&amp;nbsp;안&amp;nbsp;되는&amp;nbsp;상황을&amp;nbsp;TTL이&amp;nbsp;자동으로&amp;nbsp;막아준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;추가로&amp;nbsp;EventParticipant&amp;nbsp;테이블에&amp;nbsp;(event_id,&amp;nbsp;user_id)&amp;nbsp;UniqueConstraint를&amp;nbsp;걸어&lt;br /&gt;Redisson 락이 leaseTime 초과로 뚫리는 극한 케이스에서도 DB 레벨에서 최종 방어하는 이중&amp;nbsp;동시성&amp;nbsp;제어&amp;nbsp;구조로&amp;nbsp;설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;1차&amp;nbsp;방어&amp;nbsp;-&amp;nbsp;Redisson&amp;nbsp;분산락&amp;nbsp;:&amp;nbsp;순차&amp;nbsp;처리,&amp;nbsp;선착순&amp;nbsp;인원&amp;nbsp;제어&lt;br /&gt;2차&amp;nbsp;방어&amp;nbsp;-&amp;nbsp;DB&amp;nbsp;UniqueConstraint&amp;nbsp;:&amp;nbsp;중복&amp;nbsp;참여&amp;nbsp;최종&amp;nbsp;방어&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;2-2.&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;전략&amp;nbsp;-&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;부하&amp;nbsp;분산&lt;/b&gt;&lt;br /&gt;이벤트&amp;nbsp;조회&amp;nbsp;API는&amp;nbsp;참여&amp;nbsp;신청과&amp;nbsp;달리&amp;nbsp;실시간&amp;nbsp;정확성이&amp;nbsp;덜&amp;nbsp;중요한&amp;nbsp;반면&amp;nbsp;요청이&amp;nbsp;매우&amp;nbsp;많을&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;캐싱&amp;nbsp;대상과&amp;nbsp;TTL을&amp;nbsp;역할에&amp;nbsp;따라&amp;nbsp;명확히&amp;nbsp;나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;사용자 이벤트 목록 조회 - TTL 5분 &lt;/b&gt;제목,&amp;nbsp;기간&amp;nbsp;등&amp;nbsp;메타&amp;nbsp;정보는&amp;nbsp;자주&amp;nbsp;바뀌지&amp;nbsp;않으므로&amp;nbsp;단기&amp;nbsp;캐싱을&amp;nbsp;적용했다.&lt;br /&gt;관리자가&amp;nbsp;이벤트를&amp;nbsp;생성하면&amp;nbsp;캐시를&amp;nbsp;즉시&amp;nbsp;evict해&amp;nbsp;정합성을&amp;nbsp;유지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;종료된 이벤트 상세 조회 - TTL 7일 &lt;/b&gt;이벤트가&amp;nbsp;종료되면&amp;nbsp;결과가&amp;nbsp;확정되어&amp;nbsp;더&amp;nbsp;이상&amp;nbsp;변경이&amp;nbsp;없다.&lt;br /&gt;종료&amp;nbsp;시점에&amp;nbsp;Redis에&amp;nbsp;캐싱하면&amp;nbsp;이후&amp;nbsp;결과&amp;nbsp;조회&amp;nbsp;트래픽이&amp;nbsp;몰려도&amp;nbsp;DB에&amp;nbsp;부하가&amp;nbsp;없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;진행 중 이벤트 / 참여 API - 캐싱 없음 &lt;/b&gt;실시간&amp;nbsp;참여&amp;nbsp;현황과&amp;nbsp;선착순&amp;nbsp;결과는&amp;nbsp;정확성이&amp;nbsp;중요하므로&amp;nbsp;캐싱을&amp;nbsp;적용하지&amp;nbsp;않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는&amp;nbsp;사용자&amp;nbsp;결과&amp;nbsp;조회도&amp;nbsp;캐싱&amp;nbsp;없이&amp;nbsp;가면&amp;nbsp;부하가&amp;nbsp;생기지&amp;nbsp;않냐는&amp;nbsp;이슈가&amp;nbsp;있었는데,&lt;br /&gt;이벤트&amp;nbsp;종료&amp;nbsp;후에는&amp;nbsp;결과가&amp;nbsp;고정되므로&amp;nbsp;종료&amp;nbsp;시점에&amp;nbsp;캐싱하면&amp;nbsp;된다는&amp;nbsp;방향으로&amp;nbsp;정리됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;2-3.&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;알림&amp;nbsp;발송&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;독자&amp;nbsp;전체&amp;nbsp;공지&lt;/b&gt;&lt;br /&gt;알림&amp;nbsp;팀원이&amp;nbsp;이미&amp;nbsp;ApplicationEventPublisher&amp;nbsp;+&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;알림&amp;nbsp;인프라를&amp;nbsp;구축해놓았다.&lt;br /&gt;@TransactionalEventListener(phase&amp;nbsp;=&amp;nbsp;AFTER_COMMIT)로&amp;nbsp;트랜잭션&amp;nbsp;커밋&amp;nbsp;이후에&lt;br /&gt;Kafka에&amp;nbsp;메시지를&amp;nbsp;발행하는&amp;nbsp;구조라&amp;nbsp;알림&amp;nbsp;발송&amp;nbsp;실패가&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;트랜잭션에&amp;nbsp;영향을&amp;nbsp;주지&amp;nbsp;않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이&amp;nbsp;인프라를&amp;nbsp;그대로&amp;nbsp;활용해&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;READER&amp;nbsp;역할을&amp;nbsp;가진&amp;nbsp;사용자에게만&amp;nbsp;알림을&amp;nbsp;발송했다.&lt;br /&gt;전체&amp;nbsp;유저를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;메모리에&amp;nbsp;올리면&amp;nbsp;OOM&amp;nbsp;위험이&amp;nbsp;있어&amp;nbsp;1000건씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;배치&amp;nbsp;조회하는&amp;nbsp;방식을&amp;nbsp;적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;javaint&amp;nbsp;page&amp;nbsp;=&amp;nbsp;0;&lt;br /&gt;int&amp;nbsp;batchSize&amp;nbsp;=&amp;nbsp;1000;&lt;br /&gt;while&amp;nbsp;(true)&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;PageRequest&amp;nbsp;pageRequest&amp;nbsp;=&amp;nbsp;PageRequest.of(page,&amp;nbsp;batchSize);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;User&amp;gt;&amp;nbsp;readers&amp;nbsp;=&amp;nbsp;userRepository.findAllByRole(UserRole.READER,&amp;nbsp;pageRequest).getContent();&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;(readers.isEmpty())&amp;nbsp;break;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;readers.forEach(user&amp;nbsp;-&amp;gt;&amp;nbsp;eventPublisher.publishEvent(&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;NotificationEvent.eventCreated(user.getId(),&amp;nbsp;saved.getTitle(),&amp;nbsp;saved.getId())&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;));&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;page++;&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3.&amp;nbsp;트러블슈팅&lt;/b&gt;&lt;br /&gt;&lt;b&gt;3-1.&amp;nbsp;LocalDateTime&amp;nbsp;Redis&amp;nbsp;직렬화&amp;nbsp;실패&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;사용자&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;API를&amp;nbsp;호출하자&amp;nbsp;500&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;에러&amp;nbsp;메시지는&amp;nbsp;다음과&amp;nbsp;같았다.&lt;br /&gt;SerializationException:&amp;nbsp;Could&amp;nbsp;not&amp;nbsp;write&amp;nbsp;JSON:&amp;nbsp;Java&amp;nbsp;8&amp;nbsp;date/time&amp;nbsp;type&lt;br /&gt;`java.time.LocalDateTime`&amp;nbsp;not&amp;nbsp;supported&amp;nbsp;by&amp;nbsp;default&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;RedisConfig를&amp;nbsp;확인하니&amp;nbsp;JavaTimeModule이&amp;nbsp;적용된&amp;nbsp;ObjectMapper로&amp;nbsp;serializer를&amp;nbsp;만들었는데&lt;br /&gt;실제&amp;nbsp;setValueSerializer()에는&amp;nbsp;기본&amp;nbsp;생성자로&amp;nbsp;새로&amp;nbsp;만든&amp;nbsp;serializer를&amp;nbsp;넣고&amp;nbsp;있었다.&lt;br /&gt;JavaTimeModule이&amp;nbsp;적용된&amp;nbsp;serializer&amp;nbsp;변수가&amp;nbsp;완전히&amp;nbsp;무시되고&amp;nbsp;있었던&amp;nbsp;것이다.&lt;br /&gt;java//&amp;nbsp;문제&amp;nbsp;코드&amp;nbsp;-&amp;nbsp;serializer&amp;nbsp;변수가&amp;nbsp;있지만&amp;nbsp;새&amp;nbsp;객체를&amp;nbsp;넣음&lt;br /&gt;GenericJackson2JsonRedisSerializer&amp;nbsp;serializer&amp;nbsp;=&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new&amp;nbsp;GenericJackson2JsonRedisSerializer(objectMapper);&amp;nbsp;//&amp;nbsp;이&amp;nbsp;변수가&amp;nbsp;무시됨&lt;br /&gt;&lt;br /&gt;template.setValueSerializer(new&amp;nbsp;GenericJackson2JsonRedisSerializer());&amp;nbsp;//&amp;nbsp;기본&amp;nbsp;생성자&lt;br /&gt;template.setHashValueSerializer(new&amp;nbsp;GenericJackson2JsonRedisSerializer());&amp;nbsp;//&amp;nbsp;기본&amp;nbsp;생성자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;팀원들과&amp;nbsp;협의&amp;nbsp;후&amp;nbsp;RedisConfig에서&amp;nbsp;두&amp;nbsp;줄을&amp;nbsp;수정했다.&lt;br /&gt;기본&amp;nbsp;생성자&amp;nbsp;대신&amp;nbsp;JavaTimeModule이&amp;nbsp;적용된&amp;nbsp;serializer&amp;nbsp;변수를&amp;nbsp;그대로&amp;nbsp;사용하도록&amp;nbsp;변경했다.&lt;br /&gt;기존&amp;nbsp;도메인에는&amp;nbsp;LocalDateTime을&amp;nbsp;Redis에&amp;nbsp;캐싱하는&amp;nbsp;곳이&amp;nbsp;없어&amp;nbsp;사이드이펙트가&amp;nbsp;없었다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;후&lt;br /&gt;template.setValueSerializer(serializer);&lt;br /&gt;template.setHashValueSerializer(serializer);&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-2.&amp;nbsp;SecurityConfig&amp;nbsp;권한&amp;nbsp;설정&amp;nbsp;-&amp;nbsp;SUPER_ADMIN&amp;nbsp;vs&amp;nbsp;ADMIN&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;이벤트&amp;nbsp;생성&amp;nbsp;API를&amp;nbsp;Postman으로&amp;nbsp;테스트하니&amp;nbsp;403&amp;nbsp;Forbidden이&amp;nbsp;떨어졌다.&lt;br /&gt;DB를&amp;nbsp;확인하니&amp;nbsp;테스트&amp;nbsp;계정의&amp;nbsp;role이&amp;nbsp;SUPER_ADMIN이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;SecurityConfig에&amp;nbsp;/api/admin/**&amp;nbsp;에&amp;nbsp;대해&amp;nbsp;hasAuthority(&quot;SUPER_ADMIN&quot;)으로&amp;nbsp;설정했는데,&lt;br /&gt;이게&amp;nbsp;SUPER_ADMIN&amp;nbsp;단독으로만&amp;nbsp;열려있는&amp;nbsp;상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;실무상&amp;nbsp;SUPER_ADMIN&amp;nbsp;외에도&amp;nbsp;담당&amp;nbsp;ADMIN도&amp;nbsp;이벤트&amp;nbsp;관리가&amp;nbsp;가능해야&amp;nbsp;한다는&amp;nbsp;판단으로&lt;br /&gt;hasAnyAuthority(&quot;ADMIN&quot;,&amp;nbsp;&quot;SUPER_ADMIN&quot;)으로&amp;nbsp;변경했다.&lt;br /&gt;CodeRabbit이&amp;nbsp;권한&amp;nbsp;범위가&amp;nbsp;넓다고&amp;nbsp;지적했지만&amp;nbsp;기획&amp;nbsp;의도에&amp;nbsp;맞는&amp;nbsp;설정이라&amp;nbsp;의견을&amp;nbsp;답변으로&amp;nbsp;달고&amp;nbsp;유지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3.&amp;nbsp;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;반영&amp;nbsp;-&amp;nbsp;7건&lt;/b&gt;&lt;br /&gt;&lt;b&gt;3-3-1.&amp;nbsp;evictEventListCache()&amp;nbsp;중복&amp;nbsp;호출&amp;nbsp;(Minor)&lt;/b&gt;&lt;br /&gt;AdminEventService의&amp;nbsp;createEvent()에서&amp;nbsp;동일한&amp;nbsp;evictEventListCache()를&amp;nbsp;두&amp;nbsp;번&amp;nbsp;호출하고&amp;nbsp;있었다.&lt;br /&gt;불필요한 Redis 스캔/삭제가 2배 발생하는 문제였다. 한&amp;nbsp;번만&amp;nbsp;호출하도록&amp;nbsp;수정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-2.&amp;nbsp;캐시&amp;nbsp;키에&amp;nbsp;size/sort&amp;nbsp;누락&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;UserEventService의&amp;nbsp;getEventList()에서&amp;nbsp;캐시&amp;nbsp;키를&amp;nbsp;status:pageNumber로만&amp;nbsp;구성했다.&lt;br /&gt;같은&amp;nbsp;페이지&amp;nbsp;번호라도&amp;nbsp;size나&amp;nbsp;sort가&amp;nbsp;다른&amp;nbsp;요청이&amp;nbsp;동일&amp;nbsp;캐시를&amp;nbsp;재사용하는&amp;nbsp;문제가&amp;nbsp;있었다.&lt;br /&gt;캐시&amp;nbsp;키에&amp;nbsp;pageSize와&amp;nbsp;sort를&amp;nbsp;추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&lt;br /&gt;String&amp;nbsp;cacheKey&amp;nbsp;=&amp;nbsp;EVENT_LIST_CACHE_KEY&amp;nbsp;+&amp;nbsp;status&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getPageNumber();&lt;br /&gt;&lt;br /&gt;//&amp;nbsp;수정&amp;nbsp;후&lt;br /&gt;String&amp;nbsp;cacheKey&amp;nbsp;=&amp;nbsp;EVENT_LIST_CACHE_KEY&amp;nbsp;+&amp;nbsp;status&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getPageNumber()&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getPageSize()&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getSort();&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-3.&amp;nbsp;UPCOMING&amp;nbsp;default&amp;nbsp;처리&amp;nbsp;불일치&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;EventStatus의&amp;nbsp;switch문에서&amp;nbsp;UPCOMING이&amp;nbsp;default로&amp;nbsp;처리되어&amp;nbsp;findAll()이&amp;nbsp;호출됐다.&lt;br /&gt;status=UPCOMING&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;진행&amp;nbsp;중/종료&amp;nbsp;이벤트가&amp;nbsp;섞여서&amp;nbsp;반환되는&amp;nbsp;버그였다.&lt;br /&gt;EventRepository에&amp;nbsp;findAllUpcoming()&amp;nbsp;쿼리를&amp;nbsp;추가하고&amp;nbsp;switch&amp;nbsp;case를&amp;nbsp;명시적으로&amp;nbsp;분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;// 수정 후&lt;br /&gt;Page&amp;lt;Event&amp;gt;&amp;nbsp;events&amp;nbsp;=&amp;nbsp;switch&amp;nbsp;(status)&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;case&amp;nbsp;UPCOMING&amp;nbsp;-&amp;gt;&amp;nbsp;eventRepository.findAllUpcoming(now,&amp;nbsp;pageable);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;case&amp;nbsp;ONGOING&amp;nbsp;&amp;nbsp;-&amp;gt;&amp;nbsp;eventRepository.findAllOngoing(now,&amp;nbsp;pageable);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;case&amp;nbsp;ENDED&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt;&amp;nbsp;eventRepository.findAllEnded(now,&amp;nbsp;pageable);&lt;br /&gt;};&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-4.&amp;nbsp;전체&amp;nbsp;READER&amp;nbsp;일괄&amp;nbsp;로드&amp;nbsp;OOM&amp;nbsp;위험&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;userRepository.findAllByRole(READER)로&amp;nbsp;전체&amp;nbsp;독자를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;메모리에&amp;nbsp;올리는&amp;nbsp;방식이었다.&lt;br /&gt;독자&amp;nbsp;수가&amp;nbsp;늘어날수록&amp;nbsp;생성&amp;nbsp;API&amp;nbsp;지연과&amp;nbsp;메모리&amp;nbsp;압박이&amp;nbsp;커지는&amp;nbsp;구조였다.&lt;br /&gt;1000건씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;배치&amp;nbsp;조회하도록&amp;nbsp;변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-5.&amp;nbsp;EventCreateRequest&amp;nbsp;기간&amp;nbsp;검증&amp;nbsp;없음&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;DTO에서&amp;nbsp;startedAt과&amp;nbsp;endedAt의&amp;nbsp;선후&amp;nbsp;관계&amp;nbsp;검증이&amp;nbsp;없어&lt;br /&gt;startedAt&amp;nbsp;&amp;gt;&amp;nbsp;endedAt인&amp;nbsp;비정상&amp;nbsp;이벤트가&amp;nbsp;생성될&amp;nbsp;수&amp;nbsp;있었다.&lt;br /&gt;@AssertTrue로&amp;nbsp;DTO&amp;nbsp;레벨에서&amp;nbsp;검증을&amp;nbsp;추가했다.&lt;br /&gt;서비스&amp;nbsp;레벨의&amp;nbsp;중복&amp;nbsp;검증은&amp;nbsp;제거하고&amp;nbsp;EventExceptionEnum.EVENT_INVALID_PERIOD는&amp;nbsp;향후를&amp;nbsp;위해&amp;nbsp;남겨뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;@AssertTrue(message = &quot;이벤트 시작일은 종료일보다 이전이어야 합니다&quot;)&lt;br /&gt;public&amp;nbsp;boolean&amp;nbsp;isValidPeriod()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;(startedAt&amp;nbsp;==&amp;nbsp;null&amp;nbsp;||&amp;nbsp;endedAt&amp;nbsp;==&amp;nbsp;null)&amp;nbsp;return&amp;nbsp;true;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;startedAt.isBefore(endedAt);&lt;br /&gt;}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-6.&amp;nbsp;maxParticipants&amp;nbsp;null&amp;nbsp;허용&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;@Min만&amp;nbsp;있고&amp;nbsp;@NotNull이&amp;nbsp;없어&amp;nbsp;null&amp;nbsp;값이&amp;nbsp;통과될&amp;nbsp;수&amp;nbsp;있었다.&lt;br /&gt;포인트&amp;nbsp;지급&amp;nbsp;이벤트&amp;nbsp;특성상&amp;nbsp;참여&amp;nbsp;인원&amp;nbsp;제한&amp;nbsp;없이&amp;nbsp;운영하면&amp;nbsp;예산&amp;nbsp;통제가&amp;nbsp;불가능하다.&lt;br /&gt;@NotNull을&amp;nbsp;추가해&amp;nbsp;필수값으로&amp;nbsp;변경하고,&amp;nbsp;서비스의&amp;nbsp;null&amp;nbsp;체크&amp;nbsp;분기도&amp;nbsp;제거했다.&lt;br /&gt;DB의&amp;nbsp;max_participants&amp;nbsp;컬럼도&amp;nbsp;NOT&amp;nbsp;NULL로&amp;nbsp;변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-4.&amp;nbsp;테스트&amp;nbsp;코드&amp;nbsp;-&amp;nbsp;JPA&amp;nbsp;엔티티&amp;nbsp;mock()&amp;nbsp;사용&amp;nbsp;불가&amp;nbsp;문제&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;UserEventServiceTest에서&amp;nbsp;mock(Event.class)으로&amp;nbsp;Event&amp;nbsp;객체를&amp;nbsp;만들고&lt;br /&gt;given(event.getId()).willReturn(EVENT_ID)로&amp;nbsp;stubbing을&amp;nbsp;하니&lt;br /&gt;UnfinishedStubbingException과&amp;nbsp;UnnecessaryStubbingException이&amp;nbsp;동시에&amp;nbsp;터졌다.&lt;br /&gt;UnfinishedStubbingException:&lt;br /&gt;-&amp;gt;&amp;nbsp;at&amp;nbsp;UserEventServiceTest.mockOngoingEvent(UserEventServiceTest.java:59)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;JPA&amp;nbsp;엔티티에&amp;nbsp;@Getter,&amp;nbsp;@NoArgsConstructor&amp;nbsp;등&amp;nbsp;Lombok&amp;nbsp;어노테이션이&amp;nbsp;붙어있으면&lt;br /&gt;Mockito가&amp;nbsp;final&amp;nbsp;메서드로&amp;nbsp;인식하는&amp;nbsp;케이스가&amp;nbsp;있다.&lt;br /&gt;특히&amp;nbsp;getId()&amp;nbsp;같은&amp;nbsp;메서드는&amp;nbsp;내부적으로&amp;nbsp;stubbing이&amp;nbsp;완료되지&amp;nbsp;않은&amp;nbsp;상태에서&lt;br /&gt;다른&amp;nbsp;메서드&amp;nbsp;호출이&amp;nbsp;중간에&amp;nbsp;끼어들어&amp;nbsp;UnfinishedStubbingException이&amp;nbsp;발생했다.&lt;br /&gt;또한&amp;nbsp;각&amp;nbsp;테스트에서&amp;nbsp;사용하지&amp;nbsp;않는&amp;nbsp;stubbing이&amp;nbsp;포함되어&amp;nbsp;UnnecessaryStubbingException도&amp;nbsp;함께&amp;nbsp;발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;mock(Event.class)&amp;nbsp;대신&amp;nbsp;실제&amp;nbsp;Event.create()&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;메서드로&amp;nbsp;실제&amp;nbsp;객체를&amp;nbsp;생성하도록&amp;nbsp;변경했다.&lt;br /&gt;엔티티는&amp;nbsp;mock()을&amp;nbsp;피하고&amp;nbsp;실제&amp;nbsp;객체를&amp;nbsp;만드는&amp;nbsp;것이&amp;nbsp;올바른&amp;nbsp;테스트&amp;nbsp;패턴이다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&amp;nbsp;-&amp;nbsp;mock()&amp;nbsp;사용&amp;nbsp;(문제&amp;nbsp;발생)&lt;br /&gt;private&amp;nbsp;Event&amp;nbsp;mockOngoingEvent()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Event&amp;nbsp;event&amp;nbsp;=&amp;nbsp;mock(Event.class);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(event.getId()).willReturn(EVENT_ID);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(event.isOngoing()).willReturn(true);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;...&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;//&amp;nbsp;수정&amp;nbsp;후&amp;nbsp;-&amp;nbsp;실제&amp;nbsp;객체&amp;nbsp;사용&lt;br /&gt;private&amp;nbsp;Event&amp;nbsp;ongoingEvent()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Event.create(1L,&amp;nbsp;&quot;테스트&amp;nbsp;이벤트&quot;,&amp;nbsp;&quot;설명&quot;,&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;5000L,&amp;nbsp;100L,&amp;nbsp;NOW.minusDays(1),&amp;nbsp;FUTURE);&lt;br /&gt;}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;RLock은&amp;nbsp;인터페이스라&amp;nbsp;@Mock&amp;nbsp;필드&amp;nbsp;선언이&amp;nbsp;불가능했다.&lt;br /&gt;setupLock()&amp;nbsp;헬퍼&amp;nbsp;메서드&amp;nbsp;내부에서&amp;nbsp;직접&amp;nbsp;mock(RLock.class)로&amp;nbsp;생성하는&amp;nbsp;방식으로&amp;nbsp;해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;javaprivate&amp;nbsp;RLock&amp;nbsp;setupLock(boolean&amp;nbsp;acquired)&amp;nbsp;throws&amp;nbsp;InterruptedException&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RLock&amp;nbsp;mockLock&amp;nbsp;=&amp;nbsp;mock(RLock.class);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(redissonClient.getLock(anyString())).willReturn(mockLock);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(mockLock.tryLock(anyLong(),&amp;nbsp;anyLong(),&amp;nbsp;any(TimeUnit.class))).willReturn(acquired);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lenient().when(mockLock.isHeldByCurrentThread()).thenReturn(acquired);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;mockLock;&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4.&amp;nbsp;느낀&amp;nbsp;점&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;단순히&amp;nbsp;CRUD를&amp;nbsp;넘어&amp;nbsp;실제&amp;nbsp;서비스에서&amp;nbsp;고민해야&amp;nbsp;할&amp;nbsp;기술적인&amp;nbsp;문제들을&amp;nbsp;직접&amp;nbsp;설계하고&amp;nbsp;해결한&amp;nbsp;하루였다.&lt;br /&gt;선착순&amp;nbsp;이벤트&amp;nbsp;참여라는&amp;nbsp;요구사항&amp;nbsp;하나에서&amp;nbsp;Redisson&amp;nbsp;분산락,&amp;nbsp;DB&amp;nbsp;UniqueConstraint&amp;nbsp;이중&amp;nbsp;방어,&lt;br /&gt;Redis&amp;nbsp;캐싱&amp;nbsp;전략,&amp;nbsp;페이지&amp;nbsp;단위&amp;nbsp;배치&amp;nbsp;알림&amp;nbsp;발송까지&amp;nbsp;여러&amp;nbsp;기술이&amp;nbsp;자연스럽게&amp;nbsp;연결됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;기술은&amp;nbsp;문제를&amp;nbsp;해결하기&amp;nbsp;위해&amp;nbsp;선택하는&amp;nbsp;것이고,&amp;nbsp;각&amp;nbsp;선택에는&amp;nbsp;명확한&amp;nbsp;이유가&amp;nbsp;있어야&amp;nbsp;한다는&amp;nbsp;것을&amp;nbsp;다시&amp;nbsp;한번&amp;nbsp;체감했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;CodeRabbit&amp;nbsp;리뷰에서&amp;nbsp;캐시&amp;nbsp;키에&amp;nbsp;size/sort가&amp;nbsp;빠진&amp;nbsp;것과&amp;nbsp;UPCOMING&amp;nbsp;default&amp;nbsp;처리&amp;nbsp;불일치&amp;nbsp;버그를&amp;nbsp;잡아줬다.&lt;br /&gt;혼자&amp;nbsp;개발하면&amp;nbsp;놓치기&amp;nbsp;쉬운&amp;nbsp;부분인데&amp;nbsp;자동화된&amp;nbsp;리뷰가&amp;nbsp;실질적인&amp;nbsp;버그를&amp;nbsp;찾아준다는&amp;nbsp;점이&amp;nbsp;인상적이었다.&lt;br /&gt;특히&amp;nbsp;전체&amp;nbsp;READER&amp;nbsp;일괄&amp;nbsp;로드&amp;nbsp;OOM&amp;nbsp;위험&amp;nbsp;지적은&amp;nbsp;지금은&amp;nbsp;유저&amp;nbsp;수가&amp;nbsp;적어&amp;nbsp;문제가&amp;nbsp;없지만&lt;br /&gt;서비스가&amp;nbsp;커지면&amp;nbsp;치명적인&amp;nbsp;문제가&amp;nbsp;될&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;부분이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;코드를&amp;nbsp;작성할&amp;nbsp;때&amp;nbsp;현재&amp;nbsp;상황뿐&amp;nbsp;아니라&amp;nbsp;스케일이&amp;nbsp;커졌을&amp;nbsp;때를&amp;nbsp;항상&amp;nbsp;염두에&amp;nbsp;두어야&amp;nbsp;한다는&amp;nbsp;것을&amp;nbsp;배웠다.&lt;br /&gt;테스트&amp;nbsp;코드에서&amp;nbsp;JPA&amp;nbsp;엔티티를&amp;nbsp;mock()으로&amp;nbsp;쓰면&amp;nbsp;안&amp;nbsp;된다는&amp;nbsp;것도&amp;nbsp;직접&amp;nbsp;겪으면서&amp;nbsp;알게&amp;nbsp;됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이론으로는&amp;nbsp;&quot;엔티티는&amp;nbsp;실제&amp;nbsp;객체를&amp;nbsp;써야&amp;nbsp;한다&quot;는&amp;nbsp;걸&amp;nbsp;알고&amp;nbsp;있었지만,&lt;br /&gt;UnfinishedStubbingException이&amp;nbsp;왜&amp;nbsp;발생하는지&amp;nbsp;원인을&amp;nbsp;파악하고&amp;nbsp;해결하는&amp;nbsp;과정에서&amp;nbsp;더&amp;nbsp;깊이&amp;nbsp;이해하게&amp;nbsp;됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;테스트&amp;nbsp;코드도&amp;nbsp;설계가&amp;nbsp;필요하고,&amp;nbsp;올바른&amp;nbsp;패턴을&amp;nbsp;지켜야&amp;nbsp;유지보수가&amp;nbsp;편하다는&amp;nbsp;것을&amp;nbsp;다시&amp;nbsp;느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;오후에&amp;nbsp;알림&amp;nbsp;팀원이&amp;nbsp;Kafka&amp;nbsp;인프라를&amp;nbsp;완성하면&amp;nbsp;eventCreated()&amp;nbsp;연동만&amp;nbsp;하면&amp;nbsp;전체&amp;nbsp;흐름이&amp;nbsp;완성된다.&lt;br /&gt;오늘&amp;nbsp;설계한&amp;nbsp;구조가&amp;nbsp;팀원의&amp;nbsp;코드와&amp;nbsp;깔끔하게&amp;nbsp;붙을&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;인터페이스를&amp;nbsp;잘&amp;nbsp;맞춰둔&amp;nbsp;것&amp;nbsp;같아&amp;nbsp;뿌듯했다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/211</guid>
      <comments>https://minwoo95.tistory.com/211#entry211comment</comments>
      <pubDate>Thu, 30 Apr 2026 20:11:01 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 11</title>
      <link>https://minwoo95.tistory.com/210</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777467518809&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777467526823&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ohIO6/dJMb81fVQUQ/3ZcfsH8y2zk8kJB5spzfaK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/e4nKd/dJMb87f9Ist/OkS1mvJm7jlTzWEFZxMcsk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ohIO6/dJMb81fVQUQ/3ZcfsH8y2zk8kJB5spzfaK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/e4nKd/dJMb87f9Ist/OkS1mvJm7jlTzWEFZxMcsk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1.&amp;nbsp;오늘&amp;nbsp;한&amp;nbsp;일&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;NovelCraft&amp;nbsp;프로젝트에서&amp;nbsp;수익&amp;nbsp;환전&amp;nbsp;도메인의&amp;nbsp;관리자&amp;nbsp;기능을&amp;nbsp;추가&amp;nbsp;구현하고,&amp;nbsp;컨트롤러/서비스&amp;nbsp;테스트&amp;nbsp;코드를&amp;nbsp;전면&amp;nbsp;재작성했다.&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;-&amp;nbsp;GET&amp;nbsp;/api/admin/exchanges&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;-&amp;nbsp;GET&amp;nbsp;/api/admin/exchanges/{id}&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;승인&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;-&amp;nbsp;PUT&amp;nbsp;/api/admin/exchanges/{id}/approve&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;거절&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;-&amp;nbsp;PUT&amp;nbsp;/api/admin/exchanges/{id}/reject&lt;br /&gt;AdminWithdrawalController,&amp;nbsp;AdminWithdrawalService를&amp;nbsp;domain.admin&amp;nbsp;패키지에&amp;nbsp;추가&lt;br /&gt;WithdrawalRepositoryCustom에&amp;nbsp;관리자용&amp;nbsp;findAllWithFilters()&amp;nbsp;추가&amp;nbsp;(authorId&amp;nbsp;없이&amp;nbsp;전체&amp;nbsp;조회)&lt;br /&gt;컨트롤러&amp;nbsp;테스트&amp;nbsp;3개,&amp;nbsp;서비스&amp;nbsp;테스트&amp;nbsp;3개,&amp;nbsp;엔티티&amp;nbsp;로직&amp;nbsp;테스트&amp;nbsp;1개&amp;nbsp;전면&amp;nbsp;재작성&lt;br /&gt;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;3건&amp;nbsp;반영&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2.&amp;nbsp;트러블슈팅&lt;/b&gt;&lt;br /&gt;&lt;b&gt;2-1.&amp;nbsp;@WebMvcTest에서&amp;nbsp;Security&amp;nbsp;빈&amp;nbsp;누락으로&amp;nbsp;전체&amp;nbsp;컨텍스트&amp;nbsp;로딩&amp;nbsp;실패&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;@WebMvcTest로&amp;nbsp;컨트롤러&amp;nbsp;테스트를&amp;nbsp;실행하자&amp;nbsp;애플리케이션&amp;nbsp;컨텍스트&amp;nbsp;자체가&amp;nbsp;뜨지&amp;nbsp;않았다.&amp;nbsp;에러&amp;nbsp;메시지는&amp;nbsp;BeanCreationException으로,&amp;nbsp;JwtFilter&amp;nbsp;&amp;rarr;&amp;nbsp;JwtUtil&amp;nbsp;&amp;rarr;&amp;nbsp;RedisUtil&amp;nbsp;&amp;rarr;&amp;nbsp;UserDetailsService&amp;nbsp;&amp;rarr;&amp;nbsp;UserCacheService&amp;nbsp;순으로&amp;nbsp;빈을&amp;nbsp;찾지&amp;nbsp;못한다는&amp;nbsp;내용이었다.&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;@WebMvcTest는&amp;nbsp;컨트롤러&amp;nbsp;레이어만&amp;nbsp;로드하지만&amp;nbsp;SecurityConfig가&amp;nbsp;함께&amp;nbsp;로드되면서&amp;nbsp;JwtFilter가&amp;nbsp;등록된다.&amp;nbsp;JwtFilter는&amp;nbsp;JwtUtil을&amp;nbsp;주입받고,&amp;nbsp;JwtUtil은&amp;nbsp;RedisUtil을&amp;nbsp;주입받는&amp;nbsp;의존성&amp;nbsp;체인이&amp;nbsp;형성된다.&amp;nbsp;@WebMvcTest는&amp;nbsp;이&amp;nbsp;빈들을&amp;nbsp;자동으로&amp;nbsp;등록하지&amp;nbsp;않으므로&amp;nbsp;컨텍스트&amp;nbsp;로딩&amp;nbsp;자체가&amp;nbsp;실패한&amp;nbsp;것이다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;Security&amp;nbsp;관련&amp;nbsp;빈&amp;nbsp;5개를&amp;nbsp;@MockBean으로&amp;nbsp;등록하고,&amp;nbsp;@AutoConfigureMockMvc(addFilters&amp;nbsp;=&amp;nbsp;false)로&amp;nbsp;실제&amp;nbsp;필터&amp;nbsp;체인을&amp;nbsp;비활성화했다.&amp;nbsp;인증은&amp;nbsp;SecurityContextHolder에&amp;nbsp;직접&amp;nbsp;인증&amp;nbsp;객체를&amp;nbsp;세팅하는&amp;nbsp;방식으로&amp;nbsp;처리했다.&lt;br /&gt;java@WebMvcTest(BankAccountController.class)&lt;br /&gt;@AutoConfigureMockMvc(addFilters&amp;nbsp;=&amp;nbsp;false)&lt;br /&gt;class&amp;nbsp;BankAccountControllerTest&amp;nbsp;{&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@MockBean&amp;nbsp;JwtUtil&amp;nbsp;jwtUtil;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@MockBean&amp;nbsp;RedisUtil&amp;nbsp;redisUtil;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@MockBean&amp;nbsp;UserDetailsService&amp;nbsp;userDetailsService;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@MockBean&amp;nbsp;UserCacheService&amp;nbsp;userCacheService;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@MockBean&amp;nbsp;JpaMetamodelMappingContext&amp;nbsp;jpaMappingContext;&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@BeforeEach&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;void&amp;nbsp;setUp()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;User&amp;nbsp;mockUser&amp;nbsp;=&amp;nbsp;mock(User.class);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(mockUser.getId()).willReturn(1L);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;UserDetailsImpl&amp;nbsp;userDetails&amp;nbsp;=&amp;nbsp;mock(UserDetailsImpl.class);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(userDetails.getUser()).willReturn(mockUser);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SecurityContextHolder.getContext().setAuthentication(&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new&amp;nbsp;UsernamePasswordAuthenticationToken(userDetails,&amp;nbsp;null,&amp;nbsp;List.of())&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;br /&gt;}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;2-2.&amp;nbsp;BankAccountServiceTest&amp;nbsp;-&amp;nbsp;save()&amp;nbsp;반환값&amp;nbsp;미사용으로&amp;nbsp;id가&amp;nbsp;null&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;BankAccountServiceTest의&amp;nbsp;계좌&amp;nbsp;등록&amp;nbsp;성공&amp;nbsp;케이스에서&amp;nbsp;res.expiredAt()&amp;nbsp;검증&amp;nbsp;시&amp;nbsp;Expecting&amp;nbsp;actual&amp;nbsp;not&amp;nbsp;to&amp;nbsp;be&amp;nbsp;null&amp;nbsp;에러가&amp;nbsp;발생했다.&amp;nbsp;expiredAt이&amp;nbsp;null이&amp;nbsp;되는&amp;nbsp;이유를&amp;nbsp;찾아야&amp;nbsp;했다.&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;서비스&amp;nbsp;코드를&amp;nbsp;보니&amp;nbsp;bankAccountRepository.save(bankAccount)&amp;nbsp;후&amp;nbsp;반환값을&amp;nbsp;변수에&amp;nbsp;담지&amp;nbsp;않고&amp;nbsp;원본&amp;nbsp;bankAccount를&amp;nbsp;계속&amp;nbsp;사용하고&amp;nbsp;있었다.&amp;nbsp;Mock&amp;nbsp;환경에서는&amp;nbsp;JPA처럼&amp;nbsp;save()&amp;nbsp;후&amp;nbsp;같은&amp;nbsp;객체에&amp;nbsp;id가&amp;nbsp;채워지지&amp;nbsp;않는다.&amp;nbsp;결국&amp;nbsp;bankAccount.getId()가&amp;nbsp;null인&amp;nbsp;채로&amp;nbsp;AccountVerification.create(null,&amp;nbsp;code)가&amp;nbsp;호출되고,&amp;nbsp;BankAccountVerifyResponse.of()에서&amp;nbsp;bankAccountId가&amp;nbsp;null로&amp;nbsp;세팅된&amp;nbsp;것이다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;서비스&amp;nbsp;코드에서&amp;nbsp;save()&amp;nbsp;반환값을&amp;nbsp;savedAccount로&amp;nbsp;받아&amp;nbsp;이후&amp;nbsp;모든&amp;nbsp;곳에서&amp;nbsp;savedAccount를&amp;nbsp;사용하도록&amp;nbsp;수정했다.&amp;nbsp;Mock에서도&amp;nbsp;반환값을&amp;nbsp;사용하는&amp;nbsp;것이&amp;nbsp;올바른&amp;nbsp;패턴이다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&lt;br /&gt;bankAccountRepository.save(bankAccount);&lt;br /&gt;&lt;br /&gt;//&amp;nbsp;수정&amp;nbsp;후&lt;br /&gt;BankAccount&amp;nbsp;savedAccount&amp;nbsp;=&amp;nbsp;bankAccountRepository.save(bankAccount);&lt;br /&gt;AccountVerification&amp;nbsp;verification&amp;nbsp;=&amp;nbsp;AccountVerification.create(savedAccount.getId(),&amp;nbsp;verificationCode);&lt;br /&gt;String&amp;nbsp;maskedNumber&amp;nbsp;=&amp;nbsp;savedAccount.getMaskedAccountNumber(request.accountNumber());&lt;br /&gt;return&amp;nbsp;BankAccountVerifyResponse.of(savedAccount,&amp;nbsp;maskedNumber,&amp;nbsp;verification.getExpiredAt());&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-3.&amp;nbsp;단일&amp;nbsp;테스트&amp;nbsp;실패로&amp;nbsp;전체&amp;nbsp;테스트가&amp;nbsp;실행되지&amp;nbsp;않아&amp;nbsp;커버리지가&amp;nbsp;한&amp;nbsp;클래스만&amp;nbsp;표시&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;IntelliJ에서&amp;nbsp;exchange&amp;nbsp;패키지&amp;nbsp;전체를&amp;nbsp;실행했는데&amp;nbsp;커버리지&amp;nbsp;리포트에&amp;nbsp;WithdrawalService&amp;nbsp;하나만&amp;nbsp;잡혔다.&amp;nbsp;나머지&amp;nbsp;클래스들이&amp;nbsp;전혀&amp;nbsp;커버리지에&amp;nbsp;반영되지&amp;nbsp;않는&amp;nbsp;상황이었다.&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;Gradle은&amp;nbsp;테스트&amp;nbsp;중&amp;nbsp;하나라도&amp;nbsp;실패하면&amp;nbsp;빌드를&amp;nbsp;중단하는&amp;nbsp;기본&amp;nbsp;동작을&amp;nbsp;한다.&amp;nbsp;BankAccountServiceTest가&amp;nbsp;실패하면서&amp;nbsp;이후&amp;nbsp;테스트들이&amp;nbsp;아예&amp;nbsp;실행되지&amp;nbsp;않은&amp;nbsp;것이었다.&amp;nbsp;커버리지는&amp;nbsp;실행된&amp;nbsp;테스트만&amp;nbsp;반영하므로&amp;nbsp;WithdrawalServiceTest만&amp;nbsp;실행된&amp;nbsp;결과가&amp;nbsp;나온&amp;nbsp;것이다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;BankAccountService&amp;nbsp;서비스&amp;nbsp;코드의&amp;nbsp;save()&amp;nbsp;반환값&amp;nbsp;문제를&amp;nbsp;수정하여&amp;nbsp;BankAccountServiceTest가&amp;nbsp;통과되도록&amp;nbsp;했다.&amp;nbsp;이후&amp;nbsp;전체&amp;nbsp;테스트가&amp;nbsp;정상&amp;nbsp;실행되면서&amp;nbsp;커버리지가&amp;nbsp;모든&amp;nbsp;클래스에&amp;nbsp;반영됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3.&amp;nbsp;코드래빗&amp;nbsp;리뷰&amp;nbsp;반영&lt;/b&gt;&lt;br /&gt;&lt;b&gt;3-1.&amp;nbsp;락&amp;nbsp;해제&amp;nbsp;원자성&amp;nbsp;문제&amp;nbsp;-&amp;nbsp;Lua&amp;nbsp;Script&amp;nbsp;교체&amp;nbsp;(Critical)&lt;/b&gt;&lt;br /&gt;기존&amp;nbsp;코드는&amp;nbsp;get()&amp;nbsp;후&amp;nbsp;delete()를&amp;nbsp;별도로&amp;nbsp;호출하는&amp;nbsp;비원자적&amp;nbsp;방식이었다.&amp;nbsp;락&amp;nbsp;만료&amp;nbsp;직후&amp;nbsp;다른&amp;nbsp;요청이&amp;nbsp;같은&amp;nbsp;키로&amp;nbsp;락을&amp;nbsp;재획득한&amp;nbsp;사이에&amp;nbsp;delete()가&amp;nbsp;호출되면&amp;nbsp;타인의&amp;nbsp;락을&amp;nbsp;지우는&amp;nbsp;경쟁&amp;nbsp;조건이&amp;nbsp;발생한다.&amp;nbsp;Lua&amp;nbsp;Script로&amp;nbsp;get과&amp;nbsp;delete를&amp;nbsp;하나의&amp;nbsp;원자적&amp;nbsp;연산으로&amp;nbsp;묶어&amp;nbsp;해결했다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&amp;nbsp;-&amp;nbsp;비원자적&lt;br /&gt;Object&amp;nbsp;currentValue&amp;nbsp;=&amp;nbsp;redisTemplate.opsForValue().get(lockKey);&lt;br /&gt;if&amp;nbsp;(lockValue.equals(currentValue))&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;redisTemplate.delete(lockKey);&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;//&amp;nbsp;수정&amp;nbsp;후&amp;nbsp;-&amp;nbsp;Lua&amp;nbsp;Script로&amp;nbsp;원자적&amp;nbsp;처리&lt;br /&gt;private&amp;nbsp;static&amp;nbsp;final&amp;nbsp;DefaultRedisScript&amp;lt;Long&amp;gt;&amp;nbsp;RELEASE_LOCK_SCRIPT&amp;nbsp;=&amp;nbsp;new&amp;nbsp;DefaultRedisScript&amp;lt;&amp;gt;(&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;if&amp;nbsp;redis.call('get',&amp;nbsp;KEYS[1])&amp;nbsp;==&amp;nbsp;ARGV[1]&amp;nbsp;then&amp;nbsp;return&amp;nbsp;redis.call('del',&amp;nbsp;KEYS[1])&amp;nbsp;else&amp;nbsp;return&amp;nbsp;0&amp;nbsp;end&quot;,&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Long.class&lt;br /&gt;);&lt;br /&gt;redisTemplate.execute(RELEASE_LOCK_SCRIPT,&amp;nbsp;List.of(lockKey),&amp;nbsp;lockValue);&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3-2.&amp;nbsp;startDate&amp;nbsp;&amp;gt;&amp;nbsp;endDate&amp;nbsp;역전&amp;nbsp;검증&amp;nbsp;추가&amp;nbsp;(Minor)&lt;/b&gt;&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;API에서&amp;nbsp;startDate가&amp;nbsp;endDate보다&amp;nbsp;늦은&amp;nbsp;경우&amp;nbsp;그대로&amp;nbsp;쿼리가&amp;nbsp;실행되어&amp;nbsp;항상&amp;nbsp;빈&amp;nbsp;결과를&amp;nbsp;반환하는&amp;nbsp;문제가&amp;nbsp;있었다.&amp;nbsp;컨트롤러에서&amp;nbsp;400&amp;nbsp;에러로&amp;nbsp;빠르게&amp;nbsp;실패하도록&amp;nbsp;검증을&amp;nbsp;추가했다.&lt;br /&gt;javaif&amp;nbsp;(startDate&amp;nbsp;!=&amp;nbsp;null&amp;nbsp;&amp;amp;&amp;amp;&amp;nbsp;endDate&amp;nbsp;!=&amp;nbsp;null&amp;nbsp;&amp;amp;&amp;amp;&amp;nbsp;startDate.isAfter(endDate))&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw&amp;nbsp;new&amp;nbsp;IllegalArgumentException(&quot;시작일이&amp;nbsp;종료일보다&amp;nbsp;클&amp;nbsp;수&amp;nbsp;없습니다&quot;);&lt;br /&gt;}&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3-3.&amp;nbsp;정렬&amp;nbsp;tie-breaker&amp;nbsp;추가&amp;nbsp;(Minor)&lt;/b&gt;&lt;br /&gt;findWithFilters,&amp;nbsp;findAllWithFilters&amp;nbsp;모두&amp;nbsp;requestedAt&amp;nbsp;단일&amp;nbsp;정렬이라&amp;nbsp;동일&amp;nbsp;시각에&amp;nbsp;신청된&amp;nbsp;레코드가&amp;nbsp;존재할&amp;nbsp;경우&amp;nbsp;페이지&amp;nbsp;경계에서&amp;nbsp;중복/누락이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있었다.&amp;nbsp;id&amp;nbsp;DESC를&amp;nbsp;보조&amp;nbsp;정렬로&amp;nbsp;추가하여&amp;nbsp;안정적인&amp;nbsp;페이지네이션을&amp;nbsp;보장했다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&lt;br /&gt;.orderBy(withdrawal.requestedAt.desc())&lt;br /&gt;&lt;br /&gt;//&amp;nbsp;수정&amp;nbsp;후&lt;br /&gt;.orderBy(withdrawal.requestedAt.desc(),&amp;nbsp;withdrawal.id.desc())&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4.&amp;nbsp;느낀&amp;nbsp;점&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;테스트&amp;nbsp;코드와&amp;nbsp;관련된&amp;nbsp;트러블슈팅이&amp;nbsp;많았다.&amp;nbsp;@WebMvcTest&amp;nbsp;환경에서&amp;nbsp;Security&amp;nbsp;빈&amp;nbsp;의존성&amp;nbsp;체인이&amp;nbsp;컨텍스트&amp;nbsp;로딩&amp;nbsp;자체를&amp;nbsp;막는다는&amp;nbsp;걸&amp;nbsp;직접&amp;nbsp;겪으면서&amp;nbsp;Spring&amp;nbsp;Security와&amp;nbsp;테스트&amp;nbsp;환경의&amp;nbsp;관계를&amp;nbsp;더&amp;nbsp;깊이&amp;nbsp;이해하게&amp;nbsp;됐다.&lt;br /&gt;특히&amp;nbsp;save()&amp;nbsp;반환값을&amp;nbsp;사용하지&amp;nbsp;않는&amp;nbsp;서비스&amp;nbsp;코드&amp;nbsp;문제가&amp;nbsp;운영&amp;nbsp;환경에서는&amp;nbsp;JPA가&amp;nbsp;알아서&amp;nbsp;처리해줘서&amp;nbsp;드러나지&amp;nbsp;않다가&amp;nbsp;Mock&amp;nbsp;환경에서만&amp;nbsp;터지는&amp;nbsp;케이스였다.&amp;nbsp;테스트&amp;nbsp;코드가&amp;nbsp;운영&amp;nbsp;코드의&amp;nbsp;숨어있는&amp;nbsp;문제를&amp;nbsp;찾아주는&amp;nbsp;역할도&amp;nbsp;한다는&amp;nbsp;점을&amp;nbsp;다시&amp;nbsp;한번&amp;nbsp;체감했다.&lt;br /&gt;CodeRabbit의&amp;nbsp;Lua&amp;nbsp;Script&amp;nbsp;제안은&amp;nbsp;단순히&amp;nbsp;get&amp;nbsp;후&amp;nbsp;delete가&amp;nbsp;비원자적이라는&amp;nbsp;이론적&amp;nbsp;지식을&amp;nbsp;실제&amp;nbsp;코드에&amp;nbsp;적용하는&amp;nbsp;좋은&amp;nbsp;계기가&amp;nbsp;됐다.&amp;nbsp;분산&amp;nbsp;환경에서&amp;nbsp;원자성이&amp;nbsp;보장되지&amp;nbsp;않으면&amp;nbsp;어떤&amp;nbsp;문제가&amp;nbsp;생기는지&amp;nbsp;구체적으로&amp;nbsp;생각해볼&amp;nbsp;수&amp;nbsp;있었다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/210</guid>
      <comments>https://minwoo95.tistory.com/210#entry210comment</comments>
      <pubDate>Wed, 29 Apr 2026 21:59:44 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 10</title>
      <link>https://minwoo95.tistory.com/209</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt; &lt;/p&gt;
&lt;figure id=&quot;og_1777379325147&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/GckQV/dJMb9fZyoRO/ymg4LqMY4hhFBsfgdnaHOK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/42YNT/dJMb8SXASIr/aCfeWkKyQW6j94ynvOjqb1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/GckQV/dJMb9fZyoRO/ymg4LqMY4hhFBsfgdnaHOK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/42YNT/dJMb8SXASIr/aCfeWkKyQW6j94ynvOjqb1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777379330624&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/da712j/dJMb9kT52IP/9W71mv7frRxQ88H02f9fK0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bENled/dJMb9hC4lDp/dkOIK3bGfuovQgUskKafYk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/da712j/dJMb9kT52IP/9W71mv7frRxQ88H02f9fK0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bENled/dJMb9hC4lDp/dkOIK3bGfuovQgUskKafYk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1.&amp;nbsp;오늘&amp;nbsp;한&amp;nbsp;일&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;수익/환전&amp;nbsp;도메인&amp;nbsp;전체를&amp;nbsp;설계부터&amp;nbsp;구현까지&amp;nbsp;완료했다.&amp;nbsp;튜터님이&amp;nbsp;&quot;어줍잖게&amp;nbsp;구현하면&amp;nbsp;포트폴리오에서&amp;nbsp;발목&amp;nbsp;잡힌다&quot;고&amp;nbsp;하신&amp;nbsp;만큼,&amp;nbsp;금융&amp;nbsp;도메인에&amp;nbsp;맞는&amp;nbsp;디테일을&amp;nbsp;최대한&amp;nbsp;챙기며&amp;nbsp;작업했다.&lt;br /&gt;수익/환전&amp;nbsp;도메인&amp;nbsp;ERD&amp;nbsp;설계&amp;nbsp;(BankAccount,&amp;nbsp;AccountVerification,&amp;nbsp;Revenue,&amp;nbsp;Withdrawal&amp;nbsp;4개&amp;nbsp;엔티티)&lt;br /&gt;계좌&amp;nbsp;등록&amp;nbsp;+&amp;nbsp;1원&amp;nbsp;인증&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;(2-step:&amp;nbsp;인증&amp;nbsp;요청&amp;nbsp;/&amp;nbsp;코드&amp;nbsp;검증&amp;nbsp;분리)&lt;br /&gt;수익&amp;nbsp;현황&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;(Revenue&amp;nbsp;집계&amp;nbsp;쿼리&amp;nbsp;+&amp;nbsp;Redis&amp;nbsp;캐싱)&lt;br /&gt;환전&amp;nbsp;신청&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;(Redis&amp;nbsp;분산락&amp;nbsp;동시성&amp;nbsp;제어&amp;nbsp;+&amp;nbsp;수수료&amp;nbsp;계산&amp;nbsp;+&amp;nbsp;잔액&amp;nbsp;트랜잭션)&lt;br /&gt;환전&amp;nbsp;내역/상세&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;(QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리&amp;nbsp;+&amp;nbsp;기간/상태&amp;nbsp;필터&amp;nbsp;+&amp;nbsp;페이징)&lt;br /&gt;수익&amp;nbsp;분석&amp;nbsp;통계&amp;nbsp;API&amp;nbsp;구현&amp;nbsp;(QueryDSL&amp;nbsp;GROUP&amp;nbsp;BY&amp;nbsp;월별/주별&amp;nbsp;집계&amp;nbsp;+&amp;nbsp;Redis&amp;nbsp;캐싱)&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;승인/거절&amp;nbsp;서비스&amp;nbsp;로직&amp;nbsp;선작업&amp;nbsp;(TODO:&amp;nbsp;Admin&amp;nbsp;Controller&amp;nbsp;연결&amp;nbsp;대기)&lt;br /&gt;API&amp;nbsp;명세서&amp;nbsp;작성&amp;nbsp;(전체&amp;nbsp;8개&amp;nbsp;엔드포인트)&lt;br /&gt;총&amp;nbsp;37개&amp;nbsp;파일,&amp;nbsp;API&amp;nbsp;6개(작가측)&amp;nbsp;+&amp;nbsp;2개(관리자&amp;nbsp;TODO)&amp;nbsp;구현&amp;nbsp;완료.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2.&amp;nbsp;트러블슈팅&lt;/b&gt;&lt;br /&gt;&lt;b&gt;2-1.&amp;nbsp;BankVerificationClient&amp;nbsp;빈&amp;nbsp;등록&amp;nbsp;실패&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;애플리케이션&amp;nbsp;실행&amp;nbsp;시&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;Parameter&amp;nbsp;2&amp;nbsp;of&amp;nbsp;constructor&amp;nbsp;in&amp;nbsp;BankAccountService&amp;nbsp;required&amp;nbsp;a&amp;nbsp;bean&amp;nbsp;of&amp;nbsp;type&amp;nbsp;'BankVerificationClient'&amp;nbsp;that&amp;nbsp;could&amp;nbsp;not&amp;nbsp;be&amp;nbsp;found.&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;처음에&amp;nbsp;외부&amp;nbsp;은행&amp;nbsp;API&amp;nbsp;구현체를&amp;nbsp;@Profile(&quot;dev&quot;)와&amp;nbsp;@Profile(&quot;prod&quot;)로&amp;nbsp;분리했는데,&amp;nbsp;spring.profiles.active&amp;nbsp;설정이&amp;nbsp;없어서&amp;nbsp;어떤&amp;nbsp;프로파일에도&amp;nbsp;해당하지&amp;nbsp;않았고&amp;nbsp;Mock&amp;nbsp;빈이&amp;nbsp;등록되지&amp;nbsp;않은&amp;nbsp;것이었다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;@Profile&amp;nbsp;대신&amp;nbsp;@ConditionalOnProperty로&amp;nbsp;전환했다.&amp;nbsp;useb.api.enabled:&amp;nbsp;true이면&amp;nbsp;실제&amp;nbsp;API&amp;nbsp;구현체가,&amp;nbsp;설정이&amp;nbsp;없거나&amp;nbsp;false이면&amp;nbsp;Mock&amp;nbsp;구현체가&amp;nbsp;자동으로&amp;nbsp;등록되도록&amp;nbsp;변경했다.&amp;nbsp;이후&amp;nbsp;금융규제로&amp;nbsp;인해&amp;nbsp;실제&amp;nbsp;은행&amp;nbsp;API&amp;nbsp;테스트가&amp;nbsp;불가능하다는&amp;nbsp;점을&amp;nbsp;고려하여,&amp;nbsp;최종적으로는&amp;nbsp;UseBVerificationClient를&amp;nbsp;삭제하고&amp;nbsp;LocalBankVerificationClient(시뮬레이션)를&amp;nbsp;@Component로&amp;nbsp;단순&amp;nbsp;등록하는&amp;nbsp;구조로&amp;nbsp;정리했다.&amp;nbsp;BankVerificationClient&amp;nbsp;인터페이스는&amp;nbsp;유지하여&amp;nbsp;운영&amp;nbsp;전환&amp;nbsp;시&amp;nbsp;실제&amp;nbsp;구현체로&amp;nbsp;교체할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;구조를&amp;nbsp;열어두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-2.&amp;nbsp;AES&amp;nbsp;암호화&amp;nbsp;설정값&amp;nbsp;미등록으로&amp;nbsp;빈&amp;nbsp;생성&amp;nbsp;실패&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;@Profile(&quot;dev&quot;)&amp;nbsp;문제를&amp;nbsp;해결한&amp;nbsp;뒤에도&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;Could&amp;nbsp;not&amp;nbsp;resolve&amp;nbsp;placeholder&amp;nbsp;'encryption.aes.secret-key'&amp;nbsp;in&amp;nbsp;value&amp;nbsp;&quot;${encryption.aes.secret-key}&quot;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;계좌번호를&amp;nbsp;AES&amp;nbsp;암호화하기&amp;nbsp;위해&amp;nbsp;AesEncryptionUtil을&amp;nbsp;@Component로&amp;nbsp;등록했는데,&amp;nbsp;application.yml에&amp;nbsp;encryption.aes.secret-key와&amp;nbsp;encryption.aes.iv&amp;nbsp;값을&amp;nbsp;정의하지&amp;nbsp;않은&amp;nbsp;상태였다.&amp;nbsp;Spring이&amp;nbsp;빈&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;@Value&amp;nbsp;플레이스홀더를&amp;nbsp;해석하지&amp;nbsp;못해&amp;nbsp;에러가&amp;nbsp;발생한&amp;nbsp;것이다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;application.yml에&amp;nbsp;아래&amp;nbsp;설정을&amp;nbsp;추가했다.&amp;nbsp;AES-128은&amp;nbsp;키와&amp;nbsp;IV가&amp;nbsp;정확히&amp;nbsp;16바이트여야&amp;nbsp;한다.&lt;br /&gt;yamlencryption:&lt;br /&gt;&amp;nbsp;&amp;nbsp;aes:&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;secret-key:&amp;nbsp;novelcraft1234ab&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;iv:&amp;nbsp;novelcraft1234iv&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-3.&amp;nbsp;기존&amp;nbsp;예외&amp;nbsp;처리&amp;nbsp;구조와&amp;nbsp;불일치&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;컴파일은&amp;nbsp;통과했지만,&amp;nbsp;ExchangeException이라는&amp;nbsp;커스텀&amp;nbsp;예외&amp;nbsp;클래스를&amp;nbsp;별도로&amp;nbsp;만들어&amp;nbsp;사용하고&amp;nbsp;있었는데&amp;nbsp;기존&amp;nbsp;프로젝트의&amp;nbsp;예외&amp;nbsp;처리&amp;nbsp;구조와&amp;nbsp;달랐다.&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;기존&amp;nbsp;프로젝트는&amp;nbsp;ErrorCode&amp;nbsp;인터페이스&amp;nbsp;+&amp;nbsp;ServiceErrorException&amp;nbsp;구조로&amp;nbsp;GlobalExceptionHandler에서&amp;nbsp;일괄&amp;nbsp;처리하는&amp;nbsp;방식이었다.&amp;nbsp;ExchangeException은&amp;nbsp;이&amp;nbsp;핸들러에&amp;nbsp;잡히지&amp;nbsp;않아&amp;nbsp;500&amp;nbsp;에러로&amp;nbsp;빠질&amp;nbsp;수&amp;nbsp;있었다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;ExchangeException&amp;nbsp;클래스를&amp;nbsp;삭제하고,&amp;nbsp;ExchangeExceptionEnum이&amp;nbsp;기존&amp;nbsp;ErrorCode&amp;nbsp;인터페이스를&amp;nbsp;implements하도록&amp;nbsp;변경했다.&amp;nbsp;서비스&amp;nbsp;레이어에서는&amp;nbsp;new&amp;nbsp;ServiceErrorException(ExchangeExceptionEnum.XXX)&amp;nbsp;형태로&amp;nbsp;던지도록&amp;nbsp;통일하여&amp;nbsp;GlobalExceptionHandler에서&amp;nbsp;정상적으로&amp;nbsp;처리되도록&amp;nbsp;했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3.&amp;nbsp;의사결정&amp;nbsp;-&amp;nbsp;1원&amp;nbsp;계좌&amp;nbsp;인증&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;선택&lt;/b&gt;&lt;br /&gt;&lt;b&gt;배경&lt;/b&gt;&lt;br /&gt;수익&amp;nbsp;환전&amp;nbsp;기능에서&amp;nbsp;계좌&amp;nbsp;인증이&amp;nbsp;필수적이었다.&amp;nbsp;실제&amp;nbsp;서비스에서는&amp;nbsp;useB,&amp;nbsp;CODEF&amp;nbsp;등&amp;nbsp;외부&amp;nbsp;은행&amp;nbsp;API를&amp;nbsp;통해&amp;nbsp;1원&amp;nbsp;입금&amp;nbsp;&amp;rarr;&amp;nbsp;인증코드&amp;nbsp;확인&amp;nbsp;방식으로&amp;nbsp;계좌를&amp;nbsp;검증한다.&amp;nbsp;포트원(PortOne)은&amp;nbsp;결제와&amp;nbsp;본인인증에&amp;nbsp;특화되어&amp;nbsp;있어&amp;nbsp;1원&amp;nbsp;계좌&amp;nbsp;인증은&amp;nbsp;지원하지&amp;nbsp;않았다.&lt;br /&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;useB,&amp;nbsp;CODEF,&amp;nbsp;금융결제원&amp;nbsp;오픈뱅킹&amp;nbsp;등을&amp;nbsp;조사한&amp;nbsp;결과,&amp;nbsp;모든&amp;nbsp;서비스가&amp;nbsp;사업자&amp;nbsp;등록&amp;nbsp;또는&amp;nbsp;기업&amp;nbsp;계약이&amp;nbsp;필요했다.&amp;nbsp;CODEF는&amp;nbsp;샌드박스를&amp;nbsp;제공하지만&amp;nbsp;고정&amp;nbsp;응답값만&amp;nbsp;반환하는&amp;nbsp;구조여서&amp;nbsp;실질적인&amp;nbsp;테스트&amp;nbsp;효과는&amp;nbsp;Mock과&amp;nbsp;차이가&amp;nbsp;없었다.&lt;br /&gt;&lt;b&gt;결정&lt;/b&gt;&lt;br /&gt;BankVerificationClient&amp;nbsp;인터페이스로&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;호출을&amp;nbsp;추상화하고,&amp;nbsp;LocalBankVerificationClient에서&amp;nbsp;실제&amp;nbsp;1원&amp;nbsp;인증&amp;nbsp;플로우를&amp;nbsp;자체&amp;nbsp;시뮬레이션하는&amp;nbsp;구조로&amp;nbsp;구현했다.&amp;nbsp;사전&amp;nbsp;등록된&amp;nbsp;가상&amp;nbsp;계좌&amp;nbsp;5개(국민/신한/우리/하나/카카오뱅크)를&amp;nbsp;두고&amp;nbsp;예금주&amp;nbsp;확인,&amp;nbsp;인증코드&amp;nbsp;생성,&amp;nbsp;은행&amp;nbsp;점검시간&amp;nbsp;체크까지&amp;nbsp;실제와&amp;nbsp;동일한&amp;nbsp;비즈니스&amp;nbsp;로직이&amp;nbsp;동작한다.&amp;nbsp;운영&amp;nbsp;환경에서는&amp;nbsp;인터페이스를&amp;nbsp;구현한&amp;nbsp;실제&amp;nbsp;API&amp;nbsp;연동&amp;nbsp;구현체로&amp;nbsp;교체하면&amp;nbsp;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4.&amp;nbsp;느낀&amp;nbsp;점&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;수익/환전이라는&amp;nbsp;금융&amp;nbsp;도메인을&amp;nbsp;처음부터&amp;nbsp;끝까지&amp;nbsp;설계하고&amp;nbsp;구현하면서,&amp;nbsp;일반적인&amp;nbsp;CRUD와는&amp;nbsp;차원이&amp;nbsp;다른&amp;nbsp;복잡도를&amp;nbsp;체감했다.&lt;br /&gt;가장&amp;nbsp;고민이&amp;nbsp;깊었던&amp;nbsp;부분은&amp;nbsp;동시성&amp;nbsp;제어였다.&amp;nbsp;같은&amp;nbsp;작가가&amp;nbsp;동시에&amp;nbsp;환전을&amp;nbsp;신청하면&amp;nbsp;잔액&amp;nbsp;초과&amp;nbsp;환전이&amp;nbsp;일어날&amp;nbsp;수&amp;nbsp;있는데,&amp;nbsp;Redis&amp;nbsp;분산락으로&amp;nbsp;하나의&amp;nbsp;요청만&amp;nbsp;통과시키는&amp;nbsp;구조를&amp;nbsp;잡았다.&amp;nbsp;단순히&amp;nbsp;&quot;락을&amp;nbsp;걸었다&quot;가&amp;nbsp;아니라&amp;nbsp;왜&amp;nbsp;비관적&amp;nbsp;락&amp;nbsp;대신&amp;nbsp;Redis를&amp;nbsp;선택했는지,&amp;nbsp;finally에서&amp;nbsp;반드시&amp;nbsp;락을&amp;nbsp;해제하는&amp;nbsp;이유가&amp;nbsp;뭔지&amp;nbsp;등을&amp;nbsp;설명할&amp;nbsp;수&amp;nbsp;있어야&amp;nbsp;포트폴리오에서&amp;nbsp;의미가&amp;nbsp;있다는&amp;nbsp;걸&amp;nbsp;느꼈다.&lt;br /&gt;외부&amp;nbsp;API&amp;nbsp;연동&amp;nbsp;부분에서는&amp;nbsp;금융&amp;nbsp;규제의&amp;nbsp;벽을&amp;nbsp;실감했다.&amp;nbsp;개인&amp;nbsp;개발자가&amp;nbsp;실제&amp;nbsp;은행&amp;nbsp;API를&amp;nbsp;테스트할&amp;nbsp;방법이&amp;nbsp;사실상&amp;nbsp;없었다.&amp;nbsp;처음에는&amp;nbsp;useB를&amp;nbsp;연동하려고&amp;nbsp;했지만,&amp;nbsp;사업자&amp;nbsp;등록&amp;nbsp;없이는&amp;nbsp;불가능하다는&amp;nbsp;걸&amp;nbsp;알게&amp;nbsp;됐고,&amp;nbsp;결국&amp;nbsp;인터페이스&amp;nbsp;분리&amp;nbsp;+&amp;nbsp;시뮬레이션&amp;nbsp;구현체로&amp;nbsp;방향을&amp;nbsp;잡았다.&amp;nbsp;이&amp;nbsp;과정에서&amp;nbsp;&quot;없는&amp;nbsp;걸&amp;nbsp;있는&amp;nbsp;척&amp;nbsp;하는&amp;nbsp;것보다,&amp;nbsp;구조만&amp;nbsp;깔끔하게&amp;nbsp;열어두는&amp;nbsp;게&amp;nbsp;낫다&quot;는&amp;nbsp;판단을&amp;nbsp;했는데,&amp;nbsp;면접에서도&amp;nbsp;솔직하게&amp;nbsp;&quot;금융규제로&amp;nbsp;실연동은&amp;nbsp;불가능했고,&amp;nbsp;대신&amp;nbsp;비즈니스&amp;nbsp;로직은&amp;nbsp;실제와&amp;nbsp;동일하게&amp;nbsp;구현했습니다&quot;라고&amp;nbsp;설명할&amp;nbsp;수&amp;nbsp;있을&amp;nbsp;것&amp;nbsp;같다.&lt;br /&gt;상태&amp;nbsp;전이&amp;nbsp;검증도&amp;nbsp;인상&amp;nbsp;깊었다.&amp;nbsp;WithdrawalStatus&amp;nbsp;enum에&amp;nbsp;canTransitionTo()&amp;nbsp;메서드를&amp;nbsp;넣어서&amp;nbsp;COMPLETED&amp;nbsp;&amp;rarr;&amp;nbsp;REJECTED&amp;nbsp;같은&amp;nbsp;잘못된&amp;nbsp;전이를&amp;nbsp;엔티티&amp;nbsp;레벨에서&amp;nbsp;차단하는&amp;nbsp;구조인데,&amp;nbsp;이런&amp;nbsp;방어&amp;nbsp;로직&amp;nbsp;하나가&amp;nbsp;금융&amp;nbsp;도메인의&amp;nbsp;신뢰도를&amp;nbsp;결정한다는&amp;nbsp;걸&amp;nbsp;배웠다.&lt;br /&gt;내일은&amp;nbsp;Postman으로&amp;nbsp;전체&amp;nbsp;API&amp;nbsp;플로우를&amp;nbsp;테스트하고,&amp;nbsp;시간이&amp;nbsp;되면&amp;nbsp;서비스&amp;nbsp;레이어&amp;nbsp;단위&amp;nbsp;테스트&amp;nbsp;코드&amp;nbsp;작성까지&amp;nbsp;진행할&amp;nbsp;예정이다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/209</guid>
      <comments>https://minwoo95.tistory.com/209#entry209comment</comments>
      <pubDate>Tue, 28 Apr 2026 21:31:13 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 9</title>
      <link>https://minwoo95.tistory.com/208</link>
      <description>&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777026249205&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dln35T/dJMb9eTSfht/wgO3a93ZoheY2CSZrYyPu1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/d3w79D/dJMb9lMd5HY/V4IL31AXPUoGRbN1DOhrhk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dln35T/dJMb9eTSfht/wgO3a93ZoheY2CSZrYyPu1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/d3w79D/dJMb9lMd5HY/V4IL31AXPUoGRbN1DOhrhk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777026255921&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/qUy2z/dJMb8XkhX19/CmAeyUOnkBvNSdZVIUTZP1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/1YIaf/dJMb8U8WbKB/pEEA5k0CxC0xDi2bsSNArk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/qUy2z/dJMb8XkhX19/CmAeyUOnkBvNSdZVIUTZP1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/1YIaf/dJMb8U8WbKB/pEEA5k0CxC0xDi2bsSNArk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1.&amp;nbsp;오늘&amp;nbsp;한&amp;nbsp;일&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;중간&amp;nbsp;발표&amp;nbsp;준비를&amp;nbsp;진행했다.&amp;nbsp;발표&amp;nbsp;주제는&amp;nbsp;mentor/mentoring&amp;nbsp;도메인&amp;nbsp;고도화&amp;nbsp;내용으로&amp;nbsp;선정했고,&amp;nbsp;의사결정&amp;nbsp;1개와&amp;nbsp;문제해결&amp;nbsp;2개&amp;nbsp;구조로&amp;nbsp;구성했다.&lt;br /&gt;&lt;br /&gt;발표&amp;nbsp;주제&amp;nbsp;선정&amp;nbsp;및&amp;nbsp;구성&amp;nbsp;(의사결정&amp;nbsp;1개&amp;nbsp;+&amp;nbsp;문제해결&amp;nbsp;2개)&lt;br /&gt;발표&amp;nbsp;문서&amp;nbsp;작성&amp;nbsp;(배경,&amp;nbsp;요구사항,&amp;nbsp;의사결정,&amp;nbsp;장단점)&lt;br /&gt;발표&amp;nbsp;대본&amp;nbsp;작성&amp;nbsp;(5분&amp;nbsp;분량)&lt;br /&gt;팀&amp;nbsp;목표&amp;nbsp;재설정&amp;nbsp;논의&lt;br /&gt;&lt;br /&gt;또한&amp;nbsp;팀&amp;nbsp;회의에서&amp;nbsp;프로젝트&amp;nbsp;방향을&amp;nbsp;재설정했다.&amp;nbsp;기존에는&amp;nbsp;도메인별&amp;nbsp;기능&amp;nbsp;구현&amp;nbsp;완성도에&amp;nbsp;집중했는데,&amp;nbsp;앞으로는&amp;nbsp;프로젝트&amp;nbsp;디테일보다&amp;nbsp;고도화와&amp;nbsp;부하테스트&amp;nbsp;쪽에&amp;nbsp;더&amp;nbsp;집중하기로&amp;nbsp;팀&amp;nbsp;목표를&amp;nbsp;수정했다.&amp;nbsp;기능이&amp;nbsp;돌아가는&amp;nbsp;것보다&amp;nbsp;왜&amp;nbsp;이렇게&amp;nbsp;설계했는가,&amp;nbsp;실제&amp;nbsp;트래픽에서&amp;nbsp;얼마나&amp;nbsp;버티는가를&amp;nbsp;증명하는&amp;nbsp;방향으로&amp;nbsp;진행할&amp;nbsp;예정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2.&amp;nbsp;발표&amp;nbsp;내용&amp;nbsp;정리&lt;/b&gt;&lt;br /&gt;의사결정&amp;nbsp;-&amp;nbsp;멘토&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;v1/v2&amp;nbsp;분리&amp;nbsp;(QueryDSL&amp;nbsp;N+1&amp;nbsp;해결)&lt;br /&gt;getMyMentees&amp;nbsp;API가&amp;nbsp;반복문&amp;nbsp;안에서&amp;nbsp;멘티,&amp;nbsp;소설,&amp;nbsp;피드백을&amp;nbsp;각각&amp;nbsp;개별&amp;nbsp;조회하는&amp;nbsp;N+1&amp;nbsp;구조였다.&amp;nbsp;멘티가&amp;nbsp;N명이면&amp;nbsp;쿼리가&amp;nbsp;1+3N번&amp;nbsp;나가는&amp;nbsp;문제였다.&amp;nbsp;이를&amp;nbsp;해결하기&amp;nbsp;위해&amp;nbsp;기존&amp;nbsp;v1은&amp;nbsp;유지하고&amp;nbsp;QueryDSL&amp;nbsp;JOIN&amp;nbsp;단일&amp;nbsp;쿼리로&amp;nbsp;구현한&amp;nbsp;v2&amp;nbsp;엔드포인트를&amp;nbsp;추가했다.&amp;nbsp;팀&amp;nbsp;컨벤션상&amp;nbsp;엔티티&amp;nbsp;연관관계를&amp;nbsp;걸지&amp;nbsp;않아&amp;nbsp;Fetch&amp;nbsp;Join을&amp;nbsp;쓸&amp;nbsp;수&amp;nbsp;없었고,&amp;nbsp;QueryDSL로&amp;nbsp;직접&amp;nbsp;JOIN&amp;nbsp;쿼리를&amp;nbsp;짜는&amp;nbsp;방식을&amp;nbsp;선택했다.&lt;br /&gt;문제해결&amp;nbsp;1&amp;nbsp;-&amp;nbsp;스케줄러&amp;nbsp;메모리&amp;nbsp;부하&lt;br /&gt;매일&amp;nbsp;자정에&amp;nbsp;멘토&amp;nbsp;등급을&amp;nbsp;자동&amp;nbsp;조정하는&amp;nbsp;배치가&amp;nbsp;승급&amp;nbsp;대상&amp;nbsp;멘토를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;전부&amp;nbsp;List로&amp;nbsp;가져오는&amp;nbsp;구조였다.&amp;nbsp;코드래빗&amp;nbsp;리뷰에서&amp;nbsp;두&amp;nbsp;가지&amp;nbsp;문제를&amp;nbsp;잡아줬다.&amp;nbsp;첫째,&amp;nbsp;멘토&amp;nbsp;수가&amp;nbsp;늘어나면&amp;nbsp;메모리가&amp;nbsp;터질&amp;nbsp;수&amp;nbsp;있다.&amp;nbsp;둘째,&amp;nbsp;@Transactional&amp;nbsp;안에서&amp;nbsp;처리한&amp;nbsp;엔티티가&amp;nbsp;배치&amp;nbsp;종료까지&amp;nbsp;persistence&amp;nbsp;context에&amp;nbsp;쌓인다.&amp;nbsp;청크&amp;nbsp;100명&amp;nbsp;단위&amp;nbsp;페이지네이션,&amp;nbsp;ID&amp;nbsp;ASC&amp;nbsp;정렬&amp;nbsp;고정,&amp;nbsp;청크마다&amp;nbsp;flush()/clear()&amp;nbsp;호출로&amp;nbsp;해결했다.&lt;br /&gt;문제해결&amp;nbsp;2&amp;nbsp;-&amp;nbsp;QueryDSL&amp;nbsp;LEFT&amp;nbsp;JOIN&amp;nbsp;null&amp;nbsp;노출&lt;br /&gt;v1은&amp;nbsp;반복문&amp;nbsp;안에서&amp;nbsp;orElse(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;)로&amp;nbsp;null을&amp;nbsp;방어했는데,&amp;nbsp;v2로&amp;nbsp;QueryDSL&amp;nbsp;단일&amp;nbsp;쿼리로&amp;nbsp;전환하면서&amp;nbsp;이&amp;nbsp;방어&amp;nbsp;로직을&amp;nbsp;놓쳤다.&amp;nbsp;탈퇴한&amp;nbsp;유저나&amp;nbsp;삭제된&amp;nbsp;소설&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;응답에&amp;nbsp;null이&amp;nbsp;그대로&amp;nbsp;들어가는&amp;nbsp;문제였다.&amp;nbsp;Expressions.cases()로&amp;nbsp;쿼리&amp;nbsp;단에서&amp;nbsp;직접&amp;nbsp;fallback&amp;nbsp;처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3.&amp;nbsp;느낀&amp;nbsp;점&lt;/b&gt;&lt;br /&gt;발표&amp;nbsp;준비를&amp;nbsp;하면서&amp;nbsp;코드를&amp;nbsp;짜는&amp;nbsp;것과&amp;nbsp;그걸&amp;nbsp;말로&amp;nbsp;설명하는&amp;nbsp;건&amp;nbsp;완전히&amp;nbsp;다른&amp;nbsp;작업이라는&amp;nbsp;걸&amp;nbsp;느꼈다.&amp;nbsp;코드는&amp;nbsp;짰는데&amp;nbsp;왜&amp;nbsp;그렇게&amp;nbsp;짰는지&amp;nbsp;말로&amp;nbsp;정리하려니&amp;nbsp;생각보다&amp;nbsp;어려웠다.&lt;br /&gt;팀&amp;nbsp;목표를&amp;nbsp;고도화와&amp;nbsp;부하테스트&amp;nbsp;중심으로&amp;nbsp;바꾼&amp;nbsp;것도&amp;nbsp;의미있는&amp;nbsp;결정이었다.&amp;nbsp;발표&amp;nbsp;준비를&amp;nbsp;하면서&amp;nbsp;&quot;개선&amp;nbsp;전후&amp;nbsp;수치가&amp;nbsp;있으면&amp;nbsp;훨씬&amp;nbsp;설득력&amp;nbsp;있을&amp;nbsp;텐데&quot;라는&amp;nbsp;생각이&amp;nbsp;계속&amp;nbsp;들었다.&amp;nbsp;앞으로는&amp;nbsp;N+1&amp;nbsp;개선&amp;nbsp;전후&amp;nbsp;쿼리&amp;nbsp;수&amp;nbsp;비교,&amp;nbsp;인덱스&amp;nbsp;적용&amp;nbsp;전후&amp;nbsp;실행&amp;nbsp;계획&amp;nbsp;비교,&amp;nbsp;부하테스트&amp;nbsp;결과를&amp;nbsp;직접&amp;nbsp;측정하고&amp;nbsp;정리하는&amp;nbsp;작업을&amp;nbsp;병행할&amp;nbsp;예정이다.&lt;/p&gt;
&lt;figure id=&quot;og_1777026240193&quot; style=&quot;color: #333333; text-align: start;&quot; contenteditable=&quot;false&quot; data-og-image=&quot;https://blog.kakaocdn.net/dna/bVHZNU/dJMb8WMr4sz/AAAAAAAAAAAAAAAAAAAAALDMCu9ZPCcD1TRrvjjFIwwUMLQYfFyoXwZVlHzOSbwF/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1777561199&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=kkn6Rj94pQd0kAXvg1k3z%2BhC8fw%3D&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-host=&quot;github.com&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-type=&quot;object&quot; data-ke-align=&quot;alignCenter&quot; data-ke-type=&quot;opengraph&quot;&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/208</guid>
      <comments>https://minwoo95.tistory.com/208#entry208comment</comments>
      <pubDate>Fri, 24 Apr 2026 19:24:48 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 8</title>
      <link>https://minwoo95.tistory.com/207</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776943528374&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bVHZNU/dJMb8WMr4sz/zk0rtzzLPN8KG7mEcairE1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/92Bk5/dJMb8ZvDU7j/kFeDHrGPzqK6KUnSiiYklk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bVHZNU/dJMb8WMr4sz/zk0rtzzLPN8KG7mEcairE1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/92Bk5/dJMb8ZvDU7j/kFeDHrGPzqK6KUnSiiYklk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776943555637&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cBtwE7/dJMb89555xd/dyCl5zR08zkk7RGMp20nOK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/EP7q3/dJMb82MFy3R/eddLpQJGfAj2rPqg0MqoxK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cBtwE7/dJMb89555xd/dyCl5zR08zkk7RGMp20nOK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/EP7q3/dJMb82MFy3R/eddLpQJGfAj2rPqg0MqoxK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1.&amp;nbsp;오늘&amp;nbsp;한&amp;nbsp;일&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;NovelCraft&amp;nbsp;프로젝트의&amp;nbsp;Jenkins&amp;nbsp;CI/CD&amp;nbsp;파이프라인&amp;nbsp;완성&amp;nbsp;및&amp;nbsp;AWS&amp;nbsp;앱&amp;nbsp;서버&amp;nbsp;배포까지&amp;nbsp;전&amp;nbsp;과정을&amp;nbsp;마무리했다.&lt;br /&gt;Jenkins&amp;nbsp;Credentials&amp;nbsp;등록&amp;nbsp;(Docker&amp;nbsp;Hub,&amp;nbsp;App&amp;nbsp;EC2&amp;nbsp;SSH&amp;nbsp;Key,&amp;nbsp;MySQL,&amp;nbsp;AWS&amp;nbsp;S3,&amp;nbsp;OAuth2,&amp;nbsp;JWT,&amp;nbsp;PortOne,&amp;nbsp;CoolSMS,&amp;nbsp;국립도서관&amp;nbsp;API&amp;nbsp;총&amp;nbsp;18개)&lt;br /&gt;GitHub&amp;nbsp;Webhook&amp;nbsp;연동&amp;nbsp;(main&amp;nbsp;브랜치&amp;nbsp;Push&amp;nbsp;시&amp;nbsp;자동&amp;nbsp;빌드&amp;nbsp;트리거)&lt;br /&gt;Jenkins&amp;nbsp;Pipeline&amp;nbsp;생성&amp;nbsp;및&amp;nbsp;Jenkinsfile&amp;nbsp;작성&lt;br /&gt;Docker&amp;nbsp;Hub&amp;nbsp;레포지토리&amp;nbsp;생성&amp;nbsp;및&amp;nbsp;이미지&amp;nbsp;Push&amp;nbsp;성공&lt;br /&gt;앱&amp;nbsp;EC2&amp;nbsp;Docker&amp;nbsp;설치&amp;nbsp;및&amp;nbsp;Redis&amp;nbsp;설치&lt;br /&gt;보안&amp;nbsp;그룹&amp;nbsp;인바운드&amp;nbsp;규칙&amp;nbsp;추가&amp;nbsp;(Jenkins&amp;nbsp;EC2&amp;nbsp;&amp;rarr;&amp;nbsp;앱&amp;nbsp;EC2&amp;nbsp;SSH&amp;nbsp;허용)&lt;br /&gt;Jenkins&amp;nbsp;&amp;rarr;&amp;nbsp;Docker&amp;nbsp;Hub&amp;nbsp;&amp;rarr;&amp;nbsp;앱&amp;nbsp;EC2&amp;nbsp;자동&amp;nbsp;배포&amp;nbsp;파이프라인&amp;nbsp;완성&lt;br /&gt;application-prod.yml&amp;nbsp;환경변수&amp;nbsp;누락&amp;nbsp;수정&amp;nbsp;(app.frontend.url)&lt;br /&gt;Postman으로&amp;nbsp;API&amp;nbsp;응답&amp;nbsp;확인&amp;nbsp;완료&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2.&amp;nbsp;트러블슈팅&lt;/b&gt;&lt;br /&gt;&lt;b&gt;2-1.&amp;nbsp;Jenkinsfile&amp;nbsp;파일명&amp;nbsp;대소문자&amp;nbsp;문제&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;Jenkins&amp;nbsp;빌드&amp;nbsp;시&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;ERROR:&amp;nbsp;Unable&amp;nbsp;to&amp;nbsp;find&amp;nbsp;Jenkinsfile&amp;nbsp;from&amp;nbsp;git&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;파일명을&amp;nbsp;jenkinsfile(소문자)로&amp;nbsp;생성했는데&amp;nbsp;Jenkins는&amp;nbsp;반드시&amp;nbsp;Jenkinsfile(대문자&amp;nbsp;J)을&amp;nbsp;찾는다.&amp;nbsp;대소문자가&amp;nbsp;달라&amp;nbsp;Jenkins가&amp;nbsp;파일을&amp;nbsp;인식하지&amp;nbsp;못한&amp;nbsp;것이다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;bashgit&amp;nbsp;mv&amp;nbsp;jenkinsfile&amp;nbsp;Jenkinsfile&lt;br /&gt;git&amp;nbsp;commit&amp;nbsp;-m&amp;nbsp;&quot;fix:&amp;nbsp;Jenkinsfile&amp;nbsp;파일명&amp;nbsp;대문자로&amp;nbsp;수정&quot;&lt;br /&gt;git&amp;nbsp;push&amp;nbsp;origin&amp;nbsp;main&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-2.&amp;nbsp;Dockerfile&amp;nbsp;주석&amp;nbsp;파싱&amp;nbsp;에러&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;Docker&amp;nbsp;빌드&amp;nbsp;중&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;ERROR:&amp;nbsp;failed&amp;nbsp;to&amp;nbsp;solve:&amp;nbsp;dockerfile&amp;nbsp;parse&amp;nbsp;error&amp;nbsp;on&amp;nbsp;line&amp;nbsp;1:&amp;nbsp;unknown&amp;nbsp;instruction:&amp;nbsp;1단계:&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;Dockerfile에&amp;nbsp;한글&amp;nbsp;주석을&amp;nbsp;#&amp;nbsp;없이&amp;nbsp;그냥&amp;nbsp;텍스트로&amp;nbsp;작성했다.&amp;nbsp;Docker&amp;nbsp;파서가&amp;nbsp;첫&amp;nbsp;줄의&amp;nbsp;1단계:를&amp;nbsp;Dockerfile&amp;nbsp;명령어로&amp;nbsp;인식하려다&amp;nbsp;에러를&amp;nbsp;낸&amp;nbsp;것이다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;모든&amp;nbsp;한글&amp;nbsp;주석&amp;nbsp;앞에&amp;nbsp;#을&amp;nbsp;붙였다.&lt;br /&gt;dockerfile#&amp;nbsp;1단계:&amp;nbsp;빌드&lt;br /&gt;FROM&amp;nbsp;gradle:8.5-jdk17&amp;nbsp;AS&amp;nbsp;build&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-3.&amp;nbsp;openjdk:17-jdk-slim&amp;nbsp;이미지&amp;nbsp;not&amp;nbsp;found&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;Docker&amp;nbsp;빌드&amp;nbsp;중&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;ERROR:&amp;nbsp;failed&amp;nbsp;to&amp;nbsp;solve:&amp;nbsp;openjdk:17-jdk-slim:&amp;nbsp;not&amp;nbsp;found&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;openjdk:17-jdk-slim&amp;nbsp;이미지가&amp;nbsp;Docker&amp;nbsp;Hub에서&amp;nbsp;deprecated&amp;nbsp;되어&amp;nbsp;더&amp;nbsp;이상&amp;nbsp;존재하지&amp;nbsp;않았다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;실행&amp;nbsp;스테이지&amp;nbsp;베이스&amp;nbsp;이미지를&amp;nbsp;eclipse-temurin:17-jre-jammy로&amp;nbsp;교체했다.&lt;br /&gt;dockerfile#&amp;nbsp;2단계:&amp;nbsp;실행&lt;br /&gt;FROM&amp;nbsp;eclipse-temurin:17-jre-jammy&lt;br /&gt;WORKDIR&amp;nbsp;/app&lt;br /&gt;COPY&amp;nbsp;--from=build&amp;nbsp;/app/build/libs/*.jar&amp;nbsp;app.jar&lt;br /&gt;ENTRYPOINT&amp;nbsp;[&quot;java&quot;,&amp;nbsp;&quot;-jar&quot;,&amp;nbsp;&quot;app.jar&quot;]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-4.&amp;nbsp;Docker&amp;nbsp;Hub&amp;nbsp;Push&amp;nbsp;denied&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;Docker&amp;nbsp;이미지&amp;nbsp;Push&amp;nbsp;시&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;denied:&amp;nbsp;requested&amp;nbsp;access&amp;nbsp;to&amp;nbsp;the&amp;nbsp;resource&amp;nbsp;is&amp;nbsp;denied&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;두&amp;nbsp;가지&amp;nbsp;원인이&amp;nbsp;있었다.&amp;nbsp;첫째로&amp;nbsp;Jenkinsfile의&amp;nbsp;이미지&amp;nbsp;태그가&amp;nbsp;minwoo/novelcraft로&amp;nbsp;되어있었는데&amp;nbsp;실제&amp;nbsp;Docker&amp;nbsp;Hub&amp;nbsp;유저네임은&amp;nbsp;jmw1995였다.&amp;nbsp;둘째로&amp;nbsp;Docker&amp;nbsp;Hub에&amp;nbsp;novelcraft&amp;nbsp;레포지토리&amp;nbsp;자체가&amp;nbsp;생성되어&amp;nbsp;있지&amp;nbsp;않았다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;Jenkinsfile의&amp;nbsp;이미지&amp;nbsp;이름을&amp;nbsp;jmw1995/novelcraft로&amp;nbsp;수정하고,&amp;nbsp;Docker&amp;nbsp;Hub에서&amp;nbsp;novelcraft&amp;nbsp;레포지토리를&amp;nbsp;Public으로&amp;nbsp;생성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-5.&amp;nbsp;Jenkins&amp;nbsp;Built-In&amp;nbsp;Node&amp;nbsp;오프라인&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;Jenkins&amp;nbsp;빌드가&amp;nbsp;Waiting&amp;nbsp;for&amp;nbsp;next&amp;nbsp;available&amp;nbsp;executor&amp;nbsp;상태에서&amp;nbsp;멈추고&amp;nbsp;Built-In&amp;nbsp;Node가&amp;nbsp;오프라인으로&amp;nbsp;표시됐다.&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;두&amp;nbsp;가지&amp;nbsp;문제가&amp;nbsp;복합적으로&amp;nbsp;발생했다.&amp;nbsp;Free&amp;nbsp;Swap&amp;nbsp;Space가&amp;nbsp;0B로&amp;nbsp;메모리가&amp;nbsp;부족했고,&amp;nbsp;Free&amp;nbsp;Temp&amp;nbsp;Space가&amp;nbsp;951MB로&amp;nbsp;Jenkins의&amp;nbsp;임계값인&amp;nbsp;1GiB&amp;nbsp;미만이어서&amp;nbsp;노드가&amp;nbsp;자동으로&amp;nbsp;오프라인&amp;nbsp;처리된&amp;nbsp;것이었다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;Swap&amp;nbsp;메모리&amp;nbsp;2GB를&amp;nbsp;추가&amp;nbsp;생성하고,&amp;nbsp;Jenkins&amp;nbsp;Nodes&amp;nbsp;설정에서&amp;nbsp;Free&amp;nbsp;Temp&amp;nbsp;Space&amp;nbsp;Threshold를&amp;nbsp;500MB로&amp;nbsp;낮춘&amp;nbsp;후&amp;nbsp;노드를&amp;nbsp;온라인으로&amp;nbsp;복구했다.&lt;br /&gt;bashsudo&amp;nbsp;dd&amp;nbsp;if=/dev/zero&amp;nbsp;of=/swapfile&amp;nbsp;bs=128M&amp;nbsp;count=16&lt;br /&gt;sudo&amp;nbsp;chmod&amp;nbsp;600&amp;nbsp;/swapfile&lt;br /&gt;sudo&amp;nbsp;mkswap&amp;nbsp;/swapfile&lt;br /&gt;sudo&amp;nbsp;swapon&amp;nbsp;/swapfile&lt;br /&gt;echo&amp;nbsp;'/swapfile&amp;nbsp;swap&amp;nbsp;swap&amp;nbsp;defaults&amp;nbsp;0&amp;nbsp;0'&amp;nbsp;|&amp;nbsp;sudo&amp;nbsp;tee&amp;nbsp;-a&amp;nbsp;/etc/fstab&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-6.&amp;nbsp;App&amp;nbsp;EC2&amp;nbsp;SSH&amp;nbsp;Connection&amp;nbsp;timed&amp;nbsp;out&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;Jenkins에서&amp;nbsp;앱&amp;nbsp;EC2로&amp;nbsp;SSH&amp;nbsp;배포&amp;nbsp;시&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;ssh:&amp;nbsp;connect&amp;nbsp;to&amp;nbsp;host&amp;nbsp;43.200.129.27&amp;nbsp;port&amp;nbsp;22:&amp;nbsp;Connection&amp;nbsp;timed&amp;nbsp;out&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;앱&amp;nbsp;EC2의&amp;nbsp;보안&amp;nbsp;그룹(novel-app-security-group)에&amp;nbsp;Jenkins&amp;nbsp;EC2&amp;nbsp;IP(43.201.153.0)에&amp;nbsp;대한&amp;nbsp;SSH&amp;nbsp;인바운드&amp;nbsp;규칙이&amp;nbsp;없었다.&amp;nbsp;Jenkins&amp;nbsp;EC2와&amp;nbsp;앱&amp;nbsp;EC2가&amp;nbsp;서로&amp;nbsp;다른&amp;nbsp;보안&amp;nbsp;그룹을&amp;nbsp;사용하고&amp;nbsp;있었는데&amp;nbsp;처음에&amp;nbsp;Jenkins&amp;nbsp;서버의&amp;nbsp;보안&amp;nbsp;그룹(novel-craft-security-group)만&amp;nbsp;수정하고&amp;nbsp;앱&amp;nbsp;EC2의&amp;nbsp;보안&amp;nbsp;그룹은&amp;nbsp;수정하지&amp;nbsp;않은&amp;nbsp;것이&amp;nbsp;원인이었다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;novel-app-security-group에&amp;nbsp;Jenkins&amp;nbsp;EC2&amp;nbsp;IP를&amp;nbsp;SSH&amp;nbsp;허용&amp;nbsp;규칙으로&amp;nbsp;추가했다.&lt;br /&gt;유형&amp;nbsp;:&amp;nbsp;SSH&lt;br /&gt;포트&amp;nbsp;:&amp;nbsp;22&lt;br /&gt;소스&amp;nbsp;:&amp;nbsp;43.201.153.0/32&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-7.&amp;nbsp;app.frontend.url&amp;nbsp;환경변수&amp;nbsp;미설정으로&amp;nbsp;애플리케이션&amp;nbsp;실행&amp;nbsp;실패&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;배포&amp;nbsp;후&amp;nbsp;컨테이너&amp;nbsp;로그에서&amp;nbsp;아래&amp;nbsp;에러가&amp;nbsp;반복됐다.&lt;br /&gt;Could&amp;nbsp;not&amp;nbsp;resolve&amp;nbsp;placeholder&amp;nbsp;'app.frontend.url'&amp;nbsp;in&amp;nbsp;value&amp;nbsp;&quot;${app.frontend.url}&quot;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;OAuth2SuccessHandler에서&amp;nbsp;@Value(&quot;${app.frontend.url}&quot;)&amp;nbsp;로&amp;nbsp;프론트엔드&amp;nbsp;URL을&amp;nbsp;주입받는데&amp;nbsp;application-prod.yml에&amp;nbsp;해당&amp;nbsp;키가&amp;nbsp;정의되어&amp;nbsp;있지&amp;nbsp;않았다.&amp;nbsp;Jenkinsfile에서는&amp;nbsp;FRONTEND_URL&amp;nbsp;환경변수로&amp;nbsp;넘기고&amp;nbsp;있었지만&amp;nbsp;yml에서&amp;nbsp;app.frontend.url로&amp;nbsp;매핑하는&amp;nbsp;설정이&amp;nbsp;누락된&amp;nbsp;것이었다.&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;application-prod.yml&amp;nbsp;최상단에&amp;nbsp;아래&amp;nbsp;설정을&amp;nbsp;추가했다.&lt;br /&gt;yamlapp:&lt;br /&gt;&amp;nbsp;&amp;nbsp;frontend:&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;url:&amp;nbsp;${FRONTEND_URL}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3.&amp;nbsp;느낀&amp;nbsp;점&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;CI/CD&amp;nbsp;파이프라인을&amp;nbsp;처음부터&amp;nbsp;끝까지&amp;nbsp;직접&amp;nbsp;구축하면서&amp;nbsp;배포&amp;nbsp;전&amp;nbsp;과정에서&amp;nbsp;마주칠&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;문제들을&amp;nbsp;거의&amp;nbsp;다&amp;nbsp;경험한&amp;nbsp;것&amp;nbsp;같다.&lt;br /&gt;가장&amp;nbsp;인상&amp;nbsp;깊었던&amp;nbsp;부분은&amp;nbsp;보안&amp;nbsp;그룹이었다.&amp;nbsp;Jenkins&amp;nbsp;EC2와&amp;nbsp;앱&amp;nbsp;EC2가&amp;nbsp;서로&amp;nbsp;다른&amp;nbsp;보안&amp;nbsp;그룹을&amp;nbsp;사용하고&amp;nbsp;있다는&amp;nbsp;걸&amp;nbsp;뒤늦게&amp;nbsp;파악했는데,&amp;nbsp;네트워크&amp;nbsp;인프라&amp;nbsp;설정에서는&amp;nbsp;각&amp;nbsp;리소스가&amp;nbsp;어떤&amp;nbsp;보안&amp;nbsp;그룹에&amp;nbsp;속해있는지&amp;nbsp;먼저&amp;nbsp;확인하는&amp;nbsp;습관이&amp;nbsp;필요하다는&amp;nbsp;것을&amp;nbsp;느꼈다.&lt;br /&gt;app.frontend.url&amp;nbsp;누락&amp;nbsp;문제는&amp;nbsp;환경변수&amp;nbsp;이름과&amp;nbsp;yml&amp;nbsp;키&amp;nbsp;이름이&amp;nbsp;다를&amp;nbsp;수&amp;nbsp;있다는&amp;nbsp;점을&amp;nbsp;다시&amp;nbsp;상기시켜줬다.&amp;nbsp;Jenkinsfile에서&amp;nbsp;FRONTEND_URL로&amp;nbsp;넘겨도&amp;nbsp;애플리케이션&amp;nbsp;코드가&amp;nbsp;app.frontend.url을&amp;nbsp;참조하면&amp;nbsp;다리를&amp;nbsp;놓아줘야&amp;nbsp;한다.&amp;nbsp;배포&amp;nbsp;전에&amp;nbsp;application.yml의&amp;nbsp;모든&amp;nbsp;플레이스홀더와&amp;nbsp;실제&amp;nbsp;주입되는&amp;nbsp;환경변수&amp;nbsp;이름을&amp;nbsp;맞춰두는&amp;nbsp;것이&amp;nbsp;중요하다는&amp;nbsp;것을&amp;nbsp;배웠다.&lt;br /&gt;Built-In&amp;nbsp;Node가&amp;nbsp;Swap&amp;nbsp;메모리&amp;nbsp;부족으로&amp;nbsp;오프라인이&amp;nbsp;된&amp;nbsp;것도&amp;nbsp;흥미로운&amp;nbsp;경험이었다.&amp;nbsp;t3.small&amp;nbsp;인스턴스는&amp;nbsp;메모리가&amp;nbsp;2GB밖에&amp;nbsp;안&amp;nbsp;돼서&amp;nbsp;Jenkins&amp;nbsp;자체만으로도&amp;nbsp;메모리가&amp;nbsp;빡빡하다.&amp;nbsp;Swap을&amp;nbsp;추가해서&amp;nbsp;해결했지만&amp;nbsp;장기적으로는&amp;nbsp;인스턴스&amp;nbsp;타입을&amp;nbsp;올리거나&amp;nbsp;빌드를&amp;nbsp;별도&amp;nbsp;에이전트로&amp;nbsp;분리하는&amp;nbsp;방향을&amp;nbsp;고려해야&amp;nbsp;할&amp;nbsp;것&amp;nbsp;같다.&lt;br /&gt;무엇보다&amp;nbsp;오늘&amp;nbsp;GitHub&amp;nbsp;Push&amp;nbsp;한&amp;nbsp;번으로&amp;nbsp;Docker&amp;nbsp;빌드,&amp;nbsp;이미지&amp;nbsp;Push,&amp;nbsp;앱&amp;nbsp;서버&amp;nbsp;자동&amp;nbsp;배포까지&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;이루어지는&amp;nbsp;파이프라인이&amp;nbsp;완성됐다는&amp;nbsp;게&amp;nbsp;뿌듯했다.&amp;nbsp;앞으로&amp;nbsp;팀원들이&amp;nbsp;코드를&amp;nbsp;Push할&amp;nbsp;때마다&amp;nbsp;자동으로&amp;nbsp;배포되니&amp;nbsp;수동&amp;nbsp;배포의&amp;nbsp;번거로움&amp;nbsp;없이&amp;nbsp;개발에&amp;nbsp;집중할&amp;nbsp;수&amp;nbsp;있을&amp;nbsp;것&amp;nbsp;같다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/207</guid>
      <comments>https://minwoo95.tistory.com/207#entry207comment</comments>
      <pubDate>Thu, 23 Apr 2026 20:27:51 +0900</pubDate>
    </item>
  </channel>
</rss>