<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Dean83</title>
    <link>https://dean83.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Fri, 10 Apr 2026 18:59:14 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Dean83</managingEditor>
    <image>
      <title>Dean83</title>
      <url>https://tistory1.daumcdn.net/tistory/5315217/attach/67d2701208e4410e85f56cedfcf88cf3</url>
      <link>https://dean83.tistory.com</link>
    </image>
    <item>
      <title>[Spring Security] XSS 방어</title>
      <link>https://dean83.tistory.com/425</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;XSS 방어는 사용자가 신뢰하는 웹사이트 내에서 악의적인 스크립트가 실행되지 않도록 막는 보안 전략이다. (JS 포함 HTML 등도 해당)&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; (보통은 서버에서 html을 내려줄 일이 없으므로)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CSP (Content Security Policy) 설정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 출처에서 온 리소스만 사용할 수 있도록 하는 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;쿠키 이용시, HttpOnly, Secure 설정&lt;/li&gt;
&lt;/ul&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;b&gt;CSP 설정 예&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;httpSecurity의 header 에 추가하는 설정&lt;/li&gt;
&lt;li&gt;스크립트, 이미지 등의 출처를 명시해 두어, 해당 출처로 부터 온 데이터들만 사용 할 수 있도록 한다.&lt;/li&gt;
&lt;li&gt;공백이 매우 중요하다. 하나의 설정이 끝나는 지점에 ; 를 적어두고 공백을 하나 띄워야 한다.&lt;/li&gt;
&lt;li&gt;연속된 설정의 경우 다음 항목에 공백을 두어야 하고, 값 배정에도 공백을 두어야 한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예 : connect-src 공백 'self' 공백 https: 공백 wss:; 공백&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;여기서 self 의 의미는 프론트엔드가 서빙되고 있는 주소&lt;/b&gt;를 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ....
    
    http.sessionManagement(management -&amp;gt;
                    management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .headers(header -&amp;gt; header
                    .contentSecurityPolicy(csp -&amp;gt; csp
                            .policyDirectives(
                                    &quot;default-src 'self'; &quot; +
                                            &quot;script-src 'self'; &quot; +
                                            &quot;script-src-attr 'none'; &quot; +
                                            &quot;script-src-elem 'self'; &quot; +
                                            &quot;object-src 'none'; &quot; +
                                            &quot;base-uri 'none'; &quot; +
                                            &quot;img-src 'self' data: https://*.ap-northeast-2.amazonaws.com; &quot; +
                                            &quot;form-action 'self' https://백앤드주소; &quot;  +
                                            &quot;connect-src 'self' https:백앤드주소 and 허용하는_서버_주소 나열 wss:; &quot;+
                                            &quot;frame-ancestors 'none'&quot;
                            ))&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;default-src &amp;lsquo;self&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본정책으로, 따로 명시되지 않은 리소스는 같은 origin만 허용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;script-src &amp;lsquo;self&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 서버의 javascript만 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;script-src-attr&amp;nbsp;&amp;lsquo;none&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스크립트 내 인라인 이벤트 스크립트 차단 (예 : 버튼 클릭 이벤트)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;script-src-elem&amp;nbsp;&amp;lsquo;self&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;lt;script&amp;nbsp;src=&quot;&quot;&amp;gt;로&amp;nbsp;불러오는&amp;nbsp;JS&amp;nbsp;제한.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;object-src&amp;nbsp;&amp;lsquo;none&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Flash, plugin, object 태그 차단&lt;/li&gt;
&lt;li&gt;거의 필수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;base-uri&amp;nbsp;&amp;lsquo;none&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;lt;base href=&quot;주소&quot;&amp;gt; 같은 경로 조작 차단&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;img-src&amp;nbsp;&amp;lsquo;self&amp;rsquo;&amp;nbsp;data:&amp;nbsp;S3도메인&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미지 파일은 나와 같은 도메인 혹은 S3에 있는 도메인만 허용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;form-action&amp;nbsp;&amp;lsquo;self&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다른 사이트로 Form을 제출 할 수 없도록 제한&lt;/li&gt;
&lt;li&gt;https://백앤드주소 를 추가하여 OAuth가 리다이렉트 하는 경로를 추가할 수 있다 (폼로그인 OAuth는 거의 없음)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;connect-src&amp;nbsp;&amp;lsquo;self&amp;rsquo;&amp;nbsp;https:&amp;nbsp;wss:;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fetch, axios, WebSocket, SSE 허용 출처 설정&lt;/li&gt;
&lt;li&gt;https:, wss: 는 모든 연결을 허용&lt;/li&gt;
&lt;li&gt;만일 특정 주소로 제한하고 싶다면, https://백앤드주소 https://외부api서버주소 등 연장하여 설정해야 한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;wss://백앤드주소&amp;nbsp; &amp;nbsp;이것도 마찬가지.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;frame-ancestors&amp;nbsp;&amp;lsquo;none&amp;rsquo;;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다른 사이트에서 내 사이트에 iframe 넣는것을 차단하여 클릭재킹 방어&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/425</guid>
      <comments>https://dean83.tistory.com/425#entry425comment</comments>
      <pubDate>Wed, 11 Feb 2026 14:32:58 +0900</pubDate>
    </item>
    <item>
      <title>[Entity] GeneratedValue 대신 TSID 사용하기</title>
      <link>https://dean83.tistory.com/424</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;보통 Entity를 구성할 때 id 필드 값에 아래의 어노테이션을 사용할 때가 많다.&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@GeneratedValue(strategy = GenerationType.IDENTITY)&lt;/code&gt;&lt;/pre&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;이 경우, DB의 DDL 에도 아래와 구성을 해 주어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1770703782183&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;id                BIGINT GENERATED BY DEFAULT AS IDENTITY&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;의미 부터 보자면, db에 값을 저장할때 id 값을 db 에서 자동으로 생성하여 증가 해주는것을 의미한다. &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;(JPA에서 해주지 않는다)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 경우 &lt;b&gt;다음의 문제점&lt;/b&gt;이 발생한다.&lt;/span&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;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;대량의 bulk insert가 필요 할 때 bulk 동작을 JPA 및 hibernate가 수행하지 않고, 건별로 insert 쿼리를 날린다.&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;&lt;b&gt;속도가 엄청나게 느리고 과부하가 걸린다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 1개를 입력 -&amp;gt; 증가된 ID 값을 받아옴 -&amp;gt; 다음 데이터 입력 을 해야 하므로 bulk insert가 수행되지 않는다.&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;따라서, @TSID 어노테이션&lt;/b&gt; 을 이용하여 &lt;b&gt;프로그램 단에서 자동으로 ID를 생성&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;DDL 에서 GENERATED BY DEFAULT AS IDENTYTY 항목 삭제&lt;/li&gt;
&lt;li&gt;Entity의 id 컬럼에서 @GeneratedValue 대신 @Tsid 어노테이션 이용&lt;/li&gt;
&lt;li&gt;id 는 bigint (Long) 형으로 구성&lt;/li&gt;
&lt;/ul&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;b&gt;build.gradle 에 추가&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;/ul&gt;
&lt;pre id=&quot;code_1770704235999&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.15.1'&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;&lt;b&gt;@Tsid 어노테이션 사용&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;엔티티의 ID 컬럼에 기존 @GeneratedValue(strategy=GenerationType.IDENTYTY) 대신 @Tsid 어노테이션 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1770704365590&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	.....
    @Id
    @Tsid
    @Column(name = &quot;id&quot;, nullable = false)
    protected Long id;
    
    .....&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;TSID 특징&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;Bigint (Long) 형 이다.&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;gt; 테이블 수정을 통해 Tsid를 사용할 수 있다.&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;이를 해결하기 위해선 다양한 방법이 있겠으나, 나라면 bulk insert가 필요한 항목에 한해&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Raw SQL을 통해 Bulk insert 를 수동으로 구현하여 운영 (기존 id 자동 생성 유지) 을 할 것 같다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다 점차 해당 항목들이 많아 진다면, 그때는 Tsid를 도입하고,&amp;nbsp;기존 데이터는 점진적으로 새 테이블로 마이그레이션 수행을 할 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;처음부터 id 자동 생성 보다는 어플리케이션 에서 생성해 주는것이 좋다&lt;/b&gt;고 생각한다.&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/424</guid>
      <comments>https://dean83.tistory.com/424#entry424comment</comments>
      <pubDate>Tue, 10 Feb 2026 15:28:45 +0900</pubDate>
    </item>
    <item>
      <title>OAuth 연동</title>
      <link>https://dean83.tistory.com/423</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;모바일일 경우에는 OAuth 연동을 모바일쪽에서 직접 구현 -&amp;gt; 해당 정보를 백앤드로 전달 해주었다. 웹페이지의 경우에도 그렇게 할 수 있으나 클라이언트 측에 키값이 존재한다는 점이 불안 요소가 있다. (물론 이를 보완할 수도 있다. 키값만 db에 저장한다든지, 서버가 전달 해준다든지 등등)&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;서버에서 OAuth를 연동할 경우 장점은,&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;각기 다른 플랫폼에 OAuth 연동해도 연동이 일괄적으로 간편
&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;/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;일단, 구글 기준으로 스프링부트에서 연동하는 내용을 정리해 둔다.&amp;nbsp;&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;b&gt;우선순위 부터 보자면&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;각 플랫폼의 dev 사이트에서 프로젝트 생성&lt;/li&gt;
&lt;li&gt;프로젝트에서 OAuth 연동을 위한 콜백 Uri 지정&lt;/li&gt;
&lt;li&gt;builld.gradle 에 라이브러리 추가&lt;/li&gt;
&lt;li&gt;발급된 클라이언트ID, 비밀번호를 스프링부트의 환경변수에 삽입 및 application.yaml 설정&lt;/li&gt;
&lt;li&gt;SecurityConfig 설정&lt;/li&gt;
&lt;li&gt;연동 Service 코드 구현&lt;/li&gt;
&lt;li&gt;JWT 연동하여 토큰 발급 후 프론트엔드에 전달&lt;/li&gt;
&lt;/ul&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;b&gt;각 플랫폼의 dev 혹은 console 사이트에서 프로젝트 생성&lt;/b&gt;&lt;/h4&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가지는 기억하는게 좋다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로젝트에서 클라이언트 생성시, 웹 애플리케이션으로 생성할 것&lt;/li&gt;
&lt;li&gt;웹 애플리케이션에서 승인된 리디렉션 URI에 주소값을 넣을것
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구글 예 : 개발중일경우 : http://localhost:8080/login/oauth2/code/google
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/login/oauth2/code/google 이게 항상 일치해야 한다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Build.gradle 추가&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;Oauth2 를 위한 라이브러리가 있다.&lt;/li&gt;
&lt;li&gt;각 플랫폼에 요청을 위한 uri가 security에 고정되어 있다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구글 : http://localhost:8080/oauth2/authorization/google&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&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;b&gt;application.yaml 수정&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 Security의 oauth2 항목을 설정해주면 된다.&lt;/li&gt;
&lt;li&gt;다른 플랫폼과 연동시 단순히 registration, provider 쪽에 항목을 추가해주면 된다.&lt;/li&gt;
&lt;li&gt;구글의 경우 provider 부분은 필수는 아니고 선택사항이다. 그러나 kakao 같은 경운 필수로 넣어야 한다.&lt;/li&gt;
&lt;li&gt;provider 에 있는 각 주소들은 해당 플랫폼에서 제공하는 주소이니, 값의 변화가 없다.&lt;/li&gt;
&lt;li&gt;scope 하단에 서버가 가져올 정보를 명시한다. 각 제공처 마다 다르니 주의할것.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구글의 경우 profile 을 추가 하면 이름을 가져 올 수 있다.&lt;/li&gt;
&lt;li&gt;이를 가져오기 위해선 구글 콘솔 -&amp;gt; OAuth 에서 해당 정보를 가져오겠다는 것을 설정을 통해 해 주어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;server:
  forward-headers-strategy: framework

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_SECRET}
            scope:
              - email
              - profile
        provider:
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
            user-name-attribute: sub&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;server.forward-headers-strategy : framework&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;OAuth 로그인시 각 OAuth 제공처 (예 : 구글 콘솔) 에서 설정한 Redirect Url 관련된 설정&lt;/li&gt;
&lt;li&gt;AWS에 서버를 올리고, 앞단에 nginx가 있을 경우, redirect url 이 원본 요청이 아니라 다른 url로 변경됨
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예 : appserver/api/xxx/xxx&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이를 방지 하기 위한 설정으로, 물론 nginx 에도 설정을 따로 해주어야 함.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Nginx, ALB등 백앤드가 뒤에 있을때, 변경된 주소가 아닌 클라이언트가 보낸 원래 요청 주소를 복원하는 설정임.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;OAuthService 구현&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;OAuth2UserService 를 implement 하여 구현한다.&lt;/li&gt;
&lt;li&gt;이메일이 없을경우 처리는 비즈니스 로직 부분이다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;필요하다면 profile 등 을 통해 이름을 가져올 수도 있다. (application.yaml 설정 및 각 서비스 도메인 설정 필요)&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class OAuthService implements OAuth2UserService&amp;lt;OAuth2UserRequest, OAuth2User&amp;gt; {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService&amp;lt;OAuth2UserRequest, OAuth2User&amp;gt; oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
        Map&amp;lt;String,Object&amp;gt; attributes = oAuth2User.getAttributes();

        String email = (String) attributes.get(&quot;email&quot;);

        ....//비즈니스 로직 구현 부분

        return new DefaultOAuth2User(
                Set.of(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;)),
                attributes,
                &quot;sub&quot;
        );
    }
}&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;SecurityConfig 설정 추가&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;oauth2Login 설정만 해주면 된다.&lt;/li&gt;
&lt;li&gt;userService 에 위에 설정한 서비스를 넣어준다.&lt;/li&gt;
&lt;li&gt;따로 csrf 설정이나 authorizeHttpRequests.requestMatchers 설정을 하지 않아도, Spring Security에서 별도로 다른 filter를 태운다&lt;/li&gt;
&lt;li&gt;로그인 성공, 실패 콜백을 등록하여, 로그인 성공시 JWT 토큰 발급 등을 할 수 있도록 할 필요가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1770341401916&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	....
    private final OAuthService oAuthService;
    private final OAuthSuccessHandler oAuthSuccessHandler;
    private final OAuthFailureHandler oAuthFailureHandler;
    ...

	@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
                http.oauth2Login(oauth -&amp;gt; oauth.userInfoEndpoint(
                        info -&amp;gt; info.userService(oAuthService)                                              
                        .successHandler(oAuthSuccessHandler)
                        .failureHandler(jwtLoginFailureHandler)
                ))

				....&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;&lt;b&gt;로그인 성공, 실패 핸들러&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;요점은, 토큰 발급 후 프론트엔드 에서 원하는 주소로 re-direct 해주어야 한다는 점이다. (실패시 에도 마찬가지)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;redirect 할 때 토큰을 바로 노출하는건 좋지 않고, 쿠키에 넣어 전달 -&amp;gt; 클라이언트가 쿠키에서 가져와 처리 하는게 좋다 (프론트와 협의 필요)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1770345846730&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler
        implements AuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;

    public void onAuthenticationSuccess(HttpServletRequest request, 
    HttpServletResponse response, 
    Authentication authentication) throws IOException, ServletException {

        String token = jwtProvider.createToken(authentication);

        response.sendRedirect(
            &quot;http://frontend.com/login?token=&quot; + token
        );
    }
}&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;&lt;b&gt;접근 테스트 (구글)&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;http://localhost:8080/login/oauth2/code/google&amp;nbsp; 주소를 통해 브라우저로 접속하여 테스트 한다. (개발환경)&lt;/li&gt;
&lt;li&gt;프론트엔드 페이지가 이미 구현완료 되었다면 프론트엔드를 통해 완료한다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/423</guid>
      <comments>https://dean83.tistory.com/423#entry423comment</comments>
      <pubDate>Fri, 6 Feb 2026 11:46:21 +0900</pubDate>
    </item>
    <item>
      <title>Cursor 기반 Paging 추가 정리(QueryDsl 기준)</title>
      <link>https://dean83.tistory.com/422</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dean83.tistory.com/285&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dean83.tistory.com/285&lt;/a&gt; 여기에서 Paging 에 대한걸 간략하게 정리 하였다. 그러나 실제로는 Cursor 기반을 많이 사용하기도 하고, 좀 더 까다롭기도 하다.&amp;nbsp; 이 부분 또한 위의 페이지에 정리 하려 하다가 너무 복잡해져 따로 정리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예는 유저를 pageing 하는 예 이다.&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;b&gt;중요한 내용 정리 먼저,&lt;/b&gt;&amp;nbsp;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;엔티티에 1:N, N:M 등 List 형태의 컬럼이 있을경우 해당 테이블과 fetch join을 하면 안된다.&lt;/b&gt;&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fetch join으로 인해 row의 갯수가 증가해 버린다 -&amp;gt; limit에 의해 의도치 않은 데이터 갯수가 생성된다.&lt;/li&gt;
&lt;li&gt;예 ) 유저 조회가 메인인 경우, 유저에 roles 라는 List 컬럼이 있을경우
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동일 유저인데 role이 다를 경우 row 갯수 증가 -&amp;gt; 그러나 페이지네이션에선 동일한 유저이므로 limit보다 적은 유저 리턴&lt;/li&gt;
&lt;li&gt;distinct를 걸어도 row 자체는 중복이 아니기 때문에 소용 없음.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이후 별도로 ids를 통해 fetch join을 수행해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Cursor은 정렬 조건과 동일한 필드를 사용한다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만일 정렬 조건 필드가 name, email 등 여러개 일 경우, cursor 또한 각 조건과 부합한다.&lt;/li&gt;
&lt;li&gt;즉, 클라이언트에서 정렬조건으로 name을 주었다면,&amp;nbsp; cursor 또한 클라이언트에서 name 값을 준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Distinct를 사용하지 않는다&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Service에서 호출하는 Repository 메서드 예 (메인 진입점)&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770097301683&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public List&amp;lt;User&amp;gt; findAllUsers(UserCursorRequest request) {

        return findUserExpression(true)
                .where(
                        getEmailExpression(request),
                        getRoleExpression(request),
                        getLockedExpression(request),
                        cursorExpression(request)
                )
                .orderBy(orderByDirection(request), orderByDirection(request.sortDirection(), user.uuid))
                .limit(request.limit()+1)
                .fetch();
    }&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;Where 조건문을 완성해 주는 메서드를 호출하여 사용한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 조건문이 null 일 경우에도 NPE 터지지 않고 처리해 줘서 상관없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;orderby 조건문을 완성해주는 메서드를 호출하여 사용한다. 또한 id를 보조 커서로 사용한다.&lt;/li&gt;
&lt;li&gt;limit 은 +1을 하여 nextCursor 정보 등을 담을 수 있도록 한다.&lt;/li&gt;
&lt;li&gt;Slice가 아니라 List로 리턴하고, Service에서 Dto를 생성하여 컨트롤러로 리턴한다.&lt;/li&gt;
&lt;li&gt;예에서는 일반쿼리(fetch join필요), Slice쿼리 두 경우가 다 있다보니 따로 Select 쿼리를 메소드로 분리하였다.&lt;/li&gt;
&lt;/ul&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;b&gt;Repository의 Select 부분 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770101476370&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private JPAQuery&amp;lt;User&amp;gt; findUserExpression(boolean isSlice)
    {
        JPAQuery&amp;lt;User&amp;gt; query = jpaQueryFactory
                                .select(user)
                                .from(user);

        //list 인 경우, slice 할때에는 fetch join을 쓰면 안된다. (limit 등 쿼리 select 오동작)
        if(isSlice)
        {
            query.join(user.userRoles, userRole);
            query.join(user.profile,profile);
            query.join(userRole.role,role);
        }
        else
        {
            query.join(user.userRoles, userRole).fetchJoin();
            query.join(user.profile,profile).fetchJoin();
            query.join(userRole.role,role).fetchJoin();
            query.distinct();
        }

        return  query;
    }&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;만일 Repository 에서 일반 select와 Paging을 둘 다 쓰고 있을경우, 그리고 엔티티에 1:N 관계의 List 멤버변수가 있을경우 fetch join을 하지 않기 위해 위와 같이 메서드로 따로 분리해 주었다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;일반적인 Where 조건절 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770097474172&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;   private BooleanExpression getEmailExpression(UserCursorRequest request)
    {
        return StringUtils.hasText(request.emailLike()) ? user.email.like(request.emailLike()) : null;
    }&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;BooleanExpression을 리턴한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;null이 아닌경우 조건에 맞는 문장리턴, null일 경우 null을 리턴한다.&lt;/li&gt;
&lt;/ul&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;b&gt;Cursor Where 조건절 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770097573792&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private BooleanExpression cursorExpression(UserCursorRequest request) {
        if (request.cursor() == null || request.idAfter() == null) {
            return null; // 최초 페이지 &amp;rarr; 커서 조건 없음
        }

        if(request.sortDirection() == UserSortDirection.ASCENDING)
            return cursorExpressionAsc(request);

        return cursorExpressionDesc(request);
    }

    private BooleanExpression cursorExpressionDesc(UserCursorRequest request)
    {
        switch (request.sortBy())
        {
            case name:
                return profile.name.lt(request.cursor())
                        .or(
                                profile.name.eq(request.cursor())
                                        .and(user.uuid.lt(request.idAfter()))
                        );
            case email:
                return user.email.lt(request.cursor()).
                        or(
                                user.email.eq(request.cursor())
                                        .and(user.uuid.lt(request.idAfter())
                        ));
            case role:
            case isLocked:
                return user.uuid.lt(request.idAfter());
            case createdAt:
                Instant cursorTime = Instant.parse(request.cursor());
                return user.createdAt.lt(cursorTime)
                        .or(
                                user.createdAt.eq(cursorTime)
                                        .and(user.uuid.lt(request.idAfter())
                        ));
            default:
                return null;
        }
    }

    private BooleanExpression cursorExpressionAsc(UserCursorRequest request)
    {
        switch (request.sortBy())
        {
            case name:
                return profile.name.gt(request.cursor())
                        .or(
                                profile.name.eq(request.cursor())
                                        .and(user.uuid.gt(request.idAfter()))
                        );
            case email:
                return user.email.gt(request.cursor()).
                        or(
                                user.email.eq(request.cursor())
                                        .and(user.uuid.gt(request.idAfter())
                                        ));
            case role:
            case isLocked:
                return user.uuid.gt(request.idAfter());
            case createdAt:
                Instant cursorTime = Instant.parse(request.cursor());
                return user.createdAt.gt(cursorTime)
                        .or(
                                user.createdAt.eq(cursorTime)
                                        .and(user.uuid.gt(request.idAfter())
                                        ));
            default:
                return null;
        }
    }&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;정렬 조건이 Desc 이냐, Asc 이냐에 따라 검색 조건이 달라진다. (값이 커야 하는지 작아야 하는지 조건이 달라지기 때문)&lt;/li&gt;
&lt;li&gt;또한 정렬 조건이 여러개 일 경우, Cursor 또한 여러 조건값이 될 수 있기 때문에 이를 고려해야 한다.&lt;/li&gt;
&lt;li&gt;Cursor 비교값과 결과가 동일할 경우가 있을 수 있기 때문에 후행에 or 절을 이용하여 보조커서(보통 ID)를 이용해 조건을 걸어준다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최초 검색 조건에는 Cursor 등이 없으므로 예외처리를 해주어야 한다.&lt;/b&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;OrderBy 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770098122300&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private OrderSpecifier orderByDirection(UserCursorRequest request)
    {
        switch (request.sortBy())
        {
            case name:
                return orderByDirection(request.sortDirection(), profile.name);
            case email:
                return orderByDirection(request.sortDirection(), user.email);
            case role:
                return orderByDirection(request.sortDirection(), role.name);
            case isLocked:
                return orderByDirection(request.sortDirection(), user.locked);
            case createdAt:
                return orderByDirection(request.sortDirection(), user.createdAt);
            default:
                return null;
        }
    }

    private OrderSpecifier orderByDirection(UserSortDirection direction, ComparableExpressionBase field)
    {
        if(direction == UserSortDirection.ASCENDING)
            return field.asc();

        return field.desc();
    }&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;OrderSpecifier를 리턴한다.&lt;/li&gt;
&lt;li&gt;어떤 필드든 정렬 조건을 리턴하는 메소드를 이용한다. (정렬 조건이 여러개 올 수 있기 때문에 각 이 메소드를 호출한다)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Service 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770102401222&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
    public CursorResponseUserDto getAllUsers(UserCursorRequest request)
    {
        //QueryDsl 메서드 호출
        List&amp;lt;User&amp;gt; users = userRepository.findAllUsers(request);
        boolean hasNext = false;
        String nextCursor = null;
        UUID idAfter = null;
        //JpaRepository 기본 메서드 count 호출
        Long totalCount = userRepository.count();
        
         if(users.size() &amp;gt; request.limit())
        {
            hasNext = true;
            users.remove(users.size() - 1);
            idAfter = users.get(users.size() - 1).getUuid();
            nextCursor = getNextCursor(request, users);
        }
        return CursorResponseUserDto.builder()
                .data(orderedUsers.stream()
                        .map(x -&amp;gt; UserDto.builder()
                                .user(x)
                                .build()).toList())
                .hasNext(hasNext)
                .nextCursor(nextCursor)
                .nextIdAfter(idAfter)
                .totalCount(totalCount)
                .sortDirection(request.sortDirection().name())
                .sortBy(request.sortBy().name())
                .build();

    }

    private String getNextCursor(UserCursorRequest request, List&amp;lt;User&amp;gt; users)
    {
        switch (request.sortBy())
        {
            case name:
                return users.get(users.size() - 1).getProfile().getName();
            case email:
                return users.get(users.size() - 1).getEmail();
            case role:
                return users.get(users.size() - 1).getUserRoles().get(0).getRole().getName().name();
            case isLocked:
                return users.get(users.size() - 1).getLocked().toString();
            case createdAt:
                return users.get(users.size() - 1).getCreatedAt().toString();
            default:
                return null;
        }
    }&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;Slice를 이용하지 않고 따로 Dto를 통해 결과를 리턴한다.&lt;/li&gt;
&lt;li&gt;Repository 조회 수가 limit 보다 적을경우 next 는 없다고 간주하여 처리한다.&lt;/li&gt;
&lt;li&gt;Cursor의 경우 정렬조건이 여러개 일 수 있으므로 이에 각각 해당하는 값을 넣어준다.&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Fetch Join을 위한 처리&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;일반적인 Paging 에선 Fetch join을 하면 안된다. 따라서 이후 fetch join을 통해 데이터를 한번 더 가져와야 한다. (N+1 방지)&lt;/li&gt;
&lt;li&gt;결국 Entity에서 List 형태로 멤버변수를 갖고 있다면 2번의 쿼리를 하게 되는 셈이다.&lt;/li&gt;
&lt;/ul&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;b&gt;Repositoty 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770101357318&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    //N+1 을 해결하기 위한 페이지네이션 후속 쿼리
    @Override
    public List&amp;lt;User&amp;gt; findUsersByIds(List&amp;lt;UUID&amp;gt; ids) {
        return findUserExpression(false)
                .where(user.uuid.in(ids))
                .fetch();
    }&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;ids를 통해 in 을 이용하여 모든 항목을 fetch join 하여 가져온다.&lt;/li&gt;
&lt;/ul&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;b&gt;Service 예 (Fetch Join&amp;nbsp; 예)&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770102337852&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
    public CursorResponseUserDto getAllUsers(UserCursorRequest request)
    {
        //QueryDsl 메서드 호출
       ...

        //userroles n+1 해결을 위해 fetch join을 해오기 위한 부분
        List&amp;lt;UUID&amp;gt; ids = users.stream().map(User::getUuid).toList();
        if(ids.isEmpty())
            return new CursorResponseUserDto(
                    null,
                    nextCursor,
                    idAfter,
                    hasNext,
                    totalCount,
                    request.sortBy().name(),
                    request.sortDirection().name());

        //fetch join 후 정렬이 깨짐
        List&amp;lt;User&amp;gt; fetchedUsers = userRepository.findUsersByIds(ids);

        //uuid를 key로 하는 userMap을 생성 -&amp;gt; 정렬된 리스트를 재구성할떄 사용
        Map&amp;lt;UUID, User&amp;gt; userMap = fetchedUsers.stream()
                .collect(Collectors.toMap(User::getUuid, u -&amp;gt; u));

        //ids 는 paging 조건에 맞는 정렬형태. userMap에서 가져와 기존 정렬된 형태로 복구
        List&amp;lt;User&amp;gt; orderedUsers = ids.stream().map(userMap::get).toList();

       ...

    }&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;1차로 Repository에서 Paging된 데이터를 조회한다.&lt;/li&gt;
&lt;li&gt;이 List 에서 id 값만 따로 추출한다.&lt;/li&gt;
&lt;li&gt;추출한 id값들을 인자로 하여 fetch join을 진행하는 repository 메서드를 호출한다.&lt;/li&gt;
&lt;li&gt;fetch join된 결과는 정렬되지 않으므로, 기존 id List 순서 기준으로 값을 재배치 하여 원본 순서를 맞춘다.&lt;/li&gt;
&lt;li&gt;이를 이용해 controller에 리턴한다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/422</guid>
      <comments>https://dean83.tistory.com/422#entry422comment</comments>
      <pubDate>Tue, 3 Feb 2026 16:11:04 +0900</pubDate>
    </item>
    <item>
      <title>임시 비번 발급 후 비교할때 주의점(PasswordEncoder)</title>
      <link>https://dean83.tistory.com/420</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;실제 회원가입 등 비밀번호 저장을 DB에 할 때에는 PasswordEncoder 의 encode 메서드를 이용해서 저장만 하지, 실제 비교할 일은 거의 없었다. 보통은 이미 구현이 되어 있는 DaoAuthenticationProvider 에서 인증을 해주기 때문이다.&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;DaoAuthenticationProvider 을 상속받아 구현하는 구현체를 만들어서 이곳에서 비교를 해야 하는 상황이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(만일 아이디 / 비번이 아닐경우 다른 Provider를 사용할 수 있고, 이 경우, AuthenticationManager 구현체를 디버깅 걸어서 확인필요)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알맞게 임시 비밀번호를 발급하고 입력했음에도 계속하여 로그인 실패가 나서 그 이유를 찾아보니, 다음과 같았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PasswordEncoder 를 이용해 암호화 할 때 마다 값이 바뀐다. (BCryptEncoder)&lt;/li&gt;
&lt;li&gt;PasswordEncoder 를 통해 matches 메서드를 이용해 사용자 입력값과 암호화된 비밀번호를 매칭한다.
&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;하다. 1&lt;b&gt;번째 인자값이 사용자 입력값 (암호화 안된 순수 텍스트)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2번째 인자값이 암호화된 비밀번호&lt;/b&gt; 이다.&amp;nbsp;&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;이거 순서가 중요한거 모르고 계속 헤맸어서 기록용으로 남겨둔다.&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;참고로, DaoAuthentication 을 상속받아 구현한 예는 아래와 같다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770009238318&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CustomDaoAuthenticationProvider extends DaoAuthenticationProvider {
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
                                                  UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

        String normalPassword = authentication.getCredentials().toString();
        CustomUserDetails customUserDetails = (CustomUserDetails) userDetails;
        boolean normalPasswordMatches = getPasswordEncoder().matches(normalPassword, customUserDetails.getPassword());
        if(normalPasswordMatches == false) {
            Instant temporaryPasswordCreatedAt = customUserDetails.getTemporaryPasswordCreatedAt();
            String temporaryPassword = customUserDetails.getTemporaryPassword();

            if(temporaryPasswordCreatedAt == null
                    || StringUtils.hasText(temporaryPassword) == false
                    || Instant.now().minus(3, ChronoUnit.MINUTES).isAfter(temporaryPasswordCreatedAt)) {
                throw new BadCredentialsException(&quot;Bad credentials&quot;);
            }

            boolean tempPasswordMatches = getPasswordEncoder().matches(normalPassword,
                    temporaryPassword);

            if(tempPasswordMatches == false) {
                throw new BadCredentialsException(&quot;Bad credentials&quot;);
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/420</guid>
      <comments>https://dean83.tistory.com/420#entry420comment</comments>
      <pubDate>Mon, 2 Feb 2026 14:14:19 +0900</pubDate>
    </item>
    <item>
      <title>Hexagonal Architecture 일부적용 및 느낀점</title>
      <link>https://dean83.tistory.com/419</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;스프링부트를 하면서, Hexagonal Architecture 에 대한 고민을 했던 기억이 있어 내용을 정리해 둔다.&amp;nbsp;&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;b&gt;고민시점&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;도메인 위주로 패키지를 나누어 둠 (혹시 모를, 추후 MSA 전환을 대비)&lt;/li&gt;
&lt;li&gt;도메인 A 에서 도메인 B의 Service나 Repository를 주입받아 이용하는곳이 있었음.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;이 연관관계를 깨고 싶었음.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에 고민을 했었고, 실제 완전한 Hexagonal 은 아니어도, 어느정도 의미있게 분리를 했다고 생각한다.&amp;nbsp;&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;b&gt;서버 이벤트 방식 고민&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;자료를 리턴받는 콜백 개념이 필요했으므로, fire-forget인 이벤트와는 맞지않다고 생각했다.&lt;/li&gt;
&lt;li&gt;충분히 큰 규모였다면 kafka 나 MQ 처럼 외부 서비스를 구성해 볼수도 있겠지만, 현재는 시작단계여서 오버스펙이었다.&lt;/li&gt;
&lt;li&gt;또한 백앤드는 클라이언트와 다르게 수많은 클라이언트를 대상으로 하기도 하고, 역할 분리 등이 엄격하다고 느꼈다.&lt;/li&gt;
&lt;li&gt;그러나 사실 서버 이벤트 방식도 고민을 했었고, 충분히 적용해볼 만 하다고도 생각된다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;대략적으로 구상한 방법은,
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도메인 A 에서 &quot;나 이 자료 필요해&quot; 라고 이벤트 발생&lt;/li&gt;
&lt;li&gt;도메인 B 에서 해당 이벤트 수신 후 DB 조회 등 작업 수행&lt;/li&gt;
&lt;li&gt;도메인 B 에서 &quot;자료 여기있어&quot; 라고 이벤트 발생&lt;/li&gt;
&lt;li&gt;도메인 A 에서 이 이벤트를 수신하여 실제 비즈니스 로직 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;실제 이 방식은 C#을 이용했을때 많이들 사용했던 방식이고, 사실 delegate를 이용하여 조금 모습이 다르긴 하다만, 충분히 반영해봐도 좋다고 생각한다.&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;다시 돌아와서, 내가 적용했던 Hexagonal 내용을 정리하자면,&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;도메인 A에서만 사용할 Dto 생성&lt;/li&gt;
&lt;li&gt;Port 인터페이스 생성&lt;/li&gt;
&lt;li&gt;Adapter 클래스 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도로만 구성을 끝냈다. 원래대로라면 좀 더 복잡하지만, 어짜피 Springboot 라는 조건하에 굳이 순수 Java 코딩을 해야 하는 부분들은 필요 없다고 생각 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 각각 어떤것을 적용했냐 하면,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Dto 생성&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;도메인 A에서만 사용할 Dto로, 외부 데이터를 이용하여 구성한다.&lt;/li&gt;
&lt;li&gt;이를 통해 도메인 A 에서는 외부 자료형 등에 영향을 받지 않고, 내부 도메인 Dto만 바라볼 수 있어 결합도가 낮아진다.&lt;/li&gt;
&lt;/ul&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;b&gt;Port 인터페이스 생성&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;외부 통신을 정의한 인터페이스로, 위에서 정의한 Dto를 리턴하는 메서드를 정의만 해 둔다.&lt;/li&gt;
&lt;/ul&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;b&gt;Adapter 클래스 생성&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;사실상 핵심이라고 볼 수 있다. Port를 구현한 클래스로, 실제 외부 통신을 수행하게 된다.&lt;/li&gt;
&lt;li&gt;역할은, 비즈니스 로직을 제외하고 순수 외부 통신 -&amp;gt; Dto로 데이터를 변환하여 리턴만 한다.&lt;/li&gt;
&lt;li&gt;외부 통신 및 외부에서 쓰는 자료형을 리턴받기 때문에 더러워 질 수 밖에 없다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;그러나 외부의 영향을 받는 모든것들을 이곳에 모아둠으로서, 도메인 A에 있는 다른 모든 항목들은 영향을 받지 않고 분리된다.&lt;/li&gt;
&lt;li&gt;여기서 B 도메인의 Service를 주입받아, 메소드 호출 및 리턴 자료형을 도메인 A에 사용하는 Dto로 변환 하였다.&lt;/li&gt;
&lt;li&gt;도메인 A 에 있는 Service에서 이 Adapter를 주입받아 호출하여 사용한다.&amp;nbsp;&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;물론 도입 과정에서 Dto가 중복되는 점 (클래스는 다르나 내용은 같은점) 등 많은 고민이 있었으나 나름 좋은 경험이었다고 생각된다.&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/419</guid>
      <comments>https://dean83.tistory.com/419#entry419comment</comments>
      <pubDate>Fri, 30 Jan 2026 15:21:59 +0900</pubDate>
    </item>
    <item>
      <title>Application.yaml 값 불러오기 (Class에 매핑)</title>
      <link>https://dean83.tistory.com/418</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;어딘가에 기록을 해 둔거 같은데...찾을수가 없어서 다시 기록해 둔다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다양한 방법으로 yaml 에 있는 값을 불러올 수 있으나, 여기서는 Class 혹은 Record에 매칭 하는 방법을 기준으로 정리해둔다.&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;application.yaml에 다음과 같이 정의되어 있다면,&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769669754478&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;...
jwt:
  secret: ${JWT_SECRET_KEY}
  access_key_expiration: 600000
  refresh_key_expiration: 1209600000
  issuer: &quot;test-server&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;아래와 같이 클래스 및 맴버변수로 매핑할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769669712805&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = &quot;jwt&quot;)
public class JwtProperties {
    private String issuer;
    private Long accessKeyExpiration;
    private Long refreshKeyExpiration;
    private String secret;
}&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;이렇게 별도로 클래스로 떼어 매핑하는 이유는, 역할 분리를 하기 위함이다.&amp;nbsp;&lt;br /&gt;record로 매핑할 경우 불변이 되므로 좀 더 좋은 매핑이 될것으로 생각된다.&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/418</guid>
      <comments>https://dean83.tistory.com/418#entry418comment</comments>
      <pubDate>Thu, 29 Jan 2026 15:57:02 +0900</pubDate>
    </item>
    <item>
      <title>Cache, RefreshToken 용 Redis를 쓸 경우.</title>
      <link>https://dean83.tistory.com/417</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Redis를 통해 Cache와 RefreshToken을 둘다 관리하는 경우, 설정이 헷갈릴 수 있어 정리해 둔다.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1개의 Redis를 띄워 각각 cache, token용으로 나누어 운용 가능&lt;/li&gt;
&lt;li&gt;단, database를 나누어야 함.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예 : 0번은 cache용, 1번은 token용&lt;/li&gt;
&lt;li&gt;기본적으로 0 ~ 15까지 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;application.yaml 설정을 통해 둘중 하나의 config는 자동으로, 하나의 config는 수동으로 설정해야 함.&amp;nbsp;
&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;수동으로 설정할때 주의점은, bean 이름을 반드시 명시해야 한다는 점이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기본 bean 으로 사용할 경우 application.yaml의 설정을 덮어 쓴다&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;RefreshToken 용 config 수동 설정 예&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769665418291&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;
@Configuration
@RequiredArgsConstructor
public class TokenRedisConfig {

    private final RedisProperties redisProperties;

    @Bean(name = &quot;refreshTokenRedisConnectionFactory&quot;)
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config =
                new RedisStandaloneConfiguration(
                        redisProperties.getHost()
                        , redisProperties.getPort());

        config.setDatabase(1);

        GenericObjectPoolConfig&amp;lt;StatefulConnection&amp;lt;?, ?&amp;gt;&amp;gt; poolConfig = new GenericObjectPoolConfig&amp;lt;&amp;gt;();
        poolConfig.setMaxTotal(redisProperties.getLettuce().getPool().getMaxActive());
        poolConfig.setMaxIdle(redisProperties.getLettuce().getPool().getMaxIdle());
        poolConfig.setMinIdle(redisProperties.getLettuce().getPool().getMinIdle());
        poolConfig.setMaxWait(redisProperties.getLettuce().getPool().getMaxWait());

        LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig)
                .commandTimeout(redisProperties.getConnectTimeout())
                .build();

        return new LettuceConnectionFactory(config, clientConfig);
    }

    @Bean(name = &quot;tokenRedisTemplate&quot;)
    public RedisTemplate&amp;lt;String,String&amp;gt; tokenRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate&amp;lt;String,String&amp;gt; redisTemplate = new RedisTemplate&amp;lt;&amp;gt;();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}&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;RedisProperties 는 &lt;a href=&quot;https://dean83.tistory.com/418&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dean83.tistory.com/418&lt;/a&gt; 여기에 정리해 놓은것 처럼, yaml 에 있는 설정을 매핑한 클래스 이다.&lt;/li&gt;
&lt;li&gt;GenericObjectPoolConfig를 이용하기 위해 아래의 라이브러리를 gradle에 추가해야 한다.&lt;/li&gt;
&lt;li&gt;기본적인 redis 동작 설정 (pool 개수 등), 타임아웃을 포함해야 한다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'org.apache.commons:commons-pool2'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;br /&gt;RefreshToken을 관리하는 Repository 예&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;redisTemplate을 주입받아 이를 통해 redis에 저장&lt;/li&gt;
&lt;li&gt;참고로 별도의 서비스를 구현하여 repository에 접근할 경우 @Transactional은 쓰지 않는다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis에선 일반적인 Transactional과 개념이 다름&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769665539599&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Repository
public class RefreshTokenRedisRepository {

    private final StringRedisTemplate redisTemplate;

    public RefreshTokenRedisRepository(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void save(Long userId, String refreshToken, long ttlMs) {
        String key = &quot;refresh_token:&quot; + userId;
        redisTemplate.opsForValue()
                .set(key, refreshToken, ttlMs, TimeUnit.MILLISECONDS);
    }

    public String find(Long userId) {
        return redisTemplate.opsForValue()
                .get(&quot;refresh_token:&quot; + userId);
    }

    public void delete(Long userId) {
        redisTemplate.delete(&quot;refresh_token:&quot; + userId);
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/417</guid>
      <comments>https://dean83.tistory.com/417#entry417comment</comments>
      <pubDate>Thu, 29 Jan 2026 14:46:34 +0900</pubDate>
    </item>
    <item>
      <title>Security 에서 Role 적용시 헷갈리는 부분 정리</title>
      <link>https://dean83.tistory.com/416</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;보통, User의 Role을 이용할 경우, enum을 통해서 ADMIN, USER 처럼 정의하고 이용하게 된다. DB에도 이렇게 저장된다.&lt;br /&gt;그러나 권한 확인 메소드인 hasRole 같은 경우에는 자동으로 앞에 &quot;ROLE_&quot; 문자열을 붙여서 비교하게 된다.&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;사용자 인증 할때 보통 Custom 한 UserDetails 를 구현한 구현체 구성을 하게 되는데, 오버라이드 한 메소드 중 아래 메소드에서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;ROLE_&quot; 문자열을 붙여줘야 한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #080808;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Override
public Collection&amp;lt;? extends GrantedAuthority&amp;gt; getAuthorities() {
    if(userDto == null)
        return List.of();

    return List.of(new SimpleGrantedAuthority(&quot;ROLE_&quot; + userDto.getRole()));
}&lt;/code&gt;&lt;/pre&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;이제 이 경우, 어느 Role 에서는 ROLE_가 붙고, 어디서는 enum값 그대로 쓸 수 있는지 헷갈리게 된다.&lt;/p&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;SecurityContext에 저장된 항목 : ROLE_ 가 붙어 있음&lt;/li&gt;
&lt;li&gt;그외 개발자가 개발한 API, 엔티티 등 항목 : ROLE_가 붙어있지 않음&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;&amp;nbsp;&lt;/p&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;DB, 엔티티에는 Enum에서 명시한 Role 이름 그대로.&lt;/li&gt;
&lt;li&gt;UserDetails를 구현한 구현체에서 ROLE_ 문자열 붙이기&lt;/li&gt;
&lt;li&gt;이후 SecurityContext 에 저장된 Role을 가져올 경우에는 ROLE_이 붙어있음 (GrantedAuthority 관련된 항목들)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단, SecurityContext에 Dto를 저장했다면, 해당 Dto에 있는 role 은 enum 그대로&lt;/li&gt;
&lt;li&gt;Authentication의 getPrincipal 메소드는 ROLE_&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;hasRole() 메서드를 이용하는 경우에는 자동으로 ROLE_가 붙음&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/416</guid>
      <comments>https://dean83.tistory.com/416#entry416comment</comments>
      <pubDate>Wed, 28 Jan 2026 15:04:12 +0900</pubDate>
    </item>
    <item>
      <title>SSE 예제</title>
      <link>https://dean83.tistory.com/413</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이론부분은 &lt;a href=&quot;https://dean83.tistory.com/412&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dean83.tistory.com/412&lt;/a&gt; 이부분을 보면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도로 build.gradle에 추가할 필요는 없다. (대부분 starter-web은 이미 추가 되어 있다)&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;b&gt;Async 및 TaskExecutor 설정&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;a href=&quot;https://dean83.tistory.com/393&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dean83.tistory.com/393&lt;/a&gt; 에서 자세히 다루었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768376687524&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {

    @Bean(&quot;sseExecutor&quot;)
    public TaskExecutor sseTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix(&quot;sse-&quot;);
        executor.initialize();
        return executor;
    }
}&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;&lt;b&gt;전송 데이터 정의 예&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;SSE는 text 기반이므로, 직렬화가 가능해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768376753622&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class NotificationEvent {
    private String id;
    private String type;
    private String message;
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;SSE 이벤트 데이터 엔티티 예 (DB 저장용)&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;서버가 재부팅 되거나 클라이언트 재접속 등 이슈가 발생했을때 이어서 동작할 수 있도록 DB에 내용 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768377386961&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;sse_event&quot;)
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SseEventEntity {

    @Id
    private String eventId;

    private String eventType;
    private String targetGroup;

    @Lob
    private String payload;

    private LocalDateTime createdAt;
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;이벤트 저장 및 조회 서비스 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1768377420217&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 EventStoreService {

    private final SseEventRepository repository;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public void save(String eventId, String eventType, String group, Object payload) {
        try {
            repository.save(
                SseEventEntity.builder()
                    .eventId(eventId)
                    .eventType(eventType)
                    .targetGroup(group)
                    .payload(objectMapper.writeValueAsString(payload))
                    .createdAt(LocalDateTime.now())
                    .build()
            );
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    public List&amp;lt;SseEventEntity&amp;gt; findAfter(String lastEventId, String group) {
        return repository.findByEventIdGreaterThanAndTargetGroup(
            lastEventId, group
        );
    }
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;클라이언트 정보 저장 클래스 예&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;SseEmitter 를 관리하는 측에서 이 정보를 토대로 생성/삭제 등 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768377582116&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class ClientSubscription {
    private String clientId;
    private String group;
    private Set&amp;lt;String&amp;gt; eventTypes;
    private SseEmitter emitter;
}&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;&lt;b&gt;SseEmitter 관리&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;Sse 생성 및 관리 코드 예제이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768376835958&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class SseEmitterManager {

    private static final long TIMEOUT = 60 * 60 * 1000;
    private static final int MAX_CONNECTION = 1000;

    // clientId 기준으로 관리 (정책적으로 단일 연결)
    private final Map&amp;lt;String, ClientSubscription&amp;gt; clients = new ConcurrentHashMap&amp;lt;&amp;gt;();

    private final SseReplayService replayService;

    public SseEmitter create(
        String clientId,
        String group,
        Set&amp;lt;String&amp;gt; eventTypes,
        String lastEventId
    ) {
        if (clients.size() &amp;gt;= MAX_CONNECTION) {
            throw new IllegalStateException(&quot;Max SSE connections exceeded&quot;);
        }

        // 기존 연결 정리 (중복 로그인 / 재연결 대비)
        remove(clientId);

        SseEmitter emitter = new SseEmitter(TIMEOUT);

        ClientSubscription subscription =
            new ClientSubscription(clientId, group, eventTypes, emitter);

        clients.put(clientId, subscription);

        emitter.onCompletion(() -&amp;gt; remove(clientId));
        emitter.onTimeout(() -&amp;gt; remove(clientId));
        emitter.onError(e -&amp;gt; remove(clientId));

        //핵심: emitter 생성 직후 재전송 트리거
        replayService.replay(subscription, lastEventId);

        return emitter;
    }

    public Collection&amp;lt;ClientSubscription&amp;gt; getAll() {
        return clients.values();
    }

    public Optional&amp;lt;ClientSubscription&amp;gt; get(String clientId) {
        return Optional.ofNullable(clients.get(clientId));
    }

    public void remove(String clientId) {
        ClientSubscription client = clients.remove(clientId);
        if (client != null) {
            client.getEmitter().complete();
        }
    }
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비동기 전송 및 필터링 서비스&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;/ul&gt;
&lt;pre id=&quot;code_1768376910043&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 SseSendService {

    private final SseEmitterManager manager;
    private final EventStoreService storeService;

    @Async(&quot;sseExecutor&quot;)
    public void publish(
        String eventType,
        String targetGroup,
        Object payload
    ) {
        String eventId = UUID.randomUUID().toString();

        // 1️⃣ DB 저장
        storeService.save(eventId, eventType, targetGroup, payload);

        // 2️⃣ 필터링 후 전송
        manager.getAll().stream()
            .filter(c -&amp;gt; c.getGroup().equals(targetGroup))
            .filter(c -&amp;gt; c.getEventTypes().contains(eventType))
            .forEach(c -&amp;gt; send(c, eventId, eventType, payload));
    }

    private void send(
        ClientSubscription client,
        String eventId,
        String eventType,
        Object payload
    ) {
        try {
            client.getEmitter().send(
                SseEmitter.event()
                    .id(eventId)
                    .name(eventType)
                    .data(payload)
            );
        } catch (IOException e) {
            client.getEmitter().complete();
        }
    }
}&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;&lt;b&gt;주기적으로 연결 정리&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;스케줄러를 통해 heartbeat을 보내고 정리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768377754350&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class SseHeartbeatScheduler {

    private final SseEmitterManager manager;

    @Scheduled(fixedRate = 30_000)
    public void heartbeat() {
        manager.getAll().forEach(c -&amp;gt; {
            try {
                c.getEmitter().send(
                    SseEmitter.event()
                        .name(&quot;heartbeat&quot;)
                        .data(&quot;ping&quot;)
                );
            } catch (IOException e) {
                c.getEmitter().complete();
            }
        });
    }
}&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;&lt;b&gt;재전송 서비스 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1768377921570&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 SseReplayService {

    private final EventStoreService storeService;

    @Async(&quot;sseExecutor&quot;)
    public void replay(
        ClientSubscription client,
        String lastEventId
    ) {
        if (lastEventId == null) return;

        List&amp;lt;SseEventEntity&amp;gt; events =
            storeService.findAfter(lastEventId, client.getGroup());

        events.stream()
            .filter(e -&amp;gt; client.getEventTypes().contains(e.getEventType()))
            .forEach(e -&amp;gt; send(client, e));
    }

    private void send(ClientSubscription client, SseEventEntity e) {
        try {
            client.getEmitter().send(
                SseEmitter.event()
                    .id(e.getEventId())
                    .name(e.getEventType())
                    .data(e.getPayload())
            );
        } catch (IOException ex) {
            client.getEmitter().complete();
        }
    }
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;컨트롤러 예&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1768378147228&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api/sse&quot;)
public class SseController {

    private final SseEmitterManager sseEmitterManager;

    public SseController(SseEmitterManager sseEmitterManager) {
        this.sseEmitterManager = sseEmitterManager;
    }

    @GetMapping(&quot;/subscribe&quot;)
    public SseEmitter subscribe(
            @RequestHeader(value = &quot;Last-Event-ID&quot;, required = false) String lastEventId,
            @RequestParam String userId
    ) {
        return sseEmitterManager.subscribe(userId, lastEventId);
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Dean83</author>
      <guid isPermaLink="true">https://dean83.tistory.com/413</guid>
      <comments>https://dean83.tistory.com/413#entry413comment</comments>
      <pubDate>Wed, 14 Jan 2026 17:17:01 +0900</pubDate>
    </item>
  </channel>
</rss>