<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hwan_94.log</title>
        <link>https://velog.io/</link>
        <description>자바,스프링 백엔드 개발자를 꿈꾸는 초보아빠</description>
        <lastBuildDate>Thu, 21 Aug 2025 06:19:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hwan_94.log</title>
            <url>https://velog.velcdn.com/images/hwan_94/profile/4c22b242-5d13-4b37-8c52-15ee9a393010/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hwan_94.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hwan_94" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Java] jwt 토큰이란?]]></title>
            <link>https://velog.io/@hwan_94/Java-jwt-%ED%86%A0%ED%81%B0%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@hwan_94/Java-jwt-%ED%86%A0%ED%81%B0%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Thu, 21 Aug 2025 06:19:14 GMT</pubDate>
            <description><![CDATA[<h2 id="jwt-토큰이란">jwt 토큰이란?</h2>
<p>JWT(Json Web Token)는 프론트엔드와 백엔드가 분리된 구조에서 인증 정보를 안전하게 주고받기 위한 방식이다.<br>JWT는 <code>Header</code>, <code>Payload</code>, <code>Signature</code> 세 부분으로 구성되며,<br>서버는 사용자의 인증이 성공하면 <code>Access Token</code>과 <code>Refresh Token</code>을 발급한다.
Access token 은 약 10분 ~ 30분 정도의 짧은 유효시간을 가지며 , api 요청 시 인증 수단으로 사용된다.
Refresh Token은 1일 ~ 2주 정도의 유효 시간을 가지며, Access Token이 만료가 되었을 때 새로운 Access Token을 발급 받을 때 사용 된다.
Refresh Token은 보안상 DB에 저장하고, 탈취 시를 대비해 블랙리스트 처리 등을 할 수 있도록 관리한다.</p>
<h2 id="jwt-토큰의-구성-요소">jwt 토큰의 구성 요소</h2>
<h3 id="jwt의-구조-전체">jwt의 구조 전체</h3>
<pre><code>eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9   ← Header (Base64 인코딩)
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZSI6IlVTRVIiLCJleHAiOjE3MTUwMDMyMDB9  ← Payload (Base64 인코딩)
.
YIgIvxf3LgOt4GpNaWYGMmhZi...qRr-4Zc   ← Signature (암호화된 서명)</code></pre><p>jwt는 이와 같이 3가지의 . 으로 구분된 문자열이 들어 온다.</p>
<h3 id="header">Header</h3>
<pre><code>{
  &quot;alg&quot;: &quot;RS256&quot;,   // 사용된 서명 알고리즘
  &quot;typ&quot;: &quot;JWT&quot;,     // 토큰 타입 (항상 &quot;JWT&quot;)
  &quot;kid&quot;: &quot;key-2025&quot; // (선택) 공개키 ID - RSA 방식일 때 유용
}</code></pre><p>header의 경우 jwt를 어떻게 검증 할 지 알려주는 메타 데이터이다.
alg는 어떤 방식으로 signature을 생성했는지 확인을 한다.
kid는 RSA 방식일 때 공개키 식별을 위해 사용된다(RSA 방식이 아닌 경우 안써도 된다.)</p>
<h4 id="서명-방식-차이">서명 방식 차이</h4>
<pre><code>HS256    대칭키 (Secret Key)    같은 키로 서명 &amp; 검증
RS256    비대칭키 (Private/Public)    PrivateKey로 서명, PublicKey로 검증</code></pre><h3 id="payload">Payload</h3>
<pre><code>{
  &quot;sub&quot;: &quot;123456&quot;,       // 사용자 ID (subject)
  &quot;name&quot;: &quot;SeokHwan&quot;,    // 사용자 이름 (custom claim)
  &quot;role&quot;: &quot;ADMIN&quot;,       // 사용자 권한 (custom claim)
  &quot;iat&quot;: 1715100000,     // 발급 시간 (issued at)
  &quot;exp&quot;: 1715103600      // 만료 시간 (expiration)
}</code></pre><p>iat와 exp 의 경우 유닉스 타임스탬프로 기입을 하며 
유닉스 타임스탬프란 1970년 1월 1일 00:00:00 UTC부터 지난 초(seconds) 를 의미한다.</p>
<h3 id="signature">Signature</h3>
<pre><code>Signature = sign(
  base64UrlEncode(Header) + &quot;.&quot; + base64UrlEncode(Payload),
  secretOrPrivateKey
)</code></pre><p>Header과 Payload를 합쳐서 만들어 Header과 Payload가 변조가 되지 않았는지를 확인하는데 사용된다.
이 서명 검증을 통해 토큰이 유효한지 판단한다.</p>
<h3 id="시크릿-키-만드는법">시크릿 키 만드는법</h3>
<pre><code>openssl rand -base64 32</code></pre><p>git bash에서 타이핑을 하면 알아서 만들어준다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS 배포] JAVA, NEXT.JS, DOCKER-COMPOSE, 가비아]]></title>
            <link>https://velog.io/@hwan_94/AWS-%EB%B0%B0%ED%8F%AC-JAVA-NEXT.JS-DOCKER-COMPOSE</link>
            <guid>https://velog.io/@hwan_94/AWS-%EB%B0%B0%ED%8F%AC-JAVA-NEXT.JS-DOCKER-COMPOSE</guid>
            <pubDate>Thu, 31 Jul 2025 19:26:28 GMT</pubDate>
            <description><![CDATA[<p>오늘은 배포를 하는 방법에 대해서 알아 볼 것이다.</p>
<p>1.ec2 인스턴스를 만든다.
ㄴ 보안 그룹에서 필요한 포트를 열어두고 ubuntu를 기반으로 생성한다.</p>
<p>2.가비아에서 dns 설정을 함.</p>
<ol start="3">
<li><p>docker-compose.yml을 ec2로 옮긴다.
ㄴscp -i (pem 파일 경로) (docker-compose.yml) (ec2사용자@공인 ip:ec2경로 )
예시 : scp -i mungpedia-keypair.pem docker-compose.yml <a href="mailto:ubuntu@43.203.90.39">ubuntu@43.203.90.39</a>:~/</p>
</li>
<li><p>MungPe.main.jar 파일을 ec2로 옮긴다.
ㄴscp -i mungpedia-keypair.pem MungPe.main.jar <a href="mailto:ubuntu@43.203.90.39">ubuntu@43.203.90.39</a>:~/</p>
</li>
<li><p>mungfront.tar.gz을 ec2로 옮긴다.(tar -czf mungfront.tar.gz --exclude=node_modules .로 생성)
ㄴscp -i C:\Users\jsver\Downloads\mungpedia-keypair.pem mungfront.tar.gz <a href="mailto:ubuntu@43.203.90.39">ubuntu@43.203.90.39</a>:/home/ubuntu/</p>
</li>
</ol>
<p>--------ec2로 이동-------
6. node 설치 저장소를 등록
ㄴcurl -fsSL <a href="https://deb.nodesource.com/setup_18.x">https://deb.nodesource.com/setup_18.x</a> | sudo -E bash -</p>
<ol start="7">
<li><p>node 설치
ㄴsudo apt install -y nodejs</p>
</li>
<li><p>폴더를 생성한다.(프론트를 따로 폴더에 압축 해제 해서 돌리려고 하기 때문)
ㄴmkdir (폴더 이름)
예시 : mkdir mungfront</p>
</li>
</ol>
<p>9.이 상태가 되었다면 ec2 서버에서 ls -l을 했을때
ㄴdocker-compose.yml, spring.jar, next.js.tar.gz 파일이 3개가 나온다.</p>
<ol start="10">
<li><p>docker 컨테이너를 생성 및 실행 한다.
ㄴdocker compose up -d</p>
</li>
<li><p>백그라운드에서 jar 파일을 실행 시킨다.
ㄴ nohup java -jar (jar 파일 경로) &gt; (로그가 저장될 파일명) 2&gt;&amp;1 &amp;
예시 nohup java -jar MungPe.main.jar &gt; backend.log 2&gt;&amp;1 &amp;</p>
</li>
</ol>
<p>12.로그를 실시간으로 확인하고 싶다면 이렇게 한다.
ㄴtail -f backend.log </p>
<ol start="13">
<li><p>tar.gz 파일을 압축 해제 한다.
ㄴ tar -xzf (압축 파일) -C  (해제 할 경로)
예시 : tar -xzf mungfront.tar.gz -C ~/mungfront</p>
</li>
<li><p>압축 해제 한곳으로 이동
ㄴ cd (이동 할 경로)
예시 : cd ~/mungfront</p>
</li>
<li><p>npm을 설치한다.
ㄴnpm install </p>
</li>
<li><p>npm run build를 한다.
ㄴnpm run build</p>
</li>
<li><p>pm2를 설치한다. 
ㄴsudo npm install -g pm2</p>
</li>
<li><p>pm2를 통해서 tar.gz를 백그라운드 실행한다.
ㄴpm2 start npm --name &quot;mung-front&quot; -- run start -- -p 3000 -H 0.0.0.0</p>
</li>
<li><p>pm2가 자동실행 되도록 등록해준다.
ㄴsudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu</p>
</li>
<li><p>pm2의 설정을 저장한다.
ㄴpm2 save</p>
</li>
<li><p>nginx 파일 생성 nano /경로/파일이름 (파일이 있으면 열고 없으면 생성 후 열음)
자신의 배포 환경에 맞게 리버스 프록시 설정을 해준다.
ㄴsudo nano /etc/nginx/sites-available/mungpedia</p>
</li>
<li><p>nginx 활성화
ㄴsudo ln -sf /etc/nginx/sites-available/mungpedia /etc/nginx/sites-enabled/</p>
</li>
<li><p>nginx 문법 확인 (재실행을 하려면 필수적으로 확인을 해야 함)
ㄴsudo nginx -t</p>
</li>
<li><p>nginx 재실행
ㄴsudo systemctl reload nginx</p>
</li>
</ol>
<pre><code>참고
홈 디렉토리 이동
ㄴcd ~/ 

삭제
ㄴ sudo rm -fr 삭제 할 파일이름

포트 실행되는 프로그램 확인 (열린 파일 목록을 보여주는 도구)
ㄴsudo lsof -i :8080

포트 3000을 청취 중인 소켓정보만 필터링해서 보여 줌
ㄴsudo ss -lptn &#39;sport = :3000&#39;

포트 죽이기
ㄴsudo kill -9 PID

노드 버전 확인
ㄴnode -v

pm2 로그 확인 방법
ㄴpm2 logs mung-front
pm2 실행 리스트 조회
ㄴpm2 list
pm2 에서 특정 인스턴스 정지
ㄴpm2 stop mung-front
pm2 에서 특정 인스턴스 삭제
ㄴpm2 delete mung-front

nginx 문법 확인
ㄴsudo nginx -t
nginx 재실행
ㄴsudo systemctl reload nginx</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[RDS(Maria DB) 사용법]]></title>
            <link>https://velog.io/@hwan_94/RDSMaria-DB-%EC%84%A4%EC%A0%95%EB%B2%95</link>
            <guid>https://velog.io/@hwan_94/RDSMaria-DB-%EC%84%A4%EC%A0%95%EB%B2%95</guid>
            <pubDate>Wed, 09 Jul 2025 14:06:48 GMT</pubDate>
            <description><![CDATA[<p>필자는 기존에 사용하던 MariaDb를 도커로 띄우고 사용을 하고 있었는데
배포를 하면서 Rds를 사용을 하게 되었다.</p>
<h2 id="ec2-설정">EC2 설정</h2>
<h3 id="보안그룹-설정">보안그룹 설정</h3>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/e0da946d-2368-4959-8b14-0140b21228ba/image.png" alt="">
일단 EC2 &gt; 보안그룹 설정으로 가준다.
그 후 보안 그룹을 생성을 할 것이다.</p>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/6551716a-fc95-48c6-b9a0-7ad35978f096/image.png" alt=""></p>
<p>그 후 인바운드 규칙에서 사진과 같이 유형 MYSQL/Aurora를 선택하고 소스를 한개는 Anywhere 0.0.0.0/0 , 다른 하나는 내 ip를 설정을 해준다.
<img src="https://velog.velcdn.com/images/hwan_94/post/47887bf6-e554-4a8e-a9ba-d37a69473451/image.png" alt="">
이렇게 사진과 같이 되었다면 일단 끝이다.</p>
<h2 id="vpc-설정">VPC 설정</h2>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/1e5ae2f4-2bc1-4c5d-b85f-c520b494670b/image.png" alt="">
일단 vpc를 생성을 해준다.
<img src="https://velog.velcdn.com/images/hwan_94/post/af4a20e2-384b-4fe0-9ee5-450463a30f80/image.png" alt="">
Ipv4 CIDR에 <code>172.31.0.0/16</code>을 입력 해준다.
<img src="https://velog.velcdn.com/images/hwan_94/post/5b83e814-303d-426c-b010-93789dac81da/image.png" alt="">
그러고 나면 위와 같은 화면이 보일텐데 이제 이것들을 설정을 해줄것이다.</p>
<h3 id="서브넷">서브넷</h3>
<p>VPC &gt; 서브넷 &gt; 서브넷 생성으로 간다.
서브넷은 2개를 설치를 해줄 것이다 
<img src="https://velog.velcdn.com/images/hwan_94/post/0aa09ef4-7387-4b84-854e-738555aed772/image.png" alt="">
일단 내가 서브넷을 만들 vpc를 설정을 해준다.
필자는 test라고 만들어야하는데 실수로 text라고 했다...
여기에다가 진행을 할 것이다.
<code>서브넷의 이름</code>을 정해주고 2개를 만들것이기 때문에 2개를 구분하게 만들어주자
<code>가용 영역</code>에서 <code>ap-northeast-2a</code>를 선택 해준다. 꼭 a를 선택하자
다음에 만들 서브넷은  <code>ap-northeast-2b</code>를 사용 할 것이다.
<code>IPv4 서브넷 CIDR 블록</code>은 적혀 있는걸 따라서 만들면 된다.
그 후 생성을 해준다.</p>
<p>똑같이 서브넷을 한개를 더 만들어주는데
<code>가용 영역</code> 에서 <code>ap-northeast-2b</code> 를 선택해주고
<code>ipvr 서브넷 CIDR</code> 블록만 <code>172.31.0.0/24</code>로 설정 해주자</p>
<p>그러면 2가지의 서브넷을 만들고 서브넷 생성은 끝난다. </p>
<h3 id="인터넷-게이트웨이">인터넷 게이트웨이</h3>
<p>VPC &gt; 인터넷 게이트웨이 &gt; 게이트웨이 생성에서
<img src="https://velog.velcdn.com/images/hwan_94/post/68fc53e6-2d28-4f04-a6b6-ca268dc946fd/image.png" alt="">
게이트 웨이를 생성 해준 후 
<img src="https://velog.velcdn.com/images/hwan_94/post/e268f655-a5ff-480a-9b05-b49df0c74fcd/image.png" alt="">
우측 위의 작업에서 VPC에 연결을 눌러준다.
<img src="https://velog.velcdn.com/images/hwan_94/post/3e6078af-7d07-4021-bec7-fdf355a6b3f8/image.png" alt="">
우리가 만든 VPC를 연결하고 게이트 생성을 해주면 끝난다.</p>
<h3 id="라우팅-테이블">라우팅 테이블</h3>
<p>VPC &gt; 라우팅 테이블 &gt; 라우팅 테이블 생성으로 이동한다.
<img src="https://velog.velcdn.com/images/hwan_94/post/1660bf97-f28d-4762-ab48-44a8ca7a0c6e/image.png" alt="">
이름을 적고, VPC에 우리가 만든 VPC를 등록을 해주면 끝난다.
<img src="https://velog.velcdn.com/images/hwan_94/post/644fbf22-5484-4633-8dd5-c951ea90cec5/image.png" alt="">
그후 라우팅 편집으로가서
<img src="https://velog.velcdn.com/images/hwan_94/post/91aba4a1-3b7d-428a-b60a-4c3b220be545/image.png" alt="">
라우팅 추가를 누른 뒤
<img src="https://velog.velcdn.com/images/hwan_94/post/20b3070c-d477-4417-bc03-91f5c5811ef8/image.png" alt="">
대상과 같은 ip를 입력 해준후 인터넷 게이트 웨이를 클릭한다.
<img src="https://velog.velcdn.com/images/hwan_94/post/f4c2a339-4bc0-47ee-962f-06b381bff897/image.png" alt="">
<img src="https://velog.velcdn.com/images/hwan_94/post/5a55c468-463d-4c03-8566-af3f17842dc3/image.png" alt="">
그후 우리가 만든 게이트 웨이를 선택을 해준다.</p>
<p>그 후 VPC에서 우리가 만든 VPC를 들어오면
<img src="https://velog.velcdn.com/images/hwan_94/post/034fb056-bfa2-43b2-8b3f-6f60ede158ae/image.png" alt="">
이러한 형태로 리소스맵이 만들어 진 것을 알 수 있다.</p>
<h2 id="rds-설정">RDS 설정</h2>
<h3 id="파라미터-그룹mariadb만">파라미터 그룹(MariaDB만)</h3>
<p><a href="https://ryean.tistory.com/47#google_vignette">https://ryean.tistory.com/47#google_vignette</a>
이부분은 Tisroty의 Ryean님이 올리신 부분이 설명이 잘되어있기 때문에 따라하면 된다.</p>
<h3 id="rds-생성">RDS 생성</h3>
<p>RDS &gt; 데이터베이스 &gt; 데이터 베이스 생성으로 이동한다.
<img src="https://velog.velcdn.com/images/hwan_94/post/cd864545-3252-46d5-869e-72355fc84f29/image.png" alt="">
마리아 db를 고른다.
<img src="https://velog.velcdn.com/images/hwan_94/post/1f1ece5e-ebb9-4e2f-a130-e60fb72ff581/image.png" alt="">
템플릿은 프리티어를 고르고
db 인스턴스 이름을 설정해주고
id와 암호를 까먹지 말고 기억을 하자
<img src="https://velog.velcdn.com/images/hwan_94/post/db30c0f0-0047-48ab-b418-1977d7ce053e/image.png" alt="">
추가 스토리지 구성에서 자동 조정 활성화를 꺼주자
돈이 나가도 상관 없다면 켜도 된다.
<img src="https://velog.velcdn.com/images/hwan_94/post/0d29c783-1a69-46e9-b07e-bded0c1140d7/image.png" alt="">
EC2는 나중에 연결 해도된다. 연결이 안되어있다면 연결 안함으로 하자
VPC를 우리가 만든 VPC로 설정을 해주고
꼭 퍼블릭 엑세스를 예로 해주자 그래야지 개발 환경에서 접근 할 수 잇다.
<img src="https://velog.velcdn.com/images/hwan_94/post/9d3e1cd3-0a30-4262-abba-05f2a26e37fd/image.png" alt="">
그리고 보안그룹도 우리가 ec2에서 만든 보안그룹을 등록을 해주자
<img src="https://velog.velcdn.com/images/hwan_94/post/44dcb964-5e01-455e-8c9c-ff75f8070449/image.png" alt="">
그 후에는 따로 설정 할 것들은 없고 추가 구성 탭에서 데이터베이스 이름만 설정을 해주면 끝난다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 웹소켓 + DOCKER + NEXT.JS]]></title>
            <link>https://velog.io/@hwan_94/Java-%EC%9B%B9%EC%86%8C%EC%BC%93-DOCKER-NEXT.JS</link>
            <guid>https://velog.io/@hwan_94/Java-%EC%9B%B9%EC%86%8C%EC%BC%93-DOCKER-NEXT.JS</guid>
            <pubDate>Tue, 01 Jul 2025 19:17:46 GMT</pubDate>
            <description><![CDATA[<h2 id="사전-설명">사전 설명</h2>
<pre><code>채팅기능을 구현 하려면 웹소켓을 사용해야 한다.
http 요청은 일반적으로 단 방향 요청이다.
그렇기에 채팅을 보내는 것과 받는 것이 동시에 실시간으로 되지 않는다.

이를 해결 하기 위한 방식이 웹소켓이다.
웹 소켓은 처음에 연결을 하고 지속적으로 유지를 하며, 양방향으로 데이터를 주고 받을 수 있게 해준다.

여기서 알아야 하는것이 주제(topic)이랑 구독(subscribe)이다.
youtube와 같이 구독을 하면 알림을 받을 수가 있는데
내가 어떠한 정보를 구독을 하고 있는데 그에 대한 업데이트(주제)가 추가가 된다면
그 정보를 실시간으로 클라이언트에 받을 수가 있다</code></pre><h2 id="java-설정">java 설정</h2>
<h3 id="gradle-설정">gradle 설정</h3>
<pre><code class="language-java">  implementation &#39;org.springframework.boot:spring-boot-starter-websocket&#39;</code></pre>
<h3 id="config-설정">config 설정</h3>
<pre><code class="language-java">
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    // 웹 소켓을 연경 할 Endpoint
    // 이 엔드포인트를 통해서 handShaking을 시작한다.
    //setAllowedOriginPatterns(&quot;*&quot;)은 어떤 주소로 요청이 들어오는 것을 허용 할지에 대한 설정이다.
    // 필자는 현재 개발환경이기 때문에 모든 주소를 열어놨다.
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint(&quot;/chat&quot;).setAllowedOriginPatterns(&quot;*&quot;).withSockJS();
    }

    //
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {        
        //메세지를 구독해서 받을 수 있는 경로를 설정
        registry.enableSimpleBroker(&quot;/topic&quot;);

    }
}


</code></pre>
<h3 id="domain">Domain</h3>
<pre><code class="language-java">
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Chat {

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

    //받는 사람 정보
    private Long receiverNum;

    //보내는 사람 정보
    private Long senderNum;

    //메세지
    private String message;

    //구독을 하려면 roomId 같이 따로 처리 할 수 있는게 필요하다.
    private String roomId;

    //보낸 시간
    private LocalDateTime sendTime = LocalDateTime.now();

    @Builder
    public Chat (Long receiverNum, Long senderNum, String message, String roomId) {
        this.receiverNum = receiverNum;
        this.senderNum = senderNum;
        this.message = message;
        this.roomId = roomId;

    }

}
</code></pre>
<h3 id="dto">dto</h3>
<ol>
<li>채팅을 보내고 난 후 결과를 받기 위한 dto<pre><code class="language-java"></code></pre>
</li>
</ol>
<p>@Data
@Builder
public class ChatBasicResponse {</p>
<pre><code>private Boolean result;
private String message;</code></pre><p>}</p>
<pre><code>
2.Chat 에 대한 정보가 들어 있는 dto


```java

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageDto {

    private Long senderNum;
    private Long receiverNum;
    private String message;
    private String roomId;
    private LocalDateTime sendTime;

}
</code></pre><ol start="3">
<li>Message를 보내낼 때 어떠한 내용의 메세지와 받는이의 정보를 가지고 있는 dto<pre><code class="language-java"></code></pre>
</li>
</ol>
<p>@Data
public class SendMessageRequest {</p>
<pre><code>private Long receiverNum;
private String message;</code></pre><p>}</p>
<pre><code>
### Repository
```java


public interface ChatRepository extends JpaRepository&lt;Chat, Long&gt; {

    // 서로 상호간에 메세지를 주고 받은 내역이 있는지 확인을 하기위한 메소드
    // chat이 하나라도 있다면 true 없다면 false를 나타낸다.
    @Query(&quot;&quot;&quot;
    SELECT CASE WHEN COUNT(c) &gt; 0 THEN TRUE ELSE FALSE END
    FROM Chat c
    WHERE (c.senderNum = :senderNum AND c.receiverNum = :receiverNum)
       OR (c.senderNum = :receiverNum AND c.receiverNum = :senderNum)
&quot;&quot;&quot;)
    boolean existsChatBetweenUsers(@Param(&quot;senderNum&quot;) Long senderNum, @Param(&quot;receiverNum&quot;) Long receiverNum);

    // 채팅 내역 조회
    List&lt;Chat&gt; findAllChatByRoomId(String roomId);



</code></pre><h3 id="service">Service</h3>
<pre><code class="language-java">
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {

    private final ChatRepository chatRepository;
    private final MemberService memberService;
    private final SimpMessagingTemplate messagingTemplate;

    //채팅을 시작하는 부분
    @Override
    public List&lt;MessageDto&gt; openChat() {
        // 필자는 단순 하드 코딩으로 해놨다
        // 추가적으로 운영자는 없을 것이기 때문
        Member admin = memberService.getMemberByEmail(&quot;OOOO@gmail.com&quot;);
        Long adminId = admin.getId();


        //현재 로그인 한 유저의 정보를 가져온다.
        Long loginUserId = SecurityUtill.getCurrentMemberId();

        // roomId를 설정 해준다.
        String roomId = &quot;room&quot; + loginUserId;

        // 기존에 이야기를 한적이 있는지 없는지를 조회
        boolean talkedBefore = chatRepository.existsChatBetweenUsers(loginUserId, adminId);

        // 이야기 한적이 없다면 관리자가 &quot;무엇을 도와 드릴까요&quot; 라는 채팅이 보내짐
        if (!talkedBefore) {
            Chat chat = Chat.builder()
                    .senderNum(adminId)
                    .receiverNum(loginUserId)
                    .message(&quot;무엇을 도와 드릴까요?&quot;)
                    .roomId(roomId)
                    .build();
            chatRepository.save(chat);
        }


        // 모든 채팅 내역을 가져온다.
        List&lt;Chat&gt; chatList = chatRepository.findAllChatByRoomId(roomId);

        //chatList를 dto로 변환 해서 반환시킨다.
        return chatList.stream().map(chat -&gt; MessageDto.builder()
                .senderNum(chat.getSenderNum())
                .receiverNum(chat.getReceiverNum())
                .message(chat.getMessage())
                .roomId(chat.getRoomId())
                .sendTime(chat.getSendTime())
                .build()
        ).toList();
    }


    // 관리자가 사용하는 부분
    // 유저와 채팅한 내역을 가져온다.
    @Override
    public List&lt;MessageDto&gt; getChatWithUser(Long userId) {

        Long adminId = SecurityUtill.getCurrentMemberId();

        String roomId = &quot;room&quot; + userId;

        Member member = memberService.getMemberById(adminId);
        if (!member.getRole().equals(Role.ADMIN)) {
            throw new NotAdminException(ExceptionMessage.NOT_ADMIN);
        }

        List&lt;Chat&gt; chatList = chatRepository.findAllChatByRoomId(roomId);

        return chatList.stream().map(chat -&gt; MessageDto.builder()
                .senderNum(chat.getSenderNum())
                .receiverNum(chat.getReceiverNum())
                .message(chat.getMessage())
                .roomId(chat.getRoomId())
                .sendTime(chat.getSendTime())
                .build()
        ).toList();
    }

    //채팅을 보내는 부분
    @Override
    public ChatBasicResponse sendChat(SendMessageRequest request) {
        //로그인 정보 조회
        Long loginId = SecurityUtill.getCurrentMemberId();

           //관리자의 정보를 가져온다.
        Member admin = memberService.getMemberByEmail(&quot;OOOO@gmail.com&quot;);
        Long adminId = admin.getId();

        // 보내는이,받는이,roomId는 위에 가져온 정보에 따라 달라 질 수 있기에 선언만 한다.
        Long receiverId;
        Long senderId;
        String roomId;

        // 만약 로그인 한 유저가 고객이라면
        if (!loginId.equals(adminId)) {

            receiverId = adminId;
            senderId = loginId;
            roomId = &quot;room&quot; + loginId;
        } else {
            // 관리자가 보냄
            senderId = adminId;
            receiverId = request.getReceiverNum();
            roomId = &quot;room&quot; + receiverId;
        }

        // 혹시나 보낸이와 받는이가 같을 경우를 대비한 예외처리
        if (receiverId.equals(senderId)) {
            throw new NoSameSenderAndReciverException(ExceptionMessage.NO_SAME_SANDER_RECEIVER);
        }

        // Chat을 저장해준다.
        Chat chat = Chat.builder()
                .senderNum(senderId)
                .receiverNum(receiverId)
                .message(request.getMessage())
                .roomId(roomId)
                .build();
        chatRepository.save(chat);

        // * 중요
        // /topic/roomId를 구독중인 곳에 message객체를 보내준다.
        MessageDto message = MessageDto.builder()
                .senderNum(senderId)
                .receiverNum(receiverId)
                .message(request.getMessage())
                .roomId(roomId)
                .sendTime(chat.getSendTime())
                .build();
        messagingTemplate.convertAndSend(&quot;/topic/&quot; + roomId, message);

        log.info(&quot;웹소켓 발송: roomId={}, message={}&quot;, roomId, message);

        return ChatBasicResponse.builder()
                .result(true)
                .message(&quot;Success Send Message&quot;)
                .build();
    }
}
</code></pre>
<h3 id="controller">Controller</h3>
<pre><code class="language-java">package com.est.mungpe.chat.controller;


import com.est.mungpe.chat.dto.ChatBasicResponse;
import com.est.mungpe.chat.dto.MessageDto;
import com.est.mungpe.chat.dto.SendMessageRequest;
import com.est.mungpe.chat.dto.getAllRoomResponse;
import com.est.mungpe.chat.service.ChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@RestController
@RequestMapping(&quot;/api/chat&quot;)
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    //채팅 시작 부분
    //첫 채팅이면 관리자의 chat &quot;무엇을 도와드릴까요?&quot;가 보내지고
    //roomId가 배정된다.
    @GetMapping
    public ResponseEntity&lt;List&lt;MessageDto&gt;&gt; openChat() {
        log.info(&quot;Open Chat Controller 도착&quot;);

        List&lt;MessageDto&gt; result = chatService.openChat();

        log.info(&quot;result = {}&quot;, result);

        return ResponseEntity.ok(result);
    }

    //관리자가 유저와 주고 받은 채팅을 가져오는 부분
    @GetMapping(&quot;/with/{userId}&quot;)
    public ResponseEntity&lt;List&lt;MessageDto&gt;&gt; getAdminChat(@PathVariable Long userId) {

        log.info(&quot;Admin Chat Controller 도착&quot;);

        List&lt;MessageDto&gt; result = chatService.getChatWithUser(userId);

        log.info(&quot;result = {}&quot;, result);

        return ResponseEntity.ok(result);
    }

    // 채팅을 보내는 부분
    @PostMapping(path = &quot;send&quot;)
    public ResponseEntity&lt;ChatBasicResponse&gt; sendMessage(@RequestBody SendMessageRequest request) {

        log.info(&quot;Send Controller 도착 {}&quot;, request);

        ChatBasicResponse result = chatService.sendChat(request);

        log.info(&quot;result = {}&quot;, result);

        return ResponseEntity.ok(result);
    }



}
</code></pre>
<p>이러면 백엔드에서 기본적인 설정은 끝난다.</p>
<h2 id="nextjs-설정">Next.js 설정</h2>
<p>사용할 라이브러리</p>
<pre><code>import { Client } from &#39;@stomp/stompjs&#39;
import SockJS from &#39;sockjs-client&#39;;</code></pre><p>stomp js랑 sockjs-client를 설치해준다.</p>
<h3 id="service-1">Service</h3>
<p>백엔드 Controller와 연결하는 부분(api 통신 모듈)</p>
<pre><code class="language-javascript">import {axiosAuthRequest} from &quot;@/service/AxiosConfig&quot;;
const API_ORIGIN = &quot;http://localhost:8080&quot;;

export async function openChat() {
    try {
        const response = await axiosAuthRequest.get(`${API_ORIGIN}/api/chat`)

        return response.data;

    } catch (error) {
        console.log(&quot;chat 오픈 실패 &quot;,error)
        throw new Error(&quot;chat 오픈 실패&quot;)
    }
}


export async function adminChat(userId) {
    try {
        const response = await axiosAuthRequest.get(`${API_ORIGIN}/api/chat/with/${userId}`);

        return response.data;

    } catch (error) {
        console.log(&quot;admin chat 오픈 실패 &quot;,error)
        throw new Error(&quot;admin chat 오픈 실패&quot;)
    }
}

export async function sendMessage(data) {
    try {
        const response = await axiosAuthRequest.post(`${API_ORIGIN}/api/chat/send`,data);

        return response.data;

    } catch (error) {
        console.log(&quot;메세지 전송 실패 &quot;,error)
        throw new Error(&quot;메세지 전송 오픈 실패&quot;)
    }
}

</code></pre>
<h3 id="componenet">Componenet</h3>
<pre><code class="language-javascript">&#39;use client&#39;;

import React, { useEffect, useRef, useState } from &#39;react&#39;;
import { Client } from &#39;@stomp/stompjs&#39;;
import SockJS from &#39;sockjs-client&#39;;
import {adminChat, openChat, sendMessage} from &quot;@/service/ChatService&quot;;
import { useSearchParams } from &quot;next/navigation&quot;;

export default function MemberChat() {
      //stomp 클라이언트가 재랜더링 되지 않기 위해 사용
    const clientRef = useRef(null);
    // 기존 채팅 내역
    const [messages, setMessages] = useState([]);

      // 채팅 스크롤을 제일 아래로 내리기 위해서 사용
    const messagesEndRef = useRef(null);

      //roomId랑 message
    const [roomId, setRoomId] = useState(null);
    const [message, setMessage] = useState(&#39;&#39;);

     // 첫 채팅이면 chat을 만들고 chatlist를 가져오고,
    // 기록이 있다면 chatlist를 가져온다.
    useEffect(() =&gt; {
        async function fetchData() {
            try {
                const data = await openChat();
                setMessages(data);
                setRoomId(data[0].roomId);
            } catch (e) {
                console.log(&quot;채팅 open 실패&quot;, e);
            }
        }
        fetchData();
    },[]);

      //  Stomp 연결 부분
    useEffect(() =&gt; {
          // roomId를 가져오기 전 랜더링 되는 것을 막기 휘함
        if (!roomId) return;

          // hand shaking을 할 주소를 설정한다.
        const socket = new SockJS(&#39;http://localhost:8080/chat&#39;);
          // Stomp 설정
        const client = new Client({

            webSocketFactory: () =&gt; socket,
              // 연결이 끊겼을때 5초마다 자동 재접속 시도 설정
            reconnectDelay: 5000,

            // 연결이 되었다면 실행되는 코드
            onConnect: () =&gt; {

                console.log(&quot;STOMP 연결 성공, 구독 주소 = &quot;, roomId);

                  // 서버가 topic/{roomId}로 알림이 보내지는 메세지를 구독하고 있음
                  // 구독에 대한 설정
                client.subscribe(`/topic/${roomId}`, (message) =&gt; {
                    const msg = JSON.parse(message.body);
                    console.log(&quot;실시간 수신&quot;, msg);
                      //기존 가져온 메세지 list에 추가를 해야지 리랜더링이 됨
                    setMessages(prev =&gt; [...prev, msg]);
                });


            },
              // 에러 부분 설정
            onStompError: (err) =&gt; console.error(&quot;STOMP 에러&quot;, err),
        });
        // 실제로 연결 시작을 하는 명령
        client.activate();
          // 연결 정보를 보관해둠
        clientRef.current = client;
          //연결 해제 부분
          //컴포넌트가 언마운트 되거나 roomId가 바뀔때 연결 해제
        return () =&gt; client.deactivate();
    }, [roomId]);


    //Submit을 할때 작동하는 코드
    const handleSubmit = async (e) =&gt; {
        e.preventDefault();
        try {
            const data = { message };
            // backend send 부분과 연동
            await sendMessage(data);
              //메세지를 보내고 난후 메세지를 초기화 해줘야지 input이 비워짐
            setMessage(&quot;&quot;);
        } catch (e) {
            console.log(&quot;메세지 전송 실패&quot;, e);
        }
    };


    useEffect(() =&gt; {
          // 지정한 요소(메세지 마지막 부분이)가 보이도록 스크롤을 이동
        messagesEndRef.current?.scrollIntoView(
            {behavior:&quot;smooth&quot;}
        );
    })

    return (
        &lt;form onSubmit={handleSubmit} className=&quot;gap-2&quot;&gt;
            &lt;div className=&quot;text-black&quot;&gt;
                여기는 소비자 {roomId}
            &lt;/div&gt;
            &lt;div className=&quot; h-100 overflow-y-auto mb-2 p-2 bg-white text-black&quot;&gt;

                {messages.map((msg, idx) =&gt; (

                    &lt;div key={idx} className={`mb-1 flex ${msg.senderNum === 1 ? &#39;justify-end&#39; : &#39;justify-start&#39;}`}&gt;
                        &lt;div className={`px-3 py-2 rounded max-w-xs ${msg.senderNum === 1 ? &#39;bg-orange-200&#39; : &#39;bg-gray-100&#39;}`}&gt;
                            &lt;b&gt;{msg.senderNum === 1 ? &quot;관리자&quot; : `나`}&lt;/b&gt;: {msg.message}
                        &lt;/div&gt;
                        &lt;div ref={messagesEndRef}/&gt;
                    &lt;/div&gt;

                ))}

            &lt;/div&gt;
            &lt;div className=&quot;flex justify-center items-center gap-2 w-full p-2&quot;&gt;
                &lt;input
                    type=&quot;text&quot;
                    value={message}
                    onChange={(e) =&gt; setMessage(e.target.value)}
                    placeholder=&quot;메세지를 입력 하세요&quot;
                    className=&quot;flex justify-center items-center focus:outline-orange-400 bg-white border-2 border-orange-300 hover:border-orange-400 w-full rounded h-full text-black p-2&quot;
                /&gt;
                &lt;button type=&quot;submit&quot;
                        className=&quot;bg-orange-300 w-25 rounded hover:bg-orange-400 h-full p-2&quot;&gt;
                    보내기
                &lt;/button&gt;
            &lt;/div&gt;

        &lt;/form&gt;
    );
}</code></pre>
<p>이렇게 하면 일단 소비자가 관리자랑 채팅 하는 부분은 끝난다
관리자가 소비자랑 채팅 하는 부분은 위의 컴포넌트를 참고해서 만들면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 엘라스틱 서치 + Docker Compose]]></title>
            <link>https://velog.io/@hwan_94/Java-%EC%97%98%EB%9D%BC%EC%8A%A4%ED%8B%B1-%EC%84%9C%EC%B9%98-Docker-Compose</link>
            <guid>https://velog.io/@hwan_94/Java-%EC%97%98%EB%9D%BC%EC%8A%A4%ED%8B%B1-%EC%84%9C%EC%B9%98-Docker-Compose</guid>
            <pubDate>Fri, 13 Jun 2025 07:56:29 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-yml-설정">Spring yml 설정</h2>
<p>일단 메인 디렉토리에 
docker-compose.yml을 만들어 준다.
이는 도커 컨테이너 설정 파일을 만든 것이며 </p>
<p>이리 하면 docker-compose up를 자동적으로 사용해 설정을 읽는다</p>
<pre><code class="language-yml">version: &#39;3.8&#39; # Docker-compose 파일 포맷 버전
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:9.0.0 #도커 다운로드 이미지
    container_name: es-container #이미지로 만들 컨테이너 이름
    environment:
      - discovery.type=single-node # 단일 노드 클러스터 사용
      - ES_JAVA_OPTS=-Xms512m -Xmx512m #Elasticsearch JVM 메모리 설정 (512MB 고정)
      - xpack.security.enabled=false  # 개발환경 보안 비활성화
    ports:
      - &quot;9200:9200&quot; #ElasticSearch 용 포트 개방
    volumes:
      - es-data:/usr/share/elasticsearch/data #Elastic Search로 저장된 정보가 등록되는 공간
    networks:
      - elastic #네트워크 이름 elastic

#volume 설정
volumes:
  es-data: 
    driver: local #로컬 디스크에 저장 (기본),컨테이너가 사라져도 남아있음
    #만약 컨테이너와 함께 정보를 지우고 싶다면 docker-compose down -v로 볼륨도 지워야지 데이터가 같이 사라짐

#networks 설정
networks:
  elastic: 
    driver: bridge # Docker 기본 네트워크 드라이버 (가상 네트워크, 컨테이너 간 통신 가능)
</code></pre>
<p>를 설정 한다.</p>
<h3 id="도커-이미지">도커 이미지</h3>
<p><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-basic">https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-basic</a></p>
<p>도커의 공식 이미지는 </p>
<pre><code>docker.elastic.co/elasticsearch/elasticsearch:9.0.0</code></pre><p>공식 사이트에 들어가면 위와 같이 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/09ede13e-fb16-4569-a607-9351f74ded57/image.png" alt=""></p>
<p>그 후에는 터미널에서 <code>docker-compose up -d</code> 를 실행하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/f9ee9c21-6d11-4dd9-ab4d-f5fd0d6b0a57/image.png" alt="">
성공 했다면 docker-desktop 에서 이렇게 작동이 될 것이고</p>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/9d907836-bac0-45bf-99a5-b8ea126de1c1/image.png" alt=""></p>
<p>docker desktop이 없다면 인터넷에서 <code>http://localhost:9200</code> 로 접속 시에 위와 같은 화면이 나온다면 문제가 없는 것이다.</p>
<h3 id="그래들-추가">그래들 추가</h3>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-data-elasticsearch&#39;</code></pre><h3 id="yml-추가">yml 추가</h3>
<pre><code>spring:
  elasticsearch:
  uris: http://localhost:9200
  username:  # 보안 활성화 시 필요
  password:  # 보안 활성화 시 필요</code></pre><h2 id="java-설정">java 설정</h2>
<h3 id="dto">dto</h3>
<pre><code class="language-java">@Data
@Document(indexName = &quot;documents&quot;)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class SearchDto {

    @Id
    @EqualsAndHashCode.Include
    private String id;
    private String title;
    private String content;
}</code></pre>
<p>위 클래스는 Elasticsearch 문서 매핑 클래스이다.</p>
<p>title과 content는 내가 검색 대상으로 삼을 필드 이름이며, 이 필드들에 대해 부분 검색(Containing) 또는 전문 검색(Full-text Search) 을 수행할 수 있다.</p>
<p>Elasticsearch는 RDB와 별개의 검색 전용 저장소이며, DB처럼 영속성을 가지지만 Spring Data JPA와는 별도로 작동한다.</p>
<p>따라서 기본 DB를 삭제해도, Elasticsearch에 저장된 문서들은 삭제되지 않는다.</p>
<p>Elasticsearch에 저장된 정보는 컨테이너 내부 경로 또는 외부에 마운트된 volume 경로에 저장된다,
그렇기에 Docker로 컨테이너를 삭제하거나 해당 볼륨을 정리해야만 완전히 제거된다.</p>
<h3 id="repository">Repository</h3>
<pre><code class="language-java">public interface ElasticRepository extends ElasticsearchRepository&lt;SearchDto, String&gt; {
    List&lt;SearchDto&gt; findByTitleContaining(String keyword);
    List&lt;SearchDto&gt; findByContentContaining(String keyword);
}</code></pre>
<p>위 메서드들은 주어진 keyword가 title 또는 content 필드에 포함되어 있는지(부분 일치) 확인한 후, 해당 문서들을 반환한다.</p>
<h3 id="게시물-등록-service">게시물 등록 Service</h3>
<pre><code class="language-java">    @Override
    @Transactional(readOnly = false)
    public BoardMessageResponse createPost(BoardRequest request, List&lt;MultipartFile&gt; sourceImage) throws IOException {

        // 포스트 저장
        Board newPost = savePost(request);

        log.info(&quot;newPost = {}&quot;, newPost);

        List&lt;byte[]&gt; files = imageConvertService.convert(sourceImage);

        for (byte[] fileData : files) {

            //유니크 파일 경로 만들기
            String fileName = UUID.randomUUID().toString();

            //s3 업로드
            String imageUrl = s3StorageService.upload(fileData,fileName);
            log.info(&quot;imageUrl = {}&quot;, imageUrl);

            // product image 객체 생성
            imageService.savePostImage(imageUrl, newPost.getId(), ImageDomainType.POST);
        }

        //엘라스틱 서치에 추가
        SearchDto dto = new SearchDto();
        dto.setId(Type.BOARDDATA + &quot;_&quot; + newPost.getId().toString());
        dto.setTitle(newPost.getTitle());
        dto.setContent(newPost.getContent());
        elasticRepository.save(dto);

        return BoardMessageResponse.builder()
                .message(&quot;Success Created Post&quot;)
                .result(true)
                .build();
    }
</code></pre>
<p>기존의 게시물 작성 코드에 SearchDto 객체를 생성 해주는 코드를 추가 해준다.</p>
<h3 id="검색-service">검색 Service</h3>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class ElasticService {

    private final ElasticRepository elasticRepository;
    private final BoardService boardService;
    private final PediaService pediaService;

    public SearchBothResponse searchInfo(String keyword) {

        // 반환용 게시판 리스트
        List&lt;GetBoardMessageReponse&gt; boardResponses = new ArrayList&lt;&gt;();

        // 게시판 검색
        List&lt;SearchDto&gt; boardResults = mergeResults(keyword);

        for (SearchDto boardResult : boardResults) {

            Long postId = Long.valueOf(boardResult.getId().split(&quot;BOARDDATA_&quot;)[1]);

            GetBoardMessageReponse post = boardService.getPost(postId);

            boardResponses.add(post);
        }

        SearchBothResponse result = SearchBothResponse.builder()
                .message(&quot;Success Get Both Document&quot;)
                .result(true)
                .boardResults(boardResponses)
                .build();
        return result;
    }

    //title을 검색한 내용과  Content로 검색한 내용이 LinkedHash로 겹치는 것을 합쳐준다.
    public List&lt;SearchDto&gt; mergeResults(String keyword) {

        Set&lt;SearchDto&gt; mergedSet = new LinkedHashSet&lt;&gt;();

        List&lt;SearchDto&gt; byTitleContaining = elasticRepository.findByTitleContaining(keyword);
        List&lt;SearchDto&gt; byContentContaining = elasticRepository.findByContentContaining(keyword);

        mergedSet.addAll(byTitleContaining);
        mergedSet.addAll(byContentContaining);

        List&lt;SearchDto&gt; mergedList = new ArrayList&lt;&gt;(mergedSet);
        log.info(&quot;mergedList = {}&quot;, mergedList);

        return mergedList;
    }
}
</code></pre>
<p>keyword로 title과 content를 각각 검색했을 때, 동일한 문서가 중복으로 검색될 수 있다.
이를 방지하기 위해 LinkedHashSet을 사용하여 중복을 제거하면서도 삽입 순서를 유지한 결과 리스트를 만든다.</p>
<h3 id="controller">Controller</h3>
<pre><code class="language-java">@Slf4j
@RestController
@RequestMapping(&quot;/api/search&quot;)
@RequiredArgsConstructor
public class ElasticController {

    private final ElasticService elasticService;


    @GetMapping(path = &quot;/both&quot;)
    public ResponseEntity&lt;SearchBoardResponse&gt; searchBoth(@RequestParam String keyword) {

        log.info(&quot;Search Both 컨트롤러 도착&quot;);

        SearchBoardResponse result = elasticService.searchInfo(keyword);

        log.info(&quot;result = {}&quot;, result);

        return ResponseEntity.ok(result);
    }

}
</code></pre>
<p>이렇게 하면 기본적인 엘라스틱 서치의 설정은 끝난다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java]JWT 토큰 설정 2편 (JwtTokenFilter)]]></title>
            <link>https://velog.io/@hwan_94/JavaJWT-%ED%86%A0%ED%81%B0-%EC%84%A4%EC%A0%95-2%ED%8E%B8-JwtTokenFilter</link>
            <guid>https://velog.io/@hwan_94/JavaJWT-%ED%86%A0%ED%81%B0-%EC%84%A4%EC%A0%95-2%ED%8E%B8-JwtTokenFilter</guid>
            <pubDate>Tue, 13 May 2025 09:54:58 GMT</pubDate>
            <description><![CDATA[<h2 id="사전-설명">사전 설명</h2>
<h3 id="jwttokenfilter가-하는-역활">JwtTokenFilter가 하는 역활</h3>
<pre><code>jwt 토큰 필터는 Http요청이 들어오면 토큰이 유효한지 확인한다.
유효 하지 않다면 401 error를 띄워준다.</code></pre><h3 id="tokenbody-dto">TokenBody dto</h3>
<pre><code class="language-java">@Data
@AllArgsConstructor
public class TokenBody {

    private Long memberId;
    private String role;

}</code></pre>
<p>jwt 토큰에서 memberId와 권한을 가져오기 위한 dto</p>
<h3 id="jwttokenprovider-기존-코드-추가">JwtTokenProvider (기존 코드 추가)</h3>
<pre><code class="language-java">@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
//  jwt 토큰을 발급, 검증, 파싱 하는 클래스
public class JwtTokenProvider {
    // access Token과 Refresh 토큰의 재발급 정보를 담당
    private final JwtConfiguration configuration;

    // Refresh Token의 발급, 조회, 블랙리스트 등록을 담당
    private final TokenRepository refreshTokenRepositoryAdapter;

    // 시크릿 키 생성
    private SecretKey getSecretKey() {
        // JJWT 라이브러리의 유틸 클래스인 io.jsonwebtoken.security.Keys에서 제공하는 메서드로,HMAC 방식 서명을 위한 시크릿 키를 생성
        return Keys.hmacShaKeyFor(configuration.getSecret().getAppKey().getBytes());
    }

    //jwtToken 생성
    private String issue(Long memberId, String role, Long validTime) {

        // Payload = subject, claim(&quot;role&quot;), issuedAt(iat), expiration(exp)
        // Signature  = signWith를 통해 생성
        // 아래의 코드에는 header에 해당되는 코드가 없는데 자동적으로 생성된다.  (alg: HS256, typ: JWT)
        String jwtToken = Jwts.builder()
                .setSubject(memberId.toString())                          // subject: 사용자 ID
                .claim(&quot;role&quot;, role)                                   // 사용자 역할(권한)을 추가
                .issuedAt(new Date())                                     // 발급 시간 (iat) 현재 시간
                .expiration(new Date(new Date().getTime() + validTime))   // 만료 시간 (ext) 현재 시간 + yml 설정 시간
                .signWith(getSecretKey(), Jwts.SIG.HS256)                 // 시크릿 키로 서명하여 Signature 생성
                .compact();                                               //  Header + Payload + Signature 결합 → 최종 JWT 문자열 반환

        return jwtToken;
    }

    // Accesss 토큰 생성
    public String issueAccessToken(Long memberId, String role) {
        return issue(memberId, role, configuration.getValidation().getAccess());
    }

    // Refresh 토큰 생성
    public String issueRefreshToken(Long memberId, String role) {
        return issue(memberId, role, configuration.getValidation().getRefresh());
    }

    // 토큰 두개를 묶는다.
    public KeyPair generateKeyPair(Member member) {

        String accessToken = issueAccessToken(member.getId(),member.getRole().name());

        String refreshToken = issueRefreshToken(member.getId(),member.getRole().name());

        refreshTokenRepositoryAdapter.save(member, refreshToken);

        KeyPair jwtTokens = KeyPair.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .memberId(member.getId().toString())
                .build();

        return jwtTokens;
    }

    //특정 사용자의 유효한 RefreshToken이 DB에 있는지 확인
    public RefreshToken validateRefreshToken(Long memberId) {

        Optional&lt;RefreshToken&gt; validRefTokenOptional = refreshTokenRepositoryAdapter.findValidRefTokenByMemberId(memberId);


        return validRefTokenOptional.orElse(null);
    }

    ////추가 되는 부분 ↓////
    ////추가 되는 부분 ↓////

    // 클라이언트가 보낸 JWT의 유효성을 서명(Signature) 기반으로 검증한다.
    // - parser(): JWT 문자열을 파싱할 준비를 한다.
    // - verifyWith(): 서명 검증에 사용할 SecretKey를 설정한다. (구버전은 setSigningKey() 사용 → 허용 타입 제한적)
    // - parseSignedClaims(): 토큰을 header.payload.signature로 분리하고, 서명이 유효한지 확인한다.
    public boolean validate(String token) {
        try {
            Jwts.parser()
                    .verifyWith(getSecretKey())
                    .build()
                    .parseSignedClaims(token);
            return true;

        // JWT 관련 최상위 예외    만료, 위조, 포맷 문제 등 대부분의 JWT 검증 실패 시 발생
        } catch (JwtException e) {
            log.info(&quot;JWT 토큰에 문제가 있습니다. = {}&quot;, e.getMessage());
            log.info(&quot;TOKEN : {}&quot;, token);

        // Java 기본 예외    null이거나 빈 토큰이 들어온 경우
        } catch (IllegalArgumentException e) {
            log.info(&quot;JWT 토큰이 Null입니다. = {}&quot;, e.getMessage());

        // 모든 나머지 예외 예상 못 한 오류 (시스템 오류 등)
        } catch (Exception e) {
            log.info(&quot;JWT 토큰 검증 중 예상치 못한 예외가 발생 했습니다. = {}&quot;, e.getMessage());
        }

        return false;
    }

    //토큰 내부 정보를 파싱해서 사용자 ID (sub)와 역할 (role)을 꺼냄
    public TokenBody parseJwt(String token) {
        Jws&lt;Claims&gt; parsed = Jwts.parser()
                .verifyWith(getSecretKey())
                .build()
                .parseSignedClaims(token);

        return new TokenBody(
                // 토큰을 만들때 payload의 Subject 에 setter 로 memeberId를 넣었음, memberId 반환 받기
                Long.parseLong(parsed.getPayload().getSubject()),
                // role이라는 커스텀 Claim을 가져온다.
                parsed.getPayload().get(&quot;role&quot;).toString()
        );
    }

}
</code></pre>
<p>기존 코드에서 추가 된 부분이 validate랑 parseJwt이가 있다. </p>
<h3 id="jwttokenfilter">JwtTokenFilter</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
//OncePerRequestFilter는 매 http요청 마다 한번만 실행되는 필터
public class JwtTokenFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;


    // jwt 인증의 핵심 메소드 이며 토큰 추출, 유효성 검사, 사용자 정보 추출, DB에서 사용자 조회,SecurityContext에 등록할 인증 객체 생성 및 인증 객체 설정을 해야한다.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info(&quot;jwt 필터 도착&quot;);

        // 토큰 추출
        String realToken = resolveToken(request);

        if (realToken == null || !jwtTokenProvider.validate(realToken)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;Access token invalid or expired&quot;);
            return;
        }

        // 유효성 검사
        if (realToken != null &amp;&amp; jwtTokenProvider.validate(realToken)) {
            // 사용자 정보 추출
            TokenBody tokenBody = jwtTokenProvider.parseJwt(realToken);
            // DB에서 사용자 조회
            Member member = memberRepository.findById(tokenBody.getMemberId())
                    .orElseThrow(() -&gt; new MemberNotFound(ExceptionMessage.MEMBER_NOT_FOUND));

            //SecurityContext에 등록할 인증 객체 생성
            //Spring Security의 인증 처리 규칙에 따라, SecurityContext에는 반드시 UserDetails 또는 OAuth2User를 구현한 인증된 사용자 객체가 들어가야한다.
            //attributes란?    OAuth2 로그인 시, 제공자로부터 받은 사용자 정보 (JSON)
            //JWT 기반 인증에서는 클라이언트(브라우저 등)에서 이미 인증이 끝난 후, JWT만 주고받기 때문에 attributes는 필요 없다.
            CustomUserPrincipal customUserPrincipal = CustomUserPrincipal.from(member,null);
            log.info(&quot;customUserPrincipal.getId() = {}&quot;, customUserPrincipal.getId());

            // Spring Security는 JWT 내부 정보를 자동으로 인식하지 못하기 때문에 파싱한 사용자 정보 및 권한을 직접 Authentication 객체에 담아서 알려줘야 한다.
            // SecurityContext에 인증 객체 설정 (유저 정보, jwt토큰, 권한)
            Authentication authentication = new UsernamePasswordAuthenticationToken(customUserPrincipal, realToken, customUserPrincipal.getAuthorities());
            log.info(&quot;authentication.getPrincipal() = {}&quot;, ((CustomUserPrincipal) authentication.getPrincipal()).getEmail());
            log.info(&quot;authentication.getPrincipal() = {}&quot;, ((CustomUserPrincipal) authentication.getPrincipal()).getId());


            // SecurityContextHolder에 인증 정보를 넣어 줌으로써 현재 요청을 보낸 사용자가 인증된 사용자임을 Spring Security에게 알려준다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        log.info(&quot;jwt 필터 성공&quot;);
        //    필터 체인 계속 진행
        filterChain.doFilter(request, response);
    }


    // http 요청에서 토큰만 추출 한다.
    private String resolveToken(HttpServletRequest request) {
        // 요청 헤더 중 Authorization 값을 가져온다
        // 요청 헤드의 생김새 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0...
        String bearerToken = request.getHeader(&quot;Authorization&quot;);

        // Bearer로 시작하는지 확인
        if (bearerToken != null &amp;&amp; bearerToken.startsWith(&quot;Bearer &quot;)) {
            log.info(&quot;resolvToken&quot;);
            log.info(&quot;bearerToken.substring(7) = {}&quot;, bearerToken.substring(7));
            // Bearer 이후 실제 토큰 문자열만 잘라서 반환
            return bearerToken.substring(7);
        }
        return null;
    }

}</code></pre>
<h3 id="security-config">Security Config</h3>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final JwtTokenFilter jwtTokenFilter;


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

                //cors 기본 설정 활성화
                .cors(Customizer.withDefaults())

                //csrf 비활성화
                .csrf(csrf -&gt; csrf.disable())

                // form 로그인 비활성화
                .formLogin(formLogin -&gt; formLogin.disable())

                //인증 권한 설정
                .authorizeHttpRequests(auth -&gt; auth
                        .requestMatchers(&quot;/&quot;, &quot;/login/**&quot;, &quot;/oauth2/**&quot;).permitAll()
                        .requestMatchers(&quot;/api/auth/logout&quot;, &quot;/api/auth/reissue&quot;).permitAll()
                        .requestMatchers(&quot;/api/member&quot;).authenticated()
                        .anyRequest().authenticated()
                )

                // Oauthlogin 설정
                .oauth2Login(oauth2 -&gt; oauth2
                        .userInfoEndpoint(userInfo -&gt; userInfo
                                .userService(customOAuth2UserService)
                        )
                        //로그인이 성공 했을 때 작동하는 핸들러
                        .successHandler(oAuth2SuccessHandler)
                )

                // jwt 필터 설정
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)

                .build();
    }
}</code></pre>
<p>이렇게 Security Config 까지 설정을 해주면 jwtTokenFilter에 대한 설정이 끝난다</p>
<p>현재는 Access 코드를 재발급 하고 검사하는 코드가 작성되지 않았으며, ogout을 했을 때 Refresh 토큰을 블랙 리스트로 등록 하는 코드가 없다.</p>
<p>다음편에는 재발급 코드와 Logout 코드를 만들어보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java]JWT 토큰(대칭 키) 설정 1편 (SuccessHandler),]]></title>
            <link>https://velog.io/@hwan_94/JavaJWT-%ED%86%A0%ED%81%B0-%EC%84%A4%EC%A0%95-1%ED%8E%B8</link>
            <guid>https://velog.io/@hwan_94/JavaJWT-%ED%86%A0%ED%81%B0-%EC%84%A4%EC%A0%95-1%ED%8E%B8</guid>
            <pubDate>Tue, 13 May 2025 09:32:53 GMT</pubDate>
            <description><![CDATA[<h2 id="사전-설명">사전 설명</h2>
<h3 id="jwt-토큰이란">Jwt 토큰이란?</h3>
<pre><code>jwt 토큰이란 프론트와 백엔드가 나눠져 있을 경우에는 서로 로그인을 했는지 안했는지를 알 수가 없다.
이를 위해서 만들어진 방식이며
JWT 토큰은 Header, Payload, Signature로 구성된 문자열이며,
이 전체 구조를 HS256 등의 알고리즘으로 서명하여 생성한다.
Header : 어떤 알고리즘으로 서명했는지
Payload : 사용자 정보 및 토큰의 메타데이터
Signature :  헤더 + 페이로드 + 비밀키를 가지고 만든 해시 값
으로 이루어져있다.</code></pre><h3 id="jwt-토큰의-생성-순서">Jwt 토큰의 생성 순서</h3>
<pre><code>1. Base 64로 이루어진 난수(origin Secret Key)를 생성한다.
2. origin Secret Key를 바이트 배열(Byte [])로 디코딩 한다.
3. 바이트 배열을 HmacShaKeyFor을 통해 Secret Key를 생성 해준다.
4. jwt 토큰을 생성 (Header = Secret Key + 서명 알고리즘 정보 / PayLoad = 고객 id, 이름, 권한 등의 고객 정보 + 토큰의 발급,만료 시간 / Signature = Header + PayLoad를 합친 위조 방지용 서명)
( jwt 토큰의 형태 = Header. Payload. Signature 의 형태를 가짐)
</code></pre><h3 id="accesstoken-과-refresh-토큰이란">AccessToken 과 Refresh 토큰이란?</h3>
<pre><code>Access Token과 Refresh Token은 모두 JWT 형식의 토큰이지만, **역할과 유효 기간이 다르다.**

Access Token은 유효 기간이 짧으며, 사용자의 인증 정보를 담고 있어 &#39;API 요청 시 인증 수단&#39;으로 사용된다.
Refresh Token은 유효 기간이 길고, Access Token이 만료되었을 때 &#39;새로운 Access Token을 발급받기 위한 용도&#39;로 사용된다.

즉, Access Token은 매 요청마다 서버에 전달되어 사용자를 인증하는 데 사용되며,  
Refresh Token은 사용자의 로그인 상태를 유지하면서, Access Token을 갱신하는 데 사용됩니다.</code></pre><h3 id="이번-편에서-만들-코드">이번 편에서 만들 코드</h3>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/7b562793-6b07-409e-804e-97f8d99f696c/image.png" alt="">
위의 사진은 oauth2.0로그인이 되고 난후 SuccessHandler과 jwt필터 설정이 끝난 security config 이다.
이를보면 SuccessHandler와 Jwtfilter을 만들어야 한다는 것을 알 수 있다. 
오늘은 시큐리티 필터체인에서 OauthLogin이 성공 했을때 어떻게 작동할 지에 대한 SuccessHandler를 만들어 볼 것이다.</p>
<h3 id="작성할-코드-설명">작성할 코드 설명</h3>
<pre><code>config
ㄴ SuccessHandler(Oauth Login이 성공 후 작동할 클래스)
   ㄴ onAuthenticationSuccess (SuccessHwandler를 호출 했을때 자동으로 실행 될 메서드)
   ㄴ addCookie(쿠키에 추가해주는 메소드)
ㄴ JwtConfiguration (Access 토큰 및 Refresh 토큰의 정보를 가지고 있는 클래스)

repositody
ㄴ TokenRepositoryAdapter(복잡한 쿼리를 따로 분리하는 Adapter 클래스)

service
ㄴJwtTokenProvider(jwt 토큰을 발급, 검증, 파싱 하는 클래스)

domain
ㄴ RefreshToken(리프레쉬 토큰)</code></pre><h2 id="사전-설정">사전 설정</h2>
<h3 id="app-key--origin-key-발급">app-key &amp;&amp; origin-key 발급</h3>
<p>터미널(Git Bash 등)을 열고 명령어 <code>openssl rand -base64 32</code> 입력 해준다.</p>
<pre><code>openssl rand -base64 32</code></pre><p>입력하면 아래와 같은 형식의 난수 문자열이 출력된다.</p>
<pre><code>1RbVb88pTsO3OPcJonK/y5441pzohe8+ECCDiCmpPk=</code></pre><p>이 문자열은 절대로 외부에 유출되면 안 되는 중요 정보이며, 보통 시크릿 키 생성의 기반 값으로 사용된다.</p>
<p>origin-key란?</p>
<pre><code>새로운 키를 발급하거나 키를 교체할 경우, 기존 app-key를 백업해두는 용도이며,주로 키 롤링(Key Rotation)을 위한 대비용이다.
→ 새로운 키를 사용할 때 기존 토큰을 검증하기 위해 origin-key가 필요할 수 있다.</code></pre><h3 id="yml">yml</h3>
<pre><code class="language-yml">      jwt :
    secret:
      app-key: 
      origin-key: 
    validation:
      access:  1800000 #30분(30분 × 60초 × 1000ms)
      refresh: 604800000 #7일(7일 × 24시간 × 60분 × 60초 × 1000ms)
    custom:
      frontend:
        redirect-uri: &quot;http://localhost:3000&quot;  
</code></pre>
<p>app-key의 경우 위에서 만든 키를 가지고 오면 된다.
validation 부분은 access token과 refresh 토큰의 유효 시간에 대한 설정이다.</p>
<h4 id="유효-시간-계산-공식">유효 시간 계산 공식:</h4>
<pre><code>(일) × 24 × 60 × 60 × 1000 → 밀리초
(분) × 60 × 1000 → 밀리초</code></pre><p><code>redirect-uri</code> redirect-uri는 로그인 성공 후, 클라이언트로 리디렉션할 프론트엔드 주소를 설정한다.</p>
<h3 id="gradle">gradle</h3>
<pre><code>    implementation &#39;io.jsonwebtoken:jjwt-api:0.12.3&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.12.3&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.12.3&#39;</code></pre><p>JWT 토큰을 생성하고 파싱하며 서명/검증하는 기능을 제공하는 라이브러리</p>
<h2 id="java-적용법">Java 적용법</h2>
<h3 id="refreshtoken-도메인">RefreshToken 도메인</h3>
<pre><code class="language-java">@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {

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

    private String refreshToken;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;member_id&quot;)
    private Member member;

    private LocalDateTime createdAt = LocalDateTime.now();

    @Builder
    public RefreshToken(String refreshToken, Member member) {
        this.refreshToken = refreshToken;
        this.member = member;
    }
}
</code></pre>
<p>리프레쉬 토큰은 id, 토큰(String), member, 생성시간만 들어가면 된다.
Access 토큰은 도메인이 필요하지 않다.</p>
<h3 id="refreshtokenblacklist-도메인">RefreshTokenBlackList 도메인</h3>
<pre><code class="language-java">@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshTokenBlackList {

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

    private LocalDateTime createdAt = LocalDateTime.now();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;refresh_token_id&quot;)
    private RefreshToken refreshToken;

    @Builder
    public RefreshTokenBlackList(RefreshToken refreshToken) {
        this.refreshToken = refreshToken;
    }
}</code></pre>
<p>이 코드는 현재 사용하지 않으나 이 다음에 만들 RefreshToken Repository에서 활성화 된 Token을 가져오는 코드를 사용하기 위해 필요하며
추후 로그아웃 기능을 만들면 연결 할 것이다.</p>
<h3 id="tokenrepository">TokenRepository</h3>
<pre><code class="language-java">public interface TokenRepository {

    //토큰을 저장한다.
    RefreshToken save(Member member,String token);

    // MemberId로 유효한 토큰 찾기
    Optional&lt;RefreshToken&gt; findValidRefTokenByMemberId(Long memberId);</code></pre>
<h3 id="refreshtokenrepository">RefreshTokenRepository</h3>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class TokenRepositoryAdapter implements TokenRepository {

    //리프레쉬 토큰을 가져온다. 토큰의 별칭은 rt이다
    //RefreshTokenBlackList를 rt에 LEFT JOIN 한다. rt가 블랙리스트에 등록되어 있다면 rtb에 매칭되고, 아니면 rtb는 null이다.
    //지정한 사용자만 조회한다 (member.id = : memberId)
    //블랙리스트에 id가 없는 것만 조회 한다.
    @Query(&quot;&quot;&quot;
    SELECT rt FROM RefreshToken rt
    LEFT JOIN RefreshTokenBlackList  rtb ON rtb.refreshToken = rt
    WHERE rt.member.id = :memberId
    AND rtb.id IS NULL
    &quot;&quot;&quot;)
    Optional&lt;RefreshToken&gt; findValidTokenByMemberId(Long memberId);

    }
</code></pre>
<h3 id="tokenrepositoryadapter">TokenRepositoryAdapter</h3>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
// RefreshToken과 RefreshTokenBlackList를 중심으로, &quot;토큰 저장&quot;, &quot;조회&quot;, &quot;블랙리스트 등록&quot; 등의 로직을 캡슐화한 데이터 접근 계층
public class TokenRepositoryAdapter implements TokenRepository {

    private final RefreshTokenRepository refreshTokenRepository;
    private final RefreshTokenBlackListRepository refreshTokenBlackListRepository;

    //새 RefreshToken을 생성하고 DB에 저장
    @Override
    public RefreshToken save(Member member, String token) {

        RefreshToken refreshToken = RefreshToken.builder()
                .refreshToken(token)
                .member(member)
                .build();

        return refreshTokenRepository.save(refreshToken);
    }

    //회원 ID로 유효한 토큰 조회 (블랙리스트 제외 조건 내장)
    @Override
    public Optional&lt;RefreshToken&gt; findValidRefTokenByMemberId(Long memberId) {
        return refreshTokenRepository.findValidTokenByMemberId(memberId);
    }
}
</code></pre>
<p>여기 까지 온다면 왜 굳이 TokenRepositoryAdapter가 필요한가를 생각 할 것이다. </p>
<pre><code>블랙리스트, 복합 로직 등 ‘어댑터 레이어’에 숨기기
JPA 쿼리나 블랙리스트 제외 조건 같은 복잡한 로직은 Adapter에 숨겨서, 상위 계층인 Service는 단순한 ‘토큰 저장/조회’만 알도록 하는 게 일반적이라고 한다.</code></pre><h3 id="keypair-dto">KeyPair dto</h3>
<pre><code class="language-java">@Data
@Builder
public class KeyPair {

    private String accessToken;
    private String refreshToken;
    // 쿠키에 값을 넣으려면 String 이여야 한다.
    private String memberId;
}</code></pre>
<h3 id="jwtcofiguration">JwtCofiguration</h3>
<pre><code class="language-java">@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = &quot;jwt&quot;)
public class JwtConfiguration {

    // Access Token과 Refresh Token의 유효 시간 설정을 담고 있다.
    // 값은 application.yml에서 설정된다.
    private final Validation validation;

    // 시크릿 키를 두 개로 분리해서 관리한다.
    // - appKey: 현재 사용 중인 시크릿 키
    // - originKey: 이전에 사용하던 시크릿 키 (기존 토큰 검증용)
    private final Secret secret;

    // 왜 굳이 내부 클래스로 정의했는가?
    // Validation은 JwtConfiguration 내부에서만 사용되며,
    // 다른 클래스에서 독립적으로 사용될 일이 없기 때문이다.
    // 관련 설정을 그룹화하여 구성의 명확성과 가독성을 높이기 위함이다.
    @Data
    public static class Validation {
        private Long access;
        private Long refresh;
    }

    @Getter
    @RequiredArgsConstructor
    public static class Secret {
        private final String appKey;
        private final String originKey;
    }
}
</code></pre>
<h3 id="jwttokenprovider">JwtTokenProvider</h3>
<pre><code class="language-java">@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
//  jwt 토큰을 발급, 검증, 파싱 하는 클래스
public class JwtTokenProvider {
    // access Token과 Refresh 토큰의 재발급 정보를 담당
    private final JwtConfiguration configuration;

    // Refresh Token의 발급, 조회, 블랙리스트 등록을 담당
    private final TokenRepository refreshTokenRepositoryAdapter;

    // JJWT 라이브러리의 유틸 클래스인 io.jsonwebtoken.security.Keys에서 제공하는 메서드로,HMAC 방식 서명을 위한 시크릿 키를 생성
    private SecretKey getSecretKey() {

        //base64 기반의 난수 Origin Secret Key
        String base64SecretKey = configuration.getSecret().getAppKey().getBytes();

        //byte 배열로 디코딩 
          byte[] BytesSecretKey = Base64.getDecoder().decode(base64SecretKey);



        return Keys.hmacShaKeyFor(BytesSecretKey);
    }

    //jwtToken 생성
    private String issue(Long memberId, String role, Long validTime) {

        // Payload = subject, claim(&quot;role&quot;), issuedAt(iat), expiration(exp)
        // Signature  = signWith를 통해 생성
        // 아래의 코드에는 header에 해당되는 코드가 없는데 자동적으로 생성된다.  (alg: HS256, typ: JWT)
        String jwtToken = Jwts.builder()
                .setSubject(memberId.toString())                          // subject: 사용자 ID
                .claim(&quot;role&quot;, role)                                   // 사용자 역할(권한)을 추가
                .issuedAt(new Date())                                     // 발급 시간 (iat) 현재 시간
                .expiration(new Date(new Date().getTime() + validTime))   // 만료 시간 (ext) 현재 시간 + yml 설정 시간
                .signWith(getSecretKey(), Jwts.SIG.HS256)                 // 시크릿 키로 서명하여 Signature 생성
                .compact();                                               //  Header + Payload + Signature 결합 → 최종 JWT 문자열 반환

        return jwtToken;
    }

    // Accesss 토큰 생성
    public String issueAccessToken(Long memberId, String role) {
        return issue(memberId, role, configuration.getValidation().getAccess());
    }

    // Refresh 토큰 생성
    public String issueRefreshToken(Long memberId, String role) {
        return issue(memberId, role, configuration.getValidation().getRefresh());
    }

    // 토큰 두개를 묶는다.
    public KeyPair generateKeyPair(Member member) {

        String accessToken = issueAccessToken(member.getId(),member.getRole().name());

        String refreshToken = issueRefreshToken(member.getId(),member.getRole().name());

        refreshTokenRepositoryAdapter.save(member, refreshToken);

        KeyPair jwtTokens = KeyPair.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .memberId(member.getId().toString())
                .build();

        return jwtTokens;
    }

    //특정 사용자의 유효한 RefreshToken이 DB에 있는지 확인
    public RefreshToken validateRefreshToken(Long memberId) {

        Optional&lt;RefreshToken&gt; validRefTokenOptional = refreshTokenRepositoryAdapter.findValidRefTokenByMemberId(memberId);


        return validRefTokenOptional.orElse(null);
    }
}
</code></pre>
<h3 id="successhandler">SuccessHandler</h3>
<pre><code class="language-java">
@Slf4j
@Component
@RequiredArgsConstructor
// 유저 차단 확인, 리프레쉬 토큰 확인 및 액세스토큰 생성
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;
    private final JwtConfiguration jwtConfiguration;


    @Value(&quot;${custom.frontend.redirect-uri}&quot;)
    private String baseUrl;


    // SuccessHandler을 조회하면 자동적으로 onAuthenticationSuccess 메소드를 조회 한다.
    // Spring Security는 인증이 성공하면 AuthenticationSuccessHandler 타입으로 등록된 객체를 찾는다.
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        // 유저 정보를 가져온다.
        CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();

        //member Id를 가져온다.
        Long memberId = principal.getId();

        //SecurityContext에 어떤 객체가 들어갔는지 디버깅용으로 확인하기 위해
        log.info(&quot;Principal set to SecurityContext: {}&quot;, ((CustomUserPrincipal) authentication.getPrincipal()).getClass().getName());

        // 쿠키 생존 시간에 대한 설정
        int accessCookieMaxAge = (int) (jwtConfiguration.getValidation().getAccess() / 1000);
        int refreshCookieMaxAge = (int) (jwtConfiguration.getValidation().getRefresh() / 1000);

        // 유저의 벤 여부를 확인한다.
        if (principal.isBlocked()) {
            log.warn(&quot;Blocked user attempted to log in: {}&quot;, principal.getId());
            response.sendError(HttpServletResponse.SC_FORBIDDEN, ExceptionMessage.MEMBER_BLOCKED_ERROR);
            return;
        }

        //Refresh 토큰 조회
        RefreshToken findRefreshToken = jwtTokenProvider.validateRefreshToken(memberId);

        if (findRefreshToken == null) {
            // 회원 조회
            Member findMember = memberRepository.findById(memberId)
                    .orElseThrow(() -&gt; new MemberNotFound(ExceptionMessage.MEMBER_NOT_FOUND));

            // Access,Refresh 토큰 생성 및 저장
            KeyPair keyPair = jwtTokenProvider.generateKeyPair(findMember);
            String accessToken = keyPair.getAccessToken();
            String refreshToken = keyPair.getRefreshToken();

            // 쿠키에 토큰 추가
            addCookie(response, &quot;accessToken&quot;, accessToken, accessCookieMaxAge);
            addCookie(response, &quot;refreshToken&quot;, refreshToken, refreshCookieMaxAge);

            log.info(&quot;accessToken = {}&quot;, accessToken);
            log.info(&quot;refreshToken = {}&quot;, refreshToken);
            log.info(&quot;유효한 Refresh Token 없음 Refresh,Access Token 발급&quot;);

        } else {
            // 토큰이 있다면 Access 토큰만 발급.
            String accessToken = jwtTokenProvider.issueAccessToken(principal.getId(), principal.getRole().name());
            String refreshToken = findRefreshToken.getRefreshToken();

            // 쿠키에 토큰 추가
            addCookie(response, &quot;accessToken&quot;, accessToken, accessCookieMaxAge);
            addCookie(response, &quot;refreshToken&quot;, refreshToken, refreshCookieMaxAge);
            log.info(&quot;기존 Refresh Token 유효 AccessToken만 발급&quot;);
        }

        // Success 후 리디렉션
        // 기존 Security Config의 .defaultSuccessUrl(&quot;/&quot;) 대신 사용
        getRedirectStrategy().sendRedirect(request,response,baseUrl);
        log.info(&quot;succesHandler 성공&quot;);

    }

    // 쿠키에 대한 설정
    private void addCookie(HttpServletResponse response,
                           String name,
                           String value,
                           int maxAgeSeconds) {
        Cookie cookie = new Cookie(name, value);
        cookie.setMaxAge(maxAgeSeconds);
        cookie.setPath(&quot;/&quot;);
        cookie.setHttpOnly(false);
        cookie.setSecure(false); //나중에 true로 바꿔야지 https 에서만 전송 된다.
//        cookie.setAttribute(&quot;SameSite&quot;, &quot;None&quot;);
        response.addCookie(cookie);
    }

}
</code></pre>
<p>배포시 수정해야 할 부분</p>
<pre><code>1. cookie.setHttpOnly(false); → true
브라우저의 JavaScript에서 접근 불가
XSS(스크립트 해킹)를 통한 쿠키 탈취 방지
보안상 **항상 true**로 설정하는 게 좋다

2. cookie.setSecure(false); → true
HTTPS 연결일 때만 쿠키 전송
HTTP 연결에서는 쿠키가 절대 노출되지 않음
실제 배포에서는 HTTPS를 기본으로 사용해야 하므로 반드시 true
</code></pre><h3 id="security-config">Security Config</h3>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final JwtTokenFilter jwtTokenFilter;


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

                //cors 기본 설정 활성화
                .cors(Customizer.withDefaults())

                //csrf 비활성화
                .csrf(csrf -&gt; csrf.disable())

                // form 로그인 비활성화
                .formLogin(formLogin -&gt; formLogin.disable())

                //인증 권한 설정
                .authorizeHttpRequests(auth -&gt; auth
                        .requestMatchers(&quot;/&quot;, &quot;/login/**&quot;, &quot;/oauth2/**&quot;).permitAll()
                        .requestMatchers(&quot;/api/auth/logout&quot;, &quot;/api/auth/reissue&quot;).permitAll()
                        .requestMatchers(&quot;/api/member&quot;).authenticated()
                        .anyRequest().authenticated()
                )

                // Oauthlogin 설정
                .oauth2Login(oauth2 -&gt; oauth2
                        .userInfoEndpoint(userInfo -&gt; userInfo
                                .userService(customOAuth2UserService)
                        )
                        //꼭 추가를 해야지 작동한다!!!!
                        //로그인이 성공 했을 때 작동하는 핸들러
                        .successHandler(oAuth2SuccessHandler)
                )


                .build();
    }
}</code></pre>
<p>마지막으로 Security Config에 .successHandler(oAuth2SuccessHandler)를 추가 해주면 된다.</p>
<p>이렇게 하면 SuccessHandler에 대한 설정은 끝난다.
그러면 다음에는 jwt 필터를 만들어 보자</p>
<h3 id="궁금증-정리">궁금증 정리</h3>
<pre><code>1. 어차피 디코딩 할건데 Byte 배열로 그냥 오리진 시크릿키를 입력을 하면 되는 것 아닌가? 
 ㄴ byte 배열은 이진 데이터이기 때문에 관리 및 저장을 하기가 어렵다. 그렇기에 base64로 만든 난수를 사용을 한다.

2. 시크릿 키를 생성 할때 hs256이란 서명 알고리즘만 사용 할 수 있는가?
 ㄴ 아니다. 서명 알고리즘은 대칭키와 비대칭키,  타원 곡선 기반 비대칭키가  2가지 방식이 있으며
    대칭 키에서는 HMAC 기반의 hs256, hs384, hs512 가 있으며 
    비대칭 키에는 RSA 기반의 rs256, rs 384, rs512가 있다. 또한 ECDSA 기반의 타원 곡선 기반 서명도 있으나 이는 여기서 설명하지 않겠다.

    대칭키의 장점은 속도가 빠르고 구현이 간단하며, 대칭 키 하나만 있으면 된다
    단점은 키 유출 시 보안이 위험하다. 

    비대칭 키 RSA의 장점은 발급자는 개인키로 서명, 검증자는 공개키로 검증을 하여 보안에 유리하며, 공개키만 배포하면 되므로 구조가 유연하다.
    단점은 HMAC보다 연산이 무겁다.
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Java]OAuth 2.0 구글 설정]]></title>
            <link>https://velog.io/@hwan_94/JavaOauth-2.0-%EA%B5%AC%EA%B8%80-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@hwan_94/JavaOauth-2.0-%EA%B5%AC%EA%B8%80-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 29 Apr 2025 10:48:00 GMT</pubDate>
            <description><![CDATA[<h2 id="사전-설명">사전 설명</h2>
<p>Oauth 2.0에 앞서서 우리는 시큐리티 필터체인의 구조를 볼 것이다.
<img src="https://velog.velcdn.com/images/hwan_94/post/c5fd7a0b-2b45-41a1-b04f-d83a4200c0fa/image.png" alt=""></p>
<p>시큐리티 필터 체인의 구조는 cors 설정, 및 csrf 설정, formlogin, 인증 권한설정, oauthlogin 설정, 추가 필터 설정 순으로 설정하며</p>
<p>필자는 이에 따라 총 4개의 글을 적을 것이다.
1편 OAuth 2.0 구글 설정 
2편 SuccessHandler
3편 JWT 필터
4편 액세스 토큰 갱신 및 프론트 api 연결
에 대해서 글을 적을 것이다.
이를 다 따라 했을 경우에는 기본적인 OauthLogin 및 jwt토큰 설정이 완료가 될 것이다.</p>
<h2 id="사전-설정">사전 설정</h2>
<p>공식 사이트 설정 방법</p>
<pre><code>https://developers.google.com/identity/protocols/oauth2/service-account?hl=ko#creatinganaccount</code></pre><p>위의 사이트를 들어가면 google에서 공식적으로 어떻게 설정하는지 설명이 되어 있다. 이가 어렵다면 아래와 같이 따라하자</p>
<p><a href="https://console.cloud.google.com/projectselector2/iam-admin/serviceaccounts?hl=ko&amp;supportedpurview=project&amp;allowsmanagementprojects=true">https://console.cloud.google.com/projectselector2/iam-admin/serviceaccounts?hl=ko&amp;supportedpurview=project&amp;allowsmanagementprojects=true</a>
먼저 위의 링크에 들어가서 프로젝트를 만들어준다.<img src="https://velog.velcdn.com/images/hwan_94/post/b0ba6824-ae3b-491a-acb7-5d43cfa8f91d/image.png" alt="">
프로젝트 만들기를 생성을 누른 후에 <img src="https://velog.velcdn.com/images/hwan_94/post/c4060b59-82cf-4699-b7ca-2289acf2cdd2/image.png" alt="">
원하는 프로젝트 이름을 생성한다.
<img src="https://velog.velcdn.com/images/hwan_94/post/7990eff6-255b-4f22-876f-b54338a5745a/image.png" alt="">
만들고 나면 아래와 같이 알림이 뜨는데 프로젝트 만들기를 누르면
<img src="https://velog.velcdn.com/images/hwan_94/post/468d5967-dbe0-4cd5-982a-400c27bc4eaf/image.png" alt="">
이러한 페이지로 오게 된다.
다 되었다면 중간에 검색으로 Oauth 동의 화면을 검색 하면 된다.
<img src="https://velog.velcdn.com/images/hwan_94/post/4953d403-bfae-4bc5-a164-b120d7953048/image.png" alt="">
<img src="https://velog.velcdn.com/images/hwan_94/post/a5317cd6-7195-492a-992e-9db80a2aaca1/image.png" alt="">
동의화면으로 오면 시작하기를 눌러주고</p>
<h3 id="프로젝트-구성">프로젝트 구성</h3>
<p>앱정보에서는 원하는 앱 이름, 이메일을 넣어주면 되고
대상은 외부 를 선택을 해주자,
연락처 정보는 자기가 연락 받고싶은 이메일을 넣으면 되고 
완료 해주고 만들면 된다.<img src="https://velog.velcdn.com/images/hwan_94/post/8136cb3c-4d08-4ecf-ae29-2edaebef13ed/image.png" alt=""></p>
<h3 id="oauth-개요">Oauth 개요</h3>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/84805c1f-1c20-4fa9-8f84-ce65a4c40cdb/image.png" alt="">
여기서 이제 클라이언트 만들기를 진행 하면 되는데
<img src="https://velog.velcdn.com/images/hwan_94/post/b86154a5-fcb2-410e-a863-c27446ebdf99/image.png" alt="">
위에서 우리는 웹을 설정 할거기에 웹, 이름은 자기가 원하는 프로젝트,
그다음에 승인된 JavaScript원본 이부분은 넘어가면되고
승인된 리디렉션 URI를 적어주는데 필자는 현재 도메인이 없이 그냥 할 것이기 때문에 <a href="http://localhost:8080/%EB%A5%BC">http://localhost:8080/를</a> 적어 주었고.
도메인이 있다면 도메인 주소로, 아니면 자기가 사용할 포트 주소로 적어주면 된다.</p>
<pre><code>login/oauth2/code/google</code></pre><p>그리고 생성 하면 되는데 <span style="color:red;">주의</span> 해야하는 것이 있다.
<span style="color:red;">절대로 클라이언트 ID랑 비밀번호는 유출이 되면 안된다.</span><img src="https://velog.velcdn.com/images/hwan_94/post/7f3057d3-ba62-43d7-9d55-63c0987866c2/image.png" alt=""></p>
<pre><code>클라이언트 ID는 공개돼도 비교적 위험이 적지만,클라이언트 Secret은 비밀번호처럼 취급해야 하며
→ 이게 유출되면 제3자가 당신의 앱을 가장해서 OAuth 인증 요청을 보낼 수 있다.
→ 심할 경우, 구글 API 사용량 초과, 요금 폭탄, 보안 사고가 발생할 수 있으니 꼭 주의하자.</code></pre><p>잊으면 안되니 json파일로 다운로드 해서 잘 보관 해두자.
<img src="https://velog.velcdn.com/images/hwan_94/post/c47e1bab-3a73-4f7d-9392-1e64056ad309/image.png" alt="">
설정이 잘되었다면 위의 사진과 같이 잘 설정이 되었을 것이다.
이렇게 까지 잘 따라 왔다면 사전 설정은 끝이다.</p>
<h2 id="java-적용법">Java 적용법</h2>
<h3 id="설명-전-확인-사항">설명 전 확인 사항</h3>
<p>필자는 일단 초심자가 뭐부터 만들어야 할지 이해가 힘들다는 점에 따라 먼저 구성하는 요소들 부터 만들었다.
OAuth2.0로그인의 흐름이 어떻게 작동하는지 확인하고 싶다면 아래의 SecurityConfig 부터 위로 올라오면서 보면 된다.</p>
<h3 id="yml-설정">yml 설정</h3>
<pre><code class="language-yml">    security:
      oauth2:
        client:
          registration:
            google:
              client-id: id키
              client-secret: secret키
              scope:
                - email
                - profile</code></pre>
<p>위에서 받은 id랑 secret 을 넣어준 상태로 위의 코드를 spring 의 하위 레벨에 넣어주면 된다.</p>
<h3 id="폴더-구성">폴더 구성</h3>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/f89ee9b7-69be-4b3b-8366-18ffc86be651/image.png" alt=""></p>
<p>폴더 구성은 이렇게 되어있으며 위와 디렉토리에 위의 클래스 들을 만들어 줄 것이다.</p>
<h3 id="provider-enum-클래스">provider Enum 클래스</h3>
<pre><code class="language-java">public enum Provider {
    GOOGLE
}</code></pre>
<p>처음에는 Enum클래스를 만들어 줄 것이다.
Provider이라는 것은 어떤 제공자의 Oauth2.0을 사용 했는지를 뜻하며
이는 엔티티의 관리를 위해서 member에 필드로 넣어주기 위해서 만들었다.
필자는 Google만 하려고 이리하였으나 GOOGLE,NAVER,KAKAO 등 더 넣어도 된다.</p>
<h3 id="role-enum-클래스">Role Enum 클래스</h3>
<pre><code class="language-java">public enum Role {
    MEMBER,ADMIN
}</code></pre>
<p>Role이라는 것은 멤버의 권한을 설정을 해주기 위해서 만들었다.</p>
<h3 id="엔티티-클래스-설정">엔티티 클래스 설정</h3>
<pre><code class="language-java">@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

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

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

    @Column(nullable = false, unique = true, length = 20)
    private String nickname;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private Provider provider;

    @Enumerated(EnumType.STRING)
    private Role role = Role.MEMBER;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @Builder
    public Member(String email, String nickName, Provider provider, LocalDateTime createdAt) {
        this.email = email;
        this.nickname = nickName;
        this.provider = provider;
        this.createdAt = createdAt;
    }

}
</code></pre>
<p>필자는 정말 기본적인 것들만 설정을 해줄 것이다.
주 키(id), email,nickName,provicer,createdAt만 설정해주고, role은 기본 가입시에 member로 초기값만 설정해준다.
Builder 패턴을 만들어준다.(나중에 Member 객체를 만들기 위해서 필요)</p>
<h3 id="repository">Repository</h3>
<pre><code class="language-java">public interface MemberRepository extends JpaRepository&lt;Member, Long&gt; {
    Optional&lt;Member&gt; findByEmail(String email);
}</code></pre>
<p>리포지토리는 너무 간단하다.
email을 받아서 Optional타입의 Member을 반환 받는다.
이는 나중에 로그인 후 이메일을 통해 가입되어 있는 Member이라면 찾은 member을 반환 받고 아니라면 새로운 Member객체를 만들어 주기 위해서 사용한다.</p>
<h3 id="customuserprincipal">CustomUserPrincipal</h3>
<pre><code class="language-java">@Getter
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
//사용자 정보를 조회하는 용도의 클래스
public class CustomUserPrincipal implements OAuth2User, UserDetails {

    @Setter
    private Long id;

    private String name;
    private String email;

    @Setter
    private Role role;

    @Setter
    private boolean isBlocked;

    private Map&lt;String, Object&gt; attributes;

    @Override
    public Map&lt;String, Object&gt; getAttributes() {
        return attributes;
    }

    @Override
    public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
        return List.of(new SimpleGrantedAuthority(&quot;ROLE_&quot; + role.name()));
    }

    @Override
    public String getPassword() {
        return &quot;&quot;;
    }

    @Override
    public String getUsername() {
        return name;
    }

    @Override
    public String getName() {
        return name;
    }


    public static CustomUserPrincipal from(Member member, Map&lt;String, Object&gt; attributes) {
        CustomUserPrincipal customUserPrincipal = new CustomUserPrincipal();
        customUserPrincipal.id = member.getId();
        customUserPrincipal.email = member.getEmail();
        customUserPrincipal.role = member.getRole();
        customUserPrincipal.name = member.getNickname();
        customUserPrincipal.attributes = attributes;
        return customUserPrincipal;

    }

}</code></pre>
<p>이 클래스는 OAuth2 로그인 후 전달받은 사용자 정보를 담고 있으며,
Spring Security의 OAuth2User 인터페이스를 구현하여 인증된 사용자의 정보를 표현하는 역할을 한다.
추후에 유저 정보도 가져와야 하기 때문에 UserDetail또한 구현 해야한다.
getPassword의 경우 Oauth로그인이기 때문에 필요없다.</p>
<h3 id="oauthattributes">OAuthAttributes</h3>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public class OAuthAttributes {
    private String name;
    private String email;
    private String provider;
    private String providerId;

    //registrationId가 google이라면 google에 맞는 dto를 생성한다.
    public static OAuthAttributes of(String registrationId, Map&lt;String, Object&gt; attributes) {
        if (registrationId.equals(&quot;google&quot;)) {
            return ofGoogle(attributes);
        }
        throw new IllegalArgumentException(&quot;Unknown provider: &quot; + registrationId);
    }

    // registrationId가 &quot;google&quot;인 경우 Google 사용자 정보를 처리하는 메서드
    // DefaultOAuth2UserService의 loadUser 메서드를 통해 가져온 OAuth2 사용자 정보를 
    // OAuthAttributes DTO로 매핑하여 저장하기 위해 사용된다.
    private static OAuthAttributes ofGoogle(Map&lt;String, Object&gt; attributes) {
        return new OAuthAttributes(
                (String) attributes.get(&quot;name&quot;),
                (String) attributes.get(&quot;email&quot;),
                &quot;GOOGLE&quot;,
                (String) attributes.get(&quot;sub&quot;)
        );
    }
}</code></pre>
<p>이 부분은 provider에 따라 DTO를 다르게 생성한다.<br>왜 이렇게 설계했냐면, Kakao, Naver, Google 등 각 사이트마다 사용자 정보를 제공하는 방식이 다르기 때문에,<br>각 provider에 맞는 별도의 처리가 필요하기 때문이다.</p>
<h3 id="customoauth2userservice">CustomOAuth2UserService</h3>
<pre><code class="language-java">
@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {

        // 제공자 정보가 들어 있다.
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // loadUser 에는 사용자 정보, 고유 식별자, 권한 정보를 포함하고 있다..
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 고유 id, 이름, 사진, 이메일, 지역 등에 대한 정보가 들어 있다.
        Map&lt;String, Object&gt; attributes = oAuth2User.getAttributes();

        // 제공자 정보, 및 id에 대한 정보를 dto로 변환한다.
        OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, attributes);

        // 기존 id가 없다면 새로운 Member등록을 하고 , 기존 id가 있다면 Email을 반환한다.
        Member member = saveOrUpdate(oAuthAttributes);

        return new CustomUserPrincipal(member, attributes);
    }

    // 기존 회원가입 정보가 없다면 가입을 시켜주고 있다면 기존의 Email을 사용하여 Member을 가져온다.
    private Member saveOrUpdate(OAuthAttributes attributes) {

        Member member = Member.builder()
                .email(attributes.getEmail())
                .nickname(attributes.getName())
                .provider(Provider.valueOf(attributes.getProvider()))
                .createdAt(LocalDateTime.now())
                .build();

        return memberRepository.findByEmail(attributes.getEmail())
                .orElseGet(() -&gt; memberRepository.save(member));
    }

}</code></pre>
<p>제공자 정보를 확인한 후, loadUser를 통해 사용자 정보를 가져온다.<br>그 다음, member 등록에 필요한 attributes만 추출하여 제공자 정보와 함께 DTO로 변환한다.<br>이렇게 변환된 정보를 기반으로 회원 가입 또는 로그인 처리를 진행한다.</p>
<h3 id="securityconfig">SecurityConfig</h3>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                // CSRF 토큰 검사를 비활성화한다.
                .csrf(csrf -&gt; csrf.disable())
                // index, 로그인 및 OAuth 관련 경로는 인증 없이 접근 가능하다.
                .authorizeHttpRequests(authorize -&gt; authorize
                        .requestMatchers(&quot;/&quot;, &quot;/login/**&quot;, &quot;/oauth2/**&quot;).permitAll()
                        .anyRequest().authenticated()
                )
                // OAuth2 로그인 시 사용자 정보를 customOAuth2UserService로 처리한다.
                .oauth2Login(oauth2 -&gt; oauth2
                        .userInfoEndpoint(userInfo -&gt; userInfo
                                .userService(customOAuth2UserService)
                        )
                )
                // 로그인 성공 시 무조건 index으로 리디렉션
                .defaultSuccessUrl(&quot;/&quot;) 
)
                .build();
    }
}</code></pre>
<p>가장 중요한 점은, SecurityConfig에서 .userService()에 OAuth2UserService 구현체를 등록하면
Spring Security가 OAuth2 로그인 처리 과정 중 자동으로 해당 구현체의 loadUser() 메서드를 실행한다는 것이다.</p>
<p>현재는 defaultSuccessUrl(&quot;/&quot;)을 사용하고 있지만, 다음 편에서는 SuccessHandler를 활용하여 로그인 성공 이후의 동작을 세밀하게 제어할 예정이다다.</p>
<h3 id="흐름-정리">흐름 정리</h3>
<p>OAuth2 로그인 시 내부 흐름은 다음과 같다:</p>
<pre><code>SecurityFilterChain -&gt; CustomOauthUserService -&gt; LoadUser 메서드 -&gt; saveOrUpdate메서드 -&gt; CustomUserPrincipal객체 생성(SecurityContext저장)</code></pre><p>이 설정을 통해 OAuth2.0 Google 로그인을 위한 기본적인 인증/인가 흐름이 구성된다.<br>이후에는 사용자의 정보를 세션 또는 토큰(JWT 등)으로 관리하거나, 프론트엔드와의 인증 상태 연동, 리디렉션 처리 등 후속 작업을 이어가면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] Aop 적용법]]></title>
            <link>https://velog.io/@hwan_94/Java-Aop-%EC%A0%81%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@hwan_94/Java-Aop-%EC%A0%81%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Wed, 23 Apr 2025 07:46:06 GMT</pubDate>
            <description><![CDATA[<h1 id="java-aop-적용법">Java Aop 적용법</h1>
<h2 id="1-anotation-생성">1. Anotation 생성</h2>
<pre><code class="language-java">@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CheckTeam {

}</code></pre>
<p>어노테이션을 생성하는 것은 간단하다.
다만 중요한게 Retention 과 Traget이다.</p>
<p><code>Retention(RetentionPolicy.RUNTIME)</code>
→ 어노테이션을 런타임까지 유지하도록 설정한다. 즉, 실행 중 리플렉션 등을 통해 어노테이션 정보를 읽을 수 있다.</p>
<p><code>Target(ElementType.METHOD)</code>
→ 이 어노테이션은 메서드에만 적용 가능하다는 뜻이다. 클래스, 필드 등 다른 요소엔 붙일 수 없다.</p>
<h2 id="2-aop의-기본-틀">2. Aop의 기본 틀</h2>
<pre><code class="language-java">
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class MyAspect {

    @Around(&quot;@annotation(내어노테이션)&quot;)
    public Object adviceMethod(ProceedingJoinPoint joinPoint, 내어노테이션 어노인스턴스) throws Throwable {

        // 1. 전처리 로직 (Before)
        Object result = joinPoint.proceed(); // 대상 메서드 실행

        // 2. 후처리 로직 (After)
        return result;
    }
}
</code></pre>
<p>기본 틀은 이렇다 그러면 어떠한 어노테이션이 적용 되었는지 보자.</p>
<p><code>Aspect</code>    이 클래스가 AOP 역할을 한다는 선언
<code>Component</code>    스프링 빈으로 등록
<code>Around</code>    대상 메서드 실행 전후로 로직 삽입
<code>annotation(적용할 어노테이션)</code> 어노테이션에서 적용</p>
<p>그리고 parameter을 좀 보면 <code>ProceedingJoinPoint</code> 라는 것이 있는데
이는 대상 메서드를 가로 챌 수 있게 한다고 생각하면된다.</p>
<p><code>joinPoint.proceed()</code>는 가로챈 원래 메서드를 실제로 실행한다.</p>
<p>이러한 형태가 기본적인 형태이며 <code>ProceedingJoinPoint</code>를 통해서 아래와 같은 행동을 할 수있다.</p>
<pre><code>`getArgs()`    : 메서드에 전달된 매개변수들 가져오기
`getSignature()` : 메서드 이름, 클래스, 리턴타입 등의 정보 가져오기
`proceed()`    : 실제로 그 메서드를 실행 시킴 (즉, 본래 로직으로 진입)</code></pre><p>그렇다면 이제는 예시를 보자.</p>
<h2 id="3-aop의-예시">3. Aop의 예시</h2>
<pre><code class="language-java">@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class TeamCheckAspect {

    //의존성 주입
    private final TeamService teamService;
    private final TeamMemberService teamMemberService;
    private final MemberService memberService;

    //어노테이션 설정
    @Around(&quot;@annotation(checkTeam)&quot;)
    public Object teamCheck(ProceedingJoinPoint joinPoint, CheckTeam checkTeam) throws Throwable {


        Long teamId = null;
        //joinPoint.getSignature()를 하면 메소드에 대한 정보를 가지고 있다.
        //MethodSignature로 캐스팅하면, 메서드 이름, 파라미터 정보 등을 더 쉽게 가져올 수 있다.
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

        //실제로 실행될 메서드 객체를 가져온다.
        //이 method 객체로부터 파라미터 정보를 가져올 수 있다.
        Method method = methodSignature.getMethod();

        // 메서드의 파라미터중 id값을 가져오기 위한 작업
        //getParameters()는 Parameter 객체 배열을 리턴하고,
        //getName()은 각 파라미터의 변수 이름(예: teamId)을 가져온다
        for (int i = 0; i &lt; method.getParameters().length; i++) {

            String parameterName = method.getParameters()[i].getName();

            //파라미터의 이름이 teamId이면 Teamid를 할당 해준다.
            if (parameterName.equals(&quot;teamId&quot;)) {
                teamId = (Long) joinPoint.getArgs()[i];
                break;
            }

        }

        // team id가 없다면 예외 처리
        if (teamId == null) {
            throw new KanbanBoardTeamIdNotProvidedException(ExceptionMessage.Kanbanboard.KANBANBOARD_TEAM_ID_NOT_PROVIDED);
        }

        //Security Util을 이용해서 멤버의 id를 가져온다.
        Long currentMemberId = SecurityUtil.getCurrentMemberId();

        //Member을 조회한다.
        Member member = memberService.getByMemberId(currentMemberId);

        //Team을 조회한다.
        Team team = teamService.getTeamById(teamId);
        //Team Member을 조회한다. 아직 가입이 안된지 확인을 위함
        TeamMember teamMember = teamMemberService.getTeamMemberByMemberAndTeam(member, team);
        //team에 가입되어 있지 않다면 기존 메서드를 실행한다.
        if (teamMember != null) {
            return joinPoint.proceed();
        }

        //아니면 예외 처리
        throw new SecurityException(&quot;접근 권한이 없습니다. &quot; + &quot; 팀에 소속되지 않았습니다.&quot;);

    }

}

</code></pre>
<p>필자는 위의 코드와 같이 member을 Team에 초대하는데 기존 teamMember로 가입이 되어 있지 않다면 예외 처리를 하고 가입이 되어있다면 기존 메서드를 실행 할 수 있게 하였다.</p>
<h2 id="4-왜-aop를-사용하는가">4. 왜 aop를 사용하는가?</h2>
<ol>
<li>공통된 로직을 중복 없이 관리하기 위해</li>
<li>핵심 비즈니스 로직과 분리</li>
<li>유지보수와 확장성 향상</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js, Java] API 비동기 통신 방법 (fetch / FormData / JSON 응답 정리)]]></title>
            <link>https://velog.io/@hwan_94/Next.js-Java-api-%ED%86%B5%EC%8B%A0-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@hwan_94/Next.js-Java-api-%ED%86%B5%EC%8B%A0-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 23 Apr 2025 06:57:30 GMT</pubDate>
            <description><![CDATA[<h1 id="api-비동기-통신">api 비동기 통신</h1>
<h2 id="1-기본-형태">1. 기본 형태</h2>
<pre><code class="language-js">export const postRequest = async (formData) =&gt; {
    const response = await fetch(&quot;백엔드 컨트롤러 주소&quot;,{
        method: &quot;POST&quot;, // HTTP POST 방식으로 요청
        body: formData,    // formData 객체를 요청 본문으로 전달
    })

    if (!response.ok) {
        throw new Error(&quot;post요청 실패&quot;)
    }

    return await response.json() // 응답 데이터를 JSON 형태로 반환
}</code></pre>
<p>여기서 <code>response.json()</code>은 백엔드에서 작성된 DTO 객체를 JSON으로 직렬화한 결과를 받아오는 것이다.</p>
<h2 id="2-nextjs-서비스-예시">2. next.js 서비스 예시</h2>
<pre><code class="language-js">export const createTradePost = async (formData) =&gt; {
    const response = await fetch(&quot;http://localhost:8080/api/Trade/createPost&quot;,{
        method: &quot;POST&quot;,
        body: formData,
    })

    if (!response.ok) {
        throw new Error(&quot;게시글 작성 실패&quot;)
    }

    return await response.json()
}</code></pre>
<p>백엔드가 <code>ResponseEntity&lt;TradePostMessageResponse&gt;</code>로 반환하면, 그 구조가 프론트에서 그대로 JSON으로 들어온다.
예시로 필자가 직접 적용한 부분을 보자</p>
<h2 id="3-java-controller-예시">3. Java Controller 예시</h2>
<pre><code class="language-java">    @PostMapping(path = &quot;/createPost&quot;, consumes = &quot;multipart/form-data&quot;)
    public ResponseEntity&lt;TradePostMessageResponse&gt; createTradePost(@RequestPart(&quot;data&quot;) CreateTradeBoardRequest request, @RequestPart(&quot;images&quot;) List&lt;MultipartFile&gt; images) throws IOException {

        TradePostMessageResponse result = tradePostService.createTradePost(request, images);

        return ResponseEntity.ok(result);

    }</code></pre>
<ul>
<li><code>@RequestPart(&quot;data&quot;)</code>는 FormData의 <code>data</code> 키에 해당하는 JSON 문자열을 DTO로 변환해서 받는 어노테이션이다.</li>
<li><code>@RequestPart(&quot;images&quot;)</code>는 FormData로 업로드된 파일 리스트를 MultipartFile로 매핑한다.
위의 코드에서 result부분을 ResposeEntity의 ok에 넣어서 보내준다.
그렇다면 이 result는 뭐가 들어있을까?</li>
</ul>
<h2 id="-java-service-예시">. Java Service 예시</h2>
<pre><code class="language-java">    @Override
    public TradePostMessageResponse createTradePost(CreateTradeBoardRequest request, List&lt;MultipartFile&gt; sourceImage) throws IOException {

        TradePost tradePost = saveTradePost(request);

        log.info(&quot;tradePost = {}&quot;, tradePost);

        // 컨버트한 바이트 배열 리스트.
        List&lt;byte[]&gt; files = productImageConvertService.convert(sourceImage);


        for (byte[] fileData : files) {

            //유니크 파일 경로 만들기
            String fileName = UUID.randomUUID().toString();

            //s3 업로드
            String imageUrl = s3StorageService.upload(fileData,fileName);
            System.out.println(imageUrl);

            // product image 객체 생성
            ProductImage productImage = productImageService.createTradeProductImage(tradePost.getPostId(), imageUrl);

        }

        return TradePostMessageResponse.builder()
                .message(&quot;Success Created Trade Post&quot;)
                .result(true)
                .build();


    }</code></pre>
<p>중간의 코드는 신경 안써도 된다.
그냥 return 부분만 보면 되는데
Java에서 <code>TradePostMessageResponse</code>는 단순한 DTO이지만,
Spring Boot에서는 이를 자동으로 JSON으로 직렬화하여 응답 본문으로 전송한다.
그러면 이러한 결과물이 json으로 어떻게 들어갈까?</p>
<h2 id="4-nextjs-반환-되는-값">4. Next.js 반환 되는 값</h2>
<pre><code class="language-json">{
  &quot;message&quot;: &quot;Success Created Trade Post&quot;,
  &quot;result&quot;: true
}</code></pre>
<p>이러한 값으로 반환이 된다.</p>
<h2 id="5참고용-api">5.참고용 api</h2>
<pre><code class="language-js">export const createTradePost = async (formData) =&gt; {
    const response = await fetch(&quot;http://localhost:8080/api/Trade/createPost&quot;,{
        method: &quot;POST&quot;,
        body: formData,
    })

    console.log(&quot;fetch&quot;,response)

    if (!response.ok) {
        throw new Error(&quot;게시글 작성 실패&quot;)
    }

    return response.json()
}

export const getTop10Post = async () =&gt; {
    const response = await fetch(&quot;http://localhost:8080/api/Trade/Top10Post&quot;,{
        method: &quot;GET&quot;,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
    })

    console.log(&quot;fetch&quot;,response)

    return response.json()
}

export const getAllPost = async (page, sort) =&gt; {
    const response = await fetch(`http://localhost:8080/api/Trade/readAllPost?page=${page}&amp;sort=${sort}`, {
        method: &quot;GET&quot;,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
    });

    console.log(&quot;📡 응답 상태코드:&quot;, response.status);

    if (!response.ok) {
        throw new Error(&quot;모든 게시글 조회 실패&quot;);
    }

    return response.json();
};

export const getPostInfoAndImages = async (postId) =&gt; {
    const response = await fetch(`http://localhost:8080/api/Trade/readPost/${postId}`, {
        method: &quot;GET&quot;,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
    });

    if (!response.ok) {
        throw new Error(&quot;게시글 조회 실패&quot;);
    }

    return response.json();
};

export const bumpPost = async (postId) =&gt; {
    const response = await fetch(`http://localhost:8080/api/Trade/bumpPost/${postId}`, {
        method: &quot;PUT&quot; ,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
    });

    if (!response.ok) {
        throw new Error(&quot;끌어올리기 실패&quot;);
    }

    return response.json();

}

export const deletePost = async (postId) =&gt; {
    const response = await fetch(`http://localhost:8080/api/Trade/deletePost/${postId}`, {
        method: &quot;DELETE&quot;,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;
        },
    });

    if (!response.ok) {
        throw new Error(&quot;게시글 삭제 실패&quot;);
    }

    return response.json();
}

export const updateOnlyStatusTradePost = async (postId, status) =&gt; {
    const response = await fetch(`http://localhost:8080/api/Trade/updatePostStatus/${postId}`, {
        method: &quot;PUT&quot;,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
        body: JSON.stringify({ status })
    })

    if (!response.ok) {
        throw new Error(&quot;게시글 상태 변경 실패&quot;);
    }

    return response.json();
}

export const updateTradePost = async (postId, formData) =&gt; {

    const response = await fetch(`http://localhost:8080/api/Trade/updatePost/${postId}`, {
        method: &quot;PUT&quot;,

        body: formData 
    });

    if (!response.ok) {
        throw new Error(`수정 실패: ${response.status}`);
    }

    return response.json();
};
</code></pre>
<p>반환되는 JSON에는 단순한 메시지뿐만 아니라 원하는 DTO 객체를 포함할 수 있다.<br>또한 JSON 형식으로 데이터를 주고받고 싶다면, <code>headers</code>에 아래와 같이 명시해주는 것이 좋다</p>
<pre><code class="language-js">headers: {
  &quot;Content-Type&quot;: &quot;application/json
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] s3 사용법]]></title>
            <link>https://velog.io/@hwan_94/Java-s3-%EC%82%AC%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@hwan_94/Java-s3-%EC%82%AC%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Wed, 23 Apr 2025 04:54:10 GMT</pubDate>
            <description><![CDATA[<h1 id="--사전-설정">- 사전 설정</h1>
<p>사전 설정은 버킷을 만들고 사용자 생성 및 액세스 시크릿 키 발급을 해주면 된다.</p>
<h3 id="버킷-이란">버킷 이란?</h3>
<p>이미지를 저장하는 폴더라고 생각하면 된다.</p>
<h2 id="버킷-만들기">버킷 만들기</h2>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/s3/get-started?region=ap-northeast-2">https://ap-northeast-2.console.aws.amazon.com/s3/get-started?region=ap-northeast-2</a>
위의 s3 사이트에 들어간후<img src="https://velog.velcdn.com/images/hwan_94/post/493347a0-cea2-4fd8-9ea1-28625387c418/image.png" alt="">
버킷 만들기를 클릭 한다.</p>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/08e2d9b7-9dca-4fc1-b017-a8dab60e14be/image.png" alt="">
버킷 생성을 했다면
버킷 이름을 정해주고
모든 퍼블랙 엑세스 차단을 해제 해준다.
그 후 아래의 알림을 체크 해주면 된다.
<img src="https://velog.velcdn.com/images/hwan_94/post/04be6e1a-7a7a-44de-8c9b-4548f86f6633/image.png" alt="">
버킷 버전 관리를 활성화 하면 좋지만 좀 더 추가 된다.
그리하여 비활성화를 해줄 것이다.
나머지는 다 기본 설정대로 그냥 생성 해주면 된다.
<img src="https://velog.velcdn.com/images/hwan_94/post/e6a43986-e292-42a3-a77d-1a12d692dd2f/image.png" alt="">
버킷이 생선되면 다시 버킷 이름을 클릭한다.
<img src="https://velog.velcdn.com/images/hwan_94/post/47d0afe2-4192-4005-a356-c6a964c8d8c7/image.png" alt="">
편집을 누른후 아래의 코드를 복사해서 넣어준다.(이미지 url로 들어갔는지 확인 해주기 위함)</p>
<pre><code>{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;AllowPublicReadAccess&quot;,
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: &quot;*&quot;,
      &quot;Action&quot;: &quot;s3:GetObject&quot;,
      &quot;Resource&quot;: &quot;arn:aws:s3:::mungpedia/*&quot;
    }
  ]
}</code></pre><p><img src="https://velog.velcdn.com/images/hwan_94/post/3c279d5b-fa35-4dbc-8670-097946f4eb37/image.png" alt="">
후에 저장을 눌러주면 끝난다.
<img src="https://velog.velcdn.com/images/hwan_94/post/82d36eae-7f20-4748-b1d8-a3ff451aacad/image.png" alt="">
만약 이 버킷 정책을 추가를 안해주면 url로 접근 했을시에 위와 같은 창이 뜨고 사진이 보이지 않는다.</p>
<h3 id="iam-설정-사용자-등록">iam 설정 (사용자 등록)</h3>
<p><a href="https://us-east-1.console.aws.amazon.com/iam/home?region=ap-northeast-2#/home">https://us-east-1.console.aws.amazon.com/iam/home?region=ap-northeast-2#/home</a>
이제는 사용자 등록을 해줘야 한다.
위의 링크로 IAM 설정으로 가도 되고 aws에서 검색으로 IAM으로 가도 된다.
<img src="https://velog.velcdn.com/images/hwan_94/post/15647a6f-3348-4e59-891e-24b33cf9681b/image.png" alt="">
처음 들어오면 위와 같은 대쉬보드가 나온다
왼쪽의 사용자를 클릭 하자
<img src="https://velog.velcdn.com/images/hwan_94/post/1dccf63c-6f37-4592-bf39-387ffcdbbf1c/image.png" alt="">
그다음 사용자 생성을 해준다.
<img src="https://velog.velcdn.com/images/hwan_94/post/ec1df198-43ba-4cd5-8653-4399639260d2/image.png" alt="">
이름만 설정해주고 바로 다음으로 넘어가자
<img src="https://velog.velcdn.com/images/hwan_94/post/f6447900-10e7-45a7-a97e-6eff1167247c/image.png" alt="">
권한 옵션에서 직접 정책 연결을 눌러주고
아래의 권한 정책에서 s3FullAccess를 찾아 체크 해준 후 다음으로 넘어가면 된다.
<img src="https://velog.velcdn.com/images/hwan_94/post/26b9e5f9-82d4-414c-9d3b-eb8add6e52fd/image.png" alt="">
그후 사용자 생성을 해준다.</p>
<h3 id="액세스-키-및-시크릿-키-발급">액세스 키 및 시크릿 키 발급</h3>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/88333c21-12ac-4eae-9bc6-bf55e5980718/image.png" alt=""></p>
<p>이제는 사용자가 만들어 졌다 
사용자 이름를 눌러서 들어가자
<img src="https://velog.velcdn.com/images/hwan_94/post/9ad977b6-217c-43dd-9908-07971393a440/image.png" alt="">
그다음 액세스 키 만들기를 눌러준다
<img src="https://velog.velcdn.com/images/hwan_94/post/3613f523-3990-4f16-8729-3a8a81585474/image.png" alt="">
AWS 컴퓨팅 서비스에서 실행되는 애플리케이션 과 확인 을 클릭 해준 후 다음으로 넘어간다.
<img src="https://velog.velcdn.com/images/hwan_94/post/fe9ba662-ce55-4cc5-82df-c3495a1dc2b1/image.png" alt="">
설명 태그 값은 굳이 안해도 된다. 
<img src="https://velog.velcdn.com/images/hwan_94/post/b8c9d76b-224f-4b41-a585-e4f19c9ab7cf/image.png" alt="">
이제 액세스 키랑 시크릿 키를 발급 받았다 
이는 잃어버리거나 노출되면 안되니 주의하자
.CSV 파일을 다운로드 하면 액세스키와 시크릿키를 저장 할 수 있다.</p>
<h1 id="--자바-적용법">- 자바 적용법</h1>
<h2 id="1-gradle-설정">1. gradle 설정</h2>
<pre><code class="language-java">
dependencies {

     //  실제 AWS S3 기능 제공 (AWS SDK v2 기반)
    // - 파일 업로드, 다운로드, 삭제 등의 S3 기능을 수행하는 핵심 API들이 포함되어 있음
    // - 예: S3Client, PutObjectRequest, DeleteObjectRequest, RequestBody 등
    implementation &#39;software.amazon.awssdk:s3&#39;

    //  Spring Cloud AWS 3.x (Spring Boot 3.x 지원)
    // - Spring 환경에서 AWS SDK를 쉽게 사용할 수 있도록 도와주는 설정 지원 모듈
    // - application.yml을 통해 region, bucket 설정 가능
    // - 내부적으로 AWS SDK v2의 BOM을 포함하므로, 위 S3 의존성의 버전을 생략해도 됨
    implementation &#39;io.awspring.cloud:spring-cloud-aws-s3:3.0.2&#39;


}</code></pre>
<h2 id="2-yml-설정">2. yml 설정</h2>
<pre><code class="language-yml">  cloud:
    aws:
      credentials:
        accessKey: &quot;발급받은 accessKey&quot;
        secretKey: &quot;발급받은 secretKey&quot;
      s3:
        bucket: 버킷 이름
      region:
        static: ap-northeast-2</code></pre>
<p>사용자 등록 후 발급 받은 액세스키와 시크릿키를 등록 해주고
버킷 이름을 적어준다.(큰따음표 필요 없음)</p>
<p>yml은 spring의 하위 레벨로 만들어주면 된다.            </p>
<h2 id="3-s3config-설정">3. S3Config 설정</h2>
<pre><code class="language-java">@Configuration
public class S3Config {

    //지역 정보 yml에 있는 정보를 가져왔다.
    @Value(&quot;${spring.cloud.aws.region.static}&quot;)
    private String region;

    //IAM에서 만든 AccessKey
    @Value(&quot;${spring.cloud.aws.credentials.accessKey}&quot;)
    private String accessKey;

    //IAM에서 만든 SecretKey
    @Value(&quot;${spring.cloud.aws.credentials.secretKey&quot;)
    private String secretKey;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                // 지역 설정
                .region(Region.of(region))
                // 자격 증명자 설정
                .credentialsProvider(StaticCredentialsProvider.create(
                        // accessKey와 secretKey로 자격 증명 생성
                        AwsBasicCredentials.create(accessKey,secretKey)
                ))
                .build();
    }
}</code></pre>
<p>S3를 사용하기 위해서는 S3Client 인스턴스를 생성해야 한다.
이를 위해 S3Config 클래스에서 AWS 리전, 자격 증명 정보를 설정하고 @Bean으로 등록해 스프링 컨테이너에서 사용할 수 있도록 해준다.</p>
<h2 id="3-s3-서비스-구현">3. s3 서비스 구현</h2>
<pre><code class="language-java">
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class S3StorageServiceImpl implements S3StorageService {

    //yml 에서 작성한 static을 가져왔다.
    @Value(&quot;${spring.cloud.aws.region.static}&quot;)
    private String region;


    //yml에서 사용한 버킷을 가져왔다.
    @Value(&quot;${spring.cloud.aws.s3.bucket}&quot;)
    private String bucketName;


    //S3Client를 주입 받는다 
    //이는 우리가 만든 클래스가 아니고 gradle을 추가하면 자동적으로 만들어지는 클래스이다.
    private final S3Client s3Client;


    //필자는 서비스를 구현을 하여 사용하기에 override를 썼으나 단일 클래스로 사용시 @Override는 생략 가능하다.
    @Override
    public String upload(byte[] file, String fileName) throws IOException {

        // s3에 실직적으로 넣어 주는 부분이다.
        // 버킷 이름, 적용할 파일이름, contentType를 PutObjectRequest타입으로 만들어 
        //s3파일을 업로드 할 때 어디에,어떻게 업로드 할 지 설정하는 요청을 만든다.
        // RequestBody.fromBytes(file))에 실제로 얻르도 할 데이터를 넣어준다.
        s3Client.putObject(
                PutObjectRequest.builder()
                        .bucket(bucketName)
                        .key(fileName)
                        .contentType(&quot;image/jpeg&quot;)
                        .build(),
                RequestBody.fromBytes(file));

        //업로드가 완료된 파일의 S3 URL을 반환 해준다.
        return &quot;https://&quot; + bucketName + &quot;.s3.&quot; + region + &quot;.amazonaws.com/&quot; + fileName;
    }

    @Override
    public void delete(String fileName) {

        // s3의 파일을 지우는 요청을 만든다.
        s3Client.deleteObject(
                DeleteObjectRequest.builder()
                        .bucket(bucketName)
                        .key(fileName)
                        .build()
        );
    }

}

</code></pre>
<p>위 코드는 필자가 실제로 작성한 예제이며, S3StorageService 인터페이스를 구현한 S3StorageServiceImpl 클래스를 기반으로 구성되었으나  반드시 이렇게 인터페이스를 구현해야 하는 것은 아니며, 단일 서비스 클래스로 작성해도 무방하다.</p>
<h2 id="4-post-서비스-구현">4. post 서비스 구현</h2>
<pre><code class="language-java">   @Override
    public TradePostMessageResponse createTradePost(CreateTradeBoardRequest request, List&lt;MultipartFile&gt; sourceImage) throws IOException {

        //필자는 거래글을 만들고 그리고 사진을 추가 하니 무시해도 된다.
        TradePost tradePost = saveTradePost(request);


        //사진은 MultipartFile타입으로 받는다.
        //MulipartFile을 우리가 s3에 업로드를 하려면 바이트 배열로 컨버트를 해줘야한다.
        List&lt;byte[]&gt; files = productImageConvertService.convert(sourceImage);

        //필자는 List로 파일받기에 for each문으로 작업 했다.
        for (byte[] fileData : files) {

            //유니크 파일 경로 만들기
            String fileName = UUID.randomUUID().toString();

            //s3 업로드 후 이미지 url을 반환 받는다.
            String imageUrl = s3StorageService.upload(fileData,fileName);


            // product image 객체 생성
            // 이부분은 필자는 productImage라는 엔티티를 만들고 postId 와 imageUrl을 보관 할 것이기 때문에 이리 했으나
            // 굳이 이렇게 하지는 않아도 된다.
            ProductImage productImage = productImageService.createTradeProductImage(tradePost.getPostId(), imageUrl);

        }

        return TradePostMessageResponse.builder()
                .message(&quot;Success Created Trade Post&quot;)
                .result(true)
                .build();


    }</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] smtp 사용법 (g-mail)]]></title>
            <link>https://velog.io/@hwan_94/%EC%9E%90%EB%B0%94-smtp-%EC%82%AC%EC%9A%A9%EB%B2%95-g-mail</link>
            <guid>https://velog.io/@hwan_94/%EC%9E%90%EB%B0%94-smtp-%EC%82%AC%EC%9A%A9%EB%B2%95-g-mail</guid>
            <pubDate>Sun, 13 Apr 2025 19:33:23 GMT</pubDate>
            <description><![CDATA[<h1 id="--사전-설정">- 사전 설정</h1>
<p>일어나면 작성 할 예정이다.</p>
<h1 id="--자바-적용법">- 자바 적용법</h1>
<h2 id="1-gradle-설정">1. gradle 설정</h2>
<p><img src="https://velog.velcdn.com/images/hwan_94/post/c2de0b0f-4738-4c15-8163-59b6a8eff71f/image.png" alt=""></p>
<pre><code> implementation &#39;org.springframework.boot:spring-boot-starter-mail&#39;
</code></pre><p>스타터 편집으로 사진과 같이 Java mail Sender을 추가 해주거나 
위의 코드를 그래들에 직접 적어 주는 방식으로
Java Mail Sender을 추가를 해준다.</p>
<h2 id="2-yml-설정">2. yml 설정</h2>
<pre><code class="language-yml">mail:
    host: smtp.gmail.com
    port: 587
    username: // 이메일
    password: //비밀번호
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true</code></pre>
<p>yml은 spring의 하위 레벨로 만들어주면 된다.            </p>
<h2 id="3-서비스-구현">3. 서비스 구현</h2>
<pre><code class="language-java">@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SendEmailServiceImpl implements SendEmailService {

    //JavaMailSender 의존성을 추가해준다.
    private final JavaMailSender mailSender;
    //필자는 member을 조회해서 보내야 함으로 member서비스 또한 추가를 해줬다.
    private final MemberService memberService;

    // 이메일 발신자 주소를 yml에서 주입받았다. (설정만 바꿔서 관리 할 수 있다.)
    @Value(&quot;${custom.mail}&quot;)
    private String sender;

    // 이 부분은 팀에 가입하기를 눌렀을때 보내질 프론트엔드 페이지 주소이다.
      // SMTP로 HTML 형식의 메일 본문을 구성할 수 있다
    private String url = &quot;http://localhost:3000/accept-invite&quot;;


    @Override
    public InviteMemberResponse sendInviteEmail(long teamId, long memberId) {

        String inviter = memberService.getByMemberId(SecurityUtil.getCurrentMemberId()).getNickname();

        Member member = memberService.findById(memberId).orElseThrow();
        String receiverEmail = member.getEmail();

        // 이 부분에서 메일 메시지 객체를 생성한다.
        // 이후 MimeMessageHelper를 사용해 제목, 본문, 수신자 등의 내용을 설정할 수 있다.
        MimeMessage message = mailSender.createMimeMessage();


        try {
            // MimeMessageHelper 객체를 생성한다.
            MimeMessageHelper helper = new MimeMessageHelper(message, false, &quot;UTF-8&quot;);        
            //보내는 사람을 설정한다
            helper.setFrom(sender);
            //받는 사람을 설정한다.
            helper.setTo(receiverEmail);
            //메일의 제목을 설정 해준다
            helper.setSubject(&quot;[Accord] &quot; + inviter + &quot;님이 팀에 초대를 했습니다&quot;);
            //메일의 본문을 추가 해준다.
            //true를 넣어줘야지 html로 인식한다.
            helper.setText(&quot;&lt;h3&gt;안녕하세요!&lt;/h3&gt;&lt;p&gt;&quot;+inviter+&quot;님이 초대를 하셨습니다&lt;/p&gt;&quot; +
                            &quot;&lt;a href=&#39;&quot; + url + &quot;/&quot; + teamId  + &quot;&#39; &quot; +
                            &quot;style=&#39;display: inline-block; padding: 10px 20px; font-size: 16px; color: white; &quot; +
                            &quot;background-color: #0078D7; text-decoration: none; border-radius: 5px;&#39;&gt;팀에 가입하기&lt;/a&gt;&quot;,
                    true);

            // 위에서 만들어진 메세지를 보낸다.
            mailSender.send(message);

            return InviteMemberResponse.builder()
                    .message(&quot;Success Send Email&quot;)
                    .result(true)
                    .build();

        } catch (MessagingException e) {
            throw new EmailSendException(ExceptionMessage.EMAIL_SENDING_ERROR);
        }

    }
}</code></pre>
<p>위 코드는 필자가 실제로 작성한 예제이며, SendEmailService 인터페이스를 구현한 SendEmailServiceImpl 클래스를 기반으로 구성되었으나  반드시 이렇게 인터페이스를 구현해야 하는 것은 아니며, 단일 서비스 클래스로 작성해도 무방하다.</p>
<p>또한 필자는 InviteMemberResponse라는 dto를 통해서 builder패턴을 사용해 결과물을 보냈으나 굳이 이렇게 하지 않아도 된다.</p>
<p>혹시나 이것이 이해가 안되는 사람들은 더욱 간단하게 설명을 하자면 void로 반환을 해도 문제가 없다는 것이다.</p>
<pre><code class="language-java">
@Service
@RequiredArgsConstructor
public class SendEmailService {
    //JavaMailSender 의존성 주입
    private final JavaMailSender mailSender;
    //보내는 사람 이메일
    private String sender = &quot;sender@gmail.com&quot;;
    //받는 사람 이메일
    private String receiverEmail = &quot;receiver@gmail.com&quot;;


    public void sendInviteEmail() {

         try {
        // 이 부분에서 메일 메시지 객체를 생성한다.
        // 이후 MimeMessageHelper를 사용해 제목, 본문, 수신자 등의 내용을 설정할 수 있다.
        MimeMessage message = mailSender.createMimeMessage();

        // MimeMessageHelper 객체를 생성한다.
        MimeMessageHelper helper = new MimeMessageHelper(message, false, &quot;UTF-8&quot;);    
        //보내는 사람을 설정한다
        helper.setFrom(sender);
        //받는 사람을 설정한다.
        helper.setTo(receiverEmail);
        //메일의 제목을 설정 해준다
        helper.setSubject(&quot;메일 제목입니다.&quot;);
        //메일의 본문을 추가 해준다.
        //true를 넣어줘야지 html로 인식한다.
        helper.setText(&quot;&lt;h3&gt;안녕하세요!&lt;/h3&gt;&quot;, true);

        // 위에서 만들어진 메세지를 보낸다.
        mailSender.send(message);
        } catch (MessagingException e) {
            // 전송 중 예외 처리
            e.printStackTrace();
        }

     }    
}</code></pre>
<p>이렇게만 해도 문제가 없다.
<code>@Service</code>
<code>@RequiredArgsConstructor</code>
혹시나 위의 어노테이션을 모른다면 따로 Lombok 어노테이션을 공부를 하면 좋을 듯하다.</p>
<p>나머지는 컨트롤러만 만들어주면 끝이다. 이 부분은 따로 설명을 하지는 않겠다.</p>
]]></description>
        </item>
    </channel>
</rss>