<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>henhen</title>
    <link>https://henhen.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 11 May 2026 12:53:41 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>헨헨7</managingEditor>
    <item>
      <title>[프로그래머스/Python] 해시 - 폰켓몬, 완주하지 못한 선수, 전화번호 목록</title>
      <link>https://henhen.tistory.com/118</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;문제 풀이를 너무 안 했더니 유형이 기억이 잘 안 나서 프로그래머스 알고리즘 고득점 kit을 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 바퀴 돌고 백준으로 넘어가거나 기출 문제 중 랜덤 유형으로 풀려고 한다!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;폰켓몬&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vBKVI/btsLWQ5Jc7X/04LHD8duNkUFFapfwfpIOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vBKVI/btsLWQ5Jc7X/04LHD8duNkUFFapfwfpIOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vBKVI/btsLWQ5Jc7X/04LHD8duNkUFFapfwfpIOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvBKVI%2FbtsLWQ5Jc7X%2F04LHD8duNkUFFapfwfpIOk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;662&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;662&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;775&quot; data-origin-height=&quot;441&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgDtwV/btsLXtCb66k/OkDbKgPoHCLvEg5qCfH1q1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgDtwV/btsLXtCb66k/OkDbKgPoHCLvEg5qCfH1q1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgDtwV/btsLXtCb66k/OkDbKgPoHCLvEg5qCfH1q1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgDtwV%2FbtsLXtCb66k%2FOkDbKgPoHCLvEg5qCfH1q1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;775&quot; height=&quot;441&quot; data-origin-width=&quot;775&quot; data-origin-height=&quot;441&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 설명&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #263747; text-align: left;&quot;&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;당신은 폰켓몬을 잡기 위한 오랜 여행 끝에, 홍 박사님의 연구실에 도착했습니다. 홍 박사님은 당신에게 자신의 연구실에 있는 총 N 마리의 폰켓몬 중에서 N/2마리를 가져가도 좋다고 했습니다.&lt;br /&gt;홍 박사님 연구실의 폰켓몬은 종류에 따라 번호를 붙여 구분합니다. 따라서 같은 종류의 폰켓몬은 같은 번호를 가지고 있습니다. 예를 들어 연구실에 총 4마리의 폰켓몬이 있고, 각 폰켓몬의 종류 번호가 [3번, 1번, 2번, 3번]이라면 이는 3번 폰켓몬 두 마리, 1번 폰켓몬 한 마리, 2번 폰켓몬 한 마리가 있음을 나타냅니다. 이때, 4마리의 폰켓몬 중 2마리를 고르는 방법은 다음과 같이 6가지가 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;첫 번째(3번), 두 번째(1번) 폰켓몬을 선택&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;첫 번째(3번), 세 번째(2번) 폰켓몬을 선택&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;첫 번째(3번), 네 번째(3번) 폰켓몬을 선택&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;두 번째(1번), 세 번째(2번) 폰켓몬을 선택&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;두 번째(1번), 네 번째(3번) 폰켓몬을 선택&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;세 번째(2번), 네 번째(3번) 폰켓몬을 선택&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;이때, 첫 번째(3번) 폰켓몬과 네 번째(3번) 폰켓몬을 선택하는 방법은 한 종류(3번 폰켓몬 두 마리)의 폰켓몬만 가질 수 있지만, 다른 방법들은 모두 두 종류의 폰켓몬을 가질 수 있습니다. 따라서 위 예시에서 가질 수 있는 폰켓몬 종류 수의 최댓값은 2가 됩니다.&lt;br /&gt;당신은 최대한 다양한 종류의 폰켓몬을 가지길 원하기 때문에, 최대한 많은 종류의 폰켓몬을 포함해서 N/2마리를 선택하려 합니다. N마리 폰켓몬의 종류 번호가 담긴 배열 nums가 매개변수로 주어질 때, N/2마리의 폰켓몬을 선택하는 방법 중, 가장 많은 종류의 폰켓몬을 선택하는 방법을 찾아, 그때의 폰켓몬 종류 번호의 개수를 return 하도록 solution 함수를 완성해주세요.&lt;/p&gt;
제한사항
&lt;ul style=&quot;list-style-type: disc; color: #000000;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;nums는 폰켓몬의 종류 번호가 담긴 1차원 배열입니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;nums의 길이(N)는 1 이상 10,000 이하의 자연수이며, 항상 짝수로 주어집니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;폰켓몬의 종류 번호는 1 이상 200,000 이하의 자연수로 나타냅니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;가장 많은 종류의 폰켓몬을 선택하는 방법이 여러 가지인 경우에도, 선택할 수 있는 폰켓몬 종류 개수의 최댓값 하나만 return 하면 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;풀이&lt;/h3&gt;
&lt;pre id=&quot;code_1737611108222&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from collections import Counter
def solution(nums):
    answer = 0
    dict_nums = Counter(nums)
    li = list(dict_nums.keys())
    if len(li) &amp;gt;= len(nums) / 2:
        answer = len(nums) / 2
    else : 
        answer = len(li)
    return answer&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 해시 문제에서는 파이썬은 딕셔너리를 많이 사용하는 것 같다. 개인적으로 딕셔너리 문법은 해도해도 매번 까먹어서 최대한 리스트로 돌리려고 했는데, 오늘 좀 열심히 써서 꼭 기억해 가려고 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Counter 함수로 넘겨받은 폰켓몬의 수를 키에 따라 카운트 해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 전체의 절반까지 가져갈 수 있으므로 키의 수가 절반을 넘으면 절반을, 넘지 않으면 키의 수를 리턴한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;완주하지 못한 선수&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;743&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0JhGF/btsLYDRocTT/kgdMlxv44jOKkKhLLwr1q0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0JhGF/btsLYDRocTT/kgdMlxv44jOKkKhLLwr1q0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0JhGF/btsLYDRocTT/kgdMlxv44jOKkKhLLwr1q0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0JhGF%2FbtsLYDRocTT%2FkgdMlxv44jOKkKhLLwr1q0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;730&quot; height=&quot;743&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;743&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot; data-text-less=&quot;닫기&quot; data-text-more=&quot;더보기&quot; data-ke-type=&quot;moreLess&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 설명&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #263747; text-align: left;&quot;&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;수많은 마라톤 선수들이 마라톤에 참여하였습니다. 단 한 명의 선수를 제외하고는 모든 선수가 마라톤을 완주하였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;마라톤에 참여한 선수들의 이름이 담긴 배열 participant와 완주한 선수들의 이름이 담긴 배열 completion이 주어질 때, 완주하지 못한 선수의 이름을 return 하도록 solution 함수를 작성해주세요.&lt;/p&gt;
제한사항
&lt;ul style=&quot;list-style-type: disc; color: #000000;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;마라톤 경기에 참여한 선수의 수는 1명 이상 100,000명 이하입니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;completion의 길이는 participant의 길이보다 1 작습니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;참가자의 이름은 1개 이상 20개 이하의 알파벳 소문자로 이루어져 있습니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;참가자 중에는 동명이인이 있을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
입출력 예 설명
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;예제 #1&lt;br /&gt;&quot;leo&quot;는 참여자 명단에는 있지만, 완주자 명단에는 없기 때문에 완주하지 못했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;예제 #2&lt;br /&gt;&quot;vinko&quot;는 참여자 명단에는 있지만, 완주자 명단에는 없기 때문에 완주하지 못했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;예제 #3&lt;br /&gt;&quot;mislav&quot;는 참여자 명단에는 두 명이 있지만, 완주자 명단에는 한 명밖에 없기 때문에 한명은 완주하지 못했습니다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;풀이&lt;/h3&gt;
&lt;pre id=&quot;code_1737611370017&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from collections import Counter

def solution(participant, completion):
    answer = 0
    union_li = participant + completion
    union_dict = Counter(union_li)
    # print(union_dict)
    for key, value in union_dict.items():
        if value % 2 == 1:
            answer = key
    return answer&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. 완주하지 못한 사람은 단 한명이므로, 명단에는 반드시 짝수(참여자, 완주자)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 동명이인이 있는 경우에도 마찬가지&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;3. 참여자, 완주자 명단을 합쳐서, Counter 함수로 개수를 세어서 홀수 값이 있는 키값을 리턴&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;전화번호 목록&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;661&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0rC9E/btsLYNM8ywS/W72MpkXRzDrk36axFzru10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0rC9E/btsLYNM8ywS/W72MpkXRzDrk36axFzru10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0rC9E/btsLYNM8ywS/W72MpkXRzDrk36axFzru10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0rC9E%2FbtsLYNM8ywS%2FW72MpkXRzDrk36axFzru10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;739&quot; height=&quot;661&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;661&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot; data-text-less=&quot;닫기&quot; data-text-more=&quot;더보기&quot; data-ke-type=&quot;moreLess&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 설명&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #263747; text-align: left;&quot;&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;전화번호부에 적힌 전화번호 중, 한 번호가 다른 번호의 접두어인 경우가 있는지 확인하려 합니다.&lt;br /&gt;전화번호가 다음과 같을 경우, 구조대 전화번호는 영석이의 전화번호의 접두사입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;구조대 : 119&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;박준영 : 97 674 223&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;지영석 : 11 9552 4421&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000;&quot; data-ke-size=&quot;size16&quot;&gt;전화번호부에 적힌 전화번호를 담은 배열 phone_book 이 solution 함수의 매개변수로 주어질 때, 어떤 번호가 다른 번호의 접두어인 경우가 있으면 false를 그렇지 않으면 true를 return 하도록 solution 함수를 작성해주세요.&lt;/p&gt;
제한 사항
&lt;ul style=&quot;list-style-type: disc; color: #000000;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;phone_book의 길이는 1 이상 1,000,000 이하입니다.
&lt;ul style=&quot;list-style-type: disc; color: #000000;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;각 전화번호의 길이는 1 이상 20 이하입니다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: inherit; color: #000000;&quot;&gt;같은 전화번호가 중복해서 들어있지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;풀이&lt;/h3&gt;
&lt;pre id=&quot;code_1737611509891&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from collections import Counter
def solution(phone_book):
    phone_hash = {phone: True for phone in phone_book}
    # print(phone_hash)
    for phone in phone_book:
        for i in range(1, len(phone)):
            if phone[:i] in phone_hash:
                return False
    return True&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. phone_book에 있는 번호값을 키값, value를 True로 세팅하여 딕셔너리 생성&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. phone_book 내부의 전화번호를 돌면서, 해당 전화번호의 한 자리씩 떼어서 phone_hash 딕셔너리와 비교&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;3. 있으면 False 반환&lt;/p&gt;</description>
      <category>Solve</category>
      <category>Python</category>
      <category>딕셔너리</category>
      <category>완주하지 못한 선수</category>
      <category>전화번호 목록</category>
      <category>폰켓몬</category>
      <category>프로그래머스</category>
      <category>해시</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/118</guid>
      <comments>https://henhen.tistory.com/118#entry118comment</comments>
      <pubDate>Thu, 23 Jan 2025 14:54:38 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Spring Security JWT 구현 (2)</title>
      <link>https://henhen.tistory.com/117</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;darr; 이 글에서 이어집니다.&lt;/p&gt;
&lt;figure id=&quot;og_1733926198292&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Spring] Spring Security JWT 구현 (1)&quot; data-og-description=&quot;프로젝트 진행 중 필요했던 로그인 기능 구현을 위해 학습한 내용, 구현한 내용을 정리한 글입니다.   프로젝트 진행에 필요한 정도만 구현하였기 때문에 OAuth 소셜 로그인이나 요런 녀석들은 &quot; data-og-host=&quot;henhen.tistory.com&quot; data-og-source-url=&quot;https://henhen.tistory.com/114&quot; data-og-url=&quot;https://henhen.tistory.com/114&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bAihtI/hyXKqsWRPC/CKYBg8k1jpke3gnazXVe8K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bGJzvN/hyXKlLZT5V/Oe5pKwaGROVlorMbQYvTP1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://henhen.tistory.com/114&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://henhen.tistory.com/114&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bAihtI/hyXKqsWRPC/CKYBg8k1jpke3gnazXVe8K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bGJzvN/hyXKlLZT5V/Oe5pKwaGROVlorMbQYvTP1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Spring] Spring Security JWT 구현 (1)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 진행 중 필요했던 로그인 기능 구현을 위해 학습한 내용, 구현한 내용을 정리한 글입니다.   프로젝트 진행에 필요한 정도만 구현하였기 때문에 OAuth 소셜 로그인이나 요런 녀석들은&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;henhen.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 이전 글에서 구현 방식 개요와 토큰 저장소 관련 세팅, 토큰 관련 서비스를 생성했다. 이어서 회원 서비스에서 편리하게 사용하기 위하여 UserDetails, UserDetailsService를 구현하고, 세팅한 토큰과 서비스에 대해 커스텀 필터를 구현해 준 다음 SecurityConfig를 구성해보려고 한다. 분량이 된다면 회원 기능에서 인증과 연관이 있을 로직 처리까지 작성해 보겠습니다...&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현&lt;/b&gt;&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;5. UserDetails&lt;/h4&gt;
&lt;pre id=&quot;code_1733926632694&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class SecurityUserDetails implements UserDetails {
    private final AdminEntity admin;

    @Override
    public Collection&amp;lt;? extends GrantedAuthority&amp;gt; getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(&quot;ROLE_&quot; + admin.getRole().toString()));
    }

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

    @Override
    public String getUsername() {
        return admin.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return admin.getStatus();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return admin.getStatus();
    }

    public AdminEntity getAdmin() {
        return admin;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UserDetails를 사용할 때, 서비스에서 로그인 한 유저에 대해 접근하려고 하는 경우 기본으로 존재하는 메서드 뿐만 아니라 엔티티의 다른 필드에도 접근할 일이 종종 있어서, 엔티티 자체를 반환하는 메서드를 추가했다.&lt;/li&gt;
&lt;li&gt;getAuthorities()와 isEnabled()에서 현재 엔티티에 맞게 넣어줬다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UserDetailsService&lt;/h4&gt;
&lt;pre id=&quot;code_1733926862806&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class SecurityUserDetailsService implements UserDetailsService {
    private final AdminJpaRepository adminJpaRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AdminEntity admin = adminJpaRepository.findByUsername(username)
                .orElseThrow(() -&amp;gt; new CustomException(JwtMessageType.USER_NOT_FOUND));

        return new SecurityUserDetails(admin);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JwtAuthenticationFilter에서 UserDetails를 세팅해주기 위해 구현했다. 변수로 받은 아이디에 해당하는 유저가 존재하지 않는 경우, 커스텀 예외 처리를 해주었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;6. Filter&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtAuthenticationFilter&lt;/h4&gt;
&lt;pre id=&quot;code_1733927038190&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final SecurityUserDetailsService userDetailsService;

    @Value(&quot;${jwt.access-token-expiration}&quot;)
    private long accessTokenExpiration;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String accessToken = getToken(request, &quot;accessToken&quot;);
        String username = null;

        if (accessToken == null) {
            String refreshToken = getToken(request, &quot;refreshToken&quot;);
            if (refreshToken == null) {

                //swagger, auth 예외처리
                String path = request.getRequestURI();
                if (path.startsWith(&quot;/swagger-ui&quot;) || path.startsWith(&quot;/v3/api-docs&quot;) || path.equals(&quot;/api/v1/admins/auth&quot;)) {
                    filterChain.doFilter(request, response);
                    return;
                }

                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write(&quot;리프레시 토큰이 존재하지 않습니다.&quot;);
                return;
            }
            if (!jwtUtil.validateRefreshToken(refreshToken)) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write(&quot;리프레시 토큰이 유효하지 않습니다.&quot;);
            }
            // 액세스 토큰이 만료되어 재발급 받는 상태는 로그인 된 상태
            username = jwtUtil.getUsername(refreshToken);
            String userId = jwtUtil.getUserId(refreshToken);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            accessToken = jwtUtil.createAccessToken(username, userId, userDetails.getAuthorities().toString());
            ResponseCookie newAccessTokenCookie = ResponseCookie.from(&quot;accessToken&quot;, accessToken)
                    .httpOnly(true)
                    .secure(true)
                    .path(&quot;/&quot;)
                    .maxAge(accessTokenExpiration)
                    .build();
            response.addHeader(HttpHeaders.SET_COOKIE, newAccessTokenCookie.toString());
        } else {
            if (!jwtUtil.validateAccessToken(accessToken)) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write(&quot;액세스 토큰이 유효하지 않습니다.&quot;);
                return;
            }
            username = jwtUtil.getUsername(accessToken);
        }
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        if (!userDetails.isEnabled()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        SecurityContextHolder.getContext().setAuthentication(authentication);

        filterChain.doFilter(request, response);

    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.equals(&quot;/api/v1/admins/login&quot;);
    }

    private String getToken(HttpServletRequest request, String token) {
        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                if (token.equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;jwt 인증에 성공하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;SecurityContextHolder에 인증된 Authentication&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 set 해주고 필터를 넘기는 역할.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;나의 경우에는 토큰을 클라이언트의 쿠키에 저장하기 때문에 따로 Authorization 헤더에 Bearer 방식으로 토큰을 보낸다거나 하지는 않는 대신&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;, &lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;XSS 공격에 대한 방어를 위해 HTTP-Only 및 Secure 속성을 설정했다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;첫 로그인 시에는 토큰이 존재하지 않으므로 해당 로그인 api를 제출할 때에는 해당 필터를 실행하지 않았다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;브라우저 새로고침 시 로그인 유저 정보를 vuex에 업데이트 해주기 위해 인증을 확인하는 api를 app.vue 파일에 추가했는데, 해당 api의 경우 access token과 refresh token 둘 다 없는 경우 비로그인 상태(초기 화면)으로 판단하여 해당 필터를 넘겨주었다.&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;* 테스트를 위해 같은 경우에서 swagger uri도 넘겨줬다..&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;refresh token을 확인하여 access token을 재발급 받는 로직도 이 필터에서 수행하고, 쿠키에 새 jwt를 세팅한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;caret-color: #222222;&quot;&gt;마지막으로 jwt 인증한 사용자가 유효한 사용자인지 검증한 후, authentication 객체를 set 한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;CsrfTokenFilter&lt;/h4&gt;
&lt;pre id=&quot;code_1733928603027&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class CsrfTokenFilter extends OncePerRequestFilter {
    private final CsrfTokenUtil csrfTokenUtil;
    private final RedisTokenService redisTokenService;

    @Value(&quot;${server.servlet.session.timeout}&quot;)
    private long sessionTTL;

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

        if (request.getRequestURI().equals(&quot;/api/v1/admins/login&quot;)) {
            filterChain.doFilter(request, response);
            return;
        }

        if (request.getMethod().equalsIgnoreCase(&quot;POST&quot;) ||
                request.getMethod().equalsIgnoreCase(&quot;PUT&quot;) ||
                request.getMethod().equalsIgnoreCase(&quot;DELETE&quot;)) {

            String csrfToken = request.getHeader(&quot;X-CSRF-Token&quot;);

            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            String username = null;

            if (authentication != null &amp;amp;&amp;amp; authentication.isAuthenticated() &amp;amp;&amp;amp; !(authentication instanceof AnonymousAuthenticationToken)) {
                username = authentication.getName();
            }
            
            try {
                if (!csrfTokenUtil.validateToken(csrfToken, username, redisTokenService)) {
                    String newCsrfToken = csrfTokenUtil.generateToken();
                    response.setHeader(&quot;X-CSRF-Token&quot;, newCsrfToken);
                    log.info(&quot;CSRF 토큰이 갱신되었습니다: {}&quot;, newCsrfToken);
                    redisTokenService.saveCsrfToken(username, newCsrfToken, sessionTTL);
                }
            } catch (Exception e) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }

        filterChain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP-Only 옵션으로 XSS 공격을 방어하고, CSRF 공격 방지를 위해 CSRF 토큰을 도입했다. redis에서 토큰을 관리할 예정이라 기본 시큐리티 설정을 사용하지 않고 util과 필터를 따로 구현해줬다.&lt;/li&gt;
&lt;li&gt;클라이언트 측 코드 간편화 등 편의성을 위해 토큰을 쿠키에서 관리하는데, 매번 csrf 토큰을 redis에 접근해서 읽어오면 뭔가 좀 아깝다고 느껴져서(?) POST, PUT, DELETE api에서만 토큰을 읽도록 했다. 마찬가지로 로그인 api인 경우 필터를 넘겨줬다(왜 이건 ShouldNotFilter 메서드로 처리 안했지?)&lt;/li&gt;
&lt;li&gt;여차저차 구현하고 나서 문제가 발견됐는데, 재발급이 정말 신경써야할 게 많았던 것 같다. &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;사용성때문에 재발급되는 경우에는 추가 검증을 거치지 않고 필터를 통과하도록 했는데, 재발급하는 조건을 redis나 헤더에 하나라도 없으면 발급하게 구현해서 csrf 토큰이 헤더에 존재하지 않는 경우에 따로 인증 절차도 없고, security 오류를 반환하지 않고 갱신 후 필터를 통과시키는 거였다. 사실상 동작을 안했다........&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;결과적으로는 csrf 토큰을 없앴다. 보안을 위한 보안이라는 생각이 계속해서 들었고, rest api로 구현한다는 점, secure 설정을 한다는 점 등을 이유로...&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;7. SecurityConfig.java&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;여기부터는 차차 구현중이다... 아직 개발을 덜 해서 CORS 설정이나 Origins 설정을 덜 했다 ^_^..&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;우선 필터 순서만 맞춰준 정도..ㅎㅎ&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733931571187&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterAfter(csrfTokenFilter, JwtAuthenticationFilter.class);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Code</category>
      <category>JWT</category>
      <category>Spring Security</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/117</guid>
      <comments>https://henhen.tistory.com/117#entry117comment</comments>
      <pubDate>Thu, 12 Dec 2024 00:39:52 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Spring Security JWT 구현 (1)</title>
      <link>https://henhen.tistory.com/114</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 프로젝트 진행 중 필요했던 로그인 기능 구현을 위해 학습한 내용, 구현한 내용을 정리한 글입니다.   프로젝트 진행에 필요한 정도만 구현하였기 때문에 OAuth 소셜 로그인이나 요런 녀석들은 생략했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;JWT(&lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot;&gt;JSON Web Token)&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Header, Payload, Signature로 구성&lt;/li&gt;
&lt;li&gt;정보를 Base64 URL-safe Encode을 통해 인코딩해 직렬화&lt;/li&gt;
&lt;li&gt;API 요청 시 JWT를 전달하여 인증, 인가를 진행하는 토큰 인증 방식의 한 종류&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현 방식&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 고려 사항&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모바일로 접속하는 유저풀이 없으며, 비브라우저 환경을 고려하지 않음&lt;/li&gt;
&lt;li&gt;모든 회원은 관리자 레벨임&lt;/li&gt;
&lt;li&gt;Access Token, Refresh Token
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;값이 존재하지 않는 경우&lt;/li&gt;
&lt;li&gt;유효하지 않은 경우(사용자가 일치하지 않는 경우 등)&lt;/li&gt;
&lt;li&gt;유효시간이 만료된 경우&lt;/li&gt;
&lt;li&gt;Access Token이 만료되지 않았는데 Refresh Token을 통해 재발급된 경우&lt;/li&gt;
&lt;li&gt;로그아웃 처리를 완료했는데 JWT가 만료되지 않은 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;CSRF Token
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;값이 존재하지 않는 경우&lt;/li&gt;
&lt;li&gt;유효하지 않은 경우(다른 세션에서 사용하려고 하는 경우)&lt;/li&gt;
&lt;li&gt;유효시간이 만료된 경우&lt;/li&gt;
&lt;li&gt;유효시간이 만료되었는데 앞의 두 토큰이 탈취당한 채로 재발급된 경우&lt;br /&gt;-&amp;gt; 이건 CSRF Token의 보안 영역이 아니라고 해서 제외(&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;API &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;요청&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;위조&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;방지에&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt; 목적)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 구현 방식&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Access Token, Refresh Token의 저장 위치
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Local Storage나 Session에 저장: XSS에 대해 고민해야 함&lt;/li&gt;
&lt;li&gt;Header에 저장: 프론트에서 따로 처리해줘야 해서 귀찮다.. 이런 작업을 최소화하고 싶다. 웹소켓처럼 따로 헤더로 쏴줘서 인증해야 하는 다른 통신이 없어서 그냥 패스&lt;/li&gt;
&lt;li&gt;Cookie에 저장하는 것으로 결정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;HttpOnly, Secure 설정을 하고 쿠키에 저장해야 한다.&lt;/li&gt;
&lt;li&gt;Redis(In-Memory DB) 방식으로 토큰을 관리한다.&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;{토큰명}:username:{아이디}로 저장한다.&lt;/li&gt;
&lt;li&gt;Refresh token은 Redis에 바로 저장하되, Access token은 (나름) stateless한 관리를 위해 쿠키로 관리한다.&lt;/li&gt;
&lt;li&gt;각 토큰은 유효시간이 지나면 Redis에서 삭제하는 방식으로 관리한다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;두 토큰을 모두 쿠키에 전송하므로, CSRF 공격의 방지를 위해 CSRF 토큰 처리&lt;/li&gt;
&lt;li&gt;정상적인 로그아웃을 수행해 쿠키를 삭제했으나, JWT의 유효시간이 남은 경우&lt;br /&gt;-&amp;gt; 토큰의 탈취 가능성을 방지하기 위해 Redis에서 JWT에 대한 블랙리스트를 관리한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 프로젝트 환경 설정&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;application.yml&lt;/h4&gt;
&lt;pre id=&quot;code_1732975178203&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;jwt:
  secret: &quot;secret key&quot;
  access-token-expiration: 300
  refresh-token-expiration: 86400&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;secret는 임의의 난수를 생성해서 추가했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;build.gradle&lt;/h4&gt;
&lt;pre id=&quot;code_1732975316113&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    // redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    
    // security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    // jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;redis, security, jwt 관련 의존성 설정을 추가해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Entity, Repository 생성&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저에 대한 기본 엔티티와 레포지토리 생성(생략)&lt;/li&gt;
&lt;li&gt;본 글에서는 역할 기반으로 권한을 나눠줄 거라 Role에 대한 정보도 저장했다!&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;3. Redis 설정&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;3-0. RedisConfig.java&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;connection, template를 구현해줬다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;3-1. RedisService.java&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;레디스가 모든 토큰의 저장소가 될 것이기 때문에 토큰별로 redis를 거치는 서비스들은 전부 여기에서 관리하고, util에서는 호출만 하는 형식으로 분리하고자 했다... 그런데 비슷한 이름과 역할의 메서드가 util에서도 중복으로 생겨서 어떤 식으로 관리하는 게 효율적인지 모르겠다.&lt;/li&gt;
&lt;li&gt;redis에서는 {토큰명}:{log여부}:username:{username} 의 형식으로 키값을 저장했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1733582490837&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class RedisTokenService {

    @Value(&quot;${jwt.refresh-token-expiration}&quot;)
    private long refreshTokenExpiration;

    private final RedisTemplate&amp;lt;String, String&amp;gt; redisTemplate;

    // Refresh Token
    public void saveRefreshToken(String username, String refreshToken) {
        String key = &quot;refreshToken:username:&quot; + username;
        redisTemplate.opsForValue().set(key, refreshToken, refreshTokenExpiration, TimeUnit.SECONDS);
    }

    public void deleteRefreshToken(String username) {
        String key = &quot;refreshToken:username:&quot; + username;
        redisTemplate.delete(key);
    }

    public boolean validateRefreshToken(String username, String refreshToken) {
        String key = &quot;refreshToken:username:&quot; + username;
        String storedToken = redisTemplate.opsForValue().get(key);
        return storedToken != null &amp;amp;&amp;amp; storedToken.equals(refreshToken);
    }

    // Access Token
    public void addBlacklist(String token, long expirationTime) {
        String key = &quot;blacklist:&quot; + token;
        redisTemplate.opsForValue().set(key, &quot;blacklisted&quot;, expirationTime, TimeUnit.SECONDS);
    }

    public boolean isBlacklisted(String token) {
        String key = &quot;blacklist:&quot; + token;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    // CSRF Token
    public void saveCsrfToken(String username, String csrfToken, long csrfTokenExpiration) {
        String key = &quot;csrfToken:username:&quot; + username;
        String logKey = &quot;csrfToken:log:username:&quot; + username;
        redisTemplate.opsForValue().set(key, csrfToken, csrfTokenExpiration, TimeUnit.SECONDS);
        redisTemplate.opsForValue().set(logKey, csrfToken, refreshTokenExpiration, TimeUnit.SECONDS);
    }

    public String getCsrfToken(String username) {
        String key = &quot;csrfToken:username:&quot; + username;
        return redisTemplate.opsForValue().get(key);
    }

    public boolean validateCsrfToken(String username, String csrfToken) {
        String logKey = &quot;csrfToken:log:username:&quot; + username;
        String logToken = redisTemplate.opsForValue().get(logKey);
        if (logToken != null &amp;amp;&amp;amp; Objects.equals(logToken, csrfToken)) {
            redisTemplate.delete(logKey);
            return true;
        }
        return false;
    }

    public void deleteCsrfToken(String username) {
        String key = &quot;csrfToken:username:&quot; + username;
        String logKey = &quot;csrfToken:log:username:&quot; + username;
        redisTemplate.delete(key);
        redisTemplate.delete(logKey);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;4. TokenUtil&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtUtil.java&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AccessToken, RefreshToken에 대한 전반적인 로직(Create, Get, Delete, Validate)을 담당한다.&lt;/li&gt;
&lt;li&gt;JWT는 그 자체로 정보를 담고 있기 때문에, 최소한의 정보만 저장하려고 했는데...... 뭔가 많이 생겼다.&lt;br /&gt;특히 RefreshToken은 AccessToken의 재발급 목적으로만 존재하니 username만 남겨두려고 했는데 생각 없이 짜다가 결국 하나 더 넣어줬다. role에 대한 정보는 제외했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732976557697&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class JwtUtil {

    @Value(&quot;${jwt.secret}&quot;)
    private String secret;

    @Value(&quot;${jwt.access-token-expiration}&quot;)
    private long accessTokenExpiration;

    @Value(&quot;${jwt.refresh-token-expiration}&quot;)
    private long refreshTokenExpiration;

    private final RedisTokenService redisTokenService;

    private String createToken(String sub, String userId, String roleType, long expiration) {
        Header header = Jwts.header()
                .add(&quot;typ&quot;, &quot;JWT&quot;)
                .build();

        Claims claims = Jwts.claims()
                .add(&quot;sub&quot;, sub)
                .add(&quot;user_id&quot;, userId)
                .add(&quot;role_type&quot;, roleType)
                .build();

        Date now = new Date();

        return Jwts.builder()
                .header().add(header).and()
                .claims(claims)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expiration * 1000L))
                .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .compact();
    }

    private Claims extractClaims(String token) {
        return Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public boolean validateAccessToken(String accessToken) {
        if (redisTokenService.isBlacklisted(accessToken)) {
            throw new CommonException(JwtMessageType.INVALID_VERIFICATION_TOKEN);
        }
        try {
            extractClaims(accessToken);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public boolean validateRefreshToken(String refreshToken) {
        if (refreshToken == null) {
            throw new CommonException(JwtMessageType.REFRESH_TOKEN_NOT_EXIST);
        }
        try {
            extractClaims(refreshToken);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public void addBlacklist(String accessToken) {
        long expirationTime = extractClaims(accessToken).getExpiration().getTime()
                - new Date().getTime();
        redisTokenService.addBlacklist(accessToken, expirationTime);
    }

    public String createAccessToken(String username, String userId, String roleType) {
        return createToken(username, userId, roleType, accessTokenExpiration);
    }

    public String createRefreshToken(String username, String userId) {
        return createToken(username, userId, null, refreshTokenExpiration);
    }


    public String getUsername(String token) {
        return Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get(&quot;sub&quot;, String.class);
    }

    public String getUserId(String token) {
        return Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get(&quot;user_id&quot;, String.class);
    }

    public String getRoleType(String token) {
        return Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get(&quot;role_type&quot;, String.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CsrfTokenUtil&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;임의의 문자열로 csrf 토큰을 생성하는 메서드와 토큰을 검증하는 메서드를 추가해줬다.&lt;/li&gt;
&lt;li&gt;토큰을 검증할 때 true이면 유효한 토큰이고, 레디스 서비스에서 validate할 때 error가 잡히지 않고 false로 리턴하는 경우 재발급되도록 했다.&lt;/li&gt;
&lt;li&gt;csrf 토큰은 redis에 세션 시간만큼 유지되므로, redis에서 날아가면 재발급을 받을 때 따로 검증할 수 있는 방법이 없었다...&lt;/li&gt;
&lt;li&gt;그래서 redisTokenService에서 csrf 토큰을 저장할 때, 같은 내용을 log:라는 키를 추가하여 refresh token만큼 길게 저장을 하고, 재발급받을 때 해당 유저가 발급했던 csrf 토큰인지 확인하고 log를 삭제하도록 했다.. 원시적인 방법이라고 생각한다..^_ㅠ&lt;/li&gt;
&lt;li&gt;해당 고민 내용은 필터에서 좀 더 자세하게 적어보려고 한다...&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1733582094195&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class CsrfTokenUtil {
    private final SecureRandom secureRandom = new SecureRandom();

    public String generateToken() {
        byte[] tokenBytes = new byte[32];
        secureRandom.nextBytes(tokenBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
    }

    public boolean validateToken(String providedToken, String username, RedisTokenService redisTokenService) {
        String storedToken = redisTokenService.getCsrfToken(username);
        if (storedToken == null) {
            if (!redisTokenService.validateCsrfToken(username, providedToken)) {
                throw new CommonException(JwtMessageType.INVALID_VERIFICATION_TOKEN);
            }
            return true;
        }
        if (!providedToken.equals(storedToken)) {
            throw new CommonException(JwtMessageType.INVALID_VERIFICATION_TOKEN);
        }
        return true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 여기까지 토큰에 관련된 세팅과 서비스 생성이 끝났다. 작성하다 보니 내용이 길어져서 시큐리티 설정과 커스텀 필터, 유저 서비스에 대한 부분은 다음 글에서 작성하려고 한다! 확실히 프론트까지 연동시키려고 하니 중간중간 코드에 변화가 많이 생겼다... 양도 많아지고... &lt;/p&gt;</description>
      <category>Code</category>
      <category>JWT</category>
      <category>Spring Security</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/114</guid>
      <comments>https://henhen.tistory.com/114#entry114comment</comments>
      <pubDate>Sat, 7 Dec 2024 23:46:55 +0900</pubDate>
    </item>
    <item>
      <title>[Udemy] Spring Security 사용자 정의, JWT</title>
      <link>https://henhen.tistory.com/111</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;* 해당 강의에 대한 정리 글입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24&quot;&gt;https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Authorization 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;권한 기반 인가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GrantedAuthority 구현 -&amp;gt; SimpleGrantedAuthority.java: role 필드 보유(String)&lt;/li&gt;
&lt;li&gt;getAuthority(): 로그인한 사용자에게 할당된 역할, 권한 get&lt;/li&gt;
&lt;li&gt;로그인한 사용자의 UserDetails를 Authentication 구현 클래스 객체 형태로 저장: UserDetailsService에서 loadUesrByUsername 시 authorities에 유저의 권한 정보(getRole())를 List로 전달(한 유저가 여러 역할을 가질 수 있으므로)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732349944001&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class EazyBankUserDetailsService implements UserDetailsService {

    private final CustomerRepository customerRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Customer customer = customerRepository.findByEmail(username).orElseThrow(() -&amp;gt; new
                UsernameNotFoundException(&quot;User details not found for the user: &quot; + username));
        List&amp;lt;GrantedAuthority&amp;gt; authorities = customer.getAuthorities().stream().map(authority -&amp;gt; new
                        SimpleGrantedAuthority(authority.getName())).collect(Collectors.toList());
        return new User(customer.getEmail(), customer.getPwd(), authorities);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;authorities에 @JsonIgnore 어노테이션 추가: 권한 제어를 백엔드에서 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732350785060&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class ProjectSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
        http.securityContext(contextConfig -&amp;gt; contextConfig.requireExplicitSave(false))
                // .sessionManagement(sessionConfig -&amp;gt; sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                // .cors 설정...
                // .csrf 설정...
                .authorizeHttpRequests((requests) -&amp;gt; requests
                        .requestMatchers(&quot;/myAccount&quot;).hasAuthority(&quot;VIEWACCOUNT&quot;)
                        .requestMatchers(&quot;/myBalance&quot;).hasAnyAuthority(&quot;VIEWBALANCE&quot;, &quot;VIEWACCOUNT&quot;)
                        .requestMatchers(&quot;/myLoans&quot;).hasAuthority(&quot;VIEWLOANS&quot;)
                        .requestMatchers(&quot;/myCards&quot;).hasAuthority(&quot;VIEWCARDS&quot;)
                        .requestMatchers(&quot;/user&quot;).authenticated()
                        .requestMatchers(&quot;/notices&quot;, &quot;/contact&quot;, &quot;/error&quot;, &quot;/register&quot;, &quot;/invalidSession&quot;).permitAll());
        http.formLogin(withDefaults());
        http.httpBasic(hbc -&amp;gt; hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
        http.exceptionHandling(ehc -&amp;gt; ehc.accessDeniedHandler(new CustomAccessDeniedHandler()));
        return http.build();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;hasAuthority(): requestMatchers()에 해당하는 api에 적용하고자 하는 권한명 전달&lt;br /&gt;hasAnyAuthority(): 여러 권한을 지정하는 경우&lt;/li&gt;
&lt;li&gt;access(): 권한 부여에 대한 복잡한 로직 혹은 규칙이 존재하는 경우 사용할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;역할 기반 인가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;역할 관련 테이블 생성: 데이터베이스 내부에는 Role에 해당하는 접두사(default: ROLE_)를 붙이고 생성해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732351755033&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class ProjectSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
        http.securityContext(contextConfig -&amp;gt; contextConfig.requireExplicitSave(false))
                // .sessionManagement(sessionConfig -&amp;gt; sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                // .cors 설정...
                // .csrf 설정...
                .authorizeHttpRequests((requests) -&amp;gt; requests
                        .requestMatchers(&quot;/myAccount&quot;).hasRole(&quot;USER&quot;)
                        .requestMatchers(&quot;/myBalance&quot;).hasAnyRole(&quot;USER&quot;, &quot;ADMIN&quot;)
                        .requestMatchers(&quot;/myLoans&quot;).hasRole(&quot;USER&quot;)
                        .requestMatchers(&quot;/myCards&quot;).hasRole(&quot;USER&quot;)
                        .requestMatchers(&quot;/user&quot;).authenticated()
                        .requestMatchers(&quot;/notices&quot;, &quot;/contact&quot;, &quot;/error&quot;, &quot;/register&quot;, &quot;/invalidSession&quot;).permitAll());
        http.formLogin(withDefaults());
        http.httpBasic(hbc -&amp;gt; hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
        http.exceptionHandling(ehc -&amp;gt; ehc.accessDeniedHandler(new CustomAccessDeniedHandler()));
        return http.build();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;hasRole(): requestMatchers()에 해당하는 api에 적용하고자 하는 역할명 전달&lt;br /&gt;hasAnyRole(): 여러 역할을 지정하는 경우&lt;/li&gt;
&lt;li&gt;access(): 권한 부여에 대한 복잡한 로직 혹은 규칙이 존재하는 경우 사용할 수 있음&lt;/li&gt;
&lt;li&gt;권한 부여 실패 시 403 에러 반환 -&amp;gt; 리다이렉트하거나 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;비즈니스 로직에 따라&lt;/span&gt;&amp;nbsp;Authorization 이벤트를 트리거할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Custom Filter 정의&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VirtualFilterChain -&amp;gt; 필터체인 내부의 시큐리티 필터를 호출하는 로직&lt;/li&gt;
&lt;li&gt;(1) Filter Interface 구현: java 내부에 존재(jakarta.servlet)&lt;br /&gt;doFilter() 오버라이드해서 비즈니스로직 사용자 정의&lt;br /&gt;init(): 대체로 데이터베이스나 데이터 소스에 연결하는 로직 작성&lt;br /&gt;destroy(): 대체로 데이터베이스나 데이터 소스와의 연결을 해제하는 로직 작성&amp;nbsp;&lt;/li&gt;
&lt;li&gt;(2) GenericFilterBean: spring boot 라이브러리 내에 존재&lt;br /&gt;서블릿 관련 init 매개변수를 읽어야 하는 요구 사항이 있거나 서블릿 컨텍스트 세부 정보, 환경 속성 세부 정보를 읽을 수 있는 옵션이 필요한 경우 해당 클래스 활용 가능&lt;/li&gt;
&lt;li&gt;(3) OncePerRequestFilter: GenericFilterBean 확장하여 정의, 각 요청에 대해 필터가 최대 한 번만 실행되도록 보장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RequestValidationBeforeFilter.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732360882150&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class RequestValidationBeforeFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        String header = req.getHeader(HttpHeaders.AUTHORIZATION);
        if(null != header) {
            header = header.trim();
            if(StringUtils.startsWithIgnoreCase(header, &quot;Basic &quot;)) {
                byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
                byte[] decoded;
                try {
                    decoded = Base64.getDecoder().decode(base64Token);
                    String token = new String(decoded, StandardCharsets.UTF_8); // un:pwd
                    int delim = token.indexOf(&quot;:&quot;);
                    if(delim== -1) {
                        throw new BadCredentialsException(&quot;Invalid basic authentication token&quot;);
                    }
                    String email = token.substring(0,delim);
                    if(email.toLowerCase().contains(&quot;test&quot;)) {
                        res.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                        return;
                    }
                } catch (IllegalArgumentException exception) {
                    throw new BadCredentialsException(&quot;Failed to decode basic authentication token&quot;);
                }
            }
        }
        chain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이메일에 &quot;test&quot;라는 문자가 포함되면 authorize하지 않고 400 오류를 반환하도록 하는 비즈니스 로직&lt;/li&gt;
&lt;li&gt;chain.doFilter(): 필터 내에서 비즈니스 로직 수행 후 필터체인 내부의 다음 필터 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AuthoritiesLoggingAfterFilter.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732360687820&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
public class AuthoritiesLoggingAfterFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(null != authentication) {
            log.info(&quot;User &quot; + authentication.getName() + &quot; is successfully authenticated and &quot;
                    + &quot;has the authorities &quot; + authentication.getAuthorities().toString());
        }
        chain.doFilter(request,response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AuthoritiesLoggingAtFilter.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732360723844&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
public class AuthoritiesLoggingAtFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.info(&quot;Authentication Validation is in progress&quot;);
        chain.doFilter(request,response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CsrfCookieFilter.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732360775761&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CsrfCookieFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        // Render the token value to a cookie by causing the deferred token to be loaded
        csrfToken.getToken();
        filterChain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732360549203&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class ProjectSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
        http.securityContext(contextConfig -&amp;gt; contextConfig.requireExplicitSave(false))
                // .sessionManagement(sessionConfig -&amp;gt; sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                // .cors 설정...
                // .csrf 설정...
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(new RequestValidationBeforeFilter(), BasicAuthenticationFilter.class)
                .addFilterAfter(new AuthoritiesLoggingAfterFilter(), BasicAuthenticationFilter.class)
                .addFilterAt(new AuthoritiesLoggingAtFilter(), BasicAuthenticationFilter.class)
                // .authorize 설정...
        http.formLogin(withDefaults());
        http.httpBasic(hbc -&amp;gt; hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
        return http.build();
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;addFilterBefore(): 두 번째 인자로 작성된 필터 이전에 사용자 정의 필터 실행&lt;/li&gt;
&lt;li&gt;addFilterAfter(): 두 번째 인자로 작성된 필터 이후에 사용자 정의 필터 실행 -&amp;gt; 같은 순서에 여러 개의 필터가 적용된 경우, 무작위 순서로 실행&lt;/li&gt;
&lt;li&gt;addFilterAt(): 두 번째 인자로 작성된 필터와 동일한 순서에 사용자 정의 필터 실행 -&amp;gt; 둘 중 어느 필터가 먼저 적용될 지 알 수 없음(무작위 순서로 실행)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. JWT 기반 인증&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;621&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5SI8H/btsKTzDRGu7/EbDHFGP3u5iKSz1nDUYFTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5SI8H/btsKTzDRGu7/EbDHFGP3u5iKSz1nDUYFTK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5SI8H/btsKTzDRGu7/EbDHFGP3u5iKSz1nDUYFTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5SI8H%2FbtsKTzDRGu7%2FEbDHFGP3u5iKSz1nDUYFTK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;621&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;621&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 입력과 함께 api 요청 시 백엔드 서버는 자격 증명을 검증하고 토큰을 응답으로 제공&lt;/li&gt;
&lt;li&gt;토큰 생성 후 클라이언트 애플리케이션은 보안 api에 접근할 때마다 해당 토큰을 포함하여 요청 전송&lt;/li&gt;
&lt;li&gt;토큰이 유효하다면 백엔드 서버는 성공 응답 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JWT - Json Web Token&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Self-contained: 로그인한 사용자의 역할과 권한에 대한 정보를 저장할 수 있어 백엔드 서버에 매번 의존할 필요가 없음&lt;/li&gt;
&lt;li&gt;Statelessness: 사용자 정보는 토큰 정보의 일부이기 때문에 세션의 도움으로 애플리케이션이 기억할 필요가 없음 -&amp;gt; 토큰을 읽고 사용자 정보를 가져오는 방식&lt;/li&gt;
&lt;li&gt;period(.)를 기준으로 Header, Payload, Signature으로 구분하며 Base64로 인코딩&lt;br /&gt;(1) Header: 토큰에 대한 메타정보 저장 -&amp;gt; 토큰의 종류, 토큰 서명에 따른 알고리즘 저장&lt;br /&gt;(2) Payload: 사용자와 역할에 대한 정보 저장 -&amp;gt; &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;비즈니스 로직에 필요한&lt;/span&gt; 인가에 사용될 수 있으며 최대한 가벼운 정보로 유지&lt;br /&gt;(3) Signature: 서명(선택) -&amp;gt; 서명값을 가지고 토큰의 변조 여부를 확인(해싱: header + &quot;.&quot; + payload, secret)&lt;/li&gt;
&lt;li&gt;jwt.io&lt;/li&gt;
&lt;li&gt;jwt 관련 의존성 추가 -&amp;gt; jsonwebtoken(jjwt-api, impl, jackson)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732364586061&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class ProjectSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
        http.sessionManagement(sessionConfig -&amp;gt; sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .cors(corsConfig -&amp;gt; corsConfig.configurationSource(new CorsConfigurationSource() {
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration config = new CorsConfiguration();
                        config.setAllowedOrigins(Collections.singletonList(&quot;http://localhost:4200&quot;));
                        config.setAllowedMethods(Collections.singletonList(&quot;*&quot;));
                        config.setAllowCredentials(true);
                        config.setAllowedHeaders(Collections.singletonList(&quot;*&quot;));
                        config.setExposedHeaders(Arrays.asList(&quot;Authorization&quot;));
                        config.setMaxAge(3600L);
                        return config;
                    }
                }))
                .csrf(csrfConfig -&amp;gt; csrfConfig.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
                        .ignoringRequestMatchers( &quot;/contact&quot;,&quot;/register&quot;, &quot;/apiLogin&quot;)
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(new RequestValidationBeforeFilter(), BasicAuthenticationFilter.class)
                .addFilterAfter(new AuthoritiesLoggingAfterFilter(), BasicAuthenticationFilter.class)
                .addFilterAt(new AuthoritiesLoggingAtFilter(), BasicAuthenticationFilter.class)
                .addFilterAfter(new JWTTokenGeneratorFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(new JWTTokenValidatorFilter(), BasicAuthenticationFilter.class)
                .requiresChannel(rcc -&amp;gt; rcc.anyRequest().requiresInsecure()) // Only HTTP
                .authorizeHttpRequests((requests) -&amp;gt; requests
                        .requestMatchers(&quot;/myAccount&quot;).hasRole(&quot;USER&quot;)
                        .requestMatchers(&quot;/myBalance&quot;).hasAnyRole(&quot;USER&quot;, &quot;ADMIN&quot;)
                        .requestMatchers(&quot;/myLoans&quot;).hasRole(&quot;USER&quot;)
                        .requestMatchers(&quot;/myCards&quot;).hasRole(&quot;USER&quot;)
                        .requestMatchers(&quot;/user&quot;).authenticated()
                        .requestMatchers(&quot;/notices&quot;, &quot;/contact&quot;, &quot;/error&quot;, &quot;/register&quot;, &quot;/invalidSession&quot;, &quot;/apiLogin&quot;).permitAll());
        http.formLogin(withDefaults());
        http.httpBasic(hbc -&amp;gt; hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
        http.exceptionHandling(ehc -&amp;gt; ehc.accessDeniedHandler(new CustomAccessDeniedHandler()));
        return http.build();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;config.setExposedHeaders(Arrays.asList(&quot;Authorization&quot;)): 헤더명 Authorization을 백엔드에서 UI로 헤더를 노출하고 동일한 헤더를 사용하여 JWT 값 전송&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;JWT 값을 생성하는 필터와 유효성을 검증하는 커스텀 필터 생성하여 필터 체인에 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;JWTTokenGeneratorFilter.java&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732364829959&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class JWTTokenGeneratorFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // 현재 인증된 세부 정보를 읽는다.
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (null != authentication) { // 인증이 있는 경우 비즈니스 로직 실행
            // 환경 클래스의 객체를 생성해서 getEnvironment() 출력 할당
            Environment env = getEnvironment(); // GenericFilterBean의 구성으로 환경 변수를 읽을 수 있다.
            if (null != env) {
                // 환경 변수로 지정된 시크릿 값을 가져온다. -&amp;gt; 없는 경우 고려할 기본값을 함께 전달한다.
                String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY,
                        ApplicationConstants.JWT_SECRET_DEFAULT_VALUE);
                // 시크릿 값을 바이트 형태로 전달해서 시크릿 키 셋
                SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
                String jwt = Jwts.builder().issuer(&quot;Eazy Bank&quot;).subject(&quot;JWT Token&quot;) // issuer(): 발행 조직 명확성
                        .claim(&quot;username&quot;, authentication.getName()) // 제공할 정보 추가
                        .claim(&quot;authorities&quot;, authentication.getAuthorities().stream().map(
                                GrantedAuthority::getAuthority).collect(Collectors.joining(&quot;,&quot;)))
                        .issuedAt(new Date())
                        .expiration(new Date((new Date()).getTime() + 30000000)) // 토큰 만료 시간 설정
                        .signWith(secretKey).compact(); // 디지털 서명 추가하고 .compact(): 문자열 형식으로 변환
                response.setHeader(ApplicationConstants.JWT_HEADER, jwt); // 헤더에 &quot;Authorization&quot; 추가하고 jwt 변수에 jwt 할당
            }
        }
        // 요청과 응답을 전달하여 다음 필터를 호출한다.
        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return !request.getServletPath().equals(&quot;/user&quot;);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청에 따라 해당 로직은 인증이 성공한 후에 한 번만 실행되어야 함&lt;/li&gt;
&lt;li&gt;/user 경로가 아닌 요청의 경우 해당 필터는 실행되지 않아야 함&lt;/li&gt;
&lt;li&gt;ApplicationConstants 파일을 final class로 생성하여 환경 변수 값을 저장한다.(public static final JWT_SECRET ...)&lt;/li&gt;
&lt;li&gt;claim(): jwt 객체 내부에 전달할 값 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732366069951&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public final class ApplicationConstants {

    public static final String JWT_SECRET_KEY = &quot;JWT_SECRET&quot;;
    public static final String JWT_SECRET_DEFAULT_VALUE = &quot;jxgEQeXHuPq8VdbyYFNkANdudQ53YUn4&quot;;
    public static final String JWT_HEADER = &quot;Authorization&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;JWTTokenValidatorFilter.java&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732364926334&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class JWTTokenValidatorFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
       String jwt = request.getHeader(ApplicationConstants.JWT_HEADER);
       if(null != jwt) {
           try {
               Environment env = getEnvironment();
               if (null != env) {
                   String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY,
                           ApplicationConstants.JWT_SECRET_DEFAULT_VALUE);
                   SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
                   if(null != secretKey) {
                       // claims에서 username, authority 정보 가져옴
                       Claims claims = Jwts.parser().verifyWith(secretKey)
                                .build().parseSignedClaims(jwt).getPayload(); // parseSignedClaims에서 헤더 정보 가져옴
                       String username = String.valueOf(claims.get(&quot;username&quot;));
                       String authorities = String.valueOf(claims.get(&quot;authorities&quot;));
                       // 위 작업에서 얻은 username과 authorities를 제공하고 password는 null로 전달한다.
                       // authentication 객체를 생성할 때 마다 Authenticated는 true로 생성된다. (이미 인증이 완료됨)
                       Authentication authentication = new UsernamePasswordAuthenticationToken(username, null,
                               AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));
                       // setAuthentication(): 인증 객체를 SecurityContextHolder에 저장한다.
                       SecurityContextHolder.getContext().setAuthentication(authentication);
                   }
               }

           } catch (Exception exception) {
               // JWT 값 검증에 실패하는 경우 발생하는 예외 처리
               throw new BadCredentialsException(&quot;Invalid Token received!&quot;);
           }
       }
        filterChain.doFilter(request,response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return request.getServletPath().equals(&quot;/user&quot;);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/user 경로의 요청의 경우 해당 필터는 실행되지 않아야 하며 이후의 모든 보안 api에 대한 요청은 해당 필터가 실행되어야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Client&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Authorization 값을 sessionStorage에 설정(로컬 스토리지에 저장하지 않음)하고 requestHeader에 해당 값을 전달&lt;/li&gt;
&lt;li&gt;logout 시 Authorization 값을 null로 변경해서 권한 삭제&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용자 인증이 필요한 REST API 구축(인증 수동 호출)&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732367753749&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {
        EazyBankUsernamePwdAuthenticationProvider authenticationProvider =
                new EazyBankUsernamePwdAuthenticationProvider(userDetailsService, passwordEncoder);
        ProviderManager providerManager = new ProviderManager(authenticationProvider);
        // Authentication 객체 내부의 비밀번호를 삭제하지 않음(다른 유효성 검사를 위해 비밀번호를 사용하는 경우에 설정)
        providerManager.setEraseCredentialsAfterAuthentication(false);
        return providerManager;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메소드 내에서 인증 제공자 객체 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UserController.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732367646921&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostMapping(&quot;/apiLogin&quot;)
    public ResponseEntity&amp;lt;LoginResponseDTO&amp;gt; apiLogin (@RequestBody LoginRequestDTO loginRequest) {
        String jwt = &quot;&quot;;
        // 로그인 요청을 Authentication 객체로 변환(생성)
        Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.username(),
                loginRequest.password());
        // authenticationManager을 통해 인증에 대한 결과를 Authentication 객체로 반환
        Authentication authenticationResponse = authenticationManager.authenticate(authentication);
        if(null != authenticationResponse &amp;amp;&amp;amp; authenticationResponse.isAuthenticated()) { // 인증이 성공한 경우
            if (null != env) {
            // 인증에 성공한 경우 jwt를 수동으로 생성하는 로직 실행
                String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY,
                        ApplicationConstants.JWT_SECRET_DEFAULT_VALUE);
                SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
                 jwt = Jwts.builder().issuer(&quot;Eazy Bank&quot;).subject(&quot;JWT Token&quot;)
                        .claim(&quot;username&quot;, authenticationResponse.getName())
                        .claim(&quot;authorities&quot;, authenticationResponse.getAuthorities().stream().map(
                                GrantedAuthority::getAuthority).collect(Collectors.joining(&quot;,&quot;)))
                        .issuedAt(new java.util.Date())
                        .expiration(new java.util.Date((new java.util.Date()).getTime() + 30000000))
                        .signWith(secretKey).compact();
            }
        }
        // HttpStatus.OK를 반환하면서 헤더명을 Authentication으로 jwt값을 함께 전송하고 body에 responseDTO와 jwt 값을 전송
        // 보통은 한 곳에만 전송한다...
        return ResponseEntity.status(HttpStatus.OK).header(ApplicationConstants.JWT_HEADER,jwt)
                .body(new LoginResponseDTO(HttpStatus.OK.getReasonPhrase(), jwt));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;jwt 값을 responseBody에 보내는 rest api 구현&lt;/li&gt;
&lt;li&gt;LoginRequestDto, LoginResponseDto 생성&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>수업 내용 정리</category>
      <category>Spring Security</category>
      <category>udemy</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/111</guid>
      <comments>https://henhen.tistory.com/111#entry111comment</comments>
      <pubDate>Sat, 23 Nov 2024 17:50:45 +0900</pubDate>
    </item>
    <item>
      <title>[Udemy] Spring Security 예외 처리, CORs, CSRF</title>
      <link>https://henhen.tistory.com/110</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;* 해당 강의에 대한 정리 글입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24&quot;&gt;https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. SecurityConfig - HTTPS 프로토콜 허용&lt;/h4&gt;
&lt;pre id=&quot;code_1732243295126&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.requiresChannel(rcc -&amp;gt; rcc.anyRequest().requiresSecure()) // HTTPS 트래픽만 허용
        // ... 이후 로직
    return http.build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;http.requiresChannel(rcc -&amp;gt; rcc.anyRequest().requiresSecure()): HTTPS 트래픽만 허용(InSecure(): HTTP 프로토콜만 허용), 해당 설정 없을 시 둘 다 허용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Custom Exception&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ExceptionTranslationFilter&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수신된 예외의 유형을 확인하고 관련 구현을 호출&lt;/li&gt;
&lt;li&gt;AuthenticationException -&amp;gt; AuthenticationEntryPoint&lt;/li&gt;
&lt;li&gt;AccessDeniedException -&amp;gt; AccessDeniedHandler&lt;/li&gt;
&lt;li&gt;doFilter()에서 예외 처리하며, 예외가 발생하지 않을 경우 필터 체인 내부의 다음 필터를 호출&lt;br /&gt;예외가 발생할 경우, handleSpringSecurityException()에서 예외 유형을 확인하고 Exception 호출&lt;/li&gt;
&lt;li&gt;각 Exception() 메서드 내부에서 로직(sendStartAuthentication)에 따라 예외 처리 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AuthenticationException(인증 예외)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;401 status 반환(인증되지 않음)&lt;/li&gt;
&lt;li&gt;BadCredentialsException, UsernameNotFoundException&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AccessDeniedException&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;403 status(Forbidden)&lt;/li&gt;
&lt;li&gt;사용자나 클라이언트 애플리케이션이 올바르게 인증되었지만 보안된 API에 접근할 수 있는 권한이나 역할이 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CustomBasicAuthenticationEntryPoint.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732246672656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CustomBasicAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {
        response.setHeader(&quot;system-error-reason&quot;, &quot;Authentication failed&quot;); // 사용자 정의 로직 사용 가능
        response.sendStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(&quot;application/json;charset=UTF-8&quot;);
        
        String jsonResponse = 
            String.format(&quot;&amp;lt;jsonFormat&amp;gt;&quot;, &amp;lt;object&amp;gt;);
        response.getWriter().write(jsonResponse);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설정 후 &lt;b&gt;SecurityConfig.java&lt;/b&gt;에서 http.httpBasic(hbc -&amp;gt; hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())); 지정하거나&lt;/li&gt;
&lt;li&gt;http.exceptionHandling(ehc -&amp;gt; ehc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())); 으로 전역 설정 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CustomAccessDeniedHandler.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732248771246&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
        /*
        사용자 정의 populate dynamic value 사용 가능...
        */
        response.setHeader(&quot;system-error-reason&quot;, &quot;Authentication failed&quot;); // 사용자 정의 로직 사용 가능
        response.sendStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(&quot;application/json;charset=UTF-8&quot;);
        
        String jsonResponse = 
            String.format(&quot;&amp;lt;jsonFormat&amp;gt;&quot;, &amp;lt;object&amp;gt;); // 여기서 value construct
        response.getWriter().write(jsonResponse);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732248959430&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.requiresChannel(rcc -&amp;gt; rcc.anyRequest().requiresSecure()) // HTTPS 트래픽만 허용
        .csrf(csrfConfig -&amp;gt; csrfConfig.disable())
        .authorizeHttpRequiest((requests) -&amp;gt; requests
        .requestMatchers(&quot;/myAccount&quot;, &quot;/myBalance&quot;, &quot;/myLoans&quot;, &quot;/myCards&quot;).authenticated()
        .requestMatchers(&quot;/notices&quot;, &quot;/contact&quot;, &quot;/error&quot;, &quot;/register&quot;).permitAll());

    http.formLogin(withDefaults());
    http.http.httpBasic(hbc -&amp;gt; hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
    http.exceptionHandling(ehc -&amp;gt; ehc.accessDeniedHandler(new CustomAccessDeniedHandler())
        //.accessDeniedPage(&quot;/denied&quot;)); // 최종 경로 리다이렉션 하는 경우

    return http.build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;exceptionHandling에 의해 리다이렉션되는 경우, 해당 경로에 해당하는 MVC 경로가 구성되어 있어야 한다.&lt;br /&gt;accessDeniedHandler와 accessDeniedPage를 한번에 정의하는 것 비추 -&amp;gt; REST API를 지원하는 애플리케이션에서만 엄격하게 적용되므로 별도의 ui 애플리케이션이 백엔드 로직을 사용하려 한다면 accessDeniedHandler만 정의하는 것이 합리적&lt;/li&gt;
&lt;li&gt;ui와 백엔드 로직이 멀티 애플리케이션(Spring MVC ...)처럼 구축된 경우 둘 다 사용하여 리다이렉션한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타임아웃 설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;application.properties 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 동시 세션 관리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732260526334&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.sessionManageMent(smc -&amp;gt; smc.invalidSessionUrl(&quot;/invalidSession&quot;)
            .maximumSessions(1).maxSessionsPreventsLogin(true)) // 최대 세션 수 지정
        .requiresChannel(rcc -&amp;gt; rcc.anyRequest().requiresSecure()) // HTTPS 트래픽만 허용
        
        // ... 이후 로직

    return http.build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;maximunSessions() 메서드: 클라이언트 애플리케이션의 최대 세션 수 지정 -&amp;gt; 세션 수를 초과하는 경우 이전에 유지하던 세션이 동시 로그인으로 인해 만료됨&lt;/li&gt;
&lt;li&gt;maxSessionsPreventsLogin(): Dafault(false)인 경우 이전에 유지하던 세션이 만료되고 새로 로그인한 세션이 사용자 인증 허가&lt;br /&gt;true인 경우 이전 세션이 유지되고 새로 로그인하는 경우 유효성 검사를 통해 사용자 인증 절차를 수행하지 않음&lt;br /&gt;* maxSessionsPreventsLogin(true).expiredUrl()을 통해 세션이 만료된 경우 최종 사용자를 리다이렉션 할 수 있음&lt;/li&gt;
&lt;li&gt;세션 하이재킹: URL 내부에 세션 ID가 존재하는 경우, 쿠키에서 세션 ID를 저장하는 경우&lt;br /&gt;-&amp;gt; HTTPS 프로토콜 사용&lt;br /&gt;-&amp;gt; 세션 ID 타임아웃을 짧게 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. CORs&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DVkbe/btsKVc1yZLC/sGyjvUXUvtvlGTy9zRWyIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DVkbe/btsKVc1yZLC/sGyjvUXUvtvlGTy9zRWyIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DVkbe/btsKVc1yZLC/sGyjvUXUvtvlGTy9zRWyIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDVkbe%2FbtsKVc1yZLC%2FsGyjvUXUvtvlGTy9zRWyIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;638&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;638&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;origin: scheme, domain, port&lt;/li&gt;
&lt;li&gt;CORs 에러: 두 개의 서로 다른 오리진에 배포된 두 개의 다른 애플리케이션이 통신을 통해 리소스를 공유하려고 할 때 CORs 정책에 따라 다른 오리진을 가진 애플리케이션 간의 통신이 차단되면서 발생하는 문제&lt;/li&gt;
&lt;li&gt;@CrossOrigin(origins = &quot;http://localhost:4200&quot;) 등 어노테이션을 통해 해결&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732340218560&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class ProjectSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
        http.cors(corsConfig -&amp;gt; corsConfig.configurationSource(new CorsConfigurationSource() {
            @Override
            public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                CorsConfiguration config = new CorsConfiguration();
                config.setAllowedOrigins(Collections.singletonList(&quot;http://localhost:4200&quot;));
                config.setAllowedMethods(Collections.singletonList(&quot;*&quot;));
                config.setAllowCredentials(true);
                config.setAllowedHeaders(Collections.singletonList(&quot;*&quot;));
                config.setMaxAge(3600L);
                return config;
            }
        }))&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;setAllowOrigins(): 트래픽을 수락하고자 하는 출처 목록 제공&lt;/li&gt;
&lt;li&gt;setAllowMethod(): 특정 HTTP 메소드에 대해서만 트래픽 허용 가능&lt;/li&gt;
&lt;li&gt;setAllowCredentials(): 브라우저가 백엔드 api에 요청을 보낼 때 자격 증명이나 적용 가능한 쿠키를 전송할 수 있도록 설정&lt;/li&gt;
&lt;li&gt;setAllowedHeaders(): ui 애플리케이션이나 다른 출처에서 백엔드가 수락할 수 있는 헤더 목록 정의&lt;/li&gt;
&lt;li&gt;setMaxAge(): 설정 유지 시간(캐시) 지정&lt;/li&gt;
&lt;li&gt;preflight 요청: 실제 api 요청 전에 브라우저가 백엔드 서버로 보낼 요청(CORs 관련 설정 탐색)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. CSRF attack&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CSRF 공격: 해커가 사용자의 명시적인 동의 없이 웹 애플리케이션에서 사용자를 대신하여 작업을 수행하려고 시도&lt;/li&gt;
&lt;li&gt;백엔드 서버가 요청이 원래 웹사이트에서 온 것인지 타 웹사이트에서 온 것인지 인지할 수 있어야 함&lt;/li&gt;
&lt;li&gt;CSRF 토큰 솔루션: 사용자 세션마다 고유한 토큰 값을 쿠키로 ui 애플리케이션에 전달, RequestHeader 혹은 RequestBody에서 토큰을 받았는 지 식별&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732343910888&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@Profile(&quot;!prod&quot;)
public class ProjectSecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
        http.securityContext(contextConfig -&amp;gt; contextConfig.requireExplicitSave(false))
                .sessionManagement(sessionConfig -&amp;gt; sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                // .cors 설정...
                .csrf(csrfConfig -&amp;gt; csrfConfig.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
                        .ignoringRequestMatchers( &quot;/contact&quot;,&quot;/register&quot;)
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
        // 이후 설정...
        return http.build();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()): CSRF 토큰을 쿠키로 저장, 내부적으로 해당 객체를 설정하고 cookieHttpOnly를 false로 지정 -&amp;gt; ui 프레임워크나 자바스크립트 코드가 쿠키 값을 읽을 수 있도록 하기 위함(true인 경우 쿠키는 브라우저에서만 읽을 수 있어 해당 값을 수동으로 읽을 수 없음)&lt;/li&gt;
&lt;li&gt;addFilterAfter(): 기본 인증 필터 실행 후 CsrfCookieFilter()가 실행되도록 함&lt;/li&gt;
&lt;li&gt;CsrfTokenRequestAttributeHandler: 로그인 작업 후 요청 내부와 쿠키로 토큰 값을 전송할 때 AttributeHandler가 토큰 값을 읽어서 _csrf 속성으로 요청 내부에 추가&lt;/li&gt;
&lt;li&gt;SessionCreationPolicy.ALWAYS: 항상 세션 생성 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CsrfCookieFileter.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732344381452&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CsrfCookieFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        // Render the token value to a cookie by causing the deferred token to be loaded
        csrfToken.getToken();
        filterChain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OncePerRequestFilter: 확장하는 필터가 있을 때마다 라이브러리가 요청의 일부로 필터를 한 번만 실행하도록 관리하며 불필요하게 실행하지 않음&lt;/li&gt;
&lt;li&gt;getToken(): 실제 토큰 생성 및 쿠키로 전송 처리&lt;/li&gt;
&lt;li&gt;filterChain.doFilter(): 해당 필터에서 필터 체인의 다음 필터로 이동&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>수업 내용 정리</category>
      <category>Spring Security</category>
      <category>udemy</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/110</guid>
      <comments>https://henhen.tistory.com/110#entry110comment</comments>
      <pubDate>Fri, 22 Nov 2024 13:17:10 +0900</pubDate>
    </item>
    <item>
      <title>[Udemy] Spring Security Basic</title>
      <link>https://henhen.tistory.com/109</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;* 해당 강의에 대한 정리 글입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24&quot;&gt;https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;0. 기본 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;spring security dependency 추가&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;application.properties 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 id, pw 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring.security.user.name=${SECURITY_USERNAME:(id)}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring.security.user.password=${SECURITY_PASSWORD:(password)}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; SecurityProperties.java 내부에 정의된 로직에 따라 properties에서 자체 비밀번호를 정의할 경우, 프레임워크에서 검증 후 비밀번호를 자동으로 생성하지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 서블릿 및 필터&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Servlet&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서블릿 컨테이너: Web Server, App Server&lt;/li&gt;
&lt;li&gt;모든 커버는 웹 또는 앱 서버 내에 배포된다.&lt;/li&gt;
&lt;li&gt;모든 http 요청을 ServletRequest 객체로 변환 -&amp;gt; 내부에 클라이언트 애플리케이션이 보낸 데이터 존재&lt;/li&gt;
&lt;li&gt;ServletRequest 객체는 해당 서블릿으로 전달되어 SpringBoot나 SpringSecurity와 같은 프레임워크에서 활용&lt;/li&gt;
&lt;li&gt;즉, 모든 비즈니스 로직은 서블릿에서 호출됨&lt;/li&gt;
&lt;li&gt;클라이언트 요청 -&amp;gt; 백엔드 서버에서 요청 처리 -&amp;gt; 서블릿 컨테이너: 클라이언트 애플리케이션에 응답을 전달하기 전, servletResponse 객체를 http, https 프로토콜로 변환 -&amp;gt; 클라이언트에 응답(ServletResponse 객체)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Filter&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비즈니스 로직 요청과 응답이 실제 서블릿에 도달하기 전에 필터에서 모든 요청과 응답을 intercept&lt;/li&gt;
&lt;li&gt;보안 관련 로직 수행&lt;/li&gt;
&lt;li&gt;e.g. REST API, MVC 경로에 접근 시 요청을 가로채고 사용자 인증 여부를 식별하고 리다이렉션 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Spring Security Internal Flow&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;585&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Es82d/btsKRaJEUKx/8ocGj7HvKLG7jVTD2Nehfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Es82d/btsKRaJEUKx/8ocGj7HvKLG7jVTD2Nehfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Es82d/btsKRaJEUKx/8ocGj7HvKLG7jVTD2Nehfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEs82d%2FbtsKRaJEUKx%2F8ocGj7HvKLG7jVTD2Nehfk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;585&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;585&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트: 애플리케이션을 통해 REST API, MVC 경로에 접근하는 요청 전송&lt;br /&gt;RequestHeader, RequestBody에 자격 증명을 제공해야 함 -&amp;gt; 존재하지 않는 경우, 로그인 페이지로 리다이렉션&lt;br /&gt;Spring Security Filters: 전송된 요청을 가로채 자격 증명 검증 -&amp;gt; 예외 유형에 따라 401, 403 오류 발생 가능&lt;/li&gt;
&lt;li&gt;필터 통과 시 Authentication 객체 생성: 사용자 이름, 비밀번호, 인증 여부(Boolean: 초기 false) 등의 필드 존재&lt;br /&gt;일반적으로 자격 증명은 http 요청 내에 존재하므로, 필터는 http 서블릿 요청 객체에서 자격 증명을 Authentication 객체로 변환&lt;/li&gt;
&lt;li&gt;요청을 Authentication Manager에 전달할 필터 작동: 인증 성공 여부에 관계없이 다른 구성 요소의 도움을 받아 인증 로직을 완료하고 결과를 다시 필터로 전달하는 책임&lt;br /&gt;실제 인증 조작을 하지 않으며, 인증을 완료하는 책임만 가짐&lt;/li&gt;
&lt;li&gt;실제 인증 조작을 위해 Authentication Provider에 요청 전달: 적용되는 모든 Provider을 통하여 인증 성공 여부 식별(실제 인증 수행)&lt;/li&gt;
&lt;li&gt;인증 객체 형태로 요청을 받았을 때, 해당하는 User의 UserName을 기반으로 UserDetails을 로드하기 위한 클래스&lt;br /&gt;사용자 세부 정보를 불러와 다시 Authentication Provider에 제공&lt;br /&gt;이 때 제공한 세부 정보의 비밀번호와 데이터베이스 상의 비밀번호를 비교하지는 않음&lt;/li&gt;
&lt;li&gt;Password Encoder: 제공한 세부 정보의 비밀번호와 데이터베이스 상의 비밀번호를 비교하여 처리함 -&amp;gt; 평문으로 저장하거나 해싱 알고리즘을 통하여 처리 가능&lt;/li&gt;
&lt;li&gt;해당 단계들을 거쳐 Authentication Provider은 인증이 성공했다고(Authentication 객체의 isAuthenticated = true 값을 포함) Authentication Manager에게 전달&lt;/li&gt;
&lt;li&gt;Authentication Manager은 이를 확인하고 다시 객체를 필터로 전송&lt;/li&gt;
&lt;li&gt;필터는 인증 성공 여부를 알 수 있으며, 성공 여부과 관계없이 인증 세부 정보를 Security Context에 저장&lt;br /&gt;Authentication 객체는 주어진 브라우저에 대해 생성된 세션 id에 따라 저장되어 동일한 브라우저에서 동일한 페이지에 접근하려고 하면 주어진 세션 id를 기반으로 필터가 Security Context에서 Authentication 객체의 세부 정보를 로드하고 인증 여부 반환&lt;/li&gt;
&lt;li&gt;클라이언트 애플리케이션에 응답 전송하여 REST API, MVC 경로 응답을 받게되며, 실패할 경우 로그인 페이지로 리다이렉션(401, 403 오류 발생)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UserDetails 세부&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;591&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cEQV1r/btsKQOU6Nyp/Dbpvnc1JwKvIuh8UnKwbA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cEQV1r/btsKQOU6Nyp/Dbpvnc1JwKvIuh8UnKwbA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cEQV1r/btsKQOU6Nyp/Dbpvnc1JwKvIuh8UnKwbA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcEQV1r%2FbtsKQOU6Nyp%2FDbpvnc1JwKvIuh8UnKwbA0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;591&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;591&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Spring Security Filters&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;logging.level.org.springframework.security=${SPRING_SECURITY_LOG_LEVEL:TRACE}&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;: Spring Security 프레임워크의 로그 전체 콘솔 출력 목적(실제로 쓸 때는 조정하자)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AuthorizationFilter: 인증 필터 관련 로직&lt;/li&gt;
&lt;li&gt;DefaultLoginPageGenerationgFilter: 사용자에게 AuthorizationFilter에서 예외 발생 시 로그인 페이지로 리다이렉션하는 역할&lt;/li&gt;
&lt;li&gt;AbstractAuthenticationProcessingFilter: 인증이 필요한지 여부를 결정하고, 인증이 필요하다고 판단되면 &amp;nbsp;-&amp;gt; UsernamePasswordAuthenticationFilter 내부에 비즈니스 로직이 존재하는 attemptAuthentication() 메소드 호출&lt;br /&gt;Security Context가 Authentication 세부 정보로 채워지는 필터&lt;/li&gt;
&lt;li&gt;UsernamePasswordAuthenticationFilter: 최종 사용자가 입력한 자격 증명을 추출하여 매개변수 값(username, password)을 기반으로 obtainUsername()에서 사용자 이름 세부정보를 가져오고, 마찬가지로 password 정보를 가져오온 후 UsernamePasswordAuthenticationToken 객체를 생성&lt;br /&gt;* UsernamePasswordAuthenticationToken -&amp;gt; AbstractAuthenticationToken을 extends -&amp;gt; Authentication을 implements&lt;/li&gt;
&lt;li&gt;DaoAuthenticationProvider: UserDetailsManager, UserDetailsService를 통해 UserDetails 반환 -&amp;gt; 예외 처리: 비밀번호 검증 - addititionalAuthenticationChecks -&amp;gt; 처리 후 다시 AbstractAuthenticationProcessingFilter으로 이동, &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;Authentication 객체의 isAuthenticated = true 값을 포함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;4. SecurityConfig 구성&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SpringBootWebSecurityConfiguration.java&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;defaultSecurityFilterChain(): SecurityFilterChain Bean 생성, 익명의 사용자가 api에 접근하지 못하도록 보안, 폼 기반 로그인 활성화 목적(formLogin), httpBasic 인증 활성화(증명을 Base64 인코딩 및 httpRequest 헤더에 전송하는 방식) 목적&lt;/li&gt;
&lt;li&gt;=&amp;gt; SecurityConfig 구성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;securityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732117224580&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        // http.authorizeHttpRequests((requests) -&amp;gt; requests.anyRequest().authenticated());
        // http.authorizeHttpRequests((requests) -&amp;gt; requests.anyRequest().permitAll());
        // http.authorizeHttpRequests((requests) -&amp;gt; requests.anyRequest().denyAll());
        http.authorizeHttpRequests((requests) -&amp;gt; requests.requestMatchers(
            &quot;/myAccount&quot;, &quot;/myBalance&quot;, &quot;/myCards&quot;).authenticated());
        // http.formLogin(withDefaults());
        http.formLogin(flc -&amp;gt; flc.disable()); // formLogin 방식 비활성화
        http.httpBasic(withDefaults());
        return http.build();
    }
    
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;requests.anyRequest().permitAll()/denyAll(): 웹 애플리케이션 내의 모든 요청을 보안 없이 허용/모든 요청을 거부(403 error)&lt;/li&gt;
&lt;li&gt;requestMatchers().authenticated(): requestMatchers 메서드에 api 주소를 제공하고, 해당 주소 세트를 보안함(authenticated() 메서드 호출) -&amp;gt; 다른 설정으로 보안 설정하고 싶은 경우, requestMatchers 메서드를 여러 번 호출 가능&lt;/li&gt;
&lt;li&gt;formLogin 방식으로 요청에서 자격 증명을 추출하는 방식은 UsernamePasswordAuthenticationFilter에서 처리한다.&lt;/li&gt;
&lt;li&gt;httpBasic 방식으로 요청하는 경우 BasicAuthenticationFilter의 doFilterInternal() 메서드에서 자격 증명을 추출하고 Authentication 객체를 반환한다. -&amp;gt; 자격 증명은 AUTHORIZATION 헤더 이름으로 requestHeader 내부에 전송되므로 요청의 헤더에서 해당 헤더명을 찾는 방식으로 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. InMemoryUserDetailsManager를 사용한 사용자 정의, 관리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SpringBoot 애플리케이션의 메모리를 사용하여 여러 사용자 생성 -&amp;gt; 데이터베이스를 사용하여 여러 사용자를 생성하는 방법으로 확장&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UserDetailsService interface -&amp;gt; loadUserByUsername(): 사용자 이름을 기반으로 사용자 세부 정보 로드(인메모리 혹은 데이터베이스) -&amp;gt; UserDetailsManager(새로운 사용자 생성, 최종 사용자 삭제, 정보 업데이트, 비밀번호 변경 등 유저 관련 작업)에서 extends UserDetailsService&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732160291724&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public UserDetailsService userDetailsService() {
    UserDetails user = User.withUsername(&quot;user&quot;).password(&quot;{noop}12345&quot;).authorities(&quot;read&quot;).build();
    UserDetails admin = User.withUsername(&quot;admin&quot;).password(&quot;{noop}12345&quot;).authorities(&quot;admin&quot;).build();
    
    return new InMemoryUserDetailsManager(user, admin);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;authorities() 메서드: 특정 사용자가 가질 권한이나 접근 수준 지정 가능 -&amp;gt; 역할명 혹은 api 주소로 주어질 수도 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;6. Database를 사용한 사용자 정의, 관리&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) JdbcUserDetailsManager을 이용한 인증 수행 방식&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JDBC API, MySQL Driver, Spring Data JPA 의존성 추가, application.properties에 DB 관련 설정 추가&lt;/li&gt;
&lt;li&gt;SecurityConfig에서 UserDetailsService() 메서드 설정 -&amp;gt; DataSource 객체에서 데이터베이스 연결 정보를 확인, 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;미리 정의된 스키마, 테이블 구조를 강제하므로 추후 커스텀 빈 생성 필요(2번부터 내용)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732175898876&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
    return new JdbcUserDetailsManager(dataSource);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;pw 해시 시 길이가 길어지므로 충분히 주어야 함, role 칼럼 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) user 관리를 위한 JPA 엔티티, 레포지토리, 서비스, 컨트롤러 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;알고 있는 그거 만들기&lt;/li&gt;
&lt;li&gt;서비스단 혹은 컨트롤러에서 passwordEncorder 의존성 주입&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732180200219&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class MemberService {

    private final PasswordEncoder passwordEncoder;
    private final MemberRepository memberRepository;

...

        String hashPassword = passwordEncoder.encode(memberRegisterRequestDto.getPassword());

        Member member = Member.builder()
                .email(memberRegisterRequestDto.getEmail())
                .password(hashPassword)
                .build();

...
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityUserDetailsService&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@Service 어노테이션, UserDetailsService implements 필요&lt;/li&gt;
&lt;li&gt;MemberRepository 의존성 주입&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732178631918&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class SecurityUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(username).orElseThrow(() -&amp;gt;
            new UsernameNotFoundException(&quot;User details not found for the user: &quot; + username));
    }
    List&amp;lt;GrantedAuthority&amp;gt; authorities = List.of(new SimpleGraontedAuthority(member.getRole()));
    
    return new User(member.getEmail(), member.getPwd(), authorities);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GrantedAuthority: 권한, 역할 관련으로, 역할에 대한 정보를 해당 컬렉션 형태로 변환 -&amp;gt; 구현 클래스: SimpleGrantedAuthority&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SecurityConfig.java 수정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JdbcUserDetailsManager 관련 빈 사용 X&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;7. PasswordEncoder&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hashing의 단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입력이 동일하면 항상 동일한 해시 값을 반환하므로 해시 탈취 가능성&lt;/li&gt;
&lt;li&gt;속도가 매우 빠른 함수 -&amp;gt; 무차별 대입 공격 수행 가능성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;salt 사용: 해싱하기 전 무작위 값 생성한 후 해당 값에 비밀번호를 조합하여 해싱&lt;/li&gt;
&lt;li&gt;다회 로그인 시도 시 계정 lock&lt;/li&gt;
&lt;li&gt;회원가입 과정에서 복잡한 password 조합 요구&lt;/li&gt;
&lt;li&gt;해싱 알고리즘 수행 속도 지연 요구&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;570&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cg3aRx/btsKSB1sXbP/MCsn0N85wk2Sg2Cy7GGDU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cg3aRx/btsKSB1sXbP/MCsn0N85wk2Sg2Cy7GGDU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cg3aRx/btsKSB1sXbP/MCsn0N85wk2Sg2Cy7GGDU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcg3aRx%2FbtsKSB1sXbP%2FMCsn0N85wk2Sg2Cy7GGDU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;570&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;570&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PasswordEncoder.java&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;BCryptPasswordEncoder 권장&lt;/li&gt;
&lt;li&gt;encode() 메서드: 해시 값을 반환 값으로 제공 -&amp;gt; 동일한 해시 값을 db에 저장(service에서 passwordEncoder.encode(member.getPwd());으로 구현)&lt;/li&gt;
&lt;li&gt;matches(): 로그인 작업에서 최종 사용자가 입력한 원시 비밀번호와 db에서 가져온 인코딩된 비밀번호(해싱된 비밀번호)를 요구하며 salt를 추출하고 비밀번호가 일치하는 지 확인(boolean 값 반환) -&amp;gt; 일치 여부를 확인하고 디코딩하지 않는다.&lt;br /&gt;해당 메서드를 호출하는 주체는 AuthenticationProvider으로, additionalAuthenticationChecks() 메서드에서 호출&lt;/li&gt;
&lt;li&gt;upgradeEncoding(): 재암호화 시(두 번 해싱) true값 사용(기본 false 반환)&lt;/li&gt;
&lt;li&gt;PasswordEncoder의 bean을 Spring Security 구성에 하드코딩하지 말아야 함(e.g. BCryptPasswordEncoder의 bean을 생성한다거나) -&amp;gt; DelegatingPasswordEncoder 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;8. 사용자 정의 AuthenticationProvider&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;authenticate(): 실제 인증 로직을 정의하는 메서드 -&amp;gt; authentication 객체는 userDetails와 isAuthentication boolean 값을 가지며 수신한 인증 세부 정보를 기반으로 userDetails를 로드하고 있는 지 확인하고, authentication이 일치하는 경우 추가 검증 수행 가능 -&amp;gt; 최종적으로 인증 성공 여부를 나타내는 authentication 객체 반환&lt;/li&gt;
&lt;li&gt;supports(): 어떤 유형의 인증을 지원하는 지 전송 -&amp;gt; ProviderManager는 주어진 인증 스타일에 지원되는 구현 클래스 선택&lt;/li&gt;
&lt;li&gt;provider 객체의 도움으로 authenticate() 메서드를 호출하고 결과를 얻으며 주어진 Authentication 객체가 여러 AuthenticationProvider에 의해 지원될 때 ProviderManager는 인증이 성공하거나 모든 AuthenticationProvider가 성공적으로 실행될 때까지 모든 AuthenticationProvider를 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1732200094920&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class UsernamePwdAuthenticationProvider implements AuthenticationProvider {

    private final SecurityUserDetailService userDetailsService;
    private final PasswordEncoder passwordEncoder;
    
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 주로 단일 로직으로 구현하며, 해당 메소드 내부에서 userDetails를 직접 로드할 수도 있지만
        // 자체 사용자 세부 정보 서비스 구현을 통해 userDetails 로드
        String username = authentication.getName();
        String pwd = authentication.getCredentials.toString();
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        
        // 사용자가 입력한 비밀번호와 저장 시스템에서 로드된 비밀번호 일치 여부 비교
        if (passwordEncoder.matches(pwd, userDetails.getPassword())) {
            // 추가 검사 로직 수행 가능
            return new UsernamePasswordAuthenticationToken(username, pwd, userDetails.getAuthorities());
        } else {
            throw new BadCredentialsException(&quot;Invalid Password&quot;);
        }
    }
    
    @Override
    public boolean supports(Class&amp;lt;?&amp;gt; authentication) {
        return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>수업 내용 정리</category>
      <category>Spring Security</category>
      <category>udemy</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/109</guid>
      <comments>https://henhen.tistory.com/109#entry109comment</comments>
      <pubDate>Thu, 21 Nov 2024 15:08:54 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Kafka 채팅 파티션 분산 (3)</title>
      <link>https://henhen.tistory.com/107</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&amp;darr; 여기서 이어집니다&lt;/i&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1731306498839&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현&quot; data-og-description=&quot;웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementation 'org.webjars:stomp-websocket:2.3.4'&amp;nbsp;Websocket + STOMP config 구성@Configuration@Enable&quot; data-og-host=&quot;henhen.tistory.com&quot; data-og-source-url=&quot;https://henhen.tistory.com/100&quot; data-og-url=&quot;https://henhen.tistory.com/100&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://henhen.tistory.com/100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://henhen.tistory.com/100&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementation 'org.webjars:stomp-websocket:2.3.4'&amp;nbsp;Websocket + STOMP config 구성@Configuration@Enable&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;henhen.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;figure id=&quot;og_1731306467735&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;웹소켓 중복 구독, 연결 끊김 문제 해결&quot; data-og-description=&quot;&amp;darr; 여기서 이어집니다&amp;nbsp;Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementati&quot; data-og-host=&quot;henhen.tistory.com&quot; data-og-source-url=&quot;https://henhen.tistory.com/105&quot; data-og-url=&quot;https://henhen.tistory.com/105&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/PuUR9/hyXwnpsZzp/3XhkNkXudzKsgbdmtW21R1/img.png?width=732&amp;amp;height=1121&amp;amp;face=0_0_732_1121&quot;&gt;&lt;a href=&quot;https://henhen.tistory.com/105&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://henhen.tistory.com/105&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/PuUR9/hyXwnpsZzp/3XhkNkXudzKsgbdmtW21R1/img.png?width=732&amp;amp;height=1121&amp;amp;face=0_0_732_1121');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;웹소켓 중복 구독, 연결 끊김 문제 해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr; 여기서 이어집니다&amp;nbsp;Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementati&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;henhen.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;어느정도 채팅 기능이 동작하고 틀이 잡힐 무렵...&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;이제 진짜 흐린눈하고 있던 문제를 해결할 때가 됐다고 생각했다...&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 방식에서는 특정 채팅방에 메세지를 전송할 경우, 채팅방 id로 kafka 토픽이 자동생성 된다.&lt;/li&gt;
&lt;li&gt;즉, client가 첫 메세지를 보내기 전까지는 토픽이 존재하지 않아서 이때 토픽이 생기기 전까지 메세지가 정상적으로 전송되지 않는다.&lt;/li&gt;
&lt;li&gt;그리고 서비스가 제공되면 채팅방은 무수히 많이 생겨날텐데, 그 때마다 토픽을 하나씩 생성하는 건 너무 비효율적이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Kafka의 특징 중 분산 처리(파티셔닝)를 활용하여 해결한다.
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;채팅방 id에 의해 여러개로 쪼개진 토픽을 chat 하나로 정리하고,&lt;/li&gt;
&lt;li&gt;파티셔닝을 활용하여 3개의 partition에 채팅 메세지를 뿌려준다.&lt;/li&gt;
&lt;li&gt;중요한 건, 순서 보장을 위해 partition key를 사용하여 메세지가 정해진 파티션으로만 전송되도록 해야한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;mod 연산에 따른 파티션 분리 방법은 다음 블로그의 글들을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1731308334146&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[WebRTC] 채팅 서버 - 채팅 메시지 순서 보장(카프카 순서 보장)&quot; data-og-description=&quot;현재 채팅서버는 카프카를 통해 채팅을 구현하였다. 카프카를 통해 채팅 서버가 scale out 하더라도 여러 채팅 서버간 분산되어 있는 메시지를 관리하기 위해서 사용하게 되었다. 하지만 고려 못&quot; data-og-host=&quot;jhl8109.tistory.com&quot; data-og-source-url=&quot;https://jhl8109.tistory.com/m/65&quot; data-og-url=&quot;https://jhl8109.tistory.com/m/65&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bBz32w/hyXwi9wiZc/0lXTJISEXTGTgLJ0s50dM1/img.png?width=250&amp;amp;height=125&amp;amp;face=0_0_250_125,https://scrap.kakaocdn.net/dn/bl3fm8/hyXwld71e8/7iV3DED8EqNQoiL1f3Qmek/img.png?width=250&amp;amp;height=125&amp;amp;face=0_0_250_125,https://scrap.kakaocdn.net/dn/c7Eyew/hyXwrd4y1I/X4fCs3Q8oOFKpHf5s6VKUk/img.png?width=1574&amp;amp;height=217&amp;amp;face=0_0_1574_217&quot;&gt;&lt;a href=&quot;https://jhl8109.tistory.com/m/65&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://jhl8109.tistory.com/m/65&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bBz32w/hyXwi9wiZc/0lXTJISEXTGTgLJ0s50dM1/img.png?width=250&amp;amp;height=125&amp;amp;face=0_0_250_125,https://scrap.kakaocdn.net/dn/bl3fm8/hyXwld71e8/7iV3DED8EqNQoiL1f3Qmek/img.png?width=250&amp;amp;height=125&amp;amp;face=0_0_250_125,https://scrap.kakaocdn.net/dn/c7Eyew/hyXwrd4y1I/X4fCs3Q8oOFKpHf5s6VKUk/img.png?width=1574&amp;amp;height=217&amp;amp;face=0_0_1574_217');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[WebRTC] 채팅 서버 - 채팅 메시지 순서 보장(카프카 순서 보장)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;현재 채팅서버는 카프카를 통해 채팅을 구현하였다. 카프카를 통해 채팅 서버가 scale out 하더라도 여러 채팅 서버간 분산되어 있는 메시지를 관리하기 위해서 사용하게 되었다. 하지만 고려 못&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;jhl8109.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1731308344072&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Kafka를 이용한 채팅서버 개발&quot; data-og-description=&quot;서론&amp;nbsp;Spring Boot를 이용하여 채팅서버를 개발해보았다. 기본적으로 채팅같은 경우에, 실시간성이 중요하고 지속적으로 통신을 진행해야 하기 때문에 socket&amp;nbsp;통신을 기반으로 제작하였다. 만약 채&quot; data-og-host=&quot;kevin0459.tistory.com&quot; data-og-source-url=&quot;https://kevin0459.tistory.com/11&quot; data-og-url=&quot;https://kevin0459.tistory.com/11&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/mOiwd/hyXwiVY5Uz/YE2et4FVhb3vDgJYQ6zMi0/img.png?width=800&amp;amp;height=401&amp;amp;face=0_0_800_401,https://scrap.kakaocdn.net/dn/zV3ha/hyXwq0Li7U/HNUPugxIajK9FQwvMjObaK/img.png?width=800&amp;amp;height=401&amp;amp;face=0_0_800_401,https://scrap.kakaocdn.net/dn/bJZA5G/hyXwnptICG/xzlewF7ZUQxPTeWmPkksJ0/img.png?width=2054&amp;amp;height=1552&amp;amp;face=0_0_2054_1552&quot;&gt;&lt;a href=&quot;https://kevin0459.tistory.com/11&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://kevin0459.tistory.com/11&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/mOiwd/hyXwiVY5Uz/YE2et4FVhb3vDgJYQ6zMi0/img.png?width=800&amp;amp;height=401&amp;amp;face=0_0_800_401,https://scrap.kakaocdn.net/dn/zV3ha/hyXwq0Li7U/HNUPugxIajK9FQwvMjObaK/img.png?width=800&amp;amp;height=401&amp;amp;face=0_0_800_401,https://scrap.kakaocdn.net/dn/bJZA5G/hyXwnptICG/xzlewF7ZUQxPTeWmPkksJ0/img.png?width=2054&amp;amp;height=1552&amp;amp;face=0_0_2054_1552');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Kafka를 이용한 채팅서버 개발&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;서론&amp;nbsp;Spring Boot를 이용하여 채팅서버를 개발해보았다. 기본적으로 채팅같은 경우에, 실시간성이 중요하고 지속적으로 통신을 진행해야 하기 때문에 socket&amp;nbsp;통신을 기반으로 제작하였다. 만약 채&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;kevin0459.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. docker-compose.yaml&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 이런 식으로 도커 컴포즈파일 올리면서 한번에 토픽 생성&amp;amp;파티션 생성까지 완료할 생각이었는데, 잘 안 돌아갔다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 그냥 로컬도 마찬가지고 서버 띄웠을 때 kafka 올리고 바로 명령어 실행해서 토픽 생성했다.하하&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1731308465358&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  kafka:
    image: 'bitnami/kafka:latest'
    container_name: 'kafka'
    ports:
      - '9092:9092'
    environment:
      KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
      KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:9092'
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://localhost:9092'
    command:
      - 'sh'
      - '-c'
      - &quot; /opt/bitnami/kafka/bin/kafka-server-start.sh &amp;amp; \
          sleep 20; \
          until /opt/bitnami/kafka/bin/kafka-topics.sh --create --topic chat --partitions 3 --replication-factor 1 --if-not-exists --bootstrap-server localhost:9092; do \
            echo 'Waiting for Kafka to be ready...'; \
            sleep 5; \
          done; \
          wait&quot;
    depends_on:
      - 'zookeeper'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. KafkaConstants 설정&lt;/h4&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4; text-align: start;&quot;&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;public class KafkaConstants {
//    public static String name = UUID.randomUUID().toString();
    public static final String GROUP_ID = &quot;chat-group&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; 하나의 토픽에서 채팅 메세지를 관리할 예정이므로 uuid로 생성 중인 컨슈머 그룹을 하나로 묶어 관리될 수 있도록 해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. ChatMessageService 수정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;producer&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위에서 참고한 글들과 마찬가지로, 3개의 파티션으로 나눈 후 모듈 연산에 따라 채팅방을 배정한다.
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;채팅방 3, 6, 9 ... &lt;i&gt;&amp;rarr; &lt;/i&gt;partition 0&lt;/li&gt;
&lt;li&gt;채팅방 1, 4, 7 ... &lt;i&gt;&amp;rarr;&lt;/i&gt;&amp;nbsp;partition 1&lt;/li&gt;
&lt;li&gt;채팅방 2, 5, 8 ... &lt;i&gt;&amp;rarr;&lt;/i&gt;&amp;nbsp;partition 2&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1731309864310&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void sendMessage(Long chatRoomId, ChatMessageRequestDto chatMessageRequestDto) {

        ChatMessage chatMessage = chatMessageRepository.save(ChatMessage.createMessage(
                chatRoomId, chatMessageRequestDto.getMemberId(), chatMessageRequestDto.getNickname(),
                chatMessageRequestDto.getMessage(), null, null));

        try {
            kafkaTemplate.send(&quot;chat&quot;, chatRoomId.intValue() % 3, null, chatMessage);
        } catch (Exception e) {
            log.error(&quot;Failed to send message&quot;, e);
        }
    }

    public void sendFileMessage(Long chatRoomId, Long memberId, String nickname, List&amp;lt;StorageResponseDto&amp;gt; storageFiles) {
        for (StorageResponseDto storageResponse : storageFiles) {
            StorageFile storageFile = storageFileService.findByStorageIdAndId(
                    storageResponse.getStorageId(), storageResponse.getStorageFileId());

            Long fileId = storageFile.getId();
            String fileName = storageFile.getOriginalName();

            ChatMessage chatMessage = chatMessageRepository.save(ChatMessage.createMessage(
                    chatRoomId, memberId, nickname, null, fileId, fileName));

            try {
                kafkaTemplate.send(&quot;chat&quot;, chatRoomId.intValue() % 3, null, chatMessage);
//                log.info(&quot;Sent message to topic {} on partition {}&quot;, chatRoomId, chatRoomId.intValue() % 3);
            } catch (Exception e) {
                log.error(&quot;Failed to send message&quot;, e);
            }
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;chat&quot; 토픽의 모듈러 연산에 따른 나머지 값에 따른 파티션으로 메세지를 전송한다.&lt;/li&gt;
&lt;li&gt;까먹지 않고 파일 메세지 전송 부분도 챙겨준다..&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;consumer&lt;/p&gt;
&lt;pre id=&quot;code_1731310303062&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@KafkaListener(groupId = &quot;chat-group&quot;, topicPartitions = @TopicPartition(topic = &quot;chat&quot;, partitions = {&quot;0&quot;}))
public void consumeMessagePartition0(ChatMessage chatMessage) {
    consumeMessage(chatMessage, 0);
}

@KafkaListener(groupId = &quot;chat-group&quot;, topicPartitions = @TopicPartition(topic = &quot;chat&quot;, partitions = {&quot;1&quot;}))
public void consumeMessagePartition1(ChatMessage chatMessage) {
    consumeMessage(chatMessage, 1);
}

@KafkaListener(groupId = &quot;chat-group&quot;, topicPartitions = @TopicPartition(topic = &quot;chat&quot;, partitions = {&quot;2&quot;}))
public void consumeMessagePartition2(ChatMessage chatMessage) {
    consumeMessage(chatMessage, 2);
}

private void consumeMessage(ChatMessage chatMessage, int partition) {
    try {
        simpMessagingTemplate.convertAndSend(&quot;/sub/chat/&quot;+chatMessage.getChatRoomId().toString(), chatMessage);
        log.info(&quot;Received message from partition {}: {}&quot;, partition, chatMessage);
    } catch (Exception e) {
        log.error(&quot;Failed to process message from partition {}: {}&quot;, partition, chatMessage, e);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;테스트를 위해 처리한 파티션과 메세지를 남기는 로그를 찍었습니다...&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;consumeMessage 메서드를 수정해서 @KafkaListener 어노테이션을 분리한다. 이 메서드는 단순히 chatMessage와 partition number을 수신한 후, 지정한 웹소켓 경로로 수신한 메세지를 전송하는 동작을 관리하는 로직으로 변경했다.&lt;/li&gt;
&lt;li&gt;@KafkaListener 어노테이션을 통해 &quot;chat-group&quot; 그룹의 컨슈머가 메세지를 받을 수 있도록 지정해 준다.&lt;/li&gt;
&lt;li&gt;이 때, @TopicPartition 어노테이션으로 특정 토픽(&quot;chat&quot;)의 특정 파티션(partitions = {&quot;&quot;}) 에서만 수신할 수 있도록 해서 정해진 파티션으로 전달된 메세지만 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;998&quot; data-origin-height=&quot;198&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EWw6j/btsKDU86FAZ/vMZ7MpJyxUsbM6ThyjKWQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EWw6j/btsKDU86FAZ/vMZ7MpJyxUsbM6ThyjKWQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EWw6j/btsKDU86FAZ/vMZ7MpJyxUsbM6ThyjKWQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEWw6j%2FbtsKDU86FAZ%2FvMZ7MpJyxUsbM6ThyjKWQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;563&quot; height=&quot;112&quot; data-origin-width=&quot;998&quot; data-origin-height=&quot;198&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 토픽을 제대로 생성하고, 파티셔닝 된 것도 확인했다. 내용도 어렵고 뭔가 많이 변경해야 할 것 같아서 솔직히 뒤로 미뤄뒀던 건데 생각보다 금방 해결됐다! 이로써 채팅방 생성할 때마다 테스트하려고 백엔드 청기백기 안 해도 된다.. 이제서야 제대로 된 채팅방 기능을 구현한 것 같다. 사실 정리된 글이 많아서 도움을 많이 받아가지고 구현 시간은 엄청 길지 않았는데, 블로그 정리하는데 한 세월 걸렸다.. 메세지 큐의 기능 말고도 서비스 분리에 있어서 kafka 기능도 경험해보고 싶다.&lt;/p&gt;</description>
      <category>Code</category>
      <category>KAFKA</category>
      <category>spring</category>
      <category>websocket</category>
      <category>채팅</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/107</guid>
      <comments>https://henhen.tistory.com/107#entry107comment</comments>
      <pubDate>Mon, 11 Nov 2024 17:04:10 +0900</pubDate>
    </item>
    <item>
      <title>[한화시스템 BEYOND SW캠프 8기] 마지막 회고</title>
      <link>https://henhen.tistory.com/106</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;다운로드.jfif&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWIEVT/btsKDhvTGTw/zf263wM72Wj0tkaiaYbslk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWIEVT/btsKDhvTGTw/zf263wM72Wj0tkaiaYbslk/img.jpg&quot; data-alt=&quot;수고했어~&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWIEVT/btsKDhvTGTw/zf263wM72Wj0tkaiaYbslk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWIEVT%2FbtsKDhvTGTw%2Fzf263wM72Wj0tkaiaYbslk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;470&quot; height=&quot;376&quot; data-filename=&quot;다운로드.jfif&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수고했어~&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 시간이 너무 빠르다. 잉잉 코딩 처음 시작하는데 한개도 몰라요 했던 지가 엊그제 같은데(사실 지금도 한다) 벌써 6개월이 지나고 파이널 프로젝트가 끝나고 8기 수료를 했다. 처음 들어올 때 나와 지금의 내가 크게 달라졌냐 하면 아직까지 같은 사람이고, 똑같냐고 하면 또 그건 아니다. 그래도 처음에 생각했던 것보다 많은 걸 얻어가게 된 것 같아서 중간에 1n주간 기록이 빠졌지만 마지막 회고라도 써보려고 한다. 6개월을 9 to 6으로 어딘가에 소속되어 생활한다는 건 내가 생각했던 것보다 더 더 많은 정이 들고, 더 많이 마음을 쓰게 된다. 파이널 프로젝트를 하면서 잠도 많이 줄이고 개인 생활을 많이 덜어내다 보니 예민하게 굴었던 것도 감정을 냅다 표출했던 것도 뒤돌아보니 후회도 되고 마음이 좋지 않았다. 어쩌면 이 글을 쓰는 것도 셀프 위로를 하기 위해서 쓰는 걸지도 모른다. 힘든 건 다 같이 힘들었을 텐데 쩝 이 글을 보지는 않겠지만 미안했습니다!&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 가장 크게 얻어간 것을 꼽자면 아무래도 공부하는 방법이다. 이 부트캠프를 수강하기 전에 1~2달 정도 김영한님의 국룰 강의를 수강했었는데, 스프링이든 자바든 뭔지도 잘 모르는 채로 알려고 하지도 않고 코드 따라치기에 급급했던 것 같다. 처음이라 그게 당연할 수도 있긴 하지만, 강의와 똑같은 리턴이 나오는가? 나오면 ok.에 머물렀다. 당연히 코드 안 틀리고 똑같이 쳤으면 똑같은 리턴이 나오는데도. 사실 모르는 내용이다 보니 이해라도 하면 다행이었던 것 같기도 하다. 그런데 부트캠프를 수강하면서 비슷한 내용을 한번 더 듣고, 미니 프로젝트를 통해 새로운 내용으로 적용해보면서 공부하다 보니 조금씩 이해도가 더 생겼고, 잘 안 되거나 꼬이는 부분이 있으면 구글링도 한번이라도 더 해보고 블로그 글이라도 한개 더 읽을 수 있었다. 정말 하다하다 안되면 거의 손에 꼽게 없는 일이지만 한번쯤 공식 문서도 열어봤던 것 같다. 이거 진짜 예전이었으면 말도 안되는 소리인거 아시죠&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 두 번째는 별건 아니지만 &quot;코딩하는 거 재밌다&quot; 다. 앞에서도 말했지만 구글링 한번이라도 더 해보고 블로그 한개라도 더 읽고 지피티 깡통한테 이렇게 물었다가 저렇게 물었다가도 해보고(프론트 할 때 얘 없었으면 죽었을지도?) 하는 과정이 생각보다 많이 재밌었다. 물론 뭐 어떻게 해도 안될 때가 있는데 그 때는 진짜 분조장 ..은 아니고 그냥 냅다 누워서 자기도 했다. 퇴사 후에 혼자 집에서 공부할 때는 솔직히 초반이라 처음 보는 내용이라 그런지 어렵기도 하고, 강의 듣기에 바빠서 재미 붙일 시간도 없었다. 그 때는 오히려 너무 집중이 안 돼서 내가 성인 ADHD가 아닐까 정말 진지하게 혼자서 고민하기도 했다. 그래도 마지막으로 갈수록 재미 자체를 붙이기도 했고, 할 일도 왕창 생기다 보니 집에서 새벽까지 코딩할 일도 종종 생겼는데, 그 때마다 나 생각보다 엉덩이를 오래 붙이고 있을 줄 아는 사람일지도? 혼자 좀 뿌듯했던 거 혹시 알아줄 사람? 새벽에 집중력 떨어질 때마다 테트리스 같이 해주셔서 감사해요..;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2024-08-23 오후 2.37.28.png&quot; data-origin-width=&quot;704&quot; data-origin-height=&quot;1099&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zmSeH/btsKEta4X3x/EOuIK6dbbp2oIQJM0kFpJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zmSeH/btsKEta4X3x/EOuIK6dbbp2oIQJM0kFpJ0/img.png&quot; data-alt=&quot;어쩐지 수상한 테트리스&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zmSeH/btsKEta4X3x/EOuIK6dbbp2oIQJM0kFpJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzmSeH%2FbtsKEta4X3x%2FEOuIK6dbbp2oIQJM0kFpJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;424&quot; height=&quot;691&quot; data-filename=&quot;edited_스크린샷 2024-08-23 오후 2.37.28.png&quot; data-origin-width=&quot;704&quot; data-origin-height=&quot;1099&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;어쩐지 수상한 테트리스&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 마지막은 아무래도 사람과 의사소통이지 않을까 싶다. 아무리 컴퓨터랑 하는 일이지만, 결국 그 뒤에는 사람이다. 사람 좋아하고 놀기 좋아하는 것과는 별개로 일할 때는 개인주의적인 성향이 강했던 것 같다. 솔직히 내 일 다 했으면 끝 이라고 물론 지금도 크게 틀리지는 않지만 좀 더 강하게 생각하기도 했고, 다른 사람을 돌아볼 생각을 안 했다. 사실 학부생 때도 그렇고, 업무를 맡았을 때도 그렇고 협업을 크게 많이 하지 않는 프로세스 속에서 살아가다 보니 자연스러웠을 수도 있다. (핑계 맞음) 여기 와서 프로젝트를 하다 보니 본인 역할을 넘어서도 일을 하고, 다른 팀원들과 조율해 가며 안되는 걸 늦게까지 나서서 도와주기도 하고 또 그걸 자연스럽게 생각하며 작업하는 것을 보고 신기하기도 하고 대단하다고 느꼈다. 내 일 남 못 주고 남 일 못 받는 사람이라 처음에는 에바다 라고 생각했는데 시간이 지날 수록 그건 진짜 가치가 맞았다. 솔직히 파이널 프로젝트 때 의도치 않게 한 명이 중탈을 하게 되면서 넘어온 일 덕분에 그때서야 뼈저리게 느꼈다. &lt;s&gt;협업이란거 정말 미친놈이구나!&lt;/s&gt; 이거 아니고 좋은 사람들 덕분에 월요일마다 술로 연명하면서 프로젝트를 잘 마무리할 수 있게 일적으로도 멘탈적으로도 많은 도움을 받았다. 뭐랄까 팀과 협업이라는 것에 대해서 살면서 뭔가 머릿속에서 정의내리거나 생각해본 적이 없었는데, 부트캠프를 하면서 고민도 많이 해보고, 어떤 사람들이 이상적인 사람인지 혹은 나는 어떤 사람이 되어야 할 지에 대해서 생각도 해봤다. 근데 나중에 면접때 써먹어야 하니까 이건 비밀로 해야지 아무도 안 궁금해&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 기술적으로 공부하고 얻어간 것들도 구구절절 적어볼까 했는데, 개인적인 소감으로도 이미 너무 구구절절99절절구질구질 얘기해버린 것 같아서 이 부분은 이미 써놓은 블로그 글도 꽤 쌓인 것 같아서 슬쩍 넘어가야겠다. 공부한 것들 메모한 글이라 다른 사람들이 정보성 글로 볼 수 있을 수준도 아니지만, 이렇게 뭔가 블로그를 꾸려보는 게 처음이라 전부 모아보니 나름대로 뿌듯하다. 부트캠프는 끝났지만 앞으로도 공부한 내용들은 종종 블로그로 정리하고, 이제 다시 알고리즘 문제도 풀어볼까 싶다. 목표는 1일 1솔으로 많이 푸는 것보다는 꾸준히 푸는 것에 의미를 두려고 한다. 원래 많이 하는 것보다 꾸준히 하는 걸 더 어려워하는 사람이라 1일 1X라는 글자만 봐도 막 벌써 두렵다..&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1731217427637&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - henhen7/be08-fin-200OK-CONCENTRIC-backend:  김도하, 박성준, 서현지, 조혜인 &quot; data-og-description=&quot; 김도하, 박성준, 서현지, 조혜인 . Contribute to henhen7/be08-fin-200OK-CONCENTRIC-backend development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-backend&quot; data-og-url=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-backend&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cZKLYX/hyXsUPBYEe/VsS38hjdMTAE5KjY25y4t0/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282,https://scrap.kakaocdn.net/dn/c6nN3p/hyXwv8vxjY/sEKSZI0dWMcaJcdQbRjhj0/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282&quot;&gt;&lt;a href=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-backend&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-backend&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cZKLYX/hyXsUPBYEe/VsS38hjdMTAE5KjY25y4t0/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282,https://scrap.kakaocdn.net/dn/c6nN3p/hyXwv8vxjY/sEKSZI0dWMcaJcdQbRjhj0/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - henhen7/be08-fin-200OK-CONCENTRIC-backend:  김도하, 박성준, 서현지, 조혜인 &lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt; 김도하, 박성준, 서현지, 조혜인 . Contribute to henhen7/be08-fin-200OK-CONCENTRIC-backend development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;figure id=&quot;og_1731217418960&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - henhen7/be08-fin-200OK-CONCENTRIC-frontend:  김도하, 박성준, 서현지, 조혜인 &quot; data-og-description=&quot; 김도하, 박성준, 서현지, 조혜인 . Contribute to henhen7/be08-fin-200OK-CONCENTRIC-frontend development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-frontend&quot; data-og-url=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-frontend&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cJrcbH/hyXs0oOtw9/JmTbsklJDBMYfi4MQOKV6k/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282,https://scrap.kakaocdn.net/dn/bxGd6M/hyXsSdduA3/oYjgkKVdV83aDlmuWDZBck/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282&quot;&gt;&lt;a href=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-frontend&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/henhen7/be08-fin-200OK-CONCENTRIC-frontend&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cJrcbH/hyXs0oOtw9/JmTbsklJDBMYfi4MQOKV6k/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282,https://scrap.kakaocdn.net/dn/bxGd6M/hyXsSdduA3/oYjgkKVdV83aDlmuWDZBck/img.png?width=1200&amp;amp;height=600&amp;amp;face=982_200_1057_282');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - henhen7/be08-fin-200OK-CONCENTRIC-frontend:  김도하, 박성준, 서현지, 조혜인 &lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt; 김도하, 박성준, 서현지, 조혜인 . Contribute to henhen7/be08-fin-200OK-CONCENTRIC-frontend development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;s&gt;그래도 슬쩍 얹어보는 파이널 프로젝트 레포지토리&lt;/s&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-10 오후 2.50.37.png&quot; data-origin-width=&quot;2226&quot; data-origin-height=&quot;174&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tgvMu/btsKD0tFikk/BPnCG27XkAu7vzUTGYaovK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tgvMu/btsKD0tFikk/BPnCG27XkAu7vzUTGYaovK/img.png&quot; data-alt=&quot;하루 1만원짜리 채팅방도 퍼어엉&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tgvMu/btsKD0tFikk/BPnCG27XkAu7vzUTGYaovK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtgvMu%2FbtsKD0tFikk%2FBPnCG27XkAu7vzUTGYaovK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2226&quot; height=&quot;174&quot; data-filename=&quot;스크린샷 2024-11-10 오후 2.50.37.png&quot; data-origin-width=&quot;2226&quot; data-origin-height=&quot;174&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;하루 1만원짜리 채팅방도 퍼어엉&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;여하튼 6개월간 beyond sw캠프 8기 즐거웠습니다! 다들 앞으로 하려는 일 모두 잘 되길!&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;6개월간의 데이터베이스를 바탕으로 개인적으로 꼽은 신대방삼거리 goat 윤윤차이나를 마지막으로 진짜 빠이 &lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 1106px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 553px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 553px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20241110_142704550.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0YeNx/btsKEtPFVJd/FtBeydWkXTZJYfhXlkpiX1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0YeNx/btsKEtPFVJd/FtBeydWkXTZJYfhXlkpiX1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0YeNx/btsKEtPFVJd/FtBeydWkXTZJYfhXlkpiX1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0YeNx%2FbtsKEtPFVJd%2FFtBeydWkXTZJYfhXlkpiX1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;4032&quot; data-filename=&quot;KakaoTalk_20241110_142704550.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 553px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20241110_142704550_01.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKLqS2/btsKDFwBACo/Rnj5f0vsxHCA2ixDPkKi3k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKLqS2/btsKDFwBACo/Rnj5f0vsxHCA2ixDPkKi3k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKLqS2/btsKDFwBACo/Rnj5f0vsxHCA2ixDPkKi3k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKLqS2%2FbtsKDFwBACo%2FRnj5f0vsxHCA2ixDPkKi3k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;4032&quot; data-filename=&quot;KakaoTalk_20241110_142704550_01.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 553px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 553px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20241110_142704550_02.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZ3KUF/btsKDpU0MFf/ojkfivzQUkFAoJsd8wpkkK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZ3KUF/btsKDpU0MFf/ojkfivzQUkFAoJsd8wpkkK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZ3KUF/btsKDpU0MFf/ojkfivzQUkFAoJsd8wpkkK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZ3KUF%2FbtsKDpU0MFf%2FojkfivzQUkFAoJsd8wpkkK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;4032&quot; data-filename=&quot;KakaoTalk_20241110_142704550_02.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 553px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20241110_142704550_03.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Db5QU/btsKDSCuKVk/R7R0ADxo1WiZ6dB42gvQdk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Db5QU/btsKDSCuKVk/R7R0ADxo1WiZ6dB42gvQdk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Db5QU/btsKDSCuKVk/R7R0ADxo1WiZ6dB42gvQdk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDb5QU%2FbtsKDSCuKVk%2FR7R0ADxo1WiZ6dB42gvQdk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;4032&quot; data-filename=&quot;KakaoTalk_20241110_142704550_03.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;양장피 드세용&lt;/span&gt;&lt;/p&gt;</description>
      <category>한화시스템 beyond SW 캠프</category>
      <category>부트캠프</category>
      <category>한화시스템 beyond sw캠프</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/106</guid>
      <comments>https://henhen.tistory.com/106#entry106comment</comments>
      <pubDate>Sun, 10 Nov 2024 14:45:45 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 채팅 구현 중 웹소켓 중복 구독, 연결 끊김 문제 해결 (2)</title>
      <link>https://henhen.tistory.com/105</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&amp;darr; 여기서 이어집니다&lt;/i&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1731306398478&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현&quot; data-og-description=&quot;웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementation 'org.webjars:stomp-websocket:2.3.4'&amp;nbsp;Websocket + STOMP config 구성@Configuration@Enable&quot; data-og-host=&quot;henhen.tistory.com&quot; data-og-source-url=&quot;https://henhen.tistory.com/100&quot; data-og-url=&quot;https://henhen.tistory.com/100&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://henhen.tistory.com/100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://henhen.tistory.com/100&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementation 'org.webjars:stomp-websocket:2.3.4'&amp;nbsp;Websocket + STOMP config 구성@Configuration@Enable&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;henhen.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;문제&lt;/h3&gt;
&lt;h4 style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;frontend&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;채팅 서비스 구현 중 웹소켓을 중복 구독해서 메세지가 이중으로 표시되는 문제&lt;/li&gt;
&lt;li&gt;페이지 새로고침이나 컴포넌트 이동 시 연결이 끊기는 문제
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;구독 확인 후 unsubscribe하고 다시 연결&lt;/li&gt;
&lt;li&gt;context api&lt;/li&gt;
&lt;li&gt;부모 컴포넌트에 연결 후 하위 컴포넌트로 전달&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;=&amp;gt; 웹소켓을 이용하는 기능이 채팅 뿐이라 방법 3으로 선택&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;backend&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일정 짧은 시간(1분 ~ 2분) 사이에 웹소켓 동작이 없을 시 연결이 끊기는 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;=&amp;gt; stomp 기본 설정으로 heart-beat 설정이 되어 있었지만 프론트엔드에서 연결이 풀리는 문제를 해결하고자 ping-pong 로직을 따로 생성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인&lt;/h3&gt;
&lt;figure id=&quot;og_1730622094276&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Chrome 88부터 체인으로 연결된 JS 타이머의 과도한 제한 &amp;nbsp;|&amp;nbsp; Blog &amp;nbsp;|&amp;nbsp; Chrome for Developers&quot; data-og-description=&quot;강도 조절은 페이지가 5분 이상 숨겨졌거나, 페이지가 30초 이상 무음 상태이고, WebRTC가 사용되지 않고, 타이머 체인이 5개 이상인 경우에 적용됩니다.&quot; data-og-host=&quot;developer.chrome.com&quot; data-og-source-url=&quot;https://developer.chrome.com/blog/timer-throttling-in-chrome-88?hl=ko&quot; data-og-url=&quot;https://developer.chrome.com/blog/timer-throttling-in-chrome-88?hl=ko&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://developer.chrome.com/blog/timer-throttling-in-chrome-88?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.chrome.com/blog/timer-throttling-in-chrome-88?hl=ko&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Chrome 88부터 체인으로 연결된 JS 타이머의 과도한 제한 &amp;nbsp;|&amp;nbsp; Blog &amp;nbsp;|&amp;nbsp; Chrome for Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;강도 조절은 페이지가 5분 이상 숨겨졌거나, 페이지가 30초 이상 무음 상태이고, WebRTC가 사용되지 않고, 타이머 체인이 5개 이상인 경우에 적용됩니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.chrome.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1730622141541&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;브라우저 최소화 상태에서 웹소켓 끊김 현상&quot; data-og-description=&quot;업무 중 겪었던 문제와 해결방법 기록. 현상 회사에서 진행 중인 프로젝트에서 mqtt.js 를 사용하는데, 브라우저를 최소화한 상태로 일정 시간이 지나면 웹소켓 연결이 끊기는 현상이 발견됐다. &quot; data-og-host=&quot;imsangin.tistory.com&quot; data-og-source-url=&quot;https://imsangin.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%B5%9C%EC%86%8C%ED%99%94-%EC%83%81%ED%83%9C%EC%97%90%EC%84%9C-%EC%9B%B9%EC%86%8C%EC%BC%93-%EB%81%8A%EA%B9%80-%ED%98%84%EC%83%81&quot; data-og-url=&quot;https://imsangin.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%B5%9C%EC%86%8C%ED%99%94-%EC%83%81%ED%83%9C%EC%97%90%EC%84%9C-%EC%9B%B9%EC%86%8C%EC%BC%93-%EB%81%8A%EA%B9%80-%ED%98%84%EC%83%81&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b1h971/hyXs2L2k64/AvjavlNvUfNqY0BONsEzD1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/BboEt/hyXsSQb4JD/JDdQdFGGXd6YZZI6BkISW0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/naJdx/hyXs1Gmh6N/kfHpdmdqQ76sLqxixDWSOK/img.png?width=264&amp;amp;height=200&amp;amp;face=0_0_264_200&quot;&gt;&lt;a href=&quot;https://imsangin.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%B5%9C%EC%86%8C%ED%99%94-%EC%83%81%ED%83%9C%EC%97%90%EC%84%9C-%EC%9B%B9%EC%86%8C%EC%BC%93-%EB%81%8A%EA%B9%80-%ED%98%84%EC%83%81&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://imsangin.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%B5%9C%EC%86%8C%ED%99%94-%EC%83%81%ED%83%9C%EC%97%90%EC%84%9C-%EC%9B%B9%EC%86%8C%EC%BC%93-%EB%81%8A%EA%B9%80-%ED%98%84%EC%83%81&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b1h971/hyXs2L2k64/AvjavlNvUfNqY0BONsEzD1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/BboEt/hyXsSQb4JD/JDdQdFGGXd6YZZI6BkISW0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/naJdx/hyXs1Gmh6N/kfHpdmdqQ76sLqxixDWSOK/img.png?width=264&amp;amp;height=200&amp;amp;face=0_0_264_200');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;브라우저 최소화 상태에서 웹소켓 끊김 현상&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;업무 중 겪었던 문제와 해결방법 기록. 현상 회사에서 진행 중인 프로젝트에서 mqtt.js 를 사용하는데, 브라우저를 최소화한 상태로 일정 시간이 지나면 웹소켓 연결이 끊기는 현상이 발견됐다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;imsangin.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;(원인 파악에 있어서 참고한 링크들)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; stomp heart-beat 설정처럼 일정 간격에 따라 메세지를 전달하고, 마지막으로 전달한 ping의 여부에 따라 연결 상태를 파악하는 방법을 사용했다. heart-beat의 경우 해당 브라우저에서 이탈하지 않으면 로그아웃 상태가 되더라도 지속적으로 ping을 보내기 때문에 이것을 내 입맛대로 잡아내기가 어려웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. WebSocketController 생성&lt;/h4&gt;
&lt;pre id=&quot;code_1730622255667&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Controller
@RequiredArgsConstructor
public class WebSocketController {

    private final WebSocketService webSocketService;

    @MessageMapping(&quot;/ping&quot;)
    @SendTo(&quot;/sub/pong&quot;)
    public String handlePing(String message) {
        return &quot;pong&quot;;
    }

    @GetMapping(value = &quot;/last/connect&quot;)
    public ResponseEntity&amp;lt;WebSocketResponseDto&amp;gt; findLastWebsocket(@RequestParam(&quot;memberId&quot;) Long memberId) {
        WebSocketResponseDto webSocketResponseDto = webSocketService.findLastWebsocket(memberId);
        return ResponseEntity.ok(webSocketResponseDto);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ping-pong 로직 컨트롤러 생성&lt;br /&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;단순히 핑 하면 퐁 하고 돌아오는 컨트롤러다.&lt;/li&gt;
&lt;li&gt;프론트에서 wss로 연결해주고 stomp 연결해준 뒤에 첫번째 메세지로 바로 보내줄 수 있도록 설정할 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;마지막으로 웹소켓과 연결한 시간을 반환하는 컨트롤러 생성
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;꼭 필요한 컨트롤러는 아니지만 접속을 종료한 사이 참여 중인 채팅방에 메세지가 존재하는지 확인하고, 안읽은 채팅 알림 표시를 위해 마지막으로 웹소켓과 연결한 시간(종료 기준)을 저장했다.&lt;/li&gt;
&lt;li&gt;자세한건 서비스에서 ..&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. WebSocketService 생성&lt;/h4&gt;
&lt;pre id=&quot;code_1730623244364&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class WebSocketService extends TextWebSocketHandler {

    private final WebSocketRepository webSocketRepository;
    private final Map&amp;lt;String, Timer&amp;gt; sessionTimers = new ConcurrentHashMap&amp;lt;&amp;gt;();

    public WebSocketResponseDto findLastWebsocket(Long memberId) {

        List&amp;lt;WebSocket&amp;gt; webSocketList = webSocketRepository.findByMemberIdOrderByLastConnectDesc(memberId);
        if (webSocketList.isEmpty()) {
            throw new CustomException(CONNECTION_NOT_FOUND);
//            saveWebSocketConnection(memberId, null);
        }
        WebSocket mostRecentWebSocket = webSocketList.get(0);

        WebSocketResponseDto ResponseDto = new WebSocketResponseDto(mostRecentWebSocket);
        return ResponseDto;
    }

    // WebSocket 연결 시 MongoDB에 저장
    public void saveWebSocketConnection(Long memberId, String sessionId) {
        webSocketRepository.save(WebSocket.createLastConnect(memberId, sessionId, LocalDateTime.now().toString()));
        startPingTimeoutTimer(sessionId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;(레알 서비스단의 로직)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;마지막 웹소켓 연결시간을 찾는 서비스 -&amp;gt; 레포지토리에서 기본으로 제공하는 함수로 정렬한 후 가장 첫 시간을 가져왔다.&lt;/li&gt;
&lt;li&gt;처음 웹소켓 연결 정보를 저장하는 서비스(현재 접속중인 멤버의 id와 세션 id, 현재 타임스탬프를 저장)
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;아래 타이머에서 연결 종료 시 레포지토리에 연결 종료 시간을 저장해서 해당 멤버의 연결 시간을 알 수 있다.&lt;/li&gt;
&lt;li&gt;해당 정보는 조작 없이 단순히 읽기만 할 내용이라 RDBMS에 저장하기보다는 NOSQL에 저장하고 일정 기간마다 날려주는 게 좋을 것 같다고 판단, 채팅 기록이 저장되는 mongoDB에 저장했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1730623521021&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private void startPingTimeoutTimer(String sessionId) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                handlePingTimeout(sessionId);  // 타임아웃 발생 시 처리
            }
        }, 60000);  // 60초 후 타임아웃
        sessionTimers.put(sessionId, timer);
    }

    private void resetPingTimeoutTimer(String sessionId) {
        cancelPingTimeoutTimer(sessionId);
        startPingTimeoutTimer(sessionId);
    }

    private void cancelPingTimeoutTimer(String sessionId) {
        Timer timer = sessionTimers.remove(sessionId);
        if (timer != null) {
            timer.cancel();
        }
    }
    
    // ping 발생 시 타이머 리셋
    public void updateLastConnectTime(String sessionId) {
        List&amp;lt;WebSocket&amp;gt; webSocket = webSocketRepository.findBySessionIdOrderByLastConnectDesc(sessionId);

        if (webSocket != null) {
            resetPingTimeoutTimer(sessionId);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;차례로 ping의 타이머를 조작하는 로직들이다.
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;start: 타이머를 60초로 설정하고 초과할 시 타임아웃 서비스를 실행한다.&lt;/li&gt;
&lt;li&gt;reset: 타이머를 초기화하고 다시 타이머를 시작한다.&lt;/li&gt;
&lt;li&gt;cancel: 타이머 초기화 서비스&lt;/li&gt;
&lt;li&gt;update: ping이 발생할 경우 타이머 reset 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1730624197864&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    // 웹소켓 연결 종료 시 호출
    public void handleWebSocketDisconnection(String sessionId) {
        List&amp;lt;WebSocket&amp;gt; webSocket = webSocketRepository.findBySessionIdOrderByLastConnectDesc(sessionId);

        if (webSocket != null) {
            webSocket.get(0).updateLastConnect(LocalDateTime.now().toString());
            webSocketRepository.save(webSocket.get(0));
            cancelPingTimeoutTimer(sessionId);
        }
    }

    // 타임아웃 발생 시 WebSocket 연결 종료 처리
    public void handlePingTimeout(String sessionId) {
        List&amp;lt;WebSocket&amp;gt; webSocket = webSocketRepository.findBySessionIdOrderByLastConnectDesc(sessionId);

        if (webSocket != null) {
            webSocket.get(0).updateLastConnect(LocalDateTime.now().toString());  // 마지막 연결 시간 기록
            webSocketRepository.save(webSocket.get(0));
        }
        handleWebSocketDisconnection(sessionId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;웹소켓이 정상적으로 종료되거나(DISCONNECT), Timeout이 발생하는 경우 동일한 세션 id를 가져와 마지막 연결 시간을 업데이트 및 저장하고, 타이머를 종료한다.&lt;/li&gt;
&lt;li&gt;지금 보니까 두 서비스가 똑같은 내용이라 분리할 필요가 없는 것 같다..^^ timeout 시 이중으로 저장하겠다..&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. StompHandler 수정&lt;/h4&gt;
&lt;pre id=&quot;code_1730624039065&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;        if (StompCommand.CONNECT.equals(stompHeaderAccessor.getCommand())) {
			// 인증 관련 로직들...
            webSocketService.saveWebSocketConnection(memberId, sessionId);

        }

        else if (StompCommand.DISCONNECT.equals(stompHeaderAccessor.getCommand())) {
            String sessionId = stompHeaderAccessor.getSessionId();
            webSocketService.handleWebSocketDisconnection(sessionId);
        }

        else if (StompCommand.SEND.equals(stompHeaderAccessor.getCommand())) {
            if (&quot;/pub/ping&quot;.equals(stompHeaderAccessor.getDestination())) {
                String sessionId = stompHeaderAccessor.getSessionId();
                webSocketService.updateLastConnectTime(sessionId);
            }
        }
        return message;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;StompCommand == CONNECT인 경우: 최초 연결시간을 저장한다(save)&lt;/li&gt;
&lt;li&gt;StompCommand == DISCONNECT인 경우: 정상 종료로 판단, disconnection 서비스를 실행한다.&lt;/li&gt;
&lt;li&gt;StompCommand에서 ping메세지를 SEND한 경우: 정상 유지로 판단, 타이머를 update한다.&lt;/li&gt;
&lt;li&gt;타이머 분기를 거치고 message를 리턴한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 프론트에서 웹소켓 연결 중앙 처리&lt;/h4&gt;
&lt;pre id=&quot;code_1730624999774&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const connectWebSocket = (chatRooms) =&amp;gt; {
    if (!stompClient.value || !stompClient.value.connected) {
        const socket = new SockJS('/wss');
        stompClient.value = Stomp.over(socket);
        stompClient.value.debug = function (string) {
            if (!(string.includes('ping')) &amp;amp;&amp;amp; !(string.includes('pong'))) {
            console.log(string);
            }
        };
    }

        stompClient.value.connect({ Authorization: `${accessToken}` }, () =&amp;gt; {
            subscribeChatRoom(chatRooms);
            setInterval(() =&amp;gt; {
                lastPingTime.value = new Date();
                stompClient.value.send(&quot;/pub/ping&quot;, {}, JSON.stringify({ message: 'ping' }));

                clearTimeout(timeoutHandle);
                timeoutHandle = setTimeout(() =&amp;gt; {
                    handlePingTimeout();
                }, 60000);
            }, 20000);  
            
            stompClient.value.subscribe('/sub/pong', (message) =&amp;gt; {
                console.log(&quot;Pong 받음:&quot;, message.body);
                clearTimeout(timeoutHandle);
            });
        }, (error) =&amp;gt; {
            console.error('채팅방을 연결하는 데 실패했습니다.', error);
        });
    };&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프론트에서 웹소켓 연결과 동시에 ping pong 서비스를 끌어와 ping 메세지를 전송하고 pong 채널을 구독한다.&lt;/li&gt;
&lt;li&gt;stomp 메세지가 좀 콘솔로 찍히면 내용이 많기도 하고.. 1분마다 찍히면 너무 메세지 고봉밥이라서 'ping'이나 'pong'이 들어가는 메세지는 콘솔에서 무시하기로 했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1730625208378&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const findLastWebSocketApi = async (memberId) =&amp;gt; {
    try {
        const response = await axios.get(`/chat/last/connect?memberId=${memberId}`);
        lastPingTime.value = response.data.lastConnect;
        console.log(&quot;lastPingTime: &quot;, lastPingTime.value);

        findLastMessageApi();
    } catch (err) {
        console.error(&quot;웹소켓 저장 시간을 가져오는데 실패했습니다.&quot;, err);
    }
}

const findLastMessageApi = async () =&amp;gt; {
    try {
        const response = await axios.get(&quot;/chat/last/message&quot;);
        lastMessageTime = response.data;
        console.log(&quot;lastMessageTime: &quot;, lastMessageTime);

        filterNewMessages(lastMessageTime);
    } catch (err) {
        console.error(&quot;마지막 메세지 저장 시간을 가져오는데 실패했습니다.&quot;, err);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;안읽은 알림 처리를 위한 마지막 웹소켓 저장 시간과 구독한 채팅방에 대한 마지막 메세지 저장 시간을 찾는다.&lt;/li&gt;
&lt;li&gt;원래였으면 구독한 채팅방들마다 메세지를 300개정도 읽고, &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;마지막 웹소켓 저장 시간을 기준으로&lt;span&gt; 이분탐색해서 메세지 개수를 카운트하는 방법(멋져보였다!)을 사용하려고 했는데, 시간적인 문제로 마지막 한개의 메세지만 읽고 마지막 웹소켓 저장 시간 이후면 알림 뱃지를, 그 이전이면 표시하지 않았다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1730625399470&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const props = defineProps({
    chat: {
        type: Object,
        required: true,
    },
    showChatRoom: {
        type: Boolean,
        required: true
    },
    messages: Array,
    stompClient: Object
});

const sendMessage = async () =&amp;gt; {
    if (!newMessage.value || newMessage.value.trim() === &quot;&quot;) {
        return;
    }

    const message = {
        memberId: loggedInMemberId.value,
        nickname: loggedInMemberName.value,
        message: newMessage.value,
    };

    props.stompClient.send(`/pub/chat/${props.chat.chatRoomId}`, {}, JSON.stringify(message));
    scrollToBottom();
    newMessage.value = '';
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;부모 컴포넌트에서는 웹소켓과 채팅방 구독 관련한 모든 로직을 처리하고, 하위 컴포넌트인 채팅방에서는 stompClient에 대한 정보를 props로 전달받았다.&lt;/li&gt;
&lt;li&gt;여기서는 메세지 전송만 처리했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;채팅방_메세지_전송.png&quot; data-origin-width=&quot;732&quot; data-origin-height=&quot;1121&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9YLKL/btsKuUUTRoH/e1lbImbU6Jx6SzInLrzUXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9YLKL/btsKuUUTRoH/e1lbImbU6Jx6SzInLrzUXK/img.png&quot; data-alt=&quot;프론트 많이 깎았다..!!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9YLKL/btsKuUUTRoH/e1lbImbU6Jx6SzInLrzUXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9YLKL%2FbtsKuUUTRoH%2Fe1lbImbU6Jx6SzInLrzUXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;335&quot; height=&quot;513&quot; data-filename=&quot;채팅방_메세지_전송.png&quot; data-origin-width=&quot;732&quot; data-origin-height=&quot;1121&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;프론트 많이 깎았다..!!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 위 과정들을 통해서 웹소켓이 연결 도중 끊기거나 중복 구독 문제가 발생하지 않도록 해결할 수 있었다. 끊기는 것도 따로 어느 정도 시간 기준인지 모르겠어서 내가 잘못 코드를 짠건지 웹소켓이 이상한 건지 판단이 어려웠는데 이런 문제가 있었다,..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자꾸 연결이 내 맘같지 않다 보니 처음에는 채팅방에 접근할 때마다 웹소켓을 새로 연결하거나, 채팅 목록을 열 때마다 연결하는 방식을 사용했는데, 그렇게 하다 보니 또 1분 내로 동작했던 소켓의 경우 중복 구독이 되면서 토스트 메세지 알림이 이중 삼중으로 오는 문제가 연달아서 생기고 했는데 그래도 소켓 연결 때문에 신경 쓰이는 문제는 많이 잡은 것 같다.&lt;/p&gt;</description>
      <category>Code</category>
      <category>spring</category>
      <category>Stomp</category>
      <category>vue.js</category>
      <category>websocket</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/105</guid>
      <comments>https://henhen.tistory.com/105#entry105comment</comments>
      <pubDate>Sun, 3 Nov 2024 18:23:40 +0900</pubDate>
    </item>
    <item>
      <title>Argo cd</title>
      <link>https://henhen.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(2024.10.17)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 환경 세팅&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;kubernetes ingress 접근&lt;br /&gt;* service: L4 수준의 로드밸런서&lt;/li&gt;
&lt;li&gt;ingress: 요청을 원하는 pod로 분산(로드밸런싱), L7 계층 -&amp;gt; 클러스터 외부에서 http요청을 통해서 라우팅해서 pod로 분산&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이미지 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker&amp;nbsp;build&amp;nbsp;--no-cache&amp;nbsp;-t&amp;nbsp;testweb:1.0&amp;nbsp;./&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;object 생성&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. service object&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부에서 직접 접근이 아니라 ingress를 통해서 접근하므로 yaml -&amp;gt; ClusterIP type으로 생성&lt;br /&gt;* port: 서비스 포트, targetPort: 포드의 포트&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. ingress object&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ingress.yaml&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ingress 실행 규칙을 지정한 파일&lt;/li&gt;
&lt;li&gt;ingress nginx 컨트롤러 사용할 예정이므로 yaml에 관련 설정 추가&lt;/li&gt;
&lt;li&gt;host: 도메인 지정해서 해당 경로로 요청 전송(kuweb.beyond.com: kuweb -&amp;gt; 서브 도메인, beyond.com -&amp;gt; 도메인)&lt;br /&gt;(hosts 파일을 수정해서 로컬로 테스트 가능하도록 함)&lt;/li&gt;
&lt;li&gt;과정: url(domain(.com)/endpoint) 요청을 했을 때 컨트롤러가 service에 도메인을 제외한 요청 전달 -&amp;gt; service는 엔드포인트에 지정된 pod로 요청 전송&lt;br /&gt;kubectl apply ingress.yaml&lt;br /&gt;kubectl get ingress, kubectl describe ingress로 확인
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;rules: host/endpoint로 요청을 넘기면 kuweb-service:8080의 3개 포트 중 하나로 요청을 넘긴다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ingress controller: ingress-nginx&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/kubernetes/ingress-nginx&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/kubernetes/ingress-nginx&lt;/a&gt; 에서 깃 클론&lt;/li&gt;
&lt;li&gt;ingress-nginx/deploy/static/provider/cloud/deploy.yaml -&amp;gt; 366행 type: NodePort로 변경한 후 kubectl apply
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&amp;nbsp;kubectl get all -n ingress-nginx했을 때 create 시 job.batch~가 1회 실행된 후 completions 됨. controller가 running status인지 확인&lt;/li&gt;
&lt;li&gt;&amp;nbsp; 80 포트(http), 443 포트(https) 생략 가능 -&amp;gt; service/ingress-nginx-controller의 포트 생성 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;kubectl logs -f ingress-nginx-controller-7f9bbf6ddd-2nwtw -n ingress-nginx로 로그 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로컬에서 테스트를 진행하기 위해 hosts 파일에서 host를 등록&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메모장 관리자 권한으로 실행&lt;br /&gt;mac -&amp;gt; sudo vi /private/etc/hosts&lt;br /&gt;window -&amp;gt; System32/drivers/etc/hosts&lt;/li&gt;
&lt;li&gt;내부에&amp;nbsp;127.0.0.1&amp;nbsp;&amp;nbsp; &amp;nbsp; &amp;nbsp; kuweb.beyond.com&amp;nbsp;추가&lt;/li&gt;
&lt;li&gt;domain:port값으로 요청 전달(ex: kuweb.beyond.com:31438)&lt;br /&gt;* 동작 과정: kuweb-service 위에 ingress를 두고 url open -&amp;gt; 서비스(kuweb-service)가 도커 클러스터로 생성된 test-web1 ~ 3에 요청 분산, 즉 서비스에 직접 연결하지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1436&quot; data-origin-height=&quot;724&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brj0bW/btsJ8K7qYsM/T2Xh3n6KvD8uWPR2THdIF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brj0bW/btsJ8K7qYsM/T2Xh3n6KvD8uWPR2THdIF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brj0bW/btsJ8K7qYsM/T2Xh3n6KvD8uWPR2THdIF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbrj0bW%2FbtsJ8K7qYsM%2FT2Xh3n6KvD8uWPR2THdIF1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;628&quot; height=&quot;317&quot; data-origin-width=&quot;1436&quot; data-origin-height=&quot;724&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;Argo cd&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Https 서버 통신 -&amp;gt; 인증서 발급 필요&lt;/li&gt;
&lt;li&gt;TLS: 네트워크 보안 프로토콜. 데이터를 암호화해서 보호(https 사용할 때 tls 기반으로 구현) -&amp;gt; ingress에 TLS 설정 추가해서 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1704&quot; data-origin-height=&quot;902&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bV0ujU/btsJ8qVF98W/iS1lu57geD6ACwrDlPT8l1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bV0ujU/btsJ8qVF98W/iS1lu57geD6ACwrDlPT8l1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bV0ujU/btsJ8qVF98W/iS1lu57geD6ACwrDlPT8l1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbV0ujU%2FbtsJ8qVF98W%2FiS1lu57geD6ACwrDlPT8l1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;781&quot; height=&quot;413&quot; data-origin-width=&quot;1704&quot; data-origin-height=&quot;902&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. cert-manager&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://cert-manager.io/docs/installation/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://cert-manager.io/docs/installation/&lt;/a&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;kubernetes의 cert-manager를 통해 인증서 발급 -&amp;gt; ingress에 적용&lt;/li&gt;
&lt;li&gt;인증서 발급, 갱신, 관리 자동화&lt;/li&gt;
&lt;li&gt;로컬 테스트를 위해 자체 서명 발급&lt;br /&gt;certificates: 인증서&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;install&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e1e59; text-align: start;&quot;&gt;helm repo add jetstack &lt;a href=&quot;https://charts.jetstack.io&quot;&gt;https://charts.jetstack.io&lt;/a&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;helm install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --version v1.16.1 --set crds.enabled=true&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;resource 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;cluster-issuer.yaml 생성 -&amp;gt; kubectl apply&amp;nbsp;-f&amp;nbsp;./cluster-issuer.yaml&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1729150892064&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-cluster-issuer
spec:
  selfSigned: {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;kubectl get ClusterIssuer -&amp;gt; ready:true 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;issuer을 통해서 인증서 발급&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ingress에만 TLS 설정하기 위함(외부에서 접근할 때만 https 통신) // 클러스터 내부 pod끼리는 http 통신으로 효율적 관리&lt;/li&gt;
&lt;li&gt;kuweb-ingress.yaml 수정 -&amp;gt; apply
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;annotation -&amp;gt; cert-manager cluster-issuer 설정 추가(cluster-issuer의 kind가 ClusterIssuer임)&lt;/li&gt;
&lt;li&gt;tls - secretName 추가: ingress에서 사용하는 Name, -hosts 추가: 인증서 적용할 도메인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;kubectl get certificate / kubectl get secret -&amp;gt; tls 생성 확인 -&amp;gt; 이제 https port로 접근해야 한다&lt;br /&gt;* ingress 설정 yaml에 해당 내용 추가했기 때문에 ingress 생성 시 자동으로 생성됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;566&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dahH5N/btsJ9NI1Rwv/KIKzk0CekrwDWEnXapo39k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dahH5N/btsJ9NI1Rwv/KIKzk0CekrwDWEnXapo39k/img.png&quot; data-alt=&quot;자체 인증이라서 인증서는 유효하지 않지만 tls 통신한다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dahH5N/btsJ9NI1Rwv/KIKzk0CekrwDWEnXapo39k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdahH5N%2FbtsJ9NI1Rwv%2FKIKzk0CekrwDWEnXapo39k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;331&quot; height=&quot;319&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;566&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;자체 인증이라서 인증서는 유효하지 않지만 tls 통신한다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(2024.10.18)&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Argo cd 개요, Install&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개요&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitOps 패턴 사용 -&amp;gt; 애플리케이션의 배포, 관리 자동화&lt;/li&gt;
&lt;li&gt;깃에서 설정 내용 관리하므로 안정적 배포 환경 제공&lt;/li&gt;
&lt;li&gt;다양한 클러스터에 접근해서 효율적으로 상태 관리&lt;/li&gt;
&lt;li&gt;Health check 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;install&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설치파일&lt;br /&gt;helm repo add argo &lt;a href=&quot;https://argoproj.github.io/argo-helm&quot;&gt;https://argoproj.github.io/argo-helm&lt;/a&gt;&lt;br /&gt;helm repo update&lt;br /&gt;kubectl create namespace argocd&lt;br /&gt;파일 이동: helm show values argo/argo-cd &amp;gt; argo-cd-values.yaml&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;argo-cd-values.yaml 파일 수정&lt;br /&gt;global:domain: argocd.도메인:443port(e.g. argocd.beyond.com:32464)&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;agrocd-ingress.yaml 파일 생성&lt;br /&gt;tls: secret를 이용해서 관리, 인증서를 적용할 hosts 설정&lt;br /&gt;*argocd-server: 서비스에 접근할 때 사용하는 pod&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;구성파일명을 통해서 argocd 설치&lt;br /&gt;helm install argocd -n argocd argo/argo-cd -f ./argo-cd-values.yaml&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;secret에 tls 인증서 생성 확인&lt;br /&gt;kubectl get secret -n argocd&lt;br /&gt;kubectl get certificate -n argocd&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;로컬에서 테스트를 진행하기 위해 hosts 파일에서 host를 등록&lt;br /&gt;mac -&amp;gt; sudo vi /private/etc/hosts&lt;br /&gt;내부에&amp;nbsp;127.0.0.1&amp;nbsp;&amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;nbsp;argocd.beyond.com 추가&lt;br /&gt;안전하지 않음 으로 이동 후 argo cd 리다이렉트 확인&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;3. 초기 설정&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;초기 패스워드 확인&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(git)bash&amp;gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath=&quot;{.data.password}&quot; | base64 -d&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;결과값: (초기 패스워드)(컴퓨터 계정 정보)&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개요&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;젠킨스의 경우 서비스를 생성해서 직접 localhost로 접근하는 반면, argocd는 ingress를 통해서 url을 생성해서 도메인으로 접근&lt;/li&gt;
&lt;li&gt;Applications에서 관리 -&amp;gt; git에서 application 정보를 가져와서 git과 클러스터 내부에서 변경된 파일을 대조해서 동기화 작업&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;레포지토리&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;레포지토리 생성
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;argo cd 설정 파일을 보관할 레포지토리 생성 -&amp;gt; 따로 레포지토리를 분리하므로 application별로 관리하기 용이하다&lt;br /&gt;* 관리할 application 내에 설정 파일을 보관할 경우, application 이동 및 관리가 어려울 수 있다.&lt;/li&gt;
&lt;li&gt;application 별로 폴더를 생성하여 내부에 해당 애플리케이션에 대한 yaml 파일 저장(deployment, service, ingress)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;ssh 키 생성
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;ssh-keygen -t ed25519 -C &quot;메일 주소&quot;&lt;/li&gt;
&lt;li&gt;github repository - settings - deploy key 등록(공개키 -&amp;gt; cat id_ed25519.pub) -&amp;gt; Allow write access 허용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;깃 레포지토리 등록
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;settings - repositories - connect repo: VIA SSH, Name(Argo cd에서 식별할 이름), project default, repo url, private key(&amp;nbsp;id_ed25519)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;&lt;span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start;&quot;&gt;리소스 생성&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;git 저장소의 kuweb 폴더 내에 있는 yaml 설정 파일을 통해 클러스터에 리소스 생성&lt;/li&gt;
&lt;li&gt;applications - new app&lt;br /&gt;name: 필요 시 kuweb-dev, kuweb-prod 등 설정&lt;br /&gt;sync policy: 동기화 정책(개발 환경에서 automatic를 쓰기도 함)&lt;br /&gt;Repository URL: argo cd와 연결된 레포지토리 주소&lt;br /&gt;Path: application의 설정 정보 파일을 찾을 경로&lt;br /&gt;destination: 리소스를 생성할 클러스터명(dev, main 등으로 클러스터를 분리하기도 한다)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1f2328;&quot;&gt;&lt;span style=&quot;caret-color: #1f2328; background-color: #ffffff;&quot;&gt;실행 확인&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1340&quot; data-origin-height=&quot;672&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WMo61/btsKaxUsOjn/Yt99yNGldLK8V9aLPY0bBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WMo61/btsKaxUsOjn/Yt99yNGldLK8V9aLPY0bBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WMo61/btsKaxUsOjn/Yt99yNGldLK8V9aLPY0bBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWMo61%2FbtsKaxUsOjn%2FYt99yNGldLK8V9aLPY0bBK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;617&quot; height=&quot;309&quot; data-origin-width=&quot;1340&quot; data-origin-height=&quot;672&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1656&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dBKaHZ/btsKauiVuDd/pSNka7psolSkxDEw3kNpi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dBKaHZ/btsKauiVuDd/pSNka7psolSkxDEw3kNpi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dBKaHZ/btsKauiVuDd/pSNka7psolSkxDEw3kNpi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdBKaHZ%2FbtsKauiVuDd%2FpSNka7psolSkxDEw3kNpi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;827&quot; height=&quot;50&quot; data-origin-width=&quot;1656&quot; data-origin-height=&quot;100&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OutOfSync: git 저장소와 상태가 일치하지 않아 동기화가 필요한 상태&lt;br /&gt;Sync OK, Healthy: health check 및 동기화가 이루어진 상태&lt;br /&gt;Diff: git 레포지토리 상의 상태와 쿠버네티스에 배포된 상태가 일치하지 않는 경우, 차이 확인 가능&lt;br /&gt;Sync: git 저장소의 내용과 클러스터에서 실행되고 있는 애플리케이션을 동기화&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2088&quot; data-origin-height=&quot;650&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/exLaQ8/btsKb0tTxiC/l4hey4TMK6n6kwvqST7uFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/exLaQ8/btsKb0tTxiC/l4hey4TMK6n6kwvqST7uFk/img.png&quot; data-alt=&quot;object 생성을 시각적으로 체크할 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/exLaQ8/btsKb0tTxiC/l4hey4TMK6n6kwvqST7uFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FexLaQ8%2FbtsKb0tTxiC%2Fl4hey4TMK6n6kwvqST7uFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2088&quot; height=&quot;650&quot; data-origin-width=&quot;2088&quot; data-origin-height=&quot;650&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;object 생성을 시각적으로 체크할 수 있다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;변경사항이 있는 경우, deploy에서 버전을 변경한 후 commit change하면 sync status가 OutOfSync로 바뀌는 것 확인 가능&lt;br /&gt;* Sync할 경우, 새로운 replica set을 생성해서 pod 생성 및 롤링 업데이트 진행 후 기존의 pod 삭제하고 배포&lt;/li&gt;
&lt;li&gt;History and Rollback 메뉴를 통해 빠른 롤백 가능&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;4. jenkins 연결&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;webhook 연결(ngrok)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ngrok http http://localhost:30020&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;dockerfile 변경점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터를 복사해서 이미지 내에서 빌드를 처리 -&amp;gt; 앞서 만든 base image에서 같은 별칭인 이미지를 복사해 from 2번째 이미지로부터 root 디렉터리로 복사&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;credentials 추가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;manifests 저장소 접근 credential -&amp;gt; key에 private key 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #14141f; text-align: start;&quot;&gt;jenkins item 생성&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;pipeline
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;general: giithub project -&amp;gt; SSH clone url copy&lt;/li&gt;
&lt;li&gt;pipeline: pipeline script from scm -&amp;gt; git -&amp;gt; SSH clone url copy, 저장소 접근 credential 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;argo cd 설정 파일을 보관하는 레포지토리(manifests)에 Jenkinsfile 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #14141f; text-align: start;&quot;&gt;argo cd에서 app 생성&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;source
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #14141f; background-color: #ffffff; letter-spacing: 0px;&quot;&gt;repository url: &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #14141f; text-align: start;&quot;&gt;argo cd 설정 파일을 보관하는 레포지토리(manifests) ssh clone url copy&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #14141f; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #14141f; text-align: start;&quot;&gt;ssh agent plugin 플러그인 설치 후 파이프라인 테스트&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;department&amp;nbsp;repository 변동 사항 발생&lt;/li&gt;
&lt;li&gt;jenkins - department에서 웹훅 발생&lt;/li&gt;
&lt;li&gt;department 파이프라인 실행해서 docker-hub에 이미지 업로드까지 완료&lt;/li&gt;
&lt;li&gt;department pipeline 에서 이미지 생성 후 trigger 발생해서 manifests pipeline 실행&lt;/li&gt;
&lt;li&gt;manifests pipeline 실행으로 배포 완료 후 department 파이프라인 실행 완료(manifests pipeline 완료까지 대기)&lt;/li&gt;
&lt;li&gt;argo cd에서 sync 확인 후 배포 상태 확인&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;** 5. 무중단 배포 관련&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;deploy.yaml 파일
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;probe에 해당하는 요청을 보내면서 지속적으로 health check하고 기존에 있던 pod를 지우고 새로운 pod를 생성하면서 롤링 무중단 배포 환경 구성&lt;/li&gt;
&lt;li&gt;health check에서 실패하면 배포를 실행하지 않는다&lt;br /&gt;health: health check&lt;br /&gt;health/liveness: 시간을 두고 check해서 애플리케이션이 실행 가능한지 확인&lt;br /&gt;health/readiness: 실제로 서비스가 가능한지 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;spring 설정 변경
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;pom.xml:&lt;br /&gt;management -&amp;gt; enable, health 관련 설정 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #14141f;&quot;&gt;&lt;span style=&quot;caret-color: #14141f; background-color: #ffffff;&quot;&gt;code//&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #14141f;&quot;&gt;&lt;span style=&quot;caret-color: #14141f; background-color: #ffffff;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #14141f;&quot;&gt;&lt;span style=&quot;caret-color: #14141f; background-color: #ffffff;&quot;&gt;&lt;a href=&quot;https://github.com/beyond-sw-camp-08/department&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/beyond-sw-camp-08/department&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/beyond-sw-camp-08/k8s-manifests&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/beyond-sw-camp-08/k8s-manifests&lt;/a&gt;&lt;/p&gt;</description>
      <category>수업 내용 정리</category>
      <category>argo cd</category>
      <category>CICD</category>
      <author>헨헨7</author>
      <guid isPermaLink="true">https://henhen.tistory.com/102</guid>
      <comments>https://henhen.tistory.com/102#entry102comment</comments>
      <pubDate>Thu, 17 Oct 2024 17:20:19 +0900</pubDate>
    </item>
  </channel>
</rss>