<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>HueJo.log</title>
        <link>https://velog.io/</link>
        <description>모르는 건 모른다고 하는 사람</description>
        <lastBuildDate>Sat, 26 Jul 2025 12:31:45 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>HueJo.log</title>
            <url>https://velog.velcdn.com/images/hyu-jo/profile/bf956987-11bb-41a1-b6cb-90919afcea3c/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. HueJo.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hyu-jo" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[AWS 프리티어 정책 변경?]]></title>
            <link>https://velog.io/@hyu-jo/AWS-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%A0%95%EC%B1%85-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@hyu-jo/AWS-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%A0%95%EC%B1%85-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Sat, 26 Jul 2025 12:31:45 GMT</pubDate>
            <description><![CDATA[<p>내가 쓰려고 서비스 하나를 작게 만들어서 이를 배포하기 위해 2025년 7월 23일, AWS 계정을 별 생각없이 예전처럼 프리티어로 가입하려 했다. 그런데 뭔가.... 이질적인 회원가입 절차. 
무료? 유료? 크레딧? 뭐야 이거... 암튼 무료지 무료!
그렇게 가입을 했는데....... 
EC2 인스턴스 생성하는데 뭔가 내가 원하는 선택지를 맘대로 고를 수 없었다. 그걸 사용하려면 유료플랜으로 업그레이드를 하라나? 그래서 에휴 어쩔 수 없지 하고 업그레이드를 바로 해줬다. 업그레이드를 한다고 해서 돈이 나가는 건 아니니까! 
그랬더니 크레딧으로 100달러가 들어왔다. 오... 돈을 주네? 
그리고 이번에는 처음으로 RDS를 사용해봤다. 
t3 micro로 프리티어면 월 750시간 무료였으까~ 하고 생성해주고, 
HTTPS로도 배포했으니 Route 53 호스팅 영역 생성한 것만 내면 되겠지!
혹시 모르니까 2달러 비용 넘어가면 메일로 연락오게 설정해뒀다.  </p>
<p>그런데 배포 후 이틀 후 아침, 메일이 왔다. 너 2달러 이미 넘침 ㅋ 
진짜 아침부터 말벌 아저씨처럼 AWS에 들어갔다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/6cb53479-9230-46c6-a57d-4ba0390bf7c2/image.png" alt="">
지... 진짜잖아......... 왜..?? 왜? 왜 벌써 이렇게? 
<del>(니가 유료 플랜으로 업그레이드 했잖아....)</del></p>
<p>알고보니 2025년 7월 15일부터 정책이 바뀌었단다. 
이전에 프리티어로 가입했던 건 유지가 되지만, 
2025년 7월 15일부터는 예전에 제공했던 여러 (유사)무료 서비스를 제공하지 않는다고 한다. 
물론 무료 플랜이면 6개월은 아예 과금되지 않는다. (그런데 여러모로 제한이 있는 것 같음)
그리고 유료 플랜에게는 처음 가입시 100달러의 크레딧과 5개의 활동을 하면 추가로 100달러를 크레딧으로 준다. (한 활동당 20달러)
그리고 뭐든지 다 비용처리? 된다. (이제 EC2 인스턴스 1개만 생성해도 마찬가지) 
비용이 나온다고 해도 크레딧에서 먼저 빠져나간다고 한다. (휴!) </p>
<p>그런데 23일에 만든 계정의 26일의 비용 상태이다. 
(EC2 t3 micro &amp; RDS t3 micro &amp; Route 53 호스팅영역)</p>
<p><img src="https://velog.velcdn.com/images/hyu-jo/post/18d034a7-89fa-4582-9244-f2b47c01227d/image.png" alt=""></p>
<p>4일만에 4-5달러, 그러면 한 달이면 약 4-5만원?
받은 크레딧으로 몇 달정도는 무료로 사용할 수 있겠지만 
어떤 단체에서 공적으로 사용할 용도로 만든 거라 비용 청구하기 좀 죄송스러운데.... 잘 사용되면 장기적으로 사용할 수도 있을 것 같으니 비용에서 가장 많은 비중을 차지하는 RDS를 안 쓰고 우분투에 직접 DB를 설치하는 건 어떨지 고민이다.</p>
<p>내가 잘 이해한 거 맞겠지...? 
왜 바뀐 걸까....... 프리티어 넘 좋았는데..ㅠ 
이제 신규 고객 유치 차원에서 12개월이나 무료로 주기에는 고객이 많아졌나봐...? </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바 언어의 특징 & JVM]]></title>
            <link>https://velog.io/@hyu-jo/%EC%9E%90%EB%B0%94-%EC%96%B8%EC%96%B4%EC%9D%98-%ED%8A%B9%EC%A7%95-JVM</link>
            <guid>https://velog.io/@hyu-jo/%EC%9E%90%EB%B0%94-%EC%96%B8%EC%96%B4%EC%9D%98-%ED%8A%B9%EC%A7%95-JVM</guid>
            <pubDate>Sun, 20 Jul 2025 09:12:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>자바의 정석 4판이 나와 새로운 마음으로 처음부터 차근차근 읽어보기로 했다. </p>
</blockquote>
<h1 id="자바-언어의-특징">자바 언어의 특징</h1>
<h2 id="1-운영체제에-독립적">1. 운영체제에 독립적</h2>
<p>자바 응용프로그램은 운영체제/하드웨어와 통신하는 것이 아니라 JVM과 통신을 한다.</p>
<h3 id="jvm이란">JVM이란?</h3>
<p>JVM은 &#39;Java Virtual Machine&#39;으로, 자바를 실행하기 위한 가상 컴퓨터이다. 
자바로 작성된 애플리케이션은 모두 <em><strong>JVM에서만</strong></em> 실행된다. </p>
<blockquote>
<p>Java 애플리케이션 &lt;-&gt; <strong>JVM</strong> &lt;-&gt; OS &lt;-&gt; 하드웨어 </p>
</blockquote>
<p>즉, 자바 애플리케이션이 실행되기 위해서는 JVM이 필요하고,
Java 애플리케이션은 JVM과만 상호작용을 하기 때문에 OS와 하드웨어에 독립적으로 실행 가능한 것이다. </p>
<h3 id="jvm은-어떻게-설치하나">JVM은 어떻게 설치하나?</h3>
<p>자바로 프로그래밍을 하려면 먼저 <em><strong>JDK</strong></em>(Java Deveopment Kit)를 설치해야 한다. 
이것을 설치하면 자바를 개발하는 데 필요한 프로그램들과 함께 JVM도 설치가 된다! </p>
<h2 id="2-객체지향-언어-함수형-언어">2. 객체지향 언어, 함수형 언어</h2>
<p>상속, 캡슐화, 다형성이 잘 적용된 객체지향 언어이다
(함수형 언어라는 것도 적어주셨지만 설명이 없어 찾아보니 람다와 스트림을 말하는 것이었다. )</p>
<h2 id="3-자동-메모리-관리">3. 자동 메모리 관리</h2>
<p>자바로 작성된 프로그램이 실행되면 GC(Garbage Collector 가비지 콜렉터)가 자동으로 메모리를 관리해주기 때문에 프로그래머가 메모리를 따로 관리하지 않아도 된다는 장점이 있다. </p>
<h2 id="4-멀티쓰레드-지원">4. 멀티쓰레드 지원</h2>
<p>멀티쓰레드 = 하나의 프로그램을 사용하면서 여러 작업을 동시에 처리할 수 있도록 하는 것이다. </p>
<h2 id="5-동적-로딩-지원">5. 동적 로딩 지원</h2>
<p>동적 로딩 = &#39;프로그램 실행 중에&#39; 필요한 시점에 JVM이 특정 클래스를 메모리로 불러오는 과정
즉, 프로그램 실행시 모든 클래스가 한꺼번에 로딩되는 것이 아니라 특정 클래스가 필요할 때 로딩해서 사용한다. </p>
<h2 id="6-네트워크와-분산처리-지원">6. 네트워크와 분산처리 지원</h2>
<p>자바 언어는 인터넷을 통해 여러 컴퓨터가 데이터를 주고받을 수 있도록 지원한다.
예를 들어 Socket, URLConnection, HttpURLConnection과 같은 클래스를 활용하면 TCP/IP , HTTP 프로토콜 기반의 네트워크 통신을 구현할 수 있다. 
이 덕분에 자바 애플리케이션은 다른 컴퓨터/서버와 데이터를 주고받는 기능을 구현할 수 있다.</p>
<p>또한 자바 애플리케이션의 규모가 커지면, 하나의 애플리케이션으로 모든 기능을 처리하기 어려워진다.
이러한 문제를 해결하기 위해 기능을 작은 단위(서비스 단위)로 분리하고, 이를 여러 서버에 배포하여 동시에 분산적으로 처리할 수 있도록 지원한다.<br>예를 들어, 우리가 잘 사용하는 Spring Boot는 사용자 관리, 주문, 결제 등 각 기능을 독립적인 애플리케이션으로 구성하여 각 서비스는 REST API를 통해 서로 통신할 수 있게 해준다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security + SSE 사용 중, Access Denied 예외가 발생하는 이슈 해결 2]]></title>
            <link>https://velog.io/@hyu-jo/Spring-Security-SSE-%EC%82%AC%EC%9A%A9-%EC%A4%91-Access-Denied-%EC%98%88%EC%99%B8%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0-2</link>
            <guid>https://velog.io/@hyu-jo/Spring-Security-SSE-%EC%82%AC%EC%9A%A9-%EC%A4%91-Access-Denied-%EC%98%88%EC%99%B8%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0-2</guid>
            <pubDate>Mon, 23 Jun 2025 01:04:25 GMT</pubDate>
            <description><![CDATA[<p>한동안 Access Denied가 나오지 않고 잘 굴러가는 줄 알았다.
그런데 또 Access Denied가 나오는 로그를 발견,,ㅠ </p>
<pre><code>[   scheduling-1] c.d.d.notification.NotificationService   : Ping 전송 실패 : userId = 13, error = ServletOutputStream failed to flush: java.io.IOException: Broken pipe
[nio-8080-exec-9] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] threw exception
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied

[nio-8080-exec-9] o.a.c.c.C.[Tomcat].[localhost]           : Exception Processing [ErrorPage[errorCode=0, location=/error]]
jakarta.servlet.ServletException: Unable to handle the Spring Security Exception because the response is already committed.
 Caused by: org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
</code></pre><p>지난번의 필터로 해결될 문제가 아니었나 싶어 심란한 마음에 앞의 상황 맥락과 로그들을 살펴봤다.</p>
<blockquote>
<p>이 로그가 나오는 것의 공통점은 사용자가 로그아웃을 하거나 탈퇴를 했을 때였다. </p>
</blockquote>
<h2 id="1-broken-pipe-ioexception">1. Broken pipe (IOException):</h2>
<ul>
<li>사용자가 로그아웃/회원퇴함에 따라 연결은 끊겼는데 서버는 그걸 인식하지 못하고 데이터를 계속 보내려다가 예외가 터진 것이다. </li>
</ul>
<h2 id="2-authorizationdeniedexception-access-denied">2. AuthorizationDeniedException: Access Denied:</h2>
<ul>
<li>나는 로그아웃을 하면 토큰들을 삭제하거나 블랙리스트로 설정했기 때문에 더이상 인증이 되지 않도록 구현했다. 그런데 계속 30초마다 ping으로 SSE에 데이터를 보내려고 하다보니 인증 실패 예외를 터트린 것이다. </li>
</ul>
<blockquote>
<p>그러면 로그아웃, 회원탈퇴를 했을 때 연결이 제대로 끊어지도록 뭔가 조치를 취해야겠지! </p>
</blockquote>
<h1 id="해결한-방법">해결한 방법</h1>
<p>NotificationService 내에 </p>
<pre><code>
  private void removeEmitter(Long userId, SseEmitter emitter, String reason) {
    emitters.compute(userId, (key, currentEmitter) -&gt; {
      if (currentEmitter == emitter) {
        try {
          emitter.complete();
        } catch (Exception e) {
          log.warn(&quot;Emitter 종료 중 예외 발생: userId = {}, error = {}&quot;, userId, e.getMessage());
        }
        log.debug(&quot;Emitter 제거 완료: userId = {}, 이유 = {}&quot;, userId, reason);
        return null;
      }
      return currentEmitter;
    });
  }
</code></pre><p>이렇게 제거 메서드를 만들어두고, 이 메서드를 사용하여 연결을 끊는 메서드를 만들었다. </p>
<pre><code>public void disconnectEmitter(Long userId) {
    SseEmitter emitter = emitters.remove(userId);
    if (emitter != null) {
      removeEmitter(userId, emitter, &quot;로그아웃/탈퇴에 의한 SSE 연결 종료&quot;);
    }
  }</code></pre><blockquote>
<p>작성하다보니 뭔가.... 부족한 게 느껴지는데 좀 더 보완해야겠다ㅠ 
뭔가 명확하질 않네... 
아무튼 흐름은 깨달았고, 제대로 돌아가긴 한다. </p>
</blockquote>
<p>그리고 로그아웃을 한 후, 회원탈퇴로 사용자를 완전히 delete 하기 전에 disconnectEmitter()를 넣어주면 되겠다! </p>
<pre><code>    notificationService.disconnectEmitter(user.getId());
    log.info(&quot;SSE 연결 종료(로그아웃) : userId = {}&quot;, user.getId());


    log.info(&quot;로그아웃 성공&quot;);</code></pre><pre><code>    notificationService.disconnectEmitter(user.getId());
    log.info(&quot;SSE 연결 종료(회원탈퇴) : userId = {}&quot;, user.getId());

    // 사용자 삭제
    userRepository.delete(user);
    log.info(&quot;회원탈퇴 완료&quot;);
</code></pre><p>(원래는 UserService 내에 로그인, 로그아웃, 회원탈퇴 등의 인증 관련 로직을 함께 관리하고 있었는데, 
NotificationService에서 UserService를 참조하고, 이번 작업으로 인해 UserService가 다시 NotificationService를 참조하게 되어 순환 참조 문제가 발생했다.
이를 해결하기 위해 인증 관련 기능은 AuthService로 따로 분리했다.)</p>
<p>그러면 이제 Access Denied 예외는 더이상 터지지 않게 된다! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[타임존 누락으로 인해 9시간이 사라진 LocalDateTime, createdAt 표시 오류 잡기]]></title>
            <link>https://velog.io/@hyu-jo/%ED%83%80%EC%9E%84%EC%A1%B4-%EB%88%84%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EC%9D%B8%ED%95%B4-9%EC%8B%9C%EA%B0%84%EC%9D%B4-%EC%82%AC%EB%9D%BC%EC%A7%84-LocalDateTime-createdAt-%ED%91%9C%EC%8B%9C-%EC%98%A4%EB%A5%98-%EC%9E%A1%EA%B8%B0</link>
            <guid>https://velog.io/@hyu-jo/%ED%83%80%EC%9E%84%EC%A1%B4-%EB%88%84%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EC%9D%B8%ED%95%B4-9%EC%8B%9C%EA%B0%84%EC%9D%B4-%EC%82%AC%EB%9D%BC%EC%A7%84-LocalDateTime-createdAt-%ED%91%9C%EC%8B%9C-%EC%98%A4%EB%A5%98-%EC%9E%A1%EA%B8%B0</guid>
            <pubDate>Tue, 10 Jun 2025 07:33:43 GMT</pubDate>
            <description><![CDATA[<h1 id="문제-상황">문제 상황</h1>
<blockquote>
<ul>
<li>리뷰 상세 페이지 조회시, 업로드된 실제 시간과 9시간 차이 발생 <ul>
<li>방금 업로드한 리뷰에 업로드 된 시간이 &#39;9시간 후&#39;로 표시됨 </li>
</ul>
</li>
</ul>
</blockquote>
<p>리뷰를 작성하고 등록시간이 잘 들어오나 확인을 하던 중 발견한 이슈이다. </p>
<p><img src="https://velog.velcdn.com/images/hyu-jo/post/42d089e2-200a-415f-86ea-b32b4a646323/image.jpg" alt=""></p>
<p>최신 리뷰에는 n분 전으로 잘 나오는데 
방금 작성한 동일한 리뷰인데도 불구하고 
내가 쓴 리뷰 쪽에 나오는 리뷰에는 &#39;약 9시간 후&#39;로 뜨는 게 아닌가! 
아니 지금 썼는데 뭐 미래에서 썼슈? 
응답은 제대로 잘 나오는데 왜.... 저렇게 나오지? 
요상하네... 일단 FE님께 말씀드려야지 </p>
<blockquote>
<p>BE : FE님! 9시간 차이가 나는데용? 뭔가 시간 계산에 착오가 있는 걸까요? 
FE : 엥??? 둘 다 똑같은 컴포넌트 쓰는데요...? 
BE: ???</p>
</blockquote>
<hr>
<p>아니 근데 생각해보니까 9시간?
어디서 많이... 봤는데.....? 
타임존...?? 너니...? 나는 KST로 설정해뒀지! </p>
<blockquote>
<p>BE : FE님! 9시간 차이인 거 보면 타임존 문제인 것 같아요! 
FE : 응답에 타임존이 없어서 동일한 시간을 </p>
</blockquote>
<ul>
<li>~에서는 UTC 기준으로,</li>
<li>~~에서는 KST 기준으로 
계산한것 같으니 CreatedAt에 타임존을 추가해서 보내주시면 될 것 같아요!</li>
</ul>
<hr>
<h1 id="발생-이유">발생 이유</h1>
<blockquote>
<ul>
<li>LocalDateTime에는 타임존이 존재하지 않기 때문에 FE에서는 이게 어떤 기준인지 몰라 UTC와 KST를 혼용하게 되어 9시간 차이 발생한 것</li>
</ul>
</blockquote>
<p>근데 LocalDateTime은 타임존이 없어서 편리했던 거 아닌가? 
불필요한 타임존 계산 없이 깔끔하게 날짜와 시간만 다루기 때문에 굉장히 편리하다.</p>
<p>그런데 이걸 FE로 전송할때는 타임존이 없어서 어떤 기준으로 해석할지가 애매해진다. 
예를 들어서 <code>createdAt: 2025-06-10T12:30:00</code>을 
FE에 보내면 이게 UTC인지 KST인지를 알 수가 없다. 
그래서 어떤 곳에서는 UTC로 해석하기도 하고 
어떤 곳에서는 브라우저 시간대(KST)로 해석하기도 하기 때문에 
동일한 시간인데도 불구하고 화면마다 다르게 보인 것이다. </p>
<hr>
<h1 id="문제-해결">문제 해결</h1>
<blockquote>
<ul>
<li>문제가 생긴 ReviewResponseDTO의 createdAt, updatedAt 필드에 KST(+09:00) 타임존 정보를 포함하도록 변경</li>
</ul>
</blockquote>
<p>Entity에는 LocalDateTime으로 저장하되, 
DTO에는 OffsetDateTime으로 변경하여 +09:00을 포함하여 전송하는 방법이다. </p>
<pre><code>public class ReviewResponseDTO {
  private LocalDateTime createdAt; -&gt; private OffsetDateTime createdAt;
}</code></pre><p>이렇게 수정해주고, 
서비스 로직에서는  </p>
<pre><code>return ReviewREsponseDTO.builder()
.createdAt(review.getCreatedAt().atOffset(ZoneOffset.of(&quot;+09:00&quot;)))</code></pre><p>이렇게 해주면 응답값으로 
&quot;createdAt&quot;: &quot;2025-06-10T12:30:00.000+09:00&quot;
이렇게 나오게 된다. 
그러면 FE쪽에서는 아! 이건 KST구나! 하고 제대로 인식하게 되면서 제시간으로 보여주게 된다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[파일 업로드가 필요한 Controller에 MediaType 명시해주기]]></title>
            <link>https://velog.io/@hyu-jo/%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-Controller%EC%97%90-MediaType-%EB%AA%85%EC%8B%9C%ED%95%B4%EC%A3%BC%EA%B8%B0</link>
            <guid>https://velog.io/@hyu-jo/%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-Controller%EC%97%90-MediaType-%EB%AA%85%EC%8B%9C%ED%95%B4%EC%A3%BC%EA%B8%B0</guid>
            <pubDate>Tue, 10 Jun 2025 06:31:47 GMT</pubDate>
            <description><![CDATA[<p>프로필 등록/수정 API를 만들어두고 
잘 되려니~ FE께서 어련히 잘 하고 계시려니~ 하고 있었는데 
FE 개발자님의 잔뜩 지친 디엠 하나..</p>
<blockquote>
<p>프로필 사진 등록/수정
multipart/form-data 이걸로 되어있나요?
스웨거에 나와있는대로
application/json 으로
{
  &quot;file&quot;: &quot;string&quot;
}
이렇게 보내니 안 돼요</p>
</blockquote>
<p>descrption에 &quot;파일 업로드&quot;라고 적어둬서 어련히 알아서 하시는 줄 알았지만 
API 명세서 대로 작업하시는 분들이었음 ㅠ 죄송.... 송구...<br><img src="https://velog.velcdn.com/images/hyu-jo/post/cad16cc6-f445-4768-9a86-a08850a61a07/image.jpg" alt="">
오른쪽 중단에 보면 application/json이라고 나와있어서 
요청 바디에 json으로 계속 요청을 하셨는데 안 되셨던 거였다.
FE께서 이런 저런 삽질을 하다가 content-type에 대해 알아보고 
혼자 해결을 하셨다고 하여 더욱 송구...... 미리 말씀을 드릴 걸......<br><img src="https://velog.velcdn.com/images/hyu-jo/post/a981b563-2b7d-49ec-b418-ffe114358856/image.jpg" alt="">
이게 본래 코드였는데 스웨거 설정을 더 추가해줬다.</p>
<pre><code>  @Operation(summary = &quot;프로필사진 등록/수정&quot;)
  @PutMapping(value = &quot;/profile-image&quot;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
  public ResponseEntity&lt;Map&lt;String, String&gt;&gt; updateProfileImage(
      @Parameter(description = &quot;프로필 사진 파일&quot;)
      @RequestPart(&quot;file&quot;) MultipartFile file) {
    String profileImageUrl = profileImageService.updateProfileImage(file);
    Map&lt;String, String&gt; profileResponse = new HashMap&lt;&gt;();
    profileResponse.put(&quot;profileImageUrl&quot;, profileImageUrl);
    return ResponseEntity.ok(profileResponse);
  }</code></pre><p>이렇게 컨트롤러쪽에 consumes를 추가하여 multipart/form-data로 넣으라고 명시를 해주고, 어떤 파일을 넣어야 하는지 Parameter(description = &quot;<del>~</del>&quot;)로 설명해주었다. 
그러면 이제 스웨거는 이렇게 나온다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/1a873b69-cc29-4405-ad4d-00abe6d1d798/image.png" alt="">
FE 개발자가 삽질하지 않도록 API 명세서를 상세하게 잘 써주자...!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[FE: 서비스 내에서 제가 쓰던 계정 비번을 까먹었는데 비번 알려주실 수 있나요......]]></title>
            <link>https://velog.io/@hyu-jo/FE-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%82%B4%EC%97%90%EC%84%9C-%EC%A0%9C%EA%B0%80-%EC%93%B0%EB%8D%98-%EA%B3%84%EC%A0%95-%EB%B9%84%EB%B2%88%EC%9D%84-%EA%B9%8C%EB%A8%B9%EC%97%88%EB%8A%94%EB%8D%B0-%EB%B9%84%EB%B2%88-%EC%95%8C%EB%A0%A4%EC%A3%BC%EC%8B%A4-%EC%88%98-%EC%9E%88%EB%82%98%EC%9A%94</link>
            <guid>https://velog.io/@hyu-jo/FE-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%82%B4%EC%97%90%EC%84%9C-%EC%A0%9C%EA%B0%80-%EC%93%B0%EB%8D%98-%EA%B3%84%EC%A0%95-%EB%B9%84%EB%B2%88%EC%9D%84-%EA%B9%8C%EB%A8%B9%EC%97%88%EB%8A%94%EB%8D%B0-%EB%B9%84%EB%B2%88-%EC%95%8C%EB%A0%A4%EC%A3%BC%EC%8B%A4-%EC%88%98-%EC%9E%88%EB%82%98%EC%9A%94</guid>
            <pubDate>Tue, 10 Jun 2025 06:03:25 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>BE: 엥 그걸 제가 어케 알아요 </p>
</blockquote>
<p>왜냐하면 비번은 <code>PasswordEncoder</code>로 암호화되어 저장되었기 때문이다. 
Spring Security를 쓰면서 </p>
<pre><code>public class SecurityConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}</code></pre><p>이렇게 <code>단방향 해시함수</code>인 <code>BcrptPasswordEncoder</code>를 사용하였기 때문에 
평문으로 복호화할 수 없다. </p>
<p>그러면 어떻게 하지...? 이거 관리자 계정이란 말이야...!!!! </p>
<blockquote>
<p>DB에 쿼리를 날려서 덮어쓰면 되지 않을까? </p>
</blockquote>
<p>UPDATE user 
SET password = &#39;새 비번&#39;
WHERE 조건;</p>
<p>이렇게 하면 될 것 같지만.... 이러면 쉬웠겠쥬... 
하지만 Spring Security의 BCrpty는
<code>로그인할 때 입력받은 비번</code>을 <code>암호화</code>하여 
<code>DB에 있는 암호화된 비번</code>과 <code>비교</code>를 하기 때문에
DB에 암호화된 비번을 저장해두어야 한다. </p>
<blockquote>
<p>그렇다면 비번을 암호화를 내가 어케 하지? 그동안은 Spring Security가 해줬는데 ㅠ </p>
</blockquote>
<p>프로젝트의 main() 클래스에서  </p>
<pre><code>public static void main(String[] args) {
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    String encoded = encoder.encode(&quot;새 비번&quot;);
    System.out.println(encoded);
}</code></pre><p>이렇게 하고 메인 메서드를 실행하면 콘솔에 암호화된 비번 문자열이 출력된다. </p>
<p>이렇게 얻은 암호화된 비번으로 쿼리를 날려보자! </p>
<pre><code>UPDATE user
SET password = &#39;암호화된 비번&#39;
WHERE 조건 </code></pre><p>이렇게 SQL을 날리면 
DB에 새로운 암호화된 비번으로 업데이트 되고, 
FE 개발자에게는 새로운 비번을 알려주면 된다! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[정렬 기준 하나로 두 개의 정렬 설정하기]]></title>
            <link>https://velog.io/@hyu-jo/%EC%A0%95%EB%A0%AC-%EA%B8%B0%EC%A4%80-%ED%95%98%EB%82%98%EB%A1%9C-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%A0%95%EB%A0%AC-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyu-jo/%EC%A0%95%EB%A0%AC-%EA%B8%B0%EC%A4%80-%ED%95%98%EB%82%98%EB%A1%9C-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%A0%95%EB%A0%AC-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 16 May 2025 16:19:59 GMT</pubDate>
            <description><![CDATA[<p>말이 좀 이상하다
정렬 기준 하나로 어떻게 정렬을 두 개 설정?한다는거지 싶겠지만 
차차 설명해보도록 하겠다. </p>
<h1 id="수정-전">수정 전</h1>
<pre><code>@GetMapping(&quot;/movie/{tmdbId}&quot;)
  public ResponseEntity&lt;PageResponse&lt;ReviewResponseDTO&gt;&gt; getReviewsByMovieId(
      @PathVariable Long tmdbId,
      @RequestParam(value = &quot;certifiedFilter&quot;, required = false, defaultValue = &quot;false&quot;) Boolean certifiedFilter,
      @PageableDefault(size = 20, sort = &quot;createdAt&quot;, direction = Direction.DESC) Pageable pageable
  ) {
    Page&lt;ReviewResponseDTO&gt; reviews = reviewService.getReviewByMovieId(tmdbId, pageable, certifiedFilter);
    return ResponseEntity.ok(new PageResponse&lt;&gt;(reviews));
  }</code></pre><p>@PageableDefault로 디폴트 정렬을 사이즈는 20, createdAt 최신순으로 리뷰 데이터를 조회할 수 있도록 했다. 
그러면 <code>/api/reviews/movie/{tmdbId}</code>로만 요청을 하면 기본적으로 작성일시 기준 최신순으로 조회가 된다. 
Review 엔티티에는 좋아요 수(likeCount)도 있기 때문에
/api/reviews/movie/{tmdbId}<code>?sort=likeCount,desc</code>
로 요청을 하면 좋아요 수를 기준으로 내림차순으로 보여준다.
그런데 좋아요 수가 같은 경우가 있지 않은가!
이럴 때를 위해 두 번째 정렬이 필요하기 때문에 
/api/reviews/movie/{tmdbId}<code>?sort=likeCount,desc&amp;sort=createdAt,desc</code>
이런 식으로 정렬 기준을 2개를 같이 적어줘야 
좋아요 순으로 내림차순 정렬하되, 동수가 발생하면 작성일을 기준으로 최신순으로 보여주게 된다. </p>
<blockquote>
<ul>
<li>BE 
: FE님! 좋아요순으로 정렬하고 싶으시면 ?sort=likeCount,desc&amp;sort=createdAt,desc 이런 식으로 요청하시면 됩니다~</li>
</ul>
</blockquote>
<ul>
<li>FE
: 정렬 기준은 좋아요 수 한 개만 주세요. 근데 동수 발생시 최신순으로는 나오게 해주세요 </li>
<li>BE 
: 앗...! 넹... (일단 해본다고 한다)</li>
</ul>
<h1 id="수정-1차">수정 1차</h1>
<p>@PageableDafault 대신 @RequestParam을 사용하여 수동으로 정렬로직을 구현해봤다. 
디폴트 정렬은 createdAt으로 하되,
좋아요 순 정렬 요청이 들어오면(sortType이 &quot;likeCount&quot;인 경우,) 좋아요 수 내림차순을 정렬을 하고, 
좋아요 수가 동점인 경우 createdAt(작성일시) 기준으로 추가 정렬을 해주는 것이다. </p>
<pre><code>@GetMapping(&quot;/movie/{tmdbId}&quot;)
public ResponseEntity&lt;PageResponse&lt;ReviewResponseDTO&gt;&gt; getReviewsByMovieId(
    @PathVariable Long tmdbId,
    @RequestParam(value = &quot;certifiedFilter&quot;, required = false, defaultValue = &quot;false&quot;) Boolean certifiedFilter,
    @RequestParam(value = &quot;sort&quot;, required = false, defaultValue = &quot;createdAt&quot;) String sortType,
    @PageableDefault(size = 20) Pageable pageable
) {
  Sort sort;
  if (sortType.equals(&quot;likeCount&quot;)) {
    sort = Sort.by(Sort.Order.desc(&quot;likeCount&quot;), Sort.Order.desc(&quot;createdAt&quot;));
  } else {
    sort = Sort.by(Sort.Order.desc(&quot;createdAt&quot;));
  }

  Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);
  Page&lt;ReviewResponseDTO&gt; reviews = reviewService.getReviewByMovieId(tmdbId, sortedPageable, certifiedFilter);

  return ResponseEntity.ok(new PageResponse&lt;&gt;(reviews));
}
</code></pre><p>특히 likeCount가 정렬 기준으로 들어오면 <code>sort = Sort.by(Sort.Order.desc(&quot;likeCount&quot;),</code> 이 부분을 통해 좋아요 수 내림차순 정렬로 고정을 하게 된다. 
좋아요 수는 대체로 내림차순 정렬을 하니까 고정하는 게 효율적이라고 생각했다.
그리고 <code>Sort.Order.desc(&quot;createdAt&quot;));</code> 이 부분을 통해 좋아요 수 정렬 후, 최신순으로 정렬을 하게 된다. </p>
<blockquote>
<ul>
<li>BE 
: FE님! <code>?sort=likeCount</code> 이렇게 요청하시면 desc도 작성하실 필요 없고 다른 정렬 기준도 작성하실 필요 없어용! 효율적이죠!! </li>
</ul>
</blockquote>
<ul>
<li>FE 
: 그냥 다른 거 주던 거랑 똑같이 주세요. sort=likeCount,desc 이렇게요. 나중에 다른 정렬 기준도 생길 수 있어서 확장성을 위해서 동일하게 하는 게 낫지 않을까요? </li>
<li>BE 
: 앗... 넹....  (이제 어떻게 해야할지 모르겠지만 일단 해본다고 한다)</li>
</ul>
<h1 id="수정-2차">수정 2차</h1>
<p>다시 원상태로 돌아왔다. </p>
<blockquote>
<p>?sort=likeCount,desc로든 ?sort=likeCount,asc로든 정렬 기준과 정렬 방향을 적어주되,
 동수가 나와서 정렬 기준이 한 개 더 필요함에도 불구하고 정렬 기준은 한 개만 달라..... 
어떻게 하면 좋을까? </p>
</blockquote>
<p>일단 다시 <code>@PageableDefault</code>를 사용하여 <code>?sort=정렬기준,정렬방향</code> 형식으로 정렬 요청을 할 수 있도록 수정했다. </p>
<p>추후에 likeCount 외에도 rating(별점)도 정렬 기준으로 사용할 수 있기 때문에 <code>Sort.Order</code> 객체들을 리스트에 담기로 했다.
이를 위해 pageable.getSort().toList 를 이용해서 현재 요청된 정렬 조건을 List&lt;Sort.Order&gt;형태로 꺼내서 새 리스트에 복사했다. 
즉, pageable 객체에 들어 있는 정렬 조건들을 꺼내서 리스트로 만든 것이다. </p>
<pre><code>List&lt;Sort.Order&gt; orders = new ArrayList&lt;&gt;(pageable.getSort().toList());</code></pre><p>그리고 orders 리스트를 .stream()으로 순회하며 
order.getProperty().anyMatch()를 이용하여 좋아요수(&quot;likeCount&quot;)가 있는지 확인하고 있으면 true를 반환한다. </p>
<pre><code>orders.stream().anyMatch(order -&gt; order.getProperty().equals(&quot;likeCount&quot;)</code></pre><p>만약 이게 참인데 (좋아요 수 정렬 요청했는데) 정렬 조건에 createdAt이 포함되지 않은 경우, 동점 처리용으로 createdAt 기준 내림차순 정렬을 추가로 넣어줬다:</p>
<pre><code>boolean hasCreatedAt = orders.stream()
                             .anyMatch(order -&gt; order.getProperty().equals(&quot;createdAt&quot;));
if (!hasCreatedAt) {
  orders.add(Sort.Order.desc(&quot;createdAt&quot;));
}</code></pre><p>마지막으로 orders 리스트를 기반으로 새로운 Sort 객체를 만들고, 
이를 반영한 새로운 Pageable을 생성하여 내가 원하는 복합 정렬 조건이 적용되도록 해줬다. </p>
<pre><code>  @Operation(summary = &quot;특정 영화에 대한 리뷰 조회&quot;, description = &quot;댓글은 포함되어있지 않습니다.&quot; )
  @GetMapping(&quot;/movie/{tmdbId}&quot;)
  public ResponseEntity&lt;PageResponse&lt;ReviewResponseDTO&gt;&gt; getReviewsByMovieId(
      @PathVariable Long tmdbId,
      @RequestParam(value = &quot;certifiedFilter&quot;, required = false, defaultValue = &quot;false&quot;) Boolean certifiedFilter,
      @PageableDefault(size = 20, sort = &quot;createdAt&quot;, direction = Direction.DESC) Pageable pageable
  ) {
    List&lt;Sort.Order&gt; orders = new ArrayList&lt;&gt;(pageable.getSort().toList());
    if (orders.stream().anyMatch(order -&gt; order.getProperty().equals(&quot;likeCount&quot;))) {
      boolean hasCreatedAt = orders.stream().anyMatch(order -&gt; order.getProperty().equals(&quot;createdAt&quot;));
      if (!hasCreatedAt) {
        orders.add(Sort.Order.desc(&quot;createdAt&quot;));
      }
    }
    Pageable newPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(orders));
    Page&lt;ReviewResponseDTO&gt; reviews = reviewService.getReviewByMovieId(tmdbId, newPageable, certifiedFilter);
    return ResponseEntity.ok(new PageResponse&lt;&gt;(reviews));
  }</code></pre><p>이러면 ?sort=likeCount,desc와 같이 정렬 기준을 likeCount 한 개로 요청시, 
좋아요 순으로 정렬이 되고, 동수가 발생하면 작성 일시 기준 최신순으로 정렬이 되는 복합 정렬이 잘 이루어진다! </p>
<hr>
<p>문제는 해결이 됐다만, 
그런데 뭔가 좀..... 지저분한 거 같은데 
좀 더 좋은 방법이 있으면 알려주시기를 부탁드립니다..!!!!! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[없을 수도 있는 리소스의 경우, 404 Not Found? 200 OK? (Status Code)]]></title>
            <link>https://velog.io/@hyu-jo/%EC%97%86%EC%9D%84-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8A%94-%EB%A6%AC%EC%86%8C%EC%8A%A4%EC%9D%98-%EA%B2%BD%EC%9A%B0-404-Not-Found-200-OK-Status-Code</link>
            <guid>https://velog.io/@hyu-jo/%EC%97%86%EC%9D%84-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8A%94-%EB%A6%AC%EC%86%8C%EC%8A%A4%EC%9D%98-%EA%B2%BD%EC%9A%B0-404-Not-Found-200-OK-Status-Code</guid>
            <pubDate>Sun, 04 May 2025 12:41:34 GMT</pubDate>
            <description><![CDATA[<p>진행중인 프로젝트에서 
사용자가 인증샷을 업로드하는 기능이 있다. 
또한 사용자는 자신이 업로드한 인증샷을 조회할 수 있게 하는 기능도 제공한다.</p>
<hr>
<p>그런데 이 인증샷은 필수가 아니라 사용자가 <code>선택적으로</code> 업로드해도 되는 것이다.</p>
<p>그러면 인증샷을 올리지 않은 사용자가 
GET /api/certifications/me로 자신의 인증샷을 조회하는 요청을 보내면 
기존에는 <code>요청받은 리소스가 존재하지 않으니 404(Not Found) 에러</code>를 내보냈다.</p>
<p>BE 관점에서는 요청한 리소스가 존재하지 않기 때문에 당연히 404가 맞다고 생각했다. 
FE 개발자는 에러 코드가 있으니 예상 가능한 에러이면 올바른 에러 페이지로 보내는 처리를 해야 한다. </p>
<p>그런데! 인증샷 업로드는 선택사항이므로 <code>없을 수도 있는 리소스</code>인데 이게 존재하지 않는다고 해서 <code>비정상인 것까지는 아니지 않는가...!</code></p>
<hr>
<p>그래서 다음과 같은 흐름으로 가는 건 어떨지 의견을 주셔서 응답을 변경했다. </p>
<blockquote>
<ul>
<li><h3 id="인증샷을-올린-사용자의-요청">인증샷을 올린 사용자의 요청</h3>
➡️ 200 OK + 인증샷 데이터 반환</li>
</ul>
</blockquote>
<ul>
<li><h3 id="인증샷을-안-올린-사용자의-요청">인증샷을 안 올린 사용자의 요청</h3>
➡️ 200 OK + {&quot;인증샷url&quot;: null, &quot;status&quot;: &quot;NONE&quot;}과 같은 빈 값 반환</li>
</ul>
<p>즉, 에러 대신 정상 응답을 반환하고, 이와 함께 null 데이터를 보내는 방식으로 변경한 것이다.
이런 식으로 반환을 받은 FE 개발자는 인증샷이 아직 업로드 되지 않았다는 것을 인지하고
조건 분기(예를 들어, 업로드 폼 띄우기)를 깔끔하게 처리할 수 있게 된다. </p>
<blockquote>
<h2 id="결론">결론</h2>
</blockquote>
<h3 id="없을-수도-있는-리소스라면">없을 수도 있는 리소스라면</h3>
<p>404로 내보내는 것도 좋지만, 
각 서비스의 환경에 맞게 200 OK + 빈 응답으로 처리하는 것도 좋은 방법이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[페이지네이션을 처리하는 방법 : offset 기반 vs. cursor 기반]]></title>
            <link>https://velog.io/@hyu-jo/%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-offset-%EA%B8%B0%EB%B0%98-vs-cursor-%EA%B8%B0%EB%B0%98</link>
            <guid>https://velog.io/@hyu-jo/%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-offset-%EA%B8%B0%EB%B0%98-vs-cursor-%EA%B8%B0%EB%B0%98</guid>
            <pubDate>Sat, 03 May 2025 02:06:18 GMT</pubDate>
            <description><![CDATA[<h1 id="offset-기반-페이지네이션">offset 기반 페이지네이션</h1>
<ul>
<li><p>개념 : 몇 번째 페이지인지 기준으로 데이터를 잘라 가져오는 방식</p>
<blockquote>
<ul>
<li>요청 예시 : <code>GET /엔드포인트?page=4&amp;size=20</code></li>
<li><blockquote>
<p>5번째 페이지의 20개 가져와줘 </p>
</blockquote>
</li>
</ul>
</blockquote>
</li>
<li><p>Spring Data JPA의 <code>Pageable</code> 인터페이스로 구현이 가능하다. </p>
</li>
<li><p>응답 정보</p>
<ul>
<li>현재 페이지 번호</li>
<li>전체 페이지 수</li>
<li>전체 데이터 수</li>
<li>첫번째/마지막 페이지 여부</li>
</ul>
</li>
<li><p>단점 </p>
<blockquote>
<p>데이터가 많을수록 느려질 수 있다.
정적인 데이터에는 적절하지만, 실시간으로 변경되는 데이터가 있는 경우 문제가 생긴다. </p>
</blockquote>
</li>
</ul>
<ul>
<li>데이터 추가/삭제로 인한 &quot;밀림 현상&quot;이 일어날 수 있다. </li>
<li>예를 들어, 1~3번 데이터를 본 후 다음 페이지로 넘어갔는데, 
4번 데이터가 빠지고 5번부터 나올 수 있다. </li>
</ul>
<p>이처럼 데이터가 실시간으로 변경될 수 있다면 offset 방식은 데이터 누락 문제로 이어질 수 있다. 
예를 들어, 관리자 권한으로 대기 ➔ 승인으로 상태를 변경할 시, 실시간으로 데이터가 빠지는 등, 데이터 변동이 잦기 때문에
정확한 이어보기와 부드러운 무한스크롤을 위해 cursor 기반 페이지네이션을 사용하는 게 낫다. </p>
<hr>
<h1 id="cursor-기반-페이지네이션">cursor 기반 페이지네이션</h1>
<ul>
<li><p>개념 : 특정 데이터(커서)를 기준으로 그 이후의 데이터를 가져오는 방식
즉, 마지막으로 본 데이터를 기준으로 삼고, 그 다음 데이터를 가져오는 방식이다.</p>
</li>
<li><p>응답에 포함되는 정보: </p>
<ul>
<li>현재 응답 데이터 목록</li>
<li>다음 요청에 사용할 커서 값</li>
<li>다음 페이지 존재 여부 (hasNext)    </li>
</ul>
</li>
<li><p>요청 : </p>
<blockquote>
<p>최초 요청 시: 커서 없이 요청 → 첫 번째 커서 기준으로 응답
이후 요청 시: 마지막으로 받은 커서 값을 기준으로 다음 데이터 요청</p>
</blockquote>
</li>
<li><p>특징 : </p>
<ul>
<li>데이터가 많아도 성능이 일정하다</li>
<li>page 개념이 없고, &quot;어디서부터 이어받을지&quot;만 신경을 쓴다. <ul>
<li>몇 번째 페이지를 요쳥하는 게 아니라 어디까지 봤는지를 기억해서 요청하는 것</li>
</ul>
</li>
</ul>
</li>
<li><p>주의할 점</p>
<ul>
<li><p>커서로 사용하는 값은 정렬 가능한 고유한 값이어야 함 </p>
</li>
<li><p>커서 값이 동일한 데이터가 여러 개 있을 경우, 중복 조회되거나 누락되는 문제 발생 가능
→ 두 번째 정렬 기준 필요 (ex. 첫 번째 커서 - createdAt, 두 번째 커서 - id)</p>
<ul>
<li>이런 식으로 진행하려면 
첫 요청에는 첫 번째 정렬기준(커서)를 필수로 넣어야 하고, 
두 번째 요청부터는 첫 번째 요청에서부터 응답받은 첫 번째, 두 번째 정렬기준(커서)를 모두 필수로 넣어줘야한다. 
그리고 이러한 과정을 hasNext가 true인 동안 반복해서 호출해준다. </li>
</ul>
</li>
</ul>
</li>
<li><p>구현 방식 : 
Page<T> 처럼 공식적으로 정해진 클래스가 없으니 
각자의 서비스에 맞게 CursorPageResponse<T> 커스텀 응답 클래스를 정의해서 사용해야 한다.</p>
<pre><code>@Getter
@AllArgsConstructor
public class CursorPageResponse&lt;T&gt; {

private List&lt;T&gt; content; // 데이터들
private LocalDateTime nextCreatedAt; // 마지막 인증의 생성시간
private Long nextCertificationId; // 마지막 인증의 ID
private boolean hasNext; // 더 많은 데이터가 있는지 여부
}
</code></pre></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[DB에서 n개의 데이터를 가져오는 로직을 짰는데 n-k개의 데이터만 반환되는 이슈 ]]></title>
            <link>https://velog.io/@hyu-jo/DB%EC%97%90%EC%84%9C-n%EA%B0%9C%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EA%B0%80%EC%A0%B8%EC%98%A4%EB%8A%94-%EB%A1%9C%EC%A7%81%EC%9D%84-%EC%A7%B0%EB%8A%94%EB%8D%B0-n-k%EA%B0%9C%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A7%8C-%EB%B0%98%ED%99%98%EB%90%98%EB%8A%94-%EC%9D%B4%EC%8A%88</link>
            <guid>https://velog.io/@hyu-jo/DB%EC%97%90%EC%84%9C-n%EA%B0%9C%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EA%B0%80%EC%A0%B8%EC%98%A4%EB%8A%94-%EB%A1%9C%EC%A7%81%EC%9D%84-%EC%A7%B0%EB%8A%94%EB%8D%B0-n-k%EA%B0%9C%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A7%8C-%EB%B0%98%ED%99%98%EB%90%98%EB%8A%94-%EC%9D%B4%EC%8A%88</guid>
            <pubDate>Fri, 02 May 2025 09:13:33 GMT</pubDate>
            <description><![CDATA[<h1 id="프롤로그">프롤로그</h1>
<p>TMDB API로부터 넷플릭스에서 제공하고 있는 영화 데이터를 받아서 사용중이다. 
넷플 내의 인기순으로 받아오기는 쉽지 않았기 때문에 
어쩔 수 없이 TMDB 내의 인기도 순으로 정렬을 했다. 
그리고 인덱스 페이지에 인기 영화 20위를 보여주는 API를 넣어두었다. 
그런데 인도 영화가 인기가 높더라구여......? 
인도 영화 당연히 있을 수 있지. 발리우드 재밌지. 응응 
그런데 제목이 인도어?힌두어? 되어 있는 영화가 있었다. 
(아마도 아직 한국에는 정식으로 들어오지 않은 영화가 아닌가 싶다. 제목도 번역이 안 되어 있고, 줄거리조차 적혀있지 않음)
우리는 볼 수도 없는 영화를 봐야하니 꽤나 미관참시가..... 
방법을 찾아봐야지... 그래서 일단!  </p>
<h2 id="한국어영어로-번역된-제목만-필터링하기">한국어/영어로 번역된 제목만 필터링하기</h2>
<p>넷플릭스에서는 어지간하면 영화 제목을 한국어로 번역을 해주고 
정 번역이 안 되어 있으면 영어 제목으로 보여주는 것에 착안하여 
TMDB의 API에서 반환값 중 title에 한국어, 영어만 조회해오도록 필터링을 거는 것이 좋겠다고 판단했다. 
(original_title이 아니라 title이다!)</p>
<h3 id="정규식을-사용해서-메서드를-만들어줬다">정규식을 사용해서 메서드를 만들어줬다.</h3>
<pre><code>  private boolean isKoreanOrEnglishTitle(String title) {
    if (title == null) return false;

    boolean containsKorean = title.matches(&quot;.*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*&quot;);
    boolean isEnglishOnly = title.matches(&quot;^[a-zA-Z0-9\\s.,!?\&quot;&#39;\\-:()]+$&quot;);

    return containsKorean || isEnglishOnly;
  }</code></pre><p>먼저, containsKorean에는 title 내에 한 글자 이상의 한글 문자가 포함되어 있는지 검사하는 정규식을 사용했다.
일반적인 한글 글자 뿐만 아니라 자음, 모음도 넣어뒀다. (혹시 모르니께.. 나중에 업데이트 되는 영화 제목이 자음 하나일수도 있으니까...? ㅎ )</p>
<p>isEnglishOnly에는 영어/숫자/공백/문장부호가 포함되는지 검사하는 정규식을 넣어줬다. </p>
<p>이렇게 하면 </p>
<ol>
<li>한글이 포함되어 있든지</li>
<li>영어만 있든지 (예. CTRL)</li>
<li>한글과 영어가 섞였든지</li>
<li>숫자나 기호로만 되어있든지 (예. 9-5)
이러한 것들은 true로 반환된다.  </li>
</ol>
<pre><code>/**
 * 인기도 탑 n 영화 세부정보 조회
 * @param size
 */
  public List&lt;MovieDTO&gt; getTopMovies(int size) {
    Pageable pageable = PageRequest.of(0, size);
    Page&lt;Movie&gt; topMovies = movieRepository.findAllByAvailableIsTrueOrderByPopularityDesc(pageable);
    log.info(&quot;인기도 탑{} 영화 조회 성공&quot;, size);
    return topMovies.stream()
        .filter(movie -&gt; isKoreanOrEnglishTitle(movie.getTitle()))
        .map(this::convertToDtoWithoutReviews)
        .collect(Collectors.toList());
  }</code></pre><p>이렇게 JPA로 영화 데이터를 조회하고, 
.filter(movie -&gt; isKoreanOrEnglishTitle(movie.getTitle()))
를 통해 필터링을 거쳐준다. 
그러면 이제 힌두어?로 되어 있는 영화들은 보이지 않게 되었다. </p>
<h2 id="n개가-다-반환이-안-됐어요">n개가 다 반환이 안 됐어요!</h2>
<p>나는 사실 응답이 나온 것만 확인하고 개수까진 확인하지 않았었다. 알아서 20개 나왔으려니 하고 뿌듯해하고 있었음... 
그런데 FE 분이 작업하시다가 20개가 아닌 18개의 데이터만 들어왔다는 사실을 알려주셔서 알게 되었다. 
분명 캐시 초기화도 해서 새롭게 들어갔을텐데 왜 18개만 들어갔다는 거지?
나는 분명 getTopMovies의 size에 20을 적었는데? </p>
<pre><code>/**
 * 인기도 탑 20 영화 세부정보 조회
 */
  @Transactional(readOnly = true)
  @Cacheable(value = &quot;top20Movies&quot;, key = &quot;&#39;top20Movies&#39;&quot;)
  public List&lt;MovieDTO&gt; getTop20Movies() {
    return getTopMovies(20);
  }</code></pre><p>그럼에도 불구하고 정말로 20개가 반환이 안 된 것이다. </p>
<h2 id="이미-가져온-데이터를-또-필터링한-죄">이미 가져온 데이터를 또 필터링한 죄</h2>
<p>생각을 해보자... 그러자 너무나 당연한 결과였다. 
getTopMovies()에서 </p>
<pre><code>Pageable pageable = PageRequest.of(0, size);
    Page&lt;Movie&gt; topMovies = movieRepository.findAllByAvailableIsTrueOrderByPopularityDesc(pageable);</code></pre><p>여기에서 size를 20으로 잡았으니 
데이터에서 20개를 가져오고, 
&#39;가져온 데이터에서&#39; 필터링을 또! 거치는 로직이었던 것이다. </p>
<p>그래서 상위 20위 중에 존재하던 힌두어 제목의 영화 2개가 제외되면서 18개만 반환된 것이다.  </p>
<h2 id="db의-정규식을-이용해서-필터링된-데이터-조회하여-가져오기">DB의 정규식을 이용해서 필터링된 데이터 조회하여 가져오기</h2>
<p>그동안 정규식은 유효성 검사할 때나 써봤다. 
정규식을 데이터를 조회할 때도 쓸 수 있지 않을까 하여 적용해보기로 했다. </p>
<p>그런데...!</p>
<h3 id="jpa에서는-정규식-기반의-데이터-조회를-지원하지-않는다">JPA에서는 정규식 기반의 데이터 조회를 지원하지 않는다</h3>
<p>이가 없으면 잇몸이닷!
네이티브 쿼리를 쓰는 방법이 있지!! SQL을 써버려~~!! 
어지간하면 쓰지 말라고 했던 것 같지만
네이티브 쿼리가 필요한 게 바로 이런 순간이 아닐까...!!! </p>
<h3 id="네이티브-쿼리-nativequery-로-sql-쿼리-작성하기">네이티브 쿼리 (nativeQuery) 로 SQL 쿼리 작성하기</h3>
<blockquote>
<p>네이티브 쿼리 쓰는 방법 : 
@Query(value = SQL 쿼리, nativeQuery = true)</p>
</blockquote>
<pre><code>  // 인기도 상위 n개의 영화정보 조회
  @Query(
      value = &quot;SELECT * FROM movie WHERE is_available = true AND&quot; +
          &quot;(title REGEXP &#39;[ㄱ-ㅎㅏ-ㅣ가-힣]&#39; OR title REGEXP &#39;^[a-zA-Z0-9\\s.,!?~&amp;=\\\&quot;&#39;&#39;:()\\-\\+\\#\\*\\%\\/]+&#39;)&quot; +
          &quot;ORDER BY popularity DESC&quot; +
          &quot;LIMIT :limit&quot;,
      nativeQuery = true)
  List&lt;Movie&gt; findAllByAvailableIsTrueOrderByPopularityDesc(@Param(&quot;limit&quot;) int limit);</code></pre><p>value 부분에는 내가 원래 조회하고 싶었던 쿼리를 작성해준다. 
AND를 사용해서 정규표현식(REGEXP) 두 조건을 넣어 필터링해준다. 
(두 정규식 사이에는 OR 연산자로 연결해서 조건에 맞는 제목들을 모두 조회하도록 한다.) 
그리고 popularity 컬럼 기준 내림차순으로 정렬해주고, 
LIMIT를 활용해서 결과의 개수를 제한하도록 해준다. 
마지막엔 nativeQuery = true를 넣어주어 네이티브 SQL 쿼리를 사용함을 명시해줘야 한다. </p>
<p>주의해야할 것은, DB에서 사용하는 컬럼명과 일치해야 한다는 것이다. 
처음에는 isAvailable로 적었다가 컬럼이 존재하지 않는다길래 당황했지만 <code>describe Movie;</code>를 해보니 is_available로 되어있길래 수정했다. </p>
<p>그리고 다시 서비스 코드를 수정해준다. 
이제 필터링은 할 필요가 없으니 isKoreanOrEnglishTitle() 메서드는 삭제하고, 
getTopMovies() 내부에서의 필터링 코드도 삭제해준다. </p>
<pre><code>  /**
   * 인기도 탑 n 영화 세부정보 조회
   * @param size
   */
  public List&lt;MovieDTO&gt; getTopMovies(int size) {
    List&lt;Movie&gt; topMovies = movieRepository.findAllByAvailableIsTrueOrderByPopularityDesc(size);
    log.info(&quot;인기도 탑{} 영화 조회 성공&quot;, size);
    return topMovies.stream()
        .map(this::convertToDtoWithoutReviews)
        .collect(Collectors.toList());
  }</code></pre><p>이러면 이제 문제 없이 의도했던 대로 n개의 데이터가 잘 받아와진다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PageImpl 직렬화 경고 (ration$PageModule$WarningLoggingModifier ) 로그가 뜰 때]]></title>
            <link>https://velog.io/@hyu-jo/PageImpl-%EC%A7%81%EB%A0%AC%ED%99%94-%EA%B2%BD%EA%B3%A0-rationPageModuleWarningLoggingModifier-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EB%9C%B0-%EB%95%8C</link>
            <guid>https://velog.io/@hyu-jo/PageImpl-%EC%A7%81%EB%A0%AC%ED%99%94-%EA%B2%BD%EA%B3%A0-rationPageModuleWarningLoggingModifier-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EB%9C%B0-%EB%95%8C</guid>
            <pubDate>Tue, 29 Apr 2025 12:51:11 GMT</pubDate>
            <description><![CDATA[<p>서버를 가동한 후 페이징 처리가 된 기능을 쓰면 딱 한 번 나오는 로그가 있다. </p>
<blockquote>
<p>ration$PageModule$WarningLoggingModifier 
: Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure!
For a stable JSON structure, please use Spring Data&#39;s PagedModel 
(globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))
or Spring HATEOAS and Spring Data&#39;s PagedResourcesAssembler as documented in <a href="https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables">https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables</a>.</p>
</blockquote>
<p>이 로그는 PageImpl 객체를 ResponseEntity&lt;Page<T>&gt; 형태로 반환하면, JSON 구조가 불안정할 수 있다고 경고해주는 로그이다. </p>
<p>현재 별 문제는 없이 돌아가고 있지만 뭔가 로그에 찍히면 고치고 싶다.</p>
<hr>
<h1 id="1-로그에-적힌-대로-pagedmodel과-enablespringdatawebsupport-사용하기">1. 로그에 적힌 대로 PagedModel과 @EnableSpringDataWebSupport 사용하기</h1>
<p>이걸 쓰면 전역 설정이 되어 자동 변환 가능하다<br>  하지만!!!!! 
  PagedModel을 사용하면 반환형이 </p>
<pre><code>{
&quot;content&quot;: [데이터들......],
&quot;page&quot;: {
&quot;size&quot;: 10,
&quot;number&quot;: 1,
&quot;totalElements&quot;: 228,
&quot;totalPages&quot;: 23
}
}</code></pre><p>  이렇게 고정이 되어버린다. </p>
<p>  그런데 우리 서비스는 </p>
<pre><code>&quot;content&quot;: [],              // 데이터들
&quot;number&quot;: 1,                // 현재 페이지 (0부터 시작)
&quot;size&quot;: 2,                  // 요청한 페이지 크기
&quot;totalPages&quot;: 10,           // 전체 페이지 수
&quot;totalElements&quot;: 20,        // 전체 아이템 수
&quot;numberOfElements&quot;: 2,      // 현재 페이지에 실제 담긴 아이템 수
&quot;first&quot;: false,             // 첫 페이지인지 여부
&quot;last&quot;: false,              // 마지막 페이지인지 여부
&quot;empty&quot;: false              // 현재 페이지가 비었는지</code></pre><p>  이런 부가적인 정보들이 더 필요했다. 
  그래서 이 방법 말고, 다른 방법을 사용해야 했다. </p>
<hr>
<h1 id="2-pageresponset-dto를-커스텀하여-만들어-사용하기">2. PageResponse<T> DTO를 커스텀하여 만들어 사용하기</h1>
<pre><code>public class PageResponse&lt;T&gt; {
private List&lt;T&gt; content;
private int number;             // 현재 페이지 번호 (0부터 시작)
private int size;               // 페이지당 아이템 수
private int totalPages;         // 전체 페이지 수
private long totalElements;     // 전체 아이템 수
private int numberOfElements;   // 현재 페이지의 아이템 수
private boolean first;          // 첫 페이지 여부
private boolean last;           // 마지막 페이지 여부
private boolean empty;          // 현재 페이지가 비어있는지 여부

public PageResponse(Page&lt;T&gt; page) {
    this.content = page.getContent();
    this.number = page.getNumber();
    this.size = page.getSize();
    this.totalPages = page.getTotalPages();
    this.totalElements = page.getTotalElements();
    this.numberOfElements = page.getNumberOfElements();
    this.first = page.isFirst();
    this.last = page.isLast();
    this.empty = page.isEmpty();
}
}</code></pre><p>  여기서 
<code>private long totalElements; // 전체 아이템 수</code>
이것만 long 타입인 이유는 Spring Data의 Page<T> 인터페이스에서 정해둔 규칙이기 때문이다. </p>
<pre><code>public interface Page&lt;T&gt; extends Slice&lt;T&gt; {  
int getTotalPages();  
long getTotalElements(); 
}</code></pre><p>  실제 데이터 개수가 int의 범위를 넘을 수도 있기 때문에 더 큰 범위를 표현할 수 있는 long을 사용하는 것이다. </p>
<p>  그리고 컨트롤러에서는 </p>
<pre><code>Page&lt;ReviewResponseDTO&gt; reviews = ~~~~~~
return ResponseEntity.ok(new PageResponse&lt;&gt;(reviews));</code></pre><p>  요렇게 내가 만든 응답 객체인 new PageResponse&lt;&gt;()를 사용해주면 
  더이상 PageImpl 직렬화 경고 로그가 뜨지 않게 된다! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그아웃을 했음에도 예전에 쓰던 엑세스 토큰으로 요청이 되네...? 이러면 안 되는 거 아닌가? (로그아웃 시, 엑세스 토큰을 블랙리스트로 관리하기)]]></title>
            <link>https://velog.io/@hyu-jo/%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83%EC%9D%84-%ED%96%88%EC%9D%8C%EC%97%90%EB%8F%84-%EC%98%88%EC%A0%84%EC%97%90-%EC%93%B0%EB%8D%98-%EC%97%91%EC%84%B8%EC%8A%A4-%ED%86%A0%ED%81%B0%EC%9C%BC%EB%A1%9C-%EC%9A%94%EC%B2%AD%EC%9D%B4-%EB%90%98%EB%8A%94%EB%8D%B0-%EC%9D%B4%EB%9F%AC%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EA%B1%B0-%EC%95%84%EB%8B%8C%EA%B0%80</link>
            <guid>https://velog.io/@hyu-jo/%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83%EC%9D%84-%ED%96%88%EC%9D%8C%EC%97%90%EB%8F%84-%EC%98%88%EC%A0%84%EC%97%90-%EC%93%B0%EB%8D%98-%EC%97%91%EC%84%B8%EC%8A%A4-%ED%86%A0%ED%81%B0%EC%9C%BC%EB%A1%9C-%EC%9A%94%EC%B2%AD%EC%9D%B4-%EB%90%98%EB%8A%94%EB%8D%B0-%EC%9D%B4%EB%9F%AC%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EA%B1%B0-%EC%95%84%EB%8B%8C%EA%B0%80</guid>
            <pubDate>Tue, 29 Apr 2025 12:16:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>맞습니다. 그러면 안 됩니다.</p>
</blockquote>
<p>로그아웃을 해도 이전에 쓰던 엑세스 토큰을 넣어 요청을 하면 응답이 제대로 오는 게 뭔가 이상하다 싶었는데... 역시 문제가 있는 이슈였다! </p>
<h1 id="--access-token">- Access Token</h1>
<p>엑세스 토큰은 한 번 발급 되면 
서버가 이를 별도로 저장하지 않고, 클라이이언트가 갖고 있게 된다.
서버는 토큰을 만들어서 클라이언트에게 주기만 할 뿐, 
이후에는 해당 토큰의 유효성만 검증을 한다. 
즉, 토큰에는 여러 정보가 담겨있는데 서버는 그 정보를 해석해서 인증 처리를 해줄 뿐이다.
엑세스 토큰은 stateless 방식(=상태를 갖지 않음)이기 때문에 
서버는 클라이언트의 로그인 상태나 세션 정보를 기억하지 않아 보안적 허점이 생길 수 있다. </p>
<h2 id="🧨--엑세스-토큰의-보안적-허점">🧨  엑세스 토큰의 보안적 허점</h2>
<h3 id="1-로그아웃을-했음에도-유효한-토큰-존재">1. 로그아웃을 했음에도 유효한 토큰 존재</h3>
<ul>
<li>사용자는 로그아웃을 하면 서버는 SecurityContextHolder.clearContext()로 현재 요청의 인증 상태만 초기화할 뿐이다.
엑세스 토큰은 여전히 클라이언트에 남아 있기 때문에 
서버는 만료시간 전까지 해당 토큰을 정상적인 토큰으로 받아들인다.</li>
</ul>
<h3 id="2-토큰-탈취-시-피해-복구-불가">2. 토큰 탈취 시, 피해 복구 불가</h3>
<p>브라우저나 디바이스에 저장된 토큰이 탈취(ex.XSS) 되면 
해당 토큰으로 타인이 내 인증정보로 요청을 보낼 수 있게 된다. 
심지어 사용자가 로그아웃을 해도 소용이 없게 된다. 
왜냐하면 그 토큰은 유효한 이상, 공격자는 계속 접근을 할 수 있기 때문..! </p>
<hr>
<h2 id="🛡️-사고-대응-방안">🛡️ 사고 대응 방안</h2>
<blockquote>
<p>로그아웃 시, 엑세스 토큰을 블랙리스트로 관리하기 </p>
</blockquote>
<h3 id="jwtfilter에-블랙리스트-확인-로직-추가">JwtFilter에 블랙리스트 확인 로직 추가</h3>
<p>클라이언트가 요청 시 전달한 토큰이 Redis 블랙리스트에 등록되어 있는 경우,
해당 요청을 즉시 거부(401 Unauthorized)하도록 필터를 추가해준다. </p>
<pre><code>@Component
public class JwtFilter extends OncePerRequestFilter {

  private final JwtProvider jwtProvider;
  private final UserDetailsService userDetailsService;
  private final RedisTemplate&lt;String, String&gt; redisTokenTemplate;

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

    // jwtProvider에 토큰을 추출하는 로직이 있어야 함! 
    // 요청 헤더 중 Authorization에서 값을 꺼내 &quot;Bearer &quot;를 제외한 JWT 토큰 문자열 추출
    String token = jwtProvider.extractToken(request);

    try {
      if (token != null) {

         // 토큰이 Redis의 블랙리스트에 등록된 토큰인지 확인
        String isBlacked = redisTokenTemplate.opsForValue().get(token);
        // 값이 &quot;logout&quot;이면 블랙리스트
        if (&quot;logout&quot;.equals(isBlacked)) {
          log.warn(&quot;블랙리스트로 등록된 토큰. 접근 거부&quot;);
          setErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, &quot;로그아웃된 토큰입니다.&quot;);
          return;
        }

        // 토큰이 유효한지 검증 
        if (jwtProvider.validateToken(token)) {
          String email = jwtProvider.extractEmail(token);
          Role role = jwtProvider.getRoleFromToken(token);

          UserDetails userDetails = userDetailsService.loadUserByUsername(email);
          Authentication authentication = new UsernamePasswordAuthenticationToken(
              userDetails,
              null,
              Collections.singletonList(new SimpleGrantedAuthority(role.name())));
          SecurityContextHolder.getContext().setAuthentication(authentication);
        }
      }

      // 위의 로직을 모두 통과했으면 다음 필터나 서블릿으로 요청을 넘김
      filterChain.doFilter(request, response);

    } catch (ExpiredJwtException e) {
      log.warn(&quot;만료된 JWT 토큰: {}&quot;, e.getMessage());
      setErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, &quot;토큰이 만료되었습니다.&quot;);
    } catch (JwtException e) {
      log.warn(&quot;잘못된 JWT 토큰: {}&quot;, e.getMessage());
      setErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, &quot;유효하지 않은 토큰입니다.&quot;);
    }
  }
</code></pre><h3 id="로그아웃회원탈퇴-시-사용하던-엑세스-토큰을-redis에-블랙리스트로-등록한다">로그아웃/회원탈퇴 시, 사용하던 엑세스 토큰을 Redis에 블랙리스트로 등록한다.</h3>
<p>토큰이 아직 유효한(만료되지 않은) 토큰이라도, 
서버는 그걸 &quot;차단된 토큰&quot;으로 인식하여 강제로 무효화하는 방식이다. </p>
<pre><code>@RestController
@RequestMapping(&quot;/api/users&quot;)
public class UserController {

   @DeleteMapping(&quot;/logout&quot;)
  public ResponseEntity&lt;Void&gt; logout(HttpServletRequest request) {
    userService.logout(request);
    return ResponseEntity.noContent().build();

  }
 }


@Service
public class UserService { 
  private final RedisTemplate&lt;String, String&gt; redisTokenTemplate;
  private final JwtProvider jwtProvider;

  public void logout(HttpServletRequest request) {

    ~~~~
    // 리프레시 토큰 삭제
    ~~~

    // 기존의 엑세스토큰은 블랙리스트로 등록
    String accessToken = jwtProvider.extractToken(request);

    if (accessToken != null) {
      // 남은 시간 = 만료시간 - 현재시간
      long remainTime = jwtProvider.getExpirationTimeFromToken(accessToken) - System.currentTimeMillis();

      if (remainTime &gt; 0) {
        // 남은 시간동안 블랙리스트로 등록
        redisTokenTemplate.opsForValue().set(accessToken, &quot;logout&quot;, remainTime, TimeUnit.MILLISECONDS);
        log.info(&quot;엑세스토큰 블랙리스트로 등록 완료&quot;);
      } else {
        log.info(&quot;만료된 엑세스 토큰&quot;);
      }
    }

    // SecurityContext 초기화
    SecurityContextHolder.clearContext();
    log.info(&quot;SecurityContext 초기화&quot;);
    log.info(&quot;로그아웃 성공&quot;);
  }
  }</code></pre><h3 id="그런데-왜-redis에-저장하지">그런데 왜 Redis에 저장하지?</h3>
<ul>
<li>Redis는 메모리 기반 NoSQL이기 때문에 빠르게 블랙리스트에 있는지 여부를 확인할 수 있다. </li>
<li>또한 Redis는 TTL 설정이 가능하기 때문에 (set(키, 값, ttl)) 자동으로 ttl 시간 뒤에 삭제할 수 있어서 편리하다.  </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security + SSE 사용 중, 동일한 토큰을 사용했음에도 Access Denied 예외가 발생하는 이슈 해결 1]]></title>
            <link>https://velog.io/@hyu-jo/Spring-Security-SSE-%EC%82%AC%EC%9A%A9-%EC%A4%91-%EB%8F%99%EC%9D%BC%ED%95%9C-%ED%86%A0%ED%81%B0%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%96%88%EC%9D%8C%EC%97%90%EB%8F%84-Access-Denied-%EC%98%88%EC%99%B8%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@hyu-jo/Spring-Security-SSE-%EC%82%AC%EC%9A%A9-%EC%A4%91-%EB%8F%99%EC%9D%BC%ED%95%9C-%ED%86%A0%ED%81%B0%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%96%88%EC%9D%8C%EC%97%90%EB%8F%84-Access-Denied-%EC%98%88%EC%99%B8%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Wed, 23 Apr 2025 12:44:27 GMT</pubDate>
            <description><![CDATA[<p>SSE를 사용해서 알림 기능을 구현하고 있다. </p>
<h1 id="🔥-problem">🔥 Problem</h1>
<blockquote>
<p>첫 구독은 잘 되고, 
두 번째 구독은 Access Denied가 뜨면서 emitter가 종료되고, 
세 번째 구독부터 다시 정상적으로 구독이 되는 문제가 생겼다. </p>
</blockquote>
<p>로그로 흐름을 보자면 다음과 같다. </p>
<h2 id="첫-번째-구독-시도---성공">첫 번째 구독 시도 -&gt; 성공</h2>
<pre><code>SSE 구독 시작
SSE 초기 메시지 전송 시도: userId = 2
SSE 초기 메시지 전송 완료
SSE 구독 시작 : userId = 2</code></pre><h2 id="두-번째-구독-시도---emitter-종료">두 번째 구독 시도 -&gt; emitter 종료</h2>
<pre><code>기존 emitter 존재: userId = 2
기존 emitter 제거 완료: userId = 2
SSE 초기 메시지 전송 시도: userId = 2
SSE 초기 메시지 전송 완료</code></pre><p>이렇게 초기 메시지까지는 전송이 완료됐으나 </p>
<pre><code>2025-04-23T11:49:27.764+09:00 ERROR 21356 --- [ddv] [nio-8080-exec-9] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] threw exception

org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
2025-04-23T11:49:27.772+09:00 ERROR 21356 --- [ddv] [nio-8080-exec-9] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Unable to handle the Spring Security Exception because the response is already committed.] with root cause

org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
2025-04-23T11:49:27.775+09:00 ERROR 21356 --- [ddv] [nio-8080-exec-9] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] threw exception

org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
2025-04-23T11:49:27.776+09:00 ERROR 21356 --- [ddv] [nio-8080-exec-9] o.a.c.c.C.[Tomcat].[localhost]           : Exception Processing [ErrorPage[errorCode=0, location=/error]]

jakarta.servlet.ServletException: Unable to handle the Spring Security Exception because the response is already committed.
Caused by: org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
SSE 연결 종료 : userId = 2
</code></pre><p>이런 식으로 바로 Access Denied 예외가 터지면서 emitter가 종료됐다. 
이 때문에 새로고침을 하면 알림이 바로 오지 않는 문제가 발생했다.  </p>
<h2 id="세-번째-구독-시도---성공">세 번째 구독 시도 -&gt; 성공</h2>
<p>여기서는 두 번째 구독에서 기존 emitter를 삭제했기 때문에
마치 처음 구독하는 것처럼 로그가 찍혔다. </p>
<pre><code>SSE 구독 시작
SSE 초기 메시지 전송 시도: userId = 2
SSE 초기 메시지 전송 완료
SSE 구독 시작 : userId = 2</code></pre><p>이러한 현상이 생기면서 처음 새로고침을 하면 
emitter가 종료됨에 따라 30초마다 보내는 ping이 가지 않게 된다.
이 때문에 타임아웃에 따라 구독이 끊기면서 재구독이 되기도 하고, 
새로고침을 두 번 해야(= 3번째 구독 시도를 하게 되는 것) 재구독이 되는 식으로 비정상적으로 알림처리가 되는 문제가 생겼다.  </p>
<blockquote>
<p>엑세스 거절? 
나는 로그인 한 사용자만 알림을 받을 수 있도록 구현했고,
동일 유저에게 동일한 토큰으로 테스트를 해 본 건데 왜 접근이 불가능 한거지? 
두 번째 구독 요청을 처리하는 중간에 Spring Security 인증이 끊기는 문제 같은데.... 왜 끊기지????  </p>
</blockquote>
<hr>
<h1 id="🧨-reason"><strong>🧨 Reason</strong></h1>
<p>SSE는 일반 HTTP 요청과는 달리, 
서버와 클라이언트가 계속 연결을 유지하는 통신이다. </p>
<p>Spring Security는 일반적으로 요청-응답을 할 때마다 매번 Authentication을 검사한다.
하지만 SSE 연결은 한 번 열리면 계속 연결을 유지하기 때문에, 
그 과정에서 Security Context 관리가 꼬일 수 있다.</p>
<p>특히 SSE에서는 연결이 끊기지 않게 주기적으로 &quot;ping&quot;을 보내는데 
이건 새로운 HTTP 요청이 아니라, 기존 열린 연결 안에서 발생하는 데이터 전송이라 Authorization 헤더를 새롭게 보내지 않는다. 
이 때문에 서버는 &#39;이게 뭔 요청이여?&#39; 하면서 
Security Context를 잃어버리게 되고, 
인증이 없다고 판단하여 AccessDeniedException이 발생하게 된 것이다. 
(즉, 연결은 되어 있는데, 인증 상태가 끊겨버린 상황)</p>
<blockquote>
<p>Spring Security의 SecurityContext가 SSE 연결 유지 과정에서 사라짐</p>
</blockquote>
<blockquote>
<p>그래서 SSE 통신에서는
Authorization: Bearer ~~ 뿐만 아니라
Accept: text/event-stream 이 포함된 경우를 감지해서,
SecurityContext를 다시 채워주는 특별한 인증 처리가 추가로 필요하다.</p>
</blockquote>
<hr>
<h1 id="🧯-solution-">*<em>🧯 Solution *</em></h1>
<h2 id="1-securityconfig에서-sse-구독-엔드포인트를-permitall로-열어주기">1. SecurityConfig에서 SSE 구독 엔드포인트를 permitAll()로 열어주기</h2>
<pre><code>@Bean
  public SecurityFilterChain securityFilterChain(
      HttpSecurity httpSecurity,
      JwtFilter jwtFilter,
      JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
      SseAuthenticationFilter sseAuthenticationFilter)
      throws Exception {
 return httpSecurity
        .cors(cors -&gt; cors.configurationSource(corsConfigurationSource()))
        .csrf(AbstractHttpConfigurer::disable) // REST API이므로 CSRF 비활성화
        .formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 비활성화
        .httpBasic(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(auth -&gt; auth
            .requestMatchers(AUTH_WHITELIST).permitAll()
            .anyRequest().authenticated()</code></pre><p>여기에서 AUTH_WHITELIST에 sse 구독 엔드포인트를 넣어두면 된다. 
.requestMatchers(&quot;엔드포인트&quot;).permitAll()로 적어도 된다. </p>
<p>어랏? 이러면 모두가 접근할 수 있잖아. 
나는 로그인 한 사람만 알람을 받을 수 있게 하고 싶은건데?</p>
<p>그래서! </p>
<h2 id="2-sseauthenticationfilter를-구현하여-로그인을-한-사용자인지-체크하는-장치-마련하기">2. SseAuthenticationFilter를 구현하여 로그인을 한 사용자인지 체크하는 장치 마련하기</h2>
<p>SseAuthenticationFilter를 만들어서 
여기에서 다시 JWT 검사를 하면 토큰이 있는 사람만 접근할 수 있게 하면 된다. 
(문은 누구나 들어올 수는 있지만, 발권 검사를 하는 것으로 생각하면 된다.)</p>
<p>SseAuthenticationFilter가 직접 Authorization 헤더를 읽고, 
거기에 들어있는 JWT을 꺼내서 직접 토큰을 검증하도록 다시 인증을 세팅한다. 
이 다음부터는 인증된 사용자로 SSE 연결을 안전하게 유지할 수 있게 되어 
Access Denied 예외가 터지지 않는다. </p>
<pre><code>@Component
@AllArgsConstructor
public class SseAuthenticationFilter extends OncePerRequestFilter {
  private final JwtProvider jwtProvider;

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

    // 1. 헤더에서 Accept 가져오기
    String acceptHeader = request.getHeader(HttpHeaders.ACCEPT);

    // 2. SSE(text/event-stream)인지 확인
    if (acceptHeader != null &amp;&amp; acceptHeader.contains(&quot;text/event-stream&quot;)) {

      // 3. Authorization 헤더에서 JWT 토큰 추출
      String token = extractTokenForSse(request);

      // 4. 토큰이 없거나 유효하지 않은 경우 401 Unauthorized 반환
      if (token == null || !jwtProvider.validateToken(token)) {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        return;
      }

      // 5. 토큰이 유효한 경우, Authentication을 생성해서 SecurityContext에 저장해서 인증 세팅
      Authentication authentication = jwtProvider.getAuthentication(token);
      SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    // 6. 다음에 실행될 Spring Security 필터로 요청 넘김
    filterChain.doFilter(request, response);
  }


  // Authorization 헤더에서 토큰만 추출하는 메서드
  private String extractTokenForSse(HttpServletRequest request) {
    String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
    if (bearerToken != null &amp;&amp; bearerToken.startsWith(&quot;Bearer &quot;)) {
      return bearerToken.substring(7); // &quot;Bearer &quot; 이후 토큰 부분만 추출
    }
    return null;
  }
}</code></pre><h2 id="3-securityconfig-마지막-부분에-필터-등록하기">3. SecurityConfig 마지막 부분에 필터 등록하기</h2>
<pre><code>            .anyRequest().authenticated()
)
        .exceptionHandling(exception -&gt;
            exception.authenticationEntryPoint(jwtAuthenticationEntryPoint)) // 401 처리
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
        .addFilterAfter(sseAuthenticationFilter, JwtFilter.class) // SSE 인증 필터
        .build();
  }</code></pre><p>여기에서 
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(sseAuthenticationFilter, JwtFilter.class)<br>이 부분의 순서가 중요한데, 
꼬옥 JwtFilter 뒤에 SseAuthenticationFilter를 추가해야 한다. </p>
<p>일단 요청이 들어오면 
JwtFilter가 JWT을 검증해서 SecurityContext를 세팅한다. 
(doFilterInternal에서의  <em>SecurityContextHolder.getContext().setAuthentication(authentication)</em> 이 부분을 말한다.) 
그런데 SSE 요청이 들어오면 따로 JWT을 다시 인증해서 SecurityContext를 채워주는 흐름으로 가야 한다.  </p>
<hr>
<p>구독을 했다가 재구독을 하면 바로 Access Denied가 나왔었는데 
이제는 이런 식으로 로그가 찍히면서 더이상 Access Denied가 나오지 않는 것을 볼 수 있게 된다. </p>
<pre><code>SSE 구독 시작 : userId = 2
SSE 초기 메시지 전송 시도: userId = 2
SSE 초기 메시지 전송 완료

SSE 구독 시작 : userId = 2
기존 emitter 존재: userId = 2
기존 emitter 제거 완료: userId = 2
SSE 초기 메시지 전송 시도: userId = 2
SSE 초기 메시지 전송 완료</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[유효성 검사를 위한 커스텀 애너테이션 구현하기 ]]></title>
            <link>https://velog.io/@hyu-jo/%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%A0%EB%84%88%ED%85%8C%EC%9D%B4%EC%85%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyu-jo/%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%A0%EB%84%88%ED%85%8C%EC%9D%B4%EC%85%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 21 Apr 2025 09:35:03 GMT</pubDate>
            <description><![CDATA[<p>리뷰를 작성하면서 별점을 부여하는 기능을 구현했다. 
그런데 별점은 0.5점 단위로 주는 게 좀 더 좋을 듯하여 유효성 검사를 추가해주기로 했다. 
FE쪽에서 0.5 단위로 보여줄 수 있다고 하셨지만
BE쪽에서 유효성 검사를 처리해두는 게 좋겠다고 판단하여 진행하게 되었다. </p>
<p>일단은 최솟값은 0이 아니라 0.5로 두고 싶기 때문에 
DTO쪽에 @Min(value=0.5)로 두려고 했다. 
하지만 @Min과 @Max는 int 값만 허용하기 때문에 
double 값인 0.5로 설정하면 컴파일 에러가 났다. </p>
<p>이를 해결하기 위해서는 (소수점 이하를 포함하여 검증하기 위해서)
@DecimalMin(value = &quot;0.5&quot;), @DecimalMax(value =&quot;5.0&quot;) 이렇게 하는 방법도 있으나, </p>
<p>유효성 검사로 0.5 단위만 허용하는 
내가 만든 RatingValid라는 이름의 커스텀 Validator를 사용할 수 있다. </p>
<hr>
<h2 id="constraintvalidatora-t-를-구현한-ratingvalidator-클래스-만들기">ConstraintValidator&lt;A, T&gt; 를 구현한 RatingValidator 클래스 만들기</h2>
<p>여기서 A는 애너테이션의 타입이고, T는 검사할 값의 타입이다. 
애너테이션은 RatingValid로 이름을 정하고, 0.5 단위이기 때문에 Double로 타입을 적었다.
<img src="https://velog.velcdn.com/images/hyu-jo/post/b645409f-90b9-40d5-808b-a612c9844add/image.png" alt="">
반환값으로는 0.5 이상, 5.0 이하의 값이면서, 0.5단위로만 값을 받았는지 여부를boolean으로 받도록 했다. 
(rating == null 이 조건은 내 서비스 내에서는 리뷰 수정시 별점은 수정하지 않을 수도 있게 한 거라 이건 각자의 서비스에 따라 달라지는 조건이다.)</p>
<hr>
<h2 id="커스텀-애너테이션-ratingvalid-만들기">커스텀 애너테이션 RatingValid 만들기</h2>
<p>만드는 방법은 아래와 같다. 
<a href="https://jakarta.ee/specifications/bean-validation/3.0/apidocs/jakarta/validation/constraint">https://jakarta.ee/specifications/bean-validation/3.0/apidocs/jakarta/validation/constraint</a>
<img src="https://velog.velcdn.com/images/hyu-jo/post/caf70357-4d68-444b-bc30-da70d3dc067e/image.png" alt=""></p>
<h3 id="contraint">@Contraint</h3>
<p>단어 그대로 강제하는 기능을 가진 애너테이션이라는 것을 표시한 것이다. 
그리고 실제 검증 로직은 (validatedBy = RatingValidator.class)에 따라
RatingValidator 클래스에서 한다는 뜻이다. 
이로써 @RatingValid를 넣으면 유효성 검사를 할 수 있게 된다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/24850003-db05-457e-ae81-913f0f429f86/image.png" alt=""></p>
<h3 id="targetelementtypefield-elementtypeparameter">@Target({ElementType.FIELD, ElementType.PARAMETER})</h3>
<p>이건 어디에 이 애너테이션을 붙일 수 있는지 정하는 것이다. 
DTO에서 멤버변수에 붙이기 위해서 ElementType.FIELD를,
Controller에서 파라미터에 붙이기 위해서 ElementType.PARAMETER를 붙여준 것이다. </p>
<h3 id="retentionretnionpolicyruntime">Retention(RetnionPolicy.RUNTIME)</h3>
<p>요거는 Contraint에 들어가보니 되어 있길래따라 적어줬다. 
찾아보니 스프링이 구동중이어야 유효성 검사가 실행되는 것이기 때문이라고 한다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/831cd736-684e-47df-8196-65d659e3e329/image.png" alt=""></p>
<h3 id="interface">@interface</h3>
<p>나만의 애테이션을 만들거야. 그것의 이름은! <strong>_ _</strong> ___ __</p>
<h3 id="string-message-default-어쩌구저쩌구">String message() default &quot;어쩌구저쩌구&quot;</h3>
<p>유효성 검사에 실패하면 나올 에러 메시지를 적는 곳이다. </p>
<h3 id="class-groups-default-">Class&lt;?&gt;[] groups() default {};</h3>
<h3 id="class-extends-payload-payload-default-">Class&lt;? extends Payload&gt;[] payload() default {};</h3>
<p>이것들은 실제로 사용되는 부분은 아니다.
하지만 Bean Validation(= Java 표준 유효성 검사)에서
커스텀 제약 조건 애너테이션을 만들 때 message 뿐만 아니라 
groupg(), payload()를 필수로 작성해야 한다고 한다. 
그래서 안 쓰더라도 형식 맞추기 용도로라도 정의를 해 놓아야 하는 부분이다. 
기본값으로 {} 이렇게 비워두면 된다. </p>
<hr>
<p>이렇게 유효성 검사를 하도록 애너테이션을 만들어두면 잊지말고 
Controller에서 @RequestBody만 적는 게 아니라 @Valid를 적어줘야 한다..!
<img src="https://velog.velcdn.com/images/hyu-jo/post/56b1c9eb-6458-44c5-92a2-d1db604494bd/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DELETE 메서드에서는 RequestBody를 사용하지 않는 게  좋다던데... 그런데 나는 회원 탈퇴할 때 비밀번호로 본인이 맞는지 확인하고 싶단 말이야! ]]></title>
            <link>https://velog.io/@hyu-jo/DELETE-%EB%A9%94%EC%84%9C%EB%93%9C%EC%97%90%EC%84%9C%EB%8A%94-RequestBody%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B2%8C-%EC%A2%8B%EB%8B%A4%EB%8D%98%EB%8D%B0-%EA%B7%B8%EB%9F%B0%EB%8D%B0-%EB%82%98%EB%8A%94-%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4%ED%95%A0-%EB%95%8C-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EB%A1%9C-%EB%B3%B8%EC%9D%B8%EC%9D%B4-%EB%A7%9E%EB%8A%94%EC%A7%80-%ED%99%95%EC%9D%B8%ED%95%98%EA%B3%A0-%EC%8B%B6%EB%8B%A8-%EB%A7%90%EC%9D%B4%EC%95%BC</link>
            <guid>https://velog.io/@hyu-jo/DELETE-%EB%A9%94%EC%84%9C%EB%93%9C%EC%97%90%EC%84%9C%EB%8A%94-RequestBody%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B2%8C-%EC%A2%8B%EB%8B%A4%EB%8D%98%EB%8D%B0-%EA%B7%B8%EB%9F%B0%EB%8D%B0-%EB%82%98%EB%8A%94-%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4%ED%95%A0-%EB%95%8C-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EB%A1%9C-%EB%B3%B8%EC%9D%B8%EC%9D%B4-%EB%A7%9E%EB%8A%94%EC%A7%80-%ED%99%95%EC%9D%B8%ED%95%98%EA%B3%A0-%EC%8B%B6%EB%8B%A8-%EB%A7%90%EC%9D%B4%EC%95%BC</guid>
            <pubDate>Mon, 21 Apr 2025 03:08:12 GMT</pubDate>
            <description><![CDATA[<p>회원탈퇴라고 하니까 당연히 DELETE라고 생각하고 구현을 했다. 
그리고 탈퇴시에는 비밀번호 확인을 해야 하니 
요청 바디도 넣도록 구현했다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/25e67e46-afd0-48a8-a4bc-06ac1e17a21c/image.png" alt=""></p>
<p>그런데</p>
<p><img src="https://velog.velcdn.com/images/hyu-jo/post/9fa72c32-588c-427f-a0d1-bd3ead9fcf73/image.png" alt="">
<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/DELETE">출처 : MDN</a></p>
<p>이렇게 DELETE 메서드는 요청 본문을 포함하지 않는 것이 일반적이라고 한다는 것이다..! </p>
<blockquote>
<p>그러면 비번 확인을 어떻게 하지...?</p>
</blockquote>
<p>요청 바디를 받는 메서드로 바꾸면 되지 않을까? 
@DeleteMapping -&gt; @PostMapping으로 바꾸면 
고민하던 부분이 굉장히 간단히 해결이 된다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/ce7f09ce-32d7-4b42-aac9-f250a7801d23/image.png" alt=""></p>
<p><em>*<em>REST원칙만 따지면 DELETE가 맞지만 
서비스 상황(예를 들어 비밀번호 검증)에 따라 POST를 쓰는 게 더 현실적일 때가 있는 듯하다. *</em></em></p>
<p>하지만 더 좋은 방안이 있으면 추천 부탁드리겠습니다...! </p>
<p>참고로 쿼리 파라미터로 비밀번호를 전달하는 방법도 있으나, 
비밀번호가 URL에 노출될 가능성이 있어 보안상 하지 않는 것을 추천</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[회원 탈퇴 이후로도 jwt으로 요청을 할 경우, 예외 처리를 제대로 하지 않았다는 에러 로그가 뜰 때]]></title>
            <link>https://velog.io/@hyu-jo/%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4-%EC%9D%B4%ED%9B%84%EB%A1%9C%EB%8F%84-jwt%EC%9C%BC%EB%A1%9C-%EC%9A%94%EC%B2%AD%EC%9D%84-%ED%95%A0-%EA%B2%BD%EC%9A%B0-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%A0%9C%EB%8C%80%EB%A1%9C-%ED%95%98%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4%EB%8A%94-%EC%97%90%EB%9F%AC-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EB%9C%B0-%EB%95%8C</link>
            <guid>https://velog.io/@hyu-jo/%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4-%EC%9D%B4%ED%9B%84%EB%A1%9C%EB%8F%84-jwt%EC%9C%BC%EB%A1%9C-%EC%9A%94%EC%B2%AD%EC%9D%84-%ED%95%A0-%EA%B2%BD%EC%9A%B0-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%A0%9C%EB%8C%80%EB%A1%9C-%ED%95%98%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4%EB%8A%94-%EC%97%90%EB%9F%AC-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EB%9C%B0-%EB%95%8C</guid>
            <pubDate>Mon, 21 Apr 2025 02:35:57 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>처음에는 회원탈퇴를 한 후에 그 회원의 토큰으로 뭔가를 시도할 시, 커스텀예외를 날리도록 구현을 해둔 상태였다. </p>
</blockquote>
<p>처음 구현을 했을 때의 UserDetailsServiceImpl의 상태
<img src="https://velog.velcdn.com/images/hyu-jo/post/a7cfabe8-34ec-48c1-ae25-6f165fba5dba/image.png" alt=""></p>
<h2 id="🔥-problem">🔥 Problem</h2>
<p>그런데 나오라는 예외 메시지는 는 안 나오고 </p>
<pre><code>ERROR 202766 : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

community.ddv.exception.DeepdiviewException: null
        at community.ddv.service.UserDetailsServiceImpl.lambda$loadUserByUsername$0(UserDetailsServiceImpl.java:23) ~[!/:0.0.1-SNAPSHOT]
        at java.base/java.util.Optional.orElseThrow(Optional.java:403) ~[na:na]
        at community.ddv.service.UserDetailsServiceImpl.loadUserByUsername(UserDetailsServiceImpl.java:23) ~[!/:0.0.1-SNAPSHOT]
        at community.ddv.component.JwtFilter.doFilterInternal(JwtFilter.java:42) ~[!/:0.0.1-SNAPSHOT]</code></pre><p>이런 에러 로그가 뜨는 게 아닌가! 왜 null로 나오는거얏..... </p>
<p>유저 정보를 삭제했기 때문에,
UserDetailsServiceImpl.loadUserByUsername()에서 유저를 찾지 못하게 되면서
orElseThrow()로 커스텀 예외를 던졌어야 한다. 
그런데 그 예외 메시지가 null이라고......? 
저렇게 USER NOT FOUND가 잘 써 있는데?</p>
<h2 id="🧨-reason"><strong>🧨 Reason</strong></h2>
<p>Spring Security는 유저가 존재하지 않는 경우 
기본적으로 UsernameNotFoundException을 기대한다. 
그런데 커스텀 예외인 DeepdiviewException을 넣으면서<br>Security 내부에서 처리하지 못하고 500 오류가 나오게 된 것이다. <del>(떼잉...)</del></p>
<h2 id="🧯-try-to-solve-1"><strong>🧯 Try to solve (1)</strong></h2>
<p>그래서<br><img src="https://velog.velcdn.com/images/hyu-jo/post/814b295e-b3c4-4658-a994-6f8deeaf2b7e/image.png" alt="">
이렇게 Security가 기대하는 예외로 수정하여 넣어주었다. </p>
<h2 id="🧯-try-to-solve-2"><strong>🧯 Try to solve (2)</strong></h2>
<p>이 뿐만 아니라 회원탈퇴 후 로그아웃 처리도 추가해주었다. 
이전에는 Redis에 저장된 리프레시 토큰 삭제까지만 구현해놓았으나 </p>
<p><img src="https://velog.velcdn.com/images/hyu-jo/post/8a8ad884-e69b-4e02-838f-57456848d290/image.png" alt=""></p>
<p>SecurityContext를 초기화하여 탈퇴가 되면 로그아웃 처리가 되도록 수정했다. 
Spring Security 메모리에는 탈퇴한 사용자 인증 정보도 들어 있기 때문에
이를 초기화해줘야, 이후 요청에서 해당 사용자가 더 이상 인증된 상태로 인식되지 않게 된다. </p>
<p>이 덕분에 탈퇴 후에도 토큰으로 요청이 들어오면 500 에러가 아닌 401 에러가 나오게 된다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그에 자꾸 이상한 게 뜬다... [/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=md5&vars[1][]=Hello ]]]></title>
            <link>https://velog.io/@hyu-jo/%EB%A1%9C%EA%B7%B8%EC%97%90-%EC%9E%90%EA%BE%B8-%EC%9D%B4%EC%83%81%ED%95%9C-%EA%B2%8C-%EB%9C%AC%EB%8B%A4...-index.phpsindexthinkappinvokefunctionfunctioncalluserfuncarrayvars0md5vars1Hello</link>
            <guid>https://velog.io/@hyu-jo/%EB%A1%9C%EA%B7%B8%EC%97%90-%EC%9E%90%EA%BE%B8-%EC%9D%B4%EC%83%81%ED%95%9C-%EA%B2%8C-%EB%9C%AC%EB%8B%A4...-index.phpsindexthinkappinvokefunctionfunctioncalluserfuncarrayvars0md5vars1Hello</guid>
            <pubDate>Wed, 09 Apr 2025 14:19:26 GMT</pubDate>
            <description><![CDATA[<h1 id="🌱-프롤로그">🌱 프롤로그</h1>
<p>로그를 보다 보면 하루에 한 번씩은 꼭 보게 되는 로그가 있다. </p>
<pre><code>o.apache.coyote.http11.Http11Processor   
: Error parsing HTTP request header
 Note
: further occurrences of HTTP request parsing errors will be logged at DEBUG level.

java.lang.IllegalArgumentException
: Invalid character found in the request target
 [/index.php?s=/index/\think\app/invokefunction&amp;function=call_user_func_array&amp;vars[0]=md5&amp;vars[1][]=Hello ]. 
The valid characters are defined in RFC 7230 and RFC 3986</code></pre><p>처음에는 &#39;이 ...이게 뭐여?&#39; 하고 당황했지만 
아무리 모니터링을 해봐도 내 프로젝트에는 아무런 영향이 없었다.
INFO 레벨 로그이기도 하고 해서 그냥 넘어가려다가 
요청 URL에 PHP가 써 있는 것을 보고 
&#39;흠... PHP쓰는 프로젝트에 봇이 뭔가 이상한 요청을 보내는 건가?&#39; 라는 생각이 들었다. </p>
<h1 id="🪴-왜-이런-게-로그에-찍히지">🪴 왜 이런 게 로그에 찍히지?</h1>
<p>조금 찾아보니 역시 예상대로 
PHP 기반의 ThinkPHP 프레임워크를 노리는 봇 스캐닝 공격이었다. 
여기서 ThinkPHP는 중국에서 많이 사용하는 프레임워크인데, 
취약점이 많아 자동화된 봇이 무차별적으로 스캔을 해서 해킹을 시도하는 것이라고 한다. </p>
<blockquote>
<p>도둑놈: 이 집 털 수 있나? 
(덜컹덜컹)
도둑놈: 에이... 안 되네. </p>
</blockquote>
<h1 id="🌲-그럼-내-서버는-괜찮은-건가">🌲 그럼 내 서버는 괜찮은 건가?</h1>
<p>YES!</p>
<p>일단 나는 PHP를 사용한 것도 아니고, 
SpringBoot는 요청 URI에 RFC(인터넷 기술들의 공식 규칙)에 맞지 않는 문자(<code>\</code>, <code>[</code>, <code>]</code> )가 들어오면 요청 자체를 거부해버려서 컨트롤러까지 도달조차 안 된다.<br>그래서 로그에는 남지만, 실제로 내 애플리케이션 로직에는 아무 영향이 없다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[파라미터가 잘못된 타입으로 들어간 경우 400 에러가 아닌 401 에러가 나올 때 ]]></title>
            <link>https://velog.io/@hyu-jo/%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0%EA%B0%80-%EC%9E%98%EB%AA%BB%EB%90%9C-%ED%83%80%EC%9E%85%EC%9C%BC%EB%A1%9C-%EB%93%A4%EC%96%B4%EA%B0%84-%EA%B2%BD%EC%9A%B0-400-%EC%97%90%EB%9F%AC%EA%B0%80-%EC%95%84%EB%8B%8C-401-%EC%97%90%EB%9F%AC%EA%B0%80-%EB%82%98%EC%98%AC-%EB%95%8C</link>
            <guid>https://velog.io/@hyu-jo/%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0%EA%B0%80-%EC%9E%98%EB%AA%BB%EB%90%9C-%ED%83%80%EC%9E%85%EC%9C%BC%EB%A1%9C-%EB%93%A4%EC%96%B4%EA%B0%84-%EA%B2%BD%EC%9A%B0-400-%EC%97%90%EB%9F%AC%EA%B0%80-%EC%95%84%EB%8B%8C-401-%EC%97%90%EB%9F%AC%EA%B0%80-%EB%82%98%EC%98%AC-%EB%95%8C</guid>
            <pubDate>Wed, 09 Apr 2025 08:33:20 GMT</pubDate>
            <description><![CDATA[<h1 id="🔥-problem">🔥 Problem</h1>
<p>엔드포인트가 <code>/api/reviews/{reviewId}</code> 이런 식으로 되어 있고, 
reviewId는 Long 타입으로 해두었던 상태였다. 
그런데 테스트를 해보며 파라미터에 <code>Long</code> 타입이 아닌 <code>String</code> 을 넣었을 때, 401(Unauthorized) 예외가 터지면서 엑세스 토큰을 넣으라는 메시지가 나왔다. 
<del>토큰에는 죄가 없는걸!</del> </p>
<h1 id="🧨-reason">🧨 Reason</h1>
<p>요청한 URL이 <code>&#39;/api/reviews/{Long}</code> 형식이어야 하는데 
예를 들어 <code>/api/reviews/{String}</code>  이런 식으로 요청을 하면 
String을 파라미터로 인식하지 않고 인증이 필요한 다른 URL로 인식되어 401 에러가 발생하게 된 것이다. 
즉, 컨트롤러에서 타입 매핑 오류가 발생한 것인데
Spring Security에서 인증 실패로 오해를 하고 401 응답을 보낸 것이다. </p>
<h1 id="🧯-try-to-solve">🧯 Try to solve</h1>
<p>다른 타입으로 들어왔을 때 발생하는 MethodArgumentTypeMismatchException에 대해 전역 예외 처리를 추가했다.</p>
<p>ErrorCode에는 이렇게 추가해주고 </p>
<pre><code>  INVALID_INPUT_VALUE(&quot;입력값이 올바르지 않습니다.&quot;, HttpStatus.BAD_REQUEST);
</code></pre><p>GlobalExceptionHandler에는</p>
<pre><code>@ExceptionHandler(MethodArgumentTypeMismatchException.class)
  public ResponseEntity&lt;ErrorResponse&gt; handleTypeMismatchException(MethodArgumentTypeMismatchException e) {
    ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE;
    ErrorResponse errorResponse = new ErrorResponse(errorCode.name(), errorCode.getDescription());
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
  }</code></pre><p>이렇게 추가해줬다. </p>
<p>그 후 다시 Long이 아닌 값을 넣고 테스트를 해보면 
400(존재하지 않는 게 아니라 요청이 잘못됐으니까 Bad Request)에러가 뜨면서 </p>
<pre><code>{
  &quot;errorCode&quot;: &quot;INVALID_INPUT_VALUE&quot;,
  &quot;errorMessage&quot;: &quot;입력값이 올바르지 않습니다.&quot;
}</code></pre><p>이렇게 이쁘게 예외 메시지가 나오게 된다. </p>
<h2 id="참고-400-vs-404">참고 (400 vs. 404)</h2>
<ul>
<li>400: 요청 자체가 잘못됐을 때</li>
<li>404: 리소스가 존재하지 않을 때</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE 사용중, 로컬에서는 되는데 서버 환경에서는 핑이 안 온다면? ]]></title>
            <link>https://velog.io/@hyu-jo/SSE-%EC%82%AC%EC%9A%A9%EC%A4%91-%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C%EB%8A%94-%EB%90%98%EB%8A%94%EB%8D%B0-%EC%84%9C%EB%B2%84-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C%EB%8A%94-%ED%95%91%EC%9D%B4-%EC%95%88-%EC%98%A8%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@hyu-jo/SSE-%EC%82%AC%EC%9A%A9%EC%A4%91-%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C%EB%8A%94-%EB%90%98%EB%8A%94%EB%8D%B0-%EC%84%9C%EB%B2%84-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C%EB%8A%94-%ED%95%91%EC%9D%B4-%EC%95%88-%EC%98%A8%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Mon, 31 Mar 2025 12:32:29 GMT</pubDate>
            <description><![CDATA[<p>이번 프로젝트를 진행하면서 SSE를 활용하여 알림 기능을 구현했다. 
그 동안 생긴 트러블 슈팅 기록이다. </p>
<h1 id="1-초기-메시지-보내기">1. 초기 메시지 보내기</h1>
<p>일단, 클라이언트 쪽에서 SSE를 구독하면 초기 메시지를 서버로부터 받아야 한다. 
이 메시지를 받아야 SSE 연결이 성공적으로 되었는지 확인할 수 있다. 
또한 프록시 서버 (Nginx)는 일정 시간동안 응답이 없으면 내가 설정한 타임아웃(자원 낭비 방지를 위해 설정)과 관계없이 자체적으로 연결을 끊어버린다. 
그래서 SSE 구독 시, 초기 메시지를 전송하는 메서드를 넣어두었다. 
(다만 subscribe() 메서드는 조금 불안정 한 것 같으니 참고만 부탁드리겠습니다...! 기존 emitter가 제대로 제거되지 않는 느낌.... ) </p>
<pre><code>public class NotificationService {

  // SSE 연결을 저장할 Map
  private final Map&lt;Long, SseEmitter&gt; emitters = new ConcurrentHashMap&lt;&gt;();

  /**
   * SSE 구독 메서드
   * @param userId
   */
  public SseEmitter subscribe(Long userId) {

    log.info(&quot;SSE 구독 시작 : userId = {}&quot;, userId);

    // 1. 기존 emitter 끊기
    SseEmitter previousEmitter = emitters.remove(userId);

    if (previousEmitter != null) {
      log.info(&quot;기존 emitter 존재 -&gt; 제거 완료 userId = {}&quot;, userId);
    }

    // 2. 새 emitter 저장
    SseEmitter newEmitter = new SseEmitter(30 * 60 * 1000L); // 30 * 60 초 (타임아웃 30분)
    emitters.put(userId, newEmitter);

    // 3. 초기 메시지 전송
    sendFirstMessage(userId, newEmitter);

    newEmitter.onCompletion(() -&gt; {
      log.info(&quot;SSE 연결 종료 : userId = {}&quot;, userId);
      emitters.remove(userId);
    });

    newEmitter.onTimeout(() -&gt; {
      log.warn(&quot;SSE 연결 타임아웃 : userId = {}&quot;, userId);
      emitters.remove(userId);
    });

    newEmitter.onError((e) -&gt; {
      log.error(&quot;SSE 연결 에러 발생 : userId = {}, error = {}&quot;, userId, e.getMessage());
      emitters.remove(userId);
    });

    log.info(&quot;SSE emitter 등록 완료 : userId = {}, 현재 emitter 수 = {}&quot;, userId, emitters.size());
    return newEmitter;
  }

  //  SSE 초기 메시지 전송 메서드
  private void sendFirstMessage(Long userId, SseEmitter emitter) {
    try {
      log.info(&quot;SSE 초기 메시지 전송 시도: userId = {}&quot;, userId);
      emitter.send(SseEmitter.event()
          .name(&quot;connect&quot;)
          .data(&quot;SSE connect success&quot;));
      log.info(&quot;SSE 초기 메시지 전송 완료&quot;);
    } catch (IOException e) {
      log.error(&quot;SSE 초기 메시지 전송 실패: userId = {}, error = {}&quot;, userId, e.getMessage());
      emitter.completeWithError(e);
      emitters.remove(userId);
    }
  }</code></pre><pre><code>@RestController
@RequestMapping(&quot;/api/notifications&quot;)
@RequiredArgsConstructor
public class NotificationController {

  private final NotificationService notificationService;

  // 클라이언트가 SSE 연결 구독
  @Operation(summary = &quot;SSE 연결 구독&quot;)
  @GetMapping(value = &quot;/subscribe&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  public SseEmitter subscribe(@RequestParam Long userId) {

    return notificationService.subscribe(userId);
  }</code></pre><p>cf) Postman으로도 테스트가 가능하다. 
Header에는 Accept: text/event-stream을 넣어주어야 한다. 
cf) 추후 수정하면서 파라미터로 userId를 넣지 않는 것으로 바뀌었다. </p>
<p>로컬 환경에서 테스트를 해보니 초기 메시지도 잘 오고 알림도 잘 온다! 굿! 
알림 구현했구나!생각보다 SSE 까다로운 게 아니었네~ 하고 편안하게 있었다. </p>
<p>그런데....</p>
<h1 id="2-주기적인-ping-메시지-보내기">2. 주기적인 Ping 메시지 보내기</h1>
<blockquote>
<p>BE님~ 서버에서 SSE를 구독하면 45초마다 연결이 끊겨요! </p>
</blockquote>
<p>엥??? 왜요? 로컬에선 잘 되는디...? </p>
<p>위에서도 썼지만 프록시 서버(나의 경우, Nginx)는 일정 시간동안 응답이 없으면 타임아웃과 관계없이 자체적으로 연결을 끊어버린다. 
내가 구현한 알림은 내가 작성한 글에 좋아요가 달리거나 댓글이 달렸을 때, 일주일에 한 번 인증상태가 변경되었을 때 오도록 구현했기 때문에 지속적으로 알림이 와다다닥 오지 않는다. (그래서 SSE를 쓴 거기도 하고!) 
그래서 응답이 계속 없으니 Nginx가 연걸을 끊어버린 것이다. 
(나는 로컬에서 진행을 했으니 연결을 끊어버릴 주체가 없어서 계속 연결이 유지됐던 거였다.)
따라서 서버 환경에서는 연결 유지를 위해 주기적으로 핑 메시지를 보내면 연결이 끊기는 것을 막을 수 있다. </p>
<pre><code>// 30초마다 ping 보내기
  @Scheduled(fixedRate = 30000)
  public void sendPingToClients() {
    if (emitters.isEmpty()) {
      return; // ping 보낼 구독자가 없으면 return
    }

    emitters.forEach((userId, emitter) -&gt; {
      if (emitter != null) {
        try {
          emitter.send(SseEmitter.event()
              .name(&quot;ping&quot;)
              .data(&quot;keep-alive&quot;));
        } catch (IOException | IllegalStateException e) {
          log.warn(&quot;Ping 전송 실패 : userId = {}, error = {}&quot;, userId, e.getMessage());
          emitter.completeWithError(e); // 오류 발생 시 연결 종료 처리
          emitters.remove(userId); // 연결 종료
        }
      }
    });
  }
</code></pre><p>이렇게 30초마다 핑 메시지를 보내면 응답이 계속 있는 거라고 여기고 Nginx는 연결을 끊지 않고 유지하게 해주는 것이다. </p>
<p>Postman으로 테스트를 해보면 이런 식으로 나온다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/d45760d3-e3a7-4ca7-92be-5c0cc549d691/image.png" alt=""></p>
<p>자! FE님~ 초기 메시지랑 핑 메시지 받고 계시죠? </p>
<h1 id="3-nginx-설정-수정하기">3. Nginx 설정 수정하기</h1>
<blockquote>
<p>BE님~ 계속 끊기는데요? ㅠ 그리고 초기 메시지부터 안 와요 ㅠ</p>
</blockquote>
<p>사실 이 말을 하시기 전까지 Nginx 설정을 수정해야 한다는 생각을 못했다. </p>
<p><em>*<em>로컬에서는 되는데 서버에서는 안 된다 *</em></em></p>
<p>이걸 진작 생각했으면 내 멀쩡한 초기/핑 메시지 보내는 코드를 누덕누덕 고치지 않아도 됐을 것이다. 나는 내가 잘못 짜서 메시지가 안 가는 것이라고 여겨서 대체 뭐가 문제지 하며 하루종일 화면만 노려보고 있었다. 
그런데 그동안 Postman에서 http://탄력적IP 이거로 테스트를 했는데 
분위기 환기겸(?) https://도메인주소 이거로 테스트를 해보니
나도 안 되는 게 아닌가!!!! 
아!!!!
HTTPS일 때 문제가 생긴다??? 
프록시 서버 Nginx 너...? 너 설마....? 너였냐? 
알아보니 Nginx 설정에서 수정을 해줘야 했던 것이다.</p>
<p>특히 HTTP/1.1을 사용해야 SSE 연결이 유지된다고 한다. 
SSE는 지속적인 연결을 유지하면서 서버에서 클라이언트로 이벤트를 스트리밍하는 방식인데, 
HTTP/1.1에서는 Connection: keep-alive가 기본값이므로, 한 번 연결을 열면 지속적으로 데이터를 보낼 수 있게 된다. </p>
<pre><code>sudo nano /etc/nginx/sites-available/default</code></pre><p>여기에서 알림 부분만 설정을 추가해줘야 한다. </p>
<pre><code>server {

        location / {
                proxy_pass http://탄력적 IP 주소:8080;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto https;
        }

        location /api/notifications {
                proxy_pass http://탄력적 IP 주소:8080;
                proxy_http_version 1.1;
                proxy_set_header Connection &#39;&#39;;
                proxy_buffering off;


        }
</code></pre><p>나는 이렇게 설정을 해줬다. 추가적인 설정이 필요하면 더 넣어도 좋다.  </p>
<pre><code>사실 Connection 부분을 저렇게 해도 되는 건지는 확실하지 않다. 
어디서는 keep-alive를 쓰라고 하고, 어디서는 &#39;&#39;를 쓰라고 하기도 하고....  
HTTP/1.1에서 keep-alive가 디폴트라면서 뭘 쓰라고 하는 게 사람의 맴을 힘들게 해요잉.... 
아무튼 나는 &#39;&#39;만 적었는데 이제 모든 오류가 사라지게 되어 일단은 &#39;&#39;로 두고 
문제가 생기면 keep-alive로 수정할 예정이다. 
챗지피티에게 물어보니 장기적으로는 keep-alive로 설정하는 게 낫다고 한다..!</code></pre><p>그리고 SSE 메시지가 즉시 클라이언트로 전달되도록 설정한다. </p>
<pre><code>proxy_buffering off;</code></pre><p>이제 설정창을 닫고, 설정 파일에 오류가 있난 확인하기 위해 </p>
<pre><code>sudo nginx -t</code></pre><p>오류가 없으면 Nginx를 재시작해준다.</p>
<pre><code>sudo systemctl restart nginx</code></pre><p>이제 서버 환경에서도 알림이 잘 오게 되었다! 문제 해결~ </p>
<p>로컬에서는 되는데 서버 환경에서 안 될 땐? 
코드도 코드지만 다른 것도 고려를 해봐야 한다~ </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[계층형 패키지 구조에서 도메인형 패키지 구조로 변경하기]]></title>
            <link>https://velog.io/@hyu-jo/%EA%B3%84%EC%B8%B5%ED%98%95-%ED%8C%A8%ED%82%A4%EC%A7%80-%EA%B5%AC%EC%A1%B0%EC%97%90%EC%84%9C-%EB%8F%84%EB%A9%94%EC%9D%B8%ED%98%95-%ED%8C%A8%ED%82%A4%EC%A7%80-%EA%B5%AC%EC%A1%B0%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyu-jo/%EA%B3%84%EC%B8%B5%ED%98%95-%ED%8C%A8%ED%82%A4%EC%A7%80-%EA%B5%AC%EC%A1%B0%EC%97%90%EC%84%9C-%EB%8F%84%EB%A9%94%EC%9D%B8%ED%98%95-%ED%8C%A8%ED%82%A4%EC%A7%80-%EA%B5%AC%EC%A1%B0%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 31 Mar 2025 09:51:23 GMT</pubDate>
            <description><![CDATA[<h1 id="계층형-패키지">계층형 패키지</h1>
<p>원래의 패키지 구조는 이러했다. 
계층형으로 Controller, Service, Repository, Dto, Entity 나누어 작업하고 있었다.<br><img src="https://velog.velcdn.com/images/hyu-jo/post/7908e1f9-99a8-4ead-9c13-20952857cc49/image.png" alt=""></p>
<p>작은 프로젝트라면 확실히 계층형이 익숙하고 직관적이라 작업하기 편하다. 
그런데 작업량이 많아지고 저 폴더들을 열어둔 채 작업을 하다보면 
뭐가 어디에 박혀있는지 마우스 휠을 계속 오르락내리락 해야하는 귀찮음이 생긴다.<br>그리고 함께 작업하는 다른 개발자와 소통을 하다보면
그 자리에서 관련된 코드들을 까봐야하는 경우가 생기는데 
관련된 곳을 찾아가기 위해 뭔가 헤매는 모습을 보이는 게 스스로 영 신통치 않아보였다. 
&quot;코드 좀 봐볼게요~ 서비스에서... NotificationService... 여기서 DTO.. DTO.. 아 여깄네&quot; 이런 나의 모습이 보기 좋지 않다고 생각되었다.
<del>(물론 인텔리제이에서는 컨트롤 + 마우스 왼쪽 클릭을 하면 바로 관련된 곳으로 이동이 됩니다)</del>
또한 설정들이 추가되면서 계속 다른 폴더들이 생기게 되는데 
이런 애들까지 같은 계층에 있게 되니 보기가 영 좋지 않았다. . </p>
<hr>
<h1 id="도메인형-패키지">도메인형 패키지</h1>
<p>그래서 도메인형으로 나눠봤다. </p>
<p><img src="https://velog.velcdn.com/images/hyu-jo/post/d2f5c585-f41a-43f8-a6f3-02eeb3bfa3f6/image.png" alt=""></p>
<p>주요 기능들을 domain 패키지로 묶고 관련된 코드들을 모아 두었다. 
그리고 전역적으로 쓰는 건 global 패키지로 두면 깔끔하게 정리가 된다. </p>
<p>열어보면 이런 모습이다. 
<img src="https://velog.velcdn.com/images/hyu-jo/post/afd94abb-b294-4ead-b725-e366be1b10ff/image.png" alt="">
응집도가 높아져 유지보수하거나 확장하기에 좋을 듯하다. 
또한 협업을 할 때는 자신의 도메인 폴더 내에서 작업을 하기 좋아 협업시에도 좀 더 도움이 되지 않을까 생각한다. 
그런데 폴더가 꽤나 많아져서 처음에는 오히려 더 복잡하게 느껴질 수도 있을 것 같다. <del>지금 사실 좀 어색한 상태;;;</del></p>
<h1 id="뭐가-더-좋지">뭐가 더 좋지?</h1>
<p>더 좋은 건 없고.... 그냥 자신의 상황에 맞게 하는 게 옳은 듯 하다. 
초기에는, 혹은 작은 프로젝트를 하게된다면 직관적인 계층형으로 하다가 
기능이 많아지거나 큰 프로젝트를 하게 된다면 도메인형으로 변경하는 것도 괜찮을 것 같다. </p>
]]></description>
        </item>
    </channel>
</rss>