<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>초보 성장기</title>
    <link>https://byungil.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 9 Apr 2026 17:07:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>초보병일이</managingEditor>
    <image>
      <title>초보 성장기</title>
      <url>https://tistory1.daumcdn.net/tistory/5408334/attach/9337adca1fa24640bcbd1a33c1384aed</url>
      <link>https://byungil.tistory.com</link>
    </image>
    <item>
      <title>Swagger @ApiResponses 커스텀 적용하기</title>
      <link>https://byungil.tistory.com/333</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OAsSb/btsIUtLMqEB/kPvkFFAWCcW42lN3RSgao0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OAsSb/btsIUtLMqEB/kPvkFFAWCcW42lN3RSgao0/img.gif&quot; data-alt=&quot;공통 400예외에 대한 분류&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OAsSb/btsIUtLMqEB/kPvkFFAWCcW42lN3RSgao0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/OAsSb/btsIUtLMqEB/kPvkFFAWCcW42lN3RSgao0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;967&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;공통 400예외에 대한 분류&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger를 사용하여 Api 명세로 의사소통 없이 API에 대한 요청과 응답을 확실하게 하고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@ApiResponses를 이용하여 응답값에 대한 모든 예외를 처리하였는데, 문제점이 많아 어떻게 하면 편리하게 사용하고 보여줄 수 있는지 고민하였고 해결 과정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 기존 방식의 문제점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 정확한 값이 나오지 않는다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger에서는 @RestControllerAdvice와 같은 어노테이션을 이용한 전역 예외 처리를 식별하여 문서화하는 기능이 포함되어있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1367&quot; data-origin-height=&quot;267&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zwOEx/btsIVD7Xv9W/QAXstHQQGlTFYpEUKK84M0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zwOEx/btsIVD7Xv9W/QAXstHQQGlTFYpEUKK84M0/img.png&quot; data-alt=&quot;ApiResponse를 사용하지 않았을 때 나타나는 응답값입니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zwOEx/btsIVD7Xv9W/QAXstHQQGlTFYpEUKK84M0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzwOEx%2FbtsIVD7Xv9W%2FQAXstHQQGlTFYpEUKK84M0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;142&quot; data-origin-width=&quot;1367&quot; data-origin-height=&quot;267&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ApiResponse를 사용하지 않았을 때 나타나는 응답값입니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되었을 때, 400에 대한 무슨 에러인지 어떤 메세지가 발생하는지에 대한 예외를 알 수 없습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 responseCode, description을 직접 작성해야 한다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 에러코드를 묶고, 정확한 설명을 위해서 수작업을 해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;862&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bL9gSm/btsIVju6yj8/9KWTK44IC6ENopQzolspB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bL9gSm/btsIVju6yj8/9KWTK44IC6ENopQzolspB0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bL9gSm/btsIVju6yj8/9KWTK44IC6ENopQzolspB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbL9gSm%2FbtsIVju6yj8%2F9KWTK44IC6ENopQzolspB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;524&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;862&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러가 너무 길고 가독성이 좋지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 ExampleObject를 직접 작성해야 한다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외에 대한 공통 응답이 존재하여도, 이렇게 직접 작성해야 하기 때문에 불편함이 너무 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명히 실수를 할 수 있고, 컨트롤러가 너무 길어지기 때문에 안 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 프로덕션 코드에 집중을 할 수 없는 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 스웨거 타입 분석&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;874&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6HAPv/btsITUiNf4C/GKtzfkpAzEvfiDI3RdrOlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6HAPv/btsITUiNf4C/GKtzfkpAzEvfiDI3RdrOlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6HAPv/btsITUiNf4C/GKtzfkpAzEvfiDI3RdrOlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6HAPv%2FbtsITUiNf4C%2FGKtzfkpAzEvfiDI3RdrOlK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;851&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;874&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Media Type Object 안에 examples 안에는 Example Object가 올 수 있는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;figure id=&quot;og_1734422565609&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;OpenAPI-Specification/versions/3.1.0.md at 3.1.0 &amp;middot; OAI/OpenAPI-Specification&quot; data-og-description=&quot;The OpenAPI Specification Repository. Contribute to OAI/OpenAPI-Specification development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md#media-type-object&quot; data-og-url=&quot;https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/qfHRl/hyXOjUbWdz/8QpFSdOKKTFznKguTSuuZ0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/sE2Ki/hyXKjO51yt/skGfykPH0CLUx23WvgvBi1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md#media-type-object&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md#media-type-object&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/qfHRl/hyXOjUbWdz/8QpFSdOKKTFznKguTSuuZ0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/sE2Ki/hyXKjO51yt/skGfykPH0CLUx23WvgvBi1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;OpenAPI-Specification/versions/3.1.0.md at 3.1.0 &amp;middot; OAI/OpenAPI-Specification&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The OpenAPI Specification Repository. Contribute to OAI/OpenAPI-Specification development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Example Object를 직접 커스텀하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 커스텀 적용하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 어노테이션 생성&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorCodeExamples {

    ErrorCode[] value();
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApiErrorCodeExamples 어노테이션을 만들고 ErrorCode를 받도록 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 어노테이션 정보 불러오기&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Bean
public OperationCustomizer customize() {
    return (Operation operation, HandlerMethod handlerMethod) -&amp;gt; {
        ApiErrorCodeExamples apiErrorCodeExamples = handlerMethod.getMethodAnnotation(
                ApiErrorCodeExamples.class);

        // @ApiErrorCodeExamples 어노테이션이 붙어있다면
        if (apiErrorCodeExamples != null) {
            generateErrorCodeResponseExample(operation, apiErrorCodeExamples.value());
        } 

        return operation;
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SwaggerConfig에 직접 커스텀 메소드를 작성하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApiErrorCodeExamples 어노테이션이 있다면 스웨거 응답 커스텀을 진행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 응답 커스텀&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] errorCodes) {
    ApiResponses responses = operation.getResponses();

    // ExampleHolder(에러 응답값) 객체를 만들고 에러 코드별로 그룹화
    Map&amp;lt;Integer, List&amp;lt;ExampleHolder&amp;gt;&amp;gt; statusWithExampleHolders;
    statusWithExampleHolders = Arrays.stream(errorCodes)
            .map(
                    errorCode -&amp;gt; ExampleHolder.builder()
                            .holder(getSwaggerExample(errorCode))
                            .code(errorCode.getStatus().value())
                            .name(errorCode.name())
                            .build()
            )
            .collect(Collectors.groupingBy(ExampleHolder::getCode));

    // ExampleHolders를 ApiResponses에 추가
    addExamplesToResponses(responses, statusWithExampleHolders);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 오픈소스에서 스웨거의 응답을 분석했을 때, 여러개의 Example Object가 올 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// exampleHolder를 ApiResponses에 추가
private void addExamplesToResponses(ApiResponses responses,
                                    Map&amp;lt;Integer, List&amp;lt;ExampleHolder&amp;gt;&amp;gt; statusWithExampleHolders) {
    statusWithExampleHolders.forEach(
            (status, v) -&amp;gt; {
                Content content = new Content();
                MediaType mediaType = new MediaType();
                ApiResponse apiResponse = new ApiResponse();

                v.forEach(
                        exampleHolder -&amp;gt; mediaType.addExamples(
                                exampleHolder.getName(),
                                exampleHolder.getHolder()
                        )
                );
                content.addMediaType(&quot;application/json&quot;, mediaType);
                apiResponse.setContent(content);
                responses.addApiResponse(String.valueOf(status), apiResponse);
            }
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 형식으로 상태코드 기준으로 응답을 모아 ApiResponses 객체에 넣어주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 최종 결과&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1077&quot; data-origin-height=&quot;211&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Oq2HA/btsLlQkmvN5/cJQDoAciKN9oyhPq2zmMU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Oq2HA/btsLlQkmvN5/cJQDoAciKN9oyhPq2zmMU1/img.png&quot; data-alt=&quot;어노테이션을 활용하여 응답 예외 커스텀&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Oq2HA/btsLlQkmvN5/cJQDoAciKN9oyhPq2zmMU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOq2HA%2FbtsLlQkmvN5%2FcJQDoAciKN9oyhPq2zmMU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;142&quot; data-origin-width=&quot;1077&quot; data-origin-height=&quot;211&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;어노테이션을 활용하여 응답 예외 커스텀&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1415&quot; data-origin-height=&quot;221&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ejACYK/btsLkJ7wjXj/EXUEsyHuZImW0Ldwbowk2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ejACYK/btsLkJ7wjXj/EXUEsyHuZImW0Ldwbowk2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ejACYK/btsLkJ7wjXj/EXUEsyHuZImW0Ldwbowk2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FejACYK%2FbtsLkJ7wjXj%2FEXUEsyHuZImW0Ldwbowk2k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;113&quot; data-origin-width=&quot;1415&quot; data-origin-height=&quot;221&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger에서 위와 같은 에러 응답을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스웨거가 어떤 구조로 되어있는가를 파악하는 것이 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 이해하시면 커스텀하기 편하실 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/333</guid>
      <comments>https://byungil.tistory.com/333#entry333comment</comments>
      <pubDate>Tue, 17 Dec 2024 17:28:51 +0900</pubDate>
    </item>
    <item>
      <title>Spring Security - Swagger 적용하기</title>
      <link>https://byungil.tistory.com/334</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 진행 중, 프론트엔드 개발자분이 로그인 API가 Swagger 문서에 없다는 말씀을 하셨고, 확인해보니 Swagger 문서에 API가 존재하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 Security를 활용하여 인증/인가를 구현하였고, Springdoc은 Spring Security 필터를 자동으로 등록해주지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/login 엔드포인트를 문서에서 확인하기 위해서는 추가 설정이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger를 보다 효율적으로 활용하기 위한 과정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제점이 존재하는 방법 - yml 설정 파일로 end-point 추가&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1011&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/coZXip/btsJESwMNOd/oUEYK49SSYO1ctXctaqZ3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/coZXip/btsJESwMNOd/oUEYK49SSYO1ctXctaqZ3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/coZXip/btsJESwMNOd/oUEYK49SSYO1ctXctaqZ3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcoZXip%2FbtsJESwMNOd%2FoUEYK49SSYO1ctXctaqZ3K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;143&quot; data-origin-width=&quot;1011&quot; data-origin-height=&quot;200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://springdoc.org/#how-can-i-make-spring-security-login-endpoint-visible&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://springdoc.org/#how-can-i-make-spring-security-login-endpoint-visible&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726665543983&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;OpenAPI 3 Library for spring-boot&quot; data-og-description=&quot;Library for OpenAPI 3 with spring boot projects. Is based on swagger-ui, to display the OpenAPI description.Generates automatically the OpenAPI file.&quot; data-og-host=&quot;springdoc.org&quot; data-og-source-url=&quot;https://springdoc.org/#how-can-i-make-spring-security-login-endpoint-visible&quot; data-og-url=&quot;http://springdoc.org/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/FnA2R/hyW2Y5zAMz/N00zuZX3w6lge3or73WZa1/img.png?width=2172&amp;amp;height=1144&amp;amp;face=0_0_2172_1144,https://scrap.kakaocdn.net/dn/cgjEFd/hyW21gVoVv/PcjMCzJZkY72UYBzjrK1PK/img.png?width=1762&amp;amp;height=816&amp;amp;face=0_0_1762_816&quot;&gt;&lt;a href=&quot;https://springdoc.org/#how-can-i-make-spring-security-login-endpoint-visible&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://springdoc.org/#how-can-i-make-spring-security-login-endpoint-visible&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/FnA2R/hyW2Y5zAMz/N00zuZX3w6lge3or73WZa1/img.png?width=2172&amp;amp;height=1144&amp;amp;face=0_0_2172_1144,https://scrap.kakaocdn.net/dn/cgjEFd/hyW21gVoVv/PcjMCzJZkY72UYBzjrK1PK/img.png?width=1762&amp;amp;height=816&amp;amp;face=0_0_1762_816');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;OpenAPI 3 Library for spring-boot&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Library for OpenAPI 3 with spring boot projects. Is based on swagger-ui, to display the OpenAPI description.Generates automatically the OpenAPI file.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;springdoc.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;yml 파일에 show-login-endpoint: true를 작성하였을 때 Swagger 문서에 login-endpoint가 잘 나타나는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 추가된 것을 확인하겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1447&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJWtfR/btsJETictZp/AIHIUDSScLBVPvAjCFEKX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJWtfR/btsJETictZp/AIHIUDSScLBVPvAjCFEKX1/img.png&quot; data-alt=&quot;요청과 응답에 대한 값이 제대로 나타나지 않음.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJWtfR/btsJETictZp/AIHIUDSScLBVPvAjCFEKX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJWtfR%2FbtsJETictZp%2FAIHIUDSScLBVPvAjCFEKX1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;361&quot; data-origin-width=&quot;1447&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;요청과 응답에 대한 값이 제대로 나타나지 않음.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1476&quot; data-origin-height=&quot;550&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tUbJL/btsJDV8PQzn/kf3Ixl4wRHdkxcu1DemCTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tUbJL/btsJDV8PQzn/kf3Ixl4wRHdkxcu1DemCTk/img.png&quot; data-alt=&quot;API가 분리되어있음.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tUbJL/btsJDV8PQzn/kf3Ixl4wRHdkxcu1DemCTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtUbJL%2FbtsJDV8PQzn%2Fkf3Ixl4wRHdkxcu1DemCTk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;270&quot; data-origin-width=&quot;1476&quot; data-origin-height=&quot;550&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API가 분리되어있음.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger 문서에 나타났음에도 불구하고 아직 문제가 해결되지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 응답값, 요청값에 어떤 값이 들어가는지 자세하게 알 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. UserController에 대한 API와 login API가 따로 분리되어 한 눈에 보기 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 로그인 실패에 대한 응답은 403이 아닌 401이 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 해결하기위해 직접 커스텀하여 작성해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Springdoc 오픈소스 Login Endpoint 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;springdoc 오픈소스에 org.springdoc.core.configuration.SpringDocSecurityConfiguration에 있는 코드를 확인해보겠습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Bean
@ConditionalOnProperty(SPRINGDOC_SHOW_LOGIN_ENDPOINT)
@Lazy(false)
OpenApiCustomizer springSecurityLoginEndpointCustomiser(ApplicationContext applicationContext) {
    FilterChainProxy filterChainProxy = applicationContext.getBean(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class);
    return openAPI -&amp;gt; {
       for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) {
          Optional&amp;lt;UsernamePasswordAuthenticationFilter&amp;gt; optionalFilter =
                filterChain.getFilters().stream()
                      .filter(UsernamePasswordAuthenticationFilter.class::isInstance)
                      .map(UsernamePasswordAuthenticationFilter.class::cast)
                      .findAny();
          Optional&amp;lt;DefaultLoginPageGeneratingFilter&amp;gt; optionalDefaultLoginPageGeneratingFilter =
                filterChain.getFilters().stream()
                      .filter(DefaultLoginPageGeneratingFilter.class::isInstance)
                      .map(DefaultLoginPageGeneratingFilter.class::cast)
                      .findAny();
          if (optionalFilter.isPresent()) {
             UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get();
             Operation operation = new Operation();
             Schema&amp;lt;?&amp;gt; schema = new ObjectSchema()
                   .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema())
                   .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema());
             String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
             if (optionalDefaultLoginPageGeneratingFilter.isPresent()) {
                DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get();
                Field formLoginEnabledField = FieldUtils.getDeclaredField(DefaultLoginPageGeneratingFilter.class, &quot;formLoginEnabled&quot;, true);
                try {
                   boolean formLoginEnabled = (boolean) formLoginEnabledField.get(defaultLoginPageGeneratingFilter);
                   if (formLoginEnabled)
                      mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE;
                }
                catch (IllegalAccessException e) {
                   LOGGER.warn(e.getMessage());
                }
             }
             RequestBody requestBody = new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema)));
             operation.requestBody(requestBody);
             ApiResponses apiResponses = new ApiResponses();
             apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase()));
             apiResponses.addApiResponse(String.valueOf(HttpStatus.FORBIDDEN.value()), new ApiResponse().description(HttpStatus.FORBIDDEN.getReasonPhrase()));
             operation.responses(apiResponses);
             operation.addTagsItem(&quot;login-endpoint&quot;);
             PathItem pathItem = new PathItem().post(operation);
             try {
                Field requestMatcherField = AbstractAuthenticationProcessingFilter.class.getDeclaredField(&quot;requiresAuthenticationRequestMatcher&quot;);
                requestMatcherField.setAccessible(true);
                AntPathRequestMatcher requestMatcher = (AntPathRequestMatcher) requestMatcherField.get(usernamePasswordAuthenticationFilter);
                String loginPath = requestMatcher.getPattern();
                requestMatcherField.setAccessible(false);
                openAPI.getPaths().addPathItem(loginPath, pathItem);
             }
             catch (NoSuchFieldException | IllegalAccessException |
                   ClassCastException ignored) {
                // Exception escaped
                LOGGER.trace(ignored.getMessage());
             }
          }
       }
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위처럼 직접 OpenApiCustomiser를 구현해서 swagger에 등록하고 있었습니다다. 이 코드를 활용하여 직접 만들어서 적용하면 되겠다 생각하였고 바로 적용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Swagger를 직접 커스텀하여 로그인 API 적용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 yml에 end-point를 true로 설정하였을 때 문제를 다시 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 요청값, 응답값에 대한 명세가 정확하지 않음.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Swagger에 API가 분리되어 보이기 때문에 가독성이 좋지 않음.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 로그인 실패에 대한 응답은 403이 아닌 401이 맞음.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 이유로 직접 커스텀하여 적용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 작성한 코드입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.0 커스텀 코드&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;@Bean
OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext) {
    FilterChainProxy filterChainProxy = applicationContext.getBean(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class);
    return openAPI -&amp;gt; {
        for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) {
            Optional&amp;lt;UsernamePasswordAuthenticationFilter&amp;gt; optionalFilter =
                    filterChain.getFilters().stream()
                            .filter(UsernamePasswordAuthenticationFilter.class::isInstance)
                            .map(UsernamePasswordAuthenticationFilter.class::cast)
                            .findAny();
            if (optionalFilter.isPresent()) {
                UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get();
                Operation operation = new Operation();
                Schema&amp;lt;?&amp;gt; schema = new ObjectSchema()
                        .addProperties(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema()._default(&quot;email@email.com&quot;))
                        .addProperties(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema()._default(&quot;password&quot;));
                RequestBody requestBody = new RequestBody().content(new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, new MediaType().schema(schema)));
                operation.requestBody(requestBody);
                ApiResponses apiResponses = new ApiResponses();
                apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()),
                        new ApiResponse().description(HttpStatus.OK.getReasonPhrase())
                                .content(new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE,
                                        new MediaType().example(&quot;{\&quot;token\&quot;:\&quot;sample-jwt-token\&quot;}&quot;))));

                apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()),
                        new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase())
                                .content(new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE,
                                        new MediaType().example(&quot;{\&quot;error\&quot;:\&quot;UNAUTHORIZED\&quot;}&quot;))));

                operation.responses(apiResponses);
                operation.addTagsItem(&quot;user-controller&quot;);
                operation.summary(&quot;로그인&quot;);

                PathItem pathItem = new PathItem().post(operation);
                openAPI.getPaths().addPathItem(&quot;/api/v1/user/sign-in&quot;, pathItem);
            }
        }
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 ._default()를 활용하여 Request 명세 작성&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Schema&amp;lt;?&amp;gt; schema = new ObjectSchema()
        .addProperties(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema()._default(&quot;email@email.com&quot;))
        .addProperties(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema()._default(&quot;password&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;._default()를 활용하여 Request에 대한 명세를 작성하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1410&quot; data-origin-height=&quot;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6vKj0/btsJFyklQ9V/ezzheWZMoLLglqqZPUoKXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6vKj0/btsJFyklQ9V/ezzheWZMoLLglqqZPUoKXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6vKj0/btsJFyklQ9V/ezzheWZMoLLglqqZPUoKXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6vKj0%2FbtsJFyklQ9V%2FezzheWZMoLLglqqZPUoKXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;154&quot; data-origin-width=&quot;1410&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 분리된 API를 하나로 합친다.&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;operation.responses(apiResponses);
operation.addTagsItem(&quot;user-controller&quot;);
operation.summary(&quot;로그인&quot;);

PathItem pathItem = new PathItem().post(operation);
openAPI.getPaths().addPathItem(&quot;/api/v1/user/sign-in&quot;, pathItem);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;addTagsItem을 사용하여 같은 태그로 묶어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;summary를 사용하여 API에 대한 설명을 작성하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PathItem을 사용하여 도메인을 제외한 REQUEST_URI를 작성하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1496&quot; data-origin-height=&quot;311&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEUjkr/btsJFDy9CCP/vUkaM7wN6KY7sVK5QSHXK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEUjkr/btsJFDy9CCP/vUkaM7wN6KY7sVK5QSHXK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEUjkr/btsJFDy9CCP/vUkaM7wN6KY7sVK5QSHXK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEUjkr%2FbtsJFDy9CCP%2FvUkaM7wN6KY7sVK5QSHXK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;151&quot; data-origin-width=&quot;1496&quot; data-origin-height=&quot;311&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 로그인 실패 응답 403 -&amp;gt; 401로 작성, 성공 응답 작성&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()),
        new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase())
                .content(new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE,
                        new MediaType().example(&quot;{\&quot;error\&quot;:\&quot;UNAUTHORIZED\&quot;}&quot;))));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 실패 응답을 401로 작성하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공 응답을 작성하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1421&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjuOBF/btsJE02DOJv/LnXF5XoH66qlR5tKykOI7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjuOBF/btsJE02DOJv/LnXF5XoH66qlR5tKykOI7k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjuOBF/btsJE02DOJv/LnXF5XoH66qlR5tKykOI7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjuOBF%2FbtsJE02DOJv%2FLnXF5XoH66qlR5tKykOI7k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;333&quot; data-origin-width=&quot;1421&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 최종 결과&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1078&quot; data-origin-height=&quot;948&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x6kuN/btsJDp3YNCJ/AobVJjjJL6KbKPlM7dWgtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x6kuN/btsJDp3YNCJ/AobVJjjJL6KbKPlM7dWgtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x6kuN/btsJDp3YNCJ/AobVJjjJL6KbKPlM7dWgtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx6kuN%2FbtsJDp3YNCJ%2FAobVJjjJL6KbKPlM7dWgtk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;638&quot; data-origin-width=&quot;1078&quot; data-origin-height=&quot;948&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenApiCustomizer를 활용하여 Spring Security 기반의 로그인 API를 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Springdoc에 오픈 소스 기여&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 커스텀하여 Swagger 문서를 작성하면서, 오픈 소스에 기여할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenApiCustomizer를 사용하면서 springSecurityLoginEndporintCustomizer() 메소드에 오타를 발견하여 수정하였고, 로그인 실패에 대한 응답값을 403이 아닌 401로 수정하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/springdoc/springdoc-openapi/pull/2659&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/springdoc/springdoc-openapi/pull/2659&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726669802897&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;fix: typo in SpringSecurityLoginEndpointCustomizer method name by YunByungil &amp;middot; Pull Request #2659 &amp;middot; springdoc/springdoc-openap&quot; data-og-description=&quot;SpringSecurityLoginEndpointCustomiser -&amp;gt; SpringSecurityLoginEndpointCustomizer&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/springdoc/springdoc-openapi/pull/2659&quot; data-og-url=&quot;https://github.com/springdoc/springdoc-openapi/pull/2659&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cjcjZI/hyW6zQA8Ny/M6vOD7DL6fn9RnJJBlCcVk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/springdoc/springdoc-openapi/pull/2659&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/springdoc/springdoc-openapi/pull/2659&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cjcjZI/hyW6zQA8Ny/M6vOD7DL6fn9RnJJBlCcVk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;fix: typo in SpringSecurityLoginEndpointCustomizer method name by YunByungil &amp;middot; Pull Request #2659 &amp;middot; springdoc/springdoc-openap&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SpringSecurityLoginEndpointCustomiser -&amp;gt; SpringSecurityLoginEndpointCustomizer&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/springdoc/springdoc-openapi/pull/2660&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/springdoc/springdoc-openapi/pull/2660&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726669809494&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;fix: Update Response Code by YunByungil &amp;middot; Pull Request #2660 &amp;middot; springdoc/springdoc-openapi&quot; data-og-description=&quot;Updated the response code for login failures from 403 Frobidden to 401 UnAuthorized to more accurately reflect the correct HTTP status code for authentication failures. Changes include: Response c...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/springdoc/springdoc-openapi/pull/2660&quot; data-og-url=&quot;https://github.com/springdoc/springdoc-openapi/pull/2660&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cgrmru/hyW6ziMh9w/vjsxU4QzNKb1Xbj0wWoFKk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/springdoc/springdoc-openapi/pull/2660&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/springdoc/springdoc-openapi/pull/2660&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cgrmru/hyW6ziMh9w/vjsxU4QzNKb1Xbj0wWoFKk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;fix: Update Response Code by YunByungil &amp;middot; Pull Request #2660 &amp;middot; springdoc/springdoc-openapi&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Updated the response code for login failures from 403 Frobidden to 401 UnAuthorized to more accurately reflect the correct HTTP status code for authentication failures. Changes include: Response c...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger 문서 커스텀을 위해 오픈소스를 직접 활용하였고, 오픈소스에 기여할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenApiCustomizer를 활용하여 직접 커스텀하여 Spring Security 기반의 로그인 API를 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실&amp;nbsp;소스보다&amp;nbsp;더&amp;nbsp;중요한것은&amp;nbsp;스웨거가&amp;nbsp;어떤&amp;nbsp;구조로&amp;nbsp;되어있는가를&amp;nbsp;파악해야&amp;nbsp;합니다. &lt;br /&gt;스웨거의&amp;nbsp;구조를&amp;nbsp;이해하시면&amp;nbsp;좀&amp;nbsp;더&amp;nbsp;커스텀하기&amp;nbsp;편할&amp;nbsp;수&amp;nbsp;있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 오픈 소스를 활용하면서 컨트리뷰트, 스웨거 구조를 확실하게 이해할 수 있었습니다.&lt;/p&gt;</description>
      <category>Spring</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/334</guid>
      <comments>https://byungil.tistory.com/334#entry334comment</comments>
      <pubDate>Wed, 18 Sep 2024 22:54:13 +0900</pubDate>
    </item>
    <item>
      <title>게시글 좋아요가 높은 순으로 정렬하여 출력할 때 발생한 문제와 해결 과정</title>
      <link>https://byungil.tistory.com/332</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://byungil.tistory.com/331&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://byungil.tistory.com/331&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 불러오는 쿼리와 마찬가지로, 게시글을 좋아요가 높은 순으로 정렬하여 불러올 때 속도가 매우 느렸고, OutOfMemory 장애가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 게시글과 마찬가지로 현재 어떠한 상황인지, 어떻게 해결하였는지 정리하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 좋아요 순으로 정렬하여 No-Offset 방식을 적용한 쿼리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 좋아요 순으로 정렬하여 No-Offset 방식을 활용하여 쿼리를 짰습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 쿼리부터 문제가 있었고 하나하나 문제점을 찾고 해결하겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 쿼리&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1108&quot; data-origin-height=&quot;198&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cwhHMu/btsIkFrvFua/hZupoRPMqD8aCOLe5Nghkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cwhHMu/btsIkFrvFua/hZupoRPMqD8aCOLe5Nghkk/img.png&quot; data-alt=&quot;쿼리를 이상하게 짰다..&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cwhHMu/btsIkFrvFua/hZupoRPMqD8aCOLe5Nghkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcwhHMu%2FbtsIkFrvFua%2FhZupoRPMqD8aCOLe5Nghkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;130&quot; data-origin-width=&quot;1108&quot; data-origin-height=&quot;198&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;쿼리를 이상하게 짰다..&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 문제점 (1) - 여러명이 동시에 요청을 하면, OOM (OutOfMemory) 발생으로 서버가 종료됩니다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 명이 요청하였을 때 문제가 발생하지 않았지만, 5명이 동시에 요청한다고 가정하고 테스트를 진행했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OOM으로 서버가 종료됨을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 ec2 프리티어로 용량이 매우 작은 상태인데, .size()때문에 너무 많은 데이터를 DB에서 동시에 조회하기 때문에 발생합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Jmeter&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;399&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ce30Ky/btsIniElVDt/z2mhYTPtuNhOyUqvJOEeFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ce30Ky/btsIniElVDt/z2mhYTPtuNhOyUqvJOEeFk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ce30Ky/btsIniElVDt/z2mhYTPtuNhOyUqvJOEeFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fce30Ky%2FbtsIniElVDt%2Fz2mhYTPtuNhOyUqvJOEeFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;309&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;399&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;그라파나로 확인하는 CPU Usage, Heap Used&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRQ8xd/btsInwvAB8V/Y5r7XQl1C2KD2SzYo2AMo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRQ8xd/btsInwvAB8V/Y5r7XQl1C2KD2SzYo2AMo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRQ8xd/btsInwvAB8V/Y5r7XQl1C2KD2SzYo2AMo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRQ8xd%2FbtsInwvAB8V%2FY5r7XQl1C2KD2SzYo2AMo0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;426&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배포 서버 로그&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1912&quot; data-origin-height=&quot;183&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Og3tJ/btsInOv0YHM/mA3kjld54cfiyK1e5nkxO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Og3tJ/btsInOv0YHM/mA3kjld54cfiyK1e5nkxO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Og3tJ/btsInOv0YHM/mA3kjld54cfiyK1e5nkxO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOg3tJ%2FbtsInOv0YHM%2FmA3kjld54cfiyK1e5nkxO0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;69&quot; data-origin-width=&quot;1912&quot; data-origin-height=&quot;183&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Jmeter 결과&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;618&quot; data-origin-height=&quot;557&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIG7E2/btsInkhPpWc/XwiLHDYKnnZo1kasLUu1h1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIG7E2/btsInkhPpWc/XwiLHDYKnnZo1kasLUu1h1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIG7E2/btsInkhPpWc/XwiLHDYKnnZo1kasLUu1h1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIG7E2%2FbtsInkhPpWc%2FXwiLHDYKnnZo1kasLUu1h1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;653&quot; data-origin-width=&quot;618&quot; data-origin-height=&quot;557&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결국 서버 다운&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1566&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dCfuOh/btsImIQVymT/uUnWSUPYE2OqfoNSfJ66xk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dCfuOh/btsImIQVymT/uUnWSUPYE2OqfoNSfJ66xk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dCfuOh/btsImIQVymT/uUnWSUPYE2OqfoNSfJ66xk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdCfuOh%2FbtsImIQVymT%2FuUnWSUPYE2OqfoNSfJ66xk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;361&quot; data-origin-width=&quot;1566&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 문제점 (2) - No Offset 방식은 정렬 기준이 되는 고유한 키를 가져야 합니다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 게시글을 불러올 때, WHERE 절을 이용하여 No-Offset 방식을 활용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WHERE 절에 PK 값인 postId를 활용하였지만, 지금 이 쿼리에서는 where절에 PK 값을 활용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 개수를 Join을 통하여 얻고 있고, 그러한 값은 고유한 값으로 WHERE절에 정의할 수 없었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 쿼리는 똑같은 게시글을 계속 불러오는 현상이 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;having절에 게시글 좋아요 마지막 값을 이용하려고 했던 제가 바보였습니다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 게시글에 대한 정의를 정확하게 할 수 없기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clc7UD/btsIjijL9go/93BXkJehVRbCXfSfklkdVK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clc7UD/btsIjijL9go/93BXkJehVRbCXfSfklkdVK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clc7UD/btsIjijL9go/93BXkJehVRbCXfSfklkdVK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/clc7UD/btsIjijL9go/93BXkJehVRbCXfSfklkdVK/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;332&quot; height=&quot;720&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 문제점 (3) - 게시글을 불러오는데 시간이 너무 오래 걸리거나 장애가 발생합니다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 값을 계속 불러오고, 좋아요 개수가 많을 수록 게시글을 불러올 때, 시간이 오래 걸림을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 위에 제가 이전에 작성했던 글과 마찬가지로, getPostLikes().size()를 이용하여 개수를 조회했기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Query에서 사용했던 COUNT(pl.post_like_id)를 전혀 활용하지 못하고, PostResponse.of에서 마찬가지로 .size()로 모든 게시글을 DB에서 불러와 list에 담고 그 사이즈를 출력했기 때문에 발생한 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;좋아요 개수로 정렬하여 Post를 불러왔지만, 개수는 사용하지 못하는 상황이고 그 개수는 다시 DB를 조회하여 list에 담는 치명적인 실수입니다.&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 기준 - 5s&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 서버 기준 좋아요 순으로 정렬하여 값을 불러오는 데 6s가 걸립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요가 많으면 마찬가지로 OutOfMemory 에러가 발생합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;694&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Bgal7/btsIkE0q4HN/KLmt9FV6o1dQP2JXLN8J00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Bgal7/btsIkE0q4HN/KLmt9FV6o1dQP2JXLN8J00/img.png&quot; data-alt=&quot;서버 기준 조회 속도 6s&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Bgal7/btsIkE0q4HN/KLmt9FV6o1dQP2JXLN8J00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBgal7%2FbtsIkE0q4HN%2FKLmt9FV6o1dQP2JXLN8J00%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;660&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;694&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;서버 기준 조회 속도 6s&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 개선하는지 알아보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 고민 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 .size()를 없애고 좋아요 개수 쿼리를 따로 하나 빼서 적용한다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 단건 조회에서는 count 쿼리를 하나 만들어서 .size()대신, 개수를 직접 넣어줬습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이런 방법은 List를 조회할 때 적용할 수 없었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1194&quot; data-origin-height=&quot;287&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NFXGC/btsIkse48pQ/pWWAmUwate9eV6kJ58L8aK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NFXGC/btsIkse48pQ/pWWAmUwate9eV6kJ58L8aK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NFXGC/btsIkse48pQ/pWWAmUwate9eV6kJ58L8aK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNFXGC%2FbtsIkse48pQ%2FpWWAmUwate9eV6kJ58L8aK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;174&quot; data-origin-width=&quot;1194&quot; data-origin-height=&quot;287&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 로직에서 PostListResponse 값에 count한 값을 넣어줄 수 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 이 방법은 불가능합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 DTO Projection을 전체 활용한다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostListResponse에는 컬럼이 13개가 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO Projection을 활용하기에는 컬럼이 너무 많고 오히려 가독성을 안 좋게 만든다고 판단하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO Projection은 적용하지 않았습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 PostListResponse 분석&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1014&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bI1smb/btsIlA4KqTl/9IzGkTGeMhSxJiAkYzfefK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bI1smb/btsIlA4KqTl/9IzGkTGeMhSxJiAkYzfefK/img.png&quot; data-alt=&quot;getPostLikes()로 인해 불필요한 쿼리 발생&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bI1smb/btsIlA4KqTl/9IzGkTGeMhSxJiAkYzfefK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbI1smb%2FbtsIlA4KqTl%2F9IzGkTGeMhSxJiAkYzfefK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;400&quot; data-origin-width=&quot;1014&quot; data-origin-height=&quot;560&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;getPostLikes()로 인해 불필요한 쿼리 발생&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getPostLikes()를 이용하여 likeCount, isLike값을 불러왔기 때문에 불필요한 쿼리가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 적을 때는 문제가 없지만, 데이터가 많으면 성능, 서비스 장에 등 여러가지 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단, likeCount와 isLike를 먼저 개선하는 과정입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 좋아요 여부, 좋아요 개수 쿼리 분리 후 합치기&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1337&quot; data-origin-height=&quot;462&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6JHzT/btsIkzZS4kl/RcA5ifa5v2K41sZ7D8Wz5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6JHzT/btsIkzZS4kl/RcA5ifa5v2K41sZ7D8Wz5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6JHzT/btsIkzZS4kl/RcA5ifa5v2K41sZ7D8Wz5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6JHzT%2FbtsIkzZS4kl%2FRcA5ifa5v2K41sZ7D8Wz5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;251&quot; data-origin-width=&quot;1337&quot; data-origin-height=&quot;462&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리를 분리하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 게시글을 불러올 때, 그 게시글에 대한 좋아요 개수와 여부는 따로 쿼리가 날라가는 형식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 로직을 구성하였을 때, 성능은 기존보다 훨씬 좋아졌음을 알 수 있습니다. &lt;b&gt;성능 50% 향상&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;245&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNu4cf/btsIk1hxUY5/KQfqtpp16A0TLkaDW7sQck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNu4cf/btsIk1hxUY5/KQfqtpp16A0TLkaDW7sQck/img.png&quot; data-alt=&quot;로컬 기준 5s -&amp;amp;gt; 2.5s로(50% 향상) 성능 개선&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNu4cf/btsIk1hxUY5/KQfqtpp16A0TLkaDW7sQck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNu4cf%2FbtsIk1hxUY5%2FKQfqtpp16A0TLkaDW7sQck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;190&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;245&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;로컬 기준 5s -&amp;gt; 2.5s로(50% 향상) 성능 개선&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배포 서버&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;성능 약 58% 향상&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;554&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AB7cb/btsIoLylfRE/6CBHkjTOYF8l56CEgPK2dK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AB7cb/btsIoLylfRE/6CBHkjTOYF8l56CEgPK2dK/img.png&quot; data-alt=&quot;ec2 프리티어 6s -&amp;amp;gt; 2.5s로(58% 향상) 성능 개선&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AB7cb/btsIoLylfRE/6CBHkjTOYF8l56CEgPK2dK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAB7cb%2FbtsIoLylfRE%2F6CBHkjTOYF8l56CEgPK2dK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;423&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;554&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ec2 프리티어 6s -&amp;gt; 2.5s로(58% 향상) 성능 개선&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여전히 느린 속도를 기록하였고, 게시글을 불러오는 개수만큼 count쿼리와 exists 쿼리가 발생하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;845&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lIVf2/btsIkjbV0eW/WUfLxddHoabM1EKdZfwmP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lIVf2/btsIkjbV0eW/WUfLxddHoabM1EKdZfwmP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lIVf2/btsIkjbV0eW/WUfLxddHoabM1EKdZfwmP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlIVf2%2FbtsIkjbV0eW%2FWUfLxddHoabM1EKdZfwmP0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;1230&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;845&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Jmeter 쓰레드5 테스트&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;507&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lKgTz/btsInOixE0s/gAmeWcXUP09u87FXAzbgB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lKgTz/btsInOixE0s/gAmeWcXUP09u87FXAzbgB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lKgTz/btsInOixE0s/gAmeWcXUP09u87FXAzbgB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlKgTz%2FbtsInOixE0s%2FgAmeWcXUP09u87FXAzbgB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;368&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;507&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확실히 Heap Used와 CPU Usage가 안정적임을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 스레드 30개로 테스트 한 결과 -&amp;gt; 커넥션 풀 타임아웃 발생&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cq8l5h/btsImE2pdrY/Kdozs4V5w7tfqv2ENi8JMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cq8l5h/btsImE2pdrY/Kdozs4V5w7tfqv2ENi8JMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cq8l5h/btsImE2pdrY/Kdozs4V5w7tfqv2ENi8JMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcq8l5h%2FbtsImE2pdrY%2FKdozs4V5w7tfqv2ENi8JMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;87&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리를 처리하는 속도는 2.5초 이상이 소모됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커넥션 풀은 최대 10개로 셋팅되어있고, 10개가 사용중일 때, 나머지 20개는 대기 상태이며 대기 상태가 30초가 넘었을 때 타임아웃이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커넥션 풀 사이즈를 늘려서 해결하는 것보다 쿼리 속도를 줄여서 해결하는 방식이 현재 상황에 더 적합하다고 판단하였기 때문에 추가적으로 개선해보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 게시글 개수만큼 발생하던 쿼리를 in 쿼리를 사용하여 한 번에 조회하는 방식으로 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 쿼리는 불러오는 게시글 하나당 - 게시글 좋아요 개수 조회 쿼리, 게시글 좋아요 여부 쿼리가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 목록을 10개 불러온다고 가정하면, 게시글 목록 조회 10개 + 좋아요 개수 10 + 좋아요 여부 10&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 30번 SELECT문이 실행되고, 지금 당장 문제는 없지만 네트워크 오버헤드가 각 쿼리마다 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 연결을 여러 번 연결하게 되고, 도중에 문제가 생기면 장애로 이어지기 때문에 in 쿼리와 DTO Projection을 활용하여 횟수를 줄이는 방법을 적용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDTAUI/btsIpg7RomU/GAOOxnsxHZ2chN26P8xkr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDTAUI/btsIpg7RomU/GAOOxnsxHZ2chN26P8xkr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDTAUI/btsIpg7RomU/GAOOxnsxHZ2chN26P8xkr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDTAUI%2FbtsIpg7RomU%2FGAOOxnsxHZ2chN26P8xkr1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;925&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;823&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HashMap을 이용하여 좋아요 여부를 저장하였습니다.&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;List&amp;lt;Long&amp;gt; postIds = posts.stream()
        .map(Post::getId)
        .toList();
List&amp;lt;PostLike&amp;gt; byUserAndPostIds = postLikeRepository.findByUserAndPostIds(user, postIds);
Map&amp;lt;Long, Boolean&amp;gt; likeMap = new HashMap&amp;lt;&amp;gt;();
for (PostLike like : byUserAndPostIds) {
    likeMap.put(like.getPost().getId(), true);
}

for (Long postId : postIds) {
    likeMap.putIfAbsent(postId, false);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HashMap을 이용하여 좋아요 개수를 저장하였습니다.&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;List&amp;lt;PostLikeCountResponse&amp;gt; likeCount = postLikeRepository.findByPostIdsAndLikeCount(postIds);
Map&amp;lt;Long, Long&amp;gt; likeCountMap = new HashMap&amp;lt;&amp;gt;();
for (PostLikeCountResponse postLikeCountResponse : likeCount) {
    likeCountMap.put(postLikeCountResponse.getPostId(), postLikeCountResponse.getLikeCount());
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1176&quot; data-origin-height=&quot;156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sm2KE/btsIoQn5GIs/SIS55Uv5Gr5aQkkyhbBmtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sm2KE/btsIoQn5GIs/SIS55Uv5Gr5aQkkyhbBmtk/img.png&quot; data-alt=&quot;불러온 값을 활용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sm2KE/btsIoQn5GIs/SIS55Uv5Gr5aQkkyhbBmtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fsm2KE%2FbtsIoQn5GIs%2FSIS55Uv5Gr5aQkkyhbBmtk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;96&quot; data-origin-width=&quot;1176&quot; data-origin-height=&quot;156&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;불러온 값을 활용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;in 쿼리를 사용한다고 해서 조회 속도가 빨라지는 것은 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근본적인 해결을 위해 현재 게시글 목록을 불러오는 쿼리를 분석하여 어떤 부분에서 시간이 오래걸리는지 확인해봤습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Explain Analyze를 통한 쿼리 분석&lt;/h2&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;explain analyze
select p.*, COUNT(pl.post_like_id) as LikeCount
from post p inner join post_like pl
on p.post_id = pl.post_id
group by p.post_id
having LikeCount &amp;lt; 1000000
order by LikeCount Desc, p.post_id Desc
limit 10;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 쿼리는 Post와 PostLike를 조인하고, postId를 그룹화한 후, 정렬을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;explain analyze를 통하여 어떻게 쿼리가 실행되고 있는지 확인해봤습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1294&quot; data-origin-height=&quot;239&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d06Prq/btsIpW1WzXE/hHEjz8RvybqcF1f83UmLMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d06Prq/btsIpW1WzXE/hHEjz8RvybqcF1f83UmLMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d06Prq/btsIpW1WzXE/hHEjz8RvybqcF1f83UmLMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd06Prq%2FbtsIpW1WzXE%2FhHEjz8RvybqcF1f83UmLMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;134&quot; data-origin-width=&quot;1294&quot; data-origin-height=&quot;239&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 Post에는 45만개의 데이터가 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;45만개의 데이터를 정렬&lt;/b&gt;&lt;/span&gt;하는 과정에서 시간이 오래 걸림을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 정렬하는 과정이 없으면 좋아요가 많은 순으로 데이터를 불러올 수 없는 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 반정규화를 통해 LikeCount를 Post 엔티티에 컬럼으로 추가하였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 반정규화 - Post Entity에 LikeCount 컬럼 추가&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;alter table post add like_count INT;

update post set like_count = (
    select count(*)
    from post_like
    where post_like.post_id = post.post_id
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;464&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rNpy7/btsIq91xDMr/qHfgC4KkgkJZckPFShjiz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rNpy7/btsIq91xDMr/qHfgC4KkgkJZckPFShjiz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rNpy7/btsIq91xDMr/qHfgC4KkgkJZckPFShjiz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrNpy7%2FbtsIq91xDMr%2FqHfgC4KkgkJZckPFShjiz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;500&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;464&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;563&quot; data-origin-height=&quot;259&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bymTrh/btsIsyzmE9R/CVkL7WNhZlSKmIxC7PVBtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bymTrh/btsIsyzmE9R/CVkL7WNhZlSKmIxC7PVBtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bymTrh/btsIsyzmE9R/CVkL7WNhZlSKmIxC7PVBtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbymTrh%2FbtsIsyzmE9R%2FCVkL7WNhZlSKmIxC7PVBtk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;334&quot; data-origin-width=&quot;563&quot; data-origin-height=&quot;259&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 잘 들어간 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 Join, Group by, COUNT(), 대용량 데이터 정렬 연산 빠진 쿼리로 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 쿼리에는 Join, Group by, COUNT(), 대용량 데이터 정렬이 존재했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 현재 반정규화를 통하여 좋아요 개수 컬럼이 존재하기 때문에 위에 나와있는 연산이 다 빠질 수 있게 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1118&quot; data-origin-height=&quot;118&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfrcgt/btsIry08XKH/AXBjXkPNtzKjaCHLxwx5tK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfrcgt/btsIry08XKH/AXBjXkPNtzKjaCHLxwx5tK/img.png&quot; data-alt=&quot;짧아진 것을 확인할 수 있습니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfrcgt/btsIry08XKH/AXBjXkPNtzKjaCHLxwx5tK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbfrcgt%2FbtsIry08XKH%2FAXBjXkPNtzKjaCHLxwx5tK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;77&quot; data-origin-width=&quot;1118&quot; data-origin-height=&quot;118&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;짧아진 것을 확인할 수 있습니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 2.5s -&amp;gt; 512ms로 조회 성능 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2.5초에서 512ms로 조회 성능을 개선하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;962&quot; data-origin-height=&quot;651&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHS9Yc/btsIsK0EPrK/12ZSTF3nnhaIG7O1DXImFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHS9Yc/btsIsK0EPrK/12ZSTF3nnhaIG7O1DXImFk/img.png&quot; data-alt=&quot;성능 약 79% 향상&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHS9Yc/btsIsK0EPrK/12ZSTF3nnhaIG7O1DXImFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHS9Yc%2FbtsIsK0EPrK%2F12ZSTF3nnhaIG7O1DXImFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;491&quot; data-origin-width=&quot;962&quot; data-origin-height=&quot;651&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;성능 약 79% 향상&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 엔티티에 컬럼을 추가하였기 때문에, 기존에 사용하던 in쿼리를 활용하여 postId 값으로 좋아요 개수 COUNT 쿼리를 날릴 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.4 수정한 쿼리 Explain Analyze, 아직도 Sort 연산 발생&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;explain analyze
select *
from post
order by like_count desc, post_id desc
limit 10;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 쿼리는 Using temporary와 Using filesort가 발생했던 쿼리를 개선하여 성능을 향상시켰습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 쿼리를 다시 explain 하였더니 여전히 Using filesort가 발생하였고, 45만개 데이터를 Sort하고 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1165&quot; data-origin-height=&quot;113&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqEm9c/btsIsYYEYwK/97hWXc57KVq1qJHpaPZvXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqEm9c/btsIsYYEYwK/97hWXc57KVq1qJHpaPZvXK/img.png&quot; data-alt=&quot;아직도 Sort연산 발생&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqEm9c/btsIsYYEYwK/97hWXc57KVq1qJHpaPZvXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqEm9c%2FbtsIsYYEYwK%2F97hWXc57KVq1qJHpaPZvXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;70&quot; data-origin-width=&quot;1165&quot; data-origin-height=&quot;113&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;아직도 Sort연산 발생&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능을 더 개선하기 위해서 Sort연산이 발생하면 안 되기 때문에, 인덱스를 적절하게 설정하여 Sort 연산을 생략하겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 인덱스 생성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 LikeCount 인덱스 생성 후 Sort 연산 생략&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;501&quot; data-origin-height=&quot;40&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSY5S4/btsIqRfPgqu/cOKOrs9vOwdHK1vzMw9HQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSY5S4/btsIqRfPgqu/cOKOrs9vOwdHK1vzMw9HQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSY5S4/btsIqRfPgqu/cOKOrs9vOwdHK1vzMw9HQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSY5S4%2FbtsIqRfPgqu%2FcOKOrs9vOwdHK1vzMw9HQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;58&quot; data-origin-width=&quot;501&quot; data-origin-height=&quot;40&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Limit 연산이 존재하기 때문에, 인덱스를 이용하면 정렬된 순서로 데이터를 빠르게 검색할 수 있으므로 정렬 연산을 줄일 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1183&quot; data-origin-height=&quot;89&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWavtf/btsIre9LAlY/NFBCuCWSlxUhkuoIJkfuIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWavtf/btsIre9LAlY/NFBCuCWSlxUhkuoIJkfuIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWavtf/btsIre9LAlY/NFBCuCWSlxUhkuoIJkfuIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWavtf%2FbtsIre9LAlY%2FNFBCuCWSlxUhkuoIJkfuIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;55&quot; data-origin-width=&quot;1183&quot; data-origin-height=&quot;89&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.2 인덱스 활용으로 512ms -&amp;gt; 123ms로 성능 개선&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EXoub/btsIqd4NhTm/k7nh2VTak2x2oOAaDijsq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EXoub/btsIqd4NhTm/k7nh2VTak2x2oOAaDijsq1/img.png&quot; data-alt=&quot;성능 약 76% 향상&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EXoub/btsIqd4NhTm/k7nh2VTak2x2oOAaDijsq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEXoub%2FbtsIqd4NhTm%2Fk7nh2VTak2x2oOAaDijsq1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;503&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;660&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;성능 약 76% 향상&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 활용하여 정렬된 순서로 데이터를 빠르게 검색할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스로 인하여 데이터 전체 검색 후 정렬 -&amp;gt; 정렬되어있는 데이터 사용으로 바뀌었기 때문에 조회 속도가 더 빨라질 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 개수를 이용하여 정렬을 해야 하기 때문에 Join을 통한 연산은 비용이 너무 많이 드는 것을 알 수 있었고, 반정규화를 통하여 개선할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LikeCount를 컬럼으로 뺀다는 것이 항상 장점만 존재하는 것은 아닙니다. 데이터 정합성 문제가 발생할 수 있고, 작성한 코드에 따라 데드락도 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상황에 맞게 해결하는 방법을 찾는게 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 반정규화를 통하여 성능을 개선하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 작성한 쿼리는 5s,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 여부와 개수 쿼리 분리 후 2.5s,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 개수와 여부 조회 in을 활용하여 네트워크 오버헤드를 줄임,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Post Entity에 LikeCount 컬럼을 추가하여 512ms,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 생성으로 Sort연산을 생략하여 123ms 까지 성능을 개선하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 5s -&amp;gt; 123ms 성능 약 97% 향상되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/332</guid>
      <comments>https://byungil.tistory.com/332#entry332comment</comments>
      <pubDate>Mon, 1 Jul 2024 21:55:27 +0900</pubDate>
    </item>
    <item>
      <title>게시글을 조회할 때, 좋아요 개수가 많으면 왜 느리거나 에러가 발생할까?</title>
      <link>https://byungil.tistory.com/331</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 불러오는 쿼리를 개선하기 위해 좋아요 데이터를 많이 넣어보다가 좋아요 데이터가 많아지면 조회 속도가 매우 느려지는 것을 확인했고, 더 많은 데이터를 넣었을 때 Java heap space 에러가 발생하여 해결 방법을 정리하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1088&quot; data-origin-height=&quot;40&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mO01U/btsIg47pV7a/wCzlCwSOeMSgJneVHIwWA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mO01U/btsIg47pV7a/wCzlCwSOeMSgJneVHIwWA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mO01U/btsIg47pV7a/wCzlCwSOeMSgJneVHIwWA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmO01U%2FbtsIg47pV7a%2FwCzlCwSOeMSgJneVHIwWA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;27&quot; data-origin-width=&quot;1088&quot; data-origin-height=&quot;40&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 현재 상황&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 Post Entity, PostLike Entity&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1091&quot; data-origin-height=&quot;734&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVvZSV/btsIg8Wi1FD/B9lKLo38Jk7FIn2Opcb1j1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVvZSV/btsIg8Wi1FD/B9lKLo38Jk7FIn2Opcb1j1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVvZSV/btsIg8Wi1FD/B9lKLo38Jk7FIn2Opcb1j1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVvZSV%2FbtsIg8Wi1FD%2FB9lKLo38Jk7FIn2Opcb1j1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;488&quot; data-origin-width=&quot;1091&quot; data-origin-height=&quot;734&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 PostService - getPost()&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;777&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wQpCk/btsIhOJvf5G/q0IkGlBKskG30kJ2r1LWIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wQpCk/btsIhOJvf5G/q0IkGlBKskG30kJ2r1LWIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wQpCk/btsIhOJvf5G/q0IkGlBKskG30kJ2r1LWIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwQpCk%2FbtsIhOJvf5G%2Fq0IkGlBKskG30kJ2r1LWIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;334&quot; data-origin-width=&quot;777&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 PostResponse.of()&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;874&quot; data-origin-height=&quot;497&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AFtdh/btsIhcK2AK3/KDCTic2BTCdIPLxZVyFrQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AFtdh/btsIhcK2AK3/KDCTic2BTCdIPLxZVyFrQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AFtdh/btsIhcK2AK3/KDCTic2BTCdIPLxZVyFrQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAFtdh%2FbtsIhcK2AK3%2FKDCTic2BTCdIPLxZVyFrQ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;412&quot; data-origin-width=&quot;874&quot; data-origin-height=&quot;497&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 게시글에 좋아요가 478,416개 있는 상황에서 단건 조회 응답 시간 - 9s&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;787&quot; data-origin-height=&quot;262&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/49E6J/btsIihxPKsP/51qZjBoEI48a4xp1cpHnUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/49E6J/btsIihxPKsP/51qZjBoEI48a4xp1cpHnUK/img.png&quot; data-alt=&quot;9초가 걸린다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/49E6J/btsIihxPKsP/51qZjBoEI48a4xp1cpHnUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F49E6J%2FbtsIihxPKsP%2F51qZjBoEI48a4xp1cpHnUK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;241&quot; data-origin-width=&quot;787&quot; data-origin-height=&quot;262&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;9초가 걸린다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.5 발생하는 쿼리&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;496&quot; data-origin-height=&quot;717&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUrqtO/btsIiOhIBgC/W1MeXDkem2kyrJMFDiEzGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUrqtO/btsIiOhIBgC/W1MeXDkem2kyrJMFDiEzGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUrqtO/btsIiOhIBgC/W1MeXDkem2kyrJMFDiEzGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUrqtO%2FbtsIiOhIBgC%2FW1MeXDkem2kyrJMFDiEzGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;1048&quot; data-origin-width=&quot;496&quot; data-origin-height=&quot;717&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostLike를 전체 조회하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 발생 원인&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 PostResponse 코드 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발생원인은 PostResponse에서 찾을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;post.getPostLikes().size() 쿼리에서 문제가 발생합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Post와 연관되어있는 PostLike를 DB에서 모두 조회한 후, list에 담고 해당 list의 사이즈를 반환하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 .size()를 사용하면 count쿼리가 발생하는 것으로만 생각했는데, 해당 상황을 겪고 발생하는 쿼리를 보고 문제가 여기서 발생했음을 알 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostLike를 모두 select하여 47만건을 가져오기 때문에 시간이 매우 오래 걸리는 것을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬인 경우 9초가 걸렸지만 게시글을 불러올 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 배포 환경에서는 심각한 장애가 발생합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 OutOfMemoryError&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1109&quot; data-origin-height=&quot;68&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjBsgU/btsIihEBLNZ/4tBQlKbF88dYhsVQh55gx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjBsgU/btsIihEBLNZ/4tBQlKbF88dYhsVQh55gx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjBsgU/btsIihEBLNZ/4tBQlKbF88dYhsVQh55gx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjBsgU%2FbtsIihEBLNZ%2F4tBQlKbF88dYhsVQh55gx0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;44&quot; data-origin-width=&quot;1109&quot; data-origin-height=&quot;68&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 환경에 비해 배포 환경은 용량이 작습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 힙 스페이스 부족으로 OutOfMemoryError라는 치명적인 장애가 발생할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 count 쿼리를 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Repository에서 직접 Count 쿼리를 만들어서 Response 값에 넣어주는 방법을 선택했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;251&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmMWiM/btsIiO9Rvd2/lwpTQgzybcOvyrjDeDxSsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmMWiM/btsIiO9Rvd2/lwpTQgzybcOvyrjDeDxSsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmMWiM/btsIiO9Rvd2/lwpTQgzybcOvyrjDeDxSsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmMWiM%2FbtsIiO9Rvd2%2FlwpTQgzybcOvyrjDeDxSsK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;227&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;251&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;count 쿼리를 이용하여 불필요한 select을 최소화 하였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ecUzJu/btsIhf8FMRi/JaUAhMA8cCAlIFk5odHFy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ecUzJu/btsIhf8FMRi/JaUAhMA8cCAlIFk5odHFy0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ecUzJu/btsIhf8FMRi/JaUAhMA8cCAlIFk5odHFy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FecUzJu%2FbtsIhf8FMRi%2FJaUAhMA8cCAlIFk5odHFy0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;337&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;눈에 띄게 개선 되었음을 알 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 결론&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;로컬 환경&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 개수가 478,416일 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;9s -&amp;gt; 763ms로 로컬 환경에서 8초 이상 속도가 개선 되었음을 확인할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 개수가 178,416일 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3.6s -&amp;gt; 160ms로 3.5초 이상 속도가 개선되었음을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배포 환경&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 환경에서는 OutOfMemoryError가 발생하였던 것을 해결할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 개수가 178,416개일 때&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5ms -&amp;gt; 200ms로 4.8초 이상 개선되었음을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/331</guid>
      <comments>https://byungil.tistory.com/331#entry331comment</comments>
      <pubDate>Sat, 29 Jun 2024 21:16:56 +0900</pubDate>
    </item>
    <item>
      <title>WebScoket 실시간 매칭 시스템 - 동시성 문제 발생과 해결 과정</title>
      <link>https://byungil.tistory.com/330</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹소켓을 이용하여 실시간 매칭 시스템을 구현하고 동시에 여러 사용자가 접근하는 테스트를 하는 여러 에러를 겪었고, 서비스에 큰 문제가 생길 수 있기 때문에 해결하는 과정을 정리하였습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 동시성 문제가 어떻게 발생할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시성 문제가 발생하는 상황을 시뮬레이션 해보겠습니다. 이 상황에서 여자 1명이 대기 중이고, 남자 2명이 동시에 웹소켓 매칭 시스템에 접속하면 어떤 문제가 발생할 수 있는지 설명하겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 상황 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 여자 사용자 A가 매칭 대기 중&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 남자 사용자 B, C가 동시에 웹소켓 매칭 시스템에 접속&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 시스템은 1:1 매칭을 해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 동시성 문제 시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기 상태 [A]이며 B, C가 동시에 매칭 요청을 보냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 B의 매칭 요청 처리 -&amp;gt; A를 선택하여 B와 매칭을 시도합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 C의 매칭 요청 처리 -&amp;gt; A와 B는 매칭이 되었기 때문에 다른 사용자가 접속할 때 까지 대기합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;동시성 문제 발생 가능성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 동시성 문제가 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 B, C가 거의 동시에 매칭 요청을 보냈고, 시스템이 동시성을 제대로 처리하지 못한다면 어떻게 될까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 B와 C는 사용자 A와 매칭되려고 시도할 수 있고 이 경우 A는 동시에 두 명과 매칭된 상태가 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 Jmeter를 이용하여 동시성 시나리오 설정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;416&quot; data-origin-height=&quot;521&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BE6E8/btsIcP18gsm/uzZoeLv7GWcgOCfMzZtPsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BE6E8/btsIcP18gsm/uzZoeLv7GWcgOCfMzZtPsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BE6E8/btsIcP18gsm/uzZoeLv7GWcgOCfMzZtPsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBE6E8%2FbtsIcP18gsm%2FuzZoeLv7GWcgOCfMzZtPsk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;908&quot; data-origin-width=&quot;416&quot; data-origin-height=&quot;521&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1번 남자와 2번 남자가 동시에 매칭 시스템에 접속하는 시나리오입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매칭 시스템에서 대기 중인 사람이 존재하고, 1번 남자 2번 남자가 동시에 접근하였을 때 어떤 상황이 발생하는지 알아보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. [TEXT_PARTIAL_WRITING] 문제 발생&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 발생 지점을 살펴보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 발생 지점과 원인&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1151&quot; data-origin-height=&quot;547&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wHHvj/btsIaD93ifx/EC7xkWEpSo1zEHddzGe9aK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wHHvj/btsIaD93ifx/EC7xkWEpSo1zEHddzGe9aK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wHHvj/btsIaD93ifx/EC7xkWEpSo1zEHddzGe9aK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwHHvj%2FbtsIaD93ifx%2FEC7xkWEpSo1zEHddzGe9aK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;345&quot; data-origin-width=&quot;1151&quot; data-origin-height=&quot;547&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매칭이 되었을 때, 사용자에게 상대방 정보를 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sendMessage를 통하여 서로의 정보를 제공하는 과정에서 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 스레드나 비동기 함수가 동시에 같은 웹소켓 연결에 접근하여 메세지를 보내려고 할 때 이런 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에 상황을 연결시키면 A와 B는 서로에게 sendMessage를 통하여 정보를 전달하는 과정에서 A와 C도 sendMessage에 동시에 접근하여 발생한 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이러한 동시성 문제를 해결하기 위해 매칭 로직에 동기화 메커니즘을 도입하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java의 synchronized를 이용하여 동시 접근을 막아 해결하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;478&quot; data-origin-height=&quot;171&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beCZIL/btsIcy7rkjN/2ukLfy1Kgtx1J1kbyzSMO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beCZIL/btsIcy7rkjN/2ukLfy1Kgtx1J1kbyzSMO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beCZIL/btsIcy7rkjN/2ukLfy1Kgtx1J1kbyzSMO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeCZIL%2FbtsIcy7rkjN%2F2ukLfy1Kgtx1J1kbyzSMO0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;259&quot; data-origin-width=&quot;478&quot; data-origin-height=&quot;171&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TEXT_PARTIAL_WRITING에 대한 문제는 해결하였지만,&amp;nbsp; 맨처음 시나리오에서 예상했던 동시성 문제가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 동시성 문제 발생&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시성 문제가 발생하였을 때 어떻게 되는지 확인해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래라면, 먼저 접속한 1번 유저랑만 매칭이 되어야 하지만 동시성 문제로 인하여 2명과 매칭이 된 상황입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;275&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/db62TB/btsIcoDRkGD/5tS44bKcAk7Uw3S1KcQtaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/db62TB/btsIcoDRkGD/5tS44bKcAk7Uw3S1KcQtaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/db62TB/btsIcoDRkGD/5tS44bKcAk7Uw3S1KcQtaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdb62TB%2FbtsIcoDRkGD%2F5tS44bKcAk7Uw3S1KcQtaK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;208&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;275&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 synchronized를 사용하였는데 왜 발생할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java의 synchronized는 여러 유형의 블록에 사용될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 현재 메소드 안의 동기화 블록을 만들어서 사용하였기 때문에 sendMessage에 대한 동기화만 처리할 수 있고 메소드 자체 동기화 처리가 된 것이 아니기 때문에 문제가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 해결하기 위해서 메소드를 동기화하여 해결하였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 메소드를 동기화하여 해결&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1115&quot; data-origin-height=&quot;125&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJAGoW/btsIa7JQySo/HD3JbyuJsA9ssqb8EwtIXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJAGoW/btsIa7JQySo/HD3JbyuJsA9ssqb8EwtIXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJAGoW/btsIa7JQySo/HD3JbyuJsA9ssqb8EwtIXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJAGoW%2FbtsIa7JQySo%2FHD3JbyuJsA9ssqb8EwtIXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;81&quot; data-origin-width=&quot;1115&quot; data-origin-height=&quot;125&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메소드를 동기화 하여 동시에 여러 사용자와 매칭이 되는 상황을 막았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;295&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OrEPD/btsIchLEVU6/jz3qWEKb87Y2jIqSZHcBrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OrEPD/btsIchLEVU6/jz3qWEKb87Y2jIqSZHcBrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OrEPD/btsIchLEVU6/jz3qWEKb87Y2jIqSZHcBrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOrEPD%2FbtsIchLEVU6%2Fjz3qWEKb87Y2jIqSZHcBrK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;225&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;295&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized를 이용하여 동시성 문제를 해결할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러가지 방법이 존재하지만 이러한 방식을 적용한 이유는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.직관적이고 간편하기 때문에 이러한 방식을 적용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2.단일 서버 환경이기 때문에 적용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3.매칭 시스템이 오래 걸리지 않기 때문에 사용자가 많아도 충분하다고 판단되어 적용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기화 방식을 사용하였을 때 발생할 수 있는 문제점에 대해서는 다음 포스팅에서 알아보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/330</guid>
      <comments>https://byungil.tistory.com/330#entry330comment</comments>
      <pubDate>Tue, 25 Jun 2024 16:05:30 +0900</pubDate>
    </item>
    <item>
      <title>Scale-out 상황, 채팅 서비스 Redis 도입</title>
      <link>https://byungil.tistory.com/329</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에 내장된 Simple Message Broker는 서버 내부 메모리에서 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메세지 발행 시 서버가 다운 되어 메세지 전송을 실패하게 된다면, 인메모리 기반으로 동작하는 메세지 큐로 인해 메세지를 유실할 가능성이 높고, scale-out 상황에서 메세지를 못 받는 경우가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메세지를 받지 못하는 상황을 개선하기 위한 과정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 서버가 다른 경우 메세지 수신이 안 되는 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2개의 서버가 존재하고 각각 다른 서버에서 구독을 하고 있다는 상황을 가정하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내장된 SimpleMessageBroker를 사용하면 스프링 부트 서버의 내부 메모리에서 동작하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버간 채팅을 공유할 수 없는 상태입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;375&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kKVlA/btsH5W2mnwP/AzQOSSSyZgKBECBgAkMZOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kKVlA/btsH5W2mnwP/AzQOSSSyZgKBECBgAkMZOK/img.png&quot; data-alt=&quot;다른 서버에서 같은 지점을 구독 중인 상황&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kKVlA/btsH5W2mnwP/AzQOSSSyZgKBECBgAkMZOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkKVlA%2FbtsH5W2mnwP%2FAzQOSSSyZgKBECBgAkMZOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;299&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;375&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다른 서버에서 같은 지점을 구독 중인 상황&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 다른 경우 메세지를 수신할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채팅 전용 서버를 두고, 채팅 관련하여 모든 처리는 전용 서버에서 하면 해결할 수 있지만 여러 기능들을 분산 처리하기 위한 환경을 구성하였다고 가정했기 때문에 Redis를 도입하여 해결하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Redis Pub/Sub 적용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 Redis를 선택한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis는 STOMP 프로토콜을 지원하지 않지만, Redis의 Pub/Sub 기능을 통해 메세지 브로커로 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메세지를 전송한 후 삭제되며 실시간 데이터 처리에 적합하지만 메세지 전송 신뢰성을 보장하지 않는다는 단점이 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메세지 전용 브로커를 사용한다면 인프라 비용 증가 및 러닝 커브가 매우 높습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 저희 서비스에 대용량 트래픽이 없기 때문에 메세지 전용 브로커까지 필요 없었고, 더 빠르고 쉬운 Redis를 이용하여 충분히 해결할 수 있다고 판단하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 RedisConfig 코드 및 설명&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@Configuration
public class RedisConfig {

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer( // (1)
                                                                       RedisConnectionFactory connectionFactory,
                                                                       MessageListenerAdapter listenerAdapter,
                                                                       ChannelTopic channelTopic) {
        log.info(&quot;redisMessageListenerContainer&quot;);
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, channelTopic);
        return container;
    }

    @Bean
    public MessageListenerAdapter listenerAdapter(RedisMessageSubscriber subscriber) { // (2)
        log.info(&quot;listenerAdapter&quot;);
        return new MessageListenerAdapter(subscriber, &quot;onMessage&quot;);
    }

    @Bean
    public RedisTemplate&amp;lt;String, Object&amp;gt; redisTemplate
            (RedisConnectionFactory connectionFactory) { // (3)
        log.info(&quot;redisTemplate&quot;);
        RedisTemplate&amp;lt;String, Object&amp;gt; redisTemplate = new RedisTemplate&amp;lt;&amp;gt;();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer&amp;lt;&amp;gt;(String.class));
        return redisTemplate;
    }

    @Bean
    public ChannelTopic channelTopic() { // (4)
        log.info(&quot;channelTopic&quot;);
        return new ChannelTopic(&quot;chatroom&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(1) RedisMessageListenerContainer는 Redis의 Pub/Sub을 관리하는 컨테이너입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구독 대상이 되는 채널 (ChannelTopic)과 해당 채널에 메세지가 발행되었을 때 핸들링 하는 메소드(MessageListener)를 등록해줄 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(2) 실제 메세지를 처리하는 비즈니스 로직이 담긴 Bean을 추가해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(3) RedisTemplate 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(4) ChannelTopic 단일화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 MessageSubscriber 코드 및 설명&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class RedisMessageSubscriber implements MessageListener {
    private final ObjectMapper objectMapper;
    private final RedisTemplate redisTemplate;
    private final SimpMessagingTemplate messagingTemplate;

    /**
     * 여기서 메세지를 다시 구독자들에게 전송합니다.(레디스 pub/sub)
     */
    @Override
    public void onMessage(final Message message, final byte[] pattern) {
        try {
            String publishMessage = (String) redisTemplate.getStringSerializer().deserialize(message.getBody());
            ChatMessage chatResponseDto = objectMapper.readValue(publishMessage, ChatMessage.class);

            log.info(&quot;redisMessageSubscriber: data from {}&quot;, chatResponseDto);
            log.info(&quot;redisMessageSubscriber: data to {}&quot;, &quot;/topic/&quot; + chatResponseDto.getRoomId());
            messagingTemplate.convertAndSend(&quot;/topic/&quot; + chatResponseDto.getRoomId(), chatResponseDto);

        } catch (Exception e) {
            throw new RuntimeException();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 메세지를 발행합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버마다 메세지 리스너를 등록해두고, onMessage 메소드로 SimpleMessageBroker에 메세지를 보냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 서버에서 발행된 메세지도 onMessage를 타고 구독자에게 전달되는 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 결론&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;455&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D6lbp/btsH5MZ9VIv/uJ2tBNWCN9S0SUqWkef1I0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D6lbp/btsH5MZ9VIv/uJ2tBNWCN9S0SUqWkef1I0/img.png&quot; data-alt=&quot;메세지 수신 완료&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D6lbp/btsH5MZ9VIv/uJ2tBNWCN9S0SUqWkef1I0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD6lbp%2FbtsH5MZ9VIv%2FuJ2tBNWCN9S0SUqWkef1I0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;401&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;455&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;메세지 수신 완료&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 서버에서 메세지를 수신할 수 있음을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RedisTemplate의 convertAndSend 메소드는 메세지를 Listener로 전달하는 역할을 합니다. (ChannelTopic 을 이용하여)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;onMessage에서 SimpleMessageBroker로 직접 서부 내부의 메세지 브로커를 통해 메세지를 발행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1020&quot; data-origin-height=&quot;580&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SiF2P/btsH5BkimQj/ITS2CtoxlL6UddOMin8Fkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SiF2P/btsH5BkimQj/ITS2CtoxlL6UddOMin8Fkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SiF2P/btsH5BkimQj/ITS2CtoxlL6UddOMin8Fkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSiF2P%2FbtsH5BkimQj%2FITS2CtoxlL6UddOMin8Fkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;412&quot; data-origin-width=&quot;1020&quot; data-origin-height=&quot;580&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Channel을 이용하였기 때문에 다수의 서버일 경우 채팅을 공유할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 메세지 전용 브로커, Redis 각각 장단점이 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Redis를 이용하여 빠르고 쉽게 처리할 수 있다고 판단하여 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 더 나아가 메세지가 유실되면 안 되고, 트래픽이 많아질 경우 전용 브로커를 사용하여 개선할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/329</guid>
      <comments>https://byungil.tistory.com/329#entry329comment</comments>
      <pubDate>Thu, 20 Jun 2024 17:37:43 +0900</pubDate>
    </item>
    <item>
      <title>JPA: 알람 기능과 댓글 기능, 도메인 로직과 서비스 로직의 트랜잭션 분리 작업</title>
      <link>https://byungil.tistory.com/328</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작성된 게시글에 댓글을 작성하는 기능을 추가하였고, 알람 기능이 존재하지 않아 실시간으로 확인이 불가능하여 불편함을 겪는 사용자가 존재한다고 생각했습니다. 요구사항에 따라 알람 기능을 추가하면서 문제가 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 제가 짠 로직은 댓글 작성 로직과 알람 발송 로직이 하나의 트랜잭션 안에서 처리되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API - FCM과, Alarm Entity저장 그리고 댓글 작성을 하나로 처리하였을 때 발생할 수 있는 문제는 FCM과 알람 저장 기능에 문제가 생겼을 때 댓글 저장도 롤백되어버리는 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;비즈니스 중요도를 고려하면 사용자 편의를 위한 부가적인 알림 기능(서비스 로직)이 댓글 작성(도메인 로직)이라는 메인 기능에 영향을 미치는 것이 부자연스럽다고 판단해 트랜잭션 분리를 고려하게 되었습니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;1. 한 트랜잭션에서 처리가 될 때 문제 발생 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 CommentService 코드는 다음과 같습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public CommentResponse createComment(final Long postId, final CommentCreateRequest request, final String mobileNumber) {
    Post post = postRepository.getById(postId);

    User user = userRepository.getByMobileNumber(mobileNumber);

    Comment comment = commentRepository.save(request.toEntity(post, user));

    if (!post.getUser().getId().equals(user.getId())) {
        alarmService.createCommentAlarm(post.getId(), user.getId(), user.getNickname(), post.getUser(), comment.getId());

        NotificationReceiveResponse notificationReceiveResponse =
                notificationReceiveService.getNotificationReceive(post.getUser().getMobileNumber());

        if (notificationReceiveResponse.isFeedNotification()) {
            fcmService.sendNotificationWithComment(post.getUser(), user, request.getContent());
        }
    }

    return CommentResponse.of(comment, post, user);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public AlarmResponse createCommentAlarm(Long postId, Long fromUser, String fromUserNickname, User toUser, Long commentId) {
    Alarm alarm = Alarm.builder()
            .isRead(false)
            .alarmType(AlarmType.FEED)
            .commentId(commentId)
            .fromUser(fromUser)
            .content(fromUserNickname + &quot;님이 댓글을 남겼습니다.&quot;)
            .postId(postId)
            .user(toUser)
            .build();

    alarmRepository.save(alarm);
    return AlarmResponse.of(alarm);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void sendNotificationWithComment(final User postWriter, final User commentWriter, final String content) {
    Notification notification = Notification.builder()
            .setTitle(commentWriter.getNickname() + &quot;님이 댓글을 남겼습니다.&quot;)
            .setBody(content)
            .build();

    Message message = Message.builder()
            .setToken(postWriter.getDeviceToken())
            .setNotification(notification)
            .build();

    try {
        firebaseMessaging.send(message);
    } catch (FirebaseMessagingException e) {
        throw new RuntimeException(e);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. commentRepository.save()를 이용하여 댓글을 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 게시글 작성자와 댓글 작성자가 다르다면 Alarm을 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 게시글 작성자 app push 알람 동의가 되어있다면, fcm 서비스를 이용하여 앱 알람을 실시간으로 보내줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 AlarmService 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;createCommentAlarm 에서 예외가 발생했다고 가정하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;createCommentAlarm 에서 예외가 발생하면 createComment 메소드도 정상 실행이 되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 문제가 발생한 이유는 댓글 등록 트랜잭션과 알림 등록 트랜잭션이 분리되지 않았기 때문입니다. AlarmService의 createCommentAlarm() 메서드는 @Transactional 어노테이션을 가지고 있고, 전파 옵션은 디폴트인 propagation = Propagation.REQUIRED로 지정되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 AlarmService의 createCommentAlarm() 메서드는 CommentService의 createComment() 트랜잭션에 참여하게 되고, 두 트랜잭션은 하나의 물리 트랜잭션으로 묶이게 됩니다. 이때 동일한 물리 트랜잭션에는 같은 롤백 규칙이 적용되는데, 트랜잭션 롤백 규칙 기본값은 외부 트랜잭션에 참여한 내부 트랜잭션에서 RuntimeException이 발생하는 경우 rollback-only 마킹을 하는 것입니다. 따라서 CommentService의 createComment() 트랜잭션에서 예외가 발생하지 않았지만, 해당 트랜잭션이 완료되는 시점에 rollback-only 마킹으로 인해 최종적으로 물리 트랜잭션이 롤백 되는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 FcmService 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 AlarmService와 마찬가지로 하나의 물리 트랜잭션으로 묶이게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AlarmService같은 경우에 예외가 터질 확률이 높지 않다고 생각합니다. DB 근본적인 문제 발생이기 때문에 AlarmService에서 문제가 발생한다면 당연히 CommentService에도 똑같은 문제가 발생할 것이라고 예측할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나, FCM같은 경우에는 예측할 수 없는 예외가 존재합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;대표적으로 FCM 토큰 형식이 유효하지 않을 때 발생하는 예외가 존재합니다.&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1537&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lLevs/btsHVIW0Snm/qviEN04Kk6FuXUxSMUpNHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lLevs/btsHVIW0Snm/qviEN04Kk6FuXUxSMUpNHk/img.png&quot; data-alt=&quot;토큰 형식이 올바르지 않은 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lLevs/btsHVIW0Snm/qviEN04Kk6FuXUxSMUpNHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlLevs%2FbtsHVIW0Snm%2FqviEN04Kk6FuXUxSMUpNHk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;268&quot; data-origin-width=&quot;1537&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;토큰 형식이 올바르지 않은 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 서비스에서는 로그아웃 하였을 때 User의 토큰 값을 null로 수정하여 알람을 받지 않는 식으로 구현하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인을 시도할 때, 기기에 맞게 deviceToken값을 업데이트 하고 있지만, 해당 유저가 로그인을 장시간 하지 않은 상태에서 댓글 알림을 받는다면 deviceToken 값이 올바르지 않은 상태였을 때 예외가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 예측할 수 없는 예외가 발생하여 댓글 작성에 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제는 댓글뿐만 아니라 서비스 전체적인 기능에서 발생할 수 있는 예외입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FCM은 외부 API이기 때문에 외부 통신에 문제가 발생하는 예외가 존재합니다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 경우 서비스 운영쪽에서 바로 해결할 수 없는 문제이기 때문에 심각한 서비스 장애로 이어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제들을 해결하기 위해 어떠한 방법들이 존재하는지 어떤 방법으로 해결했는지 알아보겠습니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 기존 방식 문제점 - Comment 엔티티에 알람 발송 여부 컬럼 추가&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 기존 Comment Entity 코드&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
public class Comment extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;post_id&quot;)
    private Post post;

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

    private String content;

    private boolean alarmStatus;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알람 발송 여부에 대한 컬럼이 존재했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;댓글 작성과 알람은 실시간이 보장되지 않아도 된다고 판단하여 트랜잭션을 분리하지 않고 Comment에 알람 발송 여부 컬럼을 만들어 스프링 @Scheduled 기능을 활용하여 5~10초 마다 false 값들을 알람 전송하도록 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 오히려 복잡해지는 서비스 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommentService에서 CommentRepository.save()를 사용할 때, 코드가 복잡하다는 것을 알 수 있습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기존 댓글 작성 코드&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public CommentResponse createComment(final Long postId, final CommentCreateRequest request, final String mobileNumber) {
    log.info(&quot;createComment&quot;);

    Post post = postRepository.getById(postId);

    User user = userRepository.getByMobileNumber(mobileNumber);

    Comment comment = commentRepository.save(request.toEntity(post, user));
    return CommentResponse.of(comment, post, user);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;복잡한 댓글 작성&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public CommentResponse createComment(final Long postId, final CommentCreateRequest request, final String mobileNumber) {
    log.info(&quot;createComment&quot;);

    Post post = postRepository.getById(postId);

    User user = userRepository.getByMobileNumber(mobileNumber);
    boolean result = post.getUser().getId().equals(user.getId()); // 게시글 작성자와 댓글 작성자가 일치하면 true
    
    Comment comment = commentRepository.save(request.toEntity(post, user, result));
    return CommentResponse.of(comment, post, user);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 작성자와 댓글 작성자를 확인하는 result 값이 필요했고, 저희가 운영하는 서비스에서 댓글 작성 하나만 알람이 발송되는 시스템이 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로필 잠금 해제, 게시글 좋아요, 댓글 좋아요, 채팅 등 여러 알람이 존재하였고 하나 하나 다 코드를 작성하기에 오히려 더 불편하고 복잡하다는 것을 느꼈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 불필요한 DB 접근 발생&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알람과 댓글 작성에 대해서 실시간 API CALL이 중요하지 않다고 판단하였지만 댓글이 작성이 되었을 때 바로 알람 발송은 아니지만 어느정도 짧은 시간안에 알람이 생성되어야 한다고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 스케쥴러를 활용하여 5초마다 알람을 발송하는 시스템을 구축했습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Scheduled(fixedDelay = 5_000L)
@Transactional
public void createAlarm() {
    List&amp;lt;Comment&amp;gt; byAlarmStatusIsFalse = commentRepository.findALlByAlarmStatusIsFalse();
    for (Comment comment : byAlarmStatusIsFalse) {
        try {
            AlarmResponse commentAlarm =
                    alarmService.createCommentAlarm(comment.getPost().getId(), comment.getUser().getId(), comment.getUser().getNickname(), comment.getPost().getUser(), comment.getId());
            fcmService.sendNotificationWithComment(comment.getPost().getUser(), comment.getUser(), comment.getContent());
            comment.updateAlarmStatus(true);
        } catch (RuntimeException e) {
            log.info(&quot;알람 발송 예외 발생&quot;);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5초마다 DB를 조회하고 업데이트를 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스를 이용하는 사람이 없을 때도 계속 DB에 접근을 하게 되고, 갑자기 사용자가 몰리면 한번에 처리해야 할 데이터가 많아집니다. 댓글 뿐만 아니라 좋아요, 프로필 잠금 해제 등 여러 스케쥴러를 구현하는 것이 오히려 더 복잡하고 자원 낭비라고 판단하였고 이렇게 구현하면 하나의 물리 트랜잭션을 이용하는 것이기 때문에 효율적인 방법은 아니라고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 어떤 방법으로 개선하였는지 알아보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 트랜잭션 전파 옵션 (Propagation.REQUIRES_NEW) 활용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 REQUIRES_NEW 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 트랜잭션에 참여하는 REQUIRES 대신에, REQUIRES_NEW를 적용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REQUIRES_NEW를 활용하면 항상 새로운 물리 트랙잭션을 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 DB 커넥션도 별도로 사용하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션이 분리되기 때문에 rollbackOnly가 적용되지 않음을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommentService에서 예외를 복구하고 정상적으로 리턴합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Comment는 저장 되고, Alarm과 Fcm은 롤백 되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 REQUIRES_NEW 적용 서비스 코드&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AlarmService&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;@Transactional(propagation = Propagation.REQUIRES_NEW)
public AlarmResponse createCommentAlarm(Long postId, Long fromUser, String fromUserNickname, User toUser, Long commentId) {
    log.info(&quot;createCommentAlarm&quot;);
    Alarm alarm = Alarm.builder()
            .isRead(false)
            .alarmType(AlarmType.FEED)
            .commentId(commentId)
            .fromUser(fromUser)
            .content(fromUserNickname + &quot;님이 댓글을 남겼습니다.&quot;)
            .postId(postId)
            .user(toUser)
            .build();

    try {
        alarmRepository.save(alarm);
    } catch (RuntimeException e) {
        log.info(&quot;alarmService - createCommentAlarm - {}&quot;, e.getMessage());
    }

    return AlarmResponse.of(alarm);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FcmService&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotificationWithComment(final User postWriter, final User commentWriter, final String content) {
    if (existDeviceToken(postWriter)) return;
    Notification notification = Notification.builder()
            .setTitle(commentWriter.getNickname() + &quot;님이 댓글을 남겼습니다.&quot;)
            .setBody(content)
            .build();

    Message message = Message.builder()
            .setToken(postWriter.getDeviceToken())
            .setNotification(notification)
            .build();

    try {
        firebaseMessaging.send(message);
    } catch (FirebaseMessagingException e) {
        System.out.println(&quot;예외 발생!@!@&quot;);
        throw new RuntimeException(e);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 Alarm과 Fcm에서 예외가 발생하지 않을 때 테스트 코드&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;@DisplayName(&quot;댓글을 등록한다. 본인이 작성한 게시글이 아니면 작성자에게 알람이 생성된다.&quot;)
@Test
void createCommentWithAlarm() {
    // given
    User writer = createUser(&quot;박지성&quot;, &quot;01012345678&quot;);
    User user = createUser(&quot;강혜원&quot;, &quot;01011112222&quot;);
    userRepository.saveAll(List.of(writer, user));

    NotificationReceive notificationReceive = createNotificationReceive(writer, true, true, true);
    notificationReceiveRepository.save(notificationReceive);

    Post post = createPost(&quot;내용&quot;, writer);
    postRepository.save(post);

    CommentCreateRequest request = CommentCreateRequest.builder()
            .content(&quot;댓글&quot;)
            .build();

    // when
    CommentResponse commentResponse = commentService.createComment(post.getId(), request, user.getMobileNumber());

    // then
    assertThat(commentResponse.getCommentId()).isNotNull();
    assertThat(commentResponse)
            .extracting(&quot;postId&quot;, &quot;userId&quot;, &quot;content&quot;)
            .containsExactlyInAnyOrder(post.getId(), user.getId(), request.getContent());

    List&amp;lt;Alarm&amp;gt; alarms = alarmRepository.findAllByUserAndIsReadIsFalse(writer);
    assertThat(alarms).hasSize(1)
            .extracting(&quot;alarmType&quot;, &quot;fromUser&quot;, &quot;postId&quot;)
            .containsExactlyInAnyOrder(
                    tuple(AlarmType.FEED, user.getId(), post.getId()));
    verify(fcmService, times(1));
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;331&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdfR8E/btsHWAYjGzM/neH4oqoKgOkfSvz5IN5VhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdfR8E/btsHWAYjGzM/neH4oqoKgOkfSvz5IN5VhK/img.png&quot; data-alt=&quot;테스트 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdfR8E/btsHWAYjGzM/neH4oqoKgOkfSvz5IN5VhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdfR8E%2FbtsHWAYjGzM%2FneH4oqoKgOkfSvz5IN5VhK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;282&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;331&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fcmService가 1번 호출 되었고, comment와 alarm 모두 정상적으로 저장되었음을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 Alarm에 예외가 발생했을 때 테스트 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임의로 Alarm을 저장할 때 예외를 발생시켜보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1067&quot; data-origin-height=&quot;439&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/orDR2/btsHVHDTqrg/cb64c4Cow6Wehsa7G4sQvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/orDR2/btsHVHDTqrg/cb64c4Cow6Wehsa7G4sQvK/img.png&quot; data-alt=&quot;alarm을 저장할 때 예외 발생&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/orDR2/btsHVHDTqrg/cb64c4Cow6Wehsa7G4sQvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2ForDR2%2FbtsHVHDTqrg%2Fcb64c4Cow6Wehsa7G4sQvK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;298&quot; data-origin-width=&quot;1067&quot; data-origin-height=&quot;439&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;alarm을 저장할 때 예외 발생&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외가 발생하고 Comment와 Fcm은 각각 어떻게 되는지 확인해보겠습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@DisplayName(&quot;댓글을 등록한다. 본인이 작성한 게시글이 아니면 작성자에게 알람이 생성된다.&quot;)
@Test
void createCommentWithAlarm() {
    // given
    User writer = createUser(&quot;박지성&quot;, &quot;01012345678&quot;);
    User user = createUser(&quot;강혜원&quot;, &quot;01011112222&quot;);
    userRepository.saveAll(List.of(writer, user));

    NotificationReceive notificationReceive = createNotificationReceive(writer, true, true, true);
    notificationReceiveRepository.save(notificationReceive);

    Post post = createPost(&quot;내용&quot;, writer);
    postRepository.save(post);

    CommentCreateRequest request = CommentCreateRequest.builder()
            .content(&quot;댓글&quot;)
            .build();

    // when
    CommentResponse commentResponse = commentService.createComment(post.getId(), request, user.getMobileNumber());

    // then
    assertThat(commentResponse.getCommentId()).isNotNull();
    assertThat(commentResponse)
            .extracting(&quot;postId&quot;, &quot;userId&quot;, &quot;content&quot;)
            .containsExactlyInAnyOrder(post.getId(), user.getId(), request.getContent());

    List&amp;lt;Alarm&amp;gt; alarms = alarmRepository.findAllByUserAndIsReadIsFalse(writer);
    assertThat(alarms).hasSize(0);
    verify(fcmService, times(1));
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;967&quot; data-origin-height=&quot;332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cX3ZDI/btsHUGsmr7V/32jRaRTBVQJHWWMwWqvR31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cX3ZDI/btsHUGsmr7V/32jRaRTBVQJHWWMwWqvR31/img.png&quot; data-alt=&quot;테스트 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cX3ZDI/btsHUGsmr7V/32jRaRTBVQJHWWMwWqvR31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcX3ZDI%2FbtsHUGsmr7V%2F32jRaRTBVQJHWWMwWqvR31%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;249&quot; data-origin-width=&quot;967&quot; data-origin-height=&quot;332&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 작동함을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따러서 REQUIRES_NEW를 사용하면 알람과 FCM에 문제가 발생해도 Comment는 정상 작동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 트랜잭션 전파 옵션을 통하여 해결할 수 있지만, 단점이 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금부터 트랜잭션 전파 옵션으로 해결하였을 때 발생할 수 있는 문제를 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.5 REQUIRES_NEW의 단점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 DB 커넥션을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 적은 서비스는 하나의 요청에 2개의 DB 커넥션을 사용한다고 해도 성능적인 측면에서 문제될 것이 없다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 서비스도 사용자가 없기 때문에 2개의 커넥션에 대한 문제가 발생하지 않았는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FCM이라는 외부 API를 사용하기 때문에 발생할 수 있는 문제를 미리 예측하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커넥션의 사이즈는 10개로 설정이 되어있는 상태입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1591&quot; data-origin-height=&quot;347&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmdPkz/btsHVUbTx1P/7OVyrkzqkWrPFpYpyKopyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmdPkz/btsHVUbTx1P/7OVyrkzqkWrPFpYpyKopyk/img.png&quot; data-alt=&quot;그라파나를 활용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmdPkz/btsHVUbTx1P/7OVyrkzqkWrPFpYpyKopyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmdPkz%2FbtsHVUbTx1P%2F7OVyrkzqkWrPFpYpyKopyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;158&quot; data-origin-width=&quot;1591&quot; data-origin-height=&quot;347&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;그라파나를 활용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 FCM 서버에 문제가 발생하여 send가 되지 않고 로딩 중인 상황을 가정하였습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;try {
    Thread.sleep(10000L);
    firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
    log.info(&quot;FcmService - sendNotificationWithComment - {}&quot;, e.getMessage());
    throw new RuntimeException(e);
} catch (InterruptedException e) {
    throw new RuntimeException(e);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우에 댓글을 작성하면 커넥션을 2개를 점유하고 있는 상태가 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1566&quot; data-origin-height=&quot;339&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sCKaN/btsHUIjvtXn/eGdueOU3XsE3FClKB8KuVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sCKaN/btsHUIjvtXn/eGdueOU3XsE3FClKB8KuVK/img.png&quot; data-alt=&quot;Active 2개임을 확인 가능.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sCKaN/btsHUIjvtXn/eGdueOU3XsE3FClKB8KuVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsCKaN%2FbtsHUIjvtXn%2FeGdueOU3XsE3FClKB8KuVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;157&quot; data-origin-width=&quot;1566&quot; data-origin-height=&quot;339&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Active 2개임을 확인 가능.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 요청이 동시에 들어와 DB 커넥션을 전부 점유 중인 상태라면, 다른 API를 사용할 때 사용자가 기다려야 한다는 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능이 중요한 곳에서는 주의가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 단점 때문에 전파 옵션을 활용하면 안 된다! 가 아니라, 각각 장단점이 존재하기 때문에 현재 상황에 맞게 사용하면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.6 단점 개선 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REQUIRES_NEW를 사용하지 않고 위와 같은 문제를 해결하기 위해서는 구조를 변경하는 방법이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommentService에 @Transactional을 무작정 거는 패턴이 아닌, Service와 ImplementLayer로 나누어 구현하는 쪽에 Transactional을 사용하는 방법으로 구조를 변경할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 저희 프로젝트에서는 전자의 방식을 사용 중이여서 추후에 리팩토링을 통하여 더 나은 구조로 개선해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;4. @TransactionalEventListener 사용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 리스너를 사용한다면 트랜잭션 전파 옵션으로만 설정했던 것보다 장점이 존재합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 ApplicationEventPublisher를&amp;nbsp;통해&amp;nbsp;양방향&amp;nbsp;패키지&amp;nbsp;의존&amp;nbsp;삭제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommentService가 AlarmService, FcmService를 의존하지 않고 ApplicationEventPublisher를 통해 이벤트를 발행할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존 관계를 없앴을 경우 장점은 테스트 코드 작성에 매우 유리한 부분이라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;createComment() (&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;댓글 작성)라는 테스트를 짤 때 댓글 작성에 초첨을 맞출 수 없고 알림까지 테스트를 작성해야 되기 떄문에 핵심 기능 외에 다른 부가적인 기능 때문에 테스트의 집중도가 떨어집니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 ApplicationEventPublisher를 활용한다면 댓글 알람에 대한 Mock가 필요 없고, EventPublisher에 대한 count만 체크해주면 되기 때문에 테스트 복잡도가 개선될 것이라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 방식은 위에 트랜잭션 전파 옵션에 대한 코드와 다르지 않다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 Service에서 모든 것을 처리하냐 아니면 이벤트를 발행하여 따른 패키지에서 처리를 하냐 차이입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트를 발행하여 처리하는 것도 똑같이 DB 커넥션을 2개 사용하기 때문에 더 효율적인 방법을 사용하려면 3번에서 말한 implementLayer를 구축하여 활용하는 것이 더 좋다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 나아가 규모가 큰 서비스인 경우 kafka를 활용하여 댓글, 이벤트를 각각 별도의 시스템으로 구축하여 두 시스템이 의존성을 가지지 않고 동작할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추후에 메시지 브로커를 활용하여 개선해보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 규모가 작다면 어떤 방법을 써도 문제가 없다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 현재 주어진 상황에 맞게 적합한 방법을 선택하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. DB 커넥션에 대한 부담이 없으려면 Facade를 만들어서 각각 Transaction을 부여하고 DB 커넥션을 2개 사용하는 일이 없도록 만드는게 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JPA</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/328</guid>
      <comments>https://byungil.tistory.com/328#entry328comment</comments>
      <pubDate>Tue, 11 Jun 2024 15:03:13 +0900</pubDate>
    </item>
    <item>
      <title>JPA: 페이징이 필요한 이유와 페이징 성능 개선하기 - No Offset 사용</title>
      <link>https://byungil.tistory.com/327</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA로 개발을 하면서, 페이징을 사용하지 않았을 때 성능 저하, 서비스 장애로 이어질 수 있는 부분을 깨닫고 정리하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Paging을 사용하지 않고 모든 게시글을 불러왔을 때 문제 발생&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터가 많으면 장애 발생&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프로젝트에서 사용한 코드&lt;/h4&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;@Query(value = &quot;select p.*, COUNT(pl.post_like_id) as LikeCount &quot; +
        &quot;from post p &quot; +
        &quot;inner join post_like pl on p.post_id = pl.post_id &quot; +
        &quot;group by p.post_id &quot; +
        &quot;order by LikeCount Desc, p.post_id Desc&quot;, nativeQuery = true)
List&amp;lt;Post&amp;gt; customFindByLikeCountLessThanOrderByLikeCountDescAndIdDesc();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글을 좋아요 순으로, 좋아요가 같다면 최신순으로 정렬하여 불러오는 쿼리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글이 많지 않으면 문제 없이 사용할 수 있지만, 저는 N + 1 문제를 batch_size 를 이용하여 해결하였고, 게시글이 백만개 이상이 넘는다고 하였을 때, 응답으로 불러오지 못하는 에러가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발생하는 쿼리수가 어마어마하며, 충분히 서비스 장애가 일어날 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;707&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kTnoQ/btsHQn5Pr2Y/3I1KDkfvdO6lqUXiBbGW60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kTnoQ/btsHQn5Pr2Y/3I1KDkfvdO6lqUXiBbGW60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kTnoQ/btsHQn5Pr2Y/3I1KDkfvdO6lqUXiBbGW60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkTnoQ%2FbtsHQn5Pr2Y%2F3I1KDkfvdO6lqUXiBbGW60%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;538&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;707&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터가 많지 않을 때 문제 없음&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 10,000개 정도라고 하였을 때는 문제 없이 작동하였지만, 한 번에 데이터를 많이 조회하였고 당장 불필요한 데이터가 포함되어있기 때문에 성능 저하가 발생합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;970&quot; data-origin-height=&quot;730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNkvYa/btsHPeWq84O/A1sKE3cGQDoL6drcsGok90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNkvYa/btsHPeWq84O/A1sKE3cGQDoL6drcsGok90/img.png&quot; data-alt=&quot;10,000개 데이터 약 1371ms 소요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNkvYa/btsHPeWq84O/A1sKE3cGQDoL6drcsGok90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNkvYa%2FbtsHPeWq84O%2FA1sKE3cGQDoL6drcsGok90%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;546&quot; data-origin-width=&quot;970&quot; data-origin-height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10,000개 데이터 약 1371ms 소요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 1,000,000개 이상이라면 게시글 목록을 좋아요 개수순으로 조회할 때, 10초 이상이 걸리게 되며 사용자 입장에서 말도 안 되는 상황이기 때문에 개선이 필요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Paging을 사용하여 필요한 데이터를 조회&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 페이징 방식은 페이지 번호(offset)와 페이지 사이즈(limit)를 기반으로 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;781&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNQUh5/btsHOh7JOsB/TAb6FhtLewqeuxvSkZMf90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNQUh5/btsHOh7JOsB/TAb6FhtLewqeuxvSkZMf90/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNQUh5/btsHOh7JOsB/TAb6FhtLewqeuxvSkZMf90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNQUh5%2FbtsHOh7JOsB%2FTAb6FhtLewqeuxvSkZMf90%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;860&quot; height=&quot;107&quot; data-origin-width=&quot;781&quot; data-origin-height=&quot;97&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흔히 사용하는 페이징 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지 번호와 그 페이지에 뿌려줄 게시글 개수를 정하여 나타냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 코드로 적용하여 얼마나 개선이 되었는지 알아보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지 번호 0, 페이지 사이즈 20으로 설정하여 첫 번째 페이지에 존재하는 게시글 20개만 조회한 결과입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;950&quot; data-origin-height=&quot;747&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2yNL0/btsHPVINZcU/XBnN5AkS3mLT90C6TTjBXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2yNL0/btsHPVINZcU/XBnN5AkS3mLT90C6TTjBXK/img.png&quot; data-alt=&quot;10,000개 데이터 중 좋아요 개수, 최신순 정렬하여 20개 조회 - 634ms 소요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2yNL0/btsHPVINZcU/XBnN5AkS3mLT90C6TTjBXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2yNL0%2FbtsHPVINZcU%2FXBnN5AkS3mLT90C6TTjBXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;570&quot; data-origin-width=&quot;950&quot; data-origin-height=&quot;747&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10,000개 데이터 중 좋아요 개수, 최신순 정렬하여 20개 조회 - 634ms 소요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회하는 속도가 2배 이상이 되었음을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 Paging 쿼리가 느린 이유 (직접 구현 및 비교)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 사용하는 페이징 쿼리 예시입니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #f6f8fa; color: #24292e; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;SELECT *
FROM post
WHERE 조건문
ORDER BY id DESC
OFFSET 페이지번호
LIMIT 페이지사이즈&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;이런 형태의 페이징 쿼리가 뒤로갈수록 느린 이유는 앞에서 읽었던 행을 다시 읽어야 하기 때문입니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offset 49990, limit 20 이라고 하면 총 50,010개의 행을 읽어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 앞에 49,990개 행을 버리게 됩니다. 필요한 것은 20개뿐이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤로 갈수록 필요없지만 읽어야 할 행의 개수가 많아 느려지는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;약 1,000,000개의 데이터로 테스트 한 결과입니다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offset = 0일 때 결과입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;543&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wUSly/btsHQj3DBnK/NA2k7ahESRyqPYPzOy4tw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wUSly/btsHQj3DBnK/NA2k7ahESRyqPYPzOy4tw1/img.png&quot; data-alt=&quot;시간 - 8.09s&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wUSly/btsHQj3DBnK/NA2k7ahESRyqPYPzOy4tw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwUSly%2FbtsHQj3DBnK%2FNA2k7ahESRyqPYPzOy4tw1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;411&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;543&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;시간 - 8.09s&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offset = 49,990일 때 결과입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;543&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buwKLk/btsHPm1ciEF/s1Wti3em9nBMTKhSlKySMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buwKLk/btsHPm1ciEF/s1Wti3em9nBMTKhSlKySMk/img.png&quot; data-alt=&quot;시간 - 8.46s&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buwKLk/btsHPm1ciEF/s1Wti3em9nBMTKhSlKySMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuwKLk%2FbtsHPm1ciEF%2Fs1Wti3em9nBMTKhSlKySMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;410&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;543&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;시간 - 8.46s&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offset에 따라 수행 시간 차이가 나는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터의 개수가 많으면 많아질수록 더 큰 차이가 나고, 현재 저희 프로젝트에서는 이러한 페이징 방식을 사용하지 않기 때문에 No-Offset 방식을 이용하여 개선하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. No-Offset을 이용한 페이징 성능 개선&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;No-Offset 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;No Offset 방식은 조회 시작 부분을 인덱스로 빠르게 찾아 첫 페이지만 읽도록 하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #f6f8fa; color: #24292e; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;SELECT *
FROM post
WHERE 조건문
AND id &amp;lt; 마지막조회ID # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT 페이지사이즈&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 조회 결과의 ID를 조건문에 사용하여 이전 페이지 전체를 건너 뛸 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offset을 이용할 때는 앞에 게시글을 읽어야 했다면, No Offset 방식은 처음 페이지 읽은 것과 동일한 성능을 가지게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 장점 1 - 불필요한 count 쿼리가 발생하지 않는다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 페이징 방식은 데이터 조회하 함께 count 쿼리가 수행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 건수를 알아야 PageNo를 알 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;count 쿼리는 데이터 조회만큼 오래 걸릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Page를 이용하여 게시글 목록을 최신으로 불러 올 때 발생하는 쿼리입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;794&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oiFhV/btsHOEhobDx/vVAJgooRuqSbFLPuM8roS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oiFhV/btsHOEhobDx/vVAJgooRuqSbFLPuM8roS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oiFhV/btsHOEhobDx/vVAJgooRuqSbFLPuM8roS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoiFhV%2FbtsHOEhobDx%2FvVAJgooRuqSbFLPuM8roS0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;643&quot; data-origin-width=&quot;794&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 쿼리는 복잡하지 않기 때문에 복잡한 연산은 없지만, inner join으로 여러 관계가 얽혀있다면 count 쿼리를 수행하는데 여러 join 연산이 추가되므로 그만큼 쿼리 수행 시간이 더 오래 걸리게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;No-Offset 방식은 마지막 조회 ID를 기준으로 불러오는 방식이기 때문에, Page로 반환할 필요가 없어 count 쿼리가 발생하지 않습니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 장점 2 - count 쿼리 발생으로 인한 예측 불가능한 에러 걱정을 할 필요가 없다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에 사용된 쿼리랑 다른 쿼리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 좋아요 순으로 조회하여 마지막 좋아요 개수를 기준으로 추가적인 게시글을 받는 로직입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;@Query(value = &quot;select p.*, COUNT(pl.post_like_id) as LikeCount &quot; +
        &quot;from post p &quot; +
        &quot;inner join post_like pl on p.post_id = pl.post_id &quot; +
        &quot;group by p.post_id &quot; +
        &quot;having LikeCount &amp;lt; :lastLikeCount &quot; +
        &quot;order by LikeCount Desc, p.post_id Desc&quot;, nativeQuery = true)
Page&amp;lt;Post&amp;gt; customFindByLikeCountLessThanOrderByLikeCountDescAndIdDesc(Long lastLikeCount, Pageable pageable);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 쿼리를 수행했을 때 발생하는 문제는 count 쿼리에서 having절을 인식하지 못하여 SQL Error: 1054, SQLState: 42S22가 발생합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;787&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2vkPF/btsHQkuL0A6/MSSoEQrsvvuGEhJZTAcfgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2vkPF/btsHQkuL0A6/MSSoEQrsvvuGEhJZTAcfgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2vkPF/btsHQkuL0A6/MSSoEQrsvvuGEhJZTAcfgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2vkPF%2FbtsHQkuL0A6%2FMSSoEQrsvvuGEhJZTAcfgk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;322&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;787&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;count 쿼리가 어떻게 발생하는지에 대해 나중에 다루겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 count쿼리가 발생할 때, native 쿼리를 기준으로 발생하는데, native 쿼리에서 사용된 LikeCount를 count 쿼리에서 제대로 불러오지 못하며, 이러한 이유로 count 쿼리에서 에러가 발생하여 서비스 장애 문제를 겪었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;No Offset 방식을 이용하면, Page를 이용할 필요가 없기 때문에 이러한 에러를 걱정할 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 장점 3 - count 쿼리 발생X로 인한 성능 향상&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Page를 이용한 쿼리를 사용하였을 때 게시글 20개 불러오기 - 830ms 소요&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1731&quot; data-origin-height=&quot;588&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9sWaj/btsHOG7iy8x/ax6POK6CgSYcFcSeWguGNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9sWaj/btsHOG7iy8x/ax6POK6CgSYcFcSeWguGNk/img.png&quot; data-alt=&quot;count 쿼리가 발생하였습니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9sWaj/btsHOG7iy8x/ax6POK6CgSYcFcSeWguGNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9sWaj%2FbtsHOG7iy8x%2Fax6POK6CgSYcFcSeWguGNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;246&quot; data-origin-width=&quot;1731&quot; data-origin-height=&quot;588&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;count 쿼리가 발생하였습니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Page -&amp;gt; List를 이용한 쿼리 사용하였을 때 게시글 20개 불러오기 - 524ms 소요&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1691&quot; data-origin-height=&quot;563&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Uxaz1/btsHONkTG64/PjLqESHEujs6yoJrQAz0k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Uxaz1/btsHONkTG64/PjLqESHEujs6yoJrQAz0k0/img.png&quot; data-alt=&quot;count 쿼리가 발생하지 않았습니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Uxaz1/btsHONkTG64/PjLqESHEujs6yoJrQAz0k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUxaz1%2FbtsHONkTG64%2FPjLqESHEujs6yoJrQAz0k0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;241&quot; data-origin-width=&quot;1691&quot; data-origin-height=&quot;563&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;count 쿼리가 발생하지 않았습니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉬운 설명을 위하여 간단한 쿼리로 테스트 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;join 연산이 하나도 없는 count 쿼리여도 시간 차이가 발생하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;join이 여러개 존재하고 데이터가 많아진다면, 더 큰 시간 차이가 발생할 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;count 쿼리가 발생하지 않음으로 인하여 성능 개선이 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 페이징 방식과 No-Offset 방식을 비교하여 페이징 성능을 개선하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제 블로그에서는 다루지 않았지만 기존 페이징도 성능을 개선할 수 있으며, No-Offset으로만 성능을 개선할 수 있음을 얘기하는 것이 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 기획에 따라 어떠한 방식을 이용할 것인지 거기에 적합한 방식이 무엇인지 생각하며 적용하는 것이 중요하다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;No-Offset 방식은 무한 스크롤 방식 또는 More 버튼을 눌러 다음 게시글을 불러오는 화면에서만 가능하며, 1페이지에서 갑자기 12페이지로 가는 방식에서는 사용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;375&quot; data-origin-height=&quot;798&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNcJRL/btsHPc5JA6u/Mx77spHYo1qhA8phwJZ9oK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNcJRL/btsHPc5JA6u/Mx77spHYo1qhA8phwJZ9oK/img.png&quot; data-alt=&quot;무한 스크롤을 이용한 피드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNcJRL/btsHPc5JA6u/Mx77spHYo1qhA8phwJZ9oK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNcJRL%2FbtsHPc5JA6u%2FMx77spHYo1qhA8phwJZ9oK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;375&quot; height=&quot;798&quot; data-origin-width=&quot;375&quot; data-origin-height=&quot;798&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무한 스크롤을 이용한 피드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이징을 적용하지 않았을 때 서비스에 문제가 발생하였고, 그러한 문제를 해결하면서 페이징의 중요성과 기획에 맞는 방식, 성능 개선을 경험할 수 있었습니다.&lt;/p&gt;</description>
      <category>JPA</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/327</guid>
      <comments>https://byungil.tistory.com/327#entry327comment</comments>
      <pubDate>Wed, 5 Jun 2024 14:42:01 +0900</pubDate>
    </item>
    <item>
      <title>좌표값(객체) 테스트에 대한 고민</title>
      <link>https://byungil.tistory.com/326</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 제가 작성한 회원가입 ReuqestDto 값입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserCreateRequestDto {
    @NotBlank(message = &quot;아이디는 필수로 입력해야 됩니다.&quot;)
    private String username;

    @NotBlank(message = &quot;비밀번호는 필수로 입력해야 됩니다.&quot;)
    private String password;

    private String phoneNumber;
    private String email;
    private String nickname;
    private Address address;
    private String userImage;

    @NotNull(message = &quot;좌표는 필수로 입력해야 됩니다.&quot;)
    private Coordinate coordinate;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좌표(Coordinate) 정보를 필수적으로 입력을 해줘야하는 상태입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입을 진행할 때, 위도와 경도값을 받고 그 받은 값을 Coordinate 객체에 저장을 한 후 Service에 보내는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 과정에서 위도값 또는 경도값이 비어있는 경우, 예외처리를 어떻게 할 것인가에 대한 고민입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 작성했던 코드를 컨트롤러 테스트를 하겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1723&quot; data-origin-height=&quot;828&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mDKVZ/btsAVrHm9Gl/EUhkfRnSr10qf91KgIQSV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mDKVZ/btsAVrHm9Gl/EUhkfRnSr10qf91KgIQSV0/img.png&quot; data-alt=&quot;coordinate값이 null일 경우 컨트롤러 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mDKVZ/btsAVrHm9Gl/EUhkfRnSr10qf91KgIQSV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmDKVZ%2FbtsAVrHm9Gl%2FEUhkfRnSr10qf91KgIQSV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1723&quot; height=&quot;828&quot; data-origin-width=&quot;1723&quot; data-origin-height=&quot;828&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;coordinate값이 null일 경우 컨트롤러 테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 예외가 발생하며 작성했던 메세지가 출력됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데, 여기서 접한 문제는 위도값 또는 경도값이 비어있을 경우 예외 처리에 대한 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확한 실험을 위해 Postman을 이용하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 성공&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 회원가입이 되었을 때 Body값 입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1002&quot; data-origin-height=&quot;681&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l8IG1/btsATCiqZ7Q/K6wKIK4OLLxXgDOyYln1t1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l8IG1/btsATCiqZ7Q/K6wKIK4OLLxXgDOyYln1t1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l8IG1/btsATCiqZ7Q/K6wKIK4OLLxXgDOyYln1t1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl8IG1%2FbtsATCiqZ7Q%2FK6wKIK4OLLxXgDOyYln1t1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1002&quot; height=&quot;681&quot; data-origin-width=&quot;1002&quot; data-origin-height=&quot;681&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 실패&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1012&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckLAm8/btsATmfLiYQ/2RLgL7ITGExs4FmlXsxtQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckLAm8/btsATmfLiYQ/2RLgL7ITGExs4FmlXsxtQK/img.png&quot; data-alt=&quot;lat값이 빠져있는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckLAm8/btsATmfLiYQ/2RLgL7ITGExs4FmlXsxtQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckLAm8%2FbtsATmfLiYQ%2F2RLgL7ITGExs4FmlXsxtQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1012&quot; height=&quot;520&quot; data-origin-width=&quot;1012&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;lat값이 빠져있는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lat값이 빠져있을 때, 예외 메세지가 발생하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1718&quot; data-origin-height=&quot;209&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsf9YG/btsARV4neG5/W99aDJO2HU7qpAYdlkg5Hk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsf9YG/btsARV4neG5/W99aDJO2HU7qpAYdlkg5Hk/img.png&quot; data-alt=&quot;Service에서 발생하는 Exception&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsf9YG/btsARV4neG5/W99aDJO2HU7qpAYdlkg5Hk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbsf9YG%2FbtsARV4neG5%2FW99aDJO2HU7qpAYdlkg5Hk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1718&quot; height=&quot;209&quot; data-origin-width=&quot;1718&quot; data-origin-height=&quot;209&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Service에서 발생하는 Exception&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lat값 또는 lng값이 들어있기만 하면, Coordinate값이 null이 아니기 때문에 Controller 단에서 예외가 발생하지 않고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스에서 Coordinate의 lat값과 lng값을 이용할 때 예외가 발생하는 모습입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lat값과 lng값을 이용하여 Point 객체를 생성하고 그 생성된 Point 객체를 User Entity에 저장하는 과정에서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lat값과 lng값 중 하나가 존재하지 않기 때문에 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service단에서 예외 처리를 하자.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lat값과 Lng값을 검증하는 메서드를 추가했습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private void validateLatAndLng(final Coordinate coordinate) {
    if (coordinate.getLat() == null || coordinate.getLng() == null) {
        throw new MarketAppException(NOT_FOUND_COORDINATE, NOT_FOUND_COORDINATE.getMessage());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1019&quot; data-origin-height=&quot;696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcginW/btsASFfDgpq/nbdwGK8FwWFY5BcFkaaFrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcginW/btsASFfDgpq/nbdwGK8FwWFY5BcFkaaFrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcginW/btsASFfDgpq/nbdwGK8FwWFY5BcFkaaFrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcginW%2FbtsASFfDgpq%2FnbdwGK8FwWFY5BcFkaaFrK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1019&quot; height=&quot;696&quot; data-origin-width=&quot;1019&quot; data-origin-height=&quot;696&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 작동함을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service단으로 예외처리를 한 이유는,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lat값과 Lng값을 제대로 작성했는지에 대한 깊은 테스트가 필요했기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lat값과 Lng값을 0, 0 이런 식으로 작성하는 경우 ControllerTest 하나로 해결하기에는 무리가 있다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Service에서 값이 들어있는지에 대한 유무와 제대로 된 좌표값을 입력했는지에 대한 테스트를 더 추가하기 위해서는 Service에서 테스트하는 것이 맞다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 성공&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;882&quot; data-origin-height=&quot;877&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VkwBz/btsATfugaIa/DbQ7ydyxaY0kvmdgYDxk5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VkwBz/btsATfugaIa/DbQ7ydyxaY0kvmdgYDxk5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VkwBz/btsATfugaIa/DbQ7ydyxaY0kvmdgYDxk5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVkwBz%2FbtsATfugaIa%2FDbQ7ydyxaY0kvmdgYDxk5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;882&quot; height=&quot;877&quot; data-origin-width=&quot;882&quot; data-origin-height=&quot;877&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;과정에서 깨달은 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller와 Service에 대한 테스트 범위를 고민하게 되었고, 이러한 과정을 통해 Controller와 Service에 대한 명확한 범위를 생각하여 테스트를 작성할 수 있었습니다.&lt;/p&gt;</description>
      <category>Spring</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/326</guid>
      <comments>https://byungil.tistory.com/326#entry326comment</comments>
      <pubDate>Sat, 25 Nov 2023 19:04:37 +0900</pubDate>
    </item>
    <item>
      <title>테스트 코드 - Presentation Layer</title>
      <link>https://byungil.tistory.com/325</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 제가 진행 중인 프로젝트 Test 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Persistence Layer와 Business Layer에서 스프링을 통으로 띄워서 통합테스트를 진행했습니다. 그리고 기존 Presentation Layer 테스트를 작성할 때 같은 환경에서 테스트를 진행했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 방식은 서버를 띄우는 시간과 사용 중인 Bean을 모두 불러오고 중복된 테스트 작성이 발생하기 때문에 시간이 오래 걸리고 비효율적인 테스트 작성입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presentation Layer 테스트 범위에 대한 생각과 리팩토링 과정을 작성하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Presentation Layer에서 무엇을 테스트할까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;진행 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presentation Layer를 테스트 할 땐, Persistence Layer와 Business Layer를 Mocking처리 후, 테스트를 진행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 Persistence Layer와 Business Layer에 대한 테스트를 진행한 상태이기 때문에 잘 작동한다는 가정하에 Presentation Layer 단위 테스트 느낌으로 테스트에 집중하기 위함입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Web MVC&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2000&quot; data-origin-height=&quot;1534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkZyTd/btsAeAZa1OO/KI7QSpdpwXDkJyJM5OuEp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkZyTd/btsAeAZa1OO/KI7QSpdpwXDkJyJM5OuEp1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkZyTd/btsAeAZa1OO/KI7QSpdpwXDkJyJM5OuEp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkZyTd%2FbtsAeAZa1OO%2FKI7QSpdpwXDkJyJM5OuEp1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2000&quot; height=&quot;1534&quot; data-origin-width=&quot;2000&quot; data-origin-height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 url을 요청하면, 웹 브라우저에서 스프링으로 request가 보내진다.&lt;/li&gt;
&lt;li&gt;Dispatcher Servlet이 request를 받으면, Handler Mapping을 통해 해당 url을 담당하는 Controller를 탐색 후 찾아낸다.&lt;/li&gt;
&lt;li&gt;찾아낸 Controller로 request를 보내주고, 보내주기 위해 필요한 Model을 구성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Presentation Layer 테스트 범위&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 구조를 설명한 이유는 Controller 테스트 시에 Controller 내부의 코드 뿐만 아니라 filter부터 Handler 내부에 이르기까지 테스트 범위가 수행될 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 테스트 목적에 이를 구분하고 필요한 Bean들을 생성하여 범위를 나누어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Presentation Layer 테스트 내용&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부 세계의 요청을 가장 먼저 받는 계층&lt;/li&gt;
&lt;li&gt;파라미터에 대한 최소한의 검증을 수행한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 구현 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;@WebMvcTest&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Bean 생성 범위&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;@Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;@WebMvcTest(controllers = {.class})와 같이 특정 Controller만 로딩하여 테스트 할 수 있다.&lt;/h4&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;리팩토링 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Before, UserController&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기존 코드&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 작성했던 UserController에 대한 내용입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;@ExtendWith({RestDocumentationExtension.class})
@SpringBootTest
@Transactional
class UserControllerTest {

    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;

    @Autowired
    UserService userService;

    @Autowired
    UserRepository userRepository;

    @Autowired
    CommentRepository commentRepository;

    @Autowired
    ItemRepository itemRepository;

    private ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void setUp(RestDocumentationContextProvider restDocumentation) {
        userRepository.deleteAll();

        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation)
                        .operationPreprocessors()
                        .withRequestDefaults(prettyPrint())
                        .withResponseDefaults(prettyPrint()))
                .build();
    }

    @DisplayName(&quot;회원가입 API 테스트&quot;)
    @Test
    void createUser() throws Exception {
        // given
        UserCreateRequestDto createDto = UserCreateRequestDto.builder()
                .username(&quot;아이디&quot;)
                .password(&quot;비밀번호&quot;)
                .email(&quot;이메일&quot;)
                .userImage(&quot;사진&quot;)
                .nickname(&quot;닉네임&quot;)
                .phoneNumber(&quot;번호&quot;)
                .address(new Address(&quot;city&quot;, &quot;street&quot;, &quot;zipcode&quot;))
                .build();
        String url = &quot;http://localhost:8080/join&quot;;

        // when
        ResultActions perform = mvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(createDto)));

        // then
        perform.andExpect(status().isOk())
                .andDo(document(&quot;/join&quot;,
                        requestFields(
                                fieldWithPath(&quot;username&quot;).description(&quot;계정&quot;),
                                fieldWithPath(&quot;password&quot;).description(&quot;비밀번호&quot;),
                                fieldWithPath(&quot;email&quot;).description(&quot;이메일&quot;),
                                fieldWithPath(&quot;userImage&quot;).description(&quot;사진&quot;),
                                fieldWithPath(&quot;nickname&quot;).description(&quot;닉네임&quot;),
                                fieldWithPath(&quot;phoneNumber&quot;).description(&quot;번호&quot;),
                                fieldWithPath(&quot;address.city&quot;).description(&quot;city&quot;),
                                fieldWithPath(&quot;address.street&quot;).description(&quot;street&quot;),
                                fieldWithPath(&quot;address.zipcode&quot;).description(&quot;zipcode&quot;)
                        )));

    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@SpringBootTest 사용하여 모든 Bean을 불러옵니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;311&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/be6SGQ/btsz9ZsoH9w/2R43KwcIOqTFwDKV7iUzN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/be6SGQ/btsz9ZsoH9w/2R43KwcIOqTFwDKV7iUzN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/be6SGQ/btsz9ZsoH9w/2R43KwcIOqTFwDKV7iUzN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbe6SGQ%2Fbtsz9ZsoH9w%2F2R43KwcIOqTFwDKV7iUzN1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;952&quot; height=&quot;311&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;311&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserController에 대한 테스트를 진행하면서(RequestBody 값 검증), userService.createUser까지 테스트 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 방식은 이미 Service Test를 통해 진행된 부분을 중복하여 테스트 하게 되는 것이고, 중복된 테스트라 필요 없는 테스트라고 생각합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 시간 측정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;338&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lawh8/btsAbaGBO8o/V5vyyjpNLyAQaYnpKh9IdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lawh8/btsAbaGBO8o/V5vyyjpNLyAQaYnpKh9IdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lawh8/btsAbaGBO8o/V5vyyjpNLyAQaYnpKh9IdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flawh8%2FbtsAbaGBO8o%2FV5vyyjpNLyAQaYnpKh9IdK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;852&quot; height=&quot;338&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;338&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1개를 테스트 하는 데에 걸리는 시간입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;After, UserController&lt;/h3&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@WebMvcTest(controllers = UserController.class)
class UserControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected UserService userService;

    @DisplayName(&quot;회원가입 테스트 신규 회원을 등록한다.&quot;)
    @Test
    @WithMockUser
    void createUser() throws Exception {
        // given
        UserCreateRequestDto request = UserCreateRequestDto.builder()
                .username(&quot;아이디&quot;)
                .password(&quot;비밀번호&quot;)
                .build();

        // when // then
        mockMvc.perform(
                        post(&quot;/join&quot;).with(csrf())
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(request))
                )
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath(&quot;$.code&quot;).value(&quot;200&quot;))
                .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;OK&quot;));
    }

    @DisplayName(&quot;회원가입할 때 아이디는 꼭 입력해야 한다.&quot;)
    @Test
    @WithMockUser
    void createUserWithEmptyUsername() throws Exception {
        // given
        UserCreateRequestDto request = UserCreateRequestDto.builder()
//                .username(&quot;아이디&quot;)
                .password(&quot;비밀번호&quot;)
                .build();

        // when // then
        mockMvc.perform(
                        post(&quot;/join&quot;).with(csrf())
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(request))
                )
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath(&quot;$.code&quot;).value(&quot;400&quot;))
                .andExpect(jsonPath(&quot;$.status&quot;).value(&quot;BAD_REQUEST&quot;))
                .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;아이디는 필수로 입력해야 됩니다.&quot;))
                .andExpect(jsonPath(&quot;$.data&quot;).isEmpty());
    }

    @DisplayName(&quot;회원가입할 때 비밀번호는 꼭 입력해야 한다.&quot;)
    @Test
    @WithMockUser
    void createUserWithEmptyPassword() throws Exception {
        // given
        UserCreateRequestDto request = UserCreateRequestDto.builder()
                .username(&quot;아이디&quot;)
//                .password(&quot;비밀번호&quot;)
                .build();

        // when // then
        mockMvc.perform(
                        post(&quot;/join&quot;).with(csrf())
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(request))
                )
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath(&quot;$.code&quot;).value(&quot;400&quot;))
                .andExpect(jsonPath(&quot;$.status&quot;).value(&quot;BAD_REQUEST&quot;))
                .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;비밀번호는 필수로 입력해야 됩니다.&quot;))
                .andExpect(jsonPath(&quot;$.data&quot;).isEmpty());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 시간 측정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1041&quot; data-origin-height=&quot;367&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDHNX4/btsAjsNB058/bMOll08Cl4rsaoQGd5T9z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDHNX4/btsAjsNB058/bMOll08Cl4rsaoQGd5T9z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDHNX4/btsAjsNB058/bMOll08Cl4rsaoQGd5T9z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDHNX4%2FbtsAjsNB058%2FbMOll08Cl4rsaoQGd5T9z0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1041&quot; height=&quot;367&quot; data-origin-width=&quot;1041&quot; data-origin-height=&quot;367&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적인 조건을 설정하여 명확한 테스트 결과를 얻을 수 있습니다. - Valid값 검증&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 Bean을 불러오는 것이 아니기 때문에 테스트 시간을 최소화할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 리팩토링 하면서 기존에 작성했던 테스트들이 효율적이기 못하다는 것을 학습했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 계층마다 테스트하는 범위가 명확하지 않았고, 통합 환경을 구성하지 않았기 때문에 서버가 불필요하게 많이 뜨는 현상도 겪었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 계기로 Controller, Service, Repository에 대한 테스트 환경 분리 및 통합 과정을 통해 시간을 줄일 수 있었고, 계층마다 어떤 테스트가 필요한 지 제대로 학습하고 적용할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>초보병일이</author>
      <guid isPermaLink="true">https://byungil.tistory.com/325</guid>
      <comments>https://byungil.tistory.com/325#entry325comment</comments>
      <pubDate>Sun, 12 Nov 2023 15:05:10 +0900</pubDate>
    </item>
  </channel>
</rss>