<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>da_na.log</title>
        <link>https://velog.io/</link>
        <description>컴퓨터공학과 학생이며, 백엔드 개발자입니다🐰</description>
        <lastBuildDate>Thu, 09 Jan 2025 08:16:11 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>da_na.log</title>
            <url>https://velog.velcdn.com/images/da_na/profile/2f9c13c7-eec4-408e-bb4f-4142a578e16c/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. da_na.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/da_na" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Spring] Header의 title을 변경하자!]]></title>
            <link>https://velog.io/@da_na/Spring-Header%EC%9D%98-title%EC%9D%84-%EB%B3%80%EA%B2%BD%ED%95%98%EC%9E%90</link>
            <guid>https://velog.io/@da_na/Spring-Header%EC%9D%98-title%EC%9D%84-%EB%B3%80%EA%B2%BD%ED%95%98%EC%9E%90</guid>
            <pubDate>Thu, 09 Jan 2025 08:16:11 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/da_na/post/b922bf8d-d5cd-4377-bf63-de36959b6541/image.png" alt=""></p>
<h2 id="1️⃣-서론">1️⃣ 서론</h2>
<p>현재 관리자가 웹 사이트의 Header 타이틀을 변경하고자 합니다. 이때, 해당 타이틀은 아래의 사진과 같이 왼쪽 상단에 어느 페이지든지 제공되어야 합니다. 또한 관리자가 변경하기 전에 <strong>로그인한 유저의 화면에도 변경된 타이틀이 반영</strong>되어야 합니다.
<img src="https://velog.velcdn.com/images/da_na/post/780fefd6-f786-43eb-8885-b36289f72a6a/image.png" alt=""></p>
<p>해당 Header와 관련된 정보는 아래와 같이 <code>&lt;h1&gt;MySite&lt;/h1&gt;</code> 고정된 값으로 받고 있었습니다. </p>
<pre><code class="language-java">&lt;%@ taglib uri=&quot;jakarta.tags.core&quot; prefix=&quot;c&quot;%&gt;
&lt;%@ taglib uri=&quot;jakarta.tags.fmt&quot; prefix=&quot;fmt&quot;%&gt;
&lt;%@ taglib uri=&quot;jakarta.tags.functions&quot; prefix=&quot;fn&quot;%&gt;
&lt;%@ page language=&quot;java&quot; contentType=&quot;text/html; charset=UTF-8&quot; pageEncoding=&quot;UTF-8&quot;%&gt;
&lt;div id=&quot;header&quot;&gt;
    &lt;h1&gt;MySite&lt;/h1&gt;
    &lt;ul&gt;
        &lt;c:choose&gt;
            &lt;c:when test=&quot;${empty authUser}&quot; &gt;
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/login&quot;&gt;로그인&lt;/a&gt;&lt;/li&gt;
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/join&quot;&gt;회원가입&lt;/a&gt;&lt;/li&gt;
            &lt;/c:when&gt;
            &lt;c:otherwise&gt;            
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/update&quot;&gt;회원정보수정&lt;/a&gt;&lt;/li&gt;
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/logout&quot;&gt;로그아웃&lt;/a&gt;&lt;/li&gt;
                &lt;li&gt;${authUser.name}님 안녕하세요 ^^;&lt;/li&gt;
            &lt;/c:otherwise&gt;
        &lt;/c:choose&gt;    
    &lt;/ul&gt;
&lt;/div&gt;</code></pre>
<p>따라서 2가지의 방법을 사용해서 변경되는 Header의 타이틀을 반영하도록 하겠습니다.</p>
<h2 id="2️⃣-본론-1--interceptor">2️⃣ 본론 1 : Interceptor</h2>
<p>가장 먼저, <strong>Interceptor</strong>를 생각해볼 수 있습니다. Interceptor는 아래의 사진과 같이 원하는 Controller로 넘어가기 전에 거쳐가는 역할을 합니다. 따라서 Client의 요청인 URL을 보고 나서 원하는 작업이 가능합니다. 
<img src="https://velog.velcdn.com/images/da_na/post/e7a7d8a4-f1c9-4168-b014-565254dfe388/image.png" alt=""></p>
<p>즉, 해당 사이트로 들어오는 사용자의 URL마다 title이 업데이트되었는지 확인하며 가져오는 코드를 Interceptor에 추가하여 최신의 title을 가져오도록 할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/46df4172-2a2f-4cc5-b19e-5d18c1b4bef5/image.png" alt="">
그리고 아래와 같이 spring-servlet.xml 파일에 interceptor를 추가하여 /**처럼 모든 URL마다 해당 interceptor를 적용할 수  있습니다.
<img src="https://velog.velcdn.com/images/da_na/post/1d26feb3-b5ad-4b2b-a298-5fff33c90573/image.png" alt=""></p>
<p>해당 코드의 가장 큰 단점으로는 <strong>매번 URL 요청마다 *<em>siteService.getSite().getTitle()이라는 코드가 실행되면서 *</em>쿼리가 항상 실행된다</strong>는 점입니다.</p>
<p>따라서 이를 개선하기 위해 타이틀이 수정될 때에만 쿼리를 요청하는 방법으로 수정해야 합니다.</p>
<h2 id="2️⃣-본론-2-interceptor--servletcontext">2️⃣ 본론 2. Interceptor + ServletContext</h2>
<p>따라서 ServletContext의 Attribute Map에 타이틀이 있는지 확인하고 없으면 쿼리문을 실행하고, 있으면 기존의 타이틀을 가져오도록 구현할 수 있습니다. 이때, 타이틀을 siteVo라는 객체에 담아 표현하였습니다.
<img src="https://velog.velcdn.com/images/da_na/post/4067843c-b65f-432b-9ad7-fe65bac76515/image.png" alt="">
 가장 먼저, spring-servlet.xml의 interceptors에 SiteInterceptor를 추가해줍니다. </p>
<pre><code class="language-xml">    &lt;!-- Interceptors --&gt;
    &lt;mvc:interceptors&gt;
        &lt;mvc:interceptor&gt;
            &lt;mvc:mapping path=&quot;/**&quot;&gt;&lt;/mvc:mapping&gt;
            &lt;mvc:exclude-mapping path=&quot;/assets/**&quot;&gt;&lt;/mvc:exclude-mapping&gt;
            &lt;bean class=&quot;mysite.interceptor.SiteInterceptor&quot;&gt;&lt;/bean&gt;
        &lt;/mvc:interceptor&gt;
    &lt;/mvc:interceptors&gt;</code></pre>
<p>그리고 아래의 코드에서 본격적으로 SiteInterceptor를 작성합니다. 이때 ServletContext에 siteVo가 있는지 확인하고 없으면 쿼리를 수행하여 siteVo를 가져오는 코드를 실행합니다.</p>
<pre><code class="language-java">public class SiteInterceptor implements HandlerInterceptor {

    private SiteService siteService;

    public SiteInterceptor(SiteService siteService) {
        this.siteService = siteService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        SiteVo siteVo = (SiteVo) request.getServletContext().getAttribute(&quot;siteVo&quot;);

        if(siteVo == null) { // siteVo가 ServletContext&#39;s Attribute Map이 없는 경우 세팅
            siteVo = siteService.getSite();
            request.getServletContext().setAttribute(&quot;siteVo&quot;, siteVo);
        }

        return true;
    }

}</code></pre>
<p>마지막으로 관리자가 타이틀과 같은 사이트 정보를 수정할 때 ServletContext의 siteVo를 수정해줍니다. <code>servletContext.setAttribute(&quot;siteVo&quot;, siteVo)</code></p>
<pre><code class="language-java">@Auth(role = &quot;ADMIN&quot;)
@Controller
@RequestMapping(&quot;/admin&quot;)
public class AdminController {

    private final SiteService siteService;
    private final FileUploadService fileUploadService;
  private final ServletContext servletContext;

    public AdminController(SiteService siteService, ServletContext servletContext, FileUploadService fileUploadService) {
        this.siteService = siteService;
        this.servletContext = servletContext;
        this.fileUploadService = fileUploadService;
    }

    @RequestMapping(&quot;/main/update&quot;)
    public String date(
        @RequestParam(&quot;title&quot;) String title, 
        @RequestParam(&quot;welcomeMessage&quot;) String welcomeMessage, 
        @RequestParam(&quot;description&quot;) String description, 
        @RequestParam(&quot;file1&quot;) MultipartFile file) {

        String url = Optional.ofNullable(fileUploadService.restore(file)).orElse(&quot;&quot;);

        SiteVo siteVo = new SiteVo();

        siteVo.setDescription(description);
        siteVo.setProfile(url);
        siteVo.setWelcome(welcomeMessage);
        siteVo.setTitle(title);

        siteService.updateSite(siteVo);
        servletContext.setAttribute(&quot;siteVo&quot;, siteVo);

        return &quot;redirect:/admin&quot;;
    }
}
</code></pre>
<p>그리고 헤더 관련 jsp에서 해당 siteVo.title를 출력할 수 있습니다.
<code>&lt;h1&gt;${siteVo.title }&lt;/h1&gt;</code></p>
<pre><code class="language-java">&lt;%@ taglib uri=&quot;jakarta.tags.core&quot; prefix=&quot;c&quot;%&gt;
&lt;%@ taglib uri=&quot;jakarta.tags.fmt&quot; prefix=&quot;fmt&quot;%&gt;
&lt;%@ taglib uri=&quot;jakarta.tags.functions&quot; prefix=&quot;fn&quot;%&gt;
&lt;%@ page language=&quot;java&quot; contentType=&quot;text/html; charset=UTF-8&quot; pageEncoding=&quot;UTF-8&quot;%&gt;
&lt;div id=&quot;header&quot;&gt;
    &lt;h1&gt;${siteVo.title }&lt;/h1&gt;
    &lt;ul&gt;
        &lt;c:choose&gt;
            &lt;c:when test=&quot;${empty authUser}&quot; &gt;
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/login&quot;&gt;로그인&lt;/a&gt;&lt;/li&gt;
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/join&quot;&gt;회원가입&lt;/a&gt;&lt;/li&gt;
            &lt;/c:when&gt;
            &lt;c:otherwise&gt;            
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/update&quot;&gt;회원정보수정&lt;/a&gt;&lt;/li&gt;
                &lt;li&gt;&lt;a href=&quot;${pageContext.request.contextPath}/user/logout&quot;&gt;로그아웃&lt;/a&gt;&lt;/li&gt;
                &lt;li&gt;${authUser.name}님 안녕하세요 ^^;&lt;/li&gt;
            &lt;/c:otherwise&gt;
        &lt;/c:choose&gt;    
    &lt;/ul&gt;
&lt;/div&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] JSON 형태의 데이터 저장하기]]></title>
            <link>https://velog.io/@da_na/MySQL-JSON-%ED%98%95%ED%83%9C%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/MySQL-JSON-%ED%98%95%ED%83%9C%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 22 Oct 2023 09:54:20 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 작성한 게시글에서도 말씀드렸듯이 저희 서비스에서는 DND을 이용한 보드에 스크랩을 추가하고 이동하는 기능인 보드 기능을 추가하였습니다.</p>
<p><a href="https://velog.io/@da_na/UUID-%EC%82%AC%EC%9A%A9%EC%9E%90%EC%97%90%EA%B2%8C-%EB%B3%B4%EC%97%AC%EC%A3%BC%EB%8A%94-URL-%EA%B4%80%EB%A0%A8-%EB%B3%B4%EB%93%9C%EC%9D%98-%EA%B3%A0%EC%9C%A0%ED%82%A4-%EA%B0%92-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0">https://velog.io/@da_na/UUID-사용자에게-보여주는-URL-관련-보드의-고유키-값-구성하기</a></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/eaf2c7a4-aa7e-47ae-9445-bbc30db5a329/image.png" alt=""></p>
<p>이때, 프론트 파트에서 <strong>React의 DND-kit 라이브러리</strong>를 사용하여, 보드 기능을 구현하였습니다.</p>
<p>아래의 링크는 저희 팀의 프론트 개발자의 관련 글입니다.</p>
<p><a href="https://velog.io/@hannatoo/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-jsx-import%ED%95%98%EA%B8%B0">https://velog.io/@hannatoo/프로젝트-타입스크립트-프로젝트에서-jsx-import하기</a></p>
<p>따라서 보드 기능을 사용하기 위해서 어떠한 DB를 가져야 하는지 데이터 구조가 적합한지 살펴보겠습니다!</p>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-보드-기능의-데이터-구조-살펴보기">1. 보드 기능의 데이터 구조 살펴보기</h2>
<p>위의 그림의 보드 DND-kit 라이브러리에서 사용하는 데이터 구조입니다.</p>
<p>헤딩 구조는 JSON과 같은 데이터 구조임을 알 수 있습니다.</p>
<p>즉, &quot;A&quot;는 첫번째 열을 의미하고, A열에 배열을 가지고 있으며 배열의 순서에 따라서 A열의 행이 정해집니다.</p>
<p>아래의 데이터 구조는 중요한 부분을 위주로 간략하게 나타내었습니다.</p>
<pre><code class="language-json">{&quot;A&quot;:[
    {&quot;scrapId&quot;:9,
    &quot;description&quot;:&quot;이번에 프로젝트에서 보드 관련된 기능을 제공하기로 하였습니다...&quot;,
    &quot;pageUrl&quot;:&quot;https://velog.io/@da_na/HTTP-API-URI-설계-프로젝트에-컨트롤-URI-설계하기&quot;,&quot;siteName&quot;:&quot;Velog&quot;,
    &quot;title&quot;:&quot;[HTTP API URI 설계] 프로젝트에 컨트롤 URI 설계하기&quot;,
    &quot;author&quot;:&quot;da_na&quot;,
    &quot;authorImageUrl&quot;:&quot;https://velog.velcdn.com/images/da_na/profile/image.jpg&quot;,
    &quot;blogName&quot;:&quot;da_na.log&quot;,
    &quot;dtype&quot;:&quot;article&quot;,
    &quot;id&quot;:&quot;9.616648527914574&quot;}],
&quot;B&quot;:[
    {&quot;scrapId&quot;:8,
    &quot;description&quot;:&quot;IS NULL을 사용하면 인덱스를 사용해도, 전체 table을 full scan한다는 글을 보았습니다...&quot;,
    &quot;pageUrl&quot;:&quot;https://velog.io/@da_na/SQL-튜닝-유저-조회-SQL-튜닝-및-성능-최적화-1&quot;,&quot;siteName&quot;:&quot;Velog&quot;,
    &quot;title&quot;:&quot;[실행계획] MySQL 데이터 베이스의 실행계획(Explain)을 통한 유저 테이블 인덱스 설계/수정 1&quot;,&quot;author&quot;:&quot;da_na&quot;,
    &quot;blogName&quot;:&quot;da_na.log&quot;,
    &quot;dtype&quot;:&quot;article&quot;,
    &quot;id&quot;:&quot;8.425313977182695&quot;}],
&quot;C&quot;:[
    {&quot;scrapId&quot;:7,
    &quot;description&quot;:&quot;이전에 마지막으로 이야기한 유저 조회 최적화 방법을 적용해보겠습니다...&quot;,
    &quot;pageUrl&quot;:&quot;https://velog.io/@da_na/실행계획-MySQL-데이터-베이스의-실행계획Explain을-통한-유저-테이블-인덱스-설계수정-2&quot;,
    &quot;siteName&quot;:&quot;Velog&quot;,
    &quot;title&quot;:&quot;[실행계획] MySQL 데이터 베이스의 실행계획(Explain)을 통한 유저 테이블 인덱스 설계/수정 2&quot;,
    &quot;author&quot;:&quot;da_na&quot;,
    &quot;blogName&quot;:&quot;da_na.log&quot;,
    &quot;dtype&quot;:&quot;article&quot;,
    &quot;id&quot;:&quot;7.837758400701152&quot;}]}</code></pre>
<hr>
<h2 id="2-mysql의-데이터-구조">2. MySQL의 데이터 구조</h2>
<p>저희 서비스는 MySQL인 RDBMS의 DB 구조를 가지고 있습니다.</p>
<p>따라서 RDBMS 구조로 보드의 기능을 구현하는 경우 기존에 저장되어 있는 스크랩과 보드를 연결해주는 또다른 스크랩-보드를 매핑하는 테이블을 추가하여, 보드 내의 해당 스크랩 위치 정보를 저장해야 합니다. </p>
<p>저희는 x를 열, y를 행을 저장하는 column을 추가해주었습니다.</p>
<p>위의 데이터 구조에서 몇 번째 열인지(예시 : &quot;A&quot;), 몇번째 행(&quot;A&quot;열에서 배열의 몇 번째 스크랩인지)인지를 저장하면 됩니다. </p>
<p>예시로 scrapId가 9번인 것은 x = A, y = 1를 저장할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/91fc26ba-b56a-40f4-bacc-b421b61224e2/image.png" alt=""></p>
<p>여기에서 살펴봐야 할 점은 장점과 단점을 살펴보고 해당 데이터 구조가 적합한지 살펴보겠습니다.</p>
<p>가장 큰 장점은 <strong>데이터가 중복되지 않는다</strong>는 점입니다.
스크랩의 정보가 이미 스크랩 테이블에 저장되었기 때문에 해당 scrapId만 참조하는 정보가 있으면 해당 스크랩의 정보를 따로 저장하지 않아도 됩니다.</p>
<p>첫 번째 단점은 보드 내에서 스크랩을 삭제하거나, 스크랩의 행을 변경하는 등 <strong>스크랩 위치를 변경하는 경우 다른 스크랩의 행과 열에 영향을 주게 됩니다</strong>.
저희 서비스는 DND를 사용하기 때문에 스크랩의 위치 변경이 매우 빈번할 것이라고 예상됩니다.</p>
<p>예를 들어, 아래의 표와 같이 스크랩이 보드안에 존재한다고 하면, 스크랩 ID가 1인 스크랩을 B 열에 1로 옮기겠습니다.</p>
<table>
<thead>
<tr>
<th>보드ID</th>
<th>스크랩 ID</th>
<th>x좌표</th>
<th>y좌표</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1</td>
<td>A</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>A</td>
<td>2</td>
</tr>
<tr>
<td>1</td>
<td>3</td>
<td>A</td>
<td>3</td>
</tr>
</tbody></table>
<p>그러면 같은 x좌표에 있었던 스크랩들의 y좌표 모두 변경해야 합니다.</p>
<table>
<thead>
<tr>
<th>보드ID</th>
<th>스크랩 ID</th>
<th>x좌표</th>
<th>y좌표</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1</td>
<td>B</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>A</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>3</td>
<td>A</td>
<td>2</td>
</tr>
</tbody></table>
<p>두 번째 단점은 DND-kit의 데이터 구조인 JSON으로 변경해줘야 합니다.</p>
<p>위의 DB의 데이터를 프론트에게 전달해주기 위해서 <code>A : [&quot;scrapId:1&quot;, &quot;scrapID:2&quot;, &quot;scrapID:3&quot;]</code> 이런식으로 변경해줘야 합니다.
더 나아가, 프론트에서 백엔드로 데이터를 전달해줄때에도 y좌표를 찾기 위해서 해당 배열을 돌면서 y좌표를 추출해내는 과정이 필요합니다.</p>
<p>그리고 마지막 단점은 <strong>JOIN문이 빈번해집니다.</strong></p>
<p>현재 보드 테이블과 스크랩 테이블을 매핑하는 테이블에서 스크랩 테이블을 참조하고 있기 때문에 해당 스크랩 ID와 스크랩 테이블의 ID를 조인해야 합니다. 그리고 해당 스크랩의 메모들도 별도의 메모 테이블이 존재하기 때문에, 스크랩 테이블과 메모 테이블을 조인하는 쿼리가 발생하게 됩니다.</p>
<p>보드에서는 업데이트가 계속 발생하므로 조회가 몇초 간격으로 일어나는 데 이때에 JOIN문이 다수 발생합니다.</p>
<p>장점 </p>
<ul>
<li>데이터가 중복되지 않는다.</li>
</ul>
<p>단점 </p>
<ul>
<li>행을 변경한 경우, 다른 스크랩의 모든 y를 변경해야 한다.</li>
<li>json으로 변환해줘야 한다.</li>
<li>join이 빈번하다.</li>
</ul>
<p>따라서 이러한 여러 단점이 있기 때문에 RDBMS를 활용한 데이터 구조는 적합하지 않다고 판단하였습니다.</p>
<hr>
<h2 id="3-nosql-데이터-구조">3. NoSQL 데이터 구조</h2>
<p>그러면 이러한 단점을 해결하기 위해서는 어떠한 데이터 구조가 적합할지 생각하던 중에 DND-kit의 데이터 구조와 매우 유사한 NoSQL 데이터 베이스를 사용하면 따로 위치 정보를 저장할 필요없이, 그대로 저장하면 되지 않을까??라는 생각을 하게 되었습니다.</p>
<p>따라서 NoSQL 중 가장 대표적인 DB인 MongoDB를 사용하려고 하였습니다.</p>
<p>하지만, MongoDB를 사용하면, 기존의 AWS RDS가 아닌 MongoDB 용 DB를 따로 생성해야 하며 사용해본 적이 없어서 학습 시간이 소요되는 등 시간 및 비용 측면에서 부담이 되었습니다. </p>
<p>그리고 RDBMS보다 NoSQL 데이터 베이스는 비교적 조회 속도가 훨씬 빠르지만, 업데이트와 삽입이 느리다는 단점이 있습니다.</p>
<p>더 나아가, 보드에서는 하나의 보드 안에 들어가는 스크랩을 조회하는 기능과 업데이트하는 기능만 존재하기 때문에 해당 보드 안에 스크랩을 검색하거나, 정렬하는 기능을 제공할 예정이 없어서 NoSQL의 장점을 잘 살릴 수 없었습니다.</p>
<hr>
<h2 id="4-json-형태를-mysql의-열에-저장">4. JSON 형태를 MySQL의 열에 저장</h2>
<p>따라서 저희는 MySQL의 장점과 NoSQL 구조를 살리기 위해서, 원래의 MySQL의 데이터 구조에서 하나의 열만을 추가하여 해당 열에 보드의 정보인 JSON 데이터구조를 저장하기로 하였습니다.</p>
<p>해당 열은 <code>@Column(columnDefinition = &quot;TEXT&quot;) private String contents;</code>
매우 긴 문자열 구조이기 때문에 TEXT 데이터 타입으로 데이터를 저장하였습니다.</p>
<pre><code class="language-java">public class Board extends BaseTimeEntity {

    @Id
    @GeneratedValue
    @Column(name = &quot;board_id&quot;)
    private Long id;

    @Column(columnDefinition = &quot;BINARY(16)&quot;, nullable = false, unique = true)
    private UUID uuid;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;user_id&quot;, nullable = false)
    private User user;

    @Column(length = 100, nullable = false)
    private String title;

    @Column(columnDefinition = &quot;boolean&quot;, nullable = false)
    private boolean isPublic;

    @Column(columnDefinition = &quot;boolean default false&quot;, nullable = false)
    private boolean isShared;

    private LocalDateTime fixedDate;

    @Column(nullable = false)
    private TAG tag;

    @Column(length = 1000)
    private String description;

    @Column(columnDefinition = &quot;TEXT&quot;)
    private String contents;

    @ColumnDefault(&quot;0&quot;)
    private Long heartCnt;

    @ColumnDefault(&quot;0&quot;)
    private Long viewCnt;

    @ColumnDefault(&quot;0&quot;)
    private Long shareCnt;
}</code></pre>
<p>첫 번째 단점으로는 &#39;스크랩 데이터가 중복된다.&#39;입니다.</p>
<p>스크랩 테이블이 따로 존재하지만, 해당 contents의 컬럼에도 해당 데이터가 동시에 들어가므로 데이터가 중복될 수 있습니다.</p>
<p>두 번째 단점은 &#39;유효성 검사가 되지 않는다&#39;입니다.</p>
<p>해당 컬럼이 null일 수도 있고, null이 아닐 수도 있어서 null 관련된 유효성을 검사하지 못하고, 데이터 구조가 올바르게 저장되었는지도 정확하게 파악할 수 없습니다.</p>
<p>세 번째 단점은 &#39;검색과 정렬 등 여러 기능을 사용하지 못한다.&#39;입니다.</p>
<p>그러나, 현재 저희 보드 서비스에서는 검색, 정렬을 사용하지 않으므로 해당되지는 않습니다. 그러나, 나중에 서비스가 더 고도화되어 검색, 정렬, 개수 조회등을 구현하게 된다면 MonogDB를 사용할 계획을 하고 있습니다.</p>
<p>하지만 이러한 단점에도 불구하고, 빠른 기능 구현 및 새로운 DB 구축 최소화와 같이 시간 및 비용 측면에서도 우수하고, 프론트와 백엔드 모두 정보를 가공할 필요가 없는 등 여러 장점이 더 많아서 해당 방법을 사용하기로 하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/23a608fa-36ec-4fe0-aea7-3f35c8b9ad99/image.png" alt=""></p>
<p>참고 자료 : <a href="https://velog.io/@effirin/DB%EC%97%90-JSON-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0">https://velog.io/@effirin/DB에-JSON-저장하기</a></p>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이전까지는 MySQL은 당연히 관계형 데이터구조를 가지고 있어야하고, NoSQL DB는 JSON과 같이 비관계형 데이터구조를 가지고 있으므로 두 가지를 혼합해서 사용하는 방법을 생각해보지 못했습니다. 그러나, 멘토님들의 조언과 참고자료를 바탕으로 장점과 단점을 비교해보니, 현재 저희 서비스에 적합한 DB 구조는 MySQL과 NoSQL 데이터 베이스 구조를 혼합한 구조라는 것을 알 수 있었습니다. 그러나, 여러 단점이 아직도 존재하기 때문에 완벽한 구조라고는 할 수 없지만 현재 상황에서는 빠른 개발과 배포 및 비용 측면에서 적합한 구조였고, 서비스가 더 고도화될수록 언제든지 데이터베이스 구조가 변경될 수 있기 때문에 앞으로 기능 개발을 위해서 MongoDB도 학습하여 철저하게 꼭 필요한 순간이 되면 도입할 수 있도록 대비해야겠다는 생각을 하게 되었습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UUID] 사용자에게 보여주는 URL 관련 보드의 고유키 값 구성하기]]></title>
            <link>https://velog.io/@da_na/UUID-%EC%82%AC%EC%9A%A9%EC%9E%90%EC%97%90%EA%B2%8C-%EB%B3%B4%EC%97%AC%EC%A3%BC%EB%8A%94-URL-%EA%B4%80%EB%A0%A8-%EB%B3%B4%EB%93%9C%EC%9D%98-%EA%B3%A0%EC%9C%A0%ED%82%A4-%EA%B0%92-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/UUID-%EC%82%AC%EC%9A%A9%EC%9E%90%EC%97%90%EA%B2%8C-%EB%B3%B4%EC%97%AC%EC%A3%BC%EB%8A%94-URL-%EA%B4%80%EB%A0%A8-%EB%B3%B4%EB%93%9C%EC%9D%98-%EA%B3%A0%EC%9C%A0%ED%82%A4-%EA%B0%92-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 17 Oct 2023 10:01:08 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>저희 서비스에서 이번에 새로운 기능으로 스크랩들을 정리하는 보드 기능을 추가하기로 하였습니다.</p>
<p>해당 보드에서 원하는 스크랩들을 추가하여, 주제에 맞는 스크랩들을 정리할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/b5258317-24e9-4b5d-84ce-6719890a0de5/image.png" alt=""></p>
<p>저희는 어떤 보드인지 식별해야 하므로, 유저가 직접 보는 보드의 URL에 보드의 고유 식별키를 넣는 방식을 선택하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/d370aa31-2278-45bf-825a-107b445011d9/image.png" alt=""></p>
<p>이때, 보드의 고유 식별키를 UUID로 할지 아니면, auto increment인 board_id로 할지를 선택해야 했습니다.</p>
<p>저희가 위의 사진처럼 boardUUID를 사용했는데, 왜 UUID를 선택하였는지 board_id로 하면 어떠한 문제가 발생할지 작성해보겠습니다.</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-auto-increment인-board_id를-선택하면-발생하는-문제점">1. Auto Increment인 board_id를 선택하면 발생하는 문제점</h2>
<p>board_id는 아래의 코드로 생성된 id로 GeneratedValue입니다.</p>
<pre><code class="language-java">public class Board extends BaseTimeEntity {

    @Id
    @GeneratedValue
    @Column(name = &quot;board_id&quot;)
    private Long id;
}</code></pre>
<h3 id="✍️-사용자가-임의로-id-값을-변경하여-오류-발생">✍️ 사용자가 임의로 ID 값을 변경하여 오류 발생</h3>
<p>이전의 스크랩 관련 API에서는 해당 스크랩을 식별하는 값으로 scrap의 id와 같이 자동으로 증가되는 키 값을 사용했습니다. 
이때에는 사용자가 직접 보는 URL이 아닌 API 자체에서 사용하고 있었습니다.
따라서 사용자가 직접적으로 id의 값을 볼 수 없습니다.</p>
<p>사용자에게 직접 공유되는 URL 자체에 id를 넣게 되면, 사용자가 손쉽게 임의로 ID 값을 변경하여 남의 보드를 보게 되는 경우가 발생할 수 있다고 생각했습니다.</p>
<p>물론 남의 보드를 보게 되는 경우, 해당 보드의 유저가 아니므로 에러가 발생하는 로직이 추가는 되어 있습니다.</p>
<p>하지만, 이러한 경우 API에서 발생하는 오류가 많아지고, 악의적인 유저에 의해 DB에서 해당 유저의 보드인지 확인하는 로직 등 API 서버에 요청을 많이 보내어 부담을 주게 될 수도 있습니다. </p>
<h3 id="✍️-db의-데이터-예측-가능">✍️ DB의 데이터 예측 가능</h3>
<p>Auto Increment를 사용하면, 저장되는 보드의 개수가 많아질 수록 숫자가 커지기 때문에 DB에 보드 데이터가 얼마나 존재하는지를 예측할 수 있습니다. 하지만, 올바르게 API 호출을 통해서 얻은 데이터 정보가 아닌 board_id 값으로 알 필요는 없다고 생각했습니다.</p>
<h3 id="✍️-단축-url">✍️ 단축 URL</h3>
<p>저희 서비스에서는 보드를 다른 사람들에게 공유할 수 있는 기능도 제공할 예정입니다.
그러나, 자동으로 증가하는 키를 사용하면 URL을 단축할 때 이때에도 key의 길이가 일정하지 않고 일정한 길이의 URL로 단축할 수 없다는 판단을 하였습니다.</p>
<p>즉, 6자리로 줄이려고 했는데, 이미 board_id가 1~2자리이면 동일한 단축 URL을 사용할 수 없습니다.</p>
<hr>
<h2 id="2-uuid이란">2. UUID이란?</h2>
<p>UUID는 네트워크 상에서 <strong>고유성이 보장되는 id를 만들기 위한 표준 규약</strong>입니다.</p>
<p>즉, Universally Unique IDentifier의 약어입니다.</p>
<p>32자리의 16진수로 표현되며, 8자리-4자리-4자리-4자리-12자리 패턴으로 되어 있습니다.</p>
<p>UUID는 사용자가 최대한 예측할 수 없는 값을 사용하고, DB 데이터 개수와 연관이 없습니다. 그리고 길이가 일정하기 때문에 단축 URL도 동일하게 모든 URL에 적용할 수 있습니다.</p>
<ul>
<li>UUID 종류 <ul>
<li>버전은 1,3,4,5가 있습니다.</li>
<li>이중 가장 많이 사용하는 버전은 1,4 버전입니다.</li>
<li>1버전 : 타임스탬프를 기준으로 생성합니다. -&gt; 호스트 ID, 시퀀스 번호 및 현재 시각으로 UUID를 발급합니다.</li>
<li>4버전 : 랜덤 생성 (무작위 UUID 생성)</li>
<li>주로 1버전보다는 4버전을 많이 사용합니다.</li>
<li>이때, 애플에서도 UUID를 생성할 때 4 버전을 사용하는데, 보안성이 높은 랜덤 생성 UUID인 4버전을 사용합니다.</li>
</ul>
</li>
</ul>
<p>따라서 저희는 사용자가 DB를 예측하면 안되고, 보안상으로 뛰어나며 일정한 길이를 가지고 있는 4버전의 UUID를 선택하여 보드의 키를 선택하겠습니다.</p>
<p>참고 자료 1 : <a href="https://mattmk.tistory.com/31">https://mattmk.tistory.com/31</a></p>
<p>참고 자료 2 : <a href="https://americanopeople.tistory.com/378">https://americanopeople.tistory.com/378</a></p>
<hr>
<h2 id="3-uuid-적용하기">3. UUID 적용하기</h2>
<p>Java에서의 UUID를 사용해보겠습니다.</p>
<p>자바에서는 java.util.UUID의 randomUUID로 4버전의 UUID를 생성할 수 있습니다.</p>
<pre><code class="language-java">import java.util.UUID;

public class UUIDService {
    public static UUID generateUUID() {
        return java.util.UUID.randomUUID();
    }
}</code></pre>
<p>그러면 위에서 본 UUID처럼 <code>f1c0e2e0-b2af-43e1-b4c9-3d017215c657</code>가 생성됩니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/118ba6da-8a6a-46ef-b113-8505a87bcb92/image.png" alt=""></p>
<p>이때, 위의 UUID에서 43e1에서 맨 앞의 4는 UUID의 버전을 나타냅니다.</p>
<p>따라서 4버전의 UUID가 정상적으로 생성되었음을 알 수 있습니다.</p>
<h2 id="4-uuid-컬럼-생성하기">4. UUID 컬럼 생성하기</h2>
<p>UUID가 겹칠 확률은 매우 매우 낮지만, <code>unique = true</code>를 설정하여 unique 값이 아닌 경우에는 보드를 생성할 수 없도록 설정을 하였습니다.</p>
<p>그리고 unique로 설정하면, 이전의 게시글에서도 볼 수 있듯이 index도 생성해주기 때문에 uuid로 보드를 찾을 때에도 조회 성능이 향상될 것입니다.</p>
<p><a href="https://velog.io/@da_na/%EC%8B%A4%ED%96%89%EA%B3%84%ED%9A%8D-MySQL-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B2%A0%EC%9D%B4%EC%8A%A4%EC%9D%98-%EC%8B%A4%ED%96%89%EA%B3%84%ED%9A%8DExplain%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%9C%A0%EC%A0%80-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%84%A4%EA%B3%84%EC%88%98%EC%A0%95-2">https://velog.io/@da_na/실행계획-MySQL-데이터-베이스의-실행계획Explain을-통한-유저-테이블-인덱스-설계수정-2</a></p>
<p>이때, UUID 컬럼은 <code>columnDefinition = &quot;BINARY(16)&quot;</code>으로 생성하였습니다.</p>
<p>UUID가 32자리의 숫자로 이루어져 있어서, CHAR(32)라고 정의할 수도 있지만, BIGINT(8바이트)보다 4배가 크기 때문에 Binary형태로 변환하면 크기를 절반 줄일 수 있고 Binary(16)으로 설정하였습니다.</p>
<p>참고 자료 : <a href="https://chanos.tistory.com/entry/MySQL-UUID%EB%A5%BC-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-%EC%9C%84%ED%95%9C-%EB%85%B8%EB%A0%A5%EA%B3%BC-%ED%95%9C%EA%B3%84">https://chanos.tistory.com/entry/MySQL-UUID를-효율적으로-활용하기-위한-노력과-한계</a></p>
<p>아래의 코드는 Board 테이블에 대한 코드입니다.</p>
<pre><code class="language-java">public class Board extends BaseTimeEntity {

    @Id
    @GeneratedValue
    @Column(name = &quot;board_id&quot;)
    private Long id;

    @Column(columnDefinition = &quot;BINARY(16)&quot;, nullable = false, unique = true)
    private UUID uuid;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;user_id&quot;, nullable = false)
    private User user;

    @Column(length = 100, nullable = false)
    private String title;

    @Column(columnDefinition = &quot;boolean&quot;, nullable = false)
    private boolean isPublic;

    private LocalDateTime fixedDate;

    @Column(nullable = false)
    private TAG tag;

    @Column(length = 1000)
    private String description;

    @Column(columnDefinition = &quot;TEXT&quot;)
    private String contents;

    @ColumnDefault(&quot;0&quot;)
    private Long heartCnt;

    @ColumnDefault(&quot;0&quot;)
    private Long viewCnt;

    @ColumnDefault(&quot;0&quot;)
    private Long shareCnt;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/46dd3662-3397-4451-a9f5-90c4a79df305/image.png" alt=""></p>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이전까지는 당연히 key값은 Auto Increment한 키일거야!라는 생각을 하고 있었고, UUID를 사용하면 길이가 길어지기 때문에 성능을 고려하면 board_id로 해야 되지 않을까하는 우려가 있었습니다. 그러나, 실제로 기능을 개발하면서 여러 가지의 상황을 비교하고 문제점을 파악하였을 때, 성능도 중요하지만 보안과 사용자의 측면에서 바라볼 때 UUID가 더 적합함을 파악하고 도입하게 되었습니다. 이처럼 이론으로 배울 때보다 <strong>실제 서비스를 구축해나가면서 운영에 대한 생각을 하게 되는 시각</strong>을 배우게 되었습니다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[실행계획] MySQL 데이터 베이스의 실행계획(Explain)을 통한 유저 테이블 인덱스 설계/수정 2]]></title>
            <link>https://velog.io/@da_na/%EC%8B%A4%ED%96%89%EA%B3%84%ED%9A%8D-MySQL-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B2%A0%EC%9D%B4%EC%8A%A4%EC%9D%98-%EC%8B%A4%ED%96%89%EA%B3%84%ED%9A%8DExplain%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%9C%A0%EC%A0%80-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%84%A4%EA%B3%84%EC%88%98%EC%A0%95-2</link>
            <guid>https://velog.io/@da_na/%EC%8B%A4%ED%96%89%EA%B3%84%ED%9A%8D-MySQL-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B2%A0%EC%9D%B4%EC%8A%A4%EC%9D%98-%EC%8B%A4%ED%96%89%EA%B3%84%ED%9A%8DExplain%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%9C%A0%EC%A0%80-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%84%A4%EA%B3%84%EC%88%98%EC%A0%95-2</guid>
            <pubDate>Sun, 15 Oct 2023 09:11:25 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 마지막으로 이야기한 유저 조회 최적화 방법을 적용해보겠습니다.</p>
<p><a href="https://velog.io/@da_na/SQL-%ED%8A%9C%EB%8B%9D-%EC%9C%A0%EC%A0%80-%EC%A1%B0%ED%9A%8C-SQL-%ED%8A%9C%EB%8B%9D-%EB%B0%8F-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-1">https://velog.io/@da_na/SQL-튜닝-유저-조회-SQL-튜닝-및-성능-최적화-1</a></p>
<p>그리고 해당 유저 조회 최적화 방법으로 얼마나 이전보다 성능이 향상되었는지를 확인해보는 시간을 가지도록 하겠습니다.</p>
<p>이전에 이야기한 최적화 방법은 아래와 같습니다.</p>
<ol>
<li>email을 unique 키로 변경하여 MySQL Explain의 type을 const로 변경한다.</li>
<li>email에 index를 생성합니다.</li>
</ol>
<ul>
<li>현재 아래의 사진처럼 users의 index는 primary인 user_id만 되어있습니다.</li>
<li>따라서, email 관련 index를 추가하여 테이블 풀 스캔을 하지 않도록 할 예정입니다.</li>
</ul>
<p>이때, 이전에는 유저가 1천만 명인 경우를 예를 들어서, 더미데이터를 생성했지만 실제 성능 향상을 비교할 때에는 실제 서비스 사용자가 될 가능성이 높은 1만명의 유저를 더미데이터로 생성하여 성능을 비교하고자 합니다.</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-email을-unique-index-생성하기">1. Email을 Unique index 생성하기</h2>
<p>이전에 이야기한 방법은 총 2가지 방법이었습니다.</p>
<ol>
<li>email을 unique 키로 변경한다.</li>
<li>email 관련 index를 생성한다.</li>
</ol>
<p>그러나, JPA에서는 unique 키로 설정해주면 index를 생성해주기 때문에 따로 index 설정은 하지 않아도 되기 때문에 아래의 코드처럼 unique = true만 추가하여 주겠습니다.</p>
<pre><code class="language-java">@Column(length = 320, nullable = false, unique = true)
private String email;</code></pre>
<p>그러면 아래의 그림과 같이 users 테이블의 indexes에 email이 생성되었음을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/64192f58-fd80-420f-a972-04a22336d781/image.png" alt=""></p>
<p>Users 테이블 관련 JPA 전체 코드입니다.</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@Entity
@ToString(exclude = &quot;scrapList&quot;)
@Table(name = &quot;users&quot;)
public class User extends BaseTimeEntity implements Serializable {

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

    @OneToMany(mappedBy = &quot;user&quot;)
    private List&lt;Scrap&gt; scrapList = new ArrayList&lt;&gt;();

    @Column(length = 100, nullable = false)
    private String name;

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

    @Column(length = 2083, nullable = false)
    private String profileUrl;

    @Column(nullable = false)
    private Provider provider;

    private String uuid;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;
}</code></pre>
<p>그리고 MySQL 데이터 베이스의 실행계획(Explain)의 결과를 확인해보겠습니다.</p>
<pre><code class="language-sql">EXPLAIN
    SELECT
        users.user_id,
        users.created_date,
        users.deleted_date,
        users.modified_date,
        users.email,
        users.name,
        users.profile_url,
        users.provider,
        users.role,
        users.uuid
    from
        users
    where
        users.email = &#39;7000@gmail.com&#39;
        and users.deleted_date is null</code></pre>
<ul>
<li><p>EMAIL UNIQUE 인덱스 전
<img src="https://velog.velcdn.com/images/da_na/post/32390324-0d5d-4865-a08e-8c759d8bc477/image.png" alt=""></p>
</li>
<li><p>EMAIL UNIQUE 인덱스 후
<img src="https://velog.velcdn.com/images/da_na/post/37183a38-2fb1-41eb-b2ac-961aaf5ead33/image.png" alt=""></p>
</li>
</ul>
<p>여기에서 가장 크게 달라진 점은 type 부분입니다.</p>
<p>즉, 이전에 예측한 결과처럼 EMAIL을 Unique로 변경하였기 때문에 반드시 1건의 레코드만을 반환한다는 조건을 만족하게 됩니다. </p>
<p>따라서 <strong>Type이 ALL 테이블 풀 스캔에서 const로 변경되었습니다</strong>.</p>
<p>다시 한번 const의 방식을 설명해보면, const는 테이블의 레코드 건수와 관계없이 쿼리가 프라이머리 키나 유니크 키 컬럼을 이용하는 WHERE 조건절을 가지고 있으며, 반드시 1건을 반환하는 쿼리의 처리 방식입니다.</p>
<p>Type에는 system, const, eq_ref, ref, ref_or_null, index_merge, unique_subquery, index_subquery, range, index, all 방식이 있는데 system에서 all로 갈 수록 성능이 느려지기 때문에 all에서 const로 변경되었으므로 성능이 향상되었다고 예측할 수 있습니다.</p>
<hr>
<h2 id="2-인덱스-생선-전-후의-성능-비교">2. 인덱스 생선 전 후의 성능 비교</h2>
<p>성능을 비교하기 전에 속도에 영향을 주는 캐시를 지워서, 성능 비교를 정확하게 비교하고자 하였습니다.</p>
<pre><code class="language-sql">SELECT SQL_NO_CACHE * FROM users;

SELECT
    users.user_id,
    users.created_date,
    users.deleted_date,
    users.modified_date,
    users.email,
    users.name,
    users.profile_url,
    users.provider,
    users.role,
    users.uuid
from
    users
where
    users.email = &#39;7000@gmail.com&#39;
    and users.deleted_date is null</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/6b5470ce-a0d6-4d58-82b3-9ce2fd6be6e1/image.png" alt=""></p>
<p>그러나, 아래와 같이 현재 MySQL의 버전이 8.0.33이기 때문에, SQL_NO_CACHE 관련 Query Cache문은 MySQL 5.7.20 버전부터 Deprecated되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/3e0a9fc2-3973-4a2e-a6fe-5897142c390b/image.png" alt=""></p>
<pre><code class="language-sql">SELECT SQL_NO_CACHE * FROM users;</code></pre>
<p>따라서 해당 쿼리를 사용하지 않고, mysql 서버를 종료했다가 다시 실행시켜서 캐시를 직접 없애는 방법을 사용하여 시간을 측정하였습니다.</p>
<ul>
<li><p>인덱스 생성 전 (1만건의 더미데이터)
<img src="https://velog.velcdn.com/images/da_na/post/b2390e9d-0ab2-4ca0-a483-f31d526a036a/image.png" alt=""></p>
</li>
<li><p>인덱스 생성 후 (1만건의 더미데이터)
<img src="https://velog.velcdn.com/images/da_na/post/6f074d4b-635c-4741-804d-f39d3155b4a6/image.png" alt=""></p>
</li>
</ul>
<p>Actual Total Time : 12msec -&gt; 0.000292msec 로 줄어들었음을 알 수 있습니다.</p>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이때, 유의해야 할 점은 인덱스를 생성하면 조회 성능은 향상되지만, 삽입, 수정 성능은 떨어질 수 있습니다. <strong>유저 테이블에 유저를 생성하고 수정하는 경우가 많은지 아니면 조회하는 경우가 많은지 판단하여 성능을 최적화해야 합니다.</strong> 하지만 이번 경우에는 회원가입을 하는 경우보다 유저가 회원을 조회하는 경우가 많기 때문에, 해당 인덱스를 추가하도록 하겠습니다.</li>
<li>스크랩 테이블과 같은 경우에는 생성이 많은지 조회가 많은지를 알 수 없기 때문에 <strong>사용자 로그 분석을 통해서 사용자들의 행동 패턴을 추적하여 성능을 최적화</strong>하는 방식으로 진행해야 될 것 같습니다.</li>
<li>이번 경우의 1만건의 더미 데이터에서는 12msec -&gt; 0.000292msec로 줄어들었지만, 더 많은 데이터가 있을 수록 더 많은 격차가 생길 것입니다. 하지만 유저가 1만건보다 더 적을 수도 많을 수도 있기 때문에, 적절하게 생성하거나 현재 데이터를 토대로 최적화하는 것이 좋을 것이라고 생각합니다. </li>
<li>정확한 성능 비교를 위해, 비슷한 조건을 만들어 주고자 하였습니다. 따라서 성능에 영향을 줄 수 있는 캐시를 지우고 mysql 서버를 종료시켜서 비슷한 조건으로 통일시켰습니다. 이처럼 성능을 수치화해서 비교하는 것을 하기 위해서, 정확한 환경을 구축하는 것의 중요성을 다시 한 번 깨달을 수 있었습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[실행계획] MySQL 데이터 베이스의 실행계획(Explain)을 통한 유저 테이블 인덱스 설계/수정 1]]></title>
            <link>https://velog.io/@da_na/SQL-%ED%8A%9C%EB%8B%9D-%EC%9C%A0%EC%A0%80-%EC%A1%B0%ED%9A%8C-SQL-%ED%8A%9C%EB%8B%9D-%EB%B0%8F-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-1</link>
            <guid>https://velog.io/@da_na/SQL-%ED%8A%9C%EB%8B%9D-%EC%9C%A0%EC%A0%80-%EC%A1%B0%ED%9A%8C-SQL-%ED%8A%9C%EB%8B%9D-%EB%B0%8F-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-1</guid>
            <pubDate>Mon, 09 Oct 2023 17:16:29 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>API 성능 향상을 위해서, SQL 튜닝 관련 블로그 글을 읽다가 IS NOT NULL, IS NULL을 사용하면 인덱스를 사용해도, 전체 table을 full scan한다는 말을 보게 되었습니다.</p>
<p>저희 서비스에서는 User Table에서 탈퇴 날짜인 deleted_date를 null로 하고, 탈퇴하면 해당 column을 업데이트하는 방식으로 탈퇴여부를 확인하고 있었습니다.</p>
<p>모든 API에서는 해당 회원이 유효한 회원인지 확인하기 위해서 탈퇴한 회원인지 확인하는 과정이 모두 들어갑니다.</p>
<p>아래의 코드는 스크랩을 검색하는 서비스 로직입니다.</p>
<p>이처럼  <code>User user = userService.validateUser(email);</code>으로 해당 회원의 유효성을 검사를 하는 과정이 있습니다.</p>
<pre><code class="language-java">@Transactional
public Slice&lt;GetScrapResponse&gt; searchScraps(String email, String keyword, Pageable pageable) {
    User user = userService.validateUser(email);

    Slice&lt;Scrap&gt; scrapSlice = scrapRepository.searchKeywordInScrapOrderByCreatedDateDesc(user,
            keyword, pageable);

    return scrapSlice.map(scrap -&gt; GetScrapResponse.of(scrap,
            memoRepository.findMemosByScrapAndDeletedDateIsNull(scrap)));
}</code></pre>
<pre><code class="language-java">@Transactional
public User validateUser(String email) {
    return userRepository.findByEmailAndDeletedDateIsNull(email).orElseThrow(
            () -&gt; new NotFoundException(ErrorCode.NOT_EXISTS_MEMBER)
    );
}</code></pre>
<p>즉, users 테이블에서 Email이 유저의 email 인 지와 deletedDate is null인지를 확인하는 SQL 쿼리가 나가게 됩니다.</p>
<p>그러면 해당 SQL 쿼리가 유저가 10000000(1천만)명인 경우에 성능이 어떻게 되는지와 해당 SQL 쿼리를 개선하기 위해서 튜닝하는 과정까지 알아보겠습니다.</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-더미데이터-생성">1. 더미데이터 생성</h2>
<p>우선, SQL 쿼리의 성능의 차이를 확실하게 비교하기 위해서는 데이터가 최대한 많이 있는 것이 좋습니다.</p>
<p>따라서, 유저가 10000000(1천만)명인 경우의 SQL의 성능을 확인하기 위해서, 아래의 더미데이터들을 생성하겠습니다.</p>
<p>이때의, <strong>프로시저</strong>를 이용해서 생성해주었습니다.</p>
<ul>
<li><strong>프로시저</strong>는 RDBMS에서 다수의 쿼리를 하나의 함수처럼 실행하기위한 쿼리의 집합입니다. </li>
<li>장점은 하나의 요청으로 SQL 명령을 여러번 실행할 수 있습니다.</li>
<li>단점은 데이터에 대한 내용 변경시, 프로시저를 변경해야 할 가능성이 존재합니다.</li>
</ul>
<p>참고 자료 : <a href="https://velog.io/@jkijki12/MySql-%EB%8D%94%EB%AF%B8%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0">https://velog.io/@jkijki12/MySql-더미데이터-생성하기</a></p>
<pre><code class="language-sql">DELIMITER $$

DROP PROCEDURE IF EXISTS insertUserDummyData$$

CREATE PROCEDURE insertUserDummyData()
BEGIN
    DECLARE i INT DEFAULT 1;

    WHILE i &lt;= 10000000 DO
      IF i &lt;= 1000000 THEN
            INSERT INTO users(user_id, created_date, modified_date, email, name, profile_url, provider, role)
          VALUES(i, now(), now(), concat(i,&#39;@gmail.com&#39;), concat(&#39;이름&#39;, i), concat(&#39;profile_url&#39;, i), 0, &#39;USER&#39;);

      ELSEIF (i&lt;= 3000000 AND i &gt;= 1000001) THEN
            INSERT INTO users(user_id, created_date, modified_date, deleted_date, email, name, profile_url, provider, role)
          VALUES(i, now(), now(), now(), concat(i,&#39;@gmail.com&#39;), concat(&#39;이름&#39;, i), concat(&#39;profile_url&#39;, i), 0, &#39;USER&#39;);

      ELSEIF (i&lt;= 5000000 AND i &gt;= 3000001) THEN
            INSERT INTO users(user_id, created_date, modified_date, email, name, profile_url, provider, role)
          VALUES(i, now(), now(), concat(i,&#39;@gmail.net&#39;), concat(&#39;이름&#39;, i), concat(&#39;profile_url&#39;, i), 0, &#39;USER&#39;);

      ELSEIF (i &lt;= 6000000 AND i&gt;= 5000001) THEN
            INSERT INTO users(user_id, created_date, modified_date, deleted_date, email, name, profile_url, provider, role)
          VALUES(i, now(), now(), now(), concat(i,&#39;@gmail.com&#39;), concat(&#39;이름&#39;, i), concat(&#39;profile_url&#39;, i), 0, &#39;USER&#39;);

      ELSEIF (i&lt;= 8000000 AND i &gt;= 6000001) THEN
            INSERT INTO users(user_id, created_date, modified_date, email, name, profile_url, provider, role)
          VALUES(i, now(), now(), concat(i,&#39;@gmail.com&#39;), concat(&#39;이름&#39;, i), concat(&#39;profile_url&#39;, i), 0, &#39;USER&#39;);
      ELSE
        INSERT INTO users(user_id, created_date, modified_date, deleted_date, email, name, profile_url, provider, role)
          VALUES(i, now(), now(), now(), concat(i,&#39;@gmail.com&#39;), concat(&#39;이름&#39;, i), concat(&#39;profile_url&#39;, i), 0, &#39;USER&#39;);
      END IF;
        SET i = i + 1;

    END WHILE;
END$$
DELIMITER $$</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/8fe55d26-0ce8-4a9b-8182-0ba76d68decc/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/3c00394a-d16d-423b-8078-e57e0e0636f3/image.png" alt=""></p>
<p>위의 사진에서 더미 데이터들이 성공적으로 생성되었음을 확인할 수 있습니다.</p>
<hr>
<h2 id="2-mysql-explain을-사용하여-현재-유저-조회-sql-성능-살펴보기">2. MySQL Explain을 사용하여 현재 유저 조회 SQL 성능 살펴보기</h2>
<p>MySQL Explain은 &#39;데이터 베이스가 데이터를 찾아가는 일련의 과정을 사람이 알아보기 쉽게 DB 결과 셋으로 보여주는 것&#39;입니다.</p>
<p>따라서, MySQL Explain 실행계획을 활용하여 기존의 쿼리를 튜닝할 수 있을 뿐만 아니라 성능 분석, 인덱스 전략 수립 등과 같이 성능 최적화에 대한 전반적인 업무를 처리할 수 있습니다.</p>
<p>아래의 SQL 쿼리를 MySQL Explain으로 데이터 베이스의 성능 및 쿼리를 살펴보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/8a0ccd4b-bd88-4cd9-85e1-b3eedcc70a5a/image.png" alt=""></p>
<p>위의 SQL 쿼리를 아래의 EXPLAIN으로 나타내었습니다.</p>
<pre><code class="language-sql">EXPLAIN
    SELECT
        users.user_id,
        users.created_date,
        users.deleted_date,
        users.modified_date,
        users.email,
        users.name,
        users.profile_url,
        users.provider,
        users.role,
        users.uuid
    from
        users
    where
        users.email = &#39;7000000@gmail.com&#39;
        and users.deleted_date is null</code></pre>
<hr>
<h2 id="mysql-explain-결과의-각-항목별-의미">MySQL Explain 결과의 각 항목별 의미</h2>
<h3 id="1-id">1. <strong>id</strong></h3>
<ul>
<li>SELECT 쿼리 별 부여되는 식별자 값(행이 어떤 SELECT 구문을 나타내는 지를 알려주는 것)</li>
</ul>
<h3 id="2-select_type--각-단위-select-쿼리가-어떤-타입의-쿼리인지-표시되는-칼럼">2. <strong>select_type</strong> : 각 단위 SELECT 쿼리가 어떤 타입의 쿼리인지 표시되는 칼럼</h3>
<ul>
<li><strong>SIMPLE</strong> : 단순 SELECT (Union 이나 Sub Query 가 없는 SELECT 문)</li>
<li><strong>PRIMARY</strong> : UNION이나 서브쿼리를 가지는 SELECT 쿼리의 실행 계획에서 가장 바깥쪽에 있는 단위 쿼리</li>
<li><strong>UNION</strong> : UNION 쿼리에서 Primary를 제외한 나머지 SELECT, UNION과 UNION ALL 절로 생성된 임시 테이블을 의미</li>
<li><strong>DEPENDENT_UNION</strong> : UNION 과 동일하나, 외부 결과에 의존할 때 표현된다. UNION 쿼리가 내부에서 사용되었을때 표현된다.</li>
<li><strong>UNION_RESULT</strong> : UNION 쿼리의 결과물</li>
<li><strong>SUBQUERY</strong> : Sub Query 또는 Sub Query를 구성하는 여러 쿼리 중 첫 번째 SELECT문(FROM절 외에서 사용되는 서브 쿼리를 의미한다.)</li>
<li><strong>DEPENDENT_SUBQUERY</strong> : Sub Query 와 동일하나, 외곽쿼리에 의존적임 (값을 공급 받음)</li>
<li><strong>DERIVED</strong> : SELECT로 추출된 테이블 (FROM 절 에서의 서브쿼리 또는 Inline View)</li>
<li><strong>MATERIALIZED</strong> : MySQL 5.6 에서부터 추가되었으며, IN 절 내의 서브쿼리를 임시테이블로 만들어 조인을 하는 형태로 최적화를 해준다.</li>
<li><strong>UNCACHEABLE SUBQUERY</strong> : Sub Query와 동일하지만 공급되는 모든 값에 대해 Sub Query를 재처리하여 캐싱되지 못한다.</li>
<li><strong>UNCACHEABLE UNION</strong> : UNION 과 동일하지만 공급되는 모든 값에 대하여 UNION 쿼리를 재처리</li>
</ul>
<h3 id="3-type--각-테이블의-레코드를-어떻게-읽었는지에-대한-접근-방식">3. Type : 각 테이블의 레코드를 어떻게 읽었는지에 대한 접근 방식</h3>
<p>💡 쿼리 튜닝 시 반드시 체크해야 할 중요한 정보입니다.</p>
<ul>
<li>ALL(테이블 풀 스캔)을 제외한 나머지는 모두 인덱스를 사용하는 접근 방식입니다.</li>
<li>system에서 ALL으로 갈수록 성능이 느려집니다.</li>
<li>MySQL 옵티마이저는 이러한 접근 방법과 비용을 함께 계산해서 최소의 비용이 필요한 접근 방법을 선택해 쿼리를 처리합니다.</li>
</ul>
<p>접근 방식</p>
<ul>
<li><strong>system</strong> : 레코드가 1건만 존재하는 테이블 또는 한 건도 존재하지 않는 테이블을 참조하는 형태</li>
<li><strong>const</strong> : 테이블의 레코드 건수와 관계없이 쿼리가 프라이머리 키나 유니크 키 컬럼을 이용하는 WHERE 조건절을 가지고 있다.<ul>
<li>반드시 1건을 반환하는 쿼리의 처리 방식</li>
</ul>
</li>
<li><strong>eq_ref</strong> : 조인을 할 때 Primary Key</li>
<li><strong>ref</strong> : 조인을 할 때 Primary Key 혹은 Unique Key가 아닌 Key로 매칭하는 경우<ul>
<li>동등(Equal) 조건으로 검색할 때 사용하는 접근 방식</li>
<li>ref 타입은 반환되는 레코드가 반드시 1건이라는 보장이 없으므로 const, eq_ref 보다는 느리지만 동등한 조건으로만 비교되기에 매우 빠른 레코드 조회 방법 중 하나</li>
</ul>
</li>
<li><strong>ref_or_null</strong> : ref 와 같지만 null 이 추가되어 검색되는 경우</li>
<li><strong>index_merge</strong> : 두 개의 인덱스가 병합되어 검색이 이루어지는 경우</li>
<li><strong>unique_subquery</strong> : IN 절 안의 서브쿼리에서 Primary Key가 오는 특수한 경우</li>
<li><strong>index_subquery</strong> : unique_subquery와 비슷하나 Primary Key가 아닌 인덱스인 경우</li>
<li><strong>range</strong> : 특정 범위 내에서 인덱스를 사용하여 원하는 데이터를 추출하는 경우 <ul>
<li>인덱스를 하나의 값이 아닌 범위로 검색하는 경우</li>
<li>주로 “&lt;, &gt;, IS NULL, BETWEEN, IN, LIKE” 등의 연산자를 이용해 인덱스 검색할 때 사용</li>
<li>일반적으로 애플리케이션의 쿼리가 가장 많이 사용하는 접근 방법</li>
</ul>
</li>
<li><strong>index</strong> : 인덱스를 처음부터 끝까지 찾아서 검색하는 경우(인덱스 풀스캔)</li>
<li><strong>all</strong> : 테이블을 처음부터 끝까지 검색하는 경우(테이블 풀 스캔)<ul>
<li>가장 비효율적인 방법</li>
</ul>
</li>
</ul>
<h3 id="4-key--최종-선택된-실행-계획에서-사용되는-인덱스">4. key : 최종 선택된 실행 계획에서 사용되는 인덱스</h3>
<ul>
<li>쿼리 튜닝 시 key 칼럼에 의도했던 인덱스가 표시되는지 확인하는 것이 중요</li>
<li>실행 계획의 type이 ALL일 때와 같이 인덱스를 전혀 사용하지 못하면 NULL로 표시됨</li>
</ul>
<h3 id="5-key_len--선택된-인덱스의-길이">5. key_len : 선택된 인덱스의 길이</h3>
<ul>
<li>쿼리를 처리하기 위해 다중 칼럼으로 구성된 인덱스에서 몇 개의 칼럼까지 사용했는지 표기</li>
</ul>
<h3 id="6-ref--접근-방법이-ref면-참조-조건equal-비교-조건으로-어떤-값이-제공됐는지-표시">6. ref : 접근 방법이 ref면 참조 조건(equal 비교 조건)으로 어떤 값이 제공됐는지 표시</h3>
<ul>
<li>상숫값 → const, 다른 테이블의 칼럼값이면 그 테이별명과 칼럼명이 표시</li>
<li>조인 칼럼의 타입(NUMBER, VARCHAR 등)은 동일하게 일치시키는 편이 좋다.</li>
</ul>
<h3 id="7-rows--실행-계획의-효율성-판단을-위해-예측했던-레코드-건수">7. rows : 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수</h3>
<ul>
<li>쿼리를 처리하기 위해 얼마나 많은 레코드를 읽고 체크해야 하는지를 의미</li>
</ul>
<h3 id="8-filtered--필터링되고-남은-레코드의-비율">8. filtered : 필터링되고 남은 레코드의 비율</h3>
<ul>
<li>통계 값 바탕으로 계산한 값으로 실제 결과 값과 반드시 일치하지 않는다.</li>
</ul>
<h3 id="9-extra--옵티마이저가-어떻게-동작하는지에-대해-알려주는-힌트-값">9. extra : 옵티마이저가 어떻게 동작하는지에 대해 알려주는 힌트 값</h3>
<ul>
<li><strong>Using filesort</strong> : ORDER BY 처리가 인덱스를 사용하지 못할 때 실행 계획의 Extra 칼럼에 표시<ul>
<li>이는 조회된 레코드를 정렬용 메모리 버퍼에 복사해 퀵 소트 또는 힙 소트 알고리즘을 이용해 정렬 수행</li>
<li>ORDER BY가 사용된 쿼리의 실행 계획에서만 나타날 수 있음</li>
<li>많은 부하를 일으키므로 가능하다면 쿼리를 튜닝하거나 인덱스를 생성하는 것이 좋음</li>
</ul>
</li>
<li><strong>Using index(커버링 인덱스)</strong> : 인덱스만 읽어서 쿼리를 모두 처리할 수 있는 경우</li>
<li><strong>Using temporary</strong> : 쿼리를 처리하는 동안 중간 결과를 담아 두기 위해 임시 테이블(Temporary Table)을 생성하여 표시된 것</li>
<li><strong>Using index for skip scan</strong> : MySQL 옵티마이저가 인덱스 스킵 스캔 최적화를 사용할 경우</li>
<li><strong>Using join buffer(Block Nested Loop, hash join)</strong> : 빠른 쿼리 실행을 위해 조인되는 칼럼에는 인덱스를 생성</li>
<li><strong>Using where</strong> : MySQL 엔진에서 별도의 가공을 해서 필터링 작업을 처리한 경우</li>
</ul>
<h3 id="참고-자료">참고 자료</h3>
<ul>
<li><a href="https://nomadlee.com/mysql-explain-sql/">https://nomadlee.com/mysql-explain-sql/</a></li>
<li><a href="https://zzang9ha.tistory.com/436">https://zzang9ha.tistory.com/436</a></li>
</ul>
<hr>
<p>위의 MySQL Explain을 실행하면, 아래와 같이 결과가 나오게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/eac3f8fb-dcec-454b-8c84-2659b8fb5be8/image.png" alt=""></p>
<p>위의 결과표를 각 항목에서 SQL 성능에서 중요한 항목인 type과 rows, extra를 해석해보겠습니다.</p>
<ul>
<li><em>type</em>은 <strong>ALL</strong>입니다.<ul>
<li>즉, 현재는 테이블을 처음부터 끝까지 검색하는 경우(테이블 풀 스캔)입니다.</li>
<li>따라서, 가장 비효율적인 방법이므로 성능을 최적화하는 것이 중요합니다.</li>
</ul>
</li>
<li><em>rows</em>는 <strong>9966666</strong>입니다.<ul>
<li>실행 계획의 효율성 판단을 위해 읽고 체크해야하는 레코드 건수가 9966666로 매우 많습니다.</li>
</ul>
</li>
<li><em>extra</em>는 <strong>Using where</strong>입니다.<ul>
<li>즉, MySQL 엔진에서 별도의 가공을 해서 필터링 작업을 처리한 경우입니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-최적화할-수-있는-방법-생각해보기">3. 최적화할 수 있는 방법 생각해보기</h2>
<p>MySQL Explain 결과값을 보고 나서, 생각한 최적화 방법입니다. </p>
<p>따라서, 실제로 최적화되었는지 알 수 없지만, 다음 블로그글으로 실제로 최적화되는지 확인해보겠습니다.</p>
<h4 id="1-저희는-이메일이-고유값이기-때문에-반환되는-레코드가-반드시-1건이라는-보장이-있습니다-">1. *<em>저희는 이메일이 고유값이기 때문에 반환되는 레코드가 반드시 1건이라는 보장이 있습니다. *</em></h4>
<ul>
<li>const: 모든 컬럼에 대해 PK, 유니크 키 등 동등 조건으로 검색 (반드시 1건의 레코드만 반환)</li>
<li>따라서, email을 unique 키로 변경하여 MySQL Explain의 type을 const로 변경하려고 합니다.</li>
</ul>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@Entity
@ToString(exclude = &quot;scrapList&quot;)
@Table(name = &quot;users&quot;)
public class User extends BaseTimeEntity implements Serializable {

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

    @OneToMany(mappedBy = &quot;user&quot;)
    private List&lt;Scrap&gt; scrapList = new ArrayList&lt;&gt;();

    @Column(length = 100, nullable = false)
    private String name;

    @Column(length = 320, nullable = false)
    private String email;

    @Column(length = 2083, nullable = false)
    private String profileUrl;

    @Column(nullable = false)
    private Provider provider;

    private String uuid;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;
}</code></pre>
<h4 id="2-email에-index를-생성합니다">2. email에 index를 생성합니다.</h4>
<ul>
<li>현재 아래의 사진처럼 users의 index는 primary인 user_id만 되어있습니다.</li>
<li>따라서, email 관련 index를 추가하여 테이블 풀 스캔을 하지 않도록 할 예정입니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/d95329cb-7392-4484-8a4d-056271bfa1c6/image.png" alt=""></p>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이전까지는 기능 개발을 하는 데에만 집중하고, 프로젝트 기간이 2~3개월로 짧아서 최적화까지는 하지 못했습니다. 그러나, 이번달 초에 출시하고 나서 거의 기능 개발이 마무리 되어서 <strong>성능 향상을 위해서 최적화를 하기 시작하면서</strong> 새로운 부분과 SQL을 더 깊이 알 수 있는 시간이어서 아주 뜻깊은 시간이었습니다.</li>
<li>처음으로 MySQL Explain을 사용해서 현재 작성한 코드 및 쿼리의 성능을 살펴보았습니다. 당연히 email과 같은 부분이 index되어 있는 줄 알았는데, 자세히 살펴보면서 index가 user_id만 되어 있는 모습을 보고, 당연한 것은 없고 <strong>더 자세하게 살펴보아야겠다</strong>는 생각을 했습니다. </li>
<li>성능을 향상할 부분은 스크랩 조회 기능과 같이 여러 테이블을 조인하고 복잡한 쿼리만 해당되는 줄 알았습니다. 더 나아가, index는 되어 있으므로 SQL 튜닝보다는 open search와 같은 검색 오픈소스를 사용하는 것이 성능면에서는 훨씬 좋지 않을까 생각했습니다. 그러나, 바로 새로운 기술을 도입하면 새로운 기술을 배우고 DB를 만들고 비용이 들기 때문에 <strong>현재 시점에서 최대한 SQL을 최적화하여 최대한 투입되는 리소스를 줄이고 최적화가 더이상 되지 않는 경우에 새로운 기술을 도입하는 것이 좋겠다</strong>는 생각을 했습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[HTTP API URI 설계] 프로젝트에 컨트롤 URI 설계하기]]></title>
            <link>https://velog.io/@da_na/HTTP-API-URI-%EC%84%A4%EA%B3%84-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%BB%A8%ED%8A%B8%EB%A1%A4-URI-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/HTTP-API-URI-%EC%84%A4%EA%B3%84-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%BB%A8%ED%8A%B8%EB%A1%A4-URI-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 06 Oct 2023 14:36:59 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이번에 프로젝트에서 보드 관련된 기능을 제공하기로 하였습니다.</p>
<p>이때, 저희는 보드 관련 기능을 아래와 같이 정의하였습니다.</p>
<ol>
<li>보드 생성</li>
<li>보드 삭제</li>
<li>보드 수정</li>
<li>보드 조회</li>
<li>보드 고정</li>
</ol>
<p>이때, 다른 기능들은 CRUD로 정의가 되지만, 5번의 <strong>보드 고정은 CRUD로 정의할 수 없었습니다</strong>.</p>
<p>보드 고정은 아래의 피그마의 예시와 같이 스크랩들을 담은 보드를 고정핀(별)으로 고정하게 된다면, 보드 목록 내에서 맨 위에 고정되어 맨처음에 조회할 수 있는 기능을 만들고자 하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/5da3572a-432c-4ff5-a3ee-cdced637cabd/image.png" alt=""></p>
<p>저는 해당 기능의 HTTP API의 URI를 설계하려고 했습니다.</p>
<p>그러나, HTTP 메소드는 GET, POST, PUT, PATCH, DELETE로 이루어져 있어서 <strong>리소스와 행위를 분리하는 방식을 적용해서 보드를 고정하는 행위를 HTTP의 메소드로 나타내기가 어려웠습니다</strong>.</p>
<p>따라서 김영한님의 인프런 HTTP 강의를 듣고 나서, 프로젝트에 어떻게 적용시켜야할지를 되돌아보았습니다!</p>
<p>해당 강의 주소 : <a href="https://www.inflearn.com/course/http-%EC%9B%B9-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC/dashboard">https://www.inflearn.com/course/http-웹-네트워크/dashboard</a></p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-http-api-uri-설계">1. HTTP API URI 설계</h2>
<h3 id="여기에서-가장-중요한-점은-리소스-식별입니다">여기에서 가장 중요한 점은 &quot;리소스 식별&quot;입니다.</h3>
<p>즉, 위에서 이야기한 보드의 기능을 살펴보면서 이야기하겠습니다.</p>
<p>아래의 보드 기능에서 리소스는 바로 &quot;보드&quot;입니다.</p>
<ol>
<li>보드 생성 -&gt; /boards</li>
<li>보드 삭제 -&gt; /boards/{id}</li>
<li>보드 수정 -&gt; /boards/{id}</li>
<li>보드 조회 -&gt; /boards/{id}</li>
<li>보드 고정 -&gt; /boards/{id}</li>
</ol>
<h3 id="리소스와-행위를-분리해야-합니다">리소스와 행위를 분리해야 합니다.</h3>
<ul>
<li>URI는 리소스만 식별합니다.</li>
<li>리소스와 해당 리소스를 대상으로 하는 행위를 분리합니다.</li>
<li>위에서의 행위는 &quot;생성, 삭제, 수정, 조회, 고정&quot;입니다.</li>
</ul>
<h3 id="행위는-http-메소드로-구분합니다">행위는 HTTP 메소드로 구분합니다.</h3>
<p>주요 메소드</p>
<ol>
<li><strong>GET</strong> : 리소스를 조회합니다.</li>
<li><strong>POST</strong> : 요청 데이터 처리, 주로 등록에 사용됩니다.</li>
<li><strong>PUT</strong> : 리소스 대체, 해당 리소스가 없으면 생성합니다.</li>
<li><strong>PATCH</strong> : 리소스 부분을 변경합니다.</li>
<li><strong>DELETE</strong> : 리소스를 삭제합니다.</li>
</ol>
<hr>
<h3 id="control-uri-컨트롤-uri">Control URI (컨트롤 URI)</h3>
<ul>
<li>단순한 데이터를 생성하거나, 변경하는 것을 넘어서 <strong>프로세스를 처리해야 하는 경우에 사용</strong>됩니다.</li>
<li>예를 들어서, 주문에서 결제완료 -&gt; 배달 시작 -&gt; 배달 완료처럼 <strong>단순히 값 변경을 넘어 프로세스의 상태가 변경되는 경우에 사용</strong>될 수 있습니다.</li>
<li>HTTP 주요 메소드로 나타낼 수 없는 경우에도 사용됩니다.<ul>
<li>POST <code>/orders/{orderId}/start-delivery</code></li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-프로젝트에-적용하기">2. 프로젝트에 적용하기</h2>
<p>저희 프로젝트는 API의 버전 관리를 위해서 v1을 맨 처음에 붙이기로 하였습니다.</p>
<p>그리고 고정하다를 &quot;fix&quot;로 하여 API를 설계해줍니다.</p>
<p>따라서 <code>/v1/boards/{boardId}/fix</code>로 해주었습니다. </p>
<p>그러나, 과연 <strong>boardId가 가운데에 API URI로 들어가는 것이 좋을까??</strong>라는 의문이 들었습니다.</p>
<p>가운데에 넣으면 <strong>URI의 Validation 유효성 검사를 하기가 어려워질 것 같다</strong>는 생각을 했습니다.</p>
<p><code>/v1/boards/{boardId}/fix</code>를 한다면, boardId가 null일 때에는 boardId가 fix로 인식되어서 @NotNull을 못 잡아낼 수도 있을 것 같았습니다.</p>
<p>따라서 {boardId}를 마지막에 한다면, /v1/boards/fix와 /v1/boards/fix/1 처럼 null과 boardId 여부가 명확해져서 null 관련 validation 처리가 좋을 것 같다고 판단하였습니다.</p>
<p><code>/v1/boards/{boardId}/fix</code>로 작성해도 유효성 검사가 잘 작동될지를 검토하고, URI를 선택해보겠습니다.</p>
<p>service 로직을 검증하는 것이 아니므로 간단하게 controller 테스트를 위해서 간단하게 아래의 코드로 시험해보겠습니다!</p>
<h3 id="1-v1boardsboardidfix">1. /v1/boards/{boardId}/fix</h3>
<p>이때의 API URI만 테스트하기 위해서, POST와 PATCH로 나눠서 진행하였습니다.</p>
<p>따라서 해당 테스트에서는 HTTP 메소드에 크게 의미를 부여하지 않고 있습니다.</p>
<pre><code class="language-java">@Operation(summary = &quot;보드 고정&quot;, description = &quot;1개의 보드를 보드 카테고리에서 상단에 고정합니다.&quot;)
@PostMapping(&quot;/v1/boards/{boardId}/fix&quot;)
public ApiResponse&lt;String&gt; fixBoards(@PathVariable(required = false) @Positive @NotNull Long boardId,
        Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(&quot;boardId : &quot; + boardId + &quot; email : &quot; + email);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/545437f1-9c5b-4b8b-831b-1903fed95011/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/4f956fa2-efe4-41a4-8d9c-b8c42fc91c5d/image.png" alt=""></p>
<h3 id="2-v1boardsfixboardid">2. /v1/boards/fix/{boardId}</h3>
<pre><code class="language-java">@Operation(summary = &quot;보드 고정&quot;, description = &quot;1개의 보드를 보드 카테고리에서 상단에 고정합니다.&quot;)
@PatchMapping(&quot;/v1/boards/fix/{boardId}&quot;)
public ApiResponse&lt;String&gt; fixedBoards(@PathVariable(required = false) @Positive @NotNull Long boardId,
        Authentication authentication) {
    String email = authentication.getName();
    boardService.fixedBoards(email, boardId);
    return ApiResponse.success();
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/579b27bd-19cc-4874-a528-cdb680bc7a59/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/352ec886-cc82-441c-8e9f-1e213f8d9084/image.png" alt=""></p>
<p>이때, 두 가지의 URI 모두 <code>@NotNull</code>임을 확인하지 못하고 에러가 난다는 사실을 알 수 있습니다.</p>
<p>따라서 Controller에서는 @NotNull인 상황은 기본적으로 처리해주지 못하기 때문에 <code>/v1/boards/{boardId}/fix</code>와 <code>/v1/boards/fix/{boardId}</code>가 <strong>동일한 null 처리를 추가해주어야 하기 때문에 큰 차이가 없을 것</strong>이라고 생각했습니다.</p>
<h3 id="참고-자료">참고 자료</h3>
<ul>
<li><a href="https://memo-the-day.tistory.com/108">https://memo-the-day.tistory.com/108</a></li>
<li><a href="https://www.baeldung.com/spring-optional-path-variables">https://www.baeldung.com/spring-optional-path-variables</a></li>
</ul>
<hr>
<h2 id="3-notnull-처리">3. @NotNull 처리</h2>
<p>위의 두 개를 @NotNull인 경우를 처리를 해보겠습니다.</p>
<pre><code class="language-java">@Operation(summary = &quot;보드 고정&quot;, description = &quot;1개의 보드를 보드 카테고리에서 상단에 고정합니다.&quot;)
@PatchMapping(value = {&quot;/v1/boards/fix/{boardId}&quot;, &quot;/v1/boards/fix&quot;})
public ApiResponse&lt;String&gt; fixedBoards(@PathVariable(required = false) @Positive @NotNull Long boardId,
        Authentication authentication) {
    String email = authentication.getName();
    boardService.fixedBoards(email, boardId);
    return ApiResponse.success();
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/8354efce-199d-4eb4-ad17-abba1bff846b/image.png" alt=""></p>
<pre><code class="language-java">@Operation(summary = &quot;보드 고정&quot;, description = &quot;1개의 보드를 보드 카테고리에서 상단에 고정합니다.&quot;)
@PostMapping(value = {&quot;/v1/boards/{boardId}/fix&quot;, &quot;/v1/boards/fix&quot;})
public ApiResponse&lt;String&gt; fixBoards(@PathVariable(required = false) @Positive @NotNull Long boardId,
        Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(&quot;boardId : &quot; + boardId + &quot; email : &quot; + email);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/0ad4138d-633f-4ca6-ba4f-7319352139a4/image.png" alt=""></p>
<p>따라서, 위의 코드로 <strong>NotNull 처리가 가능</strong>합니다.</p>
<p>많은 예시를 찾아보았을 때, NotNull을 처리하는 경우에는 대부분 null로 들어오면 디폴트 값을 설정해주어서 에러가 나지 않게 하는 방향으로 많이 하는 방향으로 많이 했습니다.</p>
<p>저희 서비스는 보드 ID가 사용자마다 다르기 때문에, <strong>임의값을 지정해줄 수 없어서 디폴트 값 처리가 필요하지 않습니다</strong>.</p>
<p>그러나, 아래와 같이 @NotNull인 경우에는 404 NOT FOUND 에러가 아닌 500 Internal Server Error가 나오기 때문에, <strong>Null로 입력한 클라이언트 오류가 서버 오류가 되어서 최대한으로 500에러를 안내는 것이 서버 API가 좋다고 판단</strong>하여서 아래의 사진 밑 코드로 프로젝트에 반영하기로 하였습니다!</p>
<ul>
<li>NotNull을 처리하지 못한 경우
<img src="https://velog.velcdn.com/images/da_na/post/a74af817-1fe9-460d-80ff-d5271c284ff1/image.png" alt=""></li>
<li>최종적으로 프로젝트에 적용한 코드<pre><code class="language-java">@Operation(summary = &quot;보드 고정&quot;, description = &quot;1개의 보드를 보드 카테고리에서 상단에 고정합니다.&quot;)
@PostMapping(value = {&quot;/v1/boards/{boardId}/fix&quot;, &quot;/v1/boards/fix&quot;})
public ApiResponse&lt;String&gt; fixBoards(@PathVariable(required = false) @Positive @NotNull Long boardId,
      Authentication authentication) {
  String email = authentication.getName();
  return ApiResponse.success(&quot;boardId : &quot; + boardId + &quot; email : &quot; + email);
}</code></pre>
</li>
</ul>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>HTTP API URI 설계가 쉬운 듯 해보이지만, 클라이언트와의 약속 및 API 서버로 들어오기 위한 출입구 같은 역할을 하기 때문에 잘 설계하는 것이 매우 중요하고, 팀끼리 정하기에 따라서 달리지는 만큼 <strong>사람들마다 다르게 설계하여 명확한 기준이 없어서 어려운 부분</strong>도 있었습니다.</li>
<li>그러나, 이번 기회에 HTTP API를 다시 한 번 살펴보고 처리를 어떻게 해야할지 테스트를 해보면서 더 많이 알게 된 것 같았습니다.</li>
<li>그리고 이전에는 GET, POST, PATCH, DELETE와 같이 CRUD 기본적인 것들만 API를 구현하다보니까, 컨트롤 URI를 처음으로 사용하게 되었는데, 실제 서비스에서는 이것보다 더 훨씬 복잡한 로직을 수행하는 API를 구현할 수도 있는 만큼 이번에 했던 것들을 떠올리면서 <strong>더 좋은 설계</strong>를 위해서 힘써야겠다는 다짐을 하게 되었습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Chrome Extension] Shadow Dom을 사용해서 모든 웹 페이지의 injected CSS 동일하게 적용하는 방법]]></title>
            <link>https://velog.io/@da_na/Chrome-Extension-Shadow-Dom%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EB%AA%A8%EB%93%A0-%EC%9B%B9-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9D%98-injected-CSS-%EB%8F%99%EC%9D%BC%ED%95%98%EA%B2%8C-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@da_na/Chrome-Extension-Shadow-Dom%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EB%AA%A8%EB%93%A0-%EC%9B%B9-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9D%98-injected-CSS-%EB%8F%99%EC%9D%BC%ED%95%98%EA%B2%8C-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 05 Oct 2023 03:02:20 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 저희 프로젝트의 크롬 확장 프로그램을 개발한 글을 작성한 적이 있습니다!</p>
<p><a href="https://handayeon-coder.github.io/posts/%ED%81%AC%EB%A1%AC-%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%8B%A4%EB%8B%B4%EB%8B%A4-%ED%99%95%EC%9E%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0/">https://handayeon-coder.github.io/posts/크롬-익스텐션-다담다-확장-프로그램-개발-회고/</a></p>
<p>저희의 다담다 크롬 확장 프로그램을 누르면, 해당 웹 페이지에 HTML 와 CSS에 같이 들어가게 됩니다.</p>
<p>즉, 아래의 크롬 확장 프로그램의 구조를 살펴보면 contentscript.js를 통해서 웹 페이지에 DOM에 접근할 수 있게 되고, DOM에 원하는 HTML과 CSS을 삽입하여 사용자가 보고 있는 웹 페이지에서 확인할 수 있게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/a6dd7def-837b-41fd-acd3-a1324b4979a4/image.png" alt=""></p>
<p>사진 출처 : <a href="https://developer.chrome.com/docs/extensions/mv2/architecture-overview/">https://developer.chrome.com/docs/extensions/mv2/architecture-overview/</a></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/10060a94-016f-44e4-aee8-79da4e7c31df/image.png" alt=""></p>
<p>사진 출처 : <a href="https://developer.chrome.com/docs/extensions/mv3/devtools/">https://developer.chrome.com/docs/extensions/mv3/devtools/</a></p>
<p>저희 서비스는 스크랩하고 싶은 웹 페이지에서 크롬 확장 프로그램을 누르게 된다면, 아래의 사진과 같이 스크랩이 저장되고 있는 화면과 성공, 실패 여부를 알려주는 화면을 확장 프로그램을 누른 웹 페이지에 보여주게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/6b92d717-09d1-489b-9231-c01768ea29c9/image.png" alt=""></p>
<p>그러나, 아래의 사진과 같이 원래 의도한 CSS가 제대로 적용되지 않는 모습을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/35ecfa47-3b57-4819-be68-eb9253452e70/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/6d6f9cc1-e0bd-4644-b7ec-c388f4c69aff/image.png" alt=""></p>
<p>이러한 원인이 무엇인지, 그리고 이를 해결하기 위해서 어떻게 해야할지를 이야기해보겠습니다.</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-현재-코드-살펴보기">1. 현재 코드 살펴보기</h2>
<p>아래의 코드는 background.js로 크롬 익스텐션에서 특정한 웹 사이트 tabId를 통해서 해당 페이지에 JS와 CSS를 넣어주게 되는 로직입니다.</p>
<h3 id="backgroundjs-파일-일부분">background.js 파일 일부분</h3>
<pre><code class="language-js">function contentScriptJS(tabId, file) {
  chrome.scripting.executeScript({
    target: { tabId: tabId },
    files: [`${file}`]
  });
}

function contentScriptCSS(tabId, file) {
  chrome.scripting.insertCSS({
    target: { tabId: tabId },
    files: [`${file}`]
  })
}

function handleScrapOrHighlightResponse(url, tab, data) {
  chrome.storage.local.get(&#39;signedIn&#39;).then(async (result) =&gt; {
    if (result.signedIn) {
      contentScriptJS(tab.id, &quot;content/content.js&quot;);
      contentScriptCSS(tab.id, &quot;content/content.css&quot;);

      await chrome.storage.local.get(&#39;accessToken&#39;).then((result) =&gt; {
        newToken = result.accessToken;
      });

      let response = await postAPI(url, data);

      if (response === &quot;Success&quot;) {
        contentScriptJS(tab.id, &quot;content/successContent.js&quot;);
      } else if (response === &quot;BR002&quot;) {
        contentScriptJS(tab.id, &quot;content/duplicatedScrap.js&quot;);
      } else if (response === &quot;NF002&quot; || response === &quot;BR001&quot;) {
        googleLogin();
      } else {
        contentScriptJS(tab.id, &quot;content/errorContent.js&quot;);
      }
    } else {
      googleLogin();
    }
  })
}</code></pre>
<h3 id="contentcss">content.css</h3>
<pre><code class="language-css">@import url(https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@2.0/nanumsquare.css);

#dadamda-popup{
  box-sizing: border-box;
  font-family: &#39;NanumSquare&#39;, sans-serif;
  z-index: 100001212 !important;
  position: fixed !important;
  left: 80%;
  margin: 10px 0 20px 0;
  background: #fff;
  padding: 25px;
  border-radius: 15px;
  max-width: 380px;
  width: 100%;
  box-shadow: 0 10px 15px rgba(0,0,0,0.1);
  top: 80px;
  opacity: 1;
  pointer-events: auto;
  transform:translate(-50%, -50%) scale(1);
}

#dadamda-icon {
  width: 13%;
}

#dadamda-popup :is(header){
  display: flex;
  align-items: center;
  justify-content: space-between;
}

#dadamda-popup header{
  padding-bottom: 15px;
}

#dadamda-title{
  font-size: 21px;
  font-weight: 700;
  color: #475467;
}

#dadamda-popup #dadamda-content{
  margin: 20px 0;
}

#dadamda-text1 {
  font-size: 16px;
  font-weight: 300;
  color: #98A2B3;
}

#dadamda-loader {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

#dadamda-progress-bar {
  position: relative;
  width: 100%;
  height: 5px;
  border-radius: 100px;
  background-color: #F8FAFC;
  overflow: hidden;
}

#dadamda-progress-bar-gauge {
  position: absolute;
  height: 5px;
  border-radius: 15px;
  background-color: #B2CCFF;
  animation-name: loading-bar;
  animation-duration: 15s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}

@keyframes loading-bar {
  0% {
    width: 0;
    opacity: 1;
  }
  80% {
    width: 100%;
    opacity: 1;
  }
  100% {
    width: 100%;
    opacity: 0;
  }
}</code></pre>
<p>더 자세한 코드는 <a href="https://github.com/SWM-team-forever/dadamda-chrome-extension">https://github.com/SWM-team-forever/dadamda-chrome-extension</a> 에서 확인할 수 있습니다.</p>
<p>아래의 사진과 같이 똑같은 content.js와 successContent.js를 웹 페이지에 삽입하더라도 웹 페이지 별로 적용되는 CSS가 다르기 때문에, 제가 크롬 확장 프로그램을 통해서 넣은 CSS와 HTMl이 작용되지 않습니다.</p>
<ul>
<li>정상적인 경우
<img src="https://velog.velcdn.com/images/da_na/post/144e9b5f-aa50-4b6d-82e5-d2ece5c7ee80/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/1934ea6d-adc6-4124-b812-9dda3c57d95c/image.png" alt=""></p>
<ul>
<li>정상적으로 적용되지 않은 경우
<img src="https://velog.velcdn.com/images/da_na/post/90603d74-6a39-44de-88e2-83f837792127/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/fe39938d-6cb0-4098-9745-e5da2bf8d25e/image.png" alt=""></p>
<p>즉, 해당 웹 페이지의 CSS의 영향을 받지 않도록 해야 합니다.</p>
<hr>
<h2 id="2-해결-방법-검토하기">2. 해결 방법 검토하기</h2>
<p>해당 웹 페이지에 적용된 기존 CSS의 영향을 받지 않도록 하기 위해서 해결 방법을 검토했습니다.</p>
<p>즉, 기존의 CSS와 새롭게 적용할 CSS 스타일 충돌을 방지해야 했습니다.</p>
<p>따라서 아래의 3가지의 방법 중 한 가지를 사용하기로 하였습니다.</p>
<ol>
<li><strong>문제가 되는 부분을 !important로 처리하기</strong></li>
<li><strong>기존 CSS 스타일 초기화하기</strong></li>
<li><strong>Shadow DOM 사용하기</strong></li>
</ol>
<h3 id="2-1-문제가-되는-부분을-important로-처리하기">2-1. 문제가 되는 부분을 !important로 처리하기</h3>
<p>문제가 되는 부분을 !important하는 것은 어떤 웹 페이지의 CSS와 충돌날지를 모르기 때문에 제가 작성한 크롬 익스텐션의 CSS 모든 부분을 !important를 적용시켜야 했습니다.</p>
<p>그리고 나중에 다른 스타일을 적용하는 경우 우선순위를 정하기 어렵다는 생각을 했습니다.</p>
<p>따라서 모든 부분의 공통적으로 적용되어야 하는 부분인 위치 정보와 관련된 #dadamda-popup의 z-index와 position에서만 !important를 적용시켰습니다.</p>
<h3 id="2-2-기존-css-스타일-초기화하기">2-2. 기존 CSS 스타일 초기화하기</h3>
<p>CSS 스타일 초기화는 브라우저마다 기본으로 제공하는 스타일을 기본값으로 설정해주는 세팅입니다.</p>
<p>따라서 크로스 브라우징 문제를 해결하기 위해서 자주 사용되는 방법입니다.</p>
<p><a href="https://stackoverflow.com/questions/10608924/how-can-i-efficiently-overwrite-css-with-a-content-script">https://stackoverflow.com/questions/10608924/how-can-i-efficiently-overwrite-css-with-a-content-script</a></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/653fda14-5f97-4043-8066-a6fbf0539f73/image.png" alt=""></p>
<pre><code class="language-css">html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed, 
figure, figcaption, footer, header, hgroup, 
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
    margin: 0;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure, 
footer, header, hgroup, menu, nav, section {
    display: block;
}</code></pre>
<p>그러나, 동일하게 injected stylesheet보다 우선순위가 높은 CSS가 적용되기 때문에 CSS 스타일이 초기화되지 않고 그대로 CSS가 이상하게 적용되는 모습을 볼 수 있습니다.</p>
<p>더 나아가서, CSS 스타일을 초기화하면 기존의 CSS 스타일이 초기화되어 웹 페이지의 모든 CSS 스타일에 영향을 줄 수도 있어서, 해당 방법은 사용하지 않는 것이 좋다고 판단하였습니다!</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/074d9a83-ed81-4cf0-82e7-eccfd41ef41c/image.png" alt=""></p>
<p>위의 사진에서 중간에 회색의 선을 보면, 의도한 CSS가 적용되지 않음을 알 수 있습니다.</p>
<p>출처 : <a href="https://inpa.tistory.com/entry/CSS-%F0%9F%93%9A-RESET-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%B4%88%EA%B8%B0%ED%99%94-%F0%9F%94%A8">https://inpa.tistory.com/entry/CSS-📚-RESET-스타일-초기화-🔨</a></p>
<h3 id="2-3-shadow-dom-사용하기">2-3. Shadow DOM 사용하기</h3>
<p>크롬 익스텐션을 사용한 CSS를 초기화하는 방법만 찾아보다가, isolate라는 단어를 보게 되었습니다!!</p>
<p>따라서, 이처럼 CSS를 기존의 CSS에도 영향을 주지 않도 나만의 HTML에 CSS를 적용시키는 독립(격리)시켜야겠다고 생각했습니다.</p>
<p>아래의 참고 자료를 통해서 shadow DOM을 사용해야겠다고 생각했습니다.</p>
<p><a href="https://stackoverflow.com/questions/12783217/how-to-really-isolate-stylesheets-in-the-google-chrome-extension">https://stackoverflow.com/questions/12783217/how-to-really-isolate-stylesheets-in-the-google-chrome-extension</a></p>
<h3 id="shadow-dom이란">Shadow DOM이란?</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/f0e24860-739c-4880-8b5b-4ae19f6fc454/image.png" alt=""></p>
<ul>
<li>웹 컴포넌트는 재사용할 수 있는 커스텀 HTML element를 생성하고, 해당 요소를 캡슐화하는 기술입니다.</li>
<li>캡슐화를 통해 마크업, 스타일, 동작을 외부로부터 격리하여, 웹페이지의 다른 구성 요소의 간섭을 방지할 수 있게 도와줍니다.</li>
</ul>
<p>따라서 저의 크롬 익스텐션을 통해서 넣은 CSS와 HTML element를 Shadow Tree를 통해서 분리시켜서 외부로부터 격리하고 캡슐화를 하면 외부의 CSS의 간섭을 방지할 수 있다고 판단하였습니다.</p>
<hr>
<h2 id="3-shadow-dom-적용하기">3. Shadow DOM 적용하기</h2>
<p><img src="https://velog.velcdn.com/images/da_na/post/09ab4a4e-e0cd-4114-9be7-5ba60e3a3ab9/image.png" alt=""></p>
<p>먼저, shadow DOM을 붙일 Shadow host(Shadow DOM이 붙어 있는 일반적인 DOM 노드)를 선언해주고 일반적인 DOM에 해당 Shadow host를 child로 추가해줍니다.</p>
<pre><code class="language-js">// 여기에서 shadowDiv는 shadow host입니다.
var shadowDiv = document.createElement(&quot;div&quot;); 
shadowDiv.setAttribute(&quot;id&quot;, &quot;dadamda-shadow&quot;);

document.body.appendChild(shadowDiv);</code></pre>
<p>shadowRoot를 생성해줍니다. </p>
<pre><code class="language-js">var shadowRoot = shadowDiv.attachShadow({ mode: &#39;open&#39; });</code></pre>
<p>그리고 해당 shadowRoot 안에 독립될 element들(shadow tree)을 넣어줍니다.</p>
<p>저희는 CSS와 dadamda-popup 창을 독립시킬 예정이라서 shadowStyle이라는 CSS 스타일 코드를 만들어서 shadowRoot의 child로 넣어줍니다.</p>
<pre><code class="language-js">var shadowStyle = document.createElement(&#39;style&#39;);
shadowStyle.textContent = ``; // 실제 위의 content.css를 넣어주면 됩니다. 
shadowRoot.appendChild(shadowStyle);</code></pre>
<p>그리고 dadamda-popup의 div를 생성하여 shadowRoot의 child로 추가해주었습니다.</p>
<pre><code class="language-js">var popupDiv = document.createElement(&quot;div&quot;);
popupDiv.setAttribute(&quot;id&quot;, &quot;dadamda-popup&quot;);
shadowRoot.appendChild(popupDiv);</code></pre>
<p>따라서 아래와 같이 정상적으로 원하는 CSS만 적용되는 모습을 확인할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/748e1674-c2d9-4da0-8640-81a30027b765/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/ec256a97-16a7-4c36-8388-996fe1b70d21/image.png" alt=""></p>
<p>참고 자료 </p>
<ul>
<li><a href="https://tech.inflab.com/202208-shadow-root/">https://tech.inflab.com/202208-shadow-root/</a></li>
<li><a href="https://leeproblog.tistory.com/185">https://leeproblog.tistory.com/185</a></li>
</ul>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이전까지는 웹 페이지마다 다르게 적용되어서, 어디에서 오류가 날지를 예측하지 못한 채로 크롬 확장 프로그램을 사용하면서 불안감을 느꼈습니다. 그러나, 이번에 드디어 모든 웹 페이지에서 동일하게 적용되어 크롬 확장 프로그램을 안정적으로 운영할 수 있게 되어서 정말 뿌듯한 시간이었습니다.</li>
<li>shadow DOM이라는 새로운 용어와 DOM의 기술을 살펴보면서 <strong>새로운 웹 기술</strong>에 대해서 알 수 있게 되는 시간이었습니다.</li>
<li>이번에 CSS 스타일을 분리하자!!라는 생각으로 구글에 검색했는데, 원하는 내용을 얻기까지 많은 시간이 걸렸고, isolate라는 단어만 검색하면 바로 원하는 답을 얻을 수 있었다는 것을 알고 나서, 검색의 중요성을 다시 한 번 알게 되었습니다. <strong>앞으로는 알고 싶은 내용을 검색하기 전에 어떠한 단어를 선정하여 검색할 지를 신중하게 선택</strong>해야겠습니다!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Infinite Scroll] Slice와 Page 차이점 분석하여 무한 스크롤 적용하기]]></title>
            <link>https://velog.io/@da_na/Infinite-Scroll-Slice%EC%99%80-Page-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B6%84%EC%84%9D%ED%95%98%EC%97%AC-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/Infinite-Scroll-Slice%EC%99%80-Page-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B6%84%EC%84%9D%ED%95%98%EC%97%AC-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 29 Sep 2023 17:53:57 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 검색 기능을 QueryDSL로 변경하면서, 검색 기능이 무한 스크롤을 적용하고 있어서 반환값을 Slice로 반환해줘야 했습니다!</p>
<ul>
<li><a href="https://velog.io/@da_na/QueryDSL-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-QueryDSL%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0">https://velog.io/@da_na/QueryDSL-검색-기능-QueryDSL로-변경하기</a></li>
</ul>
<p>위의 블로그 글에서는 QueryDSL에서 Slice의 로직을 사용해서 잘 적용하고 있습니다.</p>
<p>그러나, QueryDSL을 사용하는 초기에는 Slice의 값으로 반환하지만, Page를 사용해서 하는 로직으로 하고 있었습니다.</p>
<p>즉, <strong>Slice와 Page 사용방법을 혼동</strong>하여 로직은 Page이지만 반환값은 Slice인 혼합된 코드가 되어버렸습니다.</p>
<pre><code class="language-java">@Override
public Slice&lt;Scrap&gt; searchKeywordInScrapOrderByCreatedDateDesc(User user, String keyword,
        Pageable pageable) {
    List&lt;Scrap&gt; contents = queryFactory
            .selectFrom(scrap)
            .where(
                    scrap.user.eq(user)
                            .and(scrap.deletedDate.isNull())
                            .and(scrap.title.containsIgnoreCase(keyword)
                                        .or(scrap.description.containsIgnoreCase(keyword)))
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(scrap.createdDate.desc())
                .fetch();

        JPAQuery&lt;Long&gt; count = queryFactory.query()
                .from(scrap)
                .select(scrap.count())
                .where(scrap.user.eq(user));

        return PageableExecutionUtils.getPage(contents, pageable, count::fetchOne);
}</code></pre>
<p>그 이유는 QueryDSL을 사용하면서, <strong>Slice에 대한 작동 원리 등을 정확하게 고려하지 않았기 때문</strong>이었습니다.</p>
<pre><code class="language-java">Slice&lt;Scrap&gt; scrapSlice = scrapRepository.findAllByUserAndDeletedDateIsNullAndTitleContainingIgnoreCaseOrDescriptionContainingIgnoreCase(user, keyword, keyword, pageRequest)</code></pre>
<p>따라서 이번 기회에 <strong>Slice와 Page의 차이점을 명확하게 파악</strong>하여 <strong>무한 스크롤 기능을 구현</strong>해보겠습니다!!</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-pagination-vs-무한-스크롤">1. Pagination vs 무한 스크롤</h2>
<h3 id="1-1-pagination">1-1. Pagination</h3>
<p>페이지네이션은 <strong>페이지 단위로 분할</strong>하는 방법입니다.</p>
<p>즉, 아래의 사진과 같이 웹 사이트 내에서 보려는 목록이 많은 경우 페이지 단위로 분할하여 사용자가 필요한 부분만 볼 수 있도록 도와줍니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/f31b2b33-5e39-41b4-a596-d0e8fd37ecc3/image.png" alt=""></p>
<p>이미지 출처 : 네이버 검색 페이지 결과 일부</p>
<h3 id="1-2-infinite-scroll">1-2. Infinite Scroll</h3>
<p>무한 스크롤은 <strong>스크롤을 내릴때마다, 새로운 컨텐츠가 계속해서 로드</strong>되는 방식입니다.</p>
<p>흔히 인스타그램에서 피드를 보기 위해서 계속해서 내리면 새로운 컨텐츠가 나오는 형식입니다.</p>
<hr>
<h2 id="2-page-vs-slice">2. Page vs Slice</h2>
<p>Spring Data JPA에서는 <strong>Pagination</strong>을 위한 두 가지의 객체인 <strong>Page</strong>와 <strong>Slice</strong>를 제공합니다.</p>
<p>Page와 Slice에 공통적으로 필요한 정보가 있습니다.</p>
<ol>
<li><strong>page</strong> : 페이지 번호</li>
<li><strong>size</strong> : 한 페이지에 불러올 데이터 건수</li>
<li><strong>sort</strong> : 정렬 조건</li>
</ol>
<p>Pageable의 구현체인 PageRequest를 통해서 page, size, sort를 입력받음을 알 수 있습니다.
<img src="https://velog.velcdn.com/images/da_na/post/ffb3ff09-4aeb-4179-a41d-2683bd06c462/image.png" alt=""></p>
<p>아래의 저희 프로젝트의 스크랩 Controller 중 스크랩 조회 메소드에서 Pageable은 Pagination을 위한 정보를 저장하는 객체입니다.</p>
<pre><code class="language-java">@GetMapping(&quot;/v1/scraps&quot;)
public ApiResponse&lt;Slice&lt;GetScrapResponse&gt;&gt; getScraps(Pageable pageable,
        Authentication authentication) {

        String email = authentication.getName();

        return ApiResponse.success(scrapService.getScraps(email, pageable));
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/ac97dafa-8a11-487d-9042-2c08102616cd/image.png" alt=""></p>
<p>위의 그림처럼 Swagger에서 Pageable이라는 객체의 page, size, sort를 입력받습니다.</p>
<h3 id="2-1-slice">2-1. Slice</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/e14e1a31-ad8c-4585-81a4-9c42aa22f570/image.png" alt=""></p>
<p><a href="https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Slice.html">https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Slice.html</a></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/cf374095-eac2-43fd-bc2c-872036a9fee7/image.png" alt=""></p>
<h2 id="2-2-page">2-2. Page</h2>
<p>Page는 Slice를 상속하고 있기 때문에 Slice의 모든 메소드를 사용할 수 있습니다.</p>
<p>그러나, 아래의 사진과 같이 Page는 Slice에는 없는 getTotalElements, getTotalPages를 가지고 있습니다.</p>
<p>따라서 이러한 getTotalElements를 위해서 조회 쿼리 이후 전체 데이터 개수를 조회하는 <strong>count 쿼리가 한번 더 실행하게 됩니다</strong>.</p>
<p>이러한 점이 가장 큰 차이점이라고 할 수 있습니다!!</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/cfd0318b-fd1d-49b6-994d-8780142b83ab/image.png" alt="">
<img src="https://velog.velcdn.com/images/da_na/post/fae82915-afb4-4cfa-8d53-52a7475566d1/image.png" alt=""></p>
<hr>
<p>저희 프로젝트에서 스크랩을 조회하는 부분을 Slice와 Page 모두 적용해보면서 실제 실행되는 query비교해보겠습니다.</p>
<pre><code class="language-java">@Transactional
public Slice&lt;GetScrapResponse&gt; getScraps(String email, Pageable pageable) {
        User user = userService.validateUser(email);

        Sort sort = Sort.by(Sort.Direction.DESC, &quot;createdDate&quot;);
        PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
                sort);

        Slice&lt;Scrap&gt; scrapSlice = scrapRepository.findAllByUserAndDeletedDateIsNull(user,
                pageRequest).orElseThrow(() -&gt; new NotFoundException(ErrorCode.NOT_EXISTS_SCRAP));

        return scrapSlice.map(scrap -&gt; GetScrapResponse.of(scrap,
                memoRepository.findMemosByScrapAndDeletedDateIsNull(scrap))
        );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/ca73b3a1-e512-4c9a-9ecc-d7cf705175e9/image.png" alt=""></p>
<pre><code class="language-java">@Transactional
public Slice&lt;GetScrapResponse&gt; getScraps(String email, Pageable pageable) {
        User user = userService.validateUser(email);

        Sort sort = Sort.by(Sort.Direction.DESC, &quot;createdDate&quot;);
        PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
                sort);

        Page&lt;Scrap&gt; scrapSlice = scrapRepository.findAllByUserAndDeletedDateIsNull(user,
                pageRequest).orElseThrow(() -&gt; new NotFoundException(ErrorCode.NOT_EXISTS_SCRAP));

        return scrapSlice.map(scrap -&gt; GetScrapResponse.of(scrap,
                memoRepository.findMemosByScrapAndDeletedDateIsNull(scrap))
        );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/d4d5c78a-7e30-4ef9-9422-c90224e6dee4/image.png" alt=""></p>
<p>위의 사진에서도 볼 수 있듯이, <strong>Page를 사용하면 count 쿼리가 실행</strong>되지만, <strong>Slice는 count 쿼리가 실행되지 않는 모습</strong>을 볼 수 있습니다.</p>
<h2 id="3-slice-vs-page-중-프로젝트에-적합한-것-찾기">3. Slice VS Page 중 프로젝트에 적합한 것 찾기</h2>
<ul>
<li><strong>Slice</strong>는 전체 데이터 개수를 조회하지 않고 <strong>이전이나 다음의 데이터가 존재하는지만을 확인</strong>할 수 있습니다. <ul>
<li>따라서 Slice는 데이터의 개수가 많은 경우 Page보다 <strong>성능상으로 유리</strong>합니다.</li>
</ul>
</li>
<li><strong>Page</strong>는 <strong>전체 데이터 개수를 조회</strong>하기 때문에, <strong>전체 페이지 개수가 필요한 경우인 상황</strong>에서 사용됩니다.<ul>
<li>Page는 pagination과 같이 아래의 검색 결과 페이지 수를 보여줘야 하는 경우에도 사용됩니다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/f440df38-bc4e-4893-9c2b-d5f6554ad7ad/image.png" alt=""></p>
<p>이미지 출처 : 네이버 검색 페이지 결과 일부</p>
<p>저희 프로젝트에서는 스크랩의 개수를 조회하고 있지만, <strong>무한 스크롤하고 있는 중에는 전체 스크롤의 개수를 계속해서 구하지 않아도 되고</strong>, 스<strong>크랩을 추가한 순간에만 해당 전체 스크랩 전체 개수를 부르기 때문에</strong> count를 호출하는 API를 따로 만들어놓았습니다. 따라서 Slice를 사용하여 프로젝트에 도입하기로 하였습니다!</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/bf96e1d1-d690-4e3e-88c2-901f94dbd6cd/image.png" alt=""></p>
<h3 id="참고-자료">참고 자료</h3>
<ul>
<li><a href="https://colour-my-memories-blue.tistory.com/10">https://colour-my-memories-blue.tistory.com/10</a></li>
<li><a href="https://rachel0115.tistory.com/entry/QueryDsl-Page-Slice-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4">https://rachel0115.tistory.com/entry/QueryDsl-Page-Slice-페이지네이션-무한-스크롤</a></li>
<li><a href="https://junior-datalist.tistory.com/342">https://junior-datalist.tistory.com/342</a></li>
</ul>
<h2 id="4-프로젝트에-적용하기">4. 프로젝트에 적용하기</h2>
<h3 id="4-1-page-사용하기">4-1. Page 사용하기</h3>
<ul>
<li><p>아래의 코드는 맨 위의 서론에서 언급한 코드입니다.</p>
</li>
<li><p>즉, <strong>JPAQuery<Long> count = queryFactory.query().from(scrap).select(scrap.count()).where(scrap.user.eq(user))</strong> 코드가 포함되어 있다는 의미는 Page를 사용했다는 것을 의미합니다.</p>
<pre><code class="language-java">@Override
public Slice&lt;Scrap&gt; searchKeywordInScrapOrderByCreatedDateDesc(User user, String keyword,
      Pageable pageable) {
  List&lt;Scrap&gt; contents = queryFactory
          .selectFrom(scrap)
          .where(
                  scrap.user.eq(user)
                          .and(scrap.deletedDate.isNull())
                          .and(scrap.title.containsIgnoreCase(keyword)
                                      .or(scrap.description.containsIgnoreCase(keyword)))
              )
              .offset(pageable.getOffset())
              .limit(pageable.getPageSize())
              .orderBy(scrap.createdDate.desc())
              .fetch();

      JPAQuery&lt;Long&gt; count = queryFactory.query()
              .from(scrap)
              .select(scrap.count())
              .where(scrap.user.eq(user));

      return PageableExecutionUtils.getPage(contents, pageable, count::fetchOne);
}</code></pre>
</li>
</ul>
<p>하지만 저희 프로젝트에서는 count 쿼리가 필요없기 때문에 Slice 방식으로 변경해주겠습니다!</p>
<h3 id="4-2-slice-사용하기">4-2. Slice 사용하기</h3>
<ul>
<li>Page와 다르게 count하는 부분이 사라졌고, <strong>다음의 데이터가 존재하는 지만을 확인할 수 있도록 hasNextPage만 추가</strong>되었습니다.</li>
<li>그리고, <strong>요청한 pagesize보다 1을 크게 조회</strong>하여 만약에 1개가 더 조회되었다면 다음 데이터가 존재하는 것이므로 다음 페이지의 존재 여부는 true가 되고 아니라면 false가 됩니다.</li>
</ul>
<pre><code class="language-java">@Override
public Slice&lt;Scrap&gt; searchKeywordInScrapOrderByCreatedDateDesc(User user, String keyword,
        Pageable pageable) {
        List&lt;Scrap&gt; contents = queryFactory
                .selectFrom(scrap)
                .where(
                        scrap.user.eq(user)
                                .and(scrap.deletedDate.isNull())
                                .and(scrap.title.containsIgnoreCase(keyword)
                                        .or(scrap.description.containsIgnoreCase(keyword)))
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize()+1)
                .orderBy(scrap.createdDate.desc())
                .fetch();

        return new SliceImpl&lt;&gt;(contents, pageable, hasNextPage(contents, pageable.getPageSize()));
}

private boolean hasNextPage(List&lt;Scrap&gt; contents, int pageSize) {
        if (contents.size() &gt; pageSize) {
                contents.remove(pageSize);
                return true;
        }
        return false;
}</code></pre>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>무한 스크롤에 대해서 학습하다가, <strong>no-offset 방식을 사용하여 성능을 개선</strong>하는 블로그를 보게 되었습니다. 따라서 출시 이후에 사용자 수가 많아져서 스크랩의 개수가 많아진다면 조회가 매우 느려질 것으로 예상되어 성능 개선하기 위해서 no-offset 방식을 적용해야겠다는 생각을 했습니다. 이처럼 <strong>조회의 Slice와 Page 객체들의 원리와 동작을 정확하게 이해</strong>하면서, <strong>제공되는 기본 로직뿐만 아니라 성능 개선을 위해서 어떤 부분을 수정해야 할지를 볼 수 있는 시야</strong>를 가지게 된 것 같습니다.<ul>
<li><a href="https://jojoldu.tistory.com/528">https://jojoldu.tistory.com/528</a></li>
</ul>
</li>
<li>더 나아가서, <strong>Slice와 Page의 공식 문서를 확인</strong>하고 구현된 코드를 확인해보면서 어떠한 점이 다르고 왜 다르게 설계되었는지를 파악할 수 있게 되어서 신기하기도 했고, 공식 문서를 어떻게 봐야할지를 조금이나마 알게 되었습니다!!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Security] JWT 만료 예외 처리하기]]></title>
            <link>https://velog.io/@da_na/Spring-Security-JWT-%EB%A7%8C%EB%A3%8C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/Spring-Security-JWT-%EB%A7%8C%EB%A3%8C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 28 Sep 2023 05:54:47 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 Spring Security를 사용하여 JWT를 구현하였습니다.</p>
<p><a href="https://velog.io/@da_na/Spring-Security-OAuth2-%EB%A1%9C%EA%B7%B8%EC%9D%B8-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0">https://velog.io/@da_na/Spring-Security-OAuth2-로그인-JWT-구현하기</a></p>
<p>따라서 JWT를 사용하여 인증과 인가가 성공적으로 구현되었고, 그대로 프로젝트 개발을 진행하였습니다.</p>
<p>그러다가,,, JWT 만료된 시간이 다가왔고, 만료된 JWT를 사용하여 API를 요청했을 때 아래와 같이 500 Internal Server Error가 나왔습니다. 🥲</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/4206ba94-5920-4112-827d-f821e10f965b/image.png" alt=""></p>
<p>따라서 클라이언트쪽에서는 위의 에러가 어떠한 에러인지 자세하게 알 수 없기 때문에 다시 JWT를 발급하기 위해서 로그인으로 돌아가는 과정을 추가할 수 없었습니다.</p>
<p>즉, JWT 예외가 발생하는 경우에 이전에 유효성 검사와 같이 에러 응답과 동일한 형태로 아래의 사진 형태처럼 resultCode와 message를 전달해야했습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/a3a589da-c0d6-4f0a-b61e-2d59943afe3b/image.png" alt=""></p>
<p>하지만 유효성 검사의 에러 응답 같은 경우에는 @RestControllerAdvice에서 처리가 가능했지만, 이번에 발생한 JWT 만료 에러의 경우에는 Spring Security 필터에서 발생한 에러이기 때문에 Controller 단까지 가지 않기 때문에 Filter 자체에서 에러를 처리하는 로직이 추가되어야만 했습니다.</p>
<p>이전에 작성한 JWT 처리하는 로직을 천천히 다시 살펴보면서 어떤 부분을 추가해야 할지를 생각해보겠습니다! 그리고 이 과정에서 발생한 문제점과 해결책을 같이 이야기하겠습니다.</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-jwt-만료-예외-메시지를-살펴보자">1. JWT 만료 예외 메시지를 살펴보자.</h2>
<p>위의 JWT 만료시에 스프링 부트 내에서 발생한 오류 기록을 보면, <strong>ExpiredJwtException</strong>이 발생한 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/f6552617-16f0-4305-bf9b-396431a707e0/image.png" alt=""></p>
<ul>
<li>즉, JwtAuthFilter에서 Jwt 토큰의 만료기한을 확인하는 validateToken에서 만료기한이 넘으면 ExpiredJwtException 예외가 발생함을 알 수 있습니다.</li>
</ul>
<hr>
<h2 id="2-잘못된-jwt-만료-처리-추가">2. 잘못된 JWT 만료 처리 추가</h2>
<p>따라서 Spring Security의 Filter에서 예외가 발생할 경우, 예외를 처리해줄 부분을 추가해주어야 했습니다.</p>
<p>JWT 만료 처리를 하는 방법이 아래와 같이 크게 2가지로 있었습니다.</p>
<p><code>.exceptionHandling().authenticationEntryPoint(new JwtAuthenticationEntryPoint())</code>와 <code>.addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class)</code> 중에서 addFilterBefore를 사용하여 SecurityFilterChain의 filterChain에 추가하였습니다.</p>
<h3 id="addfilterbeforenew-jwtexceptionfilter-jwtauthfilterclass">addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class)</h3>
<ul>
<li>JwtAuthFilter에서 발생한 예외를 JwtExceptionFilter에서 잡아내고, ExpiredJwtException 예외에 대한 request.setAttribute(&quot;exception&quot;, e.getMessage());를 설정하여 JwtAuthenticationEntryPoint에서 commence에서 에러 응답을 클라이언트에 보내도록 하였습니다.</li>
</ul>
<pre><code class="language-java">@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final TokenService tokenService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final OAuth2FailureHandler oAuth2FailureHandler;

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

        http
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests()
                .antMatchers(&quot;/h2-console/**&quot;, &quot;/actuator/**&quot;,
                        &quot;/&quot;, &quot;/api-docs/**&quot;, &quot;/swagger-ui/**&quot;).permitAll()
                .antMatchers(&quot;/v1/**&quot;, &quot;/login/**&quot;).hasRole(Role.USER.name())
                .anyRequest().authenticated()
                .and()
                .logout()
                .logoutSuccessUrl(&quot;/&quot;)
                .and()
                .addFilterBefore(new JwtAuthFilter(tokenService),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class)
                .oauth2Login()
                .successHandler(oAuth2SuccessHandler)
                .failureHandler(oAuth2FailureHandler)
                .userInfoEndpoint()
                .userService(customOAuth2UserService);

        return http.build();
    }
}</code></pre>
<pre><code class="language-java">@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        response.setCharacterEncoding(&quot;utf-8&quot;);

        try{
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e){
            request.setAttribute(&quot;exception&quot;, e.getMessage());
        }
        filterChain.doFilter(request, response);

    }
}</code></pre>
<pre><code class="language-java">@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}</code></pre>
<pre><code class="language-java">@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {

    private final TokenService tokenService;

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

        String token = tokenService.resolveToken((HttpServletRequest) request);

        if (token != null &amp;&amp; tokenService.validateToken(token)) {
            String email = tokenService.getEmail(token);

            Authentication auth = new UsernamePasswordAuthenticationToken(email, &quot;&quot;,
                    Arrays.asList(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;)));

            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        chain.doFilter(request, response);
    }
}</code></pre>
<ul>
<li>JWT 만료시에도 Internal Server가 아니라, 위에서 입력한 대로 JwtAuthenticationEntryPoint에서 처리한 대로 SC_UNAUTHORIZED가 나옴을 알 수 있습니다.</li>
<li>따라서 JWT 만료의 경우, 원하는 대로 에러를 처리할 수 있게 되었습니다.
<img src="https://velog.velcdn.com/images/da_na/post/b4e0772d-30a8-4dbc-806a-5c614b262c39/image.png" alt=""></li>
</ul>
<ul>
<li>그런데 오히려 JWT 만료되지 않는 경우가 에러가 발생했습니다...
<img src="https://velog.velcdn.com/images/da_na/post/ed3d4104-6de9-4e99-9e36-eb6ca1111c82/image.png" alt=""></li>
</ul>
<ul>
<li>NullPointerException이 null로 출력되었습니다. 그리고 Usercontroller의 getUserInfo 메소드(UserController의 29번째 줄)인 String email = authetication.getName() 부분이 null임을 알 수 있습니다. 따라서 authetication 부분이 null로 들어가고 있는 것이었습니다.</li>
<li>authetication 부분은 인증을 받고 나서 SecurityContextHolder 부분에 들어가 있는 부분입니다.
<img src="https://velog.velcdn.com/images/da_na/post/93774915-2654-45ff-95de-947665b866dd/image.png" alt="">
<img src="https://velog.velcdn.com/images/da_na/post/e6efbf90-8196-454d-9a5d-86fafd0cb396/image.png" alt=""></li>
</ul>
<ul>
<li>오류를 찾기 위해서, 다시 localhost에 API를 호출하였는데, 1개의 API에 2개의 응답이 오고 있었습니다.
<img src="https://velog.velcdn.com/images/da_na/post/0f1dc127-68f0-4a1b-aa81-c1e7d5dc13a8/image.png" alt=""></li>
</ul>
<ul>
<li>다른 API를 호출하였을 때에는 호출을 제대로 하고, 다음으로는 null을 반환하였습니다.</li>
<li>즉, 1개의 API가 2번 수행되고 있고, 1번의 수행은 제대로 되었지만, 2번째의 수행에서는 제대로 되지 않았음을 알 수 있습니다.
<img src="https://velog.velcdn.com/images/da_na/post/7f728b3b-7974-439f-8382-bc09990f70ab/image.png" alt=""></li>
</ul>
<p>따라서 JwtExceptionFilter에서 filterChain.doFilter를 1개 지워졌더니, null에러가 나오지 않았지만 JWT 만료시에도 예외처리가 되지 않은 모습을 볼 수 있었습니다.</p>
<pre><code class="language-java">public class JwtExceptionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        response.setCharacterEncoding(&quot;utf-8&quot;);

        try{
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e){
            //만료 에러
            request.setAttribute(&quot;exception&quot;, e.getMessage());

        }
//        filterChain.doFilter(request, response);

    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/3a7d2402-4fd6-4ec2-aedf-400558f1756c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/da0d2eda-8287-46a0-a5ce-775f6967b0d8/image.png" alt=""></p>
<p>따라서, JwtExceptionFilter에서 dofilter가 2번 되어 있기 때문에 만료되지 않은 JWT를 사용하면 2번의 API가 실행됨을 알 수 있습니다.
하지만 dofilter를 지우게 되면 만료된 경우를 예외 처리할 수 없게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/40ada95c-7ac5-4453-8bcf-d7c2c3733e90/image.png" alt=""></p>
<h3 id="참고-자료">참고 자료</h3>
<ul>
<li><a href="https://velog.io/@jkijki12/JWT-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-JWT-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-%EB%B0%8F-%EA%B3%A0%EC%B0%B0">https://velog.io/@jkijki12/JWT-스프링-시큐리티-JWT-예외처리하기-및-고찰</a></li>
<li><a href="https://ailiartsua.tistory.com/25">https://ailiartsua.tistory.com/25</a></li>
<li><a href="https://velog.io/@hellonayeon/spring-boot-jwt-expire-exception">https://velog.io/@hellonayeon/spring-boot-jwt-expire-exception</a></li>
<li><a href="https://yoo-dev.tistory.com/28">https://yoo-dev.tistory.com/28</a></li>
</ul>
<hr>
<h2 id="3-성공한-jwt-만료-처리-추가-방법">3. 성공한 JWT 만료 처리 추가 방법</h2>
<h3 id="exceptionhandlingauthenticationentrypointnew-jwtauthenticationentrypoint">exceptionHandling().authenticationEntryPoint(new JwtAuthenticationEntryPoint())</h3>
<ul>
<li>filter에서 발생한 예외를 handling 할 수 있도록 추가해줍니다.</li>
<li>이때, 저희는 Jwt 인증 관련 예외를 처리할 예정이기 때문에, 인증 예외를 처리하는 authenticationEntryPoint를 추가해주겠습니다.</li>
<li>저희는 기본 오류 페이지가 아닌 만료된 JWT임을 알려주는 JSON 데이터 등으로 응답해야 하기 때문에, 직접 AuthenticationEntryPoint 인터페이스를 구현하고 구현체를 시큐리티에 등록하여 사용하겠습니다.</li>
<li>즉, AuthenticationEntryPoint 인터페이스는 인증되지 않은 사용자가 인증이 필요한 요청 엔드포인트로 접근하려 할 때, 예외를 핸들링 할 수 있도록 도와줍니다.</li>
</ul>
<pre><code class="language-java">@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests()
                .antMatchers(&quot;/h2-console/**&quot;, &quot;/actuator/**&quot;,
                        &quot;/&quot;, &quot;/api-docs/**&quot;, &quot;/swagger-ui/**&quot;).permitAll()
                .antMatchers(&quot;/v1/**&quot;, &quot;/login/**&quot;).hasRole(Role.USER.name())
                .anyRequest().authenticated()
                .and()
                 .logout()
                 .logoutSuccessUrl(&quot;/&quot;)
                 .and()
                 .exceptionHandling()
                 .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                 .and()
                 .addFilterBefore(new JwtAuthFilter(tokenService),
                         UsernamePasswordAuthenticationFilter.class)
                 .oauth2Login()
                .successHandler(oAuth2SuccessHandler)
                .failureHandler(oAuth2FailureHandler)
                .userInfoEndpoint()
                .userService(customOAuth2UserService);
        return http.build();
    }
}</code></pre>
<pre><code class="language-java">public class JwtAuthFilter extends GenericFilterBean {

    private final TokenService tokenService;

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

         try {
             String token = tokenService.resolveToken((HttpServletRequest) request);

             if (token != null &amp;&amp; tokenService.validateToken(token)) {
                 String email = tokenService.getEmail(token);

                 Authentication auth = new UsernamePasswordAuthenticationToken(email, &quot;&quot;,
                         Arrays.asList(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;)));

                 SecurityContextHolder.getContext().setAuthentication(auth);
             }
         } catch (Exception e) {
             request.setAttribute(&quot;exception&quot;, e.getMessage());
         }

         chain.doFilter(request, response);
     }
}

@Service
@Slf4j
public class TokenService {

    public boolean validateToken(String token) {
        Jws&lt;Claims&gt; claims = Jwts.parserBuilder().setSigningKey(secretKey)
                .build().parseClaimsJws(token);

        return claims.getBody().getExpiration()
                .after(new Date(System.currentTimeMillis()));
    }
}</code></pre>
<pre><code class="language-java">@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}</code></pre>
<ul>
<li>아래의 사진처럼 성공적으로 JWT 만료시에는 401 에러가 나오고, JWT가 만료되지 않았다면 200 성공 응답과 관련 json이 응답됨을 알 수 있습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/48dd0806-20fa-4558-9bd4-a2014cb6d2ce/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/3a048ddf-a76b-419d-a936-43487b957975/image.png" alt=""></p>
<ul>
<li>이때, 저희는 아래와 같이 401 에러가 아닌 400 에러와 함께 json으로 응답 메시지까지 설정해주어서 동일한 에러 응답 형태로 반환할 수 있게 되었습니다!</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/2467e87c-ab1d-4025-9a9b-0dae994e83cc/image.png" alt=""></p>
<pre><code class="language-java">@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException {

        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.setCharacterEncoding(&quot;utf-8&quot;);
        response.setContentType(&quot;application/json&quot;);

        JwtErrorResponse jwtErrorResponse = new JwtErrorResponse(
                ErrorCode.INVALID_AUTH_TOKEN.getCode(), ErrorCode.INVALID_AUTH_TOKEN.getMessage());
        ObjectMapper objectMapper = new ObjectMapper();
        String result = objectMapper.writeValueAsString(jwtErrorResponse);

        response.getWriter().write(result);
    }
}</code></pre>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이번 계기로 JWT 도입과 같이 새로운 기능을 추가하게 되는 경우, <strong>모든 에러 처리를 대비하는 것이 매우 중요하다</strong>는 것을 알게 되었습니다.</li>
<li>다행히도 출시 전인 개발 과정에서 에러 처리를 추가할 수 있게 되었지만, 만약에 출시 후였다면 모든 회원들에게 영향을 미칠 정도로 매우 큰 에러이기 때문에 <strong>다음부터는 더 세세하게 많은 에러의 상황을 고려하고 전반적인 에러 처리를 해야겠다</strong>는 다짐을 할 수 있게 되었습니다.</li>
<li>에러를 처리할 때에도 <strong>여러 개의 예시들을 찾아보면서 나의 프로젝트에 맞는 에러 방법을 찾아가고</strong>, 예외를 처리를 추가하다가 오히려 JWT 만료되지 않은 경우와 같이 정상적으로 동작하는 로직을 건들여서 에러를 발생할 수 있는 것처럼 <strong>추가한 상황에 대한 테스트뿐만 아니라 이전의 모든 테스트 상황도 테스트가 완벽하게 되는지 확인하고 코드를 추가해야겠다</strong>는 다짐을 한 번 더 하게 되었습니다!!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] @Valid, @Validated 차이점 분석해서 유효성 검사 적용하기]]></title>
            <link>https://velog.io/@da_na/Spring-Boot-Valid-Validated-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B6%84%EC%84%9D%ED%95%B4%EC%84%9C-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/Spring-Boot-Valid-Validated-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B6%84%EC%84%9D%ED%95%B4%EC%84%9C-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 26 Sep 2023 13:19:08 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 클라이언트의 요청 관련 Validation을 @Valid를 사용해서 @NotBlank, @URL, @Size 어노테이션을 사용하여 <strong>DTO에서는 유효성 검사</strong>를 했습니다.</p>
<p>아래의 예시는 스크랩 생성할 때, url을 입력하지 않고 &quot;&quot; 공백을 입력하는 경우에 에러가 발생하고 클라이언트에게 에러 메시지를 보내주게 됩니다.</p>
<p>이러한 유효성 검사가 있어야만 사용자의 잘못된 입력으로 인한 서버 오류(sql에 null이 들어가면 안되는 경우 등)를 미리 처리하여 시간 및 리소스 낭비 등을 방지할 수 있습니다!</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class CreateScrapRequest {

    @NotBlank(message = &quot;url을 입력해주세요.&quot;)
    @URL(message = &quot;URL 형식이 유효하지 않습니다.&quot;)
    @Size(max = 2083, message = &quot;최대 2083자까지 입력할 수 있습니다.&quot;)
    private String pageUrl;
}</code></pre>
<p>따라서 아래와 같이 RestControllerAdvice인 ControllerExceptionAdvice의 handleValidationError로 잡아서 에러를 클라이언트에게 알려주었습니다.</p>
<pre><code class="language-java">/**
* 400 Bad Request (잘못된 요청, Validation Exception)
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
private ApiResponse&lt;Object&gt; handleValidationError(BindException e) {
String errorMessage = e.getBindingResult().getFieldErrors().stream()
        .map(DefaultMessageSourceResolvable::getDefaultMessage)
        .collect(joining(&quot;\n&quot;));
Sentry.captureException(e);
return ApiResponse.error(ErrorCode.INVALID, errorMessage);
}</code></pre>
<p>아래와 같이 resultCode, message 형태로 response를 반환함을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/b0adc63b-d2c6-43eb-bf79-01c7102dd8aa/image.png" alt=""></p>
<p>그러면 DTO를 사용하지 않고 <strong>Parameter와 PathVariable로 입력받는 경우에 유효성 검사</strong>를 위해서 @Valid를 사용했는데 이 과정에서 발생한 오류와 해결과정을 소개해드리겠습니다!</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="1-validated-어노테이션-notblank-사용하기">1. @Validated 어노테이션, @NotBlank 사용하기</h2>
<p>저는 처음에는 @RequestParam(&quot;keyword&quot;) 뒤에 @NotBlank를 붙여주고, Controller Class에 @Validated 어노테이션을 작성함으로써 유효성 검사를 하려고 했습니다.</p>
<pre><code class="language-java">@Validated
@RequiredArgsConstructor
@RestController
public class ArticleController {
    @Operation(summary = &quot;아티클 스크랩 검색&quot;, description = &quot;스크랩을 검색할 수 있습니다.&quot;)
    @GetMapping(&quot;/v1/scraps/article/search&quot;)
    public ApiResponse&lt;Slice&lt;GetScrapResponse&gt;&gt; searchArticles(
            @RequestParam(&quot;keyword&quot;) @NotBlank String keyword,
            Pageable pageable,
            Authentication authentication) {

        if(keyword.isBlank()) {
            throw new InvalidException(&quot;검색어를 입력해주세요.&quot;);
        }
        String email = authentication.getName();

        return ApiResponse.success(articleService.searchArticles(email, keyword, pageable));
    }
}</code></pre>
<p>그러나, 위의 코드에서 ControllerExceptionAdvice의 handleValidationError로 에러를 처리하지 못하고 원하는 응답값을 얻을 수 없었습니다.</p>
<p>아래의 사진처럼 Internal Server Error가 나왔습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/da575744-ff36-4726-8b7e-18fbddd74f1a/image.png" alt=""></p>
<p>원하는 형태의 에러 응답 형식이 나오지 않았습니다.</p>
<p>따라서 아래와 같이 if 문을 사용해서 예외 처리하는 로직을 추가했습니다.</p>
<p>그러면, 예외 처리 로직이 추가될 때마다 if문이 늘어나기 때문에 DTO의 @NotBlank와 같은 어노테이션이 없는지 처리를 더 간결하게 할 수 없는지 고민해보았습니다.</p>
<pre><code class="language-java">@Operation(summary = &quot;스크랩 검색&quot;, description = &quot;스크랩을 검색할 수 있습니다.&quot;)
@GetMapping(&quot;/v1/scraps/search&quot;)
public ApiResponse&lt;Slice&lt;GetScrapResponse&gt;&gt; searchScraps(
        @RequestParam(&quot;keyword&quot;) String keyword,
        Pageable pageable,
        Authentication authentication) {

    if(keyword.isBlank()) {
        throw new InvalidException(&quot;검색어를 입력해주세요.&quot;);
    }
    String email = authentication.getName();

    return ApiResponse.success(scrapService.searchScraps(email, keyword, pageable));
}</code></pre>
<p>그러면 어떻게 에러를 처리해야 할까?? 유효성 검사니까 동일한 에러가 나오지 않을까?라는 의문을 가지다가 에러의 trace 응답값에 주목하게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/06435faa-e8c6-477e-8105-8d2c8098f5a5/image.png" alt=""></p>
<p>이전에는 유효성 검사 handler는 @ExceptionHandler(BindException.class)였습니다.</p>
<p>그러나, Parameter의 유효성 검사에서 발생한 예외는 BindException이 아닌 <strong>ConstraintViolationException</strong>였습니다.</p>
<p>따라서 아래와 같이 ConstraintViolationException 예외를 처리해주는 handler를 추가해주었습니다.</p>
<pre><code class="language-java">@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ConstraintViolationException.class)
private ApiResponse&lt;Object&gt; handleValidationParameterError(ConstraintViolationException e) {
    String errorMessage = e.getMessage();
    Sentry.captureException(e);
    return ApiResponse.error(ErrorCode.INVALID, errorMessage);
}</code></pre>
<p>아래와 같이 원하는 형태의 에러 응답 형식을 반환할 수 있게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/08543b7b-7fed-4bf7-862e-9cec087b6bd6/image.png" alt=""></p>
<hr>
<h2 id="2-valid와-validated-차이점">2. @Valid와 @Validated 차이점</h2>
<p>그러면 왜 비슷한 어노테이션에서 동일한 유효검사를 해주었는데 왜 다른 예외가 발생하고 어느 차이가 있을지 학습하고 싶었습니다.</p>
<h3 id="2-1-valid-어노테이션-동작-원리">2-1. @Valid 어노테이션 동작 원리</h3>
<p>@Valid 어노테이션은 SR-303 표준 스펙(자바 진영 스펙)으로써 빈 검증기(Bean Validator)를 이용해 객체의 제약 조건을 검증하도록 지시하는 어노테이션입니다.</p>
<p>Controller 로 요청이 들어오면, Dispatcher Servlet을 걸쳐서 Controller가 호출이 됩니다.
이 과정에서 Dispatcher Servlet 에서 @Valid 를 찾아서 검증을 진행하게 됩니다.</p>
<p>@Valid를 통해 검증을 한 후, 만약 요구사항을 충족하지 못하여 실패한 경우 위의 두 예외를 발생시키게 됩니다.</p>
<p>@ModelAttribute 어노테이션으로 받은 파라미터에서 검증 오류가 발생한 경우 <strong>BindException</strong>이 발생하고
@RequestBody 어노테이션으로 받은 파라미터에서 검증 오류가 발생한 경우 <strong>MethodArgumentNotValidException</strong>를 발생시킵니다.</p>
<h3 id="2-2-validated-어노테이션-동작-원리">2-2. @Validated 어노테이션 동작 원리</h3>
<p>@Validated는 <strong>AOP 기반으로 메소드 요청을 인터셉터하여 처리</strong>됩니다. </p>
<p>@Validated를 클래스 레벨에 선언하면 해당 클래스에 유효성 검증을 위한 AOP의 어드바이스 또는 인터셉터(MethodValidationInterceptor)가 등록됩니다. </p>
<p>그리고 해당 클래스의 메소드들이 호출될 때 AOP의 포인트 컷으로써 요청을 가로채서 유효성 검증을 진행합니다.</p>
<ul>
<li><p>즉, validated 어노테이션은 createMethodValidationAdvice() 메서드를 통해 AOP Advice인 MethodValidationInterceptor를 등록합니다.</p>
</li>
<li><p>여기서 등록된 MethodValidationInterceptor가 메서드 요청을 가로채 유효성 검사를 진행하게 됩니다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/fcd2845e-fded-4cf8-8d3d-72f57db3c3bd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/1c56a054-df17-40d8-88f2-7eed710825bf/image.png" alt=""></p>
<p>이러한 이유로 @Valid에 의한 예외는 MethodArgumentNotValidException이며, @Validated에 의한 예외는  ConstraintViolationException입니다.</p>
<p>참고 자료 1 : <a href="https://mangkyu.tistory.com/174">https://mangkyu.tistory.com/174</a></p>
<p>참고 자료 2 : <a href="https://kapentaz.github.io/spring/Spring-Boo-Bean-Validation-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90/#">https://kapentaz.github.io/spring/Spring-Boo-Bean-Validation-제대로-알고-쓰자/#</a></p>
<p>참고 자료 3 : <a href="https://wildeveloperetrain.tistory.com/158">https://wildeveloperetrain.tistory.com/158</a></p>
<p>참고 자료 4 : <a href="https://kdhyo98.tistory.com/80">https://kdhyo98.tistory.com/80</a></p>
<hr>
<h1 id="3️⃣-결론">3️⃣ 결론</h1>
<ul>
<li>스프링의 어노테이션을 사용하면서, 어노테이션의 동작하는 원리에 대해 더 깊이 있게 이해할 수 있는 시간이 된 것 같습니다.</li>
<li>이름과 기능이 비슷한 valid과 validated 어노테이션을 사람들의 코드를 참고하여 무조건 사용하기보다는 동작 원리를 이해하면서 나의 상황에 맞는 처리 방법을 알게 되었습니다.</li>
<li>라이브러리의 로직 및 스프링의 동작 원리(AOP, Dispatcher Servlet)을 알고 이해하며, 라이브러리가 처리하는 방식을 통해서 예외 처리 방법을 알게 된 것 같습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] 08. Mediator 패턴]]></title>
            <link>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-08.-Mediator-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-08.-Mediator-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Mon, 25 Sep 2023 02:50:50 GMT</pubDate>
            <description><![CDATA[<h1 id="part-1-mediator-패턴-다이어그램">PART 1. Mediator 패턴 다이어그램</h1>
<p>Mediator 패턴은 <strong>멤버(Colleague)들이 Medaiator 조정자이자 중재자에게 이야기를 하고, 중재자만이 멤버에게 지시를 내리는 패턴</strong>입니다.</p>
<p>예를 들어서, <em>관제탑</em>을 생각했을 때 여러 비행기의 조종사가 대화하지 않고 관제탑과 이야기를 하는 것과 유사하다고 생각할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/ddde4eca-1db1-4d71-8afe-e4f32e9c2d5b/image.png" alt=""></p>
<p>따라서 위의 패턴 다이어그램에서 볼 수 있듯이 여러 Colleague가 있고 서로 이야기하지 않고 Mediator와 연결되어 있음을 알 수 있습니다.</p>
<p>Colleague 쪽에서는 Mediator를 설정(setMediator)해줘야 하고, Mediator는 Colleauge들을 설정(생성)(createColleagues)해줘야 합니다.</p>
<p>colleagueChanged는 Colleague들이 Mediator에게 이야기를 하는 메소드이고, controlColleague는 Mediator가 내린 지시를 따르는 과정입니다.</p>
<p>즉, <strong>N:M 관계를 N:1 관계로 바꾸어줍니다</strong>. 
(이때, 1은 Mediator, N은 Colleague입니다.)</p>
<p>하지만 여기에서 혼동하지 말아야 할 점은 <strong>Colleague들은 서로 이야기를 하고 싶어한다</strong>는 점입니다. </p>
<hr>
<p>그러면 Mediator는 언제 사용될까요??</p>
<p>주로 <strong>다수의 객체 사이에서 조정해야 할 때</strong> 사용됩니다.</p>
<p>Mediator 패턴은 <strong>단순화하다</strong>라는 챕터에 있습니다. </p>
<p>이처럼 Colleague들이 서로 조정해야 하는 복잡한 상황에서 Mediator를 통해서 소통하도록 단순화할 수 있음을 의미합니다.</p>
<pre><code class="language-java">public interface Mediator {
    public abstract void createColleagues();
    public abstract void colleagueChanged();
}</code></pre>
<pre><code class="language-java">public interface Colleague {
    public abstract void setMediator(Mediator mediator);
    public abstract void setColleagueEnabled(boolean enabled);
}</code></pre>
<pre><code class="language-java">public class ConcreteColleague1 implements Colleague {
    private Mediator mediator;

    public ConcreteColleague1() {}

    @Override     // Mediator를 설정한다.
    public void setMediator(Mediator mediator) {
        this.mediator = mediator;
    }

    @Override     // Mediator에서 지시한다.
    public void setColleagueEnabled(boolean enabled) {
        setEnabled(enabled);
    }
}</code></pre>
<pre><code class="language-java">public class ConcreteColleague2 implements Colleague {
    private Mediator mediator;

    public ConcreteColleague2() {}

    @Override     // Mediator를 설정한다.
    public void setMediator(Mediator mediator) {
        this.mediator = mediator;
    }

    @Override     // Mediator에서 지시한다.
    public void setColleagueEnabled(boolean enabled) {
        setEnabled(enabled);
    }

    @Override
    public void textValueChanged(TextEvent e) {
        // 문자열이 바뀌면 Mediator에 알린다.
        mediator.colleagueChanged();
    }
}</code></pre>
<pre><code class="language-java">public class ConcreteMediator implements Mediator {
    private ConcreteColleague1 colleague1;
    private ConcreteColleague2 colleague2;

    public ConcreteMediator() {
        createColleagues();
        colleagueChanged(); // Colleague 생성
    }

    @Override
    public void createColleagues() { 
        //Colleague를 생성한다. (Mediator가 관리할 멤버를 생성) 
        colleague1 = new ConcreteColleague1();
        colleague2 = new ConcreteColleague2();

        // Mediator 설정
        colleague1.setMediator(this);
        colleague2.setMediator(this);
    }

    @Override
    public void colleagueChanged() {
        if(colleague1.getState()) {
            colleague2.setColleagueEnabled(false);
        } else { 
            colleague2.setColleagueEnabled(true);
        }
    }
}</code></pre>
<p>ConcreteMediator에서 가장 주목해야 할 부분은 colleagueChanged입니다!</p>
<p>여기에서는 ConcreteColleague들이 서로 통신해야 하는 부분을 Mediator에서 파악하고 지시를 내리는 부분이기 때문입니다.</p>
<p>지금은 ConcreteColleague가 2개라서 복잡해보이지는 않지만 ConcreteColleague 클래스가 늘어날 수록 colleagueChanged 메소드의 처리 부분이 늘어나서 더 복잡한 처리를 수행해줄 수 있습니다.</p>
<p>아래의 시퀀스 다이어그램에서도 확인할 수 있듯이 ConcreteMediator에게 ConcreColleague가 이야기하고 지시를 내리는 부분을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/da0097ea-d791-4fdf-bfe1-24b74011bfb6/image.png" alt=""></p>
<p>하지만 ConcreteColleague1은 특정한 상황에 의존하는 코드가 없어서 다른 클래스에서 재사용하기 쉽지만, ConcreteMediator는 if, else 문과 같이 특정한 상황을 처리하고 대처하는 코드가 작성되어 있어서 재사용하기 어렵고 애플리케이션에 대한 의존성이 높다고 할 수 있습니다.</p>
<hr>
<h1 id="part-2-높은-coupling-줄이기">PART 2. 높은 Coupling 줄이기</h1>
<p>PART 1에서 Mediator 패턴은 N:M의 관계를 N:1 관계로 바꿔주는 패턴이라고 이야기를 했습니다.</p>
<p>그러면 N:M 관계이면 어떠한 문제가 발생할까요??</p>
<p>바로, <strong>높은 Coupling이 발생한다</strong>는 점입니다.</p>
<p>높은 Coupling은 서로 서로를 의존하기 때문에 다른 한개가 바뀌면 다른 것도 영향을 크게 받습니다.</p>
<p>여러 프로그램에서는 높은 coupling 같은 경우 complie이 안되는 경우도 있습니다.</p>
<p>따라서 Spring에서는 <strong>DI 의존성 주입</strong>을 통해서 이러한 Coupling을 낮추고 있습니다.</p>
<p>이때, DI 의존성 주입 방법으로 setter 주입이 있습니다.</p>
<p>이처럼 Mediator 패턴에서도 setMediator라는 메소드를 통해서 ConcreteColleauge들과 Mediator를 연결하여 Coupling을 낮출 수 있습니다.</p>
<pre><code class="language-java">public class ConcreteColleague1 implements Colleague {
    private Mediator mediator;

    public ConcreteColleague1() {}

    @Override     // Mediator를 설정한다.
    public void setMediator(Mediator mediator) {
        this.mediator = mediator;
    }

    @Override     // Mediator에서 지시한다.
    public void setColleagueEnabled(boolean enabled) {
        setEnabled(enabled);
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] 07. Visitor 패턴]]></title>
            <link>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-07.-Visitor-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-07.-Visitor-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Sat, 23 Sep 2023 13:43:41 GMT</pubDate>
            <description><![CDATA[<h1 id="part-1-visitor-패턴-다이어그램">PART 1. Visitor 패턴 다이어그램</h1>
<p>Visitor 패턴은 &quot;방문자&quot;라는 의미로 <strong>데이터 구조와 처리를 분리</strong>합니다.</p>
<p>즉, 데이터 구조 안을 돌아다니면서 처리할 ‘방문자’를 나타내는 클래스가 있습니다.</p>
<p>그리고 새로운 처리를 추가하고 싶을 때는 새로운 방문자를 만들면 됩니다.</p>
<p>데이터 구조 쪽에서는 방문자를 받아들일 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/c1efc85e-e9b6-482d-97ef-f7c5b7a67509/image.png" alt=""></p>
<p>여기에서 가장 주목해야 할 점은 Visitor와 Element가 연결되어 있는 구조가 아닌 <strong>끊어있는 구조</strong>라는 점입니다.</p>
<p>따라서 Visitor 패턴은 ConcreteElementA 클래스나 ConcreteElementB 클래스의 부품으로서의 <strong>독립성</strong>을 높여줄 수 있습니다.</p>
<p>만약에 Visitor 패턴을 사용하지 않고 ConcreteElementA 클래스에 처리할 내용을 작성하는 경우 새로운 처리를 추가하고 싶을 때마다 ConcreteElementA 클래스의 내용을 수정해야 합니다.</p>
<p>아래의 코드는 visitor 패턴을 사용하지 않고 accept 메소드에 처리할 내용을 작성하는 경우입니다.</p>
<p>이때, 처리할 내용은 간단하게 출력하는 함수로 대체해주겠습니다.</p>
<pre><code class="language-java">public class ConcreteElementA extends Element {

    public ConcreteElementA() {}

    @Override
    public void accept() {
        System.out.println(&quot;hello&quot;);
    }
}</code></pre>
<ul>
<li>accpet 메소드에서 hello 뿐만 아니라 hello2, hello3를 출력하는 처리를 추가하면 아래와 같이 ConcreteElementA를 수정해주게 됩니다!!</li>
</ul>
<pre><code class="language-java">public class ConcreteElementA extends Element {

    public ConcreteElementA() {}

    @Override
    public void accept() {
        System.out.println(&quot;hello&quot;);
        System.out.println(&quot;hello2&quot;);
        System.out.println(&quot;hello3&quot;);
    }
}</code></pre>
<p>하지만 Visitor 패턴을 사용하게 된다면 아래의 코드와 같이 나타낼 수 있습니다.</p>
<pre><code class="language-java">public class ConcreteElementA extends Element {

    public ConcreteElementA() {}

    @Override
    public void accept(Visitor v) {
        v.visit(this);
    }
}

public class ConcreteVisitor extends Visitor {

    @Override
    public void visit(ConcreteElementA concreteElementA) {
        System.out.println(&quot;hello&quot;);
    }
}</code></pre>
<ul>
<li>처리를 추가하고 싶은 경우, ConcreteElementA는 건들이지 않은 채로 ConcreteVisitor 클래스의 visit 메소드를 수정해주면 됩니다!</li>
</ul>
<pre><code class="language-java">public class ConcreteElementA extends Element {

    public ConcreteElementA() {}

    @Override
    public void accept(Visitor v) {
        v.visit(this);
    }
}

public class ConcreteVisitor extends Visitor {

    @Override
    public void visit(ConcreteElementA concreteElementA) {
        System.out.println(&quot;hello&quot;);
        System.out.println(&quot;hello2&quot;);
        System.out.println(&quot;hello3&quot;);
    }
}</code></pre>
<p>이처럼 처리를 데이터 구조와 분리할 수 있기 때문에, 데이터 구조는 수정하지 않고 처리를 담당하는 visit 클래스만 수정하면 되기 때문에 역할 분리가 명확하여 유지 보수가 용이해질 수 있습니다.</p>
<hr>
<h1 id="part-2-ocp-개방-폐쇄-원칙">PART 2. OCP 개방 폐쇄 원칙</h1>
<p>OCP는 개방 폐쇄 원칙으로 <strong>확장에 대해서는 열려있고, 수정에 대해서는 닫혀있는다</strong>는 것을 의미합니다.</p>
<p>즉, Visitor 패턴을 사용하면 OCP를 지킬 수 있게 됩니다.</p>
<p>PART 1에서도 살펴본 예시로도 처리의 내용을 추가하는 확장에 대해서도 ConcreteElementA의 입장에서는 아무런 수정이 없었지만 처리의 내용이 추가되어서 확장에 대해서는 열려 있다는 것을 살펴보았습니다!</p>
<p>하지만, OCP는 Element에만 해당하는 이야기입니다.</p>
<p>PART 1에서 Visitor 패턴을 사용하여 처리의 내용을 추가하는 과정에서 ConcreteElementA는 수정하지 않았지만, ConcreteVisitor는 확장을 하기 위해서 수정을 했기 때문입니다.</p>
<p>더 나아가서 Visitor는 Element 인터페이스를 구현하는 클래스(ConcreteElementA, ConcreteElementB)가 늘어날 수록 아래와 같이 Visitor 인터페이스와 ConcreteVisitor 클래스의 메소드가 늘어나게 됩니다.</p>
<pre><code class="language-java">public class ConcreteElementA extends Element {

    public ConcreteElementA() {}

    @Override
    public void accept(Visitor v) {
        v.visit(this);
    }
}

public class ConcreteElementB extends Element {

    public ConcreteElementB() {}

    @Override
    public void accept(Visitor v) {
        v.visit(this);
    }
}

public class ConcreteVisitor extends Visitor {

    @Override
    public void visit(ConcreteElementA concreteElementA) {
        System.out.println(&quot;hello&quot;);
        System.out.println(&quot;hello2&quot;);
        System.out.println(&quot;hello3&quot;);
    }

    @Override
    public void visit(ConcreteElementB concreteElementB) {
        System.out.println(&quot;Bye&quot;);
        System.out.println(&quot;Bye2&quot;);
        System.out.println(&quot;Bye3&quot;);
    }
}</code></pre>
<p>즉, Visitor는 모든 Element를 알고 있어야 합니다.</p>
<p>그리고 모든 Element의 상세 내용을 정의해야 합니다.</p>
<p>그러므로 <strong>Visitor 패턴은 Element의 관점에서는 OCP를 지킨다고 할 수 있지만, Visitor의 관점에서는 OCP를 지키지 않는다고 이야기할 수 있습니다</strong>.</p>
<p>따라서 이러한 장단점을 명확하게 파악하여 패턴을 더 적합한 상황에 적용해야함을 알 수 있습니다!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] 06. Strategy 패턴]]></title>
            <link>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-06.-Strategy-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-06.-Strategy-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Sat, 23 Sep 2023 13:42:05 GMT</pubDate>
            <description><![CDATA[<h3 id="part-1-strategy-패턴-다이어그램">PART 1. Strategy 패턴 다이어그램</h3>
<p>Strategy 패턴은 해당 이름 뜻에서도 알 수 있듯이 <code>전략</code>과 관련된 패턴입니다.</p>
<p>즉, 전략(알고리즘)을 바꿔서, 같은 문제를 다른 방법으로 해결하기 쉽게 만들어주는 패턴입니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/ac0c017d-3d73-4994-9e59-2583bc924c5d/image.png" alt=""></p>
<p>여기에서 살펴보아야 할 것은 <code>Context</code> 클래스입니다.</p>
<p>Context 클래스가 전략(알고리즘)을 선택적으로 교체할 수 있도록 합니다.</p>
<p>전략(알고리즘)을 선택적으로 교체해야 하는 상황을 살펴보겠습니다.</p>
<ol>
<li><code>알고리즘을 개량해서 더 빠르게 만들고 싶은 경우</code></li>
</ol>
<ul>
<li>Strategy패턴을 이용하면, Strategy 역의 인터페이스를 변경하지 않고, ConcreteStrategy역만 수정하면 됩니다.</li>
</ul>
<ol start="2">
<li><p><code>원래 알고리즘과 개량한 알고리즘의 속도를 비교하고 싶은 경우</code></p>
</li>
<li><p><code>알고리즘의 동작이 런타임에 실시간으로 교체 되어야 하는 경우</code></p>
</li>
</ol>
<ul>
<li>만약에 메모리가 적은 환경인 경우 속도는 느리지만, 메모리를 절약하는 전략을 사용하고, 메모리가 많은 환경에서는 속도가 빠르지만 메모리를 더 쓰는 전략을 사용할 수 있습니다.</li>
</ul>
<br/>

<p>여기에서 가장 중요하게 Strategy 패턴를 사용해야 하는 경우는 3번과 같이 알고리즘의 동작이 런타임에 실시간으로 교체되어야 하는 경우라고 할 수 있습니다.</p>
<p>즉, 객체가 할 수 있는 행위들 각각을 전략으로 만들어 놓고, 동적으로 행위의 수정이 필요한 경우 전략을 바꾸는 것만으로 행위의 수정이 가능하도록 만든 패턴입니다.
(출처 : <a href="https://victorydntmd.tistory.com/292">https://victorydntmd.tistory.com/292</a>)</p>
<hr>
<h3 id="part-2-strategy-패턴-다이어그램과-builder-패턴-다이어그램-비교">PART 2. Strategy 패턴 다이어그램과 Builder 패턴 다이어그램 비교</h3>
<ol>
<li>Strategy 패턴 다이어그램
<img src="https://velog.velcdn.com/images/da_na/post/ffaad7bc-576c-4a40-8a10-0329a0771c28/image.png" alt=""></li>
</ol>
<ol start="2">
<li>Builder 패턴 다이어그램
<img src="https://velog.velcdn.com/images/da_na/post/848c7ff8-61c7-415d-8b0d-78565b4924af/image.png" alt=""></li>
</ol>
<p>이전 글에서 Builder 패턴에 대해서 다루어보았습니다.</p>
<p>만약에 Builder 패턴에서 Client가 없다면, Strategy 패턴 다이어그램과 Builder 패턴 다이어그램은 굉장히 유사한 구조라고 할 수 있습니다.</p>
<p>따라서, 해당 패턴을 비교하기 위해서는 Context와 Director의 역할을 잘 생각하면 됩니다.</p>
<ul>
<li><strong>Context 클래스</strong>가 <strong>전략(알고리즘)을 선택적으로 교체</strong>할 수 있도록 한다.</li>
<li><strong>director 클래스</strong>는 <strong>builder의 복잡도를 낮추기 위한 것</strong>입니다.</li>
</ul>
<hr>
<h3 id="part-3-strategy-패턴이-사용되는-예시">PART 3. Strategy 패턴이 사용되는 예시</h3>
<ul>
<li><code>Collections의 sort() 메서드에 의해 구현되는 compare() 메서드</code>에서 사용됩니다.</li>
</ul>
<p>비교하려는 전략 알고리즘을 변경하여, 원하는 전략으로 선택적인 교체가 가능합니다.</p>
<pre><code class="language-java">class StrategyInJava {
    public static void main(String[] args){
        List&lt;Integer&gt; numbers = new ArrayList&lt;&gt;();
        numbers.add(1);
        numbers.add(2);

        Collections.sort(numbers, new Comparator&lt;Integer&gt;()){
            @Override //비교하는 전략 알고리즘을 지정하여, 전략 객체 할당
            public int compare(Integer o1, Integer o2) {
                return o1 - o2;
            }
        }
    }
}</code></pre>
<ul>
<li>더 나아가서, 위의 예시뿐만 아니라 시간을 비교해서 정렬하는 경우와 같이 여러 상황에 맞추어서 전략을 변경할 수도 있습니다.</li>
</ul>
<p>출처 1 : <a href="https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%A0%84%EB%9E%B5Strategy-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90#comparator">https://inpa.tistory.com/entry/GOF-💠-전략Strategy-패턴-제대로-배워보자#comparator</a></p>
<p>출처 2 : <a href="https://m.blog.naver.com/writer0713/221896418002">https://m.blog.naver.com/writer0713/221896418002</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] 05. Builder 패턴]]></title>
            <link>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-05.-Builder-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-05.-Builder-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Sat, 23 Sep 2023 13:40:41 GMT</pubDate>
            <description><![CDATA[<h3 id="part-1-builder-패턴-다이어그램">PART 1. Builder 패턴 다이어그램</h3>
<p>먼저, 생성 패턴 중 하나인 builder 패턴에 대해서 알아보도록 하겠습니다!</p>
<p>build라는 영어의 뜻에서도 이 패턴을 왜 사용하는지 추측해볼 수도 있습니다.</p>
<ul>
<li><p>build는 <code>구조를 갖춘 구조물을 짓다</code>를 의미합니다. </p>
</li>
<li><p>구조물을 짓기 위해서는 매우 복잡한 과정을 차례 차례 수행해야합니다.</p>
</li>
</ul>
<p>따라서, builder 패턴은 복잡한 객체의 생성 과정을 위해서 만들어진 패턴입니다.</p>
<p>여기에서 가장 주목해야 하는 것은 <code>Director 클래스</code>입니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/37da9133-5353-4e7d-afeb-6bfeaa7a100d/image.png" alt=""></p>
<p>위의 builder 패턴 다이어 그램에서 client는 director 클래스를 사용해서, 단지 construct 메소드만 호출하면, director 클래스가 알아서 복잡한 과정(buildPart1, buildPart2)를 수행해서 인스턴스를 만들어줍니다.</p>
<p>이처럼 <strong>director 클래스는 builder의 복잡도를 낮추기 위한 것</strong>입니다.</p>
<p>따라서, 복잡도가 높은 로직을 만들 때에는 builder 패턴을 사용하면 client가 쉽게 사용할 수 있어서 유용하게 사용됩니다.</p>
<pre><code class="language-java">public class Director {
    private Builder builder;

    //생성자
    public Director(Builder builder) { 
        //Builder 클래스는 추상 클래스 -&gt; 인스턴스 생성 X
        //Director의 생성자에 실제로 전달되는 것 = Builder의 하위 클래스의 인스턴스
        this.builder = builder;
    }

    public void construct() {
        builder.builPart1(&quot;Greeting&quot;);
        builder.buildPart2(&quot;일반적인 인사&quot;);
    }
}</code></pre>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        Builder b = (Builder) o;
        Director director = new Director(b);
        director.construct();
    }
}</code></pre>
<hr>
<h3 id="part-2-lombok-builder">PART 2. lombok @Builder</h3>
<p>위의 패턴과 동일한 이름을 가진 스프링 부트의 어노테이션이 있습니다.</p>
<p>여기에서 언급하는 GoF의 디자인 패턴 중 Builder 패턴과 완벽하게 동일하지는 않지만, 해당 이름과 연관성이 있습니다.</p>
<p>먼저, <code>@Builder 어노테이션</code>을 주로 사용하는 이유에 대해서 언급해 보겠습니다.</p>
<p>@Builder 어노테이션은 클래스의 <strong>선택적 매개변수가 많은 상황</strong>에서 가장 유용하게 사용됩니다.</p>
<p>아래의 코드처럼 여러 속성값이 가지고 있는데, 인스턴스 생성시에 어떤 속성값은 들어가야 하고, 안 들어가는 속성값을 따로 명시해줘야 하는 경우 여러 생성자를 명시해주기 어렵습니다. </p>
<pre><code class="language-java">@Builder
public class Person {

    private String username;
    private int age;
    private String area;
    private String phoneNumber;
    private Long money;
    private int cars;
}</code></pre>
<p>따라서 @Builder 어노테이션을 사용하면, 아래의 코드처럼 자동적으로 여러 생성자를 생성해줍니다.</p>
<p>만약에, username을 지정해주지 않더라도 해당 값을 제외한 생성자를 사용할 수 있습니다.</p>
<pre><code class="language-java">public class Person {
    private String username;
    private int age;
    private String area;
    private String phoneNumber;
    private Long money;
    private int cars;

    Person(String username, int age) {
        this.username = username;
        this.age = age;
    }

    public static Person.PersonBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

        //Builder 클래스
    public static class PersonBuilder {
        private String username;
        private int age;

        BuildMeBuilder() {
        }

        public Person.PersonBuilder username(String username) {
            this.username = username;
            return this;
        }

        public Person.PersonBuilder age(int age) {
            this.age = age;
            return this;
        }

        public Person.PersonBuilder area(String area) {
            this.area = area;
            return this;
        }

        public Person.PersonBuilder phoneNumber(String phoneNumber) {
            this.phoneNumber = phoneNumber;
            return this;
        }

        public Person.PersonBuilder money(Long money) {
            this.money = money;
            return this;
        }

        public Person.PersonBuilder cars(int cars) {
            this.cars = cars;
            return this;
        }

        public Person build() {
            return new Person(this.username, this.age, this.area, this.phoneNumber, this.money, this.cars);
        }
    }
}</code></pre>
<p>이와 같이 속성값이 많은 것도 복잡도가 높다는 측면에서 보면, <strong>속성값이 많은 클래스의 인스턴스를 쉽게 생성해주기 때문에 복잡도가 높은 클래스를 사용하기 편리하게 만들어 줍니다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[QueryDSL] 검색 기능 QueryDSL로 변경하기]]></title>
            <link>https://velog.io/@da_na/QueryDSL-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-QueryDSL%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/QueryDSL-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-QueryDSL%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 22 Sep 2023 17:50:40 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>기존에는 JPA를 사용하여 스크랩의 검색을 원하는 로직을 구현하려고 했습니다.</p>
<p>그러나, 원래 의도는 <strong>타이틀이나 디스크립션(설명)에서 keyword가 있는 삭제되지 않고 사용자만의 스크랩을 찾는 로직</strong>이었습니다.</p>
<p>따라서 위의 로직을 지키기 위해서 생성되어야 하는 sql문의 조건을 살펴보겠습니다.</p>
<ol>
<li><strong>삭제되지 않는 스크랩</strong>(DeletedDateIsNull)이어야 한다.</li>
<li><strong>사용자의 스크랩</strong>이어야 한다. (User)</li>
<li><strong>대소문자를 무시</strong>하고 <strong>타이틀이나 디스크립션(설명)에서 keyword가 있어야 한다</strong>. (TitleContainingIgnoreCaseOrDescriptionContainingIgnoreCase)</li>
</ol>
<p>따라서 아래와 같이 코드를 작성했습니다!</p>
<pre><code class="language-java">@Transactional
public Slice&lt;GetScrapResponse&gt; searchScraps(String email, String keyword, Pageable pageable) {
    User user = userService.validateUser(email);

    Sort sort = Sort.by(Sort.Direction.DESC, &quot;createdDate&quot;);
    PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
            sort);

    Slice&lt;Scrap&gt; scrapSlice = scrapRepository
                .findAllByUserAndDeletedDateIsNullAndTitleContainingIgnoreCaseOrDescriptionContainingIgnoreCase(
                        user, keyword, keyword, pageRequest)
                .orElseThrow(() -&gt; new NotFoundException(ErrorCode.NOT_EXISTS_SCRAP));

    return scrapSlice.map(GetScrapResponse::of);
}</code></pre>
<p>그러나, 아래와 같이 실제로 작동되는 로직은 제가 의도한 로직이 아니라 타이틀이나 디스크립션에 or문이 적용되지 않고 아래로 내려갑니다.</p>
<p>따라서 삭제된 scrap에서도 keyword가 검색되는 로직이 됩니다...🥲</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/62689f1e-a63e-4ea6-8deb-a81503269897/image.png" alt=""></p>
<p>더 나아가서, jpa에서 주어진 sql문을 사용하면 <strong>메소드 명이 엄청나게 길어져서 조건을 붙일 때마다 길어져서 메소드를 파악하기 어렵습니다</strong>.
<code>scrapRepository.findAllByUserAndDeletedDateIsNullAndTitleContainingIgnoreCaseOrDescriptionContainingIgnoreCase(user, keyword, keyword, pageRequest)</code></p>
<p>그리고 keyword를 2번 입력해주었는데 sql문의 조건이 4개이기 때문에 각각의 조건을 맞추어주기 위해서 <strong>동일한 keyword임에도 불구하고 두 번 반복해서 중복이 발생하게 된다</strong>는 문제점이 있습니다.</p>
<p>그러면 이러한 문제를 해결하기 위해서 도입한 방법을 소개하겠습니다!!</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<p>먼저 중요한 점은 의도한 로직을 지키기 위해서는 <strong>타이틀이나 디스크립션에 or문이 먼저 적용되어야 하는 우선순위</strong>가 있습니다.</p>
<p>즉, 완벽하게는 sql을 작성하지는 않았지만 아래와 같이 scrap의 타이틀과 디스크립션에서 키워드가 있는지 판단하는 sql문이 ( ) 소괄호 부분이 먼저 실행되어야 합니다.</p>
<p>select scrap.blog_name from scrap where scrap.user_id =? and scrap_deleted_date is null and (scrap.title like %keyword% or scrap.description like %keyword%) </p>
<p>이렇게 우선순위를 정해야 하거나, 검색 같이 조건문과 로직이 복잡한 경우에는 2가지 방식으로 해결할 수 있습니다.</p>
<h2 id="1-✍️-직접-query문-작성하기">1. ✍️ 직접 Query문 작성하기</h2>
<ul>
<li>Java Persistence Query Language(JPQL)은 객체지향 쿼리로 JPA가 지원하는 다양한 쿼리 방법 중 하나입니다.</li>
<li>@Query 어노테이션를 사용해서 직접 query문을 정의할 수 있게 됩니다.</li>
</ul>
<pre><code class="language-java">public interface ScrapRepository extends JpaRepository&lt;Scrap, Long&gt; {

    @Query(value = &quot;SELECT s FROM Scrap s WHERE s.user = :user AND s.deletedDate IS NULL AND (s.title LIKE %:keyword% OR s.description LIKE %:keyword%) ORDER BY s.createdDate DESC&quot;)
    Optional&lt;Slice&lt;Scrap&gt;&gt; findAllByUserAndDeletedDateIsNullAndTitleContainingIgnoreCaseOrDescriptionContainingIgnoreCase(User user, String title, String description, Pageable pageable);
}</code></pre>
<p>위에와 같이 @Query 어노테이션을 사용하여 원하는 query를 직접 지정해서 우선순위 및 자세한 사항을 직접 작성할 수 있습니다.</p>
<p>그러나, <strong>query문을 직접 작성하면 조건 및 오타를 작성</strong>하게 될 수도 있으며, <strong>쿼리 문자열이 상당하게 길어집니다</strong>.</p>
<h2 id="2-🐳-querydsl-적용하기">2. 🐳 QueryDSL 적용하기</h2>
<p>우선은 QueryDSL이 무엇인지 자세하게 설명해드리겠습니다.</p>
<p>QueryDSL은 <strong>하이버네이트 쿼리 언어(HQL: Hibernate Query Language)의 쿼리를 타입에 안전하게 생성 및 관리해주는 프레임워크</strong>입니다.</p>
<ul>
<li>문자가 아닌 코드로 쿼리를 작성할 수 있어 <strong>컴파일 시점에 문법 오류를 확인</strong>할 수 있습니다!!</li>
<li>인텔리제이와 같은 <strong>IDE의 자동 완성 기능</strong>의 도움을 받을 수 있습니다.</li>
<li><strong>복잡한 쿼리나 동적 쿼리 작성이 편리</strong>합니다.</li>
<li>쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있습니다.</li>
<li>JPQL 문법과 유사한 형태로 작성할 수 있어 쉽게 적응할 수 있습니다.</li>
</ul>
<h3 id="2-1-querydsl-의존성-추가하기">2-1. QueryDSL 의존성 추가하기</h3>
<ul>
<li>아래 부분은 QueryDSL로 추가되는 부분만 나타내었습니다.</li>
</ul>
<pre><code class="language-gradle">buildscript {
    ext {
        queryDslVersion = &quot;5.0.0&quot;
    }
}

plugins {
    //위에 부분은 생략
    id &quot;com.ewerk.gradle.plugins.querydsl&quot; version &quot;1.0.10&quot;
}

dependencies {
    //위에 부분은 생략

    // QueryDSL
    implementation &quot;com.querydsl:querydsl-jpa:${queryDslVersion}&quot;
    implementation &quot;com.querydsl:querydsl-apt:${queryDslVersion}&quot;
}

// querydsl
def querydslDir = &quot;$buildDir/generated/&#39;querydsl&#39;&quot;

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}
</code></pre>
<h3 id="2-2-querydslconfig-파일-설정하기">2-2. QuerydslConfig 파일 설정하기</h3>
<pre><code class="language-java">@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}</code></pre>
<h3 id="2-3-repository-구조-변경하기">2-3. Repository 구조 변경하기</h3>
<p>여기에서는 Spring Data Jpa Custom Repository를 사용하여 repository 구조(ScrapRepsitory, ScrapRepositoryCustom, ScrapRepositoryCustomImpl)를 변경하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/66af1be4-d85e-4611-b964-3c5136576454/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/eddb5df4-6f45-4758-a1eb-e6591a00cfbd/image.png" alt=""></p>
<h3 id="2-4-querydsl-사용하기">2-4. QueryDSL 사용하기</h3>
<pre><code class="language-java">public interface ScrapRepository extends JpaRepository&lt;Scrap, Long&gt;, ScrapRepositoryCustom {

}</code></pre>
<ul>
<li><strong>ScrapRepositoryCustom</strong> 클래스에 원하는 기능의 메소드를 작성합니다.</li>
<li>이때, 저는 스크랩에서 키워드를 검색하고, 최신순으로 조회할 예정이라서 <code>searchKeywordInScrapOrderByCreatedDateDesc(User user, String keyword, Pageable pageable)</code>라고 작성하였습니다.</li>
<li>이전과 달라진 점은 <code>scrapRepository.findAllByUserAndDeletedDateIsNullAndTitleContainingIgnoreCaseOrDescriptionContainingIgnoreCase(user, keyword, keyword, pageRequest)</code>에서 메소드명이 간결해지고 명확하게 나타낼 수 있게 되었습니다.</li>
<li>그리고 keyword를 한 번만 넘겨주어도 됩니다.</li>
</ul>
<pre><code class="language-java">public interface ScrapRepositoryCustom {
    Slice&lt;Scrap&gt; searchKeywordInScrapOrderByCreatedDateDesc(User user, String keyword, Pageable pageable);
}</code></pre>
<ul>
<li>JPAQueryFactory을 사용해서 QueryDSL을 본격적으로 작성해보겠습니다.</li>
<li>이때, 여기에서 주목해야 할점은 where 문 부분입니다.</li>
<li><code>scrap.user.eq(user).and(scrap.deletedDate.isNull()).and(scrap.title.containsIgnoreCase(keyword).or(scrap.description.containsIgnoreCase(keyword)))</code> 에서 타이틀과 디스크립션에서 keyword를 검색하는 부분에서 () 소괄호를 붙여서 or문이 우선순위가 높도록 설정할 수 있습니다.</li>
<li>@Query문과 다른 점은 <code>@Query(value = &quot;SELECT s FROM Scrap s WHERE s.user = :user AND s.deletedDate IS NULL AND (s.title LIKE %:keyword% OR s.description LIKE %:keyword%) ORDER BY s.createdDate DESC&quot;)</code>보다 더 구조적이라서 의도한 로직을 더 쉽게 파악할 수 있다는 점입니다.</li>
<li>그리고 offset, limit와 같이 세세한 부분도 설정해줄 수 있어서, 나중에 no-offset과 같이 성능을 향상시켜야 할 경우에도 쉽게 작성할 수 있습니다.</li>
</ul>
<pre><code class="language-java">@RequiredArgsConstructor
public class ScrapRepositoryCustomImpl implements ScrapRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Slice&lt;Scrap&gt; searchKeywordInScrapOrderByCreatedDateDesc(User user, String keyword,
            Pageable pageable) {
        List&lt;Scrap&gt; contents = queryFactory
                .selectFrom(scrap)
                .where(
                        scrap.user.eq(user)
                                .and(scrap.deletedDate.isNull())
                                .and(scrap.title.containsIgnoreCase(keyword)
                                        .or(scrap.description.containsIgnoreCase(keyword)))
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize()+1)
                .orderBy(scrap.createdDate.desc())
                .fetch();

        return new SliceImpl&lt;&gt;(contents, pageable, hasNextPage(contents, pageable.getPageSize()));
    }

    private boolean hasNextPage(List&lt;Scrap&gt; contents, int pageSize) {
        if (contents.size() &gt; pageSize) {
            contents.remove(pageSize);
            return true;
        }
        return false;
    }
}</code></pre>
<p>아래와 같이 위의 QueryDSL을 사용하면 의도한 로직대로 query문이 동작함을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/bd64f592-748e-4e11-846c-1f655681e7a0/image.png" alt=""></p>
<p>그리고 이전에는 JPA를 사용해서 단순한 로직이어서 Repository에 대한 test 코드를 작성하지 않았지만, 이번에는 <strong>직접 QueryDSL로 정의해주었기 때문에 해당 메소드에 대한 테스트를 작성</strong>했습니다.</p>
<pre><code class="language-java">@DataJpaTest
@ActiveProfiles(&quot;test&quot;)
@Sql(scripts = &quot;/truncate.sql&quot;, executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
@Sql(scripts = &quot;/setup.sql&quot;, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Import(TestConfig.class)
public class ScrapRepositoryTest {

    @Autowired
    private ScrapRepository scrapRepository;

    @Autowired
    private UserRepository userRepository;

    String email = &quot;1234@naver.com&quot;;

    @Test
    void should_has_next_is_returned_true_when_the_next_page_is_present() {
        // 다음 페이지가 있을 때, hasNext가 true로 반환된다.
        // given
        User user = userRepository.findByEmailAndDeletedDateIsNull(email).get();
        String keyword = &quot;오늘&quot;;
        Pageable pageable = PageRequest.of(0, 2);

        //when
        Slice&lt;Scrap&gt; results = scrapRepository.searchKeywordInScrapOrderByCreatedDateDesc(user,
                keyword, pageable);

        // then
        assertThat(results.hasNext()).isTrue();
    }

    @Test
    void should_the_title_is_returned_when_searching_for_keywords_without_case_insensitive() {
        // 대소문자를 구분하지 않고 keyword를 검색할 때, 검색 결과가 있으면 해당 title을 반환된다.
        //스크랩 검색할 때, 대소문자를 구분하지 않고 검색할 수 있는 지 확인
        // given
        User user = userRepository.findByEmailAndDeletedDateIsNull(email).get();
        String keyword = &quot;toDay&quot;;
        Pageable pageable = PageRequest.of(0, 2);

        //when
        Slice&lt;Scrap&gt; results = scrapRepository.searchKeywordInScrapOrderByCreatedDateDesc(user,
                keyword, pageable);

        // then
        assertThat(results.getContent().get(0).getDescription()).isEqualTo(&quot;Today is rainy&quot;);
    }
}
</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/f1409c85-bfcf-4882-9221-2615fcbea900/image.png" alt=""></p>
<p>참고 자료 1 : <a href="https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/">https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/</a></p>
<p>참고 자료 2 : <a href="https://velog.io/@soyeon207/QueryDSL-Spring-Boot-%EC%97%90%EC%84%9C-QueryDSL-JPA-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0">https://velog.io/@soyeon207/QueryDSL-Spring-Boot-에서-QueryDSL-JPA-사용하기</a></p>
<hr>
<h1 id="2️⃣-결론">2️⃣ 결론</h1>
<ul>
<li>이전 프로젝트에서 QueryDSL을 사용해본 적이 있어서, 어떤 방식으로 동작하는지는 알고는 있었지만, 이전에는 필요해서 QueryDSL을 도입하기보다는 프로젝트가 이미 QueryDSL을 사용하고 있어서 사용했습니다.</li>
<li>따라서 왜 QueryDSL을 사용해야 하는지 정확하게 알지는 못한 채로 JPA의 기본 문법보다 길어서 최대한 JPA를 사용하려고 하고 QueryDSL을 사용하려고 하지 않았던 것 같습니다.</li>
<li>그러나, 이번에는 <strong>QueryDSL을 먼저 도입하지 않고 직접 JPA를 사용하다가 불편함을 느끼고 왜 QueryDSL을 사용하는 지 직접 체험해보고 정말 필요하구나</strong>!!라는 점을 깨닫고 사용하게 된 것 같아서 QueryDSL의 진가를 더 알게 되고 더 잘 사용할 수 있겠다는 생각을 했습니다☺️</li>
<li>앞으로도 기술이나 새로운 라이브러리 등을 도입할 때 <strong>필요성을 깨닫고 제대로 사용해야겠다</strong>는 다짐을 했습니다!!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Error] Java Optional 문법 오류로 인한 에러 발생 해결 과정]]></title>
            <link>https://velog.io/@da_na/Error-Java-Optional-%EB%AC%B8%EB%B2%95-%EC%98%A4%EB%A5%98%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EC%97%90%EB%9F%AC-%EB%B0%9C%EC%83%9D-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@da_na/Error-Java-Optional-%EB%AC%B8%EB%B2%95-%EC%98%A4%EB%A5%98%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EC%97%90%EB%9F%AC-%EB%B0%9C%EC%83%9D-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sun, 17 Sep 2023 15:03:08 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전 글에서 작성한 WebClient 관련 리팩토링을 진행한 뒤에 develop 브랜치로 머지하고 dev 환경에 배포를 마쳤습니다.</p>
<p><a href="https://velog.io/@da_na/WebClient-WebClient-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0">https://velog.io/@da_na/WebClient-WebClient-사용해서-외부-API-호출하기</a></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/ffde533a-8ea3-4570-ac37-0ef452b874a5/image.png" alt=""></p>
<p>그리고 dev 환경에서 실제로 스크랩을 추가한 뒤에 스크랩이 잘 조회되는지 확인해보았습니다.</p>
<p>그러나,,, 아래와 같이 <strong>1개의 스크랩을 추가</strong>했는데, 아래의 사진에서 빨간색 박스로 되어 있는 것처럼 <strong>정상적인 스크랩 1개와 비정상적인 스크랩(아무것도 나오지 않는 스크랩)이 2개가 추가</strong>되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/13ba0f39-eb34-45b9-b5f8-729e636571e6/image.png" alt=""></p>
<p>그래서, dev DB를 확인해봤는데 2개의 스크랩이 추가되었습니다.
<img src="https://velog.velcdn.com/images/da_na/post/4f7325d2-f226-4694-a4c5-3d64ab053590/image.png" alt=""></p>
<ul>
<li><strong>Article 스크랩과 Other 스크랩 2개가 생성</strong>되었습니다.</li>
</ul>
<p>그래서 <strong>WebClient 리팩토링 코드에서 어느 부분이 틀렸는지</strong> 앞으로 <strong>이러한 상황을 대비</strong>하기 위해서는 어떻게 해야할지를 이야기해보겠습니다!!</p>
<hr>
<h1 id="1️⃣-본론-1">1️⃣ 본론 1</h1>
<h2 id="이전-코드">이전 코드</h2>
<ul>
<li>이전에는 WebClient의 에러가 발생하는 경우 NotFoundException처리가 되어 있기 때문에 따로 <strong>스크랩을 저장하지 않는 로직</strong>이었습니다.</li>
</ul>
<pre><code class="language-java">@Transactional
public JSONObject crawlingItem(String pageUrl) throws ParseException {
    Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
    bodyMap.put(&quot;url&quot;, pageUrl);
    WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

    Map&lt;String, Object&gt; response = webClient.post()
            .bodyValue(bodyMap)
            .retrieve()
            .bodyToMono(Map.class)
            .block();

    JSONParser jsonParser = new JSONParser();

    Object obj = jsonParser.parse(response.get(&quot;body&quot;).toString());

    JSONObject jsonObject = (JSONObject) obj;

    return jsonObject;
}</code></pre>
<pre><code class="language-java">@Transactional
public Scrap saveScraps(User user, String pageUrl) throws ParseException {
    JSONObject crawlingResponse = webClientService.crawlingItem(pageUrl);

    String type = &quot;&quot;;
    try {
        type = crawlingResponse.get(&quot;type&quot;).toString();
    } catch (NullPointerException e) {
        throw new NotFoundException(ErrorCode.NOT_EXISTS);
    }

    switch (type) {
        case &quot;video&quot;:
            return videoService.saveVideo(crawlingResponse, user, pageUrl);
        case &quot;article&quot;:
            return articleService.saveArticle(crawlingResponse, user, pageUrl);
        case &quot;product&quot;:
            return productService.saveProduct(crawlingResponse, user, pageUrl);
    }
    return otherService.saveOther(crawlingResponse, user, pageUrl);
}</code></pre>
<h2 id="수정된-코드">수정된 코드</h2>
<ul>
<li>WebClient를 리팩토링하면서, <strong>WebClient 에러가 발생하면 null을 반환</strong>하여 <strong>other 스크랩으로 저장</strong>되도록 로직을 변경했습니다.</li>
</ul>
<pre><code class="language-java">@Transactional
public WebClientBodyResponse crawlingItem(String crawlingApiEndPoint, String pageUrl) {
    Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
    bodyMap.put(&quot;url&quot;, pageUrl);

    WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

    try {
        WebClientResponse webClientResponse = webClient.post()
                .bodyValue(bodyMap)
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                    throw new RuntimeException(&quot;4xx&quot;);
                })
                .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                    throw new RuntimeException(&quot;5xx&quot;);
                })
                .bodyToMono(WebClientResponse.class)
                .block();

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        return objectMapper.readValue(
                webClientResponse != null ? webClientResponse.getBody() : null,
                WebClientBodyResponse.class);


    } catch (Exception e) {
        return null;
    }
}</code></pre>
<pre><code class="language-java">@Transactional
public Scrap saveScraps(User user, String pageUrl) throws ParseException {
    WebClientBodyResponse crawlingResponse = webClientService.crawlingItem(crawlingApiEndPoint, pageUrl);

    return Optional.ofNullable(crawlingResponse)
            .map(response -&gt; {
                String type = response.getType();
                switch (type) {
                    case &quot;video&quot;:
                        return videoService.saveVideo(response, user, pageUrl);
                    case &quot;article&quot;:
                        return articleService.saveArticle(response, user, pageUrl);
                    case &quot;product&quot;:
                        return productService.saveProduct(response, user, pageUrl);
                    case &quot;place&quot;:
                        return placeService.savePlace(response, user, pageUrl);
                    default:
                        return otherService.saveOther(response, user, pageUrl);
                }
            })
            .orElse(otherService.saveOther(new WebClientBodyResponse(), user, pageUrl));
}</code></pre>
<ul>
<li>여기에서 잘못된 부분은 <code>.orElse(otherService.saveOther(new WebClientBodyResponse(), user, pageUrl));</code> 이었습니다.</li>
<li>orElse 문을 사용하면 안되는 이유를 아래의 문법 관련해서 같이 설명드리겠습니다.</li>
</ul>
<hr>
<h2 id="📄-optional-orelse문">📄 Optional orElse문</h2>
<p><strong>Java Optional 클래스</strong>는 Java 8에서 추가되었으며 NullpointerException 문제를 해결할 수 있는 방법을 제공합니다.</p>
<p>Optional<T> 클래스는 Integer나 Double 클래스처럼 &#39;T&#39;타입의 객체를 포장해 주는 래퍼 클래스(Wrapper class)입니다.
따라서 Optional 인스턴스는 모든 타입의 참조 변수를 저장할 수 있습니다.</p>
<p>이러한 Optional 객체를 사용하면 예상치 못한 NullPointerException 예외를 제공되는 메소드로 간단히 회피할 수 있습니다.</p>
<p>즉, 복잡한 조건문 없이도 널(null) 값으로 인해 발생하는 예외를 처리할 수 있게 됩니다.</p>
<h3 id="optionalofnullbale---값이-null일수도-아닐수도-있는-경우">Optional.ofNullbale() - 값이 Null일수도, 아닐수도 있는 경우</h3>
<p>만약 어떤 데이터가 null이 올 수도 있고 아닐 수도 있는 경우에는 Optional.ofNullbale로 생성할 수 있습니다. 그리고 이후에 orElse 또는 orElseGet 메소드를 이용해서 값이 없는 경우라도 안전하게 값을 가져올 수 있습니다.</p>
<h3 id="orelse문과-orelseget-문-차이점">orElse문과 orElseGet 문 차이점</h3>
<ul>
<li><strong>orElse</strong>는 <strong>null이든지 말든지 항상 불립니다</strong>.</li>
<li><strong>orElseGet</strong>은 <strong>null일 때만 불립니다</strong>.</li>
</ul>
<h3 id="orelse문">orElse문</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/2ff96a53-92eb-404b-a8e7-15aaece94d05/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/c978d619-f44f-429f-a8b6-dbaa93fb10a5/image.png" alt=""></p>
<ul>
<li>orElse문은 optional이 null일 때도 null이 아닌 경우에도 2번 다 호출됨을 알 수 있습니다.</li>
</ul>
<h3 id="orelseget-문">orElseGet 문</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/8e525b42-0874-405c-9bef-1dab5c907f66/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/da_na/post/353e6d6d-bd15-4d7f-80ac-48e8597fc332/image.png" alt=""></p>
<ul>
<li><strong>orElseGet 문</strong>은 <strong>optional이 null일 때에만 실행됨</strong>을 알 수 있습니다.</li>
</ul>
<p>참고 자료 1 : <a href="https://engkimbs.tistory.com/646">https://engkimbs.tistory.com/646</a></p>
<p>참고 자료 2 : <a href="https://cfdf.tistory.com/34">https://cfdf.tistory.com/34</a></p>
<p>참고 자료 3 : <a href="http://www.tcpschool.com/java/java_stream_optional">http://www.tcpschool.com/java/java_stream_optional</a></p>
<p>참고 자료 4 : <a href="https://mangkyu.tistory.com/70">https://mangkyu.tistory.com/70</a></p>
<hr>
<h3 id="리팩토링힌-코드-잘못된-점">리팩토링힌 코드 잘못된 점</h3>
<ul>
<li>제가 원래 의도대로 설계한 로직은 WebClient 에러가 발생하면 null을 반환하여 other 스크랩으로 저장되는 로직이었습니다.</li>
<li>WebClient 에러가 발생하지 않는다면, 반환한 스크랩으로만 저장하는 로직입니다.</li>
<li>그러나, <code>.orElse(otherService.saveOther(new WebClientBodyResponse(), user, pageUrl))</code> 코드가 WebClient가 응답한 값인 crawlingResponse가 null이 아닌 경우에도 실행되므로 <code>articleService.saveArticle(response, user, pageUrl)</code>과 <code>otherService.saveOther(new WebClientBodyResponse(), user, pageUrl)</code> 총 2번 스크랩이 저장됩니다.</li>
</ul>
<pre><code class="language-java">return Optional.ofNullable(crawlingResponse)
            .map(response -&gt; {
                String type = response.getType();
                switch (type) {
                    case &quot;video&quot;:
                        return videoService.saveVideo(response, user, pageUrl);
                    case &quot;article&quot;:
                        return articleService.saveArticle(response, user, pageUrl);
                    case &quot;product&quot;:
                        return productService.saveProduct(response, user, pageUrl);
                    case &quot;place&quot;:
                        return placeService.savePlace(response, user, pageUrl);
                    default:
                        return otherService.saveOther(response, user, pageUrl);
                }
            })
            .orElse(otherService.saveOther(new WebClientBodyResponse(), user, pageUrl));</code></pre>
<ul>
<li>따라서 orElse문이 아닌 <strong>orElseGet을 사용</strong>해서 원래의 의도로 null이 아닌 경우 1번만 동작하도록 변경해주도록 하겠습니다.</li>
</ul>
<pre><code class="language-java">return Optional.ofNullable(crawlingResponse)
            .map(response -&gt; {
                String type = response.getType();
                switch (type) {
                    case &quot;video&quot;:
                        return videoService.saveVideo(response, user, pageUrl);
                    case &quot;article&quot;:
                        return articleService.saveArticle(response, user, pageUrl);
                    case &quot;product&quot;:
                        return productService.saveProduct(response, user, pageUrl);
                    case &quot;place&quot;:
                        return placeService.savePlace(response, user, pageUrl);
                    default:
                        return otherService.saveOther(response, user, pageUrl);
                }
            })
            .orElseGet(() -&gt; otherService.saveOther(new WebClientBodyResponse(), user, pageUrl));</code></pre>
<hr>
<h1 id="2️⃣-본론-2">2️⃣ 본론 2</h1>
<h2 id="🚨-문제가-생긴-원인과-앞으로의-대비책">🚨 문제가 생긴 원인과 앞으로의 대비책</h2>
<ul>
<li>다행히도 <strong>Dev 서버(개발 환경)과 Prod 서버(운영 환경)을 따로 분리</strong>했기 때문에, 실제로 개발 서버에서 동작을 점검해보면서 미처 놓쳤던 부분을 확인할 수 있었고, 운영환경에는 배포하지 않아서 영향이 없었습니다.</li>
</ul>
<h3 id="1-문제-원인--테스트-코드가-모든-로직을-커버하지-못함">1. 문제 원인 : 테스트 코드가 모든 로직을 커버하지 못함</h3>
<ul>
<li>테스트 코드를 모두 통과한 상태였기 때문에, <strong>테스트 코드가 모든 로직을 커버하지는 못하고 있음</strong>을 알게 되었습니다.</li>
<li>실제로 테스트 코드를 살펴봐도 ScrapService 부분에서 scrapService.saveScraps() 메소드 부분은 아래의 코드만 있었습니다.</li>
<li>아래의 코드는 null인 경우만 확인했기 때문에 <strong>not null인 경우는 정상적인지를 확인할 수 없는 것</strong>이었습니다.</li>
</ul>
<pre><code class="language-java">@Test
void should_other_type_of_scrap_is_saved_When_webClientService_crawlingItem_returns_null() throws ParseException {
    // webClientService.crawlingItem()이 null을 반환할 때, Other 타입의 Scrap이 저장되는지 확인
    //given
    memoRepository.deleteAll();
    scrapRepository.deleteAll();

    BDDMockito.when(webClientService.crawlingItem(&quot;http://localhost:123&quot;, pageUrl))
            .thenReturn(null);

    User user = userRepository.findById(1L).get();

    //when
    //then
    assertThat(scrapService.saveScraps(user, pageUrl)).isInstanceOf(Other.class);
    assertThat(scrapRepository.findByPageUrlAndUserAndDeletedDateIsNull(pageUrl, user)
            .isPresent()).isTrue();
}</code></pre>
<ul>
<li>따라서 not null인 경우의 테스트를 추가해주겠습니다.</li>
</ul>
<pre><code class="language-java">@Test
void should_one_article_scrap_is_saved_When_webClientService_crawlingItem_returns_article() throws ParseException {
    // webClientService.crawlingItem()이 type을 article로 반환할 때, Article 타입의 Scrap이 1개만 저장되는지 확인
    //given
    memoRepository.deleteAll();
    scrapRepository.deleteAll();

    WebClientBodyResponse webClientBodyResponse = new WebClientBodyResponse().builder()
            .title(&quot;title&quot;)
            .type(&quot;article&quot;)
            .build();

    BDDMockito.when(webClientService.crawlingItem(&quot;test&quot;, pageUrl))
            .thenReturn(webClientBodyResponse);

    User user = userRepository.findById(1L).get();

    //when
    //then
    assertThat(scrapService.saveScraps(user, pageUrl)).isInstanceOf(Article.class);
    assertThat(scrapRepository.count()).isEqualTo(1);
}</code></pre>
<ul>
<li>만약에 이 테스트를 넣고 리팩토링 코드를 orElse문을 그대로 사용했다면, 테스트에서 통과하지 못해서 dev 서버에 반영하지 못했을 것입니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/594ff21a-615f-4f21-a377-fcd4be78baba/image.png" alt=""></p>
<ul>
<li>그리고 orElse문이 아닌 orElseGet으로 변경했다면, 성공적으로 저장됨을 알 수 있습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/7a52ee80-d67f-4767-9701-62102780c016/image.png" alt=""></p>
<p>따라서 앞으로는 이러한 문제가 발생하지 않도록 <strong>테스트 코드를 예외 처리뿐만 아니라 정상적인 로직 및 로직을 체계적으로 세워서 발생할 수 있는 다양한 상황을 테스트 코드에 반영해야겠다</strong>고 다짐하게되었습니다!!</p>
<h3 id="2-문제-원인--pr-단위가-크다">2. 문제 원인 : PR 단위가 크다.</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/5b3109e2-7e55-4ee5-ad5b-600c1b87c6bb/image.png" alt=""></p>
<ul>
<li>리팩토링 과정에서 <strong>WebClient 로직</strong> 뿐만 아니라, <strong>Optional 처리</strong>까지 <strong>여러 가지 코드를 수정</strong>하다 보니, WebClient 로직만 테스트 코드를 작성해서 Optional과 같이 문법적인 요소는 크게 신경쓰지 못한 것 같습니다. 따라서, 앞으로는 <strong>리팩토링 과정도 세분화</strong>해서 하나의 PR이 아닌 <strong>여러 개의 PR</strong>로 나누어서 로직 변경, 문법 변경과 같이 나눈 뒤에 그에 맞는 테스트 코드와 에러 처리를 추가해야겠다는 생각을 하게 되었습니다.</li>
</ul>
<hr>
<h1 id="3️⃣-결론">3️⃣ 결론</h1>
<ul>
<li>(위에서 언급한 이야기) 앞으로는 이러한 문제가 발생하지 않도록 <strong>테스트 코드를 예외 처리뿐만 아니라 정상적인 로직 및 로직을 체계적으로 세워서 발생할 수 있는 다양한 상황을 테스트 코드에 반영해야겠다</strong>고 다짐하게되었습니다!!</li>
<li>(위에서 언급한 이야기) 리팩토링 과정에서 <strong>WebClient 로직</strong> 뿐만 아니라, <strong>Optional 처리</strong>까지 <strong>여러 가지 코드를 수정</strong>하다 보니, WebClient 로직만 테스트 코드를 작성해서 Optional과 같이 문법적인 요소는 크게 신경쓰지 못한 것 같습니다. 따라서, 앞으로는 <strong>리팩토링 과정도 세분화</strong>해서 하나의 PR이 아닌 <strong>여러 개의 PR</strong>로 나누어서 로직 변경, 문법 변경과 같이 나눈 뒤에 그에 맞는 테스트 코드와 에러 처리를 추가해야겠다는 생각을 하게 되었습니다.</li>
<li>멘토님이 &#39;회사에서 신입이 실수를 했는데, 이로 인해서 큰 장애가 서비스 전체에 영향을 미쳤어. 그런데, 이러한 원인은 신입에게 서비스 전체에 영향을 줄 수 있는 권한을 부여한 <strong>시스템의 잘못이지 신입의 잘못이 아니야</strong>.&#39;라고 말씀해주셨습니다. 그리고 &#39;<strong>회사에 코드 리뷰 시간을 늘리고, 실제 운영 환경에 배포하기 전에 승인받는 시스템으로 변경되었다</strong>&#39;고 말씀해주셨습니다.</li>
<li>문법의 실수로 에러가 발생해서 약간 부끄럽지만, <strong>언제나 실수나 에러가 발생할 수 있는 만큼 부끄러움을 이겨내고 기록으로 남겨 놓음</strong>으로써 <strong>실수의 원인을 바로 직면하여 파악하고 시스템을 점검</strong>하고 <strong>대책을 마련</strong>하여 해결해나가는 것이 중요함을 깨닫게 되었습니다!!</li>
<li>마지막으로 운영 환경과 개발 환경을 나누어서 테스트를 여러 번 하는 것처럼 테스트 코드로는 모든 상황을 다 대비하고 알아낼 수 없기 때문에 <strong>테스트 환경을 최대한으로 마련</strong>하는 것이 매우 매우 유용하고 실제 서비스에서는 필수적임을 알았습니다!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] Spring 다담다 프로젝트에 전략 패턴 적용하기]]></title>
            <link>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-Spring-%EB%8B%A4%EB%8B%B4%EB%8B%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%A0%84%EB%9E%B5-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-Spring-%EB%8B%A4%EB%8B%B4%EB%8B%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%A0%84%EB%9E%B5-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 16 Sep 2023 16:17:30 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<h2 id="📄-현재-프로젝트-구조">📄 현재 프로젝트 구조</h2>
<p>현재 <strong>Scrap</strong>이라는 부모 클래스로 <strong>Video, Product, Article, Place, Other</strong> 카테고리의 자식 클래스가 있습니다.</p>
<p>따라서 각각의 자식 클래스는 각각의 Entity로 되어 있습니다.</p>
<p>하지만, 상속 관계에 있는 만큼 조회 기능, 검색 기능, 수정 기능 등 각각의 Video, Product, Article, Place, Other <strong>카테고리는 기능이 거의 유사</strong>합니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/e6a4a929-84dc-426d-9771-976f2c9e4bab/image.png" alt=""></p>
<p>그러나, 각각은 다른 Repository와 Service, Controller를 가지고 있기 떄문에 ArticleController, OtherController, ProductController, VideoController에서는 동일한 기능이어도 아래와 같이 따로 각자 분리되어 있습니다.</p>
<pre><code class="language-java">@Operation(summary = &quot;아티클 스크랩 개수 조회&quot;, description = &quot;아티클 스크랩 개수 정보를 조회할 수 있습니다.&quot;)
@GetMapping(&quot;/v1/scraps/articles/count&quot;)
public ApiResponse&lt;GetArticleCountResponse&gt; getArticleCount(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(
            GetArticleCountResponse.of(articleService.getArticleCount(email)));
}

@Operation(summary = &quot;기타 스크랩 개수 조회&quot;, description = &quot;기타 스크랩 개수 정보를 조회할 수 있습니다.&quot;)
@GetMapping(&quot;/v1/scraps/others/count&quot;)
public ApiResponse&lt;GetOtherCountResponse&gt; getOtherScrap(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(GetOtherCountResponse.of(otherService.getOtherCount(email)));
}

@Operation(summary = &quot;상품 스크랩 개수 조회&quot;, description = &quot;상품 스크랩 개수 정보를 조회할 수 있습니다.&quot;)
@GetMapping(&quot;/v1/scraps/products/count&quot;)
public ApiResponse&lt;GetProductCountResponse&gt; getProductScrap(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(
            GetProductCountResponse.of(productService.getProductCount(email)));
}

@Operation(summary = &quot;비디오 스크랩 개수 조회&quot;, description = &quot;비디오 스크랩 개수 정보를 조회할 수 있습니다.&quot;)
@GetMapping(&quot;/v1/scraps/videos/count&quot;)
public ApiResponse&lt;GetVideoCountResponse&gt; getVideoCount(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(GetVideoCountResponse.of(videoService.getVideoCount(email)));
}
</code></pre>
<p>이 때의 단점은 동일한 기능을 나눠서 작성했기 때문에 <strong>코드의 길이가 길어진다</strong>는 단점이 있습니다.</p>
<p>저희 서비스는 카테고리가 점차 늘어날 수도 있기 때문에 그럴 때마다 <strong>동일한 로직이 계속되어서 추가</strong>됩니다.</p>
<p>그러나, 제가 생각한 가장 큰 단점은 &quot;<strong>테스트 코드 작성시 동일한 기능을 테스트하게 된다</strong>&quot;라는 점입니다.</p>
<p>즉, 테스트 코드 작성시에 카테고리의 스크랩 개수를 조회하는 같은 기능도 Controller의 메소드로 나누어져 있기 때문에 각각의 테스트 코드를 작성해줘야 합니다. </p>
<p>그러나, 한 개의 Controller를 테스트 완료하면 다른 Controller의 메소드도 동일한 로직이기 때문에 동일한 결과가 나올지 알 수 있지만 커버리지 및 테스트 코드를 위해서는 동일한 로직도 테스트 코드를 작성해야 한다는 단점이 있었습니다.</p>
<p>따라서 이 구조를 refactoring하는 과정을 소개해드리겠습니다!</p>
<hr>
<h1 id="1️⃣-본론-1">1️⃣ 본론 1</h1>
<h2 id="🖍️-if-문-사용하기">🖍️ if 문 사용하기</h2>
<p>🥹 여기서 잠깐, 여기에서 if문을 사용해서 하나의 Controller method를 사용하면 안될까요??</p>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
public class ScrapController {

    @GetMapping(&quot;/v1/scraps/{category}/count&quot;)
    public ApiResponse&lt;GetScrapCountResponse&gt; getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();

        if(category == &quot;video&quot;) {
            return ApiResponse.success(GetScrapCountResponse.of(videoService.getVideoCount(email)));
        } else if (category == &quot;product&quot;) {
            return ApiResponse.success(GetScrapCountResponse.of(productService.getProductCount(email)));
        } else if (category == &quot;other&quot;) {
            return ApiResponse.success(GetScrapCountResponse.of(otherService.getOtherCount(email)));
        }

        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}</code></pre>
<p>그러면 카테고리가 늘어날 때마다 Controller의 코드를 수정해줘야 합니다 🥲</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/98d5a980-5ef0-438b-a349-4ab4f1612c06/image.png" alt=""></p>
<h2 id="🔎-solid-객체-지향-5가지-설계-원칙">🔎 SOLID 객체 지향 5가지 설계 원칙</h2>
<p>혹시 SOLID라는 <strong>객체지향설계의 원칙</strong>을 들어보셨나요??</p>
<p>여기에서 O는 OCP로 개방 폐쇄 원칙입니다.
<strong>확장에 대해는 열려 있고, 수정에 대해서는 닫혀 있어야 합니다.</strong></p>
<p>그런데 위의 코드와 같이 if문을 사용하면, <strong>수정에는 닫혀 있는 구조가 아닌 계속해서 if문을 추가해야 하는 수정에 열려있는 구조</strong>가 됩니다.</p>
<p>그리고  DIP 의존 관계 역전 원칙을 여기게 됩니다.
<strong>DIP는 추상화에 의존해야 하고, 구체화에 의존하면 안된다</strong>는 것을 의미합니다.</p>
<p>즉, if문으로 구체화 되어 있는 videoService, productService 클래스를 의존하게 됩니다!!</p>
<hr>
<h1 id="2️⃣-본론-2">2️⃣ 본론 2</h1>
<h2 id="🧚♀️-프로젝트에-전략-패턴-적용하기">🧚‍♀️ 프로젝트에 전략 패턴 적용하기</h2>
<p>그러면 과연 이러한 문제를 해결할 수 있는 방법은 없을까?? 라는 고민을 하다가 <strong>전략 패턴</strong>을 떠올리게 되었습니다.</p>
<p>이전에 디자인 패턴으로 전략 패턴을 게시글로 올렸던 적이 있습니다.</p>
<p><a href="https://handayeon-coder.github.io/posts/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-06.-Strategy-%ED%8C%A8%ED%84%B4/">https://handayeon-coder.github.io/posts/디자인-패턴-06.-Strategy-패턴/</a></p>
<p>이때, <strong>알고리즘의 동작이 런타임에 실시간으로 교체 되어야 하는 경우</strong>에 사용될 수 있다고 했습니다.</p>
<p>아래는 전략 패턴 다이어그램입니다!</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/0e5c7911-0ae0-46b1-93bc-382b56dad8ce/image.png" alt=""></p>
<p>따라서 전략 패턴처럼 Client가 Controller에 PathVariable로 입력한 
<code>@GetMapping(&quot;/v1/scraps/{category}/count&quot;)</code> <strong>category에 따라서 videoService.getVideoCount, productService,getProductCount와 같이 실시간으로 교체</strong>되면 될 것 같다는 생각을 했습니다.</p>
<p>전략 패턴 다이어그램을 저희 서비스에도 적용해보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/935b74e0-943f-4fce-a698-45c36c25db48/image.png" alt=""></p>
<h3 id="1-strategy-인터페이스-생성하기">1. Strategy 인터페이스 생성하기</h3>
<p>먼저 <em>Strategy</em>를 의미하는 <em>ScrapService</em>라는 인터페이스를 만들어주도록 하겠습니다.</p>
<pre><code class="language-java">public interface ScrapService {
    Long getScrapCount(String email);
}</code></pre>
<h3 id="2-concretestrategy를-정의하기">2. ConcreteStrategy를 정의하기</h3>
<p>각각의 ConcretStrategy가 되는 VideoService, ProductService, ArticleService에 getScrapCount 전략 메소드를 @Override해서 나타내줍니다. </p>
<ul>
<li>(이때, 다른 PlaceService, OtherService는 동일한 로직이라서 아래의 코드에서 생략하였습니다.)</li>
</ul>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class ProductService implements ScrapService {

    private final ProductRepository productRepository;
    private final UserService userService;

    @Override
    @Transactional
    public Long getScrapCount(String email) {
        User user = userService.validateUser(email);
        return productRepository.countByUserAndDeletedDateIsNull(user);
    }
}</code></pre>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class ArticleService implements ScrapService {

    private final ArticleRepository articleRepository;
    private final UserService userService;

    @Override
    @Transactional
    public Long getScrapCount(String email) {
        User user = userService.validateUser(email);
        return articleRepository.countByUserAndDeletedDateIsNull(user);
    }
}</code></pre>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class VideoService implements ScrapService {

    private final VideoRepository videoRepository;
    private final UserService userService;

    @Override
    @Transactional
    public Long getScrapCount(String email) {
        User user = userService.validateUser(email);
        return videoRepository.countByUserAndDeletedDateIsNull(user);
    }
}</code></pre>
<h3 id="3-context-클래스에서-strategy-선택하기">3. Context 클래스에서 Strategy 선택하기</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
public class ScrapController {

    private final Map&lt;String, ScrapService&gt; scrapServices;

    @GetMapping(&quot;/v1/scraps/{category}/count&quot;)
    public ApiResponse&lt;GetScrapCountResponse&gt; getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();

        ScrapService scrapService = scrapServices.get(category.getServiceName());
        Long scrapCount = scrapService.getScrapCount(email);
        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}</code></pre>
<ul>
<li>이때, scrapServices로 선택할 전략을 필드에 나타내줍니다.</li>
<li><code>@GetMapping(&quot;/v1/scraps/{category}/count&quot;)</code> <ul>
<li>@PathVariable로 원하는 Category를 입력받아줍니다.</li>
</ul>
</li>
<li><code>ScrapService scrapService = scrapServices.get(category.getServiceName());</code> <ul>
<li>여러 전략(VideoService, ProductService, ArticleService, PlaceService, TotalService)에서 입력된 카테고리에 해당하는 서비스 로직을 선택해줍니다. </li>
</ul>
</li>
</ul>
<pre><code class="language-java">@Getter
public enum Category {
    product(&quot;productService&quot;),
    article(&quot;articleService&quot;),
    place(&quot;placeService&quot;),
    video(&quot;videoService&quot;),
    other(&quot;otherService&quot;);

    private final String serviceName;

    Category(String serviceName) {
        this.serviceName = serviceName;
    }
}</code></pre>
<hr>
<h2 id="🌱-spring-빈에-대해서-알아보자">🌱 Spring 빈에 대해서 알아보자!</h2>
<ul>
<li>어떻게 전략 패턴을 사용해서 원하는 서비스 전략을 선택할 수 있는 건지 알아보겠습니다!!</li>
</ul>
<p>따라서 scrapServices에는 어떠한 것들이 담겨있는지 확인해보겠습니다.</p>
<p>이를 위해서 <code>System.out.println(&quot;scrapServiceMap: &quot; + scrapServiceMap);</code> 출력하는 문장을 추가했습니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
public class ScrapController {

    private final Map&lt;String, ScrapService&gt; scrapServices;

    @GetMapping(&quot;/v1/scraps/{category}/count&quot;)
    public ApiResponse&lt;GetScrapCountResponse&gt; getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();


        for (ScrapService scrapServiceMap : scrapServices.values()) {
            System.out.println(&quot;scrapServiceMap: &quot; + scrapServiceMap);
        }

        ScrapService scrapService = scrapServices.get(category.getServiceName());

        Long scrapCount = scrapService.getScrapCount(email);
        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/f7073358-9a26-407a-8063-5f9fa32789fb/image.png" alt=""></p>
<ul>
<li>즉, 여기에는 ScrapService 인터페이스를 구현한 ArticleService, OtherService, PlaceService, ProductService, VideoService이 담아졌음을 알 수 있습니다.</li>
<li><code>private final Map&lt;String, ScrapService&gt; scrapServices;</code><ul>
<li>map의 키에 스프링 빈의 이름을 넣어주고, 그 값으로  ScrapService 타입으로 조회한 모든 스프링 빈을 담아줍니다.</li>
</ul>
</li>
<li>이 중에서 동일한 이름의 Service를 scrapServices.get(category.getServiceName())해서 가져오고 사용해줍니다.</li>
</ul>
<hr>
<h2 id="🤖-테스트-코드-작성하기">🤖 테스트 코드 작성하기</h2>
<pre><code class="language-java">@Test
@WithCustomMockUser
public void should_it_returns_the_number_of_saved_videos_When_getting_the_number_of_videos() throws Exception {
    // 비디오 개수 조회시 저장된 비디오의 개수를 반환하는지 확인
    mockMvc.perform(get(&quot;/v1/scraps/video/count&quot;)
                    .header(&quot;X-AUTH-TOKEN&quot;, &quot;aaaaaaa&quot;))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath(&quot;$.data.count&quot;).value(2L));
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/766f09da-b4d2-4dd1-9b8b-7771a65e2c93/image.png" alt=""></p>
<p>위의 작성하여 카테고리별 스크랩 개수 조회하는 Controller를 테스트할 수 있습니다.</p>
<p>따라서, 동일한 기능의 핵심 로직을 하나의 테스트만으로 기능이 정상적으로 작동하는지 확인할 수 있고, 앞으로는 같은 기능과 로직에 대해서는 동일한 로직의 검증 테스트 코드를 여러 개 작성하지 않고 다른 에러 처리와 같은 테스트 되지 않은 부분을 더 구체적으로 테스트 코드 작성할 수 있게 되었습니다.</p>
<hr>
<h2 id="🔎-solid-객체-지향-5가지-설계-원칙-살펴보기">🔎 SOLID 객체 지향 5가지 설계 원칙 살펴보기</h2>
<p>if문 적용하기에서 if문을 사용하면 SOLID 원칙 중 O와 D에 해당하는 원칙을 어기게 된다고 이야기했습니다.</p>
<p>그러면 전략 패턴을 적용했을 때에는 과연 원칙을 잘 지키고 있는지 살펴보겠습니다!</p>
<ol>
<li>ScrapController가 ScrapService 인터페이스를 의존하고 있기 때문에 DIP 의존관계 역전 원칙을 지킬 수 있게 되었습니다.
<img src="https://velog.velcdn.com/images/da_na/post/7a37ba79-072f-4c6a-8ae7-b2089172f724/image.png" alt=""></li>
</ol>
<ol start="2">
<li>카테고리가 추가되어도 아래의 코드는 변화되지 않기 때문에 OCP 개발 폐쇄 원칙을 지키게 됩니다.</li>
</ol>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
public class ScrapController {

    private final Map&lt;String, ScrapService&gt; scrapServices;

    @GetMapping(&quot;/v1/scraps/{category}/count&quot;)
    public ApiResponse&lt;GetScrapCountResponse&gt; getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();

        ScrapService scrapService = scrapServices.get(category.getServiceName());

        Long scrapCount = scrapService.getScrapCount(email);
        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}</code></pre>
<hr>
<h1 id="3️⃣-결론">3️⃣ 결론</h1>
<ul>
<li>현재 배우고 있는 <strong>GoF 디자인 패턴</strong> 중 하나인 <strong>전략 패턴</strong>을 직접 프로젝트에 적용해보게 될 수 있어서 뜻깊은 시간이었습니다.</li>
<li>이러한 리팩토링을 시작하기 전에는 어떠한 서비스를 의미하는지 모르게 되고 코드에 대한 가독성이 떨어지게 되며 디자인 패턴을 적용하고자 하는 나의 욕심이 아닐까라는 생각이 들게 되었습니다. 그러나, 이전까지는 스크랩의 카테고리가 상품, 영상, 아티클, 기타까지만 있었는데 이번에 상품 카테고리를 늘리게 되면서 <strong>앞으로 카테고리가 늘어날 수 있다</strong>는 생각을 하게 되었고 그러면 앞으로 <strong>기능 추가</strong> 및 <strong>유지 보수</strong>를 위해서라도 전략 패턴을 사용해야겠다는 다짐을 하게 되었습니다. 그리고 실제로 리팩토링을 하면서 <strong>테스트 코드와 유지보수 측면 등 여러 가지의 장점이 있는 것</strong>을 느끼고 리팩토링을 하기를 잘 했다는 생각이 들었습니다.</li>
<li>그리고 이 블로그를 작성하는 이유 중 하나가 팀원들에게 해당 리팩토링 과정을 소개하고 장점에 대해서 소개한 뒤, <strong>실제 프로젝트에도 도입하자고 제안</strong>하기 위해서 작성된 이유도 있는 만큼 실제 프로젝트에서 리팩토링을 도입되었으면 좋겠다는 바램으로 이 글을 작성합니다 🌸</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Test Code] WebClinet 외부 호출 API를 MockWebServer 사용해서 테스트하기]]></title>
            <link>https://velog.io/@da_na/Test-Code-WebClinet-%EC%99%B8%EB%B6%80-%ED%98%B8%EC%B6%9C-API%EB%A5%BC-MockWebServer-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/Test-Code-WebClinet-%EC%99%B8%EB%B6%80-%ED%98%B8%EC%B6%9C-API%EB%A5%BC-MockWebServer-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 15 Sep 2023 17:44:22 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전 게시글에서 &#39;<em>WebClient를 사용해서, 외부 API를 호출하기</em>&#39;라는 글을 작성했습니다.</p>
<p><a href="https://velog.io/@da_na/WebClient-WebClient-사용해서-외부-API-호출하기"> https://velog.io/@da_na/WebClient-WebClient-사용해서-외부-API-호출하기 </a></p>
<p>저희 서비스는 크롤링 서버를 따로 분리했기 때문에 WebClient를 사용해서 Spring Boot(AWS EC2)가 외부 API인 크롤링 서버를 호출하는 방식으로 되어 있습니다. </p>
<p>따라서 아래의 WebClient 서비스 로직은 Spring Boot가 외부 API 크롤링 서버를 호출하는 로직입니다.</p>
<pre><code class="language-java">@Service
public class WebClientService {

    @Transactional
    public WebClientBodyResponse crawlingItem(String crawlingApiEndPoint, String pageUrl) {
        Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
        bodyMap.put(&quot;url&quot;, pageUrl);

        WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

        try {
            WebClientResponse webClientResponse = webClient.post()
                    .bodyValue(bodyMap)
                    .retrieve()
                    .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                        throw new RuntimeException(&quot;4xx&quot;);
                    })
                    .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                        throw new RuntimeException(&quot;5xx&quot;);
                    })
                    .bodyToMono(WebClientResponse.class)
                    .block();

            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

            return objectMapper.readValue(
                    webClientResponse != null ? webClientResponse.getBody() : null,
                    WebClientBodyResponse.class);


        } catch (Exception e) {
            return null;
        }

    }
}</code></pre>
<p>이전의 테스트 코드를 도입하기 전까지는 POSTMAN을 사용하여 <strong>직접 외부 서버와 통신</strong>하고 값을 확인했습니다.</p>
<p>따라서, 테스트가 외부 서버에 매우 <strong>의존적</strong>이었고 서버를 직접 호출하기 때문에 <strong>테스트 시간이 오래 걸렸습니다</strong>.</p>
<p>그리고 이전 게시글의 마지막에 WebClient를 리팩토링하는데, WebClient로 부터 받은 값을 변환하는 과정을 변경할 때에도 계속 외부 서버와 통신하는 것도 계속 동일한 값을 받는 데에 외부 서버를 호출하는 것은 <strong>비효율적</strong>이라고 생각했습니다.</p>
<p>더 나아가, 400번 대, 500번 대의 외부 서버 에러를 발생시키려면 일부러 에러를 요청하는 크롤링 웹 페이지 URL을 찾아서 보내줘야 해서 <strong>에러 처리하기가 어려웠습니다</strong>.</p>
<p>그러면 직접 외부 서버와 통신하는 방법 말고는 다른 방법으로 테스트하는 방법은 없을지 고민하다가, <strong>MockWebServer</strong>를 선택하여 테스트에 도입할 수 있었습니다.</p>
<hr>
<h1 id="1️⃣-본론-1">1️⃣ 본론 1</h1>
<h2 id="😎-mockwebserver가-무엇인가">😎 MockWebServer가 무엇인가?</h2>
<p>Square 팀에서 만든 MockWebServer는 <strong>HTTTP Request를 받아서 Response를 반환하는 간단하고 작은 웹서버</strong>입니다.</p>
<p>WebClient를 사용하여, Http를 호출하는 메서드의 테스트 코드를 작성할때 이 MockWebServer를 호출하게 함으로써 쉽게 테스트 코드를 작성할 수 있습니다.</p>
<p>그리고 실제로 Spring Team도 MockWebServer를 사용하여 테스트하라고 권장한다고 합니다.</p>
<p>MockWebServer 공식 문서 : <a href="https://github.com/square/okhttp/tree/master/mockwebserver"> https://github.com/square/okhttp/tree/master/mockwebserver </a></p>
<p>참고 자료 : <a href="https://www.devkuma.com/docs/mock-web-server/"> https://www.devkuma.com/docs/mock-web-server/ </a></p>
<p>Spring Team 출처 : <a href="https://github.com/spring-projects/spring-framework/issues/19852#issuecomment-453452354"> https://github.com/spring-projects/spring-framework/issues/19852#issuecomment-453452354 </a></p>
<h2 id="💼-mockwebserver-사용해서-외부-서버를-mocking하기">💼 MockWebServer 사용해서 외부 서버를 Mocking하기</h2>
<h3 id="1-mockwebserver-의존성-추가하기">1. MockWebServer 의존성 추가하기</h3>
<pre><code class="language-yml">testImplementation &#39;com.squareup.okhttp3:mockwebserver&#39;</code></pre>
<h3 id="2-테스트-코드에-mockwebserver-추가하기">2. 테스트 코드에 MockWebServer 추가하기</h3>
<ul>
<li>@BeforeEach를 선언하여 각각의 테스트마다 mockWebServer.start()를 사용해서, 가짜 API 서버인 MockWebServer를 켭니다.</li>
<li>@AfterEach를 선언하여 각각의 테스트마다 mockWebServer.shutdown()를 사용해서, 켰던 MockWebServer를 끕니다.</li>
</ul>
<pre><code class="language-java">@Autowired
private WebClientService webClientService;

private MockWebServer mockWebServer;

private String mockWebServerUrl;

@BeforeEach
void setUp() throws IOException {
    mockWebServer = new MockWebServer();
    mockWebServer.start();
    mockWebServerUrl = mockWebServer.url(&quot;/v1/crawling&quot;).toString();
}

@AfterEach
void terminate() throws IOException {
    mockWebServer.shutdown();
}</code></pre>
<h3 id="3-mockwebserver-사용하여-서비스-로직-테스트하기">3. MockWebServer 사용하여 서비스 로직 테스트하기</h3>
<ul>
<li>여기에서는 mockWebServer.enqueue를 사용하여 mockWebServer 가짜 서버가 응답할 값을 설정해줄 수 있습니다.</li>
<li>아래의 코드에서는 성공적으로 응답하는 것을 확인하고, 응답값을 DTO로 잘 변환해주는지 확인하는 과정이기 때문에 .setResponseCode(200)으로 응답값을 200으로 설정해주었습니다. 그리고 mockScrap은 따로 private String mockScrap으로 선언해었습니다.(아래에서는 선언한 것은 생략하였습니다!)</li>
</ul>
<pre><code class="language-java">@Test 
void should_title_is_returned_When_the_webClient_server_responds_successfully() {
    //given
    mockWebServer.enqueue(new MockResponse()
            .setResponseCode(200)
            .setBody(mockScrap)
            .addHeader(&quot;Content-Type&quot;, &quot;application/json&quot;));

    //when
    WebClientBodyResponse webClientBodyResponse = webClientService.crawlingItem(
            mockWebServerUrl, &quot;https://www.naver.com&quot;);

    //then
    assertThat(webClientBodyResponse.getTitle()).isEqualTo(&quot;서울역&quot;);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/0252917b-1b22-4b38-9fc6-3864a777c96d/image.png" alt=""></p>
<h3 id="4-mockserver-사용하여-400번대의-응답-코드-보내서-에러-처리-로직-테스트-하기">4. MockServer 사용하여 400번대의 응답 코드 보내서 에러 처리 로직 테스트 하기</h3>
<ul>
<li><p>다음으로는 정상적인 응답이 아니라, 400번의 에러가 발생했을 때 서비스 로직이 에러 처리가 제대로 되고 있는지 확인하는 테스트 코드를 작성해주었습니다.</p>
</li>
<li><p>이때, 저는 외부 API 서버가 에러가 발생하면 null을 반환하도록 설계했기 때문에 아래의 테스트 코드는 null이 나오는지 확인하는 구조입니다.</p>
</li>
<li><p>이처럼 쉽게 외부 API 서버의 에러를 발생시킬 수 있어서, 에러 처리를 확인하고 더 구체적으로 작성할 수 있습니다.</p>
<pre><code class="language-java">@Test
void should_it_returns_null_When_webClient_server_responds_unsuccessfully() {
  //given
  mockWebServer.enqueue(new MockResponse()
          .setResponseCode(400)
  );

  //when
  WebClientBodyResponse webClientBodyResponse = webClientService.crawlingItem(
          mockWebServerUrl, &quot;https://www.naver.com&quot;);

  //then
  assertThat(webClientBodyResponse).isEqualTo(null);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/5fcfcf86-8e66-4d57-8148-c8b8e093ade0/image.png" alt=""></p>
</li>
</ul>
<hr>
<p>전체적인 MockWebServer를 이용한 테스트 코드입니다!</p>
<pre><code class="language-java">@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
public class WebClientServiceTest {

    @Autowired
    private WebClientService webClientService;

    private MockWebServer mockWebServer;

    private String mockWebServerUrl;

    private final String mockScrap = &quot;{\n&quot;
            + &quot;  \&quot;statusCode\&quot;: 200,\n&quot;
            + &quot;  \&quot;headers\&quot;: {\n&quot;
            + &quot;    \&quot;Content-Type\&quot;: \&quot;application/json\&quot;\n&quot;
            + &quot;  },\n&quot;
            + &quot;  \&quot;body\&quot;: \&quot;{\\\&quot;type\\\&quot;: \\\&quot;place\\\&quot;, \\\&quot;page_url\\\&quot;: \\\&quot;https://map.kakao.com/1234\\\&quot;, &quot;
            + &quot;\\\&quot;site_name\\\&quot;: \\\&quot;KakaoMap\\\&quot;, \\\&quot;lat\\\&quot;: 37.50359439708544, \\\&quot;lng\\\&quot;: 127.04484896895218, &quot;
            + &quot;\\\&quot;title\\\&quot;: \\\&quot;서울역\\\&quot;, \\\&quot;address\\\&quot;: \\\&quot;서울특별시 중구 소공동 세종대로18길 2\\\&quot;, &quot;
            + &quot;\\\&quot;phonenum\\\&quot;: \\\&quot;1522-3232\\\&quot;, \\\&quot;zipcode\\\&quot;: \\\&quot;06151\\\&quot;, &quot;
            + &quot;\\\&quot;homepageUrl\\\&quot;: \\\&quot;https://www.seoul.co.kr\\\&quot;, \\\&quot;category\\\&quot;: \\\&quot;지하철\\\&quot;}\&quot;\n&quot;
            + &quot;}\n&quot;;

    @BeforeEach
    void setUp() throws IOException {
        mockWebServer = new MockWebServer();
        mockWebServer.start();
        mockWebServerUrl = mockWebServer.url(&quot;/v1/crawling&quot;).toString();
    }

    @AfterEach
    void terminate() throws IOException {
        mockWebServer.shutdown();
    }

    @Test 
    void should_title_is_returned_When_the_webClient_server_responds_successfully() {
        //given
        mockWebServer.enqueue(new MockResponse()
                .setResponseCode(200)
                .setBody(mockScrap)
                .addHeader(&quot;Content-Type&quot;, &quot;application/json&quot;));

        //when
        WebClientBodyResponse webClientBodyResponse = webClientService.crawlingItem(
                mockWebServerUrl, &quot;https://www.naver.com&quot;);

        //then
        assertThat(webClientBodyResponse.getTitle()).isEqualTo(&quot;서울역&quot;);
    }


    @Test
    void should_it_returns_null_When_webClient_server_responds_unsuccessfully() {
        //given
        mockWebServer.enqueue(new MockResponse()
                .setResponseCode(400)
        );

        //when
        WebClientBodyResponse webClientBodyResponse = webClientService.crawlingItem(
                mockWebServerUrl, &quot;https://www.naver.com&quot;);

        //then
        assertThat(webClientBodyResponse).isEqualTo(null);
    }
}</code></pre>
<hr>
<h1 id="2️⃣-본론-2">2️⃣ 본론 2</h1>
<h2 id="의존성-줄이기-➡️-webclientservice-로직-구조-변경">의존성 줄이기 ➡️ WebClientService 로직 구조 변경</h2>
<ul>
<li>이러한 WebClientService를 테스트하기 위한 과정에서 MockWebServer로 테스트가 가능한 유연한 구조를 변경했습니다.</li>
<li>아래는 이전의 테스트를 작성하기 전의 코드입니다.</li>
</ul>
<h3 id="이전의-구조">이전의 구조</h3>
<pre><code class="language-java">@Service
 public class WebClientService {

     @Value(&quot;${crawling.server.post.api.endPoint}&quot;)
     private String crawlingApiEndPoint;

     @Transactional
     public JSONObject crawlingItem(String pageUrl) throws ParseException {
         Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
         bodyMap.put(&quot;url&quot;, pageUrl);
         WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

         Map&lt;String, Object&gt; response = webClient.post()
                 .bodyValue(bodyMap)
                 .retrieve()
                 .bodyToMono(Map.class)
                 .block();

         JSONParser jsonParser = new JSONParser();

         Object obj = jsonParser.parse(response.get(&quot;body&quot;).toString());

         JSONObject jsonObject = (JSONObject) obj;

         return jsonObject;
     }
 }</code></pre>
<ul>
<li>이전 코드의 문제점은 <strong>WebClientService의 crawlingItem 메소드가 pageUrl만 받는 구조</strong>라는 점입니다.<ul>
<li>이러한 구조 때문에 MockWebServer로 만든 가짜 서버의 URL을 입력해주지 못해서 해당 메소드를 테스트하지 못합니다.</li>
<li>그러면, 🤔 이때 드는 의문은 <em>@Value로 crawling.server.post.api.endPoint에서 값을 가져오고 있는데 여기에 값을 미리 입력해주면 되는 거 아닌가??</em> 라는 의문이 들게 됩니다.</li>
<li>하지만, <strong>MockWebServer</strong>는 <strong>port 번호를 테스트할 때마다 마음대로 생성</strong>하기 때문에 미리 URL을 입력할 수 없습니다.</li>
<li>따라서, <strong>외부 API URL을 외부에서 주입하는 구조로 변경</strong>하여 테스트에 유연한 구조로 변경하였습니다. (현재 인프런의 김영한님 스프링 강의를 듣고 있는데 생성자 주입 DI가 떠올랐습니다 ㅎㅎ)</li>
</ul>
</li>
</ul>
<h3 id="mockwebserver의-port-번호-확인하기">MockWebServer의 Port 번호 확인하기</h3>
<ul>
<li>MockWebServer는 port 번호를 테스트할 때마다 마음대로 생성하는지 확인하기 위해서 아래와 같이 출력하는 코드를 입력해서 테스트를 2번 실행해보았습니다.</li>
</ul>
<pre><code class="language-java">@BeforeEach
void setUp() throws IOException {
    mockWebServer = new MockWebServer();
    mockWebServer.start();
    mockWebServerUrl = mockWebServer.url(&quot;/v1/crawling&quot;).toString();

    System.out.println(&quot;mockWebServerUrl = &quot; + mockWebServerUrl);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/da_na/post/af8f489d-202e-4147-ab3a-36639bc182f2/image.png" alt="">
<img src="https://velog.velcdn.com/images/da_na/post/4549edcc-779c-4bdc-8f6e-d56e79ce1c86/image.png" alt=""></p>
<ul>
<li>위의 사진처럼 포트 번호가 50346, 50352로 다른 것을 알 수 있습니다.</li>
</ul>
<h3 id="변경한-구조">변경한 구조</h3>
<ul>
<li><p>아래처럼 <strong>WebClientService의 crawlingItem 메소드 파라미터로 외부 API URL을 입력받는 구조로 변경</strong>하였습니다.</p>
</li>
<li><p>따라서, 가짜 외부 API 서버의 URL과 실제 외부 API 서버의 URL을 모두 받을 수 있는 유연한 구조가 되었습니다!</p>
</li>
<li><p>해당 메소드 테스트 시 사용하는 코드 : <code>WebClientBodyResponse webClientBodyResponse = webClientService.crawlingItem(mockWebServerUrl, &quot;https://www.naver.com&quot;);</code></p>
<pre><code class="language-java">@Service
public class WebClientService {

   @Transactional
   public WebClientBodyResponse crawlingItem(String crawlingApiEndPoint, String pageUrl) {
       Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
       bodyMap.put(&quot;url&quot;, pageUrl);

       WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

       try {
           WebClientResponse webClientResponse = webClient.post()
                   .bodyValue(bodyMap)
                   .retrieve()
                   .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                       throw new RuntimeException(&quot;4xx&quot;);
                   })
                   .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                       throw new RuntimeException(&quot;5xx&quot;);
                   })
                   .bodyToMono(WebClientResponse.class)
                   .block();

           ObjectMapper objectMapper = new ObjectMapper();
           objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

           return objectMapper.readValue(
                   webClientResponse != null ? webClientResponse.getBody() : null,
                   WebClientBodyResponse.class);

</code></pre>
</li>
</ul>
<pre><code>     } catch (Exception e) {
         return null;
     }

 }</code></pre><p> }</p>
<pre><code>---

# 3️⃣ 결론

### 1. ⛑️ **안정적이고 다양한 에러 처리 가능** 
- 해당 테스트 코드를 작성하기 전에는 직접 외부 API 서버를 호출해서 테스트를 진행했기 떄문에 에러가 나는 호출을 찾기 위해서 여러 번의 에러가 나는 페이지들을 찾아서 해당 페이지를 가지고 외부 API를 호출했습니다. 
- 그러나, 테스트를 준비하기 위해서 더 오랜 시간이 걸렸고, 웹 페이지 구조가 변경되면 해당 페이지가 에러가 나지 않을 수도 있는 등의 외부 서버에 너무 높은 의존도를 가지고 있었습니다. 
- 하지만 이번 테스트 코드를 작성하고 나니까, **훨씬 더 다양한 에러를 항상 빠르게 발생**시킬 수 있었고 다양한 에러를 발생시키다 보니까 **미처 놓쳤던 에러에 적합한 처리**도 할 수 있게 되어서 더 **안정적인 서비스가 가능**해진 것 같습니다.

### 2. 🤸‍♀️ **테스트 하기 위해서 유연한 구조로 변경**
- 테스트를 진행하기 위해서 외부에서 주입받는 형식으로 변경하여 테스트에 유연한 구조로 변경하였는데, **유연한 구조 설계의 중요성**을 다시 한 번 느끼게 된 것 같습니다.

### 3. 🤝 **테스트 코드는 약속이다.** 
- 테스트 코드를 작성하면서, 이전에는 외부 API에서 에러가 발생했을 때 따로 처리를 하지 않아서 외부 API의 에러가 발생하면 현재 프로젝트의 Spring Boot 서버(외부 API 호출한 원래의 서버)도 에러가 발생하였습니다.
- 그러나, 이번 테스트 코드로 에러 처리가 가능해지면서, 외부 API 에러를 원래의 서버에 영향을 줄이고자 null을 반환하도록 하고 Other(기타 스크랩)으로 저장되는 로직으로 변경하였습니다.
- 테스트 관련 강의를 들으면서 테스트 코드를 작성함으로써, 어떠한 행동과 로직을 수행했을 때 어떠한 결과가 나와야 한다고 문서화 및 약속이 가능하다!!라는 말이 떠올랐습니다. (왜냐하면, 테스트 코드는 모든 팀원들이 봐야하고 항상 모든 서비스 로직이 테스트 코드를 통과해야 하며, 행동에 대한 결과가 정해져있기 때문입니다.)
- 따라서 이러한 과정을 아래의 테스트 코드로 작성해놓으면서,  **팀원들과 같이 이야기를 하고 로직에 대한 약속을 정할 수 있게 되었습니다**.
- **WebClient(외부 API 서버)가 비정상적인 응답을 준 경우 null로 반환하고 Other 스크랩으로 저장되는 로직**을 수행했습니다.

```java
@Test
void should_it_returns_null_When_webClient_server_responds_unsuccessfully() {
    // webClient 서버가 정상적으로 응답을 주지 않는 경우, null로 반환되는지 확인
    //given
    mockWebServer.enqueue(new MockResponse()
            .setResponseCode(400)
    );

    //when
    WebClientBodyResponse webClientBodyResponse = webClientService.crawlingItem(
            mockWebServerUrl, &quot;https://www.naver.com&quot;);

    //then
    assertThat(webClientBodyResponse).isEqualTo(null);
}</code></pre><pre><code class="language-java">@Test
void should_other_type_of_scrap_is_saved_When_webClientService_crawlingItem_returns_null() throws ParseException {
    // webClientService.crawlingItem()이 null을 반환할 때, Other 타입의 Scrap이 저장되는지 확인
    //given
    memoRepository.deleteAll();
    scrapRepository.deleteAll();

    BDDMockito.when(webClientService.crawlingItem(&quot;http://localhost:123&quot;, pageUrl))
            .thenReturn(null);

    User user = userRepository.findById(1L).get();

    //when
    //then
    assertThat(scrapService.saveScraps(user, pageUrl)).isInstanceOf(Other.class);
    assertThat(scrapRepository.findByPageUrlAndUserAndDeletedDateIsNull(pageUrl, user)
            .isPresent()).isTrue();
}</code></pre>
<h3 id="4-🛟-리팩토링시-테스트-코드를-통해서-훨씬-안전하게-변경가능">4. 🛟 <strong>리팩토링시, 테스트 코드를 통해서 훨씬 안전하게 변경가능</strong></h3>
<ul>
<li>이전 글에서 외부 API 호출하는 로직을 리팩토링한 적이 있습니다. 이번 글의 테스트 코드와 리팩토링 글이 나누어져 있지만 원래는 리팩토링 과정에서 테스트 코드도 같이 도입된 상태였습니다.</li>
<li>이때, 느꼈던 점은 리팩토링을 할 때 테스트 코드를 작성해 놓아서 테스트 코드를 통과하면 <strong>로직이 정상적으로 돌아간다는 보장</strong>이 있기 때문에 <strong>테스트 코드를 믿으면서 안전하게 코드를 마음대로 수정</strong>할 수 있게 되었습니다.</li>
<li>그리고 테스트 코드를 통해서 응답 결과를 바로 확인할 수 있어서 실제 외부 API를 호출하지 않고도 쉽게 수정이 용이했습니다!!</li>
<li>이전 글 : <a href="https://velog.io/@da_na/WebClient-WebClient-사용해서-외부-API-호출하기"> https://velog.io/@da_na/WebClient-WebClient-사용해서-외부-API-호출하기 </a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[WebClient] WebClient 사용해서 외부 API 호출하기]]></title>
            <link>https://velog.io/@da_na/WebClient-WebClient-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/WebClient-WebClient-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 14 Sep 2023 09:09:02 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>저희 서비스는 크롤링하는 서버를 EC2와 분리해서 서비스를 분리시켰기 때문에, <strong>Spring Boot(AWS EC2)에서 AWS Lambda 서버를 호출</strong>하는 형식으로 외부 API를 호출하고 있습니다.</p>
<p>따라서, 간략하게 AWS EC2에서 AWS Lambda를 호출하는 흐름을 아래의 그림과 같이 나타내었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/202287f1-fd11-4ea0-9755-8fe1a1d16efc/image.png" alt=""></p>
<p>그러면 Spring Boot에서는 어떻게 외부의 API를 호출시킬까요??</p>
<p>저희는 <strong>WebClient</strong>라는 외부 호출 방식를 사용하였습니다.</p>
<p>그러면 도대체 WebClient가 무엇이길래 이거를 선택했고 어떻게 사용할지 소개해드리겠습니다.</p>
<hr>
<h1 id="1️⃣-본론-1">1️⃣ 본론 1</h1>
<h2 id="✔️-외부-호출-방식-결정하기">✔️ 외부 호출 방식 결정하기</h2>
<p>먼저, &#39;Spring으로 외부 API 호출하기&#39;라고 구글에 검색했을 때 가장 많이 나오는 방식은 &#39;<strong>RestTemplate</strong>&#39;과 &#39;<strong>WebClient</strong>&#39;입니다.</p>
<p>그러면 두 가지 방식은 어떠한 차이가 있을까요??</p>
<h3 id="resttemplate">RestTemplate</h3>
<ul>
<li>Rest API를 호출하고, 응답을 제어할 수 있는 스프링에서 제공하는 클래스입니다.</li>
<li>스프링 3.0에서부터 지원하는 RestTemplate은 HTTP 통신에 유용하게 쓸 수 있는 템플릿입니다.</li>
<li>REST 서비스를 호출하도록 설계되어 HTTP 프로토콜의 메서드 (GET, POST, DELETE, PUT)에 맞게 여러 메서드를 제공합니다.</li>
<li>통신을 단순화하고 <strong>RESTful 원칙</strong>을 지킵니다.</li>
<li><strong>멀티쓰레드 방식</strong>을 사용합니다.</li>
<li><strong>Blocking 방식</strong>을 사용합니다.</li>
</ul>
<p>그러나, 스프링 5.0에서 WebClient가 나오면서 WebClient를 공식 문서에서는 추천해주고 있다.
(아직은 소문이지만, RestTemplate이 deprecated 될 수도 있다는 소리를 들어서, 이전에 RestTemplate를 사용한 것이 아니라 새로운 프로젝트를 하는 저의 경우에는 WebClient가 더 좋다고 생각했습니다.)</p>
<blockquote>
<p>NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.</p>
</blockquote>
<h3 id="webclient">WebClient</h3>
<ul>
<li>스프링 5.0에서 추가된 인터페이스입니다.</li>
<li><strong>싱글 스레드 방식</strong>을 사용합니다.</li>
<li><strong>Non-Blocking 방식</strong>을 사용합니다.</li>
<li><strong>JSON, XML</strong>을 쉽게 응답받습니다.</li>
</ul>
<p>따라서 가장 큰 차이점은 <em>Non-Blocking 여부</em>와 <em>비동기화 여부</em>입니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>RestTemplate</th>
<th>WebClient</th>
</tr>
</thead>
<tbody><tr>
<td>Non-Blocking</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>비동기화</td>
<td>불가능</td>
<td>가능</td>
</tr>
</tbody></table>
<ul>
<li>즉, WebCLient는 시스템을 호출한 직후에 프로그램으로 제어가 다시 돌아와서 시스템 호출의 종료를 기다리지 않고 다음 동작을 진행합니다. 따라서 호출한 시스템의 동작을 기다리지 않고 동시에 다른 작업을 진행할 수 있습니다.</li>
<li>그리고 WebClient는 <strong>block()</strong>을 사용해서 <strong>Non-Blocking과 Blocking을 자유롭게 변경</strong>할 수 있습니다.</li>
<li>따라서 이러한 장점을 많이 가지고 있고, 공식 문서에서 추천하는 WebClient를 저희 프로젝트에 도입하게 되었습니다.</li>
<li>현재는 외부 API를 호출하고 나서 외부 API의 응답값을 DTO로 변환하여 DB에 저장하는 방식으로 진행하고 있어서 WebClient를 Blocking으로 사용하고 있습니다... 그래서 큰 장점을 활용하지 못하고 있지만, 앞으로 사용자가 많아진다면 아키텍처 구조를 변경하여 WebClient를 Non-Blocking 구조로 변경할 예정입니다.!</li>
</ul>
<p>참고 자료 1 : <a href="https://velog.io/@chlwogur2/Spring-외부-API-호출-로직에-관해"> https://velog.io/@chlwogur2/Spring-외부-API-호출-로직에-관해 </a></p>
<p>참고 자료 2 : <a href="https://tecoble.techcourse.co.kr/post/2021-07-25-resttemplate-webclient/"> https://tecoble.techcourse.co.kr/post/2021-07-25-resttemplate-webclient/ </a></p>
<hr>
<h2 id="💼-webclient-사용해서-외부-api-호출하기">💼 WebClient 사용해서 외부 API 호출하기</h2>
<h3 id="1--의존성-추가하기">1.  의존성 추가하기</h3>
<pre><code class="language-yml">implementation &#39;org.springframework.boot:spring-boot-starter-webflux&#39;</code></pre>
<h3 id="2-webclient-생성하기">2. WebClient 생성하기</h3>
<ul>
<li>Builder를 사용하여 WebClient를 생성하겠습니다.</li>
<li>이때, 외부 API의 주소를 baseUrl에 AWS Lambda의 EndPoint(API GATEWAY)를 작성해주었습니다.</li>
</ul>
<pre><code class="language-java">WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();</code></pre>
<h3 id="3-외부-api-호출하기">3. 외부 API 호출하기</h3>
<ul>
<li>WebClient로 외부 API를 Post 메서드를 사용해서 호출해줍니다.</li>
</ul>
<pre><code class="language-java">Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
bodyMap.put(&quot;url&quot;, pageUrl);

WebClientResponse webClientResponse = webClient.post()
                    .bodyValue(bodyMap)
                    .retrieve()
                    .bodyToMono(WebClientResponse.class)
                    .block();</code></pre>
<ul>
<li>위에 같이 webClient.post().bodyValue(bodyMap)을 사용하면 아래의 postman처럼 보내는 것과 동일한 형태입니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/da_na/post/8676a3b5-46fb-41fd-bf97-eef94b5bb6fd/image.png" alt=""></p>
<ul>
<li><strong>block()</strong> 을 이용해서 Non-Blocking 형태가 아닌 <strong>Blocking 형태로 변경</strong>할 수도 있습니다.<ul>
<li>현재는 외부 API를 호출하고 나서 외부 API의 응답값을 DTO로 변환하여 DB에 저장하는 방식으로 진행하고 있어서 blocking으로 사용했습니다.</li>
</ul>
</li>
<li><strong>retrive()</strong> 는 CleintResponse 개체의 body를 받아 디코딩하고 사용자가 사용할 수 있도록 미리 만든 개체를 제공하는 간단한 메소드 입니다.</li>
<li>retrive() 를 사용할 때는, toEntity(), bodyToMono(), bodyToFlux() 이렇게 response를 받아올 수 있습니다.<ul>
<li>bodyToFlux, bodyToMono 는 가져온 body를 각각 Reactor의 Flux와 Mono 객체로 바꿔줍니다.</li>
<li>이때, mono는 외부 서비스에 요청을 할 때 <code>최대 하나의 결과를 예상</code>할 때 <strong>Mono</strong>를 사용해야 합니다.</li>
<li>외부 서비스 호출에서 <code>여러 결과</code>가 예상되는 경우 <strong>Flux</strong>를 사용해야 합니다.</li>
<li>저희 서비스는 하나의 결과가 나오기 때문에, 응답 결과를 <strong>bodyToMono</strong>로 받아오겠습니다.</li>
</ul>
</li>
</ul>
<p>WebClient 사용방법 참고 자료 : <a href="https://thalals.tistory.com/379"> https://thalals.tistory.com/379 </a></p>
<p>Mono, Flux 참고 자료 : <a href="https://recordsoflife.tistory.com/799"> https://recordsoflife.tistory.com/799 </a></p>
<h3 id="4-외부-api-에러-처리하기">4. 외부 API 에러 처리하기</h3>
<p>외부 API의 응답 status가 400대, 500대 에러가 발생할 경우, RuntimeException를 발생시켜서 null을 반환하도록 하였습니다.</p>
<ul>
<li>외부 API의 오류로 인해서 Spring Boot(EC2)에도 오류를 발생하면 서로 너무 의존하고 있다고 생각해서, null을 반환하여 Spring Boot에는 외부 API 결과에 영향을 받지 않도록 하였습니다.</li>
</ul>
<pre><code class="language-java">WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
bodyMap.put(&quot;url&quot;, pageUrl);

try {
    WebClientResponse webClientResponse = webClient.post()
            .bodyValue(bodyMap)
            .retrieve()
            .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                throw new RuntimeException(&quot;4xx&quot;);
            })
            .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                throw new RuntimeException(&quot;5xx&quot;);
            })
            .bodyToMono(WebClientResponse.class)
            .block();
} catch (Exception e) {
    return null;
}</code></pre>
<p>에러 참고 자료 : <a href="https://dkswnkk.tistory.com/708"> https://dkswnkk.tistory.com/708 </a></p>
<hr>
<h1 id="2️⃣-본론-2">2️⃣ 본론 2</h1>
<h2 id="😎-외부-api-호출-응답값-로직-변경-이유">😎 외부 API 호출 응답값 로직 변경 이유</h2>
<p>이전에는 Map&lt;String, Object&gt; response로 외부 API 호출 응답값을 받았습니다. </p>
<pre><code class="language-java">WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
bodyMap.put(&quot;url&quot;, pageUrl);

Map&lt;String, Object&gt; response = webClient.post()
        .bodyValue(bodyMap)
        .retrieve()
        .bodyToMono(Map.class)
        .block();

JSONParser jsonParser = new JSONParser();

Object obj = jsonParser.parse(response.get(&quot;body&quot;).toString());

JSONObject jsonObject = (JSONObject) obj;</code></pre>
<p>따라서, JSONObject에서 각각의 속성값(title, thumbnail_url, descrption 등)을 가져와서 <strong>Object를 각각의 속성값에 맞는 타입(string, long 등)으로 변환</strong>해줘야 했습니다.</p>
<p>이 과정에서 만약에 null이면 타입을 변환할 때 에러가 발생하므로 <strong>null인지 파악하는 코드</strong>도 추가해줘야 했습니다.</p>
<pre><code class="language-java">public Article saveArticle(JSONObject crawlingResponse, User user, String pageUrl) {

    Article article = Article.builder().user(user).pageUrl(pageUrl)
            .title(Optional.ofNullable(crawlingResponse.get(&quot;title&quot;)).map(Object::toString)
                    .orElse(null))
            .thumbnailUrl(Optional.ofNullable(crawlingResponse.get(&quot;thumbnail_url&quot;))
                    .map(Object::toString).orElse(null))
            .description(Optional.ofNullable(crawlingResponse.get(&quot;description&quot;))
                    .map(Object::toString).orElse(null))
            .author(Optional.ofNullable(crawlingResponse.get(&quot;author&quot;)).map(Object::toString)
                    .orElse(null))
            .authorImageUrl(Optional.ofNullable(crawlingResponse.get(&quot;author_image_url&quot;))
                    .map(Object::toString).orElse(null))
            .blogName(
                    Optional.ofNullable(crawlingResponse.get(&quot;blog_name&quot;)).map(Object::toString)
                            .orElse(null))
            .publishedDate(Optional.ofNullable(crawlingResponse.get(&quot;published_date&quot;))
                    .map(Object::toString).map(Long::parseLong).map(
                            TimeService::fromUnixTime).orElse(null))
            .siteName(
                    Optional.ofNullable(crawlingResponse.get(&quot;site_name&quot;)).map(Object::toString)
                            .orElse(null)).build();

    return articleRepository.save(article);
}</code></pre>
<ul>
<li>앞으로 속성값이 늘어날 때마다 null 에러 처리 및 타입 변경을 일일히 해줘야 했습니다.</li>
<li>그리고 JSONParser jsonParser = new JSONParser();는 decrepted되었다고 합니다. </li>
<li>따라서 JSONObject를 사용하지 않기로 하였습니다.</li>
</ul>
<p>JSONParser 관련 자료 : <a href="https://myeongju00.tistory.com/77"> https://myeongju00.tistory.com/77 </a></p>
<hr>
<p>따라서 ObjectMapper를 이용해서 원하는 DTO로 변환하도록 하였습니다!</p>
<h3 id="외부-api-호출하여-받은-json-값을-dto로-변환하기">외부 API 호출하여 받은 JSON 값을 DTO로 변환하기</h3>
<p>현재 외부 API인 AWS Lambda에서는 아래의 값을 응답 Body로 반환하고 있습니다.</p>
<pre><code class="language-json">{
  &quot;statusCode&quot;: 200,
  &quot;headers&quot;: {
    &quot;Content-Type&quot;: &quot;application/json&quot;
  },
  &quot;body&quot;: &quot;{\&quot;type\&quot;: \&quot;place\&quot;, \&quot;page_url\&quot;: \&quot;https://map.kakao.com/?map_type=TYPE_MAP&amp;itemId=\&quot;, \&quot;site_name\&quot;: \&quot;KakaoMap\&quot;, \&quot;lat\&quot;: 37.50359, \&quot;lng\&quot;: 127.044848, \&quot;title\&quot;: \&quot;서울역\&quot;, \&quot;address\&quot;: \&quot;서울\&quot;, \&quot;phonenum\&quot;: \&quot;1234-1234\&quot;, \&quot;zipcode\&quot;: \&quot;12345\&quot;, \&quot;homepage\&quot;: \&quot;https://www.seoul.co.kr\&quot;, \&quot;category\&quot;: \&quot;지하철\&quot;}&quot;
}</code></pre>
<p>위의 구조를 큰 형태로 보면 statusCode, headers, body로 이루어져 있음을 알 수 있습니다.</p>
<p>아래와 같은 DTO로 응답을 받을 수 있습니다.</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
public class WebClientResponse {

    private String statusCode;
    private WebClientHeaderResponse headers;
    private String body;
}</code></pre>
<p>그리고 body는 아래의 WebClientBodyResponse 형태를 띄고 있습니다.</p>
<pre><code class="language-java">@Getter
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) //응답값이 python의 snake case이므로 아래의 속성값을 snake case로 변경해줍니다.
@NoArgsConstructor
public class WebClientBodyResponse {

    // 공통 부분
    private String type;
    private String description;
    private String pageUrl;
    private String siteName;
    private String thumbnailUrl;
    private String title;

    // Video 부분
    private String channelImageUrl;
    private String channelName;
    private String embedUrl;
    private Long playTime;
    private Long watchedCnt;
    private Long publishedDate; // Video, Article 공통 부분

    // Article 부분
    private String author;
    private String authorImageUrl;
    private String blogName;

    // Product 부분
    private String price;

    // Place 부분
    private String address;

    @JsonProperty(&quot;lat&quot;)
    private BigDecimal latitude;

    @JsonProperty(&quot;lng&quot;)
    private BigDecimal longitude;

    @JsonProperty(&quot;phonenum&quot;)
    private String phoneNumber;
    private String zipCode;

    @JsonProperty(&quot;homepage&quot;)
    private String homepageUrl;
    private String category;
}
</code></pre>
<p>따라서 아래의 <strong>.bodyToMono(WebClientResponse.class)에서 외부 API 호출하여 받은 JSON 값</strong>을 <strong>WebClientResponse DTO로 변환</strong>해주고.
ObjectMapper를 통해서 String으로 들어온 <strong>WebClientResponse의 body 부분</strong>을 <strong>WebClientBodyResponse DTO로 변환</strong>해주는 작업을 수행해주면 됩니다.</p>
<pre><code class="language-java">WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();

Map&lt;String, Object&gt; bodyMap = new HashMap&lt;&gt;();
bodyMap.put(&quot;url&quot;, pageUrl);

try {
    WebClientResponse webClientResponse = webClient.post()
            .bodyValue(bodyMap)
            .retrieve()
            .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                throw new RuntimeException(&quot;4xx&quot;);
            })
            .onStatus(HttpStatus::is4xxClientError, clientResponse -&gt; {
                throw new RuntimeException(&quot;5xx&quot;);
            })
            .bodyToMono(WebClientResponse.class)
            .block();

    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    return objectMapper.readValue(
            webClientResponse != null ? webClientResponse.getBody() : null,
            WebClientBodyResponse.class);


} catch (Exception e) {
    return null;
}</code></pre>
<ul>
<li>이때 유의할 점은 <code>objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);</code>을 사용했다는 점입니다.</li>
<li>json을 Dto로 변환하는 과정에서 맵핑되지 않는 속성이 있는 경우 오류를 발생시키지 않고 서로 동일한 속성값만 변환해줍니다. </li>
</ul>
<p>objectAmpper 관련 참고 자료 : <a href="https://tomining.tistory.com/191"> https://tomining.tistory.com/191 </a></p>
<pre><code class="language-java">public Article saveArticle(WebClientBodyResponse crawlingResponse, User user, String pageUrl) {

    Article article = Article.builder().user(user).pageUrl(pageUrl)
            .title(crawlingResponse.getTitle())
            .thumbnailUrl(crawlingResponse.getThumbnailUrl())
            .description(crawlingResponse.getDescription())
            .author(crawlingResponse.getAuthor())
            .authorImageUrl(crawlingResponse.getAuthorImageUrl())
            .blogName(crawlingResponse.getBlogName())
            .publishedDate(TimeService.fromUnixTime(crawlingResponse.getPublishedDate()))
            .siteName(crawlingResponse.getSiteName()).build();

    return articleRepository.save(article);
}</code></pre>
<p>따라서 이전에 방식보다 속성값의 타입을 따로 변환해주지 않아도 되고 null 처리도 안해도 되어서 간편해졌습니다!</p>
<hr>
<h1 id="3️⃣-결론">3️⃣ 결론</h1>
<ul>
<li>외부 API를 호출하는 작업은 크게 오래 걸리지 않았지만, <strong>응답 값을 받고 이를 저희 서비스에 맞는 형태로 변환</strong>해주기 위해서는 많은 작업과 시행착오가 있었습니다.</li>
<li>그러나, 이전보다 더 에러 처리부분에서 단단해지고 코드가 간략해진 모습을 보고 <strong>refactoring의 중요성</strong>을 다시 한 번 느끼게 된 것 같습니다.</li>
<li>위에서도 언급했지만, 현재는 외부 API를 호출하고 나서 외부 API의 응답값을 DTO로 변환하여 DB에 저장하는 방식으로 진행하고 있어서 WebClient를 큰 장점인 Non-Blocking을 활용하지 못하고 있지만, 앞으로 사용자가 많아진다면 아키텍처 구조를 변경하여 WebClient를 <strong>Non-Blocking 구조로 변경</strong>해야 제대로된 WebClient를 사용할 수 있겠다는 생각을 했습니다!</li>
<li>이전에 WebClient를 사용할 때에는 응답값의 구조를 제대로 살펴보지 않아서 Dto 변환이 어려워서 MAP 구조를 사용했는데, 이번 계기로 외부 <strong>API 응답값을 더 자세하게 살펴보고 이해</strong>하게 되어 더 적합한 refactoring이 가능했던 것 같습니다.☺️</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Git & GitHub] GitHub의 Merge, Squash and merge, Rebase and merge에서 Merge 방식 선택하기]]></title>
            <link>https://velog.io/@da_na/Git-GitHub-GitHub%EC%9D%98-Merge-Squash-and-merge-Rebase-and-merge%EC%97%90%EC%84%9C-Merge-%EB%B0%A9%EC%8B%9D-%EC%84%A0%ED%83%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@da_na/Git-GitHub-GitHub%EC%9D%98-Merge-Squash-and-merge-Rebase-and-merge%EC%97%90%EC%84%9C-Merge-%EB%B0%A9%EC%8B%9D-%EC%84%A0%ED%83%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 10 Sep 2023 17:23:45 GMT</pubDate>
            <description><![CDATA[<h1 id="0️⃣-서론">0️⃣ 서론</h1>
<p>이전에 &#39;<em>jacoco를 사용하여 test coverage report 적용하기</em>&#39;라는 글의 제목으로 test coverage report 작성시 발생한 문제점 해결책으로 <strong>Pull Request를 보내는 구조를 변경</strong>한 적이 있습니다.</p>
<p><a href="https://handayeon-coder.github.io/posts/jacoco를-사용하여-test-coverage-report-적용하기/"> https://handayeon-coder.github.io/posts/jacoco를-사용하여-test-coverage-report-적용하기/ </a></p>
<p><a href="https://velog.io/@da_na/TestCode-jacoco를-사용하여-test-coverage-report-적용하기"> https://velog.io/@da_na/TestCode-jacoco를-사용하여-test-coverage-report-적용하기 </a></p>
<p>아래의 사진으로 이전 구조와 변경된 구조를 나타냈습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/8cfe042e-87ee-4775-96d0-1926f415d4e9/image.png" alt=""></p>
<p>즉, 1회의 Pull Request와 Merge 구조에서 <strong>총 2회의 Pull Request와 Merge 구조로 변경되었음</strong>을 알 수 있습니다.</p>
<p>따라서, 이러한 구조로 인해서 기존의 Git-Flow 구조에서 깔끔한 Develop Branch에서 Featrue Branch로 뻗어나가는 구조에서 <strong>Develop Branch에서 2개로 분기되는 구조로 Branch를 파악하기 어려운 구조</strong>가 되었습니다.</p>
<p>아래의 사진은 Upstream/develop 브랜치를 기준으로 본 Github Branch 구조입니다.</p>
<ul>
<li>이전의 Git-Flow의 Branch 구조
<img src="https://velog.velcdn.com/images/da_na/post/da925cae-debd-413e-8ae8-a0ca223ca856/image.png" alt=""></li>
<li>바뀐 후의 Git-Flow의 Branch 구조
<img src="https://velog.velcdn.com/images/da_na/post/31757184-a1b4-4609-82b3-b5864e50dc40/image.png" alt=""></li>
</ul>
<p>아래의 사진으로  이러한 구조가 나오게 된 이유를 파악해보면, <strong>2회의 Pull Request &amp; Merge 구조</strong>와 <strong>Merge의 방식</strong> 때문이었습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/043d3e71-3994-41b7-8ffc-9e28fb262d34/image.png" alt=""></p>
<ul>
<li>1️⃣ Pull Request and Merge 사진
<img src="https://velog.velcdn.com/images/da_na/post/fa18aa6a-51dd-4b61-97a9-a78664c1d0e4/image.png" alt=""></li>
<li>2️⃣ Pull Request and Merge 사진
<img src="https://velog.velcdn.com/images/da_na/post/81547242-cebb-42ef-a503-22e1824cee7f/image.png" alt=""></li>
</ul>
<p>Merge 방식은 pull Request를 Merge할 때 기본적으로 설정되어 있는 &#39;<strong>Create a merge commit</strong>&#39;이라는 것을 선택했습니다.</p>
<p><img src="https://velog.velcdn.com/images/da_na/post/7b3939e6-1575-4553-ac61-9ebdc4036feb/image.png" alt=""></p>
<p>그러면 바뀐 후의 Git-Flow의 Branch 구조를 이전처럼 깔끔하고 파악하기 쉬운 Git-Flow Branch으로 변경하기 위해서는 어떻게 할까??</p>
<p>고민하다가, Merge 방식을 <strong>Squash and merge</strong>, <strong>Rebase and merge</strong>로 변경해보면 어떨까??라는 생각이 들었습니다.</p>
<p>해당 Squash and merge와 Rebase and merge이 어떻게 다른지 파악해보고 더 적절한 방식을 선택해보고자 하였습니다.</p>
<hr>
<h1 id="1️⃣-본론">1️⃣ 본론</h1>
<h2 id="🖍️-merge-방식-비교하기">🖍️ Merge 방식 비교하기</h2>
<h3 id="1-create-a-merge-commit">1. Create a merge commit</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/f4102c40-c98a-40c3-ace1-6ab78688dab1/image.png" alt=""></p>
<p>하단의 있는 브랜치를 Main 브랜치(파란색), 위의 있는 브랜치(주황색)를 feature 브랜치라고 하겠습니다.</p>
<p>featrue 브랜치의 변경 이력 전체(a, b, c)를 main 브랜치에 합칩니다.</p>
<p>이때, m이라는 노드를 통해서 <strong>a + b + c</strong>가 main 브랜치에 추가됩니다.</p>
<h3 id="2-squash-and-merge">2. Squash and merge</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/ff2ed59c-8e69-4b32-96da-0564c73ec9f9/image.png" alt=""></p>
<p>featrue 브랜치의 a + b + c 커밋들을 합쳐서 새로운 commit, <strong>abc를 만들어지고 main 브랜치에 추가</strong>됩니다.</p>
<p>feature 브랜치의 commit history를 합쳐서 깔끔하게 만들기 위해 사용합니다.</p>
<h3 id="3-rebase-and-merge">3. Rebase and merge</h3>
<p><img src="https://velog.velcdn.com/images/da_na/post/d774f4d6-fb7c-42ec-97fc-0adaba2ce33f/image.png" alt="">
모든 commit들이 합쳐지지 않고 각각 master 브랜치에 추가됩니다.</p>
<p><strong>각 commit은 모두 하나의 parent를 가지게 됩니다.</strong></p>
<p>참고 자료 : <a href="https://im-developer.tistory.com/182">https://im-developer.tistory.com/182</a></p>
<hr>
<h2 id="🔄-merge-방식-변경하기">🔄 Merge 방식 변경하기</h2>
<p>1️⃣번의 Pull Request &amp; merge에서 선택한 Merge 방식은 <strong>Rebase and merge</strong>로 하기로 하였습니다.</p>
<p>이전과 같은 Bracnh 구조를 하기 위해서는 merge된 branch는 없애고 모든 commit들을 merge한 브랜치에 추가한 구조기 때문에 Rebase and merge 구조가 적합하다고 생각했습니다.</p>
<ul>
<li>이전 Branch 구조
<img src="https://velog.velcdn.com/images/da_na/post/c724f5ec-e350-4165-a834-34e106d45f57/image.png" alt=""></li>
<li>Rebase and merge를 사용했을 때의 Branch 구조
<img src="https://velog.velcdn.com/images/da_na/post/ddc07f7a-91d9-4007-9243-33f774febaab/image.png" alt=""></li>
</ul>
<p>아래와 같이 2회의 Pull Request and Merge가 있었지만, 이전처럼 Branch 구조로 변경되었습니다.
<img src="https://velog.velcdn.com/images/da_na/post/696e355b-9a7d-4e51-8d08-9da0a30a6df8/image.png" alt=""><img src="https://velog.velcdn.com/images/da_na/post/32759ec4-ca77-46de-b048-8ca1b52640c0/image.png" alt=""></p>
<hr>
<h1 id="3️⃣-결론">3️⃣ 결론</h1>
<p>이번 계기를 통해서 더 자세하게 <strong>Github의 Merge 구조</strong>를 더 정확하게 파악해볼 수 있었습니다.</p>
<p>이전에는 Pull Request를 합칠 때에 기본으로 되어 있는 Create a merge commit만을 사용해서 merge를 했습니다.
이렇게 3가지 옵션으로 <strong>Create a merge commit, Squash and merge</strong>, <strong>Rebase and merge</strong>이 있는지 처음으로 알게 되었습니다.</p>
<p>따라서 더 다양한 시각을 가지게 되었고, Github를 더 효율적으로 사용함을 익혔을뿐만 아니라 앞으로 Github를 사용할 때에는 다양한 기능을 더 자세하게 살펴본 뒤에 프로젝트에 적합한 방식을 사용해야겠다고 생각했습니다.</p>
<p>이번 글에서는 Rebase and merge을 사용해서 merge했지만, Git-flow를 따른다고 했을 때 유용한 구조는 아래와 같다고 합니다.</p>
<blockquote>
<p><strong>develop - feature 브렌치간 머지</strong> : <strong>Squash and Merge</strong>가 유용합니다. feature의 복잡하고 지저분한 커밋 히스토리를 모두 묶어 완전 새로운 커밋으로 develop 브렌치에 추가하여, develop 브렌치에서 독자적으로 관리할 수 있기 때문입니다. 일반적으로 머지 후에 feature 브렌치를 삭제해버리는 점을 떠올려 보면, feature 브렌치의 커밋 히스토리를 모두 develop 브렌치에 직접 연관 지어 남길 필요가 없습니다.</p>
</blockquote>
<blockquote>
<p><strong>master - develop 브렌치간 머지</strong> : <strong>Rebase and Merge</strong>가 유용합니다. develop의 내용을 master에 추가할 때에는 별도의 새로운 커밋을 생성할 이유가 없기 때문입니다.</p>
</blockquote>
<blockquote>
<p><strong>hotfix - develop, hotfix - master 브렌치간 머지</strong> : <strong>Merge</strong> 또는 *<em>Squash and Merge *</em>모두 유용합니다. 때에 따라 골라 사용하면 좋을 것 같습니다. hotfix 브렌치 작업의 각 커밋 히스토리가 모두 남아야 하는 경우 Merge, 필요 없는 경우 Squash and Merge를 사용하면 됩니다.</p>
</blockquote>
<p>출처 : <a href="https://meetup.nhncloud.com/posts/122">https://meetup.nhncloud.com/posts/122</a></p>
<p>현재 저희 팀에서는 위의 방식으로 합의되지 않아서, 당장 위의 구조로 변경할 수는 없지만 앞으로 프로젝트에서는 위의 머지 방식으로 commit 히스토리와 branch를 관리하여 Git-flow 방식을 더 효율적으로 사용해보고 싶다는 다짐을 했습니다.</p>
]]></description>
        </item>
    </channel>
</rss>