<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>T_era</title>
    <link>https://t-era.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 21 Jun 2026 02:29:30 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>블스뜸</managingEditor>
    <item>
      <title>설문 프로젝트 모니터링 및 부하 테스트 + 성능 개선</title>
      <link>https://t-era.tistory.com/296</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 개요&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.1 운영 환경&lt;/b&gt;&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;EC2&lt;/b&gt;: vCPU 2개, 메모리 4GB&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RDS&lt;/b&gt;: vCPU 1개, 메모리 2GB&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 환경&lt;/b&gt;: Docker 컨테이너를 통한 운영 환경 시뮬레이션&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.2 성능 목표&lt;/b&gt;&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;처리량&lt;/b&gt;: 40~60 RPS&lt;/li&gt;
&lt;li&gt;&lt;b&gt;응답시간&lt;/b&gt;: p95 &amp;lt; 100ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CPU 점유율&lt;/b&gt;: 최대 30%&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: &amp;lt; 0.1%&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.3 부하 테스트 시나리오&lt;/b&gt;&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;테스트 도구&lt;/b&gt;: k6&lt;/li&gt;
&lt;li&gt;&lt;b&gt;총 테스트 시간&lt;/b&gt;: 약 30분&lt;/li&gt;
&lt;li&gt;&lt;b&gt;가상 사용자&lt;/b&gt;: 10명 시작, 10명씩 2분마다 증가, 최대 100명&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중단 조건&lt;/b&gt;: 에러율 5% 초과 또는 응답시간 수용 불가&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 1차 부하 테스트 결과&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.1 테스트 설정&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# Tomcat 스레드 풀
server:
  tomcat:
    threads:
      max: 20
      min-spare: 10

# HikariCP 커넥션 풀
hikari:
  minimum-idle: 5
  maximum-pool-size: 10
  connection-timeout: 5000
  idle-timeout: 600000
  max-lifetime: 1800000

# HTTP 클라이언트 타임아웃
RequestConfig:
  connection-request-timeout: 3초
  connect-timeout: 5초
  response-timeout: 10초

# HTTP 커넥션 풀
PoolingHttpClientConnectionManager:
  max-total: 20
  default-max-per-route: 20&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.2 성능 지표 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-06 오후 5.56.45.png&quot; data-origin-width=&quot;2878&quot; data-origin-height=&quot;998&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bso2Ls/btsPLIBrm8O/wDBUTyLSkFJRzs0IsizQRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bso2Ls/btsPLIBrm8O/wDBUTyLSkFJRzs0IsizQRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bso2Ls/btsPLIBrm8O/wDBUTyLSkFJRzs0IsizQRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbso2Ls%2FbtsPLIBrm8O%2FwDBUTyLSkFJRzs0IsizQRK%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;2878&quot; height=&quot;998&quot; data-filename=&quot;스크린샷 2025-08-06 오후 5.56.45.png&quot; data-origin-width=&quot;2878&quot; data-origin-height=&quot;998&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Max RPS&lt;/b&gt;: 19.7 req/s (목표 40~60 RPS 미달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max Latency (p95)&lt;/b&gt;: No data (모니터링 설정 미완료)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max CPU Usage&lt;/b&gt;: 1.10% (목표 30% 이하 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max Error Rate&lt;/b&gt;: 81.2% (목표 0.1% 초과, 심각한 문제)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-06 오후 5.56.54.png&quot; data-origin-width=&quot;2888&quot; data-origin-height=&quot;1084&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfEbwW/btsPJWntlzY/fRlI6HJkWpen1YeQRasw6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfEbwW/btsPJWntlzY/fRlI6HJkWpen1YeQRasw6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfEbwW/btsPJWntlzY/fRlI6HJkWpen1YeQRasw6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfEbwW%2FbtsPJWntlzY%2FfRlI6HJkWpen1YeQRasw6K%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;2888&quot; height=&quot;1084&quot; data-filename=&quot;스크린샷 2025-08-06 오후 5.56.54.png&quot; data-origin-width=&quot;2888&quot; data-origin-height=&quot;1084&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CPU Usage&lt;/b&gt;: 평균 0.6%, 최대 26.5% (정상 범위)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JVM Memory&lt;/b&gt;: 사용량 280MiB, 최대 2.23GiB (여유)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;GC Pause&lt;/b&gt;: 평균 0~20ms, 최대 40ms (정상)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Threads&lt;/b&gt;: Live 29개, Peak 40개 (안정적)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HikariCP Pool&lt;/b&gt;: Active 10개, Idle 0개 (풀 고갈)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.3 문제점 분석&lt;/b&gt;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.3.1 스레드 연쇄 대기 현상&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상&lt;/b&gt;: 동시 요청 수가 Tomcat 스레드 풀 최대치(20개)에 근접할 때 대규모 타임아웃 발생&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;도메인 A의 API 처리 중 RestClient로 도메인 B 호출&lt;/li&gt;
&lt;li&gt;RestClient 요청 처리에 새로운 Tomcat 스레드 필요&lt;/li&gt;
&lt;li&gt;모든 스레드가 초기 요청 처리 중으로 여분 스레드 부재&lt;/li&gt;
&lt;li&gt;스레드 1이 무한 대기 후 타임아웃으로 실패&lt;/li&gt;
&lt;li&gt;다중 스레드에서 동시 발생으로 시스템 마비&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로그 분석&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;# 정상 요청 (부하 적음)
=== 전체 메서드 완료 - 총 실행시간: 10ms (DB: 2ms, API: 7ms, 변환: 0ms) ===

# 타임아웃 요청 (부하 많음)
=== 외부 API 호출 실패 - API 실행시간: 5022ms, 전체 실행시간: 5023ms, 에러: 500 ===&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 최적화 전략 수립&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3.1 해결 방안 검토&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&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;td&gt;우선순위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;캐싱 도입&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;구현 간단, 즉시 효과&lt;/td&gt;
&lt;td&gt;데이터 정확성 저하, 캐시 스탬피드&lt;/td&gt;
&lt;td&gt;1순위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;조회용 테이블&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;근본적 해결, 안정적&lt;/td&gt;
&lt;td&gt;구현 복잡도 중간&lt;/td&gt;
&lt;td&gt;2순위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;WebFlux 전환&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;비동기 처리 완전 해결&lt;/td&gt;
&lt;td&gt;아키텍처 변경 대규모&lt;/td&gt;
&lt;td&gt;3순위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;서버 증설&lt;/b&gt;&lt;/td&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3.2 캐싱 방법 비교&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구분&lt;/td&gt;
&lt;td&gt;@Cacheable&lt;/td&gt;
&lt;td&gt;ConcurrentMap&lt;/td&gt;
&lt;td&gt;Caffeine&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구현 복잡도&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&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;b&gt;성능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음&lt;/td&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;b&gt;분산 지원&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;제한적&lt;/td&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;b&gt;운영 관리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;간단&lt;/td&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선택&lt;/b&gt;: @Cashable (단일 서버 환경에서 압도적 성능)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 2차 부하 테스트: 캐싱 적용&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4.1 캐싱 구현&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
@Cacheable(value = &quot;participationCounts&quot;, key = &quot;#surveyIds.toString()&quot;)
public ParticipationCountDto getParticipationCounts(String authHeader, List&amp;lt;Long&amp;gt; surveyIds) {
    ExternalApiResponse participationCounts = participationApiClient.getParticipationCounts(authHeader, surveyIds);
    Map&amp;lt;String, Integer&amp;gt; rawData = (Map&amp;lt;String, Integer&amp;gt;)participationCounts.getData();
    return ParticipationCountDto.of(rawData);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4.2 성능 지표 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/uMATvTqjhJHcXfoW6aHZxmvvn00hzGgz&quot;&gt;그라파나 결과 스냅샷&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-07 오전 11.33.57.png&quot; data-origin-width=&quot;2904&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0ITxq/btsPJDBq0Gy/MIvhUfD0CJ62lSxDD4vlr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0ITxq/btsPJDBq0Gy/MIvhUfD0CJ62lSxDD4vlr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0ITxq/btsPJDBq0Gy/MIvhUfD0CJ62lSxDD4vlr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0ITxq%2FbtsPJDBq0Gy%2FMIvhUfD0CJ62lSxDD4vlr1%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;2904&quot; height=&quot;1000&quot; data-filename=&quot;스크린샷 2025-08-07 오전 11.33.57.png&quot; data-origin-width=&quot;2904&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Max RPS&lt;/b&gt;: 100 req/s (목표 40~60 RPS 초과 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max Latency (p95)&lt;/b&gt;: No data (모니터링 설정 미완료)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max CPU Usage&lt;/b&gt;: 0.817% (목표 30% 이하 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max Error Rate&lt;/b&gt;: No data (에러 없음, 목표 달성)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-07 오전 11.34.08.png&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1084&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BILm6/btsPJdXwkBg/aoqnOSOJmpqSSHYKJUDKG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BILm6/btsPJdXwkBg/aoqnOSOJmpqSSHYKJUDKG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BILm6/btsPJdXwkBg/aoqnOSOJmpqSSHYKJUDKG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBILm6%2FbtsPJdXwkBg%2FaoqnOSOJmpqSSHYKJUDKG0%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;1084&quot; data-filename=&quot;스크린샷 2025-08-07 오전 11.34.08.png&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1084&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CPU Usage&lt;/b&gt;: 평균 1.0%, 최대 29.6% (정상 범위)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JVM Memory&lt;/b&gt;: 사용량 302MiB, 최대 2.23GiB (여유)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;GC Pause&lt;/b&gt;: 평균 0~10ms, 최대 40ms (정상)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Threads&lt;/b&gt;: Live 39개, Peak 41개 (안정적)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HikariCP Pool&lt;/b&gt;: Active 1-2개, Idle 8-10개 (여유)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4.3 캐싱 한계점 분석&lt;/b&gt;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4.3.1 고려되지 않은 사항&lt;/b&gt;&lt;/h3&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;: 캐시 만료 시 갑작스러운 부하 대응 미흡&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐싱 데이터 활용도&lt;/b&gt;: 참여자 수만 캐싱으로는 근본적 해결 한계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 API 호출 범위&lt;/b&gt;: 설문 목록 조회 외 다른 API에서도 동일 문제 발생 가능&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4.3.2 결론&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐싱은 성능 향상에 유의미한 효과를 보였으나, 근본적인 스레드 연쇄 대기 문제의 완전한 해결책이 되지 못함. 더 근본적이고 안정적인 해결책 필요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 3차 부하 테스트: 조회용 테이블 구축&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5.1 MongoDB 기반 조회용 테이블 설계&lt;/b&gt;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5.1.1 엔티티 설계&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Document(collection = &quot;survey_summaries&quot;)
public class SurveyReadEntity {
    @Id
    private String id;

    @Indexed
    private Long surveyId;

    @Indexed
    private Long projectId;

    private String title;
    private String description;
    private String status;
    private Integer participationCount;

    private SurveyOptions options;
    private List&amp;lt;QuestionSummary&amp;gt; questions;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5.1.2 동기화 전략&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 설문 생성 시 비동기 동기화
@Async
@Transactional
public void surveyReadSync(SurveySyncDto dto) {
    // MongoDB에 설문 데이터 저장
}

// 질문 생성 시 비동기 동기화
@Async
@Transactional
public void questionReadSync(Long surveyId, List&amp;lt;QuestionSyncDto&amp;gt; dtos) {
    // MongoDB에 질문 데이터 저장
}

// 참여자 수 배치 동기화 (5분 간격)
@Scheduled(fixedRate = 300000)
public void batchParticipationCountSync() {
    // 외부 API에서 참여자 수 조회 후 MongoDB 업데이트
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5.2 성능 지표 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/U43Nr44TofIUvTiWsOpLlXzpo42QM3y0&quot;&gt;그라파나 결과 스냅샷&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-07 오후 7.13.28.png&quot; data-origin-width=&quot;2888&quot; data-origin-height=&quot;988&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dgRvzO/btsPLA4vcwO/AlDkBmqZ9fwKT7xwEVW3fK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dgRvzO/btsPLA4vcwO/AlDkBmqZ9fwKT7xwEVW3fK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dgRvzO/btsPLA4vcwO/AlDkBmqZ9fwKT7xwEVW3fK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdgRvzO%2FbtsPLA4vcwO%2FAlDkBmqZ9fwKT7xwEVW3fK%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;2888&quot; height=&quot;988&quot; data-filename=&quot;스크린샷 2025-08-07 오후 7.13.28.png&quot; data-origin-width=&quot;2888&quot; data-origin-height=&quot;988&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Max RPS&lt;/b&gt;: 99.8 req/s (목표 40~60 RPS 초과 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max Latency (p95)&lt;/b&gt;: 60.5 ms (목표 p95 &amp;lt; 100ms 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max CPU Usage&lt;/b&gt;: 0.836% (목표 30% 이하 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Max Error Rate&lt;/b&gt;: No data (에러 없음, 목표 달성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Rate&lt;/b&gt;: 0~90 ops/s 범위에서 점진적 증가 후 안정화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Errors&lt;/b&gt;: No data (에러 없음)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Duration&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP - AVG: 6.54 ms (평균 응답시간)&lt;/li&gt;
&lt;li&gt;HTTP - MAX: 68.8 ms (최대 응답시간)&lt;/li&gt;
&lt;li&gt;18:45경 450ms 스파이크 발생 후 안정화(테스트 데이터 삽입 이슈)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-07 오후 7.13.44.png&quot; data-origin-width=&quot;2922&quot; data-origin-height=&quot;1158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLobEl/btsPLW7ivKu/gxapDWIHZKmyoCaSSJ9HCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLobEl/btsPLW7ivKu/gxapDWIHZKmyoCaSSJ9HCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLobEl/btsPLW7ivKu/gxapDWIHZKmyoCaSSJ9HCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLobEl%2FbtsPLW7ivKu%2FgxapDWIHZKmyoCaSSJ9HCk%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;2922&quot; height=&quot;1158&quot; data-filename=&quot;스크린샷 2025-08-07 오후 7.13.44.png&quot; data-origin-width=&quot;2922&quot; data-origin-height=&quot;1158&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CPU Usage&lt;/b&gt;: 평균 0.8%, 최대 29.9% (정상 범위)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JVM Memory&lt;/b&gt;: 사용량 333MiB, 최대 2.23GiB (여유)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;GC Pause&lt;/b&gt;: 평균 0~30ms, 최대 50ms (정상)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Threads&lt;/b&gt;: Live 44개, Peak 45개 (안정적)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HikariCP Pool&lt;/b&gt;: Active 10개, Idle 0개 (풀 고갈)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 최적화 효과 분석&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.1 성능 개선 비교&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;지표&lt;/td&gt;
&lt;td&gt;1차 테스트&lt;/td&gt;
&lt;td&gt;2차 테스트&lt;/td&gt;
&lt;td&gt;3차 테스트&lt;/td&gt;
&lt;td&gt;목표&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max RPS&lt;/td&gt;
&lt;td&gt;19.7 req/s&lt;/td&gt;
&lt;td&gt;100 req/s&lt;/td&gt;
&lt;td&gt;99.8 req/s&lt;/td&gt;
&lt;td&gt;40~60 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max Latency (p95)&lt;/td&gt;
&lt;td&gt;측정 불가&lt;/td&gt;
&lt;td&gt;측정 불가&lt;/td&gt;
&lt;td&gt;60.5 ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 100ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max CPU Usage&lt;/td&gt;
&lt;td&gt;1.10%&lt;/td&gt;
&lt;td&gt;0.817%&lt;/td&gt;
&lt;td&gt;0.836%&lt;/td&gt;
&lt;td&gt;&amp;lt; 30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max Error Rate&lt;/td&gt;
&lt;td&gt;81.2%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.2 최적화 단계별 효과&lt;/b&gt;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.2.1 1차 &amp;rarr; 2차 (캐싱 적용)&lt;/b&gt;&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;RPS&lt;/b&gt;: 19.7 &amp;rarr; 100 req/s (약 400% 향상)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 81.2% &amp;rarr; 0% (완전 해결)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.2.2 2차 &amp;rarr; 3차 (조회용 테이블)&lt;/b&gt;&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;RPS&lt;/b&gt;: 19.7 &amp;rarr; 99.8 req/s (약 400% 향상)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 81.2% &amp;rarr; 0% (완전 해결)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;p95 응답시간&lt;/b&gt;: 측정 불가 &amp;rarr; 60.5 ms (목표 달성)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.3 남은 과제&lt;/b&gt;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.3.1 DB 커넥션 풀 최적화&lt;/b&gt;&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;HikariCP Pool&lt;/b&gt;: Active 10개로 풀 고갈 상태&lt;/li&gt;
&lt;li&gt;&lt;b&gt;고려사항&lt;/b&gt;: maximum-pool-size 증가 또는 쿼리 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6.3.2 입력 요청 최적화&lt;/b&gt;&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;초기 테스트 데이터 삽입 시 CPU 사용률 스파이크 현상&lt;/b&gt;: 대량 데이터 입력 시 시스템 리소스에 부하 발생&lt;/li&gt;
&lt;li&gt;&lt;b&gt;고려사항&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처리 방식 개선으로데이터 삽입 최적화&lt;/li&gt;
&lt;li&gt;입력 요청에 대한 비동기 처리 도입 검토&lt;/li&gt;
&lt;li&gt;데이터 입력 시 리소스 사용량 모니터링 환경 구축&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 결론 및 권장사항&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;7.1 최적화 성과&lt;/b&gt;&lt;/h3&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;: 목표 40~60 RPS를 99.8 RPS로 초과 달성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;응답시간&lt;/b&gt;: p95 60.5ms로 목표 &amp;lt; 100ms 달성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;안정성&lt;/b&gt;: 에러율 0%로 완전 해결&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리소스 효율성&lt;/b&gt;: CPU 사용률 목표 달성&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;7.2 핵심 해결책&lt;/b&gt;&lt;/h3&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;: 외부 API 호출 감소로 즉시 성능 향상 - 롤백&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조회용 테이블&lt;/b&gt;: MongoDB 기반으로 근본적 문제 해결 - fix&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 동기화&lt;/b&gt;: 데이터 정확성과 성능의 균형 달성 - fix&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;7.3 MongoDB선택 이유&lt;/b&gt;&lt;/h3&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;현 프로젝트에는 데이터 구조가 복잡하여 (VO, List 등)의 이유로 직렬화가 복잡해진다
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;레디스는 복잡한 객체를 저장하기 어렵다&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복합 인덱스 : &lt;/b&gt;projectId surveyId 등 복합적인 인덱싱이 가능하고 효율적인 페이징 처리가 가능하다
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;레디스는 인메모리 기반이라 속도는 빠르지만 설문 목록 조회 등의 기능을 사용하면 모든 키를 조회해야한다&lt;/li&gt;
&lt;li&gt;또한 페이징, 커서 처리가 상대적으로 많이 복잡하다&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결론&lt;/b&gt;&lt;b&gt;&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;복잡한 설문 구조와 중첩된 객체 저장에 유리하다&lt;/li&gt;
&lt;li&gt;쿼리 패턴이 상대적으로 쉬운 편이다&lt;/li&gt;
&lt;li&gt;직관적인 json 구조를 사용하여 확장성 좋고 유연하다&lt;/li&gt;
&lt;li&gt;MongoDB는 json구조를 통해 조회에 매우 유리한 성능을 가지고 있다&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;7.4 최종 평가&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;조회용 테이블 구축을 통한 최적화로 모든 목표 성능을 초과 달성했으며, 시스템의 안정성과 확장성을 크게 향상시키고, 특히 p95 응답시간 60.5ms 달성으로 사용자 경험을 크게 개선.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/296</guid>
      <comments>https://t-era.tistory.com/296#entry296comment</comments>
      <pubDate>Thu, 7 Aug 2025 21:53:25 +0900</pubDate>
    </item>
    <item>
      <title>깃 액션으로 CICD 사용하기</title>
      <link>https://t-era.tistory.com/295</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. Dockerfile 작성하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 가장 최상위 위치(.gitignore 파일이 있는 곳)에 Dockerfile이라는 이름의 파일을 만들기 확장자 X&lt;/p&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;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# 1. 베이스 이미지 선택 (JDK 17, MAC 기반)
FROM eclipse-temurin:17-jdk

# 2. JAR 파일이 생성될 경로를 변수로 지정
ARG JAR_FILE_PATH=build/libs/*.jar

# 3. build/libs/ 에 있는 JAR 파일을 app.jar 라는 이름으로 복사
COPY ${JAR_FILE_PATH} app.jar

# 4. 컨테이너가 시작될 때 이 명령어를 실행
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;/app.jar&quot;]

#FROM: 어떤 환경을 기반으로 이미지를 만들지 선택.
#
#COPY: 내 컴퓨터에 있는 파일(빌드된 .jar 파일)을 도커 이미지 안으로 복사하는 명령어
#
#ENTRYPOINT: 도커 컨테이너가 시작될 때 실행될 명령어. 즉, java -jar app.jar 명령으로 스프링 부트 애플리케이션을 실행&lt;/code&gt;&lt;/pre&gt;
&lt;/div&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;&lt;b&gt;FROM&lt;/b&gt;: 어떤 환경을 기반으로 이미지를 만들지 선택합니다. openjdk:17-jdk-alpine은 JDK 17이 설치된 가벼운 리눅스 환경입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;COPY&lt;/b&gt;: 내 컴퓨터에 있는 파일(빌드된 .jar 파일)을 도커 이미지 안으로 복사하는 명령어입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ENTRYPOINT&lt;/b&gt;: 도커 컨테이너가 시작될 때 실행될 명령어입니다. 즉, java -jar app.jar 명령으로 스프링 부트 애플리케이션을 실행합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. .dockerignore 작성하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불필요한 파일들이 도커 이미지에 포함되지 않도록 .dockerignore 파일을 만듭니다. 이렇게 하면 이미지 용량이 줄고 빌드 속도가 빨라집니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# .dockerignore

.git
.idea
.gradle&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 로컬에서 테스트&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# 1. 프로젝트 빌드 (Gradle 기준)
./gradlew build

# 2. 도커 이미지 빌드 (my-app 이라는 이름으로)
docker build -t my-app .

# 3. 빌드된 이미지로 컨테이너 실행
docker run -p 8080:8080 my-app&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;http://localhost:8080로 접속해서 확인&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커 이미지를 배포할 EC2와 GitHub Actions가 AWS에 접근할 수 있도록 권한을 설정&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. AWS EC2 인스턴스 생성&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;AWS 콘솔 접속 &amp;gt; EC2 &amp;gt; 인스턴스 시작&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이름 설정:&lt;/b&gt; 알아보기 쉬운 이름&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OS 선택 (AMI):&lt;/b&gt; Ubuntu, 프리티어로 사용 가능한 버전&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인스턴스 유형:&lt;/b&gt; t2.micro&lt;/li&gt;
&lt;li&gt;&lt;b&gt;키 페어 생성:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;생성 후 반드시 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네트워크 설정 (보안 그룹):&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;보안 그룹 생성을 선택하고, 아래 규칙을 추가합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;SSH (22번 포트):&lt;/b&gt;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HTTP (80번 포트):&lt;/b&gt; 0.0.0.0/0&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 지정 TCP (8080번 포트):&lt;/b&gt; 0.0.0.0/0&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스토리지 설정:&lt;/b&gt; 기본값(8GB)으로 충분&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인스턴스 시작&lt;/b&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. EC2 서버에 Docker 설치하기&lt;/b&gt;&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# SSH 접속 명령어 (Mac 기준)
# chmod 400 your-key.pem  (최초 한 번만, 키 파일 권한 변경)
ssh -i &quot;tera199810-KeyPair.pem&quot; ubuntu@52.79.239.64
# 실행 최초에 yes입력해야함

# Ubuntu 패키지 업데이트
sudo apt-get update

# Docker 설치
sudo apt-get install -y docker.io

# ubuntu 사용자를 docker 그룹에 추가 (sudo 없이 docker 명령어 사용)
sudo usermod -aG docker ubuntu

# 변경사항 적용을 위해 SSH 접속 종료 후 재접속
exit&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재접속 후 docker --version 명령어로 설치를 확인합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. GitHub Actions를 위한 IAM 사용자 생성&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions가 내 AWS 계정에 접근해서 배포 작업을 하려면 권한이 필요&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;AWS 콘솔 &amp;gt; IAM &amp;gt; 사용자 &amp;gt; 사용자 생성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 이름:&lt;/b&gt; 알아볼 수 있도록 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;권한 설정:&lt;/b&gt; 직접 정책 연결 선택 후 아래 내용 검색 후 선택.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AmazonEC2FullAccess (EC2 제어 권한)&lt;/li&gt;
&lt;li&gt;AmazonS3FullAccess (S3 버킷 접근 권한, 배포 스크립트 저장용)&lt;/li&gt;
&lt;li&gt;AmazonRDSFullAccess (RDS DB 접근 권한)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;사용자 생성을 끝내고 들어가서&amp;nbsp;&lt;b&gt;액세스 키 만들기&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;CLI를 선택하고, 경고 문구를 확인한 뒤 키 생성&lt;/li&gt;
&lt;li&gt;CSV로 다운받아서 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. GitHub Actions을 위한 Docker Token발급&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&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;도커 UI &amp;gt; 우측 상단 프로필 &amp;gt; Account Settings &amp;gt; &lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot;&gt;Personal access tokens &amp;gt; Generate new Token&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만료기한 설정&lt;/li&gt;
&lt;li&gt;접근 권한 설정 Read,Write,Delete&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Generate 선택&lt;/li&gt;
&lt;li&gt;제일 하단에 나온 두가지 값 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;GitHub Actions 자동화 파이프라인 설정하기&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. GitHub Repository Secrets 설정&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;내 &lt;b&gt;GitHub Repository &amp;gt; Settings &amp;gt; Secrets and variables &amp;gt; Actions&lt;/b&gt;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;New repository secret로 하나씩 추가&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AWS_ACCESS_KEY_ID: 위에서 발급받은 IAM 사용자의 액세스 키&lt;/li&gt;
&lt;li&gt;AWS_SECRET_ACCESS_KEY: 위에서 발급받은 IAM 사용자의 비밀 액세스 키&lt;/li&gt;
&lt;li&gt;AWS_REGION: 내 AWS 리전 코드 (예: ap-northeast-2 for Seoul)&lt;/li&gt;
&lt;li&gt;AWS_S3_BUCKET: 배포 스크립트를 저장할 S3 버킷 이름 (직접 만드셔야 합니다. 예: my-cicd-deploy-scripts)&lt;/li&gt;
&lt;li&gt;EC2_INSTANCE_ID: 배포할 EC2 인스턴스의 ID (EC2 콘솔에서 확인)&lt;/li&gt;
&lt;li&gt;EC2_HOST: EC2 인스턴스의 퍼블릭 IP 주소&lt;/li&gt;
&lt;li&gt;EC2_USERNAME: ubuntu (Ubuntu AMI 기준)&lt;/li&gt;
&lt;li&gt;EC2_SSH_KEY: 위에서 다운로드한 .pem 키 파일의 내용을 그대로 복사해서 붙여넣기(메모장으로 열고 --begin--부터 --end까지 전부 복사 붙여넣기)&lt;/li&gt;
&lt;li&gt;DOCKERHUB_TOKEN: 도커 토큰을 생성하며 나온 값 중dckr_pat_...으로 시작하는 값 복사 붙여넣기&lt;/li&gt;
&lt;li&gt;DOCKERHUB_USERNAME: 도커 토큰을 생성하며 나온 값 중 유저이름 부분만 붙여넣기 예(ljy981008)&lt;/li&gt;
&lt;li&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. cicd.yml 수정 (SSH 방식)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.github/workflows 폴더의 cicd.yml 파일&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# 워크플로우의 전체 이름을 &quot;CI/CD Docker to EC2&quot;로 정했음.
name: CI/CD Docker to EC2

# 언제 이 워크플로우를 실행할지 정하는 부분임.
on:
  push:
    # &quot;main&quot; 브랜치에 코드가 push 될 때마다 실행될 거임.
    branches: [ &quot;main&quot; ]

# 워크플로우가 해야 할 작업(job)들을 정의함.
jobs:
  # &quot;build-and-deploy&quot;라는 이름의 작업을 하나 만들었음.
  build-and-deploy:
    # 이 작업은 GitHub이 제공하는 최신 우분투 가상머신에서 돌아감.
    runs-on: ubuntu-latest
    # 이 작업이 수행할 단계(step)들을 순서대로 나열함.
    steps:
      # 1단계: 코드 내려받기
      - name: Checkout
        # GitHub 저장소에 있는 코드를 가상머신으로 복사해오는 액션을 사용함.
        uses: actions/checkout@v3

      # 2단계: 자바(JDK) 설치
      - name: Set up JDK 17
        # 가상머신에 특정 버전의 자바를 설치하는 액션을 사용함.
        uses: actions/setup-java@v3
        with:
          # 자바 버전을 '17'로 지정함.
          java-version: '17'
          # 'temurin'이라는 배포판을 사용함.
          distribution: 'temurin'
      
      # 3단계: gradlew 파일에 실행 권한 주기
      - name: Grant execute permission for gradlew
        # gradlew 파일이 실행될 수 있도록 권한을 변경함. 리눅스 환경이라 필수임.
        run: chmod +x gradlew
        
      # 4단계: 프로젝트 빌드
      - name: Build with Gradle
        # gradlew 명령어로 스프링 부트 프로젝트를 빌드함. 이걸 해야 .jar 파일이 생김.
        run: ./gradlew build

      # 5단계: 도커 빌드 환경 설정
      - name: Set up Docker Buildx
        # 도커 이미지를 효율적으로 빌드하기 위한 Buildx라는 툴을 설정함.
        uses: docker/setup-buildx-action@v2

      # 6단계: 도커 허브 로그인
      - name: Login to Docker Hub
        # 도커 이미지를 올릴 Docker Hub에 로그인하는 액션을 사용함.
        uses: docker/login-action@v2
        with:
          # 아이디는 GitHub Secrets에 저장된 DOCKERHUB_USERNAME 값을 사용함.
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          # 비밀번호는 GitHub Secrets에 저장된 DOCKERHUB_TOKEN 값을 사용함.
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # 7단계: 도커 이미지 빌드 및 푸시(업로드)
      - name: Build and push
        # Dockerfile을 이용해 이미지를 만들고 Docker Hub에 올리는 액션을 사용함.
        uses: docker/build-push-action@v4
        with:
          # 현재 폴더(.)에 있는 Dockerfile을 사용해서 빌드함.
          context: .
          # 빌드 성공하면 바로 Docker Hub로 푸시(업로드)함.
          push: true
          # 이미지 이름(태그)은 &quot;아이디/my-spring-app:latest&quot; 형식으로 지정함.
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/my-spring-app:latest

      # 8단계: EC2 서버에 배포
      - name: Deploy to EC2
        # SSH를 통해 원격 서버(EC2)에 접속해서 명령어를 실행하는 액션을 사용함.
        uses: appleboy/ssh-action@master
        with:
          # 접속할 EC2 서버의 IP 주소. Secrets에서 값을 가져옴.
          host: ${{ secrets.EC2_HOST }}
          # EC2 서버의 사용자 이름 (보통 ubuntu). Secrets에서 값을 가져옴.
          username: ${{ secrets.EC2_USERNAME }}
          # EC2 접속에 필요한 .pem 키. Secrets에서 값을 가져옴.
          key: ${{ secrets.EC2_SSH_KEY }}
          # EC2 서버에 접속해서 아래 스크립트를 순서대로 실행시킬 거임.
          script: |
            # EC2 서버에서도 Docker Hub에 로그인해야 이미지를 받을 수 있음.
            docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
            # Docker Hub에서 방금 올린 최신 버전의 이미지를 내려받음 (pull).
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/my-spring-app:latest
            # 기존에 실행 중이던 'my-app' 컨테이너가 있으면 중지시킴. 없으면 그냥 넘어감 (|| true).
            docker stop my-app || true
            # 기존 'my-app' 컨테이너가 있으면 삭제함. 없으면 그냥 넘어감.
            docker rm my-app || true
            # 새로 받은 이미지로 'my-app'이라는 이름의 컨테이너를 실행함.
            # -d: 백그라운드에서 실행, -p 8080:8080: 포트 연결
            docker run -d -p 8080:8080 --name my-app ${{ secrets.DOCKERHUB_USERNAME }}/my-spring-app:latest&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ECR을 쓰면 더 안전하고 빠르게 할 수 있다 하지만 복잡하다&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;&lt;b&gt;RDS 연결&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml 프로젝트의 yml설정을 통해 rds 저장 설정을 추가&lt;/p&gt;
&lt;pre id=&quot;code_1753082929536&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 공통 설정
spring:
  # 기본 프로필을 dev로 설정
  profiles:
    active: dev
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        show_sql: true

---
# 개발 환경(dev) 설정 - 로컬 DB 정보
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/testDB
    username: root
    password: '비밀번호'

---
# 운영 환경(prod) 설정 - rds 설정
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서는 dev로 명시된 설정을 우선 실행하는데 후에 설정할 cicd.yml을 통해 우선순위를 prod를 했기 때문에 같은 yml에서 설정해도 된다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;test/resources/application.yml&lt;br /&gt;테스트 환경을 만들어놓지 않으면 rds가 작동하지않음&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1753086231587&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# 테스트 환경 전용 설정
spring:
  datasource:
    # H2 데이터베이스를 사용하도록 설정
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RDS 인스턴스 생성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AWS 콘솔에서 &lt;b&gt;RDS&lt;/b&gt; &amp;gt;&amp;nbsp;&lt;b&gt;데이터베이스 생성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표준 생성&lt;/b&gt;, 엔진 유형은 &lt;b&gt;MySQL&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;템플릿은 &lt;b&gt;프리티어&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB 인스턴스 식별자&lt;/b&gt; 입력&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;마스터 사용자 이름&lt;/b&gt; 및 &lt;b&gt;마스터 암호&lt;/b&gt;를 설정하고 반드시 메모를 해놓든 기억해놓기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;연결&lt;/b&gt; 섹션에서 EC2 컴퓨팅 리소스에 연결은 연결 안 함으로. 보안 그룹을 직접 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안 그룹 설정&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;VPC 보안 그룹&lt;/b&gt;에서 새로 생성을 선택하고, 보안 그룹 이름 입력&lt;/li&gt;
&lt;li&gt;나머지 설정은 그대로 두고 &lt;b&gt;데이터베이스 생성&lt;/b&gt;을 클릭&lt;/li&gt;
&lt;li&gt;생성이 완료되면 생성된 DB를 클릭하여 세부 정보 페이지로 이동 &lt;b&gt;엔드포인트&lt;/b&gt; 주소를 복사해둔다. 이 주소가 yml의 DB_URL 환경변수 값&lt;/li&gt;
&lt;li&gt;&lt;b&gt;연결 &amp;amp; 보안&lt;/b&gt; 탭에서 &lt;b&gt;VPC 보안 그룹&lt;/b&gt; 이름을 클릭&lt;/li&gt;
&lt;li&gt;보안 그룹 페이지에서 &lt;b&gt;인바운드 규칙 편집&lt;/b&gt;을 클릭하고 아래 규칙을 &lt;b&gt;추가&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;유형:&lt;/b&gt; MYSQL/Aurora (3306)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소스:&lt;/b&gt; 규칙 추가를하고 사용자 지정을 선택, 검색창에 &lt;b&gt;EC2 인스턴스가 사용 중인 보안 그룹&lt;/b&gt;의 ID(예: sg-...)를 찾아 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;추가구성 &amp;gt; 퍼블릭 엑세스 허용으로 변경&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;규칙 저장&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;.GitHub Secrets 추가&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;내&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;GitHub Repository &amp;gt; Settings &amp;gt; Secrets and variables &amp;gt; Actions&lt;/b&gt;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;New repository secret로 하나씩 추가&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB_URL: jdbc:mysql:// + 위에서 복사한 &lt;b&gt;RDS 엔드포인트 주소&lt;/b&gt; + /[데이터베이스명]
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예: jdbc:mysql://my-db.xxxxxxxx.ap-northeast-2.rds.amazonaws.com:3306/testDB&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;DB_USERNAME: 아까 기억해둔 RDS 생성 시 설정한 &lt;b&gt;마스터 사용자 이름&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;DB_PASSWORD: 아까 기억해둔 RDS 생성 시 설정한 &lt;b&gt;마스터 암호&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;cicd.yml 수정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1753083490461&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker run -d -p 8080:8080 --name my-app ${{ secrets.DOCKERHUB_USERNAME }}/my-spring-app:latest
-&amp;gt;
docker run -d -p 8080:8080 --name my-app \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e DB_URL=${{ secrets.DB_URL }} \
  -e DB_USERNAME=${{ secrets.DB_USERNAME }} \
  -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
  ${{ secrets.DOCKERHUB_USERNAME }}/my-spring-app:latest&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 내용을 아래 내용으로 변경 (yml우선순위 및 rds 환경변수 추가)&lt;/p&gt;</description>
      <category>Programing/Datababse</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/295</guid>
      <comments>https://t-era.tistory.com/295#entry295comment</comments>
      <pubDate>Mon, 21 Jul 2025 17:29:34 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 대량 데이터 처리 성능 튜닝</title>
      <link>https://t-era.tistory.com/293</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10,000건의 이벤트 데이터를 처리하는 과정에서 발생한 성능 문제를 해결한 방법. 초기에는 컨트롤러에서 3-4초, 서비스에서 900ms 정도 소요되던 작업을 최종적으로 80% 정도의 성능을 개선했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초기 성능 문제 상황&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 측정 결과 (개선 전)&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;1단계 - Event 저장 완료: 22ms
2단계 - ProductApiClient 호출 완료: 236ms (조회된 상품 수: 10000)
3단계 - EventItem 객체 생성 완료: 4ms
4단계 - EventItem insert 완료: 1052ms
5단계 - Event에 EventItem 리스트 설정 완료: 0ms
6단계 - WSEventProduct 객체 생성 완료: 1ms
7단계 - 이벤트 발행 완료: 799ms
=== createEvent 총 실행시간: 2115ms ===
8단계 - 컨트롤러 실행 완료: 4126ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컨트롤러 전체 실행 시간이 4126ms로 심각한 지연&lt;/li&gt;
&lt;li&gt;EventItem 벌크 insert가 1052ms로 큰 병목&lt;/li&gt;
&lt;li&gt;이벤트 리스너가 동기적으로 처리되어 지연 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성능 튜닝 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Notification 벌크 인서트 적용&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 전: 개별 insert&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 기존 방식 - 개별 insert
notificationRepository.save(notification);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 후: 벌크 insert&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
public class NotificationService {
    private final List&amp;lt;Notification&amp;gt; buffer = new ArrayList&amp;lt;&amp;gt;();

    public void addNotification(Notification notification) {
        synchronized (lock) {
            buffer.add(notification);
            if(buffer.size() &amp;gt;= 1000) {
                // 1000개씩 벌크 insert
                insertBatch();
            }
        }
    }

    @Async
    public void insertBatch() {
        List&amp;lt;Notification&amp;gt; notifications;
        synchronized (lock) {
            notifications = new ArrayList&amp;lt;&amp;gt;(buffer);
            buffer.clear();
        }
        notificationRepository.insertNotifications(notifications);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 효과&lt;/b&gt;: 개별 insert &amp;rarr; 벌크 insert로 변경하여 DB 호출 횟수 대폭 감소&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. EventItem 벌크 인서트 적용&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 전: JPA saveAll 사용&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 기존 방식
eventItemRepository.saveAll(eventItems);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 후: JdbcTemplate batchUpdate 사용&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Repository
public class EventItemInsertRepository {

    public void insertEventItem(List&amp;lt;EventItem&amp;gt; eventItems, Long eventId) {
        String sql = &quot;INSERT INTO event_items (event_id, product_id, product_name, original_price, discount_price, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)&quot;;

        jdbcTemplate.batchUpdate(sql, eventItems, 1000, (ps, eventItem) -&amp;gt; {
            ps.setLong(1, eventId);
            ps.setLong(2, eventItem.getProductId());
            ps.setString(3, eventItem.getProductName());
            ps.setBigDecimal(4, eventItem.getOriginalPrice());
            ps.setBigDecimal(5, eventItem.getDiscountPrice());
            ps.setObject(6, LocalDateTime.now());
            ps.setObject(7, LocalDateTime.now());
        });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 효과&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPA saveAll &amp;rarr; JdbcTemplate batchUpdate로 변경&lt;/li&gt;
&lt;li&gt;배치 크기 1000으로 설정하여 메모리 효율성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 이벤트 발행 비동기 처리 + 트랜잭션 일관성 보장&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 상황&lt;/h4&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Transactional
public EventResponse createEvent(EventCrateRequest request) {
    // ... 데이터 저장 ...

    // 이벤트 발행 (동기적)
    wsEventProducts.forEach(wsEvent -&amp;gt; {
        eventPublisher.publishEvent(wsEvent);
    });

    return new EventResponse(event);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점&lt;/b&gt;: 이벤트 발행이 동기적으로 처리되어 지연 발생&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결 방안: @TransactionalEventListener 적용&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
public class NotificationListener {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void addProductDiscountEvent(WSEventProduct event) {
        try {
            log.info(&quot;addProductDiscountEvent 시작 - 단일 이벤트: {}&quot;, event.product_id());

            ListenProductEvent listenProductEvent = new ListenProductEvent(event);
            notificationService.notifyProductEventMessage(listenProductEvent);

            log.info(&quot;addProductDiscountEvent 종료&quot;);
        } catch (Exception e) {
            log.error(&quot;addProductDiscountEvent 처리 실패 message : {}&quot;, e.getMessage());
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 효과&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션 커밋 후 이벤트 리스너 실행으로 데이터 일관성 보장&lt;/li&gt;
&lt;li&gt;비동기 처리로 컨트롤러 응답 시간 단축&lt;/li&gt;
&lt;li&gt;트랜잭션 롤백 시 이벤트 리스너 미실행으로 안정성 확보&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. JPA 연관관계 매핑 제거&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 전: 일대다 연관관계&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Entity
public class Event {
    @OneToMany(mappedBy = &quot;event&quot;, cascade = CascadeType.ALL)
    private List&amp;lt;EventItem&amp;gt; products;
}

@Entity
public class EventItem {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;event_id&quot;)
    private Event event;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조인 테이블(events_products)에 대량 insert 쿼리 발생&lt;/li&gt;
&lt;li&gt;트랜잭션 종료 시 지연 발생&lt;/li&gt;
&lt;li&gt;메모리 사용량 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선 후: 연관관계 제거&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Entity
public class Event {
    @Transient  // 조회용으로만 사용
    private List&amp;lt;EventItem&amp;gt; products;
}

@Entity
public class EventItem {
    private Long eventId;  // 단순 외래키만 저장
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 효과&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조인 테이블 insert 쿼리 제거&lt;/li&gt;
&lt;li&gt;트랜잭션 종료 시 오버헤드 대폭 감소&lt;/li&gt;
&lt;li&gt;메모리 사용량 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 성능 개선 결과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 측정 결과 (개선 후)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1단계 - Event 저장 완료: 29ms
2단계 - ProductApiClient 호출 완료: 260ms (조회된 상품 수: 10000)
3단계 - EventItem 객체 생성 완료: 3ms
4단계 - EventItem 벌크 insert 완료: 520ms
5단계 - Event에 EventItem 리스트 설정 완료: 0ms
6단계 - WSEventProduct 객체 생성 완료: 1ms
7단계 - 이벤트 발행 완료: 10ms
=== createEvent 총 실행시간: 823ms ===
8단계 - 컨트롤러 실행: 844ms&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 개선 요약&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;개선 전&lt;/th&gt;
&lt;th&gt;개선 후&lt;/th&gt;
&lt;th&gt;개선율&lt;/th&gt;
&lt;th&gt;주요 개선 사항&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;서비스 레이어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2115ms&lt;/td&gt;
&lt;td&gt;823ms&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;td&gt;벌크 인서트 + 비동기 이벤트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;컨트롤러 레이어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;4126ms&lt;/td&gt;
&lt;td&gt;844ms&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;전체 시스템 최적화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;EventItem Insert&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1052ms&lt;/td&gt;
&lt;td&gt;520ms&lt;/td&gt;
&lt;td&gt;51%&lt;/td&gt;
&lt;td&gt;JdbcTemplate batchUpdate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;이벤트 발행&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;799ms&lt;/td&gt;
&lt;td&gt;10ms&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;@TransactionalEventListener&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 개선 포인트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 벌크 인서트 최적화&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;개별 insert &amp;rarr; 벌크 insert&lt;/b&gt;: DB 호출 횟수 대폭 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JdbcTemplate batchUpdate&lt;/b&gt;: JPA 오버헤드 제거&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배치 크기 최적화&lt;/b&gt;: 메모리와 성능의 균형점 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 트랜잭션 일관성 보장&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;@TransactionalEventListener&lt;/b&gt;: 트랜잭션 완료 후 이벤트 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 처리&lt;/b&gt;: 컨트롤러 응답 시간 단축&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 일관성&lt;/b&gt;: 트랜잭션 롤백 시 이벤트 미실행&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. JPA 연관관계 최적화&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;연관관계 제거&lt;/b&gt;: 조인 테이블 insert 쿼리 제거&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단순 외래키 사용&lt;/b&gt;: 성능과 복잡성의 균형&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Transient 활용&lt;/b&gt;: 조회용 데이터만 메모리에 로드&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대량 데이터 처리 시 성능 튜닝의 핵심은 &lt;b&gt;DB 호출 최소화&lt;/b&gt;, &lt;b&gt;트랜잭션 일관성 보장&lt;/b&gt;, &lt;b&gt;JPA 오버헤드 제거&lt;/b&gt;. 특히 10,000건 이상의 데이터를 처리할 때는 JPA의 편의성보다는 &lt;b&gt;성능 최적화&lt;/b&gt;가 우선되어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 튜닝을 통해 &lt;b&gt;컨트롤러 응답 시간을 80% 단축&lt;/b&gt;하고, 시스템의 안정성과 확장성을 동시에 확보할 수 있었다.&lt;/p&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/293</guid>
      <comments>https://t-era.tistory.com/293#entry293comment</comments>
      <pubDate>Tue, 15 Jul 2025 13:02:46 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트를 진행할 때 웹소켓을 이용하면서 생긴 문제점</title>
      <link>https://t-era.tistory.com/292</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;STOMP 프로토콜을 사용한 이유가 있을까요?&lt;/b&gt;&lt;br /&gt;웹소켓은 통신 채널을 제공해주지만 메시지에 대한 규약이 존재하지 않는다&lt;br /&gt;STOMP 프로토콜을 사용하면 정형화된 메시지 프레임을 사용할 수 있어 좀 더 체계적인 데이터 관리가 가능하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;웹소켓 설정에서&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #282c34; color: #bbbbbb;&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;.setAllowedOriginPatterns(&quot;*&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;이 설정을 전부 허용한 이유가 있을까요?&lt;br /&gt;&lt;/b&gt;개발 단계에서 서버와 클라이언트 연결을 테스트 할때 origin이 달라 발생하는 cors 문제를 빠르게 해결하고 기능 구현에 집중하기 위해서 전부 허용하였다. 이렇게 설정하면 보안상 어느 클라이언트 에서도 접근이 가능하게 되어 보안상 취약하다는 점도 인지하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;웹소켓에 연결한 클라이언트들을 어떻게 관리했나요?&lt;br /&gt;&lt;/b&gt;작성한 기능이 관리자와 항상 연결된 상태에서 이벤트 생성 시 메시지를 보내는 기능이라 세션관리는 추가적으로 하지 않았다.&lt;b&gt;&lt;br /&gt;&lt;/b&gt;하지만 WebSocketSession을 사용해 단일 서버 환경에서는 ConcurrentHashMap, 스케일 아웃하는 서버 환경에서는 레디스를 활용해 세션을 관리하는 방법을 찾아보며 학습 중이다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 웹소켓을 사용했나요?&lt;br /&gt;&lt;/b&gt;완성된 기능을 기준으로 볼 때 기술 선택이 다소 아쉬웠다고 생각한다&lt;br /&gt;초기에는 제품을 구독한 사용자에게 실시간으로 통신을 하는 방식을 생각해 웹소켓을 사용하였지만 변경된 기능을 보았을 때 서버에서 클라이언트로 데이터를 푸시하기만 하면 되는 기능이 되었고 이는 SSE를 사용하는 것이 더 적합한 기술 이였다고 생각한다. 그리고 항상 연결 상태를 유지 해야하기 때문에 브라우저가 자동으로 재연결을 시도한다는 장점도 있어 더더욱 좋았을 듯 하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;성능상의 문제는 없나요?&lt;br /&gt;&lt;/b&gt;현재는 특정 관리자와 1:1 연결 방식을 사용하므로 연결 리소스 자체의 부담은 적다.&lt;br /&gt;하지만 단시간에 대량의 이벤트가 생성될 경우 성능 개선 방법을 파악하고 있다&lt;br /&gt;예를 들어 10000개의 제품이 이벤트가 등록되었을 때 서버에서는 일일히 이벤트를 발생 시키고 DB도 각각 Insert해야 하는 상황이 생기므로 큰 부하를 줄 수 있다. 또한 클라이언트에서는 갑작스럽게 대량의 메시지를 수신 받아 브라우저가 느려지는 현상도 발생할 수 있다&lt;br /&gt;이를 해결하기 위해 배치처리를 생각하고 있고 insert는 bulk 방식을 사용해 해결할 수 있을 것 같다&lt;/p&gt;</description>
      <category>이론/오늘의 학습 내용 요약</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/292</guid>
      <comments>https://t-era.tistory.com/292#entry292comment</comments>
      <pubDate>Mon, 14 Jul 2025 12:09:02 +0900</pubDate>
    </item>
    <item>
      <title>Chapter 1: WebSocket과 STOMP 프로토콜</title>
      <link>https://t-era.tistory.com/291</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 내용:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;웹소켓 프로토콜의 동작 원리 이해&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;STOMP의 역할과 필요성 인지&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;양방향 통신 vs 지속적 연결&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;양방향 통신 (Full-Duplex)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정의&lt;/b&gt;&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;동시에 양쪽 방향으로 데이터 전송 가능&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;서버 &amp;harr; 클라이언트가 &lt;b&gt;동시에&lt;/b&gt; 일어날 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Java WebSocket 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;// 서버 측 WebSocket 핸들러
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private final Map&amp;lt;String, WebSocketSession&amp;gt; sessions = new ConcurrentHashMap&amp;lt;&amp;gt;();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.put(session.getId(), session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 클라이언트 &amp;rarr; 서버 메시지 수신 (동시에 가능)
        String clientMessage = message.getPayload();
        log.info(&quot;클라이언트 메시지 수신: {}&quot;, clientMessage);

        // 서버 &amp;rarr; 클라이언트 메시지 전송 (동시에 가능)
        try {
            session.sendMessage(new TextMessage(&quot;서버 응답: &quot; + clientMessage));
        } catch (IOException e) {
            log.error(&quot;메시지 전송 실패&quot;, e);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 클라이언트 측 (JavaScript)
const socket = new WebSocket('ws://localhost:8080/chat');

// 클라이언트 &amp;rarr; 서버 (동시에 가능)
socket.send('안녕하세요!');

// 서버 &amp;rarr; 클라이언트 (동시에 가능)
socket.onmessage = function(event) {
    console.log('서버 응답:', event.data);
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&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;실시간 양방향 대화&lt;/b&gt; 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;채팅, 게임, 협업 도구&lt;/b&gt; 등에 적합&lt;/li&gt;
&lt;li&gt;&lt;b&gt;즉시 응답&lt;/b&gt; 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&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;복잡한 상태 관리&lt;/b&gt; 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시성 문제&lt;/b&gt; 발생 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  &lt;b&gt;지속적 연결 (Persistent Connection)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정의&lt;/b&gt;&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;연결을 유지&lt;/b&gt;하여 &lt;b&gt;재연결 오버헤드 제거&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;연결이 끊어지지 않고 계속 유지됨&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Java HTTP Keep-Alive 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 일반 HTTP (연결 매번 생성/해제)
@Service
public class HttpService {

    private final RestTemplate restTemplate;

    public HttpService() {
        // 연결 풀 설정 없음 - 매번 새로운 연결
        this.restTemplate = new RestTemplate();
    }

    public String fetchData(String url) {
        // 매번 새로운 HTTP 연결 생성
        ResponseEntity&amp;lt;String&amp;gt; response = restTemplate.getForEntity(url, String.class);
        return response.getBody();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// HTTP Keep-Alive (연결 유지)
@Configuration
public class HttpConfig {

    @Bean
    public RestTemplate restTemplate() {
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();

        // 연결 유지 설정
        factory.setConnectionRequestTimeout(5000);
        factory.setConnectTimeout(5000);
        factory.setReadTimeout(5000);

        // Keep-Alive 설정
        HttpClient httpClient = HttpClients.custom()
            .setConnectionManager(createConnectionManager())
            .build();

        factory.setHttpClient(httpClient);
        return new RestTemplate(factory);
    }

    private PoolingHttpClientConnectionManager createConnectionManager() {
        PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager();
        manager.setMaxTotal(100); // 최대 연결 수
        manager.setDefaultMaxPerRoute(20); // 라우트당 최대 연결 수
        return manager;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&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;연결 설정 오버헤드 감소&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빠른 응답 시간&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리소스 효율성&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&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;서버 리소스 사용&lt;/b&gt; (연결 유지)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;연결 수 제한&lt;/b&gt; (메모리, 파일 디스크립터)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;비교표&lt;/b&gt;&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;양방향 통신&lt;/th&gt;
&lt;th&gt;지속적 연결&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;목적&lt;/b&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;b&gt;방향성&lt;/b&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;b&gt;사용 사례&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;채팅, 게임, 실시간 협업&lt;/td&gt;
&lt;td&gt;API 호출, 파일 전송&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;복잡도&lt;/b&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;b&gt;리소스&lt;/b&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;WebSocket의 경우 (Java)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;WebSocket은 둘 다 제공&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// 서버 측 WebSocket 설정
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatHandler(), &quot;/chat&quot;)
               .setAllowedOrigins(&quot;*&quot;);
    }

    @Bean
    public WebSocketHandler chatHandler() {
        return new ChatWebSocketHandler();
    }
}

@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private final Map&amp;lt;String, WebSocketSession&amp;gt; sessions = new ConcurrentHashMap&amp;lt;&amp;gt;();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        // 1. 지속적 연결 (Persistent) - 연결 유지
        sessions.put(session.getId(), session);
        log.info(&quot;연결 유지 중... 세션 ID: {}&quot;, session.getId());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 2. 양방향 통신 (Full-Duplex) - 동시 양방향 전송
        String clientMessage = message.getPayload();

        // 클라이언트 &amp;rarr; 서버
        log.info(&quot;클라이언트 메시지: {}&quot;, clientMessage);

        // 서버 &amp;rarr; 클라이언트 (동시에 가능)
        try {
            session.sendMessage(new TextMessage(&quot;서버 응답: &quot; + clientMessage));
        } catch (IOException e) {
            log.error(&quot;메시지 전송 실패&quot;, e);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        // 연결 끊김 처리
        sessions.remove(session.getId());
        log.info(&quot;연결 종료: {}&quot;, session.getId());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실제 사용 예시&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;양방향 통신이 필요한 경우&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 채팅 애플리케이션
@Component
public class ChatService {

    private final SimpMessagingTemplate messagingTemplate;

    public ChatService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    // 클라이언트에서 메시지 수신
    @MessageMapping(&quot;/chat&quot;)
    @SendTo(&quot;/topic/messages&quot;)
    public ChatMessage handleChatMessage(ChatMessage message) {
        log.info(&quot;클라이언트 메시지 수신: {}&quot;, message);

        // 서버에서 즉시 응답 (양방향 통신)
        return new ChatMessage(&quot;서버&quot;, &quot;메시지 수신됨: &quot; + message.getContent());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;지속적 연결이 중요한 경우&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 실시간 알림 시스템
@Service
public class NotificationService {

    private final SimpMessagingTemplate messagingTemplate;

    public NotificationService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    // 서버에서 알림 전송 (단방향이지만 연결 유지)
    public void sendNotification(String userId, String message) {
        messagingTemplate.convertAndSendToUser(
            userId,
            &quot;/topic/notifications&quot;,
            new NotificationMessage(message)
        );
    }

    // 연결 상태 모니터링
    @EventListener
    public void handleSessionConnected(SessionConnectedEvent event) {
        log.info(&quot;사용자 연결됨: {}&quot;, event.getUser().getName());
    }

    @EventListener
    public void handleSessionDisconnected(SessionDisconnectEvent event) {
        log.info(&quot;사용자 연결 끊김: {}&quot;, event.getUser().getName());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;성능 관점&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;양방향 통신&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 동시성 처리가 중요
@Component
public class ConcurrentMessageHandler {

    private final ExecutorService executorService = Executors.newFixedThreadPool(10);

    public void handleConcurrentMessages(List&amp;lt;Message&amp;gt; messages) {
        // 동시에 여러 메시지 처리
        List&amp;lt;CompletableFuture&amp;lt;Void&amp;gt;&amp;gt; futures = messages.stream()
            .map(message -&amp;gt; CompletableFuture.runAsync(() -&amp;gt; {
                processMessage(message);
            }, executorService))
            .collect(Collectors.toList());

        // 모든 메시지 처리 완료 대기
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;지속적 연결&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 연결 풀 관리가 중요
@Configuration
public class WebSocketConnectionConfig {

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();

        // 연결 유지를 위한 설정
        container.setMaxTextMessageBufferSize(8192);
        container.setMaxBinaryMessageBufferSize(8192);
        container.setMaxSessions(10000); // 최대 연결 수 제한
        container.setAsyncSendTimeout(5000);

        return container;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;양방향 통신&lt;/b&gt;: &lt;b&gt;동시성&lt;/b&gt;에 초점 (채팅, 게임)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지속적 연결&lt;/b&gt;: &lt;b&gt;효율성&lt;/b&gt;에 초점 (API, 알림)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;STOMP의 역할과 필요성&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;순수 WebSocket의 한계&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;WebSocket은 단순한 데이터 전송 통로&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 순수 WebSocket - 메시지 구조나 형식에 대한 규칙 없음
@Component
public class RawWebSocketHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        String rawMessage = message.getPayload();

        // 개발자가 직접 메시지 파싱 및 라우팅 로직 구현 필요
        if (rawMessage.startsWith(&quot;CHAT:&quot;)) {
            handleChatMessage(session, rawMessage);
        } else if (rawMessage.startsWith(&quot;NOTIFICATION:&quot;)) {
            handleNotificationMessage(session, rawMessage);
        } else if (rawMessage.startsWith(&quot;GAME:&quot;)) {
            handleGameMessage(session, rawMessage);
        }
        // ... 복잡한 메시지 라우팅 로직
    }

    private void handleChatMessage(WebSocketSession session, String message) {
        // 채팅 메시지 처리 로직
    }

    private void handleNotificationMessage(WebSocketSession session, String message) {
        // 알림 메시지 처리 로직
    }

    private void handleGameMessage(WebSocketSession session, String message) {
        // 게임 메시지 처리 로직
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제점&lt;/b&gt;&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;메시지 형식 규칙 없음&lt;/b&gt;: 개발자가 직접 정의해야 함&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;복잡한 라우팅 로직&lt;/b&gt;: 메시지 타입별 분기 처리 필요&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;구독/발행 모델 구현 어려움&lt;/b&gt;: 직접 구현해야 함&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;비즈니스 로직과 인프라 로직 혼재&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;STOMP의 역할&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;STOMP = WebSocket 위의 하위 프로토콜&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// STOMP 사용 시 - 구조화된 메시지와 명확한 라우팅
@Controller
public class StompController {

    // 채팅 메시지 처리
    @MessageMapping(&quot;/chat&quot;)  // 목적지: /app/chat
    @SendTo(&quot;/topic/chat&quot;)    // 구독자들에게 브로드캐스트
    public ChatMessage handleChatMessage(ChatMessage message) {
        log.info(&quot;채팅 메시지 수신: {}&quot;, message);
        return message;
    }

    // 개인 알림 처리
    @MessageMapping(&quot;/notification&quot;)
    public void handleNotification(NotificationMessage message) {
        // 특정 사용자에게 전송
        messagingTemplate.convertAndSendToUser(
            message.getUserId(),
            &quot;/topic/notifications&quot;,
            message
        );
    }

    // 게임 이벤트 처리
    @MessageMapping(&quot;/game&quot;)
    @SendTo(&quot;/topic/game&quot;)
    public GameEvent handleGameEvent(GameEvent event) {
        log.info(&quot;게임 이벤트 수신: {}&quot;, event);
        return event;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;STOMP 메시지 구조&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// STOMP 메시지 예시
{
  &quot;command&quot;: &quot;SEND&quot;,
  &quot;destination&quot;: &quot;/app/chat&quot;,
  &quot;headers&quot;: {
    &quot;content-type&quot;: &quot;application/json&quot;,
    &quot;user&quot;: &quot;user123&quot;
  },
  &quot;body&quot;: &quot;{\&quot;content\&quot;:\&quot;안녕하세요!\&quot;,\&quot;sender\&quot;:\&quot;user123\&quot;}&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;STOMP의 핵심 기능&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. Destination 기반 라우팅&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 명확한 목적지 정의
@MessageMapping(&quot;/chat&quot;)           // 클라이언트 &amp;rarr; 서버: /app/chat
@SendTo(&quot;/topic/chat&quot;)            // 서버 &amp;rarr; 클라이언트: /topic/chat
@SendToUser(&quot;/topic/notifications&quot;) // 특정 사용자에게: /user/topic/notifications&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 구독/발행(Pub/Sub) 모델&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 클라이언트 측 구독
const stompClient = new StompJs.Client({
    webSocketFactory: () =&amp;gt; new WebSocket('ws://localhost:8080/ws')
});

// 채팅 메시지 구독
stompClient.subscribe('/topic/chat', function(message) {
    const chatMessage = JSON.parse(message.body);
    displayChatMessage(chatMessage);
});

// 개인 알림 구독
stompClient.subscribe('/user/topic/notifications', function(message) {
    const notification = JSON.parse(message.body);
    showNotification(notification);
});

// 게임 이벤트 구독
stompClient.subscribe('/topic/game', function(message) {
    const gameEvent = JSON.parse(message.body);
    handleGameEvent(gameEvent);
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 메시지 헤더 관리&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 헤더 정보 활용
@MessageMapping(&quot;/chat&quot;)
@SendTo(&quot;/topic/chat&quot;)
public ChatMessage handleChatMessage(
    @Payload ChatMessage message,
    @Header(&quot;user&quot;) String user,
    @Header(&quot;timestamp&quot;) String timestamp
) {
    log.info(&quot;사용자 {}의 메시지: {}&quot;, user, message);
    message.setTimestamp(timestamp);
    return message;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;STOMP의 필요성&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 개발 생산성 향상&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// STOMP 없이 구현 시 (복잡함)
@Component
public class ComplexWebSocketHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        String rawMessage = message.getPayload();
        JsonNode jsonNode = objectMapper.readTree(rawMessage);

        String type = jsonNode.get(&quot;type&quot;).asText();
        String destination = jsonNode.get(&quot;destination&quot;).asText();
        String payload = jsonNode.get(&quot;payload&quot;).asText();

        switch (type) {
            case &quot;CHAT&quot;:
                handleChatMessage(session, destination, payload);
                break;
            case &quot;NOTIFICATION&quot;:
                handleNotificationMessage(session, destination, payload);
                break;
            // ... 수많은 분기 처리
        }
    }
}

// STOMP 사용 시 (간단함)
@MessageMapping(&quot;/chat&quot;)
@SendTo(&quot;/topic/chat&quot;)
public ChatMessage handleChatMessage(ChatMessage message) {
    return message; // 비즈니스 로직에만 집중
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 표준화된 메시지 구조&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// STOMP 메시지 구조
public class StompMessage {
    private String command;        // SEND, SUBSCRIBE, MESSAGE 등
    private String destination;    // /app/chat, /topic/notifications 등
    private Map&amp;lt;String, String&amp;gt; headers; // 메타데이터
    private String body;           // 실제 데이터
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 자동 라우팅&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// STOMP가 자동으로 처리하는 것들
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // /app으로 시작하는 메시지는 @MessageMapping으로 라우팅
        config.setApplicationDestinationPrefixes(&quot;/app&quot;);

        // /topic으로 시작하는 메시지는 구독자들에게 브로드캐스트
        config.enableSimpleBroker(&quot;/topic&quot;);

        // /user로 시작하는 메시지는 특정 사용자에게 전송
        config.setUserDestinationPrefix(&quot;/user&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;�� &lt;b&gt;비교표&lt;/b&gt;&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;순수 WebSocket&lt;/th&gt;
&lt;th&gt;STOMP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;메시지 구조&lt;/b&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;b&gt;라우팅&lt;/b&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;b&gt;구독/발행&lt;/b&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;b&gt;개발 복잡도&lt;/b&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;b&gt;유지보수&lt;/b&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;b&gt;확장성&lt;/b&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실제 사용 예시&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;채팅 시스템&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Controller
public class ChatController {

    @MessageMapping(&quot;/chat&quot;)
    @SendTo(&quot;/topic/chat&quot;)
    public ChatMessage handleChat(ChatMessage message) {
        // 비즈니스 로직에만 집중
        message.setTimestamp(LocalDateTime.now());
        return message;
    }

    @MessageMapping(&quot;/private-chat&quot;)
    public void handlePrivateChat(PrivateChatMessage message) {
        // 특정 사용자에게만 전송
        messagingTemplate.convertAndSendToUser(
            message.getRecipientId(),
            &quot;/topic/private-chat&quot;,
            message
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;알림 시스템&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Controller
public class NotificationController {

    @MessageMapping(&quot;/subscribe&quot;)
    public void handleSubscription(SubscriptionRequest request) {
        // 구독 처리
        notificationService.addSubscription(request.getUserId(), request.getProductId());
    }

    // 서버에서 알림 전송
    @EventListener
    public void handleProductEvent(ProductDiscountEvent event) {
        messagingTemplate.convertAndSend(&quot;/topic/product-events&quot;, event);
    }
}&lt;/code&gt;&lt;/pre&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;&lt;b&gt;STOMP는 WebSocket의 한계를 보완하여:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;b&gt;구조화된 메시지&lt;/b&gt; 제공&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;자동 라우팅&lt;/b&gt; 지원&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;구독/발행 모델&lt;/b&gt; 내장&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;개발 생산성&lt;/b&gt; 향상&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;비즈니스 로직 집중&lt;/b&gt; 가능&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/291</guid>
      <comments>https://t-era.tistory.com/291#entry291comment</comments>
      <pubDate>Fri, 11 Jul 2025 13:14:33 +0900</pubDate>
    </item>
    <item>
      <title>Spring WebSocket 알림 기능 구현 및 테스트 경험 정리</title>
      <link>https://t-era.tistory.com/290</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. WebSocketConfig의 핵심 메서드&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) &lt;code&gt;registerStompEndpoints&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드는 &lt;b&gt;클라이언트가 최초로 WebSocket 연결을 맺을 엔드포인트&lt;/b&gt;를 정의.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint(&quot;/ws&quot;)
            .setAllowedOriginPatterns(&quot;*&quot;)
            .withSockJS();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;주요 속성 및 옵션&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;addEndpoint(String path)&lt;/b&gt;&lt;br /&gt;클라이언트가 연결할 엔드포인트 경로를 지정합.&lt;br /&gt;예: &lt;code&gt;/ws&lt;/code&gt; &amp;rarr; 클라이언트는 &lt;code&gt;ws://서버주소/ws&lt;/code&gt;로 연결&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setAllowedOrigins(String... origins)&lt;/b&gt;&lt;br /&gt;CORS 허용 도메인 지정.&lt;br /&gt;예: &lt;code&gt;.setAllowedOrigins(&quot;http://localhost:3000&quot;)&lt;/code&gt;&lt;br /&gt;(Spring 5.3+에서는 보안상 &lt;code&gt;*&lt;/code&gt; 사용이 제한됨)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setAllowedOriginPatterns(String... patterns)&lt;/b&gt;&lt;br /&gt;와일드카드(&lt;code&gt;*&lt;/code&gt;)를 포함한 패턴으로 CORS 허용.&lt;br /&gt;예: &lt;code&gt;.setAllowedOriginPatterns(&quot;*&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;withSockJS()&lt;/b&gt;&lt;br /&gt;브라우저가 WebSocket을 지원하지 않을 때 fallback(롱폴링 등) 지원.&lt;br /&gt;실무에서는 거의 필수!&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setHandshakeHandler(HandshakeHandler handler)&lt;/b&gt;&lt;br /&gt;핸드셰이크 커스텀 처리 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setInterceptors(HandshakeInterceptor... interceptors)&lt;/b&gt;&lt;br /&gt;연결 전후에 인증/로깅 등 인터셉터 추가 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;여러 엔드포인트 등록 가능&lt;/b&gt;&lt;br /&gt;예:
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;registry.addEndpoint(&quot;/ws1&quot;).withSockJS();
registry.addEndpoint(&quot;/ws2&quot;).withSockJS();&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안이 필요한 경우, 특정 도메인만 허용&lt;/b&gt;&lt;br /&gt;예: &lt;code&gt;.setAllowedOrigins(&quot;https://mydomain.com&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) &lt;code&gt;configureMessageBroker&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드는 &lt;b&gt;메시지 라우팅 규칙과 브로커 설정&lt;/b&gt;을 담당.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker(&quot;/topic&quot;);
    registry.setApplicationDestinationPrefixes(&quot;/app&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;주요 속성 및 옵션&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;enableSimpleBroker(String... destinationPrefixes)&lt;/b&gt;&lt;br /&gt;내장 브로커 활성화.&lt;br /&gt;예: &lt;code&gt;/topic&lt;/code&gt;, &lt;code&gt;/queue&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/topic&lt;/code&gt;: publish/subscribe(1:N)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/queue&lt;/code&gt;: point-to-point(1:1, DM 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setApplicationDestinationPrefixes(String... prefixes)&lt;/b&gt;&lt;br /&gt;클라이언트가 서버로 메시지 보낼 때 사용할 prefix 지정&lt;br /&gt;예: &lt;code&gt;/app&lt;/code&gt;&lt;br /&gt;&amp;rarr; 클라이언트가 &lt;code&gt;/app/hello&lt;/code&gt;로 메시지 전송 시, 서버의 @MessageMapping(&quot;hello&quot;)로 라우팅&lt;/li&gt;
&lt;li&gt;&lt;b&gt;enableStompBrokerRelay(...)&lt;/b&gt;&lt;br /&gt;외부 메시지 브로커(RabbitMQ, ActiveMQ 등)와 연동할 때 사용&lt;br /&gt;예:&lt;/li&gt;
&lt;li&gt;&lt;code class=&quot;language-java&quot;&gt;registry.enableStompBrokerRelay(&quot;/topic&quot;, &quot;/queue&quot;)
        .setRelayHost(&quot;localhost&quot;)
        .setRelayPort(61613);&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setUserDestinationPrefix(String prefix)&lt;/b&gt;&lt;br /&gt;사용자별 1:1 메시지 전송 시 사용&lt;br /&gt;예: &lt;code&gt;/user&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;실전 팁&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;여러 prefix 동시 사용 가능&lt;/b&gt;&lt;br /&gt;예:
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;registry.enableSimpleBroker(&quot;/topic&quot;, &quot;/queue&quot;);
registry.setApplicationDestinationPrefixes(&quot;/app&quot;, &quot;/pub&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 브로커 연동 시, 대용량 실시간 서비스에 적합&lt;/b&gt;&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. SimpMessagingTemplate의 다양한 활용법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SimpMessagingTemplate&lt;/code&gt;은 서버에서 클라이언트로 메시지를 전송할 때 사용하는 핵심 컴포넌트.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 기본 메시지 전송&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;messagingTemplate.convertAndSend(&quot;/topic/notification&quot;, message);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해당 토픽을 구독 중인 모든 클라이언트에게 메시지 전송&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 특정 사용자에게 1:1 메시지 전송&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;messagingTemplate.convertAndSendToUser(
    userId, // 세션ID 또는 사용자명
    &quot;/queue/notification&quot;, // 목적지
    message
);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트는 &lt;code&gt;/user/queue/notification&lt;/code&gt;을 구독해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) 헤더 포함 메시지 전송&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;MessageHeaders headers = new MessageHeaders(Map.of(&quot;custom-header&quot;, &quot;value&quot;));
messagingTemplate.convertAndSend(&quot;/topic/notification&quot;, message, headers);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4) 전송 전 메시지 변환&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;messagingTemplate.convertAndSend(&quot;/topic/notification&quot;, myObject, myHeaders);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;myObject는 Jackson 등으로 자동 변환되어 전송&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5) @SendTo, @SendToUser 어노테이션 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에서 직접 반환값을 특정 토픽으로 전송할 수도 있음&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@MessageMapping(&quot;/chat&quot;)
@SendTo(&quot;/topic/messages&quot;)
public ChatMessage send(ChatMessage message) {
    return message;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 기타 실무에서 유용한 WebSocket 설정/기능&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;@MessageExceptionHandler&lt;/b&gt;&lt;br /&gt;WebSocket 통신 중 발생하는 예외를 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@SubscribeMapping&lt;/b&gt;&lt;br /&gt;클라이언트가 구독할 때 최초 1회만 메시지 전송&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@EventListener(ApplicationReadyEvent.class)&lt;/b&gt;&lt;br /&gt;서버 기동 시 자동으로 메시지 전송 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WebSocketSession 관리&lt;/b&gt;&lt;br /&gt;세션별 인증, 연결/해제 이벤트 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Interceptor/HandshakeHandler&lt;/b&gt;&lt;br /&gt;JWT 인증, 커스텀 인증 등&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. Postman과 브라우저(WebSocket/SockJS) 테스트 차이&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;브라우저+SockJS+STOMP&lt;/b&gt;:&lt;br /&gt;Spring 서버와 완벽 호환, 실시간 알림 테스트에 최적&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Postman&lt;/b&gt;:&lt;br /&gt;STOMP 프레임을 직접 작성해야 하며, SockJS fallback 미지원, CORS/인증 문제 등으로 실전 테스트에 부적합&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 결론&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring WebSocket은 다양한 실시간 메시징 시나리오를 지원하며, 설정 옵션도 매우 풍부함&lt;/li&gt;
&lt;li&gt;실무에서는 브라우저 기반 SockJS+STOMP 조합으로 테스트하는 것이 가장 쉽고 확실&lt;/li&gt;
&lt;li&gt;다양한 SimpMessagingTemplate 기능을 활용하면, 1:1 알림, 그룹 알림, 헤더 커스텀 등 복잡한 요구사항도 쉽게 구현 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/290</guid>
      <comments>https://t-era.tistory.com/290#entry290comment</comments>
      <pubDate>Wed, 9 Jul 2025 18:35:36 +0900</pubDate>
    </item>
    <item>
      <title>RestTemplate 직접 생성 vs 빌더 생성 차이와 동작 원리</title>
      <link>https://t-era.tistory.com/289</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. RestTemplate 직접 생성 방식&lt;/h2&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;RestTemplate restTemplate = new RestTemplate();&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;순수한 RestTemplate 인스턴스&lt;/b&gt;만 생성됨.&lt;/li&gt;
&lt;li&gt;Spring Boot의 자동설정(메시지 컨버터, 커넥션 타임아웃, 커넥션 풀 등)이 적용되지 않음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JSON 변환&lt;/b&gt; 등 메시지 컨버터를 직접 추가해야 함.&lt;/li&gt;
&lt;li&gt;커스텀 &lt;code&gt;ClientHttpRequestFactory&lt;/code&gt;를 직접 지정해야 하며, 설정이 번거로움.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;장점:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PATCH 등 일부 HTTP 메서드 지원을 위해 커스텀 팩토리 지정이 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설정이 불편하고, 실수로 인한 오류 가능성 높음.&lt;/li&gt;
&lt;li&gt;Spring Boot의 편리한 자동설정 혜택을 못 받음.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PATCH 메서드 지원&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Java 기본 HttpURLConnection은 PATCH를 지원하지 않음.&lt;/li&gt;
&lt;li&gt;PATCH를 쓰려면 &lt;code&gt;HttpComponentsClientHttpRequestFactory&lt;/code&gt; 등 별도 팩토리 지정 필요.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;RestTemplate restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory());&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. RestTemplateBuilder를 통한 생성 방식&lt;/h2&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Autowired
private RestTemplateBuilder restTemplateBuilder;

RestTemplate restTemplate = restTemplateBuilder.build();&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot가 제공하는 &lt;b&gt;자동설정&lt;/b&gt;(메시지 컨버터, 타임아웃, 커넥션 풀 등)이 모두 적용됨.&lt;/li&gt;
&lt;li&gt;JSON 변환, UTF-8 인코딩 등 대부분의 설정이 자동으로 적용되어 편리함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메서드 체이닝&lt;/b&gt;으로 커스텀 설정도 쉽게 가능.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본적으로 내장 HttpURLConnection을 사용 &amp;rarr; PATCH 메서드 미지원.&lt;/li&gt;
&lt;li&gt;PATCH를 쓰려면 별도 팩토리 지정 필요(직접 생성 방식과 동일).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. RestTemplate과 ClientHttpRequestFactory의 관계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RestTemplate은 직접 네트워크 통신을 하지 않고,&lt;br /&gt;내부적으로 &lt;b&gt;ClientHttpRequestFactory&lt;/b&gt;라는 부품을 사용해 실제 HTTP 요청을 만듦.&lt;/li&gt;
&lt;li&gt;대표적인 팩토리 종류:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;SimpleClientHttpRequestFactory&lt;/code&gt; (기본, HttpURLConnection 기반)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HttpComponentsClientHttpRequestFactory&lt;/code&gt; (Apache HttpClient 기반, PATCH 등 지원)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RestTemplate이 요청을 만들 때, 실제 네트워크 통신은 팩토리가 담당한다.&lt;/b&gt;&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. Spring Boot 자동설정 끄기&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;application.yml&lt;/code&gt;에서 아래처럼 자동설정을 끄면,&lt;br /&gt;RestTemplateBuilder 등에서 제공하는 자동설정이 적용되지 않음.&lt;/li&gt;
&lt;li&gt;이럴 경우, 직접 생성 방식과 동일하게 모든 설정을 직접 해줘야 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;autoconfigure:
  exclude:
    - org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration
    - org.springframework.boot.autoconfigure.http.client.HttpClientAutoConfiguration
    - org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 실무에서 주의할 점 &amp;amp; 추가로 알면 좋은 내용&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;대부분의 경우 RestTemplateBuilder를 사용하는 것이 편리&lt;/b&gt;하고, Spring Boot의 장점을 살릴 수 있음.&lt;/li&gt;
&lt;li&gt;PATCH 등 특수 HTTP 메서드가 필요하다면,&lt;br /&gt;RestTemplateBuilder로 생성하더라도 팩토리를 명시적으로 지정해주면 됨.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;restTemplateBuilder
    .requestFactory(HttpComponentsClientHttpRequestFactory::new)
    .build();&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RestTemplate는 Spring 5.0 이후로는 비동기/고성능이 필요한 경우 WebClient 사용을 권장&lt;/b&gt;함.&lt;/li&gt;
&lt;li&gt;RestTemplate은 스레드 세이프하므로, 빈으로 등록해서 재사용하는 것이 좋음.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 예시 코드와 설명&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private List&amp;lt;SearchProductResponse&amp;gt; productSearch(List&amp;lt;Long&amp;gt; productIds) {
    EventAddProductRequest eventAddProductRequest = new EventAddProductRequest(productIds);
    URI uri = UriComponentsBuilder
            .fromUriString(&quot;http://localhost:8080&quot;)
            .path(&quot;/api/product/search-product&quot;)
            .encode()
            .build()
            .toUri();

    ResponseEntity&amp;lt;List&amp;lt;SearchProductResponse&amp;gt;&amp;gt; response = restTemplate.exchange(
            uri,
            HttpMethod.GET,
            new HttpEntity&amp;lt;&amp;gt;(eventAddProductRequest),
            new ParameterizedTypeReference&amp;lt;List&amp;lt;SearchProductResponse&amp;gt;&amp;gt;() {}
    );
    return response.getBody();
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;EventAddProductRequest eventAddProductRequest = new EventAddProductRequest(productIds);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API에 전달할 요청 객체를 생성.&lt;/li&gt;
&lt;li&gt;이 객체는 productIds(상품 ID 리스트)를 담고 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. URI 생성&lt;/h4&gt;
&lt;pre class=&quot;x86asm&quot;&gt;&lt;code&gt;URI uri = UriComponentsBuilder
        .fromUriString(&quot;http://localhost:8080&quot;)
        .path(&quot;/api/product/search-product&quot;)
        .encode()
        .build()
        .toUri();&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;UriComponentsBuilder&lt;/code&gt;를 사용해 요청할 API의 URI를 만듦.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.encode()&lt;/code&gt;를 호출하면, URI에 특수문자가 포함되어 있을 때 자동으로 인코딩.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.build().toUri()&lt;/code&gt;로 최종 URI 객체를 생성.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. RestTemplate의 exchange 메서드 사용&lt;/h4&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;ResponseEntity&amp;lt;List&amp;lt;SearchProductResponse&amp;gt;&amp;gt; response = restTemplate.exchange(
        uri,
        HttpMethod.GET,
        new HttpEntity&amp;lt;&amp;gt;(eventAddProductRequest),
        new ParameterizedTypeReference&amp;lt;List&amp;lt;SearchProductResponse&amp;gt;&amp;gt;() {}
);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;exchange&lt;/code&gt; 메서드는 다양한 HTTP 메서드(GET, POST, PATCH 등)로 요청을 보낼 수 있음.&lt;/li&gt;
&lt;li&gt;첫 번째 인자: 요청할 URI&lt;/li&gt;
&lt;li&gt;두 번째 인자: HTTP 메서드 (여기서는 GET)&lt;/li&gt;
&lt;li&gt;세 번째 인자: 요청 본문과 헤더를 담는 &lt;code&gt;HttpEntity&lt;/code&gt; 객체 (여기서는 요청 객체만 전달)&lt;/li&gt;
&lt;li&gt;네 번째 인자: 응답 타입을 지정하는데,&lt;br /&gt;&lt;b&gt;제네릭 타입(예: List)&lt;/b&gt;을 받을 때는&lt;br /&gt;&lt;code&gt;new ParameterizedTypeReference&amp;lt;List&amp;lt;SearchProductResponse&amp;gt;&amp;gt;() {}&lt;/code&gt;와 같이 사용해야&lt;br /&gt;타입 정보가 올바르게 전달됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 응답 결과 반환&lt;/h4&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;return response.getBody();&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;응답 객체에서 실제 데이터(List)만 꺼내서 반환.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;단일 객체를 받을 때&lt;/b&gt;는 아래처럼 간단하게 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;restTemplate.getForObject(apiUrl, User.class);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;요청 객체가 필요한 경우&lt;/b&gt;(POST 등)에는 아래처럼 사용.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;restTemplate.postForObject(apiUrl, eventAddProductRequest, User.class);&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;exchange&lt;/b&gt;는 다양한 HTTP 메서드와 복잡한 요청/응답 타입을 지원할 때 유용.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 요청하신 &lt;b&gt;보상 트랜잭션(Compensating Transaction)&lt;/b&gt; 관련 내용을 요약하여,&lt;br /&gt;RestTemplate 정리글에 자연스럽게 추가할 수 있도록 작성한 예시입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. RestTemplate와 트랜잭션의 한계, 그리고 보상 트랜잭션&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RestTemplate은 &lt;b&gt;HTTP 통신을 통해 외부 시스템(마이크로서비스, 외부 API 등)과 데이터를 주고받는 용도&lt;/b&gt;로 사용된다.&lt;/li&gt;
&lt;li&gt;하지만 RestTemplate을 사용한 외부 호출은 &lt;b&gt;Spring의 트랜잭션(@Transactional)과 직접적으로 연동되지 않는다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;즉, 한 서비스 내에서 DB 트랜잭션이 롤백되어도, 이미 외부 시스템에 요청이 나간 경우 그 요청을 자동으로 취소할 수 없다.&lt;/li&gt;
&lt;li&gt;반대로, 외부 시스템에서 실패가 발생해도, 이미 커밋된 로컬 트랜잭션을 자동으로 롤백할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이런 이유로, &lt;b&gt;분산 트랜잭션&lt;/b&gt;이 필요한 상황에서는 RestTemplate만으로는 트랜잭션의 원자성을 보장할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;보상 트랜잭션(Compensating Transaction)이란?&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;보상 트랜잭션&lt;/b&gt;은 분산 환경에서 트랜잭션의 원자성을 보장하기 어려울 때,&lt;br /&gt;실패 시 이미 수행된 작업을 &quot;취소&quot;하는 별도의 작업(보상 작업)을 추가로 수행하는 패턴.&lt;/li&gt;
&lt;li&gt;예를 들어, 서비스A에서 DB에 저장 후, 서비스B에 RestTemplate으로 요청을 보냈는데,&lt;br /&gt;서비스B에서 실패가 발생하면, 서비스A에서 이미 저장한 데이터를 삭제(취소)하는 로직을 추가로 실행하는 방식.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Saga 패턴&lt;/b&gt; 등에서 자주 사용되며, 마이크로서비스 아키텍처에서 데이터 일관성을 맞추기 위해 필수적으로 고려해야 하는 개념.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RestTemplate을 사용하는 경우, &lt;b&gt;트랜잭션을 서비스 간에 직접 공유할 수 없으므로&lt;/b&gt;&lt;br /&gt;데이터 정합성을 위해 보상 트랜잭션(혹은 Saga 패턴 등)을 반드시 고려해야 한다.&lt;/li&gt;
&lt;li&gt;단일 서비스 내 트랜잭션과는 다르게,&lt;br /&gt;외부 시스템과의 연동에서는 &quot;실패 시 어떻게 복구할 것인가&quot;에 대한 설계가 필요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RestTemplateBuilder&lt;/b&gt;를 사용하면 Spring Boot의 자동설정과 편리함을 누릴 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;직접 생성&lt;/b&gt;은 커스텀 설정이 필요할 때만 사용하고, 대부분의 경우 권장되지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PATCH 등 특수 메서드&lt;/b&gt;가 필요하면 팩토리를 명시적으로 지정해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 트랜잭션&lt;/b&gt;이 필요한 상황에서는 문제가 발생할 수 있어서 복구 방법을 설계해야한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실무에서는 WebClient로의 전환도 고려&lt;/b&gt;해보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/289</guid>
      <comments>https://t-era.tistory.com/289#entry289comment</comments>
      <pubDate>Tue, 8 Jul 2025 17:44:03 +0900</pubDate>
    </item>
    <item>
      <title>실시간 데이터 전송 방식 정리</title>
      <link>https://t-era.tistory.com/288</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 데이터 전송 방식의 종류&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;브로드캐스트(Broadcast)&lt;/b&gt;&lt;br /&gt;네트워크에 연결된 모든 디바이스(클라이언트)에게 데이터를 전송하는 방식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유니캐스트(Unicast)&lt;/b&gt;&lt;br /&gt;한 명의 특정 클라이언트에게만 데이터를 전송&lt;/li&gt;
&lt;li&gt;&lt;b&gt;멀티캐스트(Multicast)&lt;/b&gt;&lt;br /&gt;여러 명의 특정 그룹(여러 클라이언트)에게만 데이터를 전송&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. 옛날 방식: Polling&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Polling&lt;/b&gt;&lt;br /&gt;클라이언트가 주기적으로 서버에 데이터를 요청하는 방식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Short-Polling&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일정 주기마다 서버에 요청&lt;/li&gt;
&lt;li&gt;구현이 쉽지만, 불필요한 네트워크 트래픽 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Long-Polling&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 요청 후, 서버에서 데이터가 준비될 때까지 대기&lt;/li&gt;
&lt;li&gt;데이터가 준비되면 응답, 아니면 타임아웃&lt;/li&gt;
&lt;li&gt;Short-Polling보다 오버헤드가 적음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Server-Sent Events (SSE)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특징&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 &amp;rarr; 클라이언트 단방향 실시간 데이터 전송&lt;/li&gt;
&lt;li&gt;HTTP 기반 (Content-Type: &lt;code&gt;text/event-stream&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;브라우저가 자동으로 재연결 시도&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단방향(서버&amp;rarr;클라이언트)만 지원&lt;/li&gt;
&lt;li&gt;브라우저 호환성 이슈(IE 미지원)&lt;/li&gt;
&lt;li&gt;클라이언트 연결 종료 감지 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;간단 예시 (Kotlin/Spring):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
class SseController {
    @GetMapping(&quot;/sse&quot;, produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun sseEmitter(): SseEmitter {
        return SseEmitter().apply {
            send(&quot;안녕하세요! 현재 시간: ${LocalDateTime.now()}&quot;)
            complete()
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. WebSocket&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특징&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 &amp;harr; 클라이언트 양방향 실시간 통신(Full-Duplex)&lt;/li&gt;
&lt;li&gt;저지연, 낮은 오버헤드&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;세션 관리 및 Scale-Out이 어려움&lt;/li&gt;
&lt;li&gt;재연결 로직 직접 구현 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 코드 예시 (Kotlin/Spring):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
@EnableWebSocket
class WebSocketConfig : WebSocketConfigurer {
    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry.addHandler(ChatWebSocketHandler(), &quot;/chat&quot;).setAllowedOrigins(&quot;*&quot;)
    }
}

@Component
class ChatWebSocketHandler : TextWebSocketHandler() {
    private val sessions = mutableSetOf&amp;lt;WebSocketSession&amp;gt;()
    override fun afterConnectionEstablished(session: WebSocketSession) { sessions.add(session) }
    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        sessions.forEach { it.sendMessage(TextMessage(&quot;에코: ${message.payload}&quot;)) }
    }
    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { sessions.remove(session) }
}&lt;/code&gt;&lt;/pre&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;클라이언트가 HTTP 요청에 &lt;code&gt;Upgrade: websocket&lt;/code&gt; 헤더를 추가해 프로토콜 업그레이드 요청&lt;/li&gt;
&lt;li&gt;서버가 101 Switching Protocols로 응답, WebSocket 연결 성립&lt;/li&gt;
&lt;li&gt;이후 데이터는 Frame 단위로 양방향 송수신&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. STOMP (Simple Text Oriented Messaging Protocol)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특징&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WebSocket 위에서 동작하는 메시징 프로토콜&lt;/li&gt;
&lt;li&gt;목적지(토픽) 기반 메시지 송수신&lt;/li&gt;
&lt;li&gt;외부 브로커(RabbitMQ, ActiveMQ 등) 연동 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 코드 예시 (Kotlin/Spring):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfiguration : WebSocketMessageBrokerConfigurer {
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint(&quot;/ws&quot;)
    }
    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.setApplicationDestinationPrefixes(&quot;/app&quot;)
        registry.enableSimpleBroker(&quot;/topic&quot;)
    }
}

@Controller
class GreetingController {
    @MessageMapping(&quot;/greeting&quot;)
    fun handle(greeting: String): String = &quot;[${getTimestamp()}] $greeting&quot;
    private fun getTimestamp() = SimpleDateFormat(&quot;MM/dd/yyyy h:mm:ss a&quot;).format(Date())
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 &lt;code&gt;/topic/greeting&lt;/code&gt;을 구독하면, &lt;code&gt;/app/greeting&lt;/code&gt;으로 메시지 전송 시 브로커가 구독자에게 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polling&lt;/td&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;Long-Polling&lt;/td&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;SSE&lt;/td&gt;
&lt;td&gt;서버&amp;rarr;클라이언트 단방향&lt;/td&gt;
&lt;td&gt;표준, 간단&lt;/td&gt;
&lt;td&gt;단방향, IE 미지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;양방향, Full-Duplex&lt;/td&gt;
&lt;td&gt;실시간, 저지연&lt;/td&gt;
&lt;td&gt;세션/스케일 관리 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STOMP&lt;/td&gt;
&lt;td&gt;토픽 기반 메시징(WebSocket)&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/288</guid>
      <comments>https://t-era.tistory.com/288#entry288comment</comments>
      <pubDate>Tue, 1 Jul 2025 16:59:40 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot + AWS S3 프로필 이미지 API 구현</title>
      <link>https://t-era.tistory.com/287</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot에서 AWS S3를 활용해 사용자 프로필 이미지를 관리하는 API를 구현해보기&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1️⃣ 환경 설정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AWS S3 버킷 생성&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버킷명: &lt;code&gt;your-project-bucket&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;리전: &lt;code&gt;ap-northeast-2&lt;/code&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;환경변수 설정&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;DB_USERNAME=your_db_username // RDS DB master
DB_PASSWORD=your_db_password // RDS DB master password
AWS_ACCESS_KEY=your_aws_access_key
AWS_SECRET_KEY=your_aws_secret_key
AWS_REGION=ap-northeast-2
AWS_S3_BUCKET=your-project-bucket&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;build.gradle 의존성&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'software.amazon.awssdk:s3:2.24.12'&lt;/code&gt;&lt;/pre&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;S3 설정 클래스&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
public class S3Config {
    @Value(&quot;${AWS_ACCESS_KEY}&quot;)
    private String accessKey;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS S3 클라이언트를 Bean으로 등록하여 애플리케이션에서 사용할 수 있도록 설정한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;S3 서비스 핵심 메서드&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
public class S3Service {
    public String uploadProfileImage(MultipartFile file, Long userId) {
        validateFile(file);  // 파일 검증
        String key = &quot;profile-image/&quot; + userId + &quot;/&quot; + generateFileName(file);

        s3Client.putObject(PutObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .build(), RequestBody.fromInputStream(file.getInputStream(), file.getSize()));

        return getFileUrl(key);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설명&lt;/b&gt;: 파일을 S3에 업로드하고 URL을 반환하는 메서드. 파일 검증과 중복 방지를 위한 파일명 생성도 포함.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;User 엔티티 수정&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Entity
public class User {
    private String profileImageUrl;  // 추가

    public void updateProfileImage(String profileImageUrl) {
        this.profileImageUrl = profileImageUrl;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설명&lt;/b&gt;: 사용자 엔티티에 프로필 이미지 URL 필드와 업데이트 메서드를 추가&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨트롤러 엔드포인트&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RestController
public class UserController {
    @PostMapping(&quot;/users/profile-image&quot;)
    public ResponseEntity&amp;lt;ProfileImageResponse&amp;gt; uploadProfileImage(
            @AuthenticationPrincipal AuthUser authUser,
            @RequestParam(&quot;image&quot;) MultipartFile image) {
        // 이미지 업로드 로직
    }

    @DeleteMapping(&quot;/users/profile-image&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; deleteProfileImage(@AuthenticationPrincipal AuthUser authUser) {
        // 이미지 삭제 로직
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설명&lt;/b&gt;: 이미지 업로드와 삭제를 위한 REST API 엔드포인트를 구현.&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;JAR 빌드 및 업로드&lt;/h3&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;./gradlew clean build -x test
scp -i &quot;your-key.pem&quot; build/libs/your-app.jar ec2-user@your-ec2-ip:~/&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;EC2 환경변수 설정&lt;/h3&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# 환경변수 추가
echo &quot;export AWS_ACCESS_KEY=your_key&quot; &amp;gt;&amp;gt; ~/.bashrc
echo &quot;export AWS_S3_BUCKET=your_bucket&quot; &amp;gt;&amp;gt; ~/.bashrc
source ~/.bashrc&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;포트 확인 및 프로세스 정리&lt;/h3&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;sudo lsof -i :8080  # 포트 사용 확인
kill -9 &amp;lt;PID&amp;gt;       # 기존 프로세스 종료&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 실행&lt;/h3&gt;
&lt;pre class=&quot;haml&quot;&gt;&lt;code&gt;nohup java \
-DAWS_ACCESS_KEY=${AWS_ACCESS_KEY} \
-DAWS_S3_BUCKET=${AWS_S3_BUCKET} \
-jar your-app.jar &amp;gt; ~/app.log 2&amp;gt;&amp;amp;1 &amp;amp;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4️⃣ API 테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 및 로그인&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# 회원가입 (JWT 토큰 획득)
curl -X POST http://localhost:8080/auth/signup \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;email&quot;: &quot;test@example.com&quot;, &quot;password&quot;: &quot;Test1234&quot;}'&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이미지 업로드 테스트&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;# 테스트 이미지 준비
curl -o test-image.jpg https://dummyimage.com/300x300/000/fff

# 이미지 업로드
curl -X POST \
  http://localhost:8080/users/profile-image \
  -H &quot;Authorization: Bearer YOUR_JWT_TOKEN&quot; \
  -F &quot;image=@test-image.jpg&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예상 결과&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;imageUrl&quot;: &quot;https://your-bucket.s3.amazonaws.com/profile-image/1/1_uuid.jpg&quot;,
  &quot;message&quot;: &quot;프로필 이미지가 성공적으로 업로드되었습니다.&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용자 정보 조회&lt;/h3&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;curl -X GET \
  http://localhost:8080/users/1 \
  -H &quot;Authorization: Bearer YOUR_JWT_TOKEN&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예상 결과&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;id&quot;: 1,
  &quot;email&quot;: &quot;test@example.com&quot;,
  &quot;profileImageUrl&quot;: &quot;https://your-bucket.s3.amazonaws.com/profile-image/1/1_uuid.jpg&quot;
}&lt;/code&gt;&lt;/pre&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;포트 충돌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: &lt;code&gt;Port 8080 was already in use&lt;/code&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;: &lt;code&gt;sudo lsof -i :8080&lt;/code&gt; &amp;rarr; &lt;code&gt;kill -9 &amp;lt;PID&amp;gt;&lt;/code&gt;&lt;/p&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;문제&lt;/b&gt;: &lt;code&gt;@Value&lt;/code&gt;에서 환경변수를 찾지 못함&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;: &lt;code&gt;source ~/.bashrc&lt;/code&gt; 실행&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;S3 권한 에러&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: S3 업로드 시 권한 에러&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;: IAM 사용자에게 S3 접근 권한 부여&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 완성된 기능&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;이미지 업로드&lt;/b&gt;: S3에 안전하게 저장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파일 검증&lt;/b&gt;: 크기(5MB), 확장자(jpg, png, gif) 검증&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중복 처리&lt;/b&gt;: 기존 이미지 자동 삭제&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JWT 인증&lt;/b&gt;: 보안된 API 접근&lt;/li&gt;
&lt;li&gt;&lt;b&gt;환경변수 관리&lt;/b&gt;: 민감 정보 보안&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/287</guid>
      <comments>https://t-era.tistory.com/287#entry287comment</comments>
      <pubDate>Tue, 1 Jul 2025 15:12:07 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot EC2 배포 &amp;amp; 테스트 과정에서 문제 해결</title>
      <link>https://t-era.tistory.com/286</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 목표&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot 애플리케이션을 AWS EC2에 배포&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/health&lt;/code&gt; 엔드포인트가 인증 없이 정상적으로 동작하도록 구현&lt;/li&gt;
&lt;li&gt;실시간 배포/운영 환경에서 발생한 문제와 해결 과정 기록&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. JAR 빌드 및 업로드&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;./gradlew clean build -x test
scp -i &quot;/Users/ljy/Library/KeyPairs/tera199810-KeyPair.pem&quot; build/libs/expert-0.0.2-SNAPSHOT.jar ec2-user@15.165.127.92:~/&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. EC2 접속 및 기존 프로세스 종료&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;ssh -i &quot;/Users/ljy/Library/KeyPairs/tera199810-KeyPair.pem&quot; ec2-user@15.165.127.92

# 실행 중인 프로세스 확인 및 종료
ps -ef | grep expert-0.0.2-SNAPSHOT.jar
kill -9 &amp;lt;PID&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. 서버 실행&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;nohup java \
-Dspring.profiles.active=aws \
-Dspring.datasource.url=&quot;jdbc:mysql://&amp;lt;RDS-ENDPOINT&amp;gt;:3306/&amp;lt;DB_NAME&amp;gt;?allowPublicKeyRetrieval=true&amp;amp;useSSL=false&amp;amp;characterEncoding=UTF-8&amp;amp;serverTimezone=UTC&quot; \
-Dspring.jpa.hibernate.ddl-auto=update \
-DDB_SCHEME=&amp;lt;DB_NAME&amp;gt; \
-DDB_USERNAME=&amp;lt;DB_USER&amp;gt; \
-DDB_PASSWORD='&amp;lt;DB_PASSWORD&amp;gt;' \
-DSECRET_KEY='&amp;lt;SECRET_KEY&amp;gt;' \
-DAWS_ACCESS_KEY='&amp;lt;AWS_ACCESS_KEY&amp;gt;' \
-DAWS_SECRET_KEY='&amp;lt;AWS_SECRET_KEY&amp;gt;' \
-jar expert-0.0.2-SNAPSHOT.jar &amp;gt; ~/app.log 2&amp;gt;&amp;amp;1 &amp;amp;&lt;/code&gt;&lt;/pre&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;3-1. /health 400 Bad Request 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;증상: &lt;code&gt;/health&lt;/code&gt; 호출 시 400 Bad Request 발생&lt;/li&gt;
&lt;li&gt;원인:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JwtFilter에서 &lt;code&gt;/auth&lt;/code&gt;만 토큰 검사 예외, &lt;code&gt;/health&lt;/code&gt;는 예외 처리 안 됨&lt;/li&gt;
&lt;li&gt;Authorization 헤더 없으면 400 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  해결&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// JwtFilter.java
String url = httpRequest.getRequestURI();
if (url.startsWith(&quot;/auth&quot;) || url.startsWith(&quot;/health&quot;)) {
    chain.doFilter(request, response);
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/health&lt;/code&gt;도 JWT 검사 없이 통과하도록 코드 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. JAR 파일 교체 및 반영&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 JAR 삭제 후 새 JAR 업로드&lt;/li&gt;
&lt;li&gt;서버 재실행&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-3. 포트 충돌(8080 already in use)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;증상: 서버 실행 시 &quot;Port 8080 was already in use&quot; 에러&lt;/li&gt;
&lt;li&gt;원인: 이전 프로세스가 정상 종료되지 않아 8080 포트 점유&lt;/li&gt;
&lt;li&gt;해결:
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sudo lsof -i :8080
kill -9 &amp;lt;PID&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&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;pre class=&quot;vala&quot;&gt;&lt;code&gt;curl http://localhost:8080/health
# 또는 외부에서
curl http://15.165.127.92:8080/health&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;결과:
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;ok&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 느낀점 &amp;amp; 팁&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JAR 교체 시 기존 프로세스 종료, 포트 충돌 주의&lt;/li&gt;
&lt;li&gt;로그를 통한 원인 분석이 가장 빠른 해결책&lt;/li&gt;
&lt;li&gt;배포 자동화/스크립트화 방법을 알아봐야겠다. 수정할 때마다 업로드를 하다보니 생산성이 매우 낮아짐을 느꼈다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Programing/Spring</category>
      <author>블스뜸</author>
      <guid isPermaLink="true">https://t-era.tistory.com/286</guid>
      <comments>https://t-era.tistory.com/286#entry286comment</comments>
      <pubDate>Mon, 30 Jun 2025 18:29:58 +0900</pubDate>
    </item>
  </channel>
</rss>