<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>가롱 Log</title>
    <link>https://galong.tistory.com/</link>
    <description>꾸준함으로 성장하는 개발자 &amp;zwj; </description>
    <language>ko</language>
    <pubDate>Mon, 27 Jul 2026 00:46:50 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>galong</managingEditor>
    <image>
      <title>가롱 Log</title>
      <url>https://tistory1.daumcdn.net/tistory/7052666/attach/796d73bcc55249c4b4d9a375b856397f</url>
      <link>https://galong.tistory.com</link>
    </image>
    <item>
      <title>[Java] 최단거리 구하기 알고리즘 (가중치 있는 경우/ 없는 경우)</title>
      <link>https://galong.tistory.com/39</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 가중치가 없을 때 &amp;rarr; BFS&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 간선의 비용이 같을 때 (예: 한 칸 이동 = 1)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BFS는 가까운 노드부터 차례대로 탐색하기 때문에 처음 도착한 거리가 최단거리이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 칸당 이동하는 데 드는 비용이 같고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;int[][] map 환경에서 A에서 B까지 가는 데에 걸리는 최단 거리를 구하는 문제가 나오면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 풀면 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;import java.util.*;

class Solution {
    static int[] dx = {1, -1, 0, 0};
    static int[] dy = {0, 0, 1, -1};

    public int bfs(int[][] map) {
        int n = map.length;
        int m = map[0].length;
        
        boolean[][] visited = new boolean[n][m];
        Queue&amp;lt;int[]&amp;gt; q = new LinkedList&amp;lt;&amp;gt;();
        
        q.offer(new int[]{0, 0, 1}); // x, y, 거리
        visited[0][0] = true;
        
        while(!q.isEmpty()) {
            int[] now = q.poll();
            int x = now[0];
            int y = now[1];
            int dist = now[2];
            
            if(x == n-1 &amp;amp;&amp;amp; y == m-1) return dist;
            
            for(int i = 0; i &amp;lt; 4; i++) {
                int nx = x + dx[i];
                int ny = y + dy[i];
                
                if(nx &amp;gt;= 0 &amp;amp;&amp;amp; ny &amp;gt;= 0 &amp;amp;&amp;amp; nx &amp;lt; n &amp;amp;&amp;amp; ny &amp;lt; m) {
                    if(!visited[nx][ny] &amp;amp;&amp;amp; map[nx][ny] == 1) {
                        visited[nx][ny] = true;
                        q.offer(new int[]{nx, ny, dist + 1});
                    }
                }
            }
        }
        return -1;
    }
}
&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;bfs로 풀어 Queue 선언&lt;/li&gt;
&lt;li&gt;Queue에는 {start, end, distance} : &lt;b&gt;start에서 end까지 가는데 움직인 거리 distance를 저장한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;상하좌우 보면서 움직일 수 있고, 방문한 적이 없으면 다음 좌표와 distance+1를 해서 queue에 값을 추가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;시간 복잡도는&lt;b&gt; O(V+E)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 가중치가 있을 때 &amp;rarr; 다익스트라&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;길마다 비용이 다를 때,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 현재까지 비용이 적은 노드부터 탐색 &amp;rarr; PriorityQueue 사용&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;import java.util.*;

class Solution {
    static final int INF = Integer.MAX_VALUE;
    static ArrayList&amp;lt;int[]&amp;gt;[] graph; // {다음정점, 가중치}
    static int[] dist;
    
    // graph에 a -&amp;gt; b 까지 가는 거리 저장 
    graph = new ArrayList[n + 1];
		for (int i = 1; i &amp;lt;= n; i++) {
		  graph[i] = new ArrayList&amp;lt;&amp;gt;();
		}
		
		// 간선 추가 (u -&amp;gt; v, 비용 w)
		graph[u].add(new int[]{v, w});
    
    public void dijkstra(int start, n) {
	    PriorityQueue&amp;lt;int[]&amp;gt; pq = new PriorityQueue&amp;lt;&amp;gt;({o1, o2} -&amp;gt; {
		    return o1[1] - o2[1]; // 비용 기준 오름차순
	    });
	    
	    dist = new int[n+1];
	    Arrays.fill(dist, INF); // 초기화 : 모든 값을 INF로 채우기 
	    
	    dist[start] = 0; // 처음 시작 index는 거리 0
	    pq.offer(new int[]{start, 0}); // 정점, 비용
	    
	    while(!pq.isEmpty()){
		    int[] now = pq.poll();
		    int node = now[0];
		    int cost = now[1];
		    
		    // 이미 더 짧은 경로가 있으면 스킵
		    if(cost &amp;gt; distance[node]) continue;
		    
		    for(int[] next = graph[node]) {
			    int nextNode = next[0];
			    int weight = next[1];
			    
			    int newCost = cost + weight;
			    if(newCost &amp;lt; distance[nextNode]) {
				    distnace[nextNode] = newCost;
				    pq.offer(new int[]{nextNode, newCost});
			    }
			    
		    }
	    }
	    
    
    }
}
&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;초기화 : graph에 a -&amp;gt; b 까지 가는 거리 저장&lt;/li&gt;
&lt;li&gt;처음 시작 위치에서 각 위치까지 걸리는 최단 거리 구하기 (dijkstra)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PriorityQueue&amp;lt;&amp;gt; 에 {nextNode, cost}를 저장해서 cost 기준으로 오름차순으로 정렬&lt;/li&gt;
&lt;li&gt;pq에 {start, 0}을 넣고 pq가 비지 않을 때까지(while)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만약 현재 cost가 distance[node] 보다 크면 continue&lt;/li&gt;
&lt;li&gt;node에서 갈 수 있는 nextNode(for문)&lt;/li&gt;
&lt;li&gt;현재까지 온 cost + nextNode로 가는 weight (newCost) 가 distance[nextNode] 보다 작으면 갱신 &amp;rArr; 처음 시작(start) 위치에서 nextNode로 갈 수 있는 최단 거리 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;시간 복잡도 O(E logV)&lt;/span&gt;&lt;/p&gt;</description>
      <category>Algorithms</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/39</guid>
      <comments>https://galong.tistory.com/39#entry39comment</comments>
      <pubDate>Tue, 10 Feb 2026 16:40:53 +0900</pubDate>
    </item>
    <item>
      <title>[TIL]  코테에서 자주 쓰는 StringBuilder 메서드 정리</title>
      <link>https://galong.tistory.com/38</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;문자열을 반복해서 다뤄야 할때, String 대신 StringBuilder를 사용하면 시간을 단축할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;String은 불변 객체이기 때문에, 반복적인 문자열 결합시 불필요한 객체 생성 &amp;rarr; 시간 초과를 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코테에서 피해야 할 실수&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;String s = &quot;&quot;;
s += &quot;a&quot; // 시간초과 위험 높음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&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;1️⃣&amp;nbsp;append() : 문자열 추가&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;StringBuilder sb = new StringBuilder();
sb.append(&quot;hello&quot;);
sb.append(&quot; &quot;);
sb.append(&quot;world&quot;);
&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;&lt;b&gt;2️⃣&amp;nbsp;toString() : 최종 문자열로 변환&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;String result = sb.toString();
&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;&lt;b&gt;3️⃣&amp;nbsp;길이 확인&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;int len = sb.length();
&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;&lt;b&gt;4️⃣&amp;nbsp;charAt() / setCharAt() &amp;mdash; 특정 위치 접근 및 수정&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;charAt()&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;char c = sb.charAt(3);
&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;setCharAt&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;sb.setCharAt(0, 'A); // 0번째 값 'A'로 치환
&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;5️⃣&amp;nbsp;&lt;b&gt;insert() / delete() &amp;mdash; 삽입 및 삭제&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;/ul&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;sb.insert(2, &quot;ABC&quot;); // &quot;hello&quot; -&amp;gt; &quot;heABCello&quot;
&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;delete()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스 범위 (start, end)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;sb.delete(1, 4); // &quot;hello&quot; -&amp;gt; &quot;ho&quot; , 1~3 길이 삭제&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;delete(-4) 같은 표현은 X&lt;/li&gt;
&lt;li&gt;시작과 끝을 알려줘야함.&amp;nbsp;&amp;nbsp;&lt;/li&gt;
&lt;/ul&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;deleteCharAt()&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sb.deleteCharAt(0); // 특정 문자 삭제
&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;&lt;b&gt;6️⃣&amp;nbsp;reverse() - 문자열 뒤집기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;sb.reverse();
&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;li&gt;숫자 뒤집기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;7️⃣&amp;nbsp;substring() - 문자열 부분 추출&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;인덱스 범위 : [start, end]&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;String sub = sb.subString(1,4);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; String으로 반환함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;8️⃣&amp;nbsp;문자열 비교 시 주의점&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;sb.toString().equals(&quot;abc&quot;)
&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;StringBuilder는 equals()로 문자열 비교 ❌&lt;/li&gt;
&lt;li&gt;반드시 toString() 후 비교&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Algorithms</category>
      <category>java</category>
      <category>StringBuilder</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/38</guid>
      <comments>https://galong.tistory.com/38#entry38comment</comments>
      <pubDate>Mon, 12 Jan 2026 17:45:47 +0900</pubDate>
    </item>
    <item>
      <title>MSA 환경에서 JitPack으로 공통 모듈 관리하기</title>
      <link>https://galong.tistory.com/37</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;MSA 프로젝트에서 각 서비스마다 공통으로 사용하는 코드를 효율적으로 관리하기 위해 JitPack을 활용했습니다. 이 글에서 JitPack이 무엇인지와, 공통 모듈을 배포하고 서비스에 사용하는 방법을 정리해보려고 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Jitpack이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jitpack.io/&quot;&gt;https://jitpack.io/&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jipack은 Git 저장소를 Maven / Gradle 의존성으로 바로 사용할 수 있게 해주는 패키지 배포 서비스이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 공통 모듈을 여러 서비스에서 사용하려면 각 서비스에 직접 포함시키거나(코드중복), 사설 저장소를 구축해서 사용할 수 있다. 하지만 이 방식들은 모듈 변경 시 서비스 별로 동기화가 필요하거나 저장소 운영비용이 드는 단점이 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Jitpack은 이러한 문제를 Git 저장소 기반 배포라는 방식으로 해결한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Jitpack 주요 기능&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jitpack은 다음과 같은 흐름으로 동작한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;GitHub(GitLab 등)에 있는 저장소&lt;/li&gt;
&lt;li&gt;특정 대그 또는 커밋&lt;/li&gt;
&lt;li&gt;빌드 시점에 Jitpack이 직접 빌드&lt;/li&gt;
&lt;li&gt;빌드 결과를 Maven Repository 형태로 제공&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Git 저장소에 태그만 찍으면, 바로 의존성으로 사용할 수 있다.&amp;nbsp;&amp;nbsp;&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;왜 MSA에서 JitPack을 사용했는가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSA 환경에서는 여러 서비스가 공통 코드를 공유하는 경우가 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공통 응답 객체 (ApiResponse, BaseEntity)&lt;/li&gt;
&lt;li&gt;공통 예외 처리 (GlobalExceptionHandler, AppException, ErrorCode &amp;hellip;)&lt;/li&gt;
&lt;li&gt;공통 유틸 클래스 (PageableUtils, CursorUtils &amp;hellip;)&lt;/li&gt;
&lt;li&gt;인증/인가 관련 공통 로직 (UserRole, AuthUser&amp;hellip; )&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 공통 모듈을 각 서비스에 직접 복사하거나, 서브 모듈로 관리하면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공통 코드 수정 시 모든 서비스에 반영 필요&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jitpack을 사용하면&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;/li&gt;
&lt;li&gt;Gradle/Maven 의존성 추가만으로 즉시 사용 가능 (쉬움)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점이 있어서 JitPack을 도입했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공통 모듈을 jitpack으로 배포하는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣&amp;nbsp;공통 모듈 프로젝트 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 여러 서비스에서 공통으로 사용할 코드를 하나의 모듈로 분리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공통 응답 dto&lt;/li&gt;
&lt;li&gt;BaseEntity&lt;/li&gt;
&lt;li&gt;공통 예외 및 에러코드&lt;/li&gt;
&lt;li&gt;swagger 설정&lt;/li&gt;
&lt;li&gt;공통 유틸 클래스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등등&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (33).png&quot; data-origin-width=&quot;278&quot; data-origin-height=&quot;386&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvuid9/dJMcabCUtlT/gRkf1eXBwIuEzvcTfKqSz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvuid9/dJMcabCUtlT/gRkf1eXBwIuEzvcTfKqSz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvuid9/dJMcabCUtlT/gRkf1eXBwIuEzvcTfKqSz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbvuid9%2FdJMcabCUtlT%2FgRkf1eXBwIuEzvcTfKqSz0%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;278&quot; height=&quot;386&quot; data-filename=&quot;image (33).png&quot; data-origin-width=&quot;278&quot; data-origin-height=&quot;386&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣&amp;nbsp;JitPack 배포를 위한 Gradle 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JitPack은 Git 저장소를 직접 빌드하기 때문에,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle에 선언된 라이브러리의 버전이 명확하게 정의되어있어야한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (34).png&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;521&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ds7OwU/dJMcahwmqhm/9iwvATIoL8CEhpzin7AJpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ds7OwU/dJMcahwmqhm/9iwvATIoL8CEhpzin7AJpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ds7OwU/dJMcahwmqhm/9iwvATIoL8CEhpzin7AJpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fds7OwU%2FdJMcahwmqhm%2F9iwvATIoL8CEhpzin7AJpK%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;499&quot; height=&quot;421&quot; data-filename=&quot;image (34).png&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;521&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.3.5'
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전이 명시되지 않은 의존성이 있으면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jitPack 빌드 과정에서 실패할 수 있으므로 주의해야한다.&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;3️⃣&amp;nbsp;공통 모듈 변경 후 버전 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 모듈에 기능을 추가하거나 코드를 수정한 경우,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;반드시 버전을 증가시켜야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예) 1.0.0 &amp;rarr; 1.0.1 , 1.0.9 &amp;rarr; 1.1.0&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (35).png&quot; data-origin-width=&quot;178&quot; data-origin-height=&quot;163&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C2p4n/dJMcafSRFKS/4a0XhhXcaH0GoIiALY2o30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C2p4n/dJMcafSRFKS/4a0XhhXcaH0GoIiALY2o30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C2p4n/dJMcafSRFKS/4a0XhhXcaH0GoIiALY2o30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC2p4n%2FdJMcafSRFKS%2F4a0XhhXcaH0GoIiALY2o30%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;178&quot; height=&quot;163&quot; data-filename=&quot;image (35).png&quot; data-origin-width=&quot;178&quot; data-origin-height=&quot;163&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4️⃣&amp;nbsp;Git 태그 생성 및 Push&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;git commit 과 push 하고,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;git tag 1.0.7{현재 버전}&lt;/li&gt;
&lt;li&gt;git push origin 1.0.7{현재버전}&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하면 github tag에 올라간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시)&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;git commit -m &quot;release: 1.0.7&quot;
git push

git tag 1.0.7
git push origin 1.0.7&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o7tyv/dJMcahQFGjJ/aVwOuiofrhkpNOQUshnKik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o7tyv/dJMcahQFGjJ/aVwOuiofrhkpNOQUshnKik/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;366&quot; data-origin-height=&quot;334&quot; data-filename=&quot;image (36).png&quot; style=&quot;width: 34.6009%; margin-right: 10px;&quot; data-widthpercent=&quot;35.01&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o7tyv/dJMcahQFGjJ/aVwOuiofrhkpNOQUshnKik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo7tyv%2FdJMcahQFGjJ%2FaVwOuiofrhkpNOQUshnKik%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;366&quot; height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czXyRh/dJMcaiPwTVC/WGVIgAeJJzyvIDMrWD1Du1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czXyRh/dJMcaiPwTVC/WGVIgAeJJzyvIDMrWD1Du1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;291&quot; data-filename=&quot;image (37).png&quot; style=&quot;width: 64.2364%;&quot; data-widthpercent=&quot;64.99&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czXyRh/dJMcaiPwTVC/WGVIgAeJJzyvIDMrWD1Du1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczXyRh%2FdJMcaiPwTVC%2FWGVIgAeJJzyvIDMrWD1Du1%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;592&quot; height=&quot;291&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5️⃣&amp;nbsp;JitPack에서 배포 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub에 태그가 올라가면, JitPack은 해당 태그를 기준으로 공통 모듈을 빌드하고 배포한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 및 빌드 상태는 아래 JitPack 페이지에서 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로젝트의 {organization 이름}/{repository 이름} 을 검색해서 접근할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jitpack.io/&quot;&gt;https://jitpack.io/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시) KokKok-dev/Tablekok-common&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드가 성공하면 해당 버전을 Gradle/ Maven 의존성으로 바로 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (38).png&quot; data-origin-width=&quot;1972&quot; data-origin-height=&quot;870&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Sk9qH/dJMcaiPwTWd/4oI0oCkFkUK1aW83D2FJf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Sk9qH/dJMcaiPwTWd/4oI0oCkFkUK1aW83D2FJf0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Sk9qH/dJMcaiPwTWd/4oI0oCkFkUK1aW83D2FJf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSk9qH%2FdJMcaiPwTWd%2F4oI0oCkFkUK1aW83D2FJf0%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;629&quot; height=&quot;278&quot; data-filename=&quot;image (38).png&quot; data-origin-width=&quot;1972&quot; data-origin-height=&quot;870&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서비스에서 JitPack 공통 모듈 사용하는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣&amp;nbsp;JitPack Repository 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jitpack.io/&quot;&gt;https://jitpack.io/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 링크에서도 어떻게 사용하는지 방법을 알려준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (39).png&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;417&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Da4Bh/dJMcabiCGYT/f5uoUkjvI019YPTFGCikqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Da4Bh/dJMcabiCGYT/f5uoUkjvI019YPTFGCikqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Da4Bh/dJMcabiCGYT/f5uoUkjvI019YPTFGCikqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDa4Bh%2FdJMcabiCGYT%2Ff5uoUkjvI019YPTFGCikqK%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;560&quot; height=&quot;315&quot; data-filename=&quot;image (39).png&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;417&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 서비스의 build.gralde에 JitPack 저장소를 추가한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;repositories {
    mavenCentral()
    maven { url '&amp;lt;https://jitpack.io&amp;gt;' }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JitPack은 Maven Repository 형태로 동작하기 때문에 기존 mavenCentral() 설정과 함께 추가해주면 된다.&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;2️⃣&amp;nbsp;공통 모듈 의존성 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 JitPack에 배포한 공통 모듈을 의존성으로 추가한다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'com.github.KokKok-dev:Tablekok-common:1.0.0'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 사용하는 버전은 &lt;b&gt;GitHub에 생성한 태그 버전&lt;/b&gt;과 동일해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 모듈에 변경 사항이 생기면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그 버전을 올리고, 서비스에서는 해당 버전만 변경해 적용하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣&amp;nbsp;공통 모듈 사용 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성 설정을 했으면, 공통 모듈에 정의된 클래스들을 일반 라이브러리처럼 바로 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BaseEntity tablekok.entity 라이브러리에서 가지고 올 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (40).png&quot; data-origin-width=&quot;379&quot; data-origin-height=&quot;143&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CXAXo/dJMcaiveayC/EgLskDQ69iAFxgtb5gJ8nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CXAXo/dJMcaiveayC/EgLskDQ69iAFxgtb5gJ8nk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CXAXo/dJMcaiveayC/EgLskDQ69iAFxgtb5gJ8nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCXAXo%2FdJMcaiveayC%2FEgLskDQ69iAFxgtb5gJ8nk%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;379&quot; height=&quot;143&quot; data-filename=&quot;image (40).png&quot; data-origin-width=&quot;379&quot; data-origin-height=&quot;143&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;0&quot;&gt;ApiResponse도 가지고 올 수 있다.&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span style=&quot;background-color: #000000; color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;&lt;/span&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (41).png&quot; data-origin-width=&quot;373&quot; data-origin-height=&quot;132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dy6G8Q/dJMcafFjVLC/1RQQPbmqoQ8P8cOifMgQiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dy6G8Q/dJMcafFjVLC/1RQQPbmqoQ8P8cOifMgQiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dy6G8Q/dJMcafFjVLC/1RQQPbmqoQ8P8cOifMgQiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdy6G8Q%2FdJMcafFjVLC%2F1RQQPbmqoQ8P8cOifMgQiK%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;373&quot; height=&quot;132&quot; data-filename=&quot;image (41).png&quot; data-origin-width=&quot;373&quot; data-origin-height=&quot;132&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GlobalExceptionHandler도 가지고 올 수 있다.&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;background-color: #000000; color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;&lt;/span&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (42).png&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;121&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byGSmS/dJMcabCUtp9/AYMVvhcH4zyVp2OpkFXUmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byGSmS/dJMcabCUtp9/AYMVvhcH4zyVp2OpkFXUmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byGSmS/dJMcabCUtp9/AYMVvhcH4zyVp2OpkFXUmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyGSmS%2FdJMcabCUtp9%2FAYMVvhcH4zyVp2OpkFXUmk%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;586&quot; height=&quot;121&quot; data-filename=&quot;image (42).png&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;121&quot;/&gt;&lt;/span&gt;&lt;/figure&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;모두 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;com.tablekok&lt;/span&gt; &lt;/span&gt;패키지 하위에 정의된 것으로 그대로 사용할 수 있다.&lt;/p&gt;</description>
      <category>MSA</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/37</guid>
      <comments>https://galong.tistory.com/37#entry37comment</comments>
      <pubDate>Sun, 11 Jan 2026 23:22:25 +0900</pubDate>
    </item>
    <item>
      <title>[MSA] Spring Cloud 기반 MSA 아키텍처 개념 정리</title>
      <link>https://galong.tistory.com/36</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;정리&lt;br /&gt;- MSA 개념&lt;br /&gt;- 모놀리식 vs MSA&lt;br /&gt;- Spring Cloud&lt;br /&gt;- Eureka&lt;br /&gt;- FeignClient + LoadBalancing&lt;br /&gt;- 서킷브레이커: Resilience4j&lt;br /&gt;- API Gateway&lt;br /&gt;- Spring Cloud Config&lt;b&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;MSA란&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Microservices Architecture(MSA) 는 &lt;b&gt;하나의 애플리케이션을 여러 개의 독립적인 서비스로 분리하여 개발, 배포, 유지보수를 용이하게 하는 소프트웨어 아키텍처 스타일이다.&lt;/b&gt; 각 서비스는 특정 비즈니스 기능을 수행하며, 서로 독립적으로 배포되고 확장될 수 있다. 서비스 간의 통신은 HTTP/HTTPS, 메시지 큐 등을 통해 이루어진다.&lt;/p&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;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;모놀리식 아키텍처와 비교&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2jzY4/dJMcafywjse/RlBmgupkvwMyD7ZdF9hok1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2jzY4/dJMcafywjse/RlBmgupkvwMyD7ZdF9hok1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;663&quot; data-filename=&quot;image (30).png&quot; width=&quot;355&quot; style=&quot;width: 49.5817%; margin-right: 10px;&quot; data-widthpercent=&quot;50.17&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2jzY4/dJMcafywjse/RlBmgupkvwMyD7ZdF9hok1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2jzY4%2FdJMcafywjse%2FRlBmgupkvwMyD7ZdF9hok1%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;869&quot; height=&quot;663&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSUopc/dJMcahpAgG5/5x67bFuvxKzhKIfwHtpUcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSUopc/dJMcahpAgG5/5x67bFuvxKzhKIfwHtpUcK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;672&quot; data-filename=&quot;image (31).png&quot; width=&quot;384&quot; style=&quot;width: 49.2555%;&quot; data-widthpercent=&quot;49.83&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSUopc/dJMcahpAgG5/5x67bFuvxKzhKIfwHtpUcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSUopc%2FdJMcahpAgG5%2F5x67bFuvxKzhKIfwHtpUcK%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;875&quot; height=&quot;672&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;왼: 모놀리식 아키텍처 / 오: msa 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.2868%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 39.9612%;&quot;&gt;모놀리틱 아키텍처&lt;/td&gt;
&lt;td style=&quot;width: 47.7519%;&quot;&gt;MSA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.2868%;&quot;&gt;정의&lt;/td&gt;
&lt;td style=&quot;width: 39.9612%;&quot;&gt;* 하나의 큰 코드 베이스로 구성된 애플리케이션&lt;br /&gt;* 모든 기능이 하나의 애플리케이션 내에 포함&lt;/td&gt;
&lt;td style=&quot;width: 47.7519%;&quot;&gt;* 여러 개의 독립적인 서비스로 구성된 애플리케이션 &lt;br /&gt;* 각 서비스는 특정 비즈니스 기능을 수행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.2868%;&quot;&gt;장점&lt;/td&gt;
&lt;td style=&quot;width: 39.9612%;&quot;&gt;* 간단한 배포&lt;br /&gt;* 단일 데이터베이스&lt;/td&gt;
&lt;td style=&quot;width: 47.7519%;&quot;&gt;* 확장성 &lt;br /&gt;* 독립적 배포 &lt;br /&gt;* 유연성 : 서비스별로 적합한 기술 스택을 선택할 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.2868%;&quot;&gt;단점&lt;/td&gt;
&lt;td style=&quot;width: 39.9612%;&quot;&gt;* 확장성 부족 &lt;br /&gt;* 긴 개발 주기 &lt;br /&gt;* 유연성 부족&lt;/td&gt;
&lt;td style=&quot;width: 47.7519%;&quot;&gt;* 복잡성 증가 &lt;br /&gt;* 운영비용 증가 &lt;br /&gt;* 데이터 관리 : 분산된 데이터베이스로 인해 데이터 일관성 유지가 어려울 수 있음 &lt;br /&gt;* 네트워크 지연 : 서비스 간의 통신이 네트워크를 통해 이루어지므로 지연 시간이 발생할 수 있음 &lt;br /&gt;* 서비스마다 통신&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Spring Cloud란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이크로서비스 개발을 위해 다양한 도구와 서비스를 제공하는 스프링 프레임워크의 확장이다.&lt;/p&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;code&gt;Eureka&lt;/code&gt;&lt;/b&gt;, Consul, Zookeeper&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로드 밸런싱&lt;/b&gt;: &lt;b&gt;&lt;code&gt;Ribbon&lt;/code&gt;&lt;/b&gt;, Spring Cloud LoadBalancer&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서킷 브레이커&lt;/b&gt;: &lt;b&gt;&lt;code&gt;Hystrix&lt;/code&gt;&lt;/b&gt;, Resilience4j&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 게이트웨이&lt;/b&gt;: &lt;code&gt;Zuul&lt;/code&gt;, Spring Cloud Gateway&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구성 관리&lt;/b&gt;: &lt;code&gt;Spring Cloud Config&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 추적&lt;/b&gt;: Spring Cloud Sleuth, &lt;code&gt;Zipkin&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메시징&lt;/b&gt;: Spring Cloud Stream&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Eureka : 서비스 등록 및 디스커버리&lt;/b&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;/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;헬스 체크(Health check): 서비스 인스턴스의 상태를 주기적으로 확인하여 가용성을 보장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Ribbon : 로드 밸런싱&lt;/b&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;/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;Eureka로부터 서비스 인스턴스 리스트를 제공받아 로드 밸런싱에 사용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;로드밸런싱 알고리즘: &lt;b&gt;라운드 로빈&lt;/b&gt;, 가중치 기반 등 다양한 로드 밸런싱 알고리즘 지원&lt;/li&gt;
&lt;li&gt;Failover: 요청 실패 시 다른 인스턴스로 자동 전환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Hystrix : 서킷 브레이커&lt;/b&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;/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;Failback: 호출 실패 시 대체 로직을 제공하여 시스템 안정성 확보&lt;/li&gt;
&lt;li&gt;모니터링: Hystrix Dashboard를 통해 서킷 브레이커 상태 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Resilience4j : 서킷 브레이커&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Resilience4j는 자바 기반의 경량 서킷 브레이커 라이브러리로, 넷플릭스 Hystrix의 대안으로 개발&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;서킷 브레이커&lt;/b&gt;: 호출 실패를 감지하고 서킷을 열어 추가적인 호출을 차단하여 시스템의 부하를 줄임&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Failback&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;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Zuul : API 게이트웨이&lt;/b&gt;&lt;/h4&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;&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;라우팅: 요청 URL에 따라 적절한 서비스로 요청 전달&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;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Spring Cloud Config : 구성관리&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Cloud Config는 분산된 환경에서 중앙 집중식 설정 관리를 제공&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;Config 서버: 중앙에서 설정 파일을 관리하고 각 서비스에 제공&lt;/li&gt;
&lt;li&gt;Config 클라이언트: Config 서버에서 설정을 받아서 사용하는 서비스&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;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;서비스 디스커버리 : Eureka&lt;/h1&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;마이크로서비스 아키텍처에서 각 서비스의 위치를 동적으로 관리하고 찾아주는 기능을 한다. 각 서비스는 등록 서버(eureka)에 자신의 위치를 등록하고 각 서비스간의 통신도 이를 조회하여 위치를 찾는다. 주요 기능으로는 서비스 등록, 서비스 조회, 헬스 체크 등이 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Eureka란?&lt;/h2&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;h1&gt;로드밸런싱 : 클라이언트 사이드 로드 밸런싱 개요&lt;/h1&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;여러 서버로 분산시켜 서버의 부하를 줄이고&lt;/b&gt;, 시스템의 성능과 가용성을 높이는 기술을 의미한다. 서버 간 트래픽을 고르게 분배하여 특정 서버에 부하가 집중되는 것을 방지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;종류 : &lt;b&gt;클라이언트 사이드 로드 밸런싱&lt;/b&gt;, 서버 사이드 로드 밸런싱&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 사이드 로드 밸런싱이란?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 직접 여러 서버 중 하나를 선택하여 요청을 보내는 방식&lt;/li&gt;
&lt;li&gt;클라이언트는 서버의 목록을 가지고 있으며, 이를 바탕으로 로드 밸런싱 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;각 서비스 간의 통신 방법 중 FeignClient란?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;FeignClient란?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;FeignClient는 Spring Cloud에서 제공하는 HTTP 클라이언트로, 선언적으로 Restful 웹 서비스를 호출할 수 있음&lt;/li&gt;
&lt;li&gt;Eureka와 같은 서비스 디스커버리와 연동하여 동적으로 서비스 인스턴스를 조회하고 로드 밸런싱 수행&lt;/li&gt;
&lt;/ul&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;선언적 HTTP 클라이언트 : 인터페이스와 어노테이션을 사용하여 REST API 호출&lt;/li&gt;
&lt;li&gt;Eureka 연동&lt;/li&gt;
&lt;li&gt;자동 로드 밸런싱 : Ribbon이 통합되어 있어 자동으로 로드 밸런싱 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시 시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Order 서비스는 Product 서비스를 호출하여 상품 정보를 가져옵니다. 이 과정에서 어떻게 동작하는지 설명하겠습니다.&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;Order 서비스 인스턴스&lt;/b&gt;: 1개&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Product 서비스 인스턴스&lt;/b&gt;: 3개&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Order 서비스 실행&lt;/b&gt;: Order 서비스가 실행되면 Eureka 서버에서 Product 서비스 인스턴스 목록을 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Product 서비스 호출&lt;/b&gt;: Order 서비스에서 Product 서비스의 정보를 가져오기 위해 FeignClient를 사용하여 호출합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Ribbon을 통한 로드 밸런싱&lt;/b&gt;: FeignClient는 Ribbon을 통해 3개의 Product 인스턴스 중 하나를 선택하여 호출합니다. 이 과정에서 Round Robin 알고리즘을 사용하여 요청을 순차적으로 분배합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;응답 처리&lt;/b&gt;: 선택된 Product 인스턴스에서 응답을 받아 Order 서비스에 반환하고, 최종적으로 클라이언트에 응답을 전달합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;attachment:a7a73180-2cd4-4957-a1a9-e9516e125505:image.png&quot; alt=&quot;image.png&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;서킷브레이커 : Resilience4j&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서킷브레이커란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이크로서비스 간의 호출 실패를 감지하고, 시스템의 전체적인 안정성을 유지하는 패턴이다. 외부 서비스 호출 실패 시 빠른 실패를 통해 장애를 격리하고, 시스템의 다른 부분에 영향을 주지 않도록 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태 변화 : 클로즈드 &amp;rarr; 오픈 &amp;rarr; 하프-오픈&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;클로즈드&lt;/code&gt;, &lt;code&gt;오픈&lt;/code&gt;, &lt;code&gt;하프-오픈&lt;/code&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;code&gt;클로즈드(Closed)&lt;/code&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;시킵니다.&lt;/li&gt;
&lt;li&gt;이 상태에서 호출이 실패하면 실패 카운터가 증가합니다.&lt;/li&gt;
&lt;li&gt;실패율이 설정된 임계값(예: 50%)을 초과하면 서킷 브레이커가 오픈 상태로 전환됩니다.&lt;/li&gt;
&lt;li&gt;예시: 최근 5번의 호출 중 3번이 실패하여 실패율이 60%에 도달하면 오픈 상태로 전환됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;오픈(Open)&lt;/code&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;&lt;/li&gt;
&lt;li&gt;이 상태에서 요청이 실패하지 않고 바로 에러 응답을 반환합니다.&lt;/li&gt;
&lt;li&gt;설정된 대기 시간이 지난 후, 서킷 브레이커는 하프-오픈 상태로 전환됩니다.&lt;/li&gt;
&lt;li&gt;예시: 서킷 브레이커가 오픈 상태로 전환되고 20초 동안 모든 요청이 차단됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;하프-오픈(Half-Open)&lt;/code&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;li&gt;요청이 다시 실패하면 서킷 브레이커는 다시 오픈 상태로 전환됩니다.&lt;/li&gt;
&lt;li&gt;예시: 하프-오픈 상태에서 3개의 요청을 허용하고, 모두 성공하면 클로즈드 상태로 전환됩니다. 만약 하나라도 실패하면 다시 오픈 상태로 전환됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Fallback 매커니즘&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Fallback 메서드는 외부 서비스 호출이 실패했을 때 대체 로직을 제공하는 메서드&lt;/li&gt;
&lt;/ul&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;/ul&gt;
&lt;h1&gt;API Gateway&lt;/h1&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 게이트웨이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트의 요청을 받아 백엔드 서비스로 라우팅하고, 다양한 부가 기능을 제공하는 중간 서버이다. 클라이언트와 서비스 간의 단일 진입점 역할을 하며, 보안, 로깅, 모니터링, 요청 필터링 등을 처리한다.&lt;/p&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;: 클라이언트 요청을 적절한 서비스로 전달&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;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Cloud Gateway란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 프로젝트의 일환으로 개발된 API 게이트웨이로, 클라이언트 요청을 적절한 서비스로 라우팅하고 다양한 필터링 기능을 제공한다.&lt;/p&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;: 요청의 URL 패턴에 따라 동적으로 라우팅&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;Spring Cloud Gateway 필터링&lt;/h2&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;b&gt;Global Filter&lt;/b&gt;: 모든 요청에 대해 작동하는 필터&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Gateway Filter&lt;/b&gt;: 특정 라우트에만 적용되는 필터&lt;/li&gt;
&lt;/ul&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;code&gt;GlobalFilter&lt;/code&gt; 또는 &lt;code&gt;GatewayFilter&lt;/code&gt; 인터페이스를 구현하고, &lt;code&gt;filter&lt;/code&gt; 메서드를 오버라이드해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Config&lt;/h1&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Cloud Config 란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산 시스템 환경에서 중앙 집중식 구성 관리를 제공하는 프레임워크이다. 애플리케이션의 설정을 중앙에서 관리하고, 변경 사항을 실시간으로 반영할 수 있다. Git, 파일 시스템, JDBC 등 다양한 저장소를 지원한다.&lt;/p&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;</description>
      <category>MSA</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/36</guid>
      <comments>https://galong.tistory.com/36#entry36comment</comments>
      <pubDate>Wed, 7 Jan 2026 22:43:15 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 토스페이먼츠 결제 연동 구현하기 - 2편</title>
      <link>https://galong.tistory.com/35</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이전 편에서는 토스페이먼츠 결제 플로우를 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://galong.tistory.com/34&quot;&gt;https://galong.tistory.com/34&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 결제 연동 구현한 것을 정리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식이 옳은 방식은 아닐 수도 있으므로, 참고만 부탁드립니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 좋은 방식이 있으면 댓글도 부탁드립니다.  &amp;zwj;♀️&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;들어가며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 토스페이먼츠 결제 위젯을 활용해&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 &amp;rarr; 결제 요청 &amp;rarr; 결제 승인 &amp;rarr; 결과 페이지 이동까지의 흐름을 정리했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;주문서 진입 시 Payment 객체를 미리 생성해 서버 기준 결제 정보를 저장해두고, 결제 위젯 성공 이후에는 preparing 단계를 거치게 했습니다.&lt;/li&gt;
&lt;li&gt;preparing 단계에서 파라미터로 받은 paymentKey와 amount 정보 등을 서버에 보내 검증했습니다.&lt;/li&gt;
&lt;li&gt;그리고 외부 toss api 호출을 하여 최종 결제 승인을 요청합니다.&lt;/li&gt;
&lt;li&gt;결제 승인 결과에 따라 success 또는 fail 화면을 client에게 보여줍니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. checkout.html 구성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 연동 시작은 주문서 페이지(checkout.html) 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1-1. 주문서 진입 시 Payment 객체 생성&lt;/h4&gt;
&lt;pre id=&quot;code_1767617324105&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostMapping(&quot;/checkout&quot;)
public String checkout(@RequestParam(&quot;userId&quot;) Long userId, @RequestParam(&quot;orderId&quot;) UUID orderId,
	@RequestParam(&quot;orderName&quot;) String orderName, @RequestParam(&quot;amount&quot;) Long amount, Model model) {

	User user = userService.findByUser(userId);
	Order order = orderService.findByOrder(orderId);

	// Payment 객체 생성
	paymentPageService.createPayment(user, order);

	List&amp;lt;String&amp;gt; itemNames = order.getOrderItems()
		.stream()
		.map(oi -&amp;gt; oi.getMenuItem().getName())
		.collect(Collectors.toList());

	// view에 전달
	model.addAttribute(&quot;user&quot;, user);
	model.addAttribute(&quot;order&quot;, order);
	model.addAttribute(&quot;itemNames&quot;, itemNames);
	return &quot;checkout&quot;; 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문서 페이지에 진입하면 서버에서는 Payment 객체를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에 Payment를 생성하는 이유는 &lt;b&gt;이후 결제 승인 단계에서 클라이언트 위변조 여부 검증을 위해 미리 생성합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Payment.java&lt;/p&gt;
&lt;pre id=&quot;code_1767617345528&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Payment extends BaseEntity {

	@Id
	@GeneratedValue(strategy = GenerationType.UUID)
	private UUID id;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = &quot;user_id&quot;)
	private User user;

	@OneToOne
	@JoinColumn(name = &quot;order_id&quot;)
	private Order order;

	private String tossOrderId;

	@Enumerated(EnumType.STRING)
	private PaymentType paymentType;

	private BigInteger amount;

	@Enumerated(EnumType.STRING)
	private PaymentStatus paymentStatus;

	@Column(name = &quot;payment_key&quot;)
	private String paymentKey;

	@Enumerated(EnumType.STRING)
	private FailReason failReason;

	private String refundReason;

	private LocalDateTime approvedAt;
	
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1-2. 결제 위젯 초기화 및 결제 요청&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 요청 시 필요한 값들은 JavaScript에서도 사용해야해서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;js 변수에는 /[[${...}]]/ 방식을 사용해서 저 3가지 값을 가지고 옵니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;checkout.html&lt;/p&gt;
&lt;pre id=&quot;code_1767617388573&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script th:inline=&quot;javascript&quot;&amp;gt;
    /*&amp;lt;![CDATA[*/
    const totalAmount = /*[[${order.totalPrice}]]*/ 0;
    const orderId = /*[[${order.id}]]*/ '';
    const itemNames = /*[[${itemNames}]]*/ [];
    const orderName = itemNames.join(', ');
    const userId = /*[[${user.id}]]*/ 0;
    /*]]&amp;gt;*/

&amp;lt;/script&amp;gt;&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;main() 함수에서는 토스페이먼츠 결제 위젯을 초기화하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 버튼 클릭 시 결제 요청을 수행합니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;checkout.html&lt;/p&gt;
&lt;pre id=&quot;code_1767617409128&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function main() {
  const button = document.getElementById(&quot;payment-button&quot;);
  const coupon = document.getElementById(&quot;coupon-box&quot;);

  // ------ 결제위젯 초기화 ------
  const clientKey = &quot;test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm&quot;;
  const tossPayments = TossPayments(clientKey);
  const widgets = tossPayments.widgets({customerKey: &quot;eyOAk1FoZbRguExkPqAH_&quot;});

  // ------ 주문의 결제 금액 설정 ------
  await widgets.setAmount({currency: &quot;KRW&quot;, value: totalAmount});

  await Promise.all([
      widgets.renderPaymentMethods({selector: &quot;#payment-method&quot;, variantKey: &quot;DEFAULT&quot;}),
      widgets.renderAgreement({selector: &quot;#agreement&quot;, variantKey: &quot;AGREEMENT&quot;})
  ]);

  // ------ 쿠폰 체크 시 결제 금액 업데이트 ------
  coupon.addEventListener(&quot;change&quot;, async function () {
      const amountToPay = coupon.checked ? totalAmount - 5000 : totalAmount;
      await widgets.setAmount({currency: &quot;KRW&quot;, value: amountToPay});
  });

  const successUrl = window.location.origin + &quot;/preparing&quot;;
  const failUrl = window.location.origin + &quot;/fail&quot;;

  // ------ 결제하기 버튼 클릭 시 결제 요청 ------
  button.addEventListener(&quot;click&quot;, async function () {
      await widgets.requestPayment({
          orderId: finalOrderId,
          orderName: orderName,
          successUrl: successUrl,
          failUrl: failUrl,
          customerEmail: customerEmail,
          customerName: customerName,
          customerMobilePhone: customerMobileReplace,
      });
  });
}&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;여기에서 주의해야할 점은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 결제 금액은 서버 기준 값 사용&lt;/p&gt;
&lt;pre id=&quot;code_1767617455372&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;await widgets.setAmount({ value: totalAmount });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 전달한 order.totalPrice 로 set.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. successUrl을 /preparing 으로 설정&lt;/p&gt;
&lt;pre id=&quot;code_1767617505690&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const successUrl = window.location.origin + &quot;/preparing&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 위젯에서 성공적으로 승인이 일어나면 /preparing 로 redirect 됩니다.&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;2. &lt;b&gt;preparingUrl에서 결제 승인 요청을 처리하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;checkout 페이지에서 결제 위젯을 통해 사용자 정보 입력과 카드사 승인이 완료되면, 결제 위젯은 설정해둔 successUrl로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 구현에서는 successUrl을 바로 성공 페이지로 두지 않고, &lt;b&gt;중간 단계인 preparingUrl을 거치도록 설계했습니다.&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-1. 카드사 승인 이후 preparingUrl 로 redirect&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 위젯에서 카드사 승인이 완료되면 다음과 같은 파라미터와 함께 preparingUrl로 이동합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;paymentKey&lt;/li&gt;
&lt;li&gt;orderId&lt;/li&gt;
&lt;li&gt;amount&lt;/li&gt;
&lt;li&gt;userId&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;이때 paymentKey 발급됩니다!&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1767617576155&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/preparing&quot;)
public String preparing(Long userId, Model model) {
	model.addAttribute(&quot;userId&quot;, userId);
	return &quot;preparing&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-2. preparing.html&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;preparing.html 전체 코드&lt;/p&gt;
&lt;pre id=&quot;code_1767617599874&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;body&amp;gt;
&amp;lt;h2&amp;gt;결제 준비중...&amp;lt;/h2&amp;gt;
&amp;lt;p id=&quot;paymentKey&quot;&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;p id=&quot;orderId&quot;&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;p id=&quot;amount&quot;&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;script&amp;gt;
    // 쿼리 파라미터 값이 결제 요청할 때 보낸 데이터와 동일한지 반드시 확인하세요.
    // 클라이언트에서 결제 금액을 조작하는 행위를 방지할 수 있습니다.
    const urlParams = new URLSearchParams(window.location.search);
    const userId = urlParams.get(&quot;userId&quot;);
    const paymentKey = urlParams.get(&quot;paymentKey&quot;);
    const orderId = urlParams.get(&quot;orderId&quot;);
    const amount = urlParams.get(&quot;amount&quot;);

    async function confirm() {
        const requestData = {
            paymentKey: paymentKey,
            orderId: orderId,
            amount: amount,
        };

        const response = await fetch(&quot;/v1/test/payment/confirm&quot;, {
            method: &quot;POST&quot;,
            headers: {
                &quot;Content-Type&quot;: &quot;application/json&quot;,
            },
            body: JSON.stringify(requestData),
        });

        const json = await response.json();

        if (!response.ok) {
            // TossConfirmPageException 발생 &amp;rarr; fail 페이지로 redirect
            window.location.href = `/fail&amp;amp;code=${json.code}&amp;amp;message=${encodeURIComponent(json.message)}`;
            return;
        } else {
            window.location.href = `/success&amp;amp;code=${json.code}&amp;amp;message=${encodeURIComponent(json.message)}&amp;amp;paymentKey=${paymentKey}&amp;amp;orderId=${orderId}&amp;amp;amount=${amount}`;
        }

        console.log(&quot;결제 승인 성공:&quot;, json);
    }

    confirm();

    const paymentKeyElement = document.getElementById(&quot;paymentKey&quot;);
    const orderIdElement = document.getElementById(&quot;orderId&quot;);
    const amountElement = document.getElementById(&quot;amount&quot;);

    orderIdElement.textContent = &quot;주문번호: &quot; + orderId;
    amountElement.textContent = &quot;결제 금액: &quot; + amount;
    paymentKeyElement.textContent = &quot;paymentKey: &quot; + paymentKey;
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prearing.html은 결제 승인(confirm)을 서버에 요청하기 위한 중간 처리 페이지입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1767617613727&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;h2&amp;gt;결제 준비중...&amp;lt;/h2&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 화면이 잠깐 노출되는 동안,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서는 쿼리 파라미터로 전달받은 값들을 추출합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1767617628821&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const urlParams = new URLSearchParams(window.location.search);
const userId = urlParams.get(&quot;userId&quot;);
const paymentKey = urlParams.get(&quot;paymentKey&quot;);
const orderId = urlParams.get(&quot;orderId&quot;);
const amount = urlParams.get(&quot;amount&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;이 값들은 신뢰할 수 있는 값이 아니라서 서버에서 반드시 검증을 해야합니다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-3. 결제 승인(confirm) 요청을 서버로 전달&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;preparingUrl에서는 서버에 paymentKey, orderId, amount를 requestBody에 담아 보냅니다.&lt;/p&gt;
&lt;pre id=&quot;code_1767617663753&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const requestData = {
    paymentKey: paymentKey,
    orderId: orderId,
    amount: amount,
};

const response = await fetch(&quot;/v1/test/payment/confirm&quot;, {
    method: &quot;POST&quot;,
    headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;,
    },
    body: JSON.stringify(requestData),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-4. 서버: 토스에 결제 승인 요청&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;preparing 페이지에서 전달받은 paymentKey, orderId, amount를 기반으로 &lt;b&gt;서버에서 토스 결제 승인 API를 호출&lt;/b&gt;합니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1767617685865&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@ResponseBody
@PostMapping(&quot;/v1/test/payment/confirm&quot;)
public ResponseEntity&amp;lt;BaseResponseDto&amp;lt;?&amp;gt;&amp;gt; confirmPayment(@RequestBody ConfirmPaymentPageRequestDto requestDto) {
	ConfirmPaymentPageResponseDto response = paymentPageService.confirmPayment(requestDto);
	return ResponseEntity.ok(BaseResponseDto.success(&quot;결제 요청을 승인합니다.&quot;, response));
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1767617693055&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
@Override
public ConfirmPaymentPageResponseDto confirmPayment(ConfirmPaymentPageRequestDto requestDto) {
    String orderIdWithTimestamp = validateAndExtractOrderId(requestDto.getOrderId());
    Order order = findOrderOrThrow(orderIdWithTimestamp);

    validatePaymentStatus(order); // 1. payment 객체 상태가 PENDING 인지 확인
    Payment payment = findPaymentByOrderOrThrow(order);
    validateAmount(payment, requestDto.getAmount()); // 2. 서버에 담긴 값과 같은지 확인

    ConfirmPaymentPageResponseDto responseDto;

    try {
        responseDto = tossPaymentWebClient.confirmPayment(requestDto); // 3. 토스 서버에 결제 승인 요청

    } catch (TossConfirmPageException ex) {
        paymentTransactionService.markPaymentFailed(payment); // 3-1. 결제 실패 시 payment 객체 결제 상태 FAILED로 변경
        throw ex;
    }

    updatePaymentAndOrder(payment, order, responseDto, orderIdWithTimestamp); // 4. 최종결제 승인 후, payment 객체와 order 객체 상태 변경

    return responseDto;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;validatePaymentStatus()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;payment 객체 상태가 PENDING 인지 검증합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;validateAmount()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 함수에서 이전에 생성해둔 Payment에 저장된 amount 값과 client가 보낸 amount 값이 같은지 검증합니다.&lt;/li&gt;
&lt;li&gt;클라이언트의 금액 조작을 방지하기 위해 검증합니다.&lt;/li&gt;
&lt;li&gt;토스 승인 요청 전에 반드시 서버 기준 값으로 검증을해야합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;토스 서버에 결제 승인 요청.&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-5. 토스 결제 승인 API 호출&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞 단계에서 결제 금액과 결제 상태를 모두 검증했다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 서버는 토스 페이먼츠에 최종 결제 승인을 요청합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1767617721481&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;try {
    responseDto = tossPaymentWebClient.confirmPayment(requestDto); // 3. 토스 서버에 결제 승인 요청
}&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;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;토스 결제 승인 API 호출 방식&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1767617743743&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public ConfirmPaymentPageResponseDto confirmPayment(ConfirmPaymentPageRequestDto requestDto) {
    String widgetSecretKey = secretKey;
    String authorizations =
        &quot;Basic &quot; + Base64.getEncoder().encodeToString((widgetSecretKey + &quot;:&quot;).getBytes(StandardCharsets.UTF_8));

    return webClient.post()
        .uri(&quot;/payments/confirm&quot;)
        .contentType(MediaType.APPLICATION_JSON)
        .header(&quot;Authorization&quot;, authorizations)
        .bodyValue(requestDto)
        .retrieve()
        .onStatus(status -&amp;gt; !status.is2xxSuccessful(), clientResponse -&amp;gt;
            clientResponse.bodyToMono(TossErrorResponseDto.class)
                .flatMap(error -&amp;gt; Mono.error(
                    new TossConfirmPageException(error.getCode(), error.getMessage())
                ))
        )
        .bodyToMono(ConfirmPaymentPageResponseDto.class)
        .block();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/payments/confirm API는 Secrekt Key를 사용해 인증합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 키는 절대 클라이언트에 노출되면 안됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;webClient 비동기 방식으로 외부 api를 요청했습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 토스 서버에서 승인 실패 응답이 내려오면 TossConfirmPageException() 커스텀 예외처리를합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;실패 시&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1767617782250&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;catch (TossConfirmPageException ex) {
    paymentTransactionService.markPaymentFailed(payment); // 3-1. 결제 실패 시 payment 객체 결제 상태 FAILED로 변경
    throw ex;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스 결제 승인 요청 중 오류가 나거나, 서버 검증 단계에서 문제가 발생되면 결제는 실패 처리됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;markPaymentFailed() 함수를 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 서버에서는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Payment 상태를 FAILED로 변경&lt;/li&gt;
&lt;li&gt;실패 사유(failReason) 기록&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 preparing 페이지는 실패 응답을 받고 사용자를 fail 페이지로 이동시킵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 카드 잔액 부족, 네트워크 오류, 토스 승인 거절 등의 이유로 실패가 일어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;3. 결제 승인 결과 여부에 따라 successUrl/failUrl 이동&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1767617802478&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;updatePaymentAndOrder(payment, order, responseDto, orderIdWithTimestamp); // 4. 최종결제 승인 후, payment 객체와 order 객체 상태 변경&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스의 결제 승인 API가 정상적으로 응답하면,&lt;/p&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;Payment 상태를 PAID로 변환하고&lt;/li&gt;
&lt;li&gt;토스에서 발급한 paymentKey 저장&lt;/li&gt;
&lt;li&gt;Order 상태도 결제 완료 상태로 변경&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;승인 결과에 따른 클라이언트 이동 처리&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;preparing.html&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1767617824386&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (!response.ok) {
    // TossConfirmPageException 발생 &amp;rarr; fail 페이지로 redirect
    window.location.href = `/fail&amp;amp;code=${json.code}&amp;amp;message=${encodeURIComponent(json.message)}`;
    return;
} else {
    window.location.href = `/success&amp;amp;code=${json.code}&amp;amp;message=${encodeURIComponent(json.message)}&amp;amp;paymentKey=${paymentKey}&amp;amp;orderId=${orderId}&amp;amp;amount=${amount}`;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 결제 승인과 상태 변경이 모두 완료되면, preparing 페이지의 JavaScript는 응답 결과에 따라 사용자를 success 페이지로 이동시킵니다.&lt;/p&gt;
&lt;pre id=&quot;code_1767617853249&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/success&quot;)
public String success(Model model) {
    model.addAttribute(&quot;userId&quot;, userId);
    return &quot;success&quot;;
}

@GetMapping(&quot;/fail&quot;)
public String fail(Model model) {
    model.addAttribute(&quot;userId&quot;, userId);
    return &quot;fail&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 응답에 따라 successUrl로 갈 수도 있고, failUrl로 갈 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 사용자에게 페이지 보여주는 controller입니다.&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 결제 플로우를 이해하는 데 시간을 많이 썼습니다. Payment 객체에는 어떤 필드 값들이 필요하고, 결제 상태는 어떤 단계로 둘 것인지, 클라이언트 금액 위변조 방지는 어떻게 할지, Payment 객체는 언제 생성하는지 등 여러 가지를 고민하며 구현했습니다. 이 과정에서 팀원과 처음으로 페어프로그래밍을 진행했는데, 각자가 생각한 결제 플로우 의견을 얘기하면서 구현해 나간 점에서 많이 배우고 기억에 오래 남았습니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;튜터님 피드백에서는 최종 결제 승인이 났는데 서버 오류로 인해 서버에서 처리가 제대로 이루어지지 않은 경우나, 중복 결제 요청이 발생하는 경우를 고려해서 고도화해보면 좋겠다는 피드백을 남겨주셨습니다. 튜터님의 피드백을 듣고 단순히 &amp;ldquo;결제가 된다&amp;rdquo;는 결과보다, 언제 어떤 상태를 서버가 책임져야 하는지 좀 더 고민하게 됐습니다. 아직 모든 예외 상황을 완벽하게 처리된 코드는 아니지만, 결제 시스템에서 서버가 맡아야 할 역할과 책임을 인식하게 된 경험이라는 점에서 의미있는 프로젝트였습니다.&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/35</guid>
      <comments>https://galong.tistory.com/35#entry35comment</comments>
      <pubDate>Mon, 5 Jan 2026 22:04:33 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot] 토스페이먼츠 결제 플로우 이해하기 - 1편</title>
      <link>https://galong.tistory.com/34</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1차 프로젝트 때 결제 파트를 담당했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제는 처음이라 우선 결제 플로우를 이해하는 것에 집중했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 PG사로 토스페이먼츠(Toss Payments)를 선택했고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 개발자 가이드가 상세하고 친절하게 설명이 잘 되어 있어서 구현 과정에서 많은 도움을 받을 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.tosspayments.com/guides/v2/payment-widget/integration&quot;&gt;https://docs.tosspayments.com/guides/v2/payment-widget/integration&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 연동 과정에서 고민했던 부분은&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;orderId와 amount 값을 언제 서버에 저장해야 할까?
&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;카드사 승인 이후 바로 successUrl로 이동하는 것이 맞을까?
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카드사 승인 완료 이후 무조건 successURL로 이동하게 되어있습니다.&lt;/li&gt;
&lt;li&gt;하지만 이 시점은 아직 토스 서버에 최종 결제 승인(confirm)을 요청하기 전 단계입니다.&lt;/li&gt;
&lt;li&gt;이 상태에서 사용자에게 곧바로 &quot;결게 성공&quot; 화면을 보여주는 것이 사용자 경험 측면에서 적절한가에 대한 의문이 들었습니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 2가지 였습니다.&lt;/p&gt;
&lt;p 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;주문서 페이지 진입 시점에 서버에서 Payment 객체를 미리 생성
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;orderId, amount 값을 서버에 선저장&lt;/li&gt;
&lt;li&gt;클라이언트에서 전달받은 값이 아닌, 서버 기준 데이터를 신뢰&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;카드사 승인 이후 successUrl 대신 preparingUrl로 이동
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 단계에서는 아직 &quot;결제 성공&quot;으로 처리하지 않음&lt;/li&gt;
&lt;li&gt;paymentKey를 파라미터로 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;preparingUrl에서 토스 서버에 최종 결제 승인 요청
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;paymentKey, orderId, amount 를 기준으로 결제 확정&lt;/li&gt;
&lt;li&gt;승인 성공 시에만 결제 성공 상태로 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 구조로 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 기반으로 구현한 내용을 정리해보겠습니다!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;전체 플로우&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;27 (1).png&quot; data-origin-width=&quot;1268&quot; data-origin-height=&quot;915&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqTDgI/dJMcagEctep/sgfOvnKdB6JhGIpTQFwHy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqTDgI/dJMcagEctep/sgfOvnKdB6JhGIpTQFwHy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqTDgI/dJMcagEctep/sgfOvnKdB6JhGIpTQFwHy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqTDgI%2FdJMcagEctep%2FsgfOvnKdB6JhGIpTQFwHy1%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;663&quot; height=&quot;478&quot; data-filename=&quot;27 (1).png&quot; data-origin-width=&quot;1268&quot; data-origin-height=&quot;915&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체적으로 보면 이런 시퀀스다이어그램입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나씩 뜯어봅시다!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 주문서 진입 시 Payment 객체 생성&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;28.png&quot; data-origin-width=&quot;1187&quot; data-origin-height=&quot;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cFh3ck/dJMcabpkprg/Ncu4Tvnxg8y5am61Kk3s8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cFh3ck/dJMcabpkprg/Ncu4Tvnxg8y5am61Kk3s8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cFh3ck/dJMcabpkprg/Ncu4Tvnxg8y5am61Kk3s8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcFh3ck%2FdJMcabpkprg%2FNcu4Tvnxg8y5am61Kk3s8k%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;690&quot; height=&quot;174&quot; data-filename=&quot;28.png&quot; data-origin-width=&quot;1187&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;28 (1).png&quot; data-origin-width=&quot;1380&quot; data-origin-height=&quot;207&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m5QCb/dJMcagqDyvl/uc1UImzIuOcoi6xqFd6Mf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m5QCb/dJMcagqDyvl/uc1UImzIuOcoi6xqFd6Mf0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m5QCb/dJMcagqDyvl/uc1UImzIuOcoi6xqFd6Mf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm5QCb%2FdJMcagqDyvl%2Fuc1UImzIuOcoi6xqFd6Mf0%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;727&quot; height=&quot;109&quot; data-filename=&quot;28 (1).png&quot; data-origin-width=&quot;1380&quot; data-origin-height=&quot;207&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;toss에서 결제 요청과 승인 사이에 데이터 무결성을 확인하기 위해 orderId와 amount를 서버에 임시 저장했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;status pending인 Payment객체를 생성하고 {orderId, amount} 값을 저장했습니다.&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;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;payment-widget-flow2 (1) (1).png&quot; data-origin-width=&quot;1395&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bojGAr/dJMcafFg87r/ojaOOkkF1Ld4qA7jMUX2S0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bojGAr/dJMcafFg87r/ojaOOkkF1Ld4qA7jMUX2S0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bojGAr/dJMcafFg87r/ojaOOkkF1Ld4qA7jMUX2S0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbojGAr%2FdJMcafFg87r%2FojaOOkkF1Ld4qA7jMUX2S0%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;650&quot; height=&quot;328&quot; data-filename=&quot;payment-widget-flow2 (1) (1).png&quot; data-origin-width=&quot;1395&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V6T59/dJMb99LOqYh/CkhQWRAB9CLdb0sKxr6Xp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V6T59/dJMb99LOqYh/CkhQWRAB9CLdb0sKxr6Xp0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;543&quot; data-filename=&quot;29.png&quot; style=&quot;width: 56.406%; margin-right: 10px;&quot; data-widthpercent=&quot;57.07&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V6T59/dJMb99LOqYh/CkhQWRAB9CLdb0sKxr6Xp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV6T59%2FdJMb99LOqYh%2FCkhQWRAB9CLdb0sKxr6Xp0%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;833&quot; height=&quot;543&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Byejb/dJMcajgyqNZ/zRDDPgRapxHYNogCNNKZA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Byejb/dJMcajgyqNZ/zRDDPgRapxHYNogCNNKZA1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;577&quot; data-origin-height=&quot;500&quot; data-filename=&quot;30.png&quot; style=&quot;width: 42.4312%;&quot; data-widthpercent=&quot;42.93&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Byejb/dJMcajgyqNZ/zRDDPgRapxHYNogCNNKZA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FByejb%2FdJMcajgyqNZ%2FzRDDPgRapxHYNogCNNKZA1%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;577&quot; height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후에 주문서에서 결제 위젯을 렌더링하고, 결제 정보를 입력합니다. (결제수단 선택)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 카드사 승인 후 preparingUrl 이동&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;31.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SMTrb/dJMb99SALY2/vM1k4z4fYIf3KEsyrzD6v1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SMTrb/dJMb99SALY2/vM1k4z4fYIf3KEsyrzD6v1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SMTrb/dJMb99SALY2/vM1k4z4fYIf3KEsyrzD6v1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSMTrb%2FdJMb99SALY2%2FvM1k4z4fYIf3KEsyrzD6v1%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;634&quot; height=&quot;357&quot; data-filename=&quot;31.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 입력한 정보와 카드사 승인을 받으면 preparingUrl로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;preparingUrl에서 paymentKey와 orderId, amount를 쿼리파라미터로 받습니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스 개발자 가이드에서는 카드사 승인 이후 success.html 또는 fail.html로 바로 이동하도록 안내하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 시점은 토스 서버에 최종 결제 승인(confirm)을 요청하기 전 단계입니다. 그럼에도 불구하고 사용자에게 &amp;ldquo;결제 성공&amp;rdquo; 화면을 보여주는 플로우가 사용자 경험 측면에서 다소 어색하다고 느꼈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 카드사 승인과 최종결제 승인을 분리하기 위해 preparingUrl을 추가했습니다.&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;4. preparingUrl에서 결제 승인 검증&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;32.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxS7Xh/dJMcad1HzWd/t3ElbYv9hmzD1TuZKVKoVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxS7Xh/dJMcad1HzWd/t3ElbYv9hmzD1TuZKVKoVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxS7Xh/dJMcad1HzWd/t3ElbYv9hmzD1TuZKVKoVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxS7Xh%2FdJMcad1HzWd%2Ft3ElbYv9hmzD1TuZKVKoVK%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;654&quot; height=&quot;368&quot; data-filename=&quot;32.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;preparingUrl에서는 쿼리파라미터로부터 받은 paymentKey와 orderId, 결제 금액 등을 서버에 보냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서는 toss한테 결제 승인 api를 보내기 전에 client로부터 받은 amount 값과 payment 객체의 amount 파라미터의값이 같은지 검증합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같으면 toss한테 결제 승인 API를 날리고, 같지 않으면 failUrl로 redirect합니다.&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;5. 결제 승인 성공 / 실패 처리&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;33.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wfbDW/dJMcaiPt3Sz/KZksJRY3qkarSF2jrgIuuK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wfbDW/dJMcaiPt3Sz/KZksJRY3qkarSF2jrgIuuK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wfbDW/dJMcaiPt3Sz/KZksJRY3qkarSF2jrgIuuK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwfbDW%2FdJMcaiPt3Sz%2FKZksJRY3qkarSF2jrgIuuK%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;668&quot; height=&quot;376&quot; data-filename=&quot;33.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 toss한테 승인 API를 요청한 후, 승인이 나면 preparingUrl &amp;rarr; successUrl로 redirect하고, payment 객체에&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #000000; background-color: #ffc1c8;&quot; data-token-index=&quot;1&quot;&gt;payment_key &lt;/span&gt;를 저장하고, status를 &lt;span style=&quot;color: #eb5757; background-color: #ffc1c8;&quot; data-token-index=&quot;3&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;PAID&lt;/span&gt; &lt;/span&gt;로 변경합니다.&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;34.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wuHEa/dJMcacu0Qum/QEfwJ2oj9PH8EgKKFUL6lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wuHEa/dJMcacu0Qum/QEfwJ2oj9PH8EgKKFUL6lk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wuHEa/dJMcacu0Qum/QEfwJ2oj9PH8EgKKFUL6lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwuHEa%2FdJMcacu0Qum%2FQEfwJ2oj9PH8EgKKFUL6lk%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;650&quot; height=&quot;366&quot; data-filename=&quot;34.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;승인 실패가 일어나면 failUrl로 redirect되고, status를 FAILED로 변경합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 실패하는 경우는 카드에 돈이 없는 경우나, 네트워크 오류로 인한 실패입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글은 구현하는 글을 가지고오겠습니다.&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/34</guid>
      <comments>https://galong.tistory.com/34#entry34comment</comments>
      <pubDate>Sun, 4 Jan 2026 21:16:53 +0900</pubDate>
    </item>
    <item>
      <title>@OneToOne 관계에서 mappedBy 쪽은 fetch.Lazy 적용이 안되는 문제</title>
      <link>https://galong.tistory.com/33</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class Store extends BaseEntity {
    . 
    .

    @OneToOne(mappedBy = &quot;store&quot;, cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private ReservationPolicy reservationPolicy;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class ReservationPolicy extends BaseEntity {
    .
    .
    .
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;store_id&quot;)
    private Store store;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Store과 ReservationPolicy는 일대일 양방향 관계이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Store entity에서는 &lt;code&gt;mappedBy=&amp;rdquo;store&amp;rdquo;&lt;/code&gt; 라고 정의함으로서 ReservationPolicy entity에서 store 변수에 연관관계를 맺고자 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이렇게 하면 결과:&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;store 테이블에는 reservationPolicy_id 컬럼이 없고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;reservationPolicy 테이블에는 store_id가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제는 여기서부터다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Store entity를 조회하는데 1:1 매핑이 되어있었던 ReservationPolicy도 함께 조회가 된다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private Store findStore(UUID storeId) { // 여기서 store 조회
    return storeRepository.findById(storeId)
        .orElseThrow(() -&amp;gt; new AppException(StoreErrorCode.STORE_NOT_FOUND));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (22).png&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;831&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nPxG2/dJMcagRxQPi/d7TMmkncOc7bhYGY1uGzo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nPxG2/dJMcagRxQPi/d7TMmkncOc7bhYGY1uGzo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nPxG2/dJMcagRxQPi/d7TMmkncOc7bhYGY1uGzo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnPxG2%2FdJMcagRxQPi%2Fd7TMmkncOc7bhYGY1uGzo0%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;431&quot; height=&quot;831&quot; data-filename=&quot;image (22).png&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;831&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;fetch.LAZY&lt;/code&gt; 를 해줬는데도, store에 수정이 일어나면 그 하위 자식인 reservationPolicy도 조회된다. (hibernate select)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태변경 controller, service에서 그 어디도 getReservationPolicy() 하는 함수는 없는데 왜 조회가 되는걸까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인? : 양방향 @OneToOne 에서 mappedBy 지정한 객체에서 fetch.LAZY 적용안됨&lt;/h2&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;    public class Store extends BaseEntity {
        @OneToOne(mappedBy = &quot;store&quot;, cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
        private ReservationPolicy reservationPolicy;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;    public class ReservationPolicy extends BaseEntity {
        @OneToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = &quot;store_id&quot;)
        private Store store;&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;결론부터 말하면 &lt;b&gt;@OneToOne 양방향 관계에서 mappedBy 사용한 데에서 fetch.LAZY 적용이 안된다. fetch.EAGER로 매번 하위 자식까지 다 조회를한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 양방향 OneToOne에서 외래키의 주인이 아닌 상대 entity에 mappedBy 설정을 해줘야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외래키 주인이 아닌 Store entity에서 &lt;code&gt;mappedBy = &amp;ldquo;store&amp;rdquo;&lt;/code&gt; 를 설정해준 상태이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Store가 수정될 때 ReservationPolicy도 조회가 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 안되는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate가 지원하지 않음!!!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (21).png&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;295&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJhrOi/dJMcagRxQPv/YWxt8UIGcuP0unrQe8kelk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJhrOi/dJMcagRxQPv/YWxt8UIGcuP0unrQe8kelk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJhrOi/dJMcagRxQPv/YWxt8UIGcuP0unrQe8kelk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJhrOi%2FdJMcagRxQPv%2FYWxt8UIGcuP0unrQe8kelk%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;1426&quot; height=&quot;295&quot; data-filename=&quot;image (21).png&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;295&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경고문을 좀 더 빨리 봤다면 삽질의 시간을 줄일 수 있지 않았을까. 엉엉.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@OneToOne 연결에서 소유하지 않는 측에 FetchType.LAZY를 지정하면 로드에 영향을 주지 않습니다. 관련 엔티티는 FetchType.EAGER가 정의된 것처럼 로드됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@OneToOne 에서 연관관계 주인이 아닌 객체에 mappedBy를 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 mappedBy를 한 객체(Store) 테이블에는 연관관계 주인 (ReservationPolicy) 에 대한 id 컬럼이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;db 관점으로 보았을 때 연관관계 주인이 아닌 Store 테이블에서는 reservationPolicy_id 컬럼이 없어서 storeRepository.findById(storeId) 를 했을 때, Store 테이블만 읽어서 연관관계의 엔티티가 존재하는지 안하는지 확인하기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 확인하기 위해 하위 자식도 조회하는게 아닐까 싶다. (내생각..)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;mappedBy의 속성 값은 외래 키의 주인인 상대 Entity의 필드명을 의미한다.&lt;/li&gt;
&lt;li&gt;양방향 @OneToOne 에서는 mappedBy 속성을 사용한 객체가 조회될 때, 그 객체와 연관된 객체도 함께 조회된다. (fetch.EAGER)&lt;/li&gt;
&lt;li&gt;즉, 양방향 @OneToOne 에서 mappedBy 속성을 사용한 객체에 fetch.LAZY는 적용되지 않는다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Trouble Shooting</category>
      <category>MappedBy</category>
      <category>onetoone</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/33</guid>
      <comments>https://galong.tistory.com/33#entry33comment</comments>
      <pubDate>Mon, 8 Dec 2025 19:36:31 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 장바구니 기능 Redis( Hash 구조) 사용하여 리팩토링</title>
      <link>https://galong.tistory.com/32</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1차 프로젝트에서 구현했던 장바구니 단순 CRUD에 Redis를 적용해서 빠른 조회가 가능하도록 리팩토링을 진행했습니다. 이 과정을 기록하고자 합니다.&lt;br /&gt;&lt;br /&gt;들어가기에 앞서 1차 프로젝트 주제는 &lt;b&gt;음식 주문 관리 플랫폼&lt;/b&gt;으로 &quot;배민 서비스&quot;와 비슷하게 동작한다고 생각하면됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장바구니에는 &lt;b&gt;하나의 가게&lt;/b&gt;에 대해서만 &lt;b&gt;메뉴 아이템&lt;/b&gt;을 담을 수 있습니다. 이미 다른 가게의 메뉴가 담겨져 있고 새로 추가하려고하는 메뉴가 다른 가게라면 replace true/false 여부로 장바구니의 내용을 변경할 수 있습니다. 그리고, 이미 장바구니에 존재한 메뉴 아이템을 또 담으면 그 만큼 수량을 증가시킵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RedisTemplate Hash 방식으로 장바구니 정보 redis에 저장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;장바구니처럼 부분 수정이 빈번하게 일어나는 데이터에는 Hash 방식&lt;/span&gt;&lt;/b&gt;이 성능상 좋다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Hash는 하나의 키 아래에 여러 개의 필드(Field)와 값(Value) 쌍을 저장하는 구조이다. Redis Hash는 내부적으로 해시 테이블로 구현되어 있어, 특정 필드에 접근하거나 갱신하는 연산이 &lt;span data-math=&quot;O(1)&quot;&gt;O(1)&lt;/span&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;만약 String 타입이었다면 : 장바구니 전체 목록을 Redis String 타입에 하나의 거대한 JSON 문자열로 저장했다면, 수량 하나를 변경할 때마다 전체 JSON을 덮어씌워야 한다.&lt;/li&gt;
&lt;li&gt;하지만 Hash 타입이라면 : 변경된 아이템 정보만 생성하거나 수정해서 HSET 만 추가하면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해, 2개의 정보를 redis에 저장해야한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;cart:meta&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;cart:meta 키는 장바구니 아이템 목록이 저장된 메인키 cart:user:{id}와 쌍을 이루는 보조 키&lt;/li&gt;
&lt;li&gt;가게 ID, 가게 이름, Cart ID 저장&lt;/li&gt;
&lt;li&gt;String 구조&lt;/li&gt;
&lt;li&gt;장바구니에 어떤 가게의 메뉴아이템이 담겨져 있는지 알고 있어야 해서 필요한 정보이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;cart:user&lt;/span&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;cart:user 키는 장바구니 아이템 목록&lt;/li&gt;
&lt;li&gt;menu ID, RedisCartItem 객체를 Value로 저장&lt;/li&gt;
&lt;li&gt;Hash 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 사용자 ID가 1 이고, 장바구니에 아이템이 두 개 있을 때, 예) &lt;span data-token-index=&quot;0&quot;&gt;짬뽕 2개, 탕수육 1개&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 38px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px; width: 13.9534%;&quot;&gt;Redis Key&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 20.1163%;&quot;&gt;Field&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 65.814%;&quot;&gt;Value(Json)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px; width: 13.9534%;&quot;&gt;cart:meta:1&lt;/td&gt;
&lt;td style=&quot;height: 20px; width: 20.1163%;&quot;&gt;String&lt;/td&gt;
&lt;td style=&quot;height: 20px; width: 65.814%;&quot;&gt;{&quot;storeId&quot;: &quot;s1 ...&quot;, &quot;storeName&quot;: &quot;중국집 만리장성&quot;, &quot;cartId&quot;: &quot;c1...&quot;}&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;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 58px;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #9b9b9b; color: #ffffff;&quot;&gt;Redis Key&lt;/td&gt;
&lt;td style=&quot;background-color: #9b9b9b; color: #ffffff;&quot;&gt;Field&lt;/td&gt;
&lt;td style=&quot;background-color: #9b9b9b; color: #ffffff;&quot;&gt;Value(Json)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #efefef;&quot;&gt;cart:user:1&lt;/td&gt;
&lt;td&gt;e705... (짬뽕 ID)&lt;/td&gt;
&lt;td&gt;{&quot;menuName&quot;: &quot;짬뽕&quot;, &quot;unitPrice&quot;: 6000, &quot;quantity&quot;: 2, ...}&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #efefef;&quot;&gt;cart:user:1&lt;/td&gt;
&lt;td style=&quot;background-color: #f9f9f9;&quot;&gt;a3f2... (탕수육 ID)&lt;/td&gt;
&lt;td style=&quot;background-color: #f9f9f9;&quot;&gt;{&quot;menuName&quot;: &quot;탕수육&quot;, &quot;unitPrice&quot;: 20000, &quot;quantity&quot;: 1, ...}&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 저장된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;수정된 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RedisConfig.class&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;Redis Hash 및 Value 저장을 위한 Spring RedisTemplate 빈&lt;/li&gt;
&lt;li&gt;Key와 HashKey는 **StringRedisSerializer**로 설정하여 가독성을 높였고, Value와 HashValue는 GenericJackson2JsonRedisSerializer로 설정하여 자바 객체(DTO)를 JSON 형태로 Redis에 직렬화/역직렬화&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763904738610&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public RedisTemplate&amp;lt;String, Object&amp;gt; cartHashRedisTemplate(
    RedisConnectionFactory connectionFactory) {
    RedisTemplate&amp;lt;String, Object&amp;gt; template = new RedisTemplate&amp;lt;&amp;gt;();
    template.setConnectionFactory(connectionFactory);

    // Key/HashKey 직렬화 (cart:user:1, uuid)
    template.setKeySerializer(new StringRedisSerializer());
    template.setHashKeySerializer(new StringRedisSerializer());

    // Value/HashValue 직렬화 (RedisCartItem 객체)
    // Hash Value를 Object로 설정하여 RedisCartItem을 저장합니다.
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

    template.afterPropertiesSet();
    return template;
}&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;&lt;b&gt;RedisCartMeta.class&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;장바구니 전체의 메타 정보 (가게 ID, 가게 이름, Cart DB ID)를 담는 DTO&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763904964811&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RedisCartMeta implements Serializable {

    private static final long serialVersionUID = 3L;
    private UUID storeId;
    private String storeName;
    private UUID cartId;

}&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;&lt;b&gt;RedisCartItem.class&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;장바구니 내 개별 아이템 정보 (메뉴 ID, 수량, 가격)를 담는 DTO&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763904979992&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RedisCartItem implements Serializable {

    private static final long serialVersionUID = 2L;

    private UUID menuItemId;
    private String menuName;
    private BigInteger unitPrice;
    private int quantity;

    public static RedisCartItem fromEntity(CartItem ci) {
        return new RedisCartItem(
            ci.getMenuItem().getId(),
            ci.getMenuItem().getName(),
            ci.getMenuItem().getPrice(),
            ci.getQuantity()
        );
    }
}&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;&lt;b&gt;RedisKeys.class&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;Redis 키 패턴 (cart:user:%d, cart:meta:%d)과 TTL 상수를 중앙 관리하는 클래스&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763905004827&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public final class RedisKeys {

    public static final long CART_TTL_MINUTES = 30;

    // 키 패턴 정의
    private static final String CART_KEY_FORMAT = &quot;cart:user:%d&quot;;
    private static final String CART_META_KEY_FORMAT = &quot;cart:meta:%d&quot;;

    private RedisKeys() {
    }

    public static String getCartKey(Long userId) {
        return String.format(CART_KEY_FORMAT, userId);
    }

    public static String getCartMetaKey(Long userId) {
        return String.format(CART_META_KEY_FORMAT, userId);
    }
}&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;&lt;b&gt;Service에서 CRUD&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Read&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;Read-Through 전략&lt;/li&gt;
&lt;li&gt;Redis Hash와 Meta 정보가 모두 있으면 캐시 히트로 응답하고, 하나라도 없으면 DB에서 조회 후 Redis에 다시 저장(Cache Warming)하여 다음 요청에 대비한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763905058821&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
@Transactional(readOnly = true)
public CartResponseDto getCart(UserAuth userAuth) {
    Long userId = userAuth.getId();
    String cartKey = RedisKeys.getCartKey(userId);
    String cartMetaKey = RedisKeys.getCartMetaKey(userId);

    // Redis Hash에서 모든 항목 조회 (HGETALL)
    Map&amp;lt;Object, Object&amp;gt; hashEntries = cartHashRedisTemplate.opsForHash().entries(cartKey);

    if (!hashEntries.isEmpty()) {
        // 메타 정보 조회
        RedisCartMeta meta = (RedisCartMeta) cartHashRedisTemplate.opsForValue()
            .get(cartMetaKey);
        log.info(&quot;meta: {}&quot;, meta);

        // 메타 정보도 있다면 완전한 캐시 히트
        if (meta != null) {
            List&amp;lt;RedisCartItem&amp;gt; redisItems = hashEntries.values().stream()
                .filter(o -&amp;gt; o instanceof RedisCartItem)
                .map(o -&amp;gt; (RedisCartItem) o)
                .collect(Collectors.toList());

            return CartResponseDto.fromRedisItems(meta, redisItems);
        }
    }

    // 캐시 미스 (Cache Miss) 또는 Redis에 데이터가 없을 경우 DB 조회 (Read-Through)
    Cart dbCart = findByUserId(userId);

    //  DB 데이터로 Redis Hash를 채움 (Cache Warming)
    Map&amp;lt;String, RedisCartItem&amp;gt; redisMap = new HashMap&amp;lt;&amp;gt;();
    dbCart.getItems().forEach(ci -&amp;gt; {
        RedisCartItem redisItem = RedisCartItem.fromEntity(ci);
        redisMap.put(ci.getMenuItem().getId().toString(), redisItem);
    });

    if (!redisMap.isEmpty()) {
        cartHashRedisTemplate.opsForHash().putAll(cartKey, redisMap); // 새로 갱신

        //  메타 정보 저장 (누락된 로직 추가)
        RedisCartMeta meta = new RedisCartMeta(dbCart.getStore().getId(),
            dbCart.getStore().getName(),
            dbCart.getId());
        cartHashRedisTemplate.opsForValue().set(cartMetaKey, meta);

        // TTL 설정
        cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
        cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
    }

    return CartResponseDto.from(dbCart);
}&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;&lt;b&gt;Create&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;Write-Through 전략&lt;/li&gt;
&lt;li&gt;DB에 아이템을 추가/갱신한 후, Redis Hash에 해당 아이템만 HSET으로 갱신하고, 메타 정보도 SET하여 TTL을 연장한다.&lt;/li&gt;
&lt;li&gt;replace=true 시에는 DB 저장 전에 기존 Redis 키를 삭제하여 캐시를 초기화한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763905116000&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
@Transactional
public CartResponseDto addItem(UserAuth userAuth, AddCartItemRequestDto req, boolean replace) {
    User user = userService.findByUser(userAuth);

    String cartKey = RedisKeys.getCartKey(user.getId());
    String cartMetaKey = RedisKeys.getCartMetaKey(user.getId());

    MenuItem menu = menuItemService.findById(req.getMenuItemId());
    if (menu.getIsSoldout()) {
        throw new MenuItemSoldOutException();
    }
    UUID menuId = menu.getId();

    // 장바구니가 없는 경우 새 생성
    Cart cart = cartRepository.findByUserId(user.getId())
        .orElse(Cart.of(user, menu.getStore()));

    // 장바구니가 있고 다른 가게인 경우
    if (cart.getStore() != null &amp;amp;&amp;amp; !cart.getStore().getId().equals(menu.getStore().getId())) {
        if (replace) {
            cart.clear();
            cart.setStore(menu.getStore());

            cartHashRedisTemplate.delete(cartKey);
            cartHashRedisTemplate.delete(cartMetaKey);
        } else {
            throw new CartStoreConflictException();
        }
    }

    // 이미 존재하면 수량 합산, 없으면 새 아이템 추가
    cart.addItem(menu, req.getQuantity());
    Cart savedCart = cartRepository.save(cart);

    // 장바구니 아이템 Entity에서 Redis용 스냅샷 DTO 생성
    CartItem addedItem = savedCart.getItems().stream()
        .filter(ci -&amp;gt; ci.getMenuItem().getId().equals(menuId))
        .findFirst().orElseThrow(CartItemNotFoundException::new);

    RedisCartItem redisItem = RedisCartItem.fromEntity(addedItem);

    // Hash 구조에 해당 필드만 갱신
    cartHashRedisTemplate.opsForHash().put(cartKey, menuId.toString(), redisItem);

    //  메타 정보 저장 (Write-Through)
    RedisCartMeta meta = new RedisCartMeta(savedCart.getStore().getId(),
        savedCart.getStore().getName(),
        savedCart.getId());
    cartHashRedisTemplate.opsForValue().set(cartMetaKey, meta);

    cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
    cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);

    return CartResponseDto.from(savedCart);
}&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;&lt;b&gt;Update&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;Write-Through 전략&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;DB에서 수량을 변경 후 저장하고, Redis Hash에서 해당 메뉴 ID의 Field만 HSET으로 덮어쓴다. ⭐️&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;Redis의 정보를 모두 수정할 필요 없이 해당 메뉴 Field만 수정하는 것이 장점.&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;매번 TTL을 갱신하여 장바구니 만료 시간을 연장한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763905135152&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
@Transactional
public CartResponseDto updateItemQuantity(UserAuth userAuth, UUID menuItemId, int quantity) {
    Long userId = userAuth.getId();
    String cartKey = RedisKeys.getCartKey(userId);
    String cartMetaKey = RedisKeys.getCartMetaKey(userId);

    Cart cart = findByUserId(userId);
    CartItem item = getCartItem(menuItemId, cart);

    if (quantity &amp;lt;= 0) {
        throw new InvalidCartQuantityException();
    }
    item.updateQuantity(quantity);
    Cart saved = cartRepository.save(cart);

    CartItem updatedItem = getCartItem(menuItemId, saved);
    RedisCartItem redisItem = RedisCartItem.fromEntity(updatedItem);

    cartHashRedisTemplate.opsForHash().put(cartKey, menuItemId.toString(), redisItem);

    cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
    cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);

    return CartResponseDto.from(saved);
}&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;&lt;b&gt;Delete&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;deleteItem()&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Write-Through 전략&lt;/li&gt;
&lt;li&gt;DB에서 아이템을 삭제하고, 장바구니가 비지 않았다면 Redis Hash에서 해당 메뉴 ID의 Field만 HDEL로 빠르게 제거한다.&lt;/li&gt;
&lt;li&gt;TTL도 연장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;deleteCart()&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Cache Evict 전략&lt;/li&gt;
&lt;li&gt;DB에서 Cart 엔티티를 삭제한 후, cart:user와 cart:meta 두 키를 Redis에서 즉시 삭제하여 캐시를 무효화한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1763905167847&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
@Transactional
public void deleteItem(UserAuth userAuth, UUID menuItemId) {
    Long userId = userAuth.getId();
    String cartKey = RedisKeys.getCartKey(userId);
    String cartMetaKey = RedisKeys.getCartMetaKey(userId);

    Cart cart = findByUserId(userId);
    CartItem item = getCartItem(menuItemId, cart);

    cart.removeItem(item.getId());
    if (cart.getItems().isEmpty()) { // 장바구니에 메뉴가 없으면 장바구니 삭제
        cartRepository.delete(cart);

        cartHashRedisTemplate.delete(cartKey);
        cartHashRedisTemplate.delete(cartMetaKey);
    } else {
        cartRepository.save(cart);

        cartHashRedisTemplate.opsForHash().delete(cartKey, menuItemId.toString());

        cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
        cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
    }
}

@Override
@Transactional
public void deleteCart(UserAuth userAuth) {
    Long userId = userAuth.getId();
    String cartKey = RedisKeys.getCartKey(userId);
    String cartMetaKey = RedisKeys.getCartMetaKey(userId);

    Cart cart = findByUserId(userId);
    cartRepository.delete(cart);

    cartHashRedisTemplate.delete(cartKey);
    cartHashRedisTemplate.delete(cartMetaKey);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요청 시간 결과&lt;/h2&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;처음 조회(183ms)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (17).png&quot; data-origin-width=&quot;972&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAf1Zq/dJMcacO3OEj/7h8akUI8alHtPcL7kq6vs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAf1Zq/dJMcacO3OEj/7h8akUI8alHtPcL7kq6vs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAf1Zq/dJMcacO3OEj/7h8akUI8alHtPcL7kq6vs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAf1Zq%2FdJMcacO3OEj%2F7h8akUI8alHtPcL7kq6vs0%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;972&quot; height=&quot;548&quot; data-filename=&quot;image (17).png&quot; data-origin-width=&quot;972&quot; data-origin-height=&quot;548&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;40ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (18).png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;542&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blkPIH/dJMcah3UdOn/cvkGNWRlI6LpVs5hjmkkRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blkPIH/dJMcah3UdOn/cvkGNWRlI6LpVs5hjmkkRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blkPIH/dJMcah3UdOn/cvkGNWRlI6LpVs5hjmkkRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblkPIH%2FdJMcah3UdOn%2FcvkGNWRlI6LpVs5hjmkkRK%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;1000&quot; height=&quot;542&quot; data-filename=&quot;image (18).png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;542&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에만 hibernate 하여 db에서 정보를 조회하지만, 이후에는 Redis 캐시에서 계속 값을 가지고 온다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (19).png&quot; data-origin-width=&quot;2472&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DbGvX/dJMcahQm1ud/yzOKlLJiTpokoh4CC59SuK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DbGvX/dJMcahQm1ud/yzOKlLJiTpokoh4CC59SuK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DbGvX/dJMcahQm1ud/yzOKlLJiTpokoh4CC59SuK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDbGvX%2FdJMcahQm1ud%2FyzOKlLJiTpokoh4CC59SuK%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;2472&quot; height=&quot;454&quot; data-filename=&quot;image (19).png&quot; data-origin-width=&quot;2472&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image (20).png&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;806&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SEMc1/dJMcacuLimz/TG0Bivk3KkPzzKCbLKr6x0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SEMc1/dJMcacuLimz/TG0Bivk3KkPzzKCbLKr6x0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SEMc1/dJMcacuLimz/TG0Bivk3KkPzzKCbLKr6x0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSEMc1%2FdJMcacuLimz%2FTG0Bivk3KkPzzKCbLKr6x0%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;1182&quot; height=&quot;806&quot; data-filename=&quot;image (20).png&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;806&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;redis에도 meta정보와 user 정보가 잘 들어간 것을 확인할 수 있다.&lt;/p&gt;</description>
      <category>Redis</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/32</guid>
      <comments>https://galong.tistory.com/32#entry32comment</comments>
      <pubDate>Mon, 24 Nov 2025 18:00:38 +0900</pubDate>
    </item>
    <item>
      <title>트랜잭션 커밋 지연 @TransactionalEventListener로 해결</title>
      <link>https://galong.tistory.com/31</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;2차 프로젝트에서 주문 도메인을 맡게 되었습니다. 개발하는 과정에서 마주했던 트러블 슈팅을 해결했던 경험을 기록하고자 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Order Service에서 주문을 생성하는 과정에서 order 정보를 저장하고, 외부 서비스 Delivery Service에 배송 생성 FeignClient 요청한다. &lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Delivery생성에서는 해당 주문이 실제 있는지 Order Service에 FeignClient 요청하는 상황이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1763900607966&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Order Service -- feignClient: createDelivery() --&amp;gt; Delivery Service -- feignClient: getOrderDetail() --&amp;gt; Order Service&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;이런 상황이다. Order Service가 Delivery Service에 의존하고, Delivery Service가 Order Service에 다시 의존하는 &lt;b&gt;양방향 의존성&lt;/b&gt;이 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;발생한 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구조에서 &lt;span style=&quot;color: #000000;&quot;&gt;Order Service가 방금 저장한 orderId를 전달했음에도 불구하고, Delivery Service의 검증 Not Found 오류 발생하는 문제가 생겼다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1763900707977&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;feign.FeignException$NotFound: [404] ... &quot;해당 주문을 찾을 수 없음&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;커밋 지연&lt;/b&gt; : Order Service의 트랜잭션이 아직 커밋되지 않아, Delivery Service가 Order Service의 DB를 조회할 때 (Order Service의 getOrder&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;호출 시) &lt;span data-token-index=&quot;3&quot;&gt;Dirty Read를 허용하지 않는다면&lt;/span&gt;해당 주문을 찾지 못한 상황이다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1958&quot; data-origin-height=&quot;685&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFYI8H/dJMcab3GVgd/r2ZmLY76kKYJBW4fCLgEyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFYI8H/dJMcab3GVgd/r2ZmLY76kKYJBW4fCLgEyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFYI8H/dJMcab3GVgd/r2ZmLY76kKYJBW4fCLgEyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFYI8H%2FdJMcab3GVgd%2Fr2ZmLY76kKYJBW4fCLgEyk%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;1958&quot; height=&quot;685&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1958&quot; data-origin-height=&quot;685&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;@TransactionalEventListener&lt;span&gt;&amp;nbsp; 로&amp;nbsp;&lt;/span&gt;&lt;/b&gt;해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;ApplicationEventPublisher 와 @TransactionalEventListener &lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;를 통해 해결했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 이벤트 발생 : 주문 생성 완료 후, ApplicationEventPublisher를 통해 OrderCreatedEvent를 발행&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 실행 분리 : DeliveryNotifier 클래스로 delivery 생성 호출하도록 역할 분리&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 을 통해 Order Service의 DB 트랜잭션이 성공적으로 커밋되고 영구 저장된 직후에 OrderCreatedEvent를 리스너하여 배달 생성 로직 실행&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;이로 인해 Delivery Service 호출 시 Not Found 문제를 해결했다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 전달되는값이 있는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;퍼블리쉬 이벤트와 이벤트 리스너 사이에 전달되는 값 : OrderCreatedEvent&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;기존엔 savedOrder를 전달을 했다. &lt;b&gt;하지만 리스너를 사용할 땐 따로 객체(&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;OrderCreatedEvent)&lt;/span&gt;를 만들어서 사용하는 이유는 뭘까?&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;트랜잭션 이벤트 리스너를 사용하면서 OrderCreatedEvent 레코드를 따로 만들었다. 이유는 이벤트 리스너는 호출자 트랜잭션이 커밋된 후에 실행된다. 즉 이벤트 리스너가 실행될 때, &lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #0593d3;&quot; data-token-index=&quot;1&quot;&gt;createOrder&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;메서드 내의 &lt;span data-token-index=&quot;3&quot;&gt;로컬 변수들&lt;/span&gt; (예:&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;5&quot;&gt;savedOrder객체 자체)은 이미 &lt;span data-token-index=&quot;7&quot;&gt;소멸되었거나&lt;/span&gt;Spring 세션 관리 영역에서 벗어났을 수 있다. 따라서 OrderCreatedEvent에 필요한 데이터 (ID, Hub Id, Vendor Id 등) 단순한 값을 복사하여 담아두고, 이벤트 리스너가 실행될 때는 이 복사된 값을 사용하여 전달한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1878&quot; data-origin-height=&quot;594&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uEIme/dJMcabo48uq/QPCLnqcQkqLpglSDOolOY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uEIme/dJMcabo48uq/QPCLnqcQkqLpglSDOolOY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uEIme/dJMcabo48uq/QPCLnqcQkqLpglSDOolOY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuEIme%2FdJMcabo48uq%2FQPCLnqcQkqLpglSDOolOY1%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;1878&quot; height=&quot;594&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1878&quot; data-origin-height=&quot;594&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Trouble Shooting</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/31</guid>
      <comments>https://galong.tistory.com/31#entry31comment</comments>
      <pubDate>Sun, 23 Nov 2025 21:59:38 +0900</pubDate>
    </item>
    <item>
      <title>@Autowired 사용 후 &amp;quot;오토와이어링할 수 없습니다.&amp;quot; 오류 해결 방법</title>
      <link>https://galong.tistory.com/30</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;297&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BII3A/dJMcagqoeDM/jt8ppYv4GshZUubpW2PJDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BII3A/dJMcagqoeDM/jt8ppYv4GshZUubpW2PJDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BII3A/dJMcagqoeDM/jt8ppYv4GshZUubpW2PJDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBII3A%2FdJMcagqoeDM%2Fjt8ppYv4GshZUubpW2PJDk%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;715&quot; height=&quot;297&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;297&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트코드 작성하는데 @Autowired 어노테이션으로 해당 bean을 가지고 오려고하는데 위와 같이&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;오토와이어링할 수 없습니다. 'ProductService' 타입의 bean을 찾을 수 없습니다. &quot; 라는 오류가 떴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 1 : @Service, @SpringBootTest 어노테이션 잘 썼는지 확인&lt;/h3&gt;
&lt;pre id=&quot;code_1763880932886&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ProductService {&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 파일에 @Service 어노테이션도 잘 붙여줬는데 왜 bean으로 등록이 되지 않았다고 뜨는걸까.&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;시도 2: gradle clean하고 다시 build&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;462&quot; data-origin-height=&quot;365&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tWMLO/dJMcagYd97W/mHUurob1kKdIlZvrAg2Bh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tWMLO/dJMcagYd97W/mHUurob1kKdIlZvrAg2Bh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tWMLO/dJMcagYd97W/mHUurob1kKdIlZvrAg2Bh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtWMLO%2FdJMcagYd97W%2FmHUurob1kKdIlZvrAg2Bh1%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;462&quot; height=&quot;365&quot; data-origin-width=&quot;462&quot; data-origin-height=&quot;365&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해도 안된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 3:&amp;nbsp; 폴더 구조가 잘되어 있는지 확인하자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) ProductService 위치 : com.example.locking 폴더 밑에 있는지 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 메인 클래스 위치 : &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;@SpringBootApplication이 붙은&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;메인 클래스가 가장 상위 패키지 (Base Package) 에 위치하는지 확인한다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;310&quot; data-origin-height=&quot;312&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TbYnk/dJMcaaRfrEa/88KJoFH88DbxLQDDp2KWX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TbYnk/dJMcaaRfrEa/88KJoFH88DbxLQDDp2KWX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TbYnk/dJMcaaRfrEa/88KJoFH88DbxLQDDp2KWX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTbYnk%2FdJMcaaRfrEa%2F88KJoFH88DbxLQDDp2KWX0%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;310&quot; height=&quot;312&quot; data-origin-width=&quot;310&quot; data-origin-height=&quot;312&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;...&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Item 관련 파일을 item 폴더에 옮겼을 때 LockingApplication도 같이 옮겼나 보다 ..&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재 메인 클래스가 com.example.locking.item과 같은 &lt;b&gt;하위 패키지&lt;/b&gt;에 들어가 있어, 상위 패키지인 com.example.locking의 다른 Bean들(예: ProductService)을 찾지 못하고 Bean 누락 오류가 발생한 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;305&quot; data-origin-height=&quot;307&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vOaJI/dJMcaiaFQmM/4pyn5yKyEOBNI4syjRZ551/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vOaJI/dJMcaiaFQmM/4pyn5yKyEOBNI4syjRZ551/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vOaJI/dJMcaiaFQmM/4pyn5yKyEOBNI4syjRZ551/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvOaJI%2FdJMcaiaFQmM%2F4pyn5yKyEOBNI4syjRZ551%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;305&quot; height=&quot;307&quot; data-origin-width=&quot;305&quot; data-origin-height=&quot;307&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LockingApplication을 locking 폴더 밑으로 리팩토링했더니 &lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;해결됐다!&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Trouble Shooting</category>
      <author>galong</author>
      <guid isPermaLink="true">https://galong.tistory.com/30</guid>
      <comments>https://galong.tistory.com/30#entry30comment</comments>
      <pubDate>Sun, 23 Nov 2025 16:42:19 +0900</pubDate>
    </item>
  </channel>
</rss>