<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>co-cherry</title>
    <link>https://coding-cherry.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 18 May 2026 14:01:20 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>co-cherry</managingEditor>
    <item>
      <title>@Transactional과 DB 인덱스로 Spring Boot 애플리케이션 성능 최적화하기</title>
      <link>https://coding-cherry.tistory.com/69</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;@Transactional&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;트랜잭션(Transaction)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스의 상태를 변화시키는 &lt;b&gt;하나의 논리적인 작업 단위&lt;/b&gt;를 구성하는 연산들의 집합&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 개의 DB 작업을 하나의 단위로 묶고 분할할 수 없음 (원자성)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 전부 성공 또는 전부 실패만 가능&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;트랜잭션의 핵심 기능: ACID &lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Atomicity (원자성)&lt;/b&gt; &lt;i&gt;All or Nothing&lt;/i&gt; 전부 성공하거나 전부 실패&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Consistency(일관성)&amp;nbsp;&lt;/b&gt;트랜잭션 실행 전후로 데이터베이스가 일관된 상태를 유지&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Isolation(격리성)&amp;nbsp;&lt;/b&gt;동시에 실행되는 트랜잭션들이 서로 영향을 주지 않음&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Durability(지속성)&amp;nbsp;&lt;/b&gt;커밋된 트랜잭션은 영구적으로 저장됨&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;왜 트랜잭션이 필요할까?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션은 &lt;b&gt;데이터의 정합성을 보장&lt;/b&gt;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서, 정합성이란 데이터가 모순 없이 일치하는 상태를 의미한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션이 있으면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모두 성공 &amp;rarr; 정합성 유지&amp;nbsp;&lt;/li&gt;
&lt;li&gt;하나라도 실패 &amp;rarr; 모두 롤백 &amp;rarr; 정합성 유지&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;@Transactional&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Framework가 제공하는 선언적 트랜잭션 관리 어노테이션&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에게 이 메서드를 트랜잭션으로 실행해달라고 알려 주는 표시&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring이 자동으로 트랜잭션을 실행하고 종료함&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;개발자가 직접 begin, commit, &lt;span style=&quot;background-color: #dddddd;&quot;&gt;rollback&lt;/span&gt;을 안 써도 됨&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;*IOException, SQLException 같은 CheckedException에서는 자동 롤백되지 않음&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;@Transactional(readOnly = true)&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional의 옵션 중 하나로 읽기 전용 트랜잭션을 만들 때 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회만 하는 경우, 이 옵션을 통해 자원을 절약하고 불필요한 업데이트를 방지할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 프로젝트에도 이와 같이 게시글 상세 조회에서 적용한 것을 볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1778828357873&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
    public PostDetailResponseDto getPost(Long postId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -&amp;gt; new GeneralException(PostErrorCode.POST_NOT_FOUND));

        return new PostDetailResponseDto(
                post.getId(),
                post.getTitle(),
                post.getContent(),
                post.getUser().getNickname(),
                post.getCreatedAt()
        );
    }&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional을 이용해 회원 탈퇴를 구현해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원 탈퇴 시, User의 Comment와 Post, 마지막으로 User 자체를 삭제하는 방향으로 구현할 예정이다. (Hard delete)&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;먼저 CommentRepository와 PostRepository에 삭제 메서드를 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778826066728&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;void deleteAllByUserId(Long userId);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 서비스에 삭제 로직을 작성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 @Transactional 처리를 꼭 해야 작업이 하나의 단위로 처리된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778826117548&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
    public void withdraw(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -&amp;gt; new GeneralException(UserErrorCode.USER_NOT_FOUND));

        commentRepository.deleteAllByUserId(userId);
        postRepository.deleteAllByUserId(userId);
        userRepository.delete(user);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 DB 구조를 보면 Comment는 Post와 Users를 Post는 Users를 참조하고 있는데,&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778826418404&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;users 테이블
  └── post 테이블 (user_id &amp;rarr; users.user_id 참조)
        └── comment 테이블 (post_id &amp;rarr; post.post_id, user_id &amp;rarr; users.user_id 참조)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 만약 User를 먼저 삭제하면&amp;nbsp;&lt;b&gt;FK 제약 위반&lt;/b&gt;이 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 참조당하는 쪽은 참조하는 쪽이 없어진 다음에야 삭제가 가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 CasecadeType.ALL 옵션을 사용하면 JPA가 알아서 자식부터 삭제하지만 지금 같은 경우에는 순서를 꼭 지켜야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 컨트롤러에 엔트포인트를 추가하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778827198143&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Operation(summary = &quot;회원 탈퇴&quot;, description = &quot;유저의 댓글, 게시글, 계정을 모두 삭제합니다.&quot;)
@DeleteMapping(&quot;/{userId}&quot;)
public ApiResponse&amp;lt;Void&amp;gt; withdraw(@PathVariable Long userId) {
    userService.withdraw(userId);
    return ApiResponse.onSuccess(GeneralSuccessCode.OK, null);
}&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1401&quot; data-origin-height=&quot;271&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cA5AiC/dJMcaaFlCnm/TN4a2vnqCUG1ulbtXSnNV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cA5AiC/dJMcaaFlCnm/TN4a2vnqCUG1ulbtXSnNV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cA5AiC/dJMcaaFlCnm/TN4a2vnqCUG1ulbtXSnNV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcA5AiC%2FdJMcaaFlCnm%2FTN4a2vnqCUG1ulbtXSnNV0%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;1401&quot; height=&quot;271&quot; data-origin-width=&quot;1401&quot; data-origin-height=&quot;271&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;그리고 회원 탈퇴를 한 후,&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1441&quot; data-origin-height=&quot;889&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HJ0XW/dJMcacQD9kA/qxc5Ijq3AMZ0mKOAeLFLKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HJ0XW/dJMcacQD9kA/qxc5Ijq3AMZ0mKOAeLFLKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HJ0XW/dJMcacQD9kA/qxc5Ijq3AMZ0mKOAeLFLKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHJ0XW%2FdJMcacQD9kA%2Fqxc5Ijq3AMZ0mKOAeLFLKK%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;1441&quot; height=&quot;889&quot; data-origin-width=&quot;1441&quot; data-origin-height=&quot;889&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;다시 아까 작성한 게시글을 조회하면 게시글을 찾을 수 없게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1409&quot; data-origin-height=&quot;219&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cChvWG/dJMcaaegfuW/MdnquHB1LJoEjmZkLiVBf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cChvWG/dJMcaaegfuW/MdnquHB1LJoEjmZkLiVBf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cChvWG/dJMcaaegfuW/MdnquHB1LJoEjmZkLiVBf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcChvWG%2FdJMcaaegfuW%2FMdnquHB1LJoEjmZkLiVBf1%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;1409&quot; height=&quot;219&quot; data-origin-width=&quot;1409&quot; data-origin-height=&quot;219&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;회원 탈퇴와 동시에 게시글, 댓글이 트랜잭션으로 모두 삭제되었기 때문이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Index&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 테이블의&amp;nbsp;&lt;b&gt;검색 속도를 향상시키기 위한&amp;nbsp;&lt;/b&gt;자료 구조&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL은 기본적으로 B-Tree 구조의 인덱스를 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778830777405&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;인덱스 구조 (created_at 컬럼 기준):

                   [2024-03-15]
                   /          \
          [2024-02-15]      [2024-04-15]
          /        \        /          \
    [2024-01-15] [2024-02-28] [2024-03-31] [2024-05-15]
         &amp;darr;          &amp;darr;           &amp;darr;            &amp;darr;
      실제 행     실제 행      실제 행       실제 행&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;b&gt;Full Scan(O(n))&lt;/b&gt;을 하지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스가 있으면 B-Tree를 탐색하는&amp;nbsp;&lt;b&gt;Index Scan(O(log n))&lt;/b&gt;으로 편해진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Index를 사용하면 좋은 경우 &lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WHERE절에 자주 사용되는 컬럼&lt;/li&gt;
&lt;li&gt;ORDER BY절에 자주 사용되는 컬럼&amp;nbsp;&lt;/li&gt;
&lt;li&gt;JOIN 조건으로 사용되는 컬럼&lt;/li&gt;
&lt;li&gt;카디널리티가 높은 컬럼&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Index의 장/단점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조회 속도 대폭 향상&lt;/li&gt;
&lt;li&gt;ORDER BY, GROUP BY 성능 개선&lt;/li&gt;
&lt;li&gt;MIN/MAX 최적화&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;추가 저장 공간 필요&lt;/li&gt;
&lt;li&gt;INSERT/UPDATE/DELETE 성능 저하&lt;/li&gt;
&lt;li&gt;인덱스 유지 비용 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 직접 적용해보기 위해 Post 엔티티를 다음과 같이 수정했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778832157529&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Table(name = &quot;post&quot;, indexes = {
        @Index(name = &quot;idx_post_created_at&quot;, columnList = &quot;created_at&quot;)
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 인덱스를 사용하면 목록 조회 시 정렬 속도와 날짜 범위 필터 속도도 향상된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1402&quot; data-origin-height=&quot;393&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BkH1I/dJMcaciMwHi/buU6ZbSnjRwclr4rFALw5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BkH1I/dJMcaciMwHi/buU6ZbSnjRwclr4rFALw5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BkH1I/dJMcaciMwHi/buU6ZbSnjRwclr4rFALw5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBkH1I%2FdJMcaciMwHi%2FbuU6ZbSnjRwclr4rFALw5k%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;1402&quot; height=&quot;393&quot; data-origin-width=&quot;1402&quot; data-origin-height=&quot;393&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 위해&amp;nbsp;&lt;b&gt;explain(쿼리 성능 분석을 위한 도구)&lt;/b&gt;을 사용하려고 했지만, 데이터가 적은 탓에 인덱스가 있음에도 불구하고 옵티마이저가 자동으로 Full Scan을 선택했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;rows가 적으면 인덱스를 타는 비용이 오히려 Full Scan보다 더 들 수 있어 옵티마이저가 자동으로 선택한 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 더욱 확보한 뒤에 정확한 비교를 다시 해보아야겠다!&amp;nbsp;&lt;/p&gt;</description>
      <category>Springboot</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/69</guid>
      <comments>https://coding-cherry.tistory.com/69#entry69comment</comments>
      <pubDate>Fri, 15 May 2026 17:15:19 +0900</pubDate>
    </item>
    <item>
      <title>테스트 전략</title>
      <link>https://coding-cherry.tistory.com/68</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;br /&gt;&quot;You&amp;nbsp;should&amp;nbsp;write&amp;nbsp;tests&amp;nbsp;if&amp;nbsp;you&amp;nbsp;value&amp;nbsp;your&amp;nbsp;time.&amp;nbsp;&lt;br /&gt;Much&amp;nbsp;better&amp;nbsp;to&amp;nbsp;catch&amp;nbsp;a&amp;nbsp;bug&amp;nbsp;locally&amp;nbsp;from&amp;nbsp;the&amp;nbsp;tests&amp;nbsp;&lt;br /&gt;than&amp;nbsp;getting&amp;nbsp;a&amp;nbsp;call&amp;nbsp;at&amp;nbsp;2:00&amp;nbsp;in&amp;nbsp;the&amp;nbsp;morning&amp;nbsp;and&amp;nbsp;fix&amp;nbsp;it&amp;nbsp;then.&quot;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&amp;mdash;&amp;nbsp;Kent&amp;nbsp;C.&amp;nbsp;Dodds&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버그는 발견이 늦을수록 비용이 기하급수적으로 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계 단계 대비 프로덕션에서는 최대 100배, CrowdStrike처럼 한 줄이 80억 달러로 이어질 수도 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테스트는 &quot;만일의 보험&quot;이 아니라 개발 속도를 높이는 투자다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Viest + React Testing Library + MSW로 프론트엔드 테스트 전략을 완성하는 법을 알아보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트가 왜 필요할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 없는 코드는 단순히 &quot;버그가 있을 수 있는 코드&quot;가 아니라 &lt;b&gt;변경할 수 없는 코드&lt;/b&gt;다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;기도하는 배포&lt;/b&gt;&lt;/h4&gt;
&lt;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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;소스 분석하고&lt;/li&gt;
&lt;li&gt;변경하고&lt;/li&gt;
&lt;li&gt;수동으로 기능 확인하고&lt;/li&gt;
&lt;li&gt;배포하고&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기도한다   &lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜? 내가 건드린 게 어디를 망가뜨렸을지 모르니까...&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 75.1163%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.4496%;&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;&lt;b&gt;테스트가 없을 때&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.1938%;&quot;&gt;&lt;b&gt;테스트가 있을 때&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.4496%;&quot;&gt;새 기능 추가&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;&quot;이거 건드리면 뭐가 깨질까?&quot; 전전긍긍&lt;/td&gt;
&lt;td style=&quot;width: 25.1938%;&quot;&gt;CI가 깨지면 바로 알림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.4496%;&quot;&gt;리팩토링&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;무서워서 못 함 &amp;rarr; 기술부채 누적&lt;/td&gt;
&lt;td style=&quot;width: 25.1938%;&quot;&gt;자유롭게 구조 개선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.4496%;&quot;&gt;라이브러리 업데이트&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;수동 회귀 테스트 &amp;rarr; 결국 안 함&lt;/td&gt;
&lt;td style=&quot;width: 25.1938%;&quot;&gt;npm update, npm test 면 끝&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.4496%;&quot;&gt;신규 팀원 온보딩&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;코드 동작을 추측만 가능&lt;/td&gt;
&lt;td style=&quot;width: 25.1938%;&quot;&gt;테스트 = 실행 가능한 문서&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단위 테스트 VS 통합 테스트&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그램이 요구사항에 맞춰 제대로 동작하는지 검증하는 행위&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 범위에 따라 단위 테스트 &amp;rarr; 통합 테스트 &amp;rarr; E2E 테스트로 구분한다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;단일 테스트(Unit Test)&lt;/b&gt;&lt;/h4&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&gt;작은 부분만 테스트하므로 빠르게 실행&lt;/li&gt;
&lt;li&gt;개발 초기 단계에서 버그 발견 및 수정에 효과적&lt;/li&gt;
&lt;li&gt;레이어 단위 또는 특정 클래스 단위로 테스트&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 프론트엔드에서는 Jest, React Testing Library 를, 백엔드에서는 JUnit을 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;통합 테스트(Integration Test)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 단위/컴포넌트 간의 상호작용을 테스트&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개별 단위가 시스템 전체에서도 올바르게 동작하는지 확인한다.&lt;/p&gt;
&lt;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;독립된 2개 이상의 모듈이 함께 동작할 때 테스트&lt;/li&gt;
&lt;li&gt;모듈 간 연결에서 발생하는 에러 검증&lt;/li&gt;
&lt;li&gt;단위 테스트보다 실행 시간이 길고 복잡함&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;효과적인 테스트 작성 방법&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;AAA 패턴 (Arrange-Act-Assert)&lt;/b&gt;&lt;/h4&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&gt;&lt;b&gt;준비(Arrange)&lt;/b&gt; 테스트 대상의 초기 상태 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실행(Act)&amp;nbsp;&lt;/b&gt;사용자 상호작용 또는 함수 호출 실행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확인(Assert)&amp;nbsp;&lt;/b&gt;기대한 결과가 나왔는지 검증&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Given-When-Then 패턴&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Given&lt;/b&gt; 준비 구절에 해당&lt;/li&gt;
&lt;li&gt;&lt;b&gt;When&lt;/b&gt; 실행 구절에 해당&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Then&lt;/b&gt; 검증 구절에 해당&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능적으로는 AAA와 동일하나, 비개발자에게 더 읽기 쉬운 표현&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BDD(Behavior-Driven Development) 스타일에서 주로 사용&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;언제 어떤 테스트를 사용할까?&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;테스팅 피라미드 (Mike Cohn)&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;451&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ewXD1R/dJMcadPr2IL/4HKT8K5KIExr7cgtkCcUK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ewXD1R/dJMcadPr2IL/4HKT8K5KIExr7cgtkCcUK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ewXD1R/dJMcadPr2IL/4HKT8K5KIExr7cgtkCcUK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FewXD1R%2FdJMcadPr2IL%2F4HKT8K5KIExr7cgtkCcUK1%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;321&quot; height=&quot;256&quot; data-origin-width=&quot;451&quot; data-origin-height=&quot;360&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;단위 테스트 &amp;gt; 통합 테스트 &amp;gt; E2E 테스트 순으로 많이 작성&lt;/li&gt;
&lt;li&gt;테스트 커버리지 최대화 + 시간/리소스 최소화&lt;/li&gt;
&lt;li&gt;낮은 단계일수록 아이디어와 구현 간극이 짧아 안정화 필요&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;테스팅 트로피/다이아몬드 (Guillermo Rauch)&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;471&quot; data-origin-height=&quot;481&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbXAHy/dJMcahdjoEF/krj6DrBKMeHTGqYlbNuuX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbXAHy/dJMcahdjoEF/krj6DrBKMeHTGqYlbNuuX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbXAHy/dJMcahdjoEF/krj6DrBKMeHTGqYlbNuuX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbXAHy%2FdJMcahdjoEF%2Fkrj6DrBKMeHTGqYlbNuuX0%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;378&quot; height=&quot;386&quot; data-origin-width=&quot;471&quot; data-origin-height=&quot;481&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;/li&gt;
&lt;li&gt;통합 테스트 &amp;gt; 단위 테스트 &amp;gt; E2E 테스트 순&lt;/li&gt;
&lt;li&gt;정적 테스트의 추가로 에러 사전 발견&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;vitest 와 React Testing Library 실습해보기&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://white-blank.tistory.com/186&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://white-blank.tistory.com/186&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778493274694&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;vitest + React Testing Library 사용하여 테스트 케이스 만들기&quot; data-og-description=&quot;해당 글에서는 vitest 와 React Testing Library 를 이용하여 테스트 케이스를 만드는 방법을 소개합니다.vitest 는 실행속도와 vite 와 통합이 간편하며, RTL 은 리액트 컴포넌트를 테스트 하기 위한 도구 &quot; data-og-host=&quot;white-blank.tistory.com&quot; data-og-source-url=&quot;https://white-blank.tistory.com/186&quot; data-og-url=&quot;https://white-blank.tistory.com/186&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bmDSCf/dJMb8SXCpR5/eqmjJKkWsWcOLxOMSc1T9K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cYNTXl/dJMb9eTUa6b/O9vnCvfxVG41pR60gd8N8k/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://white-blank.tistory.com/186&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://white-blank.tistory.com/186&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bmDSCf/dJMb8SXCpR5/eqmjJKkWsWcOLxOMSc1T9K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cYNTXl/dJMb9eTUa6b/O9vnCvfxVG41pR60gd8N8k/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;vitest + React Testing Library 사용하여 테스트 케이스 만들기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;해당 글에서는 vitest 와 React Testing Library 를 이용하여 테스트 케이스를 만드는 방법을 소개합니다.vitest 는 실행속도와 vite 와 통합이 간편하며, RTL 은 리액트 컴포넌트를 테스트 하기 위한 도구&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;white-blank.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;uarr; 위 블로그 과정을 보고 따라 해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 필요한 라이브러리를 설치한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778493174288&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pnpm add -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom&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;그리고 vite.config.ts 를 수정한다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;&lt;b&gt; &lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;globals: true&lt;/span&gt;&lt;/b&gt; 테스트 파일에서 전역 함수를 import 없이 사용할 수 있게 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;environment: 'jsdom'&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;테스트 환경을 JSDOM(&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;Node.js 환경에서 브라우저와 유사한 DOM 환경을 시뮬레이션&lt;/span&gt;)으로 설정&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;setupFiles: './src/setupTests.ts'&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #666666; text-align: start;&quot;&gt;테스트 실행 전, 실행될 설정 파일의 경로 지정&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778493237844&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/// &amp;lt;reference types=&quot;vitest&quot; /&amp;gt;
// https://vite.dev/config/
export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.ts',
  },
})&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;그 다음, 위에서 설정한 setup 파일을 해당 경로에 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778493477814&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import '@testing-library/jest-dom'&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;tsconfig.app.json 에서 types 배열에 vitest/globals와 @testing-library/jest-dom을 추가해&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript가 전역&amp;nbsp; API를 인식하게 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778493671931&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;compilerOptions&quot;: {
    &quot;types&quot;: [&quot;vite/client&quot;, &quot;vitest/globals&quot;, &quot;@testing-library/jest-dom&quot;]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, package.json 에 스크립트를 추가하면 설정은 완료된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778493743377&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;vitest&quot;,
    &quot;test:run&quot;: &quot;vitest run&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 파일을 작성해야 하는데 나의 경우는 MovieCard 파일에 대한 테스트를 작성했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일명은 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;컴포넌트명.test.tsx&lt;/span&gt; 의 규칙으로 생성하며 컴포넌트 파일 옆에 두는 것이 일반적이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778494524699&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MovieCard from './MovieCard'
import type { Movie } from '../model/types'

const baseMovie: Movie = {
  id: 1,
  title: '인터스텔라',
  overview: '우주 탐험 영화',
  poster_path: '/poster.jpg',
  release_date: '2014-11-06',
  vote_average: 8.6,
  popularity: 100,
  adult: false,
  original_language: 'en',
  original_title: 'Interstellar',
}

describe('MovieCard', () =&amp;gt; {
  it('영화 제목이 렌더링된다', () =&amp;gt; {
    render(&amp;lt;MovieCard movie={baseMovie} onClick={() =&amp;gt; {}} /&amp;gt;)
    expect(screen.getByText('인터스텔라')).toBeInTheDocument()
  })

  it('포스터 이미지가 렌더링된다', () =&amp;gt; {
    render(&amp;lt;MovieCard movie={baseMovie} onClick={() =&amp;gt; {}} /&amp;gt;)
    const img = screen.getByAltText('인터스텔라')
    expect(img).toBeInTheDocument()
  })

  it('poster_path가 없으면 No Image 텍스트가 보인다', () =&amp;gt; {
    render(&amp;lt;MovieCard movie={{ ...baseMovie, poster_path: null }} onClick={() =&amp;gt; {}} /&amp;gt;)
    expect(screen.getByText('No Image')).toBeInTheDocument()
  })

  it('adult가 true이면 19 배지가 보인다', () =&amp;gt; {
    render(&amp;lt;MovieCard movie={{ ...baseMovie, adult: true }} onClick={() =&amp;gt; {}} /&amp;gt;)
    expect(screen.getByText('19')).toBeInTheDocument()
  })

  it('adult가 false이면 19 배지가 없다', () =&amp;gt; {
    render(&amp;lt;MovieCard movie={baseMovie} onClick={() =&amp;gt; {}} /&amp;gt;)
    expect(screen.queryByText('19')).not.toBeInTheDocument()
  })

  it('카드 클릭 시 movie.id를 인자로 onClick이 호출된다', async () =&amp;gt; {
    const handleClick = vi.fn()
    render(&amp;lt;MovieCard movie={baseMovie} onClick={handleClick} /&amp;gt;)
    await userEvent.click(screen.getByText('인터스텔라'))
    expect(handleClick).toHaveBeenCalledWith(1)
  })

  it('예매하기 버튼이 렌더링된다', () =&amp;gt; {
    render(&amp;lt;MovieCard movie={baseMovie} onClick={() =&amp;gt; {}} /&amp;gt;)
    expect(screen.getByRole('button', { name: '예매하기' })).toBeInTheDocument()
  })
})&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;pnpm test로 테스트를 실행하면 아래와 같이 테스트 결과가 나온다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;931&quot; data-origin-height=&quot;383&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPjVk8/dJMcaaZxSfl/vWCAmL1jqc3bxhRqnKFuf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPjVk8/dJMcaaZxSfl/vWCAmL1jqc3bxhRqnKFuf0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPjVk8/dJMcaaZxSfl/vWCAmL1jqc3bxhRqnKFuf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPjVk8%2FdJMcaaZxSfl%2FvWCAmL1jqc3bxhRqnKFuf0%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;931&quot; height=&quot;383&quot; data-origin-width=&quot;931&quot; data-origin-height=&quot;383&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MSW(Mock Service Worker)를 활용한 API Mocking&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mswjs.io/docs/quick-start&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://mswjs.io/docs/quick-start&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1778497478635&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Quick start&quot; data-og-description=&quot;Get MSW up and running in under five minutes.&quot; data-og-host=&quot;mswjs.io&quot; data-og-source-url=&quot;https://mswjs.io/docs/quick-start&quot; data-og-url=&quot;https://mswjs.io/docs/quick-start/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bBwdw5/dJMb9bv6PER/ez1ZkhYxUXkI8kiKYFAxwk/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/G9yzN/dJMb9jOruaV/Z3IKDAyCmB0QKcT9TtIAk1/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://mswjs.io/docs/quick-start&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://mswjs.io/docs/quick-start&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bBwdw5/dJMb9bv6PER/ez1ZkhYxUXkI8kiKYFAxwk/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/G9yzN/dJMb9jOruaV/Z3IKDAyCmB0QKcT9TtIAk1/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Quick start&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Get MSW up and running in under five minutes.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;mswjs.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;API Mocking&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제 백엔드 서버 없이 가짜 API 응답을 만들어내는 것&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;요청이 가기 전에 중간에서 가로채서 미리 만든 가짜 데이터를 응답으로 돌려준다.&amp;nbsp;&lt;/p&gt;
&lt;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;테스트 할 때 실제 서버에 의존하지 않아도 됨&amp;nbsp;&lt;/li&gt;
&lt;li&gt;에러 상황, 로딩 상황 등을 마음대로 재현 가능&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;MSW(Mock Service Worker)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 워커 기술을 활용하여 네트워크 레벨에서 API 요청을 가로채고 모킹할 수 있는 라이브러리&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말해, 실제 네트워크가 이루어지는 것처럼 프론트엔드의 응답값을 주는 라이브러리이다. (가짜 API 생성)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별도 Mocking 서버를 구축할 필요 없음&lt;/li&gt;
&lt;li&gt;프레임워크과 라이브러리에 종속되지 않음&lt;/li&gt;
&lt;li&gt;브라우저, Node.js 등 다양한 환경 지원&amp;nbsp;&lt;/li&gt;
&lt;li&gt;기존 코드 수정 없이 사용 가능&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;세팅해보기 &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. MSW 설치&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778497178647&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pnpm add -D msw&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Service Worker 파일 생성하기&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;public/mockServiceWorker.js가 생성되고 package.json에 msw.workerDirectory 항목이 자동으로 추가된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778497552659&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npx msw init public/ --save&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 가짜 데이터 파일 작성하기&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;src/mocks/data/ 위치에 가짜 데이터 파일을 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 API가 반환값과 같은 형태의 데이터를 타입에 맞게 작성해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 경우, 영화 데이터를 넣었다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778497678541&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { Movie, MovieListResponse, MovieDetail } from '@/entities/movie/model/types'

export const mockMovies: Movie[] = [
  {
    id: 1,
    title: '인터스텔라',
    overview: '우주를 배경으로 한 SF 영화',
    poster_path: '/poster1.jpg',
    release_date: '2014-11-06',
    vote_average: 8.6,
    popularity: 100,
    adult: false,
    original_language: 'en',
    original_title: 'Interstellar',
  },
  {
    id: 2,
    title: '기생충',
    overview: '계층 간의 갈등을 다룬 한국 영화',
    poster_path: '/poster2.jpg',
    release_date: '2019-05-30',
    vote_average: 8.5,
    popularity: 90,
    adult: false,
    original_language: 'ko',
    original_title: '기생충',
  },
]

export const mockMovieListResponse: MovieListResponse = {
  page: 1,
  results: mockMovies,
  total_pages: 1,
  total_results: 2,
}

export const mockMovieDetail: MovieDetail = {
  ...mockMovies[0],
  genres: [{ id: 878, name: 'SF' }],
  runtime: 169,
  tagline: '우주의 끝에서 답을 찾다',
  status: 'Released',
  vote_count: 30000,
  backdrop_path: '/backdrop1.jpg',
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 핸들러 파일 작성하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 URL로 요청이 오면 어떤 데이터를 응답할지 규칙을 정의한다.&lt;/p&gt;
&lt;pre id=&quot;code_1778497730043&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { http, HttpResponse } from 'msw'
import { mockMovieListResponse, mockMovieDetail } from './data/movies'

const BASE = 'https://api.themoviedb.org/3'

export const handlers = [
  http.get(`${BASE}/movie/popular`, () =&amp;gt; {
    return HttpResponse.json(mockMovieListResponse)
  }),

  http.get(`${BASE}/movie/now_playing`, () =&amp;gt; {
    return HttpResponse.json(mockMovieListResponse)
  }),

  http.get(`${BASE}/movie/top_rated`, () =&amp;gt; {
    return HttpResponse.json(mockMovieListResponse)
  }),

  http.get(`${BASE}/movie/upcoming`, () =&amp;gt; {
    return HttpResponse.json(mockMovieListResponse)
  }),

  http.get(`${BASE}/movie/:movieId`, () =&amp;gt; {
    return HttpResponse.json(mockMovieDetail)
  }),
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 브라우저용 설정 파일 생성하기&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 테스트와 달리, Service Worker를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt; [React 앱] &amp;rarr; fetch 요청 &amp;rarr; [Service Worker가 가로챔] &amp;rarr; 가짜 응답 반환&lt;/span&gt; 의 순서로 실행된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778497866749&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6. 테스트용 설정 파일 생성하기&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 환경(Vitest)은 브라우저가 아닌 Node.js에서 실행된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js에는 Service Worker가 없으므로 MSW가 대신 http 모듈을 가로채는 방식으로 작동된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;[테스트 코드] &amp;rarr; fetch 요청 &amp;rarr; [MSW Node 서버가 가로챔] &amp;rarr; 가짜 응답 반환&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 순서로 실행된다&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778497892758&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;7. 테스트 환경에 MSW 연결하기&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 설정한 setup 파일에 추가적으로 설정한다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;beforeAll(() =&amp;gt; server.listen())&amp;nbsp;&lt;/b&gt;테스트 시작 전 서버 켜기&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt; afterEach(() =&amp;gt; server.resetHandlers())&amp;nbsp;&lt;/b&gt;각 테스트 후 핸들러 초기화&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt; afterAll(() =&amp;gt; server.close())&amp;nbsp;&lt;/b&gt;테스트 끝나면 서버 끄기&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778498084405&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import '@testing-library/jest-dom'
import { server } from './mocks/server'

beforeAll(() =&amp;gt; server.listen())
afterEach(() =&amp;gt; server.resetHandlers())
afterAll(() =&amp;gt; server.close())&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;8. 앱 진입점에 MSW 연결하기&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main.tsx 에서 개발 환경에서만 MSW가 켜지도록 enableMocking()으로 감싼다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;onUnhandledRequest: 'bypass'&amp;nbsp;&lt;/b&gt;mock 등록 안 한 요청은 실제 서버로 그냥 통과시킨다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778498190732&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function enableMocking() {
  if (import.meta.env.DEV) {
    const { worker } = await import('./mocks/browser')
    return worker.start({ onUnhandledRequest: 'bypass' })
  }
}

enableMocking().then(() =&amp;gt; {
  createRoot(document.getElementById('root')!).render(...)
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pnpm dev&amp;nbsp;&lt;/b&gt;를 통해 실행하면, 이렇게 설정한 가짜 데이터가 응답으로 나오는 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;983&quot; data-origin-height=&quot;609&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6GxPg/dJMcaaFhUZI/kLSoXWFNUhcN5vWJQKp30k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6GxPg/dJMcaaFhUZI/kLSoXWFNUhcN5vWJQKp30k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6GxPg/dJMcaaFhUZI/kLSoXWFNUhcN5vWJQKp30k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6GxPg%2FdJMcaaFhUZI%2FkLSoXWFNUhcN5vWJQKp30k%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;983&quot; height=&quot;609&quot; data-origin-width=&quot;983&quot; data-origin-height=&quot;609&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/2613/&quot;&gt;https://techblog.woowahan.com/2613/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778406230927&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;테스트 코드 없이 레거시 코드를 다 감수하시겠습니까? | 우아한형제들 기술블로그&quot; data-og-description=&quot;부서 이동을 하다 2018년 말미, 결제/정산 파트에서 주문중계 파트로 부서 이동하게 되었습니다. 인사 발령을 받고 나서 팀 이동을 하게 되면 누구나 직면하게 되는 상황이 발생하는데요. 그것은 &quot; data-og-host=&quot;techblog.woowahan.com&quot; data-og-source-url=&quot;https://techblog.woowahan.com/2613/&quot; data-og-url=&quot;https://techblog.woowahan.com/2613/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/2613/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://techblog.woowahan.com/2613/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;테스트 코드 없이 레거시 코드를 다 감수하시겠습니까? | 우아한형제들 기술블로그&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;부서 이동을 하다 2018년 말미, 결제/정산 파트에서 주문중계 파트로 부서 이동하게 되었습니다. 인사 발령을 받고 나서 팀 이동을 하게 되면 누구나 직면하게 되는 상황이 발생하는데요. 그것은&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;techblog.woowahan.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@leeseonseonje/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%A1%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@leeseonseonje/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%A1%B0&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778491522365&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;단위 테스트 AAA 패턴&quot; data-og-description=&quot;AAA 패턴 AAA 패턴은 각 테스트를 준비(arrange), 실행(act), 검증(assert)이라는 세부분으로 나눌 수 있다. 모든 테스트가 단순하고 균일한 구조를 갖는데 도움이 된다. (일관성) 테스트를 쉽게 읽고, 이&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@leeseonseonje/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%A1%B0&quot; data-og-url=&quot;https://velog.io/@leeseonseonje/단위테스트-구조&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/wpEmE/dJMb8SpMyDQ/TkV2hI8mYsaMyz2duAJj8K/img.png?width=950&amp;amp;height=500&amp;amp;face=0_0_950_500,https://scrap.kakaocdn.net/dn/uB3Ui/dJMb8VNz59K/sqd4kZtjA7MFDzppoD20Fk/img.jpg?width=460&amp;amp;height=460&amp;amp;face=0_0_460_460&quot;&gt;&lt;a href=&quot;https://velog.io/@leeseonseonje/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%A1%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@leeseonseonje/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%A1%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/wpEmE/dJMb8SpMyDQ/TkV2hI8mYsaMyz2duAJj8K/img.png?width=950&amp;amp;height=500&amp;amp;face=0_0_950_500,https://scrap.kakaocdn.net/dn/uB3Ui/dJMb8VNz59K/sqd4kZtjA7MFDzppoD20Fk/img.jpg?width=460&amp;amp;height=460&amp;amp;face=0_0_460_460');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;단위 테스트 AAA 패턴&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;AAA 패턴 AAA 패턴은 각 테스트를 준비(arrange), 실행(act), 검증(assert)이라는 세부분으로 나눌 수 있다. 모든 테스트가 단순하고 균일한 구조를 갖는데 도움이 된다. (일관성) 테스트를 쉽게 읽고, 이&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@antisdun/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@antisdun/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778491528488&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;단위 테스트와 통합 테스트&quot; data-og-description=&quot;단위 테스트 단위 테스트는 소프트웨어 개발 과정에서 가장 작은 단위의 코드, 예를 들어 각 컴포넌트 혹은 함수가 기대한 대로 작동하며 그 기능 요구사하을 만족시키는지 검증하는 것이다. 프&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@antisdun/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8&quot; data-og-url=&quot;https://velog.io/@antisdun/단위-테스트와-통합-테스트&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/fhEp7/dJMb8YpZZI3/59hBViIBqUXoJRzEwpgU6k/img.png?width=1431&amp;amp;height=857&amp;amp;face=0_0_1431_857,https://scrap.kakaocdn.net/dn/btWF0v/dJMb85WXXpj/SSVUETmkGJVIkYaAu2StSk/img.png?width=1431&amp;amp;height=857&amp;amp;face=0_0_1431_857,https://scrap.kakaocdn.net/dn/SexFc/dJMb85vTAKa/3ifCKnkhcKBRPfKWSqXSWK/img.jpg?width=2305&amp;amp;height=1729&amp;amp;face=0_0_2305_1729&quot;&gt;&lt;a href=&quot;https://velog.io/@antisdun/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@antisdun/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/fhEp7/dJMb8YpZZI3/59hBViIBqUXoJRzEwpgU6k/img.png?width=1431&amp;amp;height=857&amp;amp;face=0_0_1431_857,https://scrap.kakaocdn.net/dn/btWF0v/dJMb85WXXpj/SSVUETmkGJVIkYaAu2StSk/img.png?width=1431&amp;amp;height=857&amp;amp;face=0_0_1431_857,https://scrap.kakaocdn.net/dn/SexFc/dJMb85vTAKa/3ifCKnkhcKBRPfKWSqXSWK/img.jpg?width=2305&amp;amp;height=1729&amp;amp;face=0_0_2305_1729');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;단위 테스트와 통합 테스트&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;단위 테스트 단위 테스트는 소프트웨어 개발 과정에서 가장 작은 단위의 코드, 예를 들어 각 컴포넌트 혹은 함수가 기대한 대로 작동하며 그 기능 요구사하을 만족시키는지 검증하는 것이다. 프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@xeropise1/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%9E%80-%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%86%B5%ED%95%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-E2E%ED%85%8C%EC%8A%A4%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@xeropise1/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%9E%80-%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%86%B5%ED%95%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-E2E%ED%85%8C%EC%8A%A4%ED%8A%B8&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778491531970&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;테스트란? (단위테스트, 통합테스트, E2E테스트)&quot; data-og-description=&quot;테스트란 소프트웨어의 관점에서 정의하면프로그램을 실행하는 경우에 요구 사항에 맞춰 동작하는지 검증하는 행위 라고 할 수 있다.테스트에는 다양한 종류가 있는데, 보통 범위에 따라서 단&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@xeropise1/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%9E%80-%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%86%B5%ED%95%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-E2E%ED%85%8C%EC%8A%A4%ED%8A%B8&quot; data-og-url=&quot;https://velog.io/@xeropise1/테스트란-단위테스트-통합테스트-E2E테스트&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/n8uPo/dJMb8SpMyDT/XYwDgnsCbPIkHbkKatMSBk/img.png?width=3088&amp;amp;height=1041&amp;amp;face=0_0_3088_1041,https://scrap.kakaocdn.net/dn/wxUmr/dJMb9bv6OYh/NWcdSpiGKBM6K2z6WRKot1/img.png?width=3088&amp;amp;height=1041&amp;amp;face=0_0_3088_1041,https://scrap.kakaocdn.net/dn/cseABn/dJMb8ZvF2EC/xwlXXupRjh6Z4R3fLKnmD1/img.png?width=3088&amp;amp;height=1041&amp;amp;face=0_0_3088_1041&quot;&gt;&lt;a href=&quot;https://velog.io/@xeropise1/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%9E%80-%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%86%B5%ED%95%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-E2E%ED%85%8C%EC%8A%A4%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@xeropise1/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%9E%80-%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%86%B5%ED%95%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-E2E%ED%85%8C%EC%8A%A4%ED%8A%B8&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/n8uPo/dJMb8SpMyDT/XYwDgnsCbPIkHbkKatMSBk/img.png?width=3088&amp;amp;height=1041&amp;amp;face=0_0_3088_1041,https://scrap.kakaocdn.net/dn/wxUmr/dJMb9bv6OYh/NWcdSpiGKBM6K2z6WRKot1/img.png?width=3088&amp;amp;height=1041&amp;amp;face=0_0_3088_1041,https://scrap.kakaocdn.net/dn/cseABn/dJMb8ZvF2EC/xwlXXupRjh6Z4R3fLKnmD1/img.png?width=3088&amp;amp;height=1041&amp;amp;face=0_0_3088_1041');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;테스트란? (단위테스트, 통합테스트, E2E테스트)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;테스트란 소프트웨어의 관점에서 정의하면프로그램을 실행하는 경우에 요구 사항에 맞춰 동작하는지 검증하는 행위 라고 할 수 있다.테스트에는 다양한 종류가 있는데, 보통 범위에 따라서 단&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@sujeong_dev/MSWMock-Service-Worker%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-API-mocking&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@sujeong_dev/MSWMock-Service-Worker%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-API-mocking&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778494719048&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;MSW(Mock Service Worker)를 이용한 API mocking&quot; data-og-description=&quot;프론트 개발을 하던 와중에 API 개발속도와 맞지 않아 동시에 개발이 진행되게 되어 /public경로에 실제 API response의 json key-value값을 맞춰서 mock data를 만들어 mocking하는 식으로 UI를 만들었다. 그러&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@sujeong_dev/MSWMock-Service-Worker%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-API-mocking&quot; data-og-url=&quot;https://velog.io/@sujeong_dev/MSWMock-Service-Worker를-이용한-API-mocking&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/blkSR7/dJMb9hC5Ovr/MMvmgFUEc7D8ckzDpMmOk1/img.png?width=326&amp;amp;height=44&amp;amp;face=0_0_326_44,https://scrap.kakaocdn.net/dn/eKi9q/dJMb83kxyYS/NqiI62S4KvlsKhWJmpUSsK/img.png?width=326&amp;amp;height=44&amp;amp;face=0_0_326_44,https://scrap.kakaocdn.net/dn/rjhW1/dJMb89yibVe/lSWeohh409OKaktrnhxDOk/img.png?width=421&amp;amp;height=421&amp;amp;face=133_154_287_322&quot;&gt;&lt;a href=&quot;https://velog.io/@sujeong_dev/MSWMock-Service-Worker%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-API-mocking&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@sujeong_dev/MSWMock-Service-Worker%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-API-mocking&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/blkSR7/dJMb9hC5Ovr/MMvmgFUEc7D8ckzDpMmOk1/img.png?width=326&amp;amp;height=44&amp;amp;face=0_0_326_44,https://scrap.kakaocdn.net/dn/eKi9q/dJMb83kxyYS/NqiI62S4KvlsKhWJmpUSsK/img.png?width=326&amp;amp;height=44&amp;amp;face=0_0_326_44,https://scrap.kakaocdn.net/dn/rjhW1/dJMb89yibVe/lSWeohh409OKaktrnhxDOk/img.png?width=421&amp;amp;height=421&amp;amp;face=133_154_287_322');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;MSW(Mock Service Worker)를 이용한 API mocking&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프론트 개발을 하던 와중에 API 개발속도와 맞지 않아 동시에 개발이 진행되게 되어 /public경로에 실제 API response의 json key-value값을 맞춰서 mock data를 만들어 mocking하는 식으로 UI를 만들었다. 그러&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tech.ktcloud.com/entry/MSW%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-API-Mocking&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tech.ktcloud.com/entry/MSW%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-API-Mocking&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778498409553&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;MSW로 프론트엔드 개발 프로세스 개선하기 : API Mocking&quot; data-og-description=&quot;[kt cloud 플랫폼Innovation팀 송재희 님]&amp;nbsp;MSW로 프론트엔드 개발 프로세스 개선하기 : API Mocking&amp;nbsp;프론트엔드 개발자라면, 종종 백엔드 API가 준비되기 전까지 대기해야 하는 상황을 경험해 보셨을 겁니&quot; data-og-host=&quot;tech.ktcloud.com&quot; data-og-source-url=&quot;https://tech.ktcloud.com/entry/MSW%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-API-Mocking&quot; data-og-url=&quot;https://tech.ktcloud.com/entry/MSW%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-API-Mocking&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/f6VuQ/dJMb9cBMC4w/nSAt4j7DwwS4buEnzqELk1/img.png?width=800&amp;amp;height=375&amp;amp;face=0_0_800_375,https://scrap.kakaocdn.net/dn/bH48ks/dJMb9jOrugR/mcpxSHuG6KtOwDwkCTexh0/img.png?width=800&amp;amp;height=375&amp;amp;face=0_0_800_375,https://scrap.kakaocdn.net/dn/bkn7RE/dJMb9cBMC4y/CkKO52szIEJckifmItkzsK/img.png?width=2336&amp;amp;height=1674&amp;amp;face=0_0_2336_1674&quot;&gt;&lt;a href=&quot;https://tech.ktcloud.com/entry/MSW%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-API-Mocking&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://tech.ktcloud.com/entry/MSW%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-API-Mocking&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/f6VuQ/dJMb9cBMC4w/nSAt4j7DwwS4buEnzqELk1/img.png?width=800&amp;amp;height=375&amp;amp;face=0_0_800_375,https://scrap.kakaocdn.net/dn/bH48ks/dJMb9jOrugR/mcpxSHuG6KtOwDwkCTexh0/img.png?width=800&amp;amp;height=375&amp;amp;face=0_0_800_375,https://scrap.kakaocdn.net/dn/bkn7RE/dJMb9cBMC4y/CkKO52szIEJckifmItkzsK/img.png?width=2336&amp;amp;height=1674&amp;amp;face=0_0_2336_1674');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;MSW로 프론트엔드 개발 프로세스 개선하기 : API Mocking&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;[kt cloud 플랫폼Innovation팀 송재희 님]&amp;nbsp;MSW로 프론트엔드 개발 프로세스 개선하기 : API Mocking&amp;nbsp;프론트엔드 개발자라면, 종종 백엔드 API가 준비되기 전까지 대기해야 하는 상황을 경험해 보셨을 겁니&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;tech.ktcloud.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/68</guid>
      <comments>https://coding-cherry.tistory.com/68#entry68comment</comments>
      <pubDate>Mon, 11 May 2026 20:20:26 +0900</pubDate>
    </item>
    <item>
      <title>게시글 목록 API 구현하기 (페이징 &amp;middot; 검색, N+1)</title>
      <link>https://coding-cherry.tistory.com/67</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;페이징&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 목록 조회 API를 구현하면 이와 같은 형태로 작성하게 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778053529093&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;Post&amp;gt; posts = postRepository.findAll();&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;로컬에서 데이터 10개 정도로 테스트할 때는 문제 없이 잘 동작하지만, 게시글이 10,000개 정도 있다고 가정해보자.&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;이 API를 호출하는 순간,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터베이스는 10,000개의 행을 읽어서 메모리에 올린다&lt;/li&gt;
&lt;li&gt;애플리케이션은 10,000개의 Post 객체를 생성한다&lt;/li&gt;
&lt;li&gt;네트워크를 통해 수 MB의 JSON 데이터가 전송된다&lt;/li&gt;
&lt;li&gt;프론트엔드는 10,000개의 게시글을 한꺼번에 렌더링하려 시도한다&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;b&gt;Paging&lt;/b&gt;이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;'한번에 모든 데이터를 보여 줄 필요가 없다면 필요한 만큼만 잘라서 보여주자' &lt;/i&gt;가 핵심 아이디어이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Data JPA 페이징의 구현&amp;nbsp;&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA에 페이징 기능이 내장되어 있으므로 이를 이용하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 페이징 정보를 담은 DTO를 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 시, 표시할 게시글들의 데이터 목록들을 List로 묶고 Page&amp;lt;T&amp;gt; 에서 제공하는 페이지 메타 정보들도 함께 리턴한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778055610353&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class PostListResponseDto {
    private List&amp;lt;PostDetailResponseDto&amp;gt; posts; // 페이지 데이터 목록

    private Integer currentPage; // 현재 페이지 번호 (0부터 시작)
    private Integer totalPages; // 전체 페이지 수
    private Long totalElements; // 전체 데이터 수
    private Integer listSize; // 이번 페이지에 실제 담긴 데이터 수
    private Boolean isFirst; // 첫번째 페이지 여부
    private Boolean isLast; // 마지막 페이지 여부
    private Boolean hasNext; // 다음 페이지 존재 여부
    private Boolean hasPrevious; // 이전 페이지 존재 여부

    public static PostListResponseDto from(Page&amp;lt;Post&amp;gt; page) {
        List&amp;lt;PostDetailResponseDto&amp;gt; posts =  page.getContent().stream()
                .map(post -&amp;gt; new PostDetailResponseDto(
                        post.getId(),
                        post.getTitle(),
                        post.getContent(),
                        post.getUser().getNickname(),
                        post.getCreatedAt()
                )).toList();

        return new PostListResponseDto(
                posts,
                page.getNumber(),           // currentPage
                page.getTotalPages(),        // totalPages
                page.getTotalElements(),     // totalElements
                page.getNumberOfElements(), // listSize
                page.isFirst(),             // isFirst
                page.isLast(),              // isLast
                page.hasNext(),             // hasNext
                page.hasPrevious()          // hasPrevious
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Page&amp;lt;T&amp;gt; &lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA가 제공하는 &lt;b&gt;페이징 결과를 담는 &lt;/b&gt;컨테이너 객체&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;제공하는 메서드 (Count 쿼리 결과)&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;메서드&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;반환 타입&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;getTotalElements()&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;long&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;조건에 맞는 전체 데이터 수&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;getTotalPages()&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;int&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;전체 페이지 수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;br /&gt;제공하는 메서드 (이번 페이지의 정보)&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;메서드&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;반환 타입&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;getContent()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;List&amp;lt;T&amp;gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;이번 페이지의 실제 데이터 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;getNumber()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;int&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;현재 페이지 번호 (0부터 시작)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;getSize()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;int&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;요청한 페이지의 크기&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;getNumberOfElements()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;int&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;이번 페이지에 실제로 담긴 데이터 수&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;hasContent()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;boolean&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;데이터가 하나라도 있는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;isFirst()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;boolean&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;첫번째 페이지인가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;isLast()&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;boolean&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;마지막 페이지인가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;hasNext()&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;boolean&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;다음 페이지가 존재하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;hasPrevious()&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;boolean&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;이전 페이지가 존재하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;getSort()&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;Sort&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;적용된 정렬 정보&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Q. getSize() 와 getNumberOfElements() 의 차이는?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getSize()는 요청한 페이지 크기이고, getNumberOfElements()는 실제로 담긴 데이터 수다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 데이터가 27개이고 size=10일 때,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 페이지(page=2)는 요청한 크기는 10이지만, 실제로 남은 데이터가 7개이므로 두 값이 달라진다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778056286429&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;page=0 &amp;rarr; getSize()=10, getNumberOfElements()=10
page=1 &amp;rarr; getSize()=10, getNumberOfElements()=10
page=2 &amp;rarr; getSize()=10, getNumberOfElements()=7  &amp;larr; 마지막 페이지&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;그 다음, Service 로직을 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778056614080&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
    public PostListResponseDto getPosts(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
        Page&amp;lt;Post&amp;gt; postPage = postRepository.findAll(pageable);
        return PostListResponseDto.from(postPage);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Pageable&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지 요청 정보를 담는 인터페이스, PageRequest.of()로 구현체를 만들어 사용&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'몇 번째 페이지(page), 몇 개씩(size), 어떤 순서로(sort)'를 묶어서 Repository에 넘겨 주는 역할&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;page&amp;nbsp;&lt;/b&gt;몇 번째 페이지(0부터 시작)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;size&amp;nbsp;&lt;/b&gt;한 페이지에 몇 개&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Sort.by(DESC | ASC, sortBy)&amp;nbsp;&lt;/b&gt;정렬 조건 객체 생성, sortBy는 정렬의 기준이 될 컬럼명&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pageable 조건에 맞게 DB에서 조회를 해 Spring Data JPA가 LIMIT, OFFSET, ORDER BY SQL을 자동으로 생성 후 결과를 DTO로 변환해 리턴한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 Controller를 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;size와 sortBy를 외부에서 받아오므로 값이 없을 것을 대비해 defaultValue를 설정했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778059716240&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Operation(summary = &quot;게시글 목록 조회&quot;, description = &quot;페이징/정렬을 지원합니다.&quot;)
    @GetMapping(&quot;/lists&quot;)
    public ApiResponse&amp;lt;PostListResponseDto&amp;gt; getPosts(
            @RequestParam(defaultValue = &quot;0&quot;) int page,
            @RequestParam(defaultValue = &quot;10&quot;) int size,
            @RequestParam(defaultValue = &quot;createdAt&quot;) String sortBy
    ) {
        return ApiResponse.onSuccess(GeneralSuccessCode.OK, postService.getPosts(page, size, sortBy));
    }&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;877&quot; data-origin-height=&quot;492&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QwdOM/dJMcadWbl7q/XB8T8NTyQ5mgHvEYIGMeUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QwdOM/dJMcadWbl7q/XB8T8NTyQ5mgHvEYIGMeUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QwdOM/dJMcadWbl7q/XB8T8NTyQ5mgHvEYIGMeUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQwdOM%2FdJMcadWbl7q%2FXB8T8NTyQ5mgHvEYIGMeUK%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;877&quot; height=&quot;492&quot; data-origin-width=&quot;877&quot; data-origin-height=&quot;492&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동적 검색 조건 처리(Keyword, 날짜 범위)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동적 쿼리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 시점에 조건에 따라 WHERE 절이 동적으로 구성되는 쿼리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구현 방법&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;JPQL&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;간단한 쿼리에 적합&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;문자열 기반, 컴파일 체크 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;Specification&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;표준 JPA 스펙&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;코드 가독성 떨어짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;b&gt;QueryDSL&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;타입 안전, 직관적&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;초기 설정 필요&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 &lt;b&gt;JPQL&lt;/b&gt;과 &lt;b&gt;QueryDSL&lt;/b&gt;로 구현하고 비교해보겠다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JPQL(Java Persistence Query Language)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL을 추상화한 객체 지향 쿼리 언어&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테이블이 아닌 엔티티 객체를 대상으로 쿼리 작성&lt;/li&gt;
&lt;li&gt;SQL과 문법이 유사하나 DB 독립적&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778060966781&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- SQL (테이블 기준)
SELECT * FROM post WHERE title LIKE '%Spring%'

-- JPQL (엔티티 기준)
SELECT p FROM Post p WHERE p.title LIKE '%Spring%'&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;JPQL을 통해 구현하기 위해 PostRepository에 아래와 같이 searchPosts 메서드를 작성하자.&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;@Query&amp;nbsp;&lt;/b&gt;직접 쿼리를 작성할 때 사용하는 어노테이션&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;value&amp;nbsp;&lt;/b&gt;실제 데이터를 가져오는 쿼리, SQL 대신 JPQL으로 사용
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;:keyword&amp;nbsp;&lt;/b&gt;파라미터 바인딩 자리 표시자, @Param(&quot;keyword&quot;)로 연결된 실제 값이 런타임에 삽입&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;countQuery&amp;nbsp;&lt;/b&gt;page 반환 시, 전체 데이터 수를 구하기 위한 별도 쿼리&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778061126381&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
    @Query(
            value = &quot;SELECT p FROM Post p JOIN FETCH p.user &quot; +
                    &quot;WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) &quot; +
                    &quot;AND (:startDate IS NULL OR p.createdAt &amp;gt;= :startDate) &quot; +
                    &quot;AND (:endDate IS NULL OR p.createdAt &amp;lt;= :endDate)&quot;,
            countQuery = &quot;SELECT COUNT(p) FROM Post p &quot; +
                    &quot;WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) &quot; +
                    &quot;AND (:startDate IS NULL OR p.createdAt &amp;gt;= :startDate) &quot; +
                    &quot;AND (:endDate IS NULL OR p.createdAt &amp;lt;= :endDate)&quot;
    )
    Page&amp;lt;Post&amp;gt; searchPosts(
            @Param(&quot;keyword&quot;) String keyword,
            @Param(&quot;startDate&quot;) LocalDateTime startDate,
            @Param(&quot;endDate&quot;) LocalDateTime endDate,
            Pageable pageable
    );
}&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;다음, 위에서 작성한 서비스를 keyword와 date 파라미터를 추가해 검색 조건을 추가하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 findAll()로 전체 조회하던 것을 keyword, date 조건을 추가해 searchPosts 메서드를 이용해 조회하는 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778061686222&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
    public PostListResponseDto getPosts(int page, int size, String sortBy,
                                        String keyword,
                                        LocalDateTime startDate, LocalDateTime endDate) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
        Page&amp;lt;Post&amp;gt; postPage = postRepository.searchPosts(keyword, startDate, endDate, pageable);
        return PostListResponseDto.from(postPage);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 컨트롤러에도 똑같이 조건을 넣어 수정한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778061865712&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Operation(summary = &quot;게시글 목록 조회&quot;, description = &quot;페이징/정렬을 지원합니다.&quot;)
    @GetMapping(&quot;/lists&quot;)
    public ApiResponse&amp;lt;PostListResponseDto&amp;gt; getPosts(
            @RequestParam(defaultValue = &quot;0&quot;) int page,
            @RequestParam(defaultValue = &quot;10&quot;) int size,
            @RequestParam(defaultValue = &quot;createdAt&quot;) String sortBy,
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) LocalDateTime startDate,
            @RequestParam(required = false) LocalDateTime endDate
    ) {
        return ApiResponse.onSuccess(GeneralSuccessCode.OK,
                postService.getPosts(page, size, sortBy, keyword, startDate, endDate));
    }&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1404&quot; data-origin-height=&quot;489&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dacAhs/dJMcaad8LtW/638X3W2HBreXOZuMT6ID2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dacAhs/dJMcaad8LtW/638X3W2HBreXOZuMT6ID2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dacAhs/dJMcaad8LtW/638X3W2HBreXOZuMT6ID2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdacAhs%2FdJMcaad8LtW%2F638X3W2HBreXOZuMT6ID2K%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;1404&quot; height=&quot;489&quot; data-origin-width=&quot;1404&quot; data-origin-height=&quot;489&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;그 다음, &quot;동적&quot; 이라는 키워드를 넣고 조회해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Curl 부분에 keyword=%EB%8F%99%EC%A0%81 가 추가된 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1402&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cibKIk/dJMcacJN0MI/teQ3EM4KrUkFYoGkZwGNx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cibKIk/dJMcacJN0MI/teQ3EM4KrUkFYoGkZwGNx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cibKIk/dJMcacJN0MI/teQ3EM4KrUkFYoGkZwGNx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcibKIk%2FdJMcacJN0MI%2FteQ3EM4KrUkFYoGkZwGNx1%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;1402&quot; height=&quot;634&quot; data-origin-width=&quot;1402&quot; data-origin-height=&quot;634&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;마지막으로, 4월 만을 기준으로 조회해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Curl 부분에 startDate=2026-04-01T00%3A00%3A00&amp;amp;endDate=2026-05-01T00%3A00%3A00 이 추가된 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1395&quot; data-origin-height=&quot;697&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dBLpKX/dJMcagk5PMW/fSOLiMONdSeksWV2VABwkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dBLpKX/dJMcagk5PMW/fSOLiMONdSeksWV2VABwkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dBLpKX/dJMcagk5PMW/fSOLiMONdSeksWV2VABwkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdBLpKX%2FdJMcagk5PMW%2FfSOLiMONdSeksWV2VABwkk%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;1395&quot; height=&quot;697&quot; data-origin-width=&quot;1395&quot; data-origin-height=&quot;697&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;QueryDSL&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타입 안전한(Type-Safe) 쿼리 빌더&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Java 코드로 쿼리를 작성해 컴파일 시점에 오류를 검출한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Q 클래스 &lt;/b&gt;엔티티를 표현하는 Java 클래스(쿼리용), 엔티티 meta 모델을 자동 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, QueryDSL을 사용하려면 의존성을 추가해줘야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 경우, Spring Boot 3.5.11 버전을 사용하므로 이에 맞는 의존성을 추가했다. 자신의 버전에 유의해서 사용하자.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;404&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dZXRNX/dJMcahdfsFZ/D5nVjL7MlcU6ejUo1R8tWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dZXRNX/dJMcahdfsFZ/D5nVjL7MlcU6ejUo1R8tWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dZXRNX/dJMcahdfsFZ/D5nVjL7MlcU6ejUo1R8tWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdZXRNX%2FdJMcahdfsFZ%2FD5nVjL7MlcU6ejUo1R8tWK%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;404&quot; height=&quot;333&quot; data-origin-width=&quot;404&quot; data-origin-height=&quot;333&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;의존성 추가 후, 빌드하면 아래와 같이 Q 클래스가 생긴 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음, QueryslConfig 파일을 생성해 아래와 같이 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778063069698&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class QueryDslConfig {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;EntityManager&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA와 DB가 소통할 때 사용하는 핵심 객체&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;JPAQueryFactory&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EntityManager를 받아 QueryDSL 문법으로 쿼리를 만들고 실행할 수 있게 해주는 객체&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778063201090&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// JPAQueryFactory 없이 (JPQL 방식)
@Query(&quot;SELECT p FROM Post p WHERE p.title LIKE %:keyword%&quot;)

// JPAQueryFactory 있으면 (QueryDSL 방식)
queryFactory
    .selectFrom(post)
    .where(post.title.contains(keyword))
    .fetch();&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;그 다음, PostRepositoryCustom 인터페이스를 생성해 QueryDSL 구현 코드를 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 CRUD는 JpaRepsitory에서, 복잡한 QueryDSL 쿼리는 PostRepositoryCustom에서 하도록 분리하기 위해 따로 선언한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778063485313&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface PostRepositoryCustom {

    Page&amp;lt;Post&amp;gt; searchPostsQueryDsl(
            String keyword,
            LocalDateTime startDate,
            LocalDateTime endDate,
            Pageable pageable
    );
}&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;PostRepository에 PostRepositoryCustom 인터페이스 상속을 추가한다. (다중 상속 가능)&lt;/p&gt;
&lt;pre id=&quot;code_1778063579387&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt;, PostRepositoryCustom {&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;그 다음, 인터페이스의 구현체인 PostRepositoryImpl을 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;goe(Greater Or Equal)&amp;nbsp;&lt;/b&gt;&amp;gt;=, QueryDSL의 비교 메서드&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;loe(Less Or Equal)&amp;nbsp;&lt;/b&gt;&amp;lt;=, QueryDSL의 비교 메서드&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;contains&amp;nbsp;&lt;/b&gt;QueryDSL의 문자열 검색 메서드&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778063833789&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {

    private final JPAQueryFactory queryFactory;
    private final QPost post = QPost.post;

    @Override
    public Page&amp;lt;Post&amp;gt; searchPostsQueryDsl(String keyword, LocalDateTime startDate,
                                          LocalDateTime endDate, Pageable pageable) {
        BooleanBuilder builder = new BooleanBuilder();

        if (keyword != null) {
            builder.and(post.title.contains(keyword)
                    .or(post.content.contains(keyword)));
        }
        if (startDate != null) {
            builder.and(post.createdAt.goe(startDate));
        }
        if (endDate != null) {
            builder.and(post.createdAt.loe(endDate));
        }

        List&amp;lt;Post&amp;gt; posts = queryFactory
                .selectFrom(post)
                .join(post.user).fetchJoin()
                .where(builder)
                .orderBy(post.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = queryFactory
                .select(post.count())
                .from(post)
                .where(builder)
                .fetchOne();

        return new PageImpl&amp;lt;&amp;gt;(posts, pageable, total);
    }
}&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;size18&quot;&gt;&lt;b&gt;BooleanBuilder&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건을 동적으로 조합하는 객체&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;if 문으로 조건이 있을 때만 .and() 로 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;쿼리 조립 부분&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL을 문자열로 쓰지 않고&amp;nbsp;&lt;b&gt;메서드 체이닝&lt;/b&gt;으로 조립하는 것이 QueryDSL의 핵심적인 특징이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778063923227&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;queryFactory
    .selectFrom(post)       // SELECT * FROM post
    .join(post.user).fetchJoin()  // JOIN FETCH (N+1 방지)
    .where(builder)         // 동적 조건 적용
    .orderBy(post.createdAt.desc())  // ORDER BY
    .offset(pageable.getOffset())    // 몇 번째부터
    .limit(pageable.getPageSize())   // 몇 개까지
    .fetch();               // 실행 후 List로 반환&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;최종적으로 목록, 페이지 정보, 전체 수를 합쳐 Page&amp;lt;Post&amp;gt;로 만들어 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 서비스와 컨트롤러에 searchPostsQueryDsl을 사용하는 방식으로 메서드와 엔드 포인트를 추가한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778064473613&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
public PostListResponseDto getPostsQueryDsl(int page, int size, String sortBy,
                                            String keyword,
                                            LocalDateTime startDate, LocalDateTime endDate) {
    Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
    Page&amp;lt;Post&amp;gt; postPage = postRepository.searchPostsQueryDsl(keyword, startDate, endDate, pageable);
    return PostListResponseDto.from(postPage);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1778064483544&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/lists/querydsl&quot;)
public ApiResponse&amp;lt;PostListResponseDto&amp;gt; getPostsQueryDsl(
        @RequestParam(defaultValue = &quot;0&quot;) int page,
        @RequestParam(defaultValue = &quot;10&quot;) int size,
        @RequestParam(defaultValue = &quot;createdAt&quot;) String sortBy,
        @RequestParam(required = false) String keyword,
        @RequestParam(required = false) LocalDateTime startDate,
        @RequestParam(required = false) LocalDateTime endDate
) {
    return ApiResponse.onSuccess(GeneralSuccessCode.OK,
            postService.getPostsQueryDsl(page, size, sortBy, keyword, startDate, endDate));
}&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1398&quot; data-origin-height=&quot;691&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bL1W6t/dJMcaf7x9zX/72SVJsk6WKHAo2kKZh5ToK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bL1W6t/dJMcaf7x9zX/72SVJsk6WKHAo2kKZh5ToK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bL1W6t/dJMcaf7x9zX/72SVJsk6WKHAo2kKZh5ToK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbL1W6t%2FdJMcaf7x9zX%2F72SVJsk6WKHAo2kKZh5ToK%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;1398&quot; height=&quot;691&quot; data-origin-width=&quot;1398&quot; data-origin-height=&quot;691&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그 다음, &quot;동적&quot; 이라는 키워드를 넣고 조회해보자.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Curl 부분에 keyword=%EB%8F%99%EC%A0%81 가 추가된 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1407&quot; data-origin-height=&quot;646&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2eD9R/dJMcaipHkt6/nVIkqeGgKkUsP0K2inLtDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2eD9R/dJMcaipHkt6/nVIkqeGgKkUsP0K2inLtDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2eD9R/dJMcaipHkt6/nVIkqeGgKkUsP0K2inLtDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2eD9R%2FdJMcaipHkt6%2FnVIkqeGgKkUsP0K2inLtDk%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;1407&quot; height=&quot;646&quot; data-origin-width=&quot;1407&quot; data-origin-height=&quot;646&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 4월 만을 기준으로 조회해보자.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Curl 부분에 startDate=2026-04-01T00%3A00%3A00&amp;amp;endDate=2026-05-01T00%3A00%3A00 이 추가된 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;693&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tgJ7V/dJMcabYpT71/Cl4wln5N6OggPnpAWIPc01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tgJ7V/dJMcabYpT71/Cl4wln5N6OggPnpAWIPc01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tgJ7V/dJMcabYpT71/Cl4wln5N6OggPnpAWIPc01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtgJ7V%2FdJMcabYpT71%2FCl4wln5N6OggPnpAWIPc01%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;1400&quot; height=&quot;693&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;693&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;얼핏 보면 이전과 같아 보이지만, Curl 부분을 보면 이전 API가 아닌 querydsl API임을 볼 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;JPQL VS QueryDSL&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;검색 조건 처리 (JPQL) - 조건을 문자열로 작성&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778081353456&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(
    value = &quot;SELECT p FROM Post p JOIN FETCH p.user &quot; +
            &quot;WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) &quot; +
            &quot;AND (:startDate IS NULL OR p.createdAt &amp;gt;= :startDate) &quot; +
            &quot;AND (:endDate IS NULL OR p.createdAt &amp;lt;= :endDate)&quot;,
    countQuery = &quot;SELECT COUNT(p) FROM Post p &quot; +
            &quot;WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) &quot; +
            &quot;AND (:startDate IS NULL OR p.createdAt &amp;gt;= :startDate) &quot; +
            &quot;AND (:endDate IS NULL OR p.createdAt &amp;lt;= :endDate)&quot;
)
Page&amp;lt;Post&amp;gt; searchPosts(@Param(&quot;keyword&quot;) String keyword, ...);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;검색 조건 처리 (QueryDSL) - 조건을 코드로 조합&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778081392921&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BooleanBuilder builder = new BooleanBuilder();

if (keyword != null) {
    builder.and(post.title.contains(keyword)
            .or(post.content.contains(keyword)));
}
if (startDate != null) {
    builder.and(post.createdAt.goe(startDate));
}
if (endDate != null) {
    builder.and(post.createdAt.loe(endDate));
}

List&amp;lt;Post&amp;gt; posts = queryFactory
        .selectFrom(post)
        .join(post.user).fetchJoin()
        .where(builder)
        .orderBy(post.createdAt.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

Long total = queryFactory
        .select(post.count())
        .from(post)
        .where(builder)
        .fetchOne();

return new PageImpl&amp;lt;&amp;gt;(posts, pageable, total);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 69.6512%; height: 114px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 13.9147%; height: 17px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%; height: 17px;&quot;&gt;&lt;b&gt;JPQL&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%; height: 17px;&quot;&gt;&lt;b&gt;QueryDSL&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.9147%; height: 21px;&quot;&gt;&lt;b&gt;작성 방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%; height: 21px;&quot;&gt;문자열로 작성&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%; height: 21px;&quot;&gt;자바 코드로 작성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.9147%; height: 21px;&quot;&gt;&lt;b&gt;오타 발견 시점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%; height: 21px;&quot;&gt;실행해야 알 수 있음&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%; height: 21px;&quot;&gt;IDE가 바로 알려줌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.9147%; height: 21px;&quot;&gt;&lt;b&gt;동적 조건 처리&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%; height: 21px;&quot;&gt;:param IS NULL 트릭 사용&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%; height: 21px;&quot;&gt;if문으로 자연스럽게 추가/제거&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 13.9147%; height: 17px;&quot;&gt;&lt;b&gt;가독성&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%; height: 17px;&quot;&gt;조건이 늘수록 문자열이 길어짐&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%; height: 17px;&quot;&gt;조건이 늘어도 코드가 깔끔함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 13.9147%; height: 17px;&quot;&gt;&lt;b&gt;초기 설정&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%; height: 17px;&quot;&gt;별도 설정 없음&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%; height: 17px;&quot;&gt;의존성, Q클래스, 빈 등록 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.9147%;&quot;&gt;&lt;b&gt;적합한 상황&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 28.6822%;&quot;&gt;조건이 단순한 경우&lt;/td&gt;
&lt;td style=&quot;width: 27.0543%;&quot;&gt;조건이 많고 복잡한 경우&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;N + 1 문제&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1번의 쿼리로 N개의 데이터를 조회했을 때, 각 데이터에 연관된 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 문제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 게시글 100개를 조회하면 총 101번의 쿼리가 실행된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;발생 원인&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA의 기본 Fetch 전략이&amp;nbsp;&lt;b&gt;LAZY(지연 로딩)&lt;/b&gt;이기 때문이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LAZY 로딩은 처음에는 연관 객체를 가져오지 않다가,&amp;nbsp;&lt;b&gt;실제로 접근하는 순간&amp;nbsp;&lt;/b&gt;DB에 쿼리를 날린다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;*그렇다고 해서 EAGER 을 사용하더라도 똑같이 N+1이 발생한다. &quot;언제&quot; 쿼리를 날리느냐의 차이일 뿐...&lt;/p&gt;
&lt;pre id=&quot;code_1778083665818&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@ManyToOne(fetch = FetchType.LAZY)
private User user;&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;내가 작성한 코드 중에 작성자의 닉네임을 가져오는 코드가 있는데 이를 보면 N+1의 문제가 발생함을 알 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;postRepository.findAll()&amp;nbsp; &amp;rarr;&amp;nbsp;&lt;/b&gt;&lt;span style=&quot;background-color: #c0d1e7;&quot;&gt;SELECT * FROM post&lt;/span&gt; 로 Post 전체 조회 (&lt;b&gt;1&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;.map(post -&amp;gt; post.getUser()&amp;nbsp; &amp;rarr; &lt;/b&gt;각 Post마다 &lt;span style=&quot;background-color: #c0d1e7;&quot;&gt;SELECT * FROM users WHERE user_id=?&lt;/span&gt; 조회 (&lt;b&gt;N&lt;/b&gt;)&lt;br /&gt;&lt;b&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.getNickname())&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1778084009730&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;postRepository.findAll()      
    .stream()
    .map(post -&amp;gt; post.getUser() 
                .getNickname())&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;해결 방안&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Fetch Join (outer join fetching)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL의 JOIN을 사용해 연관 데이터를&amp;nbsp;&lt;b&gt;처음부터 한 번에&amp;nbsp;&lt;/b&gt;가져오는 방법&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1이 발생하는 이유는 post를 먼저 가져오고, user를 나중에 개별 조회하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, Fetch Join은 처음부터 post와 user를 JOIN해서 한 번의 쿼리로 가져오기 때문에 추가 쿼리가 발생하지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778084502850&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM post 
JOIN users ON users.user_id = post.user_id&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1778084517827&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(&quot;SELECT p FROM Post p JOIN FETCH p.user&quot;)
List&amp;lt;Post&amp;gt; findAllWithUser();&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;2. Batch Fetching&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관 데이터를 개별 쿼리가 아닌 &lt;b&gt;IN절로 묶어 한 번에&lt;/b&gt; 가져오는 방법&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 user를 한 명씩 개별 조회했다면, Batch Fetching은 필요한 user의 id를 모아서 IN절로 한 번에 조회한다.&lt;/p&gt;
&lt;pre id=&quot;code_1778084622402&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE user_id IN (1, 2, 3, ...)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;batch_fetch_size: 100은 한 번에 최대 100개의 id를 IN절에 묶어 조회한다는 의미이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 250개라면 100개씩 나눠 총 3번에 걸쳐 실행된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778084642345&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;3. Subselect Fetching&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관 데이터를&amp;nbsp;&lt;b&gt;서브 쿼리&lt;/b&gt;로 한 번에 가져오는 방법&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IN절에 직접 id를 넣는 대신, 처음 실행한 쿼리를 서브 쿼리로 재사용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 post를 조회한 쿼리를 서브 쿼리로 재활용해 관련된 user를 한 번에 가져오기 때문에 추가 쿼리가 1번만 발생한다.&lt;/p&gt;
&lt;pre id=&quot;code_1778084810177&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM post
SELECT * FROM users 
WHERE user_id IN (SELECT user_id FROM post)&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;엔티티에 직접 SUBSELECT를 설정한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;size 제한 없이 전체를 한 번에 처리하지만, 서브 쿼리가 복잡할수록 성능이 떨어질 수 있다는 단점이 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778084822305&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@ManyToOne
@Fetch(FetchMode.SUBSELECT)
private User user;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://itconquest.tistory.com/entry/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://itconquest.tistory.com/entry/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778057718648&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Spring Boot] 페이징 기능 구현하기&quot; data-og-description=&quot;페이징 구현하기스프링 부트에서 페이징(Paging)을 구현하는 방법은 Spring Data JPA의 Pageable과 Page 인터페이스를 활용하는 것이다.&amp;nbsp;Page 인터페이스를 사용하면 페이징 정보를 쉽게 가져올 수 있다. g&quot; data-og-host=&quot;itconquest.tistory.com&quot; data-og-source-url=&quot;https://itconquest.tistory.com/entry/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://itconquest.tistory.com/entry/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/szi8o/dJMb9gxpn6o/zkLkMmOYElN3pNCJR8f9A0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cEEdFf/dJMb9gxpn6n/smPNb9IIKdfocHvdb9MaWK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/O0haV/dJMb9eTTzB5/xdVMjNWIeV0N4YLk4Tmkak/img.jpg?width=400&amp;amp;height=223&amp;amp;face=0_0_400_223&quot;&gt;&lt;a href=&quot;https://itconquest.tistory.com/entry/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://itconquest.tistory.com/entry/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/szi8o/dJMb9gxpn6o/zkLkMmOYElN3pNCJR8f9A0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cEEdFf/dJMb9gxpn6n/smPNb9IIKdfocHvdb9MaWK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/O0haV/dJMb9eTTzB5/xdVMjNWIeV0N4YLk4Tmkak/img.jpg?width=400&amp;amp;height=223&amp;amp;face=0_0_400_223');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Spring Boot] 페이징 기능 구현하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;페이징 구현하기스프링 부트에서 페이징(Paging)을 구현하는 방법은 Spring Data JPA의 Pageable과 Page 인터페이스를 활용하는 것이다.&amp;nbsp;Page 인터페이스를 사용하면 페이징 정보를 쉽게 가져올 수 있다. g&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;itconquest.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@joonghyun/Springboot-QueryDSL%EC%9D%98-%EC%9D%B4%ED%95%B4%EC%99%80-QueryDSL%EB%A1%9C-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@joonghyun/Springboot-QueryDSL%EC%9D%98-%EC%9D%B4%ED%95%B4%EC%99%80-QueryDSL%EB%A1%9C-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778063096857&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Springboot] QueryDSL의 이해와 동적쿼리 적용&quot; data-og-description=&quot;Spring Data JPA의 가장 큰 장점은 간편함입니다. 기본적인 CRUD 메서드, 쿼리 메서드를 사용해서 엔티티의 필드와 연관된 데이터를 쉽게 가져올 수 있습니다. &amp;gt;기본적인 CRUD 메서드는 굳이 Repository에&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@joonghyun/Springboot-QueryDSL%EC%9D%98-%EC%9D%B4%ED%95%B4%EC%99%80-QueryDSL%EB%A1%9C-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://velog.io/@joonghyun/Springboot-QueryDSL의-이해와-QueryDSL로-동적쿼리-구현하기&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/D2NO5/dJMb9kmgGyp/pLWk2UzT7LI71WEGnhB6lk/img.png?width=950&amp;amp;height=500&amp;amp;face=0_0_950_500,https://scrap.kakaocdn.net/dn/H7gyO/dJMb9lla7mf/gFpt8xJS4NUfCamXqV12fk/img.png?width=2400&amp;amp;height=2400&amp;amp;face=0_0_2400_2400,https://scrap.kakaocdn.net/dn/dKpWLs/dJMb87gatJK/9IxYDkUHowq310kapAOCEk/img.png?width=628&amp;amp;height=890&amp;amp;face=0_0_628_890&quot;&gt;&lt;a href=&quot;https://velog.io/@joonghyun/Springboot-QueryDSL%EC%9D%98-%EC%9D%B4%ED%95%B4%EC%99%80-QueryDSL%EB%A1%9C-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@joonghyun/Springboot-QueryDSL%EC%9D%98-%EC%9D%B4%ED%95%B4%EC%99%80-QueryDSL%EB%A1%9C-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/D2NO5/dJMb9kmgGyp/pLWk2UzT7LI71WEGnhB6lk/img.png?width=950&amp;amp;height=500&amp;amp;face=0_0_950_500,https://scrap.kakaocdn.net/dn/H7gyO/dJMb9lla7mf/gFpt8xJS4NUfCamXqV12fk/img.png?width=2400&amp;amp;height=2400&amp;amp;face=0_0_2400_2400,https://scrap.kakaocdn.net/dn/dKpWLs/dJMb87gatJK/9IxYDkUHowq310kapAOCEk/img.png?width=628&amp;amp;height=890&amp;amp;face=0_0_628_890');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Springboot] QueryDSL의 이해와 동적쿼리 적용&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Spring Data JPA의 가장 큰 장점은 간편함입니다. 기본적인 CRUD 메서드, 쿼리 메서드를 사용해서 엔티티의 필드와 연관된 데이터를 쉽게 가져올 수 있습니다. &amp;gt;기본적인 CRUD 메서드는 굳이 Repository에&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ksh-coding.tistory.com/146#0.%20%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0%20%EC%A0%84-1&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ksh-coding.tistory.com/146#0.%20%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0%20%EC%A0%84-1&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778084896020&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JPA] JPA N+1 문제 및 근본적인 원인에 대한 개인적인 고찰&quot; data-og-description=&quot;0. 들어가기 전JPA를 사용하면서 발생하는 N+1 문제는 널리 알려져 있고, JPA를 사용하다보면 제법 자주 만나게 됩니다.그래서 N+1 문제를 다룬 블로그나 다른 레퍼런스들이 상당히 많습니다.저 또&quot; data-og-host=&quot;ksh-coding.tistory.com&quot; data-og-source-url=&quot;https://ksh-coding.tistory.com/146#0.%20%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0%20%EC%A0%84-1&quot; data-og-url=&quot;https://ksh-coding.tistory.com/146&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bID962/dJMb84qcFZo/iXI8rHe2IMGiMBNWUkmnak/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/carwJI/dJMb9hC5fWm/fMNkMTIbOFk3ymS26T40DK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dIcVvN/dJMb83kwY18/6P5z9QkbroDebOV97kbp80/img.png?width=1332&amp;amp;height=448&amp;amp;face=0_0_1332_448&quot;&gt;&lt;a href=&quot;https://ksh-coding.tistory.com/146#0.%20%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0%20%EC%A0%84-1&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ksh-coding.tistory.com/146#0.%20%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0%20%EC%A0%84-1&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bID962/dJMb84qcFZo/iXI8rHe2IMGiMBNWUkmnak/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/carwJI/dJMb9hC5fWm/fMNkMTIbOFk3ymS26T40DK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dIcVvN/dJMb83kwY18/6P5z9QkbroDebOV97kbp80/img.png?width=1332&amp;amp;height=448&amp;amp;face=0_0_1332_448');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JPA] JPA N+1 문제 및 근본적인 원인에 대한 개인적인 고찰&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;0. 들어가기 전JPA를 사용하면서 발생하는 N+1 문제는 널리 알려져 있고, JPA를 사용하다보면 제법 자주 만나게 됩니다.그래서 N+1 문제를 다룬 블로그나 다른 레퍼런스들이 상당히 많습니다.저 또&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ksh-coding.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Springboot</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/67</guid>
      <comments>https://coding-cherry.tistory.com/67#entry67comment</comments>
      <pubDate>Thu, 7 May 2026 01:28:26 +0900</pubDate>
    </item>
    <item>
      <title>[SWEA] 26504. MST 만들기</title>
      <link>https://coding-cherry.tistory.com/66</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AZz7FPpqAUnHBIRj&amp;amp;categoryId=AZz7FPpqAUnHBIRj&amp;amp;categoryType=CODE&amp;amp;problemTitle=&amp;amp;orderBy=FIRST_REG_DATETIME&amp;amp;selectCodeLang=ALL&amp;amp;select-1=&amp;amp;pageSize=10&amp;amp;pageIndex=1&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AZz7FPpqAUnHBIRj&amp;amp;categoryId=AZz7FPpqAUnHBIRj&amp;amp;categoryType=CODE&amp;amp;problemTitle=&amp;amp;orderBy=FIRST_REG_DATETIME&amp;amp;selectCodeLang=ALL&amp;amp;select-1=&amp;amp;pageSize=10&amp;amp;pageIndex=1&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777998683235&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;SW Expert Academy&quot; data-og-description=&quot;SW 프로그래밍 역량 강화에 도움이 되는 다양한 학습 컨텐츠를 확인하세요!&quot; data-og-host=&quot;swexpertacademy.com&quot; data-og-source-url=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AZz7FPpqAUnHBIRj&amp;amp;categoryId=AZz7FPpqAUnHBIRj&amp;amp;categoryType=CODE&amp;amp;problemTitle=&amp;amp;orderBy=FIRST_REG_DATETIME&amp;amp;selectCodeLang=ALL&amp;amp;select-1=&amp;amp;pageSize=10&amp;amp;pageIndex=1&quot; data-og-url=&quot;https://swexpertacademy.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cEx9NY/dJMb9hC47Mv/7AYb9thYKSKUefIK8rSKKK/img.png?width=600&amp;amp;height=315&amp;amp;face=0_0_600_315,https://scrap.kakaocdn.net/dn/bQu0dx/dJMb9efhQp5/1rc8EgkKvgT5QPZOR0VAD0/img.png?width=3378&amp;amp;height=3378&amp;amp;face=0_0_3378_3378,https://scrap.kakaocdn.net/dn/CMROm/dJMb9lMflhT/LHctjTB1Mc13mxrSKCPGm1/img.png?width=320&amp;amp;height=320&amp;amp;face=0_0_320_320&quot;&gt;&lt;a href=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AZz7FPpqAUnHBIRj&amp;amp;categoryId=AZz7FPpqAUnHBIRj&amp;amp;categoryType=CODE&amp;amp;problemTitle=&amp;amp;orderBy=FIRST_REG_DATETIME&amp;amp;selectCodeLang=ALL&amp;amp;select-1=&amp;amp;pageSize=10&amp;amp;pageIndex=1&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AZz7FPpqAUnHBIRj&amp;amp;categoryId=AZz7FPpqAUnHBIRj&amp;amp;categoryType=CODE&amp;amp;problemTitle=&amp;amp;orderBy=FIRST_REG_DATETIME&amp;amp;selectCodeLang=ALL&amp;amp;select-1=&amp;amp;pageSize=10&amp;amp;pageIndex=1&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cEx9NY/dJMb9hC47Mv/7AYb9thYKSKUefIK8rSKKK/img.png?width=600&amp;amp;height=315&amp;amp;face=0_0_600_315,https://scrap.kakaocdn.net/dn/bQu0dx/dJMb9efhQp5/1rc8EgkKvgT5QPZOR0VAD0/img.png?width=3378&amp;amp;height=3378&amp;amp;face=0_0_3378_3378,https://scrap.kakaocdn.net/dn/CMROm/dJMb9lMflhT/LHctjTB1Mc13mxrSKCPGm1/img.png?width=320&amp;amp;height=320&amp;amp;face=0_0_320_320');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;SW Expert Academy&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SW 프로그래밍 역량 강화에 도움이 되는 다양한 학습 컨텐츠를 확인하세요!&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;swexpertacademy.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1778001531247&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;T = int(input())

for _ in range(T):
    N = int(input())
    costs = list(map(int, input().split()))
    
    costs.sort()
    
    min_mst = sum(costs[:N-1])
    
    max_mst = 0
    for i in range(N-1):
        index = (i * (i + 1)) // 2
        max_mst += costs[index]
    
    print(min_mst, max_mst)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;MST(Minimum Spanning Tree, 최소 신장 트리)&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Spanning Tree(신장 트리)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래프의 &lt;b&gt;모든 정점을 포함&lt;/b&gt;하면서, &lt;b&gt;사이클이 없는&lt;/b&gt; 부분 그래프&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;간선의 수 = 정점의 수 - 1&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MST(Minimum Spanning Tree, 최소 신장 트리)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가능한 모든 신장 트리 중에서 &lt;b&gt;간선 가중치의 합이 최소&lt;/b&gt;인 트리&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000;&quot; data-ke-size=&quot;size20&quot;&gt;최소 비용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가중치를 오름차순으로 정렬 후, 간선의 개수인 n-1 개만큼 선택하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Star 구조로 배치한다고 생각하면 쉽다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGnqQ7/dJMcaaFdKkK/4KUjNzfKd6pCLozKDkO081/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGnqQ7/dJMcaaFdKkK/4KUjNzfKd6pCLozKDkO081/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGnqQ7/dJMcaaFdKkK/4KUjNzfKd6pCLozKDkO081/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGnqQ7%2FdJMcaaFdKkK%2F4KUjNzfKd6pCLozKDkO081%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;529&quot; height=&quot;362&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;438&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000;&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;p data-ke-size=&quot;size16&quot;&gt;말이 어려운데, 그림으로 표현해보면,&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;625&quot; data-origin-height=&quot;459&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/G97Y2/dJMcaiDcXCb/FInslbxFfAoU9aT8M9PIvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/G97Y2/dJMcaiDcXCb/FInslbxFfAoU9aT8M9PIvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/G97Y2/dJMcaiDcXCb/FInslbxFfAoU9aT8M9PIvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FG97Y2%2FdJMcaiDcXCb%2FFInslbxFfAoU9aT8M9PIvK%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;493&quot; height=&quot;362&quot; data-origin-width=&quot;625&quot; data-origin-height=&quot;459&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3개의 노드가 2개의 간선으로 연결된 상황에서 4번째 노드를 추가하려 할 때, 최소 비용처럼 계산하지 않고&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;435&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjaiU7/dJMcaf0L1gN/A4sZIOPlqCIuwJaBpEtGek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjaiU7/dJMcaf0L1gN/A4sZIOPlqCIuwJaBpEtGek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjaiU7/dJMcaf0L1gN/A4sZIOPlqCIuwJaBpEtGek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjaiU7%2FdJMcaf0L1gN%2FA4sZIOPlqCIuwJaBpEtGek%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;583&quot; height=&quot;358&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;435&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 노드의 개수가 4개가 아닌 5개라고 가정한다면, 아래와 같은 모양이 나타날 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TOnmC/dJMcaaSKgWy/K57jSJk1vRoNEqs2VoTLek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TOnmC/dJMcaaSKgWy/K57jSJk1vRoNEqs2VoTLek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TOnmC/dJMcaaSKgWy/K57jSJk1vRoNEqs2VoTLek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTOnmC%2FdJMcaaSKgWy%2FK57jSJk1vRoNEqs2VoTLek%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;601&quot; height=&quot;366&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;432&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지의 간선 패턴을 보면 정점을 하나씩 추가할 때마다 이와 같은 패턴을 보이는데,&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778049695292&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;i=0 (1번째 간선): 
  - 사용 가능한 간선 범위: 0개
  - 인덱스 = 0

i=1 (2번째 간선):
  - 사용 가능한 간선 범위: 0~1 (2개)
  - 인덱스 = 1

i=2 (3번째 간선):
  - 사용 가능한 간선 범위: 0~3 (4개)
  - 하지만 0,1은 이미 썼고, 2는 사이클
  - 인덱스 = 3

i=3 (4번째 간선):
  - 사용 가능한 간선 범위: 0~6 (7개)
  - 하지만 0,1,3은 이미 썼고, 2,4,5는 사이클
  - 인덱스 = 6&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 공식화하면&lt;span style=&quot;color: #ef5369;&quot;&gt; &lt;b&gt;index = i * (i + 1) // 2&lt;/b&gt;&lt;/span&gt; 로 나타낼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, i가 증가할 때마다 해당 인덱스 값을 더해주면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 풀이로 아래 블로그의 도움을 많이 받았다. &amp;darr;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://positecoding.tistory.com/127&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://positecoding.tistory.com/127&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778001553010&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;SWEA 26504. MST 만들기&quot; data-og-description=&quot;N개의 노드에 대해서 방향성 없는 완전 그래프에서 N(N-1)/2개의 간선의 가중치만 주어질 때 MST의 최소 비용과 최대비용을 구하는 문제이다. 최소 비용은 노드들을 연결할 때 가중치가 작은 간선&quot; data-og-host=&quot;positecoding.tistory.com&quot; data-og-source-url=&quot;https://positecoding.tistory.com/127&quot; data-og-url=&quot;https://positecoding.tistory.com/127&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bWCoTw/dJMb9eTTtWA/EFt2VEtjjNBxSiugesteu1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/XCekY/dJMb9jOqMBE/W8pMN2rvqjT0WgK7kOqGlK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://positecoding.tistory.com/127&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://positecoding.tistory.com/127&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bWCoTw/dJMb9eTTtWA/EFt2VEtjjNBxSiugesteu1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/XCekY/dJMb9jOqMBE/W8pMN2rvqjT0WgK7kOqGlK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;SWEA 26504. MST 만들기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;N개의 노드에 대해서 방향성 없는 완전 그래프에서 N(N-1)/2개의 간선의 가중치만 주어질 때 MST의 최소 비용과 최대비용을 구하는 문제이다. 최소 비용은 노드들을 연결할 때 가중치가 작은 간선&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;positecoding.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;</description>
      <category>Python</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/66</guid>
      <comments>https://coding-cherry.tistory.com/66#entry66comment</comments>
      <pubDate>Wed, 6 May 2026 15:44:41 +0900</pubDate>
    </item>
    <item>
      <title>[SWEA] 372. 가능한 시험 점수</title>
      <link>https://coding-cherry.tistory.com/65</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AWHPkqBqAEsDFAUn&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AWHPkqBqAEsDFAUn&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777997344228&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;SW Expert Academy&quot; data-og-description=&quot;SW 프로그래밍 역량 강화에 도움이 되는 다양한 학습 컨텐츠를 확인하세요!&quot; data-og-host=&quot;swexpertacademy.com&quot; data-og-source-url=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AWHPkqBqAEsDFAUn&quot; data-og-url=&quot;https://swexpertacademy.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bZrjT6/dJMb89yhtz0/RoOpxrHLSRywGtxkqcI0XK/img.png?width=600&amp;amp;height=315&amp;amp;face=0_0_600_315,https://scrap.kakaocdn.net/dn/bJMAXh/dJMb9lla0va/5oLW3HJreX2ABN2DTERl0k/img.png?width=3378&amp;amp;height=3378&amp;amp;face=0_0_3378_3378,https://scrap.kakaocdn.net/dn/J8V67/dJMb9iaUZWn/AE3If3iC38lkDR40Cd6kx1/img.png?width=320&amp;amp;height=320&amp;amp;face=0_0_320_320&quot;&gt;&lt;a href=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AWHPkqBqAEsDFAUn&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AWHPkqBqAEsDFAUn&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bZrjT6/dJMb89yhtz0/RoOpxrHLSRywGtxkqcI0XK/img.png?width=600&amp;amp;height=315&amp;amp;face=0_0_600_315,https://scrap.kakaocdn.net/dn/bJMAXh/dJMb9lla0va/5oLW3HJreX2ABN2DTERl0k/img.png?width=3378&amp;amp;height=3378&amp;amp;face=0_0_3378_3378,https://scrap.kakaocdn.net/dn/J8V67/dJMb9iaUZWn/AE3If3iC38lkDR40Cd6kx1/img.png?width=320&amp;amp;height=320&amp;amp;face=0_0_320_320');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;SW Expert Academy&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SW 프로그래밍 역량 강화에 도움이 되는 다양한 학습 컨텐츠를 확인하세요!&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;swexpertacademy.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1777996961781&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;T = int(input())

for test_case in range(1, T + 1):
    n = int(input()) 
    scores = list(map(int, input().split()))  
    
    possible = {0} 
    
    for score in scores: 
        new_scores = set()
        
        for prev_score in possible:
        	new_scores.add(prev_score + score)
            
        possible = possible | new_scores
        
    print(f&quot;#{test_case} {len(possible)}&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;문제는&amp;nbsp;&lt;b&gt;DP(동적&amp;nbsp;프로그래밍)&lt;/b&gt;로&amp;nbsp;해결할&amp;nbsp;수&amp;nbsp;있다. &lt;br /&gt;&lt;br /&gt;DP[k]를&amp;nbsp;&quot;k개&amp;nbsp;문제로&amp;nbsp;만들&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;점수&amp;nbsp;집합&quot;이라고&amp;nbsp;정의하면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;DP[k+1]은&amp;nbsp;다음&amp;nbsp;두&amp;nbsp;가지&amp;nbsp;경우를&amp;nbsp;합친&amp;nbsp;것이다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DP[k+1]&amp;nbsp;=&amp;nbsp;DP[k]&amp;nbsp;&amp;cup;&amp;nbsp;{p&amp;nbsp;+&amp;nbsp;score[k+1]&amp;nbsp;|&amp;nbsp;p&amp;nbsp;&amp;isin;&amp;nbsp;DP[k]}&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;1)&amp;nbsp;(k+1)번째&amp;nbsp;문제를&amp;nbsp;틀린&amp;nbsp;경우&amp;nbsp;&amp;rarr;&amp;nbsp;DP[k]&amp;nbsp;그대로 &lt;br /&gt;2) (k+1)번째 문제를 맞춘 경우 &amp;rarr; DP[k]의 각 점수에 배점을 더함&lt;br /&gt;&lt;br /&gt;같은 점수를 여러 방법으로 만들 수 있으므로,&amp;nbsp;중복을&amp;nbsp;자동으로&amp;nbsp;제거하는&amp;nbsp;&lt;b&gt;SET&amp;nbsp;&lt;/b&gt;자료구조를&amp;nbsp;사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Python</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/65</guid>
      <comments>https://coding-cherry.tistory.com/65#entry65comment</comments>
      <pubDate>Wed, 6 May 2026 01:14:13 +0900</pubDate>
    </item>
    <item>
      <title>폴더 구조와 아키텍처: 프로젝트 구조 개선</title>
      <link>https://coding-cherry.tistory.com/64</link>
      <description>&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;145&quot; data-origin-height=&quot;459&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1rxD9/dJMcadhz8At/lHD1NWNWfZTUGxj9SskOz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1rxD9/dJMcadhz8At/lHD1NWNWfZTUGxj9SskOz1/img.png&quot; data-alt=&quot;나의 프로젝트 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1rxD9/dJMcadhz8At/lHD1NWNWfZTUGxj9SskOz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1rxD9%2FdJMcadhz8At%2FlHD1NWNWfZTUGxj9SskOz1%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;145&quot; height=&quot;459&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;145&quot; data-origin-height=&quot;459&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;나의 프로젝트 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 프로젝트를 처음 시작할 때, 파일 구조 같은 건 신경 쓰지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 구현에만 중점을 두고, '일단 되게 만들자'가 목표였으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 프로젝트가 커지면서 기존 파일들조차 찾기 힘들어지는 경우가 많았고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 한번 추가하려고 하면 components, hooks, utils, api 폴더를 전부 뒤져야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련 파일이 여러 폴더에 흩어져 있으니 수정할 때마다 탐색기를 끝없이 스크롤 했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트를 해보면서 팀원마다 각자 본인만의 기준으로 파일을 배치해 팀원들의 코드를 찾기 힘들 때도 많았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일을 빨리 찾거나 추가할 수 있으며, 누가 보더라도 직관적으로 이해되는 프로젝트 구조를 만들 수 있을까?&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD(Feature-Sliced Design) Architecture&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 애플리케이션을 구조화하는 아키텍처 방법론&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;애플리케이션을 기능 단위로 묶어&lt;/b&gt; 프로젝트를 더 이해하기 쉽고 구조적으로 만들어, 규모가 커져도 유지보수가 쉽다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777809277508&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;src/
├── features/
│   ├── send-message/    // 메시지 보내기 기능만
│   ├── edit-message/    // 메시지 수정 기능만
│   └── user-search/     // 사용자 검색 기능만
├── entities/
│   ├── message/         // 메시지 엔티티
│   └── user/            // 유저 엔티티
└── shared/
    └── ui/              // 공통 UI&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;FSD의 3가지 핵심 구조&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 &lt;b&gt;Layers(레이어) &amp;rarr; Slices(슬라이스) &amp;rarr; Segments(세그먼트)&lt;/b&gt; 3단계로 구성된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;712&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdzRFq/dJMcabjNERg/B9sd20sroFQXktaT1bLAjk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdzRFq/dJMcabjNERg/B9sd20sroFQXktaT1bLAjk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdzRFq/dJMcabjNERg/B9sd20sroFQXktaT1bLAjk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdzRFq%2FdJMcabjNERg%2FB9sd20sroFQXktaT1bLAjk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;745&quot; height=&quot;414&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;712&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;pre id=&quot;code_1777809688758&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  features (레이어)
    send-message (슬라이스)
      ui (세그먼트)
      └── MessageInput.tsx
      model (세그먼트)
      └── useSendMessage.ts
      api (세그먼트)
      └── sendMessageApi.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Layers: 표준화된 7개 계층&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 processes 레이어가 사용되지 않으므로 6개의 레이어만이 사용된다. &lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 68.8372%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;&lt;b&gt;레이어&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;app&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;앱 실행에 필요한 전역 설정&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;라우터, 프로바이더, 전역 스타일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;pages&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;라우트 페이지&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;HomePage, ArticlePage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;widgets&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;페이지를 구성하는 큰 UI 블록&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;Header, Sidebar, Footer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;features&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;사용자가 할 수 있는 행동/기능&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;좋아요 누르기, 댓글 작성, 로그인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;entities&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;비즈니스 엔티티&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;User, Post, Comment, Message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4031%;&quot;&gt;shared&lt;/td&gt;
&lt;td style=&quot;width: 26.3565%;&quot;&gt;재사용 가능한 공통 코드&lt;/td&gt;
&lt;td style=&quot;width: 30.0775%;&quot;&gt;UI 컴포넌트, utils, API 클라이언트&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Slices: 기능 단위 분리&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 레이어 안에서 기능/도메인 별로 나눈 폴더&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777810334025&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;features/              // 레이어
├── send-message/      // 슬라이스 1
├── edit-message/      // 슬라이스 2
└── delete-message/    // 슬라이스 3

entities/              // 레이어
├── user/              // 슬라이스 1
├── message/           // 슬라이스 2
└── chatroom/          // 슬라이스 3&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;슬라이스 이름은 자유롭게 선택 가능&lt;/li&gt;
&lt;li&gt;같은 레이어의 슬라이스끼리&amp;nbsp;&lt;b&gt;서로 참조 불가&amp;nbsp;&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;b&gt;Segments: 기술적 분류&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬라이스 안에서 코드의 목적별로 나눈 폴더 &lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;b&gt;세그먼트&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;b&gt;용도&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;ui/&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;React 컴포넌트, 스타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;MessageInput.tsx, Button.module.css&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;model/&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;상태 관리, 비즈니스 로직, 타입&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;useMessageStore.ts, types.ts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;api/&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;서버 통신 함수&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;fetchMessages.ts, sendMessage.ts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;lib/&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;슬라이스 내부 헬퍼 함수&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;formatDate.ts, validate.ts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;config/&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;설정, 상수&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;constants.ts, endpoints.ts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre id=&quot;code_1777810518211&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;features/send-message/
├── ui/
│   └── MessageInput.tsx       // 메시지 입력 컴포넌트
├── model/
│   ├── useSendMessage.ts      // 메시지 전송 로직
│   └── types.ts               // 타입 정의
├── api/
│   └── sendMessageApi.ts      // API 호출
└── index.ts                   // Public API&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;의존성 방향 규칙(단방향 의존성): &lt;/b&gt;각 레이어는 자기보다 아래에 있는 레이어만 참조(import) 할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하위 레이어는 재사용 가능하게 독립적으로 유지&lt;/li&gt;
&lt;li&gt;순환 참조(Circular Dependency) 방지&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1777810190204&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;app
 &amp;darr; (참조 가능)
pages
 &amp;darr;
widgets
 &amp;darr;
features
 &amp;darr;
entities
 &amp;darr;
shared&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;Public API 패턴: index.ts 의 역할&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 모듈에 외부에 공개할 인터페이스를 명시적으로 정의하는 패턴&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부에서 모듈을 사용할 때는 내부 경로를 직접 참조하지 않고 Public API를 통해서만 접근한다.&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;FSD에서는 일반적으로 각 슬라이스나 세그먼트의 최상위에 index.ts 파일을 두어 이 역할을 수행한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;*index.ts는 폴더의 대표 파일로 폴더 import 시, 자동으로 불러와지므로 index.ts에서 export 한 것만 외부에서 사용 가능&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777811066757&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;features/
├── send-message/
│   ├── ui/MessageInput.tsx
│   ├── model/useSendMessage.ts
│   ├── lib/validate.ts
│   └── index.ts              // 슬라이스 레벨
└── edit-message/
    ├── ui/EditModal.tsx
    └── index.ts              // 슬라이스 레벨&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777811080393&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// features/send-message/index.ts
export { MessageInput } from './ui/MessageInput';
export { useSendMessage } from './model/useSendMessage';
export type { SendMessageParams } from './model/types';&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;JS/TS에서는 &lt;b&gt;폴더를 import하면 자동으로 그 폴더의 index.ts를 찾아서 실행&lt;/b&gt;하므로,&amp;nbsp;사용할 때는 아래와 같이 적어주면 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777811150383&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { MessageInput, useSendMessage } from '@/features/send-message';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 슬라이스가 없는 shared나 app 레이어는 각 세그먼트마다 index.ts를 두는 방식을 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내부 구조 자유롭게 변경 가능&lt;/li&gt;
&lt;li&gt;외부에서 뭘 써야 하는지(공개할 것만) 설정 가능&amp;nbsp;&lt;/li&gt;
&lt;li&gt;번들 사이즈 최적화&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 프로젝트에 적용해보기&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 React 스터디 과제로 진행 중인 프로젝트에 FSD를 적용해보면서, 실제로 어떻게 달라지는지 확인해보려 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변경 전&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;293&quot; data-origin-height=&quot;817&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SLusJ/dJMcajougaD/OUklVNMGQnPD2wqURu97e1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SLusJ/dJMcajougaD/OUklVNMGQnPD2wqURu97e1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SLusJ/dJMcajougaD/OUklVNMGQnPD2wqURu97e1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSLusJ%2FdJMcajougaD%2FOUklVNMGQnPD2wqURu97e1%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;293&quot; height=&quot;817&quot; data-origin-width=&quot;293&quot; data-origin-height=&quot;817&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;적용 전, 그냥 page 따로, hook 따로 api 따로 대충 비슷한 기능을 하는 애들끼리 묶어서 만든 구조이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일이 적어서 찾기 쉽지만 파일이 늘어나고 프로젝트 구조가 커진다면 원하는 파일 찾는 데만 한 세월이 걸릴 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변경 후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;338&quot; data-origin-height=&quot;853&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCdwlz/dJMcai4cv71/uRRMfoy3r6W5XYk4lL2FT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCdwlz/dJMcai4cv71/uRRMfoy3r6W5XYk4lL2FT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCdwlz/dJMcai4cv71/uRRMfoy3r6W5XYk4lL2FT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCdwlz%2FdJMcai4cv71%2FuRRMfoy3r6W5XYk4lL2FT1%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;338&quot; height=&quot;853&quot; data-origin-width=&quot;338&quot; data-origin-height=&quot;853&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1777813702713&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;src/
├── main.tsx
├── assets/
│
├── app/
│   ├── providers/index.tsx        (QueryClient, ErrorBoundary)
│   ├── store/index.ts             (Redux store 조립)
│   ├── router/index.tsx           (라우트 정의)
│   └── styles/index.css
│
├── pages/
│   ├── main/
│   ├── redux-demo/
│   ├── zustand-demo/
│   ├── context-api-demo/
│   └── tanstack-query-demo/
│
├── features/
│   ├── todo-manager/
│   ├── product-filter/
│   ├── settings-panel/
│   └── movie-browser/
│       ├── model/
│       │   ├── movieKeys.ts
│       │   ├── useGetMovies.ts
│       │   ├── useGetInfiniteMovies.ts
│       │   └── useGetMovieDetail.ts
│       └── ui/
│           ├── MovieDetailModal.tsx
│           └── ModalSkeleton.tsx   
│
├── entities/
│   └── movie/
│       ├── api/
│       │   └── moviesApi.ts       
│       ├── model/
│       │   └── types.ts            &amp;larr; 실제 타입 정의
│       ├── ui/
│       │   └── MovieCard.tsx
│       └── index.ts
│
└── shared/
    ├── api/
    │   ├── instance.ts             &amp;larr; HTTP 인프라만
    │   ├── errors.ts
    │   └── index.ts
    └── ui/
        └── ModalError/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전과 후 비교를 간단히 설명해보자면,&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. main.tsx가 얇아졌다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전, main.tsx에는 QueryClient 생성, GlobalError 컴포넌트, ErrorBoundary 설정이 모두 뭉쳐 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 후, main.tsx는 진입점 역할만 하고, 이런 전역 설정들은 app/ 레이어로 분리해 역할을 명확하게 나눴다.&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. Redux store 조립이 app 레이어로 이동했다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전, &lt;span style=&quot;color: #333333;&quot;&gt;src/store/store.ts&lt;span style=&quot;text-align: start;&quot;&gt;가 &lt;/span&gt;todoSlice&lt;span style=&quot;text-align: start;&quot;&gt;를 직접 가져와서 store를 구성했다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;변경 후, Redux store는 전체 앱에 영향을 주는 전역 설정이므로 app/ 레이어로 옮겼다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;slice 로직은 각 feature 슬라이스에 두고, store는 feature들의 reducer를 가져와 조립만 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777814757353&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// app/store/index.ts
import { todoReducer } from '@/features/todo-manager'

export const store = configureStore({
  reducer: { todo: todoReducer },
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 관련 코드가 한 곳에 모였다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전에는 영화 기능 하나를 수정하려면 이 모든 폴더들을 모두 뒤져야 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777814196726&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;src/components/MovieDetailModal.tsx
src/hooks/queries/useGetInfiniteMovies.ts
src/hooks/queries/movieKeys.ts
src/api/movies.ts
src/types/movie.ts&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;변경 후에는 features/movie-browser/ 한 곳에서 시작하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 수정하거나 삭제할 때, 어느 파일을 봐야 하는지 고민할 필요가 없다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777814221536&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;src/features/movie-browser/
├── model/
│   ├── movieKeys.ts
│   ├── useGetMovies.ts
│   ├── useGetInfiniteMovies.ts
│   └── useGetMovieDetail.ts
└── ui/
    └── MovieDetailModal.tsx
    └── ModalSkeleton.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. index.ts가 문지기 역할을 한다 (Public API)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전에는 내부 경로를 직접 참조해 파일 이동 시 모든 import가 깨지는 문제가 발생했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777814298261&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useGetMovieDetail } from '../hooks/queries/useGetMovieDetail'
import MovieCard from '../components/MovieCard'&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;변경 후에는 외부에서는 항상 index.ts를 통해서만 접근한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; 내부 구조를 아무리 바꿔도 index.ts의 export만 유지하면 외부 코드는 전혀 영향받지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777814315016&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { MovieDetailModal, useInfiniteNowPlayingMovies } from '@/features/movie-browser'
import { MovieCard } from '@/entities/movie'&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;+) shared/api/ 에는 인프라만 둔다&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;shared는 가장 아래 레이어이므로 다른 레이어를 참조할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 shared/api/에는 HTTP 요청의 기반이 되는 인프라 코드만 둔다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777816046924&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// shared/api/instance.ts
export const tmdbFetch = (endpoint: string) =&amp;gt;
  fetch(`${BASE_URL}${endpoint}`, { headers })

export const handleResponse = async (res: Response) =&amp;gt; { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영화 API 함수와 타입은 영화 도메인 코드이므로 entities/movie/가 담당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;entities가 shared를 참조하는 방향은 허용된 방향이므로 규칙을 지키면서 자연스럽게 분리할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1777816105821&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// entities/movie/api/moviesApi.ts
import { tmdbFetch, handleResponse } from '@/shared/api/instance'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://feature-sliced.design/docs/get-started/overview#incremental-adoption&quot;&gt;https://feature-sliced.design/docs/get-started/overview#incremental-adoption&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777809605594&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Overview | Feature-Sliced Design&quot; data-og-description=&quot;Feature-Sliced Design (FSD) is an architectural methodology for scaffolding front-end applications. Simply put, it's a compilation of rules and conventions on organizing code. The main purpose of this methodology is to make the project more understandable &quot; data-og-host=&quot;feature-sliced.design&quot; data-og-source-url=&quot;https://feature-sliced.design/docs/get-started/overview#incremental-adoption&quot; data-og-url=&quot;https://feature-sliced.design/docs/get-started/overview&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/tijEh/dJMb87N0bbj/d3VP3ubQKxPnOevG3GfzRK/img.png?width=1200&amp;amp;height=630&amp;amp;face=298_67_408_177,https://scrap.kakaocdn.net/dn/bK03Lz/dJMb9iIKDJh/MRUY8hdVAK3eC0FHLWHiC1/img.png?width=1200&amp;amp;height=630&amp;amp;face=298_67_408_177,https://scrap.kakaocdn.net/dn/ckMhAW/dJMb86O5nYA/Om8rKZxxKlUbHlEh017ZQ1/img.png?width=2920&amp;amp;height=1040&amp;amp;face=0_0_2920_1040&quot;&gt;&lt;a href=&quot;https://feature-sliced.design/docs/get-started/overview#incremental-adoption&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://feature-sliced.design/docs/get-started/overview#incremental-adoption&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/tijEh/dJMb87N0bbj/d3VP3ubQKxPnOevG3GfzRK/img.png?width=1200&amp;amp;height=630&amp;amp;face=298_67_408_177,https://scrap.kakaocdn.net/dn/bK03Lz/dJMb9iIKDJh/MRUY8hdVAK3eC0FHLWHiC1/img.png?width=1200&amp;amp;height=630&amp;amp;face=298_67_408_177,https://scrap.kakaocdn.net/dn/ckMhAW/dJMb86O5nYA/Om8rKZxxKlUbHlEh017ZQ1/img.png?width=2920&amp;amp;height=1040&amp;amp;face=0_0_2920_1040');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Overview | Feature-Sliced Design&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Feature-Sliced Design (FSD) is an architectural methodology for scaffolding front-end applications. Simply put, it's a compilation of rules and conventions on organizing code. The main purpose of this methodology is to make the project more understandable&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;feature-sliced.design&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@clydehan/FSDFeature-Sliced-Design-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@clydehan/FSDFeature-Sliced-Design-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777809601641&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;FSD(Feature-Sliced Design) 완벽 가이드&quot; data-og-description=&quot;프론트엔드 뜨거운 감자, FSD: 이 글만 읽으면 완벽하게 이해 할 수 있음.&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@clydehan/FSDFeature-Sliced-Design-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C&quot; data-og-url=&quot;https://velog.io/@clydehan/FSDFeature-Sliced-Design-완벽-가이드&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Hpewf/dJMb86n1esp/3ZEBNWJiaAi0jDx5zhTK71/img.png?width=701&amp;amp;height=387&amp;amp;face=0_0_701_387,https://scrap.kakaocdn.net/dn/BFkly/dJMb8QepOFM/BP25dKhlMjxExezUFwDtG1/img.png?width=701&amp;amp;height=387&amp;amp;face=0_0_701_387,https://scrap.kakaocdn.net/dn/bq1kNS/dJMb8XR9ndS/IQaV0nBDKeSQSKy6RMwbeK/img.jpg?width=4032&amp;amp;height=3024&amp;amp;face=0_0_4032_3024&quot;&gt;&lt;a href=&quot;https://velog.io/@clydehan/FSDFeature-Sliced-Design-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@clydehan/FSDFeature-Sliced-Design-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Hpewf/dJMb86n1esp/3ZEBNWJiaAi0jDx5zhTK71/img.png?width=701&amp;amp;height=387&amp;amp;face=0_0_701_387,https://scrap.kakaocdn.net/dn/BFkly/dJMb8QepOFM/BP25dKhlMjxExezUFwDtG1/img.png?width=701&amp;amp;height=387&amp;amp;face=0_0_701_387,https://scrap.kakaocdn.net/dn/bq1kNS/dJMb8XR9ndS/IQaV0nBDKeSQSKy6RMwbeK/img.jpg?width=4032&amp;amp;height=3024&amp;amp;face=0_0_4032_3024');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;FSD(Feature-Sliced Design) 완벽 가이드&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 뜨거운 감자, FSD: 이 글만 읽으면 완벽하게 이해 할 수 있음.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/64</guid>
      <comments>https://coding-cherry.tistory.com/64#entry64comment</comments>
      <pubDate>Sun, 3 May 2026 22:48:40 +0900</pubDate>
    </item>
    <item>
      <title>회원가입 및 로그인 구현</title>
      <link>https://coding-cherry.tistory.com/63</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&amp;nbsp;Spring Security&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 기반 애플리케이션에 인증(Authentication)과 인가(Authorization)기능을 제공하는 프레임워크&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt; 인증(Authentication)&amp;nbsp;&lt;/b&gt;사용자가 누구인지 확인하는 과정 (로그인 폼, OAuth2, JWT, LDAP 등 다양한 방식 지원)&lt;/li&gt;
&lt;li&gt;&lt;b&gt; 인가(Authorization)&amp;nbsp;&lt;/b&gt;인증된 사용자가 어떤 리소스에 접근할 수 있는지 제어, URL 기반, 메서드 기반으로 권한 설정 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt; 보안 공격 방어&amp;nbsp;&lt;/b&gt;CSRF, XSS, Session, Fixation 등 일반적인 보안 취약점을 기본으로 방어&amp;nbsp;&lt;/li&gt;
&lt;/ul&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;FilterChain&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;HTTP 요청이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;Controller에 도달하기 전 통과해야 하는 보안 필터들&lt;/span&gt;의 묶음&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;요청이 Controller에 도달하기 전에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;인증/인가/예외 처리&lt;/b&gt;가 전부 완료된 상태로 넘어오게 한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;559&quot; data-origin-height=&quot;468&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lMeF5/dJMcahxcp4t/zaWVjXaWsq7vkjKRe7cka1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lMeF5/dJMcahxcp4t/zaWVjXaWsq7vkjKRe7cka1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lMeF5/dJMcahxcp4t/zaWVjXaWsq7vkjKRe7cka1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlMeF5%2FdJMcahxcp4t%2FzaWVjXaWsq7vkjKRe7cka1%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;535&quot; height=&quot;448&quot; data-origin-width=&quot;559&quot; data-origin-height=&quot;468&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3OWOS/dJMcabjrvCE/oepohLEw7ZtovbJMuCtjR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3OWOS/dJMcabjrvCE/oepohLEw7ZtovbJMuCtjR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3OWOS/dJMcabjrvCE/oepohLEw7ZtovbJMuCtjR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3OWOS%2FdJMcabjrvCE%2FoepohLEw7ZtovbJMuCtjR1%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;547&quot; height=&quot;568&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;638&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt; SecurityContextPersistenceFilter&lt;br /&gt;&lt;/b&gt;요청이 들어올 때 이전 인증 정보를 복원하고 요청이 끝날 때 다시 저장하는 필터&lt;br /&gt;세션 기반에선 중요하나 JWT 방식에선 거의 사용하지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt; UsernamePasswordAuthenticationFilter&lt;br /&gt;&lt;/b&gt;로그인 요청일 때만 동작&lt;br /&gt;username / password를 꺼내서 인증을 시도하고 성공하면 SecurityContext에 저장&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt; AnonymousAuthenticationFilter&lt;br /&gt;&lt;/b&gt;앞 필터들에서 인증이 안 됐을 때 ROLE_ANONYMOUS를 부여&lt;br /&gt;Authentication이 null이 되는 것을 방지하기 위해 존재&lt;/li&gt;
&lt;li&gt;&lt;b&gt; ExceptionTranslationFilter&lt;br /&gt;&lt;/b&gt;아래 필터에서 던진 예외를 잡아 HTTP 응답으로 변환&amp;nbsp;&lt;br /&gt;AuthenticationException &amp;rarr; 401&lt;br /&gt;AccessDeniedException &amp;rarr; 403&lt;/li&gt;
&lt;li&gt;&lt;b&gt; FilterSecurityInterceptor&lt;br /&gt;&lt;/b&gt;AccessDecisionManager에게 권한 판단을 넘기고 통과하면 Controller로, 실패하면 예외를 위로 던져 ExceptionTranslationFilter가 처리&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인증(Authentication) 흐름&amp;nbsp;&lt;/h3&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;544&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3OWin/dJMcahYeeJe/1c6RhMKAllTAzqasgDKkR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3OWin/dJMcahYeeJe/1c6RhMKAllTAzqasgDKkR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3OWin/dJMcahYeeJe/1c6RhMKAllTAzqasgDKkR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3OWin%2FdJMcahYeeJe%2F1c6RhMKAllTAzqasgDKkR0%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;503&quot; height=&quot;651&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;544&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;AuthenticationFilter (UsernamePasswordAuthenticationFilter)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청이 Controller에 도달하기 전에 가로채는 보안 필터&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Spring Security가 자동으로 등록하므로 직접 만들 필요 없음)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청에서 username / password 추출&lt;/li&gt;
&lt;li&gt;미인증 token을 생성해 AuthenticationManager에 넘김&lt;/li&gt;
&lt;li&gt;인증 성공 후에는 SecurityContextHolder에 결과 저장&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;UsernamePasswordAuthenticationToken&amp;nbsp;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 인증 정보를 담는 객체 (Authentication 인터페이스의 구현체)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 요청이 들어오면 UsernamePasswordAuthenticationFilter가 해당 요청을 받아 username과 password 이용해 토큰 생성&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;인증 전: username + password (authenticated = false)&lt;/li&gt;
&lt;li&gt;인증 후: UserDetails + 권한 목록 (authenticated = true, password는 null 처리)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 토큰은 AuthenticationManager에게 전달되어 인증 진행&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;AuthenticationManager&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 처리를 총괄하는 인터페이스&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 인증하지 않고 적절한 Provider에게 위임만 함&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;Token을 받아 지원 가능한 AuthenticationProvider를 찾아 인증(&lt;span style=&quot;color: #555555; text-align: start;&quot;&gt;Authentication&lt;/span&gt;)을 넘김&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;AuthenticationProvider&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 인증 로직이 실행되는 곳&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserDetailsService로 유저를 조회하고, &lt;span style=&quot;background-color: #c1bef9;&quot;&gt;PasswordEncoder&lt;/span&gt;로 비밀번호를 검증&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB에서 찾은 유저와 입력값을 비교해 일치하면 인증된 Token을, 불일치하면 BadCredentialsException을 던짐&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;PasswordEncoder&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀번호를 암호화하고 검증하는 인터페이스&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회원가입 시, 비밀번호를 암호화해 DB에 저장&lt;/li&gt;
&lt;li&gt;로그인 시, 입력값과 DB 암호화값을 비교&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;UserDetailsService&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에서 유저를 조회하는 인터페이스 (개발자가 직접 구현)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;username을 받아 DB에서 유저를 찾고 UserDetails 객체로 반환, 없으면 UsernameNotFoundException&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;UserDetails&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security가 인식하는 유저 정보 객체, DB의 User 엔티티를 Security용으로 감싸는 래퍼 역할&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비밀번호, 권한 목록, 계정 상태(잠김/만료 등)을 Security에 제공&lt;/li&gt;
&lt;li&gt;Provider가 이 객체를 기반으로 인증 Token 생성&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;&lt;b&gt;SecurityContextHolder&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증된 유저 정보를 SecurityContext에 저장 및 관리하는 저장소&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인이 완료되면 SecurityContext에 Authentication이 보관됨&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인증 완료 후, 어디서든 현재 로그인한 유저 정보를 꺼낼 수 있게 해줌&lt;/li&gt;
&lt;li&gt;스레드별로 독립적으로 관리되어 다른 사용자의 정보와 섞이지 않음&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ittrue.tistory.com/287&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ittrue.tistory.com/287&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775201307503&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Spring Security] 스프링 시큐리티 인증 처리 흐름&quot; data-og-description=&quot;스프링 시큐리티의 인증 처리 흐름 스프링 시큐리티에서는 스프링 시큐리티 필터 체인을 통해 보안을 위한 특정 작업을 처리한다. 다음은 사용자가 로그인 인증을 위한 요청을 할 경우, 스프링 &quot; data-og-host=&quot;ittrue.tistory.com&quot; data-og-source-url=&quot;https://ittrue.tistory.com/287&quot; data-og-url=&quot;https://ittrue.tistory.com/287&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b0ttKG/dJMb9c9yiN0/jiCOdyhsuzbGdopQ7Uk0a0/img.jpg?width=800&amp;amp;height=591&amp;amp;face=0_0_800_591,https://scrap.kakaocdn.net/dn/4OvwQ/dJMb9frFTtS/jsPOfY5XrGJZ3XupeZwC90/img.jpg?width=800&amp;amp;height=591&amp;amp;face=0_0_800_591,https://scrap.kakaocdn.net/dn/cCiINW/dJMb9iIHwhU/kwcLW9dbz7iqkVwSuMseh0/img.jpg?width=1440&amp;amp;height=1065&amp;amp;face=0_0_1440_1065&quot;&gt;&lt;a href=&quot;https://ittrue.tistory.com/287&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ittrue.tistory.com/287&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b0ttKG/dJMb9c9yiN0/jiCOdyhsuzbGdopQ7Uk0a0/img.jpg?width=800&amp;amp;height=591&amp;amp;face=0_0_800_591,https://scrap.kakaocdn.net/dn/4OvwQ/dJMb9frFTtS/jsPOfY5XrGJZ3XupeZwC90/img.jpg?width=800&amp;amp;height=591&amp;amp;face=0_0_800_591,https://scrap.kakaocdn.net/dn/cCiINW/dJMb9iIHwhU/kwcLW9dbz7iqkVwSuMseh0/img.jpg?width=1440&amp;amp;height=1065&amp;amp;face=0_0_1440_1065');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Spring Security] 스프링 시큐리티 인증 처리 흐름&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;스프링 시큐리티의 인증 처리 흐름 스프링 시큐리티에서는 스프링 시큐리티 필터 체인을 통해 보안을 위한 특정 작업을 처리한다. 다음은 사용자가 로그인 인증을 위한 요청을 할 경우, 스프링&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ittrue.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev-coco.tistory.com/174&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev-coco.tistory.com/174&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775201314297&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Spring Security의 구조(Architecture) 및 처리 과정 알아보기&quot; data-og-description=&quot;시작하기 앞서 스프링 시큐리티에서 어플리케이션 보안을 구성하는 두 가지 영역에 대해 간단히 알아보자.인증(Authentication)과 인가(Authorization)대부분의 시스템에서는 회원을 관리하고 있고, 그&quot; data-og-host=&quot;dev-coco.tistory.com&quot; data-og-source-url=&quot;https://dev-coco.tistory.com/174&quot; data-og-url=&quot;https://dev-coco.tistory.com/174&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/2q1J0/dJMb82MDwxM/1GFIhEvicc0lXIfdp7WdMK/img.png?width=800&amp;amp;height=600&amp;amp;face=0_0_800_600,https://scrap.kakaocdn.net/dn/c1rL2e/dJMb8UHPzVr/7OwdgI8jzqeYa2gtRdPXc0/img.png?width=800&amp;amp;height=600&amp;amp;face=0_0_800_600,https://scrap.kakaocdn.net/dn/c4zzOd/dJMb86O2dmL/pPWD7FSkvpP5FHTtkPdpp0/img.png?width=820&amp;amp;height=615&amp;amp;face=0_0_820_615&quot;&gt;&lt;a href=&quot;https://dev-coco.tistory.com/174&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-coco.tistory.com/174&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/2q1J0/dJMb82MDwxM/1GFIhEvicc0lXIfdp7WdMK/img.png?width=800&amp;amp;height=600&amp;amp;face=0_0_800_600,https://scrap.kakaocdn.net/dn/c1rL2e/dJMb8UHPzVr/7OwdgI8jzqeYa2gtRdPXc0/img.png?width=800&amp;amp;height=600&amp;amp;face=0_0_800_600,https://scrap.kakaocdn.net/dn/c4zzOd/dJMb86O2dmL/pPWD7FSkvpP5FHTtkPdpp0/img.png?width=820&amp;amp;height=615&amp;amp;face=0_0_820_615');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spring Security의 구조(Architecture) 및 처리 과정 알아보기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;시작하기 앞서 스프링 시큐리티에서 어플리케이션 보안을 구성하는 두 가지 영역에 대해 간단히 알아보자.인증(Authentication)과 인가(Authorization)대부분의 시스템에서는 회원을 관리하고 있고, 그&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-coco.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인가(Authorization) 흐름&lt;/h3&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;603&quot; data-origin-height=&quot;449&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2eHMA/dJMcafzmFZg/Q8P4kbxb6Jj7XXxD5WXS11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2eHMA/dJMcafzmFZg/Q8P4kbxb6Jj7XXxD5WXS11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2eHMA/dJMcafzmFZg/Q8P4kbxb6Jj7XXxD5WXS11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2eHMA%2FdJMcafzmFZg%2FQ8P4kbxb6Jj7XXxD5WXS11%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;603&quot; height=&quot;449&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;603&quot; data-origin-height=&quot;449&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa; color: #141413; text-align: start;&quot;&gt; FilterSecurityInterceptor&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;FilterChain의 가장 마지막에 위치하는 필터&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;실제 리소스(Controller)에 접근하기 직전에 권한 검사를 시작하는 인가의 진입점&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SecurityContextHolder에서 현재 유저의 Authentication을 꺼냄&amp;nbsp;&lt;/li&gt;
&lt;li&gt;SecurityMetadataSource에서 해당 URL에 필요한 권한(ConfigAttribute)를 조회&lt;/li&gt;
&lt;li&gt;이 두 정보를 AccessDecisionManager에게 넘겨 판단을 위임&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #99cefa; color: #141413; text-align: start;&quot;&gt; SecurityMetadataSource&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413;&quot;&gt;각 URL(리소스)에 접근하기 위해 필요한 권한 정보를 보관하는 저장소 &lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;FilterSecurityInterceptor가 &quot;이 URL에 필요한 권한이 뭐야?&quot;라고 물으면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;ConfigAttribute&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;목록으로 응답&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;SecurityConfig에서 설정한 규칙이 여기에 저장&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt; &lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;AccessDecisionManager&lt;/span&gt; &lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;인가 판단을 총괄하는 인터페이스&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413;&quot;&gt;직접 판단하지 않고 여러 AccessDecisionVoter에게 위임해 결과를 총합&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Authentication(사용자 권한)과 ConfigAttribute(필요 권한)을 받아 Voter들에게 투표 시킴&lt;/li&gt;
&lt;li&gt;투표 결과를 취합해 최종 허용/거부 결정, 거부 시 AccessDeniedException 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #c1bef9; color: #141413; text-align: start;&quot;&gt; AccessDecisionVoter&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 권한 비교를 수행하는 투표자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 권한과 필요 권한을 직접 비교해 허용/거부/기권 중 하나를 반환&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #141413; text-align: start;&quot;&gt; ExceptionTranslationFilter&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413;&quot;&gt;FilterSecurityInterceptor에서 발생한 Security 예외를 잡아 적절한 HTTP 응답으로 변환하는 필터&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;로그인이 안 된 상태의 접근은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;AuthenticationEntryPoint&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;로 넘겨 401 반환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;권한 부족은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;AccessDeniedHandler&lt;span style=&quot;background-color: #ffffff; color: #141413; text-align: start;&quot;&gt;로 넘겨 403 반환&lt;/span&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;&lt;b&gt;Security Config 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security가 제공하는 보안 기능들을&amp;nbsp;&lt;b&gt;어떻게 적용할지 설정&lt;/b&gt;하는 클래스&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;i&gt;이 URL은 누구나 접근 가능&lt;/i&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;i&gt;이 URL은 로그인한 사람만 접근 가능&lt;/i&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;i&gt;이 URL은 관리자만 접근 가능&lt;/i&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이처럼 필터 체인과 보안 정책을 설정하는 곳이 바로 Security Config이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; background-color: #f3c000;&quot;&gt;@EnableWebSecurity&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Spring Security 설정 활성화&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #f3c000;&quot;&gt;@Configuration&lt;/span&gt;&amp;nbsp;&lt;/b&gt;이 클래스가 Spring 설정 클래스임을 선언, 내부 @Bean 메서드들이 Spring 컨테이너에 등록됨&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #c0d1e7;&quot;&gt;allowUris 배열&lt;/span&gt;&amp;nbsp;&lt;/b&gt;인증 없이 접근을 허용할 URL 목록들&amp;nbsp;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;/**&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;은&amp;nbsp;&lt;b&gt;하위 경로를 모두 포함&lt;/b&gt;하는 와일드카드&amp;nbsp;&lt;/li&gt;
&lt;li&gt;나중에 허용 URL을 추가할 때 이 배열만 수정하면 되므로 유지보수에 용이&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775139938391&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.umcspringbootstudy.global.config;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    private final String[] allowUris = {
            &quot;/swagger-ui/**&quot;,
            &quot;/v3/api-docs/**&quot;,
            &quot;/users/signup&quot;,
            &quot;/users/login&quot;,
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -&amp;gt; csrf.disable())
                .sessionManagement(session -&amp;gt;
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(requests -&amp;gt; requests
                        .requestMatchers(allowUris).permitAll()
                        .requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
                        .anyRequest().authenticated()
                )
                .exceptionHandling(exception -&amp;gt; exception
                        .authenticationEntryPoint((request, response, authException) -&amp;gt;
                                response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
                        .accessDeniedHandler((request, response, accessDeniedException) -&amp;gt;
                                response.sendError(HttpServletResponse.SC_FORBIDDEN))
                );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}&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;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;securityFilterChain&lt;/span&gt;&amp;nbsp;&lt;/b&gt;HTTP 요청에 대한 보안 규칙을 정의하는 핵심 빈&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;span style=&quot;background-color: #dddddd;&quot;&gt;csrf(csrf -&amp;gt; csrf.disable())&amp;nbsp;&lt;br /&gt;&lt;/span&gt;&lt;/b&gt;CSRF은 악성 사이트가 로그인된 사용자의 권한을 도용하는 공격(JWT 기반 REST API에선 불필요하므로 비활성화)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;.sessionManagement(session -&amp;gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))&lt;/span&gt;&lt;/b&gt; &lt;br /&gt;&lt;b&gt;sessionManagement()&lt;/b&gt;는 서버의 세션 생성 방식 설정&lt;br /&gt;&lt;b&gt;SessionCreationPolicy.STATELESS&amp;nbsp;&lt;/b&gt;서버가 세션을 생성하지 않도록 함&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;i&gt;*JWT 방식은 토큰 자체에 인증 정보가 담겨 있으므로 서버가 세션을 유지할 필요 없음&amp;nbsp;&lt;/i&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;i&gt;&lt;br /&gt;&lt;/i&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd; color: #333333;&quot;&gt;&lt;b&gt; .authorizeHttpRequests(requests -&amp;gt; requests&lt;br /&gt;&lt;/b&gt;&lt;/span&gt;들어오는 HTTP 요청들에 대해 어떤 접근 권한이 필요한지 규칙 설정,&amp;nbsp;&lt;b&gt;위에서부터 순서대로 매칭(순서 중요)&lt;/b&gt;&lt;/li&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt; &lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #333333; background-color: #dddddd;&quot;&gt;.requestMatchers(allowUris).permitAll()&lt;br /&gt;&lt;/span&gt;&lt;/b&gt;&lt;b&gt;requestMatchers()&lt;/b&gt;은 규칙을 적용할 URL 패턴 지정&lt;br /&gt;&lt;b&gt;permitAll()&lt;/b&gt;은 해당 URL에 인증 없이 누구나 접근 가능하도록 허용&lt;br /&gt;&amp;rarr; allowUris 배열의 경로들은 누구나 접근 가능하도록 설정됨&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;.requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)&lt;br /&gt;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;/admin/&amp;nbsp;&lt;/b&gt;으로 시작하는 모든 경로는 ADMIN 역할을 가진 사용자만 접근 가능&lt;br /&gt;&lt;b&gt;hasRole()&lt;/b&gt;은 내부적으로&amp;nbsp;&lt;b&gt;ROLE_ADMIN&lt;/b&gt;이라는 권한명을 찾음&amp;nbsp;&lt;br /&gt;&lt;i&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;*hasRole 메서드 사용 시, DB에 ROLE_ 접두사가 붙어 있어야 함&amp;nbsp;&lt;/span&gt;&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;.anyRequest().authenticated()&lt;br /&gt;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;anyRequest()&lt;/b&gt;는 위에서 지정하지 않은 나머지 모든 요청을 의미&amp;nbsp;&lt;br /&gt;&lt;b&gt;authenticated()&lt;/b&gt;는 해당 요청에 로그인(인증)을 요구&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;.exceptionHandling(exception -&amp;gt; exception&lt;br /&gt;&lt;/b&gt;&lt;/span&gt;인증/인가 과정에서 발생하는 예외 상황을 처리하는 설정
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;.authenticationEntryPoint((request,&amp;nbsp;response,&amp;nbsp;authException)&amp;nbsp;-&amp;gt; &lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;response.sendError(HttpServletResponse.SC_UNAUTHORIZED))&lt;br /&gt;&lt;/span&gt;&lt;/b&gt;&lt;b&gt;authenticationEntryPoint&lt;/b&gt;는 인증이 필요할 때, 인증 정보가 없으면 동작&lt;br /&gt;쉽게 말해, 로그인 안 된 상태로 접근 시 401 Unauthorized 에러를 반환&amp;nbsp;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;.accessDeniedHandler((request,&amp;nbsp;response,&amp;nbsp;accessDeniedException)&amp;nbsp;-&amp;gt; &lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;response.sendError(HttpServletResponse.SC_FORBIDDEN))&lt;/span&gt;&lt;/b&gt;&lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;br /&gt;&lt;/span&gt;accessDeniedHandler&lt;/b&gt;는 인증은 됐지만 권한이 부족할 때 동작&lt;br /&gt;쉽게 말해, 일반 유저가 /admin에 접근 시 403 Forbidden 에러를 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; background-color: #dddddd;&quot;&gt; public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }&lt;br /&gt;&lt;/span&gt;PasswordEncoder&lt;/b&gt;는 비밀번호를 암호화하는 인터페이스&amp;nbsp;&lt;br /&gt;&lt;b&gt;BcryptPasswordEncoder&lt;/b&gt;는 BCrypt 해시 알고리즘을 사용하며 Spring Security에서 표준으로 사용&lt;br /&gt;DB에 비밀번호 저장 시, 평문 대신 암호화된 값을 저장하기 위해 사용&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 위에서 설정한 모든 규칙을 적용해 SecurityFilterChain 객체를 생성하고 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체가 실제로 모든 요청을 가로채는 필터 체인으로 동작한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://spring.io/projects/spring-security#overview&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://spring.io/projects/spring-security#overview&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775129559733&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Spring Security&quot; data-og-description=&quot;Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications. Spring Security is a framework that focuses on providing both authentication and authoriz&quot; data-og-host=&quot;spring.io&quot; data-og-source-url=&quot;https://spring.io/projects/spring-security#overview&quot; data-og-url=&quot;https://spring.io/projects/spring-security&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cBfswN/dJMb9jgxBOX/apa4G2qt8c1rVECtRJgDYK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/eBO19/dJMb83kthDb/LT0I6RvcGGxWJVp3SCHRU0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://spring.io/projects/spring-security#overview&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://spring.io/projects/spring-security#overview&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cBfswN/dJMb9jgxBOX/apa4G2qt8c1rVECtRJgDYK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/eBO19/dJMb83kthDb/LT0I6RvcGGxWJVp3SCHRU0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications. Spring Security is a framework that focuses on providing both authentication and authoriz&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;spring.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ROLE&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 권한 등급을 나타내는 Enum&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ROLE을 사용해 사용자 간 접근 제어 가능&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;User 엔티티에 저장&lt;/li&gt;
&lt;li&gt;회원가입 시, ROLE_USER(일반 회원)으로 고정&amp;nbsp;&lt;/li&gt;
&lt;li&gt;SecurityConfig에서 접근 제어&amp;nbsp;&lt;/li&gt;
&lt;li&gt;반드시 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;ROLE_&lt;/b&gt; &lt;/span&gt;접두사 필수: hasRole(&quot;ADMIN&quot;) 사용 시, 내부적으로 ROLE_ADMIN을 찾는 것임&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775207032970&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public enum Role {
    ROLE_ADMIN, ROLE_USER
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;회원가입 구현 (w. 비밀번호 암호화 저장)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 위 설정에서 회원가입 endpoint를 전체 접근 허용하고 passwordEncoder를 Bean으로 등록해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;DTO&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 시 필요한 DTO를 먼저 설정했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;User 도메인에 로그인과 회원가입 등 여러 DTO를 하나의 파일 안에 작성하기 위해 &lt;b&gt;Record&lt;/b&gt; 타입으로 작성했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775205502597&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class UserRequestDTO {

    public record SignUpDTO(
            @NotBlank
            String nickname,
            @NotBlank
            @Email
            String email,
            @NotBlank
            String password,
            Gender gender,
            String phoneNumber
    ) {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775205628404&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class UserResponseDTO {

    public record SignUpDTO(
            Long id,
            String nickname,
            String email
    ) {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;*&lt;b&gt;record&amp;nbsp;&lt;/b&gt;java 14에서 추가된 &lt;span style=&quot;color: #006dd7;&quot;&gt;불변&lt;/span&gt; 데이터 클래스&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;생성자, getter, equals, hashCode, toString 을 자동으로 만들어줌&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Repository &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 시, 이미 가입된 이메일은 재가입이 불가하므로 &lt;b&gt;이메일 중복 체크&lt;/b&gt;하는 쿼리를 Repository에 구현한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775206052428&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface UserRepository extends JpaRepository&amp;lt;User, Long&amp;gt; {
    boolean existsByEmail(String email);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이때, Spring Data JPA는 Repository 인터페이스에 메서드 이름을 *&lt;u&gt;특정 규칙&lt;/u&gt;으로 작성하면 쿼리를 자동으로 생성하는 것을 이용&lt;/p&gt;
&lt;pre id=&quot;code_1775206067462&quot; class=&quot;armasm&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;동사 + By + 필드명 + (조건)&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;findBy &amp;rarr; SELECT (조회)&lt;/li&gt;
&lt;li&gt;existsBy&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; SELECT EXISTS (존재 여부)&lt;/li&gt;
&lt;li&gt;countBy&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; SELECT COUNT (개수)&lt;/li&gt;
&lt;li&gt;deleteBy&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; DELETE (삭제)&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Service&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 앞에서 생성한 existsByEmail 쿼리를 이용해 중복 체크를 한다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB에 이미 같은 이메일이 있으면 409 에러를 반환하고 종료한다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 클라이언트가 보낸 평문 비밀번호를 BCrypt로 해시화한다. DB에는 해시화된 값이 저장된다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;passwordEncoder.encode()&amp;nbsp;&lt;/b&gt;평문 비밀번호를 BCrypt 알고리즘으로 해시화&amp;nbsp;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;단방향 해시: 암호화는 가능하나 복호화는 불가능&lt;/li&gt;
&lt;li&gt;솔트(Salt) 자동 추가: 같은 비밀번호도 매번 다른 해시값 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. DTO에서 받은 값 + 암호화된 비밀번호 + Role로 User 엔티티를 만들고 DB에 저장한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775206202894&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 UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserResponseDTO.SignUpDTO signup(UserRequestDTO.SignUpDTO dto) {

        if (userRepository.existsByEmail(dto.email())) {
            throw new GeneralException(UserErrorCode.USER_ALREADY_EXISTS);
        }

        String encodedPassword = passwordEncoder.encode(dto.password());

        User user = User.builder()
                .nickname(dto.nickname())
                .email(dto.email())
                .password(encodedPassword)
                .role(Role.ROLE_USER)
                .gender(dto.gender())
                .phoneNumber(dto.phoneNumber())
                .build();

        User savedUser = userRepository.save(user);

        return new UserResponseDTO.SignUpDTO(
                savedUser.getId(),
                savedUser.getNickname(),
                savedUser.getEmail()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Controller&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POST로 /users/signup 경로로 회원가입 요청을 받는다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775206773889&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Tag(name = &quot;유저 API&quot;, description = &quot;회원가입/로그인 관련 API&quot;)
@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/users&quot;)
public class UserController {

    private final UserService userService;

    @Operation(summary = &quot;회원가입&quot;, description = &quot;이메일, 비밀번호, 닉네임으로 회원가입합니다.&quot;)
    @PostMapping(&quot;/signup&quot;)
    public ApiResponse&amp;lt;UserResponseDTO.SignUpDTO&amp;gt; signup(@Valid @RequestBody UserRequestDTO.SignUpDTO dto) {
        return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, userService.signup(dto));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;테스트&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트로 tom의 정보를 입력해 회원가입을 수행했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1585&quot; data-origin-height=&quot;845&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k6obO/dJMcahcQnYw/fg2ja7mPu5nQYr6ej8DzEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k6obO/dJMcahcQnYw/fg2ja7mPu5nQYr6ej8DzEk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k6obO/dJMcahcQnYw/fg2ja7mPu5nQYr6ej8DzEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk6obO%2FdJMcahcQnYw%2Ffg2ja7mPu5nQYr6ej8DzEk%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;1585&quot; height=&quot;845&quot; data-origin-width=&quot;1585&quot; data-origin-height=&quot;845&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1550&quot; data-origin-height=&quot;483&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvoZps/dJMcahxawak/ybfFetmE7GWSa2nAzXbfXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvoZps/dJMcahxawak/ybfFetmE7GWSa2nAzXbfXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvoZps/dJMcahxawak/ybfFetmE7GWSa2nAzXbfXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvoZps%2FdJMcahxawak%2FybfFetmE7GWSa2nAzXbfXk%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;1550&quot; height=&quot;483&quot; data-origin-width=&quot;1550&quot; data-origin-height=&quot;483&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;DTO대로 id, nickname, email만이 반환된 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;intelliJ 내부에서 확인하면 비밀번호가 해시된 값으로 나오는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, ROLE 또한 ROLE_USER로 제대로 나오는 모습을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;29&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBI5iU/dJMcab4LCSP/BcGEsciP6Nyk30NejeRxe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBI5iU/dJMcab4LCSP/BcGEsciP6Nyk30NejeRxe0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBI5iU/dJMcab4LCSP/BcGEsciP6Nyk30NejeRxe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBI5iU%2FdJMcab4LCSP%2FBcGEsciP6Nyk30NejeRxe0%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;1424&quot; height=&quot;29&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;29&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;세션 기반 vs 토큰 기반 인증 비교&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;세션 기반(Stateful)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 세션 저장소(메모리/Redis)에 사용자 정보를 직접 보관하고, 클라이언트는 세션 ID만 쿠키로 갖고 있는 상태&lt;/p&gt;
&lt;pre id=&quot;code_1775386328586&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;클라이언트                    서버
   |-- POST /login -----------&amp;gt;|
   |&amp;lt;-- Set-Cookie: JSESSIONID |  &amp;larr; 세션 ID 발급, 서버 메모리에 저장
   |                           |
   |-- GET /api (Cookie) -----&amp;gt;|
   |                           |  &amp;larr; 세션 ID로 서버에서 사용자 조회
   |&amp;lt;-- 200 OK ----------------|&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;토큰 기반(Stateless)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 토큰을 저장하지 않고, 토큰의 서명(Signature)만 검증, 사용자 정보는 토큰 내부(Payload)에 담겨 있는 상태&lt;/p&gt;
&lt;pre id=&quot;code_1775386426885&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;클라이언트                    서버
   |-- POST /login -----------&amp;gt;|
   |&amp;lt;-- JWT Token -------------|  &amp;larr; 토큰 발급, 서버는 저장 안 함
   |                           |
   |-- GET /api                |
   |   Authorization: Bearer &amp;lt;token&amp;gt;
   |                           |  &amp;larr; 토큰 자체를 검증 (서명 확인)
   |&amp;lt;-- 200 OK ----------------|&lt;/code&gt;&lt;/pre&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;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;세션 기반&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;토큰 기반(JWT)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;서버 상태&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Stateful(세션 저장 필요)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Stateless(저장 불필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;확장성&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;서버 여러 대일 때 세션 공유 문제&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;어느 서버든 토큰만 검증하면 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;보안 제어&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;서버에서 즉시 세션 무효화 가능&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;토큰 만료 전까지 강제 차단 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;로그아웃&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;서버에서 세션 삭제로 완전 로그아웃&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;클라이언트 토큰 삭제 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;서버 부하&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;요청마다 세션 저장소 조회&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;저장소 조회 없이 서명만 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;모바일/분리 구조&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;쿠키 기반이라 네이티브 앱에서 불편&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Header 방식이라 어느 클라이언트든 호환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;구현 복잡도&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;상대적으로 단순&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Refresh Token 등 추가 고려 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트는 Spring Boot REST API + 프론트 분리구조이므로 JWT 방식이 더 적합하므로 로그인 구현 시, JWT를 활용해보려고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;JWT 발급과 로그인 구현&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 과정은 위에서 언급했던 인증(Authentication)의 흐름을 다시 살펴봐야 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;441&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6q3Ad/dJMcagSAvdH/HidyXZtEDzGi03AwtdjNP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6q3Ad/dJMcagSAvdH/HidyXZtEDzGi03AwtdjNP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6q3Ad/dJMcagSAvdH/HidyXZtEDzGi03AwtdjNP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6q3Ad%2FdJMcagSAvdH%2FHidyXZtEDzGi03AwtdjNP0%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;660&quot; height=&quot;441&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;441&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 과정에서 Spring Security는 &quot;어떻게 유저를 찾을지&quot; 모른다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 email 기반으로 users 테이블을 조회하고, Role enum으로 권한을 관리하기 때문에,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UserDetails&lt;/b&gt;와&amp;nbsp;&lt;b&gt;UserDetailsService&lt;/b&gt;를 우리 도메인에 맞게 직접 작성해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;UserDetails&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일, 비밀번호, 권한 등 유저 정보를 담는 객체&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB의 User 엔티티를 Spring Security가 이해할 수 있게 변환&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security의 표준 인터페이스인 UserDetails를 CustomUserDetails로 구현했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775385369474&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
    private final User user;

    public Long getUserId() {
        return user.getId();
    }

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

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

    @Override
    public String getUsername() {
        return user.getEmail();
    }
}&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;getAuthorities&amp;nbsp;&lt;/b&gt;권한을 List 형태로 반환 (ADMIN 또는 USER)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getPassword&amp;nbsp;&lt;/b&gt;비밀번호 반환&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getUsername&amp;nbsp;&lt;/b&gt;아이디(또는 이메일) 반환&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getUserId&amp;nbsp;&lt;/b&gt;컨트롤러에서 @AuthenticationPrincipal CustomUserDetails로 꺼낼 때 userId를 바로 꺼내기 위해 별도 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;UserDetailsService&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에 쿼리해서 유저 정보를 찾아오는 서비스&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp;loadUserByUsername(email)&lt;/span&gt; &lt;/i&gt;메서드로 이메일 기반 조회 (Username == email)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로그인 요청(email + password)&lt;/li&gt;
&lt;li&gt;&amp;rarr; &lt;i&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp;loadUserByUsername(email)&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/i&gt; 메서드 spring이 자동 호출&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&amp;rarr; DB에서 User 조회&amp;nbsp;&lt;/li&gt;
&lt;li&gt;CustomUserDetails로 감싸서 반환&lt;/li&gt;
&lt;li&gt;Spring이 입력된 password와 DB password 비교&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775385823121&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 CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(username)
                .orElseThrow(() -&amp;gt; new UserException(UserErrorCode.USER_NOT_FOUND));
        return new CustomUserDetails(user);
    }
}&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;&lt;b&gt;JWT 기반 인증 흐름&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 로그인 시(토큰 발급)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;POST /users/login 요청을 보냄&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&amp;rarr; UserService.login()&lt;/li&gt;
&lt;li&gt;&amp;rarr; 비밀번호 검증&lt;/li&gt;
&lt;li&gt;&amp;rarr; &lt;span style=&quot;color: #ee2323;&quot;&gt;JwtUtil.&lt;/span&gt;generateToken() 을 통해 토큰 생성&lt;/li&gt;
&lt;li&gt;&amp;rarr; 클라이언트에게 accessToken 반환&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 로그인 후 API 요청 시(토큰 검증)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET /posts/1 요청을 보냄&amp;nbsp;&lt;br /&gt;&lt;i&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;Authorization: Bearer eyJhGC...&lt;/span&gt;&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;&amp;rarr; &lt;span style=&quot;color: #ee2323;&quot;&gt;JwtAuthFilter&lt;/span&gt;가 요청을 가로채 토큰 검증&lt;/li&gt;
&lt;li&gt;&amp;rarr; SecurityContext에 인증 정보 저장&lt;/li&gt;
&lt;li&gt;&amp;rarr; Controller 실행&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;JwtUtil&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;토큰 생성/검증/데이터 추출하는 도구&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;JWT 토큰은 그냥 문자열이 아닌 서명(Signature)이 포함된 구조체&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 토큰을 직접 만들고, 검증하고, 안에서 데이터를 꺼내는 작업이 복잡하기 때문에 JwtUtil 하나에 모아두는 것&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;generateToken(userId)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;토큰 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;validateToken(token)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;토큰 유효성 검증&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getUserIdFromToken(token)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;토큰에서 userId 추출&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;JwtAuthFilter &lt;/b&gt;&lt;/span&gt;모든 요청을 감시하는 문지기&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;로그인 이후, GET 과 같은 요청이 오면 Spring Security는 이 사람이 누구인지 검증이 필요&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그러나, JWT는 서버가 저장하지 않으므로 매 요청마다 토큰을 꺼내 검증하고, 인증 정보를 SecurityContext에 직접 넣어야 함 &lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Authorization: Bearer &amp;lt;token&amp;gt; 헤더에서 토큰 추출&lt;/li&gt;
&lt;li&gt;JwtUtil.validateToken() 으로 검증&lt;/li&gt;
&lt;li&gt;유효하면 JwtUtil.getUserIdFromToken()으로 userId 추출&lt;/li&gt;
&lt;li&gt;DB에서 User를 조회해 CustomDetails 생성&lt;/li&gt;
&lt;li&gt;SecurityContextHolder에 인증 정보 저장&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 토큰 기반 인증 구현 시, JwtUtil과 JwtAuthFilter 가 필요하다는 것을 생각하고 로그인 과정을 구현해보겠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, build.gradle과 application.yml에 JWT 관련 설정을 넣어준다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml 에 넣는 시크릿 키는 환경 변수 처리를 하여 다음과 같이 설정했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775386740885&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
    // implementation 'org.springframework.boot:spring-boot-configuration-processor'&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1775386862971&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;jwt:
  token:
    secretKey: ${JWT_SECRET}
    expiration:
      access: 14400000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;b&gt;JwtUtil&lt;/b&gt;부터 작성해보자.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775389051205&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 {

    private final SecretKey secretKey;
    private final Duration accessExpiration;

    // application.yml의 jwt 값을 생성자에서 주입받음
    public JwtUtil(
            @Value(&quot;${jwt.token.secretKey}&quot;) String secret,
            @Value(&quot;${jwt.token.expiration.access}&quot;) Long accessExpiration
    ) {
        // 문자열 시크릿 키 &amp;rarr; HMAC-SHA 알고리즘용 SecretKey 객체로 변환
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        // 밀리초 숫자 &amp;rarr; Duration 타입으로 변환 (14400000ms = 4시간)
        this.accessExpiration = Duration.ofMillis(accessExpiration);
    }

    /**
     * Access Token 발급
     * 로그인 성공 시 UserService에서 호출
     */
    public String createAccessToken(CustomUserDetails user) {
        return createToken(user, accessExpiration);
    }

    /**
     * 토큰에서 이메일 추출
     * JwtAuthFilter에서 토큰 &amp;rarr; 이메일 &amp;rarr; DB 조회 순서로 사용
     */
    public String getEmail(String token) {
        try {
            return getClaims(token).getPayload().getSubject();
        } catch (JwtException e) {
            return null;
        }
    }

    /**
     * 토큰 유효성 검증
     * JwtAuthFilter에서 토큰 처리 전 가장 먼저 호출
     */
    public boolean isValid(String token) {
        try {
            getClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    // 토큰 생성 내부 로직
    private String createToken(CustomUserDetails user, Duration expiration) {
        Instant now = Instant.now();

        // 권한 목록 &amp;rarr; 콤마 구분 문자열 변환 (예: &quot;ROLE_USER&quot;)
        String authorities = user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(&quot;,&quot;));

        return Jwts.builder()
                .subject(user.getUsername())                 // 이메일을 subject로 저장
                .claim(&quot;role&quot;, authorities)                  // 권한 정보 저장
                .issuedAt(Date.from(now))                    // 발급 시간
                .expiration(Date.from(now.plus(expiration))) // 만료 시간
                .signWith(secretKey)                         // 서명 (위변조 방지)
                .compact();                                  // 최종 토큰 문자열 반환
    }

    // 토큰 파싱 + 서명 검증
    // 서명이 잘못됐거나 만료되면 JwtException 발생
    // clockSkewSeconds(60): 서버 간 시간 오차 1분까지 허용
    private Jws&amp;lt;Claims&amp;gt; getClaims(String token) throws JwtException {
        return Jwts.parser()
                .verifyWith(secretKey)
                .clockSkewSeconds(60)
                .build()
                .parseSignedClaims(token);
    }
}&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;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 생성자 - 시크릿 키와 만료 시간 준비&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775389121241&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public JwtUtil(
        @Value(&quot;${jwt.token.secretKey}&quot;) String secret,
        @Value(&quot;${jwt.token.expiration.access}&quot;) Long accessExpiration
) {
    this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    this.accessExpiration = Duration.ofMillis(accessExpiration);
}&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;&amp;nbsp;&lt;b&gt;Keys.hmacShaKeyFor()&amp;nbsp;&lt;/b&gt;문자열로 저장해둔 시크릿 키를 암호화 전용 객체(SecretKey)로 변환&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. createToken() - 토큰 조립&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775389372554&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private String createToken(CustomUserDetails user, Duration expiration) {
    Instant now = Instant.now();

    String authorities = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(&quot;,&quot;));

    return Jwts.builder()
            .subject(user.getUsername())                 // &quot;test@gmail.com&quot;
            .claim(&quot;role&quot;, authorities)                  // &quot;ROLE_USER&quot;
            .issuedAt(Date.from(now))                    // 지금 시각
            .expiration(Date.from(now.plus(expiration))) // 지금 + 4시간
            .signWith(secretKey)                         // 서명
            .compact();                                  // 문자열로 변환
}&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;stream().map().collect()&amp;nbsp;&lt;/b&gt;getAuthorities가 반환하는&lt;span style=&quot;color: #9d9d9d;&quot;&gt; [GrantedAuthority(&quot;ROLE_USER&quot;)]&lt;/span&gt;과 같은 리스트를 문자열로 변환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. getClaims()&amp;nbsp; - 토큰 파싱 + 서명 검증&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775389538888&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private Jws&amp;lt;Claims&amp;gt; getClaims(String token) throws JwtException {
    return Jwts.parser()
            .verifyWith(secretKey)    // 이 키로 서명 검증
            .clockSkewSeconds(60)     // 시간 오차 1분 허용
            .build()
            .parseSignedClaims(token);
}&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;parseSignedClaims(token)&amp;nbsp;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;토큰을 Header / Payload / Signature 세 부분으로 분리&lt;/li&gt;
&lt;li&gt;secretKey로 Signature 검증 &amp;rarr; 위변조 여부 확인&lt;/li&gt;
&lt;li&gt;exp(만료시간) 확인 &amp;rarr; 만료됐으면 JwtException 발생&lt;/li&gt;
&lt;li&gt;이상 없으면 Payload(Claims) 반환&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4. getEmail() 과 isValid()&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 getClaims()를 호출하지만 예외를 직접 던지지 않고, null / false로 변환해서 반환&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775389726764&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public String getEmail(String token) {
    try {
        return getClaims(token).getPayload().getSubject(); // subject = 이메일
    } catch (JwtException e) {
        return null; // 잘못된 토큰이면 null 반환
    }
}

public boolean isValid(String token) {
    try {
        getClaims(token);
        return true;
    } catch (JwtException e) {
        return false; // 잘못된 토큰이면 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;로그인 시에는 createAccessToken() &amp;rarr; createToken() &amp;rarr; 토큰 문자열 반환의 과정을 거치고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 요청 시에는 isValid() &amp;rarr; getClaims() &amp;rarr; 서명/만료 검증 &amp;rarr; getEmail() &amp;rarr; getClaims.getSubject() &amp;rarr; 이메일 추출(후 DB에서 User 조회)의 과정을 거친다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;i&gt;&lt;b&gt;Q. 왜 토큰만으로 끝내지 않고 다시 DB를 조회할까?&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저가 탈퇴했으나 토큰이 유효하거나, 유저 권한이 변경되었는데 토큰에 적용이 되지 않은 경우, 계정이 정지되었으나 토큰이 유효한 경우 등이 발생할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, DB를 다시 조회하면 토큰 발급 시점이 아닌 &lt;span style=&quot;color: #006dd7;&quot;&gt;항상 최신 상태의 유저 정보&lt;/span&gt;를 가져올 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 다음&amp;nbsp;&lt;b&gt;JwtAuthFilter&amp;nbsp;&lt;/b&gt;를 작성해보자.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775390851079&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// OncePerRequestFilter: 하나의 요청에 딱 한 번만 실행되는 필터
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

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

        // [1] Authorization 헤더에서 토큰 가져오기
        String token = request.getHeader(&quot;Authorization&quot;);

        // [2] 토큰이 없거나 &quot;Bearer &quot;로 시작하지 않으면 인증 없이 다음 필터로 넘김
        //     (로그인, 회원가입 등 인증이 필요 없는 요청은 여기서 통과)
        if (token == null || !token.startsWith(&quot;Bearer &quot;)) {
            filterChain.doFilter(request, response);
            return;
        }

        // [3] &quot;Bearer &quot; 제거 후 순수 토큰 값만 추출
        //     &quot;Bearer eyJhbGc...&quot; &amp;rarr; &quot;eyJhbGc...&quot;
        token = token.substring(7);

        // [4] 토큰 유효성 검증 (서명 확인 + 만료 여부)
        if (jwtUtil.isValid(token)) {
            // [5] 토큰에서 이메일 추출 후 DB 조회
            String email = jwtUtil.getEmail(token);
            UserDetails user = customUserDetailsService.loadUserByUsername(email);

            // [6] 인증 객체 생성
            //     파라미터: (principal, credentials, authorities)
            //     credentials(비밀번호)는 이미 인증된 상태이므로 null
            Authentication auth = new UsernamePasswordAuthenticationToken(
                    user,
                    null,
                    user.getAuthorities()
            );

            // [7] SecurityContext에 인증 정보 저장
            //     이후 컨트롤러에서 @AuthenticationPrincipal로 꺼낼 수 있음
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // [8] 다음 필터로 넘김 (필수 &amp;mdash; 없으면 요청이 여기서 멈춤)
        filterChain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 요청에서 Authorization 헤더 꺼내기&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775394331705&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;String token = request.getHeader(&quot;Authorization&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 토큰이 없거나 Bearer가 아니면 그냥 통과&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 요청을 request, 응답할 객체를 response에 넣음&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인, 회원가입처럼 인증이 필요 없는 요청은 여기서 바로 다음으로 넘김&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775394396002&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (token == null || !token.startsWith(&quot;Bearer &quot;)) {
    filterChain.doFilter(request, response);
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. &quot;Bearer &quot; 제거 후 순수 토큰만 추출&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&quot;Bearer&amp;nbsp;eyJhbGc...&quot;&amp;nbsp;&amp;rarr;&amp;nbsp;&quot;eyJhbGc...&quot;&lt;/span&gt;&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775394474416&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;token = token.substring(7);&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;size18&quot;&gt;&lt;b&gt;4. 토큰 유효성 검증&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 토큰의 정보를 읽고 서명이 올바른지, 만료되지 않았는지 확인 (JwtUtil 사용)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유효하지 않으면 블록 자체를 건너뜀&lt;/p&gt;
&lt;pre id=&quot;code_1775394544136&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (jwtUtil.isValid(token)) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;5. 토큰에서 이메일 추출 &amp;rarr; DB에서 유저 조회&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 토큰에서 사용자 정보를 추출 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(JwtUtil 사용)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775394581231&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;String email = jwtUtil.getEmail(token);
UserDetails user = customUserDetailsService.loadUserByUsername(email);&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;size18&quot;&gt;&lt;b&gt;6. 인증 객체 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;UsernamePasswordAuthenticationToken(user:&lt;/b&gt; 인증된 유저 객체,&lt;b&gt; null:&lt;/b&gt; 비밀번호,&lt;b&gt; authorities:&lt;/b&gt; 권한 목록&lt;b&gt;)&lt;/b&gt; &lt;br /&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;비밀번호가 null인 것은 토큰으로 이미 증명이 되었으므로 불필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775394647068&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Authentication auth = new UsernamePasswordAuthenticationToken(
        user, null, user.getAuthorities()
);&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;size18&quot;&gt;&lt;b&gt;7. SecurityContext에 저장&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775394664789&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SecurityContextHolder.getContext().setAuthentication(auth);&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;size18&quot;&gt;&lt;b&gt;8. 다음 필터로 넘김&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775394669483&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;filterChain.doFilter(request, response);&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;p data-ke-size=&quot;size16&quot;&gt;그 후, 로그인을 구현하기 위해 DTO와 Controller를 다음과 같이 작성했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775387025579&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public record LoginDTO(
            @NotBlank
            String email,
            @NotBlank
            String password
    ){}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1775387036629&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Builder
    public record LoginDTO(
            Long memberId,
            String accessToken
    ){}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1775387051975&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostMapping(&quot;/login&quot;)
    public ApiResponse&amp;lt;UserResponseDTO.LoginDTO&amp;gt; login(@Valid @RequestBody UserRequestDTO.LoginDTO dto){
        return ApiResponse.onSuccess(GeneralSuccessCode.OK, userService.login(dto));
    }&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;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;입력된 비밀번호와 DB의 암호화된 비밀번호 비교&lt;/li&gt;
&lt;li&gt;User 엔티티를 CustomUserDetails로 감쌈&lt;/li&gt;
&lt;li&gt;accessToken 발급&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1775396223762&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public UserResponseDTO.LoginDTO login(UserRequestDTO.@Valid LoginDTO dto) {
        // [1] 이메일로 유저 조회 (없으면 예외)
        User user = userRepository.findByEmail(dto.email())
                .orElseThrow(() -&amp;gt; new GeneralException(UserErrorCode.USER_NOT_FOUND));

        // [2] 입력한 비밀번호와 DB의 암호화된 비밀번호 비교
        if (!passwordEncoder.matches(dto.password(), user.getPassword())) {
            throw new GeneralException(UserErrorCode.INVALID_PASSWORD);
        }

        // [3] CustomUserDetails로 감싸기
        CustomUserDetails userDetails = new CustomUserDetails(user);
        
        // [4] accessToken 발급 
        String accessToken = jwtUtil.createAccessToken(userDetails);

        return UserResponseDTO.LoginDTO.builder()
                .memberId(user.getId())
                .accessToken(accessToken)
                .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;&lt;b&gt;passwordEncoder.matches()&amp;nbsp;&lt;/b&gt;비밀번호는 DB에 암호화되어 저장되어 있어 단순 비교가 불가능하므로 .matches()로 비교&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;마지막으로, 위에서 설정한 Security Config 설정을 일부 수정해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 폼 로그인 화면 비활성화&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 기본적으로 /login 경로에 폼 로그인 화면을 제공&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 방식에서는 오히려 충돌의 가능성이 있으므로 비활성화&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775395533876&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.formLogin(AbstractHttpConfigurer::disable)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. JwtAuthFilter를 Spring Security 필터 체인에 등록&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 인증 필터가 실행되기 전에 JWT 검증을 먼저 처리해야 하므로 UsernamePasswordAuthenticationFilter 앞에 넣음&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775395641136&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)&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;size18&quot;&gt;&lt;b&gt;3. jwtAuthFilter() Bean&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtAuthFilter 객체를 Spring Bean으로 만들어 등록하는 메서드&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jwtAuthFilter()를 호출하면 new JwtAuthFilter(jwtUtil, customUserDetailService)로 객체를 만들어서 반환&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775395774417&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public JwtAuthFilter jwtAuthFilter() {
    return new JwtAuthFilter(jwtUtil, customUserDetailsService);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 처리되었는지 확인하기 위해 회원가입 후 로그인을 해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1462&quot; data-origin-height=&quot;743&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F1G29/dJMcahEcPE5/FmWTAPVJFiK9v2spC12zK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F1G29/dJMcahEcPE5/FmWTAPVJFiK9v2spC12zK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F1G29/dJMcahEcPE5/FmWTAPVJFiK9v2spC12zK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF1G29%2FdJMcahEcPE5%2FFmWTAPVJFiK9v2spC12zK0%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;1462&quot; height=&quot;743&quot; data-origin-width=&quot;1462&quot; data-origin-height=&quot;743&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1413&quot; data-origin-height=&quot;257&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bABFkm/dJMcabYkl86/t5hPwunTZJBtk5GI3u4XN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bABFkm/dJMcabYkl86/t5hPwunTZJBtk5GI3u4XN0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bABFkm/dJMcabYkl86/t5hPwunTZJBtk5GI3u4XN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbABFkm%2FdJMcabYkl86%2Ft5hPwunTZJBtk5GI3u4XN0%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;1413&quot; height=&quot;257&quot; data-origin-width=&quot;1413&quot; data-origin-height=&quot;257&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1497&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bA7RbI/dJMcacJIzmm/f2xL0AJ6flqr9RLUziEJe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bA7RbI/dJMcacJIzmm/f2xL0AJ6flqr9RLUziEJe0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bA7RbI/dJMcacJIzmm/f2xL0AJ6flqr9RLUziEJe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbA7RbI%2FdJMcacJIzmm%2Ff2xL0AJ6flqr9RLUziEJe0%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;1497&quot; height=&quot;654&quot; data-origin-width=&quot;1497&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;228&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o4Trf/dJMcacJIzmR/UAGa0rouSfIlMYXITY5uJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o4Trf/dJMcacJIzmR/UAGa0rouSfIlMYXITY5uJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o4Trf/dJMcacJIzmR/UAGa0rouSfIlMYXITY5uJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo4Trf%2FdJMcacJIzmR%2FUAGa0rouSfIlMYXITY5uJ0%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;1454&quot; height=&quot;228&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;228&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위 사진처럼 로그인 시, accessToken을 얻을 수 있는데, 이 토큰을 Swagger 우측 상단에 있는 authorize 버튼에 등록하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;682&quot; data-origin-height=&quot;318&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7q4zq/dJMcaiJRy2I/KKjmu6n75qe6ODusPjprr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7q4zq/dJMcaiJRy2I/KKjmu6n75qe6ODusPjprr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7q4zq/dJMcaiJRy2I/KKjmu6n75qe6ODusPjprr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7q4zq%2FdJMcaiJRy2I%2FKKjmu6n75qe6ODusPjprr1%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;682&quot; height=&quot;318&quot; data-origin-width=&quot;682&quot; data-origin-height=&quot;318&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;테스트를 위해 이전에 작성한 Ping API를 실행해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인증 전&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;인증 전에는 401 인증이 필요하다는 에러가 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;279&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5nh1q/dJMcadPiYfq/dsoeLhSBml5fqu0oOUNGj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5nh1q/dJMcadPiYfq/dsoeLhSBml5fqu0oOUNGj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5nh1q/dJMcadPiYfq/dsoeLhSBml5fqu0oOUNGj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5nh1q%2FdJMcadPiYfq%2FdsoeLhSBml5fqu0oOUNGj1%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;1424&quot; height=&quot;279&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;279&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인증 후&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 후에는 요청이 정상적으로 처리되는 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1418&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/91kct/dJMcabRxvDO/eLoGj3gHQ4MddcJA770Qx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/91kct/dJMcabRxvDO/eLoGj3gHQ4MddcJA770Qx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/91kct/dJMcabRxvDO/eLoGj3gHQ4MddcJA770Qx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F91kct%2FdJMcabRxvDO%2FeLoGj3gHQ4MddcJA770Qx0%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;1418&quot; height=&quot;200&quot; data-origin-width=&quot;1418&quot; data-origin-height=&quot;200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;(추가) AuthenticationEntryPoint와 JWT 인증 예외 처리 통일&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring security는 인증 실패 시, 아래와 같은 형식으로 응답한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;279&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5nh1q/dJMcadPiYfq/dsoeLhSBml5fqu0oOUNGj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5nh1q/dJMcadPiYfq/dsoeLhSBml5fqu0oOUNGj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5nh1q/dJMcadPiYfq/dsoeLhSBml5fqu0oOUNGj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5nh1q%2FdJMcadPiYfq%2FdsoeLhSBml5fqu0oOUNGj1%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;1424&quot; height=&quot;279&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;279&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;하지만, 프로젝트의 나머지 API 에러 응답은 전부 아래처럼 통일된 형식을 사용하고 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1418&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/91kct/dJMcabRxvDO/eLoGj3gHQ4MddcJA770Qx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/91kct/dJMcabRxvDO/eLoGj3gHQ4MddcJA770Qx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/91kct/dJMcabRxvDO/eLoGj3gHQ4MddcJA770Qx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F91kct%2FdJMcabRxvDO%2FeLoGj3gHQ4MddcJA770Qx0%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;1418&quot; height=&quot;200&quot; data-origin-width=&quot;1418&quot; data-origin-height=&quot;200&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;이를 일관성을 통일하기 위해&amp;nbsp;&lt;b&gt;AuthenticationEntryPoint&lt;/b&gt;를 직접 구현해 응답을 통일해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;*AuthenticationEntryPoint &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증되지 않은 요청이 보호된 리소스에 접근했을 때, Spring Security가 호출하는 핸들러&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;commence() 메서드 하나만 구현하면 되며, 이 안에서 응답을 직접 작성하는 형태&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;case 1: 토큰이 없을 때&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트: Authorization 헤더 없이 요청&lt;/li&gt;
&lt;li&gt;JWTAuthFilter: 헤더가 null이므로 인증 처리 없이 다음 필터로 통과&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Spring Security: SecurityContext에 인증 정보가 없음을 감지 후, AuthenticationEntryPointImpl.commece() 호출&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;case 2: 토큰이 있지만 유효하지 않을 때(만료, 위조 등)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트: 만료되거나 위조된 토큰으로 요청&lt;/li&gt;
&lt;li&gt;JWTAuthFilter: 토큰 파싱 시도 &amp;rarr; JwtException 발생&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;JWTAuthFilter:&lt;span&gt; JwtException을 BadCredentialsException으로 변환해 throw&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;ExceptionTranslationFilter: AuthenticationException 감지해 AuthenticationEntryPointImpl.commece() &lt;/span&gt;&lt;/span&gt;호출&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 케이스 모두 최종 응답은 AuthenticationEntryPointImpl 에서 처리되며,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 없음/토큰이 유효하지 않음 모두 동일한 형식의 401 응답을 반환하게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1423&quot; data-origin-height=&quot;435&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czOQbv/dJMcadBKoWz/7KkJmjCDKr8AUiR4vk1zb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czOQbv/dJMcadBKoWz/7KkJmjCDKr8AUiR4vk1zb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czOQbv/dJMcadBKoWz/7KkJmjCDKr8AUiR4vk1zb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczOQbv%2FdJMcadBKoWz%2F7KkJmjCDKr8AUiR4vk1zb1%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;1423&quot; height=&quot;435&quot; data-origin-width=&quot;1423&quot; data-origin-height=&quot;435&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;Trouble Shooting &lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;문제 상황&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt; &lt;span style=&quot;text-align: start;&quot;&gt;JWT 로그인을 구현한 뒤 Swagger에서 테스트하려고 했는데, 토큰을 입력할 수 있는 &lt;/span&gt;Authorize 버튼이 보이지 않음&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;예상 원인 1&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;Spring Boot는 기본적으로 MVC 관련 설정을 자동으로 해줌 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;@EnableWebMvc 를 붙이면 그 자동 설정이 비활성화되는데 Swagger 또한 그 자동 설정에 의존함&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;예상 원인 2&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;SecurityScheme은 Swagger에게 &quot;이 API는 JWT Bearer 인증을 사용한다&quot;고 알려 주는 설정 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;SecurityRequirement는 &quot;모든 API에 그 인증을 적용한다&quot;고 지시하는 설정&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;이 두 개가 다 없었음&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;결과&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;예상 원인을 모두 수정 후, 서버를 재시작하여 확인하니 정상적으로 Swagger 에서 Authorize 버튼이 활성화됨 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;로그인으로 발급받은 토큰 입력 시, 이후 모든 API 요청에 자동으로 Authorization: Bearer {token} 헤더가 붙게 됨&amp;nbsp;&lt;/span&gt;&lt;/p&gt;</description>
      <category>Springboot</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/63</guid>
      <comments>https://coding-cherry.tistory.com/63#entry63comment</comments>
      <pubDate>Tue, 28 Apr 2026 20:09:29 +0900</pubDate>
    </item>
    <item>
      <title>에러 핸들링과 안전성</title>
      <link>https://coding-cherry.tistory.com/62</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 에러 분류&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;HTTP 상태 코드 (HTTP Status Code)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청의 성공/실패 여부를 서버에서 알려주는 코드&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 호출 시, HTTP Status Code로 API 처리 결과를 받고 HTTP Response Body로 응답 값을 받음&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;정상 호출: 200 반환, 각 API 별 지정된 포맷의 결과값을 받음&amp;nbsp;&lt;/li&gt;
&lt;li&gt;비정상 호출: 400/500 반환, 각 API 서버별 에러 코드와 에러 메세지 값을 받음&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;2XX 성공 상태 코드&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;200 OK&lt;/span&gt;&amp;nbsp;&lt;/b&gt;가장 일반적인 성공 응답, GET/PUT/PATCH 요청 성공, 응답 본문에 데이터가 포함&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;201 Created&lt;/span&gt;&amp;nbsp;&lt;/b&gt;리소스 생성 성공(POST), Location 헤더에 생성된 리소스 URL 포함&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;202 Accepted&lt;/span&gt;&amp;nbsp;&lt;/b&gt;요청 접수됨(처리는 아직 완료 아님), 비동기 작업에서 사용&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;204 No Content&lt;/span&gt;&amp;nbsp;&lt;/b&gt;성공했으나 응답할 본문이 없음, DELETE 성공 또는 PUT 업데이트 성공&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;4XX 클라이언트 에러 : 예측 가능 에러 (Expected)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;개발자가 미리 예상하고 대비할 수 있는 에러 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;개발자가 구체적인 안내 가능, 프론트에서 사전 검증으로 예방 가능&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;400 Bad Request&lt;/span&gt;&amp;nbsp;&lt;/b&gt;&lt;/span&gt;잘못된 요청(문법 오류, 필수값 누락 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;401 Unauthorized&lt;/span&gt;&amp;nbsp;&lt;/b&gt;인증 안 됨(로그인 필요), 토큰 없음, 토큰 만료, 잘못된 비밀번호&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;403 Forbidden&amp;nbsp;&lt;/b&gt;&lt;/span&gt;인증은 됐으나 권한이 없음&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;404 Not Found&lt;/span&gt;&amp;nbsp;&lt;/b&gt;리소스가 존재하지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;409 Conflict&lt;/span&gt;&amp;nbsp;&lt;/b&gt;리소스 상태 충돌&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;422 Unprocessable Entity&lt;/span&gt;&amp;nbsp;&lt;/b&gt;&lt;/span&gt;요청 형식은 맞으나 비즈니스 로직 검증 실패&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;429 Too Many Requests&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;Rate Limit 초과(요청이 너무 많음)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;5XX 서버 에러 : 예측 불가능 에러 (Unexpected)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;런타임에 갑자기 발생해 미리 대비하기 어려운 에러&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;일반적인 안내만 가능, 자동 재시도 필요, 프론트에서 예방 불가능&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;500 Internal Server Error&lt;/span&gt;&amp;nbsp;&lt;/b&gt;가장 일반적인 서버 에러, 서버에서 예상치 못한 오류 발생&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;502 Bad Gateway&lt;/span&gt;&amp;nbsp;&lt;/b&gt;게이트웨이/프록시 서버가 잘못된 응답 받음&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;503 Service Unavailable&lt;/span&gt;&amp;nbsp;&lt;/b&gt;서버가 일시적으로 요청 처리 불가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;504 Gateway Timeout&lt;/span&gt;&amp;nbsp;&lt;/b&gt;게이트웨이 서버가 서버 응답을 시간 내 못 받음&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 예측 가능한 에러는 구체적인 안내를 통해 해결하고 예측 불가능한 에러는 일반적 메세지를 표시 후, 재시도를 유도한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776957315297&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function handleApiError(error: AxiosError) {
  // 1. 예측 가능 에러
  if (error.response?.status === 401) {
    clearAuth();
    router.push('/login');
    return;
  }
  
  if (error.response?.status === 422) {
    showValidationErrors(error.response.data.errors);
    return;
  }
  
  // 2. 예측 불가능 에러
  if (error.response?.status &amp;gt;= 500) {
    Sentry.captureException(error);
    showToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요');
    return;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://sanghaklee.tistory.com/61&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://sanghaklee.tistory.com/61&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776937762751&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;REST API 관점에서 바라보는 HTTP 상태 코드(HTTP status code)&quot; data-og-description=&quot;REST API 관점에서 바라보는 HTTP 상태 코드(HTTP status code) TOC Introduction HTTP 와 REST HTTP Status Code 2XX Success 4.1. 200 OK 4.2. 201 Created 4.3. 202 Accepted 4.4. 204 No Content 4XX Client errors 5.1. 400 Bad Request 5.2. 401 Unauthori&quot; data-og-host=&quot;sanghaklee.tistory.com&quot; data-og-source-url=&quot;https://sanghaklee.tistory.com/61&quot; data-og-url=&quot;https://sanghaklee.tistory.com/61&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/by5u3A/dJMb9bv4J66/rAXN5ThqFZnHjL94j4KAI1/img.png?width=800&amp;amp;height=350&amp;amp;face=0_0_800_350,https://scrap.kakaocdn.net/dn/djwlcc/dJMb82ePsCi/ldohjSFCzxK2FeRthlGUX0/img.png?width=800&amp;amp;height=350&amp;amp;face=0_0_800_350,https://scrap.kakaocdn.net/dn/eluSpu/dJMb9iaTBx0/cfIs2TRneAaAOLF7oTLCS1/img.png?width=911&amp;amp;height=399&amp;amp;face=0_0_911_399&quot;&gt;&lt;a href=&quot;https://sanghaklee.tistory.com/61&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://sanghaklee.tistory.com/61&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/by5u3A/dJMb9bv4J66/rAXN5ThqFZnHjL94j4KAI1/img.png?width=800&amp;amp;height=350&amp;amp;face=0_0_800_350,https://scrap.kakaocdn.net/dn/djwlcc/dJMb82ePsCi/ldohjSFCzxK2FeRthlGUX0/img.png?width=800&amp;amp;height=350&amp;amp;face=0_0_800_350,https://scrap.kakaocdn.net/dn/eluSpu/dJMb9iaTBx0/cfIs2TRneAaAOLF7oTLCS1/img.png?width=911&amp;amp;height=399&amp;amp;face=0_0_911_399');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;REST API 관점에서 바라보는 HTTP 상태 코드(HTTP status code)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;REST API 관점에서 바라보는 HTTP 상태 코드(HTTP status code) TOC Introduction HTTP 와 REST HTTP Status Code 2XX Success 4.1. 200 OK 4.2. 201 Created 4.3. 202 Accepted 4.4. 204 No Content 4XX Client errors 5.1. 400 Bad Request 5.2. 401 Unauthori&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;sanghaklee.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://naver.github.io/naver-openapi-guide/errorcode.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://naver.github.io/naver-openapi-guide/errorcode.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776937766465&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;네이버 오픈 API 에러 코드 목록&quot; data-og-description=&quot;네이버 오픈 API 에러 코드 목록 API를 호출하면 HTTP Status Code로 API 처리 결과를 받고 HTTP Response Body로 응답 값을 받습니다. 응답 값은 OpenAPI에 따라 XML또는 JSON형식이 될 수 있습니다. 따라서 API 응&quot; data-og-host=&quot;naver.github.io&quot; data-og-source-url=&quot;https://naver.github.io/naver-openapi-guide/errorcode.html&quot; data-og-url=&quot;https://naver.github.io/naver-openapi-guide/errorcode.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://naver.github.io/naver-openapi-guide/errorcode.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://naver.github.io/naver-openapi-guide/errorcode.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;네이버 오픈 API 에러 코드 목록&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;네이버 오픈 API 에러 코드 목록 API를 호출하면 HTTP Status Code로 API 처리 결과를 받고 HTTP Response Body로 응답 값을 받습니다. 응답 값은 OpenAPI에 따라 XML또는 JSON형식이 될 수 있습니다. 따라서 API 응&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;naver.github.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ErrorBoundary 설계 패턴&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Error Boundary&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 컴포넌트 트리에서 발생한 JavaScript 에러를 포착하는 컴포넌트&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 발생 시, 대체 UI(Fallback UI)를 보여주기 위한 컴포넌트&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 직접 구현 (Class Component)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ErrorBoundary 클래스를 직접 구현하는 방법&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;getDerivedStateFromError()&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;에러가 발생한 후, UI 렌더링에 사용(Fallback UI 렌더링용)&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;componentDidCatch() &lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;c&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;ommit 단계에서 호출, &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;에러 정보 기록에 사용&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1776957998389&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI를 보여주도록 상태 업데이트
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 로깅 (Sentry 등)
    console.error('Error caught:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return &amp;lt;h1&amp;gt;문제가 발생했습니다.&amp;lt;/h1&amp;gt;;
    }

    return this.props.children;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 후, 컴포넌트를 감싸는 방식으로 애플리케이션에서 에러 바운더리 사용 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MyComponent나 하위 컴포넌트 중 어떤 컴포넌트가 렌더링 중 에러를 발생시킨다면, 에러 바운더리가 이를 잡아내 기록한 뒤 대체 UI를 렌더링한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776958964162&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;ErrorBoundary&amp;gt;
  &amp;lt;MyComponent /&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. react-error-boundary 라이브러리 사용&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/react-error-boundary?activeTab=readme&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.npmjs.com/package/react-error-boundary?activeTab=readme&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제공하는 라이브러리를 사용하는 방법&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777186038270&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pnpm install react-error-boundary&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;라이브러리를 사용하면 별도의 클래스 생성 없이, import를 통해 라이브러리를 호출해 에러 발생 시, fallback UI를 호출할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1777186397384&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { ErrorBoundary } from 'react-error-boundary';

&amp;lt;ErrorBoundary fallback={&amp;lt;div&amp;gt;에러가 발생했습니다&amp;lt;/div&amp;gt;}&amp;gt;
  &amp;lt;MyComponent /&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리에서는 추가적으로 아래와 같은 기능이 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;292&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d13KAF/dJMcabqtzKn/2k8ByUI9rODUlDHp6SBHgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d13KAF/dJMcabqtzKn/2k8ByUI9rODUlDHp6SBHgK/img.png&quot; data-alt=&quot;resetErrorBoundary() 사용 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d13KAF/dJMcabqtzKn/2k8ByUI9rODUlDHp6SBHgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd13KAF%2FdJMcabqtzKn%2F2k8ByUI9rODUlDHp6SBHgK%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;691&quot; height=&quot;257&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;292&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;resetErrorBoundary() 사용 예시&lt;/figcaption&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;FallbackComponent&amp;nbsp;&lt;/b&gt;에러 UI 정의(error, resetErrorBoundary 자동 전달)
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;resetErrorBoundary()&amp;nbsp;&lt;/b&gt;호출 시, 에러 상태 초기화 및 자식 컴포넌트 재 렌더링(다시 시도의 역할)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onReset&amp;nbsp;&lt;/b&gt;다시 시도 클릭 시, 캐시/상태 초기화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onError&amp;nbsp;&lt;/b&gt;에러 발생 시, Sentry 같은 모니터링 도구에 자동 전송&lt;/li&gt;
&lt;li&gt;&lt;b&gt;resetKeys&amp;nbsp;&lt;/b&gt;배열 안 값 변경되면 에러 상태 자동 리셋&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1777186519229&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { ErrorBoundary } from 'react-error-boundary';

// 1. FallbackComponent: 에러 UI
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h2&amp;gt;문제가 발생했습니다&amp;lt;/h2&amp;gt;
      &amp;lt;p&amp;gt;{error.message}&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={resetErrorBoundary}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

function App() {
  const [userId, setUserId] = useState(1);
  
  return (
    &amp;lt;ErrorBoundary
      // 1. FallbackComponent: 에러 발생 시 보여줄 컴포넌트
      FallbackComponent={ErrorFallback}
      
      // 2. onReset: 리셋 버튼 클릭 시 실행할 추가 로직
      onReset={() =&amp;gt; {
        queryClient.clear(); // 캐시 초기화
      }}
      
      // 3. onError: 에러 발생 시 로깅 (Sentry 전송)
      onError={(error, errorInfo) =&amp;gt; {
        Sentry.captureException(error);
      }}
      
      // 4. resetKeys: userId 변경 시 에러 자동 리셋
      resetKeys={[userId]}
    &amp;gt;
      &amp;lt;UserProfile userId={userId} /&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@wjdals189/React%EC%9D%98-Error-Boundary-%EA%B7%B8%EB%A6%AC%EA%B3%A0-react-error-boundary-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@wjdals189/React%EC%9D%98-Error-Boundary-%EA%B7%B8%EB%A6%AC%EA%B3%A0-react-error-boundary-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777187497786&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;React의 Error Boundary 그리고 react-error-boundary  라이브러리&quot; data-og-description=&quot;Error Boundary란 무엇이고 어디서 어떻게 쓰여지는지 궁금해서 찾아보며 이해하는 글에러 경계는 컴포넌트 트리가 깨지는 대신 자식 컴포넌트 트리에서 에러를 잡아내고, 이러한 에러의 로그를 남&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@wjdals189/React%EC%9D%98-Error-Boundary-%EA%B7%B8%EB%A6%AC%EA%B3%A0-react-error-boundary-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC&quot; data-og-url=&quot;https://velog.io/@wjdals189/React의-Error-Boundary-그리고-react-error-boundary-라이브러리&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bIfG1r/dJMb83SlFsJ/VTXsgtGgGe0uItaO2Awrik/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720,https://scrap.kakaocdn.net/dn/bzRCKT/dJMb9aKHSah/kTSI93uAm0TOkFxIucvUuK/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720,https://scrap.kakaocdn.net/dn/8qSBH/dJMb86O4w69/zP9xeXukup1qn4k5sTR8Ek/img.jpg?width=1170&amp;amp;height=1499&amp;amp;face=0_0_1170_1499&quot;&gt;&lt;a href=&quot;https://velog.io/@wjdals189/React%EC%9D%98-Error-Boundary-%EA%B7%B8%EB%A6%AC%EA%B3%A0-react-error-boundary-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@wjdals189/React%EC%9D%98-Error-Boundary-%EA%B7%B8%EB%A6%AC%EA%B3%A0-react-error-boundary-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bIfG1r/dJMb83SlFsJ/VTXsgtGgGe0uItaO2Awrik/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720,https://scrap.kakaocdn.net/dn/bzRCKT/dJMb9aKHSah/kTSI93uAm0TOkFxIucvUuK/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720,https://scrap.kakaocdn.net/dn/8qSBH/dJMb86O4w69/zP9xeXukup1qn4k5sTR8Ek/img.jpg?width=1170&amp;amp;height=1499&amp;amp;face=0_0_1170_1499');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;React의 Error Boundary 그리고 react-error-boundary 라이브러리&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary란 무엇이고 어디서 어떻게 쓰여지는지 궁금해서 찾아보며 이해하는 글에러 경계는 컴포넌트 트리가 깨지는 대신 자식 컴포넌트 트리에서 에러를 잡아내고, 이러한 에러의 로그를 남&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Error Boundary가 잡을 수 있는 에러와 없는 에러 &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;잡을 수 있는 에러&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;렌더링 중 발생한 에러&amp;nbsp;&lt;/li&gt;
&lt;li&gt;생명주기 메서드 내 에러&lt;/li&gt;
&lt;li&gt;하위 컴포넌트의 constructor 에러&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;잡을 수 없는 에러&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;이벤트 핸들러 (onClick, onChange 등)&lt;/li&gt;
&lt;li&gt;비동기 코드 (setTimeout, Promise, async/await)&lt;/li&gt;
&lt;li&gt;서버 사이드 렌더링&lt;/li&gt;
&lt;li&gt;Error Boundary 자체에서 발생한 에러&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wikidocs.net/197630&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wikidocs.net/197630&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776958421861&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;04) 에러 바운더리와 컴포넌트 에러 처리&quot; data-og-description=&quot;[TOC] ## React에서의 에러 바운더리 소개 어떤 애플리케이션에서든 에러를 우아하게 처리하는 것은 원활한 사용자 경험을 제공하기 위해 중요합니다. React는 컴포넌&amp;hellip;&quot; data-og-host=&quot;wikidocs.net&quot; data-og-source-url=&quot;https://wikidocs.net/197630&quot; data-og-url=&quot;https://wikidocs.net/197630&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/lq8fO/dJMb9jOpp9N/1yGNMntcL2olEjk7WJs4m0/img.png?width=308&amp;amp;height=400&amp;amp;face=0_0_308_400&quot;&gt;&lt;a href=&quot;https://wikidocs.net/197630&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wikidocs.net/197630&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/lq8fO/dJMb9jOpp9N/1yGNMntcL2olEjk7WJs4m0/img.png?width=308&amp;amp;height=400&amp;amp;face=0_0_308_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;04) 에러 바운더리와 컴포넌트 에러 처리&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;[TOC] ## React에서의 에러 바운더리 소개 어떤 애플리케이션에서든 에러를 우아하게 처리하는 것은 원활한 사용자 경험을 제공하기 위해 중요합니다. React는 컴포넌&amp;hellip;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wikidocs.net&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@coddingyun/Error-Boundary%EC%97%90%EB%9F%AC-%EB%B0%94%EC%9A%B4%EB%8D%94%EB%A6%AC%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@coddingyun/Error-Boundary%EC%97%90%EB%9F%AC-%EB%B0%94%EC%9A%B4%EB%8D%94%EB%A6%AC%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776958686691&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Error Boundary(에러 바운더리)로 우아하게 에러 처리하기&quot; data-og-description=&quot;\*\*공식 문서(https://react-ko.dev/reference/react/Component&amp;gt; 기본적으로 애플리케이션이 렌더링 도중 에러를 발생시키면 React는 화면에서 해당 UI를 제거합니다. 이를 방지하기 위해 UI의 일부를 에러 경계(&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@coddingyun/Error-Boundary%EC%97%90%EB%9F%AC-%EB%B0%94%EC%9A%B4%EB%8D%94%EB%A6%AC%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://velog.io/@coddingyun/Error-Boundary에러-바운더리로-우아하게-에러-처리하기&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Lpu6E/dJMb82ePuHu/kGvOVgRkm2JUdf0UvRbZV0/img.png?width=960&amp;amp;height=540&amp;amp;face=0_0_960_540,https://scrap.kakaocdn.net/dn/dIRlCr/dJMb82MFAud/Oiu1XwospoV7VagkiWPd0k/img.png?width=960&amp;amp;height=540&amp;amp;face=0_0_960_540,https://scrap.kakaocdn.net/dn/cZ6rJV/dJMb9bv4L7Z/ZqfACb8byRAq10Rg2LwxL1/img.png?width=1810&amp;amp;height=1750&amp;amp;face=0_0_1810_1750&quot;&gt;&lt;a href=&quot;https://velog.io/@coddingyun/Error-Boundary%EC%97%90%EB%9F%AC-%EB%B0%94%EC%9A%B4%EB%8D%94%EB%A6%AC%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@coddingyun/Error-Boundary%EC%97%90%EB%9F%AC-%EB%B0%94%EC%9A%B4%EB%8D%94%EB%A6%AC%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Lpu6E/dJMb82ePuHu/kGvOVgRkm2JUdf0UvRbZV0/img.png?width=960&amp;amp;height=540&amp;amp;face=0_0_960_540,https://scrap.kakaocdn.net/dn/dIRlCr/dJMb82MFAud/Oiu1XwospoV7VagkiWPd0k/img.png?width=960&amp;amp;height=540&amp;amp;face=0_0_960_540,https://scrap.kakaocdn.net/dn/cZ6rJV/dJMb9bv4L7Z/ZqfACb8byRAq10Rg2LwxL1/img.png?width=1810&amp;amp;height=1750&amp;amp;face=0_0_1810_1750');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary(에러 바운더리)로 우아하게 에러 처리하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;\*\*공식 문서(https://react-ko.dev/reference/react/Component&amp;gt; 기본적으로 애플리케이션이 렌더링 도중 에러를 발생시키면 React는 화면에서 해당 UI를 제거합니다. 이를 방지하기 위해 UI의 일부를 에러 경계(&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;선언적 vs 명령적 처리 전략 비교&amp;nbsp;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;명령적 에러 처리(Imperative)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 처리 과정을&amp;nbsp;&lt;b&gt;단계별로 명시&lt;/b&gt;하는 방식&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;어떻게(How) &lt;/b&gt;처리할지 직접 작성&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 조건별로 다른 처리 가능&lt;/li&gt;
&lt;li&gt;코드가 길고 복잡하며 에러 처리 로직이 비즈니스 로직과 섞여 재사용성이 낮음&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1777187100566&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. 이벤트 핸들러 (Error Boundary가 못 잡음)
const handleSubmit = async () =&amp;gt; {
  try {
    await submitForm();
  } catch (error) {
    showToast('제출 실패');
  }
};

// 2. 조건별 다른 처리가 필요할 때
try {
  await fetchData();
} catch (error) {
  if (error.code === 'AUTH_ERROR') {
    router.push('/login');
  } else if (error.code === 'NETWORK_ERROR') {
    showOfflineMode();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;선언적 에러 처리(Declarative)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 발생 시&amp;nbsp;&lt;b&gt;무엇을(What)&amp;nbsp;&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&gt;코드가 간결하고 읽기 쉽고 에러 처리 로직이 분리되어 재사용성이 높음&lt;/li&gt;
&lt;li&gt;세밀한 제어가 어렵고 ErrorBoundary가 못잡는 에러 존재&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1777187216607&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. 렌더링 중 에러
&amp;lt;ErrorBoundary FallbackComponent={ErrorUI}&amp;gt;
  &amp;lt;MyComponent /&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;

// 2. 비동기 데이터 로딩
&amp;lt;ErrorBoundary&amp;gt;
  &amp;lt;Suspense fallback={&amp;lt;Loading /&amp;gt;}&amp;gt;
    &amp;lt;UserData /&amp;gt;
  &amp;lt;/Suspense&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 62.907%; height: 168px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;명령적 처리&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;선언적 처리&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;방식&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;try-catch, if-else, 상태 관리&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;Error Boundary, Suspense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;에러 처리 위치&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;컴포넌트 내부&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;컴포넌트 외부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;코드 길이&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;길고 복잡함&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;짧고 간결함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;가독성&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;비즈니스 로직과 섞임&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;분리되어 깔끔&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;재사용성&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;낮음 (매번 작성해야 함)&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;높음 (한 번만 설정)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;제어 수준&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;세밀한 제어 가능&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;제한적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.6589%; height: 21px; text-align: center;&quot;&gt;사용 예&lt;/td&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;이벤트 핸들러, 특수한 경우&lt;/td&gt;
&lt;td style=&quot;width: 23.5659%; height: 21px; text-align: center;&quot;&gt;렌더링 에러, 비동기 데이터&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 렌더링은 선언적 에러 처리를 이벤트는 명령적 에러 처리를 하는 것이 좋다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@seyoung8239/Imperative-vs-Declarative-Programming&quot;&gt;https://velog.io/@seyoung8239/Imperative-vs-Declarative-Programming&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777187002682&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Imperative vs Declarative Programming&quot; data-og-description=&quot;프로그래머스에서 과제형 테스트를 연습하다 다음과 같은 요구사항을 만났다.&amp;quot;가급적 컴포넌트 형태로 추상화 하세요.&amp;quot;해설에서는 이 요구사항이 DOM에 접근하는 부분을 최소화하고, 명령형 프&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@seyoung8239/Imperative-vs-Declarative-Programming&quot; data-og-url=&quot;https://velog.io/@seyoung8239/Imperative-vs-Declarative-Programming&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Ng4xG/dJMb8ZvEerC/SfWhqlW6XH72eKiXftF48k/img.png?width=950&amp;amp;height=500&amp;amp;face=0_0_950_500&quot;&gt;&lt;a href=&quot;https://velog.io/@seyoung8239/Imperative-vs-Declarative-Programming&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@seyoung8239/Imperative-vs-Declarative-Programming&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Ng4xG/dJMb8ZvEerC/SfWhqlW6XH72eKiXftF48k/img.png?width=950&amp;amp;height=500&amp;amp;face=0_0_950_500');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Imperative vs Declarative Programming&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프로그래머스에서 과제형 테스트를 연습하다 다음과 같은 요구사항을 만났다.&quot;가급적 컴포넌트 형태로 추상화 하세요.&quot;해설에서는 이 요구사항이 DOM에 접근하는 부분을 최소화하고, 명령형 프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/react-query-2/#:~:text=Error%20Boundary%EB%8A%94%20React%20Component%20%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C%20%EC%97%90%EB%9F%AC%EA%B0%80%20%EB%B0%9C%EC%83%9D%ED%95%9C,%EB%8C%80%EC%8B%A0%20%EB%AF%B8%EB%A6%AC%20%EC%A0%95%EC%9D%98%ED%95%B4%20%EB%91%94%20Fallback%20UI%EB%A5%BC%20%ED%99%94%EB%A9%B4%EC%97%90&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tech.kakaopay.com/post/react-query-2/#:~:text=Error%20Boundary%EB%8A%94%20React%20Component%20%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C%20%EC%97%90%EB%9F%AC%EA%B0%80%20%EB%B0%9C%EC%83%9D%ED%95%9C,%EB%8C%80%EC%8B%A0%20%EB%AF%B8%EB%A6%AC%20%EC%A0%95%EC%9D%98%ED%95%B4%20%EB%91%94%20Fallback%20UI%EB%A5%BC%20%ED%99%94%EB%A9%B4%EC%97%90&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776957872013&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;React Query와 함께 Concurrent UI Pattern을 도입하는 방법 | 카카오페이 기술 블로그&quot; data-og-description=&quot;카카오페이에서 React Query를 활용하여 Concurrent UI Pattern을 도입한 사례에 대해 소개합니다. 이 글은 연작 중 2편에 해당합니다. 1편: 카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유, 2&quot; data-og-host=&quot;tech.kakaopay.com&quot; data-og-source-url=&quot;https://tech.kakaopay.com/post/react-query-2/#:~:text=Error%20Boundary%EB%8A%94%20React%20Component%20%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C%20%EC%97%90%EB%9F%AC%EA%B0%80%20%EB%B0%9C%EC%83%9D%ED%95%9C,%EB%8C%80%EC%8B%A0%20%EB%AF%B8%EB%A6%AC%20%EC%A0%95%EC%9D%98%ED%95%B4%20%EB%91%94%20Fallback%20UI%EB%A5%BC%20%ED%99%94%EB%A9%B4%EC%97%90&quot; data-og-url=&quot;https://tech.kakaopay.com/post/react-query-2/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/49Fvw/dJMb89556Xd/tO4yl9fg5BqbBtPB6Dilb1/img.png?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319,https://scrap.kakaocdn.net/dn/bTfDgf/dJMb9hC3L73/KrqHFOouvr59IWdko54Kdk/img.png?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319,https://scrap.kakaocdn.net/dn/ERy1S/dJMb86O4fya/GgdueBocVuHkKcFR5BNVM1/img.jpg?width=790&amp;amp;height=1183&amp;amp;face=0_0_790_1183&quot;&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/react-query-2/#:~:text=Error%20Boundary%EB%8A%94%20React%20Component%20%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C%20%EC%97%90%EB%9F%AC%EA%B0%80%20%EB%B0%9C%EC%83%9D%ED%95%9C,%EB%8C%80%EC%8B%A0%20%EB%AF%B8%EB%A6%AC%20%EC%A0%95%EC%9D%98%ED%95%B4%20%EB%91%94%20Fallback%20UI%EB%A5%BC%20%ED%99%94%EB%A9%B4%EC%97%90&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://tech.kakaopay.com/post/react-query-2/#:~:text=Error%20Boundary%EB%8A%94%20React%20Component%20%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C%20%EC%97%90%EB%9F%AC%EA%B0%80%20%EB%B0%9C%EC%83%9D%ED%95%9C,%EB%8C%80%EC%8B%A0%20%EB%AF%B8%EB%A6%AC%20%EC%A0%95%EC%9D%98%ED%95%B4%20%EB%91%94%20Fallback%20UI%EB%A5%BC%20%ED%99%94%EB%A9%B4%EC%97%90&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/49Fvw/dJMb89556Xd/tO4yl9fg5BqbBtPB6Dilb1/img.png?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319,https://scrap.kakaocdn.net/dn/bTfDgf/dJMb9hC3L73/KrqHFOouvr59IWdko54Kdk/img.png?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319,https://scrap.kakaocdn.net/dn/ERy1S/dJMb86O4fya/GgdueBocVuHkKcFR5BNVM1/img.jpg?width=790&amp;amp;height=1183&amp;amp;face=0_0_790_1183');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;React Query와 함께 Concurrent UI Pattern을 도입하는 방법 | 카카오페이 기술 블로그&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;카카오페이에서 React Query를 활용하여 Concurrent UI Pattern을 도입한 사례에 대해 소개합니다. 이 글은 연작 중 2편에 해당합니다. 1편: 카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유, 2&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;tech.kakaopay.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/62</guid>
      <comments>https://coding-cherry.tistory.com/62#entry62comment</comments>
      <pubDate>Sun, 26 Apr 2026 16:32:46 +0900</pubDate>
    </item>
    <item>
      <title>useMemo, useCallback을 활용한 상세 페이지 구현하기 (w. TMDB)</title>
      <link>https://coding-cherry.tistory.com/61</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1522&quot; data-origin-height=&quot;829&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpFSmH/dJMcagykBer/T0x8KzZOmnsy95vsRPg8JK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpFSmH/dJMcagykBer/T0x8KzZOmnsy95vsRPg8JK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpFSmH/dJMcagykBer/T0x8KzZOmnsy95vsRPg8JK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpFSmH%2FdJMcagykBer%2FT0x8KzZOmnsy95vsRPg8JK%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;821&quot; height=&quot;829&quot; data-origin-width=&quot;1522&quot; data-origin-height=&quot;829&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;이전에 구현한 무한 스크롤 영화 페이지에 상세 페이지 모달을 제작해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 아래 항목들을 학습해보려고 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;바운더리 패턴&amp;nbsp;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;useMemo / useCallback&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;React Devtools&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Suspense / Error Boundary 패턴&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Loading / Error UI를 상위 컴포넌트로 올려서 한 곳에서 처리하는&amp;nbsp;&lt;b&gt;관심사 분리 패턴&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Suspense&amp;nbsp;&lt;/b&gt;로딩 중에 보여 줄 대체 UI로, 자식 컴포넌트들이 로딩을 완료할 때까지 fallback 표시&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Error Boundary&lt;/b&gt; Error를 catch 하는 컴포넌트로 에러 발생 시, fallback 표시&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;*fallback&lt;/b&gt; 로딩/에러 중 보여 줄 대체 UI&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775914869351&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;throw Promise  &amp;rarr;  Suspense가 catch  &amp;rarr;  fallback(로딩UI) 표시
throw Error    &amp;rarr;  ErrorBoundary가 catch  &amp;rarr;  fallback(에러UI) 표시&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1775908006516&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;ErrorBoundary fallback={&amp;lt;ErrorUI /&amp;gt;}&amp;gt;       &amp;larr; 에러 처리 담당
  &amp;lt;Suspense fallback={&amp;lt;Spinner /&amp;gt;}&amp;gt;          &amp;larr; 로딩 처리 담당
    &amp;lt;PostList /&amp;gt;                             &amp;larr; 데이터만 신경씀
  &amp;lt;/Suspense&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식을 사용하면, 여러 컴포넌트가 모두 준비될 때까지 하나의 fallback으로 기다렸다가 한 번에 렌더링하게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상세 페이지 작성을 위해 이전 Movie 인터페이스를 확장해 MovieDetail 타입을 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775915084835&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Genre {
    id: number;
    name: string;
}

export interface MovieDetail extends Movie {
    genres: Genre[];
    runtime: number;
    tagline: string;
    status: string;
    vote_count: number;
    backdrop_path: string | null;
}&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;상세 데이터는 리스트가 아닌 MovieDetail 객체를 반환받으므로 &lt;span style=&quot;color: #009a87;&quot;&gt;/movie/{movieId}&lt;/span&gt; 경로로 데이터를 fetch 받는다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775915163494&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const getMovieDetail = async (movieId: number): Promise&amp;lt;MovieDetail&amp;gt; =&amp;gt; {
    const res = await fetch(
        `${BASE_URL}/movie/${movieId}?language=ko-KR`,
        { headers }
    )
    if (!res.ok) throw new Error('Failed to fetch movie detail')
    return res.json()
}&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;Suspense를 이용하기 위해 기존에 사용하던 useQuery 대신 &lt;b&gt;useSuspenseQuery&lt;/b&gt;를 이용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useSuspenseQuery는 useQuery의 Suspense 버전으로 상태를 직접 반환하지 않고 throw로 위임한다.&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;같은 queryKey면 재요청 없이 저장된 데이터 반환&lt;/li&gt;
&lt;li&gt;진행 중이면 Promise throw &amp;rarr; 가장 가까운 &amp;lt;Suspense&amp;gt; 가 받아 fallback 실행&amp;nbsp;&lt;/li&gt;
&lt;li&gt;실패하면&amp;nbsp; Error throw &amp;rarr; 가장 가까운 &amp;lt;ErrorBoundary&amp;gt; 가 받아 fallback 실행&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775915574480&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const useGetMovieDetail = (movieId: number) =&amp;gt;
  useSuspenseQuery({
    queryKey: movieKeys.detail(movieId),
    queryFn: () =&amp;gt; getMovieDetail(movieId),
    staleTime: 1000 * 60 * 5,
  })&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;모달 상세 페이지 구현 후, fallback으로 사용할 skeletonUI를 아래와 같은 형태로 컴포넌트를 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;825&quot; data-origin-height=&quot;556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ntv3t/dJMcabDOVwB/83iaX7HTVfI2YDfoDTJipK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ntv3t/dJMcabDOVwB/83iaX7HTVfI2YDfoDTJipK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ntv3t/dJMcabDOVwB/83iaX7HTVfI2YDfoDTJipK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNtv3t%2FdJMcabDOVwB%2F83iaX7HTVfI2YDfoDTJipK%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;561&quot; height=&quot;378&quot; data-origin-width=&quot;825&quot; data-origin-height=&quot;556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1775915861327&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function MovieDetailModal({ movieId, onClose }: Props) {
  return (
    &amp;lt;div className=&quot;fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm&quot;
      onClick={onClose}&amp;gt;
      &amp;lt;div className=&quot;bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden relative&quot;
        onClick={(e) =&amp;gt; e.stopPropagation()}&amp;gt;
        &amp;lt;button
          onClick={onClose}
          className=&quot;absolute top-3 right-3 z-20 w-8 h-8 flex items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60 transition-colors cursor-pointer&quot;&amp;gt;
          &amp;lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; className=&quot;w-4 h-4&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot;&amp;gt;
            &amp;lt;path strokeLinecap=&quot;round&quot; strokeLinejoin=&quot;round&quot; strokeWidth={2} d=&quot;M6 18L18 6M6 6l12 12&quot; /&amp;gt;
          &amp;lt;/svg&amp;gt;
        &amp;lt;/button&amp;gt;

        &amp;lt;ErrorBoundary fallback={&amp;lt;ModalError /&amp;gt;}&amp;gt;
          &amp;lt;Suspense fallback={&amp;lt;ModalSkeleton /&amp;gt;}&amp;gt;
            &amp;lt;MovieDetailContent movieId={movieId} /&amp;gt;
          &amp;lt;/Suspense&amp;gt;
        &amp;lt;/ErrorBoundary&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Suspense와 Error Boundary는 Content 영역을 대체함으로 모달 창과 닫기 버튼 밑에 구성한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로딩 중 &amp;rarr; ModalSkeleton 렌더링&lt;/li&gt;
&lt;li&gt;에러 시 &amp;rarr; ModalError 렌더링&lt;/li&gt;
&lt;li&gt;성공 시 &amp;rarr; Content 렌더&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;857&quot; data-origin-height=&quot;745&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dfqI3k/dJMcac3LtVy/YjBwuOwvs657dlnHnKAmkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dfqI3k/dJMcac3LtVy/YjBwuOwvs657dlnHnKAmkk/img.png&quot; data-alt=&quot;결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dfqI3k/dJMcac3LtVy/YjBwuOwvs657dlnHnKAmkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdfqI3k%2FdJMcac3LtVy%2FYjBwuOwvs657dlnHnKAmkk%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;633&quot; height=&quot;550&quot; data-origin-width=&quot;857&quot; data-origin-height=&quot;745&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;useMemo / useCallback&amp;nbsp;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;767&quot; data-origin-height=&quot;431&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qVgnw/dJMcabcItDR/QlbFYzIcnXRawZNL9vjYl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qVgnw/dJMcabcItDR/QlbFYzIcnXRawZNL9vjYl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qVgnw/dJMcabcItDR/QlbFYzIcnXRawZNL9vjYl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqVgnw%2FdJMcabcItDR%2FQlbFYzIcnXRawZNL9vjYl1%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;666&quot; height=&quot;374&quot; data-origin-width=&quot;767&quot; data-origin-height=&quot;431&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위까지 구현했더니 모달을 열고 닫을 때마다 TanstackQuery가 전체 리렌더 되는 문제가 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 useMemo와 useCallback에 대해 배워보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;useMemo&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수의 &lt;b&gt;결과 값을 메모이제이션&lt;/b&gt;하여 비용이 많이 드는 계산을 최적화할 때 사용&lt;/p&gt;
&lt;pre id=&quot;code_1775917067633&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const value = useMemo(() =&amp;gt; {
  return calculate();
}, [item]);&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;의존성 배열 안의 값이 업데이트 될 때만 콜백 함수를 다시 호출해 메모리에 저장된 값을 업데이트&amp;nbsp;&lt;/li&gt;
&lt;li&gt;의존성이 변경되지 않으면 이전에 계산한 값을 그대로 재사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useMemo는 외부 상태에 의존하지 않는 순수한 계산에만 사용하는 것이 원칙&amp;nbsp;&lt;/p&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;&lt;b&gt;useCallback&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;함수를 메모이제이션&lt;/b&gt;하여 필요할 때만 생성하도록 하는 훅&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useMemo가 값의 계산 결과를 캐싱하는 것과 달리, useCallback은 함수 자체를 캐싱한다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;함수는 객체이므로 리렌더링 될 때마다 새로 만들어지면 내용이 같아도 참조값이 달라져 항상 새 props로 인식하게 됨&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775918596570&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleIncreaseCount = useCallback((number: number) =&amp;gt; {
  setCount(count + number);
}, [count]);&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;의존성 배열의 값이 변경되지 않으면 이전에 생성한 함수의 참조 값 그대로 사용&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;의존성 배열이 빈 배열이면 useCallback은 항상 같은 함수 참조를 반환해&amp;nbsp;&lt;/b&gt;함수 내부에서 참조하는 상태값이 초기값으로 고정되어 아무리 클릭해도 값이 변하지 않는 문제가 발생할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 함수 내부에서 참조하는 상태나 props는 반드시 의존성 배열에 포함시켜야 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775918781911&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleIncreaseCount = useCallback((number: number) =&amp;gt; {
  setCount(count + number);
  // 빈 배열이면 count는 항상 0으로 고정
  // 클릭해도 0 + 10 = 10에서 변하지 않음
}, []); // ❌&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;useCallback 단독으로는 하위 컴포넌트의 리렌더링을 막을 수 없다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하위 컴포넌트가 props를 비교하는 최적화가 되어 있지 않으면 함수 참조가 유지되어도 리렌더링이 발생하기 때문이다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;React.memo&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수형 컴포넌트를 메모이징하는 고차 컴포넌트&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;props가 동일하다면 이전 렌더 결과를 재사용하고 변경되었다면 리렌더링되도록 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775919134943&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default memo(CountButton);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;useCallback + React.memo&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;useCallback&amp;nbsp;&lt;/b&gt;함수 참조를 안정적으로 유지 (고정)&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;React.memo&amp;nbsp;&lt;/b&gt;props 참조값을 비교해 바뀐 게 없으면 리렌더링 스킵&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, TanstackQuery 페이지에 있는 movies(영화 목록) 계산에 &lt;b&gt;useMemo&lt;/b&gt;를 적용해보겠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 selectedMovieId가 바뀔 때마다 매번 재계산 하도록 되어 있는데,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 useMemo를 적용해 영화 목록 값을 캐싱해두고 data가 바뀔 때만 재계산하도록 수정했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775919565725&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 현재 &amp;mdash; selectedMovieId 바뀔 때마다 매번 재계산
const movies = data?.pages.map((page) =&amp;gt; page.results).flat() ?? []

// 적용 후 &amp;mdash; data가 바뀔 때만 재계산
const movies = useMemo(
  () =&amp;gt; data?.pages.map((page) =&amp;gt; page.results).flat() ?? [],
  [data]
)&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;이를 통해 리렌더가 발생해 모달을 열고 닫을 때마다 movies가 재계산되는 문제를 해결했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 모달을 열고 닫을 때 각각의 카드들이 불필요하게 리렌더되는 문제를 해결해보겠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, memo를 적용하려면 컴포넌트 형태여야 하므로 MovieCard를 컴포넌트로 분리하고 &lt;b&gt;memo&lt;/b&gt;로 감쌌다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775920951937&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { memo } from 'react'
import type { Movie } from '../types/movie'

const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/w300'

interface Props {
  movie: Movie
  onClick: (id: number) =&amp;gt; void
}

const MovieCard = memo(({ movie, onClick }: Props) =&amp;gt; {

  return (
    &amp;lt;div className=&quot;flex flex-col cursor-pointer&quot; onClick={() =&amp;gt; onClick(movie.id)}&amp;gt;
      &amp;lt;div className=&quot;relative bg-gray-200 rounded overflow-hidden aspect-2/3 w-full&quot;&amp;gt;
        {movie.poster_path ? (
          &amp;lt;img
            src={`${IMAGE_BASE_URL}${movie.poster_path}`}
            alt={movie.title}
            className=&quot;w-full h-full object-cover&quot;
          /&amp;gt;
        ) : (
          &amp;lt;div className=&quot;w-full h-full flex items-center justify-center text-gray-400 text-xs&quot;&amp;gt;
            No Image
          &amp;lt;/div&amp;gt;
        )}
        {movie.adult &amp;amp;&amp;amp; (
          &amp;lt;span className=&quot;absolute top-1 left-1 w-5 h-5 bg-red-600 text-white text-[10px] font-bold rounded-full flex items-center justify-center&quot;&amp;gt;
            19
          &amp;lt;/span&amp;gt;
        )}
      &amp;lt;/div&amp;gt;
      &amp;lt;p className=&quot;mt-1 text-gray-800 text-xs truncate font-bold&quot;&amp;gt;{movie.title}&amp;lt;/p&amp;gt;
      &amp;lt;button className=&quot;mt-1 border border-gray-300 py-1 rounded text-gray-700 text-xs hover:bg-gray-100 transition-colors cursor-pointer&quot;&amp;gt;
        예매하기
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  )
})

export default MovieCard&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인라인 화살표 함수로 작성된 모달 열기/닫기 함수를 &lt;b&gt;useCallback&lt;/b&gt;으로 감싸 함수 참조를 유지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useCallback으로 감싸면 의존성이 바뀌지 않는 한 같은 함수 참조를 유지하므로 memo가 &quot;함수가 그대로다&quot;라고 판단해 불필요한 리렌더를 건너뛸 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775921662465&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 전: 매 렌더마다 새 함수 생성
onClick={() =&amp;gt; setSelectedMovieId(movie.id)}
onClose={() =&amp;gt; setSelectedMovieId(null)}

// 후: 최초 한 번만 생성, 참조 유지
const handleCardClick = useCallback((id: number) =&amp;gt; setSelectedMovieId(id), [])
const handleClose = useCallback(() =&amp;gt; setSelectedMovieId(null), [])&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;그 후, 위에서 작성한 MovieCard 컴포넌트와 onClick 함수를 교체했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1775922766169&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div className=&quot;grid grid-cols-5 gap-3&quot;&amp;gt;
              {isLoading
                ? Array.from({ length: 10 }).map((_, i) =&amp;gt; &amp;lt;MovieCardSkeleton key={i} /&amp;gt;)
                : movies.map((movie, index) =&amp;gt; (
                    &amp;lt;MovieCard
                      key={`${index}-${movie.id}`}
                      movie={movie}
                      onClick={handleCardClick}
                    /&amp;gt;
                  ))}

              {isFetchingNextPage &amp;amp;&amp;amp;
                Array.from({ length: 5 }).map((_, i) =&amp;gt; &amp;lt;MovieCardSkeleton key={`next-${i}`} /&amp;gt;)}
            &amp;lt;/div&amp;gt;

            &amp;lt;div ref={observerRef} className=&quot;h-4&quot; /&amp;gt;
          &amp;lt;/section&amp;gt;

        &amp;lt;/main&amp;gt;
      &amp;lt;/div&amp;gt;
      {selectedMovieId &amp;amp;&amp;amp; (
        &amp;lt;MovieDetailModal
          movieId={selectedMovieId}
          onClose={handleClose}
        /&amp;gt;
      )}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로써 Suspense + Error Boundary 패턴과 memo, useCallback, useMemo를 활용한 렌더링 최적화까지 적용했다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, React Devtools를 이용해 최적화가 제대로 적용되었는지 확인해보자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;React DevTools&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React로 만든 앱을 디버깅하고 분석할 수 있는&amp;nbsp;&lt;b&gt;크롬 확장 프로그램&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Components&lt;/b&gt; 현재 컴포넌트 트리와 props/state 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Profiler&amp;nbsp;&lt;/b&gt;렌더링 성능 측정 및 분석&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Profiler를 이용해 모달을 열고 닫았을 때 MovieCard의 리렌더가 일어나는지 확인해보자.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;613&quot; data-origin-height=&quot;162&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ls5As/dJMcagrz3wS/pInKhJIaIOTBpRJvSI5w11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ls5As/dJMcagrz3wS/pInKhJIaIOTBpRJvSI5w11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ls5As/dJMcagrz3wS/pInKhJIaIOTBpRJvSI5w11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fls5As%2FdJMcagrz3wS%2FpInKhJIaIOTBpRJvSI5w11%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;613&quot; height=&quot;162&quot; data-origin-width=&quot;613&quot; data-origin-height=&quot;162&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모달을 열었을 때 렌더링 결과다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TanstackQuery가 리렌더되며 Suspense, ModalSkeleton, MovieDetailModal이 함께 렌더된 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록에 MovieCard가 보이지 않는데, memo가 적용되어 모달 열기/닫기 시 MovieCard의 리렌더가 발생하지 않았음을 의미한다.&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;useCallback으로 onClick 함수 참조를 유지했기 때문에 memo가 props가 동일하다고 판단해 리렌더를 건너뛴 것이다.&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/61</guid>
      <comments>https://coding-cherry.tistory.com/61#entry61comment</comments>
      <pubDate>Sun, 12 Apr 2026 01:06:22 +0900</pubDate>
    </item>
    <item>
      <title>게시판 CRUD</title>
      <link>https://coding-cherry.tistory.com/60</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 게시글 생성과 상세 조회를 구현했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 게시글 삭제와 수정을 구현해보려고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 삭제와 수정은 작성자만 가능하므로&amp;nbsp;&lt;b&gt;작성자 권한 검증&lt;/b&gt;이 서비스 로직에 들어가야 함을 유의하고 API를 설계해보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게시글 삭제&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;921&quot; data-origin-height=&quot;59&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3t4Hc/dJMcacvO89C/uW0vllUKyFDFDqJIeNKxT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3t4Hc/dJMcacvO89C/uW0vllUKyFDFDqJIeNKxT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3t4Hc/dJMcacvO89C/uW0vllUKyFDFDqJIeNKxT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3t4Hc%2FdJMcacvO89C%2FuW0vllUKyFDFDqJIeNKxT1%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;813&quot; height=&quot;52&quot; data-origin-width=&quot;921&quot; data-origin-height=&quot;59&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;게시글을 삭제하기 위해서는&amp;nbsp;&lt;b&gt;삭제할 게시글의 Id&lt;/b&gt;와&amp;nbsp;권한 검증을 위해 &lt;b&gt;해당 게시글을 삭제할 user의 Id&lt;/b&gt;가 필요하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글을 삭제하기 전에, 아래 두 가지의 검증이 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;해당 아이디의 게시글이 존재하는지 확인 &lt;i&gt;&amp;rarr; 아니라면 POST_NOT_FOUND&lt;/i&gt; 반환&amp;nbsp;&lt;/li&gt;
&lt;li&gt;해당 게시글의 작성자가 삭제 요청을 보낸 user와 일치하는지 확인(&lt;b&gt;권한 검증&lt;/b&gt;) &lt;i&gt;&amp;rarr; 아니라면 POST_UNAUTHORIZED 반환&lt;/i&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1774864737876&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
    public void deletePost(Long postId, Long userId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -&amp;gt; new GeneralException(PostErrorCode.POST_NOT_FOUND));

        if (!post.getUser().getId().equals(userId)) {
            throw new GeneralException(PostErrorCode.POST_UNAUTHORIZED);
        }

        postRepository.delete(post);
    }&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;postRepository.delete에서 &lt;b&gt;delete&lt;/b&gt;는 JpaRepository에서 기본으로 제공하는 메서드&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;후에 Controller를 아래와 같이 작성하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환값은 성공적으로 요청을 처리했다는 뜻에서 GeneralSuccessCode.OK를 반환했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글을 삭제했으므로 result는 null로 처리했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774865107332&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@DeleteMapping(&quot;/{postId}&quot;)
    public ApiResponse&amp;lt;Void&amp;gt; deletePost(@PathVariable Long postId, @RequestParam Long userId) {
        postService.deletePost(postId, userId);
        return ApiResponse.onSuccess(GeneralSuccessCode.OK, null);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;게시글 수정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 수정을 구현할 때,&amp;nbsp;&lt;b&gt;PUT&lt;/b&gt;과&amp;nbsp;&lt;b&gt;PATCH&amp;nbsp;&lt;/b&gt;중 어떤 것을 사용해야 할까 고민하다가 아래 글을 보게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.tosspayments.com/blog/rest-api-post-put-patch&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.tosspayments.com/blog/rest-api-post-put-patch&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774865557962&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;POST, PUT, PATCH의 차이점 | 토스페이먼츠 개발자센터&quot; data-og-description=&quot;REST API 디자인의 기본이 되는 POST, PUT, PATCH 메서드를 자세히 살펴볼게요.&quot; data-og-host=&quot;docs.tosspayments.com&quot; data-og-source-url=&quot;https://docs.tosspayments.com/blog/rest-api-post-put-patch&quot; data-og-url=&quot;https://docs.tosspayments.com/blog/rest-api-post-put-patch&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dGOfZz/dJMb82eM5sv/RWZPBbQAgucrAQJw8umUL1/img.png?width=2000&amp;amp;height=1000&amp;amp;face=0_0_2000_1000&quot;&gt;&lt;a href=&quot;https://docs.tosspayments.com/blog/rest-api-post-put-patch&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.tosspayments.com/blog/rest-api-post-put-patch&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dGOfZz/dJMb82eM5sv/RWZPBbQAgucrAQJw8umUL1/img.png?width=2000&amp;amp;height=1000&amp;amp;face=0_0_2000_1000');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;POST, PUT, PATCH의 차이점 | 토스페이먼츠 개발자센터&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;REST API 디자인의 기본이 되는 POST, PUT, PATCH 메서드를 자세히 살펴볼게요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.tosspayments.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;멱등성(Idempotent)&lt;/b&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&gt;멱등성 있음: 재시도 해도 안전&lt;/li&gt;
&lt;li&gt;멱등성 없음: 매번 다른 결과 발생&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;815&quot; data-origin-height=&quot;197&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6vZaF/dJMcabcy4p0/rc8PzMhqJMVpsPr3zOWZ91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6vZaF/dJMcabcy4p0/rc8PzMhqJMVpsPr3zOWZ91/img.png&quot; data-alt=&quot;Toss Payments 기반&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6vZaF/dJMcabcy4p0/rc8PzMhqJMVpsPr3zOWZ91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6vZaF%2FdJMcabcy4p0%2Frc8PzMhqJMVpsPr3zOWZ91%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;815&quot; height=&quot;197&quot; data-origin-width=&quot;815&quot; data-origin-height=&quot;197&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Toss Payments 기반&lt;/figcaption&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;PUT&lt;/b&gt; 100번 요청을 하더라도 전체 리소스를 완전히 교체하므로 멱등성 보장 &lt;/li&gt;
&lt;li&gt;&lt;b&gt;PATCH&amp;nbsp;&lt;/b&gt;동시에 2개의 PATCH 요청이 오는 경우, &lt;span style=&quot;color: #ee2323;&quot;&gt;멱등성을 보장할 수 없음(비멱등성)&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;따라서 PATCH의 경우, 필요한 필드만 전달하는 이점이 있지만, 동시 요청 시 데이터 손실이나 예상치 못한 결과가 발생할 수 있어 멱등성을 보장하지 않는다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이에 따라, 게시글 수정 API를&amp;nbsp;&lt;b&gt;PUT&lt;/b&gt;으로 작성하기로 했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmLAhu/dJMcafMQ83i/Z3nj8SCVeOrK6nlj2muNKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmLAhu/dJMcafMQ83i/Z3nj8SCVeOrK6nlj2muNKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmLAhu/dJMcafMQ83i/Z3nj8SCVeOrK6nlj2muNKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmLAhu%2FdJMcafMQ83i%2FZ3nj8SCVeOrK6nlj2muNKK%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;826&quot; height=&quot;49&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;54&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;PUT을 통해 게시글 수정 시, 일부 값만 수정하는 것이 아닌 전체 값을 포함해야 하므로 DTO를 이와 같이 작성했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;제목과 내용 둘 중 하나라도 빠지면 안 되기 때문에 @NotBlank 조건을 추가했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774866423523&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class PostUpdateRequestDto {

    @NotBlank(message = &quot;제목은 필수입니다.&quot;)
    @Size(max = 50, message = &quot;제목은 50자 이하여야 합니다.&quot;)
    private String title;

    @NotBlank(message = &quot;내용은 필수입니다.&quot;)
    @Size(max = 1000, message = &quot;내용은 1000자 이하여야 합니다.&quot;)
    private String content;
}&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;또, 필드 수정 시 Setter를 사용할 것인가를 두고 고민이 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Setter를 사용하면 간단하게 코드를 작성할 수 있는 이점이 있지만, 아래 글들을 참고해보면 지양하라는 말밖에 없었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://colabear754.tistory.com/173&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://colabear754.tistory.com/173&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774866810868&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[OOP] Getter와 Setter는 지양하는게 좋다&quot; data-og-description=&quot;목차 들어가기 전에 얼마 전 사내에서 Getter와 Setter를 함부로 사용하면 안되는 이유에 대한 세미나가 있었다. Setter에 대한 이야기는 워낙 많이 알려져있었지만 Getter에 대한 이야기는 잘 하지 않&quot; data-og-host=&quot;colabear754.tistory.com&quot; data-og-source-url=&quot;https://colabear754.tistory.com/173&quot; data-og-url=&quot;https://colabear754.tistory.com/173&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dlqFuY/dJMb85WTor4/y1EwMr5qGvg3SFEv2U52OK/img.png?width=800&amp;amp;height=670&amp;amp;face=0_0_800_670,https://scrap.kakaocdn.net/dn/ckRCfF/dJMb85vO10N/BfP8a6EgWj7if3Jtl2Y0R0/img.png?width=800&amp;amp;height=670&amp;amp;face=0_0_800_670,https://scrap.kakaocdn.net/dn/c2alCx/dJMb86O1QB9/Q5KA8OnxvZDQwHZEwI6q9k/img.png?width=816&amp;amp;height=684&amp;amp;face=0_0_816_684&quot;&gt;&lt;a href=&quot;https://colabear754.tistory.com/173&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://colabear754.tistory.com/173&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dlqFuY/dJMb85WTor4/y1EwMr5qGvg3SFEv2U52OK/img.png?width=800&amp;amp;height=670&amp;amp;face=0_0_800_670,https://scrap.kakaocdn.net/dn/ckRCfF/dJMb85vO10N/BfP8a6EgWj7if3Jtl2Y0R0/img.png?width=800&amp;amp;height=670&amp;amp;face=0_0_800_670,https://scrap.kakaocdn.net/dn/c2alCx/dJMb86O1QB9/Q5KA8OnxvZDQwHZEwI6q9k/img.png?width=816&amp;amp;height=684&amp;amp;face=0_0_816_684');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[OOP] Getter와 Setter는 지양하는게 좋다&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;목차 들어가기 전에 얼마 전 사내에서 Getter와 Setter를 함부로 사용하면 안되는 이유에 대한 세미나가 있었다. Setter에 대한 이야기는 워낙 많이 알려져있었지만 Getter에 대한 이야기는 잘 하지 않&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;colabear754.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://octoping.tistory.com/33&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://octoping.tistory.com/33&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774866826045&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;사내 세미나 - Getter와 Setter를 함부로 사용하면 안되는 이유;;&quot; data-og-description=&quot;들어가기 앞서 지난 번에 작성했던 사내 세미나 - 테스트 코드에 대해 알아보자 세미나의 다음 편으로 진행한 세미나이다. Getter와 Setter의 사용을 금지하라 '리팩토링' 책의 저자로 유명한 Martin F&quot; data-og-host=&quot;octoping.tistory.com&quot; data-og-source-url=&quot;https://octoping.tistory.com/33&quot; data-og-url=&quot;https://octoping.tistory.com/33&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bhjp0Z/dJMb9kT222h/d6kOIbkp5ukL3FVLGI2pH1/img.png?width=405&amp;amp;height=720&amp;amp;face=0_0_405_720,https://scrap.kakaocdn.net/dn/bjLlbQ/dJMb9bv2mHj/RoZwjgc9W5jsQprWHcvlx0/img.png?width=405&amp;amp;height=720&amp;amp;face=0_0_405_720,https://scrap.kakaocdn.net/dn/cPrlAq/dJMb9cBIaRv/8WRGogSMSlgiT1e8G1kCO0/img.png?width=2256&amp;amp;height=526&amp;amp;face=0_0_2256_526&quot;&gt;&lt;a href=&quot;https://octoping.tistory.com/33&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://octoping.tistory.com/33&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bhjp0Z/dJMb9kT222h/d6kOIbkp5ukL3FVLGI2pH1/img.png?width=405&amp;amp;height=720&amp;amp;face=0_0_405_720,https://scrap.kakaocdn.net/dn/bjLlbQ/dJMb9bv2mHj/RoZwjgc9W5jsQprWHcvlx0/img.png?width=405&amp;amp;height=720&amp;amp;face=0_0_405_720,https://scrap.kakaocdn.net/dn/cPrlAq/dJMb9cBIaRv/8WRGogSMSlgiT1e8G1kCO0/img.png?width=2256&amp;amp;height=526&amp;amp;face=0_0_2256_526');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;사내 세미나 - Getter와 Setter를 함부로 사용하면 안되는 이유;;&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;들어가기 앞서 지난 번에 작성했던 사내 세미나 - 테스트 코드에 대해 알아보자 세미나의 다음 편으로 진행한 세미나이다. Getter와 Setter의 사용을 금지하라 '리팩토링' 책의 저자로 유명한 Martin F&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;octoping.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kcode-recording.tistory.com/339&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://kcode-recording.tistory.com/339&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774866837733&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Spring] Getter/Setter를 지양하자?&quot; data-og-description=&quot;편리해 보이는 @Getter / @Setter를 지양해야 한다? 지양을 해야하는 이유를 알기 전 @Getter / @Setter 애너테이션에 대해 먼저 알고가자! @Getter / @Setter 애너테이션이란? 자바를 공부하면서 객체 지향 프&quot; data-og-host=&quot;kcode-recording.tistory.com&quot; data-og-source-url=&quot;https://kcode-recording.tistory.com/339&quot; data-og-url=&quot;https://kcode-recording.tistory.com/339&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bghBxd/dJMb87NWt4F/8sfi65LGHrK0iGRfjReH5k/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cLc6qg/dJMb86O1QCa/Bpz75OpkCtFi4kLoMSeRak/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/c2ygDq/dJMb84XYNW0/r6Kk9YKQa9FzjZJSMRK8Nk/img.png?width=264&amp;amp;height=200&amp;amp;face=0_0_264_200&quot;&gt;&lt;a href=&quot;https://kcode-recording.tistory.com/339&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://kcode-recording.tistory.com/339&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bghBxd/dJMb87NWt4F/8sfi65LGHrK0iGRfjReH5k/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cLc6qg/dJMb86O1QCa/Bpz75OpkCtFi4kLoMSeRak/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/c2ygDq/dJMb84XYNW0/r6Kk9YKQa9FzjZJSMRK8Nk/img.png?width=264&amp;amp;height=200&amp;amp;face=0_0_264_200');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Spring] Getter/Setter를 지양하자?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;편리해 보이는 @Getter / @Setter를 지양해야 한다? 지양을 해야하는 이유를 알기 전 @Getter / @Setter 애너테이션에 대해 먼저 알고가자! @Getter / @Setter 애너테이션이란? 자바를 공부하면서 객체 지향 프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;kcode-recording.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 글들을 참고해 간단히 정리하자면,&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Setter를 사용하면 안 되는 이유&lt;br /&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 불명확한 의도(변경 의도를 알 수 없음)&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774867143722&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ Setter &amp;mdash; 뭘 하려는 건지 모름
post.setTitle(&quot;새 제목&quot;);
post.setContent(&quot;새 내용&quot;);

// ✅ Update() &amp;mdash; 게시글을 수정한다는 의도가 명확
post.update(&quot;새 제목&quot;, &quot;새 내용&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 검증 로직 추가 불가&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774867171263&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ Setter &amp;mdash; 빈 제목도 그냥 들어감
post.setTitle(&quot;&quot;);

// ✅ Update() &amp;mdash; 메서드 안에서 검증 가능
public void update(String title, String content) {
    if (title == null || title.isBlank()) {
        throw new IllegalArgumentException(&quot;제목은 비울 수 없습니다.&quot;);
    }
    this.title = title;
    this.content = content;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 객체 일관성 유지의 어려움&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774867179692&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ @Setter를 클래스에 붙이면 이런 것도 가능해짐
post.setCreatedAt(LocalDateTime.now()); // 생성일 조작 가능!
post.setId(999L);                       // PK 조작 가능!

// ✅ Update() &amp;mdash; title, content만 딱 열림
// createdAt, id는 건드릴 수 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 다른 객체들로 책임이 분산&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774867193366&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ Setter &amp;mdash; 검증이 Service에도 있고, Controller에도 있고...
// PostService.java
post.setTitle(title);

// PostController.java
if (title.isBlank()) { ... } // 검증이 다른 곳에 따로 있음

// ✅ Update() &amp;mdash; 로직이 엔티티 안에 응집
public void update(String title, String content) {
    // 검증 + 변경이 한 곳에
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 198px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;b&gt;Setter&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;b&gt;Update 메서드 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;캡슐화&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;약함(아무나 변경 가능)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;강함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;비즈니스 로직&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;로직 포함 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;검증&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;검증 불가&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;검증 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;복잡도&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;간단&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;복잡&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;버그 위험&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;높음&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, @Setter를 사용하는 대신 엔티티에 Update 메서드를 추가했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774867483318&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드를 활용해 서비스 로직을 작성해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제에서와 같이 수정 시, 아래 두 가지 검증이 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;해당 아이디의 게시글이 존재하는지 확인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;i&gt;&amp;rarr; 아니라면 POST_NOT_FOUND&lt;/i&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;반환&amp;nbsp;&lt;/li&gt;
&lt;li&gt;해당 게시글의 작성자가 수정 요청을 보낸 user와 일치하는지 확인(&lt;b&gt;권한 검증&lt;/b&gt;)&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;i&gt;&amp;rarr; 아니라면 POST_UNAUTHORIZED 반환&lt;/i&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 해당한다면, update 함수를 활용해 title과 content를 수정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1774867516363&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
    public PostResponseDto updatePost(Long postId, Long userId, PostUpdateRequestDto request) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -&amp;gt; new GeneralException(PostErrorCode.POST_NOT_FOUND));

        if (!post.getUser().getId().equals(userId)) {
            throw new GeneralException(PostErrorCode.POST_UNAUTHORIZED);
        }

        post.update(request.getTitle(), request.getContent());

        return new PostResponseDto(
                post.getId(),
                post.getUser().getId(),
                post.getTitle(),
                post.getContent()
        );
    }&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;Controller는 아래와 같이 작성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 작성한 서비스를 실행해 반환받은 DTO 값을 result로 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774867645926&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PutMapping(&quot;/{postId}&quot;)
    public ApiResponse&amp;lt;PostResponseDto&amp;gt; updatePost(
            @PathVariable Long postId,
            @RequestParam Long userId,
            @Valid @RequestBody PostUpdateRequestDto request) {
        return ApiResponse.onSuccess(GeneralSuccessCode.OK, postService.updatePost(postId, userId, request));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;댓글&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;961&quot; data-origin-height=&quot;514&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEuqjT/dJMcaadG9qH/0DnKvJe6ssiiv6UIJPG7R0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEuqjT/dJMcaadG9qH/0DnKvJe6ssiiv6UIJPG7R0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEuqjT/dJMcaadG9qH/0DnKvJe6ssiiv6UIJPG7R0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEuqjT%2FdJMcaadG9qH%2F0DnKvJe6ssiiv6UIJPG7R0%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;961&quot; height=&quot;514&quot; data-origin-width=&quot;961&quot; data-origin-height=&quot;514&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ERDCloud로 댓글(Comment)과 게시글(Post), 유저(User) 간 관계를 표현해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;댓글은 수정 API를 따로 만들지 않았으나, 확장 가능성을 두기 위해 수정 일자 필드도 추가했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 댓글의 엔티티다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 작성자는 여러 개의 댓글을 달 수 있으므로 1:N 관계&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 게시글에서 여러 개의 댓글을 달 수 있으므로 1:N 관계 로 명시했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, Post 때 처럼 setter 대신 엔티티 내 메서드를 이용해서 수정하도록 update 메서드를 작성했다.&lt;/p&gt;
&lt;pre id=&quot;code_1774872430219&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Table(name = &quot;comment&quot;)
@EntityListeners(AuditingEntityListener.class)
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;comment_id&quot;)
    private Long id;

    @Column(name = &quot;content&quot;, nullable = false)
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;post_id&quot;, nullable = false)
    private Post post;

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

    @CreatedDate
    @Column(name = &quot;created_at&quot;, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = &quot;updated_at&quot;)
    private LocalDateTime updatedAt;

    public void update(String content) {
        this.content = content;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;댓글 생성&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1577&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brzYvG/dJMcacWQZ2c/1O19b6uTVnhpe5BODmtS2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brzYvG/dJMcacWQZ2c/1O19b6uTVnhpe5BODmtS2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brzYvG/dJMcacWQZ2c/1O19b6uTVnhpe5BODmtS2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrzYvG%2FdJMcacWQZ2c%2F1O19b6uTVnhpe5BODmtS2K%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;1483&quot; height=&quot;51&quot; data-origin-width=&quot;1577&quot; data-origin-height=&quot;54&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;DB에 접근하고 간편한 사용을 위해 엔티티 다음 레포지토리를 생성했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774872595123&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface CommentRepository extends JpaRepository&amp;lt;Comment,Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 댓글 생성 시 필요한 Request DTO와 Response DTO를 이와 같이 선언했다.&lt;/p&gt;
&lt;pre id=&quot;code_1774872645117&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class CommentRequestDto {
    @NotNull(message = &quot;사용자 ID는 필수입니다.&quot;)
    private Long userId;

    @NotNull(message = &quot;게시글 ID는 필수입니다.&quot;)
    private Long postId;

    @NotBlank(message = &quot;댓글 내용은 필수입니다.&quot;)
    @Size(max = 500, message = &quot;댓글은 500자 이하여야 합니다.&quot;)
    private String content;
}


@Getter
@AllArgsConstructor
public class CommentResponseDto {
    private Long commentId;
    private Long postId;
    private Long userId;
    private String content;
}&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;서비스 로직은 게시글 생성과 비슷하게 작동하되, postId 검증 부분이 추가되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 존재 여부와 작성자(user) 존재 여부를 먼저 확인한 후, 댓글을 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774872687785&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; @Transactional
    public CommentResponseDto createComment(CommentRequestDto request) {
        User user = userRepository.findById(request.getUserId())
                .orElseThrow(() -&amp;gt; new GeneralException(CommentErrorCode.USER_NOT_FOUND));

        Post post = postRepository.findById(request.getPostId())
                .orElseThrow(() -&amp;gt; new GeneralException(CommentErrorCode.POST_NOT_FOUND));

        Comment comment = Comment.builder()
                .content(request.getContent())
                .user(user)
                .post(post)
                .build();

        Comment savedComment = commentRepository.save(comment);

        return new CommentResponseDto(
                savedComment.getId(),
                savedComment.getPost().getId(),
                savedComment.getUser().getId(),
                savedComment.getContent()
        );
    }&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;생성 완료 시, 리소스가 성공적으로 생성되었다는 CREATED 코드를 반환하고 result로 생성한 Comment를 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774872743795&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostMapping
    public ApiResponse&amp;lt;CommentResponseDto&amp;gt; createComment(@Valid @RequestBody CommentRequestDto request) {
        return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, commentService.createComment(request));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;댓글 삭제&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1569&quot; data-origin-height=&quot;52&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgoPHK/dJMcagkElK0/01tir3vxI7V51JOelKpIQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgoPHK/dJMcagkElK0/01tir3vxI7V51JOelKpIQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgoPHK/dJMcagkElK0/01tir3vxI7V51JOelKpIQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgoPHK%2FdJMcagkElK0%2F01tir3vxI7V51JOelKpIQK%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;1569&quot; height=&quot;52&quot; data-origin-width=&quot;1569&quot; data-origin-height=&quot;52&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;게시글 삭제 때와 동일하게, 댓글 존재 여부와 댓글 작성자가 현재 댓글 삭제를 요청하는 유저와 동일한지 검증한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774872855240&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, Long userId) {
        Comment comment = commentRepository.findById(commentId)
                .orElseThrow(() -&amp;gt; new GeneralException(CommentErrorCode.COMMENT_NOT_FOUND));

        if (!comment.getUser().getId().equals(userId)) {
            throw new GeneralException(CommentErrorCode.COMMENT_UNAUTHORIZED);
        }

        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;댓글이 정상적으로 삭제되면 OK 코드를 반환하고, 삭제되었으므로 result로는 null을 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774872935161&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@DeleteMapping(&quot;/{commentId}&quot;)
    public ApiResponse&amp;lt;Void&amp;gt; deleteComment(
            @PathVariable Long commentId,
            @RequestParam Long userId) {
        commentService.deleteComment(commentId, userId);
        return ApiResponse.onSuccess(GeneralSuccessCode.OK, null);
    }&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;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 동작하는지 확인해보겠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 작성을 하니 postId로 2가 반환되며, 작성한 게시글 내용이 보인다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;266&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNJ1mh/dJMcabjnTP3/7HgvJQ5REzwcrdKe9Xhkok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNJ1mh/dJMcabjnTP3/7HgvJQ5REzwcrdKe9Xhkok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNJ1mh/dJMcabjnTP3/7HgvJQ5REzwcrdKe9Xhkok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNJ1mh%2FdJMcabjnTP3%2F7HgvJQ5REzwcrdKe9Xhkok%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;1532&quot; height=&quot;266&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;266&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;방금 작성한 게시글을 수정하니 아래와 같이 수정한 내용이 반환된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1533&quot; data-origin-height=&quot;272&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sGoXM/dJMcaaLwBMe/ukYTOf2djnrkgY3GKksx00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sGoXM/dJMcaaLwBMe/ukYTOf2djnrkgY3GKksx00/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sGoXM/dJMcaaLwBMe/ukYTOf2djnrkgY3GKksx00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsGoXM%2FdJMcaaLwBMe%2FukYTOf2djnrkgY3GKksx00%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;1533&quot; height=&quot;272&quot; data-origin-width=&quot;1533&quot; data-origin-height=&quot;272&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;상세 조회를 해보아도 수정한 내용으로 보인다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1531&quot; data-origin-height=&quot;283&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b21Bjh/dJMcadVJzej/KU9EKlTpbLYyoiA6pM64HK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b21Bjh/dJMcadVJzej/KU9EKlTpbLYyoiA6pM64HK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b21Bjh/dJMcadVJzej/KU9EKlTpbLYyoiA6pM64HK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb21Bjh%2FdJMcadVJzej%2FKU9EKlTpbLYyoiA6pM64HK%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;1531&quot; height=&quot;283&quot; data-origin-width=&quot;1531&quot; data-origin-height=&quot;283&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;만약, 존재하지 않는 게시글을 삭제하려 한다면 게시글을 삭제할 수 없다고 404 에러 메세지가 나온다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1533&quot; data-origin-height=&quot;223&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l8oPj/dJMcahDQU6B/0zhg8MJtGmkZ7kNOU1i2w0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l8oPj/dJMcahDQU6B/0zhg8MJtGmkZ7kNOU1i2w0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l8oPj/dJMcahDQU6B/0zhg8MJtGmkZ7kNOU1i2w0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl8oPj%2FdJMcahDQU6B%2F0zhg8MJtGmkZ7kNOU1i2w0%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;1533&quot; height=&quot;223&quot; data-origin-width=&quot;1533&quot; data-origin-height=&quot;223&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;권한이 없는 사용자가 게시글을 삭제하려 하면 아래와 같은 403 에러 메세지가 뜬다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1530&quot; data-origin-height=&quot;217&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XQY8j/dJMcahRoK7B/3RzMbJd46IEYloOKzxHkdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XQY8j/dJMcahRoK7B/3RzMbJd46IEYloOKzxHkdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XQY8j/dJMcahRoK7B/3RzMbJd46IEYloOKzxHkdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXQY8j%2FdJMcahRoK7B%2F3RzMbJd46IEYloOKzxHkdk%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;1530&quot; height=&quot;217&quot; data-origin-width=&quot;1530&quot; data-origin-height=&quot;217&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;정상적으로 삭제하면 이렇게 요청이 성공적으로 처리되었다는 말과 함께 null이 반환된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKOT7U/dJMcaaLwBP8/BskUxTsB3qS0qiWEJwezhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKOT7U/dJMcaaLwBP8/BskUxTsB3qS0qiWEJwezhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKOT7U/dJMcaaLwBP8/BskUxTsB3qS0qiWEJwezhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKOT7U%2FdJMcaaLwBP8%2FBskUxTsB3qS0qiWEJwezhk%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;1532&quot; height=&quot;196&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Springboot</category>
      <author>co-cherry</author>
      <guid isPermaLink="true">https://coding-cherry.tistory.com/60</guid>
      <comments>https://coding-cherry.tistory.com/60#entry60comment</comments>
      <pubDate>Mon, 30 Mar 2026 21:21:49 +0900</pubDate>
    </item>
  </channel>
</rss>