<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>y2g.log</title>
    <link>https://y2gcoder.tistory.com/</link>
    <description>개발자로서의 저에 대한 기억을 로깅하기 위한 블로그입니다.</description>
    <language>ko</language>
    <pubDate>Fri, 4 Sep 2026 11:16:39 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>문파관작</managingEditor>
    <item>
      <title>Datadog Agentlesss Logging 시 service 이름 추가하기</title>
      <link>https://y2gcoder.tistory.com/6</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 회사 내 Spring Boot 프로젝트 중 하나의 APM을 &lt;a href=&quot;https://www.datadoghq.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Datadog&lt;/a&gt;으로 변경하는 업무를 맡았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Datadog은 정보를 수집하고 싶은 서버 당 Agent를 설치하는 방식을 권장한다. 그에 따라 수많은 환경에서 Agent를 설치할 수 있도록 지원하고 있다.&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;824&quot; data-origin-height=&quot;696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZWBQ2/btr8T5MzMAf/ESxknj5YjyYGOa9TOBJFfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZWBQ2/btr8T5MzMAf/ESxknj5YjyYGOa9TOBJFfK/img.png&quot; data-alt=&quot;Datadog의 Agent를 설치할 수 있는 환경들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZWBQ2/btr8T5MzMAf/ESxknj5YjyYGOa9TOBJFfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZWBQ2%2Fbtr8T5MzMAf%2FESxknj5YjyYGOa9TOBJFfK%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;824&quot; height=&quot;696&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;696&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Datadog의 Agent를 설치할 수 있는 환경들&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 로그 수집에 따른 요금 산정을 위해 회사 내 프로젝트에 급하게 적용할 필요가 있었고, DevOps 쪽 자원이 부족해 프로젝트에서 Agent 없이 로깅하는 형태로 먼저 적용해야 했다.&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;1단계: 공식 문서를 따라가기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.datadoghq.com/logs/log_collection/java/?tab=log4j#agentless-logging&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 문서&lt;/a&gt;를 보면 아주 Log4j, Log4j2, Logback 등 로깅 라이브러리에 따라 Agentless Logging 방법을 안내하고 있다. 특별한 방법은 아니고 &lt;a href=&quot;https://github.com/logfellow/logstash-logback-encoder&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;logstash-logback-encoder&lt;/a&gt; 라는 라이브러리와 TCP 통신을 이용해서 애플리케이션의 로그를 json 형태로 보내주는 방법을 사용하는 것 같다. API key만 전송하는 로그의 prefix에 잘 넣어두면 Datadog에서 받아서 적재하는 방식으로 보이고, 적용이 매우 쉬웠다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;351&quot; data-origin-height=&quot;133&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdvzPN/btr9eBcqddI/r0tRlTYfzmdCOSQkLXcGSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdvzPN/btr9eBcqddI/r0tRlTYfzmdCOSQkLXcGSK/img.png&quot; data-alt=&quot;Datadog Logs Explorer에 적재된 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdvzPN/btr9eBcqddI/r0tRlTYfzmdCOSQkLXcGSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdvzPN%2Fbtr9eBcqddI%2Fr0tRlTYfzmdCOSQkLXcGSK%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;351&quot; height=&quot;133&quot; data-origin-width=&quot;351&quot; data-origin-height=&quot;133&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Datadog Logs Explorer에 적재된 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: 서비스명을 추가하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Agentless Logging을 통해 아주 쉽게 애플리케이션 로그를 Datadog에 적재할 수 있게 되었지만, 아쉬운 점이 하나 있었다. 위에 보이듯이 Agent를 사용하는 방법과 다르게 해당 로그의 서비스명이 보이지 않는 것이다. 로그의 발생위치라고 할 수 있는 서비스명을 보여줘야 하는 요구사항이 있었기 때문에 해당 방법을 강구해본 결과 prefix에 api key를 넣어 보내주는 방식이니 로그로 보내는 json에 service를 키로 하여 서비스명을 값으로 보내주면 되지 않을까라는 가설에 도달했다. 1단계에서 설치했던 logstash-logback-encoder 라이브러리에서는 &amp;lt;providers&amp;gt;와 &amp;lt;pattern&amp;gt; 의 조합으로 임의의 키와 값을 로그 json에 추가하는 것이 가능했다. 이 기능을 이용해서 나는&amp;nbsp; 다음과 같이 설정해줬다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1681098060579&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;encoder class=&quot;net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder&quot;&amp;gt;
	&amp;lt;destination&amp;gt;intake.logs.datadoghq.com:10516&amp;lt;/destination&amp;gt;
    &amp;lt;keepAliveDuration&amp;gt;20 seconds&amp;lt;/keepAliveDuration&amp;gt;
    &amp;lt;encoder class=&quot;net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder&quot;&amp;gt;
        &amp;lt;providers&amp;gt;
            &amp;lt;pattern&amp;gt;
                &amp;lt;pattern&amp;gt;{&quot;service&quot;: &quot;SERVICE_NAME_TEST&quot;}&amp;lt;/pattern&amp;gt;
            &amp;lt;/pattern&amp;gt;
            &amp;lt;timestamp /&amp;gt;
            &amp;lt;logLevel /&amp;gt;
            &amp;lt;threadName /&amp;gt;
            &amp;lt;mdc /&amp;gt;
            &amp;lt;message /&amp;gt;
            &amp;lt;loggerName /&amp;gt;
            &amp;lt;logstashMarkers /&amp;gt;
            &amp;lt;arguments /&amp;gt;
            &amp;lt;stackTrace /&amp;gt;
        &amp;lt;/providers&amp;gt;
        &amp;lt;prefix class=&quot;ch.qos.logback.core.encoder.LayoutWrappingEncoder&quot;&amp;gt;
            &amp;lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&amp;gt;
                &amp;lt;pattern&amp;gt;${API 키} %mdc{keyThatDoesNotExist}&amp;lt;/pattern&amp;gt;
            &amp;lt;/layout&amp;gt;
        &amp;lt;/prefix&amp;gt;
    &amp;lt;/encoder&amp;gt;
    &amp;lt;ssl /&amp;gt;
&amp;lt;/encoder&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위는 logback.xml의 일부분이다.&amp;nbsp;Datadog API 키를 변수로 분리했고, 로깅에 필요한 정보들은 이미 존재하는 provider들을 이용했다. 자세한 것은 &lt;a href=&quot;https://github.com/logfellow/logstash-logback-encoder#registering-additional-providers&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식문서&lt;/a&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;이렇게 하고 다시 Datadog에 연동된 로그를 보면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCudhj/btr9eBp8jyN/ZAzyzgDM4Wk3F9hkZur5mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCudhj/btr9eBp8jyN/ZAzyzgDM4Wk3F9hkZur5mk/img.png&quot; data-alt=&quot;서비스명이 들어가있는 Datadog Logs Explorer&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCudhj/btr9eBp8jyN/ZAzyzgDM4Wk3F9hkZur5mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCudhj%2Fbtr9eBp8jyN%2FZAzyzgDM4Wk3F9hkZur5mk%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;702&quot; height=&quot;158&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;158&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;서비스명이 들어가있는 Datadog Logs Explorer&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;3단계: 끝인 줄 알았다.(2023-04-11 추가)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끝인줄 알고 자신만만하게 PR 후 운영 배포만 기다리고 있는데&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1681187264452&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ERROR in ch.qos.logback.classic.PatternLayout(&quot;&quot;) - Empty or null pattern&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 에러가 발생하면서 배포가 되지 않았다. 분명 로컬 환경에서 진행할 때는 잘 실행되었기 때문에 너무 당황스러웠다. 해당 프로젝트의 배포환경은 AWS ECS였기 때문에 배포용 Docker 이미지인 amazoncorreto 쪽의 문제인 줄 알고 로컬에서 배포용 이미지를 바꿔서 Dockerfile을 실행해봐도 똑같은 에러가 발생했다.&amp;nbsp;배포쪽 설정만 건드리면서 계속 원인을 찾지 못하고 있다가 혹시나 해서 공식문서의 설정을 그대로 복사해서 Logback-spring.xml을 변경하고, Dockerfile을 실행하니 해당 에러가 사라졌다.&lt;/p&gt;
&lt;p 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;Logback&amp;nbsp;설정에&amp;nbsp;무지해서&amp;nbsp;그랬다는&amp;nbsp;것을&amp;nbsp;깨닫고&amp;nbsp;찾아본&amp;nbsp;결과,&amp;nbsp;알고보니&amp;nbsp;바꾸면서&amp;nbsp;사용했던&amp;nbsp;LoggingEventCompositeJsonEncoder는&amp;nbsp;LogstashEncoder처럼&amp;nbsp;&amp;lt;prefix&amp;gt;와&amp;nbsp;함께&amp;nbsp;사용할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;것이&amp;nbsp;아니었다.&amp;nbsp;둘다&amp;nbsp;로그&amp;nbsp;이벤트를&amp;nbsp;JSON&amp;nbsp;형식으로&amp;nbsp;인코딩하는데&amp;nbsp;사용하는&amp;nbsp;것은&amp;nbsp;똑같으나&amp;nbsp;사용법에서&amp;nbsp;차이가&amp;nbsp;났던&amp;nbsp;것이다.&amp;nbsp;logstash-logback-encoder의&amp;nbsp;공식문서에서&amp;nbsp;살펴본&amp;nbsp;결과&amp;nbsp;LogstashEncoder는&amp;nbsp;기본적으로&amp;nbsp;사용하는&amp;nbsp;필드들이&amp;nbsp;고정되어&amp;nbsp;있고&amp;nbsp;&amp;lt;customFields&amp;gt;를&amp;nbsp;통해&amp;nbsp;원하는&amp;nbsp;값을&amp;nbsp;추가할&amp;nbsp;수&amp;nbsp;있었고,&amp;nbsp;prefix&amp;nbsp;+&amp;nbsp;layout을&amp;nbsp;사용할&amp;nbsp;수&amp;nbsp;있었다.&amp;nbsp;반면&amp;nbsp;LoggingEventCompositeJsonEncoder는&amp;nbsp;providers를&amp;nbsp;이용해서&amp;nbsp;원하는&amp;nbsp;필드들을&amp;nbsp;집합적으로&amp;nbsp;다룰&amp;nbsp;수&amp;nbsp;있되,&amp;nbsp;LogstashEncoder를&amp;nbsp;사용하려면&amp;nbsp;사용자가&amp;nbsp;직접&amp;nbsp;정의한&amp;nbsp;provider를&amp;nbsp;사용해야&amp;nbsp;했다.&lt;br /&gt;&lt;br /&gt;결국&amp;nbsp;LogstashEncoder의&amp;nbsp;사용방법과&amp;nbsp;LoggingEventCompositeJsonEncoder의&amp;nbsp;사용방법을&amp;nbsp;섞어서&amp;nbsp;사용하려고&amp;nbsp;하니&amp;nbsp;배포&amp;nbsp;단계에서&amp;nbsp;에러가&amp;nbsp;난&amp;nbsp;것으로&amp;nbsp;보였다.&amp;nbsp;공식문서를&amp;nbsp;보니&amp;nbsp;사용자가&amp;nbsp;직접&amp;nbsp;정의한&amp;nbsp;필드를&amp;nbsp;추가하는&amp;nbsp;것도&amp;nbsp;LogstashEncoder를&amp;nbsp;사용해서&amp;nbsp;할&amp;nbsp;수&amp;nbsp;있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1681188222020&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;...
&amp;lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;&amp;gt;
    &amp;lt;customFields&amp;gt;{&quot;service&quot;: &quot;${ddServiceName}&quot;}&amp;lt;/customFields&amp;gt;
    &amp;lt;prefix class=&quot;ch.qos.logback.core.encoder.LayoutWrappingEncoder&quot;&amp;gt;
        &amp;lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&amp;gt;
            &amp;lt;pattern&amp;gt;${API 키} %mdc{keyThatDoesNotExist}&amp;lt;/pattern&amp;gt;
        &amp;lt;/layout&amp;gt;
    &amp;lt;/prefix&amp;gt;
&amp;lt;/encoder&amp;gt;
...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위처럼 해주면 기존과 똑같이 작동하면서 에러도 발생하지 않고 도커 컨테이너가 작동했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치면서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서는 서비스명을 하드코딩했지만, 실제로는 서비스명을 동적으로 바꿀 수 있게 하기 위해 API 키와 같은 방식으로 분리했다.&amp;nbsp;기본적으로는 Datadog에서 권장하는 방식에 따라 APM이 필요한 서버마다 Agent를 설치하는 방식이 좋지만, 정말 급하게 로그만 확인해야 한다면 다음과 같은 방식으로 해볼 수 있을 것 같다.&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;(2023-04-11 추가)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 작성하고 나서 기본적으로 해줘야 하는 테스트를 꼼꼼하게 해줬다면 해당 문제는 발생하지 않았을텐데 내 자신이 너무 아쉬웠다. 최소한 배포 환경의 image를 사용하는 Dockerfile을 만들어서 실행해봤다면 사전에 방지할 수 있는 문제였다고 생각한다. 또한 라이브러리에 대한 이해가 부족한 상태로 적용하려는 데만 급급해서 이러한 에러를 만났다고 반성했다. 라이브러리를 적용하고 해당 기능을 운영에 반영하기 전에 좀 더 꼼꼼하게 테스트하고, 라이브러리를 막 사용하기 전에 공식문서를 좀 더 꼼꼼하게 읽어봐야겠다고 반성하게 되는 계기가 되었다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Java/Spring Boot</category>
      <category>DataDog</category>
      <category>java</category>
      <category>logback</category>
      <category>Spring Boot</category>
      <author>문파관작</author>
      <guid isPermaLink="true">https://y2gcoder.tistory.com/6</guid>
      <comments>https://y2gcoder.tistory.com/6#entry6comment</comments>
      <pubDate>Mon, 10 Apr 2023 22:00:02 +0900</pubDate>
    </item>
    <item>
      <title>MultipartFile과 TeeFilter를 같이 사용하지 말자</title>
      <link>https://y2gcoder.tistory.com/5</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;분명 파일을 같이 보냈는데 요청 파일을 찾을 수 없다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 받은 프로젝트에 대해서 전체적으로 테스트하고 결과를 기록하던 중에 이미지를 업로드하는 API에서 요청 파일을 MultipartFile로 받지 못하는 문제가 발생했다.&lt;/p&gt;
&lt;pre id=&quot;code_1680571072053&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostMapping(&quot;/image-upload&quot;)
public ResponseEntity&amp;lt;Void&amp;gt; imageUpload(@RequestPart(value=&quot;file&quot;) MultipartFile multipartFile) {
    //... 이미지 업로드 로직
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 API endpoint에 대해 작성되어있는 테스트 코드는 통과했기 때문에 애플리케이션 구동 시 &lt;i&gt;request part 'file' is not present&amp;nbsp;&lt;/i&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;p data-ke-size=&quot;size16&quot;&gt;처음에는 컨트롤러 자체에 문제가 있다고 생각해서 작성된 컨트롤러 코드를 살펴봤지만 당연히 문제가 없었다. 다음으로 테스트 코드도 확인해봤으나 테스트 코드도 문제는 없었다. 그러다 테스트 코드가 1) @WebMvcTest로 작성되어 있고, 2) 테스트 코드에서는 해당 로직이 통과하고, 3) 실제 애플리케이션 구동 후 테스트할 때는 에러가 발생한다는 것을 근거로 컨트롤러 단 밖의 코드에 문제가 있을 것으로 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째로는 애플리케이션을 구동해서 해당 요청을 디버깅했다. 실제로 요청을 처리하는 DispatchServlet doDispatch에서 MultipartFile 용 Request 객체인&amp;nbsp; StandardMultipartHttpServletRequest 로 request 객체를 받고 있지만 안의 multipart 관련 내용들은 전부 빈 값으로 받는 것을 알 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;552&quot; data-origin-height=&quot;65&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cLzXpc/btr7QmhsRst/6fiakztTJxNdNyKJXhIDsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cLzXpc/btr7QmhsRst/6fiakztTJxNdNyKJXhIDsk/img.png&quot; data-alt=&quot;multipartFile 데이터가 소실된 StandardMultipartHttpServletRequest 객체&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cLzXpc/btr7QmhsRst/6fiakztTJxNdNyKJXhIDsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcLzXpc%2Fbtr7QmhsRst%2F6fiakztTJxNdNyKJXhIDsk%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;552&quot; height=&quot;65&quot; data-origin-width=&quot;552&quot; data-origin-height=&quot;65&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;multipartFile 데이터가 소실된 StandardMultipartHttpServletRequest 객체&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;166&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lvIB7/btr7TZskour/xLoGMHKkp7HEkxFIRsy7dk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lvIB7/btr7TZskour/xLoGMHKkp7HEkxFIRsy7dk/img.png&quot; data-alt=&quot;정상적인 StandardMultipartHttpServletRequest 객체&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lvIB7/btr7TZskour/xLoGMHKkp7HEkxFIRsy7dk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlvIB7%2Fbtr7TZskour%2FxLoGMHKkp7HEkxFIRsy7dk%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;500&quot; height=&quot;166&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;166&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;정상적인 StandardMultipartHttpServletRequest 객체&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 디버깅 과정을 통해 프로젝트 내에서 HTTP 요청을 커스터마이징 하는 필터들 중 하나가 파일 요청을 임의로 변경하고 있다고 판단했다. 프로젝트에서 필터를 추가하는 부분은 크게 Spring Security 관련 부분과 HTTP 요청/응답 로깅 부분이 있었다. 처음에는 스프링 시큐리티의 필터들이 MultipartFile 관련 필터보다 먼저 적용되기 때문에 문제가 생기는 것으로 생각하여 스프링 시큐리티의 필터 적용 순서를 미루기도 해봤으나 다른 에러만 발생하고 문제가 해결되지 않았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 해당 버그가 발생하는 원인은 HTTP 요청/응답을 로깅하기 위해 재정의해서 등록한&amp;nbsp;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://github.com/akkinoc/logback-access-spring-boot-starter&quot;&gt;logback-access 라이브러리&lt;/a&gt;의 &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://logback.qos.ch/access.html#teeFilter&quot;&gt;TeeFilter&lt;/a&gt; 와 MultipartFile의 충돌 때문이었다. 먼저 TeeFilter는 원본의 HTTP 요청의 inputStream을 감싸주게 된다.&amp;nbsp; 그 후에 MultipartFile에서 원본 요청의 inputStream을 사용하려고 시도한다. 해당 과정에서 TeeFilter가 감싼 inputStream과 충돌하게 되고 그로 인해 요청 데이터가 손실되는 버그가 발생하게 되는 것이었다.&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;해결방안은 다음과 같았다.&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;TeeFilter에 파일 업로드 관련 url 패턴을 제외하는 규칙을 추가해준다.&lt;/li&gt;
&lt;li&gt;TeeFilter를 사용하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 TeeFilter에 파일 업로드와 관련된 url 패턴을 제외하는 규칙을 추가해주는 방법은 설정 파일에 설정값을 추가해주는 방법과 직접 TeeFilter를 등록할 때 규칙을 추가해주는 방법이 있었다. 먼저 설정 파일에 설정값을 추가해주는 방법은 application.yml 기준으로 다음과 같이 추가해주면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1680583741188&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;logging:
  filter:
    tee:
      exclude-path-patterns: /file-upload/**&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TeeFilter를 직접 추가해주는 방법은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1680583786841&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class TeeFilterConfiguration {

    @Bean
    public FilterRegistrationBean&amp;lt;TeeFilter&amp;gt; teeFilter() {
        FilterRegistrationBean&amp;lt;TeeFilter&amp;gt; registrationBean = new FilterRegistrationBean&amp;lt;&amp;gt;();
        TeeFilter teeFilter = new TeeFilter();

        registrationBean.setFilter(teeFilter);
        registrationBean.addUrlPatterns(&quot;/*&quot;);
        registrationBean.addInitParameter(&quot;excludePathPatterns&quot;, &quot;/image-upload/**&quot;);

        return registrationBean;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 두 방법 중 하나를 선택해 MultipartFile을 사용하는 요청에서는 TeeFilter을 적용하지 않도록 해주면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TeeFilter를 사용하지 않고 직접 HTTP 요청과 응답을 로깅하는 필터나 인터셉터를 등록하는 방법도 있다. TeeFilter가 편해보이긴 하지만 공식문서에서도 운영 환경에서의 적용을 추천하지 않고 있기 때문에, HTTP 요청과 응답을 로깅해주는 필터나 인터셉터를 따로 만들어 등록하는 것도 괜찮을 방법이 될 것 같다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TL;DR&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MultipartFile을 사용하는 요청 URL는 TeeFilter와 충돌이 발생한다.&lt;/li&gt;
&lt;li&gt;TeeFilter를 사용하지 않는 것도 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 문헌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://logback.qos.ch/access.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://logback.qos.ch/access.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1680584437745&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;Logback-access&quot; data-og-description=&quot;HTTP-access logs with logback-access, Jetty and Tomcat Authors: Ceki G&amp;uuml;lc&amp;uuml;, S&amp;eacute;bastien Pennec --&amp;gt; Introduction The logback-access module, part of the standard logback distribution, integrates with Servlet containers such as Jetty or Tomcat to provide ric&quot; data-og-host=&quot;logback.qos.ch&quot; data-og-source-url=&quot;https://logback.qos.ch/access.html&quot; data-og-url=&quot;https://logback.qos.ch/access.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://logback.qos.ch/access.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://logback.qos.ch/access.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;Logback-access&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;HTTP-access logs with logback-access, Jetty and Tomcat Authors: Ceki G&amp;uuml;lc&amp;uuml;, S&amp;eacute;bastien Pennec --&amp;gt; Introduction The logback-access module, part of the standard logback distribution, integrates with Servlet containers such as Jetty or Tomcat to provide ric&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;logback.qos.ch&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/akkinoc/logback-access-spring-boot-starter&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://github.com/akkinoc/logback-access-spring-boot-starter&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1680585196306&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - akkinoc/logback-access-spring-boot-starter: Spring Boot Starter for Logback-access.&quot; data-og-description=&quot;Spring Boot Starter for Logback-access. Contribute to akkinoc/logback-access-spring-boot-starter development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/akkinoc/logback-access-spring-boot-starter&quot; data-og-url=&quot;https://github.com/akkinoc/logback-access-spring-boot-starter&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/F1awy/hyR9Fiowea/eNYX27pxkFEnsuD8BkEY80/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/akkinoc/logback-access-spring-boot-starter&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/akkinoc/logback-access-spring-boot-starter&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/F1awy/hyR9Fiowea/eNYX27pxkFEnsuD8BkEY80/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - akkinoc/logback-access-spring-boot-starter: Spring Boot Starter for Logback-access.&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Spring Boot Starter for Logback-access. Contribute to akkinoc/logback-access-spring-boot-starter development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.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>Java/Spring Boot</category>
      <category>logback-access</category>
      <category>MultipartFile</category>
      <category>Spring Boot</category>
      <category>TeeFilter</category>
      <author>문파관작</author>
      <guid isPermaLink="true">https://y2gcoder.tistory.com/5</guid>
      <comments>https://y2gcoder.tistory.com/5#entry5comment</comments>
      <pubDate>Tue, 4 Apr 2023 20:00:42 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot로 만드는 API 서버 템플릿 (2) - 맛있는 Refresh Token을 위한 여정</title>
      <link>https://y2gcoder.tistory.com/4</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@y2gcoder/Spring-Boot%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-API-%EC%84%9C%EB%B2%84-%ED%85%9C%ED%94%8C%EB%A6%BF-1&quot;&gt;이전 글&lt;/a&gt; 에서 언급했던 것과 같이, API 템플릿에서 Refresh Token에 대해서 개발한 부분이 불편했다. 템플릿을 본격적으로 사용하기 전에 고쳐야 마음 편하게 API 템플릿을 사용할 수 있을 거라고 생각해서 이번에는 맛있는 Refresh Token을 만들고자 노력해봤다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 변경에 대한 고민 과정을 담는 것이 의미있을 것 같아서 Refresh Token 변경 과정을 모두 기록하고자 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;github repo: &lt;a href=&quot;https://github.com/y2gcoder/spring-api-backend-template&quot;&gt;https://github.com/y2gcoder/spring-api-backend-template&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Refresh Token을 따로 분리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh Token을 맛있게 바꾸기 위해서 제일 먼저 한 것은 Refresh Token을 분리하는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존 Member Entity&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, length = 50, nullable = false)
    private String email;

    @Column(length = 200)
    private String password;

    private String nickname;

    private String profile;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private MemberRole role;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private AuthProvider provider;

    private String refreshToken;

    private LocalDateTime tokenExpirationTime;

    @Builder
    public Member(
            String email, String password, String nickname, String profile, MemberRole role, AuthProvider provider
    ) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
        this.profile = profile;
        this.role = role;
        this.provider = provider;
    }

    public void updateRefreshToken(String refreshToken, LocalDateTime tokenExpirationTime) {
        this.refreshToken = refreshToken;
        this.tokenExpirationTime = tokenExpirationTime;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 편의를 위해 Member Entity에서 Refresh Token을 저장하고 있었다. 그래서 Refresh Token을 변경하면 결국 Member Entity와 관련한 부분들을 전부 수정해줘야 했다. Refresh Token 기능을 변경하기 위해 Member 쪽을 변경해야 한다는 것은 나쁜 설계라고 생각했다. 그래서 후의 변경을 위해 Refresh Token을 Member에서 독립해주기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변경 후 Member Entity&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, length = 50, nullable = false)
    private String email;

    @Column(length = 200)
    private String password;

    private String nickname;

    private String profile;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private MemberRole role;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private AuthProvider provider;

    @Builder
    public Member(
            String email, String password, String nickname, String profile, MemberRole role, AuthProvider provider
    ) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
        this.profile = profile;
        this.role = role;
        this.provider = provider;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh Token과 관련된 부분을 제거하고 나니 코드가 더 깔끔해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Refresh Token Entity&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class RefreshToken extends BaseTimeEntity {

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

    private Long memberId;

    private String refreshToken;

    private LocalDateTime tokenExpirationTime;

    @Builder
    public RefreshToken(Long memberId, String refreshToken, LocalDateTime tokenExpirationTime) {
        this.memberId = memberId;
        this.refreshToken = refreshToken;
        this.tokenExpirationTime = tokenExpirationTime;
    }

    public void updateRefreshToken(String refreshToken, LocalDateTime tokenExpirationTime) {
        this.refreshToken = refreshToken;
        this.tokenExpirationTime = tokenExpirationTime;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 변경 없이 Member Entity에 존재하던 Refresh Token 관련 부분을 분리해 독립된 Entity로 만들었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. Member Entity와 연관관계를 맺어주지 않은 이유는?&lt;br /&gt;A. 비용을 따져봤을 때 연관관계 없이 설정해주는 것이 맞을 것 같다고 결론을 내렸다. 연관관계를 맺어준다면 Refresh Token Entity를 조회할 때 편하게 Member Entity의 정보도 같이 불러올 수 있겠지만, 그럴 일이 많지 않을 거라 생각했고, 또 Refresh Token을 변경할지도 모르기 때문에 최대한 독립적인 엔티티로 만들고자 했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 RefreshToken에 대한 Service, Repository를 만들고 AuthService에서 로그인, 토큰 재발급, 로그아웃 로직을 변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AuthService&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AuthService {
    private final JwtTokenProvider jwtTokenProvider;
    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;
    private final RefreshTokenService refreshTokenService;

    ...

    @Transactional
    public JwtTokenDto signIn(SignInDto.Request request) {
        Member member = memberService.findMemberByEmail(request.getEmail());
        validateMemberAuthProvider(member.getProvider());
        validatePassword(request.getPassword(), member.getPassword());
        // 토큰 만들기(access, refresh)
        JwtTokenDto jwtTokenDto = jwtTokenProvider.createJwtToken(String.valueOf(member.getId()), member.getRole());
        // refresh token 저장 (DB)
        refreshTokenService.updateRefreshToken(
                member.getId(),
                jwtTokenDto.getRefreshToken(),
                jwtTokenDto.getRefreshTokenExpireTime()
        );

        return jwtTokenDto;
    }

    ...

    public TokenRefreshResponse refreshToken(String refreshToken) {
        validateRefreshToken(refreshToken);

        RefreshToken refreshTokenEntity = refreshTokenService.findTokenByRefreshToken(refreshToken);
        Member member = memberService.findMemberById(refreshTokenEntity.getMemberId());
        Date accessTokenExpireTime = jwtTokenProvider.createAccessTokenExpireTime();
        String accessToken =
                jwtTokenProvider.createAccessToken(String.valueOf(member.getId()), member.getRole(), accessTokenExpireTime);
        return TokenRefreshResponse.builder()
                .grantType(GrantType.BEARER.getType())
                .accessToken(accessToken)
                .accessTokenExpireTime(DateTimeUtils.convertToLocalDateTime(accessTokenExpireTime))
                .build();
    }

    ...

    @Transactional
    public void signOut(Long memberId) {
        refreshTokenService.removeRefreshToken(memberId);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;signIn(): 로그인할 때 Member가 아닌 RefreshToken에 저장하도록 변경했다.&lt;/li&gt;
&lt;li&gt;refreshToken(): 기존에는 refreshToken으로 바로 해당 회원을 찾을 수 있었지만, 이제는 RefreshToken에 있는 memberId를 통해 회원을 찾을 수 있도록 변경했다.&lt;/li&gt;
&lt;li&gt;signOut(): 기존처럼 해당 Member Entity를 조회해서 값을 update할 필요없이, memberId에 해당하는 RefreshToken을 삭제해주도록 변경했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 부분도 변경한 부분에 맞게 수정한 뒤 테스트를 시행해서 검증했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/cd649e76-45eb-4b09-a550-bfd186ea2f0d/image.png&quot; alt=&quot;테스트 결과 1&quot; /&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 실행해서 모두 통과할 때 오는 안정감은 최고인 것 같다.&lt;/p&gt;
&lt;del&gt;소셜 로그인 쪽은 테스트를 만들지 못해 그냥 직접 로그인해봤다.&lt;/del&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 변경을 위한 준비는 모두 마쳤다. 다음 단계로 넘어가도록 하겠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Cookie로 반환하지 말고 응답으로 보내기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음으로 고민한 것은 정말 Refresh Token을 Cookie로만 클라이언트에 제공해야 하는지에 대한 것이었다. Refresh Token을 Cookie에 담아서 보내게 된 것은 클라이언트 사이드에서 저장할 것을 생각했기 때문이다. 그런데 클라이언트에서 저장하는 곳을 미리 서버쪽에서 정해도 될까하는 의문이 들었다. 클라이언트에서는 local storage에 저장할 수도 있고 아니면 또 다른 곳에 저장할 수도 있을 것이다. 심지어 &lt;a href=&quot;https://www.bloter.net/newsView/blt202106250009&quot;&gt;구글은 크롬에서 쿠키를 퇴출&lt;/a&gt;하겠다고 한다.&lt;br /&gt;서버 사이드에서 할 역할은 어쩌면 로그인했을 때 딱 한 번만 Refresh Token을 전달해주는 것으로 충분하다는 생각이 들었다. 이미 서버 사이드에서 Refresh Token을 저장하고 있기 때문에, 더 서버 사이드에서 클라이언트 사이드에서의 refresh token 저장소를 임의로 정해줄 필요가 없다는 생각이 들었다. 또한 부수적인 이유로 DB에도 refresh token을 저장하고 있는데 쿠키로 refresh token을 저장하려고 하니, 똑같은 저장 작업, 수정 작업, 삭제 작업을 중복으로 하는 느낌이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 서버 사이드에서 Refresh Token을 Cookie와 분리해줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OAuth2AuthenticationSuccessHandler&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final OAuth2Config oAuth2Config;
    private final RefreshTokenService refreshTokenService;
    private final CustomAuthorizationRequestRepository authorizationRequestRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.debug(&quot;Response has already been committed!!!!&quot;);
            return;
        }

        String targetUrl = determineTargetUrl(request, response, authentication);
        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(
            HttpServletRequest request, HttpServletResponse response, Authentication authentication
    ) {

        ...

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam(&quot;grant&quot;, jwtTokenDto.getGrantType())
                .queryParam(&quot;access&quot;, jwtTokenDto.getAccessToken())
                .queryParam(&quot;refresh&quot;, jwtTokenDto.getRefreshToken())
                .build().toUriString();
    }

    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소셜 로그인 시에 refresh token도 반환하도록 변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AuthController&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@RequestMapping(&quot;/api/auth/&quot;)
@RestController
public class AuthController {

    private final AuthService authService;

    ...

    @PostMapping(&quot;/sign-in&quot;)
    public ResponseEntity&amp;lt;SignInDto.Response&amp;gt; signIn(@Valid @RequestBody SignInDto.Request req) {
        SignInDto.Response result = authService.signIn(req);
        return ResponseEntity.ok().body(result);
    }

    @PostMapping(&quot;/refresh&quot;)
    public ResponseEntity&amp;lt;TokenRefreshDto.Response&amp;gt; refreshToken(@Valid @RequestBody TokenRefreshDto.Request request) {
        return ResponseEntity.ok(authService.refreshToken(request));
    }

    @PostMapping(&quot;/sign-out&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; signOut(@SignInMember SignInMemberDto signInMemberDto) {
        authService.signOut(signInMemberDto.getMemberId());
        return ResponseEntity.ok().build();
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cookie 관련 로직이 사라지니 코드가 굉장히 깔끔해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MemberController&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@RequestMapping(&quot;/api/members&quot;)
@RestController
public class MemberController {

    private final MemberService memberService;

    @PreAuthorize(&quot;@memberGuard.check(#id)&quot;)
    @DeleteMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; withdrawMember(@PathVariable Long id) {
        memberService.withdrawMember(id);
        return ResponseEntity.ok().build();
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본인이 직접 회원 탈퇴를 할 때, 로그아웃과 같은 처리(Refresh Token 쿠키 삭제)를 해줬는데, 그걸 제거해줬다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/c5a30f4b-43e9-40a4-a166-eaa3cb170af8/image.png&quot; alt=&quot;테스트 결과 2&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트도 통과했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Refresh Token에 대한 보안을 강화하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 Refresh Token을 Cookie에서 제거한 이후에도 여전히 Refresh Token에 대해 불편한 점이 있었다. 현재 Refresh Token은 JWT이고, 동시에 서버(정확히는 DB)에서 보관하고 있는 형태이다. 그래서 Refresh Token을 이용한 Access Token 재발급 프로세스에서 Refresh Token에 대한 유효성 검사를 JWT에서 한 번, 서버에서 한 번을 해줘 총 2번 씩 해주고 있었다. 처음에 코딩할 때는 유효성 검사를 두 번 하니까 보안적인 측면에서 좋을 것이라 생각했는데, 점점 불필요한 과정이라는 생각이 들었다.&lt;br /&gt;그 때 &lt;a href=&quot;https://hudi.blog/refresh-token/&quot;&gt;hudi님의 블로그 글&lt;/a&gt;을 보고 내 API Template에 RTR(Refresh Token Rotation)을 적용하기로 했다. RTR은 간단하게 말하자면 Refresh Token을 일회용으로 만들어 Refresh Token에 대한 보안을 강화하는 기법이다. 사실 이전에 적용해놓고 Access Token 만료될 때마다 Refresh Token을 재생성하는 게 낭비처럼 느껴져 토큰 재발급 시 Access Token만 재발급하는 것으로 바꿨던 부분이다. 그런데 어차피 토큰 재발급 시에 API 요청을 통해 서버로 요청이 들어오는 것은 확정이기도 하고 글에서도 보안을 강화한다는 장점을 말해주고 있으니 이전에 했던 대로 토큰 재발급 시 Refresh Token도 재발급해주기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AuthService&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Service
public class AuthService {
    private final JwtTokenProvider jwtTokenProvider;
    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;
    private final RefreshTokenService refreshTokenService;

    ...

    public TokenRefreshDto.Response refreshToken(TokenRefreshDto.Request request) {
        validateRefreshToken(request.getRefreshToken());

        RefreshToken refreshTokenEntity = refreshTokenService.findTokenByRefreshToken(request.getRefreshToken());
        Member member = memberService.findMemberById(refreshTokenEntity.getMemberId());
        // Access Token, Refresh Token 재발급
        JwtTokenDto jwtTokenDto = jwtTokenProvider.createJwtToken(String.valueOf(member.getId()), member.getRole());
        // 재발급한 Refresh Token DB 저장
        refreshTokenService.updateRefreshToken(
                member.getId(),
                jwtTokenDto.getRefreshToken(),
                jwtTokenDto.getRefreshTokenExpireTime()
        );

        return TokenRefreshDto.Response.from(jwtTokenDto);
    }

    ...

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/c2344051-34a8-4d68-be74-e20fab90ef12/image.png&quot; alt=&quot;테스트 결과 3&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트도 당연히 해줬다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 총 3번의 과정을 거쳐서 Refresh Token을 보완해나가는 과정을 기록했다. 사실 이번 변화에서 제일 의미가 있었던 부분은 1.에서 Refresh Token을 Member 로직에서 분리한 것이 아닐까 싶다. 분리하고 나니 이전보다 Member와 Refresh Token 모두 변경이 수월해진 것 같아 다음 변경을 기대하고 있다.&lt;br /&gt;지금은 Refresh Token의 저장소를 변경하는 것에 대해 고민하고 있다. 현재 API 템플릿에서는 DB에 Refresh Token을 저장하고 있는데, 지금은 이걸로도 충분할 것 같다고 생각하고 있다. 개인적으로 Refresh Token 저장소에 대해 찾아봤을 때 Redis를 사용하는 방식을 적용해보고 싶었는데, 하게 되면 다음에 기록으로 남기겠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;여담&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/a0d7e35a-ab1f-4b5c-93fd-5e3cd1ccab9c/image.png&quot; alt=&quot;내다버린 내 시간들&quot; /&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 후디님 글을 보고 처음에 너무 감동해 무지성으로 후디님처럼 Refresh Token을 JWT가 아닌 UUID로 바꿔서 push까지 했다가 다시 되돌렸다. 브랜치 새로 생성해서 하지 않고, 그냥 commit, push부터 한 내 잘못이다.&lt;br /&gt;Refresh Token을 JWT가 아닌 UUID로 바꿨다가 다시 롤백한 이유는 내가 감정적으로 변경하려고 시도했기 때문이다. 다시 생각해보니 지금 API 템플릿의 설계에서는 Refresh Token이 JWT더라도 DB에 Refresh Token을 저장해주고 있기 때문에 보안적인 측면에서 보완이 될 것이라고 생각했다. 그래서 일단 원래대로 사용해보고 개선해야 할 필요성을 느낄 때 다시 고민해보기로 했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;References&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://hudi.blog/refresh-token/&quot;&gt;https://hudi.blog/refresh-token/&lt;/a&gt;&lt;/p&gt;</description>
      <category>Java/Spring Boot</category>
      <category>refresh token</category>
      <category>Spring Boot</category>
      <author>문파관작</author>
      <guid isPermaLink="true">https://y2gcoder.tistory.com/4</guid>
      <comments>https://y2gcoder.tistory.com/4#entry4comment</comments>
      <pubDate>Tue, 21 Mar 2023 14:10:36 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot로 만드는 API 서버 템플릿 (1) - 시작</title>
      <link>https://y2gcoder.tistory.com/3</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;제작 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자로 일하면서 봤던 수많은 소프트웨어 개발 관련 용어들 중 개인적으로 제일 마음에 들었던 용어 &lt;b&gt;DRY&lt;/b&gt;(Don't repeat yourself)였다. 언젠가부터 프로젝트를 시작할 때마다 환경 설정하는 과정이 너무 지루하게 느껴졌다. 똑같은 라이브러리를 추가하고, 해당 라이브러리에서 자주 사용하는 설정을 추가해주는 과정을 반복하는 것은 내가 좋아하는 DRY한 과정이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 기회에 개인적으로 스프링 부트를 이용해 프로젝트를 진행할 때마다 반복적으로 했던 기본 설정들을 Github 템플릿으로 만들어보기로 했다. 템플릿으로 만들어두면 나중에 개인 프로젝트를 진행할 때 초반 설정에 드는 시간을 절약할 수 있을 것이란 생각이 들었다. &lt;a href=&quot;https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-api-%ED%85%9C%ED%94%8C%EB%A6%BF&quot;&gt;인프런 강의&lt;/a&gt;을 참고해서 내 개인 설정과, 평소 적용하고 싶었던 라이브러리와 설정을 추가해서 내 개인 템플릿을 만들기로 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;제품 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;github repo: &lt;a href=&quot;https://github.com/y2gcoder/spring-api-backend-template&quot;&gt;https://github.com/y2gcoder/spring-api-backend-template&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패키지 구조&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/852dc9ad-f87b-4691-b06a-8dea69626d55/image.png&quot; alt=&quot;구조&quot; /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;java&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 템플릿에서 사용한 패키지 구조는 역할에 따라 패키지를 나누고, 그 안에 도메인 별로 패키지를 나눈 패키지 구조다. 도메인 별로 나눈 패키지 안에서는 계층형 아키텍처를 적용해 위치에 따라 controller, service, repository 패키지로 나눴다. dto 패키지들은 순환참조를 피하기 위해 의존관계에 따라 위치했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;api: 클라이언트에게 제공할 API 엔드포인트들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;domain: 도메인에 따라 핵심 비즈니스 로직을 구현한 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;global: 애플리케이션 내에서 전체적으로 사용하는 클래스들이 존재하는 패키지
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;config: 애플리케이션의 설정과 관련한 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;error: 예외 처리와 관련한 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;jwt: JWT 기반의 인증 처리를 담당하는 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;resolver: argument resolver 와 관련한 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;security: Spring Security, OAuth2 처리와 관련한 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;li&gt;util: 유틸리티성 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;infra: SMS, 이메일 등의 외부 서비스와 관련한 클래스들이 존재하는 패키지&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;resources&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;application-*.yml: 애플리케이션 설정 파일&lt;/li&gt;
&lt;li&gt;lucy-xss-**.yml: naver에서 만든 xss 공격에 대응하는 라이브러리 &lt;a href=&quot;https://github.com/naver/lucy-xss-servlet-filter&quot;&gt;lucy-xss-servlet&lt;/a&gt; 의 설정파일&lt;/li&gt;
&lt;/ul&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;h5&gt;Member&lt;/h5&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, length = 50, nullable = false)
    private String email;

    @Column(length = 200)
    private String password;

    private String nickname;

    private String profile;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private MemberRole role;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private AuthProvider provider;

    private String refreshToken;

    private LocalDateTime tokenExpirationTime;

    @Builder
    public Member(
            String email, String password, String nickname, String profile, MemberRole role, AuthProvider provider
    ) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
        this.profile = profile;
        this.role = role;
        this.provider = provider;
    }

    public void updateRefreshToken(String refreshToken, LocalDateTime tokenExpirationTime) {
        this.refreshToken = refreshToken;
        this.tokenExpirationTime = tokenExpirationTime;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 Member 엔티티에 모든 정보를 포함하는 방식으로 설계했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. Member 엔티티에 소셜 로그인 정보, Refresh Token에 대한 정보를 같이 담은 이유?&lt;br /&gt;A. 현재의 간단한 설계에서는 굳이 소셜 로그인 정보 테이블, Refresh Token 정보 테이블을 나눌 필요가 없다고 생각해 같이 합쳤다. 추후 업데이트하면서 필요하다면 따로 나누고 관계 설정을 하는 방향으로 진행하려 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;회원가입(로컬)&lt;/h4&gt;
&lt;h5&gt;AuthService&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
    @Transactional
    public void signUp(SignUpRequest request) {
        validateSignUpInfo(request);
        Member member = Member.builder()
                .email(request.getEmail())
                .password(passwordEncoder.encode(request.getPassword()))
                .role(MemberRole.USER)
                .provider(AuthProvider.local)
                .build();
        memberService.registerMember(member);
    }

    private void validateSignUpInfo(SignUpRequest request) {
        if (memberService.existsMemberByEmail(request.getEmail())) {
            throw new BusinessException(ErrorCode.ALREADY_REGISTERED_MEMBER);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일과 비밀번호만 사용해서 회원가입하는 간단한 기능이다. 기존 회원과의 이메일 중복 여부만 체크하고 바로 저장한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;로그인(로컬)&lt;/h4&gt;
&lt;h5&gt;AuthServie&lt;/h5&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;
    @Transactional
    public JwtTokenDto signIn(SignInDto.Request request) {
        Member member = memberService.findMemberByEmail(request.getEmail());
        validateMemberAuthProvider(member.getProvider());
        validatePassword(request.getPassword(), member.getPassword());
        // 토큰 만들기(access, refresh)
        JwtTokenDto jwtTokenDto = jwtTokenProvider.createJwtToken(String.valueOf(member.getId()), member.getRole());
        // refresh token 저장 (DB)
        memberService.updateRefreshToken(
                member.getId(),
                jwtTokenDto.getRefreshToken(),
                jwtTokenDto.getRefreshTokenExpireTime()
        );

        return jwtTokenDto;
    }

    private void validateMemberAuthProvider(AuthProvider provider) {
        if (!provider.equals(AuthProvider.local)) {
            throw new AuthenticationException(ErrorCode.SOCIAL_SIGN_IN_MEMBER);
        }
    }

    private void validatePassword(String requestPassword, String memberPassword) {
        if (!passwordEncoder.matches(requestPassword, memberPassword)) {
            throw new AuthenticationException(ErrorCode.MISMATCH_PASSWORD);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일과 비밀번호를 사용해서 로그인한다. 입력값의 유효성 검사를 제외하고 유효성 검사를 총 세 번 진행한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;email로 가입한 회원이 있는지&lt;/li&gt;
&lt;li&gt;해당 회원이 아이디/비밀번호(로컬) 회원인지&lt;/li&gt;
&lt;li&gt;입력한 비밀번호와 DB에 저장한 비밀번호가 일치하는지&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유효성 검사를 모두 통과하면 JWT를 이용해 Access Token과 Refresh Token을 생성한다. Access Token은 응답 DTO에 담아 response body로 클라이언트에 내보내고, Refresh Token은 해당 Member 테이블에 저장하고,&lt;/p&gt;
&lt;h5&gt;AuthController&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
    @PostMapping(&quot;/sign-in&quot;)
    public ResponseEntity&amp;lt;SignInDto.Response&amp;gt; signIn(@Valid @RequestBody SignInDto.Request req) {
        JwtTokenDto jwtTokenDto = authService.signIn(req);
        SignInDto.Response result = SignInDto.Response.builder()
                .grantType(jwtTokenDto.getGrantType())
                .accessToken(jwtTokenDto.getAccessToken())
                .accessTokenExpireTime(jwtTokenDto.getAccessTokenExpireTime())
                .build();

        //Cookie에 refresh token 저장!!
        ResponseCookie refreshTokenCookie = refreshTokenCookieUtils
                .generateRefreshTokenCookie(jwtTokenDto.getRefreshToken());

        return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()).body(result);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cookie에도 저장하여 클라이언트에 내보낸다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. Refresh Token를 Cookie에 담아 클라이언트에 제공하는 이유?&lt;br /&gt;A. 해당 템플릿은 웹 애플리케이션에 대한 API 서버 환경을 구성해주는 템플릿이다. 그래서 Refresh Token의 저장위치로 local storage, cookie 2곳을 고민했다. 각각 장단점이 있었고, Cookie에 저장하기로 했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. Refresh Token을 DB에도 저장한 이유?&lt;br /&gt;A. JWT를 사용하는데 DB에도 저장하는 것은 사실 token을 사용하는 의미가 살짝 퇴색한다고 생각했다. 그럼에도 불구하고 DB에 Refresh Token을 저장하는 이유는 결국 보안적인 측면을 의식했기 때문이다. 악의를 가진 누군가가 Cookie에 있는 Refresh Token을 탈취했을 상황을 생각했다. 그 때는 수동으로라도 Refresh Token을 무력화해줄 필요가 있었기 때문에, DB에도 Refresh Token을 저장해 대조하는 방식으로 개발했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;소셜 로그인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소셜 로그인 부분을 직접 구현할 지, Spring Security + OAuth2 Client를 사용할 지 많이 고민했다. Spring Security + OAuth2로 구현한 이유는 2가지가 있었다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Spring Security + JWT 조합을 사용해본 경험을 활용해보고 싶었다. 이전 회사에서 사내 커뮤니티 앱 프로젝트를 진행했던 적이 있었다. 그 때는 사원번호와 비밀번호를 기반으로 한 로그인만 가능했기 때문에, OAuth2 인증이 불필요했다. 그래서 Spring Security + JWT 조합을 사용해서 서버의 인증부를 구현했다. 이전 경험에 더해서 OAuth2 인증만 추가하면 되기에 해보고 싶었다.&lt;/li&gt;
&lt;li&gt;잘 만들어져 있는 라이브러리들을 이용해 만들어보고 싶었다. 직접 구현하는 것이 좋을 수는 있겠으나, 일단 잘 만들어놓은 라이브러리를 사용하면서 해당 라이브러리에서 전체적인 인증 과정을 어떻게 구현했는지 분석해보고 싶었다. 해당 라이브러리의 인증 과정을 참고하고 정리하면 추후에 직접 내가 인증부를 구현해야 할 때 더 좋은 설계를 할 수 있을 것 같았다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;SecurityConfig&lt;/h5&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;
@Slf4j
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(
        securedEnabled = true
        , prePostEnabled = true
        , jsr250Enabled = true
)
@EnableWebSecurity
@Configuration
public class SecurityConfig {
    private final CustomAuthorizationRequestRepository customAuthorizationRequestRepository;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomAuthenticationEntrypoint customAuthenticationEntrypoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                ...
                .anyRequest().authenticated();

        http.cors()
                .and()
                .csrf().disable()
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.formLogin().disable()
                .oauth2Login()
                .authorizationEndpoint()
                .authorizationRequestRepository(customAuthorizationRequestRepository)
                .and()
                .userInfoEndpoint()
                .userService(customOAuth2UserService)
                .and()
                .successHandler(oAuth2AuthenticationSuccessHandler)
                .failureHandler(oAuth2AuthenticationFailureHandler);

        http.exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntrypoint)
                .accessDeniedHandler(customAccessDeniedHandler);

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security + OAuth2에 대한 전반적인 설정이다.&lt;/p&gt;
&lt;h5&gt;CustomAuthorizationRequestRepository&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
@Component
public class CustomAuthorizationRequestRepository implements AuthorizationRequestRepository&amp;lt;OAuth2AuthorizationRequest&amp;gt; {
    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = &quot;oauth2_auth_request&quot;;
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = &quot;redirect_uri&quot;;

    private static final int COOKIE_EXPIRE_SECONDS = 3600;
    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -&amp;gt; CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인가 응답을 연계 하고 검증할 때는 기본적으로 세션을 사용한다. 그것을 Cookie로 바꾸어주는 역할을 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. 그냥 세션을 사용하면 되는데 왜 쿠키 기반으로 바꾸었나?&lt;br /&gt;A. 해당 프로젝트에서는 JWT를 사용한 토큰 방식 인증을 사용하기 때문에 세션이 불필요하다. 그래서 SessionCreatePolicy.STATELESS 를 사용해서 세션을 아예 사용하지 않도록 설정했다. 세션을 사용하지 않도록 설정했기 때문에 대안으로 쿠키를 사용하고자 했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h5&gt;CustomOAuth2UserService&lt;/h5&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;
@Slf4j
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        return processOAuth2User(userRequest, oAuth2User);
    }

    private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Attributes oAuth2Attributes = OAuth2AttributeFactory.getOAuth2Attributes(
                registrationId,
                oAuth2User.getAttributes()
        );
        AuthProvider authProvider = AuthProvider.valueOf(registrationId);
        Optional&amp;lt;Member&amp;gt; optionalMember = memberRepository.findByEmail(oAuth2Attributes.getEmail());
        Member member;
        if (optionalMember.isPresent()) {
            member = optionalMember.get();
            if (!member.getProvider().equals(authProvider)) {
                throw new AuthenticationException(ErrorCode.INVALID_AUTH_PROVIDER);
            }
        } else {
            member = registerMember(authProvider, oAuth2Attributes);
        }

        return CustomUserDetails.create(member);
    }

    private Member registerMember(AuthProvider authProvider, OAuth2Attributes oAuth2Attributes) {
        Member member = Member.builder()
                .email(oAuth2Attributes.getEmail())
                .role(MemberRole.USER)
                .nickname(oAuth2Attributes.getName())
                .profile(oAuth2Attributes.getImageUrl())
                .provider(authProvider)
                .build();
        return memberRepository.save(member);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소셜 로그인 시 OAuth2UserRequest를 이용해서 회원 정보를 저장하고, 유저 정보를 반환해주거나, 이미 해당 회원 정보가 이미 존재할 때는 유저 정보만 반환해준다.&lt;/p&gt;
&lt;h5&gt;OAuth2AuthenticationSuccessHandler&lt;/h5&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final OAuth2Config oAuth2Config;
    private final MemberService memberService;
    private final CustomAuthorizationRequestRepository authorizationRequestRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.debug(&quot;Response has already been committed!!!!&quot;);
            return;
        }

        String targetUrl = determineTargetUrl(request, response, authentication);
        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(
            HttpServletRequest request, HttpServletResponse response, Authentication authentication
    ) {
        Optional&amp;lt;String&amp;gt; redirectUri = CookieUtils
                .getCookie(request, CustomAuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME).map(Cookie::getValue);

        if (redirectUri.isPresent() &amp;amp;&amp;amp; !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new AuthenticationException(ErrorCode.IS_NOT_REDIRECT_URI);
        }

        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        // 토큰 생성
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
        String memberId = userDetails.getName();
        MemberRole memberRole = userDetails.getAuthorities()
                .stream().map(GrantedAuthority::getAuthority).map(MemberRole::from).findFirst().orElse(null);

        JwtTokenDto jwtTokenDto = jwtTokenProvider.createJwtToken(memberId, memberRole);
        memberService.updateRefreshToken(
                Long.parseLong(memberId),
                jwtTokenDto.getRefreshToken(),
                jwtTokenDto.getRefreshTokenExpireTime()
        );

        ResponseCookie refreshTokenCookie = CookieUtils.generateResponseCookie(
                oAuth2Config.getAuth().getRefreshCookieKey(),
                jwtTokenDto.getRefreshToken(),
                oAuth2Config.getAuth().getRefreshTokenValidityInMs() / 1000
        );

        response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam(&quot;token&quot;, jwtTokenDto.getAccessToken())
                .queryParam(&quot;grant&quot;, jwtTokenDto.getGrantType())
                .build().toUriString();
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);

        return oAuth2Config.getOAuth2().getAuthorizedRedirectUris()
                .stream()
                .anyMatch(authorizedRedirectUri -&amp;gt; {
                    URI authorizedURI = URI.create(authorizedRedirectUri);
                    return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                            &amp;amp;&amp;amp; authorizedURI.getPort() == clientRedirectUri.getPort();
                });
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OAuth2 로그인에 성공했을 때 호출하는 Handler로, JWT를 생성해서 Refresh Token 정보를 해당 유저 테이블에 저장하고 Access Token과 Grant Type을 Redirect URI에 넣어 클라이언트에 보내준다.&lt;/p&gt;
&lt;h5&gt;JwtAuthenticationFilter&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        String jwt = JwtUtils.getTokenFromRequest(authorizationHeader);

        if (StringUtils.hasText(jwt) &amp;amp;&amp;amp; jwtTokenProvider.validateAccessToken(jwt)) {
            UsernamePasswordAuthenticationToken authentication = jwtTokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug(&quot;JWT로 {}의 인증정보 저장&quot;, authentication.getName());
        }

        filterChain.doFilter(request, response);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 헤더에 담긴 JWT 토큰을 검증하고 통과했을 때 인증정보를 생성해 저장해주는 필터입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;토큰 재발급&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키로 받은 Refresh Token을 검증해서 통과한다면, Access Token을 갱신해서 발급해준다.&lt;/p&gt;
&lt;h5&gt;AuthController&lt;/h5&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;
@PostMapping(&quot;/refresh&quot;)
public ResponseEntity&amp;lt;TokenRefreshResponse&amp;gt; refreshToken(@CookieValue(&quot;refreshtoken&quot;) String refreshToken) {

    TokenRefreshResponse response = authService.refreshToken(refreshToken);
    return ResponseEntity.ok(response);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;service에서 토큰 갱신 결과를 생성해서 리턴해준다.&lt;/p&gt;
&lt;h5&gt;AuthService&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
    public TokenRefreshResponse refreshToken(String refreshToken) {
        validateRefreshToken(refreshToken);
        Member member = memberService.findMemberByRefreshToken(refreshToken);
        Date accessTokenExpireTime = jwtTokenProvider.createAccessTokenExpireTime();
        String accessToken =
                jwtTokenProvider.createAccessToken(String.valueOf(member.getId()), member.getRole(), accessTokenExpireTime);
        return TokenRefreshResponse.builder()
                .grantType(GrantType.BEARER.getType())
                .accessToken(accessToken)
                .accessTokenExpireTime(DateTimeUtils.convertToLocalDateTime(accessTokenExpireTime))
                .build();
    }

    private void validateRefreshToken(String refreshToken) {
        boolean validateToken = jwtTokenProvider.validateRefreshToken(refreshToken);
        if (!validateToken) {
            throw new AuthenticationException(ErrorCode.INVALID_REFRESH_TOKEN);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh Token에 대한 유효성 검사를 해주고, 해당 Refresh Token으로 회원을 조회한다. 그 후 그 회원 정보로 Access Token을 만들어 리턴한다.&lt;/p&gt;
&lt;h5&gt;MemberService&lt;/h5&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;    public Member findMemberByRefreshToken(String refreshToken) {
        Member member = memberRepository
                .findByRefreshToken(refreshToken)
                .orElseThrow(() -&amp;gt; new AuthenticationException(ErrorCode.NOT_FOUND_REFRESH_TOKEN));
        LocalDateTime tokenExpirationTime = member.getTokenExpirationTime();
        if (tokenExpirationTime.isBefore(LocalDateTime.now())) {
            throw new AuthenticationException(ErrorCode.EXPIRED_REFRESH_TOKEN);
        }

        return member;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh Token으로 회원을 조회할 때, DB에 저장된 Refresh Token의 만료시간도 체크한다. 토큰이 만료되었을 때는 예외를 내보낸다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;내 정보 조회&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 헤더에 담긴 액세스 토큰을 바탕으로 현재 로그인한 회원의 정보를 조회한다. 이번 프로젝트에서는 Custom ArgumentResolver와 @Annotation을 사용했다.&lt;/p&gt;
&lt;h5&gt;@SignInMember&lt;/h5&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignInMember {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파라미터에서만 사용할 수 있는 애노테이션이다.&lt;/p&gt;
&lt;h5&gt;SignInMemberDto&lt;/h5&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Getter @Builder
public class SignInMemberDto {
    private Long memberId;
    private MemberRole role;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰에서 불러온 회원 정보를 담은 DTO이다.&lt;/p&gt;
&lt;h5&gt;SignInMemberArgumentResolver&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Component
public class SignInMemberArgumentResolver implements HandlerMethodArgumentResolver {
    private final JwtTokenProvider jwtTokenProvider;


    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasSignInMemberAnnotation = parameter.hasParameterAnnotation(SignInMember.class);
        boolean hasSignInMemberDto = SignInMemberDto.class.isAssignableFrom(parameter.getParameterType());
        return hasSignInMemberAnnotation &amp;amp;&amp;amp; hasSignInMemberDto;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        String jwt = JwtUtils.getTokenFromRequest(authorizationHeader);

        Claims claims = jwtTokenProvider.parseClaims(jwt);
        Long memberId = Long.parseLong(claims.get(ClaimKeyType.MEMBER_ID.getType(), String.class));
        String role = claims.get(ClaimKeyType.ROLE.getType(), String.class);
        return SignInMemberDto.builder()
                .memberId(memberId)
                .role(MemberRole.from(role))
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SignInMemberArgumentResolver의 기능은 두 가지다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;@SignInMember 애노테이션이 파라미터에 붙어있는지 &amp;amp;&amp;amp; 해당 파라미터에 SignMemberDto 타입을 할당할 수 있는지를 따져 해당 ArgumentResolver를 적용할 수 있는 파라미터인지 체크한다.&lt;/li&gt;
&lt;li&gt;1.을 통과했을 때, access token을 파싱하여 SignMemberDto에 담아 반환한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;WebMvcConfig&lt;/h5&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final SignInMemberArgumentResolver signInMemberArgumentResolver;

    ...

    @Override
    public void addArgumentResolvers(List&amp;lt;HandlerMethodArgumentResolver&amp;gt; resolvers) {
        resolvers.add(signInMemberArgumentResolver);
    }

    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 SignInMemberArgumentResolver을 등록해준다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;로그아웃&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 헤더의 Access Token으로 회원 정보를 받아서&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;해당 회원 테이블에 저장된 Refresh Token 관련 정보를 삭제하고,&lt;/li&gt;
&lt;li&gt;Refresh Token Cookie를 만료시킨다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;AuthController&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/sign-out&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; signOut(@SignInMember SignInMemberDto signInMemberDto) {

        authService.signOut(signInMemberDto.getMemberId());

        ResponseCookie signOutCookie = refreshTokenCookieUtils.generateSignOutCookie();

        return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, signOutCookie.toString()).build();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;RefreshTokenCookieUtils&lt;/h5&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@Component
public class RefreshTokenCookieUtils {
    private final OAuth2Config oAuth2Config;

    ...

    public ResponseCookie generateSignOutCookie() {
        return CookieUtils
                .generateResponseCookie(
                        oAuth2Config.getAuth().getRefreshCookieKey(),
                        &quot;&quot;,
                        1
                );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Refresh Token Cookie의 키 값과 똑같은 쿠키를 만들고, value 값을 빈 값으로, maxAge를 1초로 주어 Refresh Token Cookie를 만료한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;회원탈퇴&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원 탈퇴에서 인증 헤더의 Access Token 을 이용하여 현재 로그인한 사용자가 자신을 탈퇴하는 것과 더불어 관리자 권한을 가진 회원이 다른 회원을 탈퇴시키는 기능까지 추가하고자 했다.&lt;/p&gt;
&lt;h5&gt;MemberController&lt;/h5&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@RequestMapping(&quot;/api/members&quot;)
@RestController
public class MemberController {

    private final MemberService memberService;
    private final RefreshTokenCookieUtils refreshTokenCookieUtils;

    @PreAuthorize(&quot;@memberGuard.check(#id)&quot;)
    @DeleteMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; withdrawMember(@PathVariable Long id, @SignInMember SignInMemberDto signInMemberDto) {
        //회원 삭제
        memberService.withdrawMember(id);

        //본인이라면 refresh token cookie도 삭제
        if (isOwnerMember(id, signInMemberDto.getMemberId())) {
            ResponseCookie signOutCookie = refreshTokenCookieUtils.generateSignOutCookie();
            return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, signOutCookie.toString()).build();
        }

        return ResponseEntity.ok().build();
    }

    private boolean isOwnerMember(Long memberId, Long signInMemberId) {
        return signInMemberId.equals(memberId);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@PreAuthorize와 MemberGuard 를 이용해서 로그인한 회원과 대상 회원이 동일한지, 관리자 회원인지 체크하고, 회원을 삭제한다. 만약 로그인했던 회원이라면 Refresh Token Cookie도 만료시킨다.&lt;/p&gt;
&lt;h5&gt;Guard&lt;/h5&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
public abstract class Guard {
    public final boolean check(Long id) {
        return hasRole(getMemberRoles()) || isResourceOwner(id);
    }

    abstract protected List&amp;lt;MemberRole&amp;gt; getMemberRoles();

    abstract protected boolean isResourceOwner(Long id);

    private boolean hasRole(List&amp;lt;MemberRole&amp;gt; memberRoles) {
        Set&amp;lt;MemberRole&amp;gt; result = AuthHelper
                .extractMemberRoles()
                .stream()
                .filter(x -&amp;gt; memberRoles.stream().anyMatch(y -&amp;gt; x.getRole().equals(y.getRole())))
                .collect(Collectors.toSet());
        return !result.isEmpty();
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;MemberGuard&lt;/h5&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;
@RequiredArgsConstructor
@Component
public class MemberGuard extends Guard {
    private final List&amp;lt;MemberRole&amp;gt; memberRoles = List.of(MemberRole.ADMIN);
    @Override
    protected List&amp;lt;MemberRole&amp;gt; getMemberRoles() {
        return memberRoles;
    }

    @Override
    protected boolean isResourceOwner(Long id) {
        return id.equals(AuthHelper.extractMemberId());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Guard라는 개념은 &lt;a href=&quot;https://kukekyakya.tistory.com/567?category=1025994&quot;&gt;해당 링크&lt;/a&gt;를 보고 &lt;a href=&quot;https://docs.nestjs.com/guards&quot;&gt;NestJS&lt;/a&gt;가 생각나서 좋다고 생각해 적용해보았다. 공통 기능은 추상 클래스 Guard로 분리하고, 각 용도에 맞게 Guard를 상속해 구현하는 방식을 취한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;공통&lt;/h4&gt;
&lt;h5&gt;전역 예외 처리&lt;/h5&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GlobalExceptionHandler (BEFORE)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    ...

    /**
     * BusinessA 예외 처리
     */
    @ExceptionHandler(BusinessAException.class)
    protected ResponseEntity&amp;lt;ErrorResponse&amp;gt; handleBusinessAException(BusinessAException e) {
        log.error(&quot;BusinessAException&quot;, e);
        ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode().getErrorCode(), e.getMessage());
        return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(errorResponse);
    }

    /**
     * BusinessB 예외 처리
     */
    @ExceptionHandler(BusinessBException.class)
    protected ResponseEntity&amp;lt;ErrorResponse&amp;gt; handleBusinessAException(BusinessBException e) {
        log.error(&quot;BusinessBException&quot;, e);
        ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode().getErrorCode(), e.getMessage());
        return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(errorResponse);
    }

    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 프로젝트를 진행할 때는 비즈니스 로직 처리 중 발생하는 모든 예외 상황에 각각 대응하는 커스텀 예외를 만들고 + @RestControllerAdvice 를 사용하여 처리해왔다. 매번 새로운 예외를 생성하는 게 번거롭다고 느끼던 중, &lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2020-08-17-custom-exception/&quot;&gt;custom exception을 언제 써야 할까?&lt;/a&gt; 라는 글을 보고 전역 예외 처리에 대한 고민이 많아졌다. 커스텀 예외를 사용해서 해당 예외를 낸 의도를 명확하게 하고 싶은 생각은 여전했지만, 그런 의도에 비해 예외를 추가하는 비용이 더 크다고 생각했던 와중, &lt;a href=&quot;https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-api-%ED%85%9C%ED%94%8C%EB%A6%BF&quot;&gt;인프런 강의&lt;/a&gt; 를 보고 이거다 싶어 적용해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;BusinessException&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/ca5e504c-00e4-4fce-9abe-5b43c87f7733/image.png&quot; alt=&quot;BusinessException Diagram&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GlobalExceptionHandler (AFTER)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    ...

    /**
     * 비즈니스 로직 실행 중 예외
     */
    @ExceptionHandler(BusinessException.class)
    protected ResponseEntity&amp;lt;ErrorResponse&amp;gt; handleBusinessException(BusinessException e) {
        log.error(&quot;BusinessException&quot;, e);
        ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode().getErrorCode(), e.getMessage());
        return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(errorResponse);
    }

    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속을 이용해서 모든 비즈니스 로직에서 발생하는 커스텀 예외의 부모인 BusinessException을 만드는 것이다. 이렇게 함으로써 비즈니스 로직을 변경하거나 추가할 때마다, 필요하면 BusinessException을 상속해 새로운 Exception을 만들어도 공통 예외처리를 담당하는 GlobalExceptionHandler에서는 BusinessException에 대한 예외 처리만 핸들링해주게 되어 예외 처리가 훨씬 편해졌다. (상속은 자바 기본 문법인데 이를 적용할 생각을 못했다는 것에 잠시 현타가 오기는 했다.)&lt;/p&gt;
&lt;h5&gt;Properties 설정값들을 Immutable한 자바 객체로 변환&lt;/h5&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알다시피 설정해둔 프로퍼티를 스프링 빈에서 사용하고 싶으면 @Value를 이용하는 방법도 있지만, 프로퍼티 바인딩을 통해 자바 객체에 담아 사용할 수도 있다. 보통 설정값을 바인딩한 객체는 변하지 않기 때문에, Spring Boot 2.2.0부터 추가된 &lt;a href=&quot;https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/context/properties/ConstructorBinding.html&quot;&gt;@ConstructorBinding&lt;/a&gt;을 사용해서 Immutable한 객체로 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OAuth2Config, PropertiesConfiguration&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;
@Validated
@Getter
@RequiredArgsConstructor
@ConstructorBinding
@ConfigurationProperties(prefix = &quot;app&quot;)
public final class OAuth2Config {
    @Valid
    private final Auth auth;
    @Valid
    private final OAuth2 oAuth2;

    @Getter
    @RequiredArgsConstructor
    public static final class Auth {
        @NotBlank
        private final String tokenSecret;
        @NotBlank
        private final String refreshCookieKey;
        @Positive
        private final long accessTokenValidityInMs;
        @Positive
        private final long refreshTokenValidityInMs;
    }

    @Getter
    @RequiredArgsConstructor
    public static final class OAuth2 {
        @NotEmpty
        private final List&amp;lt;@URL String&amp;gt; authorizedRedirectUris;
    }

}

//------------------------------------------------------------//

@EnableConfigurationProperties(value = {OAuth2Config.class})
@Configuration
public class PropertiesConfiguration {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@ConstructBinding을 사용하면 기존의 setter를 통한 바인딩이 아니라 생성자를 통한 바인딩이 가능하기 때문에 @Setter를 다 제거했다. 또한 설정값들에 대한 Validation도 추가해주었다.&lt;br /&gt;기존 설정과 또 다른 점은 해당 설정값을 바인딩한 클래스를 빈으로 등록해주기 위해 따로 @EnableConfigurationProperties를 사용해야 한다는 점이었다.&lt;/p&gt;
&lt;h5&gt;Spring Rest Docs를 사용한 API 문서화&lt;/h5&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평소에 프로젝트를 만들 때, API 문서화를 위해 Swagger(OpenAPI)를 애용해왔다. 설정과 애노테이션을 추가하면 바로 동적인 API 문서를 만들어준다는 점이 매력적이었기 때문이다. 하지만 본 프로젝트에서는 아래의 두 가지 이유로 Spring Rest Docs를 채택했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Swagger를 사용했을 때, 실제 코드에 너무 많은 애노테이션이 침투해서 애플리케이션 코드에 대한 분석이 어려웠기 때문이다. 실제 코드는 3&lt;del&gt;4줄인데, 문서화를 위해 애노테이션을 붙이다 보면 줄길이가 4&lt;/del&gt;5배로 늘어나고 있었다. API 문서를 제공하려다 실제 코드 분석이 더 어려워지는 아이러니한 상황이 발생하고 있어, 이번에는 실제 코드에 영향을 거의 주지 않는 Spring Rest Docs를 사용해보고자 했다.&lt;/li&gt;
&lt;li&gt;Spring Rest Docs는 테스트 코드 기반으로 작성하는 API 문서이기 때문이다. 전 회사에서의 모든 프로젝트의 모든 테스트는 실제로 개발 서버에 배포하여 마치 사용자처럼 UI 화면을 보고 값을 입력하거나 버튼을 클릭하여 진행하는 UI 테스트가 테스트의 전부였다. 테스트를 위해 필요한 비용, 시간이 너무 많이 들었고, 예상치 못한 버그에 대한 대응이 너무 느렸다. 이런 경험들 개인적으로 진행하는 프로젝트는 단위 테스트든 E2E테스트든 테스트 코드를 꼭 작성하기로 마음먹었다. 그러니 Spring Rest Docs를 추가해서 강제로 E2E 테스트를 추가하고 싶었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HealthCheckControllerE2ETest&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;
@AutoConfigureRestDocs(uriScheme = &quot;https&quot;, uriHost = &quot;y2gcoder.com&quot;, uriPort = 443)
@ExtendWith(RestDocumentationExtension.class)
@AutoConfigureMockMvc
@SpringBootTest
class HealthCheckControllerE2ETest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName(&quot;Health Check: 성공&quot;)
    void whenGetApiHealth_thenHealthOkActiveProfilesTest() throws Exception {
        //given
        //when
        ResultActions resultActions = this.mockMvc.perform(
                RestDocumentationRequestBuilders.get(&quot;/api/health&quot;)
                        .accept(MediaType.APPLICATION_JSON)
        );
        //then
        resultActions.andExpect(status().isOk())
                .andDo(
                        document(
                                &quot;health-check&quot;,
                                responseFields(
                                        fieldWithPath(&quot;health&quot;).description(&quot;server health status&quot;),
                                        fieldWithPath(&quot;activeProfiles&quot;).description(&quot;server active profiles&quot;)
                                )
                        )
                );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;API 문서(예시)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/948abf75-7bd8-44b7-bea6-63cd3f2dd6f7/image.png&quot; alt=&quot;Spring Rest Docs API 문서&quot; /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;피드백&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;아쉬운 점&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;github 로그인 시 이슈&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 템플릿은 OAuth2 Provider로 Google과 Github를 이용하고 있다. 문제는 OAuth2 토큰 발급 시 github에서 필요한 유저 정보를 다 받아오게끔 설정했음에도 불구하고, Github로 로그인을 시도할 때마다 email을 받아오지 못하는 것이었다. &lt;a href=&quot;https://stackoverflow.com/questions/35373995/github-user-email-is-null-despite-useremail-scope&quot;&gt;검색&lt;/a&gt;해보니 Github는 사용자가 email을 공개하지 않으면 OAuth2 로그인 시 보내는 요청에 대한 응답에서 email 값을 null로 보내는 것이다. 일단 현재 단계에서는 이메일이 없을 때, 해당 이메일 자리에 github ID를 대입하는 방식으로 처리했다. 이메일을 불러오지 못했을 때, github api를 이용해서 이메일을 조회하는 요청을 다시 보내 이메일을 불러오는 것이 가능한지 검토해보고 적용해봐야겠다.&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;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Refresh Token 저장 위치에 대한 고민&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 Refresh Token의 저장 위치에 대하여 논하긴 했지만, 좀 더 유연한 설계를 위해서는 Refresh Token에 대한 다른 저장소를 찾아볼 필요가 있다. Refresh Token을 쿠키에 저장하는 현재 방식은 해당 템플릿으로 만든 어플리케이션은 무조건 웹 관련일 수밖에 없다. 좀 더 유연하게 사용할 수 있는 템플릿으로 만들고 싶기 때문에 Refresh Token을 저장할 수 있는 다른 위치를 고민해봐야겠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/y2gcoder/post/79c72012-17f0-4b5a-8d40-4f5f49942195/image.png&quot; alt=&quot;러닝&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퇴사 후 그저 건강을 지키기 위해 시작했던 러닝이 2개월 째 이어지고 있다. 평소에 유산소 운동을 좋아하지 않아 건강이라는 이유만으로 꾸준히 이어갈 수 있을까 걱정했지만, 지금은 원래 목적인 건강 증진 뿐만 아니라 스트레스 해소, 러닝 코스에 있던 집 앞 풍경에 대한 감상까지 더해지면서 러닝을 시작하길 잘했다고 생각하고 있다. 마찬가지로 템플릿을 만든 이유는 시작 부분에 언급했던 것처럼 반복을 줄이기 위해서였지만, 템플릿을 만드는 과정에서 템플릿을 보완하는 과정에서 새로 공부하고 공부한 것을 적용해 더 나아진 코드를 보며 보람을 느끼면서 재미를 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 템플릿을 좀 더 보완하고, 보완된 템플릿을 통해 토이 프로젝트를 하나 만들어볼 계획이다. 먼저 github 로그인 이슈를 해결하여 템플릿을 보완하고, 토이 프로젝트에서는 서버 단 뿐만 아니라 해당 서버와 통신할 프론트 엔드 단도 만들어 배포까지 진행해볼 계획이다.&lt;/p&gt;
&lt;h1&gt;References&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-api-%ED%85%9C%ED%94%8C%EB%A6%BF&quot;&gt;인프런 - 생산성을 향상시키는 스프링부트 기반의 API 템플릿 프로젝트 구현&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-authservice&quot;&gt;https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-authservice&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mizzlena.tistory.com/52&quot;&gt;https://mizzlena.tistory.com/52&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kukekyakya.tistory.com/567?category=1025994&quot;&gt;https://kukekyakya.tistory.com/567?category=1025994&lt;/a&gt;&lt;/p&gt;</description>
      <category>Java/Spring Boot</category>
      <category>JWT</category>
      <category>OAuth2</category>
      <category>Spring Boot</category>
      <category>Spring Security</category>
      <author>문파관작</author>
      <guid isPermaLink="true">https://y2gcoder.tistory.com/3</guid>
      <comments>https://y2gcoder.tistory.com/3#entry3comment</comments>
      <pubDate>Tue, 21 Mar 2023 14:00:03 +0900</pubDate>
    </item>
    <item>
      <title>Web Server에서 HTTP Method 제한하기</title>
      <link>https://y2gcoder.tistory.com/2</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 글은 예전 제 깃헙 블로그에 작성했던 글을 옮겨적으며 수정한 글입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 발생&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2021년 1월 쯤 회사에서 진행했던 프로젝트의 스프링 부트 기반 백오피스 페이지 애플리케이션을 5개월 후인 2021년 6월 쯤에 유지보수할 일이 있었다. 애플리케이션에 신규 비즈니스 로직을 추가하고 기존 로직들을 수정한 뒤 테스트하고 문제없이 동작하는 것을 확인한 후에 배포했다. 그런데 로컬, 개발 환경에서는 이상없던 애플리케이션의 수정 요청 API가 스테이징 서버와 운영 서버에서 정상적으로 작동되지 않았다. 정확하게는 수정 API 요청들을 보냈음에도 애플리케이션까지 해당 요청들이 들어오지 않았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 파악&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 리팩토링하다 실수를 저지른 것으로 생각해 애플리케이션 소스코드에서 원인을 찾으려고 했지만, 수정한 코드에 문제는 없어보였다. 고민하다가 배포된 서버 환경의 차이 때문일지도 모른다는 생각에 서버 환경을 정리해봤다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;환경&lt;/th&gt;
&lt;th&gt;Web Server&lt;/th&gt;
&lt;th&gt;WAS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;로컬&lt;/td&gt;
&lt;td&gt;Tomcat(Embedded)&lt;/td&gt;
&lt;td&gt;Tomcat(Embedded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개발&lt;/td&gt;
&lt;td&gt;nginx&lt;/td&gt;
&lt;td&gt;Tomcat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스테이징&lt;/td&gt;
&lt;td&gt;webtob&lt;/td&gt;
&lt;td&gt;Jeus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;운영&lt;/td&gt;
&lt;td&gt;webtob&lt;/td&gt;
&lt;td&gt;Jeus&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정리해봤을 때 해당 이슈가 발생하는 스테이징 서버와 운영 서버의 Web Server나 WAS 중 한군데에 그 원인이 있겠다고 생각했고, Web Server와 WAS의 로그들을 확인했다. 원인은 Web Server인 webtob에서 요청으로 들어오는 &lt;b&gt;HTTP Method&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;기존프로젝트에서는 HTTP Method를 GET, POST만 사용하고 있었는데 유지보수 과정에서 PUT, DELETE를 사용하는 API를 추가하게 되었다. 그러나 webtob에서는 보안을 위해 GET, POST만 허용하고 있었기 때문에, PUT을 사용하던 수정 요청 API들이 전부 막혀 애플리케이션 단까지 로그가 가지 않은 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 여건 상 Web Server를 수정하여 HTTP Method 제한을 풀 수는 없었고, 애플리케이션 단에서 사용하는 HTTP Method들을 전부 GET, POST로 변경하는 방향으로 해당 이슈를 해결했다. 개인적으로 이 때 HTTP 에 대한 강의를 들었던 직후라 Restful한 API를 만들어보고자 하는 욕구가 충만할 때였고 그런 방향과는 정반대로 애플리케이션을 수정한다고 느껴 굉장히 아쉬웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Web</category>
      <category>http</category>
      <category>jeus</category>
      <category>web server</category>
      <category>webtob</category>
      <author>문파관작</author>
      <guid isPermaLink="true">https://y2gcoder.tistory.com/2</guid>
      <comments>https://y2gcoder.tistory.com/2#entry2comment</comments>
      <pubDate>Tue, 21 Mar 2023 13:13:08 +0900</pubDate>
    </item>
  </channel>
</rss>