<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발 기록</title>
    <link>https://devz0.tistory.com/</link>
    <description>개발 기록</description>
    <language>ko</language>
    <pubDate>Fri, 14 Aug 2026 15:17:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>limitation01</managingEditor>
    <item>
      <title>[Nbcam] 유저 검색 조회 속도 개선</title>
      <link>https://devz0.tistory.com/122</link>
      <description>&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;데이터: 500만건&lt;/li&gt;
&lt;li&gt;조건: nickname 정확히 일치&lt;/li&gt;
&lt;li&gt;결과: 중복 없기 때문에 단건 조회&lt;/li&gt;
&lt;li&gt;테스트 환경:&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조회 성능 측정 시 캐시 효과를 배제하기 위해 매번 MySQL 서버를 재시작해 동일한 스타트 환경에서 측정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;최초 조회 속도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스가 없는 상태에서 500만건중 쿼리파라미터로 들어온 값과 일치하는 단건 조회를 수행한 결과&lt;/p&gt;
&lt;pre id=&quot;code_1766548415153&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-&amp;gt; Filter: (u.nickname = 'zi0')  
(cost=529242 rows=499163) 
(actual time=0.0548..2733 rows=1 loops=1)
    -&amp;gt; Table scan on u  
    (cost=529242 rows=4.99e+6) 
    (actual time=0.0494..2589 rows=5.03e+6 loops=1)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-24 오후 12.55.52.png&quot; data-origin-width=&quot;208&quot; data-origin-height=&quot;33&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UMk1N/dJMcagc27RM/nqa1JyU2ipd4TsmYl02kJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UMk1N/dJMcagc27RM/nqa1JyU2ipd4TsmYl02kJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UMk1N/dJMcagc27RM/nqa1JyU2ipd4TsmYl02kJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUMk1N%2FdJMcagc27RM%2Fnqa1JyU2ipd4TsmYl02kJk%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;208&quot; height=&quot;33&quot; data-filename=&quot;스크린샷 2025-12-24 오후 12.55.52.png&quot; data-origin-width=&quot;208&quot; data-origin-height=&quot;33&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;소요 시간&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;DB 실행시간: 약 2.6s&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 전체 응답 시간: 약 3.53s&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전체 테이블 스캔 발생&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;조회 속도 개선 아이디어&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. 엔티티 조회방식 대신 DTO projection 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불필요한 엔티티 로딩을 피하고 필요한 컬럼만 조회하기 위해 DTO Projection 방식을 고려했다.&lt;/p&gt;
&lt;pre id=&quot;code_1766218977030&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(&quot;&quot;&quot;
            select new org.example.expert.domain.user.dto.response.UserSearchResponse(
                u.id,
                u.email,
                u.nickname
            )
            from User u
            where u.nickname = :nickname
        &quot;&quot;&quot;)
    UserSearchResponse findFirstByNickname(@Param(&quot;nickname&quot;) String nickname);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;예상&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스가 없는 상황에서 테이블 스캔 비용이 크다고 생각해 DTO Projection 단독으로는 유의미한 성능 개선은 없을 것이라 예상했다.&lt;/li&gt;
&lt;li&gt;다만, 인덱싱과 함께 사용시 테이블 접근 비용 감소에 기여할 수 있을 것이라고 판단했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1766232346455&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-&amp;gt; Filter: (u.nickname = 'zi0')  
(cost=530220 rows=499163) 
(actual time=1.88..3016 rows=1 loops=1)
    -&amp;gt; Table scan on u  
    (cost=530220 rows=4.99e+6) 
    (actual time=1.87..2510 rows=5.03e+6 loops=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;&lt;b&gt;DB실행 시간: 약 2.5s&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예상과 동일하게 조회 성능에 유의미한 변화 없음&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.&amp;nbsp; 인덱스 적용에 따른 성능 변화&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-1. nickname 필드에 인덱스 추가&lt;/h4&gt;
&lt;pre id=&quot;code_1766219312956&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@NoArgsConstructor
@Table(name = &quot;users&quot;, indexes = @Index(name = &quot;idx_user_nickname&quot;, columnList = &quot;nickname&quot;))

public class User extends Timestamped {&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1766232495914&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-&amp;gt; Index lookup on u using idx_user_nickname (nickname='zi0')  
(cost=1.1 rows=1) 
(actual time=0.257..0.258 rows=1 loops=1)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-24 오후 1.08.25.png&quot; data-origin-width=&quot;213&quot; data-origin-height=&quot;26&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kIFIR/dJMcaiIEtI5/8GV6MYcBLkL226jGekn0iK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kIFIR/dJMcaiIEtI5/8GV6MYcBLkL226jGekn0iK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kIFIR/dJMcaiIEtI5/8GV6MYcBLkL226jGekn0iK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkIFIR%2FdJMcaiIEtI5%2F8GV6MYcBLkL226jGekn0iK%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;213&quot; height=&quot;26&quot; data-filename=&quot;스크린샷 2025-12-24 오후 1.08.25.png&quot; data-origin-width=&quot;213&quot; data-origin-height=&quot;26&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;소요 시간:&amp;nbsp;&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;DB 실행시간: 약 &lt;b&gt;0.26&lt;/b&gt;s&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 전체 응답 시간: 약 &lt;b&gt;0.36&lt;/b&gt;s&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테이블 스캔이 제거되며 조회 성능이 크게 개선됨을 확인&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2-2.nickname 필드로 unique index scan&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;닉네임 중복이 없는 요구사항을 반영해 유니크 제약 조건을 적용했다.&lt;/p&gt;
&lt;pre id=&quot;code_1766227347028&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@NoArgsConstructor
@Table(name = &quot;users&quot;,
        uniqueConstraints = {
            @UniqueConstraint(
                    name=&quot;unique_users_nickname&quot;,
                    columnNames = &quot;nickname&quot;
            )
})
public class User extends Timestamped {&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;예상&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1888&quot; data-start=&quot;1837&quot;&gt;유니크 인덱스 스캔을 사용하면 옵티마이저가 논리적으로 LIMIT 1을 보장한다고 한다.&lt;/li&gt;
&lt;li data-end=&quot;1932&quot; data-start=&quot;1889&quot;&gt;결과가 항상 1건이므로 &lt;b&gt;일반 인덱스보다 더 빠를 것&lt;/b&gt;이라 예상했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-24 오후 1.11.48.png&quot; data-origin-width=&quot;208&quot; data-origin-height=&quot;30&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baKkDD/dJMcacPe9rN/wuSupWy3ewds6YOmOk3vJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baKkDD/dJMcacPe9rN/wuSupWy3ewds6YOmOk3vJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baKkDD/dJMcacPe9rN/wuSupWy3ewds6YOmOk3vJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaKkDD%2FdJMcacPe9rN%2FwuSupWy3ewds6YOmOk3vJk%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;208&quot; height=&quot;30&quot; data-filename=&quot;스크린샷 2025-12-24 오후 1.11.48.png&quot; data-origin-width=&quot;208&quot; data-origin-height=&quot;30&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1766232792669&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-&amp;gt; Rows fetched before execution  
(cost=0..0 rows=1) 
(actual time=119e-6..187e-6 rows=1 loops=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;&lt;b&gt;소요 시간:&amp;nbsp;&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;DB 실행시간: 약 &lt;b&gt;0.19&lt;/b&gt;s&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 전체 응답 시간: 약 0.46s&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;유니크 인덱스를 적용했음에도 BTREE 인덱스와 조회 속도 차이는 없었다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;도출한 인사이트&lt;/h4&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;B-Tree 인덱스 탐색&amp;nbsp;&amp;nbsp;&amp;nbsp;(BTREE vs UNIQUE 동일한 탐색을 함)&lt;/li&gt;
&lt;li&gt;PK로 테이블 row를 접근&lt;/li&gt;
&lt;li&gt;결과 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 스캔의 종류보다 2번 문제 즉 테이블의 접근 문제를 생각해야 유의미한 조회 속도 개선을 할 수 있을 것이라고 판단했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;랜덤 I/O 관점으로의 변화&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 도서에서 언급된&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;SQL 튜닝은 랜덤 I/O와의 전쟁이다&lt;/blockquote&gt;
&lt;p data-end=&quot;2909&quot; data-start=&quot;2883&quot; data-ke-size=&quot;size16&quot;&gt;라는 문장은 본 실험 결과와 정확히 일치하였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2995&quot; data-start=&quot;2911&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2939&quot; data-start=&quot;2911&quot;&gt;인덱스 탐색 자체에서는 병목이 발생하지 않았다.&lt;/li&gt;
&lt;li data-end=&quot;2995&quot; data-start=&quot;2940&quot;&gt;PK 기반 테이블 접근으로 인한 &lt;b&gt;랜덤 I/O&lt;/b&gt;가 조회 성능 저하의 주된 원인이었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 커버링 인덱스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 언급했듯 성능 개선의 핵심은 어떤 인덱스를 사용하냐가 아니라 테이블 접근을 어떻게 제거하느냐 였다. 인덱스 탐색 이후 발생하는 테이블 row접근(랜덤 I/O)을 제거할 수 있다면 조회 성능을 유의미하게 개선할 수 있을 것이라고 생각해 커버링 인덱스를 사용하게 되었다.&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 data-end=&quot;326&quot; data-start=&quot;294&quot;&gt;WHERE 절에 사용되는 컬럼이 인덱스에 포함되어 있고&lt;/li&gt;
&lt;li data-end=&quot;363&quot; data-start=&quot;327&quot;&gt;SELECT 절에서 조회하는 컬럼 또한 인덱스에 포함되어 있어&lt;/li&gt;
&lt;li data-end=&quot;410&quot; data-start=&quot;364&quot;&gt;&lt;b&gt;테이블(row)에 접근하지 않고 인덱스만으로 결과를 반환할 수 있는 경우&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의 조건을 만족하면 PK를 통해 테이블 데이터를 다시 조회하는 과정을 생략할 수 있다&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;구성&lt;/h4&gt;
&lt;pre id=&quot;code_1766234222081&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Table(
        name = &quot;users&quot;,
        indexes = {
                @Index(
                        name = &quot;idx_users_nickname_covering&quot;,
                        columnList = &quot;nickname, id, email&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 data-end=&quot;887&quot; data-start=&quot;856&quot;&gt;WHERE 조건
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;887&quot; data-start=&quot;871&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;887&quot; data-start=&quot;871&quot;&gt;nickname&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;937&quot; data-start=&quot;888&quot;&gt;SELECT 컬럼
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;937&quot; data-start=&quot;904&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;910&quot; data-start=&quot;904&quot;&gt;id&lt;/li&gt;
&lt;li data-end=&quot;922&quot; data-start=&quot;913&quot;&gt;email&lt;/li&gt;
&lt;li data-end=&quot;937&quot; data-start=&quot;925&quot;&gt;nickname&lt;/li&gt;
&lt;/ul&gt;
&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 data-end=&quot;1049&quot; data-start=&quot;1023&quot;&gt;WHERE 조건은 인덱스에서 바로 필터링&lt;/li&gt;
&lt;li data-end=&quot;1074&quot; data-start=&quot;1050&quot;&gt;SELECT 컬럼 역시 인덱스에 포함&lt;/li&gt;
&lt;li data-end=&quot;1107&quot; data-start=&quot;1075&quot;&gt;&lt;b&gt;테이블 row 접근 없이 결과 반환이 가능&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1766232094054&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-&amp;gt; Covering index lookup on u using idx_users_nickname_covering (nickname='zi0')  
(cost=1.1 rows=1) 
(actual time=0.0294..0.0335 rows=1 loops=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 data-end=&quot;3325&quot; data-start=&quot;3291&quot;&gt;&lt;b&gt;소요 시간: 약 0.03ms&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1350&quot; data-start=&quot;1330&quot;&gt;&lt;b&gt;PK 기반 테이블 접근이 제거되며 가장 유의미한 성능 개선을 보였다.&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3364&quot; data-start=&quot;3326&quot;&gt;&lt;b&gt;테이블 접근 없이 &lt;/b&gt;&lt;b&gt;인덱스만으로 조회가 수행됨을 확인하였다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;조회 성능 비교 결과&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style3&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 6.86048%;&quot;&gt;단계&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 21.8604%;&quot;&gt;개선 아이디어&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.814%;&quot;&gt;주요 목적&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 26.8024%;&quot;&gt;DB 실행 시간&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 23.5465%;&quot;&gt;API 응답 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 6.86048%;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 21.8604%;&quot;&gt;DTO Projection&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.814%;&quot;&gt;엔티티 로딩 제거&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 26.8024%;&quot;&gt;&lt;b&gt;2500ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 23.5465%;&quot;&gt;&lt;b&gt;3040ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 6.86048%;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 21.8604%;&quot;&gt;기본 인덱스&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.814%;&quot;&gt;테이블 스캔 제거&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 26.8024%;&quot;&gt;&lt;b&gt;0.26ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 23.5465%;&quot;&gt;&lt;b&gt;360ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 6.86048%;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 21.8604%;&quot;&gt;UNIQUE 인덱스&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.814%;&quot;&gt;논리적 단건 보장&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 26.8024%;&quot;&gt;&lt;b&gt;0.19ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 23.5465%;&quot;&gt;&lt;b&gt;460ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 6.86048%;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 21.8604%;&quot;&gt;커버링 인덱스&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.814%;&quot;&gt;테이블 접근 제거&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 26.8024%;&quot;&gt;&lt;b&gt;0.03ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 23.5465%;&quot;&gt;&lt;b&gt;360ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DB 실행 시간 vs API 응답 시간 차이&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;눈에띄게 차이났던 DB 실행시간과 달리 커버링 인덱스 적용 이후에도 API 응답시간이 더 이상 크게 줄지 않았다. &lt;br /&gt;그 이유를 생각해보았을 때&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 실행 시간은 쿼리 처리 시간만 측정&lt;/li&gt;
&lt;li&gt;API 응답 시간에는 컨트롤러, 서비스 호출 및 네트워크 전송 비용 등 다음과 비용이 추가로 포함됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잘못 적용했나 처음에 생각했었는데 인덱스가 제대로 동작하지 않은 것이 아니라 앱단의 오버헤드에 의한 시간의 차이가 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이후에 생각해본 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커버링 인덱스를 적용함으로써 단건 조회에 대한 DB 쿼리 최적화는 충분히 달성했다고 판단했다.&lt;br /&gt;실제로 EXPLAIN ANALYZE 기준으로 인덱스 탐색 및 테이블 접근이 제거되었고, 조회 시간 또한 수 ms 단위까지 감소했다.&lt;/p&gt;
&lt;p data-end=&quot;460&quot; data-start=&quot;350&quot; data-ke-size=&quot;size16&quot;&gt;다만 지금까지의 성능 측정은 &lt;b&gt;항상 MySQL 서버를 재시작한 후 &lt;/b&gt;진행했기 때문에, 실제 운영 환경에서 자연스럽게 발생하는 &lt;b&gt;캐시 효과&lt;/b&gt;를 고려하지 않은 상태였다.&lt;/p&gt;
&lt;p data-end=&quot;573&quot; data-start=&quot;462&quot; data-ke-size=&quot;size16&quot;&gt;현실적인 서비스 환경에서는 동일한 조회 요청이 반복적으로 발생할 가능성이 있으며,&lt;br /&gt;이 경우 DB 내부 캐시또는 앱단의 캐시를 통해 데이터 접근 비용을 더욱 줄일 수 있다고 생각했다.&lt;/p&gt;
&lt;h3 data-end=&quot;594&quot; data-start=&quot;580&quot; data-ke-size=&quot;size23&quot;&gt;캐시 전략 도입 배경&lt;/h3&gt;
&lt;p data-end=&quot;617&quot; data-start=&quot;596&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 다음과 같은 상황을 가정했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;718&quot; data-start=&quot;619&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;646&quot; data-start=&quot;619&quot;&gt;동일한 닉네임 조회 요청이 반복적으로 들어온다&lt;/li&gt;
&lt;li data-end=&quot;677&quot; data-start=&quot;647&quot;&gt;매 요청마다 DB에 접근하는 것은 불필요한 비용이다&lt;/li&gt;
&lt;li data-end=&quot;718&quot; data-start=&quot;678&quot;&gt;조회 결과가 자주 변하지 않는 데이터라면 캐시로 충분히 대응 가능하다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;824&quot; data-start=&quot;720&quot; data-ke-size=&quot;size16&quot;&gt;이에 따라 &lt;b&gt;DB 접근 횟수 자체를 줄이기 위한 캐시 전략&lt;/b&gt;을 추가로 적용했다.&lt;br /&gt;구현 단계에서는 캐시 개념을 빠르게 검증하기 위해 Spring Cache 추상화를 사용했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DB 캐시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커버링 인덱스가 적용된 상태에서의 실행 계획은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1766282485701&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-&amp;gt; Covering index lookup on u using idx_users_nickname_covering (nickname='zi0')  
(cost=1.1 rows=1) 
(actual time=0.0169..0.0193 rows=1 loops=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 data-end=&quot;1080&quot; data-start=&quot;1059&quot;&gt;테이블 접근 없이 인덱스만으로 조회&lt;/li&gt;
&lt;li data-end=&quot;1102&quot; data-start=&quot;1081&quot;&gt;DB 레벨에서 매우 빠른 응답 시간&lt;/li&gt;
&lt;li data-end=&quot;1133&quot; data-start=&quot;1103&quot;&gt;하지만 &lt;b&gt;여전히 매 요청마다 DB 접근은 발생&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Cache 적용&lt;/h3&gt;
&lt;h4 data-end=&quot;1169&quot; data-start=&quot;1160&quot; data-ke-size=&quot;size20&quot;&gt;동작 방식&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1340&quot; data-start=&quot;1171&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1254&quot; data-start=&quot;1171&quot;&gt;첫 요청(Cache Miss)
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1254&quot; data-start=&quot;1192&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1214&quot; data-start=&quot;1192&quot;&gt;캐시에 값이 없으므로 DB 조회 수행&lt;/li&gt;
&lt;li data-end=&quot;1239&quot; data-start=&quot;1217&quot;&gt;콘솔에 DB 접근 발생 로그 출력&lt;/li&gt;
&lt;li data-end=&quot;1254&quot; data-start=&quot;1242&quot;&gt;SQL 쿼리 실행됨&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1340&quot; data-start=&quot;1255&quot;&gt;두 번째 요청(Cache Hit)
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1340&quot; data-start=&quot;1278&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1292&quot; data-start=&quot;1278&quot;&gt;캐시에 저장된 값 반환&lt;/li&gt;
&lt;li data-end=&quot;1316&quot; data-start=&quot;1295&quot;&gt;서비스 메서드 자체가 호출되지 않음&lt;/li&gt;
&lt;li data-end=&quot;1340&quot; data-start=&quot;1319&quot;&gt;콘솔 로그 및 SQL 쿼리 출력 안됨&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-21 오전 11.08.26.png&quot; data-origin-width=&quot;2584&quot; data-origin-height=&quot;540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/994NJ/dJMcacIs2LR/6uOUUvDGUkWulta5EpsiWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/994NJ/dJMcacIs2LR/6uOUUvDGUkWulta5EpsiWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/994NJ/dJMcacIs2LR/6uOUUvDGUkWulta5EpsiWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F994NJ%2FdJMcacIs2LR%2F6uOUUvDGUkWulta5EpsiWk%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;2584&quot; height=&quot;540&quot; data-filename=&quot;스크린샷 2025-12-21 오전 11.08.26.png&quot; data-origin-width=&quot;2584&quot; data-origin-height=&quot;540&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;처음에 요청을 보내면 캐시에 저장된 값이 없으니 결과 사진과 같이 DB에서 직접 조회라는 로그가 찍히며 쿼리가 날라간다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;이후 바로 동일한 요청을 보내면 콘솔에 로그와 쿼리가 찍히지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이를 통해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;DB 접근이 완전히 제거되었음&lt;/b&gt;을 확인할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;참고 도서&lt;br /&gt;친절한 SQL 튜닝&lt;/blockquote&gt;</description>
      <category>BootCamp</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/122</guid>
      <comments>https://devz0.tistory.com/122#entry122comment</comments>
      <pubDate>Mon, 22 Dec 2025 08:37:33 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] QueryDSL Projection 방식 비교</title>
      <link>https://devz0.tistory.com/120</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryDSL로 검색 API를 구현하면서 엔티티가 아닌 검색 전용 DTO로 결과를 조회해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO 매핑방식으로 두가지 선택지가 있다는 것을 알게되었다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;369&quot; data-start=&quot;319&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;348&quot; data-start=&quot;319&quot;&gt;Projections.constructor()&lt;/li&gt;
&lt;li data-end=&quot;369&quot; data-start=&quot;349&quot;&gt;@QueryProjection&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;431&quot; data-start=&quot;371&quot; data-ke-size=&quot;size16&quot;&gt;두 방식은 결과는 같아 보이지만,&lt;br /&gt;&lt;b&gt;안전성 &amp;middot; 유지보수성 &amp;middot; 오류 발견 시점&lt;/b&gt;에서 큰 차이가 있었다.&lt;/p&gt;
&lt;p data-end=&quot;431&quot; data-start=&quot;371&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;431&quot; data-start=&quot;371&quot; data-ke-size=&quot;size26&quot;&gt;Projections.constructor 방식&lt;/h2&gt;
&lt;pre id=&quot;code_1765968678260&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.select(Projections.constructor(
    TodoSearchResponse.class,
    todo.title,
    manager.id.countDistinct(),
    comment.id.countDistinct()
))&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-end=&quot;663&quot; data-start=&quot;657&quot; data-ke-size=&quot;size23&quot;&gt;특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;715&quot; data-start=&quot;664&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;685&quot; data-start=&quot;664&quot;&gt;DTO에 QueryDSL 의존성 X&lt;/li&gt;
&lt;li data-end=&quot;705&quot; data-start=&quot;686&quot;&gt;생성자만 있으면 바로 사용 가능&lt;/li&gt;
&lt;li data-end=&quot;715&quot; data-start=&quot;706&quot;&gt;설정이 간단&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;731&quot; data-start=&quot;717&quot; data-ke-size=&quot;size23&quot;&gt;단점&amp;nbsp;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;823&quot; data-start=&quot;732&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;762&quot; data-start=&quot;732&quot;&gt;생성자 &lt;b&gt;파라미터 순서가 바뀌어도 컴파일 통과&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;784&quot; data-start=&quot;763&quot;&gt;타입이 애매하면 &lt;b&gt;런타임 에러&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;823&quot; data-start=&quot;785&quot;&gt;DTO 생성자 변경 시 &lt;b&gt;IDE가 쿼리 오류를 잡아주지 못함&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;862&quot; data-start=&quot;831&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;862&quot; data-start=&quot;833&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쿼리 오류를 &lt;i&gt;실행해보기 전까지&lt;/i&gt; 알 수 없다&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;@QueryProjection 방식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DTO&lt;/h3&gt;
&lt;pre id=&quot;code_1765968981766&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class TodoSearchResponse {

    private final String title;
    private final long managerCount;
    private final long commentCount;

    @QueryProjection
    public TodoSearchResponse(
            String title,
            long managerCount,
            long commentCount
    ) {
        this.title = title;
        this.managerCount = managerCount;
        this.commentCount = commentCount;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;쿼리 사용&lt;/h3&gt;
&lt;pre id=&quot;code_1765969022032&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.select(new QTodoSearchResponse(
    todo.title,
    manager.id.countDistinct(),
    comment.id.countDistinct()
))&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-end=&quot;1576&quot; data-start=&quot;1563&quot; data-ke-size=&quot;size23&quot;&gt;특징&amp;nbsp;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1670&quot; data-start=&quot;1577&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1603&quot; data-start=&quot;1577&quot;&gt;생성자 시그니처가 &lt;b&gt;컴파일 타임에 검증&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1638&quot; data-start=&quot;1604&quot;&gt;파라미터 개수/순서/타입이 틀리면 &lt;b&gt;즉시 컴파일 에러&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1670&quot; data-start=&quot;1639&quot;&gt;DTO 변경 시 &lt;b&gt;쿼리 코드에서 바로 오류 발생&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;1708&quot; data-start=&quot;1678&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1708&quot; data-start=&quot;1680&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쿼리 오류를 &lt;i&gt;컴파일 시점&lt;/i&gt;에 잡을 수 있다&lt;/b&gt;&lt;/p&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;@QueryProjection을 선택한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 검색 API는 다음 조건을 만족해야 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2108&quot; data-start=&quot;2029&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2049&quot; data-start=&quot;2029&quot;&gt;QueryDSL 기반 복잡한 검색&lt;/li&gt;
&lt;li data-end=&quot;2072&quot; data-start=&quot;2050&quot;&gt;집계(countDistinct) 포함&lt;/li&gt;
&lt;li data-end=&quot;2091&quot; data-start=&quot;2073&quot;&gt;DTO 구조 변경 가능성 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2161&quot; data-start=&quot;2110&quot; data-ke-size=&quot;size16&quot;&gt;이 상황에서는&lt;br /&gt;&lt;b&gt;실행 전에 오류를 잡을 수 있는 안정성&lt;/b&gt;이 가장 중요하다고 판단했다.&lt;/p&gt;</description>
      <category>Dev./Spring</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/120</guid>
      <comments>https://devz0.tistory.com/120#entry120comment</comments>
      <pubDate>Wed, 17 Dec 2025 19:58:47 +0900</pubDate>
    </item>
    <item>
      <title>[HTTP] HTTP 메서드 멱등성</title>
      <link>https://devz0.tistory.com/119</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;DELETE 메서드는 멱등해야한다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP DELETE 메서드는 원칙적으로 &lt;b&gt;멱등(Idempotent)&lt;/b&gt; 해야 한다. 즉,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;같은 DELETE 요청을 여러 번 보내도 서버의 최종 상태는 동일해야 한다.&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 /todos/1/managers/10 을 삭제할 때:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;472&quot; data-start=&quot;357&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;384&quot; data-start=&quot;357&quot;&gt;첫 번째 요청 &amp;rarr; manager 정상 삭제&lt;/li&gt;
&lt;li data-end=&quot;472&quot; data-start=&quot;385&quot;&gt;두 번째 요청 &amp;rarr; 이미 삭제된 상태지만
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;472&quot; data-start=&quot;385&quot;&gt;&lt;b&gt;서버의 최종 상태는 동일(삭제됨)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;472&quot; data-start=&quot;385&quot;&gt;실패가 아니라 성공처럼 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내가 받은 피드백의 핵심 - 두 번째 DELETE 요청이 실패하면 UX가 깨진다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 피드백을 받았다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;ManagerController delete 메소드의 인증된유저 처리 문제 에서 HTTP Method중 DELETE는 멱등성을 준수합니다. 제공하는 기능에 따라 멱등성을 준수하지 않도록 만들어야 할 수 도 있지만 기본적으로는 같은 값으로 요청을 보내도 동일한 결과를 주어야합니다. 모종의 이유로 인해 사용자가 보낸 요청이 빠르게 두번 연속으로 서버로 보내질 경우 두번째 요청으로 인해 유저가 보는 화면에서는 결국 오류가 발생했다고 느낄 수 있기 때문이죠. API 기능 설계 UX도 고려가 되면 좋겠습니다&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제가 발생할 수 있는 내코드는&lt;/p&gt;
&lt;pre id=&quot;code_1765324557038&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Todo todo = todoRepository.findById(todoId)
        .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;Todo not found&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;756&quot; data-start=&quot;711&quot;&gt;첫 번째 요청 &amp;rarr; Todo와 Manager 모두 존재 &amp;rarr; 정상 삭제&lt;/li&gt;
&lt;li data-end=&quot;814&quot; data-start=&quot;757&quot;&gt;두 번째 요청 &amp;rarr; Manager는 이미 삭제됨, 심지어 Todo도 존재하지 않을 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;900&quot; data-start=&quot;815&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 결과적으로 예외 터짐&lt;br /&gt;&amp;rarr; 사실은 이미 삭제된 것이므로 정상 상태인데도 사용자는 삭제가 실패한 것처럼 느낌 (UX가 깨짐)&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;902&quot; data-ke-size=&quot;size16&quot;&gt;DELETE 멱등성을 지키기 위해서는&lt;br /&gt;두 번째 요청에서도 예외가 나지 않게 설계해야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;956&quot; data-start=&quot;902&quot; data-ke-size=&quot;size23&quot;&gt;개선 방향&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Todo 조회가 중요할까?&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DELETE의 목적은 Manager 삭제&lt;/li&gt;
&lt;li&gt;Todo가 존재하는지는 두 번째 요청 이후에는 중요하지 않다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765324881678&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Manager manager = managerRepository.findById(managerId)
        .orElse(null);

if (manager == null) {
    return; // 이미 삭제됨 &amp;rarr; 멱등성 충족
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;manager만 먼저 조회해 이미 삭제되었을시 바로 리턴&lt;/li&gt;
&lt;li&gt;delete 호출은 1번만 일어남&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 조회 실패는 조용히 넘어감&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DELETE는 같은 요청을 여러 번 보내도 정상처럼 보이는 게 규약&lt;/li&gt;
&lt;li&gt;권한이 잘못되면 Forbidden이 맞지만 이미 삭제된 리소스(NotFound)는 멱등성이 보장된 정상 종료여야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배운점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 로직을 만들때 이 코드가 UX에 어떤 영향을 미치는가도 함께 고민해야 한다는 사실을 리마인드 하게되었다.&lt;/p&gt;</description>
      <category>Network./Server.</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/119</guid>
      <comments>https://devz0.tistory.com/119#entry119comment</comments>
      <pubDate>Wed, 10 Dec 2025 09:04:38 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Spring Security + JWT로 로그인 기능 구현</title>
      <link>https://devz0.tistory.com/118</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체적인 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;570&quot; data-start=&quot;530&quot;&gt;클라이언트가 /auth/login 으로 이메일/비밀번호 전송&lt;/li&gt;
&lt;li data-end=&quot;600&quot; data-start=&quot;571&quot;&gt;서버에서 사용자 검증 후 JWT 발급&lt;/li&gt;
&lt;li data-end=&quot;661&quot; data-start=&quot;601&quot;&gt;이후 요청들은 Authorization: Bearer token헤더에 JWT를 담아서 호출&lt;/li&gt;
&lt;li data-end=&quot;709&quot; data-start=&quot;662&quot;&gt;커스텀 JwtAuthFilter가 토큰을 검증하고 userId를 꺼냄&lt;/li&gt;
&lt;li data-end=&quot;800&quot; data-start=&quot;710&quot;&gt;@AuthUser 파라미터로 컨트롤러에 userId를 바로 주입&lt;br /&gt;&amp;rarr; 컨트롤러/서비스는 JWT 구조를 모른 채 로그인된 사용자 ID만 다룸&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JwtUtil - JWT 생성 및 검증&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;역할&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;886&quot; data-start=&quot;849&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;856&quot; data-start=&quot;849&quot;&gt;토큰 생성&lt;/li&gt;
&lt;li data-end=&quot;864&quot; data-start=&quot;857&quot;&gt;토큰 검증&lt;/li&gt;
&lt;li data-end=&quot;886&quot; data-start=&quot;865&quot;&gt;Claims / subject 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-end=&quot;897&quot; data-start=&quot;888&quot; data-ke-size=&quot;size20&quot;&gt;설계 기준&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;993&quot; data-start=&quot;899&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;935&quot; data-start=&quot;899&quot;&gt;subject에는 userId를 넣음 (주 식별자)&lt;/li&gt;
&lt;li data-end=&quot;962&quot; data-start=&quot;936&quot;&gt;claim으로 email 정도만 추가&lt;/li&gt;
&lt;li data-end=&quot;993&quot; data-start=&quot;963&quot;&gt;validate()는 예외 캐치로 단순 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765238232440&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class JwtUtil {

    @Value(&quot;${jwt.secret}&quot;)
    private String secret;

    @Value(&quot;${jwt.expiration}&quot;)
    private long expiration;

    private SecretKey key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
    }

    public String generateToken(Long userId, String email) {
        long now = System.currentTimeMillis();
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim(&quot;email&quot;, email)
                .issuedAt(new Date(now))
                .expiration(new Date(now + expiration))
                .signWith(key)
                .compact();
    }

    public Claims getClaims(String token) {
        return Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public boolean validate(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2459&quot; data-start=&quot;2387&quot;&gt;subject를 userId로 통일해서 로그인한 사용자 = Long userId로만 단순하게 생각&lt;/li&gt;
&lt;li data-end=&quot;2508&quot; data-start=&quot;2460&quot;&gt;나머지 email, role 같은 것들은 필요해지면 claim으로 점진적으로 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;PrincipalDetails - UserDetails 구현체&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;역할&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;849&quot; data-end=&quot;886&quot;&gt;
&lt;li data-end=&quot;3164&quot; data-start=&quot;3128&quot;&gt;Spring Security가 인식하는 인증된 사용자 객체&lt;/li&gt;
&lt;li data-end=&quot;3204&quot; data-start=&quot;3165&quot;&gt;Authentication.getPrincipal() 로 접근 가능&lt;/li&gt;
&lt;li data-end=&quot;3252&quot; data-start=&quot;3205&quot;&gt;컨트롤러에서 @AuthenticationPrincipal 로 직접 꺼내 사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;888&quot; data-end=&quot;897&quot;&gt;설계 기준&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;User 엔티티를 그대로 들고 있게 함
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3338&quot; data-start=&quot;3301&quot;&gt;필요한 모든 유저 정보는 전부 User 엔티티에서 꺼낼 수 있음&lt;/li&gt;
&lt;li data-end=&quot;3372&quot; data-start=&quot;3339&quot;&gt;서비스나 컨트롤러가 userId만 필요한 경우에도 편리함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;권한(authorities)은 최소 ROLE_USER만 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765238300231&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class PrincipalDetails implements UserDetails {

    private final User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection&amp;lt;? extends GrantedAuthority&amp;gt; getAuthorities() {
        return List.of(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;));
    }

    @Override public String getPassword() { return user.getPassword(); }
    @Override public String getUsername() { return user.getEmail(); }

    @Override public boolean isAccountNonExpired() { return true; }
    @Override public boolean isAccountNonLocked() { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return true; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PrincipalDetailsService - UserDetailsService 구현체&lt;/h3&gt;
&lt;h4 data-end=&quot;4063&quot; data-start=&quot;4055&quot; data-ke-size=&quot;size20&quot;&gt;역할&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4148&quot; data-start=&quot;4065&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4110&quot; data-start=&quot;4065&quot;&gt;JWT에서 userId를 꺼낸 뒤 &amp;rarr; DB에서 유저를 다시 조회하는 단계&lt;/li&gt;
&lt;li data-end=&quot;4148&quot; data-start=&quot;4111&quot;&gt;User not found, disabled 등 상태 체크 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;4055&quot; data-end=&quot;4063&quot;&gt;설계 기준&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;username 파라미터를 userId로 사용
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JWT subject = userId이므로 userIdString을 username처럼 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765238407109&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 PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String userIdString) throws UsernameNotFoundException {
        Long userId = Long.valueOf(userIdString);

        User user = userRepository.findById(userId)
                .orElseThrow(() -&amp;gt; new UsernameNotFoundException(&quot;Not Found User&quot;));

        return new PrincipalDetails(user);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JwtAuthenticationFilter - 핵심 필터&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;4055&quot; data-end=&quot;4063&quot;&gt;역할&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;4065&quot; data-end=&quot;4148&quot;&gt;
&lt;li data-start=&quot;4065&quot; data-end=&quot;4110&quot;&gt;토큰 파싱 &amp;rarr; 검증 (Authorization &amp;rarr; Bearer 토큰만 허용)&lt;/li&gt;
&lt;li data-start=&quot;4111&quot; data-end=&quot;4148&quot;&gt;토큰 유효시
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;6289&quot; data-start=&quot;6263&quot;&gt;JWT subject(userId) 추출&lt;/li&gt;
&lt;li data-end=&quot;6321&quot; data-start=&quot;6290&quot;&gt;DB에서 사용자 정보(UserDetails) 조회&lt;/li&gt;
&lt;li data-end=&quot;6341&quot; data-start=&quot;6322&quot;&gt;Authentication 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;SecurityContextHolder에 Authentication 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;4063&quot; data-start=&quot;4055&quot; data-ke-size=&quot;size20&quot;&gt;설계 기준&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;shouldNotFilter()로 인증 제외 경로 관리
&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;Authentication을 직접 만들어 넣음
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Security는 &lt;i&gt;SecurityContextHolder에 인증 객체가 존재하는가&lt;/i&gt;를 기준으로&lt;br /&gt;요청이 인증되었다고 판단&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765238585859&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 JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final PrincipalDetailsService principalDetailsService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith(&quot;/auth/login&quot;) || uri.startsWith(&quot;/auth/signup&quot;);
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null &amp;amp;&amp;amp; jwtUtil.validate(token)) {

            Claims claims = jwtUtil.getClaims(token);
            String userId = claims.getSubject();

            UserDetails userDetails = principalDetailsService.loadUserByUsername(userId);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );

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

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader(&quot;Authorization&quot;);

        if (bearer != null &amp;amp;&amp;amp; bearer.startsWith(&quot;Bearer &quot;)) {
            return bearer.substring(7);
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;SecurityConfig&amp;nbsp; - 필터 연결&lt;/h3&gt;
&lt;h4 data-end=&quot;7500&quot; data-start=&quot;7492&quot; data-ke-size=&quot;size20&quot;&gt;역할&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;7619&quot; data-start=&quot;7502&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;7556&quot; data-start=&quot;7502&quot;&gt;Spring Security의 필터 체인에 JwtAuthenticationFilter 추가&lt;/li&gt;
&lt;li data-end=&quot;7582&quot; data-start=&quot;7557&quot;&gt;세션 사용 안 함 (stateless)&lt;/li&gt;
&lt;li data-end=&quot;7602&quot; data-start=&quot;7583&quot;&gt;인증이 필요한 URL만 보호&lt;/li&gt;
&lt;li data-end=&quot;7619&quot; data-start=&quot;7603&quot;&gt;나머지는 permitAll&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765238740672&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(auth -&amp;gt; auth
                    .requestMatchers(&quot;/auth/login&quot;, &quot;/auth/signup&quot;).permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨트롤러에서 사용자 정보 받기&lt;/h3&gt;
&lt;pre id=&quot;code_1765238787586&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/me&quot;)
public UserResponse me(@AuthenticationPrincipal PrincipalDetails principal) {
    return new UserResponse(principal.getUser());
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Dev./Spring</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/118</guid>
      <comments>https://devz0.tistory.com/118#entry118comment</comments>
      <pubDate>Tue, 9 Dec 2025 09:07:05 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Transactional 사용시 자가호출(Self-Invocation)이슈</title>
      <link>https://devz0.tistory.com/117</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 코드에서,&lt;br /&gt;&lt;b&gt;한 메서드 안에서 다른 @Transactional 메서드를 호출&lt;/b&gt;하면&lt;br /&gt;나는 REQUIRES_NEW가 적용되어 별도 트랜잭션이 만들어질 것이라고 기대했다.&lt;/p&gt;
&lt;p data-end=&quot;283&quot; data-start=&quot;265&quot; data-ke-size=&quot;size16&quot;&gt;그러나 실제 동작은 전혀 달랐다.&lt;/p&gt;
&lt;pre id=&quot;code_1764992334758&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class OrderService {

    private final StockService stockService;
    private final PaymentService paymentService;
    private final PaymentRepository paymentRepository;
    private final OrderRecordService orderRecordService;

    @Transactional
    public void processOrder() {

        // 1. 재고 감소
        stockService.save();
        // 2. 결제 처리
        pamentSave();
        // 3. 주문 기록 s
        orderRecordService.save();

        throw new RuntimeException(&quot;예외 발생&quot;);

    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void paymentSave() {
        paymentRepository.save(new Payment(1L,&quot;GOOD&quot;));
        log.info(&quot;결제 처리 저장 완료&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 REQUIRED_NEW가 동작하지 않았을까?&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;h3 data-ke-size=&quot;size23&quot;&gt;자가호출 (Self Invocation)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;같은 객체 안에서 자기 자신의 메서드를 직접 호출하는 경우 = 프록시를 다시 거치지 않는 호출&lt;/span&gt;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1764992608062&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void processOrder() {
    paymentSave();   //자가 호출
}

@Transactional(propagation = REQUIRES_NEW)
public void paymentSave() {
    생략
}&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;li&gt;새로운 호출도 아님&lt;/li&gt;
&lt;li&gt;프록시가 아닌 실제 객체 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 전형적인 Self-invocation&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 Spring AOP는 다음처럼 판단하게 된다&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;이 호출은 내가 가로챌 수 있는 종류의 호출이 아니네&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 자가 호출에서는 @Transactional이 안먹힐까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 AOP가 개입하지 못할까 라고도 생각해볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 @Transactional은 &lt;b&gt;AOP 프록시 방식&lt;/b&gt;으로 동작한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;proxy는 입구에만 존재한다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링은 Bean을 만들 때 다음 순서로 생성한다&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;호출자&lt;/li&gt;
&lt;li&gt;OrderServiceProxy&lt;/li&gt;
&lt;li&gt;OrderService 실제 객체&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부에서 서비스 메서드를 호출할 때만 프록시가 개입해 트랜잭션을 시작하거나 종료가 가능하다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;자가 호출은 프록시 외부 호출이 아니다&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-06 오후 12.56.12.png&quot; data-origin-width=&quot;1488&quot; data-origin-height=&quot;428&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rIcIB/dJMcaaX5Gze/cnMchXjfWCbu8Iptq7ncW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rIcIB/dJMcaaX5Gze/cnMchXjfWCbu8Iptq7ncW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rIcIB/dJMcaaX5Gze/cnMchXjfWCbu8Iptq7ncW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrIcIB%2FdJMcaaX5Gze%2FcnMchXjfWCbu8Iptq7ncW1%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;1488&quot; height=&quot;428&quot; data-filename=&quot;스크린샷 2025-12-06 오후 12.56.12.png&quot; data-origin-width=&quot;1488&quot; data-origin-height=&quot;428&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;사진 구조대로 보면 자가 호출시(Object의 되돌아가는 화살표) 프록시를 호출하지 못함 = AOP가 적용될 수 없음&lt;/li&gt;
&lt;li&gt;프록시는 외부 호출만 가로챌 수 있는데 이미 프록시 안쪽으로 들어와있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; AOP가 개입할 수 없는 호출이기 때문에 @Transactional(propagation = REQUIRES_NEW) 설정이 적용되지 않는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배운점&lt;/h3&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 data-end=&quot;2671&quot; data-start=&quot;2571&quot;&gt;REQUIRES_NEW가 동작하지 않은 이유는 트랜잭션 옵션이 잘못된 것이 아니라 &lt;b&gt;AOP가 paymentSave() 호출 자체를 볼 수 없었기 때문이다&lt;/b&gt;.&lt;/li&gt;
&lt;li data-end=&quot;2772&quot; data-start=&quot;2673&quot;&gt;자가 호출(self-invocation)은 &lt;b&gt;프록시를 우회한 호출 &amp;rarr; AOP 적용 불가 &lt;/b&gt;이 구조적 한계 때문에 @Transactional 설정이 무시된다.&lt;/li&gt;
&lt;li data-end=&quot;2875&quot; data-start=&quot;2774&quot;&gt;&amp;nbsp;processOrder() 내부에서 같은 클래스의 다른 @Transactional 메소드를 호출하면 기대한 트랜잭션 경계가 절대 적용되지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dev./Spring</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/117</guid>
      <comments>https://devz0.tistory.com/117#entry117comment</comments>
      <pubDate>Mon, 8 Dec 2025 13:10:49 +0900</pubDate>
    </item>
    <item>
      <title>[Spring]왜 Security 의존성을 추가하면 API 호출시 401이 뜰까</title>
      <link>https://devz0.tistory.com/115</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 잘 돌아가던 demo 프로젝트에 Security 의존성을 추가하니 모든 API 호출시 401에러가 떴다. 왜 의존성 추가 하나만으로 잘 돌아가던 서버가 에러를 뱉는걸까?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Security의 기본 보안 정책 자동 활성화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 &lt;b&gt;의존성 추가만으로도&lt;/b&gt; 다음 설정을 자동 적용&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 data-end=&quot;316&quot; data-start=&quot;296&quot;&gt;&lt;b&gt;모든 요청은 인증 필요&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;355&quot; data-start=&quot;317&quot;&gt;인증되지 않은 요청은 &amp;rarr; &lt;b&gt;401 Unauthorized&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;380&quot; data-start=&quot;356&quot;&gt;Form 로그인 페이지가 자동 생성됨&lt;/li&gt;
&lt;li data-end=&quot;433&quot; data-start=&quot;381&quot;&gt;기본 사용자 계정이 자동 생성됨 (username=user, password=콘솔에 출력)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 security가 추가되며 모든 요청을 가로막은 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Security를 추가한 후의 동작 흐름&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-07 오전 11.13.25.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;362&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbeBUv/dJMcac2DoyJ/7mBnj1yPw7aw52X5pKBlO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbeBUv/dJMcac2DoyJ/7mBnj1yPw7aw52X5pKBlO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbeBUv/dJMcac2DoyJ/7mBnj1yPw7aw52X5pKBlO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbeBUv%2FdJMcac2DoyJ%2F7mBnj1yPw7aw52X5pKBlO1%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;1772&quot; height=&quot;362&quot; data-filename=&quot;스크린샷 2025-12-07 오전 11.13.25.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;362&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 401일까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security가 요청을 &lt;b&gt;Controller로 보내기 전에 차단하기 때문 &lt;/b&gt;, 컨트롤러에 도달하지도 않았다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 - 허용할 요청 명시&lt;/h2&gt;
&lt;pre id=&quot;code_1765073816800&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)  // .csrf().disable() 방식은 더 이상 사용 안함.
            .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
            .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화
            .addFilterBefore(jwtFilter, SecurityContextHolderAwareRequestFilter.class)
            .authorizeHttpRequests(auth -&amp;gt; auth
                .requestMatchers(&quot;/api/login&quot;).permitAll()
                .anyRequest().authenticated()
            )
            .build();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;csrf : JWT 기반 인증에서는 필요하지 않으니 CSRF 보호를 비활성화&lt;/li&gt;
&lt;li&gt;httpBasic : HTTP Basic 인증 방식 비활성화&lt;/li&gt;
&lt;li&gt;formLogin : 기본 로그인 폼과 관련된 필터 비활성화&lt;/li&gt;
&lt;li&gt;addFilterBefore : 필터의 순서를 지정해주는 역할
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;.addFilterBefore(jwtFilter, SecurityContextHolderAwareRequestFilter.class) = &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;jwtFilter 뒤에 SecurityContextHolderAwareRequestFilter 를 위치 시킬 것&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;authorizeHttpRequests : 어디에 어떻게 권한 검사를 할 것인지에 대한 구체적인 내용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1765074019578&quot; class=&quot;x86asm&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;.authorizeHttpRequests(auth -&amp;gt; auth
                .requestMatchers(&quot;/api/login&quot;).permitAll()
                .requestMatchers(&quot;/api/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
                .requestMatchers(&quot;/api/user/**&quot;).hasRole(&quot;USER&quot;)&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Dev./Spring</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/115</guid>
      <comments>https://devz0.tistory.com/115#entry115comment</comments>
      <pubDate>Sat, 6 Dec 2025 19:37:51 +0900</pubDate>
    </item>
    <item>
      <title>[Nbcam] Level6 정의하고 해결한 문제</title>
      <link>https://devz0.tistory.com/114</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. ManagerController delete 메소드의 인증된유저 처리 문제&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 deleteManager 메서드 에서는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컨트롤러가 JWT 구조를 알고있음&lt;/li&gt;
&lt;li&gt;Authorization Header를 직접 읽고 jwtUtil을 직접 사용&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1764663363430&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@DeleteMapping
public void deleteManager(
    @RequestHeader(&quot;Authorization&quot;) String bearerToken,
    @PathVariable long todoId,
    @PathVariable long managerId
) {
    Claims claims = jwtUtil.extractClaims(...);
    long userId = Long.parseLong(claims.getSubject());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller가 JWT를 알아야 해서 인증 책임이 컨트롤러단 까지 내려왔다고 생각했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증된 사용자가 Todo의 담당자를 삭제한다는 의미에 맞게 인증된 유저(AuthUser)만 전달 받는 구조가 맞다고 판단했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1764663328698&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@DeleteMapping(&quot;/todos/{todoId}/managers/{managerId}&quot;)
    public void deleteManager(
            @Auth AuthUser authUser,
            @PathVariable long todoId,
            @PathVariable long managerId
    ) {
        managerService.deleteManager(authUser, todoId, managerId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1319&quot; data-start=&quot;1301&quot;&gt;컨트롤러에서 JWT 의존 제거&lt;/li&gt;
&lt;li data-end=&quot;1339&quot; data-start=&quot;1320&quot;&gt;인증 책임을 인증 계층으로 위임&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&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. CommentAdminService delete 메소드 검증 누락 문제&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;deleteComment 메서드에서 댓글 존재 여부나 삭제 가능 여부에 대한 검증 없이 바로 deleteById를 호출했다. 존재하지 않는 ID 삭제할경우 삭제가 실패했는데, 실패했다는 사실을 아무도 모르는 상태가 된다.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;해결&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스단에서 도메인 행위의 유효성을 검증하고 실패 케이스에 대한 예외처리를 해주는것이 서비스 레이어의 책임이라고 생각했다.&lt;/p&gt;
&lt;pre id=&quot;code_1764728983353&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void deleteComment(long commentId) {
   commentRepository.deleteById(commentId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1764729125932&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void deleteComment(long commentId) {
   Comment comment = commentRepository.findById(commentId)
          .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;댓글이 존재하지 않습니다.&quot;));
   commentRepository.delete(comment);
}&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 페이지네이션 책임 위치 개선&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존코드는 디폴트 페이지 값이 사용자 관점에서 1로 지정되어있고 서비스단에서 page - 1 보정 로직을 수행했다. 그럼으로써 서비스가 사용자 요청 포맷을 알고 있고 page 보정에 대한 책임이 하위 레이어까지 내려왔다고 판단했다.&lt;/p&gt;
&lt;pre id=&quot;code_1764732991112&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    // controller
    @GetMapping(&quot;/todos&quot;)
    public ResponseEntity&amp;lt;Page&amp;lt;TodoResponse&amp;gt;&amp;gt; getTodos(
            @RequestParam(defaultValue = &quot;1&quot;) int page,
            @RequestParam(defaultValue = &quot;10&quot;) int size
    ) {
        return ResponseEntity.ok(todoService.getTodos(page, size));
    }
    
    //service
    @Transactional(readOnly = true)
    public Page&amp;lt;TodoResponse&amp;gt; getTodos(int page, int size) {
        Pageable pageable = PageRequest.of(page - 1, size);

        Page&amp;lt;Todo&amp;gt; todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable);

        return todos.map(todo -&amp;gt; new TodoResponse(
                todo.getId(),
                todo.getTitle(),
                todo.getContents(),
                todo.getWeather(),
                new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
                todo.getCreatedAt(),
                todo.getModifiedAt()
        ));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;해결&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;spring은 pageable 항상 0-base로 처리, 우리가 사용자 관점으로 맞출 필요 없다&lt;/li&gt;
&lt;li&gt;페이징 디폴트 값 처리는 컨트롤러 책임&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1764733316852&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	//controller
    @GetMapping(&quot;/todos&quot;)
    public ResponseEntity&amp;lt;Page&amp;lt;TodoResponse&amp;gt;&amp;gt; getTodos(
            @PageableDefault(page = 0, size = 10, sort = &quot;modifiedAt&quot;, direction = Sort.Direction.DESC)
            Pageable pageable
    ) {
        return ResponseEntity.ok(todoService.getTodos(pageable));
    }
    
    //service
    @Transactional(readOnly = true)
    public Page&amp;lt;TodoResponse&amp;gt; getTodos(Pageable pageable) {
        Page&amp;lt;Todo&amp;gt; todos = todoRepository.findAll(pageable);

        return todos.map(todo -&amp;gt; new TodoResponse(
                todo.getId(),
                todo.getTitle(),
                todo.getContents(),
                todo.getWeather(),
                new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
                todo.getCreatedAt(),
                todo.getModifiedAt()
        ));
    }&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;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. Signup Response DTO 설계 문제&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입,로그인 응답이 다음과 같이 토큰만 반환하고 있었다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764743038036&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;bearerToken&quot;: &quot;Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyIiwiZW1haWwiOiJ6aXkwaW5nMUBuYW1lci5jb20iLCJ1c2VyUm9sZSI6IlVTRVIiLCJleHAiOjE3NjQ3NDYyNjIsImlhdCI6MTc2NDc0MjY2Mn0.cbI5KQxPWg9oAB1PTff5EkM8evwPFNEofeK9BGzBfuY&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;해결&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;signup은 계정 생성, signin은 인증 및 토큰 발급의 책임으로 각각 응답의 구조가 달라야 한다고 생각했다. 바뀌어야 할 곳은 책임에 따라 signup이라고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 발급은 signin 역할로 분리했다.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;pre id=&quot;code_1764743252985&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;id&quot;: 1,
    &quot;email&quot;: &quot;abab@example.com&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/114</guid>
      <comments>https://devz0.tistory.com/114#entry114comment</comments>
      <pubDate>Fri, 5 Dec 2025 00:14:21 +0900</pubDate>
    </item>
    <item>
      <title>[Nbcam] 심화 주차 Level5 AOP를 활용해 특정 API 로깅 구현</title>
      <link>https://devz0.tistory.com/113</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;Interceptor 또는 AOP를 활용하는 부분이라 나는 AOP 구현을 선택했다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;조건&lt;/span&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AOP를 사용하여 구현하기&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청한 사용자의 ID&lt;/li&gt;
&lt;li&gt;API 요청 시각&lt;/li&gt;
&lt;li&gt;API 요청 URL&lt;/li&gt;
&lt;li&gt;요청 본문(RequestBody)&lt;/li&gt;
&lt;li&gt;응답 본문(ResponseBody)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;@Around 어노테이션을 사용하여 어드민 API 메서드 실행 전후에 요청/응답 데이터를 로깅합니다.&lt;/li&gt;
&lt;li&gt;요청 본문과 응답 본문은 JSON 형식으로 기록하세요.&lt;/li&gt;
&lt;li&gt;로깅은 Logger 클래스를 활용하여 기록&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;변경 파일 상세 설명&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AccessCheckAOP&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;역할&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1f2328; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어드민 전용 API 접근 시점에 실행되는 AOP&lt;/li&gt;
&lt;li&gt;요청 정보 수집 로그로 기록&lt;/li&gt;
&lt;li&gt;컨트롤러/서비스 로직과 로깅 관심사를 명확히 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Pointcut&lt;/h4&gt;
&lt;pre id=&quot;code_1764661218269&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Pointcut(&quot;@annotation(org.example.expert.config.OnlyAdmin)&quot;)
public void adminApi() {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;731&quot; data-start=&quot;695&quot;&gt;@OnlyAdmin 어노테이션이 붙은 메서드만 타겟&lt;/li&gt;
&lt;li data-end=&quot;773&quot; data-start=&quot;732&quot;&gt;URL, 패키지명에 의존하지 않고 선언적으로 어드민 API 식별&lt;/li&gt;
&lt;li data-end=&quot;819&quot; data-start=&quot;774&quot;&gt;어드민 API가 추가되더라도 AOP 수정 없이 어노테이션만 부착하면 확장 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Around Advice (&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start;&quot;&gt;accessLogToInfo 메서드)&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1764661285752&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Around(&quot;adminApi()&quot;)
public Object executionTime(ProceedingJoinPoint joinPoint) throws Throwable&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;HttpServletRequest&lt;/h4&gt;
&lt;pre id=&quot;code_1764661315653&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1176&quot; data-start=&quot;1149&quot;&gt;AOP에서는 메서드 시그니처만 접근 가능하므로 HTTP 정보(userId, URI 등)는 RequestContextHolder를 통해 획득&lt;/li&gt;
&lt;li data-end=&quot;1279&quot; data-start=&quot;1235&quot;&gt;Filter/인증 단계의 request attribute 재사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;유저 정보&lt;/h4&gt;
&lt;pre id=&quot;code_1764661431068&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Long userId = (Long) request.getAttribute(&quot;userId&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1f2328; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;request attribute 에서 userId 가져옴&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;메타 데이터 정보&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1f2328; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 시각 : LocalDateTime.now()&lt;/li&gt;
&lt;li&gt;요청 URI : request.getRequestURI()&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;RequestBody&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1f2328; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AOP는&amp;nbsp;@RequestBody&amp;nbsp;어노테이션을 직접 인식 X -&amp;gt; args 배열 순회하며 RequestBody 찾음&lt;/li&gt;
&lt;li&gt;String,Number, AuthUser(인증 객체)제외 후 dto.request에 속한 객체가 RequestBody라고 판단&lt;/li&gt;
&lt;li&gt;DTO를 JSON으로 직렬화해 로그 기록 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OnlyAdmin&lt;/h3&gt;
&lt;pre id=&quot;code_1764661578037&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OnlyAdmin {
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1f2328; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어드민 전용 API를 식별하기 위한 커스텀 메서드 어노테이션&lt;/li&gt;
&lt;li&gt;AOP Pointcut 기준&lt;/li&gt;
&lt;li&gt;METHOD 레벨에서만 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CommentAdminController /&amp;nbsp; UserAdminController&lt;/h3&gt;
&lt;pre id=&quot;code_1764661626224&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@OnlyAdmin
@DeleteMapping(&quot;/admin/comments/{commentId}&quot;)

@OnlyAdmin
@PatchMapping(&quot;/admin/users/{userId}&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@OnlyAdmin 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AccessCheckAOP 전체 코드&lt;/h3&gt;
&lt;pre id=&quot;code_1764661704934&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Aspect
@Component
@Slf4j
public class AccessCheckAOP {
    @Pointcut(&quot;@annotation(org.example.expert.config.OnlyAdmin)&quot;)
    public void adminApi() {}

    @Around(&quot;adminApi()&quot;)
    public Object accessLogToInfo(ProceedingJoinPoint joinPoint) throws Throwable {
        StringBuilder logInfo = new StringBuilder();

        //유저 ID, 요청 URI 정보는 시그니처에 없어서 RequestContextHolder의 getRequestAttributes 메서드를 사용
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        Long userId = (Long) request.getAttribute(&quot;userId&quot;);
        logInfo.append(String.format(&quot;유저 ID : %s/ &quot;,userId));

        LocalDateTime accessTime = LocalDateTime.now();
        logInfo.append(String.format(&quot;요청 시각: %s/ &quot;,accessTime));

        logInfo.append(String.format(&quot;요청 URI : %s/ &quot;,request.getRequestURI()));

        //request body 가져오기
        Object[] args = joinPoint.getArgs();
        for (Object arg : args) {
            if (arg == null) continue;
            if (arg instanceof String || arg instanceof Number) continue;
            if (arg instanceof AuthUser) continue;
            //이 시점에는 @RequestBody 확률 올라감
            String packageName = arg.getClass().getPackageName();
            if (packageName.contains(&quot;dto.request&quot;)) {
                logInfo.append(String.format(&quot;Request Body : %s&quot;,getRequestBody(arg)));
            }
        }
        log.info(logInfo.toString());
        // 실제 메서드 실행 -&amp;gt; Filter에서 doFilter 와 비슷함.
        return joinPoint.proceed();
    }

    public String getRequestBody(Object arg) throws IOException {
        //JSON으로 직력화해서 로그로 남김
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.writeValueAsString(arg);
    }

}&lt;/code&gt;&lt;/pre&gt;</description>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/113</guid>
      <comments>https://devz0.tistory.com/113#entry113comment</comments>
      <pubDate>Thu, 4 Dec 2025 00:03:42 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] AOP 정리</title>
      <link>https://devz0.tistory.com/112</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;AOP란&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;AOP (Aspect Oriented Programming) 는 여러 곳에 반복되는 공통 관심사를 비즈니스 로직과 분리해서 &lt;br /&gt;한 곳에서 관리하기 위한 프로그래밍 기법&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AOP가 왜 필요할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를들어 기획자가 &lt;i&gt;개발자님 성능 측정을 위해 모든 메서드에 시작 시간과 종료 시간을 남기는 기능 추가해주세요&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라고 요청을 했을 때&lt;/p&gt;
&lt;pre id=&quot;code_1764628804049&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void save() {
    log.info(&quot;start&quot;);
    // 비즈니스 로직
    log.info(&quot;end&quot;);
}&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;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;547&quot; data-start=&quot;510&quot;&gt;로깅, 트랜잭션, 권한 체크 코드가 모든 메서드에 흩어짐&lt;/li&gt;
&lt;li data-end=&quot;575&quot; data-start=&quot;548&quot;&gt;수정하려면 전체 코드를 다 건드려야 함&lt;/li&gt;
&lt;li data-end=&quot;595&quot; data-start=&quot;576&quot;&gt;비즈니스 로직이 더러워짐&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AOP 도입 후에는&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1269&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/butOiJ/dJMb99Y9zUt/xUWsV321XTCh7MoRX4UXa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/butOiJ/dJMb99Y9zUt/xUWsV321XTCh7MoRX4UXa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/butOiJ/dJMb99Y9zUt/xUWsV321XTCh7MoRX4UXa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbutOiJ%2FdJMb99Y9zUt%2FxUWsV321XTCh7MoRX4UXa0%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;845&quot; height=&quot;456&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1269&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간 측정은 AOP가 알아서 처리한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AOP는 어디에 적용되는걸까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AOP는 HTTP 요청 흐름과는 무관하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;770&quot; data-start=&quot;716&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;729&quot; data-start=&quot;716&quot;&gt;Service 메서드&lt;/li&gt;
&lt;li data-end=&quot;746&quot; data-start=&quot;730&quot;&gt;Repository 메서드&lt;/li&gt;
&lt;li data-end=&quot;758&quot; data-start=&quot;747&quot;&gt;Scheduler&lt;/li&gt;
&lt;li data-end=&quot;770&quot; data-start=&quot;759&quot;&gt;내부 메서드 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AOP 용어&amp;nbsp;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 87px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style3&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 40.1163%; height: 19px; text-align: center;&quot;&gt;용어&lt;/td&gt;
&lt;td style=&quot;width: 59.8837%; height: 19px; text-align: center;&quot;&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 40.1163%; height: 17px; text-align: center;&quot;&gt;Join Point&lt;/td&gt;
&lt;td style=&quot;width: 59.8837%; height: 17px; text-align: center;&quot;&gt;메서드 실행 시점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 40.1163%; height: 17px; text-align: center;&quot;&gt;Pointcut&lt;/td&gt;
&lt;td style=&quot;width: 59.8837%; height: 17px; text-align: center;&quot;&gt;어떤 메서드(Join Point)에 끼어들지 정하는 조건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 40.1163%; height: 17px; text-align: center;&quot;&gt;Advice&lt;/td&gt;
&lt;td style=&quot;width: 59.8837%; height: 17px; text-align: center;&quot;&gt;끼어들어서 실행할 실제 AOP 로직 @Before @After @Around&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 40.1163%; height: 17px; text-align: center;&quot;&gt;Aspect&lt;/td&gt;
&lt;td style=&quot;width: 59.8837%; height: 17px; text-align: center;&quot;&gt;공통 관심사를 담는 모듈(로깅, 트랜잭션, 권한 체크)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AOP 어노테이션&lt;/h4&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 data-end=&quot;1244&quot; data-start=&quot;1222&quot;&gt;@Before : 메서드 실행 전&lt;/li&gt;
&lt;li data-end=&quot;1266&quot; data-start=&quot;1245&quot;&gt;@After : 메서드 종료 후&lt;/li&gt;
&lt;li data-end=&quot;1296&quot; data-start=&quot;1267&quot;&gt;@AfterReturning : 정상 종료 후&lt;/li&gt;
&lt;li data-end=&quot;1325&quot; data-start=&quot;1297&quot;&gt;@AfterThrowing : 예외 발생 시&lt;/li&gt;
&lt;li data-end=&quot;1352&quot; data-start=&quot;1326&quot;&gt;@Around : 실행 전&amp;middot;후 모두 제어&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실습- 실행 시간 측정 AOP 만들기&lt;/h3&gt;
&lt;pre id=&quot;code_1764629910835&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package org.example.nbcam_addvanced_1.common.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Slf4j
public class TimeCheckAop {

    @Around(&quot;execution(* org.example.nbcam_addvanced_1.user.service..*(..))&quot;)
    public Object executionTime(ProceedingJoinPoint joinPoint) throws Throwable {

        long start = System.currentTimeMillis();

        Object result = joinPoint.proceed(); // 실제 메서드 실행 -&amp;gt; Filter에서 doFilter 와 비슷함.

        long end = System.currentTimeMillis();
        log.info(&quot;[AOP] {} 실행됨 in {}ms&quot; , joinPoint.getSignature() , end - start);

        return result;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dev./Spring</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/112</guid>
      <comments>https://devz0.tistory.com/112#entry112comment</comments>
      <pubDate>Wed, 3 Dec 2025 08:58:41 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Interceptor 정리</title>
      <link>https://devz0.tistory.com/111</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Interceptor란&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Spring MVC에서 컨트롤러(Controller) 호출 전&amp;middot;후에 요청을 가로채서 공통 로직을 수행하는 도구&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 기반 인증이 끝난 후, 인증된 사용자 정보(SecurityContext)를 활용해&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;API 사용 제한&lt;/li&gt;
&lt;li&gt;등의 부가 검증 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring MVC 요청 흐름&lt;/h3&gt;
&lt;pre id=&quot;code_1764597800011&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[사용자 요청]
    &amp;darr;
[JwtFilter]
    &amp;darr;
[SecurityContextHolder에 인증 저장]
    &amp;darr;
[Interceptor]
    &amp;darr;
[인증 사용자 검증]
    &amp;darr;
[Controller]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Interceptor가 왜 필요할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 컨트롤러 마다 다음과 같은 코드가 반복된다면&lt;/p&gt;
&lt;pre id=&quot;code_1764598373860&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (!isLogin(request)) {
    throw new UnauthorizedException();
}&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 data-end=&quot;786&quot; data-start=&quot;766&quot;&gt;컨트롤러 코드가 지저분해짐&lt;/li&gt;
&lt;li data-end=&quot;814&quot; data-start=&quot;787&quot;&gt;인증/권한 로직이 비즈니스 로직과 섞임&lt;/li&gt;
&lt;li data-end=&quot;837&quot; data-start=&quot;815&quot;&gt;수정 시 모든 컨트롤러를 건드려야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 로직을 컨트롤러 밖으로 빼기 위해 Interceptor를 사용&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;span&gt;HandlerInterceptor&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Filter와 마찬가지로 구현되어있는 HandlerInterceptor를 활용 &lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 70px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style3&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;메서드&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;preHandle&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;컨트롤러 실행 전에 동작 ( false 반환 시 흐름 중단 가능)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;postHandle&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;컨트롤러 실행 후, 뷰 렌더링 전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;afterCompletion&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;응답 완료 후, 예외 처리 포함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;preHandle&lt;/h4&gt;
&lt;pre id=&quot;code_1764598666595&quot; class=&quot;aspectj&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
public boolean preHandle(HttpServletRequest request,
                         HttpServletResponse response,
                         Object handler) {
    // 인증, 권한 체크
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1836&quot; data-start=&quot;1814&quot;&gt;컨트롤러에 &lt;b&gt;들어가기 전에&lt;/b&gt; 실행&lt;/li&gt;
&lt;li data-end=&quot;1858&quot; data-start=&quot;1837&quot;&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 data-end=&quot;1909&quot; data-start=&quot;1874&quot;&gt;true &amp;rarr; 요청 계속 진행 (Controller 실행)&lt;/li&gt;
&lt;li data-end=&quot;1945&quot; data-start=&quot;1910&quot;&gt;false &amp;rarr; 요청 중단 (Controller 실행 X)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;구현 예시 - 인증 체크&lt;/h4&gt;
&lt;pre id=&quot;code_1764598772453&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class AuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {

        Long userId = (Long) request.getAttribute(&quot;userId&quot;);

        if (userId == null) {
            throw new UnauthorizedException(&quot;로그인이 필요합니다.&quot;);
        }

        return true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Interceptor 등록 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Interceptor는 자동으로 적용되지 않는다.&lt;br /&gt;WebMvcConfigurer에 명시적으로 등록해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1764598893698&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 WebMvcConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns(&quot;/api/**&quot;)
                .excludePathPatterns(&quot;/api/auth/**&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 data-end=&quot;3802&quot; data-start=&quot;3773&quot;&gt;addPathPatterns : 적용할 URL&lt;/li&gt;
&lt;li data-end=&quot;3836&quot; data-start=&quot;3803&quot;&gt;excludePathPatterns : 제외할 URL&lt;/li&gt;
&lt;li data-end=&quot;3836&quot; data-start=&quot;3803&quot;&gt;interceptor는 URL 기준으로 동작&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그래서 언제 사용해야할까?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4157&quot; data-start=&quot;4139&quot;&gt;로그인 / 인증 / 권한 체크&lt;/li&gt;
&lt;li data-end=&quot;4172&quot; data-start=&quot;4158&quot;&gt;특정 URL 접근 제어&lt;/li&gt;
&lt;li data-end=&quot;4190&quot; data-start=&quot;4173&quot;&gt;요청 로깅 (URI, IP)&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;</description>
      <category>Dev./Spring</category>
      <author>limitation01</author>
      <guid isPermaLink="true">https://devz0.tistory.com/111</guid>
      <comments>https://devz0.tistory.com/111#entry111comment</comments>
      <pubDate>Tue, 2 Dec 2025 00:23:10 +0900</pubDate>
    </item>
  </channel>
</rss>