<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>끄적끄적 개발</title>
    <link>https://daeun21dev.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Wed, 19 Aug 2026 01:26:41 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>다음김</managingEditor>
    <item>
      <title>[Spring] 스프링에서의 SSE 구현 (with SseEmitter)</title>
      <link>https://daeun21dev.tistory.com/51</link>
      <description>&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&lt;a href=&quot;https://daeun21dev.tistory.com/50&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;지난 글&lt;/span&gt;&lt;/a&gt;에서 SSE 란 무엇인지에 대해 알아보았다. 개념과 실전은 다르다고, 이번 글에서는 직접 스프링에서 SSE를 구현해보고 내가 겪은 트러블 슈팅과, 약간의 의문점?들을 해결해나간 과정을 정리해보려고 한다. &lt;span style=&quot;color: #333333;&quot;&gt;거두절미하고 시작해보자.&amp;nbsp;&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;SseEmitter를 이용한 SSE 구현&lt;/h2&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;스프링 4.2부터는 SSE 통신을 지원하는&amp;nbsp;&lt;/span&gt;SseEmitter&lt;span style=&quot;color: #333333;&quot;&gt; API를 제공하여 보다 쉽게 SSE를 구현할 수 있다. 뒤에서 보겠지만 클라이언트 - 서버 간 SSE 연결 시 &lt;code&gt;SseEmitter&lt;/code&gt; 객체를 하나 생성하고 이 객체를 통해 서버 → 클라이언트로 이벤트를 전송할 수 있다.&amp;nbsp;&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;아래는 서버가 클라이언트에게 전송하는 메시지 내용인 &lt;code&gt;Notification&lt;/code&gt; 엔티티로, 간단하게 &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt; 필드 그리고 수신자를 의미하는 &lt;code&gt;User&lt;/code&gt; 엔티티에 대한 참조로만 구성되어 있다.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter @Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Notification {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Column(name = &quot;notification_id&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Long id;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String message;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@ManyToOne(fetch = FetchType.LAZY)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private User user;
}&lt;/code&gt;&lt;/pre&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface NotificationRepository extends JpaRepository&amp;lt;Notification, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;SSE 연결 API&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;SSE 연결은 클라이언트 → 서버로의 SSE 연결 요청으로부터 시작한다. 아래는 클라이언트 - 서버 간 생성된 연결을 의미하는 &lt;code&gt;SseEmitter&lt;/code&gt; 객체를 유저 ID 별로 관리하는 클래스이다. (참고로 이 &lt;code&gt;SseEmitter&lt;/code&gt;는 DB가 아닌 메모리에서 관리되고 있어, 연결 상태에서 서버가 죽는다면 모든 연결 정보가 날아간다. 이에 대한 대처 방안은 조금 뒤에서 설명)&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;code&gt;sendMessage&lt;/code&gt; 메소드는 해당 유저 ID로 생성된 모든 SSE 연결에 이벤트를 전송하는 기능을 수행한다. &lt;code&gt;SseEmitter.event()&lt;/code&gt; api를 통해 이벤트를 생성하는데, 이때 각각 이벤트 ID, 이벤트 이름, 데이터, 주석을 설정하고 있는 것이다. &lt;br&gt;&amp;nbsp;&lt;br&gt;여기서 주의할 점은 &lt;code&gt;sseEmitterRepository&lt;/code&gt; 객체에 여러 쓰레드의 동시 접근이 가능하다는 것이다. 따라서 &lt;code&gt;SseEmitter&lt;/code&gt; 저장소로는 thread-safe한 자료구조인 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;, &lt;code&gt;CopyOnWriteArrayList&lt;/code&gt;를 사용했다.&amp;nbsp;&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
public class SseService {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final Map&amp;lt;Long, List&amp;lt;SseEmitter&amp;gt;&amp;gt; sseEmitterRepository = new ConcurrentHashMap&amp;lt;&amp;gt;();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//유저 ID로 SseEmitter 리스트 조회 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public List&amp;lt;SseEmitter&amp;gt; getSseEmitters(Long userId) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!sseEmitterRepository.containsKey(userId)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitterRepository.put(userId, new CopyOnWriteArrayList&amp;lt;&amp;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;return sseEmitterRepository.get(userId);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//연결 성립된 SseEmitter 객체 저장 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void save(Long userId, SseEmitter sseEmitter) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;SseEmitter&amp;gt; sseEmitters = getSseEmitters(userId);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitters.add(sseEmitter);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//해당 SseEmitter 삭제 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void delete(Long userId, SseEmitter sseEmitter) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;SseEmitter&amp;gt; sseEmitters = getSseEmitters(userId);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitters.remove(sseEmitter);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//해당 유저 ID가 가진 모든 SSE 연결에 이벤트 전송
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void sendMessage(Long userId, NotificationCreateResponse notification) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;SseEmitter&amp;gt; sseEmitters = getSseEmitters(userId);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitters.forEach(sseEmitter -&amp;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;try {
&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;sseEmitter.send(SseEmitter.event()
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.id(String.valueOf(notification.getId())) //이벤트 ID
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.name(&quot;notification&quot;) //이벤트 이름
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.data(notification, MediaType.APPLICATION_JSON) //데이터 
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.comment(&quot;comment&quot;) //주석 
&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;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (IOException e) {
&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;throw new RuntimeException(e);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;});
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;아래는 SSE 연결 API로, &lt;code&gt;SseEmitter&lt;/code&gt; 객체를 생성하고 각각 연결 timeout 시, 에러 발생 시, 연결 종료 시 실행할 콜백을 설정한 후 내부 저장소에 객체를 저장하고 있다. SSE 연결 시마다 새로운 &lt;code&gt;SseEmitter&lt;/code&gt; 객체를 생성하기 때문에 기존의 &lt;code&gt;SseEmitter&lt;/code&gt;를 제거해주어야 한다. 따라서 각 콜백에서 자기 자신을 삭제하도록 등록해주었다. 또한 첫 연결 시 꼭 서버는 클라이언트에게 더미 데이터를 전송해야 하므로 &lt;code&gt;send&lt;/code&gt; 메소드를 통해 데이터 전송 후 &lt;code&gt;SseEmitter&lt;/code&gt; 객체를 반환하고 있다. (정확한 원인은 모르겠지만 더미 데이터를 전송하지 않으면 요청에 pending이 걸리고, 연결 종료 후 클라이언트가 재연결 요청을 할 때 503 에러가 발생한다.)&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;아래는 각 콜백 함수가 호출되는 상황을 정리한 것이다.&lt;br&gt;- &lt;code&gt;onCompletion&lt;/code&gt;: SSE 연결이 정상 완료되었을 때 호출(클라이언트가 SSE 연결 종료하는 경우, 서버에서 &lt;code&gt;SseEmitter&lt;/code&gt; 객체의 &lt;code&gt;complete&lt;/code&gt; 메소드를 호출하는 경우, 타임아웃 발생 후 연결이 닫히는 경우)&lt;br&gt;- &lt;code&gt;onTimeout&lt;/code&gt;: SSE 연결 후 설정된 타임아웃 시간 동안 서버 가 아무런 메시지도 보내지 않을 때 호출&lt;br&gt;- &lt;code&gt;onError&lt;/code&gt;: SSE 연결 중 예외가 발생했을 때 호출&lt;br&gt;&lt;br&gt;연결 타임아웃 시간이 지나 연결이 끊기면 자동적으로 클라이언트는 SSE 연결 API를 재요청하고 매번&amp;nbsp;&lt;code&gt;SseEmitter&lt;/code&gt; 객체를 생성한다. 이때 &lt;code&gt;sendMessage&lt;/code&gt; 메소드에서 이벤트 ID를 설정하여 보냈기 때문에, 클라이언트는 재연결 요청 시 가장 마지막에 수신한 이벤트 ID를 &lt;code&gt;Last-Event-ID&lt;/code&gt; 헤더에 담아 보낸다. 서버는 이를 통해 클라이언트의 메시지 수신 여부를 판단하여 클라이언트가 받지 못한 메시지들을 재전송할 수도 있다. 따라서 서버가 죽어 메모리 단에서 관리되는 연결 정보(&lt;code&gt;SseEmitter&lt;/code&gt;)가 날아가더라도, 클라이언트가 보내는 재연결 요청의 Last Event ID 값을 통해 연결 상태를 복구할 수 있다. 참고로 반환되는 &lt;code&gt;SseEmitter&lt;/code&gt; 객체는 &lt;code&gt;ResponseBodyEmitterReturnValueHandler&lt;/code&gt;가 처리한다. 만약 &lt;code&gt;SseEmitter&lt;/code&gt; 관련 설정 값들(ex. &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;retry&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;)을 가지고 어떤 방식으로 응답을 생성하는지 알고 싶다면 해당 클래스를 공부해보자.&amp;nbsp;&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@RestController
public class SseController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final SseService sseService;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Long TIMEOUT = 30000L; //timeout = 30초
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Long RECONNECT_TIME_MILLIS = 10000L; //retry = 10초 

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GetMapping(&quot;/connect&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public ResponseEntity&amp;lt;SseEmitter&amp;gt; connect(@RequestParam Long userId,
&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;&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; @RequestHeader(value = &quot;Last-Event-Id&quot;, required = false) Long notificationId) throws IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SseEmitter sseEmitter = new SseEmitter(TIMEOUT);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.onCompletion(() -&amp;gt; sseService.delete(userId, sseEmitter)); //SSE 연결 종료 시
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.onTimeout(() -&amp;gt; sseService.delete(userId, sseEmitter)); //SSE 타임아웃 발생 시 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.onError(e -&amp;gt; sseService.delete(userId, sseEmitter)); //SSE 연결 중 에러 발생 시
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseService.save(userId, sseEmitter);
&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;//TODO: 클라이언트가 수신하지 못한 이벤트 처리

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SseEmitter.SseEventBuilder dummyData = SseEmitter.event()
&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;.name(&quot;connect&quot;)
&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;.data(&quot;connected!&quot;)
&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;.reconnectTime(RECONNECT_TIME_MILLIS);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.send(dummyData); //첫 연결 시 더미 데이터 전송
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return ResponseEntity.ok(sseEmitter);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;SSE 연결을 통한 알림 발송 API&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트에서 아래 API를 호출하면 전송할 메시지인 &lt;code&gt;Notification&lt;/code&gt; 객체를 생성하여 저장하고, 해당 유저 ID로 생성된 모든 SSE 연결에 이벤트를 전송한다.&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@RestController
public class NotificationController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final NotificationService notificationService;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final SseService sseService;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@PostMapping(&quot;/notifications&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public ResponseEntity&amp;lt;Void&amp;gt; sendNotification(@RequestBody NotificationCreateRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//Notification 객체 저장 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;NotificationCreateResponse notification = notificationService.saveNotification(request.getToUserId(), request.getMessage());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//수신자 userId로 생성된 모든 SSE 연결에 메시지 전송
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseService.sendMessage(request.getToUserId(), notification);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return ResponseEntity.ok().build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class NotificationService {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final NotificationRepository notificationRepository;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Transactional
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public NotificationCreateResponse saveNotification(Long toUserId, String message) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Notification notification = Notification.builder()
&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;.message(message)
&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;.user(User.builder().id(toUserId).build())
&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;.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;notificationRepository.save(notification);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return NotificationCreateResponse.builder()
&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;.id(notification.getId())
&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;.message(notification.getMessage())
&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;.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
public class NotificationCreateRequest {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Long toUserId;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String message;
}&lt;/code&gt;&lt;/pre&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Builder
@Getter
public class NotificationCreateResponse {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Long id;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String message;
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;timeout vs retry 차이&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;SssEmitter&lt;/code&gt; 객체 생성 시 설정해준 &lt;code&gt;timeout&lt;/code&gt; 값과 &lt;code&gt;reconnectTime&lt;/code&gt; 값의 차이가 정확히 무엇인지 궁금할 것이다. 각각은 &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;MDN에서 정의한 SSE 스펙&lt;/span&gt;&lt;/a&gt;의 timeout, retry 값에 대응된다. 먼저&amp;nbsp;timeout은 클라이어트 - 서버 간 연결에서 이벤트를 전송하지 않는 동안의 최대 연결 지속 시간으로, 즉 서버가 timeout 시간 내에 매번 이벤트를 보낸다면 연결이 계속 유지되지만, timeout 시간 동안 한번도 이벤트가 전송되지 않으면 클라이언트 - 서버 연결은 끊기게 된다.&lt;br&gt;&lt;br&gt;이렇게 연결이 끊긴 후 클라이언트는 자동으로 재연결 요청을 보내는데 retry는 재연결 요청을 보내기 전까지의 대기 시간, 즉 retry가 3000(ms 단위)로 설정되었다면 연결 종료 후 3초 뒤 재연결 요청을 보내게 되는 것이다.&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1386&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cipMSe/btsJvfTrKnM/3BlLqtFEJnOofuEyLLwOH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cipMSe/btsJvfTrKnM/3BlLqtFEJnOofuEyLLwOH1/img.png&quot; data-alt=&quot;timeout vs retry&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cipMSe/btsJvfTrKnM/3BlLqtFEJnOofuEyLLwOH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcipMSe%2FbtsJvfTrKnM%2F3BlLqtFEJnOofuEyLLwOH1%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;585&quot; height=&quot;98&quot; data-origin-width=&quot;1386&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;timeout vs retry&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;자바스크립트 예시&amp;nbsp;&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 구현한 서버 API를 직접 요청하여 SSE 연결이 정상적으로 설정되고 서버가 보내는 이벤트 또한 잘 수신하는지 확인해보자. 자바스크립트는 이 SSE 통신을 지원하는 EventSource API 기본적으로 제공하고 있다. 아무? 브라우저의 콘솔 창을 열어 다음과 같이 &lt;code&gt;EventSource&lt;/code&gt; 객체를 생성하면 클라이언트와 서버 간 연결이 설정된 것이다. &lt;code&gt;addEventListener&lt;/code&gt;로 수신할 이벤트(여기는 notification)를 설정한 후 포스트맨으로 POST /notifications API를 호출하여 클라이언트에게 메시지를 전송하였다. 이벤트를 수신한 클라이언트는 콜백 함수를 호출하여 콘솔에 수신한 메시지를 출력한 것을 확인할 수 있다.&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1322&quot; data-origin-height=&quot;494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ww2AA/btsJvwApJKI/KYPUxdBhPh8lYIgV5h1Zn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ww2AA/btsJvwApJKI/KYPUxdBhPh8lYIgV5h1Zn0/img.png&quot; data-alt=&quot;SSE 연결 후 수신한 이벤트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ww2AA/btsJvwApJKI/KYPUxdBhPh8lYIgV5h1Zn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWw2AA%2FbtsJvwApJKI%2FKYPUxdBhPh8lYIgV5h1Zn0%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;595&quot; height=&quot;222&quot; data-origin-width=&quot;1322&quot; data-origin-height=&quot;494&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SSE 연결 후 수신한 이벤트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;252&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/L3hMf/btsJuvP3Ovh/h2hKiALNzFszdICAu3yrRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/L3hMf/btsJuvP3Ovh/h2hKiALNzFszdICAu3yrRK/img.png&quot; data-alt=&quot;첫 연결 시 수신한 더미 데이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/L3hMf/btsJuvP3Ovh/h2hKiALNzFszdICAu3yrRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FL3hMf%2FbtsJuvP3Ovh%2Fh2hKiALNzFszdICAu3yrRK%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;501&quot; height=&quot;120&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;252&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;첫 연결 시 수신한 더미 데이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&lt;br&gt;SSE 연결이 끊겼을 때에 브라우저는 자동 재연결 요청을 한다고 했었다. 이때 현재까지 정상적으로 받은 이벤트 ID를 &lt;code&gt;Last-Event-Id&lt;/code&gt; 헤더에 담아서 전송하는데, 아래의 두번째 /connect 요청은 timeout 후 재연결 요청으로 직전에 수신한 이벤트 ID를 &lt;code&gt;Last-Event-Id: 58&lt;/code&gt;로 보내고 있다.&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2170&quot; data-origin-height=&quot;1158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ly5o4/btsJvgYVaW1/4heWSUB93mstEvSL6Ee1ck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ly5o4/btsJvgYVaW1/4heWSUB93mstEvSL6Ee1ck/img.png&quot; data-alt=&quot;SSE 연결 timeout 후 자동 재연결 요청&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ly5o4/btsJvgYVaW1/4heWSUB93mstEvSL6Ee1ck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fly5o4%2FbtsJvgYVaW1%2F4heWSUB93mstEvSL6Ee1ck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;299&quot; data-origin-width=&quot;2170&quot; data-origin-height=&quot;1158&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SSE 연결 timeout 후 자동 재연결 요청&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;트러블 슈팅&amp;nbsp;&lt;/h2&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;SSE 연결 시 무한 로딩 + 재연결 요청 시 503 에러 발생&amp;nbsp;&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;첫 연결 시 더미 데이터 전송하지 않는다면 SSE 연결 시 무한 로딩이 발생하다 서버 설정에 따라 timeout 또는 503 Service Unavailable 에러가 발생한다. 만약 정상 연결되었다 하더라도 클라이언트의 재연결 요청 시 503 에러가 발생한다. Service Unavailable 에러가 나는 것으로 추측해봤을 때, 첫 연결 시 서버가 보내는 더미 데이터를 클라이언트는 SSE 연결 수락으로 간주하는 것 같다. 따라서 꼭 첫 연결에서는 &lt;code&gt;SseEmitter.send&lt;/code&gt; 메소드를 호출하여 아무 데이터든 전송해야 한다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;프록시 서버 Nginx&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;로컬에서 스프링 서버만으로 테스트할 때에는 정상 동작하던 SSE가 Nginx를 프록시 서버로 사용하게 되면 SSE가 정상 동작하지 않고 계속해서 요청에 pending이 걸렸었다. 찾아보니 Nginx는 기본적으로 Upstream으로 요청을 보낼때 HTTP/1.0 버전을 사용한다고 한다. 따라서 클라이언트가 HTTP/1.1로 요청을 보내더라도 Nginx가 스프링 서버로 요청을 보낼 때에는 HTTP/1.0으로 보내 &lt;code&gt;Connection: close&lt;/code&gt; 헤더를 사용하기 때문에, 지속 연결이 닫혀버려 SSE가 정상적으로 동작하지 않게 되는 것이다.&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p style=&quot;background-color: #ffffff; color: #303a3e; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 아래와 같은 nginx 설정을 추가해줘야 한다. Upstream 요청 시 HTTP/1.1 프로토콜을 사용하도록 설정해주었으며, HTTP/1.1에서 &lt;code&gt;Connection: keep-alive&lt;/code&gt; 헤더는 기본 활성화되어 있어 만약 이 헤더가 그대로 전달된다면 일부 서버에 문제가 발생할 수 있기 때문에 &lt;code&gt;proxy_set_header Connection &quot;&quot;&lt;/code&gt; 설정으로 해당 헤더를 제거해주었다.&amp;nbsp;&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;http {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;server {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;listen&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 8090;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;server_name&amp;nbsp;&amp;nbsp;localhost;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;location / {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_pass http://localhost:8080;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_set_header Host $host;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_set_header X-Real-IP $remote_addr;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_set_header X-Forwarded-Proto $scheme;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_connect_timeout 60s;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_read_timeout 60s;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_send_timeout 60s;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_http_version 1.1; //Upstream 요청 시 HTTP/1.1 사용
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_set_header Connection &quot;&quot;; //Connection: keep-alive 헤더 제거
&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;/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;code&gt;SseEmitter&lt;/code&gt;의 timeout 설정을 100초로 해두었어도 위의 nginx 설정에서 &lt;code&gt;proxy_read_timeout&lt;/code&gt; 시간을 60초로 해두었기 때문에 60초 동안 서버로부터 아무런 메시지도 받지 못한다면 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;SSE 커넥션은&lt;span&gt; &lt;/span&gt;&lt;/span&gt;닫히게 된다. 즉 프록시 서버를 사용하는 경우에는 &lt;code&gt;SseEmitter&lt;/code&gt; timeout 설정보다 프록시 서버의 timeout 설정이 우선할 수 있다는 것이다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;1246&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/U2Ht1/btsJue2gRKH/N8YUYiNJlGr0YkzyFAEqN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/U2Ht1/btsJue2gRKH/N8YUYiNJlGr0YkzyFAEqN1/img.png&quot; data-alt=&quot;마지막 서버 이벤트 수신으로부터 60초 후 SSE 연결 종료&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/U2Ht1/btsJue2gRKH/N8YUYiNJlGr0YkzyFAEqN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FU2Ht1%2FbtsJue2gRKH%2FN8YUYiNJlGr0YkzyFAEqN1%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;1886&quot; height=&quot;1246&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;1246&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;마지막 서버 이벤트 수신으로부터 60초 후 SSE 연결 종료&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;SseEmitter 저장소의 동시성 문제&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SseEmitter&lt;/code&gt; 객체 생성 시 설정한 콜백들은 연결 별로 각기 다른 쓰레드에서 호출된다. 즉 &lt;code&gt;SseEmitterService&lt;/code&gt; 내부의 저장소(&lt;code&gt;SseEmitterRepository&lt;/code&gt;)에 여러 쓰레드가 동시에 접근할 수 있다는 것인데, 만약 thread-safe하지 않은 자료구조를 사용한다면 &lt;code&gt;ConcurrnetModificationException&lt;/code&gt;이 발생할 수 있다. 따라서 &lt;code&gt;SseEmitter&lt;/code&gt;를 저장하는 자료구조는 꼭 thread-safe해야 한다.&lt;br&gt;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;DB Connection 고갈&amp;nbsp;&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;SSE 연결 시 타임아웃이 발생하거나 서버 혹은 클라이언트가 직접 연결을 끊지 않는 이상 HTTP Connection이 유지된다. 이때 JPA의 OSIV(open-in-view)를 true로 설정하게 되면 DB Connection은 요청이 종료될 때까지 열려 있게 된다. 즉 DB Connection이 SSE 연결이 열려있는 동안 유지된다는 것인데, 이는 데이터베이스 커넥션 풀이 고갈되어 다른 요청들은 커넥션을 얻지 못하는 결과로 이어질 수 있다. 따라서 SSE 연결 시에는 DB Connection을 필요로 하는 작업(ex. DB 조회 등)을 하지 않거나 OSIV=false로 설정해야 한다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;정말 OSIV=true, 최대 커넥션 풀의 크기를 3으로 설정했을 때 SSE 연결을 3개 생성하면 DB Connection Pool이 고갈되는지 확인해보자. 여기서 주의할 점은 SSE 연결 요청 시 트랜잭션이 열려야 현재 요청에 DB Connection이 할당된다는 것이다. 즉 아래 GET /connect SSE 연결 요청 로직의 &lt;code&gt;userService.getUser&lt;/code&gt; 메소드에서 트랜잭션을 시작하지 않는다면 우리가 의도한 결과(SSE 연결로 인한 DB Connection 고갈)가 발생하지 않는다.&amp;nbsp;&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;spring:
&amp;nbsp;&amp;nbsp;jpa:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;open-in-view: true #default 설정
&amp;nbsp;&amp;nbsp;datasource:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;hikari:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;maximum-pool-size: 3 #최대 pool 크기&lt;/code&gt;&lt;/pre&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@RestController
public class SseController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final SseService sseService;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final UserService userService;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Long TIMEOUT = 30000L; //timeout = 30초
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final Long RECONNECT_TIME_MILLIS = 10000L; //retry = 10초 

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GetMapping(&quot;/connect&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public ResponseEntity&amp;lt;SseEmitter&amp;gt; connect(@RequestParam Long userId,
&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;&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; @RequestHeader(value = &quot;Last-Event-Id&quot;, required = false) Long notificationId) throws IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SseEmitter sseEmitter = new SseEmitter(TIMEOUT);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.onCompletion(() -&amp;gt; sseService.delete(userId, sseEmitter)); //SSE 연결 종료 시
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.onTimeout(() -&amp;gt; sseService.delete(userId, sseEmitter)); //SSE 타임아웃 발생 시 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.onError(e -&amp;gt; sseService.delete(userId, sseEmitter)); //SSE 연결 중 에러 발생 시
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseService.save(userId, sseEmitter);
&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;User user = userService.getUser(userId); //트랜잭션이 열려 현재 요청에 DB Connection 할당

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SseEmitter.SseEventBuilder dummyData = SseEmitter.event()
&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;.name(&quot;connect&quot;)
&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;.data(&quot;connected!&quot;)
&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;.reconnectTime(RECONNECT_TIME_MILLIS);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sseEmitter.send(dummyData); //첫 연결 시 더미 데이터 전송
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return ResponseEntity.ok(sseEmitter);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;SSE 연결을 3번 요청했을 때 HikariPool의 상태는 다음과 같다. active 상태의 커넥션이 3개 idle 상태는 0개로 만약 DB Connection이 필요한 요청이 한번이라도 더 들어오게 된다면 해당 요청은 커넥션을 얻지 못해 에러가 발생한다.&amp;nbsp;&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;HikariPool-1 - Pool stats (total=3, active=3, idle=0, waiting=0)&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;실제 DB Connection이 필요한 요청을 보냈을 때 HikariPool의 상태는 다음과 같았으며 상태 코드 500이 응답으로 떨어졌다. (프록시 서버가 존재하는 등 경우에 따라 504 Timeout 에러가 발생할 수도 있다.)&lt;/p&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;HikariPool-1 - Connection not added, stats (total=3, active=3, idle=0, waiting=1)
HikariPool-1 - Pool stats (total=3, active=3, idle=0, waiting=1)
HikariPool-1 - Fill pool skipped, pool has sufficient level or currently being filled.
HikariPool-1 - Timeout failure stats (total=3, active=3, idle=0, waiting=0)&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;이렇게 SSE 연결이 종료될 때까지 할당된 DB Connection이 유지되는 것은 아주 큰 이슈이다. 이로 인해 다른 요청들은 처리되지 못할 수 있기 때문이다. 따라서 SSE 연결 요청 시 해당 요청에 DB Connection이 할당되는 로직이 있는지 확인이 필요하며, 혹은 아예 OSIV=false로 설정하는 것이 안전할 것이다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;Scale-Out&lt;/h3&gt;&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;현재 &lt;code&gt;SseEmitter&lt;/code&gt; 객체는 메모리에 저장되어 있다. 따라서 서버의 인스턴스를 늘리는 Scale-Out 시 기능이 제대로 동작하지 않거나 클라이언트와 서버 1개가 지속적으로 연결되므로 성능 또한 악화될 수 있다. 이러한 경우에는 Redis Pub/Sub 같은 메시지 브로커나 분산 캐시를 통해 문제를 해결할 수 있다고 하는데, 자세한 내용은 직접 이를 적용해본 후 글로 정리할 예정이다.&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;Connection != Thread ?&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;SSE 구현을 하던 중 'HTTP의 지속적 연결이라는 것이 톰캣의 쓰레드를 계속 잡아먹고 있는 것은 아닐까?' 이런 의문이 생겼다. 그렇게 되면 톰캣의 쓰레드 풀  또한 고갈되어 다른 요청에 작업 쓰레드를 할당할 수 없어 이 또한 중대한 문제를 일으킬 것이다. 다행히도 다른 분이 동일한 궁금증을 가지고 있었고 또 다른 친절한 분의 &lt;a href=&quot;https://www.inflearn.com/community/questions/909440/%EC%A7%80%EC%86%8D-%EC%97%B0%EA%B2%B0%EA%B3%BC-sse&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;인프런 QnA 답변&lt;/span&gt;&lt;/a&gt;을 통해 답을 얻을 수 있었다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;br&gt;먼저 SSE 커넥션 유지 시 정말 쓰레드를 잡아 먹는지 실험을 해보자. 아래는 톰캣 설정에 대한 내용을 간단히 정리한 것으로 자세한 내용을 알고 싶다면 &lt;a href=&quot;https://tomcat.apache.org/tomcat-8.5-doc/config/http.html&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;공식 문서&lt;/span&gt;&lt;/a&gt;를 참고하면 좋을 듯하다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 53px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt; 
 &lt;tbody&gt; 
  &lt;tr style=&quot;height: 19px;&quot;&gt; 
   &lt;td style=&quot;width: 33.8373%; height: 19px;&quot;&gt;&amp;nbsp;&lt;code&gt;server.tomcat.max-connections&lt;/code&gt;&lt;/td&gt; 
   &lt;td style=&quot;width: 66.1627%; height: 19px;&quot;&gt;&amp;nbsp;서버가 동시에 처리할 수 있는 최대 연결 수&amp;nbsp;&lt;/td&gt; 
  &lt;/tr&gt; 
  &lt;tr style=&quot;height: 17px;&quot;&gt; 
   &lt;td style=&quot;width: 33.8373%; height: 17px;&quot;&gt;&amp;nbsp;&lt;code&gt;server.tomcat.accept-count&lt;/code&gt;&lt;/td&gt; 
   &lt;td style=&quot;width: 66.1627%; height: 17px;&quot;&gt;&amp;nbsp;현재 연결 수가 max-connections에 도달했을 때, 들어오는 요청을 대기시킬 큐의 최대 크기&lt;/td&gt; 
  &lt;/tr&gt; 
  &lt;tr style=&quot;height: 17px;&quot;&gt; 
   &lt;td style=&quot;width: 33.8373%; height: 17px;&quot;&gt;&amp;nbsp;&lt;code&gt;server.tomcat.threads.max&lt;/code&gt;&lt;/td&gt; 
   &lt;td style=&quot;width: 66.1627%; height: 17px;&quot;&gt;&amp;nbsp;요청 처리 쓰레드의 최대 개수, 즉 서버가 동시에 처리할 수 있는 최대 요청 수&amp;nbsp;&lt;/td&gt; 
  &lt;/tr&gt; 
 &lt;/tbody&gt; 
&lt;/table&gt;&lt;pre data-ke-type=&quot;codeblock&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;server:
&amp;nbsp;&amp;nbsp;tomcat:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;max-connections: 3 #최대 동시 연결 수
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;accept-count: 1 #큐 대기 연결 수
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;threads:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;max: 1 #최대 동시 작업 쓰레드 수&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;위 설명을 통해 알겠지만 여기서 연결(Connection)과 쓰레드(Thread)는 다른 개념이다. 인프런 QnA의 답변자 분은 커넥션은 '고속도로', 쓰레드는 '작업자'와 같은 비유를 들어 이를 설명했다. SSE 연결로 커넥션이 지속되는 것은 클라이언트 - 서버 사이에 길이 하나 뚫리는 것으로, 이 뚫린 길을 통해 서버 → 클라이언트에게 이벤트를 전송하는 일은 서버의 작업자가 수행하는 일로 생각하면 이해가 쉬울 것이다.&amp;nbsp;&lt;/p&gt;&lt;blockquote data-ke-style=&quot;style2&quot;&gt;예를 들어 말씀드리면 서울에서 부산에 있는 공장까지 화물을 전달해야할 때 Connection은 서울과 부산을 잇는 고속도로, Thread는 부산 하차장에서 공장까지 화물을 옮기는 작업자라고 보시면 될 것 같습니다.&lt;/blockquote&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;정말 Connection과 Thread가 다른 개념인지 눈으로 확인해보고 싶어 실험을 진행해보았다.&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;max-connections=3, threads.max=1일 때, SSE 연결 2개 → &quot;hello world&quot; 반환하는 API 1번 호출&amp;nbsp;&lt;/h3&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;914&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btK1Yu/btsJt7PTSRk/Qxd4mEBY4k6acKuD1XLXtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btK1Yu/btsJt7PTSRk/Qxd4mEBY4k6acKuD1XLXtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btK1Yu/btsJt7PTSRk/Qxd4mEBY4k6acKuD1XLXtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtK1Yu%2FbtsJt7PTSRk%2FQxd4mEBY4k6acKuD1XLXtk%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;696&quot; height=&quot;335&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;914&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&amp;nbsp;&lt;br&gt;SSE 연결이 2개로 max-connections인 3보다 작으므로, 단순 &quot;hello world&quot;를 반환하는 GET /hello API를 정상 호출할 수 있으며 응답 또한 원하는 대로 떨어졌다. (GET /hello API 호출 시에도 2개의 SSE 연결 유지 중) maxThread 값을 1로 설정했음에도 불구하고 응답이 정상적으로 떨어진 것을 보면 SSE 연결이 종료될 때까지 쓰레드를 점유하지 않는다는 것을 알 수 있다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;max-connections=3, threads.max=1, accept-count=1일 때, SSE 연결 3개 → &quot;hello world&quot; 반환하는 API 1번 호출&amp;nbsp;&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;SSE 연결이 3개로 max-connections에 도달했으므로 더이상의 연결 요청은 처리할 수 없다. (이미 3개의 연결을 유지 중이므로) 그 대신 accept-count 값을 1로 설정했기 때문에 GET /hello 요청은 SSE 연결이 하나라도 종료될 때까지 큐에 대기하게 된다. 만약 큐에서 대기하는 시간이 길어진다면 요청이 정상적으로 처리되지 않고 다음과 같이 타임아웃 에러가 발생할 수 있다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;1302&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cwsEOX/btsJuUu9cDd/w2KEQEmkmtlfsyezUltklK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cwsEOX/btsJuUu9cDd/w2KEQEmkmtlfsyezUltklK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cwsEOX/btsJuUu9cDd/w2KEQEmkmtlfsyezUltklK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcwsEOX%2FbtsJuUu9cDd%2Fw2KEQEmkmtlfsyezUltklK%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;705&quot; height=&quot;485&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;1302&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&lt;br&gt;결론은 'SSE Connection은 연결 종료 시까지 &lt;span style=&quot;color: #333333;&quot;&gt;Thread를&amp;nbsp;&lt;/span&gt;점유하지 않는다'이다. 즉 SSE 연결 증가가 톰캣의 쓰레드 고갈로 선형적 영향을 미치지는 않는다는 것이다. 다만 앞서 살펴봤 듯이 현재 SSE 연결 수가 톰캣의 max-connections 값을 초과하게 되면 다른 요청들은 SSE 연결이 하나라도 종료될 때까지 처리되지 못하고 큐에서 대기해야 한다. 이는 당연히 서버의 요청 처리 성능에도 영향을 미칠 수 있으므로 SSE 도입 시 고려해야 하는 부분이다.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;마무리&amp;nbsp;&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;스프링에서 SSE를 구현하는 방법에 대해 알아보았다. 서버의 데이터 변경 이벤트를 실시간으로 클라이언트에게 보내야 하는 경우(ex. 댓글 알림, 피드 알림 등) + 서버 → 클라이언트로 단방향 이벤트 전송만으로 충분한 경우 SSE를 통해 이를 간단하게 구현할 수 있다. 다만 앞서 언급한 단점들 또한 존재하므로 이를 실무에 적용할 때에는 충분한 테스트가 매우매우 필요하다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;끝.&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;참고&amp;nbsp;&lt;/h4&gt;&lt;p data-ke-size=&quot;size16&quot; style=&quot;text-align: justify;&quot;&gt;&lt;a href=&quot;https://www.inflearn.com/community/questions/909440/%EC%A7%80%EC%86%8D-%EC%97%B0%EA%B2%B0%EA%B3%BC-sse&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;https://www.inflearn.com/community/questions/909440/%EC%A7%80%EC%86%8D-%EC%97%B0%EA%B2%B0%EA%B3%BC-sse&lt;/span&gt;&lt;/a&gt;&lt;br&gt;&lt;a href=&quot;https://velog.io/@byeongju/max-connections-accept-count-threads.max&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;https://velog.io/@byeongju/max-connections-accept-count-threads.max&lt;/span&gt;&lt;/a&gt;&lt;br&gt;&lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/&quot; target=&quot;_blank&quot;&gt;&lt;span&gt;https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/&lt;/span&gt;&lt;/a&gt;&lt;br&gt;&lt;br&gt;&lt;/p&gt;</description>
      <category>Spring/Spring</category>
      <category>spring</category>
      <category>SSE</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/51</guid>
      <comments>https://daeun21dev.tistory.com/51#entry51comment</comments>
      <pubDate>Mon, 9 Sep 2024 01:00:17 +0900</pubDate>
    </item>
    <item>
      <title>[Web] SSE(Server Sent Events) 란?</title>
      <link>https://daeun21dev.tistory.com/50</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;웹 서비스를 사용하다 보면 화면이 깜빡이지 않아도(클라이언트가 자체적으로 새로고침하지 않아도) 웹 페이지가 자동 업데이트되는 경우를 볼 수 있다. 유트브에서 영상 시청 중 다른 사람이 좋아요 버튼을 눌렀음에도 좋아요 수가 내 화면에 실시간으로 반영된다거나, 내가 구독한 뉴스 피드가 업로드된 경우 나에게 바로 알림이 오는 경우를 예로 들 수 있다. 사실 HTTP 프로토콜 기반의 웹 어플리케이션에서는 클라이언트의 요청이 있어야 서버의 응답이 가능하다. 하지만 앞선 경우는 클라이언트의 요청 없이도 서버로부터 데이터가 전달된 것이다. 이렇게 뉴스 피드 또는 댓글 알림 등 실시간으로 서버의 변경 사항을 클라이언트에게 전달해줘야 하는 경우, 이를 구현하기 위한 클라이언트 - 서버 간 실시간 소통 방법에는 여러가지가 있다. 이 글의 주제는 SSE이지만 자세히 알아보기 전 다른 방법들도 간단히 살펴보자.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클라이언트 서버 간 실시간 소통 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(Short) Polling&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Polling.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOe6H0/btsJnpaPEOl/qbm8TrCHXka3CxuK552tRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOe6H0/btsJnpaPEOl/qbm8TrCHXka3CxuK552tRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOe6H0/btsJnpaPEOl/qbm8TrCHXka3CxuK552tRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOe6H0%2FbtsJnpaPEOl%2Fqbm8TrCHXka3CxuK552tRk%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;450&quot; height=&quot;351&quot; data-filename=&quot;Polling.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 주기적으로 요청을 보내 응답을 받는 방법으로, 즉 클라이언트가 일정 시간마다 서버의 데이터가 갱신되었는지 확인하는 것이다. 클라이언트와 서버 모두 구현이 단순하지만, 매 요청마다 TCP 연결을 열고 닫아야 하는 등(매번 3-way handshaking 수행) 서버의 부담이 크기 때문에, 요청 주기를 넉넉히 잡아도 될 정도로 실시간성이 중요하지 않은 경우에 적절하다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Long Polling&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Long Polling.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VlvqU/btsJmtZmKIe/mbBanTzaghb9NnlYokq8h0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VlvqU/btsJmtZmKIe/mbBanTzaghb9NnlYokq8h0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VlvqU/btsJmtZmKIe/mbBanTzaghb9NnlYokq8h0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVlvqU%2FbtsJmtZmKIe%2FmbBanTzaghb9NnlYokq8h0%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;450&quot; height=&quot;351&quot; data-filename=&quot;Long Polling.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 요청을 보내고 서버에서 변경이 일어날 때까지 대기한 후, 응답이 도착하면 재요청을 보내는 방식이다. 이 방식은 서버로부터 응답으로 받고 나면 다시 연결을 요청하기 때문에, 데이터가 빈번하게 바뀐다면 클라이언트 &amp;rarr; 서버로의 연결 요청도 증가하게 된다. 따라서 서버의 상태가 자주 바뀌지 않는 경우에 적합하다. 앞선 일반적인 폴링 방식보다는 실시간성이 증가하였지만, 요청과 응답이 여전히 1:1이기 때문에 서버 부하가 발생한다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SSE(Server Sents Event)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;SSE.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IIhPi/btsJmtkPh5y/KvXmG9EOpl7BKIc1VhTKS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IIhPi/btsJmtkPh5y/KvXmG9EOpl7BKIc1VhTKS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IIhPi/btsJmtkPh5y/KvXmG9EOpl7BKIc1VhTKS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIIhPi%2FbtsJmtkPh5y%2FKvXmG9EOpl7BKIc1VhTKS0%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;450&quot; height=&quot;351&quot; data-filename=&quot;SSE.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번 클라이언트 - 서버 연결 후 일정 시간 동안 서버에서 변경이 발생할 때마다 데이터를 전송받는 단방향 통신 방법이다. 서버 응답마다 다시 요청해야 하는 Long Polling 방식보다는 통신 횟수가 적어 효율적이며(하나의 TCP 연결을 유지), 서버에서 실시간으로 이벤트 전송이 가능하여 앞선 두 방식보다 실시간성이 더 높다. 또한 HTTP의 Persistent Connections를 기반으로 하기 때문에 새로운 프로토콜을 도입할 필요가 없다는 장점이 있다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WebSocket&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Web Socket.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pSl0k/btsJoD64Sg8/UScVXKv7ZPK1cNFf7MZLZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pSl0k/btsJoD64Sg8/UScVXKv7ZPK1cNFf7MZLZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pSl0k/btsJoD64Sg8/UScVXKv7ZPK1cNFf7MZLZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpSl0k%2FbtsJoD64Sg8%2FUScVXKv7ZPK1cNFf7MZLZ1%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;450&quot; height=&quot;351&quot; data-filename=&quot;Web Socket.png&quot; data-origin-width=&quot;1027&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Restful API, SOAP, GraphQL 등 HTTP를 활용한 방식들은 클라이언트가 서버에게 요청을 보내고 서버는 요청이 있을 때에만 응답을 전달할 수 있다. 이러한 점을 극복한 클라이언트 - 서버 간의 양방향 통신 방법이 바로 WebSocket이다. 즉 &lt;code&gt;ws://&lt;/code&gt; 또는 &lt;code&gt;wss://&lt;/code&gt; 웹 소캣 프로토콜을 기반으로 클라이언트 - 서버 간에 동등하게 메시지를 주고 받을 수 있다. 따라서 온라인 게임, 주식 관련 앱, 채팅 앱과 같이 실시간으로 서버로부터의 업데이트가 필요한 서비스에서 주로 사용하는 방식이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;서비스 특성에 따라 양방향 통신이 아닌 서버로부터의 단방향 통신만으로 충분한 경우가 존재한다. 예를 들어 서버의 작업 시간이 긴 경우 프로그래스 바 표시, 실시간으로 내용이 업데이트되는 SNS, 뉴스, 주식 거래 서비스, 기타 실시간 모니터링 서비스 등은 서버에서의 변경 사항을 클라이언트에게 실시간으로 보내기만 하면 된다. 이 경우에는 웹 소캣보다는 SSE가 더 적합하다. 그럼 이 SSE 통신은 어떤 흐름으로 실행될까?&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SSE 실행 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE 통신의 실행 흐름 크게 '클라이언트의 연결 요청 &amp;rarr; 서버의 요청 수락 &amp;rarr; 서버의 이벤트 발송 &amp;rarr; 이벤트 수신한 클라이언트의 동작 &amp;rarr; 연결 종료 시 클라이언트의 재연결 요청'으로 구성된다. 각 단계에서 어떤 일이 발생하는지 자세히 살펴보자.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 클라이언트의 연결 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 SSE 통신을 위해 클라이언트 &amp;rarr; 서버로의 연결 요청이 필요하다. (즉 이벤트 구독 요청) 이때&lt;br /&gt;SSE&amp;nbsp;통신을 통해 이벤트 스트림이라는 형식의 메시지를 수신하겠다는 의미의 &lt;code&gt;Accept: text/event-stream&lt;/code&gt; 헤더가 포함되어야 한다. 또한 이벤트를 구독하기 위해 HTTP의 지속적 연결을 사용하는데, HTTP1.0 이하의 프로토콜은 지속적 연결이 기본 설정이 아니기 때문에, 해당 프로토콜을 사용하는 경우에는 명시적으로 &lt;code&gt;Connection: keep-alive&lt;/code&gt; 헤더를 설정해주어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1725183958438&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GET /sse HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 서버의 요청 수락&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트의 요청을 수신한 서버는 이벤트 스트림 형식으로 작성된 메시지임을 명시하는 헤더가 포함된 응답을 전달한다. (&lt;code&gt;Content-Type: text/event-stream;charset=UTF-8&lt;/code&gt;, &lt;code&gt;Connection: keep-alive&lt;/code&gt;, &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt;)&lt;br /&gt;&lt;br /&gt;이러한 설정으로 클라이언트가 요청을 보내 만들어진 연결이 서버가 응답한 이후로도 계속 유지되는 것이다. 참고로 서버는 동적으로 생성된 컨텐츠를 실시간으로 스트리밍하기 때문에 본문의 크기를 미리 알 수 없어 &lt;code&gt;Transfer-Encoding&lt;/code&gt; 헤더 값을 &lt;code&gt;chunked&lt;/code&gt;로 보내는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1725184053616&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HTTP/1.1 200
Content-Type: text/event-stream;charset=UTF-8
Connection: keep-alive
Transfer-Encoding: chunked&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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 서버의 이벤트 발송&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 - 서버 간 하나의 TCP 연결이 설정된 후 서버는 특정 이벤트가 있을 때마다 클라이언트에게 비동기적으로 메시지를 전송할 수 있다. (SSE에서 클라이언트는 서버가 보내는 메시지를 수신만 할 수 있고 메시지를 서버에게 전송할 수는 없는 단방향 통신이다.) 이때 서버가 보내는 데이터는 utf-8로 인코딩된 텍스트로, 서로 다른 이벤트는 다음과 같이 줄바꿈 문자 2개(\n\n)로 구분되며, 다시 각 이벤트는 줄바꿈 문자 하나(\n)로 구분되는 &lt;code&gt;name: value&lt;/code&gt; 필드로 구성된다. SSE 메시지(또는 이벤트)의 자세한 형식은 뒤에서 살펴보자.&lt;/p&gt;
&lt;pre id=&quot;code_1725184263096&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;event: type1
data: An event of type1.

event: type2
data: An event of type2.&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;h3 data-ke-size=&quot;size23&quot;&gt;4. 이벤트 수신한 클라이언트의 동작&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 서버로부터 메시지가 도착할 때마다 이에 대해 화면을 업데이트하는 등 필요한 작업 수행한다. &lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 연결 종료 시 클라이언트의 재연결 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 이벤트 발송 및 수신은 하나의 연결 안에서 지속되는 것으로, 만약 연결이 끊기면 클라이언트는 자동으로 재연결을 요청하여 통신을 재개한다. 필요한 작업을 마쳤다면 클라이언트는 서버에게 연결 종료를 통보하는 메시지를 보냄으로써 연결을 끊을 수도 있다. 만약 서버쪽에서 연결을 종료하고자 한다면, 클라이언트에게 합의된 메시지를 보내 통지한 후 클라이언트가 서버에게 연결 종료 요청을 보내면 된다. &lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SSE Event Stream 형식&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE 통신 시 서버 &amp;rarr; 클라이언트로 전달되는 event stream은 UTF-8로 인코딩된 텍스트 기반 데이터로, 스트림의 메시지는 두 개의 줄바꿈 문자(\n\n)로 구분된다. &lt;br /&gt;&lt;br /&gt;이때 첫 글자가 콜론(:)인 경우는 주석으로 간주하여 무시되는데, 주석은 timeout으로 클라이언트와의 연결이 끊기는 것을 막기 위해, 즉 연결을 유지하기 위해 서버가 주기적으로 더미 메시지를 보내기 위해 사용된다.&lt;br /&gt;&lt;br /&gt;메시지의 각 라인은 하나의 필드로 구성되어 있으며, 각 필드는 '필드 이름: 필드 값' 형식으로 구성되어 있다. 다음은 SSE Event Stream에서 유효한 필드들의 이름이다. (이외의 필드들은 무시됨) 참고로 필드 이름 바로 뒤에 콜론이 없다면 전체 라인이 필드 이름으로 처리되며 이때 필드 값은 빈 문자열이다. &lt;br /&gt;&amp;nbsp;&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: 10.8139%; text-align: justify;&quot;&gt;&lt;code&gt;event&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.1861%; text-align: justify;&quot;&gt;&amp;nbsp;서버에서 클라이언트로 전달되는 이벤트를 식별하기 위한 문자열로, 클라이언트는 특정 이벤트의 메시지만을 수신하기 위해 이벤트 네임 값을 지정할 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.8139%; text-align: justify;&quot;&gt;&lt;code&gt;data&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.1861%; text-align: justify;&quot;&gt;&amp;nbsp;메시지의 데이터 필드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.8139%; text-align: justify;&quot;&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.1861%; text-align: justify;&quot;&gt;&amp;nbsp;이벤트 id로, 클라이언트 &amp;rarr; 서버로 SSE 연결 요청 시 전달되는 Last Event ID(클라이언트가 직전에 전달받은 이벤트 id) 값에 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.8139%; text-align: justify;&quot;&gt;&lt;code&gt;retry&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.1861%; text-align: justify;&quot;&gt;&amp;nbsp;클라이언트 - 서버 간 SSE 연결이 끊겼을 때, 재연결 요청 전 대기 시간 (milliseconds 단위)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이벤트 메시지 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예시는 총 3개의 메시지가 보내진 것으로(\n\n 기준 3개로 구분됨), 첫번째 메시지는 콜론으로 시작하기 때문에 주석으로 처리되며, 두번째 메시지는 &quot;some text&quot;, 세번째 메시지는 &quot;another message\nwith two lines&quot; 데이터를 각각 전달하고 있다. 참고로 세번째 메시지 처럼 연속적인 data 필드는 각 data 필드 값에 줄바꿈 문자(\n)를 추가한 형태의 데이터 하나로 처리된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1725072223038&quot; class=&quot;haskell&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;: this is a test stream

data: some text

data: another message
data: with two lines&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;아래 예시는 Named Event, 즉 전송되는 각 이벤트는 event 필드에서 지정한 이름을 가지며, 예를 들어 &quot;userconnect&quot; 이벤트를 구독한 클라이언트는 다른 메시지는 무시하고 해당 이벤트 메시지만을 수신하여 처리할 수 있다.  추가로 data 필드는 다음과 같이 JSON 형식의 문자열일 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1725072223038&quot; class=&quot;avrasm&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;event: userconnect
data: {&quot;username&quot;: &quot;bobby&quot;, &quot;time&quot;: &quot;02:33:48&quot;}

event: usermessage
data: {&quot;username&quot;: &quot;bobby&quot;, &quot;time&quot;: &quot;02:34:11&quot;, &quot;text&quot;: &quot;Hi everyone.&quot;}

event: userdisconnect
data: {&quot;username&quot;: &quot;bobby&quot;, &quot;time&quot;: &quot;02:34:23&quot;}

event: usermessage
data: {&quot;username&quot;: &quot;sean&quot;, &quot;time&quot;: &quot;02:34:36&quot;, &quot;text&quot;: &quot;Bye, bobby.&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;h2 data-ke-size=&quot;size26&quot;&gt;자바스크립트, 스프링 이용한 간단 실습&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트, 스프링을 이용하여 SSE 통신을 간단하게 구현해보자. 먼저 Controller에서 SSE를 사용하겠다는 헤더를 설정해준 뒤 SSE 이벤트 스트림 형식에 맞게 데이터를 전송해준다. 아래 서버에서 보내는 이벤트 메시지의 의미는, &quot;이벤트 ID는 1, 이벤트 이름은 'Connected', data는 'hello\nworld\n!', 재연결 요청 전 대기 시간은 10초&quot; 라는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1725182088824&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api/v1&quot;)
public class SseController {

    @GetMapping(value = &quot;/sse&quot;)
    public void sse2(HttpServletResponse response) throws IOException {
        response.setContentType(&quot;text/event-stream&quot;);
        response.setCharacterEncoding(&quot;UTF-8&quot;); //이벤트 스트림 데이터는 항상 UTF-8 인코딩
        PrintWriter writer = response.getWriter();
        writer.write(&quot;&quot;&quot;
                id: 1\n
                event: Connected\n
                data: hello
                data: world
                data: !
                retry: 10000\n\n
                &quot;&quot;&quot;);
        writer.close();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 웹 브라우저일 경우, 브라우저가 제공하는 JavaScript Web API인 &lt;code&gt;EventSource&lt;/code&gt; 객체를 SSE에 통신에 사용할 수 있다. &lt;code&gt;EventSource&lt;/code&gt;는 HTML5 웹 표준에 정의되어 있으며, 현대적인 브라우저는 모두 이를 제공한다. 다음과 같이 서버의 SSE 연결 요청 API로 요청을 보내 &lt;code&gt;EventSource&lt;/code&gt; 객체를 생성할 수 있으며 &lt;code&gt;onmessage&lt;/code&gt; 콜백을 설정하여 클라이언트가 수신한 모든 이벤트를 처리할 수 있다. &lt;code&gt;addEventListener&lt;/code&gt; api를 통해서는 특정 이벤트에 대해서만 반응하도록 콜백을 설정할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1725182197733&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let sse = new EventSource(&quot;http://localhost:8080/api/v1/sse&quot;);
sse.onmessage = (e) =&amp;gt; console.log(e.data);
sse.addEventListener(&quot;Connected&quot;, (e) =&amp;gt; console.log(e.data)); //Connected 이벤트에 대해서만 동작&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-01 오후 6.18.48.png&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HMWtz/btsJmbEG4Kd/b1QHy3PyNA5P5jKqyy2e30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HMWtz/btsJmbEG4Kd/b1QHy3PyNA5P5jKqyy2e30/img.png&quot; data-alt=&quot;이벤트 스트림에서 수신한 메시지의 data 필드 값 출력&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HMWtz/btsJmbEG4Kd/b1QHy3PyNA5P5jKqyy2e30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHMWtz%2FbtsJmbEG4Kd%2Fb1QHy3PyNA5P5jKqyy2e30%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;757&quot; height=&quot;214&quot; data-filename=&quot;스크린샷 2024-09-01 오후 6.18.48.png&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;438&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이벤트 스트림에서 수신한 메시지의 data 필드 값 출력&lt;/figcaption&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;code&gt;EventSource&lt;/code&gt; 객체는 서버와의 연결이 끊어졌을 때 자동 재접속하는 기능을 내장하고 있다. 따라서 클라이언트 - 서버 간 SSE 연결이 끊긴 경우 앞서 설정한 retry 시간인 10초를 대기한 후 자동 SSE 재연결 요청을 보내고 있다.&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-filename=&quot;스크린샷 2024-09-01 오후 6.19.47.png&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;1452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nTlgO/btsJm7anl73/po7TtKYVYhNaSRg1GXkQP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nTlgO/btsJm7anl73/po7TtKYVYhNaSRg1GXkQP1/img.png&quot; data-alt=&quot;SSE 연결이 끊긴 경우 자동 재연결 요청&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nTlgO/btsJm7anl73/po7TtKYVYhNaSRg1GXkQP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnTlgO%2FbtsJm7anl73%2Fpo7TtKYVYhNaSRg1GXkQP1%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;595&quot; height=&quot;558&quot; data-filename=&quot;스크린샷 2024-09-01 오후 6.19.47.png&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;1452&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SSE 연결이 끊긴 경우 자동 재연결 요청&lt;/figcaption&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;&lt;span style=&quot;color: #333333;&quot;&gt;SSE  장단점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;SSE의 장단점은 다음과 같다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;장점&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- 구현이 간편 (러닝 커브 거의 없음)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- HTTP 기반으로 방화벽에 친화적 (원래 열려있는 80, 443 포트를 그대로 사용하기 때문에 웹소켓 처럼 따로 포트 관련 설정 불필요)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- Polling 방식에 비해 실시간성 증가&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;단점&amp;nbsp;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- stateful 하기 때문에 수평적 확장에 취약, 즉 다중 WAS 환경에서 문제 발생&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;SSE는 지속적인 연결을 유지 &amp;rarr; 많은 클라이언트가 동시에 연결을 유지할 경우 서버 부담이 커짐&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- 연결이 유지되는 동안 서버 &amp;rarr; 클라이언트로의 단방향 통신만 가능, 따라서 클라이언트 - 서버 간 양방향 통신이 필요한 경우 부적절&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #303a3e;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #303a3e;&quot;&gt;마무리&amp;nbsp;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #303a3e;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;아직 풀리지 않는 의문이 있다면 정확히 어떤 원리로 클라이언트 - 서버 간 연결이 끊기지 않고 지속되는 걸까?이다. 이 부분은 HTTP의 Persistent Connection에 대해 더 공부가 필요할 것 같다.&lt;/span&gt; (&lt;a href=&quot;https://www.yes24.com/Product/Goods/15381085&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP 완벽 가이드&lt;/a&gt; &lt;span style=&quot;color: #333333;&quot;&gt;책 추천)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이런 실시간 처리 방식은 Polling, WebSocket 정도만 알고 있었는데, 개인 프로젝트에서 다른 팀원이 '내가 작성한 글에 댓글이 달렸을 경우 알림' 구현에 SSE를 사용한 것을 보고 처음 'SSE'라는 키워드를 듣게 되었고, 회사에서 '대용량 데이터 처리 완료 알림' 구현에 직접 SSE를 적용해보았다. 이제 웹 좀 알겠다 싶었는데 여전히 내가 모르는 영역이 많다는걸 새삼 깨닫고 자동 겸손해지게 된다. 암튼 스프링 4.2부터는 SSE 통신을 지원하는 &lt;code&gt;SseEmitter&lt;/code&gt; API를 제공하는데, 다음 글에서는 이를 이용해 실제 프로젝트에서 활용가능한 SSE 통신을 구현해보고 트러블 슈팅 과정도 정리해보고자 한다.&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;끝.&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events&quot; target=&quot;_self&quot;&gt;&lt;span&gt;https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/EventSource/message_event&quot; target=&quot;_self&quot;&gt;&lt;span&gt;https://developer.mozilla.org/en-US/docs/Web/API/EventSource/message_event&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://boxfoxs.tistory.com/403&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://boxfoxs.tistory.com/403&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=4HlNv1qpZFY&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://www.youtube.com/watch?v=4HlNv1qpZFY&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=i4-MNzNML_c&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://www.youtube.com/watch?v=i4-MNzNML_c&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=Fjj4ZmLlrP8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://www.youtube.com/watch?v=Fjj4ZmLlrP8&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>CS/Web</category>
      <category>http</category>
      <category>SSE</category>
      <category>web</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/50</guid>
      <comments>https://daeun21dev.tistory.com/50#entry50comment</comments>
      <pubDate>Sun, 1 Sep 2024 19:38:14 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] 스프링 시큐리티의 기본 필터 체인</title>
      <link>https://daeun21dev.tistory.com/49</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서 스프링 시큐리티의 전반적인 필터 구조 및 흐름을 살펴봤다. 이번 글에서는 스프링 시큐리티가 기본적으로 제공하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DefaultSercurityFilterChain&lt;/code&gt;(일련의 시큐리티 관련 작업을 수행하는 필터들의 묶음) 내 필터들의 종류와 각 필터의 역할에 대해 간단히? 알아보고, 해당 필터들을 기반으로 커스텀 필터를 생성하고 등록해보자.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;스프링 시큐리티의 DefaultSecurityFilterChain&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;스프링 시큐리티의 필터 구조는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DelegatingFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;FilterChainProxy&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;순으로 구조화되어 있다고 했다. &lt;a href=&quot;https://daeun21dev.tistory.com/46&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt; 이때 스프링 시큐리티가 기본적으로 제공하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DefaultSecurityFilterChain&lt;/code&gt;(&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;인터페이스의 구현 클래스)이 있는데 이는&amp;nbsp;&amp;nbsp;&lt;code&gt;FilterChainProxy&lt;/code&gt;에 중단점을 찍어서 확인할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;FilterChainProxy&lt;/code&gt;는 내부에 여러&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;을 관리하며 이 중 특정 요청을 처리할 수 있는 하나의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;에게 처리를 위임한다. 스프링 시큐리티 의존성을 추가하고 아무 설정 클래스도 생성하지 않았을 때에 기본적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DefaultSecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;하나만 존재하며, 해당 클래스는 다시 (현재는) 16개의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Filter&lt;/code&gt;에 의존하게 된다.&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;여러 종류의 Filter, List&amp;lt;Filter&amp;gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1432&quot; data-origin-height=&quot;1210&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NJ9Bo/btsI6LMqZNE/Lf23b9kP2fmXGUMIRk70Ck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NJ9Bo/btsI6LMqZNE/Lf23b9kP2fmXGUMIRk70Ck/img.png&quot; data-alt=&quot;DefaultSecurityFilterChain이 의존하는 기본 필터 목록&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NJ9Bo/btsI6LMqZNE/Lf23b9kP2fmXGUMIRk70Ck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNJ9Bo%2FbtsI6LMqZNE%2FLf23b9kP2fmXGUMIRk70Ck%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;481&quot; height=&quot;406&quot; data-origin-width=&quot;1432&quot; data-origin-height=&quot;1210&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DefaultSecurityFilterChain이 의존하는 기본 필터 목록&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이렇게 스프링 시큐리티는 내부적으로 필터 체인을 유지하며 각각의 필터들은 로그아웃, 로그인 등 주요한 로직을 담당한다. 따라서 서비스 요구사항에 따라 특정 역할을 수행하는 필터가 추가되거나 제거될 수 있다.&lt;br /&gt;&lt;br /&gt;실제로 각 필터들은 주요 로직을 직접 실행하기 보다는, 요청을 가로 채 스프링 컨테이너 내 시큐리티 관련 빈에게 요청 처리를 위임한다.(각 로직의 시작점 역할을 함) 즉&amp;nbsp;&lt;code&gt;DefaultSecurityFilterChain&lt;/code&gt;&amp;nbsp;내부에 등록된 여러 필터들을 거치면서 특정 요청에 대해 로그인 처리가 필요하다면 로그인 관련 필터가 이를 가로 채고 실제 로그인 처리는 스프링 컨테이너에 등록된 시큐리티 관련 빈들에게 위임하는 것이다. 요청 처리가 완료된 후 다시 로그인 관련 필터로 되돌아 오고 다시 다음 필터로 요청 흐름이 이어진다. (필터 내부적으로 모두 처리할 수 있을 만큼 간단한 작업이라면 시큐리티 관련 빈에게 요청 처리 위임하지 않고 직접 처리하기도 한다.)&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;GenericFilterBean &amp;amp; OncePerRequestFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;시큐리티 관련 필터들은 모두&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;GenericFilterBean&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;또는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OncePerRequestFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;추상 클래스를 상속하고 있다. 이때&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;GenericFilterBean&lt;/code&gt;는 자바 서블릿 필터 기반으로, 자바 서블릿 영역에서 스프링 영역에 접근할 수 있도록 구현되어 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OncePerRequestFilter&lt;/code&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;GenericFilterBean&lt;/code&gt;를 상속한 추상 클래스로, 둘의 가장 큰 차이점은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;GenericFilterBean&lt;/code&gt;는 필터를 여러번 통과한다면 통과 수만큼 내부 로직 실행되는 반면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OncePerRequestFilter&lt;/code&gt;는 클라이언트의 한번 요청에 대해 해당 필터를 여러번 거칠 경우 딱 한번만 로직이 실행된다는 것이다.&lt;br /&gt;&lt;br /&gt;이때 중요한 것은 클라이언트의 요청이 기준이라는 것이다. 즉 302 redirect 상황에서는 실제 클라이언트 &amp;rarr; 서버로 총 2번의 요청을 보내는 것으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OncePerRequestFilter&lt;/code&gt;가 2번 동작하는 반면, 서버 내부적으로 재요청을 하는 forward의 경우에는 1번만 동작하게 된다.&lt;br /&gt;&lt;br /&gt;뒤에서 설명하겠지만 커스텀 필터를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;에 등록하기 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;GenericFilterBean&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;또는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OncePerRequestFilter&lt;/code&gt;를 구현한 필터를 생성할 것이다. 그럼 이제 본격적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DefaultSecurityFilterChain&lt;/code&gt;에 등록되는 16개의 기본 필터들의 역할과 동작 원리를 살펴보자. 조금 길 수 있으니 끊어 읽으면 좋을듯 하다. (각 필터의 주요 코드 및 로직을 중점적으로 살펴보고자 하였으며 더 자세한 내용은 첨부한 Spring Security Docs를 참고하시길. 기본적으로 스프링 시큐리티로 인증, 인가 로직을 한번이라도 구현해보고 관련 코드를 대략적으로 까본적이 있다는 것을 가정하고 글을 썼다.)&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;DisableEncodeUrlFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;필터 체인의 가장 첫번째에 위치하는 필터로, URL로 간주되지 않는 부분(보안 위협이 존재할 수 있는 부분)을 포함하지 않도록 설정하는 역할을 한다. 이게 뭔말인가 하면, 세션 기반 로그인을 구현한 경우 기본적으로 응답의 URL 파라미터에 세션 ID가 인코딩되어 전달되거나 로그로 남게 된다. 세션 ID가 유출되면 세션 공격이 가능해지기 때문에 이를 방지하는 역할을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DisableEncodeUrlFilter&lt;/code&gt;가 하는 것이다. 물론 세션 기반 로그인과 관련 있는 필터이기 때문에 다음과 같이 스프링 시큐리티의 세션 관리 설정을 disable 하면 비활성화 되며, 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;을 생성해도 자동 등록된다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.sessionManagement(config -&amp;gt; config.disable());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;code&gt;DisableEncodeUrlFilter&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;doFilterInternal&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서 다음 필터를 호출 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DisableEncodeUrlResponseWrapper&lt;/code&gt;로 현재&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;response&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 한번더 감싸서 전달하는 것을 확인할 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DisableEncodeUrlResponseWrapper&lt;/code&gt;는 &lt;code&gt;DisableEncodeUrlFilter&lt;/code&gt;의 정적 내부 클래스로, 오버라이드된 메소드를 보면 순수 url을 그대로 반환하고 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class DisableEncodeUrlFilter extends OncePerRequestFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;filterChain.doFilter(request, new DisableEncodeUrlResponseWrapper(response)); //response 객체를 wrapping
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final class DisableEncodeUrlResponseWrapper extends HttpServletResponseWrapper {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private DisableEncodeUrlResponseWrapper(HttpServletResponse response) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;super(response);
&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;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public String encodeRedirectURL(String url) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return url; //순수 URL만 반환
&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;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public String encodeURL(String url) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return url;
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;그럼 기존의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpServletResponse&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;구현체인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;org.apache.catalina.connector.Response&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;encodedRedirectURL&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;encodeURL&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드는 어떻게 정의되어 있길래 세션 ID가 파라미터로 전달되는지 확인해보자.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class Response implements HttpServletResponse {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public String encodeRedirectURL(String url) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return this.isEncodeable(this.toAbsolute(url)) ? this.toEncoded(url, this.request.getSessionInternal().getIdInternal()) : url;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public String encodeURL(String url) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String absolute;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;absolute = this.toAbsolute(url);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (IllegalArgumentException var4) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return url;
&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;if (this.isEncodeable(absolute)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (url.equalsIgnoreCase(&quot;&quot;)) {
&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;url = absolute;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else if (url.equals(absolute) &amp;amp;&amp;amp; !this.hasPath(url)) {
&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;url = url + &quot;/&quot;;
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return this.toEncoded(url, this.request.getSessionInternal().getIdInternal());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return url;
&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;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//URL에 세션 ID 추가
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected String toEncoded(String url, String sessionId) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (url != null &amp;amp;&amp;amp; sessionId != null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String path = url;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String query = &quot;&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String anchor = &quot;&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int question = url.indexOf(63);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (question &amp;gt;= 0) {
&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;path = url.substring(0, question);
&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;query = url.substring(question);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int pound = path.indexOf(35);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (pound &amp;gt;= 0) {
&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;anchor = path.substring(pound);
&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;path = path.substring(0, pound);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;StringBuilder sb = new StringBuilder(path);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (sb.length() &amp;gt; 0) {
&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;sb.append(';');
&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;sb.append(SessionConfig.getSessionUriParamName(this.request.getContext()));
&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;sb.append('=');
&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;sb.append(sessionId); //세션 ID가 쿼리 파라미터로 전달
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sb.append(anchor);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sb.append(query);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return sb.toString();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return url;
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;encodedRedirectURL&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;encodeURL&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;toEncoded&lt;/code&gt;를 호출하고 있으며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;toEncoded&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드는 기존 url에 세션 ID값을 쿼리 파라미터로 추가하여 반환하고 있다. 이렇게 세션 ID가 응답으로 전달되어 탈취될 가능성을 막고자&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DisableEncodeUrlFilter&lt;/code&gt;가 동작하는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724106538600&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;DisableEncodeUrlFilter (spring-security-docs 6.3.2 API)&quot; data-og-description=&quot;org.springframework.web.filter.OncePerRequestFilter org.springframework.security.web.session.DisableEncodeUrlFilter&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/session/DisableEncodeUrlFilter.html&quot; data-og-url=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/session/DisableEncodeUrlFilter.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/session/DisableEncodeUrlFilter.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/session/DisableEncodeUrlFilter.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;DisableEncodeUrlFilter (spring-security-docs 6.3.2 API)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;org.springframework.web.filter.OncePerRequestFilter org.springframework.security.web.session.DisableEncodeUrlFilter&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;WebAsyncManagerIntegrationFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WebAsyncManagerIntegrationFilter&lt;/code&gt;는 비동기로 처리되는 작업에 대해 알맞은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 설정해주는 역할을한다. 즉 서블릿 단에서 비동기 작업을 수행할 때 서블릿 입출력 쓰레드와 작업 쓰레드가 동일한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolder&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 참조하도록 도와주는 것이다. 다시 말해 하나의 요청을 처리하는데 여러 쓰레드가 사용될 경우 모두 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 참조할 수 있도록 싱크를 맞춰주는 역할을 한다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecurityContextHolder&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;기본 관리 전략은 ThreadLocal로, 따라서 동일한 쓰레드에서만 동일한&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에 접근 가능하다. (&lt;code&gt;SecurityContextHolderStrategy&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;내부에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ThreadLocal&amp;lt;Supplier&amp;lt;SecurityContext&amp;gt;&amp;gt;&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;필드로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 쓰레드 로컬 저장소에서 관리하고 있다.) 비동기 방식의 경우 하나의 작업을 2개 이상의 쓰레드로 수행하기 때문에 이 부분을 보완하기 위해 해당 필터가 필요하다. (커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;을 생성해도 항상 자동 등록)&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@RestController
public class AsyncController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GetMapping(&quot;/async&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public Callable&amp;lt;String&amp;gt; asyncTest() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.info(&quot;Thread = {}, SecurityContext = {}&quot;, Thread.currentThread().getName(), SecurityContextHolder.getContext().hashCode());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return () -&amp;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;Thread.sleep(4000);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.info(&quot;Thread = {}, SecurityContext = {}&quot;, Thread.currentThread().getName(), SecurityContextHolder.getContext().hashCode());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return &quot;async&quot;;
&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;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;bash&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;Thread = http-nio-8080-exec-2, SecurityContext = -1114173449
Thread = task-1, SecurityContext = -1114173449&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;위 컨트롤러를 직접 호출해보면 각기 다른 쓰레드에서 로그가 출력되지만 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 참조하는 것을 확인할 수 있다. 그럼 어떻게 컨트롤러 단에서 발생하는 멀티 쓰레드 문제를 필터에서 처리할 수 있는 것일까?? 이를 이해하기 위해서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;WebAsyncManagerIntegrationFilter&lt;/code&gt;와 반환 타입이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Callable&lt;/code&gt;인 컨트롤러의 동작 방식을 먼저 살펴봐야 한다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;code&gt;WebAsyncManagerIntegrationFilter&lt;/code&gt;는 실제로 요청 쓰레드(서블릿 컨테이너가 요청 하나를 처리하기 위해 할당한 쓰레드)의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 관리하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextCallableProcessingInterceptor&lt;/code&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;WebAyncManager&lt;/code&gt;에 등록하는 작업만을 수행하다. 실질적인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;의 동기화 작업은 MVC 단에서 수행되는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #282c34; color: #abb2bf;&quot;&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public final class WebAsyncManagerIntegrationFilter extends OncePerRequestFilter {

    //...

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
        SecurityContextCallableProcessingInterceptor securityProcessingInterceptor = (SecurityContextCallableProcessingInterceptor)asyncManager.getCallableInterceptor(CALLABLE_INTERCEPTOR_KEY);
        if (securityProcessingInterceptor == null) {
            SecurityContextCallableProcessingInterceptor interceptor = new SecurityContextCallableProcessingInterceptor();
            interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
            asyncManager.registerCallableInterceptor(CALLABLE_INTERCEPTOR_KEY, interceptor); //인터셉터 등록
        }

        filterChain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;그리고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Callable&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 반환하는 컨트롤러 호출 시 동작 원리는 아주 간단하게 말하자면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Controller&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CallableMethodReturnValueHandler&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;WebAsyncManager&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextCallableProcessingInterceptor&lt;/code&gt;가 차례대로 실행된다. 이 과정에서 미리&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;WebAsyncManagerIntegrationFilter&lt;/code&gt;를 통해 등록한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextCallableProcessingInterceptor&lt;/code&gt;를 이용해 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체에 접근할 수 있는 것이다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;컨트롤러가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Callable&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 반환하는 경우 동작하는 핸들러를 좀더 살펴보면, &lt;code&gt;handleReturnValue&lt;/code&gt; 메소드에서 비동기 작업을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;WebAyncManager&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;startCallableProcessing&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 호출함으로써 처리를 위임하고 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class CallableMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CallableMethodReturnValueHandler() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean supportsReturnType(MethodParameter returnType) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return Callable.class.isAssignableFrom(returnType.getParameterType());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (returnValue == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;mavContainer.setRequestHandled(true);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Callable&amp;lt;?&amp;gt; callable = (Callable)returnValue;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;WebAsyncUtils.getAsyncManager(webRequest).startCallableProcessing(callable, new Object[]{mavContainer}); //callable 객체의 비동기 작업을 위임
&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;/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;br /&gt;코드가 좀 긴데, 주요하게 볼 메소드 호출 흐름은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;startCallableProcessing&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;applyBeforeConcurrentHandling&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;applyPreProcess&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;call&lt;/code&gt;&lt;span&gt;&amp;nbsp;순&lt;/span&gt;이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;applyBeforeConcurrentHandling&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서는 본격적인 비동기 작업 처리 전 현재 요청 쓰레드의 쓰레드 로컬에 저장된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextCallableProcessingInterceptor&lt;/code&gt;의 내부 필드로 초기화, 즉 다른 쓰레드에서도 접근할수 있도록&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 임시 저장해둔다. 그리고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;applyPreProcess&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서는 현재 작업 쓰레드의 쓰레드 로컬에 앞서 임시 저장했던&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 옮겨 놓는다. 따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;call&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드 내에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolder&lt;/code&gt;를 통해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에 접근하더라도 (분명 다른 쓰레드임에도 불구하고) 같은 객체를 참조할 수 있는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public final class WebAsyncManager {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//WebAsyncManagerIntegrationFilter에서 SecurityContextCallableProcessingInterceptor 등록을 위해 호출되는 메소드
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void registerCallableInterceptors(CallableProcessingInterceptor... interceptors) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CallableProcessingInterceptor[] var2 = interceptors;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int var3 = interceptors.length;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for(int var4 = 0; var4 &amp;lt; var3; ++var4) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CallableProcessingInterceptor interceptor = var2[var4];
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String var10000 = interceptor.getClass().getName();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String key = var10000 + &quot;:&quot; + interceptor.hashCode();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.callableInterceptors.put(key, interceptor);
&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;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void startCallableProcessing(Callable&amp;lt;?&amp;gt; callable, Object... processingContext) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.startCallableProcessing(new WebAsyncTask(callable), processingContext);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void startCallableProcessing(final WebAsyncTask&amp;lt;?&amp;gt; webAsyncTask, Object... processingContext) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!this.state.compareAndSet(WebAsyncManager.State.NOT_STARTED, WebAsyncManager.State.ASYNC_PROCESSING)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new IllegalStateException(&quot;Unexpected call to startCallableProcessing: [&quot; + this.state.get() + &quot;]&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;CallableProcessingInterceptor&amp;gt; interceptors = new ArrayList();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptors.add(webAsyncTask.getInterceptor());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptors.addAll(this.callableInterceptors.values()); //WebAsyncManagerIntegrationFilter에서 등록한 SecurityContextCallableProcessingInterceptor 추가
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptors.add(timeoutCallableInterceptor);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Callable&amp;lt;?&amp;gt; callable = webAsyncTask.getCallable();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CallableInterceptorChain interceptorChain = new CallableInterceptorChain(interceptors);

&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptorChain.applyBeforeConcurrentHandling(this.asyncWebRequest, callable); //현재 요청 쓰레드 로컬에 저장된 SecurityContext를 인터셉터 내부 필드에 저장
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.startAsyncProcessing(processingContext);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&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;Future&amp;lt;?&amp;gt; future = this.taskExecutor.submit(() -&amp;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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Object result = null;

&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptorChain.applyPreProcess(this.asyncWebRequest, callable); //SecurityContextCallableProcessingInterceptor 내부 필드에 저장된 SecurityContext를 현재 Callable 실행 쓰레드의 쓰레드 로컬에 저장
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;result = callable.call(); //callable 실행
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Throwable var8) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Throwable ex = var8;
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;result = ex;
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} finally {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;result = interceptorChain.applyPostProcess(this.asyncWebRequest, callable, result); //현재 Callable 실행 쓰레드의 SecurityContext 초기화
&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;&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;&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;&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptorChain.setTaskFuture(future);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Throwable var10) {
&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;//...
&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;&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;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public final class SecurityContextCallableProcessingInterceptor implements CallableProcessingInterceptor {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private volatile SecurityContext securityContext;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityContextCallableProcessingInterceptor() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityContextCallableProcessingInterceptor(SecurityContext securityContext) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.setSecurityContext(securityContext);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//현재 요청 쓰레드의 쓰레드 로컬의 SecurityContext로 내부 필드 초기화
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public &amp;lt;T&amp;gt; void beforeConcurrentHandling(NativeWebRequest request, Callable&amp;lt;T&amp;gt; task) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.securityContext == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.setSecurityContext(this.securityContextHolderStrategy.getContext());
&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;//내부 필드로 작업 쓰레드의 쓰레드 로컬의 SecurityContext 객체 초기화
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public &amp;lt;T&amp;gt; void preProcess(NativeWebRequest request, Callable&amp;lt;T&amp;gt; task) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.setContext(this.securityContext);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//작업 쓰레드의 쓰레드 로컬의 SecurityContext 객체 무효화
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public &amp;lt;T&amp;gt; void postProcess(NativeWebRequest request, Callable&amp;lt;T&amp;gt; task, Object concurrentResult) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.clearContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy = securityContextHolderStrategy;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void setSecurityContext(SecurityContext securityContext) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContext = securityContext;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;그러나 아쉽게도 해당 필터는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Callable&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 통한 컨트롤러 단에서의 비동기 작업만을 보완해주며&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;@Async&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;방식의 비동기 작업에는 별도의 설정이 필요하다. 이것까지 알아보면 글의 주제와 벗어나고 글이 너무 길어지므로 일단 패스한다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724106696092&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;WebAsyncManagerIntegrationFilter (Spring Security 4.0.4.RELEASE API)&quot; data-og-description=&quot;protected void doFilterInternal(javax.servlet.http.HttpServletRequest&amp;nbsp;request, javax.servlet.http.HttpServletResponse&amp;nbsp;response, javax.servlet.FilterChain&amp;nbsp;filterChain)&amp;nbsp;&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/site/docs/4.0.x/apidocs/org/springframework/security/web/context/request/async/WebAsyncManagerIntegrationFilter.html&quot; data-og-url=&quot;https://docs.spring.io/spring-security/site/docs/4.0.x/apidocs/org/springframework/security/web/context/request/async/WebAsyncManagerIntegrationFilter.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/site/docs/4.0.x/apidocs/org/springframework/security/web/context/request/async/WebAsyncManagerIntegrationFilter.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/site/docs/4.0.x/apidocs/org/springframework/security/web/context/request/async/WebAsyncManagerIntegrationFilter.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;WebAsyncManagerIntegrationFilter (Spring Security 4.0.4.RELEASE API)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;protected void doFilterInternal(javax.servlet.http.HttpServletRequest&amp;nbsp;request, javax.servlet.http.HttpServletResponse&amp;nbsp;response, javax.servlet.FilterChain&amp;nbsp;filterChain)&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;SecurityContextHolderFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;접근한 유저에 대해 시큐리티 컨텍스트를 관리하는 필터로, 이전 요청을 통해 이미 인증된 사용자 정보를 현재 요청의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolder&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에 할당하는 역할을 수행하며, 현재 요청이 끝나면 자동으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 무효화한다. 세션 기반 로그인에서 유지되고 있는 세션의 유저 정보를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에 저장하기 위해 주로 사용되며, JWT 기반 로그인에서도 응답 전달 시 현재 요청에 대한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 자동 무효화해주기 때문에 해당 필터가 유용하게 사용될 수 있다. (특히&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;는 기본적으로 쓰레드 로컬 저장소를 사용하기 때문에, 쓰레드 풀을 통해 쓰레드를 재사용하는 서블릿 컨테이너에서는 이를 초기화 해주는 것이 중요하다.) 해당 필터는 다음과 같이 비활성화 할 수 있다. (커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 자동 등록)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.securityContext(config -&amp;gt; config.disable());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;의 코드를 좀더 살펴보자.&lt;br /&gt;이전 요청에서 사용자가 로그인을 진행한 경우, 서버 세션 또는 레디스와 같은 저장 매체에 로그인한 유저 정보가 존재한다. 이를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextRepository&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;loadDeferredContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 호출하여 저장된 정보를 조회한다. 참고로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;loadDeferredContext&lt;/code&gt;는 로딩을 지연시켜 &lt;code&gt;SecurityContext&lt;/code&gt;를 조회하는 동작인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Supplier&lt;/code&gt;를 반환하고, 필요 시점에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Supplier&lt;/code&gt;를 실행시켜&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 조회해오도록 한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;(없다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Authentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체가 null인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;반환) 이렇게 조회된 유저 정보를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolder&lt;/code&gt;에 저장하고 다음 필터를 호출한다. 이후 요청 처리가 끝나 되돌아 오는 경우에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를  무효화 해준다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class SecurityContextHolderFilter extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final String FILTER_APPLIED = SecurityContextHolderFilter.class.getName() + &quot;.APPLIED&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final SecurityContextRepository securityContextRepository;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (request.getAttribute(FILTER_APPLIED) != null) { //해당 필터가 한번 동작했다면 다음 필터로 넘어감
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Supplier&amp;lt;SecurityContext&amp;gt; deferredContext = this.securityContextRepository.loadDeferredContext(request); //저장된 SecurityContext 조회

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&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;this.securityContextHolderStrategy.setDeferredContext(deferredContext); 
&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;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} finally {
&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;this.securityContextHolderStrategy.clearContext(); //SecurityContext 무효화
&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;request.removeAttribute(FILTER_APPLIED);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;여기서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;의 저장 매체에 따라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextRepository&lt;/code&gt;의 다른 구현체를 사용한다. 주요 구현체는 다음과 같다.&lt;br /&gt;-&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpSessionSecurityContextRepository&lt;/code&gt;:  &lt;code&gt;SecurityContext&lt;/code&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpSession&lt;/code&gt;에 저장해 영속성을 유지&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;-&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;RequestAttributeSecurityContextRepository&lt;/code&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ServletContext&lt;/code&gt;에 저장해 후속 요청 시 영속성 유지 불가능&amp;nbsp;&lt;br /&gt;-&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;NullSecurityContextRepository&lt;/code&gt;: 빈&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체 반환 (세션을 사용하지 않는 JWT, OAuth2 인증 방식 등 stateless하게 동작하는 경우 사용)&lt;br /&gt;-&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DelegatingSecurityContextRepository&lt;/code&gt;: 기본 설정돼 내부적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpSessionSecurityContextRepository&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;RequestAttributeSecurityContextRepository&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;리스트의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;delegates&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;필드 관리&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이 외에도 직접&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextRepository&lt;/code&gt;를 구현하여 아래와 같이 설정해줄 수 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.securityContext(config -&amp;gt; config.securityContextRepository(new NullSecurityContextRepository()));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;SecurityContextPersistenceFilter vs SecurityContextHolderFilter&lt;/h4&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextPersistenceFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;후속으로 시큐리티 5.8 버전부터 내부 구현이 변경되면서 기존 클래스는 deprecated 되었다. 둘의 기능은 거의 동일하며 가장 큰 차이점은 응답이 되돌아올 때 변경된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 저장소에 재저장할 것인지 여부이다.&lt;br /&gt;&lt;br /&gt;이전 클래스는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;가 변경되면 변경된 객체를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextRepository&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;저장소에 업데이트한 반면, 현재 클래스(&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;)는 변경된 객체를 재저장하지 않는다. 즉 이전까지 사용됐던&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextPersistenceFilter&lt;/code&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;saveContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메서드를 강제로 수행해 세션에 인증 정보를 저장했는데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;는 사용자가 명시적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;saveContext&lt;/code&gt;를 실행하도록 한다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;인증 필터에서 인증 처리 시, 필요한 경우에만 인증 정보를 세션에 저장해야 한다. 따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;필터는 시큐리티가 무조건 인증 정보를 저장하도록 강제하기 보다는, 인증 매커니즘에 따라 다르게 구현할 수 있도록 유연성을 제공한 것이다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Deprecated
public class SecurityContextPersistenceFilter extends GenericFilterBean {

    static final String FILTER_APPLIED = &quot;__spring_security_scpf_applied&quot;;
    private SecurityContextRepository repo;
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
        .getContextHolderStrategy();
    private boolean forceEagerSessionCreation = false;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
	        throws IOException, ServletException {
        if (request.getAttribute(FILTER_APPLIED) != null) {
            chain.doFilter(request, response);
            return;
        }
        request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
        
        if (this.forceEagerSessionCreation) {
            HttpSession session = request.getSession();
        }
        HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
        SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
        try {
            this.securityContextHolderStrategy.setContext(contextBeforeChainExecution);
            //...
            chain.doFilter(holder.getRequest(), holder.getResponse());
        }
        finally {
            SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
            this.securityContextHolderStrategy.clearContext();
            this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); //인증 정보 세션에 항상 저장
            request.removeAttribute(FILTER_APPLIED);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;그러나 항상 인증 정보를 세션에 저장하도록 설정하고 싶다면 다음과 같이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityConfig&lt;/code&gt;를 작성해주면 되고, 이 경우에는&amp;nbsp;&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;가 아닌&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextPersistenceFilter&lt;/code&gt;가 필터 체인에 등록된다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.securityContext(config -&amp;gt; config.requireExplicitSave(false));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V3LLr/btsI7VADMZ5/KZLcF7LWNgqWDS1aqwkKAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V3LLr/btsI7VADMZ5/KZLcF7LWNgqWDS1aqwkKAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V3LLr/btsI7VADMZ5/KZLcF7LWNgqWDS1aqwkKAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV3LLr%2FbtsI7VADMZ5%2FKZLcF7LWNgqWDS1aqwkKAk%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;410&quot; height=&quot;217&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;418&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724106808247&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;Persisting Authentication :: Spring Security&quot; data-og-description=&quot;The first time a user requests a protected resource, they are prompted for credentials. One of the most common ways to prompt for credentials is to redirect the user to a log in page. A summarized HTTP exchange for an unauthenticated user requesting a prot&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html#securitycontextrepository&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html#securitycontextrepository&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/LRylr/hyWSelZCq7/37XlqWRIye2O7K2WmEYf80/img.png?width=1118&amp;amp;height=293&amp;amp;face=0_0_1118_293&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html#securitycontextrepository&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html#securitycontextrepository&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/LRylr/hyWSelZCq7/37XlqWRIye2O7K2WmEYf80/img.png?width=1118&amp;amp;height=293&amp;amp;face=0_0_1118_293');&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;Persisting Authentication :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The first time a user requests a protected resource, they are prompted for credentials. One of the most common ways to prompt for credentials is to redirect the user to a log in page. A summarized HTTP exchange for an unauthenticated user requesting a prot&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;HeaderWriterFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;HTTP 응답 헤더에 사용자 보호를 위한 시큐리티 관련 헤더를 추가해주는 필터로, 아래 설정을 통해 비활성화 할 수 있다. (커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 자동 등록)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.headers(config -&amp;gt; config.disable());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;code&gt;HeaderWriterFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;코드를 살펴보면 헤더를 설정하는 타이밍이 요청 처리(&lt;code&gt;filterChain.doFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;호출) 전/후인지에 따라 각기 다른 메소드를 호출한다. (기본값은 false로 요청을 모두 처리 후 헤더를 설정) 그리고 실제 헤더에 write하는 책임은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HeaderWriter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;리스트에게 위임한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class HeaderWriterFilter extends OncePerRequestFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final List&amp;lt;HeaderWriter&amp;gt; headerWriters;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean shouldWriteHeadersEagerly = false;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.shouldWriteHeadersEagerly) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.doHeadersBefore(request, response, filterChain); //요청 처리 전
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.doHeadersAfter(request, response, filterChain); //요청 처리 후
&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;private void doHeadersBefore(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.writeHeaders(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;filterChain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void doHeadersAfter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HeaderWriterResponse headerWriterResponse = new HeaderWriterResponse(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HeaderWriterRequest headerWriterRequest = new HeaderWriterRequest(request, headerWriterResponse);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;filterChain.doFilter(headerWriterRequest, headerWriterResponse);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} finally {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;headerWriterResponse.writeHeaders();
&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;void writeHeaders(HttpServletRequest request, HttpServletResponse response) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Iterator var3 = this.headerWriters.iterator();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while(var3.hasNext()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HeaderWriter writer = (HeaderWriter)var3.next();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;writer.writeHeaders(request, response);
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;HeaderWriterFilter&lt;/code&gt;가 동작함에 따라 기본 등록된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;XContentTypeOptionsHeaderWriter&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;XXssProtectionHeaderWriter&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CacheControlHeadersWriter&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HstsHeaderWriter&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;XFrameOptionsHeaderWriter&lt;/code&gt;들이 순서대로 동작해 아래 7개의 응답 헤더가 기본 설정된다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1724111558595&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Cache-Control: no-cache, no-store, max-age=0, must-revalidate 
Pragma: no-cache 
Expires: 0 
X-Content-Type-Options: nosniff 
Strict-Transport-Security: max-age=31536000 ; includeSubDomains 
X-Frame-Options: DENY 
X-XSS-Protection: 1; mode=block&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;물론 원하는 응답 헤더를 커스텀 설정할 수도 있다.&amp;nbsp;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;Security HTTP Response Headers :: Spring Security&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;Refer to the relevant sections to see how to customize the defaults for both servlet and webflux based applications. Historically, browsers, including Internet Explorer, would try to guess the content type of a request by using content sniffing. This allow&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/features/exploits/headers.html&quot; data-og-image=&quot;&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/features/exploits/headers.html&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/features/exploits/headers.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/features/exploits/headers.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;Security HTTP Response Headers :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Refer to the relevant sections to see how to customize the defaults for both servlet and webflux based applications. Historically, browsers, including Internet Explorer, would try to guess the content type of a request by using content sniffing. This allow&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;br /&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;CorsFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;먼저 Cors란? Corss-Origin Resource Sharing(교차 출처 리소스 공유)의 약자로, 출처가 다른 서버 간의 리소스 공유를 의미한다. 여기서 출처는 프로토콜, 도메인(호스트 이름), 포트로 구성되며 이 중 하나라도 다르다면 다른 출처로 간주된다. 브라우저는 본래 보안 상의 이유로 출처가 다른 리소스에 대한 접근을 차단하지만, 출처가 다르더라도 요청과 응답을 주고 받을 수 있도록 서버에 리소스 호출이 허용된 출처(Origin)을 명시해주는 것이 바로 Cors 정책이다.&amp;nbsp;&lt;span style=&quot;color: #0070d1;&quot;&gt;참고&lt;/span&gt;&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;cors 에러를 방지하기 위해서는 허용하는 출처 관련 데이터를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CorsConfigurationSource&lt;/code&gt;에 설정해주는 것이 필요하며, 이 설정값을 기반으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CorsFilter&lt;/code&gt;가 cors 관련 응답 헤더를 설정해주는 역할을 한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CorsConfigurationSource&lt;/code&gt;는 아래와 같이 스프링 빈으로 등록할 수도 있으며 여러 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;을 설정하는 경우에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;configurationSource&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 통해 직접 설정해줄 수도 있다. 아래 설정을 통해 이를 비활성화 할 수 있으며 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 자동 등록된다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.cors(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;config -&amp;gt; config.disable()
//&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.configurationSource(corsConfigurationSource()) //이렇게도 설정 가능
&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;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CorsConfigurationSource corsConfigurationSource() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CorsConfiguration configuration = new CorsConfiguration();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;configuration.setAllowedOrigins(Arrays.asList(&quot;https://example.com&quot;));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;configuration.setAllowedMethods(Arrays.asList(&quot;GET&quot;,&quot;POST&quot;));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;source.registerCorsConfiguration(&quot;/**&quot;, configuration);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return source;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;스프링 시큐리티 뿐만 아니라 스프링 MVC에서도 아래와 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;WebConfig&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;클래스 정의 또는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;@CorssOrigin&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;어노테이션을 통해 cors 설정이 가능하다. 시큐리티, MVC 각 cors 설정의 동작 방식이 어떻게 다른지 아주 간단히만 보고 넘어가자.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void addCorsMappings(CorsRegistry registry) {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;registry.addMapping(&quot;/api/**&quot;)
		&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.allowedOrigins(&quot;https://domain2.com&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.allowedMethods(&quot;PUT&quot;, &quot;DELETE&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.allowedHeaders(&quot;header1&quot;, &quot;header2&quot;, &quot;header3&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.exposedHeaders(&quot;header1&quot;, &quot;header2&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.allowCredentials(true).maxAge(3600);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;MVC에서의 cors 설정은 스프링 MVC의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HandlerMapping&lt;/code&gt;(&lt;code&gt;AbstractHandlerMapping&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;클래스의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;getHandler&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드 내에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;getCorsHandlerExecutionChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 호출하여 cors 설정)와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Interceptor&lt;/code&gt;(&lt;code&gt;CorsInterceptor&lt;/code&gt;)가 동작하는 반면, Security에서의 cors 설정은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;org.springframework.web.filter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;패키지의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CorsFilter&lt;/code&gt;가 동작한다. (스프링 MVC의 cors 설정이 나중에 동작하므로 MVC의 cors 설정이 우선하지 않을까 싶다... 확실하지 않음) 아무튼 스프링 시큐리티 의존성을 추가할 경우 MVC, Security 모두 cors 설정을 할 필요 없이 다음과 같이 설정해주면 시큐리티에서도 MVC의 cors 설정을 그대로 사용할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class WebSecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// if Spring MVC is on classpath and no CorsConfigurationSource is provided,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// Spring Security will use CORS configuration provided to Spring MVC
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.cors(Customizer.withDefaults());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;마지막으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CorsFilter&lt;/code&gt;의 코드를 살펴보자. 필터가 동작하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;doFilterInternal&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드 내에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CorsProcessor&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;processRequest&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 호출함으로써 cors 관련 응답 헤더를 설정한 후 현재 요청이 pre-flight 요청이 아니라면 다음 필터로 넘어가고 있다. (위에서 언급했듯 해당 필터는 스프링 시큐리티가 제공하는 필터가 아니기 때문에 시큐리티 의존성을 추가하지 않고도 사용할 수 있다.)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class CorsFilter extends OncePerRequestFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final CorsConfigurationSource configSource;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private CorsProcessor processor = new DefaultCorsProcessor();

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

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;boolean isValid = this.processor.processRequest(corsConfiguration, request, response); //cors 관련 응답 헤더 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!isValid || CorsUtils.isPreFlightRequest(request)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&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;filterChain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class DefaultCorsProcessor implements CorsProcessor {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//cors 관련 응답 헤더 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@SuppressWarnings(&quot;resource&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
	&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HttpServletResponse response) throws IOException {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Collection&amp;lt;String&amp;gt; varyHeaders = response.getHeaders(HttpHeaders.VARY);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!varyHeaders.contains(HttpHeaders.ORIGIN)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;response.addHeader(HttpHeaders.VARY, HttpHeaders.ORIGIN);
&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;if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD);
&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;if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
&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;if (!CorsUtils.isCorsRequest(request)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&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;if (response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.trace(&quot;Skip: response already contains \&quot;Access-Control-Allow-Origin\&quot;&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&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;boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (config == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (preFlightRequest) {
&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;rejectRequest(new ServletServerHttpResponse(response));
&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;return false;
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else {
&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;return true;
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;CORS :: Spring Security&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;Spring Framework provides first class support for CORS. CORS must be processed before Spring Security, because the pre-flight request does not contain any cookies (that is, the JSESSIONID). If the request does not contain any cookies and Spring Security is&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html&quot; data-og-image=&quot;&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/integrations/cors.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;CORS :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Spring Framework provides first class support for CORS. CORS must be processed before Spring Security, because the pre-flight request does not contain any cookies (that is, the JSESSIONID). If the request does not contain any cookies and Spring Security is&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;br /&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;CsrfFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;먼저 CSRF 공격이란? Corss-Site Request Forgery(사이트 간 요청 위조)의 약자로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 웹 취약점 공격 중 하나로, 세션 기반 로그인에서 주로 발생한다. 사이트 간 스크립팅(XSS)을 이용한 공격이 사용자가 특정 웹사이트를 신용하는 점을 노린 것이라면, 사이트간 요청 위조는 특정 웹사이트가 사용자의 웹 브라우저를 신용하는 상태를 노린 것이다. 사용자가 웹 사이트에 로그인한 상태에서 사이트간 요청 위조 공격 코드가 삽입된 페이지를 열면, 공격 대상이 되는 웹사이트는 위조된 공격 명령이 믿을 수 있는 사용자로부터 발송된 것으로 판단하여 공격에 노출된다.&amp;nbsp;&lt;span style=&quot;color: #0070d1;&quot;&gt;참고&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;스프링 시큐리티의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CsrfFilter&lt;/code&gt;의 검증 방식은 토큰 방식으로, csrf 공격 방어를 위해 HTTP 메소드 중 GET, HEAD, TRACE, OPTIONS 메소드를 제외한 요청에 대해 검증을 진행한다. 해당 요청에 대해 서버에 저장된 csrf 토큰이 없다면, csrf 토큰을 생성하여 서버 저장소에 저장하고 응답으로 csrf 토큰을 전달한다. 이후 클라이언트는 모든 요청에 csrf 토큰을 &lt;span style=&quot;color: #333333;&quot;&gt;헤더(&lt;code&gt;&lt;span style=&quot;text-align: left;&quot;&gt;X-XSRF-TOKEN&lt;/span&gt;&lt;/code&gt;)&lt;/span&gt; 또는 파라미터(&lt;code&gt;_csrf&lt;/code&gt;)로 함께 전달하고, 서버는 클라이언의 csrf 토큰과 서버에 저장된 토큰을 비교 검증한다. 마찬가지로 다음 설정을 통해 필터를 비활성화할 수 있으며 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;을 생성해도 자동 등록된다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.csrf(config -&amp;gt; config.disable())
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CsrfFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;코드를 대략적으로 살펴보자면, 먼저 현재 요청 메소드가 GET, HEAD, TRACE, OPTIONS 중 하나인 경우 csrf 토큰 검증을 할 필요가 없으므로 바로 다음 필터로 넘어간다. 만약 csrf 토큰 검증이 필요하다면 서버에 저장된 csrf 토큰과 클라이언트가 요청으로 보낸 csrf 토큰을 각각 조회하여 둘의 값이 일치하는지 검증한다. 만약 일치하지 않는 경우 403(FORBIDDEN) 에러를 뱉는다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public final class CsrfFilter extends OncePerRequestFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public static final RequestMatcher DEFAULT_CSRF_MATCHER = new DefaultRequiresCsrfMatcher();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final String SHOULD_NOT_FILTER = &quot;SHOULD_NOT_FILTER&quot; + CsrfFilter.class.getName();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final Log logger = LogFactory.getLog(this.getClass());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final CsrfTokenRepository tokenRepository;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RequestMatcher requireCsrfProtectionMatcher;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AccessDeniedHandler accessDeniedHandler;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private CsrfTokenRequestHandler requestHandler;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response); //토큰 저장소에서 csrf 토큰 조회
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken); //다음 요청을 위해 추가
&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;CsrfTokenRequestHandler var10000 = this.requestHandler;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;var10000.handle(request, response, deferredCsrfToken::get);
&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;if (!this.requireCsrfProtectionMatcher.matches(request)) { //요청 메소드가 GET, HEAD, TRACE, OPTIONS 중 하나인 경우
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;filterChain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CsrfToken csrfToken = deferredCsrfToken.get(); //서버에 저장된 토큰 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken); //클라이언트가 요청으로 보낸 토큰
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { //서버 토큰 != 클라이언트 토큰 인 경우
&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;boolean missingToken = deferredCsrfToken.isGenerated();
&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;AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
&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;this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&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;filterChain.doFilter(request, response);
&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;&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;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static boolean equalsConstantTime(String expected, String actual) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (expected == actual) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else if (expected != null &amp;amp;&amp;amp; actual != null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] expectedBytes = Utf8.encode(expected);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;byte[] actualBytes = Utf8.encode(actual);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return MessageDigest.isEqual(expectedBytes, actualBytes);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&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;private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
&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;private final HashSet&amp;lt;String&amp;gt; allowedMethods = new HashSet(Arrays.asList(&quot;GET&quot;, &quot;HEAD&quot;, &quot;TRACE&quot;, &quot;OPTIONS&quot;));

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean matches(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return !this.allowedMethods.contains(request.getMethod());
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;참고로 클라이언트의 요청으로부터 csrf 토큰을 조회하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CsrfTokenRequestHandler&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;resolveCsrfTokenValue&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 보면, 요청 헤더 또는 파라미터로부터 csrf 토큰을 가져오는 것을 확인할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@FunctionalInterface
public interface CsrfTokenRequestHandler extends CsrfTokenRequestResolver {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;void handle(HttpServletRequest request, HttpServletResponse response, Supplier&amp;lt;CsrfToken&amp;gt; csrfToken);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//요청 헤더 또는 파라미터로부터 csrf 토큰 get
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String actualToken = request.getHeader(csrfToken.getHeaderName());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (actualToken == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;actualToken = request.getParameter(csrfToken.getParameterName());
&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;return actualToken;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;csrf 토큰의 생성 및 관리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CsrfTokenRepository&lt;/code&gt;라는 인터페이스 구현체에게 위임하며, 대표적인 구현체에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpSessionCsrfTokenRepository&lt;/code&gt;(세션 기반, 기본값),&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CookieCsrfTokenRepository&lt;/code&gt;(쿠키 기반)가 있으며 아래와 같이 커스텀하게 설정 가능하다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.csrf(config -&amp;gt; config.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;기본 동작은 SSR(Server Side Rendering) 세션 기반으로 설정되어 있어, 뷰를 렌더링할 때 html form 태그의 hidden 타입의 input 태그에 csrf 토큰이 다음과 같이 포함되어야 한다. 이 경우 POST /some-endpoint 요청 시 csrf 토큰이 자동 포함되어&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CsrfFilter&lt;/code&gt;를 무사히 통과할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;html xml&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;form method=&quot;POST&quot; action=&quot;/some-endpoint&quot;&amp;gt;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;input type=&quot;hidden&quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; name=&quot;_csrf&quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; value=&quot;IcxWu3KPl2VbRxPh_4vjMYBt4ohK16rFG-gN5wT0o9dkeeSXRfk33xO_old2fyLXnqbXBrIIz7B555joKt1uhmKQkbZXT9Oj&quot;/&amp;gt;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;/form&amp;gt;
&amp;lt;/body&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Controller
public class CsrfController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GetMapping(&quot;/csrf&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public String csrf(CsrfToken csrfToken, Model model) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;model.addAttribute(&quot;_csrf&quot;, csrfToken);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return &quot;csrf.html&quot;; //csrf 토큰값으로 뷰 렌더링
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;html xml&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;html xmlns:th=&quot;http://www.thymeleaf.org&quot;&amp;gt;
&amp;lt;head&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;form method=&quot;POST&quot; action=&quot;/some-endpoint&quot;&amp;gt;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;input type=&quot;hidden&quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; th:name=&quot;${_csrf.parameterName}&quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; th:value=&quot;${_csrf.token}&quot;/&amp;gt;
&amp;lt;/form&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;반면 Stateless한 REST API에서는 해당 필터를 사용할 일이 거의 없는데, 그 이유는 JSESSION에 대한 서버 세션이 상태를 가지지 않아 csrf 공격 위험 자체가 없기 때문이다. 따라서 이 경우에는 csrf 설정을 disable 하는 것이 보편적이지만, JWT를 쿠키에 저장하는 경우에는 csrf 공격 위험이 있을 수 있기 때문에 활성화하는 것이 좋다. 이 경우에는 발급된 csrf 토큰을 클라이언트에게 전달해줄 수 있도록 별도 API가 필요하다. 아래 코드에서 GET /csrf 요청 시 응답값인 token을 클라이언트는 매 요청마다 헤더 또는 파라미터로 실어 보내야 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RestController
public class CsrfController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GetMapping(&quot;/csrf&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CsrfToken csrf(CsrfToken csrfToken) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return csrfToken;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;parameterName&quot;: &quot;_csrf&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;token&quot;: &quot;_NGSH1_BJ9cZsEMnC5Db2SGQBe0V9t1KaWsZP_84SA600oX4mOTzez7xEuU0iHIRar3v7hP1KNUmxu9nWF56Xplcem-H5LLM&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;headerName&quot;: &quot;X-XSRF-TOKEN&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;Cross Site Request Forgery (CSRF) :: Spring Security&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;To handle an AccessDeniedException such as InvalidCsrfTokenException, you can configure Spring Security to handle these exceptions in any way you like. For example, you can configure a custom access denied page using the following configuration: Configure &quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/mxAKn/hyWOfNmeRJ/WObcBugyWdpOPI1pDuQn11/img.png?width=1902&amp;amp;height=1648&amp;amp;face=0_0_1902_1648,https://scrap.kakaocdn.net/dn/b1SgJK/hyWSk7jbva/D7KSkp4S9F4AoNDZ4TfBP1/img.png?width=1010&amp;amp;height=505&amp;amp;face=0_0_1010_505&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/mxAKn/hyWOfNmeRJ/WObcBugyWdpOPI1pDuQn11/img.png?width=1902&amp;amp;height=1648&amp;amp;face=0_0_1902_1648,https://scrap.kakaocdn.net/dn/b1SgJK/hyWSk7jbva/D7KSkp4S9F4AoNDZ4TfBP1/img.png?width=1010&amp;amp;height=505&amp;amp;face=0_0_1010_505');&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;Cross Site Request Forgery (CSRF) :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;To handle an AccessDeniedException such as InvalidCsrfTokenException, you can configure Spring Security to handle these exceptions in any way you like. For example, you can configure a custom access denied page using the following configuration: Configure&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;LogoutFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;말 그대로 로그아웃을 수행하는 필터로 내부적으로 여러 로그아웃 핸들러를 의존하여 처리를 위임한다. 기본값으로 세션 기반 로그아웃이 구현되어 있어 JWT 방식이나 추가할 로직이 많은 경우 필터를 커스텀해야 한다. 마찬가지로 다음과 같이 추가 설정이 가능하며 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 자동 등록된다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.logout(config -&amp;gt; config.addLogoutHandler(new CompositeLogoutHandler()) //로그아웃 핸들러 추가 
&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;.logoutSuccessHandler(new SimpleUrlLogoutSuccessHandler()) //로그아웃 성공 핸들러 추가
&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;.disable());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class LogoutFilter extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RequestMatcher logoutRequestMatcher;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final LogoutHandler handler;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final LogoutSuccessHandler logoutSuccessHandler;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.requiresLogout(request, response)) { //로그아웃 요청인지 확인
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.handler.logout(request, response, auth); //쿠키, 세션, 시큐리티 컨텍스트 등 삭제
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&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;protected boolean requiresLogout(HttpServletRequest request, HttpServletResponse response) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.logoutRequestMatcher.matches(request)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&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;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LogoutFilter&lt;/code&gt;의 주요 로직은 로그아웃 요청인지 확인 &amp;rarr; 로그아웃 처리 &amp;rarr; 로그아웃 성공 후처리 순으로 진행된다. 실제 로그아웃 처리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;LogoutHandler&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;인터페이스의 구현체인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CompositeLogoutHandler&lt;/code&gt;에게 위임하며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;CompositeLogoutHandler&lt;/code&gt;는 다시 여러&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;LogoutHandler&lt;/code&gt;에게 처리를 위임하고 있다. 기본적으로 동작하는 핸들러에는&amp;nbsp;&amp;nbsp;&lt;code&gt;CsrfLogoutHandler&lt;/code&gt;(csrf 설정이 활성화된 경우, csrf 토큰 저장소에 토큰을 null값으로 저장),&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextLogoutHandler&lt;/code&gt;(세션 및&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;무효화),&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;LogoutSuccessEventPublishingLogoutHandler&lt;/code&gt;(&lt;code&gt;LogoutSuccessEvent&lt;/code&gt;를 발행)가 있다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public final class CompositeLogoutHandler implements LogoutHandler {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final List&amp;lt;LogoutHandler&amp;gt; logoutHandlers;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Iterator var4 = this.logoutHandlers.iterator();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while(var4.hasNext()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;LogoutHandler handler = (LogoutHandler)var4.next();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;handler.logout(request, response, authentication);
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;로그아웃 성공 후에는 URL 리다이렉션과 같은 로그아웃 성공 후처리 작업을 수행하기 위한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;LogoutSuccessHandler&lt;/code&gt;가 동작하며 기본 구현체에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;DelegatingLogoutSuccessHandler&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ForwardLogoutSuccessHandler&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpStatusReturningLogoutSuccessHandler&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SimpleUrlLogoutSuccessHandler&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;등이 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;UsernamePasswordAuthenticationFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;말 그대로 유저의 아이디와 비밀번호로 인증하는 필터로, POST /login 경로에서 form 기반 인증을 진행할 수 있도록 multipart/form-data 형태의 username, password 데이터를 받아 실제 인증 클래스에게 값을 넘겨주는 역할을 수행한다. 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시 자동 등록되지 않아 아래 구문을 통해 필터를 활성화해주어야 한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.formLogin(Customizer.withDefaults());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;code&gt;UsernamePasswordAuthentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;필터에는 템플릿 메소드 패턴이 적용되어 있다. 따라서 기능의 골격이 정의된 부모 클래스&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AbstractAuthenticationProcessingFilter&lt;/code&gt;의 요청 처리 흐름을 먼저 살펴보자. 현재 요청이 인증이 필요한 요청인지 확인 후, 만약 인증이 필요하다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;에서 구현하고 있는 메소드&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;attemptAuthentication&lt;/code&gt;를 호출하여 인증이 완료된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Authentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 반환받는다. 이후 다음 필터를 호출하거나 인증 결과에 따라 성공/실패 핸들러를 호출한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//실제 인증 처리 클래스들
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected ApplicationEventPublisher eventPublisher;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected AuthenticationDetailsSource&amp;lt;HttpServletRequest, ?&amp;gt; authenticationDetailsSource = new WebAuthenticationDetailsSource();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationManager authenticationManager;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RememberMeServices rememberMeServices = new NullRememberMeServices();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RequestMatcher requiresAuthenticationRequestMatcher;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean continueChainBeforeSuccessfulAuthentication = false;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean allowSessionCreation = true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!this.requiresAuthentication(request, response)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response); //인증이 필요하지 않은 요청이라면 다음 필터로 진행
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&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;Authentication authenticationResult = this.attemptAuthentication(request, response); //UsernamePasswordAuthenticationFilter 구현 메소드 호출 
&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;if (authenticationResult == null) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&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;}

&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;this.sessionStrategy.onAuthentication(authenticationResult, request, response);
&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;if (this.continueChainBeforeSuccessfulAuthentication) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&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;}
&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;
&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;this.successfulAuthentication(request, response, chain, authenticationResult); //인증 성공 핸들러 호출
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (InternalAuthenticationServiceException var5) {
&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;InternalAuthenticationServiceException failed = var5;
&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;this.unsuccessfulAuthentication(request, response, failed); //인증 실패 핸들러 호출
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (AuthenticationException var6) {
&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;AuthenticationException ex = var6;
&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;this.unsuccessfulAuthentication(request, response, ex); //인증 실패 핸들러 호출
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.requiresAuthenticationRequestMatcher.matches(request)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&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;//UsernamePasswordAuthenticationFilter에서 구현하는 메소드
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//인증 성공 처리
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//SecurityContext 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;context.setAuthentication(authResult);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.setContext(context);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextRepository.saveContext(context, request, response);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.rememberMeServices.loginSuccess(request, response, authResult);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.eventPublisher != null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
&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;this.successHandler.onAuthenticationSuccess(request, response, authResult);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//인증 실패 처리
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.clearContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.rememberMeServices.loginFail(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.failureHandler.onAuthenticationFailure(request, response, failed);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;에서 눈여겨 보아야 할 부분은 실제 인증 처리를 수행하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;attemptAuthentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드이다. 코드를 보면 알겠지만 요청 객체&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;request&lt;/code&gt;로부터&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;username&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;password&lt;/code&gt;를 조회한 후 아직 인증되지 않은 객체&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationToken&lt;/code&gt;을 생성하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AuthenticationManager&lt;/code&gt;에게 인증 처리를 위임하고 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public static final String SPRING_SECURITY_FORM_USERNAME_KEY = &quot;username&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = &quot;password&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(&quot;/login&quot;, &quot;POST&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String usernameParameter = &quot;username&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String passwordParameter = &quot;password&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean postOnly = true;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//인증 처리 수행
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.postOnly &amp;amp;&amp;amp; !request.getMethod().equals(&quot;POST&quot;)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new AuthenticationServiceException(&quot;Authentication method not supported: &quot; + request.getMethod());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String username = this.obtainUsername(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;username = username != null ? username.trim() : &quot;&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String password = this.obtainPassword(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;password = password != null ? password : &quot;&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//요청으로 전달된 username, password를 가진 미인증 객체 Authentication 생성&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;UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.setDetails(request, authRequest);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return this.getAuthenticationManager().authenticate(authRequest); //인증 처리 위임
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;이렇게 실질적인 인증 처리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AuthenticationManager&lt;/code&gt;의 구현체에게 위임하며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;는 단순히 인증의 시작점 역할을 할 뿐이다. 인증 관련 내부 로직을 상세히 이해하는 것도 중요한데, 이번 글의 주제와는 약간 벗어나므로 이에 대한 자세한 내용은 다음 글에서 스프링 시큐리티 로그인을 직접 구현해보면서 다루기로 하자.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;추가로 왜&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AbstractAuthenticationProcessingFilter&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;에는 템플릿 메소드 패턴이 적용되었을까? 답변하기 전 일단 사용자 인증 처리의 대략적인 흐름은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;인증이 필요한 요청인지 확인 &amp;rarr; 인증 처리 후 Authentication 객체 반환 &amp;rarr; 인증 성공/실패 핸들러 호출&lt;/blockquote&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;username&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;password&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;데이터를 form, JSON 방식 등으로 보내도 전체적인 처리 흐름은 동일하며, 이러한 흐름의 골격을 정의한 클래스가 바로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AbstractAuthenticationProcessingFilter&lt;/code&gt;인 것이다. 그리고 추상화된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;atteptAuthentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 구현하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;username&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;password&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;데이터를 어떤 방식으로 보내야 할지, 어떤 식으로 인증을 처리할지 구체적인 방식을 정의한 클래스가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;인 것이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;form 방식으로 받도록 구현된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이외에도 OAuth2 코드를 받도록 구현된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OAuth2LoginAuthenticationFilter&lt;/code&gt;도 존재하며, 만약 json 방식으로 받도록 구현하고자 한다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;를 상속한 커스텀 필터를 정의하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107016647&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;Handling Logouts :: Spring Security&quot; data-og-description=&quot;If you are using Java configuration, you can add clean up actions of your own by calling the addLogoutHandler method in the logout DSL, like so: Custom Logout Handler CookieClearingLogoutHandler cookies = new CookieClearingLogoutHandler(&amp;quot;our-custom-cookie&amp;quot;&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authentication/logout.html&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authentication/logout.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authentication/logout.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authentication/logout.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;Handling Logouts :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;If you are using Java configuration, you can add clean up actions of your own by calling the addLogoutHandler method in the logout DSL, like so: Custom Logout Handler CookieClearingLogoutHandler cookies = new CookieClearingLogoutHandler(&quot;our-custom-cookie&quot;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;DefaultLoginPageGeneratingFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;로그인 설정에 대해 GET /login 경로에 기본 로그인 페이지를 응답하는 필터이다. 여러 로그인 설정에 의존하며, 커스텀 로그인 페이지를 사용할 경우에는 자동 제외된다.&amp;nbsp;예를 들어 여러 로그인 설정 중 formLogin 기본값 설정 시 해당 필터가 자동 활성화 되며, loginPage 메소드를 통해 커스텀 로그인 페이지를 설정하지 않는 이상 기본 동작한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.formLogin(Customizer.withDefaults())
//&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;	.formLogin(config -&amp;gt; config.loginPage(&quot;/login&quot;)) //이 경우에는 DefaultLoginPageGeneratingFilter 비활성화
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DefaultLoginPageGeneratingFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;코드는 아주 간단한데, 현재 요청이 GET /login 인지를 확인하고 html 코드를 생성하여 응답으로 반환한다. 참고로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;generateLoginPageHtml&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서는 현재 요청이 form, OAuth2, saml 방식인지에 따라 각기 다른 로그인 페이지를 생성한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;generateLoginPageHtml&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드 구현 코드를 보면 참 재미있는데, html이 진짜 문자열로 하드 코딩되어 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public static final String DEFAULT_LOGIN_PAGE_URL = &quot;/login&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public static final String ERROR_PARAMETER_NAME = &quot;error&quot;;
&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;private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;boolean loginError = this.isErrorPage(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;boolean logoutSuccess = this.isLogoutSuccess(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!this.isLoginUrlRequest(request) &amp;amp;&amp;amp; !loginError &amp;amp;&amp;amp; !logoutSuccess) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess); //로그인 페이지 html 코드 생성
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;response.setContentType(&quot;text/html;charset=UTF-8&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;response.getWriter().write(loginPageHtml);
&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;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;차라리 별도의 *.html 파일을 정의하고 컨트롤러 단에서 기본 로그인 페이지를 제공하면&amp;nbsp;이렇게 html을 하드 코딩하지 않아도 될 것이다. 그러나 만약 기본 로그인 페이지를 컨트롤러 단에서 제공할 경우 디폴트 컨트롤러(스프링 시큐리티가 기본 제공하는 로그인 페이지)와 커스텀 컨트롤러(개발자가 직접 정의)가 충돌할 수 있으며(둘다 GET /login으로 정의되는 경우) (내 피셜) 아직 이를 해결하지 못했기 때문에? 필터 단에서 html을 하드 코딩하여 제공할 수 밖에 없는 것 같다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;DefaultLogoutPageGeneratingFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;GET /logout 경로에 대해 기본 로그아웃 페이지를 응답하는 필터로, 마찬가지로 여러 로그인 설정에 의존한다. (자체적인 활성/비활성 설정 불가능) 코드를 간단하게 살펴보면 현재 요청이 로그아웃 요청인지 판단 후 로그아웃 페이지를 렌더링한다. (&lt;code&gt;DefaultLoginPageGeneratingFilter&lt;/code&gt;와 동일하게 하드 코딩된 html을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;response&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;write&lt;/code&gt;하여 응답한다.)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RequestMatcher matcher = new AntPathRequestMatcher(&quot;/logout&quot;, &quot;GET&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Function&amp;lt;HttpServletRequest, Map&amp;lt;String, String&amp;gt;&amp;gt; resolveHiddenInputs = (request) -&amp;gt; {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return Collections.emptyMap();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;};

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.matcher.matches(request)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.renderLogout(request, response); //로그아웃 페이지 요청이라면 페이지 렌더링
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;filterChain.doFilter(request, response);
&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;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;BasicAuthenticationFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;말그대로 Basic 기반의 인증을 수행하는 필터로, 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시 자동 등록되지 않으므로 다음과 같이 설정해주어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.httpBasic(Customizer.withDefaults());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;먼저 Basic 인증이란? 말 그대로 가장 기본적인 인증 방식으로, Base64로 인코딩한 &quot;사용자 ID:비밀번호&quot; 문자열을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Basic-&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;prefix와 함께 인증 헤더에 입력하는 방식으로 인증을 진행한다. 서버는 요청에 대해 username/password만 일치하는지 확인 후 사용자를 기억하지 않기 때문에 매 요청마다 Authorization 헤더를 보내야 한다. 그러나 세션 기반 인증인 경우 스프링 시큐리티의 Basic 인증 로직은 매번 재인증을 요구하지 않고 세션에 값을 저장하여 유저를 기억한다. (이 부분이 어떻게 구현되어 있는지는 뒤에서 코드로 살펴보자.)&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;Authorization: Basic base64({USERNAME}:{PASSWORD})&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;참고로 Base64 인코딩은 쉽게 복호화가 가능하기 때문에 단순 Base64 인코딩된 사용자 ID, 비밀번호를 HTTP로 전달하면 보안에 매우 취약하기 때문에 Basic 인증을 사용하는 요청은 HTTPS, SSL/TLS로 통신해야 한다.&amp;nbsp;&lt;span style=&quot;color: #0070d1;&quot;&gt;참고&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class BasicAuthenticationFilter extends OncePerRequestFilter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationEntryPoint authenticationEntryPoint;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationManager authenticationManager;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RememberMeServices rememberMeServices = new NullRememberMeServices();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean ignoreFailure = false;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String credentialsCharset = &quot;UTF-8&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationConverter authenticationConverter = new BasicAuthenticationConverter();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication authRequest = this.authenticationConverter.convert(request); //요청 Authorization 헤더에서 username, password 조회 후 인증 객체로 반환
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (authRequest == null) {
&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;chain.doFilter(request, response);
&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;return;
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String username = authRequest.getName();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.authenticationIsRequired(username)) { //SecurityContext에 해당 username이 이미 존재하는지 확인
&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;Authentication authResult = this.authenticationManager.authenticate(authRequest); //인증 진행
&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;SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
&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;context.setAuthentication(authResult);
&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;this.securityContextHolderStrategy.setContext(context);

&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;this.rememberMeServices.loginSuccess(request, response, authResult);
&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;this.securityContextRepository.saveContext(context, request, response);
&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;this.onSuccessfulAuthentication(request, response, authResult);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (AuthenticationException var8) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;AuthenticationException ex = var8;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.clearContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.rememberMeServices.loginFail(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.onUnsuccessfulAuthentication(request, response, ex);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.ignoreFailure) {
&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;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&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;this.authenticationEntryPoint.commence(request, response, ex);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&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;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected boolean authenticationIsRequired(String username) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication existingAuth = this.securityContextHolderStrategy.getContext().getAuthentication();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (existingAuth == null || !existingAuth.getName().equals(username) || !existingAuth.isAuthenticated()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&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;return (existingAuth instanceof AnonymousAuthenticationToken);
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;BasicAuthenticationFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;코드를 살펴보면, 먼저 converter를 통해 요청 헤더로부터 미인증 객체를 반환받는다. 이후 &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authenticationIsRequired&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 통해  인자로 전달된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;username&lt;/code&gt;에 해당하는 인증 객체가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에 존재하는지 확인한다. 만약 해당 인증 객체가 존재한다면 Basic 재인증을 수행하지 않으며, 없다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;와 비슷하게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AuthenticationManager&lt;/code&gt;에게 인증 처리를 위임하여, 성공 시에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Authentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 저장하는 등의 후처리를 진행한다. 참고로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authenticationIsRequired&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;securityContextHolderStrategy.getContext()&lt;/code&gt;에서 조회되는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;는, 앞선 필터인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolderFilter&lt;/code&gt;에서 세션 기반 인증인 경우&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;HttpSessionSecurityContextRepository&lt;/code&gt;에 저장된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;를 조회하여 미리 세팅해둔 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;HttpServletRequest&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;요청 Authorization 헤더로부터 username/password를 조회해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Authentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체로 변환해주는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;BasicAuthenticationCoverter&lt;/code&gt;의 주요 코드는 다음과 같다. 코드 상으로 쉽게 확인할 수 있는데, Authorization 헤더에서 &lt;code&gt;Basic&amp;nbsp;&lt;/code&gt; prefix를 제거한 후 해당 토큰을 Base64로 디코딩하고 ':' 이 문자를 기준으로 username, password를 분리하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;UsernamePasswordAuthenticationToken&lt;/code&gt;를 생성해 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class BasicAuthenticationConverter implements AuthenticationConverter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public static final String AUTHENTICATION_SCHEME_BASIC = &quot;Basic&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationDetailsSource&amp;lt;HttpServletRequest, ?&amp;gt; authenticationDetailsSource;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Charset credentialsCharset;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String header = request.getHeader(&quot;Authorization&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (header == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;header = header.trim();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!StringUtils.startsWithIgnoreCase(header, &quot;Basic&quot;)) {
&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;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else if (header.equalsIgnoreCase(&quot;Basic&quot;)) {
&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;throw new BadCredentialsException(&quot;Empty basic authentication token&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&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;byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
&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;byte[] decoded = this.decode(base64Token);
&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;String token = new String(decoded, this.getCredentialsCharset(request));
&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;int delim = token.indexOf(&quot;:&quot;);
&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;if (delim == -1) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new BadCredentialsException(&quot;Invalid basic authentication token&quot;);
&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;} else {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.unauthenticated(token.substring(0, delim), token.substring(delim + 1));
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;result.setDetails(this.authenticationDetailsSource.buildDetails(request));
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return result;
&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;}
&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;&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;//...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107134987&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;Basic Authentication :: Spring Security&quot; data-og-description=&quot;When the user submits their username and password, the BasicAuthenticationFilter creates a UsernamePasswordAuthenticationToken, which is a type of Authentication by extracting the username and password from the HttpServletRequest. By default, Spring Securi&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/FBRma/hyWSmqLuhf/imJKeCnK0tVA63JComDgMK/img.png?width=1135&amp;amp;height=447&amp;amp;face=0_0_1135_447,https://scrap.kakaocdn.net/dn/eI4lP/hyWScPePVa/ae6tQXBOCTukbK0TKYWRPk/img.png?width=784&amp;amp;height=592&amp;amp;face=0_0_784_592&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/FBRma/hyWSmqLuhf/imJKeCnK0tVA63JComDgMK/img.png?width=1135&amp;amp;height=447&amp;amp;face=0_0_1135_447,https://scrap.kakaocdn.net/dn/eI4lP/hyWScPePVa/ae6tQXBOCTukbK0TKYWRPk/img.png?width=784&amp;amp;height=592&amp;amp;face=0_0_784_592');&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;Basic Authentication :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;When the user submits their username and password, the BasicAuthenticationFilter creates a UsernamePasswordAuthenticationToken, which is a type of Authentication by extracting the username and password from the HttpServletRequest. By default, Spring Securi&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;RequestCacheAwareFilter&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 HTTP 요청에서 처리할 작업이 있고 현재 요청에서 (아직 처리하지 못한) 이전 작업을 수행하기 위해 등록되는 필터로, 아래와 같이 비활성화 가능하다. (커스텀 SecurityFilterChain 생성 시 자동 등록)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.requestCache(config -&amp;gt; config.disable());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&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;&lt;span style=&quot;color: #333333;&quot;&gt;예를 들어 '로그인하지 않은 사용자가 권한이 필요한 요청 &amp;rarr; 권한 없음 예외 발생 &amp;rarr; 핸들러에서 해당 요청 경로를 캐싱 &amp;rarr; 스프링 시큐리티가 로그인 창을 띄우고 인증을 진행(&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;세션 방식에서 디폴트 formLogin 설정 시&lt;/span&gt;) &amp;rarr; 로그인 상태로 캐싱된 이전 요청 재실행' 이러한 실행 흐름을 구현하기 위해&amp;nbsp;&lt;code&gt;RequestCacheAwareFilter&lt;/code&gt;가 필요한 것이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;위 실행 흐름 중 두번째 '권한 없음 예외 발생' 시 이러한 인증, 인가 예외는 뒤에서 살펴볼&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ExceptionTranslationFilter&lt;/code&gt;에서 처리하는데, 이 중 호출되는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;sendStartAuthentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;requestCache&lt;/code&gt;에 현재 권한이 필요한 요청 객체를 저장하고 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.setContext(context);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.requestCache.saveRequest(request, response); //cookie, http session 등에 현재 요청을 저장
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.authenticationEntryPoint.commence(request, response, reason);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 사용자가 다시 정상 로그인 후 요청을 보내&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;RequestCacheAwareFilter&lt;/code&gt;를 거치게 되면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;requestCache&lt;/code&gt;에서 로그인 전 인증 예외가 발생한 요청이 있는지 확인하고, 존재한다면 해당 값으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;request&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 대체한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class RequestCacheAwareFilter extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private RequestCache requestCache;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//requestCache에서 이전 요청에 캐싱해둔 데이터가 있는지 조회
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest)request, (HttpServletResponse)response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//캐싱된 요청이 존재할 경우 reqeust 객체 대체
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter((ServletRequest)(wrappedSavedRequest != null ? wrappedSavedRequest : request), response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 유튜브의 A 영상을 보고 있다가 일시 정지 후 아주 오랜 기간동안 해당 페이지에 재접속하지 않으면 로그인이 풀리게 된다. 이때 새로고침 버튼을 누르면 로그인 페이지로 이동하는데, 로그인 성공 후 유튜브 홈이 아닌 기존에 보고 있던 A 영상으로 이동하게 되는 원리가 바로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;RequestCacheAwareFilter&lt;/code&gt;인 것이다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107253364&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;Architecture :: Spring Security&quot; data-og-description=&quot;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#savedrequests&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#savedrequests&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/g9fUa/hyWOm0iyiQ/9AUfw8PQoa85BMWpLNm0y0/img.png?width=698&amp;amp;height=610&amp;amp;face=0_0_698_610,https://scrap.kakaocdn.net/dn/Fdo2N/hyWSl6s86G/r6Qkb84U5WVxOdt0vhTIEK/img.png?width=740&amp;amp;height=507&amp;amp;face=0_0_740_507,https://scrap.kakaocdn.net/dn/fXoBF/hyWOnrkPhT/JDGkLyXzlRmwJNf9GZFvFk/img.png?width=656&amp;amp;height=508&amp;amp;face=0_0_656_508&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#savedrequests&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#savedrequests&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/g9fUa/hyWOm0iyiQ/9AUfw8PQoa85BMWpLNm0y0/img.png?width=698&amp;amp;height=610&amp;amp;face=0_0_698_610,https://scrap.kakaocdn.net/dn/Fdo2N/hyWSl6s86G/r6Qkb84U5WVxOdt0vhTIEK/img.png?width=740&amp;amp;height=507&amp;amp;face=0_0_740_507,https://scrap.kakaocdn.net/dn/fXoBF/hyWOnrkPhT/JDGkLyXzlRmwJNf9GZFvFk/img.png?width=656&amp;amp;height=508&amp;amp;face=0_0_656_508');&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;Architecture :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;SecurityContextHolderAwareRequestFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ServletRequest&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;요청에 스프링 시큐리티 API를 다룰 수 있는 메소드를 추가하기 위해 존재하는 필터이다. (&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 기본 등록)&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;'&lt;/span&gt;&lt;code&gt;ServletRequest&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;요청에 스프링 시큐리티 API를 다룰 수 있는 메소드를 추가'가 무슨 말인가 싶을 텐데, 일단&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContextHolderAwareRequestFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;코드를 먼저 살펴보자. 주요한 부분은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;requestFactory&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;create&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 통해 현재&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;request&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 대체하고 있는 부분이다. 이때 반환되는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;request&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Servlet3SecurityContextHolderAwareRequestWrapper&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(&lt;code&gt;HttpServlet3RequestFactory&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;클래스의 정적 내부 클래스)클래스 타입으로,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authentication&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;login&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;logout&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;등의 시큐리티 API를 제공하고 있다. (약간 TMI이긴 한데&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Servlet3SecurityContextHolderAwareRequestWrapper&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authentication&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;login&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;logout&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드 구현을 살펴보면, 아래&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;createServlet3Factory&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서 설정해주고 있는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AuthenticationManager&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;LogoutHandler&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;등에게 처리를 위임하는 것을 확인할 수 있다.)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class SecurityContextHolderAwareRequestFilter extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String rolePrefix = &quot;ROLE_&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private HttpServletRequestFactory requestFactory;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationEntryPoint authenticationEntryPoint;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationManager authenticationManager;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private List&amp;lt;LogoutHandler&amp;gt; logoutHandlers;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(this.requestFactory.create((HttpServletRequest)req, (HttpServletResponse)res), res);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void updateFactory() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String rolePrefix = this.rolePrefix;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.requestFactory = this.createServlet3Factory(rolePrefix); //요청 팩토리 초기화
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private HttpServletRequestFactory createServlet3Factory(String rolePrefix) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HttpServlet3RequestFactory factory = new HttpServlet3RequestFactory(rolePrefix, this.securityContextRepository);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;factory.setTrustResolver(this.trustResolver);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;factory.setAuthenticationEntryPoint(this.authenticationEntryPoint);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;factory.setAuthenticationManager(this.authenticationManager);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;factory.setLogoutHandlers(this.logoutHandlers);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;factory.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return factory;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;따라서 이 필터 덕분에 아래와 같은 구현이 가능하다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RestController
public class SecurityContextHolderAwareRequestController {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GetMapping(&quot;/test&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void test(HttpServletRequest request, HttpServletResponse response) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request.authenticate(response); //인증 여부 확인 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request.login(&quot;username&quot;, &quot;password&quot;); //AuthenticationManager를 통해 인증 진행
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request.logout(); //로그아웃 핸들러 호출
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;AsyncContext asyncContext = request.startAsync(); //Callable를 사용하여 비동기 처리 진행 시 기존의 SecurityContext를 복사
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;asyncContext.start(() -&amp;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;SecurityContext securityContext = SecurityContextHolder.getContext(); //동일한 SecurityContext에 접근 가능
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;AnonymousAuthenticationFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;최초 접속으로 기존의 인증 정보가 없고, 해당 필터까지 인증을 하지 않았을 경우 익명 사용자 데이터를 설정해주는 필터이다. 즉 여러 필터를 거치면서 해당 필터까지&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;값이 null인 경우 anonymous 값을 넣어주는 역할을 하며, 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 기본 등록된다. 아래의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;defaultWithAnonymous&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 보면 현재 필터까지&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Authentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체가 null인 경우 익명 인증 객체(&lt;code&gt;AnonymousAuthenticationToken&lt;/code&gt;)를 생성하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에 설정해주고 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class AnonymousAuthenticationFilter extends GenericFilterBean implements InitializingBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationDetailsSource&amp;lt;HttpServletRequest, ?&amp;gt; authenticationDetailsSource;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String key;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Object principal;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private List&amp;lt;GrantedAuthority&amp;gt; authorities;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Supplier&amp;lt;SecurityContext&amp;gt; deferredContext = this.securityContextHolderStrategy.getDeferredContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.setDeferredContext(this.defaultWithAnonymous((HttpServletRequest)req, deferredContext));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(req, res);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Supplier&amp;lt;SecurityContext&amp;gt; defaultWithAnonymous(HttpServletRequest request, Supplier&amp;lt;SecurityContext&amp;gt; currentDeferredContext) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return SingletonSupplier.of(() -&amp;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;SecurityContext currentContext = (SecurityContext)currentDeferredContext.get();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return this.defaultWithAnonymous(request, currentContext);
&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;private SecurityContext defaultWithAnonymous(HttpServletRequest request, SecurityContext currentContext) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication currentAuthentication = currentContext.getAuthentication();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//인증 객체가 null인 경우
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (currentAuthentication == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication anonymous = this.createAuthentication(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SecurityContext anonymousContext = this.securityContextHolderStrategy.createEmptyContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;anonymousContext.setAuthentication(anonymous); //익명 인증 객체 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return anonymousContext;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return currentContext;
&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;protected Authentication createAuthentication(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal, this.authorities);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;token.setDetails(this.authenticationDetailsSource.buildDetails(request));
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return token;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;참고로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AnonymousAuthenticationToken&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체 생성 시 전달되는 값은 중단점을 찍어 확인할 수 있는데, 기본 username 값은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;anonymousUser&lt;/code&gt;, 기본 role은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ROLE_ANONYMOUS&lt;/code&gt;이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1756&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdOybJ/btsI7IOr63X/0f5O7hKIMhvKIPcY07KOS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdOybJ/btsI7IOr63X/0f5O7hKIMhvKIPcY07KOS0/img.png&quot; data-alt=&quot;AnonymousAuthenticationFilter 필드 기본값&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdOybJ/btsI7IOr63X/0f5O7hKIMhvKIPcY07KOS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdOybJ%2FbtsI7IOr63X%2F0f5O7hKIMhvKIPcY07KOS0%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;771&quot; height=&quot;199&quot; data-origin-width=&quot;1756&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;AnonymousAuthenticationFilter 필드 기본값&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107430932&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;SecurityContextHolderAwareRequestFilter (spring-security-docs 6.3.2 API)&quot; data-og-description=&quot;setAuthenticationEntryPoint Sets the AuthenticationEntryPoint used when integrating HttpServletRequest with Servlet 3 APIs. Specifically, it will be used when HttpServletRequest.authenticate(HttpServletResponse) is called and the user is not authenticated.&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/servletapi/SecurityContextHolderAwareRequestFilter.html&quot; data-og-url=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/servletapi/SecurityContextHolderAwareRequestFilter.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/servletapi/SecurityContextHolderAwareRequestFilter.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/servletapi/SecurityContextHolderAwareRequestFilter.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;SecurityContextHolderAwareRequestFilter (spring-security-docs 6.3.2 API)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;setAuthenticationEntryPoint Sets the AuthenticationEntryPoint used when integrating HttpServletRequest with Servlet 3 APIs. Specifically, it will be used when HttpServletRequest.authenticate(HttpServletResponse) is called and the user is not authenticated.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;ExceptionTranslationFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;해당 필터 이후에 발생하는 인증, 인가 관련 예외를 핸들링하기 위해 사용되는 필터로, 커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 기본 등록된다. 참고로&amp;nbsp;&lt;code&gt;ExceptionTranslationFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;필터 이전에 발생한 예외는 처리 불가능한데, 이는 자바의 예외 전파 방식을 생각해보면 당연하다. (a 메소드에서 b 메소드 호출 &amp;rarr; b 메소드에서 예외 발생 &amp;rarr; b 메소드 내부에서 예외 처리하지 않는다면, a 메소드로 예외 전파)&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AccessDeniedHandler accessDeniedHandler;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationEntryPoint authenticationEntryPoint;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthenticationTrustResolver authenticationTrustResolver;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private ThrowableAnalyzer throwableAnalyzer;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final RequestCache requestCache;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected MessageSourceAccessor messages;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (IOException var7) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;IOException ex = var7;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw ex;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} catch (Exception var8) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Exception ex = var8;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RuntimeException securityException = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (securityException == null) {
&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;securityException = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (securityException == null) {
&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;this.rethrow(ex);
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (response.isCommitted()) {
&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;throw new ServletException(&quot;Unable to handle the Spring Security Exception because the response is already committed.&quot;, ex);
&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;&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException);
&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;private void rethrow(Exception ex) throws ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (ex instanceof ServletException) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw (ServletException)ex;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else if (ex instanceof RuntimeException) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw (RuntimeException)ex;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new RuntimeException(ex);
&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;private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (exception instanceof AuthenticationException) { //인증 예외 처리 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else if (exception instanceof AccessDeniedException) { //인가 예외 처리
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
&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;private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.sendStartAuthentication(request, response, chain, exception); //재인증 요청 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (!isAnonymous &amp;amp;&amp;amp; !this.authenticationTrustResolver.isRememberMe(authentication)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.accessDeniedHandler.handle(request, response, exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage(&quot;ExceptionTranslationFilter.insufficientAuthentication&quot;, &quot;Full authentication is required to access this resource&quot;)));
&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;protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.securityContextHolderStrategy.setContext(context);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.requestCache.saveRequest(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.authenticationEntryPoint.commence(request, response, reason);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;handleSpringSecurityException&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서 실질적인 인증, 인가 예외를 처리하며 다시 해당 메소드에서 인증 예외인지, 인가 예외인지에 따라 분기 처리를 한다. 앞선&amp;nbsp;&lt;code&gt;RequestCacheAwareFilter&lt;/code&gt;에서 언급한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;sendStartAuthentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드를 인증 예외 발생 시 호출하고 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107481587&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;Architecture :: Spring Security&quot; data-og-description=&quot;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/architecture.html#servlet-exceptiontranslationfilter&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/architecture.html#servlet-exceptiontranslationfilter&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/upT9Y/hyWSo9X6Ke/sk7VyY49kmYRZNMMoudehK/img.png?width=698&amp;amp;height=610&amp;amp;face=0_0_698_610,https://scrap.kakaocdn.net/dn/myP6j/hyWOpiotkN/xPVvR8CEEjnMP2IM7tjEG0/img.png?width=686&amp;amp;height=508&amp;amp;face=0_0_686_508,https://scrap.kakaocdn.net/dn/thDyI/hyWOg6NNEG/4l0XS4U9wxb7XskfpfigIk/img.png?width=656&amp;amp;height=508&amp;amp;face=0_0_656_508&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/architecture.html#servlet-exceptiontranslationfilter&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/architecture.html#servlet-exceptiontranslationfilter&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/upT9Y/hyWSo9X6Ke/sk7VyY49kmYRZNMMoudehK/img.png?width=698&amp;amp;height=610&amp;amp;face=0_0_698_610,https://scrap.kakaocdn.net/dn/myP6j/hyWOpiotkN/xPVvR8CEEjnMP2IM7tjEG0/img.png?width=686&amp;amp;height=508&amp;amp;face=0_0_686_508,https://scrap.kakaocdn.net/dn/thDyI/hyWOg6NNEG/4l0XS4U9wxb7XskfpfigIk/img.png?width=656&amp;amp;height=508&amp;amp;face=0_0_656_508');&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;Architecture :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.spring.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;AuthorizationFilter&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authorizeHttpRequests()&lt;/code&gt;를 통해 설정된 인가 값에 따라 최종적으로 인가를 수행하는 필터로,&amp;nbsp;커스텀&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성 시에도 기본적으로 등록되며, 다음과 같이 인가 값을 설정할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.authorizeHttpRequests(registry -&amp;gt; registry
&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;.requestMatchers(&quot;/error&quot;, &quot;/favicon.ico&quot;).permitAll() //해당 요청 인증, 인가 필요 없음 
&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;.requestMatchers(EMAIL_AUTH_API_PATH, SNS_SIGN_UP_API_PATH, LOGOUT_API_PATH).hasRole(&quot;GUEST&quot;) //해당 요청 ROLE_GUEST 이상인 유저만 요청 가능
&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;.anyRequest().hasRole(&quot;USER&quot;) //이외 요청 ROLE_USER 이상인 유저만 요청 가능
&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;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;doFilter&lt;/code&gt;의 앞부분은 부가적인 처리이고 중간의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authorizationManager&lt;/code&gt;에게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;authentication&lt;/code&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;request&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 전달하여 인가 작업을 위임하고 있다. 만약 인가 권한이 없다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AccessDeniedException&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;예외를 throw 하며(해당 예외는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ExceptionTranslationFilter&lt;/code&gt;에서 처리), 권한이 있다면 다음 필터를 호출하고 있다. 여기서 중요한건? 인가 작업을 위해 해당 필터에서만큼은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityContext&lt;/code&gt;에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Authentication&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체가 null이 아닌 값으로 존재해야 한다는 것이다. 그래서 거의 직전인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;AnonymousAuthenticationFilter&lt;/code&gt;에서 이를 설정해주는 작업을 하나보다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class AuthorizationFilter extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final AuthorizationManager&amp;lt;HttpServletRequest&amp;gt; authorizationManager;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private AuthorizationEventPublisher eventPublisher = AuthorizationFilter::noPublish;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean observeOncePerRequest = false;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean filterErrorDispatch = true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean filterAsyncDispatch = true;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HttpServletRequest request = (HttpServletRequest)servletRequest;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;HttpServletResponse response = (HttpServletResponse)servletResponse;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//특정 설정이 enable이고 이 필터가 이번 요청에서 이미 사용되었다면 건너뜀 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.observeOncePerRequest &amp;amp;&amp;amp; this.isApplied(request)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else if (this.skipDispatch(request)) { //비동기 요청과 같은 특정 상황에서 인가 작업을 건너 뛸지 결정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);

&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&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;//인가 매니저에게 인가 처리 위임
&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;AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
&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;this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
&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;if (decision != null &amp;amp;&amp;amp; !decision.isGranted()) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new AccessDeniedException(&quot;Access Denied&quot;); //인가 권한 없다면 예외 발생
&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;}

&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;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} finally {
&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;request.removeAttribute(alreadyFilteredAttributeName); //최종적으로 모든 작업 처리 후 사용 기록 삭제
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean skipDispatch(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (DispatcherType.ERROR.equals(request.getDispatcherType()) &amp;amp;&amp;amp; !this.filterErrorDispatch) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return DispatcherType.ASYNC.equals(request.getDispatcherType()) &amp;amp;&amp;amp; !this.filterAsyncDispatch;
&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;private boolean isApplied(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return request.getAttribute(this.getAlreadyFilteredAttributeName()) != null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String getAlreadyFilteredAttributeName() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String name = this.getFilterName();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (name == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;name = this.getClass().getName();
&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;return name + &quot;.APPLIED&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Authentication getAuthentication() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (authentication == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw new AuthenticationCredentialsNotFoundException(&quot;An Authentication object was not found in the SecurityContext&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return authentication;
&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;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107720534&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;Authorize HttpServletRequests :: Spring Security&quot; data-og-description=&quot;While using a concrete AuthorizationManager is recommended, there are some cases where an expression is necessary, like with or with JSP Taglibs. For that reason, this section will focus on examples from those domains. Given that, let&amp;rsquo;s cover Spring Secu&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authorization/authorize-http-requests.html#request-authorization-architecture&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authorization/authorize-http-requests.html#request-authorization-architecture&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bLBBQh/hyWSaRquF4/O9eMvEJHfTEAqiMtIP1jP1/img.png?width=1520&amp;amp;height=816&amp;amp;face=0_0_1520_816&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authorization/authorize-http-requests.html#request-authorization-architecture&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/6.3-SNAPSHOT/servlet/authorization/authorize-http-requests.html#request-authorization-architecture&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bLBBQh/hyWSaRquF4/O9eMvEJHfTEAqiMtIP1jP1/img.png?width=1520&amp;amp;height=816&amp;amp;face=0_0_1520_816');&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;Authorize HttpServletRequests :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;While using a concrete AuthorizationManager is recommended, there are some cases where an expression is necessary, like with or with JSP Taglibs. For that reason, this section will focus on examples from those domains. Given that, let&amp;rsquo;s cover Spring Secu&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;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 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;커스텀 SecurityFilterChain 필터 생성 및 등록&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;스프링 시큐리티 필터 체인에 커스텀 필터를 추가하기 위해서는 먼저 &lt;code&gt;GenericFilterBean&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;또는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;OncePerRequestFilter&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;클래스를 구현한 커스텀 &lt;code&gt;Filter&lt;/code&gt; 정의해야 한다. 앞서 말했듯 클라이언트 요청에 대해 필터를&amp;nbsp; 여러번 통과하더라도 딱 한번만 동작하도록 하고 싶다면 &lt;code&gt;OncePerRequestFilter&lt;/code&gt;를 구현하면 된다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class CustomFilter extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FilterChain chain) throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span&gt;이후&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityConfig&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정 클래스에 커스텀 필터를 다음과 같이 추가해준다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Configuration
public class CustomWebSecurityConfigurerAdapter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http.addFilterAfter(new CustomFilter(), BasicAuthenticationFilter.class);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;기존 필터에 커스텀 필터를 등록할 수 있는 api는 다음과 같다.&lt;br /&gt;- &lt;code&gt;addFilterBefore(filter, class)&lt;/code&gt;: 기존 시큐리티 필터 전에 커스텀 필터 추가&lt;br /&gt;-&amp;nbsp;&lt;code&gt;addFilterAfter(filter, class)&lt;/code&gt;: 기존 시큐리티 필터 후에 커스텀 필터 추가&lt;br /&gt;-&amp;nbsp;&lt;code&gt;addFilterAt(filter, class)&lt;/code&gt;: 기존 시큐리티 필터의 위치에 커스텀 필터 추가 (기존 필터를 replace하지 않음)&lt;br /&gt;-&amp;nbsp;&lt;code&gt;addFilter(filter)&lt;/code&gt;: 커스텀 필터는 기존 시큐리티 필터의 인스턴스이거나 이를 상속해야 하며, 기존 필터의 위치에 추가 (기존 필터를 replace하지 않음)&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724107802936&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;Architecture :: Spring Security&quot; data-og-description=&quot;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#adding-custom-filter&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#adding-custom-filter&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/fHTbu/hyWOnLEH7V/bcubGGKlxEPqWnFfy9Yt40/img.png?width=698&amp;amp;height=610&amp;amp;face=0_0_698_610,https://scrap.kakaocdn.net/dn/cwSgBy/hyWOdPNQoQ/K9K7HqiLieaZkXS2V1CafK/img.png?width=740&amp;amp;height=507&amp;amp;face=0_0_740_507,https://scrap.kakaocdn.net/dn/cJRMwM/hyWOi4Eqbw/FDwkHJGG9jZPPcMJ4cJIh1/img.png?width=686&amp;amp;height=508&amp;amp;face=0_0_686_508&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#adding-custom-filter&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#adding-custom-filter&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/fHTbu/hyWOnLEH7V/bcubGGKlxEPqWnFfy9Yt40/img.png?width=698&amp;amp;height=610&amp;amp;face=0_0_698_610,https://scrap.kakaocdn.net/dn/cwSgBy/hyWOdPNQoQ/K9K7HqiLieaZkXS2V1CafK/img.png?width=740&amp;amp;height=507&amp;amp;face=0_0_740_507,https://scrap.kakaocdn.net/dn/cJRMwM/hyWOi4Eqbw/FDwkHJGG9jZPPcMJ4cJIh1/img.png?width=686&amp;amp;height=508&amp;amp;face=0_0_686_508');&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;Architecture :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;특정 요청은 시큐리티 필터 제외&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;특정 요청은 시큐리티 필터 체인을 아예 거치지 않도록 설정해주고 싶다면, 다음과 같이 &lt;code&gt;WebSecurityCustomizer&lt;/code&gt; 빈을 등록해주면 된다. 이 경우 해당 경로의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;RequestMatcher&lt;/code&gt;를 가진&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;이 인덱스 0번으로 자동 등록되며 이 필터 체인은 내부적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;가 없다.(즉 빈 리스트) 이 설정은 &lt;code&gt;SecurityFilterChain&lt;/code&gt; 빈 등록 시의 &lt;code&gt;authorizeHttpRequests&lt;/code&gt;의 &lt;code&gt;permitAll&lt;/code&gt; 설정과 달리 시큐리티 필터 체인을 아예 타지 않으므로 인증이 필요 없는 요청을 더 빠르게 처리할 수 있다는 장점이 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public WebSecurityCustomizer webSecurityCustomizer() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return web -&amp;gt; web.ignoring().requestMatchers(&quot;/img/**&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-08-20 오전 7.52.43.png&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;642&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d2j8mF/btsI9zqypq0/y6k2rVnMV4p9XF9GcYlZo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d2j8mF/btsI9zqypq0/y6k2rVnMV4p9XF9GcYlZo0/img.png&quot; data-alt=&quot;빈 리스트 filters를 가진 SecurityFilterChain 등록&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d2j8mF/btsI9zqypq0/y6k2rVnMV4p9XF9GcYlZo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd2j8mF%2FbtsI9zqypq0%2Fy6k2rVnMV4p9XF9GcYlZo0%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;1948&quot; height=&quot;642&quot; data-filename=&quot;스크린샷 2024-08-20 오전 7.52.43.png&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;642&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;빈 리스트 filters를 가진 SecurityFilterChain 등록&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 기본 등록되는 필터들 이외에도 &lt;code&gt;SessionManagementFilter&lt;/code&gt;, &lt;code&gt;RememberMeAuthenticationFilter&lt;/code&gt; 등 중요한 역할을 하는 필터들이 있다. 다 알아보면 좋겠지만 글이 너무 길어지기도 하고 각 필터들은 하나의 글로 정리하는게 더 좋을 것 같아 일단 여기까지 이번 글을 마무리하려고 한다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;글을 단순히 읽고 끝내기 보다는 실제 코딩을 하면서 기존 필터를 내 입맛대로 커스텀해보고 이해되지 않는 부분이 있다면 공식 문서를 읽거나 실제 코드를 까보는 것이 도움이 많이 된다. 이 글은 시큐리티 필터에 대한 대략적인 그림을 그리는 용도로 사용하고 본격적인 공부는 공식 문서를 활용하면 좋을 듯 하다. 공부하다보니 스프링 시큐리티 너어무 복잡하다...&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;끝.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1724107832655&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;Architecture :: Spring Security&quot; data-og-description=&quot;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-print-filters&quot; data-og-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-print-filters&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bxjF75/hyWScn9Px5/N68vk5GR4o0XAkqfpyFXb0/img.png?width=740&amp;amp;height=507&amp;amp;face=0_0_740_507,https://scrap.kakaocdn.net/dn/q0bUm/hyWOhYWDNK/wn8DKSDmib1jzG2FtJMe3K/img.png?width=686&amp;amp;height=508&amp;amp;face=0_0_686_508,https://scrap.kakaocdn.net/dn/bIoSWu/hyWOn5VG1M/6Ri0xiTKheof8sKSNfsQC1/img.png?width=656&amp;amp;height=508&amp;amp;face=0_0_656_508&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-print-filters&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-print-filters&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bxjF75/hyWScn9Px5/N68vk5GR4o0XAkqfpyFXb0/img.png?width=740&amp;amp;height=507&amp;amp;face=0_0_740_507,https://scrap.kakaocdn.net/dn/q0bUm/hyWOhYWDNK/wn8DKSDmib1jzG2FtJMe3K/img.png?width=686&amp;amp;height=508&amp;amp;face=0_0_686_508,https://scrap.kakaocdn.net/dn/bIoSWu/hyWOn5VG1M/6Ri0xiTKheof8sKSNfsQC1/img.png?width=656&amp;amp;height=508&amp;amp;face=0_0_656_508');&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;Architecture :: Spring Security&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.spring.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;figure id=&quot;og_1724108074440&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;스프링 시큐리티 내부 구조&quot; data-og-description=&quot;스프링 시큐리티 내부 동작 원리와 구조를 학습하는 시리즈입니다.&quot; data-og-host=&quot;www.youtube.com&quot; data-og-source-url=&quot;https://www.youtube.com/playlist?list=PLJkjrxxiBSFCFM0pjDwm6F98veieD0MER&quot; data-og-url=&quot;http://www.youtube.com/playlist?list=PLJkjrxxiBSFCFM0pjDwm6F98veieD0MER&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ycejl/hyWSnJ0i7f/t6q0wZBIC0fi0KxsQ7gDTK/img.jpg?width=480&amp;amp;height=270&amp;amp;face=0_0_480_270,https://scrap.kakaocdn.net/dn/2QbMO/hyWSelZHLP/pD7nQcT6afrLK9Hw8tnqdk/img.jpg?width=480&amp;amp;height=270&amp;amp;face=0_0_480_270&quot;&gt;&lt;a href=&quot;https://www.youtube.com/playlist?list=PLJkjrxxiBSFCFM0pjDwm6F98veieD0MER&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.youtube.com/playlist?list=PLJkjrxxiBSFCFM0pjDwm6F98veieD0MER&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ycejl/hyWSnJ0i7f/t6q0wZBIC0fi0KxsQ7gDTK/img.jpg?width=480&amp;amp;height=270&amp;amp;face=0_0_480_270,https://scrap.kakaocdn.net/dn/2QbMO/hyWSelZHLP/pD7nQcT6afrLK9Hw8tnqdk/img.jpg?width=480&amp;amp;height=270&amp;amp;face=0_0_480_270');&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;www.youtube.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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring/Security &amp;amp; OAuth2</category>
      <category>Spring Security</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/49</guid>
      <comments>https://daeun21dev.tistory.com/49#entry49comment</comments>
      <pubDate>Mon, 19 Aug 2024 02:53:34 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] 양방향 @OneToOne 매핑 시 LAZY 로딩 불가?</title>
      <link>https://daeun21dev.tistory.com/48</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;지인짜 오랜만에 블로그 글을 쓴다. 마지막 글을 쓴지 거진 4개월이 지났는데... 그동안 바빴다면 바빴고, '이번 주말에는 그동안 공부했던 것 정리해서 글 좀 써야지' 결심했던 것들을 미루고 미루다가 지금이 되었다... 암튼 이제 조금 여유가 생겨서 그동안 공부했던 것들을 조금씩 정리할 것이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;회사에서는 데이터 접근 기술로 JPA가 아닌 MyBatis를 사용하고 있다. 딱히 불만은 없고 오히려 SQL에 더 친숙해지고 쿼리 튜닝도 접할 기회가 많아 좋기도 하지만, JPA를 더 공부하고 싶어서 학교 창업 동아리 소속으로 진행하고 있는 사이드 프로젝트에서는 JPA를 사용하고 있다. 역시 책으로 공부하는 것보다 프로젝트를 진행해보니, 처음 보는 문제를 맞닥뜨리면서 새롭게 알게 되는 것들이 있다. JPA 그래도 어느 정도 알고 있다고 생각했는데 착각은 금물이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;각설하고, 프로젝트를 진행하면서 양방향 매핑은 '게시글 - 댓글', '병원 - 의사'와 같이 연관관계가 긴밀한 것들, 강한 것들에만 매핑 관계를 맺어주고 웬만하면 단방향 매핑으로 구현해왔다. 따라서, &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; 엔티티가 있을 때, &lt;code&gt;UserProfile&lt;/code&gt; &amp;rarr; &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; &amp;rarr; &lt;code&gt;User&lt;/code&gt; 엔티티 방향으로만 단방향 연관관계를 생성해주었다. 따라서 &lt;code&gt;User&lt;/code&gt; 엔티티와 매핑되는 테이블은 부모 테이블이, &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; 테이블과 매핑되는 테이블은 자식 테이블이 된다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;여기서 부모 테이블은 다른 테이블과의 관계에서 상위에 있는 테이블로, 부모 테이블은 주로&amp;nbsp;기본 데이터를 저장한다. 반면 자식 테이블은 다른 테이블과의 관계에서 하위에 있는 테이블로, 부모 테이블의 주 키(primary key)를&amp;nbsp; 외래 키(foreign key)로 참조하여 부모 테이블과의 관계를 맺으며 추가적인 데이터를 저장한다. 이때 부모 테이블의 외래키를 자식 테이블의 주 키로 사용할 건지 여부에 따라 식별/비식별 관계로 정의할 수 있는데, 오늘의 주제에서는 약간 벗어나므로 자세한 내용은 다음 글을&amp;nbsp;참고하시길.&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;프로젝트를 진행하며 만난 문제상황은 다음과 같다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;기존의 &lt;code&gt;UserProfile&lt;/code&gt; &amp;rarr; &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; &amp;rarr; &lt;code&gt;User&lt;/code&gt;로의 단방향 연관관계 뿐만 아니라 &lt;code&gt;User&lt;/code&gt; &amp;rarr; &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;User&lt;/code&gt; &amp;rarr; &lt;code&gt;UserPrivacy&lt;/code&gt; 로의 양방향 연관관계가 추가되었다. (여기서 &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt;가 외래키를 관리하므로 연관관계 주인) 이때 &lt;code&gt;userRepository.findById(userId);&lt;/code&gt; 로 &lt;code&gt;User&lt;/code&gt; 엔티티를 조회하는 경우 분명 &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; 엔티티에 대한 fetch 설정은 LAZY 로딩으로 설정했음에도 불구하고 EAGER 로딩처럼 동작하는 것이었다. (즉 &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;user_profile&lt;/code&gt;, &lt;code&gt;user_privacy&lt;/code&gt; 테이블에 대한 select 쿼리가 각각 발생함)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User extends BaseTimeEntity {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Id
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GenericGenerator(name = &quot;uuid-generator&quot;, type = com.project.foradhd.global.util.UUIDGenerator.class)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GeneratedValue(generator = &quot;uuid-generator&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Column(name = &quot;user_id&quot;, columnDefinition = &quot;varchar(32)&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String id;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//..

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@OneToOne(mappedBy = &quot;user&quot;, fetch = FetchType.LAZY) //lazy 로딩 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private UserProfile userProfile;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@OneToOne(mappedBy = &quot;user&quot;, fetch = FetchType.LAZY) //lazy 로딩 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private UserPrivacy userPrivacy;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;//...
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class UserProfile extends BaseTimeEntity {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Id
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GenericGenerator(name = &quot;uuid-generator&quot;, type = com.project.foradhd.global.util.UUIDGenerator.class)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GeneratedValue(generator = &quot;uuid-generator&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Column(name = &quot;user_profile_id&quot;, columnDefinition = &quot;varchar(32)&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String id;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@OneToOne(fetch = FetchType.LAZY) //lazy 로딩 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@JoinColumn(name = &quot;user_id&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private User user;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class UserPrivacy extends BaseTimeEntity {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Id
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GenericGenerator(name = &quot;uuid-generator&quot;, type = com.project.foradhd.global.util.UUIDGenerator.class)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@GeneratedValue(generator = &quot;uuid-generator&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Column(name = &quot;user_privacy_id&quot;, columnDefinition = &quot;varchar(32)&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String id;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@OneToOne(fetch = FetchType.LAZY) //lazy 로딩 설정
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@JoinColumn(name = &quot;user_id&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private User user;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sql&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;[Hibernate] 
    select
        u1_0.user_id,
        u1_0.created_at,
        u1_0.deleted,
        u1_0.deleted_at,
        u1_0.email,
        u1_0.is_verified_email,
        u1_0.last_modified_at,
        u1_0.role 
    from
        user u1_0 
    where
        u1_0.user_id=?
        
[Hibernate] 
    select
        up1_0.user_privacy_id,
        up1_0.age_range,
        up1_0.birth,
        up1_0.created_at,
        up1_0.gender,
        up1_0.last_modified_at,
        up1_0.name,
        up1_0.user_id 
    from
        user_privacy up1_0 
    where
        up1_0.user_id=?
        
[Hibernate] 
    select
        up1_0.user_profile_id,
        up1_0.created_at,
        up1_0.for_adhd_type,
        up1_0.last_modified_at,
        up1_0.nickname,
        up1_0.profile_image,
        up1_0.user_id 
    from
        user_profile up1_0 
    where
        up1_0.user_id=?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;왜 그런지 이유를 알아보기에 앞서 JPA의 구현체인 하이버네이트에서는 LAZY 로딩이 어떤 방식으로 동작하며, 이러한 동작 방식이 왜 &lt;code&gt;@OneToOne&lt;/code&gt; 양방향 매핑에서 연관관계 주인이 아닌 엔티티를 조회 시 LAZY 로딩이 동작하지 않도록 하는지 알아보자.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;하이버네이트의 FetchType.LAZY 동작 방식&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-07-28 오전 11.46.21.png&quot; data-origin-width=&quot;2248&quot; data-origin-height=&quot;862&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uo4JM/btsIRmERw4L/4K2CF610bxfKyayCVps6L0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uo4JM/btsIRmERw4L/4K2CF610bxfKyayCVps6L0/img.png&quot; data-alt=&quot;UserProfile에 매핑된 User 클래스&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uo4JM/btsIRmERw4L/4K2CF610bxfKyayCVps6L0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fuo4JM%2FbtsIRmERw4L%2F4K2CF610bxfKyayCVps6L0%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;758&quot; height=&quot;291&quot; data-filename=&quot;스크린샷 2024-07-28 오전 11.46.21.png&quot; data-origin-width=&quot;2248&quot; data-origin-height=&quot;862&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UserProfile에 매핑된 User 클래스&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UserProfile&lt;/code&gt; 조회 시 매핑된 &lt;code&gt;User&lt;/code&gt; 엔티티의 참조 클래스를 먼저 확인해보자. &lt;code&gt;User.class&lt;/code&gt; 일 것이라 예상했지만 이상한 &lt;code&gt;$HibernateProxy$...&lt;/code&gt; 타입이 붙어있다. 즉 실제 &lt;code&gt;User&lt;/code&gt; 클래스의 객체가 아닌 가짜, 프록시 객체를 참조하고 있는 것이다. 그렇다면 프록시란 무엇일까?&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프록시란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #303a3e;&quot;&gt;프록시는 '대리', &amp;lsquo;대신하다&amp;lsquo;라는 의미를 가지고 있는 단어로, 프록시 객&lt;span style=&quot;color: #333333;&quot;&gt;체는 동작을 대&lt;/span&gt;신해주는 가짜 객체라 볼 수 있다. Mock 객체 처럼 단순히 가짜 객체로만 존재하기 보다는, 내부에 실제 객체에 대한 참조를 보관하여 프록시 객체를 통해 실제 객체에 접근할 수 있다. 또한 위에서 디버깅했을 때 프록시 객체의 클래스 타입은 &lt;code&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;User$HibernateProxy$U0KnkqF4&lt;/span&gt;&lt;/code&gt;였었다. 해당 클래스는 실제 클래스의 자식 클래스로, 이러한 특징으로 &lt;/span&gt;&lt;span style=&quot;color: #303a3e;&quot;&gt;실제 객체 타입 자리에&lt;/span&gt;&lt;span style=&quot;color: #303a3e;&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-07-28 오전 11.49.13.png&quot; data-origin-width=&quot;2254&quot; data-origin-height=&quot;870&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzm0b9/btsIQFkAfm7/EgtKBoUz1nPPH2GDcbkAVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzm0b9/btsIQFkAfm7/EgtKBoUz1nPPH2GDcbkAVK/img.png&quot; data-alt=&quot;User 클래스의 자식 클래스인 User$HibernateProxy$U0KnkqF4 프록시 클래스&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzm0b9/btsIQFkAfm7/EgtKBoUz1nPPH2GDcbkAVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbzm0b9%2FbtsIQFkAfm7%2FEgtKBoUz1nPPH2GDcbkAVK%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;707&quot; height=&quot;273&quot; data-filename=&quot;스크린샷 2024-07-28 오전 11.49.13.png&quot; data-origin-width=&quot;2254&quot; data-origin-height=&quot;870&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;User 클래스의 자식 클래스인 User$HibernateProxy$U0KnkqF4 프록시 클래스&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;lazy 로딩과 프록시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #303a3e;&quot;&gt;하나의 엔티티 조회 시 연관된 엔티티를 모두 조회한다면, DB에 불필요한 select 쿼리가 여러 번 발생하게 된다. 따라서 연관 엔티티들을 모두 조회하기 보다는 필요한 연관관계만 조회해올 수 있도록 JPA는 lazy 로딩 이라는 것을 지원한다. 직역하면 게으른 로딩으로 연관된 엔티티에 대한 조회를 실제 사용시점까지 미루는 것을 말한다. &lt;br /&gt;&lt;/span&gt;&lt;br /&gt;JPA 구현체 중 하나인 하이버네이트는 lazy 로딩의 구현 방식으로 프록시를 사용한다. (실제 JPA 명세에 지연 로딩의 구현 방법은 정의되어 있지 않고, 구현체에 이를 위임한다고 한다.) 즉 특정 엔티티 내 연관관계에 있는 엔티티의 FetchType이 LAZY로 설정되어 있다면, 해당 엔티티 조회 시 연관관계 엔티티의 참조에는 실제 객체가 아닌 프록시 객체가 주입되는 것이다. 그리고 실제 객체를 사용하는 시점에 DB에서 데이터를 조회한다.&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;프록시 초기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'DB에서 데이터를 조회하여 프록시 객체 내부의 실제 객체에 대한 참조를 초기화하는 것'을 프록시 초기화 라고 한다. 실제로 연관된 엔티티의 id가 아닌 값 조회 시 DB에 쿼리가 나가 프록시가 초기화된다. 이때 프록시 객체 자체가 실제 객체로 바뀌는 것 이 아닌 프록시 객체 내부의 실제 객체에 대한 참조가 초기화 되는 것이다. 이후 초기화된 프록시 객체를 통해 실제 객체에 접근하여 값을 조회하게 된다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 아래 코드에서 &lt;code&gt;UserProfile&lt;/code&gt; 내 매핑되는 엔티티인 &lt;code&gt;User&lt;/code&gt; 객체의 &lt;code&gt;id&lt;/code&gt; 조회 시에는 select 쿼리가 발생하지 않지만, &lt;code&gt;email&lt;/code&gt; 조회 시 DB에 실제 select 쿼리가 나가게 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1722135440633&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Hibernate] 
    select
        up1_0.user_profile_id,
        up1_0.created_at,
        up1_0.for_adhd_type,
        up1_0.last_modified_at,
        up1_0.nickname,
        up1_0.profile_image,
        up1_0.user_id 
    from
        user_profile up1_0 
    where
        up1_0.user_id=?
        
userProfile.getUser().getId() = bf7b0c5ed770434d837dc0f9ecb9b44b -- User의 id 조회 시에는 select 쿼리 발생X

[Hibernate] 
    select
        u1_0.user_id,
        u1_0.created_at,
        u1_0.deleted,
        u1_0.deleted_at,
        u1_0.email,
        u1_0.is_verified_email,
        u1_0.last_modified_at,
        u1_0.role 
    from
        user u1_0 
    where
        u1_0.user_id=?
        
userProfile.getUser().getEmail() = jkde7721@gmail.com -- User의 id가 아닌 email 조회하기 위해 user 테이블에 대한 select 쿼리 발생&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;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;양방향 @OneToOne에서 왜 LAZY 동작 안함?&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;모든 &lt;code&gt;@OneToOne&lt;/code&gt; 관계에서 lazy 로딩이 동작하지 않는 것은 아니고, 정확히 말하면 양방향 &lt;code&gt;@OneToOne&lt;/code&gt;에서 연관관계 주인이 아닌 엔티티를 조회할 경우 연관된 엔티티가 lazy 로딩되지 않고 eager 로딩된다. 예를 들어 앞의 예제에서 &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; 엔티티를 조회할 때에는 lazy 로딩이 동작하는 반면, &lt;code&gt;User&lt;/code&gt; 엔티티 조회 시에는 연관된 &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt;가 eager 로딩으로 조회된다. 왜 그럴까? 차차 살펴보자.&lt;br /&gt;&lt;br /&gt;일단 연관된 엔티티의 참조값은 null이거나 프록시 객체일 수 있다.(FetchType이 LAZY인 경우) 매핑된 엔티티가 null이라면 연관관계가 없다는 것을, 프록시 객체라면 연관관계가 분명히 존재한다는 것을 의미한다.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;영속성 컨텍스트 내 프록시 객체&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이때 영속화된 엔티티는 영속성 컨텍스트 내부에서 key-value 형태로 관리되며, key는 엔티티의 id, value는 엔티티 객체 자체이다. 따라서 영속성 컨텍스트 내부에서 관리되는 객체가 실제 객체이든, 프록시 객체이든 id가 필요하다. 즉 lazy 로딩에서 연관된 엔티티 참조인 프록시 객체도 영속성 컨텍스트에서 관리되며 이를 위해 해당 엔티티의 id값은 알고 있어야 한다는 뜻이다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-07-28 오후 12.05.52.png&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;862&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9NqIQ/btsIOI4hVzK/xwfz04Du7Yga3ZpHlizif0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9NqIQ/btsIOI4hVzK/xwfz04Du7Yga3ZpHlizif0/img.png&quot; data-alt=&quot;영속성 컨텍스트에서 프록시로서 관리되는 User 객체&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9NqIQ/btsIOI4hVzK/xwfz04Du7Yga3ZpHlizif0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9NqIQ%2FbtsIOI4hVzK%2Fxwfz04Du7Yga3ZpHlizif0%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;2250&quot; height=&quot;862&quot; data-filename=&quot;스크린샷 2024-07-28 오후 12.05.52.png&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;862&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;영속성 컨텍스트에서 프록시로서 관리되는 User 객체&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;실제 &lt;code&gt;userProfileRepository.findByUserId(userId);&lt;/code&gt; 로 &lt;code&gt;UserProfile&lt;/code&gt; 객체 조회 시 연관된 엔티티인 &lt;code&gt;User&lt;/code&gt; 객체는 영속성 컨텍스트에서 프록시 객체로서 관리되고 있다. (&lt;code&gt;getReference&lt;/code&gt; 메소드의 두번째 인자로 전달된 값은 &lt;code&gt;User&lt;/code&gt;의 &lt;code&gt;id&lt;/code&gt;이다.)&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;연관관계 주인인 엔티티는 실제 매핑되는 테이블에서 직접 외래키를 관리하기 때문에 외래키 컬럼이 null이라면 매핑된 엔티티 참조에 null을, 외래키가 존재한다면 해당 외래키를 id로 가지는 프록시 객체를 설정해준다. 즉 자신의 테이블만을 조회함으로써 연관된 엔티티의 존재 여부를 알 수 있는 것이다.&lt;br /&gt;&lt;br /&gt;그러나 연관관계 주인이 아닌, &lt;code&gt;mappedBy&lt;/code&gt;가 선언된 엔티티를 조회 시에는 연관된 엔티티의 id를 자기 테이블만으로는 알 길이 없다. (ex. &lt;code&gt;user&lt;/code&gt; 테이블에서 &lt;code&gt;user_profile&lt;/code&gt;, &lt;code&gt;user_privacy&lt;/code&gt; 테이블에 대한 외래키를 관리하고 있지 않기 때문) 따라서 연관된 엔티티의 id를 알기 위해 항상 eager 로딩이 발생하는 것이다. (&lt;code&gt;user_profile&lt;/code&gt;, &lt;code&gt;user_privacy&lt;/code&gt; 테이블을 모두 조회해야 연관된 엔티티를 null 또는 프록시 객체로 설정할 수 있음)&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;항상 null? 항상 프록시 객체?&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;연관된 엔티티의 id를 모르겠으면 일단 항상 null로 설정하면 안되나? 의문이 들 것이다. 앞서 말했듯 엔티티 참조 값이 null이라는 것은 엔티티가 존재하지 않음을 의미한다. 연관 엔티티가 존재할 수도 있는데 null로 설정하는 것은 올바르지 않다.&lt;br /&gt;&lt;br /&gt;그럼 차라리 항상 프록시로 설정 후 프록시의 id 값을 null로 설정하면 안되나? 프록시 객체가 설정되었다는 것은 엔티티가 존재함을 의미하며, 이 또한 연관 엔티티가 존재하지 않을 수도 있는데 프록시 객체로 설정하는 것은 올바르지 않다. 또한 영속성 컨텍스트에서 관리되기 위해서는 일단 id가 필수이다. &lt;br /&gt;&lt;br /&gt;즉 &lt;code&gt;@OneToOne&lt;/code&gt; 양방향 매핑에서 lazy 로딩이 동작하지 않는 것은 연관된 엔티티 참조에 대한 표현법이 null, 프록시 객체 이렇게 2가지만 존재해서 발생하는 문제이다. 만약 '엔티티가 존재할 수도, 존재하지 않을 수도 있다'는 또 다른 표현법이 추가된다면 lazy 로딩이 가능할 수도 있겠다.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;지금까지의 내용을 정리하자면 다음과 같다.&amp;nbsp;&lt;br /&gt;- &lt;code&gt;@OneToOne&lt;/code&gt; 양방향 매핑에서 lazy 로딩이 동작하지 않는 경우는 연관관계 주인이 아닌 엔티티를 조회하는 경우&lt;br /&gt;- 매핑되는 엔티티의 id를 알기 위해 항상 eager 로딩 발생&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;@OneToMany에서는 LAZY 동작하던데?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한편 양방향 &lt;code&gt;@OneToMany&lt;/code&gt;, 예를 들어 team - member 관계에서 &lt;code&gt;Team&lt;/code&gt; 엔티티를 조회하는 경우에는 &lt;code&gt;List&amp;lt;Member&amp;gt; memberList&lt;/code&gt; 에 대한 lazy 로딩이 동작한다. 앞서 말한대로라면 &lt;code&gt;team&lt;/code&gt; 테이블은 &lt;code&gt;member&lt;/code&gt; 테이블에 대한 외래키를 관리하고 있지 않으므로 lazy 로딩이 동작하지 않아야 한다. 그러나 이 경우엔 lazy 로딩이 가능하다. 왜 그럴까?&lt;/p&gt;
&lt;pre id=&quot;code_1722136458810&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;team_id&quot;)
    private Long id;

    private String name;

    @OneToMany(mappedBy = &quot;team&quot;, fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    List&amp;lt;Member&amp;gt; memberList = new ArrayList&amp;lt;&amp;gt;();
    
    //연관관계 편의 메소드
    public void updateMemberList(List&amp;lt;Member&amp;gt; memberList) {
        this.memberList = memberList;
        this.memberList.forEach(member -&amp;gt; member.updateTeam(this));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1722136477964&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;member_id&quot;)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;team_id&quot;)
    private Team team;

    public void updateTeam(Team team) {
        this.team = team;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1722137974636&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Hibernate] 
    select
        t1_0.team_id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?

team.getMemberList().getClass() = class org.hibernate.collection.spi.PersistentBag

[Hibernate] 
    select
        ml1_0.team_id,
        ml1_0.member_id,
        ml1_0.name 
    from
        member ml1_0 
    where
        ml1_0.team_id=?

team.getMemberList().size() = 3 -- memberList 컬렉션의 사이즈를 조회하기 위해 member 테이블에 대한 select 쿼리 발생&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;code&gt;Team&lt;/code&gt; 엔티티만을 조회 시 lazy 로딩으로 설정했으므로 &lt;code&gt;member&lt;/code&gt; 테이블에 대한 select 쿼리는 발생하지 않았지만, &lt;code&gt;memberList&lt;/code&gt; 필드의 &lt;code&gt;size&lt;/code&gt;를 조회하거나 각 &lt;code&gt;Member&lt;/code&gt; 엔티티에 접근 시에는 &lt;code&gt;member&lt;/code&gt; 테이블에 대한 select 쿼리가 발생한다. 여기서 주목해야 할 건 &lt;code&gt;memberList&lt;/code&gt; 참조 클래스 타입이 &lt;code&gt;PersistentBag&lt;/code&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;h3 data-ke-size=&quot;size23&quot;&gt;Hibernate의 Collection 매핑 클래스 PersistenetBag&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;@OneToMany&lt;/code&gt;에서는 매핑되는&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;엔티티를 Collection 타입으로 관리하며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;하이버네이트에서는 컬렉션 타입을 &lt;code&gt;org.hibernate.collection.spi.PersistentBag&lt;/code&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-07-28 오후 12.33.28.png&quot; data-origin-width=&quot;2244&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caukoG/btsIO4e09P2/myBffS1sBBKmBHN8HL2P70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caukoG/btsIO4e09P2/myBffS1sBBKmBHN8HL2P70/img.png&quot; data-alt=&quot;memberList 컬렉션의 클래스 타입&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caukoG/btsIO4e09P2/myBffS1sBBKmBHN8HL2P70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaukoG%2FbtsIO4e09P2%2FmyBffS1sBBKmBHN8HL2P70%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;2244&quot; height=&quot;860&quot; data-filename=&quot;스크린샷 2024-07-28 오후 12.33.28.png&quot; data-origin-width=&quot;2244&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;memberList 컬렉션의 클래스 타입&lt;/figcaption&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;code&gt;PersistentBag&lt;/code&gt; 클래스를 살펴보면 &lt;code&gt;List&lt;/code&gt; 인터페이스를 구현하고 있으며, 내부에 &lt;code&gt;List&amp;lt;E&amp;gt;&lt;/code&gt; 타입의 실제 컬렉션 객체 &lt;code&gt;bag&lt;/code&gt;를 관리하고 있다. 생성자에서는 &lt;code&gt;Collection&amp;lt;E&amp;gt;&lt;/code&gt; 타입의 인자를 받아 &lt;code&gt;bag&lt;/code&gt; 필드를 초기화하는 것을 확인할 수 있다. 이때 부모 클래스인&amp;nbsp; &lt;code&gt;AbstractPersistentCollection&amp;lt;E&amp;gt;&lt;/code&gt;의 &lt;code&gt;setInitialized()&lt;/code&gt; 메소드를 호출하여 &lt;code&gt;PersistentBag&lt;/code&gt; 객체가 초기화되었는지 여부를 관리한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1722139693745&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Incubating
public class PersistentBag&amp;lt;E&amp;gt; extends AbstractPersistentCollection&amp;lt;E&amp;gt; implements List&amp;lt;E&amp;gt; {

    protected List&amp;lt;E&amp;gt; bag;
    
    //...
    
    public PersistentBag(SharedSessionContractImplementor session, Collection&amp;lt;E&amp;gt; coll) {
        super( session );
        providedCollection = coll;
        if ( coll instanceof List ) {
            bag = (List&amp;lt;E&amp;gt;) coll;
        }
        else {
            bag = new ArrayList&amp;lt;&amp;gt;( coll );
        }
        setInitialized();
        setDirectlyAccessible( true );
    }
    
    //...
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1722139816004&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public abstract class AbstractPersistentCollection&amp;lt;E&amp;gt; implements Serializable, PersistentCollection&amp;lt;E&amp;gt; {

    //...

    private boolean initialized; //객체의 초기화 여부 관리
    private transient boolean initializing; //객체가 초기화 중인지 여부 관리

    protected final void setInitialized() {
        this.initializing = false;
        this.initialized = true;
    }
    
    //...
}&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;code&gt;isInitialized()&lt;/code&gt; 결과가 false인 반면 컬렉션 객체에 직접 접근하여 초기화된 이후에는 반환값이 true가 되는 것을 확인할 수 있다.&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-filename=&quot;스크린샷 2024-07-28 오후 1.21.52.png&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;346&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qoJkt/btsIO3AoThn/wXcoAKUOH5paZppjaMEwqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qoJkt/btsIO3AoThn/wXcoAKUOH5paZppjaMEwqk/img.png&quot; data-alt=&quot;컬렉션 객체 초기화 전&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qoJkt/btsIO3AoThn/wXcoAKUOH5paZppjaMEwqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqoJkt%2FbtsIO3AoThn%2FwXcoAKUOH5paZppjaMEwqk%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;465&quot; height=&quot;132&quot; data-filename=&quot;스크린샷 2024-07-28 오후 1.21.52.png&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;346&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;컬렉션 객체 초기화 전&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-07-28 오후 1.22.12.png&quot; data-origin-width=&quot;1210&quot; data-origin-height=&quot;352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cuNpMU/btsIQKTFeWA/4pytVSjQRtaAWNPEXXPBe1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuNpMU/btsIQKTFeWA/4pytVSjQRtaAWNPEXXPBe1/img.png&quot; data-alt=&quot;객체에 접근하여 초기화&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuNpMU/btsIQKTFeWA/4pytVSjQRtaAWNPEXXPBe1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuNpMU%2FbtsIQKTFeWA%2F4pytVSjQRtaAWNPEXXPBe1%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;461&quot; height=&quot;134&quot; data-filename=&quot;스크린샷 2024-07-28 오후 1.22.12.png&quot; data-origin-width=&quot;1210&quot; data-origin-height=&quot;352&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;객체에 접근하여 초기화&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-07-28 오후 1.22.33.png&quot; data-origin-width=&quot;1204&quot; data-origin-height=&quot;326&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NlHts/btsIO31wx2q/hdQmOusvBRBj5bZOI4s3H0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NlHts/btsIO31wx2q/hdQmOusvBRBj5bZOI4s3H0/img.png&quot; data-alt=&quot;컬렉션 객체 초기화 후&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NlHts/btsIO31wx2q/hdQmOusvBRBj5bZOI4s3H0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNlHts%2FbtsIO31wx2q%2FhdQmOusvBRBj5bZOI4s3H0%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;458&quot; height=&quot;124&quot; data-filename=&quot;스크린샷 2024-07-28 오후 1.22.33.png&quot; data-origin-width=&quot;1204&quot; data-origin-height=&quot;326&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;p data-ke-size=&quot;size16&quot;&gt;자 그럼 정리해보자. &lt;code&gt;@OneToMany&lt;/code&gt; 연관관계를 가진 엔티티는 분명 연관관계의 주인이 아님에도 불구하고 엔티티 조회 시 lazy 로딩이 동작한다. 외래키를 직접 관리하지 않으므로 &lt;code&gt;@OneToOne&lt;/code&gt; 매핑에서 처럼 eager 로딩으로 동작해야 하는거 아니야? 싶겠지만, &lt;code&gt;@OneToMany&lt;/code&gt;로 매핑되는 컬랙션 객체는 하이버네이트에서 기본적으로 &lt;code&gt;PersistentBag&lt;/code&gt; 객체로 초기화된다. 해당 객체는 내부적으로 &lt;code&gt;bag&lt;/code&gt;라는 필드 변수를 두어 실제 매핑되는 객체를 관리하며 &lt;code&gt;initialized&lt;/code&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;code&gt;PersistentBag&lt;/code&gt; 객체가 결국 프록시 객체 아니야? 싶겠지만, &lt;code&gt;@OneToMany&lt;/code&gt; 관계에서 &lt;code&gt;fetch = FetchType.EAGER&lt;/code&gt; 로 설정되어 있어도 항상 실제 객체가 아닌 &lt;code&gt;PersistentBag&lt;/code&gt; 객체로 조회된다. &lt;code&gt;@OneToOne&lt;/code&gt; 관계가 EAGER로 설정되어 있을 땐 실제 클래스 객체로 조회되는 것과 차이가 있는 것이다. 즉 &lt;code&gt;@OneToMany&lt;/code&gt; 관계에서는 일관되게 컬렉션 객체가 &lt;code&gt;PersistentBag&lt;/code&gt; 객체로 초기화 되며 내부적으로 &lt;code&gt;bag&lt;/code&gt;, &lt;code&gt;initialized&lt;/code&gt; 필드로 상태를 표현할 수 있기 때문에 lazy 로딩이 제대로 동작하는 것이다.&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 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;양방향 @OneToOne에서의 LAZY 로딩 문제 해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 양방향 &lt;code&gt;@OneToOne&lt;/code&gt;의 연관관계 주인이 아닌 엔티티 조회 시 eager 로딩되는 문제를 어떻게 해결해야 할까??&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;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; 엔티티 내 &lt;code&gt;User&lt;/code&gt; 엔티티에 대한 매핑만 남기고 &lt;code&gt;User&lt;/code&gt; 엔티티 내의 &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&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;만약 양방향 연관관계가 정말 필요하다면 불필요한 select 쿼리가 여러번 발생하지 않도록 항상 fetch join해서 조회하는 것이다. 아래와 같이 아예 &lt;code&gt;findById&lt;/code&gt; 메소드를 오버라이딩하여 불필요한 select 쿼리 3번을 1번만 발생하도록 수정할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1722141514091&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(&quot;&quot;&quot;
    select u
    from User u
    join fetch UserProfile up on up.user.id = u.id
    join fetch UserPrivacy upv on upv.user.id = u.id
    where u.id = :userId
&quot;&quot;&quot;)
Optional&amp;lt;User&amp;gt; findById(@Param(&quot;userId&quot;) String 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;code&gt;@OneToOne&lt;/code&gt; 관계를 &lt;code&gt;@OneToMany&lt;/code&gt; - &lt;code&gt;@ManyToOne&lt;/code&gt; 매핑 관계로 변경하는 방법이 있다고 하는데(이 경우 매핑되는 컬렉션 객체의 크기는 항상 1), 굉장히 오버스러운 해결방안인 것 같다. 빈대 잡으려다 초가삼간 다 태우는 느낌쓰...&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;h3 data-ke-size=&quot;size23&quot;&gt;optional = false&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 어떤 글에서는 다음과 같이 매핑을 &lt;code&gt;optional = false&lt;/code&gt;로 설정하여 연관관계가 항상 존재함을 보장함으로써 null이 아닌 항상 프록시 객체로 조회되도록 하는 방법을 소개하고 있었다. 그러나 해보면 알겠지만 이는 당연히 제대로 동작하지 않는다. 그 이유는 너무 당연한데, 영속성 컨텍스트에서 관리되는 객체는 (실제 객체이든, 프록시 객체이든) 엔티티 타입과 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;즉 &lt;code&gt;User&lt;/code&gt; 엔티티의 연관관계를 &lt;code&gt;optional = false&lt;/code&gt;로 설정한다 한들 &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;UserPrivacy&lt;/code&gt; 프록시 객체의 id를 알아야 하고, 이를 알기 위해서는 &lt;code&gt;user_profile&lt;/code&gt;, &lt;code&gt;user_privacy&lt;/code&gt; 테이블을 조회할 수 밖에 없다. 즉 &lt;code&gt;optional = false&lt;/code&gt;는 올바른 해결 방법이 아니며, 참고로 &lt;code&gt;user_profile&lt;/code&gt;, &lt;code&gt;user_privacy&lt;/code&gt; 테이블의 &lt;code&gt;user_id&lt;/code&gt; FK를 not null로 설정해도 당연히 lazy 로딩이 동작하지 않는다. 다음 글을 &lt;a href=&quot;https://vladmihalcea.com/the-best-way-to-map-a-onetoone-relationship-with-jpa-and-hibernate/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt;하면 좋을 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1722142867896&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User extends BaseTimeEntity {

    @OneToOne(mappedBy = &quot;user&quot;, fetch = FetchType.LAZY, optional = false)
    private UserProfile userProfile;

    @OneToOne(mappedBy = &quot;user&quot;, fetch = FetchType.LAZY, optional = false)
    private UserPrivacy userPrivacy;
}&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;h3 data-ke-size=&quot;size23&quot;&gt;optional = false + @MapsId&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관 엔티티가 항상 존재함을 보장하면서 연관 엔티티의 id를 자식 테이블에 대한 조회 없이 알 수만 있다면, 양방향 &lt;code&gt;@OneToOne&lt;/code&gt;에서 lazy 로딩이 제대로 동작할 것이다. 다음으로 소개하는 방법은 경우에 따라 올바른 해결책일 수 있다. 바로 &lt;code&gt;@MapsId&lt;/code&gt; 어노테이션을 이용하여 부모 테이블의 PK를 자식 테이블의 PK로 그대로 사용하는 것이다. 다시 말하지만 양방향 &lt;code&gt;@OneToOne&lt;/code&gt;에서 lazy 로딩이 동작하지 않는 이유는 연관관계 주인이 아닌 엔티티, 즉 부모 테이블 조회 시 연관된 자식 테이블의 id를 모르기 때문인데, &lt;code&gt;@MapsId&lt;/code&gt;로 자식 테이블에서 부모 테이블의 PK를 id로서 사용하겠다고 선언했기 때문에 이 경우에는 lazy 로딩이 제대로 동작한다.&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;조금 억지 예제이긴 하지만 team - member 엔티티를 다음과 같이 &lt;code&gt;@OneToOne&lt;/code&gt;으로, &lt;code&gt;team&lt;/code&gt; 테이블의 pk를 &lt;code&gt;member&lt;/code&gt; 테이블의 pk로 사용하도록 정의해보자. 생성된 &lt;code&gt;member&lt;/code&gt; 테이블의 &lt;code&gt;member_id&lt;/code&gt; 컬럼은 PK이면서 &lt;code&gt;team&lt;/code&gt; 테이블에 대한 FK이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1722143776047&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;team_id&quot;)
    private Long id;

    private String name;

    @OneToOne(mappedBy = &quot;team&quot;, fetch = FetchType.LAZY, optional = false)
    Member member;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1722143794583&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {

    @Id
    private Long id; //@GeneratedValue 어노테이션을 선언해줄 필요 없음

    private String name;

    @MapsId
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;member_id&quot;)
    private Team team;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1722144054455&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;create table member (
    member_id bigint not null,
    name varchar(255),
    primary key (member_id)
) engine=InnoDB

create table team (
    team_id bigint not null auto_increment,
    name varchar(255),
    primary key (team_id)
) engine=InnoDB
    
alter table member 
   add constraint FK417p227iw2xgj6derejje8xd1 
   foreign key (member_id) 
   references team (team_id)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 설정하면, &lt;code&gt;Team&lt;/code&gt; 엔티티 조회 시 연관된 &lt;code&gt;Member&lt;/code&gt; 엔티티가 항상 존재하여(&lt;code&gt;optional = false&lt;/code&gt;로 설정했기 때문), &lt;code&gt;team&lt;/code&gt; 객체 내&amp;nbsp;&lt;code&gt;member&lt;/code&gt; 변수를&amp;nbsp;프록시 객체로 초기화하며, 또한 &lt;code&gt;Member&lt;/code&gt; 엔티티의 id는 &lt;code&gt;Team&lt;/code&gt; 엔티티의 id와 같음을 이미 알고 있으므로(&lt;code&gt;@MapsId&lt;/code&gt;로 설정했기 때문) &lt;code&gt;member&lt;/code&gt; 테이블에 대한 추가적인 select 없이 lazy 로딩이 가능한 것이다. 실제 조회된 &lt;code&gt;team&lt;/code&gt; 객체의 연관된 &lt;code&gt;member&lt;/code&gt;의 클래스 타입은 &lt;code&gt;HibernateProxy&lt;/code&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-filename=&quot;스크린샷 2024-07-28 오후 2.35.22.png&quot; data-origin-width=&quot;1306&quot; data-origin-height=&quot;424&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9AaUm/btsIQFroBQf/z5iuMNUbEygrqZ70Z1Wp2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9AaUm/btsIQFroBQf/z5iuMNUbEygrqZ70Z1Wp2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9AaUm/btsIQFroBQf/z5iuMNUbEygrqZ70Z1Wp2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9AaUm%2FbtsIQFroBQf%2Fz5iuMNUbEygrqZ70Z1Wp2k%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;632&quot; height=&quot;205&quot; data-filename=&quot;스크린샷 2024-07-28 오후 2.35.22.png&quot; data-origin-width=&quot;1306&quot; data-origin-height=&quot;424&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;그러나 해당 방법은 특정 테이블의 PK를 다른 테이블의 PK로 재활용하기 때문에 테이블 구조의 변경에 유연하지 못한 설계이다. 예를 들어 기존 team - member 관계가 현재는 1:1이지만 이는 1:N으로 변경된 여지가 충분히 있다. 해당 변경으로 인해 &lt;code&gt;member&lt;/code&gt; 테이블은 더이상 &lt;code&gt;team&lt;/code&gt; 테이블의 id를 PK로 사용할 수 없게 된다. 애초에 &lt;code&gt;member&lt;/code&gt; 테이블만의 PK를 관리하고 있었다면 매핑관계가 변경되더라도 전체 구현에 영향이 없을 것이다. 즉 &lt;code&gt;optional = false + @MapsId&lt;/code&gt;&amp;nbsp;해결방안은 연관관계가 1:1로 고정되어 변경될 가능성이 0%인 경우에만 적용할 수 있는 방법이다.&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;br /&gt;&amp;nbsp;&lt;br /&gt;지금까지 양방향 &lt;code&gt;@OneToOne&lt;/code&gt; 관계에서 연관관계 주인이 아닌 엔티티 조회 시 lazy 로딩이 제대로 동작하지 않는 원인 및 해결방안과 이를 이해하기 위해 하이버네이트의 프록시에 대해 알아보았다. 프로젝트 초반에는 &lt;code&gt;user&lt;/code&gt; 테이블에 &lt;code&gt;user_profile&lt;/code&gt;, &lt;code&gt;user_privacy&lt;/code&gt; 테이블의 데이터까지 모두 때려 넣었었다. 그러나 &lt;code&gt;User&lt;/code&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;아무튼 JPA 은근 까다로운 기술인 것 같다. 아무 생각 없이 추가한 매핑관계가 이미 적용한 최적화까지 무색하게 할 만큼... JPA 다시 공부하자.&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;끝.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&amp;nbsp;&lt;span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;The best way to map a @OneToOne relationship with JPA and Hibernate - Vlad Mihalcea&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;Learn the best way to map a OneToOne association with JPA and Hibernate when using both unidirectional and bidirectional relationships.&quot; data-og-host=&quot;vladmihalcea.com&quot; data-og-source-url=&quot;https://vladmihalcea.com/the-best-way-to-map-a-onetoone-relationship-with-jpa-and-hibernate/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c57KC8/hyWCNW7aja/coKIS0Uks8PzBKlKWG7Gm1/img.png?width=1950&amp;amp;height=824&amp;amp;face=0_0_1950_824,https://scrap.kakaocdn.net/dn/PhuoS/hyWCKMSjW2/fMF0h9EqTXjmWJVsXnkekK/img.png?width=1950&amp;amp;height=824&amp;amp;face=0_0_1950_824,https://scrap.kakaocdn.net/dn/hEUuP/hyWCOBK9zT/Bn2SWlAtnOorSFAK94Dt0k/img.png?width=768&amp;amp;height=402&amp;amp;face=0_0_768_402&quot; data-og-url=&quot;https://vladmihalcea.com/the-best-way-to-map-a-onetoone-relationship-with-jpa-and-hibernate/&quot;&gt;&lt;a href=&quot;https://vladmihalcea.com/the-best-way-to-map-a-onetoone-relationship-with-jpa-and-hibernate/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://vladmihalcea.com/the-best-way-to-map-a-onetoone-relationship-with-jpa-and-hibernate/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c57KC8/hyWCNW7aja/coKIS0Uks8PzBKlKWG7Gm1/img.png?width=1950&amp;amp;height=824&amp;amp;face=0_0_1950_824,https://scrap.kakaocdn.net/dn/PhuoS/hyWCKMSjW2/fMF0h9EqTXjmWJVsXnkekK/img.png?width=1950&amp;amp;height=824&amp;amp;face=0_0_1950_824,https://scrap.kakaocdn.net/dn/hEUuP/hyWCOBK9zT/Bn2SWlAtnOorSFAK94Dt0k/img.png?width=768&amp;amp;height=402&amp;amp;face=0_0_768_402');&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;The best way to map a @OneToOne relationship with JPA and Hibernate - Vlad Mihalcea&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Learn the best way to map a OneToOne association with JPA and Hibernate when using both unidirectional and bidirectional relationships.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;vladmihalcea.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;OneToOne 관계는 과연 지연로딩이 되는가?&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;JPA 연관관계 매핑 - @OneToOneJPA 프록시즉시로딩과 지연로딩이란?OneToOne 은 즉시로딩인가 지연로딩인가JPA에서 단방향 매핑은 @JoinColumn과 @OneToOne 를 통해 할 수 있다.양방향 매핑은 Team Class에 Member&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@yhlee9753/OneToOne-%EA%B4%80%EA%B3%84%EB%8A%94-%EA%B3%BC%EC%97%B0-%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9%EC%9D%B4-%EB%90%98%EB%8A%94%EA%B0%80#4-onetoone-%EC%9D%80-%EC%A6%89%EC%8B%9C%EB%A1%9C%EB%94%A9%EC%9D%B8%EA%B0%80-%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9%EC%9D%B8%EA%B0%80&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bMygsJ/hyWCCONQD7/keqlNFwZoRzNnke28ieC00/img.png?width=962&amp;amp;height=507&amp;amp;face=0_0_962_507,https://scrap.kakaocdn.net/dn/csoPlW/hyWCPHozvi/yaDrrShyYYyVbrz4ryIaE0/img.png?width=962&amp;amp;height=507&amp;amp;face=0_0_962_507,https://scrap.kakaocdn.net/dn/cZGt9p/hyWCKTDEuN/cvl8Hi2VbzV3pPRL4ftkA1/img.png?width=962&amp;amp;height=507&amp;amp;face=0_0_962_507&quot; data-og-url=&quot;https://velog.io/@yhlee9753/OneToOne-관계는-과연-지연로딩이-되는가&quot;&gt;&lt;a href=&quot;https://velog.io/@yhlee9753/OneToOne-관계는-과연-지연로딩이-되는가&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@yhlee9753/OneToOne-%EA%B4%80%EA%B3%84%EB%8A%94-%EA%B3%BC%EC%97%B0-%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9%EC%9D%B4-%EB%90%98%EB%8A%94%EA%B0%80#4-onetoone-%EC%9D%80-%EC%A6%89%EC%8B%9C%EB%A1%9C%EB%94%A9%EC%9D%B8%EA%B0%80-%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9%EC%9D%B8%EA%B0%80&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bMygsJ/hyWCCONQD7/keqlNFwZoRzNnke28ieC00/img.png?width=962&amp;amp;height=507&amp;amp;face=0_0_962_507,https://scrap.kakaocdn.net/dn/csoPlW/hyWCPHozvi/yaDrrShyYYyVbrz4ryIaE0/img.png?width=962&amp;amp;height=507&amp;amp;face=0_0_962_507,https://scrap.kakaocdn.net/dn/cZGt9p/hyWCKTDEuN/cvl8Hi2VbzV3pPRL4ftkA1/img.png?width=962&amp;amp;height=507&amp;amp;face=0_0_962_507');&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;OneToOne 관계는 과연 지연로딩이 되는가?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JPA 연관관계 매핑 - @OneToOneJPA 프록시즉시로딩과 지연로딩이란?OneToOne 은 즉시로딩인가 지연로딩인가JPA에서 단방향 매핑은 @JoinColumn과 @OneToOne 를 통해 할 수 있다.양방향 매핑은 Team Class에 Member&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;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;[DB] 프로젝트 데이터베이스 모델링 기록&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;1/ 개념 정리 부모테이블, 자식 테이블 부모 테이블은 다른 테이블과 관계에서 상위에 있는 테이블을 의미한다. 부모 테이블은 주로 기본 데이터를 저장하고, 다른 테이블과의 관계에서 외래 키(&quot; data-og-host=&quot;microhabitat.tistory.com&quot; data-og-source-url=&quot;https://microhabitat.tistory.com/108&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/JMNsi/hyWCzSf6cW/kdYPRhvKoXooNf7iEw823k/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/ATEQJ/hyWCNbTkbp/8GRI8eLmI8h4JZ8wC46kR0/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/bmQjF6/hyWCDmNKxO/JyREeCDjBytOhh6or4vd3k/img.jpg?width=3086&amp;amp;height=2182&amp;amp;face=0_0_3086_2182&quot; data-og-url=&quot;https://microhabitat.tistory.com/108&quot;&gt;&lt;a href=&quot;https://microhabitat.tistory.com/108&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://microhabitat.tistory.com/108&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/JMNsi/hyWCzSf6cW/kdYPRhvKoXooNf7iEw823k/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/ATEQJ/hyWCNbTkbp/8GRI8eLmI8h4JZ8wC46kR0/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/bmQjF6/hyWCDmNKxO/JyREeCDjBytOhh6or4vd3k/img.jpg?width=3086&amp;amp;height=2182&amp;amp;face=0_0_3086_2182');&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;[DB] 프로젝트 데이터베이스 모델링 기록&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;1/ 개념 정리 부모테이블, 자식 테이블 부모 테이블은 다른 테이블과 관계에서 상위에 있는 테이블을 의미한다. 부모 테이블은 주로 기본 데이터를 저장하고, 다른 테이블과의 관계에서 외래 키(&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;microhabitat.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;figure id=&quot;og_1722145628176&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 Hibernate 프록시 제대로 알고 쓰기&quot; data-og-description=&quot;JPA&amp;hellip;&quot; data-og-host=&quot;tecoble.techcourse.co.kr&quot; data-og-source-url=&quot;https://tecoble.techcourse.co.kr/post/2022-10-17-jpa-hibernate-proxy/&quot; data-og-url=&quot;https://tecoble.techcourse.co.kr/post/2022-10-17-jpa-hibernate-proxy/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dkp7Qv/hyWGZCCYeX/4dtXPu4JesfBwknjpq2Ab0/img.png?width=1056&amp;amp;height=592&amp;amp;face=0_0_1056_592,https://scrap.kakaocdn.net/dn/mv03d/hyWCJBrJw3/WLn67r08sxVTgHivjmCTz0/img.png?width=1056&amp;amp;height=592&amp;amp;face=0_0_1056_592&quot;&gt;&lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2022-10-17-jpa-hibernate-proxy/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://tecoble.techcourse.co.kr/post/2022-10-17-jpa-hibernate-proxy/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dkp7Qv/hyWGZCCYeX/4dtXPu4JesfBwknjpq2Ab0/img.png?width=1056&amp;amp;height=592&amp;amp;face=0_0_1056_592,https://scrap.kakaocdn.net/dn/mv03d/hyWCJBrJw3/WLn67r08sxVTgHivjmCTz0/img.png?width=1056&amp;amp;height=592&amp;amp;face=0_0_1056_592');&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 Hibernate 프록시 제대로 알고 쓰기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JPA&amp;hellip;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;tecoble.techcourse.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;figure id=&quot;og_1722145641898&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 @OneToOne은 FetchType.LAZY가 안 먹힐 수 있다?&quot; data-og-description=&quot;JPA에서 @OneToOne 연관관계일 때 지연 로딩이 안 될 수 있다? 이제는 JPA가 상당히 많이 쓰이고 있기도 하고 유명한 모 강의도 있어서 많은 사람들이 잘 알고 쓰고 있긴하다. 그러나 실제 경험해본 &quot; data-og-host=&quot;jeong-pro.tistory.com&quot; data-og-source-url=&quot;https://jeong-pro.tistory.com/249&quot; data-og-url=&quot;https://jeong-pro.tistory.com/249&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/nrHc0/hyWGWePCh4/U55RkBQHWMIKw7xCqYKHG1/img.png?width=684&amp;amp;height=420&amp;amp;face=0_0_684_420,https://scrap.kakaocdn.net/dn/bFMN6m/hyWCKNSD6S/yJ8F7xld93wgVF2tAR9Qk0/img.png?width=684&amp;amp;height=420&amp;amp;face=0_0_684_420,https://scrap.kakaocdn.net/dn/dgnY7V/hyWGWePCjv/jmkK6T7QyUV6dgXE0N3Xek/img.png?width=798&amp;amp;height=896&amp;amp;face=0_0_798_896&quot;&gt;&lt;a href=&quot;https://jeong-pro.tistory.com/249&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://jeong-pro.tistory.com/249&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/nrHc0/hyWGWePCh4/U55RkBQHWMIKw7xCqYKHG1/img.png?width=684&amp;amp;height=420&amp;amp;face=0_0_684_420,https://scrap.kakaocdn.net/dn/bFMN6m/hyWCKNSD6S/yJ8F7xld93wgVF2tAR9Qk0/img.png?width=684&amp;amp;height=420&amp;amp;face=0_0_684_420,https://scrap.kakaocdn.net/dn/dgnY7V/hyWGWePCjv/jmkK6T7QyUV6dgXE0N3Xek/img.png?width=798&amp;amp;height=896&amp;amp;face=0_0_798_896');&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 @OneToOne은 FetchType.LAZY가 안 먹힐 수 있다?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JPA에서 @OneToOne 연관관계일 때 지연 로딩이 안 될 수 있다? 이제는 JPA가 상당히 많이 쓰이고 있기도 하고 유명한 모 강의도 있어서 많은 사람들이 잘 알고 쓰고 있긴하다. 그러나 실제 경험해본&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;jeong-pro.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>JPA</category>
      <category>Hibernate</category>
      <category>JPA</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/48</guid>
      <comments>https://daeun21dev.tistory.com/48#entry48comment</comments>
      <pubDate>Sun, 28 Jul 2024 17:31:39 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] 스프링 시큐리티의 핵심 필터 DelegatingFilterProxy, FilterChainProxy</title>
      <link>https://daeun21dev.tistory.com/46</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 필터를 기반으로 동작한다. 스프링 MVC에는 필터와 비슷한 개념인 인터셉터 라는 것이 존재하는데 이 둘의 차이점이 무엇인지 알아보자. 또한 스프링 MVC에서는 DispatcherServlet 이라는 것이 핵심적인 역할을 하는데, 이와 비슷하게 스프링 시큐리티에도 핵심 필터들이 존재한다. 해당 필터들은 무슨 역할을 하는지, 요청이 들어왔을 때 이를 처리하는 전체적인 플로우도 살펴보자.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Filter vs Interceptor&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java Servlet Filter vs Spring MVC HandlerInterceptor&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Servlet Filter&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 필터는 자바의 Servlet Filter를 지칭하는 것으로 즉 스프링 프레임워크에 속한 기술이 아닌 웹서버 기술(서블릿)에 속한다. 따라서 클라이언트의 요청을 컨트롤러에서 처리하기 전 요청을 조작하거나 막을 수 있으며 컨트롤러에서 처리가 끝난 응답에 대한 조작 등도 가능하다. 필터 사용의 대표적인 예가 스프링 시큐리티이며 이러한 필터를 이용해 인증 및 인가를 처리하고 있다. (Spring Security는 Spring MVC 밖에서 사용된다.) &lt;br /&gt;&lt;br /&gt;또한 필터는 &lt;code&gt;Filter&lt;/code&gt; 인터페이스를 구현하며 &lt;code&gt;doFilter&lt;/code&gt; 메소드에 해당 필터에서 원하는 동작을 정의한다. 추가적으로 필터는 필터 체인이라 불리는 연속적인 필터들로 관리되며 현재 필터에서 FilterChain의 &lt;code&gt;doFilter&lt;/code&gt; 메소드를 호출함으로써 다음 필터에게 추가적인 처리를 위임할 수도 있다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;원래 필터는 스프링과는 무관한 기술로 아무런 설정을 하지 않으며 스프링은 필터를 인식할 수 없지만, &lt;code&gt;@Component&lt;/code&gt; 어노테이션을 통해 스프링 컨테이너에 빈으로 등록할 수도 있다. 다음은 모든 요청 URL에 대해 로그를 남겨주는 역할을 하는 필터이다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
public class LogFilter implements Filter {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Logger logger = LoggerFactory.getLogger(LogFilter.class);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;Hello from: &quot; + request.getLocalAddr());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;이 외에도 &lt;code&gt;@Bean&lt;/code&gt; 어노테이션과 &lt;code&gt;FilterRegistrationBean&lt;/code&gt; 객체를 생성하여 필터를 빈으로 등록할 수 있다. 아래 필터의 경우 요청 URL이 /users/* 인 경우에 대해서만 적용이 되며, &lt;code&gt;setOrder&lt;/code&gt;로 필터의 적용 순서를 설정할 수 있다. (순서값이 작을 수록 높은 우선순위)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Bean
public FilterRegistrationBean&amp;lt;LogFilter&amp;gt; loggingFilter() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FilterRegistrationBean&amp;lt;LogFilter&amp;gt; registrationBean 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;= new FilterRegistrationBean&amp;lt;&amp;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;registrationBean.setFilter(new LogFilter());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;registrationBean.addUrlPatterns(&quot;/users/*&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;registrationBean.setOrder(1);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return registrationBean;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;필터는 스프링 빈으로 등록하지 않고 서블릿 컨테이너에서만 관리되도록 할 수도 있다. &lt;code&gt;@WebFilter&lt;/code&gt;은 Servlet 3.0 스펙에 새롭게 추가된 어노테이션으로, 아래 필터는 서블릿 컨테이너에 자동으로 등록된다. 추가로 &lt;code&gt;@WebFilter&lt;/code&gt; 어노테이션이 선언된 필터를 인식하기 위해 &lt;code&gt;@ServletComponentScan&lt;/code&gt; 어노테이션이 함께 선언되어야 한다. (해당 어노테이션이 선언된 패키지 및 하위 패키지를 스캔)&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@WebFilter(urlPatterns = &quot;/users/*&quot;)
public class LogFilter implements Filter {

    private Logger logger = LoggerFactory.getLogger(LogFilter.class);

    @Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;Hello from: &quot; + request.getLocalAddr());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chain.doFilter(request, response);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HandlerInterceptor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터셉터는 Spring MVC 프레임워크의 한 기술로 DispatcherServlet - Controllers 사이에 위치하고 있다. 따라서 요청이 컨트롤러에 도달하기 전 또는 컨트롤러 처리 후 뷰가 렌더링되기 전후에 요청과 응답을 가로챌 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class LogInterceptor implements HandlerInterceptor {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private Logger logger = LoggerFactory.getLogger(LogInterceptor.class);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;preHandle&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return true;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;postHandle&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;logger.info(&quot;afterCompletion&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터셉터는 &lt;code&gt;HandlerInterceptor&lt;/code&gt; 인터페이스를 구현해야 하며 재정의 가능한 메소드와 메소드 호출 시기는 다음과 같다.&lt;br /&gt;&lt;br /&gt;- &lt;b&gt;preHandle&lt;/b&gt;: 컨트롤러 호출 전&lt;br /&gt;- &lt;b&gt;postHandle&lt;/b&gt;: 컨트롤러 호출 후 뷰를 렌더링하기 전&lt;br /&gt;- &lt;b&gt;afterCompletion&lt;/b&gt;: 컨트롤러 호출 후 뷰를 렌더링한 후&lt;br /&gt;&lt;br /&gt;또한 &lt;code&gt;WebMvcConfigurer&lt;/code&gt;를 구현한 설정 클래스에서 &lt;code&gt;addInterceptors&lt;/code&gt; 메소드를 재정의함으로써 인터셉터를 등록할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Configuration
public class WebConfig implements WebMvcConfigurer {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void addInterceptors(InterceptorRegistry registry) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;registry.addInterceptor(new RequestInterceptor());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 차이점&lt;/h3&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;477&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sTZUd/btsFI7kqXHs/aFh7MyWkRBAEuehM1wJ2D0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sTZUd/btsFI7kqXHs/aFh7MyWkRBAEuehM1wJ2D0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sTZUd/btsFI7kqXHs/aFh7MyWkRBAEuehM1wJ2D0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsTZUd%2FbtsFI7kqXHs%2FaFh7MyWkRBAEuehM1wJ2D0%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;718&quot; height=&quot;268&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;477&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터와 인터셉터는 거의 비슷하다고 볼 수 있지만 분명한 차이점이 존재한다. 우선 필터는 요청이 DispatcherServlet에 도달하기 전 인증, 로깅, auditing, 이미지 및 데이터 압축 등의 처리를 수행한다. (Spring MVC 프레임워크와 별개로 존재)&lt;br /&gt;&lt;br /&gt;반면 인터셉터는 DispatcherServlet과 Controller 사이에 위치한다. 즉 Spring MVC 프레임워크 내에서 수행되기 때문에 필터와 달리 &lt;code&gt;Handler&lt;/code&gt; 또는 &lt;code&gt;ModelAndView&lt;/code&gt; 객체에 대한 접근이 가능하다. 따라서 인터셉터는 일반적으로 애플리케이션 로깅과 같은 cross-cutting 문제(AOP), 인가 등을 처리한다.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;필터나 인터셉터나 클라이어트 요청에 대한 전처리가 가능하기 때문에 Spring Security 프레임워크에 대한 의존성 없이 필터나 인터셉터만으로도 인증 및 인가를 직접 구현할 수 있다. (스프링 시큐리티가 러닝 커브가 조금 있지만 구현 시에는 더 편리하다.)&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Security의 핵심 필터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 전반적으로 표준 Servlet Filter를 기반으로 하고 있다. 내부적으로 Filter Chain(a sequence of filters)을 유지하며 각각의 필터는 인증, 인가, csrf 공격 방어, 예외 처리 등 특정한 역할을 수행한다. 물론 이러한 필터들은 항상 고정되어 있는 것은 아니고, 설정에 따라 시큐리티 필터 체인에 추가되거나 제거되어 특정 필터가 동작하지 않게 할 수도 있다. (csrf 공격을 굳이 방어할 필요가 없다면 &lt;code&gt;CsrfFilter&lt;/code&gt;를 사용하지 않도록 설정 가능) 또한 직접 구현한 커스텀 필터가 시큐리티 필터 체인의 특정 위치에 추가되도록 설정 가능하다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;스프링 시큐리티에서는 기본적으로 많은 필터를 제공하는데 주요 필터들과 이들의 역할에 대해 자세히 알아보기 전, Spring Security가 인증 및 인가가 필요한 요청에 대해 어떤 플로우로 이를 처리하는지 전체적인 구조를 살펴보고자 한다. &lt;br /&gt;&lt;br /&gt;&lt;br /&gt;Spring Security에는 핵심적인 3가지 필터가 있다. 바로 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;, &lt;code&gt;FilterChainProxy&lt;/code&gt;, &lt;code&gt;SecurityFilterChain&lt;/code&gt; 이다. 각 필터의 대략적인 역할은 다음과 같다.&lt;br /&gt;&lt;br /&gt;- &lt;b&gt;DelegatingFilterProxy&lt;/b&gt;: Servlet Filter의 일종으로 스프링 컨테이너에 등록된 필터 빈에게 요청 처리를 위임하는 역할을 한다. 즉 서블릿 컨테이너와 스프링 컨테이너 사이를 이어준다고 볼 수 있다.&lt;br /&gt;&lt;br /&gt;- &lt;b&gt;FilterChainProxy&lt;/b&gt;: 스프링 시큐리티가 자동으로 생성하는 스프링 빈으로, 빈 이름은 &quot;springSecurityFilterChain&quot;으로 고정되어 있다. 내부적으로 여러 &lt;code&gt;SecurityFilterChain&lt;/code&gt;을 관리하고 있으며 &lt;code&gt;FilterChainProxy&lt;/code&gt; 또한 요청에 따라 적절한 &lt;code&gt;SecurityFIlterChain&lt;/code&gt;에게 처리를 위임한다.&lt;br /&gt;&lt;br /&gt;- &lt;b&gt;SecurityFilterChain&lt;/b&gt;: 스프링 빈으로 관리되는 시큐리티 필터들의 집합으로, 하나의 &lt;code&gt;RequestMatcher&lt;/code&gt;와 여러 &lt;code&gt;Filter&lt;/code&gt;들(필터 체인)을 관리하고 있으며, &lt;code&gt;FilterChainProxy&lt;/code&gt;에서 요청 처리를 위임할 적절한 &lt;code&gt;SecurityFIlterChain&lt;/code&gt;을 선정할 때 &lt;code&gt;RequestMatcher&lt;/code&gt;를 사용한다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;즉 실질적인 보안 처리는 &lt;code&gt;SecurityFilterChain&lt;/code&gt;이 하며 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;, &lt;code&gt;FilterChainProxy&lt;/code&gt;는 이름(프록시) 답게 적절한 필터에게 요청 처리를 위임하는 역할을 수행한다. 그럼 이제 각 필터에 대해 자세히 알아보자.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DelegatingFilterProxy&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;는 Spring Security 프레임워크가 제공하는 필터는 아니다. 먼저 해당 필터 프록시가 등장하게 된 배경을 이야기 해보자. 원래 필터는 서블릿 기술로 스프링 빈으로 등록이 불가능했다. 필터 내에서 스프링 빈을 주입 받아야 할 필요성 등으로 인해 필터를 빈으로 관리하고자 하였고, 따라서 서블릿 컨테이너의 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;가 스프링 컨테이너에 등록된 필터 빈에 요청 처리를 위임함으로써 기존의 필터 체인에 필터 빈을 정상적으로 포함할 수 있게 된 것이다. (참고로 Spring Boot에서는 내장 웹 서버를 지원하기 때문에 톰캣과 같은 서블릿 컨테이너까지 Spring Boot가 제어 가능하여 필터 빈을 관리하기 위해 더이상 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;가 필요하지 않다.)&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;즉 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;는 Spring Application Context에 접근 가능한 &lt;code&gt;Filter&lt;/code&gt; 구현 클래스(필터 빈)에 요청 처리를 위임하는 Servlet Filter 프록시로, Spring Security 프레임 워크는 이러한 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt; 기술을 도입하여 스프링 컨테이너 내에서 시큐리티 FilterChain이 관리되도록 구현되었다. 참고로 &lt;span style=&quot;color: #000000;&quot;&gt;자바독에서는 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;에 대해 다음과 같이 정의하고 있다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;Proxy for a standard Servlet Filter, delegating to a Spring-managed bean that implements the Filter interface.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;정리하자면&lt;code&gt; DelegatingFilterProxy&lt;/code&gt;는 서블릿 컨테이너에서 관리되지만 보안 처리를 하는 필터는 스프링 컨테이너에 빈으로 관리된다. 이때 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;는 보안 필터들에게 요청 처리를 위임하기 때문에 Servlet Container와 Spring Application Context 사이를 이어주는 역할을 한다고 볼 수 있다. 그리고 스프링 시큐리티의&amp;nbsp;&lt;code&gt;DelegatingFilterProxy&lt;/code&gt;의 &lt;code&gt;doFilter&lt;/code&gt; 메소드 내에서 모든 클라이언트 요청에 대해 오직 하나의 필터 빈에게 처리를 위임하며 이때 하나의 필터 빈은 이어서 살펴볼 &lt;code&gt;FilterChainProxy&lt;/code&gt;이다.&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;여기서 의문이 들 수 있다. 왜 보안 필터는 스프링 빈으로 관리되어야 하는 것일까? 일반적으로 &lt;code&gt;Filter&lt;/code&gt;를 구현한 클래스 객체는 서블릿 컨테이너에서 관리되며 이 경우 스프링은 해당 필터를 인식하지 못한다. 즉 스프링이 보안 필터를 인식하기 위해서이며, 또한 필터를 빈으로 관리함으로써 DI 뿐만 아니라 필터를 빈의 생성, 초기화, 소멸 등의 라이프사이클에 따라 관리할 수 있기 때문이다. 다시 한번 말하지만 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;는 Spring Security 프레임워크가 제공하는 필터가 아니다. 시큐리티 필터를 스프링 빈으로 관리할 필요가 있었고 이를 위해 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;를 도입하게 된 것이다. 현재 Spring Boot에서는 이를 사용하지 않으나 Spring Security는 해당 기술을 활용하여 필터 빈인&amp;nbsp;&lt;code&gt;FitlerChainProxy&lt;/code&gt;에게 보안 처리를 위임하고 있는 것이다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class DelegatingFilterProxy extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private WebApplicationContext webApplicationContext;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private String targetBeanName;
    
    private Filter delegate;
&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;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void initFilterBean() throws ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;	synchronized (this.delegateMonitor) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (this.delegate == null) {
&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;if (this.targetBeanName == null) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.targetBeanName = getFilterName();
&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;}
&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;WebApplicationContext wac = findWebApplicationContext();
&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;if (wac != null) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.delegate = initDelegate(wac);
&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;}
&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;&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;protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String targetBeanName = getTargetBeanName();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Filter delegate = wac.getBean(targetBeanName, Filter.class); //처리를 위임할 필터 빈을 스프링 컨테이너에서 조회
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isTargetFilterLifecycle()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;delegate.init(getFilterConfig());
&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;return delegate;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Filter delegateToUse = this.delegate;
&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;invokeDelegate(delegateToUse, request, response, filterChain); //필터 빈에 처리를 위임
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected void invokeDelegate(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws ServletException, IOException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;delegate.doFilter(request, response, filterChain);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DelegatingFilterProxy&lt;/code&gt;를 초기화하는 &lt;code&gt;initFilterBean&lt;/code&gt; 메소드 내에서는 요청을 위임할 필터 빈도 초기화하고 있다. 이때 break point를 찍어 디버깅해보면 &lt;code&gt;targetBeanName&lt;/code&gt; 값은 &quot;springSecurityFilterChain&quot;으로 스프링 빈으로 관리되는 &lt;code&gt;FIlterChainProxy&lt;/code&gt;의 빈 이름이다. 그리고 모든 요청에 대해 &lt;code&gt;doFilter&lt;/code&gt; 메소드를 실행할 때 &lt;code&gt;invokeDelegate&lt;/code&gt; 메소드를 호출하여 &lt;code&gt;FilterChainProxy&lt;/code&gt;에게 요청 처리를 위임한다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;FilterChainProxy&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;code&gt;FilterChainProxy&lt;/code&gt;는 특수한 Filter로서, Spring Security 프레임워크 내에서 빈으로 등록된 프록시 FilterChain을 의미한다. 이 필터 또한 요청을 실질적으로 처리하진 않는다. 내부적으로 여러 개의 &lt;code&gt;SecurityFilterChain&lt;/code&gt; 이라는 것을 가지고 있는데 &lt;code&gt;FilterChainProxy&lt;/code&gt;는 현재의 요청을 처리할 수 있는 적절한 &lt;code&gt;SecurityFilterChain&lt;/code&gt;을 선택하여 처리를 위임한다.&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class FilterChainProxy extends GenericFilterBean {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private List&amp;lt;SecurityFilterChain&amp;gt; filterChains;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private FilterChainDecorator filterChainDecorator = new VirtualFilterChainDecorator();
 
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
	&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws IOException, ServletException {
&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;doFilterInternal(request, response, chain);
&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;private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throws IOException, ServletException {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;Filter&amp;gt; filters = getFilters(firewallRequest); //처리를 위임할 시큐리티 필터 체인 조회
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (filters == null || filters.size() == 0) {
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return;
&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;FilterChain reset = (req, res) -&amp;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;chain.doFilter(req, res);
&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;this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private List&amp;lt;Filter&amp;gt; getFilters(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for (SecurityFilterChain chain : this.filterChains) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (chain.matches(request)) { //현재 요청을 처리할 수 있는 SecurityFilterChain
&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;return chain.getFilters();
&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;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&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;code&gt;SecurityFilterChain&lt;/code&gt;을 선택하는 로직은 &lt;code&gt;getFilters&lt;/code&gt; 메소드에 구현되어 있다. 즉 &lt;code&gt;filterChains&lt;/code&gt; 리스트를 for문으로 순회하면서 해당 &lt;code&gt;SecurityFilterChain&lt;/code&gt;의 &lt;code&gt;matches&lt;/code&gt; 메소드를 호출하여 현재 요청을 처리할 수 있는 필터 체인(&lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;)을 조회하여 반환한다. 그리고 해당 필터 체인에게 다시 요청 처리를 위임한다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SecurityFilterChain&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt; 또한 스프링 빈으로 관리되는 필터 체인으로 &lt;code&gt;FilterChainProxy&lt;/code&gt;로부터 요청 처리를 위임받는다. 내부적으로 하나의 &lt;code&gt;RequestMatcher&lt;/code&gt;와 &lt;code&gt;Filter&lt;/code&gt; 리스트를 가지고 있어 &lt;code&gt;RequestMatcher&lt;/code&gt;로는 자신이 처리할 수 있는 요청인지 여부를 판단하며(URL 기반), 처리 가능한 요청인 경우 &lt;code&gt;Filter&lt;/code&gt; 리스트 즉 필터 체인이 본격적인 인증 및 인가 처리를 수행한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface SecurityFilterChain {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;boolean matches(HttpServletRequest request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;Filter&amp;gt; getFilters();
}

//SecurityFilterChain 인터페이스를 구현한 기본 SecurityFilterChain
public final class DefaultSecurityFilterChain implements SecurityFilterChain {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final RequestMatcher requestMatcher;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final List&amp;lt;Filter&amp;gt; filters;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean matches(HttpServletRequest request) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return this.requestMatcher.matches(request);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public List&amp;lt;Filter&amp;gt; getFilters() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return this.filters;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&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;code&gt;FilterChainProxy&lt;/code&gt;는 필터 빈으로 &lt;code&gt;DelegatingFitlerProxy&lt;/code&gt;로 감싸져 필터 체인에 등록되며,&amp;nbsp; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;모든 요청에 대해 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt; 필터가 일단 동작한&lt;/span&gt;다. 이후 &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;로부터 요청 처리를 위임받은 &lt;code&gt;FilterChainProxy&lt;/code&gt;는 여러 개의 &lt;code&gt;SecurityFilterChain&lt;/code&gt; 중 적절한 &lt;code&gt;SecurityFilterChain&lt;/code&gt;을 조회하여 다시 요청 처리를 위임한다. (예를 들어 아래의 경우 현재 요청이 /api/** 라면 SecurityFilterChain0의 doFilter 메소드를 호출한다.)&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;740&quot; data-origin-height=&quot;507&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZ7bI1/btsFRuTzzor/wIB0iLh1rxFHv3zf4EqBbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZ7bI1/btsFRuTzzor/wIB0iLh1rxFHv3zf4EqBbk/img.png&quot; data-alt=&quot;Spring Security의 요청 처리 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZ7bI1/btsFRuTzzor/wIB0iLh1rxFHv3zf4EqBbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZ7bI1%2FbtsFRuTzzor%2FwIB0iLh1rxFHv3zf4EqBbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;650&quot; height=&quot;445&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;507&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Spring Security의 요청 처리 흐름&lt;/figcaption&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;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다중 보안 설정&lt;/h2&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;FilterChainProxy 디버깅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 &lt;code&gt;FilterChainProxy&lt;/code&gt; 내에는 &lt;code&gt;SecurityFilterChain&lt;/code&gt;이 하나만 등록된다. 그러나 다음과 같이 다중 보안 설정, 즉 여러 개의 &lt;code&gt;SecurityFilterChain&lt;/code&gt;을 빈으로 등록하면 당연한 이야기이지만 &lt;code&gt;FilterChainProxy&lt;/code&gt;는 여러 개의 &lt;code&gt;SecurityFilterChain&lt;/code&gt;에 의존하게 되며, 이 중 적절한 시큐리티 필터 체인에게 요청 처리를 위임한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@EnableWebSecurity
@Configuration
public class SecurityConfig {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.securityMatcher(&quot;/&quot;, &quot;/home&quot;) //&quot;/&quot;, &quot;/home&quot; 요청을 처리할 수 있는 SecurityFilterChain 등록
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.authorizeHttpRequests(request -&amp;gt; request
&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;.requestMatchers(&quot;/&quot;, &quot;/home&quot;).authenticated())
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.formLogin(form -&amp;gt; form
&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;.loginPage(&quot;/login&quot;)
&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;.permitAll())
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.logout(LogoutConfigurer::permitAll);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public SecurityFilterChain securityFilterChain2(HttpSecurity http) throws Exception {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;http
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.securityMatcher(&quot;/admin&quot;) //&quot;/admin&quot; 요청을 처리할 수 있는 SecurityFilterChain 등록
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.authorizeHttpRequests(request -&amp;gt; request
&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;.requestMatchers(&quot;/admin&quot;).authenticated())
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.formLogin(AbstractHttpConfigurer::disable)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.csrf(CsrfConfigurer::disable);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return http.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1251&quot; data-origin-height=&quot;616&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NQ6OI/btsFPdjUVc5/J2RpevjLFHBt75mVAqVlo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NQ6OI/btsFPdjUVc5/J2RpevjLFHBt75mVAqVlo1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NQ6OI/btsFPdjUVc5/J2RpevjLFHBt75mVAqVlo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNQ6OI%2FbtsFPdjUVc5%2FJ2RpevjLFHBt75mVAqVlo1%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;1251&quot; height=&quot;616&quot; data-origin-width=&quot;1251&quot; data-origin-height=&quot;616&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecurityConfig&lt;/code&gt; 설정 시 &lt;code&gt;securityMatcher&lt;/code&gt; api를 통해 특정 URL에 대해 동작할 &lt;code&gt;SecurityFilterChain&lt;/code&gt;을 지정할 수 있으며, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;실제&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;code&gt;FilterChainProxy&lt;/code&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;code&gt;getFilters&lt;/code&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에 중단점을 찍어 디버깅해보면 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;위 설정을 통해&lt;span&gt; &lt;/span&gt;&lt;/span&gt;총 2개의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;code&gt;SecurityFilterChain&lt;/code&gt;&lt;span style=&quot;color: #333333; text-align: start;&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;code&gt;SecurityFIlterChain&lt;/code&gt; 내의 &lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;에는 시큐리티 설정에 따라 특정 필터가 추가되거나 제거된다. 실제로 formLogin, csrf 기능을 disabled한 &lt;code&gt;SecurityFilterChain&lt;/code&gt; 내의 &lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;에는 해당 기능을 처리하는 &lt;code&gt;CsrfFilter&lt;/code&gt;, &lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;491&quot; data-origin-height=&quot;450&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/by9Trz/btsFOgnXACx/QIe8dzkbL9eXcIeKfw1x5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/by9Trz/btsFOgnXACx/QIe8dzkbL9eXcIeKfw1x5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/by9Trz/btsFOgnXACx/QIe8dzkbL9eXcIeKfw1x5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fby9Trz%2FbtsFOgnXACx%2FQIe8dzkbL9eXcIeKfw1x5k%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;491&quot; height=&quot;450&quot; data-origin-width=&quot;491&quot; data-origin-height=&quot;450&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;반면 &quot;/&quot;, &quot;/home&quot; 요청을 처리하는 &lt;code&gt;SecurityFilterChain&lt;/code&gt; 내 &lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;에는 &lt;code&gt;CsrfFilter&lt;/code&gt;, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;579&quot; data-origin-height=&quot;535&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpot5F/btsFOUESMV9/wtPU4FXFRo5LJtKan6MhO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpot5F/btsFOUESMV9/wtPU4FXFRo5LJtKan6MhO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpot5F/btsFOUESMV9/wtPU4FXFRo5LJtKan6MhO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbpot5F%2FbtsFOUESMV9%2FwtPU4FXFRo5LJtKan6MhO1%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;579&quot; height=&quot;535&quot; data-origin-width=&quot;579&quot; data-origin-height=&quot;535&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;br /&gt;&lt;br /&gt;정리하자면 Spring Security에서 모든 요청은 Filter Chain &amp;rarr; DelegatingFilterProxy &amp;rarr; FilterChainProxy &amp;rarr; SecurityFilterChain &amp;rarr; List&amp;lt;Filter&amp;gt;를 통하며 필터 단에서 모든 인증 및 인가 처리가 수행된다. 다음 글에서는 이 &lt;code&gt;List&amp;lt;Filter&amp;gt;&lt;/code&gt;에 포함되는 핵심 필터들에는 어떠한 것들이 있으며 각 필터가 어떤 인증 및 인가 로직을 어떻게 처리하는지 알아보고자 한다.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;끝.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/architecture.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://docs.spring.io/spring-security/reference/servlet/architecture.html&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://kasunprageethdissanayake.medium.com/spring-security-the-security-filter-chain-2e399a1cb8e3&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://kasunprageethdissanayake.medium.com/spring-security-the-security-filter-chain-2e399a1cb8e3&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.baeldung.com/spring-delegating-filter-proxy&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://www.baeldung.com/spring-delegating-filter-proxy&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.baeldung.com/spring-mvc-handlerinterceptor-vs-filter&quot; target=&quot;_self&quot;&gt;&lt;span&gt;https://www.baeldung.com/spring-mvc-handlerinterceptor-vs-filter&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://howtodoinjava.com/spring-boot/spring-filter-examples/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://howtodoinjava.com/spring-boot/spring-filter-examples/&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.baeldung.com/spring-boot-add-filter&quot; target=&quot;_self&quot;&gt;&lt;span&gt;https://www.baeldung.com/spring-boot-add-filter&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://mangkyu.tistory.com/221&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://mangkyu.tistory.com/221&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>Spring/Security &amp;amp; OAuth2</category>
      <category>Interceptor</category>
      <category>servlet filter</category>
      <category>Spring Security</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/46</guid>
      <comments>https://daeun21dev.tistory.com/46#entry46comment</comments>
      <pubDate>Wed, 13 Mar 2024 07:09:49 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Link 헤더를 이용한 REST Pagination 구현</title>
      <link>https://daeun21dev.tistory.com/44</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 자칭 'REST API'라는 것들을 구현해왔지만 REST API가 정확히 뭐야? 라고 질문한다면 제대로 답하지 못할 것이다. 그러던 와중 &lt;a href=&quot;https://www.youtube.com/watch?v=RP_f5dMoHFc&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;그런 REST API로 괜찮은가&lt;/a&gt; 라는 컨퍼런스 영상을 보게 되었다. 내가 지금까지 만들어온 API가 REST하지 않았다는 것을 깨닫게 되었다.&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;REST API의 제약조건에는 다음 6가지가 있다. API가 HTTP 프로토콜을 따른다면 대부분의 제약조건을 만족시키지만 문제는 굵은 글씨로 표시한 uniform interface 라고 한다.&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;client-server&lt;/li&gt;
&lt;li&gt;stateless&lt;/li&gt;
&lt;li&gt;cache&lt;/li&gt;
&lt;li&gt;&lt;b&gt;uniform interface&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;layered system&lt;/li&gt;
&lt;li&gt;code-on-demand (optional)&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;uniform interface 또한 하나의 아키텍처 스타일로 다음의 4가지 제약조건으로 구성된다. 여기서 오늘날 대부분의 API가 지키지 않는 조건은 self-descriptive messages와 HATEOAS이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;identification of resources&lt;/li&gt;
&lt;li&gt;manipulation of resources through representations&lt;/li&gt;
&lt;li&gt;&lt;b&gt;self-descriptive messages&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;hypermedia as the engine of application state(HATEOAS)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 self-descriptive messages는 &quot;메시지는 스스로 설명해야 한다&quot;는 것을 의미한다. 즉 응답 메시지만으로, 전달된 JSON 혹은 XML 데이터를 해석할 수 있어야 한다는 것이다. 즉 다음과 같은 JSON 데이터가 있을 때 Content-Type인 &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc6902&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;application/json-patch+json 명세&lt;/a&gt;를 통해 JSON 데이터를 이해할 수 있기 때문에(&lt;code&gt;op&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt; 각 필드는 무엇을 의미하는지 이해할 수 있음) 이 API는 self-descriptive messages를 만족한다고 할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705246659617&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HTTP/1.1 200 OK
Content-Type: application/json-patch+json
{
    &quot;op&quot;: &quot;remove&quot;,
    &quot;path&quot;: &quot;/a/b/c&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;다음으로 HATEOAS는 &quot;애플리케이션의 상태는 Hyperlink를 이용해 전이되어야 한다&quot;는 것을 의미한다. 기존 HTML과 같은 경우에는 a 태그를 통해 HATEOAS 조건을 아주 쉽게 만족시킬 수 있었던 반면, API에는 a 태그와 같은 역할을 하는 필드 등이 따로 정의되어 있지 않아 우리가 구현한 API는 대부분 HATEOAS를 만족하지 않는다. 따라서 어플리케이션 상태 전이를 위한 링크를 응답으로 클라이언트에게 전달해주면 해당 API는 HATEOAS를 만족하게 되고, 클라이언트와 서버는 보다 동적인 상호작용이 가능해진다.&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;이를 구현하기 위한 방법에는 JSON으로 하이퍼링크를 표현하는 방법을 정의한 명세를 활용하거나 (예를 들어 Content-Type을&amp;nbsp;&lt;a href=&quot;https://www.iana.org/assignments/media-types/application/vnd.api+json&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;application/vnd.api+json&lt;/a&gt;로 설정 후 JSON 데이터에 &lt;code&gt;links&lt;/code&gt; 필드 추가), Link 또는 Location 등의 헤더로 링크를 표현하는 방법이 있다.&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;이렇게 REST API가 uniform interface를 만족한다면(완벽한 REST API 라면), 서버와 클라이언트가 각각 독립적으로 진화할 수 있게 된다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-darkreader-inline-bgcolor=&quot;&quot; data-darkreader-inline-color=&quot;&quot;&gt;즉 다른 리소스에 대한 URI를 하이퍼링크를 통해 응답으로 제공하거나, 응답 데이터를 현재 메시지만으로 해석 가능하다면, 새로운 API가 추가되거나 기존 API가 변경 혹은 삭제되더라도 클라이언트 코드는 업데이트할 필요가 없게 되는 것이다.&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;누군가는 앱에서는 애초에 완벽한 REST API란 불가능하다 등등 많은 의견이 있겠지만, 내가 여기서 주목한 것은 REST API의 HATEOAS 규약이다. 게시글 생성 API의 응답 값에 생성된 게시글 조회 API, 게시글 목록 조회 API 링크를 포함하는 것과 같이 모든 API마다 관련된 다른 API로의 전이를 위한 링크를 응답 값에 포함하는 것은 조금 오바이지 않나? 라는 생각이 처음에는 들었다. 그러나 페이징에서만큼은 API가 이 HATEOAS를 만족한다면 클라이언트 쪽 구현이 좀더 유연해질 것 같았다.&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;REST Pagination 적용 이유&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회되는 데이터셋이 매우 매우 많은 경우, 서버는 전체 데이터 리스트를 모두 조회하도록 하기 보다는 페이징 처리하여 페이지 단위로 조회할 수 있도록 한다. 이 경우 기존에는 Repository 계층에서 반환되는 &lt;code&gt;Page&lt;/code&gt; 객체를 이해하기 쉽게 변환한 커스텀 &lt;code&gt;PageResponse&lt;/code&gt;를 응답으로 반환였다. 따라서 클라이언트는 응답으로 전달된 &lt;code&gt;pageNumber&lt;/code&gt;, &lt;code&gt;totalPages&lt;/code&gt;, &lt;code&gt;first&lt;/code&gt; 등의 값을 이용해 다음 페이지 요청 URI, 마지막 페이지 요청 URI 등을 직접 유추해내야 했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705251136765&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@AllArgsConstructor
public class PageResponse {

    private final Integer pageNumber;
    private final Integer pageSize;
    private final Integer numberOfElements;
    private final Integer totalPages;
    private final Long totalElements;
    private final Boolean first;
    private final Boolean last;
    private final Boolean empty;

    public PageResponse(Page&amp;lt;?&amp;gt; page) {
        this.pageNumber = page.getPageable().getPageNumber() + 1; //페이지 번호는 1부터 시작
        this.pageSize = page.getPageable().getPageSize();
        this.numberOfElements = page.getNumberOfElements();
        this.totalPages = page.getTotalPages();
        this.totalElements = page.getTotalElements();
        this.first = page.isFirst();
        this.last = page.isLast();
        this.empty = page.isEmpty();
    }
}&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;code&gt;PageResponse&lt;/code&gt; 뿐만 아니라&amp;nbsp;첫 페이지, 이전 페이지, 다음 페이지, 마지막 페이지에 대한 링크 또한 응답 값으로 전달하면 좀더 REST한 API가 되지 않을까? 라는 생각에서 REST한 Pagination을 구현해보기로 마음 먹었다.&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;span style=&quot;letter-spacing: 0px;&quot;&gt;클라이언트는 &lt;code&gt;paging&lt;/code&gt; 필드의 값을 보고 이전 페이지, 다음 페이지 등에 대한 요청 URI를 직접 생성할 것이다. 즉 아래의 응답 데이터만으로는 다음 API로의 상태 변화가 불가능하다. (응답에 하이퍼링크가 포함되어 있지 않기 때문)&amp;nbsp;&lt;/span&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;기존 페이징 응답 데이터&lt;/h4&gt;
&lt;pre id=&quot;code_1705221813739&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-07 15:04:35&quot;,
    &quot;status&quot;: 200,
    &quot;code&quot;: &quot;OK&quot;,
    &quot;message&quot;: &quot;요청이 정상 처리되었습니다.&quot;,
    &quot;data&quot;: {
        &quot;expenditureListByDate&quot;: [
            {
                &quot;expenditureDate&quot;: &quot;2023-11-17&quot;,
                &quot;expenditureList&quot;: [
                    {
                        &quot;expenditureId&quot;: 3,
                        &quot;amount&quot;: 5000,
                        &quot;categoryId&quot;: 2,
                        &quot;type&quot;: &quot;TRAFFIC&quot;,
                        &quot;title&quot;: &quot;하루 교통비&quot;
                    }, 
                    {
                        &quot;expenditureId&quot;: 2,
                        &quot;amount&quot;: 4000,
                        &quot;categoryId&quot;: 1,
                        &quot;type&quot;: &quot;FOOD&quot;,
                        &quot;title&quot;: &quot;편의점 점심&quot;
                    }
                ]
            },
            {
                &quot;expenditureDate&quot;: &quot;2023-11-15&quot;,
                &quot;expenditureList&quot;: [
                    {
                        &quot;expenditureId&quot;: 1,
                        &quot;amount&quot;: 21000,
                        &quot;categoryId&quot;: 1,
                        &quot;type&quot;: &quot;FOOD&quot;,
                        &quot;title&quot;: &quot;점심 커피챗&quot;
                    }
                ]
            } 
        ],
    
        &quot;paging&quot;: {
            &quot;pageNumber&quot;: 3,
            &quot;pageSize&quot;: 3,
            &quot;numberOfElements&quot;: 3,
            &quot;totalPages&quot;: 5,
            &quot;totalElements&quot;: 15,
            &quot;first&quot;: false,
            &quot;last&quot;: false,
            &quot;empty&quot;: false
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;REST Pagination 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring HATEOAS 사용하지 않는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring은 REST API의 HATEOAS 규약을 만족하기 위한 &lt;a href=&quot;https://howtodoinjava.com/spring/hateoas-pagination-links/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring HATEOAS&lt;/a&gt; 라이브러리를 제공한다. 여기서 제공되는 &lt;code&gt;PagedModel&lt;/code&gt;을 통해 페이징 관련 링크들을 자동 생성하여 응답 바디로 전달해줄 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705218822323&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-hateoas'&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;Spring HATEOAS를 사용한 경우 응답 데이터 예시&lt;/h4&gt;
&lt;pre id=&quot;code_1705221496625&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-07 15:04:35&quot;,
    &quot;status&quot;: 200,
    &quot;code&quot;: &quot;OK&quot;,
    &quot;message&quot;: &quot;요청이 정상 처리되었습니다.&quot;,
  
    &quot;links&quot;: [
        {
            &quot;rel&quot;: &quot;first&quot;,
            &quot;href&quot;: &quot;http://localhost:8080/api/expenditures?page=1&amp;amp;size=3&quot;
        },
        {
            &quot;rel&quot;: &quot;prev&quot;,
            &quot;href&quot;: &quot;http://localhost:8080/api/expenditures?page=2&amp;amp;size=3&quot;
        },
        {
            &quot;rel&quot;: &quot;next&quot;,
            &quot;href&quot;: &quot;http://localhost:8080/api/expenditures?page=4&amp;amp;size=3&quot;
        },
        {
            &quot;rel&quot;: &quot;last&quot;,
            &quot;href&quot;: &quot;http://localhost:8080/api/expenditures?page=5&amp;amp;size=3&quot;
        }
    ],

    &quot;data&quot;: {
        &quot;expenditureListByDate&quot;: [
            {
                &quot;expenditureDate&quot;: &quot;2023-11-17&quot;,
                &quot;expenditureList&quot;: [
                    {
                        &quot;expenditureId&quot;: 3,
                        &quot;amount&quot;: 5000,
                        &quot;categoryId&quot;: 2,
                        &quot;type&quot;: &quot;TRAFFIC&quot;,
                        &quot;title&quot;: &quot;하루 교통비&quot;
                    }, 
                    {
                        &quot;expenditureId&quot;: 2,
                        &quot;amount&quot;: 4000,
                        &quot;categoryId&quot;: 1,
                        &quot;type&quot;: &quot;FOOD&quot;,
                        &quot;title&quot;: &quot;편의점 점심&quot;
                    }
                ]
            },
            {
                &quot;expenditureDate&quot;: &quot;2023-11-15&quot;,
                &quot;expenditureList&quot;: [
                    {
                        &quot;expenditureId&quot;: 1,
                        &quot;amount&quot;: 21000,
                        &quot;categoryId&quot;: 1,
                        &quot;type&quot;: &quot;FOOD&quot;,
                        &quot;title&quot;: &quot;점심 커피챗&quot;
                    }
                ]
            } 
        ],
    
        &quot;paging&quot;: {
            &quot;pageNumber&quot;: 3,
            &quot;pageSize&quot;: 3,
            &quot;numberOfElements&quot;: 3,
            &quot;totalPages&quot;: 5,
            &quot;totalElements&quot;: 15,
            &quot;first&quot;: false,
            &quot;last&quot;: false,
            &quot;empty&quot;: 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;그러나 이 경우 Controller의 구현 복잡성이 높아질 뿐만 아니라, 무엇보다 응답 바디에 &lt;code&gt;links&lt;/code&gt; 필드가 추가됨으로써 응답 데이터가 복잡해진다는 것이다. (별거 아닐 수 있지만 추후 페이징 링크를 응답으로 전달해주지 않기로 결정한다면, 응답 바디에서 삭제하는 것 보다는 응답 헤더에서 삭제하는 것이 더 간단하지 않을까 생각했다. 반대로 기존 API에 대해 페이징 링크를 응답 바디에 추가하려고 한다면 기존 API를 많이 고쳐야 한다.) 따라서 Spring HATEOAS를 사용하지 않고 이를 응답의 Link 헤더로 직접 설정하여 구현하려 한다.&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;h3 data-ke-size=&quot;size23&quot;&gt;Link 헤더 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 처음 알았지만 HTTP 표준에는 &lt;a href=&quot;https://www.w3.org/Protocols/9707-link-header.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Link 헤더&lt;/a&gt;가 있다. (HTML의 &amp;lt;link/&amp;gt; 태그와 같은 용도) &lt;span style=&quot;color: #555555; text-align: justify;&quot; data-darkreader-inline-color=&quot;&quot;&gt;실제 &lt;a href=&quot;https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GitHub API&lt;/a&gt;에서도 REST Pagination을 위해 응답 바디 대신 Link 헤더를 사용한다. 당연히 페이징 관련 링크를 응답 바디로 전달해줄 때보다 응답 헤더를 사용하면 좀더 깔끔하게 처리가 가능해진다. &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;Link &lt;span style=&quot;background-color: #ffffff; color: #292c32; text-align: start;&quot; data-darkreader-inline-bgcolor=&quot;&quot; data-darkreader-inline-color=&quot;&quot;&gt;엔티티 헤더 필드는 두 리소스 간의 관계, 즉 현재 요청된 리소스와 다른 리소스 간의 관계를 설명하는 수단을 제공한다. 엔티티에는 여러 개의 링크 값이 포함될 수 있으며 명세된 문법은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #ffffff; color: #333333; text-align: left;&quot; data-ke-language=&quot;java&quot; data-darkreader-inline-bgcolor=&quot;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;Link: &amp;lt;uri&amp;gt;; param1=&quot;value1&quot;; param2=&quot;value2&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;우리가 만들 Link 헤더의 값은 다음과 같다. &lt;code&gt;rel&lt;/code&gt; 속성에는 &lt;code&gt;first&lt;/code&gt;, &lt;code&gt;prev&lt;/code&gt;, &lt;code&gt;next&lt;/code&gt;, &lt;code&gt;last&lt;/code&gt; 중 하나가 가능하며, 각각 앞의 URI 값이 첫 페이지 링크, 이전 페이지 링크, 다음 페이지 링크, 마지막 페이지 링크임을 나타낸다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #ffffff; color: #333333; text-align: left;&quot; data-ke-language=&quot;java&quot; data-darkreader-inline-bgcolor=&quot;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;Link: &amp;lt;http://localhost:8080/api/expenditures?page=1&amp;amp;size=3&amp;gt;; rel=&quot;first&quot;,&amp;lt;http://localhost:8080/api/expenditures?page=2&amp;amp;size=3&amp;gt;; rel=&quot;prev&quot;,&amp;lt;http://localhost:8080/api/expenditures?page=4&amp;amp;size=3&amp;gt;; rel=&quot;next&quot;,&amp;lt;http://localhost:8080/api/expenditures?page=5&amp;amp;size=3&amp;gt;; rel=&quot;last&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;h3 data-ke-size=&quot;size23&quot;&gt;ResponseBodyAdvice 이용한 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller 계층에서 메소드의 파라미터로 &lt;code&gt;HttpServletResponse&lt;/code&gt;를 주입받아 직접 응답 헤더를 설정해줄 수도 있지만, 페이징 링크를 응답 헤더로 설정하는 것은 부가 로직에 가깝기 때문에 AOP를 통해 처리하는 것이 더 적절하다고 판단하였다. 여기에는 Controller 메소드 호출 후 &lt;code&gt;HttpMessageConverter&lt;/code&gt;에 의해 데이터가 응답 바디에 쓰여지기 전 호출되어 응답값을 조작할 수 있도록 도와주는 &lt;code&gt;ResponseBodyAdvice&lt;/code&gt; 인터페이스를 사용할 것이다. 이는 &lt;a href=&quot;https://daeun21dev.tistory.com/43&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;앞선 글&lt;/a&gt;에서 &lt;code&gt;CommonResponse&lt;/code&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;code&gt;Page&lt;/code&gt; 객체가 Repository에서만 반환되고 Controller, Service 계층에서는 각각 별도의 DTO로 변환되어 반환된다. 따라서 이 Repository에서 반환된 &lt;code&gt;Page&lt;/code&gt; 객체를 별도의 공간에 저장해두고 &lt;code&gt;ResponseBodyAdvice&lt;/code&gt; 구현체에서 저장된 &lt;code&gt;Page&lt;/code&gt; 객체를 가져와 응답 헤더에 설정하도록 구현할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705219186437&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Aspect
@Component
public class PaginationAdvice {

    private final PageStore pageStore;

    @AfterReturning(value = &quot;execution(org.springframework.data.domain.Page com.wanted.safewallet.domain..repository..*.*(..))&quot;, returning = &quot;page&quot;)
    public void processPagination(Page&amp;lt;?&amp;gt; page) {
        pageStore.setPage(page); //ThreadLocal에 저장
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 &lt;code&gt;@Aspect&lt;/code&gt; 어노테이션을 이용해 구현한 어드바이저로, 정의된 포인트컷에 따르면 해당 어드바이스는 모든 repository 하위 패키지 내 모든 클래스의 메소드들 중 &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;Page&lt;/code&gt; 객체를 반환하는 메소드&lt;/span&gt;에 대해 동작한다. 즉 Repository 메소드 중 &lt;code&gt;Page&lt;/code&gt; 객체를 반환한다면 &lt;code&gt;PageStore&lt;/code&gt; 라는 곳에 반환된 &lt;code&gt;Page&lt;/code&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_1705219198932&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class PageStore {

    private final ThreadLocal&amp;lt;Page&amp;lt;?&amp;gt;&amp;gt; page = new ThreadLocal&amp;lt;&amp;gt;();

    public void setPage(Page&amp;lt;?&amp;gt; page) {
        this.page.set(page);
    }

    public Page&amp;lt;?&amp;gt; getPage() {
        Page&amp;lt;?&amp;gt; storedPage = this.page.get();
        this.page.remove();
        return storedPage;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티 쓰레드에서도 안전하게 동작하기 위해 Repository 메소드에서 반환된 &lt;code&gt;Page&lt;/code&gt; 객체는 쓰레드 별로 관리되는 ThreadLocal에 저장하도록 하였다. 아래의 &lt;code&gt;ResponseBodyAdvice&lt;/code&gt; 구현체에서도 이 저장 공간에 접근하기 위해 &lt;code&gt;PageStore&lt;/code&gt;는 &lt;code&gt;@Component&lt;/code&gt; 어노테이션을 통해 빈으로 등록해주었다. 또한 스프링은 쓰레드 풀(Thread Pool)을 기반으로 쓰레드를 재활용하여 사용하기 때문에 ThreadLocal에 저장해둔 &lt;code&gt;Page&lt;/code&gt; 객체를 조회한 후에는 &lt;code&gt;remove()&lt;/code&gt; 메소드를 호출하여 ThreadLocal을 비워주도록 하였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705219213332&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@RestControllerAdvice
public class PaginationLinkHeaderAdvice implements ResponseBodyAdvice&amp;lt;Object&amp;gt; {

    private final PageStore pageStore;

    @Value(&quot;${spring.data.web.pageable.one-indexed-parameters}&quot;)
    private boolean oneIndexedPage;

    private static final String PAGE_PARAM_NAME = &quot;page&quot;;
    private static final String SIZE_PARAM_NAME = &quot;size&quot;;

    @Override
    public boolean supports(MethodParameter returnType,
        Class&amp;lt;? extends HttpMessageConverter&amp;lt;?&amp;gt;&amp;gt; converterType) {
        return Arrays.stream(returnType.getExecutable().getParameterTypes())
            .anyMatch(parameterType -&amp;gt; parameterType.isAssignableFrom(Pageable.class));
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
        Class&amp;lt;? extends HttpMessageConverter&amp;lt;?&amp;gt;&amp;gt; selectedConverterType,
        ServerHttpRequest request, ServerHttpResponse response) {
        Page&amp;lt;?&amp;gt; page = pageStore.getPage();
        response.getHeaders().add(HttpHeaders.LINK, getPageLinks(page));
        return body;
    }

    private String getPageLinks(Page&amp;lt;?&amp;gt; page) {
        if (page == null) return &quot;&quot;;

        int pageNumber = page.getNumber() + (oneIndexedPage ? 1 : 0);
        List&amp;lt;String&amp;gt; links = new ArrayList&amp;lt;&amp;gt;();

        if (!page.isFirst()) {
            final String firstPageLink = getPageLink(oneIndexedPage ? 1 : 0, page.getSize());
            links.add(&quot;&amp;lt;&quot; + firstPageLink  + &quot;&amp;gt;; rel=\&quot;first\&quot;&quot;);
        }
        if (page.hasPrevious()) {
            final String prevPageLink = getPageLink(pageNumber - 1, page.getSize());
            links.add(&quot;&amp;lt;&quot; + prevPageLink + &quot;&amp;gt;; rel=\&quot;prev\&quot;&quot;);
        }
        if (page.hasNext()) {
            final String nextPageLink = getPageLink(pageNumber + 1, page.getSize());
            links.add(&quot;&amp;lt;&quot; + nextPageLink + &quot;&amp;gt;; rel=\&quot;next\&quot;&quot;);
        }
        if (!page.isLast()) {
            final String lastPageLink = getPageLink(page.getTotalPages() - (oneIndexedPage ? 0 : 1), page.getSize());
            links.add(&quot;&amp;lt;&quot; + lastPageLink + &quot;&amp;gt;; rel=\&quot;last\&quot;&quot;);
        }
        return String.join(&quot;,&quot;, links);
    }

    private String getPageLink(int page, int size) {
        return ServletUriComponentsBuilder.fromCurrentRequest()
            .replaceQueryParam(PAGE_PARAM_NAME, page)
            .replaceQueryParam(SIZE_PARAM_NAME, size)
            .build().encode().toUriString();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;supports&lt;/code&gt; 메소드에서는 Controller 메소드들 중 파라미터로 &lt;code&gt;Pageable&lt;/code&gt; 객체가 전달되는, 즉 페이징 요청에 대해서만 &lt;code&gt;PaginationLinkHeaderAdvice&lt;/code&gt; 내 &lt;code&gt;beforeBodyWrite&lt;/code&gt; 메소드가 호출되도록 &lt;code&gt;boolean&lt;/code&gt; 값을 반환하고 있다. &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;beforeBodyWrite&lt;/code&gt;&lt;span&gt; 메소드에서는 앞서 &lt;code&gt;PaginationAdvice&lt;/code&gt;에서 &lt;code&gt;PageStore&lt;/code&gt;에 임시 저장해둔 &lt;code&gt;Page&lt;/code&gt; 객체를 조회해 Link 헤더를 위한 값을 생성한다. 각각 첫 페이지 링크, 이전 페이지 링크, 다음 페이지 링크, 마지막 페이지 링크를 생성하고 ,(콤마)로 연결하여 응답의 Link 헤더로 설정해준다.&amp;nbsp;&lt;/span&gt;&lt;/span&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;실제 페이징 요청에 대한 응답 Link 헤더&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;914&quot; data-origin-height=&quot;521&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Rhw30/btsDqMJRpJi/kIku2ITo8uGNZe69DfbP21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Rhw30/btsDqMJRpJi/kIku2ITo8uGNZe69DfbP21/img.png&quot; data-alt=&quot;응답으로 전달된 Link 헤더&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Rhw30/btsDqMJRpJi/kIku2ITo8uGNZe69DfbP21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRhw30%2FbtsDqMJRpJi%2FkIku2ITo8uGNZe69DfbP21%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;697&quot; height=&quot;397&quot; data-origin-width=&quot;914&quot; data-origin-height=&quot;521&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;응답으로 전달된 Link 헤더&lt;/figcaption&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;h3 data-ke-size=&quot;size23&quot;&gt;문제점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에서는 Spring RestDocs를 이용해 테스트 기반 문서화를 하고 있다. 즉 Controller 테스트를 필수로 작성해야 하는데, 여기서 사용하는 &lt;code&gt;@WebMvcTest&lt;/code&gt; 어노테이션은 컨트롤러와 관련된 &lt;code&gt;@Controller&lt;/code&gt;, &lt;code&gt;@ControllerAdvice&lt;/code&gt;, &lt;code&gt;@JsonComponent&lt;/code&gt;, &lt;code&gt;Converter&lt;/code&gt; 등을 알아서 빈으로 등록해준다. 여기서 문제는 위에서 정의한 &lt;code&gt;PaginationLinkHeaderAdvice&lt;/code&gt;도 빈으로 등록된다는 것인데, 이 클래스는 &lt;code&gt;PageStore&lt;/code&gt; 클래스에 대한 의존성을 가지고 있다. 여기서 &lt;code&gt;PageStore&lt;/code&gt;는 컨트롤러와 관련된 클래스가 아니기 때문에 자동 빈 등록되지 않는다. 즉 &lt;code&gt;@Import(PageStore.class)&lt;/code&gt; 어노테이션을 통해 직접 빈으로 등록해주어야 한다. 이렇게 되면 모든 &lt;code&gt;-ControllerTest&lt;/code&gt; 마다 &lt;code&gt;@Import(PageStore.class)&lt;/code&gt;를 추가로 선언해줘야 했다. 그렇지 않으면 &lt;code&gt;NoSuchBeanDefinitionException&lt;/code&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;생각해보면 만약 Controller에서 &lt;code&gt;Page&lt;/code&gt; 객체를 반환한다면 굳이 Repository에서 반환하는 &lt;code&gt;Page&lt;/code&gt; 객체를 ThreadLocal에 저장할 필요는 없을 텐데, 현재 프로젝트에서 Controller는 &lt;code&gt;Page&lt;/code&gt; 객체가 아닌 별도의 응답 DTO를 반환하고 있어 어쩔 수 없이 &lt;code&gt;Page&lt;/code&gt; 객체를 ThreadLocal에 저장할 수 밖에 없었고 이를 위해 &lt;code&gt;PageStore&lt;/code&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;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 페이징 관련 정보는 JSON 응답 데이터만으로 전달하는 것으로도 충분하다고 생각한다. 그러나 REST API에 더욱 부합하는 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;다만 의문이 드는 것은 Link 헤더를 전달 받은 클라이언트에서는 이걸 어떻게 파싱하지?의 문제이다. 사실 이 링크도 그냥 응답 바디에 포함하여 전달했다면 클라이언트 입장에서 이 링크를 사용하기가 훨씬 수월했을 텐데, Link 헤더로 전달하게 되면 이걸 따로 파싱해야 하지 않나? 라는 생각이 든다.(물론 Link 헤더에 대한 HTTP 명세가 있긴 하지만...) 아무튼 Pagination을 좀더 REST 하게 구현해본 경험에 만족한다. 추후 Spring HATEOAS를 통해 HATEOAS 규약을 만족하는 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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.matchilling.com/restful-pagination-in-spring-using-link-header/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.matchilling.com/restful-pagination-in-spring-using-link-header/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.w3.org/Protocols/9707-link-header.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.w3.org/Protocols/9707-link-header.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.hongminhee.org/2012/05/27/http-link-header/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.hongminhee.org/2012/05/27/http-link-header/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://joomn11.tistory.com/26&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://joomn11.tistory.com/26&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://howtodoinjava.com/spring/hateoas-pagination-links/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://howtodoinjava.com/spring/hateoas-pagination-links/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring/Spring</category>
      <category>pagination</category>
      <category>rest api</category>
      <category>spring boot</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/44</guid>
      <comments>https://daeun21dev.tistory.com/44#entry44comment</comments>
      <pubDate>Sun, 14 Jan 2024 21:59:29 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] AOP로 공통 응답 형식 처리하기</title>
      <link>https://daeun21dev.tistory.com/43</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 API 요청에 대한 응답으로 단순히 데이터만을 전달해주었다. 그러나 한 프로젝트에서 모든 API 요청에 대해 공통의 응답 형식을 가지도록 개발해달라는 요청을 받았다. 예를 들어 이전에는 아래와 같이 응답 데이터를 전달한 반면&lt;/p&gt;
&lt;pre id=&quot;code_1705205618127&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;categoryList&quot;: [
        {
            &quot;categoryId&quot;: 1,
            &quot;type&quot;: &quot;FOOD&quot;
        },
        {
            &quot;categoryId&quot;: 2,
            &quot;type&quot;: &quot;TRAFFIC&quot;
        },
        {
            &quot;categoryId&quot;: 3,
            &quot;type&quot;: &quot;RESIDENCE&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;code&gt;data&lt;/code&gt; 필드로 감싸고, &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt; 등의 필드를 추가한 공통 응답 형식을 가지도록 개발하는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705205656953&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-14 13:10:18&quot;,
    &quot;status&quot;: 200,
    &quot;code&quot;: &quot;OK&quot;,
    &quot;message&quot;: &quot;요청이 정상 처리되었습니다.&quot;,
    &quot;data&quot;: {
        &quot;categoryList&quot;: [
            {
                &quot;categoryId&quot;: 1,
                &quot;type&quot;: &quot;FOOD&quot;
            },
            {
                &quot;categoryId&quot;: 2,
                &quot;type&quot;: &quot;TRAFFIC&quot;
            },
            {
                &quot;categoryId&quot;: 3,
                &quot;type&quot;: &quot;RESIDENCE&quot;
            },
            {
                &quot;categoryId&quot;: 4,
                &quot;type&quot;: &quot;CLOTHING&quot;
            },
            {
                &quot;categoryId&quot;: 5,
                &quot;type&quot;: &quot;LEISURE&quot;
            },
            {
                &quot;categoryId&quot;: 6,
                &quot;type&quot;: &quot;ETC&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;h2 data-ke-size=&quot;size26&quot;&gt;공통 응답 형식을 처리하는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 공통 응답 형식을 가지도록 개발하는 방법에는 여러가지가 있다. Controller 계층에서 직접 공통 응답 형식 객체를 생성하여 반환해도 되고, 스프링 AOP를 통해 특정 Controller의 반환값을 가지고 공통 응답 형식 객체를 생성하여 반환해도 된다. 추가로 찾아보니 Filter를 통해 처리하는 방법도 있지만 AOP나 Filter나 구현 방식은 비슷한 것 같아 이번 글에서는 AOP를 통해 이를 해결한 방법을 위주로 알아보려고 한다.&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;CommonResponse 공통 응답 클래스 정의&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 위에서 예시로 제시한 JSON 형태의 공통 응답 형식을 반환하기 위한 &lt;code&gt;CommonResponse&lt;/code&gt; 클래스는 다음과 같다. 어떤 타입의 &lt;code&gt;data&lt;/code&gt; 값이든 가질 수 있도록 제네릭을 사용하였으며, &lt;code&gt;timestamp&lt;/code&gt; 필드는 &lt;code&gt;CommonResponse&lt;/code&gt; 객체 생성 시 자동적으로 현재 시각으로 설정된다. 또한 &lt;code&gt;@JsonInclude(JsonInclude.Include.NON_NULL)&lt;/code&gt; 어노테이션을 통해 만약 &lt;code&gt;CommonResponse&lt;/code&gt; 클래스 내 필드 중 null인 값이 있다면 응답 JSON에서 제외되도록 설정하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1705206227545&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@RequiredArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CommonResponse&amp;lt;T&amp;gt; {

    private final String timestamp = ZonedDateTime.now(ZoneId.of(&quot;Asia/Seoul&quot;))
        .format(DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;));
    private final int status;
    private final String code;
    private final String message;
    private final T 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;예를 들어 &lt;code&gt;data&lt;/code&gt; 필드가 null이라면 응답 JSON은 &lt;code&gt;data&lt;/code&gt; 값이 제외된 다음과 같은 형태가 될 것이다. 이를 통해 응답 데이터가 없는 API 요청에 대해서도 응답 상태, 메시지, 타임 스탬프 등의 메타 데이터를 전달할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1705206496822&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-14 13:10:18&quot;,
    &quot;status&quot;: 200,
    &quot;code&quot;: &quot;OK&quot;,
    &quot;message&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Controller에서 직접 생성하여 반환&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 모든 Controller 내 메소드에서 직접 &lt;code&gt;CommonResponse&lt;/code&gt; 객체를 생성하여 반환할 수 있다. 한눈에 보기에도 중복이 많다. 물론 현재 상태에서 리팩토링을 통해 중복을 조금 제거할 순 있겠지만, 이외에도 여러 문제가 있다. 먼저 만약 구현한 API가 100개가 넘어가는 경우 각 Controller 메소드 마다 &lt;code&gt;CommonRespone&lt;/code&gt; 객체를 생성하여 반환하는 코드를 추가해줘야 하며, 반대로 만약 이러한 공통 응답 형식을 더 이상 사용하지 않게 되었을 때에는 100개를 또 다시 직접 수정해줘야 한다. 즉 변경에 따른 코드 변경 범위가 매우 넓어진다.&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에 대해 수행되는 부가 로직이다. 즉 핵심 로직이 아니다. 따라서 Controller에서 직접 처리해주는 것이 아닌 별도의 클래스, 다시 말해 한 곳에서 공통적으로 처리해주는 것이 좋다. (핵심 로직과 부가 로직을 분리하자!) 이러한 문제로 Controller에서 직접 &lt;code&gt;CommonResponse&lt;/code&gt;를 생성하여 반환하는 것은 권장되지 않으며 다음에 소개할 AOP를 통해 공통 응답 형식을 반환하는 것이 더 적절하다.&lt;/p&gt;
&lt;pre id=&quot;code_1705206586418&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@RequestMapping(&quot;/api/categories&quot;)
@RestController
public class CategoryController {

    private final CategoryService categoryService;

    @GetMapping
    public CommonResponse&amp;lt;CategoryListResponse&amp;gt; getCategoryList() {
        List&amp;lt;CategoryResponse&amp;gt; categoryList = categoryService.getCategoryList();
        reutrn new CommonReponse&amp;lt;&amp;gt;(HttpStatus.OK.value(), HttpStatus.OK.name(),
            &quot;요청이 정상 처리되었습니다.&quot;, categoryList); //공통 응답 형식 객체 생성
    }
    
    @GetMapping(&quot;/{categoryId}&quot;)
    public CommonResponse&amp;lt;CategoryListResponse&amp;gt; getCategoryList(@PathVariable Long categoryId) {
        CategoryResponse category = categoryService.getCategory(categoryId);
        reutrn new CommonReponse&amp;lt;&amp;gt;(HttpStatus.OK.value(), HttpStatus.OK.name(),
            &quot;요청이 정상 처리되었습니다.&quot;, category); //공통 응답 형식 객체 생성
    }
}&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;h3 data-ke-size=&quot;size23&quot;&gt;ResponseBodyAdvice를 정의하여 AOP 처리&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 &lt;code&gt;@Aspect&lt;/code&gt; 어노테이션을 통해 직접 어드바이저를 생성하고 빈 등록하여 사용해도 된다. 그러나 직접 구현해본 결과 공통 응답 형식을 write 하기 위해 현재 요청에 대한 &lt;code&gt;HttpServletResponse&lt;/code&gt; 객체를 어드바이저에서 주입 받기가 어려우며, 주입 받았다 하더라도 실행 순서?로 인해 &lt;code&gt;response&lt;/code&gt; 객체에 변환된 공통 응답을 write 했음에도 불구하고 실제 응답 데이터에는 적용되지 않았던 문제가 있었다. (정확히 디버깅 해보진 않았지만 &lt;code&gt;@Aspect&lt;/code&gt;로 직접 구현한 어드바이저에서 write한 응답값에 대해 &lt;code&gt;HttpMessageConveter&lt;/code&gt;가 다시 overwrite하지 않았을까 예상해본다.)&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;code&gt;ResponseBodyAdvice&lt;/code&gt; 라는 것을 알게 되었다. 해당 인터페이스를 구현한 클래스는 &lt;code&gt;@ResponseBody&lt;/code&gt; 어노테이션이 선언되었거나, &lt;code&gt;ResponseEntity&lt;/code&gt;를 반환하는 Controller 메소드 호출 후 &lt;code&gt;HttpMessageConverter&lt;/code&gt;가 &lt;code&gt;response&lt;/code&gt; 객체에 응답값을 쓰기 직전에 호출되며, 따라서 &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;ResponseBodyAdvice&lt;/code&gt;&lt;span&gt; 인터페이스 메소드를 구현함으로써 응답 바디 내용을 조작할 수 있도록 해준다. &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span&gt;해당 인터페이스에는 &lt;code&gt;supports&lt;/code&gt;, &lt;code&gt;beforeBodyWrite&lt;/code&gt; 메소드가 정의되어 있는데, &lt;code&gt;supports&lt;/code&gt;는 해당 Controller 메소드에 대해 &lt;code&gt;beforeBodyWrite&lt;/code&gt; 메소드의 호출 여부를 결정한다. 즉 &lt;code&gt;supports&lt;/code&gt; 메소드가 true를 반환하면 &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;beforeBodyWrite&lt;/code&gt; 메소드가 이어 호출된다. (즉 일종의 포인트컷) 그리고 &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;beforeBodyWrite&lt;/code&gt;&lt;span&gt; 메소드의 반환값이 &lt;code&gt;HttpMessageConverter&lt;/code&gt;에 의해 &lt;code&gt;response&lt;/code&gt; 객체의 응답 바디에 write 된다. (즉 일종의 어드바이스)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1705207936606&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface ResponseBodyAdvice&amp;lt;T&amp;gt; {

    boolean supports(MethodParameter returnType, Class&amp;lt;? extends HttpMessageConverter&amp;lt;?&amp;gt;&amp;gt; converterType);
    
    @Nullable
    T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
        Class&amp;lt;? extends HttpMessageConverter&amp;lt;?&amp;gt;&amp;gt; selectedConverterType,
        ServerHttpRequest request, ServerHttpResponse 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;정리하자면 공통 응답 형식을 적용하고자 하는 Controller 메소드에 대해서는 &lt;code&gt;supports&lt;/code&gt; 메소드가 true를 반환하도록 구현하고, &lt;code&gt;beforeBodyWrite&lt;/code&gt; 메소드에서는 &lt;code&gt;CommonReponse&lt;/code&gt; 객체를 생성하여 반환하면 우리가 원하는 대로 공통 응답 형식으로의 변환을 AOP를 통해 수행할 수 있는 것이다.&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;AOP로 공통 응답 형식 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요구사항 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항이 하나 더 생겼다. 공통의 응답 형식을 반환하는 것 뿐만 아니라 API에 따라 응답 상태나 코드, 메시지 등을 Controller 단에서 설정하면 이를 응답 데이터에도 반영하였으면 좋겠다. 예를 들어 POST 요청을 통해 새로운 리소스가 생성된 경우에는 200(OK) 상태 코드보다는 201(CREATED)가 더 적절하다. 이를 기존의 &lt;code&gt;@ResponseStatus&lt;/code&gt; 어노테이션 처럼 Controller 단에서 선언하면 상태 코드나 응답 바디에도 반영되었으면 좋겠다. 즉 &lt;code&gt;CommonResponse&lt;/code&gt;&amp;nbsp;객체의 &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt; 필드 등이 항상 200이나 &quot;OK&quot;로 고정된 것이 아닌 API 별로 지정할 수 있기를 원하는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1705208529045&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-14 13:10:18&quot;,
    &quot;status&quot;: 200,
    &quot;code&quot;: &quot;OK&quot;,
    &quot;message&quot;: &quot;요청이 정상 처리되었습니다.&quot;
    &quot;data&quot;: {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1705208551073&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-14 13:10:18&quot;,
    &quot;status&quot;: 201,
    &quot;code&quot;: &quot;CREATED&quot;,
    &quot;message&quot;: &quot;새로운 리소스가 생성되었습니다.&quot;
    &quot;data&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;code&gt;CommonReponseContent&lt;/code&gt;라는 커스텀 어노테이션을 정의할 것이다. 이 어노테이션은 Controller 클래스나 메소드 레벨에서 선언할 수 있으며, 해당 어노테이션이 선언된 경우 어노테이션의 설정값을 참고해서 Controller 메소드의 반환값이 공통 응답 형식으로 변환될 것이다. 이제 하나씩 구현해보자.&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;h3 data-ke-size=&quot;size23&quot;&gt;CommonResponseContent 어노테이션 정의&lt;/h3&gt;
&lt;pre id=&quot;code_1705208864252&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CommonResponseContent {

    HttpStatus status() default HttpStatus.OK;

    String message() default &quot;요청이 정상 처리되었습니다.&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CommonReponseContent&lt;/code&gt; 어노테이션은 클래스와 메소드 레벨에서 선언할 수 있다. 따라서 &lt;code&gt;@Target&lt;/code&gt; 값을 &lt;code&gt;TYPE&lt;/code&gt;, &lt;code&gt;METHOD&lt;/code&gt;로 설정해주었으며, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt; 필드를 가져 API 별로 상태 코드와 메시지를 커스텀하게 설정할 수 있도록 하였다. 각 필드의 기본값은 &lt;code&gt;HttpStatus.OK&lt;/code&gt;와 &lt;code&gt;&quot;요청이 정상 처리되었습니다.&quot;&lt;/code&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;h3 data-ke-size=&quot;size23&quot;&gt;CommonResponseAdvice 클래스 정의&lt;/h3&gt;
&lt;pre id=&quot;code_1705208967530&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestControllerAdvice
public class CommonResponseAdvice implements ResponseBodyAdvice&amp;lt;Object&amp;gt; {

    //HttpMessageConverter가 응답값을 쓰기 직전에 호출
    @Override
    public boolean supports(MethodParameter returnType,
        Class&amp;lt;? extends HttpMessageConverter&amp;lt;?&amp;gt;&amp;gt; converterType) {
        CommonResponseContent methodContent = returnType.getExecutable() //현재 호출된 메소드의 메타 데이터 반환
            .getDeclaredAnnotation(CommonResponseContent.class);
        CommonResponseContent classContent = returnType.getDeclaringClass() //현재 호출된 클래스의 메타 데이터 반환
            .getDeclaredAnnotation(CommonResponseContent.class);
        return methodContent != null || classContent != null;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
        Class&amp;lt;? extends HttpMessageConverter&amp;lt;?&amp;gt;&amp;gt; selectedConverterType,
        ServerHttpRequest request, ServerHttpResponse response) {
        CommonResponseContent methodContent = returnType.getExecutable()
            .getDeclaredAnnotation(CommonResponseContent.class);
        CommonResponseContent classContent = returnType.getDeclaringClass()
            .getDeclaredAnnotation(CommonResponseContent.class);
        CommonResponseContent content = methodContent == null ? classContent : methodContent;

        response.setStatusCode(content.status());
        return new CommonResponse&amp;lt;&amp;gt;(content.status().value(), content.status().name(),
            content.message(), body);
    }
}&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;code&gt;ResponseBodyAdvice&lt;/code&gt; 인터페이스를 구현한 &lt;code&gt;CommonResponseAdvice&lt;/code&gt; 클래스에 &lt;code&gt;@RestControllerAdvice&lt;/code&gt; 어노테이션을 선언해주었다. &lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-darkreader-inline-bgcolor=&quot;&quot; data-darkreader-inline-color=&quot;&quot;&gt;해당 어노테이션을 사용하면 스프링 컨테이너가 관리하는 모든 Controller의 메소드에 대해 동작하는 어드바이스를 빈으로 등록해준다. &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;(내부에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;code&gt;@Component&lt;/code&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;어노테이션 포함) 한 프로젝트에서 &lt;/span&gt;ControllerAdvice 클래스는 여러 개 만들수 있지만 많을 경우 성능에 악영향을 끼칠 수 있다고 한다.&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;background-color: #ffffff; color: #000000; text-align: left;&quot; data-darkreader-inline-bgcolor=&quot;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;supports&lt;/code&gt; 메소드에서는 &lt;code&gt;returnType.getExecutable()&lt;/code&gt;, &lt;code&gt;returnType.getDeclaringClass()&lt;/code&gt; 메소드를 통해 현재 요청을 처리하고 있는 Controller에서 클래스 레벨 또는 메소드 레벨로 &lt;code&gt;CommonResponseContent&lt;/code&gt; 어노테이션이 선언되어 있는지 확인한다. 만약 하나라도 선언되어 있다면 현재 Controller 메소드의 반환값을 &lt;code&gt;CommonReponse&lt;/code&gt; 객체로 변환해야 하므로 true를 리턴한다.&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;code&gt;beforeBodyWrite&lt;/code&gt; 메소드에서는 선언된 &lt;code&gt;CommonResponseContent&lt;/code&gt; 어노테이션 정보를 가지고 &lt;code&gt;response.setStatusCode(content.status())&lt;/code&gt;로 응답 상태 코드를 지정하며, 현재 Controller 메소드의 반환값인 &lt;code&gt;body&lt;/code&gt;를 &lt;code&gt;CommonResponse&lt;/code&gt; 객체로 변환하여 리턴하고 있다. 참고로 메소드 레벨에 선언한 &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;CommonResponseContent&lt;/code&gt;&lt;span&gt; 어노테이션이, 클래스 레벨에 선언한 &lt;span style=&quot;color: #333333; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;code&gt;CommonResponseContent&lt;/code&gt;&lt;span&gt; 어노테이션 설정보다 우선한다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;공통 응답 형식이 적용된 Controller&lt;/h3&gt;
&lt;pre id=&quot;code_1705209877030&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@CommonResponseContent //추가
@RequiredArgsConstructor
@RequestMapping(&quot;/api/users&quot;)
@RestController
public class UserController {

    private final UserService userService;

    @GetMapping(&quot;/{username}&quot;)
    public UsernameCheckResponse checkForUsername(@PathVariable String username) {
        return userService.checkForUsername(username);
    }

    @CommonResponseContent(status = HttpStatus.CREATED) //추가
    @PostMapping
    public void joinUser(@RequestBody @Valid UserJoinRequest request) {
        userService.joinUser(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Controller에는 위와 같이 &lt;code&gt;@CommonResponseContent&lt;/code&gt; 어노테이션만 추가해주면 된다. 클래스 레벨에 선언하면 &lt;code&gt;UserController&lt;/code&gt; 내 모든 메소드에 대해 적용되며, &lt;code&gt;joinUser&lt;/code&gt; 메소드와 같이 메소드 레벨에 선언한 경우에는 해당 설정이 우선한다. 즉 유저 회원가입 API의 상태코드는 기본값인 200(OK)이 아니라 201(CREATED)이 될 것이다.&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;checkForUsername 메소드 응답 데이터&lt;/h4&gt;
&lt;pre id=&quot;code_1705211044136&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-14 14:43:20&quot;,
    &quot;status&quot;: 200,
    &quot;code&quot;: &quot;OK&quot;,
    &quot;message&quot;: &quot;요청이 정상 처리되었습니다.&quot;,
    &quot;data&quot;: {
        &quot;isDuplicatedUsername&quot;: true
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-darkreader-inline-color=&quot;&quot;&gt;joinUser 메소드 응답 데이터&lt;/h4&gt;
&lt;pre id=&quot;code_1705211054086&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;timestamp&quot;: &quot;2024-01-14 14:43:52&quot;,
    &quot;status&quot;: 201,
    &quot;code&quot;: &quot;CREATED&quot;,
    &quot;message&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;&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;pre id=&quot;code_1705210104590&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler
    public ResponseEntity&amp;lt;CommonResponse&amp;lt;Void&amp;gt;&amp;gt; handleBusinessException(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();
        return ResponseEntity.status(errorCode.getHttpStatus())
            .body(new CommonResponse&amp;lt;&amp;gt;(errorCode.getHttpStatus().value(), errorCode.name(),
                errorCode.getMessage(), null));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 &lt;code&gt;@RestControllerAdvice&lt;/code&gt;, &lt;code&gt;@ExceptionHandler&lt;/code&gt;를 통해 예외 처리 어드바이스를 정의하고 공통 응답 형식인 &lt;code&gt;CommonResponse&lt;/code&gt;를 반환하도록 하여, 예외 상황에서의 응답 형식과 정상 상황에서의 응답 형식이 통일될 수 있다. 이렇게 되면 프론트 쪽에서 데이터를 받아오는 것이 훨씬 수월하지 않을까 생각한다. (참고로 &lt;code&gt;BusinessException&lt;/code&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;그러나 현재 마음에 걸리는 것은 추가적인 ControllerAdvice로 프로젝트 성능이 떨어질 수 있다는 것과, &lt;code&gt;CommonResponse&lt;/code&gt; 객체에 포함된 필드들이 정말 필요한 데이터일까? 즉 상태 코드의 경우 중복되는 데이터가 아닌가? 하는 의문이 든다는 것이다. 특히 사용자 예외 처리(Exception Handling)가 아닌 공통 응답(Common Response)은 필수적으로 구현해야 하는 부분이 아니기 때문에 프론트에서 특별히 요구하지 않는다면 앞서 설명한 이유들로 굳이 도입하진 않을 것 같다. 그럼에도 불구하고 Controller 메소드 별 공통적인 부가 로직을 처리할 수 있는 &lt;code&gt;ResponseBodyAdvice&lt;/code&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;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@qotndus43/%EC%8A%A4%ED%94%84%EB%A7%81-API-%EA%B3%B5%ED%86%B5-%EC%9D%91%EB%8B%B5-%ED%8F%AC%EB%A7%B7-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@qotndus43/%EC%8A%A4%ED%94%84%EB%A7%81-API-%EA%B3%B5%ED%86%B5-%EC%9D%91%EB%8B%B5-%ED%8F%AC%EB%A7%B7-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://devlog-wjdrbs96.tistory.com/182&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devlog-wjdrbs96.tistory.com/182&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.naver.com/chgy2131/222890294173&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.naver.com/chgy2131/222890294173&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyAdvice.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyAdvice.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring/Spring</category>
      <category>spring aop</category>
      <category>spring boot</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/43</guid>
      <comments>https://daeun21dev.tistory.com/43#entry43comment</comments>
      <pubDate>Sun, 14 Jan 2024 15:48:15 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] 부모 엔티티 삭제 시 FK 제약 조건이 걸린 자식 엔티티 처리 방법</title>
      <link>https://daeun21dev.tistory.com/41</link>
      <description>&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;부모 엔티티 삭제 시 자식 엔티티를 어떻게 처리할지 따로 지정해주지 않으면, 다음과 같이 DB 상에서 FK 제약 조건을 위반했다는 오류를 뱉으면서 요청 처리에 실패하게 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1031&quot; data-origin-height=&quot;30&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKDGdn/btsC4vITdRZ/8ttl6J0QBbpKQEROl7HaC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKDGdn/btsC4vITdRZ/8ttl6J0QBbpKQEROl7HaC1/img.png&quot; data-alt=&quot;SQL 제약조건 위반 예외&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKDGdn/btsC4vITdRZ/8ttl6J0QBbpKQEROl7HaC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKDGdn%2FbtsC4vITdRZ%2F8ttl6J0QBbpKQEROl7HaC1%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;1031&quot; height=&quot;30&quot; data-origin-width=&quot;1031&quot; data-origin-height=&quot;30&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SQL 제약조건 위반 예외&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;부모 엔티티를 삭제하는 경우 자식 엔티티를 어떻게 처리할지에 대해서는 구현하고자 하는 어플케이션의 비즈니스 로직에 따라 다를 것이다. 예를 들어 게시글을 삭제하는 경우 해당 글에 포함된 댓글 역시 모두 삭제해야 하는 반면, 어느 회사 내의 팀이 해체된다면 즉 팀이 삭제되는 경우에는 해당 팀에 속한 사원들의 정보는 삭제하지 않고 해당 사원의 소속팀 값을 NULL 또는 &quot;무소속&quot; 등과 같은 기본값으로 수정해주어야 한다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;Spring Data JPA는 이렇게 부모 엔티티 삭제 시 자식 엔티티는 어떻게 처리할지에 대해 여러 옵션을 제공한다. 그럼 부모 엔티티 삭제 시 자식 엔티티 또한 삭제하는 경우, 자식 엔티티의 값을 수정하는 경우 크게 2가지로 나누어 살펴보자.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;자식 엔티티도 함께 삭제&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;자식 엔티티 삭제 설명에서 사용할 예제는 다음과 같다. 게시글과 댓글은 1:N 매핑으로 두 엔티티는 양방향 연관관계를 맺고 있다. 그럼 하나의 게시글을 삭제하는 경우 어떻게 하면 효율적으로 연관관계에 있는 댓글들을 모두 삭제할 수 있는지 알아보자.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;post_id&quot;)
    private Long id;

    private String content;

    @Builder.Default
    @OneToMany(mappedBy = &quot;post&quot;)
    private List&amp;lt;Comment&amp;gt; comments = new ArrayList&amp;lt;&amp;gt;();

    //연관관계 편의 메소드
    public void addComments(List&amp;lt;Comment&amp;gt; comments) {
        this.comments = comments;
        for (Comment comment : comments) {
            comment.updatePost(this);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Comment {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;comment_id&quot;)
    private Long id;

    private String content;

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

    public void updatePost(Post post) {
        this.post = post;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704516664483&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 style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;cascade = CascadeType.REMOVE 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1704516761705&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;post_id&quot;)
    private Long id;

    private String content;

    @Builder.Default
    @OneToMany(mappedBy = &quot;post&quot;, cascade = CascadeType.REMOVE) //추가
    private List&amp;lt;Comment&amp;gt; comments = new ArrayList&amp;lt;&amp;gt;();

    //연관관계 편의 메소드
    public void addComments(List&amp;lt;Comment&amp;gt; comments) {
        for (Comment comment : comments) {
            this.comments.add(comment);
            comment.updatePost(this);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;cascade&lt;/code&gt; 속성은 특정 엔티티를 특정한 생명주기(PERSIST, REMOVE, MERGE 등)로 변환할 때 연관된 엔티티도 동일한 생명주기로 만들어주는 기능을 한다. 즉 엔티티의 생명주기를 자신이 관리하는 것이 아닌 다른 엔티티에 의해 관리당하게 되는 것이다. 따라서 엔티티 간 생명 주기가 서로 유사하면서(함께 저장되는 경우, 함께 삭제되는 경우 등), 생명주기를 관리당하는 엔티티의 소유자가 하나인 경우에만(위 예제에서 &lt;code&gt;Comment&lt;/code&gt; 엔티티의 소유자는 &lt;code&gt;Post&lt;/code&gt; 엔티티 하나뿐) &lt;code&gt;cascade&lt;/code&gt; 속성을 사용하는 것이 좋다. 참고로 엔티티의 생명주기는 다음과 같다. (덧붙이자면 영속성 전이 &lt;code&gt;cascade&lt;/code&gt;는 연관관계 매핑과는 아무런 관련이 없으며 단순히 두 엔티티의 생명 주기를 보다 편리하게 관리하기 위해 제공되는 것일 뿐이다.)&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2100&quot; data-origin-height=&quot;1500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcZx03/btsC4p26R0F/3ueLUhqsgkGnh5F25qi7k1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcZx03/btsC4p26R0F/3ueLUhqsgkGnh5F25qi7k1/img.png&quot; data-alt=&quot;엔티티의 라이프 사이클&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcZx03/btsC4p26R0F/3ueLUhqsgkGnh5F25qi7k1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcZx03%2FbtsC4p26R0F%2F3ueLUhqsgkGnh5F25qi7k1%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;572&quot; height=&quot;409&quot; data-origin-width=&quot;2100&quot; data-origin-height=&quot;1500&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;엔티티의 라이프 사이클&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;CascadeType.REMOVE&lt;/code&gt;는 부모 엔티티 삭제 시 연관관계에 있는 자식 엔티티도 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;b&gt;JPA 상에서&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;함께 삭제하는 설정이다. (즉 영속성 컨텍스트에서 부모 엔티티가 REMOVE되면 관련 자식 엔티티들도 REMOVE) 다음과 같이 &lt;code&gt;Post&lt;/code&gt; 객체 삭제 시 해당 &lt;code&gt;Post&lt;/code&gt;와 관련된 모든 &lt;code&gt;Comment&lt;/code&gt;에 대한 delete 쿼리가 나가고 이후 &lt;code&gt;Post&lt;/code&gt;에 대한 delete 쿼리가 발생한다.&lt;/p&gt;
&lt;pre id=&quot;code_1704527157549&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Rollback(value = false) //테스트 시 트랜잭션 커밋을 위해 Rollback 하지 않음
@DataJpaTest
class SafeWalletApplicationTests {

    @Autowired
    PostRepository postRepository;

    @Autowired
    CommentRepository commentRepository;

    @Autowired
    EntityManager em;

    Post post;

    @BeforeEach
    void init() {
        post = Post.builder().content(&quot;&quot;).build();
        Comment comment1 = Comment.builder().content(&quot;&quot;).build();
        Comment comment2 = Comment.builder().content(&quot;&quot;).build();
        Comment comment3 = Comment.builder().content(&quot;&quot;).build();
        List&amp;lt;Comment&amp;gt; comments = List.of(comment1, comment2, comment3);
        post.addComments(comments); //연관관계 편의 메소드 호출
        
        postRepository.save(post);
        commentRepository.saveAll(comments);

        em.flush();
        em.clear();
    }

    @Test
    void cascadeType_remove_test() {
        //given
        Post findPost = postRepository.findById(post.getId())
			.orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 게시물 없음&quot;));

        //when
        postRepository.delete(findPost); //Post, 관련 Comment 모두 REMOVE

        //then
        assertThat(em.contains(findPost)).isFalse(); //영속성 컨텍스트에서 관리되지 않음
        assertThat(em.contains(findPost.getComments().get(0))).isFalse();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;이 경우 &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;comment1&lt;/code&gt;, &lt;code&gt;comment2&lt;/code&gt;, &lt;code&gt;comment3&lt;/code&gt; 객체가 더이상 영속성 컨텍스트에서 관리되지 않는 Removed 상태가 되며 트랜잭션 커밋 시에는 delete 쿼리가 각각 발생하게 된다. 또한 관련 &lt;code&gt;Comment&lt;/code&gt; 엔티티를 Removed 상태로 전이하기 위해서는 일단 해당 객체들이 영속성 컨텍스트에서 관리되어야 하기 때문에 delete 쿼리 전 comment 테이블에 대한 select 쿼리가 발생한 것을 확인할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1704528017796&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- cascadeType_remove_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
select * from comment c where c.post_id=1;

delete from comment where comment_id=1;
delete from comment where comment_id=2;
delete from comment where comment_id=3;
delete from post where post_id=1;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;orphanRemoval = true 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1704528082218&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;post_id&quot;)
    private Long id;

    private String content;

    @Builder.Default
    @OneToMany(mappedBy = &quot;post&quot;, cascade = CascadeType.PERSIST, orphanRemoval = true) //추가
    private List&amp;lt;Comment&amp;gt; comments = new ArrayList&amp;lt;&amp;gt;();

    //연관관계 편의 메소드
    public void addComments(List&amp;lt;Comment&amp;gt; comments) {
        for (Comment comment : comments) {
            this.comments.add(comment);
            comment.updatePost(this);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;orphanRemoval&lt;/code&gt;는 부모 엔티티와 연관관계가 끊긴 자식 엔티티를 삭제하는 설정이다. 즉 부모 엔티티는 삭제되지 않더라도 다음과 같이 부모 엔티티와의 연관관계가 끊긴 자식 엔티티(고아 객체)에 대해서는 delete 쿼리가 발생한다. 당연히 개념적으로 부모 엔티티가 제거되면 자식 엔티티들은 고아 객체가 되므로, 부모 삭제 시 자식도 함께 삭제되는 &lt;code&gt;CascadeType.REMOVE&lt;/code&gt; 옵션과 비슷하게 동작할 수 있다. (둘의 차이점은 &lt;code&gt;orphanRemoval&lt;/code&gt;는 부모 엔티티가 삭제되지 않더라도 자식 엔티티를 삭제할 수 있다는 것이다.)&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704528188163&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void orphanRemoval_test() {
    //given
    Post findPost = postRepository.findById(post.getId())
        .orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 게시물 없음&quot;));

    //when
    Comment removeComment = findPost.getComments().remove(0);//Post 관련 첫번째 Comment delete

    //then
    assertThat(em.contains(findPost)).isTrue();
    assertThat(em.contains(removeComment)).isTrue(); //여전히 영속성 컨텍스트에서 관리됨
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;post&lt;/code&gt; 객체와 관련된 &lt;code&gt;comments&lt;/code&gt; 필드를 조회한 후 첫번째 &lt;code&gt;Comment&lt;/code&gt; 객체를 remove하게 되면 &lt;code&gt;removeComment&lt;/code&gt;은 부모가 없는 고아 객체가 된다. 따라서 트랜잭션 커밋 시 comment 테이블에 delete 쿼리가 발생한다. 참고로 &lt;code&gt;removeComment&lt;/code&gt; 객체가 영속성 컨텍스트에서 REMOVE된 것은 아니므로 여전히 영속성 컨텍스트에서 관리된다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704528259356&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- orphanRemoval_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
select * from comment c where c.post_id=1;
delete from comment where comment_id=1;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;또한 &lt;code&gt;post&lt;/code&gt; 객체를 직접 delete하는 경우에는 &lt;code&gt;orphanRemoval = true&lt;/code&gt; 설정이 &lt;code&gt;cascade = CascadeType.REMOVE&lt;/code&gt; 처럼 동작한다. 아래 코드에서 삭제되는 &lt;code&gt;findPost&lt;/code&gt;와 관련된 &lt;code&gt;Comment&lt;/code&gt; 객체 또한 영속성 컨텍스트에서 더이상 관리되지 않고 Removed 상태로 전이된 것을 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1704557793829&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void orphanRemoval_test2() {
    //given
    Post findPost = postRepository.findById(post.getId())
        .orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 게시물 없음&quot;));

    //when
    postRepository.delete(findPost); //CascadeType.REMOVE 처럼 동작

    //then
    assertThat(em.contains(findPost)).isFalse();
    assertThat(em.contains(findPost.getComments().get(0))).isFalse();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;참고로 &lt;code&gt;orphanRemoval = true&lt;/code&gt; 설정은 &lt;code&gt;cascade = CascadeType.PERSIST&lt;/code&gt; 설정이 존재해야만 &lt;code&gt;post.getComments().remove(0)&lt;/code&gt; 호출 시 comment 테이블에 delete 쿼리가 날라간다. &lt;code&gt;PERSIST&lt;/code&gt; 옵션이 존재하지 않으면 아무런 쿼리가 발생하지 않는다. 누군가 &lt;a href=&quot;https://github.com/spring-projects/spring-data-jpa/issues/2145&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;스프링 프로젝트 레포&lt;/a&gt;에 이슈를 남겨주셨는데 제대로 된 답을 얻지 못했다. 왜 &lt;code&gt;PERSIST&lt;/code&gt; 옵션이 있어야만 &lt;code&gt;orphanRemoval = true&lt;/code&gt; 설정이 제대로 동작하는지 아시는 분은 댓글로 남겨주시라...&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;일반적으로 1:N 매핑 관계에서 &lt;code&gt;cascade = CascadeType.REMOVE&lt;/code&gt;, &lt;code&gt;orphanRemoval = true&lt;/code&gt; 설정을 사용하기 위해서는 두 엔티티 간 양방향 연관관계를 가질 수 밖에 없다. 어차피 연관관계 주인은 N 쪽의 엔티티이므로 1 쪽의 엔티티가 N 쪽의 엔티티를 의존하고 있을 필요가 없음에도 불구하고, &lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot;&gt;단지 연쇄적으로 삭제하기 위해서 새로운 의존 관계가 추가되는 것이다. &lt;/span&gt;이러한 양방향 연관관계에서는 순수 객체 상태를 고려해서 연관관계 편의 메소드를 통해 항상 양쪽에 값을 설정해주어야 하며, 자칫 하면 toString 또는 JSON 생성 라이브러리로 인해 서로를 계속 참조하여 무한 루프에 빠질 수 있다. 따라서 정말 필요한 경우가 아니고서는 양방향 매핑은 지양된다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;또한 무엇보다 위의 두 설정 모두 JPA 단에서 처리되기 때문에 삭제되는 엔티티 개수만큼 delete 쿼리가 각각 발생한다는 큰 문제점이 있다. 즉 삭제에서의 N + 1 문제가 발생하게 된다. (&lt;code&gt;Post&lt;/code&gt; 엔티티 1개 삭제로 인해 관련 있는 N개의 &lt;code&gt;Comment&lt;/code&gt; 엔티티 삭제) 또한 JPA에서 연관된 자식 엔티티에 대한 삭제를 위해 영속성 컨텍스트에 자식 엔티티들을 영속화해야 하기 때문에 자식 테이블에 대한 불필요한 조회 쿼리가 추가로 발생할 수 있다. (생각해보면 select할 필요 없이 DB에 직접 delete 쿼리를 날리면 된다.)&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;@OnDelete(action = OnDeleteAction.CASCADE) 설정&amp;nbsp;&lt;/h3&gt;
&lt;pre id=&quot;code_1704528499747&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;post_id&quot;)
    private Long id;

    private String content;

    @Builder.Default
    @OneToMany(mappedBy = &quot;post&quot;) //cascade, orphanRemoval 속성 제거
    private List&amp;lt;Comment&amp;gt; comments = new ArrayList&amp;lt;&amp;gt;();

    //연관관계 편의 메소드
    public void addComments(List&amp;lt;Comment&amp;gt; comments) {
        for (Comment comment : comments) {
            this.comments.add(comment);
            comment.updatePost(this);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704465650384&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Comment {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;comment_id&quot;)
    private Long id;

    private String content;

    @OnDelete(action = OnDeleteAction.CASCADE) //추가
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;post_id&quot;)
    private Post post;

    public void updatePost(Post post) {
        this.post = post;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 설정은 연관관계 주인 엔티티인 &lt;code&gt;Comment&lt;/code&gt; 엔티티 내의 &lt;code&gt;Post&lt;/code&gt;에 대한 참조에 선언하는 것으로, post 테이블에서 특정 행 삭제 시 &lt;b&gt;DB 상에서&lt;/b&gt; FK 제약조건이 걸린 관련 comment 테이블 행들을 자동으로 삭제해준다. 즉 &lt;code&gt;@OnDelete(action = OnDeleteAction.CASCADE)&lt;/code&gt; 어노테이션은 comment 테이블에 다음과 같은 DDL을 추가해준다. 또한 앞서 보았던 &lt;code&gt;cascade&lt;/code&gt;, &lt;code&gt;orphanRemoval&lt;/code&gt; 속성과 다른 점은 &lt;code&gt;cascade&lt;/code&gt;, &lt;code&gt;orphanRemoval&lt;/code&gt;는 관련 자식 엔티티에 대한 삭제 처리 주체가 JPA인 반면 &lt;code&gt;@OnDelete&lt;/code&gt; 어노테이션 설정에서는 처리 주체가 DB이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;create table comment (
    comment_id bigint not null auto_increment,
    content varchar(255),
    post_id bigint,
    primary key (comment_id)
) engine=InnoDB

create table post (
    post_id bigint not null auto_increment,
    content varchar(255),
    primary key (post_id)
) engine=InnoDB

alter table comment 
   add constraint FKs1slvnkuemjsq2kj4h3vhx7i1 
   foreign key (post_id) 
   references post (post_id) 
   on delete cascade -- 추가&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;실제 DB 내부 구현은 어떻게 동작하는지 알지 못하지만, 이렇게 하면 특정 &lt;code&gt;Post&lt;/code&gt; 엔티티에 대한 delete 쿼리 1개만으로 제약조건 위반 없이 관련 자식 엔티티를 모두 삭제할 수 있다. (DB Connection으로 많은 delete 쿼리가 전달되지는 않는다는 장점?) 그러나 DB Vendor에 따라 &lt;code&gt;on delete cascade&lt;/code&gt; DDL을 지원하지 않을 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1704559089832&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void onDelete_test() {
    //given
    Post findPost = postRepository.findById(post.getId())
        .orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 게시물 없음&quot;));

    //when
    postRepository.delete(findPost); //on delete cascade

    //then
    assertThat(em.contains(findPost)).isFalse();
    assertThat(em.contains(findPost.getComments().get(0))).isTrue();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704528540645&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- onDelete_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
select * from comment c where c.post_id=1;
delete from post where post_id=1; -- delete 쿼리 하나 발생&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;직접 delete JPQL 작성&lt;/h3&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;현재로서는 가장 좋은 방법이라고 생각한다. 물론 &lt;code&gt;Post&lt;/code&gt; 엔티티를 삭제하기 전 연관관계 엔티티들에는 어떤 것들이 있는지 개발자가 직접 판단해야 하며, 만약 잊어버리고 연관된 엔티티들에 대한 삭제 쿼리를 먼저 호출해주지 않는 경우 FK 제약조건 위반 예외를 뱉을 수 있다. 또한 만약 삭제하고자 하는 엔티티가 많은 자식 엔티티들과 관계를 맺고 있다면 일일이 모든 자식 테이블에 대해 delete 쿼리를 날려줘야 할 수도 있다. 참고로 이 경우 연관관계에 있는 comment 테이블 삭제 쿼리 +&amp;nbsp; post 테이블 삭제 쿼리 총 2개의 delete 쿼리가 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class CommentRepository {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Modifying
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Query(&quot;delete from Comment c where c.post.id = :postId&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;void deleteByPost(@Param(&quot;postId&quot;) Long postId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704466028450&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void jpql_test() {
    Post findPost = postRepository.findById(post.getId())
        .orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 게시물 없음&quot;));

    commentRepository.deleteByPost(findPost.getId()); //연관된 Comment 먼저 삭제
    postRepository.delete(findPost); //이후 Post 삭제
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704559844324&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- jpql_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
delete from comment where post_id=1;
delete from post where post_id=1;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;자식 엔티티의 FK 필드를 NULL 또는 default 값으로 수정&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;위와 같이 특정 부모 엔티티를 삭제하는 경우 관련된 자식 엔티티 또한 삭제해야 하는 경우도 있지만, 자식 엔티티 내 부모 엔티티에 대한 참조값을 NULL 또는 기본값으로 설정해야 하는 경우가 있다. 예를 들어 부모 엔티티인 &lt;code&gt;Department&lt;/code&gt; 즉 부서 엔티티가 삭제된다고 해서 해당 부서에 소속된 사원 엔티티를 삭제해서는 안된다. (부서가 사라진다고 소속 사원을 전부 해고할 순 없다?) 이 경우엔 사원 엔티티 내에서 참조하고 있는 부서 엔티티 필드값을 NULL 또는 기본값으로 수정해야 한다. 이를 구현하기 위해서는 3가지 방법이 존재하는데 각각에 대해 자세히 알아보자. 현재 케이스에서 사용할 1:N 단방향 매핑의 &lt;code&gt;Department&lt;/code&gt;, &lt;code&gt;Employee&lt;/code&gt; 엔티티 코드는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Department {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;department_id&quot;)
    private Long id;

    private String name;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Employee {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;employee_id&quot;)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;department_id&quot;)
    private Department department;

    private String name;

    private Integer age;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface DepartmentRepository extends JpaRepository&amp;lt;Department, Long&amp;gt; {

    Optional&amp;lt;Department&amp;gt; findByName(String name);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704589561906&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface EmployeeRepository extends JpaRepository&amp;lt;Employee, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;@OnDelete(action = OnDeleteAction.SET_NULL) 또는 @OnDelete(action = OnDeleteAction.SET_DEFAULT) 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1704466946606&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Employee {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;employee_id&quot;)
    private Long id;

    @OnDelete(action = OnDeleteAction.SET_NULL) //추가
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;department_id&quot;)
    private Department department;

    private String name;

    private Integer age;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704600263646&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;create table department (
    department_id bigint not null auto_increment,
    name varchar(255),
    primary key (department_id)
) engine=InnoDB

create table employee (
    employee_id bigint not null auto_increment,
    age integer,
    name varchar(255),
    department_id bigint,
    primary key (employee_id)
) engine=InnoDB

alter table employee 
   add constraint FKbejtwvg9bxus2mffsm3swj3u9 
   foreign key (department_id) 
   references department (department_id) 
   on delete set null -- 추가&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;1:N 관계인 &lt;code&gt;Department&lt;/code&gt;:&lt;code&gt;Employee&lt;/code&gt;에서 연관관계 주인인 &lt;code&gt;Employee&lt;/code&gt; 엔티티 내 &lt;code&gt;Department&lt;/code&gt; 참조에 해당 어노테이션을 선언할 수 있으며 이 경우 DDL에 &lt;code&gt;on delete set null&lt;/code&gt;이 추가된다. 위에서 설명한 것 같이 &lt;code&gt;@OnDelete&lt;/code&gt; 옵션은 DB 단에서 처리되는 것으로 DB Connection을 통해서는 &lt;span style=&quot;color: #333333;&quot;&gt;department 테이블에 대한 delete 쿼리 1개만 발생하며 DB 내부적으로 관련 있는 employee 테이블 행의 FK 값을 NULL로 update한다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704590629530&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Rollback(value = false) //테스트 시 트랜잭션 커밋을 위해 Rollback 하지 않음
@DataJpaTest
class SafeWalletApplicationTests {

    @Autowired
    DepartmentRepository departmentRepository;

    @Autowired
    EmployeeRepository employeeRepository;

    @Autowired
    EntityManager em;

    @BeforeEach
    void init() {
        Department department = Department.builder().name(&quot;QA팀&quot;).build();
        Employee employee1 = Employee.builder().name(&quot;Alice&quot;).age(21).department(department).build();
        Employee employee2 = Employee.builder().name(&quot;Dana&quot;).age(26).department(department).build();
        Employee employee3 = Employee.builder().name(&quot;Tom&quot;).age(37).department(department).build();
        List&amp;lt;Employee&amp;gt; employees = List.of(employee1, employee2, employee3);

        departmentRepository.save(department);
        employeeRepository.saveAll(employees);

        em.flush();
        em.clear();
    }

    @Test
    void onDelete_set_null_test() {
        Department department = departmentRepository.findByName(&quot;QA팀&quot;)
			.orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 부서 없음&quot;));
        departmentRepository.delete(department); //관련 Employee의 소속부서 값 NULL로 update
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704590745160&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- onDelete_set_null_test 메소드 내에서 발생한 쿼리
select * from department d where d.name='QA팀';
delete from department where department_id=1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;277&quot; data-origin-height=&quot;91&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVmwHe/btsC4wgTaXi/LDUASMH78FbAJI6zNqHpV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVmwHe/btsC4wgTaXi/LDUASMH78FbAJI6zNqHpV1/img.png&quot; data-alt=&quot;관련 employee의 department_id 값 NULL&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVmwHe/btsC4wgTaXi/LDUASMH78FbAJI6zNqHpV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVmwHe%2FbtsC4wgTaXi%2FLDUASMH78FbAJI6zNqHpV1%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;277&quot; height=&quot;91&quot; data-origin-width=&quot;277&quot; data-origin-height=&quot;91&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;관련 employee의 department_id 값 NULL&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;NULL이 아닌 기본값으로 초기화하고자 하는 경우에는 &lt;code&gt;@OnDelete(action = OnDeleteAction.SET_DEFAULT)&lt;/code&gt; 설정을 추가해주면 되는데, 이 경우 DDL에 기본값을 정의하기 위해 &lt;code&gt;@ColumnDefault&lt;/code&gt; 어노테이션으로 선언해주면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;자식 엔티티 리스트를 순회하며 직접 set&lt;/h3&gt;
&lt;pre id=&quot;code_1704593101150&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Department {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;department_id&quot;)
    private Long id;

    private String name;

    //양방향 매핑 추가
    @OneToMany(mappedBy = &quot;department&quot;)
    private List&amp;lt;Employee&amp;gt; employees;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704593128963&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@NoArgsConstructor(access =  AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Employee {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;employee_id&quot;)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;department_id&quot;)
    private Department department;

    private String name;

    private Integer age;

    //department에 대한 setter 추가
    public void updateDepartment(Department department) {
        this.department = department;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704593173989&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void setter_test() {
    Department department = departmentRepository.findByName(&quot;QA팀&quot;)
        .orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 부서 없음&quot;));
    List&amp;lt;Employee&amp;gt; employees = department.getEmployees();
    for (Employee employee : employees) { //관련 Employee 영속화
        employee.updateDepartment(null); //트랜잭션 커밋 시 Dirty Checking
    }
    departmentRepository.delete(department);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이 경우에는 자식 엔티티 리스트를 DB에서 모두 조회한 후(영속성 컨텍스트에 영속화한 후) 각 리스트 요소를 순회하며 직접 setter를 호출해주어야 한다. 그리고 실제 날라간 쿼리를 확인해보면 수정된 각 &lt;code&gt;Employee&lt;/code&gt; 엔티티마다 update 쿼리가 발생한다. 즉 삭제, 수정에 대한 N + 1 문제가 발생할 수 있다는 것이다. (&lt;code&gt;Department&lt;/code&gt; 엔티티 하나를 삭제했더니 관련된 N개의 &lt;code&gt;Employee&lt;/code&gt; 엔티티에 대한 update 쿼리 N개가 발생) 또 다른 문제는 employee 테이블에 직접 update 쿼리를 날려주면 될 것을, JPA의 Dirty Checking 기능을 위해 쓸데 없는 select 쿼리가 발생한다는 것이다. 즉 select 할 필요 없이 JPQL을 통해 DB에 직접 update 쿼리를 날려주면 된다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704593654744&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- setter_test 메소드 내에서 발생한 쿼리
select * from department d where d.name='QA팀';
select * from employee e where e.department_id=1; -- 불필요한 select 쿼리
update employee set department_id=null where employee_id=1;
update employee set department_id=null where employee_id=2;
update employee set department_id=null where employee_id=3;
delete from department where department_id=1;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;직접 update JPQL 작성&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public inteface EmployeeRepository extends JpaRepository&amp;lt;Employee, Long&amp;gt; {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Modifying
    @Query(&quot;update Employee e set e.department.id = null where e.department.id = :departmentId&quot;)
    void updateByDepartment(@Param(&quot;departmentId&quot;) Long departmentId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704593990064&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
void jpql_test() {
    Department department = departmentRepository.findByName(&quot;QA팀&quot;)
        .orElseThrow(() -&amp;gt; new RuntimeException(&quot;해당 부서 없음&quot;));
    employeeRepository.updateByDepartment(department.getId()); //관련 Employee update 후
    departmentRepository.delete(department); //Department 삭제
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1704594055538&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- jpql_test 메소드 내에서 발생한 쿼리
select * from department d where d.name='QA팀';
update employee set department_id=null where department_id=1;
delete from department where department_id=1;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;JPQL을 통해 1번의 update 쿼리로 employee 테이블의 department_id(FK) 값을 수정할 수 있다. 이 경우에도 마찬가지로 &lt;code&gt;Department&lt;/code&gt; 엔티티를 삭제하기 전&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;code&gt;Department&lt;/code&gt; 엔티티와 관련된 자식 엔티티들을 모두 확인하고&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;일일이 update 메소드를 호출해주어야 한다. 개발자가 이를 잊어버린다면 FK 제약조건 위반 에러가 발생한다. 앞서 Dirty Checking을 통해 update 쿼리가 발생했던 것과는 달리 employee 테이블에 대한 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;불필요한&amp;nbsp;&lt;/span&gt;select 쿼리가 제거되었으며 각 &lt;code&gt;Employee&lt;/code&gt; 엔티티마다 발생했던 N개의 update 쿼리가 1개로 줄어들게 되었다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;JPA를 활용하는 경우에는 N + 1의 성능 문제가 발생할 수 있으며, 직접 JPQL 쿼리를 작성하는 경우에는 만약 개발자가 자식 엔티티에 대한 삭제 쿼리 호출을 까먹는다면 FK 제약조건 오류가 발생할 수 있다. 마지막으로 &lt;code&gt;@OnDelete&lt;/code&gt; 방식을 사용하여&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;데이터베이스를 직접 다루면, &lt;code&gt;on delete cascade&lt;/code&gt; DDL에 의해 어떠한 레코드의 참조 레코드까지 연쇄적으로 삭제되는지 어플리케이션 단에서 파악이 어렵다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;부모 테이블과 자식 테이블 간 FK 제약조건이 걸려있을 때, 제약조건을 준수하기 위해 자식 테이블에 insert나 update 시 해당 FK 값이 실제 부모 테이블에 존재하는지 확인하기 위해, 또는 부모 테이블 delete 시에도 삭제되는 PK를 FK로 사용하는 레코드들이 있는지 판별하기 위해 테이블에 Lock이 걸리게 된다. 이렇게 FK 제약 조건으로 인해 Lock이 걸려 다양한 성능 이슈가 발생할 수 있으며, 변화하는 요구사항에 따른 스키마 변경의 어려움 등으로 실무에서는 FK를 지정하지 않는 경우가 많다고 한다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이러한 이유로 FK를 지정하지 않는다면 &lt;code&gt;@OnDelete&lt;/code&gt; 방법을 적용하는 것은 어려우며, JPA 상에서 Cascade 방식으로 연관된 엔티티들을 관리해주거나 직접 JPQL을 정의해야 할 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;정말 마지막으로 정리하자면 부모 엔티티 삭제 시 연관된 자식 엔티티들을 처리하는 방법에는 크게 JPA의 영속성 컨텍스트를 통해 처리하는 방법(&lt;code&gt;cascade&lt;/code&gt;, &lt;code&gt;orphanRemoval&lt;/code&gt;, Dirty Checking 등), DDL을 정의하여 처리하는 방법, 직접 JPQL을 정의하는 방법이 존재한다. 각 방법에는 장단점이 존재하며 개발 서비스 요구사항에 맞게 적절히 선택하면 될 것이다. 아직 풀리지 않는 궁금증은 실무에서 FK를 지정하지 않는다면&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt; 그럼 FK 제약조건 없이 어떻게 RDB 테이블 간 연관관계를 매핑하지?이다. 이건 실무를 좀 더 경험한 후 추후 작성해보기로 하자.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;끝.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.inflearn.com/questions/39769/%EB%B6%80%EB%AA%A8-%EC%9E%90%EC%8B%9D%EA%B4%80%EA%B3%84%EC%97%90%EC%84%9C-%EB%B6%80%EB%AA%A8-%EC%82%AD%EC%A0%9C%EC%8B%9C-set-null%EB%B0%A9%EB%B2%95%EC%97%90-%EB%8C%80%ED%95%B4%EA%B6%81%EA%B8%88%ED%95%A9%EB%8B%88%EB%8B%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.inflearn.com/questions/39769/%EB%B6%80%EB%AA%A8-%EC%9E%90%EC%8B%9D%EA%B4%80%EA%B3%84%EC%97%90%EC%84%9C-%EB%B6%80%EB%AA%A8-%EC%82%AD%EC%A0%9C%EC%8B%9C-set-null%EB%B0%A9%EB%B2%95%EC%97%90-%EB%8C%80%ED%95%B4%EA%B6%81%EA%B8%88%ED%95%A9%EB%8B%88%EB%8B%A4&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kukekyakya.tistory.com/546&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://kukekyakya.tistory.com/546&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chb2005.tistory.com/181&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://chb2005.tistory.com/181&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://00h0.tistory.com/97&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://00h0.tistory.com/97&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/8243400/on-delete-set-null-in-hibernate-in-onetomany&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/8243400/on-delete-set-null-in-hibernate-in-onetomany&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/9944137/have-jpa-hibernate-to-replicate-the-on-delete-set-null-functionality&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/9944137/have-jpa-hibernate-to-replicate-the-on-delete-set-null-functionality&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/jpa-cascade-remove-vs-orphanremoval&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/jpa-cascade-remove-vs-orphanremoval&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JPA</category>
      <category>JPA</category>
      <category>spring</category>
      <category>spring boot</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/41</guid>
      <comments>https://daeun21dev.tistory.com/41#entry41comment</comments>
      <pubDate>Fri, 5 Jan 2024 06:52:54 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] Controller - Service 계층 리팩토링 with Facade 패턴</title>
      <link>https://daeun21dev.tistory.com/40</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Facade 패턴 적용 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service가 Controller의 DTO에 의존&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 프로젝트를 진행하고 있던 중 고민인 부분이 생겼다. 바로 Controller - Service - Repository 계층에서 요청 또는 응답 DTO가 어디까지 진입 가능할지, 어디서부터 만들어져 나갈지에 대한 것이다. (참고로 여기서 말하는 DTO는 계층 간 DTO가 아닌 사용자 요청으로부터 오는 DTO 혹은 사용자에게 응답으로 전달되는 DTO를 말한다. 즉 Controller의 인자 혹은 반환값으로 전달되는 DTO) 기존에는 &lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2021-04-25-dto-layer-scope/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;해당 블로그&lt;/a&gt;를 참고하여 DTO가 Service Layer까지는 들어오고 해당 계층에서 나갈 수 있도록, Entity &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;harr;&lt;/span&gt; DTO 간 변환은 별도의 Mapper 클래스가 수행하도록 구현하였다.&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;code&gt;UserController&lt;/code&gt;, &lt;code&gt;UserService&lt;/code&gt;, &lt;code&gt;UserMapper&lt;/code&gt; 코드는 대강 다음과 같았다.&lt;/p&gt;
&lt;pre id=&quot;code_1703059225050&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequestMapping(&quot;/api/users&quot;)
@RequiredArgsConstructor
@RestController
public class UserController {

    private final UserService userService;
    
    @PostMapping
    public UserJoinResponse joinUser(@RequestBody @Valid UserJoinRequest request) {
        return userService.joinUser(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703059380677&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    private final UserMapper userMapper;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    @Transactional
    public UserJoinResponse joinUser(UserJoinRequest request) {
        checkForUsername(request.getUsername()); //username 중복 여부 확인
        String encodedPassword = passwordEncoder.encode(password); //비밀번호 인코딩
        User user = userMapper.toEntity(request, encodedPassword);
        User savedUser = userRepository.save(user);
        userMapper.toResponse(savedUser);
    }
    
    public void checkForUsername(String username) {
        if (userRepository.existsByUsername(username)) {
            throw new BusinessException(ALREADY_EXISTS_USERNAME);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703062049276&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class UserMapper {

    public User toEntity(UserJoinRequest request, String encodedPassword) {
        return User.builder()
            .username(request.getUsername())
            .password(encodedPassword)
            .birthDate(request.getBirthDate())
            .build();
    }
    
    public UserJoinResponse toResponse(User user) {
        return UserJoinResponse.builder()
            .userId(user.getId())
            .username(user.getUsername())
            .birthDate(user.getBirthDate())
            .build();
    }
}&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가 Controller의 DTO 즉 사용자의 요청, 사용자를 위한 응답에 강하게 결합되어 있다는 것이었다. 이 경우 대부분의 Service 메소드 상단에서 사용자의 요청, 위 코드에서는 &lt;code&gt;UserJoinRequest&lt;/code&gt;에 대해 본격적인 비즈니스 로직을 처리하기 전 요청을 검증하는 로직이 필요하게 되었다. (단순한 값 검증이 아닌 DB Connection이 필요한 검증 ex. 요청한 계정명이 이미 존재하는지 검증) 물론 요청을 검증하는 부분도 핵심 비즈니스 로직 중 하나로 볼 순 있다.&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의 필드명이 &lt;code&gt;username&lt;/code&gt; &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; &lt;code&gt;userName&lt;/code&gt;으로 변경되면 &lt;code&gt;UseService&lt;/code&gt;의 코드도 같이 변경해줘야 한다. 또한 Service가 별도의 DTO나 엔티티가 아닌 사용자 요청 DTO를 인자로 받고, 사용자 응답 DTO를 반환하기 때문에 해당 DTO 변경에 따라 관련 Service의 테스트 코드도 변경해줘야 했다. 즉 Controller의 DTO가 변할 때마다 계속해서 Service 쪽 코드를 확인해야 하는 상황이 발생하는 것이다. (Facade 패턴을 적용해도 100% 해결되는 문제는 아니다...)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1703063199767&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@DisplayName(&quot;유저 회원가입 서비스 테스트 : 성공&quot;)
@Test
void joinUser() {
    //given
    String username = &quot;testUsername&quot;;
    String password = &quot;testPassword&quot;;
    UserJoinRequest request = new UserJoinRequest(username, password); //Controller의 요청 DTO
    given(userRepository.existsByUsername(anyString())).willReturn(false);

    //when
    UserJoinResponse resepon = userService.joinUser(request); //Controller의 응답 DTO

    //then
    then(userRepository).should(times(1)).existsByUsername(anyString());
    then(userRepository).should(times(1)).save(userCaptor.capture());
    assertThat(resepon.getUsername()).isEqualTo(username);
    assertThat(userCaptor.getValue().getPassword()).startsWith(&quot;{bcrypt}&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;가장 큰 문제는 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;Service가 특정 사용자 요청 및 응답 DTO에 강하게 결합되어 있기 때문에 재활용성이 떨어지게 된다는 것이다. &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&lt;code&gt;UserService&lt;/code&gt; 내 메소드는 &lt;code&gt;User&lt;/code&gt; 도메인 뿐만 아니라 &lt;code&gt;Post&lt;/code&gt;, &lt;code&gt;Comment&lt;/code&gt; 도메인 등 다른 도메인에서도 호출될 수 있는데, 이때 DTO를 반환하는 메소드는 다른 도메인에서 호출될 수 없다. 또한 API 마다 응답 DTO가 다르기 때문에 이로 인해 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;비슷한 로직을 처리하되 단순히 처리 결과를 서로 다른 DTO로 변환하여 반환하는 메소드를 여러 개 만들게 된다. 즉 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;불필요한 코드 중복이 발생하며 응답 DTO 개수가 증가함에 따라 Service도 비대해질 수 있다는 것이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1703064745408&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    private final UserMapper userMapper;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    public UserDetailsResponse getUserDetails(String userId) {
        User user = getUser(userId);
        return userMapper.toResponse(user);
    }
    
    public UserShortDetailsResponse getUserShortDetails(String userId) {
        User user = getUser(userId);
        return userMapper.toShortResponse(user);
    }
    
    public User getUser(String userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -&amp;gt; new BusinessException(NOT_FOUND_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;일단 여기까지 정리하자면 현재 프로젝트에 Facade 패턴을 적용하기로 마음먹은 첫번째 이유는 Service 계층이 사용자 요청 및 응답 DTO에 강하게 결합되어 있기 때문이다. 다음 두번째 이유는 특정 비즈니스 로직을 처리하기 위해 여러 도메인 서비스가 필요한 상황에 대한 것이다. 예를 들어 &quot;사용자 등급이 골드 이상인 경우에만 게시글을 작성할 수 있다&quot;라는 로직을 구현하기 위해서는 &lt;code&gt;PostService&lt;/code&gt;, &lt;code&gt;UserService&lt;/code&gt;가 필요할 것이다. 이러한 상황에 대해 기존 코드는 다음과 같이 &lt;code&gt;PostService&lt;/code&gt;가 &lt;code&gt;UserService&lt;/code&gt;를 의존하도록 구현하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1703066624453&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {

    private final PostMapper postMapper;
    private final PostRepository postRepository;
    private final UserService userService;
    
    @Transactional
    public PostCreateResponse createPost(String userId, PostCreateRequest request) {
        User user = userService.getValidUser(userId, Rating.GOLD); //등급이 검증된 유저 엔티티 반환
        Post post = postMapper.toEntity(request, user);
        Post savedPost = postRepository.save(post); //게시글 저장
        return postMapper.toResponse(savedPost);
    }
}&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;사실 여러 도메인이 포함된 비즈니스 로직을 처리하는 방식에는 여러 가지가 있다. 위 코드처럼 중심 도메인 Service(&lt;code&gt;PostService&lt;/code&gt;)가 다른 도메인 Service(&lt;code&gt;UserService&lt;/code&gt;)를 의존하도록 하는 방법이 있으며, 이 외에도 하나의 Controller(&lt;code&gt;PostController&lt;/code&gt;)가 여러 Service(&lt;code&gt;PostService&lt;/code&gt;, &lt;code&gt;UserService&lt;/code&gt;)를 의존하는 방법, 하나의 Service(&lt;code&gt;PostService&lt;/code&gt;)가 여러 Repository(&lt;code&gt;PostRepository&lt;/code&gt;, &lt;code&gt;UserRepository&lt;/code&gt;)를 의존하는 방법이 있다. 왜 이 2가지 방법이 아닌 Service 간에 의존관계를 두는 방법을 선택했는지 이유를 정리해보고, 다시 기존에 적용했던 Service 간에 의존관계를 두는 방법이 어떤 부분에서 문제가 있길래 Facade 패턴을 적용하려고 하는지 마지막으로 살펴보겠다.&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;h3 data-ke-size=&quot;size23&quot;&gt;여러 도메인이 포함된 비즈니스 로직 처리 문제&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;하나의 Controller 내 여러 Service 의존&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1703068093655&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
@RestController
public class PostController {

    private final PostService postService;
    private final UserService userService;
    
    //참고로 @CurrentUserId는 현재 로그인한 유저의 id를 주입해주는 커스텀 어노테이션
    @PostMapping
    public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
        User user = userService.getValidUser(userId, Rating.GOLD);
        return postService.createPost(user, request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703144799581&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    private final UserRepository userRepository;
    
    public User getValidUser(String userId, Rating rating) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -&amp;gt; new BusinessException(NOT_FOUND_USER));
        if (!user.getRating().validate(rating)) {
            throw new BusinessException(FORBIDDEN_USER_RATING);
        }
        return user;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703068125174&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {

    private final PostMapper postMapper;
    private final PostRepository postRepository;
    
    @Transactional
    public PostCreateResponse createPost(User user, PostCreateRequest request) {
        Post post = postMapper.toEntity(request, user);
        Post savedPost = postRepository.save(post);
        return postMapper.toResponse(savedPost);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 &lt;code&gt;PostService&lt;/code&gt;의 &lt;code&gt;createPost&lt;/code&gt; 메소드의 크기가 줄어들었으므로 좋은거 아닌가 싶을 수 있지만 오산이다. 먼저 첫번째 문제는 Controller 내에서 처리 순서에 맞게 적절한 Service를 호출해줘야 하므로 Controller에도 비즈니스 로직이 일부 포함되게 된다는 것이다. 위 코드에서 &lt;code&gt;PostController&lt;/code&gt;는 게시글 작성 로직이 &quot;현재 유저가 골드 이상이라는 것을 검증&amp;nbsp; &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; 게시글 저장&quot;으로 구성된다는 것을 알고 있다. &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;일반적으로 트랜잭션 즉 &lt;code&gt;@Transactional&lt;/code&gt; 어노테이션은 Service 단위로 붙는다. 그리고 이 상태에서 OSIV 설정을 끄게 되면 Service 메소드를 기준으로 DB Connection이 열리고 닫힌다. 즉 OSIV 설정을 끈 상태로 위 코드를 실행하면 &lt;code&gt;userService.getValidUser&lt;/code&gt; 메소드에서 1번, &lt;code&gt;postService.createPost&lt;/code&gt; 메소드에서 1번, 총 2번 DB Connection이 열리고 닫힌다. 따라서 실시간 요청을 처리하는 서비스의 경우 성능에 악영향을 끼칠 수 있다.&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;code&gt;userService.getValidUser&lt;/code&gt; 메소드가 리소스를 변경하거나 생성하는 기능을 한다면&amp;nbsp;&lt;code&gt;postService.createPost&lt;/code&gt;에서 예외가 발생해도 &lt;code&gt;userService.getValidUser&lt;/code&gt;에서의 데이터 변경이 롤백되지 않아 데이터 불일치 문제가 발생하게 된다.(트랜잭션이 각각 묶이므로) 즉 &quot;나는 계좌이체로 돈을 보냈는데, 친구는 돈을 받지 못한 상황&quot; 같이 데이터 무결성이 깨지게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 치명적인 이유로 해당 방법은 사용하지 않는 것을 권장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;하나의 Service 내 여러 Repository 의존&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1703069856534&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
@RestController
public class PostController {

    private final PostService postService;
    
    @PostMapping
    public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
        return postService.createPost(userId, request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703069316653&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {

    private final PostMapper postMapper;
    private final PostRepository postRepository;
    private final UserRepository userRepository;
    
    @Transactional
    public PostCreateResponse createPost(String userId, PostCreateRequest request) {
        User user = getValidUser(userId, Rating.GOLD);
        Post post = postMapper.toEntity(request, user);
        Post savedPost = postRepository.save(post);
        return postMapper.toResponse(savedPost);
    }
    
    public User getValidUser(String userId, Rating rating) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -&amp;gt; new BusinessException(NOT_FOUND_USER));
        if (!user.getRating().validate(rating)) {
            throw new BusinessException(FORBIDDEN_USER_RATING);
        }
        return 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;이 경우에는 그래도 하나의 비즈니스 로직을 하나의 트랜잭션으로 묶을 수 있어 데이터 무결성이 깨지는 등의 문제는 발생하지 않는다. 그러나 위 코드의 문제점은 재사용성이 떨어져 여러 중복 코드가 발생할 수 있다는 것이다. &lt;code&gt;getValidUser&lt;/code&gt;와 같이 특정 유저의 등급을 확인하는 로직은 &lt;code&gt;Post&lt;/code&gt; 뿐만 아니라 &lt;code&gt;Comment&lt;/code&gt;, &lt;code&gt;Order&lt;/code&gt; 등의 도메인에서도 사용될 수 있다. 그러나 &lt;code&gt;CommentService&lt;/code&gt;, &lt;code&gt;OrderService&lt;/code&gt;는 &lt;code&gt;UserRepository&lt;/code&gt;만 의존할 수 있으므로 내부에 같은 구현 코드를 각각 작성해야 한다. 또한 &lt;code&gt;Post&lt;/code&gt; 도메인에서 &lt;code&gt;User&lt;/code&gt; 도메인의 비즈니스 로직까지 모두 처리하고 있는 것이 SRP 관점에서 그리 좋아보이진 않는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Service 간 의존&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1703069861431&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
@RestController
public class PostController {

    private final PostService postService;
    
    @PostMapping
    public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
        return postService.createPost(userId, request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703069824994&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {

    private final PostMapper postMapper;
    private final PostRepository postRepository;
    private final UserService userService;
    
    @Transactional
    public PostCreateResponse createPost(String userId, PostCreateRequest request) {
        User user = userService.getValidUser(userId, Rating.GOLD);
        Post post = postMapper.toEntity(request, user);
        Post savedPost = postRepository.save(post);
        return postMapper.toResponse(savedPost);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703145094870&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    private final UserRepository userRepository;
    
    public User getValidUser(String userId, Rating rating) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -&amp;gt; new BusinessException(NOT_FOUND_USER));
        if (!user.getRating().validate(rating)) {
            throw new BusinessException(FORBIDDEN_USER_RATING);
        }
        return 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;위의 2가지 방법은 데이터 무결성 깨짐, 많은 코드 중복 발생이라는 치명적인 이유로, 현재 도메인 Service 내에서 다른 도메인의 Service를 의존하는 방법을 사용해왔다. 그러나 여전히 몇몇 문제는 존재한다.&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;code&gt;PostService&lt;/code&gt;가 &lt;code&gt;UserService&lt;/code&gt;를 의존하면서 동시에 &lt;code&gt;UserService&lt;/code&gt;가 &lt;code&gt;PostService&lt;/code&gt; 또한 의존하게 되면 순환 참조로 인해 다음과 같은 예외가 발생하면서 어플리케이션 실행이 중단된다.&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;339&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b47wFl/btsClYETjp3/vKRgzarxQY40ZDGk0Dl1w0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b47wFl/btsClYETjp3/vKRgzarxQY40ZDGk0Dl1w0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b47wFl/btsClYETjp3/vKRgzarxQY40ZDGk0Dl1w0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb47wFl%2FbtsClYETjp3%2FvKRgzarxQY40ZDGk0Dl1w0%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;481&quot; height=&quot;292&quot; data-origin-width=&quot;559&quot; data-origin-height=&quot;339&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting &lt;b&gt;spring.main.allow-circular-references to true&lt;/b&gt;.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 순환 참조를 허용하도록 설정할 수 있다고 스프링이 친절하게 알려주지만, 순환 참조의 경우 변경에 따른 영향의 범위가 커져 권장되지 않는다.&amp;nbsp;&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;이외에도 무엇보다 Service 간 의존관계를 가지도록 구현하게 되면 한 도메인의 Service 내 메소드가 2가지 종류로 구분되게 된다. 즉 같은 도메인의 Controller에서 사용되는 메소드와 다른 도메인의 Service에서도 사용될 수 있는 메소드로 구분되는 것이다. 이는 SRP 원칙에 위배될 수도 있으며 다른 도메인에서는 호출될 수 없는 Service 메소드(해당 도메인 API의 요청, 응답 DTO에 결합되어 있기 때문)가 public으로 열려있다는 부분에서 여럿이서 개발 시 혼동을 줄 수 있겠다고 생각했다.&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;자 이러한 이유 &quot;Service 내에서 사용자 요청 및 응답 DTO에 강하게 의존&quot; + &quot;여러 도메인이 포함된 비즈니스 로직 처리 문제&quot;로 Controller - Service 계층 사이에 FacadeService를 추가하여 리팩토링하기로 마음 먹었다. 이렇게 구성된 계층 구조는 &quot;한 도메인의 Controller - 한 도메인의 FacadeService - 여러 도메인의 Service&quot; 식으로 구성된다. 리팩토링을 정확히 어떻게 수행했는지, 이 과정에서 느낀 해당 구조의 장단점을 정리해보고자 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Facade 패턴에 대한 개념은 다음을 &lt;a href=&quot;https://daeun21dev.tistory.com/39&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt;)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Facade 패턴 적용&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;적용 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 기존 프로젝트는 도메인 별로 패키지가 구분되어 있는 상태였다. 예를 들어 &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;comment&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt; 패키지로 분리한 후 각 도메인 패키지 아래에 &lt;code&gt;controller&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt;, &lt;code&gt;repository&lt;/code&gt; 패키지로 다시 구분하였다. 이 상태에서 각 도메인 별 FacadeService 즉 &lt;code&gt;PostFacadeService&lt;/code&gt;, &lt;code&gt;CommentFacadeService&lt;/code&gt;, &lt;code&gt;UserFacadeService&lt;/code&gt;는 &lt;code&gt;service&lt;/code&gt; 패키지 아래에 위치시켰다. 그리고 한 도메인의 FacadeService 내부에서는 동일한 도메인의 Mapper, 그리고 여러 도메인의 Service를 의존하도록 하였다.&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는 동일한 도메인의 FacadeService만을 의존하게 되며 한 도메인의 Service는 동일한 도메인의 Repository만을 의존하게 된다. 즉 아래 그림과 같은 구조가 만들어지게 된다. 또한 사용자 요청 DTO가 Service에는 넘어가지 않도록 FacadeService에서 의존하고 있는 Mapper가 엔티티 또는 별도의 DTO로 변환하여 Service에게 넘겨주도록 하였으며, 반대로 Service 계층에서 엔티티 또는 별도의 DTO를 반환하면 Mapper가 사용자 응답 DTO로 변환하도록 하였다.&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;/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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;기존 구조&lt;/b&gt;&amp;nbsp;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;original structure.png&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4SVZI/btsCrZcqHMi/NjJPtOKjYRn1eL6tWnsOc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4SVZI/btsCrZcqHMi/NjJPtOKjYRn1eL6tWnsOc0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4SVZI/btsCrZcqHMi/NjJPtOKjYRn1eL6tWnsOc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4SVZI%2FbtsCrZcqHMi%2FNjJPtOKjYRn1eL6tWnsOc0%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;635&quot; height=&quot;206&quot; data-filename=&quot;original structure.png&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;240&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;적용 후 구조&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;refactoring structure.png&quot; data-origin-width=&quot;1010&quot; data-origin-height=&quot;390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d2QS1L/btsCoFFPzSH/F2ki9i78Y8HVVnbLM6GkK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d2QS1L/btsCoFFPzSH/F2ki9i78Y8HVVnbLM6GkK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d2QS1L/btsCoFFPzSH/F2ki9i78Y8HVVnbLM6GkK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd2QS1L%2FbtsCoFFPzSH%2FF2ki9i78Y8HVVnbLM6GkK1%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;723&quot; height=&quot;279&quot; data-filename=&quot;refactoring structure.png&quot; data-origin-width=&quot;1010&quot; data-origin-height=&quot;390&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;이렇게 전체 프로젝트 구조를 설정한 후 하나의 비즈니스 로직을 처리하기 위해 어떻게 FacadeService와 Service를 구성할지 고민하였다. 일단 FacadeService의 역할은 하나의 핵심 비즈니스 로직을 처리하기 위해 여러 Service 등을 조합하는 것으로 정의를 하였다. 예를 들어 &quot;주문 생성&quot; 비즈니스 로직이 있을 때, 이 로직은 &quot;주문 검증 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr;&lt;/span&gt; 결제 수행 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; &lt;/span&gt;주문 저장&quot; 이라는 세부 로직으로 나누어질 수 있다. 즉 나누어진 각 로직의 상세 구현은 각자 도메인의 Service에게 위임하며 이 Service를 적절한 순서로 호출하면서 조합하는 역할은 FacadeService에게 있는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1703125473813&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class OrderFacadeService {

    private final OrderMapper orderMapper;
    private final OrderService orderService;
    private final PaymentService paymentService;
    
    @Transactional
    public OrderCreateResponse placeOrder(OrderCreateRequest request) {
        Order order = orderMapper.toEntity(request);
        
        //주문 검증
        orderService.validate(order);
        //결제 수행
        paymentService.pay(order.getPayment());
        //주문 저장
        Order savedOrder = orderService.save(order);
        
        return orderMapper.toResponse(savedOrder);
    }
}&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;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Facade 패턴 적용 결과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시 코드1&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 보여주었던 유저 회원가입 로직 또한 세부 로직으로 구분될 수 있다. 즉 &quot;유저의 계정명 중복 확인 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; 비밀번호 인코딩 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; 유저 저장&quot; 이라는 3가지 로직으로 구성된다. &lt;/span&gt;&lt;/span&gt;Facade 패턴을 적용한 결과는 다음과 같다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1703131951449&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@RequestMapping(&quot;/users&quot;)
@RestController
public class UserController {

    private final UserFacadeService userFacadeService;

    @PostMapping
    public UserJoinResponse joinUser(@RequestBody @Valid UserJoinRequest request) {
        return userFacadeService.joinUser(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703131962987&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserFacadeService {

    private final UserMapper userMapper;
    private final UserService userService;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserJoinResponse joinUser(UserJoinRequest request) {
        //유저의 계정명 중복 확인
        userService.checkForUsername(request.getUsername());
        //비밀번호 인코딩
        String encodedPassword = passwordEncoder.encode(request.getPassword());
        User user = userMapper.toEntity(request, encodedPassword);
        //유저 저장
        User savedUser = userService.saveUser(user);
        return userMapper.toResponse(savedUser);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703131971110&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    private final UserRepository userRepository;
    private static final String ENCODED_PASSWORD_PREFIX = &quot;{bcrypt}&quot;;

    @Transactional
    public void saveUser(User user) {
        //유저 저장 전 비밀번호 인코딩 여부 확인
        if (!user.getPassword().startsWith(ENCODED_PASSWORD_PREFIX)) {
            throw new BusinessException(PASSWORD_ENCODING_ERROR);
        }
        userRepository.save(user);
    }

    public void checkForUsername(String username) {
        if (userRepository.existsByUsername(username)) {
            throw new BusinessException(ALREADY_EXISTS_USERNAME);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&quot;유저의 계정명 중복 확인&quot;,&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt; &quot;비밀번호 인코딩&quot;,&lt;span&gt; &quot;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;유저 저장&quot; 3가지 세부 로직의 상세 구현 그러니깐 계정명 중복을 어떤 식으로 확인할지, 비밀번호를 어떻게 인코딩할지, 유저 정보는 어디에 저장할지 등은 FacadeService의 관심 사항이 아니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시 코드2&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제는 하나의 비즈니스 로직을 처리하기 위해 여러 도메인이 관여하는 경우의 예제를 보자. 앞서 언급했던 &quot;등급이 골드 이상인 유저만 게시글을 작성할 수 있는 로직&quot; 또한 여러 세부 로직으로 구성된다. &quot;현재 유저의 등급이 골드 이상인지 검증 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; 게시글 저장&quot;으로 구분될 수 있는데 너무 간단하므로 게시글 내용에 비방어, 욕설 등이 포함되어 있는지 확인하는 기능도 추가해보자. 결과적으로 해당 로직은&amp;nbsp; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&quot;현재 유저의 등급이 골드 이상인지 검증 &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr;&lt;/span&gt; 게시글 내용 검증&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; 게시글 저장&quot;으로 나눠지게 된다. Facade 패턴을 적용한 결과는 다음과 같다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1703131987528&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
@RestController
public class PostController {

    private final PostFacadeService postFacadeService;
    
    @PostMapping
    public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
        return postFacadeService.createPost(userId, request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703131998372&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostFacadeService {

    private final PostMapper postMapper;
    private final PostService postService;
    private final UserService userService;
    
    @Transactional
    public PostCreateResponse createPost(String userId, PostCreateRequest request) {
        //현재 유저의 등급이 골드 이상인지 검증
        User user = userService.getValidUser(userId, Rating.GOLD);
        Post post = postMapper.toEntity(request, user);
        //게시글 내용 검증
        postService.validateContent(post);
        //게시글 저장
        Post savedPost = postService.save(post);
        return postMapper.toResponse(savedPost);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703132010424&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    private final UserRepository userRepository;
    
    public User getValidUser(String userId, Rating rating) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -&amp;gt; new BusinessException(NOT_FOUND_USER));
        if (!user.getRating().validate(rating)) {
            throw new BusinessException(FORBIDDEN_USER_RATING);
        }
        return user;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703132018472&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {

    private final PostRepository postRepository;
    
    public void validateContent(Post post) {
        if (!validate(post.getContent()) {
            throw new BusinessException(INVALID_POST_CONTENT)
        }
    }
    
    @Transactional
    public post save(Post post) {
        return postRepository.save(post);
    }
    
    private void validate(String content) {
        //...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 &quot; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;게시글 내용 검증&quot;, &quot;게시글 저장&quot; 로직을 따로 분리하였지만 게시글 저장 시 내용 검증을 강제하고 싶다면 이를 하나의 로직으로 보고 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&quot;현재 유저의 등급이 골드 이상인지 검증&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;rarr; 게시글 저장&quot; 2개의 세부 로직으로 구성해도 될 것이다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;장점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade 패턴을 적용한 후 내가 느낀 장점은 다음과 같다.&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;- Service에는 Controller 계층의 DTO(사용자 요청 및 응답 DTO)가 넘어오지 않음&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 따라 다른 도메인에서도 해당 Service 메소드를 호출할 수 있어 Service의 재사용성이 증가한다. 또한&amp;nbsp;기존 코드에서는 이 사용자 요청 DTO가 Repository까지 넘어오는 경우가 존재했는데, 이 또한 Service로 넘겨주기 전 별도의 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;사실 Facade 패턴을 적용했기 때문에 얻을 수 있는 장점은 아니고 FacadeService 없이도 사용자 요청 DTO를 Controller에서 직접 Service를 위한 DTO로 변환해주어도 된다. 그러나 이러한 변환 처리를 별도의 FacadeService 내에서 하도록 계층을 분리했다는 점에서는 의미가 있다고 생각한다.&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;각 객체의 역할이 더 분명해지면서 계층 간 의존관계도 깔끔해졌다. 즉 Controller는 웹 요청 및 응답과 관련된 부분을 처리, Service는 세부적인 비즈니스 로직을 처리, FacadeService는 이러한 Service를 조합하여 하나의 핵심 비즈니스 로직을 처리, Mapper는 데이터 간 변환을 처리하는 역할만을 담당하게 되었다. 따라서 변경 사항 발생 시 어떤 클래스를 수정해야 할지 명확히 알 수 있으며 변경에 따른 영향 범위도 줄어들었다. 또한 FacadeService가 다른 도메인의 FacadeService는 의존할 수 없고 Service 또는 Mapper만 의존할 수 있기 때문에 Service 간의 의존관계로 인해 발생할 수 있었던 순환 참조 문제도 자동으로 해결되었다.&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;예를 들어 지난 일주일 간(2023.12.14 ~ 2023.12.20)의 지출 내역에 대한 통계값을 캐싱한 경우 해당 날짜 범위의 지출 내역이 추가되거나 수정 및 삭제되면 캐싱 데이터를 삭제해줘야 한다. 추가의 경우 요청 DTO 자체에 지출일 데이터가 있어 &lt;code&gt;@CacheEvict&lt;/code&gt; 조건을 간단하게 지정해줄 수 있지만, 수정이나 삭제의 경우 기존 데이터가 해당 날짜 범위에 포함되는지 여부를 요청값만으로는 알 수 없어 DB 조회 결과를 억지로 반환해줘야 했다. 이는 스프링의 캐시가 AOP로 동작하기 때문이다. 따라서 기존의 지출 내역 삭제 Service 메소드는 다음과 같이 DB에서 조회된 지출 엔티티의 지출일 데이터를 반환해줘야 했다. (실제로 반환값을 사용하지 않음에도 불구하고)&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;기존 코드&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1703133308358&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ExpenditureService {

    private final ExpenditureRepository expenditureRepository;

    @CacheEvict(cacheNames = CACHE_NAME, key = &quot;#userId&quot;, condition = &quot;#root.target.containsAWeekAgo(#result)&quot;) //SpEL 표현식
    @Transactional
    public LocalDateTime deleteExpenditure(String userId, Long expenditureId) {
        Expenditure expenditure = getValidExpenditure(userId, expenditureId);
        expenditure.softDelete();
        return expenditure.getExpenditureDate(); //캐싱을 위해 지출일 데이터 반환
    }

    public boolean containsAWeekAgo(LocalDateTime expenditureDate) {
        LocalDate now = LocalDate.now();
        LocalDateTime startDateTime = now.minusDays(7).atStartOfDay();
        LocalDateTime endDateTime = now.atStartOfDay();
        return (expenditureDate.isEqual(startDateTime) || expenditureDate.isAfter(startDateTime)) &amp;amp;&amp;amp;
            expenditureDate.isBefore(endDateTime);
    }

    public Expenditure getValidExpenditure(String userId, Long expenditureId) {
        Expenditure expenditure = getExpenditure(expenditureId);
        if (!Objects.equals(expenditure.getUser().getId(), userId)) {
            throw new BusinessException(FORBIDDEN_EXPENDITURE);
        }
        return expenditure;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;리팩토링 후 코드&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1703133672686&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ExpenditureFacadeService {

    private final ExpenditureMapper expenditureMapper;
    private final ExpenditureService expenditureService;

    @Transactional
    public void deleteExpenditure(String userId, Long expenditureId) {
        Expenditure expenditure = expenditureService.getValidExpenditure(userId, expenditureId);
        expenditureService.deleteExpenditure(expenditure);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1703133853695&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ExpenditureService {

    private final ExpenditureRepository expenditureRepository;

    //캐싱 조건을 Expenditure 엔티티 인자값으로 판단할 수 있으므로 반환 타입 void 가능
    @CacheEvict(cacheNames = CACHE_NAME, key = &quot;#userId&quot;, condition = &quot;#root.target.containsAWeekAgo(#expenditure.expenditureDate)&quot;) //SpEL 표현식
    @Transactional(propagation = Propagation.MANDATORY)
    public void deleteExpenditure(Expenditure expenditure) {
        expenditure.softDelete(); //Expenditure 엔티티의 deleted 필드를 true로 설정
    }

    public boolean containsAWeekAgo(LocalDateTime expenditureDate) {
        LocalDate now = LocalDate.now();
        LocalDateTime startDateTime = now.minusDays(7).atStartOfDay();
        LocalDateTime endDateTime = now.atStartOfDay();
        return (expenditureDate.isEqual(startDateTime) || expenditureDate.isAfter(startDateTime)) &amp;amp;&amp;amp;
            expenditureDate.isBefore(endDateTime);
    }

    public Expenditure getValidExpenditure(String userId, Long expenditureId) {
        Expenditure expenditure = getExpenditure(expenditureId);
        if (!Objects.equals(expenditure.getUser().getId(), userId)) {
            throw new BusinessException(FORBIDDEN_EXPENDITURE);
        }
        return expenditure;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;리팩토링 후 FacadeService에서 해당 지출 엔티티를 조회한 후 Service 메소드의 인자로 조회된 엔티티를 넘겨주기 때문에 반환 타입을 void로 바꾼 후&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;@CacheEvict&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&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;그러나&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;ExpenditureFacadeService&lt;/code&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;deleteExpenditure&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서 트랜잭션을 하나로 묶어주지 않은 상태로 OSIV를 끄게 되면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;expenditureService.deleteExpenditure&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드 내에서 발생하는 Soft Delete 즉 엔티티 값 변경에 따른 Dirty Checking이 제대로 수행되지 않아 update 쿼리가 생성되지 않는다. 이는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;Expenditure&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;엔티티를 조회하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;expenditureService.getValidExpenditure&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에서의 영속성 컨텍스트가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;expenditureService.deleteExpenditure&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;수행 시까지 유지되지 않기 때문이다. (다시 말해 &lt;code&gt;expenditureService.deleteExpenditure&lt;/code&gt; 메소드의 인자로 전달된 &lt;code&gt;Expenditure&lt;/code&gt; 엔티티가 영속화(&lt;code&gt;persist&lt;/code&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;따라서 Dirty Checking이 제대로 동작하지 않는 상황을 막기 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;expenditureService.deleteExpenditure&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메소드에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;code&gt;@Transactional(propagation = Propagation.MANDATORY)&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;트랜잭션 전파 옵션을 설정해주었다. (&lt;code&gt;MANDATORY&lt;/code&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;옵션:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: left;&quot;&gt;트랜잭션이 존재할 경우 해당 트랜잭션을 이용하며, 존재하지 않을 경우에는 예외 발생&lt;/span&gt;) 이렇듯 (뒤에서 이야기 하겠지만) FacadeService 계층 추가 시 트랜잭션 중첩이 발생하면서 트랜잭션 관리에 대한 책임이 생긴다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;단점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 리팩토링을 하던 중 내 뇌가 스파게티가 되어 버릴 지경이라 지금까지 해온 것을 reset하고 리팩토링하지 않기로 결심했었다. 그냥 관둘 순 없고 몇가지 합리적인 이유들을 생각해보았는데 아래는 이 이유들 그리고 패턴을 적용하고 난 후 느끼는 몇가지 단점들을 정리한 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 핵심 비즈니스 처리 로직을 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;FacadeService, Service로 분리함으로써 전체 로직 파악이 어려움&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;FacadeService를 구현하기 위해서는 일단 핵심 비즈니스 처리 로직을 세부 로직으로 나누는 과정이 필요하다. 그러나 항상 로직이 깔끔하게 분리되는 것은 아니다. 애초에 로직이 너무 작은 경우에는 FacadeService가 한 도메인의 Service 메소드만을 단순 호출하여 FacadeService가 불필요하게 끼어있는 계층이 되어버릴 수 있으며, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;로직이 너무 꼬여서 복잡한 경우에는 세부 로직으로 나누기 쉽지 않을 뿐더러 어찌저찌 나누었다 하더라도 전체 로직을 파악하기가 어려워질 수 있다.&lt;/span&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;&lt;b&gt;- 트랜잭션 관리의 어려움 + 중복되는 DTO 증가&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;어떻게 보면 Business 계층을 FacadeService와 Service 2개의 계층으로 나누었기 때문에 FacadeService, Service 계층 모두 &lt;code&gt;@Transactional&lt;/code&gt; 어노테이션을 신경써서 붙여줘야 한다. 즉 중첩되는 트랜잭션을 더 신경써서 관리해줘야 한다. 만약 FacadeService 메소드에서는 &lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt;로 읽기 전용 트랜잭션을 열어주었지만 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Service에서는 쓰기 작업을 한다면 다음과 같은 오류가 발생할 수 있다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;845&quot; data-origin-height=&quot;120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TjPbo/btsCk4FLLuX/dc8ImLYYpDVKQSfdCbWvnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TjPbo/btsCk4FLLuX/dc8ImLYYpDVKQSfdCbWvnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TjPbo/btsCk4FLLuX/dc8ImLYYpDVKQSfdCbWvnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTjPbo%2FbtsCk4FLLuX%2Fdc8ImLYYpDVKQSfdCbWvnk%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;803&quot; height=&quot;114&quot; data-origin-width=&quot;845&quot; data-origin-height=&quot;120&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;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;그러나 이 문제는 &lt;/span&gt;기존 Service 간 의존관계를 맺는 경우에도 발생할 수 있는 문제이긴 한다. 다음으로는 단순히 FacadeService &lt;span style=&quot;background-color: #ffffff; color: #444447; text-align: left;&quot;&gt;&amp;harr; Service 계층 간 데이터 전달을 위해 별도의 DTO를 정의함으로써 중복되는 DTO가 증가한다는 단점이 있다. 그러나 &lt;/span&gt;현재는 DTO가 중복되지만 미래에는 어떤 변경 사항이 발생할지 모를 일이므로 자연히 해결될 수 있는 문제이다.&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;- 기존 코드(테스트 코드 포함)에 대한 변경이 매우 많이 발생&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개인적으로 가장 큰 단점이라고 생각한다. 이미 서비스가 안정적으로 운영되며 규모가 큰 프로젝트의 경우 리팩토링이 부담될 수 있다. 특히 계층 간 의존관계가 강하게 결합되어 있는 경우 매우매우 많은 코드를 수정해야 할 수 있다. 특히 테스트 코드까지 많이 작성한 상태라면 이 코드 또한 같이 변경해줘야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&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 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;애초에 현재 프로젝트 규모가 크지 않아 전체 구조를 리팩토링하는 것이 크게 부담이진 않았는데, 통계 로직이나 복잡한 조회 로직 등을 최대한 코드 중복이 발생하지 않도록, 가독성 있게 작성하는 것이 약간 어려웠다. 답이 없는 문제이다 보니 이렇게도 바꿔보고 저렇게도 바꿔보면서 코드는 점점 알아볼 수 없는 지경이 되었는데, 마지막에는 이게 일단 지금 상황에서는 최선이다를 되뇌며 리팩토링을 멈추었다.&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;만약 빠르게 개발해야 하는 상황이었다면, 큰 규모의 서비스였다면 굳이 Facade 패턴을 적용하진 않았을 것 같다. 그러나 항상 개발을 하면서 비즈니스 로직에 Controller의 DTO가 침범하는 것이 내심 마음에 들지 않았었는데, 이번 기회에 직접 리팩토링해봄으로써 Facade 패턴의 장단점을 직접 느껴볼 수 있어서 좋은 경험이었다고 생각한다. 또한 기존 테스트 코드들 때문에 리팩토링이 힘들어지기도 했지만 또 덕분에, 놓칠 수 있던 부분들도 신경 쓸 수 있었다는 점에서 테스트 코드의 중요성을 다시 한번 깨닫게 되었다.&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;끝.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2021-04-25-dto-layer-scope/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tecoble.techcourse.co.kr/post/2021-04-25-dto-layer-scope/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://gose-kose.tistory.com/29&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://gose-kose.tistory.com/29&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@lsj8367/Facade-Pattern-%EC%A0%81%EC%9A%A9%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@lsj8367/Facade-Pattern-%EC%A0%81%EC%9A%A9%EA%B8%B0&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring/Spring</category>
      <category>facade 패턴</category>
      <category>spring</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/40</guid>
      <comments>https://daeun21dev.tistory.com/40#entry40comment</comments>
      <pubDate>Thu, 21 Dec 2023 17:27:38 +0900</pubDate>
    </item>
    <item>
      <title>[디자인 패턴] Facade 패턴</title>
      <link>https://daeun21dev.tistory.com/39</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;이번 글에서는 &lt;span style=&quot;background-color: #ffffff; color: #273239; text-align: justify;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Design_Patterns&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GoF Design Patterns&lt;/a&gt; 중 하나인&lt;/span&gt; Facade 패턴에 대해 알아보고자 한다. 개발을 하면서 누군가는 자신도 모르게 이 Facade 패턴을 적용해본 적이 있을 것이다. 굳이 어렵게 Facade 라는 이름을 붙였어야 했나 싶을 정도로 간단한 패턴이다. 그럼 자세히 알아보자.&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;Facade 패턴이란?&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;Facade&lt;/b&gt; is a structural design pattern that provides a simplified interface to a library, a framework, or any other complex set of classes.&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;Facade는 구조 디자인 패턴으로 복잡한 클래스들의 사용에 대한 간단한 인터페이스를 제공한다고 한다. 다시 말해 클라이언트가 A, B, C 클래스의 각 메소드를 직접 호출하던 기존 방식에 Facade 패턴을 적용하면 Facade 메소드 하나만을 호출함으로써 동일한 동작을 수행할 수 있다는 것이다. 따라서 클라이언트는 A, B, C 클래스의 복잡한 구조나 구현 상세를 모르더라도 Facade를 통해 비교적 간단하게 동일한 기능을 제공받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;*&lt;b&gt;Structural Design Pattern&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 Facade가 하는 역할은 크게 2가지로 정리할 수 있다.&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;1. 복잡한 하위 시스템을 간단한 인터페이스를 통해 캡슐화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 말하는 하위 시스템이란 위의 A, B, C 와 같은 사용자 정의 클래스이거나 외부 라이브러리일 수 있다. 클라이언트는 이러한 하위 시스템을 직접 호출하거나 의존하지 않고 Facade를 통해 접근한다. 즉 Facade는 하위 시스템들의 복잡성을 숨겨주어 클라이언트가 사용하기 쉽게 만들어준다.&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;2. 클라이언트와 하위 시스템 간의 Decoupling&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 Facade라는 간단한 인터페이스를 통해서만 하위 시스템에 접근하기 때문에 클라이언트 구현과 하위 시스템 구현이 상호 의존적이지 않게 되는 Decoupling이 발생한다. 따라서 하위 시스템에 변경이 발생하더라도 클라이언트 코드에는 영향을 미치치 않는다.&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;Facade가 클라이언트와 하위 시스템 사이의 중개자로서 동작한다는 점에서 Mediator 패턴의 Mediator와 비슷한 역할을 한다고 볼 수 있다. 그러나 하위 시스템은 Facade를 인식하지 못하며, 하위 시스템 간에는 직접 접근할 수 있다는 점에서 Mediator 패턴과의 확연한 차이점을 보인다. (자세한 내용은 추후 Mediator 패턴에서 정리.)&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;h3 data-ke-size=&quot;size23&quot;&gt;Facade의 의미?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade 패턴의 구성요소에 대해 자세히 알아보기 전 Facade 라는 단어의 의미에 대해 한번 짚고 넘어가자. Facade 라는 단어의 의미는 the face of the building으로 정의된다. 즉 길거리의 행인들은 오직 건물의 표면만을 보고 건물 내부의 구조나 상세한 디자인 등은 알지 못하듯이, Facade를 사용하는 클라이언트 또한 Facade가 제공하는 인터페이스만을 알고 있지 실제 내부적으로는 어떤 클래스들을 의존하며, 어떤 순서로 각 클래스의 메소드를 호출하는지는 알지 못하는 것을 말한다.&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;'클라이언트는 구현 상세를 알지 못한다' 라는 대목에서 그럼 Facade는 자바의 인터페이스와 비슷한 것이 아닌가? 라는 의문이 들 수 있다. 그러나 일단 Facade는 &lt;code&gt;interface&lt;/code&gt;가 아닌 &lt;code&gt;class&lt;/code&gt;로 정의되며, 자바의 인터페이스는 다형성을 구현하는 것에 조금더 초점을 맞춘 반면, Facade는 다형성 보다는 내부적으로 의존하고 있는 클래스들의 복잡성을 외부로부터 숨기는 것 즉 캡슐화에 중점을 둔다.&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;예를 들어 출발지 A에서 도착지 B로 가는 경로를 인터페이스로 정의한 경우, 실제 구현 객체에 따라 버스, 기차 또는 택시를 타고 갈 수도 있다. 즉 &lt;code&gt;AtoBRoute&lt;/code&gt; 인터페이스를 구현한 실제 클래스들 중 어떤 객체를 사용하느냐에 따라 교통 수단이 달라질 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1702800273546&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface AtoBRoute {

    public go();
}

public class BusRoute implements AtoBRoute {

    public go() {
        System.out.println(&quot;Go by bus from A to B&quot;);
    }
}

public class TrainRoute implements AtoBRoute {

    public go() {
        System.out.println(&quot;Go by train from A to B&quot;);
    }
}

public class TaxiRoute implements AtoBRoute {

    public go() {
        System.out.println(&quot;Go by taxi from A to B&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;rarr; 기차 &amp;rarr; 택시 &amp;rarr; 도보의 순서로 가야야지만 B에 도착할 수 있다고 해보자. A에서 B로 가는 방법이 오직 1가지인데 이 A에서 B로 가는 경로의 복잡성을 외부로부터 숨기기 위해 Facade를 사용할 수 있다. 즉 Facade는 단순히 여러 클래스들을 의존 및 조합하여 이러한 복잡성을 외부로부터 숨기는 것에 초점을 맞춘다. 다시 말해 Facade의 메소드는 현재 의존하고 있는 여러 객체들의 메소드를 특정 순서로 호출하여 좀더 절차 지향적으로 구성된다고 할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1702800283217&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class AtoBRoute {

    private BusRoute busRoute = new BusRoute();
    private TrainRoute trainRoute = new TrainRoute();
    private TaxiRoute taxiRoute = new TaxiRoute();
    private WalkRoute walkRoute = new WalkRoute();

    public void go() {
    	busRoute.go();
        trainRoute.go();
        taxiRoute.go();
        walkRoute.go();
    }
}

public class BusRoute {

    public void go() {
    	System.out.println(&quot;Go by bus&quot;);
    }
}

public class TrainRoute {

    public void go() {
    	System.out.println(&quot;Go by train&quot;);
    }
}

public class TaxiRoute {

    public void go() {
    	System.out.println(&quot;Go by taxi&quot;);
    }
}

public class WalkRoute {

    public void go() {
    	System.out.println(&quot;Go by walk&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;h3 data-ke-size=&quot;size23&quot;&gt;구성 요소&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade 패턴은 크게 Client, Facade, Subsystems로 구성된다. 위에서 한 설명으로도 충분하지만 각 요소의 기능과 역할을 정리해보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;608&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/badWFo/btsB7OItj4H/bNa2PEUZP7MrGSwVrfeMf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/badWFo/btsB7OItj4H/bNa2PEUZP7MrGSwVrfeMf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/badWFo/btsB7OItj4H/bNa2PEUZP7MrGSwVrfeMf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbadWFo%2FbtsB7OItj4H%2FbNa2PEUZP7MrGSwVrfeMf1%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;369&quot; data-origin-width=&quot;608&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Client&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade를 통해 Subsystems에 접근한다. 물론 직접 Subsystem을 호출할 수도 있다.&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;Facade + Additional Facade&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡하고 상호 의존적인 Subsystems에 대한 간단한 접근 방법, 즉 인터페이스를 클라이언트에게 제공한다. 또한 Subsystem의 일부 기능만을 클라이언트에게 제공하여 Subsystem에 대한 클라이언트의 무분별한 접근을 제한할 수도 있다. SRP 원칙에 따라 Facade 자체를 여러 개의 Facade로 나눌 수도 있으며, 이 나눠진 Additional Facade는 클라이언트 또는 다른 Facade에 의해 호출된다.&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;Subsystems&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다양한 기능을 하는 클래스들로 구성되며 Subsystems 간 의존성이 있을 수도 있다. 이 Subsystems는 Facade의 존재를 알지 못하며 따라서 Facade 없이 클라이언트가 직접 Subsystem에 접근할 수도 있다.&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;정리하자면 각 요소 간 의존성은 Client &amp;rarr; Facade &amp;rarr; Subsystems 방향으로만 발생한다.&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;언제 사용? 왜 사용?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;적용 상황 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 이 Facade 패턴은 어떤 상황에서 사용해야 효과적일까? 프로젝트에서 상호의존적인 클래스가 매우 많으며 특정 순서로 각 클래스의 메소드를 호출해야 할 때, 그리고 그 의존하고 있는 클래스의 일부 기능만을 필요로 하는 경우에 Facade 패턴을 적용할 수 있다. 예를 들어 아래와 같이 &lt;code&gt;Main&lt;/code&gt; 클래스에서 &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt; 클래스를 내부 필드로 의존하면서 &lt;code&gt;process&lt;/code&gt; 메소드에서는 각 클래스의 메소드인 &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c1&lt;/code&gt;&amp;nbsp;메소드를 순서대로 호출하고 있다면 이에 Facade 패턴을 적용할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1702814061915&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Main {

    private A a = new A();
    private B b = new B();
    private C c = new C();
    
    public void process() {
        a.a();
        b.c();
        c.c1();
    }
}

public class A {

    public void a() {}
}

public class B {

    public void b() {}
}

public class C {

    public void c1() {}
    public void c2() {}
}&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;Facade 패턴을 적용한 결과는 다음과 같다. &lt;code&gt;Main&lt;/code&gt; 클래스의 코드가 훨씬 깔끔해진 것을 알 수 있다. 만약 &lt;code&gt;Main&lt;/code&gt; 클래스 외에 동일한 기능을 제공받고자 하는 클래스가 또 존재한다면 클래스 내부에서 &lt;code&gt;Facade&lt;/code&gt; 객체를 생성하여 &lt;code&gt;process&lt;/code&gt;&amp;nbsp;메소드를 호출해주기만 하면 된다. 즉 &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt; 객체를 직접 생성하고 &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c1&lt;/code&gt;&amp;nbsp;메소드도 직접 호출할 필요가 없다.&lt;/p&gt;
&lt;pre id=&quot;code_1702814127415&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Main {

    private Facade facade = new Facade();
    
    public void process() {
        facade.process();
    }
}

public class Facade {

    private A a;
    private B b;
    private C c;
    
    public void process() {
        a.a();
        b.c();
        c.c1();
    }
}

public class A {

    public void a() {}
}

public class B {

    public void b() {}
}

public class C {

    public void c1() {}
    public void c2() {}
}&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;이외에도 Facade 패턴을 통해 얻을 수 있는 장점과 단점은 다음과 같다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;장점 및 단점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Subsystems의 복잡성을 Client로부터 숨길 수 있음&lt;br /&gt;2. Client - Subsystems 간의 Decoupling&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 중복 코드 감소 및 코드 재사용성 증가&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;단점으로는 Facade가 God Object가 될 수 있다는 것이다. 즉 Facade가 너무 많은 클래스를 의존하여 SRP 원칙을 위반하게 된다. 이 경우에는 Additional Facade를 별도로 정의하여 여러 개의 Facade로 분리해줌으로써 문제를 해결할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Facade 패턴 적용 예제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 Facade 패턴 자체에 대해서 알아보았다. 실제 문제 상황을 정의해보고 이를 코드를 구현해봄으로써 패턴에 대해 깊이 이해할 수 있을 것이다. 코드 구현에 앞서 문제 상황을 정의해보자.&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 구현&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;키오스크를 직접 사용하는 손님&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 패스트푸드점에 식사하러 들어온 손님이 수행하는 동작을 코드로 구현한 것이다. &lt;code&gt;Kiosk&lt;/code&gt; 클래스 구현은 그다지 중요하지 않다고 생각하여 생략하였으며, 처음 &lt;code&gt;Client&lt;/code&gt; 클래스의 &lt;code&gt;makeOrder&lt;/code&gt; 메소드와 다음 &lt;code&gt;Client&lt;/code&gt; 클래스의 &lt;code&gt;makeOrder&lt;/code&gt; 메소드 간의 차이점에 주목하면 좋을 것 같다. 아래 &lt;code&gt;Client&lt;/code&gt;는 키오스크의 사용법을 알고 있어 직접 &lt;code&gt;Kiosk&lt;/code&gt; 객체에 의존하여 주문을 생성하고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1702820710124&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Client {

    private Kiosk kiosk = new Kiosk();
    
    public void makeOrder() {
        kiosk.selectMenu(new Menu(&quot;빅맥&quot;));
        kiosk.selectHereOrToGo();
        kiosk.selectPaymentMethod();
        kiosk.pay(getCard());
    }
}&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;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 아래 &lt;code&gt;Client&lt;/code&gt;는 &lt;code&gt;Kiosk&lt;/code&gt;가 아닌 &lt;code&gt;KioskPartTimer&lt;/code&gt;에 의존하여 &lt;code&gt;Kiosk&lt;/code&gt;의 사용법을 알지 못해도 주문을 생성할 수 있다. 즉 여기서 &lt;code&gt;KioskPartTimer&lt;/code&gt;는 Facade 로서 동작하는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1702821341884&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Cient {

    private KioskPartTimer kioskPartTimer;
    
    public void makeOrder() {
        kioskPartTimer.service(new Menu(&quot;빅맥&quot;), getCard());
    }
}

public class KioskPartTimer() {

    private Kiosk kiosk;
    
    public void service(Menu menu, Card card) {
        kiosk.selectMenu(menu);
        kiosk.selectHereOrToGo();
        kiosk.selectPaymentMethod();
        kiosk.pay(card);
    }
}&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;&lt;code&gt;KioskPartTimer&lt;/code&gt;에 의존하는 손님은 키오스크의 사용법을 알지 못해도 되며 만약 &lt;code&gt;Kiosk&lt;/code&gt;의 사용법이 바뀐다 하더라도 &lt;code&gt;Client&lt;/code&gt; 코드는 변경되지 않고 오직 &lt;code&gt;KioskPartTimer&lt;/code&gt; 클래스만을 변경해주면 된다. &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;이것이 바로 Facade 패턴의 장점이다. 참고로 &lt;code&gt;Kiosk&lt;/code&gt; 클래스 또한 하나의 Facade로서 동작한다고 볼 수도 있는데, 키오스크는 복잡한 주문 시스템, 결제 시스템, 할인 시스템 등에 의존하며 이를 외부로부터 숨기기 때문이다. 따라서 손님은 키오스크가 제공하는 단순한? 인터페이스를 통해 주문을 생성할 수 있는 것이다.&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;&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;Facade 패턴에서 기억할 중요 키워드는 &lt;b&gt;Hide Complexity&lt;/b&gt;와 &lt;b&gt;Decoupling&lt;/b&gt;이다. 즉 긴밀히 연결되어 있던 두 계층 사이에 Facade를 끼워넣어 하위 계층의 복잡성을 숨겨주며 기존의 긴밀한 연결도 끊어주게 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Facade 패턴은 Layered Architecture인 Contoller - Service - Repository 계층 간에도 적용할 수 있다. 다음 글에서는 Spring에서 Controller - Service 사이에 Facade 계층을 추가하여 리팩토링한 경험을 정리해보고자 한다.&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;끝.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://refactoring.guru/design-patterns/facade&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://refactoring.guru/design-patterns/facade&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.geeksforgeeks.org/facade-design-pattern-introduction/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.geeksforgeeks.org/facade-design-pattern-introduction/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/java-facade-pattern&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/java-facade-pattern&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.tutorialspoint.com/design_pattern/facade_pattern.htm&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.tutorialspoint.com/design_pattern/facade_pattern.htm&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@andreaspoyias/design-patterns-a-quick-guide-to-facade-pattern-16e3d2f1bfb6&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@andreaspoyias/design-patterns-a-quick-guide-to-facade-pattern-16e3d2f1bfb6&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@juhaeradittya2000/facade-design-pattern-an-intuitive-overview-6be39e2b4413&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@juhaeradittya2000/facade-design-pattern-an-intuitive-overview-6be39e2b4413&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>자바/개념</category>
      <category>디자인패턴</category>
      <author>다음김</author>
      <guid isPermaLink="true">https://daeun21dev.tistory.com/39</guid>
      <comments>https://daeun21dev.tistory.com/39#entry39comment</comments>
      <pubDate>Sun, 17 Dec 2023 21:26:00 +0900</pubDate>
    </item>
  </channel>
</rss>