<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>mujik-tigers</title>
        <link>https://velog.io/</link>
        <description>mujik-tigers 프로젝트 블로그</description>
        <lastBuildDate>Sun, 24 Mar 2024 13:51:34 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>mujik-tigers</title>
            <url>https://velog.velcdn.com/images/on-and-off/profile/195c288e-1a8a-404b-bd3a-038ab585580b/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. mujik-tigers. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/on-and-off" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Client IP Address와 HTTP Header]]></title>
            <link>https://velog.io/@on-and-off/Client-IP-Address%EC%99%80-HTTP-Header</link>
            <guid>https://velog.io/@on-and-off/Client-IP-Address%EC%99%80-HTTP-Header</guid>
            <pubDate>Sun, 24 Mar 2024 13:51:34 GMT</pubDate>
            <description><![CDATA[<p><span style="color: gray"><em>어떻게 클라이언트의 IP 주소를 알 수 있을까요?</em></span></p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/179a158c-2f62-4be7-a379-f9a3c549a423/image.png" alt=""></p>
<p>우리는 request의 <code>getRemoteAddr()</code> 을 통해 IP 주소를 가져올 수 있습니다.</p>
<p>그런데 이때 가져온 주소가 정말 클라이언트의 IP 주소일까요?</p>
<br/>

<hr>
<br/>

<p><strong>사용자에 대한 정보를 얻을 수 있는 HTTP 요청 헤더 예시</strong></p>
<table>
<thead>
<tr>
<th align="center">헤더 이름</th>
<th align="center">헤더 타입</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">Form</td>
<td align="center">요청</td>
<td align="center">사용자의 이메일 주소</td>
</tr>
<tr>
<td align="center">User-Agent</td>
<td align="center">요청</td>
<td align="center">사용자의 브라우저</td>
</tr>
<tr>
<td align="center">Referer</td>
<td align="center">요청</td>
<td align="center">사용자가 현재 링크를 타고 온 근원 페이지</td>
</tr>
<tr>
<td align="center">Authorization</td>
<td align="center">요청</td>
<td align="center">사용자 이름과 비밀번호</td>
</tr>
<tr>
<td align="center">Client-ip</td>
<td align="center">확장(요청)</td>
<td align="center">클라이언트의 IP 주소</td>
</tr>
<tr>
<td align="center">X-Forwarded-For</td>
<td align="center">확장(요청)</td>
<td align="center">클라이언트의 IP 주소</td>
</tr>
<tr>
<td align="center">Cookie</td>
<td align="center">확장(요청)</td>
<td align="center">서버가 생성한 ID 라벨</td>
</tr>
</tbody></table>
<br/>

<p>일반적으로 클라이언트와 서버는 직접 메시지를 주고 받지 않습니다.</p>
<p>메시지는 복잡한 네트워크를 경유하여 목적지에 도착하게 되는데,
이 과정에서 프록시나 로드 밸런서를 거치게 되면 IP 주소가 변경될 수 있습니다.</p>
<p>이때 X-Forwarded-For 헤더를 통해 우리는 요청 메시지의 발자취를 확인할 수 있습니다.</p>
<br/>

<h3 id="x-forwarded-for">X-Forwarded-For</h3>
<p><strong>X-Forwarded-For</strong> 헤더는 HTTP 프록시나 로드 밸런서를 통해 웹 서버에 접속하는 클라이언트의 원 IP 주소를 식별하기 위해 사용하는 헤더입니다.
클라이언트와 서버 중간에서 트래픽이 프록시나 로드 밸러서를 거치면 서버 접근 로그에는 프록시나 로드 밸러서의 IP 주소가 남게 되는데 이때 클라이언트의 IP 주소를 보기 위해 X-Forwarded-For 요청 헤더가 사용됩니다.
X-Forwarded-For 헤더는 프록시 서버를 경유할 때마다 서버의 IP 주소 정보를 쉼표로 구분해 연결해 나갑니다.</p>
<pre><code>X-Forwarded-For: &lt;client&gt;, &lt;proxy1&gt;, &lt;proxy2&gt;</code></pre><p>가장 오른쪽 IP 주소는 가장 마지막에 거친 프록시 IP 주소이고, 가장 왼쪽의 IP 주소는 최초 클라이언트의 IP 주소가 됩니다.
따라서 우리는 X-Forwarded-For 해더의 맨 앞 주소를 확인하면 클라이언트의 IP를 알 수 있습니다.</p>
<blockquote>
<p>만약 X-Forwarded-For 헤더의 필드가 비어있다면 <code>getRemoteAddr()</code> 을 통해 가져온 IP 주소가 클라이언트의 IP 주소라고 판단할 수 있습니다.</p>
</blockquote>
<br/>

<hr>
<br/>

<h3 id="spring-boot-config">Spring Boot Config</h3>
<p>Spring Boot에서 클라이언트의 IP 주소를 가져오는 방법은 크게 2가지가 있습니다.</p>
<h4 id="1-직접-x-forwarded-for-header-확인하기">1. 직접 X-Forwarded-For Header 확인하기</h4>
<p><code>request.getHeader(&quot;X-Forwarded-For&quot;)</code> 를 통해 헤더 정보를 가져온 다음 직접 파싱하여 프록시나 로드 밸런서를 통해 기록된 클라이언트의 IP가 있는지 확인할 수 있습니다.</p>
<br/>

<h4 id="2-requestgetremoteaddr-설정하기">2. request.getRemoteAddr() 설정하기</h4>
<p>또는 <code>getRemoteAddr()</code> 을 호출했을 때, X-Forwarded-For 헤더의 값을 전달할 수 있도록 설정할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/147d5412-a455-4953-aa25-1c8fc280e7f2/image.png" alt=""></p>
<p>아무것도 설정하지 않으면 기본적으로 Socket으로 연결된 IP 주소를 반환합니다.</p>
<br/>

<p><img src="https://velog.velcdn.com/images/yeonise/post/dc2b7c65-2242-40dc-9b25-a9230f926759/image.png" alt=""></p>
<p><strong>native</strong></p>
<p>컨테이너의 기본 자원을 사용하는 방식입니다.</p>
<pre><code class="language-yml">server:
  forward-headers-strategy: native</code></pre>
<p><img src="https://velog.velcdn.com/images/yeonise/post/79f132b2-e9ec-4c1b-8870-b027cef8f648/image.png" alt=""></p>
<p>아래의 <code>org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer</code> 내부의 customizeRemoteIpValue를 보면, <code>getOrDeduceUseForwardHeaders()</code> 가 true일 때 새로운 RemoteIpValue 인스턴스를 생성하는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/6dcebe9c-b9ca-4b2e-907c-4fd5bf91d1e2/image.png" alt=""></p>
<blockquote>
<p><code>getOrDeduceUseForwardHeaders()</code> 는 <strong>forward-headers-strategy</strong>가 <strong>native</strong>로 설정되어 있다면 <strong>true</strong>를 반환합니다.
<img src="https://velog.velcdn.com/images/yeonise/post/d6f074cb-f1b7-4c12-bb78-6e7329cea742/image.png" alt=""></p>
</blockquote>
<p><a href="https://tomcat.apache.org/tomcat-8.5-doc/api/org/apache/catalina/valves/RemoteIpValve.html">RemoteIpValue</a>의 내부 코드를 살펴보면 다음과 같은 필드가 정의되어 있고</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/35e70b95-ded6-4a49-b7f0-123ecd7521ba/image.png" alt=""></p>
<p><code>invoke()</code> 에서 해당 헤더를 가져와 파싱하는 과정을 거치는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/d0ba8817-3943-4d8f-aca9-b8c03995963b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/f051a9f4-1c8c-482d-86b2-e7f7b5ed54bf/image.png" alt=""></p>
<p>만약 <code>String remoteIp</code> 가 <code>null</code> 이 아니라면 <code>request.setRemoteAddr()</code> 을 통해 파싱한 결과를 설정하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/59c81e85-ab9a-4975-86e6-18f7ea269629/image.png" alt=""></p>
<br/>

<p><strong>framework</strong></p>
<p>스프링에서 제공하는 ForwardedHeaderFilter를 사용하는 방식입니다.</p>
<pre><code class="language-yml">server:
  forward-headers-strategy: framework</code></pre>
<p><img src="https://velog.velcdn.com/images/yeonise/post/60e7067a-0f3c-494c-9e07-1e93d3518c79/image.png" alt=""></p>
<blockquote>
<p><em>Extract values from &quot;Forwarded&quot; and &quot;X-Forwarded-*&quot; headers, wrap the request and response, and make they reflect the client-originated protocol and address in the following methods: . . .</em></p>
</blockquote>
<p>주석을 살펴보면 <code>Forwarded</code> 및 <code>X-Forwarded-*</code> 헤더에서 값을 추출하여 요청과 응답에 반영하는 동작을 수행함을 알 수 있습니다.</p>
<p>헤더의 범위는 기본적으로 다음과 같이 설정되어 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/d24b8e39-1e0b-4a8b-a59d-655f3fb9e15e/image.png" alt=""></p>
<p>Override한 <code>getRemoteAddr()</code> 을 살펴보면 파싱한 결과가 <code>null</code> 이 아닌 경우 파싱한 결과를 반환하고 <code>null</code> 인 경우 기존의 <code>getRemoteAddr()</code> 의 결과를 그대로 반환하도록 구현돼 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/1e8dac88-4fdb-4d8c-bc97-12d66996d9cf/image.png" alt=""></p>
<p>이는 프록시나 로드 밸러서에 의해 IP 주소가 변경되지 않고 목적지에 도착한 경우 <code>X-Forwarded-For</code> 헤더에 아무것도 기록되지 않았으므로 현재 요청을 보낸 IP 주소를 그대로 반환하기 위함입니다.</p>
<br/>

<hr>
<br/>

<h3 id="nginx-config">Nginx Config</h3>
<p>만약 서버와 같은 컴퓨터에서 Nginx를 프록시로 두고 있다면 <code>127.0.0.1</code> 이라는 클라이언트 IP 주소를 얻게 될 수 있습니다.
이는 X-Forwarded-For 헤더를 제대로 설정해 주지 않아 발생하는 문제일 가능성이 높습니다.
이때 Nginx에 다음과 같은 설정을 추가해 줍니다.</p>
<pre><code>proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;</code></pre><p><code>$proxy_add_x_forwarded_for</code> 는 클라이언트의 X-Forwarded-For 헤더의 필드에 변수 <code>$remote_addr</code> 을 쉼표로 구분하며 결합하는 동작을 수행합니다.
만약 필드가 존재하지 않는다면 변수 <code>$remote_addr</code> 를 새로 필드에 설정하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/yeonise/post/f64aa10f-b1a5-4897-b0fb-3a236001046a/image.png" alt=""></p>
<p>이렇게 모든 설정을 마치면 우리는 <code>request.getRemoteAddr()</code> 을 통해 진짜 클라이언트의 IP 주소를 가져올 수 있게 됩니다!</p>
<br/>

<hr>
<br/>

<h3 id="reference">Reference</h3>
<p><a href="https://product.kyobobook.co.kr/detail/S000001033001">HTTP 완벽 가이드</a>
<a href="https://stackoverflow.com/questions/68318269/spring-server-forward-headers-strategy-native-vs-framework">stack overflow</a>
<a href="https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/autoconfigure/web/ServerProperties.ForwardHeadersStrategy.html">ServerProperties.ForwardHeadersStrategy Javadoc</a>
<a href="https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto.webserver.use-behind-a-proxy-server">Spring Boot Reference Documentation about forwarded headers</a>
<a href="https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/">NGINX</a></p>
<br/>

<p><span style="color: gray"><em>작성자 : 김서연</em></span></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Cache 사용하기]]></title>
            <link>https://velog.io/@on-and-off/Spring-Cache-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@on-and-off/Spring-Cache-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 29 Feb 2024 02:52:05 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요.
저희 수강신청 프로젝트에서 Spring Cache 를 사용하여 성능 개선을 하기까지의 과정을 블로깅하려고 합니다.</p>
<br>

<h3 id="기존의-비즈니스-로직">기존의 비즈니스 로직</h3>
<p>수강 바구니 신청 페이지나 수강 신청 페이지를 접속할 때, 반드시 첫 페이지 강의 목록을 받아오는 HTTP 요청을 보냅니다.
따라서 첫 페이지의 강의 목록들은 조회 빈도가 높습니다.</p>
<p><em>(이때의 첫 페이지는 아무런 필터링을 사용하지 않은 강의 목록 요청의 첫 페이지입니다.)</em></p>
<p><img src="https://velog.velcdn.com/images/ghkdgus29/post/e346408e-9f1b-4e79-99e7-844b714f3271/image.png" alt=""></p>
<blockquote>
<p>수강 바구니 신청 페이지</p>
</blockquote>
<br>

<p><img src="https://velog.velcdn.com/images/ghkdgus29/post/cd1aad80-4115-48bb-a0de-6a860c14b917/image.png" alt=""></p>
<blockquote>
<p>수강 신청 페이지</p>
</blockquote>
<br>

<p>HTTP 요청을 통해 받아오는 강의 목록 API는 총 20개의 강의 정보로 구성되어 있습니다.
또한 강의 목록들은 수강 신청 기간동안 데이터가 변경되지 않습니다.
이렇게 고정된 크기의 고정된 내용을 갖는 데이터를 항상 DB에서 조회해오는 것은 큰 오버헤드라 생각하여 첫 페이지의 강의 목록들을 캐싱하기로 결정하였습니다.</p>
<br>

<h3 id="spring-cache--redis-를-사용하기-위한-환경-설정">Spring Cache + Redis 를 사용하기 위한 환경 설정</h3>
<pre><code class="language-c">dependencies {

    implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;

    // ...
}</code></pre>
<blockquote>
<p><code>build.gradle</code></p>
</blockquote>
<p>다음과 같이 spring data redis 의존성만 추가해주면 Spring Cache 를 사용할 수 있습니다.</p>
<br>

<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    private final RedisProperties redisProperties;

    @Value(&quot;${spring.data.redis.port}&quot;)
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), port);
    }

    // ...

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory cf) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))        // key 는 String 직렬화
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))        // value 는 Json 직렬화
            .entryTtl(Duration.ofMinutes(30L));        // 캐시 지속 시간은 30 분으로 설정

        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(redisCacheConfiguration).build();
    }
}</code></pre>
<blockquote>
<p><code>RedisConfig</code> 클래스</p>
</blockquote>
<p>Spring Cache 에서 사용할 캐시를 Redis 로 선택하기 위해 다음과 같이 <code>CacheManager</code> 를 구현하고 스프링 빈으로 등록하였습니다.</p>
<p>캐싱하려는 데이터가 첫 번째 페이지 강의 목록 페이지이기 때문에 객체를 JSON 으로 직렬화해주는 
<code>GenericJackson2JsonRedisSerializer</code> 를 value Serializer 로 채택하였습니다.</p>
<p><code>GenericJackson2JsonRedisSerializer</code> 의 특징은 직렬화하려는 객체의 타입을 지정해주지 않아도 된다는 편리함이 있습니다. 
객체의 타입을 지정해주지 않아도 역직렬화가 가능한 이유는 저장되는 데이터에 추가적인 필드 <code>@class</code> 덕분입니다. 해당 필드는 패키지 경로와 클래스 명을 포함하여 직렬화한 객체의 정보를 나타냅니다. 
따라서 해당 필드와 역직렬화하려는 객체의 패키지 경로 + 클래스 명이 일치하고, 필드들이 일치한다면 성공적으로 역직렬화를 할 수 있습니다.</p>
<p>그러나 이러한 편리함은 단점이 될 수도 있습니다.
캐시에 데이터를 저장한 이후, 패키지 경로가 바뀌거나 클래스 명이 바뀌는 경우 <code>@class</code> 필드와 실제 역직렬화하려는 객체의 정보가 달라질 수 있습니다. 
이 경우, 필드들이 일치하더라도 역직렬화에 실패하게 됩니다.</p>
<p>따라서 이런 불편함이 발생할 수 있는 상황이라면 <code>Jackson2JsonRedisSerializer</code> 와 같은 다른 Serializer 를 사용하는 것이 좋습니다.</p>
<p>그러나 저희 프로젝트는 마무리 단계에 접어들어 패키지 경로나 클래스명이 변할 가능성이 낮기 때문에 편의상 <code>GenericJackson2JsonRedisSerializer</code> 를 사용하였습니다.</p>
<pre><code class="language-java">@SpringBootApplication
@EnableCaching
public class CourseRegistrationSystemApplication {

    public static void main(String[] args) {
        SpringApplication.run(CourseRegistrationSystemApplication.class, args);
    }

}</code></pre>
<p>마지막으로 SpringBoot 최상단에 <code>@EnableCaching</code> 을 붙여주면 Spring Cache 를 사용하기 위한 준비가 끝납니다.</p>
<br>

<h3 id="spring-cache-적용하기---절망편">Spring Cache 적용하기 - 절망편</h3>
<pre><code class="language-java">    @Cacheable(value = &quot;lecture&quot;, condition = &quot;#lectureFilterOptions.fetchNoOption() &amp;&amp; (#pageable.getPageNumber() == 0)&quot;)
    public LectureSchedulePage fetchLectureSchedule(Pageable pageable, LectureFilterOptions lectureFilterOptions) {
        return new LectureSchedulePage(
            lectureRepository.findMatchedLectures(
                    pageable,
                    lectureFilterOptions.getOpeningYear(),
                    lectureFilterOptions.getSemester(),
                    lectureFilterOptions.getSubjectDivision(),
                    lectureFilterOptions.getDepartmentId(),
                    lectureFilterOptions.getSubjectName())
                .map(LectureDetail::new));
    }</code></pre>
<p>모든 준비를 마치고 강의 목록을 조회하는 서비스 메서드에 다음과 같이 Spring Cache를 위한 애노테이션을 사용합니다.</p>
<p><code>@Cacheable</code> 은 <code>value::key</code> 를 key 값으로 하는 데이터가 캐시에 존재하지 않는다면 해당 메서드를 실행한 후, 결과물을 <code>value::key</code> 를 key 값으로 하여 저장해둡니다.
반면 <code>value::key</code> 를 key 값으로 하는 데이터가 이미 캐시에 존재한다면 해당 메서드를 실행하지 않고, 캐시에 저장된 <code>value::key</code> 에 대응되는 value 값을 반환합니다.</p>
<p>따라서 최초의 첫 강의 목록 페이지 요청 한번에 대해서만 DB에 조회하고, 후속 요청들은 DB 조회없이 캐시에 저장된 데이터를 제공받을 수 있습니다.</p>
<p><code>@Cacheable</code> 의 <code>condition</code> 필드는 캐싱이 동작할 조건을 설정합니다. 
<code>아무런 필터링이 없는</code> <code>첫 페이지</code> 강의 목록을 조회할 때만 캐싱이 동작하도록 하였습니다.</p>
<pre><code class="language-java">@Getter
public class LectureFilterOptions {

    @NotNull
    private final Year openingYear;

    @NotNull
    private final Semester semester;

    private final SubjectDivision subjectDivision;
    private final Long departmentId;
    private final String subjectName;

    @Builder
    @Jacksonized
    private LectureFilterOptions(Year openingYear, Semester semester, SubjectDivision subjectDivision,
        Long departmentId,
        String subjectName) {
        this.openingYear = openingYear;
        this.semester = semester;
        this.subjectDivision = subjectDivision;
        this.departmentId = departmentId;
        this.subjectName = subjectName;
    }

    public boolean fetchNoOption() {
        return (subjectDivision == null) &amp;&amp; (departmentId == null) &amp;&amp; (subjectName == null);
    }

}</code></pre>
<blockquote>
<p><code>LectureFilterOptions</code> 클래스의 <code>fetchNoOption</code> 메서드는 아무런 필터 조건이 없을 경우 true 를 반환한다.</p>
</blockquote>
<br>

<p>이렇게 준비를 마쳤다고 생각해 서버를 돌려보았는데, DB 조회 로그가 매 요청마다 발생하였습니다.</p>
<p>그래서 캐싱이 제대로 되고있나 확인하기 위해 redis 에 저장된 key 들을 확인해보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/ghkdgus29/post/7de7a00d-b09c-4914-a5d8-72199c957073/image.png" alt=""></p>
<blockquote>
<p>매 요청마다 새로운 key 값으로 데이터를 캐싱한다.</p>
</blockquote>
<br>

<h3 id="spring-cache-적용하기---희망편">Spring Cache 적용하기 - 희망편</h3>
<p>제가 <code>@Cacheable</code> 의 key 가 저장되는 방식을 제대로 이해하지 못해 다음과 같은 문제가 발생한 것이었습니다.</p>
<p>저는 <code>@Cacheable</code> 에서 <code>key</code> 필드를 생략하면 key가 생기지 않을거라 생각했습니다. 
그러나 실제로는 <code>key</code> 필드를 생략하면 <code>@Cacheable</code> 의 default key generator인 SimpleKeyGenerator 가 동작하여 자동으로 key를 생성하였습니다.</p>
<p>SimpleKeyGenerator 는 파라미터들의 정보를 바탕으로 <code>toString()</code> 을 호출해 key를 생성하기 때문에 <code>LectureFilterOptions</code> 와 같이 객체가 파라미터로 넘어오는 경우, 조건이 같아도 객체가 달라 생성된 key 값이 달라지게 됩니다. 따라서 저장되는 value 는 같지만 key 값이 달라 매 요청마다 DB 조회 발생 및 캐시 저장이 이루어지게 된 것입니다.</p>
<p>따라서 이러한 문제를 해결하기 위해선 모든 <code>필터링 없는 첫 페이지 강의 목록 요청</code> 이 같은 key 값을 만들도록 설정해주면 됩니다.</p>
<p>여러가지 해결 방법이 있습니다.</p>
<ol>
<li>Custom Key Generator 를 생성하고, 해당 Key Generator 가 동일한 key 값을 만들도록 설정한다.</li>
<li><code>LectureFilterOption</code> 의 ToString 값을 필드값에 따라 생성되도록 재정의한다.</li>
<li><code>key</code> 필드에 상수를 넣는다.</li>
</ol>
<p>가장 간단한 방법은 3번이라고 생각해 <code>key</code> 필드에 상수를 넣는 방식으로 문제를 해결하였습니다.</p>
<pre><code class="language-java">    @Cacheable(value = &quot;lecture&quot;, key = &quot;T(site.courseregistrationsystem.util.ProjectConstant).LECTURE_NO_OPTION_FIRST_PAGE&quot;,
        condition = &quot;#lectureFilterOptions.fetchNoOption() &amp;&amp; (#pageable.getPageNumber() == 0)&quot;)
    public LectureSchedulePage fetchLectureSchedule(Pageable pageable, LectureFilterOptions lectureFilterOptions) {
        return new LectureSchedulePage(
            lectureRepository.findMatchedLectures(
                    pageable,
                    lectureFilterOptions.getOpeningYear(),
                    lectureFilterOptions.getSemester(),
                    lectureFilterOptions.getSubjectDivision(),
                    lectureFilterOptions.getDepartmentId(),
                    lectureFilterOptions.getSubjectName())
                .map(LectureDetail::new));
    }</code></pre>
<blockquote>
<p><code>key = &quot;T(site.courseregistrationsystem.util.ProjectConstant).LECTURE_NO_OPTION_FIRST_PAGE&quot;</code> 를 추가함으로써 상수를 사용한다.</p>
</blockquote>
<br>

<p>이때 SpringEL 문법에서는 상수 클래스 내의 상수를 가져오기위해 다음과 같이 패키지명을 작성해주어야 합니다.</p>
<br>

<p><img src="https://velog.velcdn.com/images/ghkdgus29/post/c7580c9d-e8a6-470d-823d-a05a3a1d2c64/image.png" alt=""></p>
<blockquote>
<p>최초의 요청만 <code>lecture::firstPage</code> key 값으로 데이터를 캐싱한다.</p>
</blockquote>
<br>

<h3 id="성능-측정-결과">성능 측정 결과</h3>
<p>성능 측정은 1000명의 user 가 한 번의 첫 페이지 강의 목록 조회 요청을 하는 상황으로 스크립트를 작성하였습니다.</p>
<ul>
<li>캐시 적용 전
<img src="https://velog.velcdn.com/images/ghkdgus29/post/cac4eafc-796e-4b75-a362-d3603c957cfa/image.png" alt=""><blockquote>
<p>25.12 초의 평균 응답 시간</p>
</blockquote>
</li>
</ul>
<br>

<ul>
<li>캐시 적용 후
<img src="https://velog.velcdn.com/images/ghkdgus29/post/5b5136e3-2ecb-41b3-9547-c273842c058e/image.png" alt=""><blockquote>
<p>1.01 초의 평균 응답 시간</p>
</blockquote>
</li>
</ul>
<br>

<p>비록 필터링 없는 첫 페이지의 강의 목록 조회에 대한 성능 개선만 이루어졌다는 점이 아쉽지만, 해당 API 요청이 가장 많다는 점을 고려하면 엄청난 성능 개선임을 알 수 있습니다.
데이터의 변경이 없는 상황에서의 적절한 캐싱은 엄청난 성능 개선을 만들어 낼 수 있음을 배울 수 있었습니다.</p>
<p>성능 개선을 위한 캐싱은 선택이 아닌 필수임을 느꼈습니다.</p>
<p><em>작성자: Hyun</em></p>
<br>

<blockquote>
<p>출처</p>
<blockquote>
<p><a href="https://hyeri0903.tistory.com/237">https://hyeri0903.tistory.com/237</a></p>
<p><a href="https://karla.tistory.com/18">https://karla.tistory.com/18</a></p>
<p><a href="https://jgrammer.tistory.com/entry/%EB%AC%B4%EC%8B%A0%EC%82%AC-watcher-Cacheable-%EC%A4%91%EB%B3%B5%EB%90%98%EB%8A%94-key%EA%B0%92-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%A0%EA%B9%8C">https://jgrammer.tistory.com/entry/%EB%AC%B4%EC%8B%A0%EC%82%AC-watcher-Cacheable-%EC%A4%91%EB%B3%B5%EB%90%98%EB%8A%94-key%EA%B0%92-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%A0%EA%B9%8C</a></p>
<p><a href="https://stackoverflow.com/questions/13381731/caching-with-multiple-keys">https://stackoverflow.com/questions/13381731/caching-with-multiple-keys</a></p>
<p><a href="https://velog.io/@hkyo96/Spring-RedisTemplate-Serializer-%EC%84%A4%EC%A0%95">https://velog.io/@hkyo96/Spring-RedisTemplate-Serializer-%EC%84%A4%EC%A0%95</a></p>
</blockquote>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[spring data redis 사용 개선]]></title>
            <link>https://velog.io/@on-and-off/spring-data-redis-%EC%82%AC%EC%9A%A9-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@on-and-off/spring-data-redis-%EC%82%AC%EC%9A%A9-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Fri, 16 Feb 2024 12:05:00 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/on-and-off/post/31b7fe65-beab-4340-be08-e8fab28f07a0/image.png" alt=""></p>
<p>안녕하세요. 
저희 수강신청 프로젝트내에서, 수강신청 가능 기간은 저장할 내용이 한정적이기에 spring data redis 를 사용하여 메모리에 저장하였습니다.
잘 알지못하고 적용한 redis 였기에 로직상으로 불필요하고 지저분한 부분이 많았었는데 이를 개선해나가는 과정과, 개선해나가는 과정에서 배운 내용들을 블로깅하려고 합니다.</p>
<h3 id="기존의-비즈니스-로직">기존의 비즈니스 로직</h3>
<p>우선 리팩토링전의 코드들을 설명하겠습니다.</p>
<pre><code class="language-java">@RedisHash(&quot;enrollmentRegistrationPeriod&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class EnrollmentRegistrationPeriod {

    @Id
    private String targetGrade;
    private LocalDateTime startTime;
    private LocalDateTime endTime;

    @Builder
    private EnrollmentRegistrationPeriod(Grade targetGrade, LocalDateTime startTime, LocalDateTime endTime) {
        this.targetGrade = targetGrade.name();
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public boolean isWithinTimeRange(LocalDateTime now) {
        return startTime.compareTo(now) &lt;= 0 &amp;&amp; now.compareTo(endTime) &lt;= 0;
    }

}</code></pre>
<blockquote>
<p>redis 에 저장될 해쉬, 수강신청 기간을 나타낸다.</p>
</blockquote>
<br>

<pre><code class="language-java">public interface EnrollmentRegistrationPeriodStorage extends CrudRepository&lt;EnrollmentRegistrationPeriod, String&gt; {
}</code></pre>
<blockquote>
<p>redis CRUD를 편하게 사용하기 위한 추상화된 리포지토리 인터페이스</p>
</blockquote>
<br>

<p>이처럼 spring data 진영에서 제공하는 CrudRepository 와 @RedisHash 를 사용함으로써, 쉽게 객체를 redis 에 해쉬로 저장할 수 있었습니다.
그러나 CrudRepository 를 쓰면서 생긴 단점도 있었습니다.</p>
<pre><code class="language-java">    public RegistrationDate validateEnrollmentRegistrationPeriod(LocalDateTime now, Grade grade) {
        EnrollmentRegistrationPeriod registrationPeriodInGrade = enrollmentRegistrationPeriodStorage.findById(grade.name())
            .orElseThrow(EnrollmentRegistrationPeriodNotFoundException::new);

        CurrentYearAndSemester currentYearAndSemester = clockService.fetchCurrentClock();
        if (registrationPeriodInGrade.isWithinTimeRange(now)) {
            return new RegistrationDate(currentYearAndSemester);
        }

        EnrollmentRegistrationPeriod registrationPeriodInCommon = enrollmentRegistrationPeriodStorage.findById(Grade.COMMON.name())
            .orElseThrow(CommonEnrollmentRegistrationPeriodNotFoundException::new);

        if (registrationPeriodInCommon.isWithinTimeRange(now)) {
            return new RegistrationDate(currentYearAndSemester);
        }

        throw new InvalidEnrollmentTimeException();
    }</code></pre>
<blockquote>
<p>학생이 현재 수강신청을 하려는 시간이 (<code>now</code>), 적절한 수강신청 기간내의 시간인지를 검증하는 메서드</p>
</blockquote>
<br>

<p>CrudRepository 를 사용함으로써 생긴 단점을 설명하기 전에 저희 비즈니스 로직을 먼저 설명하겠습니다.</p>
<ol>
<li><p>수강신청을 하려는 학생의 학년과, 현재 시간을 바탕으로 수강신청 가능 기간인지 검증을 시작합니다.</p>
</li>
<li><p>우선, 학생의 학년을 key 로 하는 hash-value (학년 전용 수강 신청 기간) 을 가져옵니다.
2-1. 만약 학생의 학년을 key 로 하는 hash-value 가 없다면 예외를 던집니다.</p>
</li>
<li><p>현재 시간이 앞서 구한 학년 전용 수강 신청 기간내의 시간인지 검증합니다.
3-1. 검증을 성공하면, 검증 로직을 정상 종료합니다.</p>
</li>
<li><p>현재 시간이 학년 전용 수강 신청 기간이 아니므로, COMMON 을 key 로 하는 hash-value (공통 수강 신청 기간) 을 가져옵니다.
4-1. 만약 COMMON 을 key 로 하는 hash-value 가 없다면 예외를 던집니다.</p>
</li>
<li><p>현재 시간이 공통 수강 신청 기간내의 시간인지 검증합니다.
5-1. 검증을 성공하면, 검증 로직을 정상 종료합니다.</p>
</li>
<li><p>만약 검증이 정상 종료되지 않았다면, 현재 시간이 어떠한 수강 신청 기간에도 걸리지 않는 시간이므로 예외를 던집니다.</p>
</li>
</ol>
<br>

<p>말로 정리하니 더욱 복잡합니다. 
<strong>사실, redis 에 저장된 수강 신청 기간을 가져올 때, 학년 전용 수강 신청 기간과 공통 수강 신청 기간을 한번에 가져온 뒤, 한번에 검증을 진행한다면 이해하기 쉬울 것입니다.</strong>
그러나 CrudRepository 를 사용하기 때문에 세세한 쿼리를 할 수 없고 findById 로 수강 신청 기간을 하나씩 가져오다보니 로직이 이해하기 어려워졌습니다.</p>
<p>문제는 이뿐만이 아니었습니다.
이러한 비즈니스 로직으로 인해, 예외 발생이 일관적이지 않은 문제가 있었습니다.</p>
<ul>
<li>학생이 수강신청을 진행하는 시간대가 학생 수강 신청 기간 범위에 부합하지 않고</li>
<li>공통 수강 신청 기간이 redis 에 저장되지 않았다면</li>
</ul>
<p>공통 수강 신청이 존재하지 않는다는 예외가 발생합니다.</p>
<p>그러나 </p>
<ul>
<li>학생이 수강신청을 진행하는 시간대가 학생 수강 신청 기간 범위에 부합한다면</li>
<li>공통 수강 신청 기간이 redis 에 저장되지 않았더라도</li>
</ul>
<p>공통 수강 신청이 존재하지 않는다는 예외가 발생합니다.</p>
<p>이처럼 예외 발생의 일관성이 없고, 비즈니스 로직이 헷갈려 이를 좀 더 직관적으로 개선해보고자 리팩토링을 마음먹게 되었습니다.</p>
<br>

<h3 id="redistemplate-과-파이프라이닝">RedisTemplate 과 파이프라이닝</h3>
<p>redis 에 저장된 수강 신청 기간 중, 학년 전용 수강 신청 기간과 공통 수강 신청 기간을 한번에 가져오는 것이 리팩토링의 목표였습니다.
특정 수강 신청 2개만 가져오는 로직을 CrudRepository 로는 하기 힘들다 판단하여 RedisTemplate 을 사용하였습니다.</p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class EnrollmentRegistrationPeriodService {

    // ...

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

    // ...

}</code></pre>
<blockquote>
<p>서비스 클래스에 <code>RedisTemplate</code> 추가</p>
</blockquote>
<br>

<p>redis 에 파이프라이닝을 통해 특정 수강 신청들만 가져오는 로직을 구현할 수 있다고 판단하여 다음과 같이 비즈니스 로직을 수정하였습니다.</p>
<pre><code class="language-java">    public RegistrationDate validateEnrollmentRegistrationPeriod(LocalDateTime now, Grade grade) {
        ObjectMapper objectMapper = new ObjectMapper();

        redisTemplate.executePipelined(
                (RedisCallback&lt;Object&gt;)connection -&gt; {
                    StringRedisConnection stringRedisConnection = (StringRedisConnection)connection;
                    stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + grade.name());
                    stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + Grade.COMMON.name());
                    return null;
                }
            ).stream()
            .map(lhm -&gt; objectMapper.convertValue(lhm, EnrollmentRegistrationPeriod.class))
            .filter(period -&gt; period.isWithinTimeRange(now))
            .findAny()
            .orElseThrow(InvalidEnrollmentTimeException::new);

        CurrentYearAndSemester currentYearAndSemester = clockService.fetchCurrentClock();
        return new RegistrationDate(currentYearAndSemester);
    }</code></pre>
<blockquote>
<p>바뀐 비즈니스 로직</p>
</blockquote>
<br>

<p><code>redisTemplate.executePipelined(...)</code> 내부에 한번에 보내고자 하는 redis 명령어 파이프라인을 만듦으로써 한번에 실행할 수 있습니다.
이 메서드의 return 값들은 <code>List&lt;LinkedHashMap&lt;String, String&gt;&gt;</code> 으로 redis 에 저장된 각 hash-value 의 필드명-필드값들이 하나의 LinkedHashMap 이 되어 리스트를 이루게 됩니다.</p>
<p>LinkedHashMap 으로는 현재 시간이 수강 신청 기간에 부합하는지 검증할 수 없으므로 LinkedHashMap 을 <code>EnrollmentRegistrationPeriod</code> 객체로 변환하기 위해 ObjectMapper 를 생성 후 사용했습니다.
filter 를 이용하여 검증에 부합하는 수강 신청 기간이 단 하나라도 남는다면 현재 시간은 수강 신청이 가능한 기간이므로 검증을 통과합니다.
반면, 남는 수강 신청 기간이 하나도 없다면 현재 시간은 수강 신청이 가능하지 않은 기간이므로 예외를 던집니다.</p>
<p>훨씬 로직이 깔끔하고 직관적으로 변했습니다.
그러나 위의 비즈니스 코드는 정상적으로 동작하지 않습니다. </p>
<br>

<h3 id="기본설정의-objectmapper-와-springboot의-objectmapper">기본설정의 ObjectMapper 와 SpringBoot의 ObjectMapper</h3>
<p>위의 비즈니스 코드는 런타임 에러를 발생시킵니다.
런타임 에러가 발생하는 이유는 2가지입니다.</p>
<ol>
<li>기본설정의 ObjectMapper 는 입력값이 deserialize 하려는 객체 타입에게는 없는 필드를 갖고있다면 에러를 던진다.</li>
</ol>
<pre><code class="language-java">@RedisHash(&quot;enrollmentRegistrationPeriod&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class EnrollmentRegistrationPeriod {

    @Id
    private String targetGrade;
    private LocalDateTime startTime;
    private LocalDateTime endTime;

    @Builder
    private EnrollmentRegistrationPeriod(Grade targetGrade, LocalDateTime startTime, LocalDateTime endTime) {
        this.targetGrade = targetGrade.name();
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public boolean isWithinTimeRange(LocalDateTime now) {
        return startTime.compareTo(now) &lt;= 0 &amp;&amp; now.compareTo(endTime) &lt;= 0;
    }

}</code></pre>
<blockquote>
<p>redis 에 저장될 해쉬, 수강 신청 기간을 나타낸다.</p>
</blockquote>
<br>

<p>redis 에 저장될 수강 신청 기간 클래스를 다시한번 보겠습니다.
수강 신청 기간을 저장할 때 CrudRepository 를 사용해 @RedisHash 를 통째로 저장하면 redis 에 어떻게 저장될까요?
redis 에 저장되는 하나의 hash-value 가 갖는 필드들은 다음과 같습니다.</p>
<ul>
<li>targetGrade</li>
<li>startTime</li>
<li>endTime</li>
<li><strong>_class</strong></li>
</ul>
<p>_class 필드는 @RedisHash 가 붙은 클래스의 패키지 정보와 클래스 명이 저장됩니다.
이처럼 _class 필드 때문에 deserialize 하기 위한 입력값인 LinkedHashMap 에는 _class 필드가 추가됩니다.</p>
<p>반면 deserialize 의 결과값인 <code>EnrollmentRegistrationPeriod</code> 클래스에는 _class 라는 필드는 없으니 런타임 예외가 발생합니다.</p>
<p>이를 해결하기 위해서는 redis 에 hash-value 를 저장할 때, _class 필드를 저장하지 않거나, _class 필드를 deserialize 할 때 무시하는 방법이 있습니다.</p>
<br>

<ol start="2">
<li>기본설정의 ObjectMapper 는 LocalDateTime 필드를 serialize/deserialize 할 수 없다.</li>
</ol>
<p><code>EnrollmentRegistrationPeriod</code> 클래스는 수강 신청 기간 정보를 가져야하기 때문에 LocalDateTime 필드를 갖고 있습니다.</p>
<p>그러나 기본설정의 ObjectMapper 는 LocalDateTime 필드를 serialize/deserialize 할 수 없으니 런타임 예외가 발생합니다.</p>
<p>이를 해결하기 위해서는 ObjectMapper 에 시간 변환을 위한 모듈을 추가해주어야 합니다.</p>
<br>

<p>앞서 런타임 예외가 발생한 문제점과 해결방안들을 살펴보았습니다. 
각각의 문제점 해결을 위한 해결방안들을 적용해주어도 되지만, 스프링 부트 환경에서는 이 문제들을 더 쉽고 간편하게 한번에 해결할 수 있습니다.</p>
<p>바로 스프링 컨테이너에서 주입해주는 ObjectMapper 를 사용하는 것입니다.</p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class EnrollmentRegistrationPeriodService {

    // ...

    private final RedisTemplate&lt;String, String&gt; redisTemplate;
    private final ObjectMapper objectMapper;

    // ...

    public RegistrationDate validateEnrollmentRegistrationPeriod(LocalDateTime now, Grade grade) {
        redisTemplate.executePipelined(
                (RedisCallback&lt;Object&gt;)connection -&gt; {
                    StringRedisConnection stringRedisConnection = (StringRedisConnection)connection;
                    stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + grade.name());
                    stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + Grade.COMMON.name());
                    return null;
                }
            ).stream()
            .map(lhm -&gt; objectMapper.convertValue(lhm, EnrollmentRegistrationPeriod.class))
            .filter(period -&gt; period.isWithinTimeRange(now))
            .findAny()
            .orElseThrow(InvalidEnrollmentTimeException::new);

        CurrentYearAndSemester currentYearAndSemester = clockService.fetchCurrentClock();
        return new RegistrationDate(currentYearAndSemester);
    }</code></pre>
<blockquote>
<ul>
<li>최종적으로 완성된 비즈니스 로직</li>
</ul>
</blockquote>
<ul>
<li>다음과 같이 스프링 컨테이너에서 주입해주는 ObjectMapper 를 사용함으로써 앞서 런타임 에러들을 해결할 수 있다.</li>
</ul>
<br>

<p>스프링 컨테이너에 있는 ObjectMapper 는 기본설정의 ObjectMapper 와 무엇이 다를까요?
ObjectMapper 는 여러가지 옵션을 주어 커스터마이징을 할 수 있습니다.
스프링 부트 내부에서는 ObjectMapper 를 생성할 때, 앞선 1번, 2번 문제들을 해결하는 세팅을 포함한 여러가지 세팅을 합니다.</p>
<pre><code class="language-java"> configureFeature(objectMapper, DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);</code></pre>
<blockquote>
<p>입력값을 Object 로 변환하는 deserialize 시에 Object 에 없는 필드가 있어도 에러를 발생시키지 않는다. 이러한 세팅이 1번 문제를 해결한다.</p>
</blockquote>
<br>

<pre><code class="language-java">    Class&lt;? extends Module&gt; javaTimeModuleClass = (Class&lt;? extends Module&gt;)
            ClassUtils.forName(&quot;com.fasterxml.jackson.datatype.jsr310.JavaTimeModule&quot;, this.moduleClassLoader);
    Module javaTimeModule = BeanUtils.instantiateClass(javaTimeModuleClass);
    modulesToRegister.set(javaTimeModule.getTypeId(), javaTimeModule);</code></pre>
<blockquote>
<p>LocalDateTime 과 같은 시간 변환을 가능하게 해주는 모듈을 추가한다. 이러한 세팅이 2번 문제를 해결한다.</p>
</blockquote>
<br>

<p>지금까지 spring data redis 를 사용한 수강 신청 기간을 검증하는 로직을 개선하는 과정과, 그 과정에서 공부한 ObjectMapper 에 대해 설명하였습니다.</p>
<p>잘 알지 못하는 상태에서 도입하는 기술들은 지저분한 코드를 만듭니다. 
이렇게 쌓인 지저분한 코드들은 부채가 되어 마음한켠에 찝찝한 마음을 남기곤 했습니다.
이번에 리팩토링 기간을 길게 둔 덕분에, 잘 몰랐던 기술들을 다시금 차분히 공부해나가면서 리팩토링을 할 시간이 생겼는데 처음엔 막막했지만 조금씩 개선해나가면서 뿌듯함과 재미를 느낄 수 있어 뜻깊은 시간이었습니다.</p>
<p><em>작성자: Hyun</em></p>
<br>

<blockquote>
<p>출처</p>
<blockquote>
<p><a href="https://docs.spring.io/spring-data/redis/reference/redis/pipelining.html">https://docs.spring.io/spring-data/redis/reference/redis/pipelining.html</a></p>
<p><a href="https://green-bin.tistory.com/63">https://green-bin.tistory.com/63</a></p>
<p><a href="https://sabarada.tistory.com/236">https://sabarada.tistory.com/236</a></p>
</blockquote>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis를 사용하여 동시성 문제 해결하기]]></title>
            <link>https://velog.io/@on-and-off/Redis%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-fmo9ufhx</link>
            <guid>https://velog.io/@on-and-off/Redis%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-fmo9ufhx</guid>
            <pubDate>Wed, 14 Feb 2024 09:04:18 GMT</pubDate>
            <description><![CDATA[<p><span style="color: gray"><em>수강 신청 상황에서 발생한 동시성 문제를 해결한 과정을 기록한 글입니다.</em></span></p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/36c92db1-d9c4-464b-a192-7efce66c5aec/image.png" alt=""></p>
<p>프로젝트 중간 점검을 마치고 기능 개선을 시작한 첫 주였습니다.
그 중에서 수강 신청 검증 단계에서 발생한 동시성 문제를 해결한 과정을 소개합니다. 😊</p>
<br />

<h3 id="1-강의-정원을-초과해서-수강할-수-있다고">(1) 강의 정원을 초과해서 수강할 수 있다고?</h3>
<p>현재 수강 신청을 위한 검증 단계에서 <strong>정원을 초과하여 신청하려고 하는지 확인</strong>하는 코드는 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/0fbbe707-968b-4bb0-b287-20df2c275ec9/image.png" alt=""></p>
<p><code>enrollment</code> 는 강의 수강 신청에 성공하면 생기는 데이터입니다. 강의의 <code>enrollment</code> 개수를 <code>count</code> 해서 요청한 학생이 강의를 수강해도 정원을 초과하지 않는지 확인합니다.</p>
<p>위 코드가 제대로 된 검증을 수행하는지 테스트를 돌려서 확인해 보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/49b515dc-481f-45f6-ba89-26cf0ccfdea1/image.png" alt=""></p>
<p>정원이 <strong>30명</strong>인 강의에 대한 <strong>100개</strong>의 수강 신청 요청 스레드를 생성하여 수행한 결과,</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/643ef315-b8c7-4d6d-9eb3-6f509d5c7673/image.png" alt=""></p>
<p>위와 같이 테스트가 실패하는 것으로 나타났습니다.</p>
<p>즉, Race condition 문제가 발생하며 30명보다 더 많은 학생이 수강 신청에 성공하게 된 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/53b1d1a4-400d-457c-8a49-56c6602c953d/image.png" alt=""></p>
<br />

<h3 id="2-정원에-맞춰서-신청할-수-있도록-제대로-된-검증-구현하기">(2) 정원에 맞춰서 신청할 수 있도록 제대로 된 검증 구현하기</h3>
<p>Race condition 문제를 해결하기 위해 데이터베이스에 Lock을 걸어서 순차적으로 요청을 처리할 수 있지만,
이 방법은 Lock이 걸린 범위에 대한 모든 접근이 불가하므로 수많은 요청을 처리하는 데 다소 많은 시간이 소요됩니다.</p>
<p>수강 신청이라는 단기간에 많은 사용자가 몰리는 이벤트 상황에 적합하지 않다고 판단하여 Redis를 사용해서 Lock을 구현하기로 합니다. 데이터베이스에 직접 Lock을 거는 것이 아니라 외부에서 Lock을 관리하기 때문에 해당 Lock 때문에 다른 비즈니스 로직에 부수 효과가 발생하는 것을 방지할 수 있습니다.</p>
<p>Spring Boot Redis의 기본 클라이언트인 Lettuce는 Spin Lock 방식으로 동작하기 때문에 많은 재요청이 필요한 수강 신청 상황에서는 Redis에 예상치 못한 부하를 발생시킬 수 있습니다. 따라서 최종적으로 <strong>pub-sub</strong> 방식의 Lock을 제공하는 <strong>Redisson</strong> 라이브러리를 사용하기로 결정했습니다.</p>
<p><a href="https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter">Redisson GitHub</a></p>
<br />

<p>Lock의 <strong>key</strong>는 강의의 PK 값인 <code>lectureId</code> 로 지정하였습니다. 또한 <code>lectureId</code> <strong>or</strong> <code>lectureNumber</code> 를 사용하여 수강 신청 요청을 받고 있었기 때문에 <code>lectureNumber</code> 로 들어온 요청도 <code>findByNumber()</code> 를 통해 <code>lectureId</code> 를 읽어와서 Lock의 key로 사용하도록 하여 서로 다른 요청에도 정합성 문제가 발생하지 않도록 했습니다.</p>
<br />

<blockquote>
<p><strong>수정 전 EnrollmentService</strong></p>
<p>EnrollmentService에서 <code>lectureId</code> 와 <code>lectureNumber</code> 각각의 요청에 따른 전처리를 한 다음 공통된 private <code>enroll()</code> 메서드를 사용하여 작업을 수행합니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/5ea6802e-75f9-4607-8baa-41df4845ddd9/image.png" alt=""></p>
</blockquote>
<blockquote>
<p><strong>새로 생성한 RedissonEnrollmentLockFacade</strong></p>
<p>RedissonEnrollmentLockFacade를 생성하여 <code>lectureId</code> 와 <code>lectureNumber</code> 각각의 Lock 획득 Facade를 구현하고 EnrollmentService에게 <code>enroll()</code> 수행을 요청합니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/c013484e-852e-467e-9307-40b28ecbf12d/image.png" alt=""></p>
</blockquote>
<blockquote>
<p><strong>수정 후 EnrollmentService</strong></p>
<p>기존 private <code>enroll()</code> 메서드를 public으로 개방하고 <code>lectureId</code> 와 <code>lectureNumber</code> 각각의 요청에 대한 인터페이스는 제거했습니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/35b69d3f-41da-472a-951d-87a424d1312a/image.png" alt=""></p>
</blockquote>
<br />

<h3 id="3-lock-구현-후-테스트-결과-확인하기">(3) Lock 구현 후 테스트 결과 확인하기</h3>
<p>이제 수정된 코드로 동일한 테스트를 수행합니다.</p>
<p><strong>try</strong> 내부의 <code>enrollmentService.enrollLecture()</code> 를 <code>redissonEnrollmentLockFacade.enrollLecture()</code> 로 변경한 다음 테스트를 수행한 결과,</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/06491fe1-7816-42d3-afb5-ba8ec4482729/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/4e05aaaa-3606-4665-877b-4b74002a7770/image.png" alt=""></p>
<p>AssertionError가 발생하지 않고 정상적으로 종료되는 것을 확인할 수 있었습니다.</p>
<br />

<p><code>lectureId</code> 와 <code>lectureNumber</code> 요청이 함께 들어온 경우에도 동시성 문제가 발생하지 않는지 확인하기 위한 테스트도 작성해보았습니다.
번갈아가면서 <code>lectureId</code> 와 <code>lectureNumber</code> 를 사용하여 수강 신청 요청을 보내도록 했고 테스트 결과,</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/c5a89c24-ced8-4567-8755-4f6677300dc4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/cdb78fd3-2ced-43e3-a269-b9730edbfb9d/image.png" alt=""></p>
<p>문제없이 동작하는 것을 확인할 수 있었습니다.</p>
<br />

<h3 id="마무리">마무리</h3>
<p>Lock을 구현하는 과정에서 Facade라는 레이어를 추가하면서 JPA Transaction 범위에 따른 초기화 문제가 생기기도 했습니다.
해당 문제에 대해 <a href="https://cantcoding.tistory.com/78">아티클</a>을 참고하였고, 필요한 연관 관계를 Fetch Join을 사용하여 한 번에 조회함으로써 해결하였습니다.</p>
<p>또한 동시성 테스트를 통해 정합성 문제가 발생하는 것을 직접 확인함으로써 멀티 스레딩 프로그램에서 자원에 대한 접근을 관리하는 것의 중요성을 실감할 수 있었습니다.</p>
<p>이로써 과정을 모두 소개했습니다. 이어서 Redis를 캐시로 활용하여 성능을 개선해보는 작업을 해보려고 합니다.</p>
<p>긴 글 읽어주셔서 감사합니다 🙂</p>
<br />

<p><span style="color: gray"><em>작성자 : 김서연</em></span></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[수강 신청 프로젝트 중간 점검]]></title>
            <link>https://velog.io/@on-and-off/%EC%88%98%EA%B0%95-%EC%8B%A0%EC%B2%AD-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A4%91%EA%B0%84-%EC%A0%90%EA%B2%80</link>
            <guid>https://velog.io/@on-and-off/%EC%88%98%EA%B0%95-%EC%8B%A0%EC%B2%AD-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A4%91%EA%B0%84-%EC%A0%90%EA%B2%80</guid>
            <pubDate>Tue, 06 Feb 2024 13:07:30 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, mujik-tigers입니다.
수강 신청 프로젝트를 시작하고 약 6주가 지난 지금까지의 진행 상황을 점검해 보려고 합니다.</p>
<br/>

<h3 id="프로젝트-진행-상황">프로젝트 진행 상황</h3>
<p>현재 목표로 했던 모든 API를 완성했고 Vue를 사용하여 API 연동까지 끝마쳤습니다.
수강 신청 API 서버에서 제공하는 기능들은 다음과 같습니다.</p>
<ul>
<li>쿠키와 세션을 사용한 로그인</li>
<li>학생, 학과 목록, 강의 정보, 수강 신청 기간 조회</li>
<li>수강 바구니와 수강 신청 내역 조회</li>
<li>수강 바구니 담기</li>
<li>수강 신청</li>
<li>이를 수행하는 데 필요한 검증들</li>
</ul>
<p>자세한 API 문서는 <a href="https://course-registration-system.site/docs/index.html">링크</a>를 확인해 주세요.</p>
<br/>

<h3 id="api-성능-측정">API 성능 측정</h3>
<p>API의 평균 응답 속도를 측정하기 위해 K6를 사용하여 테스트를 진행했습니다.
학년별로 수강 신청을 진행함을 고려하여 1,000명의 가상 사용자를 설정하였습니다.</p>
<blockquote>
<p><em>DB에 저장한 데이터의 수는 다음과 같습니다.</em></p>
<p><em>학생 : 5,000 (학년 당 1,000명, 4학년은 2,000명)</em>
<em>학과 : 54</em>
<em>교수 : 600</em>
<em>과목 : 3,000</em>
<em>강의 : 10,000</em></p>
</blockquote>
<h4 id="성능-측정-결과">성능 측정 결과</h4>
<p>API의 평균 <code>latency</code> 결과입니다. <em>(AWS t2 micro ubuntu server)</em></p>
<table>
<thead>
<tr>
<th align="center">API</th>
<th align="center">latency</th>
<th align="center">note</th>
</tr>
</thead>
<tbody><tr>
<td align="center">현재 학기 및 년도 조회</td>
<td align="center">93.56ms</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">현재 서버 시간 조회</td>
<td align="center">82.33ms</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">남은 세션 시간 조회</td>
<td align="center">41.96ms</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">강의 목록 조회</td>
<td align="center">5.14s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 신청 인원 조회</td>
<td align="center">3s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 바구니 신청 인원 조회</td>
<td align="center">1s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 바구니 목록 조회</td>
<td align="center">5s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 바구니 신청</td>
<td align="center">1s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 바구니 삭제</td>
<td align="center">1s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 신청 기간 조회</td>
<td align="center">1.36s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">남은 세션 시간 연장</td>
<td align="center">1s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">로그인</td>
<td align="center">39.48s</td>
<td align="center">36.9% 의 응답은 time-out</td>
</tr>
<tr>
<td align="center">학생 정보 조회</td>
<td align="center">741.44ms</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">학과 조회</td>
<td align="center">1.39s</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">수강 신청</td>
<td align="center">5.99s</td>
<td align="center">5.37% 의 응답은 time-out</td>
</tr>
<tr>
<td align="center">강의 번호를 입력하는 빠른 수강 신청</td>
<td align="center">9.53s</td>
<td align="center">7.65% 의 응답은 time-out</td>
</tr>
<tr>
<td align="center">수강 신청 내역 조회</td>
<td align="center">1.07s</td>
<td align="center"></td>
</tr>
</tbody></table>
<p><br/><br/></p>
<h3 id="중간-점검-결과">중간 점검 결과</h3>
<p>MySql을 방문하는 대부분의 API는 매우 느린 응답 속도를 보였고 일부는 모든 요청을 처리하지 못하고 연결이 끊어지기도 했습니다.</p>
<p>수강 신청 인원 수를 통한 수강 신청 가능 여부 검증 단계에서는 Race condition 문제가 발생하여 강의의 정원을 초과하여 신청된 경우도 있었습니다.</p>
<h4 id="개선사항">개선사항</h4>
<p>앞으로 약 한 달동안 기능을 구현하면서 생긴 궁금증과 아쉬운 점들 중 
몇 가지를 집중적으로 개선하고 과정을 블로그에 기록할 예정입니다.</p>
<ul>
<li>Spring Boot에서 Redis 사용의 Best practice 탐구하기</li>
<li>서비스 간의 의존 관계 구조 개선하기</li>
<li>세션 연장 시에 새로운 세션을 만들지 않고 TTL만 초기화하기</li>
<li>API 변경이 있을 때, 촘촘한 테스트가 큰 오버헤드가 되는데, 어떻게 하면 이를 개선할 수 있을지 고민해보기</li>
<li>서비스 간의 의존 관계가 있는 경우, 테스트 코드에서 Spy 처리 vs given 절에서 전부 준비하기</li>
<li>데이터베이스 읽기 성능 개선하기</li>
<li>캐시를 사용하여 응답 속도 개선하기</li>
<li>k6와 같은 성능 테스트 도구를 사용하는 방법과 시나리오를 설정하는 기준에 대해 학습하기</li>
<li>Nginx worker 설정값 및 캐시 처리에 대해 학습하기</li>
<li>Race condition 문제 해결하기</li>
</ul>
<br/>

<p><a href="https://github.com/mujik-tigers/course-registration-system/tree/main?tab=readme-ov-file">프로젝트 GitHub</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Controller Test와 적절한 Mocking 처리]]></title>
            <link>https://velog.io/@on-and-off/Controller-Test%EC%99%80-%EC%A0%81%EC%A0%88%ED%95%9C-Mocking-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@on-and-off/Controller-Test%EC%99%80-%EC%A0%81%EC%A0%88%ED%95%9C-Mocking-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Mon, 04 Dec 2023 07:12:30 GMT</pubDate>
            <description><![CDATA[<p><span style="color: gray"><em>REST Docs를 사용하여 API 문서를 생성하기로 결정하고, 다양한 구조의 컨트롤러 테스트를 시도했던 과정을 기록한 글입니다.</em></span></p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/b5508558-ec64-4a2e-9c81-10149c9d4d15/image.png" alt=""></p>
<p>코드를 작성하기 시작하면서, 구조나 우선 순위에 대한 토의와 변경이 많았던 한 주였습니다.
그 중에서 컨트롤러 테스트 구조가 변화했던 과정을 소개합니다. 😊</p>
<br />

<h3 id="1-mocking-없이-실제-모듈들이-협력하도록-하자">(1) Mocking 없이 실제 모듈들이 협력하도록 하자!</h3>
<img width=550 src=https://velog.velcdn.com/images/on-and-off/post/92ad2839-709e-4dcb-84d6-ad08ea57385f/image.png />


<p>첫 번째 시도는 모든 객체들이 잘 협력하는지 확인하는 <strong>통합 테스트</strong>였습니다.</p>
<p>클라이언트의 요청부터 인터셉터를 제대로 통과하는지, 개별 객체가 우리가 기대하는 응답을 생성하여 서로 잘 주고 받는지 등...
<strong>하나의 애플리케이션으로서 문제없이 동작하는지 확인하는 것</strong>에 목적을 두었습니다.</p>
<p>따라서 mocking 없이 Spring Boot Context를 바탕으로 실제 객체를 사용하여 테스트를 수행했습니다.</p>
<br />

<h3 id="높은-커버리지-점수-그러나">높은 커버리지 점수, 그러나</h3>
<img width=350 src=https://velog.velcdn.com/images/on-and-off/post/25047c29-fa8f-4d4f-9163-92e22d5b0373/image.png />

<p>결과적으로 비교적 적은 양의 코드를 작성하고도 높은 커버리지 점수를 받을 수 있었습니다.
그러나 광범위한 범위를 포함하고 있다보니 테스트가 실패했을 때 어디에서 어떤 오류가 발생했는지, <strong>문제를 직관적으로 파악하기 어렵다</strong>는 단점을 갖게 됩니다.</p>
<p>이를 보완하기 위해서 어떤 하나의 부품이 문제없이 동작함을 보장하는 <strong>단위 테스트</strong>가 필요함을 생각했고, <code>컨트롤러</code> 라는 관심사를 큰 덩어리에서 분리하게 됩니다.</p>
<br />

<h3 id="2-컨트롤러-단위-테스트는-어때-service는-mocking">(2) 컨트롤러 단위 테스트는 어때? Service는 Mocking!</h3>
<p>이어진 두 번째 시도는 <code>컨트롤러</code> 가 제대로 동작하는지 확인하는 <strong>단위 테스트</strong>입니다.</p>
<p>예상한대로 컨트롤러가 서비스에서 전달받은 결과를 갖는 응답을 반환하는지, 요청에 따른 컨트롤러 매핑이 잘 되는지 등
우리가 결정한 관심사인 <code>컨트롤러</code> 라는 <strong>하나의 부품이 문제없이 동작하는지 확인하는 것</strong>에 목적을 두었습니다.</p>
<p>따라서 컨트롤러를 제외한 인터셉터나 서비스와 같은 객체들은 모두 <strong>mocking</strong> 처리를 해주었습니다.</p>
<p>실제 코드는 다음과 같이 작성했습니다.</p>
<pre><code class="language-java">@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsSupport {

    protected MockMvc mockMvc;
    protected ObjectMapper objectMapper = new ObjectMapper();
    private final AccessTokenInterceptor accessTokenInterceptor = mock(AccessTokenInterceptor.class);
    private final RefreshTokenInterceptor refreshTokenInterceptor = mock(RefreshTokenInterceptor.class);

    @BeforeEach
    void setUp(RestDocumentationContextProvider provider) throws Exception {
        this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
            .setControllerAdvice(GlobalExceptionHandler.class)
            .addInterceptors(accessTokenInterceptor, refreshTokenInterceptor)
            .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
            .build();

        given(accessTokenInterceptor.preHandle(any(), any(), any()))
            .willReturn(true);
        given(refreshTokenInterceptor.preHandle(any(), any(), any()))
            .willReturn(true);
    }

    protected abstract Object initController();

}</code></pre>
<pre><code class="language-java">class AuthControllerTest extends RestDocsSupport {

    private final AuthService authService = mock(AuthService.class);

    @Override
    protected Object initController() {
        return new AuthController(authService);
    }

    @Test
    @DisplayName(&quot;일반 로그인 : 성공&quot;)
    void loginSuccess() throws Exception {
        // given
        LoginForm loginForm = new LoginForm(&quot;yeon@email.com&quot;, &quot;test!1234&quot;);

        given(authService.login(any(LoginForm.class)))
            .willReturn(new AuthenticationTokenPair(&quot;accessToken&quot;, &quot;refreshToken&quot;));

        // when &amp; then
        .
        .
        .
</code></pre>
<br />

<h3 id="보다-더-의미있는-테스트를-위해">보다 더 의미있는 테스트를 위해</h3>
<p>결과적으로 덜어낸 만큼 빠르고 직관적인 컨트롤러 단위 테스트가 되었고,
자연스럽게 컨트롤러 외의 개별 객체들도 자신의 역할을 제대로 수행하는지 검증하는 작은 단위의 테스트를 갖게 되었습니다.</p>
<p>그러나 주변 환경을 전부 mocking 처리함으로써, 인터셉터에서 <code>Authorization Header</code> 를 확인하던 과정이 생략되는 문제점을 발견하게 됩니다.</p>
<p>개발자가 실수로 <code>Authorization Header</code> 에 <code>Bearer [token]</code> 을 설정하지 않고 REST Docs 문서를 생성해도 오류가 발생하지 않으므로 스스로 주의해야 할 포인트가 하나 늘어나게 되었습니다.</p>
<pre><code class="language-java">    // header 설정 예시
    ...

    // when &amp; then
    mockMvc.perform(post(&quot;/reissue&quot;)
        .header(HttpHeaders.AUTHORIZATION, &quot;Bearer refreshToken&quot;))
    .
    .
    .</code></pre>
<br />

<p>이를 해결하기 위해 mocking 처리한 인터셉터에 <code>.will()</code> 을 사용하여 임의로 헤더를 검사하는 코드를 추가할 수 있지만,
개발자가 놓친 부분이 더 있을 가능성을 고려하여 <strong>필요한 부분만 mocking 처리하는 것</strong>을 선택했습니다.</p>
<p>또한 일반적으로 앞단까지 포함하여 컨트롤러의 동작을 예상하기 때문에, 실제 애플리케이션에 가까운 컨트롤러 테스트로 개선이 필요함을 고려했습니다.</p>
<p>이어진 마지막 시도에서는 <strong>요청이 컨트롤러에 오기까지의 과정을 하나의 단위</strong>로 설정하게 됩니다.</p>
<br />

<h3 id="3-컨트롤러와-밀접한-환경도-고려하는게-좋겠어">(3) 컨트롤러와 밀접한 환경도 고려하는게 좋겠어!</h3>
<p>마지막 시도는, 관련된 context를 바탕으로 컨트롤러 테스트를 수행하는 보다 <strong>실용적인 단위 테스트</strong>입니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/a4aa6990-3bb3-4905-94c8-b53ac01d2b25/image.png" alt=""></p>
<p><code>@WebMvcTest</code> 의 설명을 보면, 다음의 내용을 확인할 수 있습니다.</p>
<blockquote>
<p><em>... &#39;focuses <strong>only</strong> on Spring MVC componets.&#39;</em>
<em>... (i.e. @Controller, @ControllerAdvice, @JsonComponent, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans not @Component, @Service or @Repository beans.).</em></p>
</blockquote>
<ul>
<li><code>Spring MVC componets</code> 에 집중한 테스트이다.</li>
<li>테스트 관심사와 관련된 빈들만 설정할 것이다.</li>
<li>따라서 <code>@Controller</code> , <code>@ControllerAdvice</code> , <code>@JsonComponent</code> , <code>Filter</code> , <code>WebMvcConfigurer</code> , <code>HandlerMethodArgumentResolver</code> 와 같은 빈들은 등록되지만 <code>@Component</code> , <code>@Service</code> 또는 <code>@Repository</code> 는 등록되지 않을 것이다.</li>
</ul>
<p>즉, 저희가 원했던 것처럼 컨트롤러와 그 앞단까지의 환경을 바탕으로 테스트를 수행할 수 있게 도와주는 설정입니다.</p>
<br/>

<p><code>@WebMvcTest</code> 를 사용하여 수정한 실제 코드는 다음과 같습니다.</p>
<pre><code class="language-java">@WebMvcTest(controllers = {AuthController.class, MemberController.class})
@AutoConfigureRestDocs
public abstract class RestDocsSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected AuthService authService;

    @MockBean
    protected AuthManager authManager;

    @MockBean
    protected TokenManager tokenManager;

    ...

    @BeforeEach
    void setUp() {
        given(tokenManager.validateRefreshToken(any()))
            .willReturn(Jwts.claims().add(&quot;id&quot;, 1L).build());
        given(tokenManager.validateAccessToken(any()))
            .willReturn(Jwts.claims().add(&quot;id&quot;, 1L).build());

        given(authManager.validateAuthorizationHeader(any()))
            .willCallRealMethod();
    }

}</code></pre>
<p><span style="color: gray"><em>참고 : 인터셉터 코드</em></span></p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class AccessTokenInterceptor implements HandlerInterceptor {

    private static final String AUTHORIZATION_HEADER = &quot;Authorization&quot;;
    public static final String ACCOUNT_ID = &quot;id&quot;;

    private final AuthManager authManager;
    private final TokenManager tokenManager;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws Exception {
        if (CorsUtils.isPreFlightRequest(request))
            return true;

        String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
        String token = authManager.validateAuthorizationHeader(authorizationHeader);

        Claims claims = tokenManager.validateAccessToken(token);

        request.setAttribute(ACCOUNT_ID, claims.get(ACCOUNT_ID, Long.class));
        return true;
    }

}</code></pre>
<br/>

<p>인터셉터에서 <code>tokenManager</code> 를 사용하여 토큰을 검증하는 부분만 mocking 처리하고,
헤더를 검증하거나 <code>request.setAttribute()</code> 하여 resolver에게 넘겨주는 부분은 정상적으로 동작하도록 했습니다.</p>
<p>위와 같은 구조는 어떤 부분이 mocking 되었는지 직관적으로 알 수 있으며, 다른 동작은 모두 정상적으로 수행될 것임을 예상할 수 있다는 장점을 갖습니다.</p>
<p>이전에 <code>Authorization Header</code> 에 값을 설정하지 않거나 <code>Bearer</code> 에 오타가 생겨도 오류를 발생하지 않았던 문제점 또한 해결되었습니다.</p>
<p>최종적으로, 현재 프로젝트에서는 위와 같은 구조를 유지하며 컨트롤러 테스트를 작성하고 있습니다.</p>
<br/>

<h3 id="interceptor에서-false를-반환하면">Interceptor에서 false를 반환하면?</h3>
<p>덧붙여, 두 번째 시도에서 <code>Authorization Header</code> 를 검증하기 위해 임의로 <code>will()</code> 메서드를 사용했던 경험을 공유하고 포스팅을 마무리하려고 합니다.</p>
<p>해당 시점에서 모든 인터셉터를 mocking 처리하여 사용하고 있었고,</p>
<pre><code class="language-java">    ...

    // 수정 전 코드
    given(accessTokenInterceptor.preHandle(any(), any(), any()))
            .willReturn(true);
    given(refreshTokenInterceptor.preHandle(any(), any(), any()))
            .willReturn(true);</code></pre>
<p><code>Authorization Header</code> 를 검증하기 위해 코드를 수정했습니다.</p>
<pre><code class="language-java">    ...

    // 수정 후 코드
    given(accessTokenInterceptor.preHandle(any(HttpServletRequest.class), any(), any()))
        .will((invocation) -&gt; {
            HttpServletRequest request = invocation.getArgument(0);
            return authManager.validateAuthorizationHeader(
            request.getHeader(HttpHeaders.AUTHORIZATION)) != null;
        });
    given(refreshTokenInterceptor.preHandle(any(HttpServletRequest.class), any(), any()))
        .will((invocation) -&gt; {
            HttpServletRequest request = invocation.getArgument(0);
            return authManager.validateAuthorizationHeader(
            request.getHeader(HttpHeaders.AUTHORIZATION)) != null;
        });</code></pre>
<br />

<p>수정한 테스트 코드를 실행하니, 해당 인터셉터를 거치는 모든 요청은 다음과 같이 응답하고 테스트가 통과되지 않았습니다.</p>
<p><img src="https://velog.velcdn.com/images/on-and-off/post/249af647-7507-43d8-981a-73d48a984d42/image.png" alt=""></p>
<blockquote>
<p><em><code>200 OK</code> 라고 하지만 아무 데이터가 없는 빈 성공 응답</em></p>
</blockquote>
<p>잘못되었다는 응답이 아니므로 어디서 무엇이 제대로 동작하지 않았는지 파악하기 어려웠으나,
디버깅 결과 mocking 처리한 <code>authManager</code> 를 간과하고 사용한 실수가 원인이었고 <code>authManager</code> 는 항상 <code>null</code> 을 반환했던 것이었습니다.</p>
<br />

<p>따라서 인터셉터는 항상 <code>false</code> 를 반환했을텐데 우리는 왜 <code>200 OK</code> 라는 답변을 받았던 걸까요?</p>
<p><a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/handlermapping-interceptor.html#page-title">Spring 공식 문서</a>에 따르면, <code>preHandle()</code> 메서드가 <code>false</code> 를 반환하면 <code>DispatcherServlet</code> 은 <strong>인터셉터가 요청을 알아서 처리한 것으로 간주</strong>한다고 합니다.</p>
<p>즉, <code>DispatcherServlet</code> 은 인터셉터에서 <code>false</code> 를 반환해도 요청을 알아서 처리했다고 생각했고 <code>response</code> 에 상태 코드를 지정해주지 않았기 때문에 기본값인 <code>200 OK</code> 를 응답했던 것입니다.</p>
<br/>

<h3 id="마무리">마무리</h3>
<p>이로써 <code>@SpringBootTest</code> 를 사용한 통합 테스트에서, 서버를 올리지 않는 빠른 단위 테스트를 거쳐, <code>@WebMvcTest</code> 를 사용하여 관련된 context를 바탕으로 컨트롤러를 테스트하는 구조까지 오게 된 과정을 모두 소개했습니다.</p>
<p>더 나은 구조를 고민하면서 낯선 mocking 기술도 시도해보고, 제대로 이해하지 못했던 동작 과정을 더 살펴보게 됐던 의미있는 시간이었습니다.</p>
<p>긴 글 읽어주셔서 감사합니다 🙂</p>
<br />

<p><span style="color: gray"><em>작성자 : 김서연</em></span></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Validation 적용기]]></title>
            <link>https://velog.io/@on-and-off/Validation-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@on-and-off/Validation-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Sat, 02 Dec 2023 06:41:19 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, On &amp; Off 프로젝트의 Hyun 입니다.</p>
<p>이번주 프로젝트를 진행하면서, 가장 오랜시간 고민하며 작업한 Validation 적용 과정을 블로깅하려고 합니다.</p>
<br>

<h3 id="길고-투박한-검증-애노테이션-제거하기">길고 투박한 검증 애노테이션 제거하기</h3>
<p>저는 첫 번째로 검증하려는 필드위에 붙이는 <code>@Max</code>, <code>@Pattern</code>, <code>@Size</code> 와 같은 애노테이션을 제거하고 싶었습니다.</p>
<pre><code class="language-java">@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

    @Pattern(regexp = &quot;^[0-9a-z-A-z]([\-.\w]*[0-9a-zA-Z\-_+])*@([0-9a-zA-Z][\-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9}$&quot;, message = &quot;이메일이 양식에 맞지 않습니다.&quot; )
    @Size(max = 62, message = &quot;이메일 길이는 62자 이하입니다.&quot;)
    private String email;

    // ...
}</code></pre>
<blockquote>
<p>컨트롤러단에서 회원가입을 위해 클라이언트가 입력한 값을 받는 DTO 클래스</p>
</blockquote>
<br>

<p>일반적이고 깔끔한 Bean Validation 적용방법이지만 제가 생각한 문제점은 다음과 같습니다.</p>
<ul>
<li><p>애노테이션 안에 하드코딩된 정보가 너무 많습니다.</p>
</li>
<li><p>동일한 이메일 검증을 다른 DTO에서 하게 된다면, 다시 저 긴 <code>@Pattern</code> 과 <code>@Size</code> 애노테이션을 붙여 사용해야 합니다.</p>
</li>
</ul>
<pre><code class="language-java">public class EditUserForm {

    @Pattern(regexp = &quot;^[0-9a-z-A-z]([\-.\w]*[0-9a-zA-Z\-_+])*@([0-9a-zA-Z][\-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9}$&quot;, message = &quot;이메일이 양식에 맞지 않습니다.&quot; )
    @Size(max = 62, message = &quot;이메일 길이는 62자 이하입니다.&quot;)
    private String email;

    // ...
}</code></pre>
<blockquote>
<p>만약 유저의 이메일을 변경하기 위해 다음과 같은 DTO를 만든다고 하면, 똑같이 여러개의 애노테이션을 붙여주어야 합니다. </p>
</blockquote>
<br>


<ul>
<li>검증이 어떤 방식으로 수행되는지 설명하기 어렵습니다.</li>
</ul>
<p>예를 들어, 다음의 정규식이 어떤식으로 만들어졌는지 최소한의 주석을 달기가 어렵습니다.</p>
<br>

<p>이러한 문제점이 있다고 생각하여 저는 다른 방식으로 애노테이션 검증을 수행하게 되었습니다.</p>
<br>

<h3 id="constraint-annotation-적용">Constraint Annotation 적용</h3>
<p>Constraint Annotation 이란 직접 만든 ConstraintValidator 를 연결한 애노테이션을 의미합니다. 
Constraint Annotation 을 검증하고 싶은 필드위에 붙임으로써, 제가 만든 Validator 의 검증을 적용할 수 있습니다.
이를 통해 DTO 코드는 다음처럼 바뀝니다.</p>
<pre><code class="language-java">@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

    @EmailForm
    @EmailSize
    private String email;

    @NicknameForm
    private String nickname;

    @PasswordForm
    private String password;

}</code></pre>
<blockquote>
<p>DTO 클래스 </p>
</blockquote>
<br>

<p>이를 통해 DTO 클래스의 불필요하게 긴 하드코딩 정보들을 제거할 수 있었습니다.
Constraint Annotation 중 <code>@EmailForm</code> 애노테이션을 한번 살펴보겠습니다.</p>
<pre><code class="language-java">@Documented
@Constraint(validatedBy = EmailForm.EmailFormValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EmailForm {

    String message() default &quot;이메일이 양식에 맞지 않습니다.&quot;;

    Class&lt;?&gt;[] groups() default {};

    Class&lt;? extends Payload&gt;[] payload() default {};

    class EmailFormValidator implements ConstraintValidator&lt;EmailForm, String&gt; {
        private final String REGEX_EMAIL = &quot;^[0-9a-z-A-z]([\\-.\\w]*[0-9a-zA-Z\\-_+])*&quot; // 소문자 알파벳이나 숫자로 시작하는 local part
            + &quot;@([0-9a-zA-Z][\\-\\w]*[0-9a-zA-Z]\\.)+[a-zA-Z]{2,9}$&quot;;    // 서브 도메인, 도메인, 최상위 도메인

        private Pattern emailRegex = Pattern.compile(REGEX_EMAIL);

        @Override
        public boolean isValid(String emailInput, ConstraintValidatorContext context) {
            return emailRegex.matcher(emailInput).matches();
        }
    }

}</code></pre>
<blockquote>
<p><code>@Constraint(validatedBy = EmailForm.EmailFormValidator.class)</code> </p>
</blockquote>
<ul>
<li>연결하고 싶은 ConstraintValidator 는 다음과 같이 연결합니다.</li>
<li>저는 가독성을 위해 애노테이션 내부에 Validator 클래스를 정의하였습니다. </li>
</ul>
<p>ConstraintAnnotation 을 정의할 때 반드시 만들어주어야 하는 것은 message, groups, payload 입니다. 
message 는 메시지 관리를 위해 사용하고, groups 는 Validation 그룹을 나누기 위해 사용하며, payload 는 심각도를 나타냅니다.</p>
<p>message 에 담고 싶은 에러 메시지를 적고, Validator 내부에서 정규식을 사용하여 이메일 양식에 대한 검증을 수행할 수 있었습니다. 또한 정규식에 대한 간략한 주석을 달 수도 있었습니다.</p>
<p>만약 다른 DTO에서 이메일 형식을 검증하는 애노테이션이 필요하다면, <code>@EmailForm</code> 을 붙여주는것만으로 충분할 것입니다.</p>
<br>

<h3 id="중복-이메일과-중복-닉네임-검증하기">중복 이메일과 중복 닉네임 검증하기</h3>
<p>사용자가 회원가입을 하는데, 이미 존재하는 이메일이거나 이미 존재하는 닉네임을 사용하려 한다면, 이를 막는것도 검증의 역할입니다. 이전에 수행한 프로젝트에서는 중복을 검사하는 검증, 즉 DB를 사용해야 하는 검증은 서비스 계층에서 수행하였습니다.</p>
<pre><code class="language-java">Service 메서드1 (DTO dto) {
    Repository.existsByXXX(dto.getXXX())
        .orElseThrow(예외);    // 검증 로직

    // 비즈니스 로직 ...
}

Service 메서드2 (DTO dto) {
    Repository.existsByXXX(dto.getXXX())
        .orElseThrow(예외);    // 검증 로직 중복!!

    // 비즈니스 로직 ...
}</code></pre>
<blockquote>
<p>대략적인 검증 형태</p>
</blockquote>
<p>그러나 이렇게 중복 검증을 수행하게 되면 중복 검증이 필요한 모든 메서드가 검증 로직을 사용하게 되므로 지저분하고 불필요한 코드 중복이 발생하게 됩니다. 
가급적이면 메서드내에서 비즈니스 로직만 수행하는 것이 보기도 좋고, 코드 중복도 제거할 수 있지 않을까 생각하여 중복검증을 수행하는 Constraint Annotation 을 만들어 DTO에 붙이기로 합니다.</p>
<br>

<pre><code class="language-java">@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

    @EmailForm
    @EmailSize
    @EmailDuplicateCheck
    private String email;

    @NicknameForm
    @NicknameDuplicateCheck
    private String nickname;

    @PasswordForm
    private String password;

}</code></pre>
<blockquote>
<p>DTO 클래스에 중복 검증을 위한 애노테이션 <code>@EmailDuplicateCheck</code>, <code>@NicknameDuplicateCheck</code> 을 추가 </p>
</blockquote>
<br>

<p>이메일 중복 검사를 수행하는 <code>@EmailDuplicateCheck</code> 애노테이션의 코드는 다음과 같습니다.</p>
<pre><code class="language-java">@Documented
@Constraint(validatedBy = EmailDuplicateCheck.EmailDuplicateValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EmailDuplicateCheck {

    String message() default &quot;이미 존재하는 이메일입니다.&quot;;

    Class&lt;?&gt;[] groups() default {};

    Class&lt;? extends Payload&gt;[] payload() default {};

    class EmailDuplicateValidator implements ConstraintValidator&lt;EmailDuplicateCheck, String&gt; {
        private MemberRepository memberRepository;
        private AES256Manager aes256Manager;

        @Autowired
        public void setMemberRepository(MemberRepository memberRepository, AES256Manager aes256Manager) {
            this.memberRepository = memberRepository;
            this.aes256Manager = aes256Manager;
        }

        @Override
        public boolean isValid(String emailInput, ConstraintValidatorContext context) {
            String encryptedEmailInput = aes256Manager.encrypt(emailInput);
            return !memberRepository.existsByEmail(encryptedEmailInput);
        }

    }

}</code></pre>
<blockquote>
<ul>
<li>ConstraintValidator 는 기본생성자가 있어야 하므로, 필요한 의존성은 <code>setter</code> 주입하였습니다.</li>
</ul>
</blockquote>
<br>

<p>다음과 같이 DB를 사용하는 Validator 를 만듦으로써 컨트롤러 계층에서 클라이언트 입력값에 대한 모든 검증을 끝낸 DTO를 순수하게 사용할 수 있게 되었습니다.</p>
<p>하지만 이 방식에도 문제점은 있었습니다. 
이메일 검증을 할 때 사용자가 정규식을 위반하는 불가능한 이메일을 입력하였다면, 굳이 DB를 거치는 비싼 검증을 수행할 필요가 있을까요?
저는 정규식이나 길이제한을 위반하는 이메일은 DB를 거치는 중복 검증을 수행하지 않아도 된다 생각하여, 정규식이나 길이제한 검증을 통과한 경우에만 DB를 거치는 중복 검증을 수행하도록 하고 싶었습니다.</p>
<br>

<h3 id="validator-순서-적용하기">Validator 순서 적용하기</h3>
<p>앞서 말한 문제점을 해결하기 위해 검증 애노테이션의 순서를 적용하였습니다.
검증 애노테이션의 순서를 적용하는 방법은 그룹과 <code>@GroupSequence</code> 를 사용하는 것입니다.</p>
<p>우선적으로 그룹을 만듭니다.</p>
<pre><code class="language-java">public interface DBUsing {
}</code></pre>
<blockquote>
<p>DB를 사용하는 그룹</p>
</blockquote>
<br>

<p>그 다음엔 DTO에 DB를 사용하는 검증 애노테이션에 앞서 만든 그룹을 설정해주고, 클래스 레벨에  <code>@GroupSequence</code> 를 적용해, 검증 순서를 정하면 됩니다.</p>
<pre><code class="language-java">@GroupSequence({SignUpForm.class, DBUsing.class})
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

    @EmailForm
    @EmailSize
    @EmailDuplicateCheck(groups = DBUsing.class)
    private String email;

    @NicknameForm
    @NicknameDuplicateCheck(groups = DBUsing.class)
    private String nickname;

    @PasswordForm
    private String password;

}</code></pre>
<p>다음의 설정으로 아무런 그룹도 아닌 검증 애노테이션, 즉 DB를 사용하지 않는 검증이 먼저 수행되고, 이전 검증이 성공한 경우에만 DB를 사용하는 검증을 수행하게 됩니다.</p>
<p>그러나 이 구조에 대해서도 마음에 들지 않는 점이 있었습니다. 
바로 <code>(groups = DBUsing.class)</code> 를 직접 넣어주어야 된다는 점입니다.
만약 엄청나게 많은 DB 검증 애노테이션이 프로젝트에 전역적으로 있다면 관리가 수월하지 않을 것입니다. </p>
<p>이를 해결할 수 있을까 많은 고민을 하였습니다. 
제가 시도했던 접근 방법은 DB 검증을 수행하는 Constraint Annotation 의 그룹에 <code>DBUsing.class</code> 를 미리 넣는 것입니다. 이렇게 하면 DTO 클래스에 직접 명시하지 않아도 되겠다고 생각했습니다. </p>
<pre><code class="language-java">@Documented
@Constraint(validatedBy = EmailDuplicateCheck.EmailDuplicateValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EmailDuplicateCheck {

    String message() default &quot;이미 존재하는 이메일입니다.&quot;;

    Class&lt;?&gt;[] groups() default {DBUsing.class};

    Class&lt;? extends Payload&gt;[] payload() default {};

    // ...

}</code></pre>
<p>그러나 이 방법은 문제에 봉착하게 됩니다. </p>
<p><img src="https://velog.velcdn.com/images/ghkdgus29/post/1400dcc4-d478-4505-a3d9-b3cb8a797b75/image.png" alt=""></p>
<blockquote>
<p>jakarta.validation.ConstraintDefinitionException: HV000077: site.onandoff.member.validator.EmailDuplicateCheck contains Constraint annotation, but the groups parameter default value is not the empty array.</p>
</blockquote>
<br>

<p><strong>Constraint Annotation 은 group 을 empty array 로 설정해야 합니다!</strong>
전 세계의 코드를 뒤져봐도 <code>Class&lt;?&gt;[] groups() default {}</code> 를 설정한 코드를 못봤는데, groups 를 설정한 코드가 없었던건, Constraint Annotation 이 group을 설정해선 안되기 때문이었습니다. </p>
<p>그래서 결국 울며 겨자먹기로 group 을 명시해주는 구조를 유지하게 됩니다.</p>
<br>

<p>그러나 또 한가지의 문제에 봉착합니다. 바로 테스트코드 작성의 어려움입니다. 
저희 프로젝트에선 컨트롤러 테스트는 RestDocsSupport 클래스를 상속받아, 하나로 통합된 테스트 환경으로 수행합니다. 또한 테스트 환경은 <code>standaloneSetup</code> 설정으로 필요한 의존성은 <code>initController()</code> 메서드를 통해, 실제 테스트코드에서 컨트롤러를 주입합니다 </p>
<pre><code class="language-java">@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsSupport {

    protected MockMvc mockMvc;
    protected ObjectMapper objectMapper = new ObjectMapper();
    private final AccessTokenInterceptor accessTokenInterceptor = mock(AccessTokenInterceptor.class);
    private final RefreshTokenInterceptor refreshTokenInterceptor = mock(RefreshTokenInterceptor.class);

    @BeforeEach
    void setUp(RestDocumentationContextProvider provider) throws Exception {
        this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
            .setControllerAdvice(GlobalExceptionHandler.class)
            .addInterceptors(accessTokenInterceptor, refreshTokenInterceptor)
            .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
            .build();

        given(accessTokenInterceptor.preHandle(any(), any(), any()))
            .willReturn(true);
        given(refreshTokenInterceptor.preHandle(any(), any(), any()))
            .willReturn(true);
    }

    protected abstract Object initController();

}</code></pre>
<blockquote>
<p>컨트롤러 테스트 통합 환경</p>
</blockquote>
<br>

<p>따라서 테스트코드는 다음과 같이 작성할 수 있습니다.</p>
<pre><code class="language-java">class MemberControllerTest extends RestDocsSupport {

    private final MemberService memberService = mock(MemberService.class);

    @Override
    protected Object initController() {
        return new MemberController(memberService);
    }

    @Test
    @DisplayName(&quot;회원가입 성공 시, Redirect URL 과 회원이 DB에 저장될때의 pk값을 반환한다.&quot;)
    void signUpSuccess() throws Exception {
        // given
        SignUpForm signUpForm = new SignUpForm(&quot;ghkdgus29@naver.com&quot;, &quot;hyun&quot;, &quot;1234567a!&quot;);

        given(memberService.signUp(any(SignUpForm.class)))
            .willReturn(new SignUpSuccessResponse(1L));

        // when &amp; then
        mockMvc.perform(post(&quot;/members&quot;)
                .content(objectMapper.writeValueAsString(signUpForm))
                .contentType(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(jsonPath(&quot;$.data.redirectURL&quot;).value(&quot;https://on-and-off.site&quot;))
            .andExpect(jsonPath(&quot;$.data.savedMemberId&quot;).value(1))
            .andDo(document(&quot;signup-success&quot;,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                requestFields(
                    fieldWithPath(&quot;email&quot;).type(JsonFieldType.STRING).description(&quot;이메일&quot;),
                    fieldWithPath(&quot;nickname&quot;).type(JsonFieldType.STRING).description(&quot;닉네임&quot;),
                    fieldWithPath(&quot;password&quot;).type(JsonFieldType.STRING).description(&quot;비밀번호&quot;)
                ),
                responseFields(
                    fieldWithPath(&quot;code&quot;).type(JsonFieldType.NUMBER).description(&quot;코드&quot;),
                    fieldWithPath(&quot;status&quot;).type(JsonFieldType.STRING).description(&quot;상태&quot;),
                    fieldWithPath(&quot;message&quot;).type(JsonFieldType.STRING).description(&quot;메시지&quot;),
                    fieldWithPath(&quot;data&quot;).type(JsonFieldType.OBJECT).description(&quot;응답 데이터&quot;),
                    fieldWithPath(&quot;data.redirectURL&quot;).type(JsonFieldType.STRING).description(&quot;리디렉션 URL&quot;),
                    fieldWithPath(&quot;data.savedMemberId&quot;).type(JsonFieldType.NUMBER).description(&quot;유저 PK&quot;)
                )
            ));
    }
}</code></pre>
<p>서비스의 동작을 mocking 하면서 잘 실행될 줄 알았던 테스트코드에 큰 문제가 발생합니다.
바로 NullPointerException 입니다.</p>
<p><img src="https://velog.velcdn.com/images/ghkdgus29/post/eb5e0502-366c-4ebe-9e27-15b7291a9fb5/image.png" alt=""></p>
<p>왜 이런 문제가 발생할까요?
컨트롤러 계층의 테스트코드이기 때문에, Service 까지는 mocking 해서 주입하지만 Repository 까지는 mocking 하지 않기 때문입니다.
DB 검증을 수행하는 ConstraintValidator 는 MemberRepository 가 필요한데, 주입해주지 않았으니 NullPointerException 이 발생합니다.</p>
<p>어떻게 해결해야 할 지 고민을 많이 했습니다.
이쯤되는 트러블 슈팅은 자료도 별로 없고, Validator 를 일일히 mocking 하자니 RestDocsSupport 를 상속받는 다른 컨트롤러 테스트에서도 테스트와 관련없는 Validator 를 mocking 해주어야 하니 복잡도가 올라가고, 가독성이 떨어지게 됩니다.
또한 Validator 를 mocking 하지 않고, 컨트롤러 테스트에서 자연스럽게 사용하는것이 더 좋다고 생각하기 때문에 저의 고민은 깊어져만 갔습니다.</p>
<p>고민하다 정신을 거의 잃기 직전에 옛 (프로그래밍) 선인들의 말씀이 떠올랐습니다.
<em>테스트 코드를 짜기 어렵다면 그것은 리팩터링의 신호이다.</em></p>
<p>이 구조가 잘못되었기 때문에 테스트 코드 작성이 어려운것은 아닐까 생각이 들었습니다.
따라서 검증 로직의 구조를 변경합니다.</p>
<br>

<h3 id="컨트롤러에서-할-검증과-서비스에서-할-검증을-나누자">컨트롤러에서 할 검증과 서비스에서 할 검증을 나누자</h3>
<p>이전 구조에서 발생하는 2가지 문제는 다음과 같습니다.</p>
<ul>
<li><p>검증 순서를 정하기 위해 group 정보를 하드코딩해야 한다.</p>
</li>
<li><p>컨트롤러 테스트 코드에서 리포지토리에 대한 의존성이 필요해진다.</p>
</li>
</ul>
<br>

<p>이를 해결하기 위해 제가 선택한 방법은 책임 분리입니다.</p>
<p>컨트롤러 계층의 DTO 가 검증해야 할 내용은 사용자의 입력이 정규식을 만족하는지, 길이제한을 만족하는지만 검사합니다. 따라서 컨트롤러 계층에서 서비스 계층으로 넘겨주는 DTO는 위 검증을 통과해야만 합니다.</p>
<p>서비스 계층의 DTO가 검증해야 할 내용은 사용자의 입력이 DB에 이미 존재하는 중복된 값이 아닌지 중복 검증을 수행합니다. 이를 통해 이전 구조에서 발생하는 2가지 문제를 모두 해결할 수 있습니다.</p>
<pre><code class="language-java">@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

    @EmailForm
    @EmailSize
    private String email;

    @NicknameForm
    private String nickname;

    @PasswordForm
    private String password;

    public UniqueSignUpForm toUnique() {
        return new UniqueSignUpForm(email, nickname, password);
    }

}</code></pre>
<blockquote>
<p>컨트롤러 계층에서 사용하는 DTO는 글자수, 정규식 만족 여부만 검증합니다.</p>
</blockquote>
<br>

<pre><code class="language-java">@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @PostMapping(&quot;/members&quot;)
    public ApiResponse&lt;SignUpSuccessResponse&gt; signUp(@RequestBody @Valid SignUpForm signUpForm) {
        SignUpSuccessResponse signUpSuccessResponse = memberService.signUp(signUpForm.toUnique());

        return ApiResponse.ok(ResponseMessage.SIGNUP_SUCCESS.getMessage(), signUpSuccessResponse);
    }

}</code></pre>
<blockquote>
<p>컨트롤러 계층에서 검증이 완료된 DTO는, 서비스 계층용 DTO 로 변환하여 넘겨줍니다.</p>
</blockquote>
<br>

<pre><code class="language-java">@AllArgsConstructor
@Getter
public class UniqueSignUpForm {

    @EmailDuplicateCheck
    private String email;

    @NicknameDuplicateCheck
    private String nickname;

    private String password;

}</code></pre>
<blockquote>
<p>서비스 계층용 DTO에서 DB를 사용하는 중복 검사를 수행합니다.</p>
</blockquote>
<br>

<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@Validated
@RequiredArgsConstructor
public class MemberService {

    @Transactional
    public SignUpSuccessResponse signUp(@Valid UniqueSignUpForm signUpForm) {

        // 비즈니스 로직 ...
    }
}</code></pre>
<blockquote>
<ul>
<li>서비스 계층에서 <code>@Valid</code> 를 하는 경우, 클래스 레벨에 <code>@Validated</code> 를 붙여주어야 합니다.</li>
</ul>
</blockquote>
<ul>
<li><code>@Transactional</code> 과 유사하게 스프링 AOP 기반으로 검증 로직이 적용됩니다.</li>
<li>프로그래머는 이미 중복검증이 끝난 DTO를 가지고 비즈니스 로직에만 집중할 수 있습니다.</li>
</ul>
<br>

<p>이때 서비스 계층에서 검증을 실패할 때 발생하는 예외는 <code>MethodArgumentNotValidException</code> 이 아닙니다. 해당 예외는 컨트롤러 계층에서 검증을 만족하지 못했을 때 ArgumentResolver에 의해 발생하는 예외입니다.
서비스 계층에서 발생하는 예외는 ConstraintViolationException 이므로 이를 숙지하고 GlobalExceptionHandler 를 작성하였습니다.</p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConstraintViolationException.class)
    public ApiResponse&lt;Object&gt; handleConstraintViolationException(ConstraintViolationException exception) {
        return ApiResponse.of(HttpStatus.BAD_REQUEST, null,
            exception.getConstraintViolations().stream()
                .collect(Collectors.groupingBy(v -&gt; parseFieldNameFrom(v.getPropertyPath())))
                .entrySet().stream()
                .map(error -&gt; {
                    Map&lt;String, Object&gt; fieldError = new HashMap&lt;&gt;();
                    fieldError.put(&quot;field&quot;, error.getKey());
                    fieldError.put(&quot;message&quot;, error.getValue().stream()
                        .map(ConstraintViolation::getMessage)
                        .collect(Collectors.joining(&quot;, &quot;)));
                    return fieldError;
                })
        );
    }

    private String parseFieldNameFrom(Path propertyPath) {
        String[] splitPath = propertyPath.toString().split(&quot;\\.&quot;);
        return splitPath[splitPath.length - 1];
    }

    // ...

}</code></pre>
<br>

<p>이전에 작성에 실패하였던 컨트롤러 테스트 코드의 경우, 서비스 계층에서 ConstraintViolationException 을 던져주도록 mocking 하면 컨트롤러 테스트 코드를 작성할 수 있습니다. 
이때 ConstraintViolationException 을 mocking 하는 과정이 다소 복잡하긴 하지만, 서비스 계층의 실패를 테스트하고 싶은 테스트 메서드에서만 복잡한 mocking 을 해주면 됩니다. </p>
<pre><code class="language-java">class MemberControllerTest extends RestDocsSupport {

    private final MemberService memberService = mock(MemberService.class);

    @Override
    protected Object initController() {
        return new MemberController(memberService);
    }

    @Test
    @DisplayName(&quot;회원가입 입력폼의 닉네임이나 이메일이 중복되어 회원가입 실패 시, 400 Bad Request를 반환하며, data엔 어떤 필드에서 실패하였는지 에러 메시지와 함께 응답한다.&quot;)
    void uniqueSignUpFail() throws Exception {
        // given
        SignUpForm signUpForm = new SignUpForm(&quot;ghkdgus29@naver.com&quot;, &quot;hyun&quot;, &quot;1234567a!&quot;);

        ConstraintViolationException exception = mock(ConstraintViolationException.class);
        Set&lt;ConstraintViolation&lt;?&gt;&gt; violations = new HashSet&lt;&gt;();
        ConstraintViolation mockedViolation = mock(ConstraintViolation.class);
        violations.add(mockedViolation);

        given(mockedViolation.getPropertyPath()).willReturn(PathImpl.createPathFromString(&quot;signUp.signUpForm.email&quot;));
        given(mockedViolation.getMessage()).willReturn(&quot;이미 존재하는 이메일입니다.&quot;);
        given(exception.getConstraintViolations()).willReturn(violations);
        given(memberService.signUp(any(UniqueSignUpForm.class)))
            .willThrow(exception);

        // when &amp; then
        mockMvc.perform(post(&quot;/members&quot;)
                .content(objectMapper.writeValueAsString(signUpForm))
                .contentType(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andDo(document(&quot;unique-signup-fail&quot;,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                requestFields(
                    fieldWithPath(&quot;email&quot;).type(JsonFieldType.STRING).description(&quot;이메일&quot;),
                    fieldWithPath(&quot;nickname&quot;).type(JsonFieldType.STRING).description(&quot;닉네임&quot;),
                    fieldWithPath(&quot;password&quot;).type(JsonFieldType.STRING).description(&quot;비밀번호&quot;)
                ),
                responseFields(
                    fieldWithPath(&quot;code&quot;).type(JsonFieldType.NUMBER).description(&quot;코드&quot;),
                    fieldWithPath(&quot;status&quot;).type(JsonFieldType.STRING).description(&quot;상태&quot;),
                    fieldWithPath(&quot;message&quot;).type(JsonFieldType.NULL).description(&quot;메시지&quot;),
                    fieldWithPath(&quot;data[]&quot;).type(JsonFieldType.ARRAY).description(&quot;응답 데이터&quot;),
                    fieldWithPath(&quot;data[].field&quot;).type(JsonFieldType.STRING).description(&quot;잘못 입력한 필드&quot;),
                    fieldWithPath(&quot;data[].message&quot;).type(JsonFieldType.STRING).description(&quot;에러 발생 이유&quot;)
                )
            ));
    }

}</code></pre>
<blockquote>
<p>ConstraintViolationExceptionHandler 에서 사용하는 메서드들을 mocking 합니다. </p>
</blockquote>
<br>

<p>ConstraintViolationExceptionHandler 에서 사용하는 메서드들은 다음과 같습니다. 
<code>ConstraintException</code></p>
<ul>
<li><code>getConstraintViolations()</code>  ➔ <code>Set&lt;ConstraintViolation&lt;?&gt;&gt; violations</code></li>
</ul>
<p><code>ConstraintViolation</code></p>
<ul>
<li><p><code>getPropertyPath()</code> ➔ <code>Path</code> 인터페이스의 구현체, <code>PathImpl</code> 의 정적 메서드를 사용하여 만들었습니다.</p>
</li>
<li><p><code>getMessage()</code> ➔ 에러 메시지</p>
</li>
</ul>
<br>

<h3 id="회고">회고</h3>
<p>이전의 프로젝트에선 크게 고민하지 않고 관성적으로 검증을 수행하였습니다. 
이번 프로젝트에서는 더 좋은 구조가 무엇일까 고민하면서 검증 로직을 구성하였는데, 시간은 굉장히 많이 걸렸지만 배워간 것이 많아 좋았습니다.</p>
<br>

<blockquote>
<p>참고</p>
<blockquote>
<p><a href="https://sas-study.tistory.com/343">스프링 Custom Bean Validation 만들어서 사용해보기</a>
<br>
<a href="https://kapentaz.github.io/java/Java-Bean-Validation-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90/#">Java Bean Validation 제대로 알고 쓰자</a>
<br>
<a href="https://mangkyu.tistory.com/174">[Spring] @Valid와 @Validated를 이용한 유효성 검증의 동작 원리 및 사용법 예시
</a>
<br>
<a href="https://stackoverflow.com/questions/22938407/bean-validation-how-can-i-manually-create-a-constraintviolation">Bean Validation: How can I manually create a ConstraintViolation?</a>
<br>
<a href="https://stackoverflow.com/questions/57413588/how-to-write-unit-test-for-below-exception-handler-method-using-mockito">How to write Unit Test for below Exception Handler method using mockito?</a></p>
</blockquote>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>