<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>life is egg</title>
        <link>https://velog.io/</link>
        <description>삶은 달걀이다</description>
        <lastBuildDate>Thu, 25 Jan 2024 02:07:47 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>life is egg</title>
            <url>https://velog.velcdn.com/images/u-nij/profile/0da12ea5-5002-4200-b1dd-805115b44805/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. life is egg. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/u-nij" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[2024년과 동시에 시작된 첫 꿈과 iOS (부제: iOS 시작하기)]]></title>
            <link>https://velog.io/@u-nij/2024%EB%85%84%EA%B3%BC-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%8B%9C%EC%9E%91%EB%90%9C-%EC%B2%AB-%EA%BF%88%EA%B3%BC-iOS-%EB%B6%80%EC%A0%9C-iOS-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@u-nij/2024%EB%85%84%EA%B3%BC-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%8B%9C%EC%9E%91%EB%90%9C-%EC%B2%AB-%EA%BF%88%EA%B3%BC-iOS-%EB%B6%80%EC%A0%9C-iOS-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 25 Jan 2024 02:07:47 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/u-nij/post/cd45ec82-c9c2-4079-8f1e-657183e0751b/image.png" alt=""></p>
<h1 id="goodbye-spring-boot👋">GoodBye, Spring Boot...👋</h1>
<p>_<span style="color:Gray">(사실 GoodBye까지도 아닌 것 같다. 지금처럼 인생은 모르는거니까🤣~!)</span>_
2024년이 되자마자 팀 내에서의 역할이 바뀌어, 모바일 담당자로 전환되면서 백엔드 개발자에서 iOS 개발자로의 새로운 도전을 맞게 되었다. 처음 직무 전환에 대한 제안을 주셨을 때에는 고민이 많았다. 현재 진행 중인 백엔드 개발이 정말로 즐거웠기 때문이다. 데이터를 다루고 다양한 기술 스택을 학습하며 비즈니스 로직을 개발하는 것이 내겐 정말 매력적인 작업이었다.
그럼에도 불구하고, 아이폰 사용자로서 직접 사용할 수 있는 앱 서비스를 개발하는 꿈이 항상 내 마음 한 켠에 남아 있었다. 하지만, 현재 하는 일과는 다른 완전히 새로운 분야에 많은 시간을 투자해야 했기 때문에 도전이 쉽지 않았다. 때문에 더욱 좋은 기회를 잡았다는 생각이 들어 iOS 개발자로의 전환을 결심하게 되었다.
프로덕트를 만들어가는 과정에서 전문성을 쌓는 즐거움을 느끼고, 팀과 함께 사용자들의 편의성을 높일 수 있는 서비스를 만드는 것을 iOS에서도 경험할 수 있다는 설렘이 크다. 하여튼 이렇게, 사용자들에게 가치 있는 경험을 제공할 수 있는 iOS 개발자로 성장하고자 하는 다짐과 함께 2024년을 시작하게 되었다!!</p>
<h1 id="첫-강의-선택🐣">첫 강의 선택!!🐣</h1>
<p>심도 깊은 강의를 듣기 전에.. Swift 문법을 먼저 익힐 필요가 있었다! 새로운 언어를 익힐 때마다 매번 드는 생각이지만, 다른 문법에 익숙한 채로 새로운 문법을 익히는 것은 좀 많이 햇갈리고.. 어렵다..🤤</p>
<h3 id="나의-강의-결정-조건">나의 강의 결정 조건</h3>
<ol>
<li>Swift의 완전 기초 문법부터 알려주는 강의일 것</li>
<li>Windows에서 학습 가능한 강의(온라인 Swift 컴파일러에서도 진행하기 위함)</li>
<li>무료 강의</li>
<li>교육 플랫폼이면 좋겠다. 진행도와 다음 강의를 보기 원활하기 때문.</li>
</ol>
<h3 id="강의-선택">강의 선택</h3>
<p><a href="https://www.inflearn.com/course/%EC%8A%A4%EC%9C%84%ED%94%84%ED%8A%B8-%EA%B8%B0%EB%B3%B8-%EB%AC%B8%EB%B2%95/dashboard"><strong>야곰의 스위프트 기본 문법 강좌</strong></a></p>
<p>시중에 나와 있는 모든 강의들이 유익하지만, 나는 위의 조건들을 바탕으로 위의 강의를 선택했다. 나에게 이 강의가 맞는지 몇 개 들어보며 핵심적이고 간결한 강의였기 때문에 집중하기 쉽다고 느껴졌기 때문이다.</p>
<p>_<span style="color:LightGray">새로운 언어와 기술 스택 익힐 생각에 설렘만 가득한 것🥰..</span>_</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + Swagger 3.0 설정 + JWT 인증 설정 + @Profile로 환경별 설정]]></title>
            <link>https://velog.io/@u-nij/Spring-Boot-Swagger-3.0-%EC%84%A4%EC%A0%95-JWT-%EC%9D%B8%EC%A6%9D-%EC%84%A4%EC%A0%95-Profile%EB%A1%9C-%ED%99%98%EA%B2%BD%EB%B3%84-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@u-nij/Spring-Boot-Swagger-3.0-%EC%84%A4%EC%A0%95-JWT-%EC%9D%B8%EC%A6%9D-%EC%84%A4%EC%A0%95-Profile%EB%A1%9C-%ED%99%98%EA%B2%BD%EB%B3%84-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Wed, 14 Dec 2022 07:44:30 GMT</pubDate>
            <description><![CDATA[<h3 id="개발-환경">개발 환경</h3>
<blockquote>
<p>Spring Boot 2.7.3
Gradle</p>
</blockquote>
<h1 id="swagger란">Swagger란?</h1>
<p>Open Api Specification(OAS), 이는 RESTful API spec을 정의된 규칙에 맞게 json이나 yaml로 표현하는 방식을 의미한다. Swagger는 OAS를 위한 프레임워크이며, API들이 갖고 있는 specification을 정의할 수 있는 툴들 중 하나이다. API의 문서를 자동화뿐만 아니라, 파라미터를 넣어보고 테스트를 진행할 수 있다. API 문서를 작성하는 시간을 절약할 수 있고, API 정보를 실시간으로 유지할 수 있다는 장점이 있다.</p>
<h3 id="springfox와-springdoc">Springfox와 Springdoc</h3>
<p>Spring에서 Swagger를 쉽게 사용할 수 있도록 도와주는 라이브러리로 Springdoc과 Springfox가 존재한다. 현재 2022년을 기준으로 사람들은 Springfox를 더 많이 사용하고 있기 때문에 Springfox를 적용해보겠다.</p>
<h1 id="적용">적용</h1>
<h2 id="buildgradle에-의존성-추가">build.gradle에 의존성 추가</h2>
<pre><code>    implementation &#39;io.springfox:springfox-boot-starter:3.0.0&#39;</code></pre><h2 id="swaggerconfig">SwaggerConfig</h2>
<pre><code class="language-java">@Configuration
public class SwaggerConfig {

    private Docket testDocket(String groupName, Predicate&lt;String&gt; selector) {
        return new Docket(DocumentationType.OAS_30)
                .apiInfo(this.apiInfo(groupName)) // ApiInfo 설정
                .useDefaultResponseMessages(false)
                .groupName(&quot;testApi&quot;)
                .select()
                .apis(RequestHandlerSelectors.
                        basePackage(&quot;패키지명&quot;))
                .paths(PathSelectors.ant(&quot;/api/**&quot;)).build();
    }

    private ApiInfo apiInfo() {
          return new ApiInfoBuilder()
                .title(&quot;제목&quot;)
                .description(&quot;설명&quot;)
                .version(version)
                .contact(new Contact(&quot;이름&quot;, &quot;홈페이지 URL&quot;, &quot;e-mail&quot;))
                .build();
    }
}</code></pre>
<ul>
<li>3.0으로 넘어오면서 <code>@EnableSwagger2</code> 는 사용하지 않아도 된다.</li>
<li><strong>Docket</strong>: Swagger 설정의 핵심이 되는 Bean이다.</li>
<li><strong>groupName()</strong>: 만약 Docket이 하나라면 생략이 가능하지만, 여러 개라면 groupName이 충돌해 오류가 발생하기 때문에 groupName을 명시해주어야 한다.</li>
<li><strong>select()</strong>: ApiSelectorBuild 클래스의 인스턴스를 반환한다.</li>
<li><strong>useDefaultResponseMessages()</strong>: true로 설정하면 Swagger에서 제공해주는 기본 응답 코드를 보여준다.</li>
<li><strong>apis()</strong>: API가 작성되어 있는 Controller 패키지를 지정한다.(예: <code>com.example.demo.XXXApiController</code>) 만약, <code>RequestHandlerSelectors.any()</code>로 설정한다면 전체 API에 대한 문서를 Swagger를 통해 나타낼 수 있다.</li>
<li><strong>paths()</strong>: 나타내고자 하는 API path를 작성한다. <code>PathSelectors.any()</code>로 설정한다면 패키지 안의 모든 API에 대한 문서를 나타낼 수 있다.</li>
</ul>
<h2 id="controller-작성">Controller 작성</h2>
<p>이제 Swagger에 나타낼 Controller를 작성해보겠다.</p>
<pre><code class="language-java">import io.swagger.annotations.Api;
import io.swagger.annotations.ApiModelProperty;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import lombok.Data;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;

//Controller
@Api(tags = &quot;Controller 이름&quot;)
@RestController
public class TestApiController {

    @Operation(summary = &quot;요약&quot;, description = &quot;설명&quot;)
    @ApiResponses({
            @ApiResponse(responseCode = &quot;200&quot;, description = &quot;OK&quot;),
            @ApiResponse(responseCode = &quot;500&quot;, description = &quot;Server Error&quot;)
    })
    @PostMapping(&quot;/api/test&quot;)
    public ResponseDto exampleMethod(
            @Parameter(description = &quot;파라미터 설명&quot;, example = &quot;1&quot;) @Valid RequestDto requestDto
    ) {
        return new ResponseDto();
    }
}

// RequestDto
@Data
class RequestDto {
    @ApiModelProperty(value=&quot;값1 설명&quot;, example=&quot;0&quot;)
    @NotNull
    private Long reqVar1;

    @ApiModelProperty(value=&quot;값2 설명&quot;, example=&quot;example string&quot;)
    private String reqVar2;
}

// ResponseDto
@Data
class ResponseDto {
    @ApiModelProperty(value=&quot;값1 설명&quot;, example=&quot;0&quot;)
    private Long resVar1;

    @ApiModelProperty(value=&quot;값2 설명&quot;, example=&quot;example string&quot;, hidden = true)
    private String resVar2;
}</code></pre>
<p>각각의 어노테이션에 다양한 값이 있어 자세히 나타낼 수 있다. 일부만 간단하게 작성해보았다.</p>
<ul>
<li><strong>@Api</strong>: <code>tags</code> 값으로 Swagger에 나타낼 Controller의 이름을 지정했다.</li>
<li><strong>@Operation</strong>: API에 대한 요약 정보(<code>summary</code>)와 설명(<code>decription</code>)을 작성했다.</li>
<li><strong>@ApiResponse</strong>: API의 HTTP Status Code 반환 값에 대한 설명을 작성했다.<ul>
<li><strong>@ApiResponses</strong> 어노테이션을 이용해 여러 개의 반환 값을 작성할 수 있다.</li>
</ul>
</li>
<li><strong>@Parameter</strong>: 파라미터에 대한 설명(<code>decription</code>)와 예시(<code>example</code>)을 작성했다.</li>
<li><strong>@ApiModelProperty</strong>: DTO 필드에 대한 설명(<code>decription</code>)와 예시(<code>example</code>)을 작성했다.</li>
</ul>
<p>애플리케이션을 실행시키고 <a href="http://localhost:8080/swagger-ui/index.html">http://localhost:8080/swagger-ui/index.html</a>로 접속해 Swagger를 실행시키면 작성한 API에 대한 문서가 생성된 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/u-nij/post/20fa09b7-59e3-463a-a146-e7abafd0c1ff/image.png" alt="">
Controller 부분과 Schemas 부분을 간단히 들여다보겠다. 직관적이라 쉽게 이해할 수 있다.</p>
<h3 id="controller">Controller</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/bec47b32-b5df-4cbe-9674-d24b4238d1b3/image.png" alt=""> <code>@Valid</code> 어노테이션에 의해 RequestDto의 <code>@NotNull</code> 어노테이션이 적용되어 resVar1 값 옆에 <strong>*required</strong>라고 명시된 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/u-nij/post/bcdbbf2b-0c6e-4a27-9955-80d58038d63b/image.png" alt=""> <strong>@ApiResponse</strong>로 작성한 반환 값들에 대한 설명을 확인할 수 있다. Docket 객체의 <strong>useDefaultResponseMessages()</strong> 값을 true로 설정해두었다면 200, 401, 403, 404에 대한 기본 응답 메세지 또한 확인할 수 있다.</p>
<h3 id="schemas">Schemas</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/b3ab0df1-ba78-4fc5-9b85-6ff4c264d2b4/image.png" alt=""> 응답 데이터에 대한 정보를 확인할 수 있다. reqVar2 값을 <code>hidden=true</code>로 설정해두었기 때문에 화면에 보이지 않는다. 요청DTO에도 설정해둘 수 있다.</p>
<h3 id="실행">실행</h3>
<p>왼쪽 상단의 &quot;Try it out&quot;을 눌러 API를 실행해보겠다.
<img src="https://velog.velcdn.com/images/u-nij/post/0915367c-6738-4423-8146-b75aa197cb31/image.png" alt=""></p>
<h2 id="apiinfo">ApiInfo</h2>
<p>ApiInfo 객체를 이용해 Swagger 위에 나타나는 부분을 커스터마이징 할 수 있다.
<img src="https://velog.velcdn.com/images/u-nij/post/81a090ee-0784-4cb8-88e4-c084a23011d0/image.png" alt=""></p>
<pre><code class="language-java">@Configuration
public class SwaggerConfig {

    private Docket testDocket(String groupName, Predicate&lt;String&gt; selector) {
        return new Docket(DocumentationType.OAS_30)
                .apiInfo(this.apiInfo(groupName)) // ApiInfo 설정
                .useDefaultResponseMessages(false)
                .groupName(&quot;testApi&quot;)
                .select()
                .apis(RequestHandlerSelectors.
                        basePackage(&quot;패키지명&quot;))
                .paths(PathSelectors.ant(&quot;/api/**&quot;)).build();
    }

    private ApiInfo apiInfo() {
          return new ApiInfoBuilder()
                .title(&quot;제목&quot;)
                .description(&quot;설명&quot;)
                .version(version)
                .contact(new Contact(&quot;이름&quot;, &quot;홈페이지 URL&quot;, &quot;e-mail&quot;))
                .build();
    }
}</code></pre>
<p>생성자를 이용해 모든 정보를 넣거나, 혹은 build() 메소드를 통해 원하는 정보를 넣어 객체를 생성할 수 있다. <img src="https://velog.velcdn.com/images/u-nij/post/a49450b7-b29c-42cf-bbc4-89c51e74f86b/image.png" alt=""></p>
<h2 id="jwt를-사용하기-위한-설정">JWT를 사용하기 위한 설정</h2>
<h3 id="swaggerconfig-1">SwaggerConfig</h3>
<pre><code class="language-java">@Configuration
public class SwaggerConfig {

    private Docket testDocket(String groupName, Predicate&lt;String&gt; selector) {
        return new Docket(DocumentationType.OAS_30)
                .useDefaultResponseMessages(false)
                .securityContexts(List.of(this.securityContext())) // SecurityContext 설정
                .securitySchemes(List.of(this.apiKey())) // ApiKey 설정
                .groupName(&quot;testApi&quot;)
                .select()
                .apis(RequestHandlerSelectors.
                        basePackage(&quot;패키지명&quot;))
                .paths(PathSelectors.ant(&quot;/api/**&quot;)).build();
    }

    // JWT SecurityContext 구성
    private SecurityContext securityContext() {
        return SecurityContext.builder()
                .securityReferences(defaultAuth())
                .build();
    }

    private List&lt;SecurityReference&gt; defaultAuth() {
        AuthorizationScope authorizationScope = new AuthorizationScope(&quot;global&quot;, &quot;accessEverything&quot;);
        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
        authorizationScopes[0] = authorizationScope;
        return List.of(new SecurityReference(&quot;Authorization&quot;, authorizationScopes));
    }

    // ApiKey 정의
    private ApiKey apiKey() {
        return new ApiKey(&quot;Authorization&quot;, &quot;Authorization&quot;, &quot;header&quot;);
    }
}</code></pre>
<blockquote>
<p>저는 JWT 토큰 방식을 이용해 <strong>Authorization Header</strong>를 &quot;Bearer {Access Token}&quot; 형식으로 받아와 인증을 처리했기 때문에, 이 글을 보시는 분들과 ApiKey를 정의하는 방식이 다를 수 있습니다.</p>
</blockquote>
<ul>
<li><strong>SecurityContext</strong>: 인증하는 방식을 설정한다. 전역 AuthorizationScope를 사용해 SecurityContext를 구성했다.</li>
<li><strong>ApiKey</strong>: Swagger 내에서 인증하는 방식으로, ApiKey는 JWT, Bearer, Authorization이 있다. Authorization을 인증 헤더로 포함하도록 ApiKey를 정의했다.(<strong>2번째 인자</strong>의 Authorization이 중요함!)</li>
</ul>
<h3 id="실행-1">실행</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/d42d4650-8486-40e3-8efb-66d14d2b4045/image.png" alt=""> Swagger를 실행해보면 오른쪽에 자물쇠 모양의 버튼이 생성된 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/u-nij/post/8860ce5e-875b-4724-950f-703b66a293d8/image.png" alt=""> 토큰을 입력하고 Authorize 버튼을 누르면 API에 대한 모든 요청이 HTTP 헤더에 토큰이 자동으로 포함된다.
<img src="https://velog.velcdn.com/images/u-nij/post/05ef9b97-c92c-4ad7-8cc4-4a105ac9d3b6/image.png" alt=""> 인증 후, 자물쇠가 잠겨있는 것을 확인할 수 있다.</p>
<h2 id="환경별-swagger-작동-처리">환경별 Swagger 작동 처리</h2>
<h3 id="시행착오">시행착오</h3>
<p>운영 환경에서는 Swagger가 작동하지 않도록 처리하는 것이 필요하다. 이 문제 때문에 며칠동안 고민이 많았는데, SwaggerConfig 클래스에 <code>@Profile</code> 어노테이션을 달았는데도 우선 &quot;작동&quot;이 된다는 점이었다.</p>
<p><strong>SwaggerConfig</strong></p>
<pre><code class="language-java">@Profile({&quot;!prod&quot;})
@Configuration
public class SwaggerConfig {
    // ...
}</code></pre>
<p>예상대로라면 <code>prod</code> 값일 때는 Swagger가 작동되지 않을 줄 알았다.</p>
<p><strong>운영 환경</strong></p>
<pre><code class="language-yaml">spring.profiles.active: prod</code></pre>
<p><img src="https://velog.velcdn.com/images/u-nij/post/57b3ead6-17d5-44bd-a000-6743b2565091/image.png" alt=""></p>
<p><strong>개발 환경</strong></p>
<pre><code class="language-yaml">spring.profiles.active: dev</code></pre>
<p><img src="https://velog.velcdn.com/images/u-nij/post/b9c2cf97-7b0c-4b0b-bc3a-5b2766f84354/image.png" alt=""></p>
<p>??????
.. 그리고 모든 Controller가 똑같이 보이고 똑같이 실행됐다. SecurityConfig에서 IP 등을 사용해 접근을 직접 제어하는 방법도 사용해보았지만, IP가 바뀔 때도 있을 것이고, 손이 많이 가는 방법이라 사용하고 싶지 않아서 더 고민을 하게 됐다.(API 문서 개발이 급해 그냥 커밋해버리고 싶다는 생각을 12129038109번정도 했다🤤...)</p>
<h3 id="찾아낸-방법👍">찾아낸 방법👍</h3>
<p>사실 이거 기록해두려고 글을 작성한 것 같다.. 구글링을 열심히 하다가!! <a href="https://hyooi.github.io/java/2021/09/22/spring-boot-swagger-3.0.html">이 글</a>을 보고 방법을 참고해 해결했다!! Docket 객체의 <code>enabled()</code> 메소드를 활용하는 방법이었다.</p>
<pre><code class="language-java">@Configuration
public class SwaggerConfig {

    @Profile({&quot;test || dev&quot;})
    @Bean
    public Docket indexApi() {
        return docket(&quot;default&quot;, PathSelectors.ant(&quot;/api/**&quot;));
    }

    @Bean
    @Profile({&quot;!test &amp;&amp; !dev&quot;})
    public Docket disable() {
        return new Docket(DocumentationType.OAS_30).enable(false);
    }</code></pre>
<p>운영 환경에서 아예 Swagger 페이지에 접근이 불가능해진다!!!
<img src="https://velog.velcdn.com/images/u-nij/post/ea9c727f-015a-4d8f-b333-67cca2c7d939/image.png" alt="">
이상 Swagger 적용기였다.. 끝까지 글을 봐주신 분들 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot JPA + QueryDSL 설정]]></title>
            <link>https://velog.io/@u-nij/Spring-Boot-JPA-QueryDSL-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@u-nij/Spring-Boot-JPA-QueryDSL-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 22 Nov 2022 04:56:40 GMT</pubDate>
            <description><![CDATA[<p>JPA를 사용하면 엔티티 중심으로 개발하게 되며, 검색을 할 때에도 테이블이 아닌 엔티티 객체를 대상으로 검색한다. 모든 DB 데이터를 객체로 변환해 검색하는 것은 불가능하며, 애플리케이션이 필요한 데이터만 DB에서 불러오려면 결국 검색 조건이 포함된 SQL이 필요하게 된다.</p>
<h1 id="querydsl이란">QueryDSL이란?</h1>
<p>정적 타입을 이용해 SQL과 같은 쿼리를 생성할 수 있도록 도와주는 프레임워크이다. 문자가 아닌 자바 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할수 있다. 또한, Query를 자동 완성해주며, 동적 쿼리 작성을 편리하게 할 수 있다. 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다는 장점도 있다.</p>
<blockquote>
<h3 id="jpql">JPQL</h3>
<p>JPA는 SQL을 추상화한 JPQL이라는 객체 지향 쿼리 언어를 제공한다. JPQL은 <strong>엔티티 객체</strong>를 대상으로 쿼리하며, SQL은 데이터베이스 테이블을 대상으로 쿼리한다.  </p>
</blockquote>
<pre><code class="language-java">List&lt;Member&gt; result = em.createQuery(
        &quot;select m From Member m where m.username like &#39;%kim%&#39;&quot;, // 테이블이 아닌 엔티티 Member
        Member.class
).getResultList();</code></pre>
<h3 id="querydsl">QueryDSL</h3>
<p>쿼리 작성이 단순하고 쉽다.</p>
<pre><code class="language-java">PAFactoryQuery query = new JPAQueryFactory(em);
QMember m = QMember.member; 
List&lt;Member&gt; list = query.selectFrom(m)
                        .where(m.name.like(&quot;kim&quot;))
                        .orderBy(m.id.desc())
                        .fetch();</code></pre>
<h1 id="querydsl-설정">QueryDSL 설정</h1>
<h2 id="개발환경">개발환경</h2>
<blockquote>
<p>Spring Boot 2.6.5
java 11.0.9
IDE Intellij
Windows</p>
</blockquote>
<p>Spring Boot 2.6 이상 버전에서는 Querydsl 5.0을 사용한다.</p>
<h2 id="buildgradle">build.gradle</h2>
<h3 id="querydsl-플러그인-추가">querydsl 플러그인 추가</h3>
<pre><code class="language-java">// Querydsl 버전 plugins 위에 추가
buildscript {
    ext {
        queryDslVersion = &quot;5.0.0&quot;
    }
}

plugins {
    id &#39;org.springframework.boot&#39; version &#39;2.6.3&#39;
    id &#39;io.spring.dependency-management&#39; version &#39;1.0.11.RELEASE&#39;
    id &#39;java&#39;
    id &quot;com.ewerk.gradle.plugins.querydsl&quot; version &quot;1.0.10&quot; // querydsl 플러그인 추가
}</code></pre>
<h3 id="querydsl-dependency-추가">querydsl dependency 추가</h3>
<pre><code class="language-java">dependencies {
    // ...
    implementation &quot;com.querydsl:querydsl-jpa:${queryDslVersion}&quot;
    implementation &quot;com.querydsl:querydsl-apt:${queryDslVersion}&quot;
}</code></pre>
<h3 id="querydsl-추가-설정">querydsl 추가 설정</h3>
<pre><code class="language-java">// querydsl에서 사용할 경로 설정(현재 지정한 부분은 .gitignore에 포함됨)
def querydslDir = &quot;$buildDir/generated/querydsl&quot;

// JPA 사용 여부 및 사용할 경로 설정
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

// build 시 사용할 sourceSet 추가 설정
sourceSets {
    main.java.srcDir querydslDir
}

// querydsl 컴파일 시 사용할 옵션 설정
compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
}

// querydsl이 compileClassPath를 상속하도록 설정
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}</code></pre>
<blockquote>
<p><img src="https://velog.velcdn.com/images/u-nij/post/03a7ebde-c171-4a15-8f52-efadff5e932e/image.png" alt=""> 나같은 경우 <code>sourceSet</code> 설정에 React와 관련된 설정을 미리 해두어서 햇갈렸는데, 설정 아래에 그대로 붙여 넣어줘도 문제가 없었다.</p>
</blockquote>
<h2 id="build">Build</h2>
<p>Intellij 우측 gradle 탭에서 other -&gt; compilQuerydsl을 누르거나 애플리케이션을 실행시켜 Build를 해주면, 위에서 지정한 querydslDir에 Q타입이 생기는 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/u-nij/post/01939813-0aec-4bbb-be2e-96e9d54cf4d5/image.png" alt=""></p>
<h1 id="jpaqueryfactory-등록">JPAQueryFactory 등록</h1>
<p>구글링을 해보면, &#39;JpaRepository&lt;XXX, Long&gt;, XXXRepositoryCustom&#39;을 <code>extends</code>하는 XXXRepository를 생성하고, XXXRepositoryCustom을 생성한 후에, XXXRepositoryImpl을 생성하는 복잡한 방법이 있다. 이러한 방법 대신, <code>@Bean</code>으로 등록하면 간단하게 QueryDSL을 사용할 수 있다.</p>
<h3 id="querydslconfig">QuerydslConfig</h3>
<pre><code class="language-java">import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;


@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {

    private final EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em){
        return new JPAQueryFactory(em);
    }
}</code></pre>
<p>이후 <code>JPAQueryFactory</code>을 선언하고 사용하면 된다.</p>
<h3 id="참고">참고</h3>
<p><a href="https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/">https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/</a>
<a href="https://dingdingmin-back-end-developer.tistory.com/entry/Spring-Data-JPA-7-Querydsl-%EC%82%AC%EC%9A%A9-gradle-7x">https://dingdingmin-back-end-developer.tistory.com/entry/Spring-Data-JPA-7-Querydsl-%EC%82%AC%EC%9A%A9-gradle-7x</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블 슈팅 221103]]></title>
            <link>https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221103</link>
            <guid>https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221103</guid>
            <pubDate>Tue, 08 Nov 2022 04:20:49 GMT</pubDate>
            <description><![CDATA[<h1 id="실행-환경">실행 환경</h1>
<ul>
<li>Spring Boot 2.7.3</li>
<li>Java 11.0.9</li>
<li>JUnit5</li>
<li>Mockito</li>
</ul>
<h1 id="상황">상황</h1>
<ul>
<li>우선, 작업이 촉박하게 이루어지는 상황이라 테스트 코드를 작성하지 못했다.. 이미 로직을 작성해둔 상태라서 로직에 맞춰서 테스트 코드를 작업하려고 했다. 일단, 좋은 테스트를 위한 FIRST 규칙의 Timely는 지키지 못했다.</li>
<li>JWT 토큰을 발급해주는 메서드의 테스트 코드를 작성 중이었다.</li>
</ul>
<h3 id="authservice">AuthService</h3>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisService redisService;

    public TokenDto generateToken(String email, String authorities) {
        TokenDto tokenDto = jwtTokenProvider.createToken(email, authorities);
        saveRefreshToken(email, tokenDto.getRefreshToken()); // // line 78
        return tokenDto;
    }

    public void saveRefreshToken(String email, String refreshToken) {
        redisService.setValuesWithTimeout(&quot;RT(&quot; + email + &quot;):&quot;, // key
                refreshToken, // value
                jwtTokenProvider.getTokenExpirationTime(refreshToken)); // timeout(milliseconds)
    }
}</code></pre>
<ul>
<li><code>generateToken</code>: Access Token과 Refresh Token을 생성하고, Redis에 Refresh Token을 저장</li>
<li><code>saveRefreshToken</code>: Redis에 Refresh Token을 유효 기간과 함께 저장. 유효 기관을 초과하면 사라진다.</li>
</ul>
<h3 id="authservicetest">AuthServiceTest</h3>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {

    @Mock
    JwtTokenProvider jwtTokenProvider;
    @Mock
    RedisService redisService;
    @Spy
    @InjectMocks
    AuthService authService;

    @Test
    public void generateToken() throws Exception {
        // given
        String email = &quot;user@email.com&quot;;
        String authorities = &quot;ROLE_USER&quot;;

        TokenDto returnTokenDto = new TokenDto(&quot;accessToken&quot;, &quot;refreshToken&quot;);

        Mockito.when(authService.generateToken(email, authorities))
                .thenReturn(returnTokenDto);

        // when
        TokenDto generateToken = authService.generateToken(email, authorities); // line 72

        // then
        assertEquals(generateToken, returnTokenDto);
    }
}</code></pre>
<ul>
<li>같은 TokenDto를 가리키게 하기 위해 <code>returnTokenDto</code> 객체를 생성했다.</li>
</ul>
<h1 id="발생한-문제--해결-방법">발생한 문제 &amp; 해결 방법</h1>
<h2 id="1-nullpointerexception">1. NullPointerException</h2>
<blockquote>
<p>java.lang.NullPointerException
    at {프로젝트명}.Service.AuthService.generateToken(AuthService.java:78)
    at {프로젝트명}.Service.AuthService.generateToken(AuthServiceTest.java:72)</p>
</blockquote>
<h3 id="왜">왜?</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/7bcb76e4-6dc4-4505-81b6-be1351b4ef4e/image.png" alt=""> 디버깅을 해보니 <code>tokenDto</code>가 비어있었는데, <code>JwtTokenProvider</code>가 Mock 객체임을 떠올렸다.
<code>AuthServiceTest</code>에 아래의 코드를 추가했다.</p>
<pre><code class="language-java">Mockito.when(jwtTokenProvider.createToken(email, authorities))
       .thenReturn(returnTokenDto);</code></pre>
<h2 id="2-cannotstubvoidmethodwithreturnvalue">2. CannotStubVoidMethodWithReturnValue</h2>
<blockquote>
<p>org.mockito.exceptions.misusing.CannotStubVoidMethodWithReturnValue: 
&#39;setValuesWithTimeout&#39; is a <em>void method</em> and it <em>cannot</em> be stubbed with a <em>return value</em>!
<strong>Voids are usually stubbed with Throwables:
    doThrow(exception).when(mock).someVoidMethod();
If you need to set the void method to do nothing you can use:
    doNothing().when(mock).someVoidMethod();</strong>
For more information, check out the javadocs for Mockito.doNothing().</p>
</blockquote>
<hr>
<p>If you&#39;re unsure why you&#39;re getting above error read on.
Due to the nature of the syntax above problem might occur because:</p>
<ol>
<li>The method you are trying to stub is <em>overloaded</em>. Make sure you are calling the right overloaded version.</li>
<li>Somewhere in your test you are stubbing <em>final methods</em>. Sorry, Mockito does not verify/stub final methods.</li>
<li>A spy is stubbed using when(spy.foo()).then() syntax. It is safer to stub spies - <ul>
<li>with doReturn|Throw() family of methods. More in javadocs for Mockito.spy() method.</li>
</ul>
</li>
<li>Mocking methods declared on non-public parent classes is not supported.</li>
</ol>
<h3 id="왜-1">왜?</h3>
<p><code>saveRefreshToken</code> 메소드의 <code>redisService.setValuesWithTimeout</code> 메소드가 void 값을 리턴하는 메소드이기 때문에, 반환 값을 가지는 stub 방식을 사용할 수 없다고 한다.
<strong>메소드를 선택적으로 stub할 수 있도록 하는 <code>@Spy</code> 어노테이션</strong>과 해결책으로 제시한 <code>doNothing().when(mock).someVoidMethod();</code>을 사용해 해결했다. <code>AuthService</code>를 Spy 객체로 만들고, 아래와 같이 <code>saveRefreshToken</code>에 대한 stub을 작성했다.</p>
<pre><code class="language-java">Mockito.doNothing()
        .when(authService)
        .saveRefreshToken(email, returnTokenDto.getRefreshToken());</code></pre>
<p>위의 코드를 추가하고 디버깅해보았더니, 실제 코드를 실행해보았더니 <code>generateToken</code>을 실행시켰을 때 AuthService <code>saveRefreshToken</code>을 호출하지 않고, stub으로 작성한 <strong>Spy 객체의 <code>saveRefreshToken</code> 메소드가 실행</strong>되는 것을 확인할 수 있었다.</p>
<h2 id="최종-코드">최종 코드</h2>
<h3 id="authservicetest-1">AuthServiceTest</h3>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {

    @Mock JwtTokenProvider jwtTokenProvider;
    @Mock RedisService redisService;

    @Spy
    @InjectMocks
    AuthService authService;

    @Test
    public void generateToken() throws Exception {
        // given
        String provider = SERVER;
        String email = &quot;user@email.com&quot;;
        String authorities = &quot;ROLE_USER&quot;;

        TokenDto returnTokenDto = new TokenDto(&quot;at&quot;, &quot;rt&quot;);

        Mockito.when(jwtTokenProvider.createToken(email, authorities))
                .thenReturn(returnTokenDto);
        Mockito.doNothing()
                .when(authService)
                .saveRefreshToken(email, returnTokenDto.getRefreshToken());
        Mockito.when(authService.generateToken(email, authorities))
                .thenReturn(returnTokenDto);

        // when
        TokenDto generateToken = authService.generateToken(email, authorities);

        // then
        assertEquals(generateToken, returnTokenDto);
    }
}</code></pre>
<h3 id="실행-결과">실행 결과</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/644dc3e6-2ec8-42e4-a1e1-aaa557b472e2/image.png" alt=""></p>
<p>한 3일동안 해결하지 못했던 문제였어서 &#39;시간도 없는데.. 통합테스트로 바꿔버릴까..&#39; 생각도 잠깐 들었지만🙄 단위 테스트 코드를 꼭 한 번 작성해보고 싶었다!! 에러를 해결하지 못했을 때, 실제 AuthService의 <code>saveRefreshToken</code> 메소드를 호출하는 것을 보면서 &#39;이 방식이 단위 테스트가 맞나..?&#39; 했는데, 결과적으로 Spy 객체를 사용함으로써 단위 테스트의 의미를 퇴색시키지 않은 것 같아 다행이다.</p>
<h3 id="참고">참고</h3>
<p><a href="https://jojoldu.tistory.com/239">https://jojoldu.tistory.com/239</a>
<a href="https://stackoverflow.com/questions/33124153/mockito-nullpointerexception-when-stubbing-method">https://stackoverflow.com/questions/33124153/mockito-nullpointerexception-when-stubbing-method</a>
<a href="https://cobbybb.tistory.com/16">https://cobbybb.tistory.com/16</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JUnit5] 단위 테스트(@Extendwith, Mockito)]]></title>
            <link>https://velog.io/@u-nij/JUnit5-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8Extendwith</link>
            <guid>https://velog.io/@u-nij/JUnit5-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8Extendwith</guid>
            <pubDate>Wed, 02 Nov 2022 16:47:15 GMT</pubDate>
            <description><![CDATA[<h1 id="단위-테스트unit-test">단위 테스트(Unit Test)</h1>
<p>테스트 대상 단위가 엄격하게 정해져있지는 않지만, 일반적으로 클래스 또는 메소드 수준으로 정해진다. 단위의 크기가 작을수록 단위의 복잡성이 낮아지고, 실행하려는 기능을 표현하기 더 쉬워진다. 단위 테스트는 해당 부분만 독립적으로 테스트하기 때문에 어떤 코드를 리팩토링하여도 빠르게 문제 여부를 파악할 수 있다. 개발자는 작성한 테스트 코드를 수시로 빠르게 돌리면서 문제점을 파악할 수 있다.</p>
<h2 id="extendwith">@ExtendWith</h2>
<p>단위 테스트에 공통적으로 사용할 확장 기능을 선언해주는 역할을 한다. 인자로 확장할 Extension을 명시하면 된다. <code>SpringExtension.class</code> 또는 <code>MockitoExtension.class</code>를 많이 사용한다. Spring Test Context 프레임워크와 Junit5와 통합해 사용할 때는 <code>SpringExtension.class</code>를 사용한다. JUniit5와 Mockito를 연동해 테스트를 진행할 경우에는 <code>MockitoExtension.class</code>를 사용한다. (<a href="https://stackoverflow.com/questions/61433806/junit-5-with-spring-boot-when-to-use-extendwith-spring-or-mockito">스택오버플로우</a>를 참고해보면 더 자세히 알 수 있다.)</p>
<h2 id="mockito">Mockito</h2>
<p>개발자가 동작을 직접 제어할 수 있는 가짜(Mock) 객체를 지원하는 테스트 프레임워크이다. 일반적으로 Spring으로 웹 애플리케이션을 개발하면, 여러 객체들 간의 의존성이 생긴다. 이러한 의존성은 단위 테스트를 작성을 어렵게 하는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있다. Mockito를 활용하면 가짜 객체에 원하는 결과를 <strong>Stub</strong>하여 단위 테스트를 진행할 수 있다.</p>
<h3 id="mock">@Mock</h3>
<p>Mock 객체를 생성한다. 실제로 메서드는 갖고 있지만 내부 구현이 없는 상태이다.</p>
<h3 id="spy">@Spy</h3>
<p>모든 기능을 가지고 있는 완전한 객체이다. Stub하지 않은 메소드들은 원본 메소드 그대로 사용한다. 즉, 테스트 대상의 일부분만 Mocking 하는 것이다. 대체로 <code>@Spy</code>보다는 <code>@Mock</code>을 쓰는 것을 추천하지만, 외부 라이브러리를 이용한 테스트에는 <code>@Spy</code>를 사용하는 것을 추천한다.</p>
<h3 id="injectmocks">@InjectMocks</h3>
<p><code>@Mock</code> 또는 <code>@Spy</code>로 생성된 가짜 객체를 자동으로 <strong>주입</strong>시켜주는 객체이다. </p>
<p><code>@InjectMocks</code> 객체에서 사용할 객체를 <code>@Mock</code>으로 만들어 쓰면 된다. 만약 Service를 테스트하는 클래스를 생성했다면, Service 객체를 <code>@InjectMocks</code> 어노테이션을 사용해 생성하고, Service단에서 사용할 Repository와 같은 객체들은 <code>@Mock</code> 어노테이션을 사용해 생성하면 된다.</p>
<h3 id="stub">Stub</h3>
<p>다른 객체 대신에 가짜 객체(Mock Object)를 주입하여 어떤 결과를 반환하라고 정해진 답변을 준비시킨다. Mock 객체의 메소드를 호출해도 실제로 코드를 실행하지 않기 때문에, 메소드의 행동을 미리 정해두어야 한다. <code>when()</code>, <code>thenReturn()</code>, <code>thenThrow()</code> 등을 사용해 리턴 값 또는 예외 발생을 정할 수 있다. 같은 조건으로 다시 stub 할 경우 이전의 행동을 덮어 씌운다.</p>
<blockquote>
<p><strong>doReturn / thenReturn</strong>
테스트 대상 기능 중 일부 기능이 준비가 되지 않았을 때 혹은 다른 시스템과 통신 등이 필요한 경우, 미리 리턴 값을 선언하여 테스트할 수 있다.</p>
<ul>
<li><strong>doReturn</strong><ul>
<li>메소드를 <strong>실제 호출하지 않으면서</strong> 리턴 값을 임의로 정할 수 있다. </li>
<li>실제로 메소드를 호출하지 않기 때문에 대상 메소드에 문제점이 있어도 알 수 없다.</li>
</ul>
</li>
<li><strong>thenReturn</strong><ul>
<li>메소드를 <strong>실제 호출</strong>하지만, 리턴 값을 임의로 정의할 수 있다.</li>
<li>메소드 작업이 오래 걸릴 경우 끝날 때까지 기다려야 한다.</li>
<li>실제 메소드를 호출하기 때문에, 대상 메소드에 문제점이 있을 경우 발견할 수 있다.</li>
</ul>
</li>
</ul>
</blockquote>
<h3 id="참고">참고</h3>
<p><a href="https://mangkyu.tistory.com/143">https://mangkyu.tistory.com/143</a>
<a href="https://velog.io/@ausg/Mockito-Test-Framework-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">https://velog.io/@ausg/Mockito-Test-Framework-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</a>
<a href="https://jojoldu.tistory.com/239">https://jojoldu.tistory.com/239</a>
<a href="https://royleej9.tistory.com/entry/Mockito-doReturn-thenReturn">https://royleej9.tistory.com/entry/Mockito-doReturn-thenReturn</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JUnit5] 통합 테스트(@SpringBootTest), @MockBean]]></title>
            <link>https://velog.io/@u-nij/JUnit5-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8SpringBootTest-%EC%8A%AC%EB%9D%BC%EC%9D%B4%EC%8A%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8MockBean</link>
            <guid>https://velog.io/@u-nij/JUnit5-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8SpringBootTest-%EC%8A%AC%EB%9D%BC%EC%9D%B4%EC%8A%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8MockBean</guid>
            <pubDate>Wed, 02 Nov 2022 09:29:20 GMT</pubDate>
            <description><![CDATA[<h1 id="통합-테스트">통합 테스트</h1>
<p>실제 운영 환경에서 사용될 클래스들을 통합해 테스트한다. 단위 테스트처럼 기능 검증을 위한 것이 아닌, 전체적으로 플로우가 제대로 동작하는지 검증하기 위해 사용한다. 애플리케이션 설정과 Bean들을 모두 로드해 운영환경과 가장 유사한 테스트가 가능하다는 장점이 있지만, 시간이 오래 걸리고 무겁다는 단점이 있다. 또, 단위가 크기 때문에 디버깅이 어려운 편이다.</p>
<h3 id="buildgradle">build.gradle</h3>
<pre><code>    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;</code></pre><h1 id="springboottest">@SpringBootTest</h1>
<pre><code class="language-java">@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class SampleControllerTest {
​
   @Autowired
   MockMvc mockMvc;
​
   @Test
   public void hello() throws Exception {
       mockMvc.perform(get(&quot;/hello&quot;))
          .andExpect(status().isOk())
          .andExpect(content().string(&quot;hello namjune&quot;))
          .andDo(print());
  }</code></pre>
<p><code>@SpringBootApplication</code>을 찾아서 테스트를 위한 빈들을 다 생성한다. 그리고, <code>@MockBean</code>으로 정의된 빈을 찾아서 교체한다.</p>
<h2 id="webenvironment">webEnvironment</h2>
<p>웹 테스트 환경 구성이 가능하다. default 값은 <code>MOCK</code>이다. 
<img src="https://velog.velcdn.com/images/u-nij/post/6c2fc10e-b344-4a92-a27c-81665aa6da19/image.png" alt=""></p>
<h3 id="mock">MOCK</h3>
<p>WebApplicationContext를 로드하며, 내장된 서블릿 컨테이너가 아닌 Mock 서블릿을 제공한다. <code>@AutoConfigureMockMvc</code> 어노테이션을 함께 사용하면 별다른 설정 없이 간편하게 <strong>MockMvc</strong>를 사용한 테스트를 진행할 수 있다. MockMvc는 실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체를 만들어 스프링 MVC 동작을 재현할 수 있게 해주는 클래스이다. 브라우저에서 요청과 응답을 의미하는 객체로써, Controller 테스트 사용을 용이하게 해준다.</p>
<pre><code class="language-java">@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
class XXXTest {
    @Autowired
    public MockMvc mockMvc;
}</code></pre>
<h3 id="random_port--defined_port">RANDOM_PORT / DEFINED_PORT</h3>
<p>EmbeddedWebApplicationContext를 로드하며 실제 서블릿 환경을 구성한다. MockMvc 대신, <strong>RestTemplate</strong>를 사용할 수 있다. 실제 가용한 포트로 내장 톰캣을 띄우고 응답을 받아, 실제 서버가 동작하는 것처럼 테스트를 수행할 수 있다.</p>
<p><strong>TestRestTemplate</strong>
RestTemplate의 테스트를 위한 버전이다. <code>NONE</code> 설정을 제외한 webEnvironment 설정시 그에 맞춰서 자동으로 설정되어 빈이 생성된다. HTTP 요청 후, JSON, xml, String과 같은 응답을 받을 수 있는 템플릿이다.</p>
<pre><code class="language-java">@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class XXXTest {
    @Autowired 
    private TestRestTemplate restTemplate;
}</code></pre>
<h3 id="none">NONE</h3>
<p>서블릿 환경을 제공하지 않는다.</p>
<blockquote>
<h3 id="mockmvc과-testresttemplate의-차이">MockMvc과 TestRestTemplate의 차이</h3>
<p><strong>Servlet Container</strong>
MockMvc는 서블릿 컨테이너를 생성하지 않는 반면, TestRestTemplate은 서블릿 컨테이너를 사용한다. 그래서 마치 실제 서버가 동작하는 것처럼 테스트를 수행할 수 있다.(몇몇 Bean은 Mock 객체로 대체될 수는 있다.)</p>
<p><strong>테스트 관점</strong>
MockMvc는 서버 입장에서 구현한 API를 통해 비즈니스 로직이 문제 없이 수행되는지 테스트할 수 있다면, TestRestTemplate은 클라이언트의 입장에서 RestTemplate을 사용하듯이 테스트를 수행할 수 있다.</p>
</blockquote>
<h2 id="properties">properties</h2>
<p>프로퍼티를 <code>{key=value}</code> 형식으로 직접 추가할 수 있다.</p>
<pre><code class="language-java">@SpringBootTest(
    properties = {
        &quot;propertyTest.value=propertyTest&quot;
    }
)
class XXXTest {

    @Value(&quot;${propertyTest.value}&quot;)
    private String propertyValue; // 값: &quot;propertyTest&quot;

}</code></pre>
<p>기본적으로 클래스 경로상의 <code>application.properties</code> 또는 <code>application.yml</code>를 통해 애플리케이션 설정을 하지만, 테스트를 위한 다른 설정이 필요할 경우 다른 프로퍼티를 로드할 수 있다.</p>
<pre><code class="language-java">@SpringBootTest(
    properties = {
        &quot;spring.config.location=classpath:application-test.yml&quot;
    }
)
class XXXTest {
    // ...
}</code></pre>
<h1 id="mockbean">@MockBean</h1>
<p>Controller 테스트 코드에서 Service 단으로 흘러 들어갈 경우, 테스트 단위가 너무 커지게 되고 구동 시간이 오래 걸린다. 만약 Controller만 테스트 하고 싶을 경우, Service 객체를 MockBean으로 만들어 사용할 수 있다. <code>@MockBean</code> 어노테이션을 사용하게 되면,  Spring Application Context에 들어있는 <strong>Bean을 Mock으로 만든 객체(가짜 객체)</strong>로 교체한다. 모든 <code>@Test</code>마다 자동으로 리셋된다. 테스트할 때 Spring Boot Container가 필요하고 Bean이 Container에 존재한다면 <code>@MockBean</code>을 사용하고, 아닌 경우에는 <code>@Mock</code>을 쓰면 된다.</p>
<pre><code class="language-java">@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
class XXXTest {
    @Autowired
    public MockMvc mockMvc;

    @MockBean
    public TestService testService;
}</code></pre>
<blockquote>
<h3 id="슬라이스-테스트">슬라이스 테스트</h3>
<p>계층(layer) 별로 테스트하고 싶을 때 사용하게 되는 방법이다. 글의 시작 부분에 적은 것처럼<code>@SpringBootTest</code>의 경우 모든 Bean을 로드하는 통합 테스트이기 때문에, 테스트 구동 시간이 오래 걸리는 단점이 있다. 특정 계층만을 테스트하고 싶을 때 사용하면 유용하다. <code>@JsonTest</code>, <code>@WebFluxTest</code>, <code>@DataJpaTest</code> 등 다양한 어노테이션이 있지만, <code>@WebMvcTest</code>만 간단하게 적고 넘어가겠다.
<strong>@WebMvcTest</strong>
Application Context을 전체 로드해 사용하지 않고, Web 계층을 테스트하고 싶을 때 사용한다.  Service, Repository 등 다른 dependency가 필요한 경우에 <code>@MockBean</code>으로 주입받아 테스트를 진행한다.</p>
</blockquote>
<h1 id="적용">적용</h1>
<h2 id="공통으로-사용할-클래스">공통으로 사용할 클래스</h2>
<h3 id="simplecontroller">SimpleController</h3>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/test&quot;)
public class SimpleController {

    @GetMapping(&quot;/&quot;)
    public ResponseEntity&lt;String&gt; getTest() {
        return ResponseEntity.ok(&quot;hello&quot;);
    }

    @PostMapping(&quot;/&quot;)
    public ResponseEntity&lt;Map&lt;String, String&gt;&gt; postTest(@RequestBody String request) {
        Map&lt;String, String&gt; body = new HashMap&lt;&gt;();
        body.put(&quot;response&quot;, request);
        return ResponseEntity.ok().body(body);
    }

    @GetMapping(&quot;/dto&quot;)
    public ResponseEntity&lt;List&lt;TestDto&gt;&gt; returnDtoTest() {
        TestDto testDto1 = TestDto.builder()
                .name(&quot;테스터1&quot;)
                .email(&quot;test1@test.co.kr&quot;)
                .build();
        TestDto testDto2 = TestDto.builder()
                .name(&quot;테스터2&quot;)
                .email(&quot;test2@test.co.kr&quot;)
                .build();
        List&lt;TestDto&gt; body = new ArrayList&lt;&gt;();
        body.add(testDto1);
        body.add(testDto2);
        return ResponseEntity.ok().body(body);
    }
}</code></pre>
<p><code>MockMvc</code>과 <code>TestRestTemplate</code>을 </p>
<h3 id="testdto">TestDto</h3>
<pre><code class="language-java">@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TestDto {
    private String name;
    private String email;

    @Builder
    public TestDto(String name, String email) {
        this.name = name;
        this.email = email;
    }
}</code></pre>
<h2 id="webenvironment별-테스트-클래스">webEnvironment별 테스트 클래스</h2>
<h3 id="mock-1">MOCK</h3>
<pre><code class="language-java">@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
class SimpleMockControllerTest {

    @Autowired
    public MockMvc mockMvc;

    @Test
    public void GET_테스트() throws Exception {
        mockMvc.perform(get(&quot;/api/test/&quot;))
                .andExpect(status().isOk());
    }

    @Test
    public void POST_테스트() throws Exception {
        String request = &quot;test&quot;;

        mockMvc.perform(post(&quot;/api/test/&quot;)
                        .content(request)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath(&quot;$.response&quot;).value(request));
    }

    @Test
    public void DTO_리턴_테스트() throws Exception {
        mockMvc.perform(get(&quot;/api/test/dto&quot;)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath(&quot;$.[0].name&quot;).value(&quot;테스터1&quot;))        
                .andExpect(jsonPath(&quot;$.[1].name&quot;).value(&quot;테스터2&quot;));
    }
}</code></pre>
<h3 id="random_port">RANDOM_PORT</h3>
<pre><code class="language-java">@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SimpleRandomPortControllerTest {

    @Autowired
    public TestRestTemplate testRestTemplate;

    // 응답 Body를 JsonNode로 변환해주는 메소드
    public JsonNode readInfo(String content) {
        try {
            return new ObjectMapper().readTree(content);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(&quot;JsonProcessingException&quot;);
        }
    }

    @Test
    public void GET_테스트() throws Exception {
        ResponseEntity&lt;String&gt; response = testRestTemplate.getForEntity(&quot;/api/test/&quot;, String.class);
        assertThat(response.getStatusCode().value()).isEqualTo(200);
    }

    @Test
    public void POST_테스트() throws Exception {
        String request = &quot;test&quot;;

        ResponseEntity&lt;String&gt; response = testRestTemplate.postForEntity(&quot;/api/test/&quot;, request, String.class);
        JsonNode responseInfo = readInfo(response.getBody());

        assertThat(response.getStatusCodeValue()).isEqualTo(200);
        assertThat(responseInfo.get(&quot;response&quot;).asText()).isEqualTo(request);
    }

    @Test
    public void DTO_리턴_테스트() throws Exception {

        ResponseEntity&lt;String&gt; response = testRestTemplate.getForEntity(&quot;/api/test/dto&quot;, String.class);
        JsonNode responseInfo = readInfo(response.getBody());

        assertThat(response.getStatusCodeValue()).isEqualTo(200);
        assertThat(responseInfo.get(0).get(&quot;name&quot;).asText()).isEqualTo(&quot;테스터1&quot;);
        assertThat(responseInfo.get(1).get(&quot;name&quot;).asText()).isEqualTo(&quot;테스터2&quot;);
    }
}</code></pre>
<h3 id="참고">참고</h3>
<p><a href="https://ict-nroo.tistory.com/96">https://ict-nroo.tistory.com/96</a>
<a href="https://www.inflearn.com/blogs/339">https://www.inflearn.com/blogs/339</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JUnit5] Spring Boot Test DB 분리]]></title>
            <link>https://velog.io/@u-nij/TDD-Spring-Boot-Test-DB-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@u-nij/TDD-Spring-Boot-Test-DB-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Wed, 02 Nov 2022 04:39:50 GMT</pubDate>
            <description><![CDATA[<p>개발환경에서 MySQL를 사용하고 있는데, 테스트 코드를 작성할 때 데이터베이스의 분리가 필요하다고 생각되어 H2를 따로 연결하게 되었다.</p>
<h1 id="h2-연결">H2 연결</h1>
<h3 id="buildgradle">build.gradle</h3>
<pre><code class="language-java">    runtimeOnly &#39;mysql:mysql-connector-java&#39; // 개발 환경에서 사용하는 DB(MySQL)
    runtimeOnly &#39;com.h2database:h2&#39; // 테스트 코드 작성시 사용할 DB(H2)</code></pre>
<h3 id="applicationyml">application.yml</h3>
<ul>
<li>위치: src/test/resources/application.yml</li>
<li>H2에서 데이터베이스를 미리 생성해주어야 한다.<pre><code class="language-yaml">spring:
datasource:
  initialization-mode: always
  url: jdbc:h2:tcp://localhost/~/test
  username: sa
  password:
  driver-class-name: org.h2.Driver
jpa:
  hibernate:
    ddl-auto: create
  properties:
    hibernate:
      format_sql: true
logging.level:
org.hibernate.SQL: debug</code></pre>
</li>
<li><code>jpa.hibernate.ddl-auto: create</code>: 기존 테이블 삭제 후 다시 생성한다.</li>
<li><code>jpa.properties.hibernate.format_sql: true</code>: DB 쿼리를 포맷해 보기 좋게 보여준다.</li>
<li><code>logging.level.org.hibernate.SQL: debug</code>: 로그의 ?에 어떤 값이 들어갔는지 확인할 수 있다.</li>
</ul>
<blockquote>
<p>H2 DB를 <strong>In-Memory 방식</strong>으로 사용할 수도 있다.(<a href="https://www.h2database.com/html/cheatSheet.html">링크</a> 참조)
<code>spring.datasource.url</code>에 <code>jdbc:h2:mem</code>을 작성해거나, <code>spring.datasource</code>의 값들을 다 삭제하면 된다.
<img src="https://velog.velcdn.com/images/u-nij/post/572733b6-01d0-46f1-8486-5e9477063eef/image.png" alt=""></p>
</blockquote>
<h3 id="test-class">Test Class</h3>
<p>만약, MySQL을 사용하다가 DB를 분리했을 경우 <code>java.lang.illegalstateexception: failed to load applicationcontext</code> 에러가 발생할 수도 있다. 보통 H2 DB를 켜두지 않았거나, H2와 MySQL 연결에 혼동이 생겨 발생하는 에러인 듯 하다.</p>
<pre><code class="language-java">@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2)
public class XXXTest {
    // ...
}</code></pre>
<p>테스트 클래스에 <code>@AutoConfigureTestDatabase</code> 어노테이션을 사용해 H2 DB와 연결할 것임을 명시해주면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블 슈팅 221029]]></title>
            <link>https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221029</link>
            <guid>https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221029</guid>
            <pubDate>Sat, 29 Oct 2022 12:57:33 GMT</pubDate>
            <description><![CDATA[<h1 id="실행-환경">실행 환경</h1>
<p>Spring Boot 2.7.3
Java 11.0.9</p>
<h1 id="상황">상황</h1>
<ul>
<li><p>Controller에서 <code>@Valid</code> 어노테이션을 사용해서</p>
</li>
<li><p>Bean Validation(<code>@NotNull</code>)를 이용해 요청으로 받은 application/json 데이터를 검사</p>
<pre><code class="language-java">// 예시
public ResponseEntity&lt;String&gt; postComment(@RequestBody @Valid CommentDto commentDto) {
      // ...
}</code></pre>
<pre><code class="language-java">// 예시
public class RequestDto {

  @Getter
  @NoArgsConstructor(access = AccessLevel.PROTECTED)
  public static class CommentDto {
      @NotNull
      private Boolean isPublic;
      private String text;
  }
}</code></pre>
</li>
<li><p>아래처럼 요청을 보내게 됐을 때, <code>MethodArgumentNotValidException</code> 예외가 발생하기 때문에</p>
<pre><code class="language-json">{
  &quot;text&quot;: &quot;abc&quot;
}</code></pre>
</li>
</ul>
<blockquote>
<p>.m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [1] in public {Response_Return_Type} {Controller_Method}: [Field error in object &#39;{DTO}&#39; on field &#39;{Field_Name}&#39;: rejected value [null]; codes [NotNull.requestDto.mobileIsPublic,NotNull.{Field_Name},NotNull.java.lang.Boolean,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [{DTO}.{Field_Name}]; arguments []; default message [{Field_Name}]]; default message [널이어서는 안됩니다]] ]</p>
<ul>
<li>Response_Return_Type: org.springframework.http.ResponseEntity&lt;java.lang.String&gt;</li>
<li>Controller_Method: postComment(CommentDto)</li>
<li>DTO: CommentDto</li>
<li>Field_Name: isPublic(예외 발생 원인)</li>
</ul>
</blockquote>
<ul>
<li><p><code>@RestControllerAdvice</code> 어노테이션을 사용한 GlobalExceptionHandler를 이용해 예외 처리를 해준 상태였다.</p>
<pre><code class="language-java">// 예시
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity&lt;ErrorResponse&gt; handleArgumentNotValidException(MethodArgumentNotValidException ex) {
      return ResponseEntity
              .status(HttpStatus.BAD_REQUEST); // 400 상태 코드 반환
  }
}</code></pre>
</li>
</ul>
<h1 id="발생한-에러">발생한 에러</h1>
<ul>
<li>스프링부트 어플리케이션 실행시 <code>BeanCreationException</code> 발생<blockquote>
<p>org.springframework.beans.factory.BeanCreationException: Error creating bean with name &#39;handlerExceptionResolver&#39; defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.web.servlet.HandlerExceptionResolver]: Factory method &#39;handlerExceptionResolver&#39; threw exception; nested exception is java.lang.IllegalStateException: <strong>Ambiguous @ExceptionHandler method mapped for [class org.springframework.web.bind.MethodArgumentNotValidException]</strong>: {public org.springframework.http.ResponseEntity {프로젝트명}.<strong>GlobalExceptionHandler.handleArgumentNotValidException(org.springframework.web.bind.MethodArgumentNotValidException)</strong>, public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}</p>
</blockquote>
</li>
</ul>
<h3 id="왜">왜?</h3>
<p><code>@ExceptionHandler</code>에 이미 <code>MethodArgumentNotValidException</code>이 구현되어있기 때문에 GlobalExceptionHandler에서 동일한 예외 처리를 하게 되면, Ambiguous(모호성) 문제가 발생한다. 즉, <code>MethodArgumentNotValidException</code> 예외에 대해 어떤 방법을 사용해야 할 지 모르게 된다.</p>
<h1 id="해결-방법">해결 방법</h1>
<ul>
<li><p><a href="https://stackoverflow.com/questions/51991992/getting-ambiguous-exceptionhandler-method-mapped-for-methodargumentnotvalidexce">스택플로우</a> 참고</p>
</li>
<li><p><code>@ExceptionHandler</code> 어노테이션을 사용하는 대신, 해당 핸들러(<code>handleMethodArgumentNotValid</code>)를 직접 오버라이드해서 사용한다.
<img src="https://velog.velcdn.com/images/u-nij/post/d96e603f-92d5-4b1a-a0e4-73b5e6125d59/image.png" alt=""></p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

  @Override
  protected ResponseEntity&lt;Object&gt; handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                                                HttpHeaders headers,
                                                                HttpStatus status,
                                                                WebRequest request) {
      return super.handleMethodArgumentNotValid(ex, headers, status, request);
  }</code></pre>
<h3 id="실행-결과">실행 결과</h3>
</li>
<li><p>저는 에러 응답 형식을 일정하게 하기 위해 ErrorCode와 ErrorResponse를 생성했고,</p>
<pre><code class="language-java">public enum ErrorCode {
  VALIDATION_FAILED(HttpStatus.BAD_REQUEST, &quot;Validation failed for argument&quot;);

  private final HttpStatus httpStatus;
  private final String message;</code></pre>
<pre><code class="language-java">@Getter
public class ErrorResponse {
  private final LocalDateTime timestamp = LocalDateTime.now();
  private final int statusCode;
  private final String error;
  private final String message;

  public ErrorResponse(ErrorCode errorCode) {
      this.statusCode = errorCode.getHttpStatus().value();
      this.error = errorCode.getHttpStatus().name();
      this.message = errorCode.getMessage();
  }
}</code></pre>
</li>
<li><p>오버라이딩한 <code>handleMethodArgumentNotValid</code> 핸들러를 아래와 같이 작성했습니다.</p>
<pre><code class="language-java">  // Bean Validation 예외 발생시
  @Override
  protected ResponseEntity&lt;Object&gt; handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
      ErrorCode errorCode = ErrorCode.VALIDATION_FAILED;
      return ResponseEntity.status(errorCode.getHttpStatus())
              .body(new ErrorResponse(errorCode));
  }
</code></pre>
</li>
</ul>
<pre><code>- Postman에서 위와 똑같은 JSON 요청을 보내게 되면, 응답이 아래와 같이 출력됩니다.
![](https://velog.velcdn.com/images/u-nij/post/21a41411-91d4-43f2-8740-f82445a9ac54/image.png)
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[트러블 슈팅 221027]]></title>
            <link>https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221027</link>
            <guid>https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221027</guid>
            <pubDate>Wed, 26 Oct 2022 18:15:25 GMT</pubDate>
            <description><![CDATA[<h1 id="실행-환경">실행 환경</h1>
<p>Spring Boot 2.7.3
Java 11.0.9</p>
<h1 id="상황">상황</h1>
<ul>
<li>SecurityConfig의 <code>securityFilterChain</code>에서 <code>.antMatchers(&quot;/api/user/**&quot;).authenticated()</code>로 접근 제어 <img src="https://velog.velcdn.com/images/u-nij/post/c963274a-5bfd-4b9f-8f76-372bea74d39e/image.png" alt=""></li>
<li>JWT 토큰을 이용해 로그인을 구현했고, 특정 API에 접근하기 위해 Authorization Header에 <code>Bearer {access-token}</code> 값을 넣었다.
<img src="https://velog.velcdn.com/images/u-nij/post/ebb5aa0f-b7b7-46ef-bcec-d16be291aea7/image.png" alt=""><h1 id="발생한-에러">발생한 에러</h1>
<blockquote>
<p><img src="https://velog.velcdn.com/images/u-nij/post/1ee952e0-1fdb-4717-a9a5-0a403646a567/image.png" alt=""> org.springframework.security.authentication.InsufficientAuthenticationException: Full authentication is required to access this resource</p>
</blockquote>
</li>
</ul>
<h3 id="왜">왜?</h3>
<ul>
<li>로직에서 문제<ul>
<li>Access Token을 검증하기 위한 필터(Filter)인 <code>JwtAuthenticationFilter</code> 에서 조건을 잘못 설정했다. 유효 기간만 제외하고 정상적인 Access Token에서 <code>true</code>를 반환시켰어야 했는데, 기간이 만료된 &amp; 정상적인 토큰일 때 <code>true</code>를 반환시켰다.</li>
</ul>
</li>
</ul>
<h1 id="해결-방법">해결 방법</h1>
<p>로직 수정
(사실 로직 수정에 관련된 글이라 트러블 슈팅까지 남겨야 할까 생각이 들었지만, 저처럼 문제 찾는데 오랜 시간을 걸리는 분들이 계실까봐 작성하게 됐습니다..😅)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (7) - Controller(AuthApiController),  Postman & Redis에서 실행해보기, GitHub 공유]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Feat.-Redis-7-ControllerAuthApiController-Postman%EC%97%90%EC%84%9C-%EC%8B%A4%ED%96%89%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Feat.-Redis-7-ControllerAuthApiController-Postman%EC%97%90%EC%84%9C-%EC%8B%A4%ED%96%89%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 25 Oct 2022 08:56:18 GMT</pubDate>
            <description><![CDATA[<h2 id="authapicontrollerclass">AuthApiController.class</h2>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/auth&quot;)
@RequiredArgsConstructor
public class AuthApiController {

    private final AuthService authService;
    private final UserService userService;
    private final BCryptPasswordEncoder encoder;

    private final long COOKIE_EXPIRATION = 7776000; // 90일

    // 회원가입
    @PostMapping(&quot;/signup&quot;)
    public ResponseEntity&lt;Void&gt; signup(@RequestBody @Valid AuthDto.SignupDto signupDto) {
        String encodedPassword = encoder.encode(signupDto.getPassword());
        AuthDto.SignupDto newSignupDto = AuthDto.SignupDto.encodePassword(signupDto, encodedPassword);

        userService.registerUser(newSignupDto);
        return new ResponseEntity&lt;&gt;(HttpStatus.OK);
    }

    // 로그인 -&gt; 토큰 발급
    @PostMapping(&quot;/login&quot;)
    public ResponseEntity&lt;?&gt; login(@RequestBody @Valid AuthDto.LoginDto loginDto) {
        // User 등록 및 Refresh Token 저장
        AuthDto.TokenDto tokenDto = authService.login(loginDto);

        // RT 저장
        HttpCookie httpCookie = ResponseCookie.from(&quot;refresh-token&quot;, tokenDto.getRefreshToken())
                .maxAge(COOKIE_EXPIRATION)
                .httpOnly(true)
                .secure(true)
                .build();

        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, httpCookie.toString())
                // AT 저장
                .header(HttpHeaders.AUTHORIZATION, &quot;Bearer &quot; + tokenDto.getAccessToken())
                .build();
    }

    @PostMapping(&quot;/validate&quot;)
    public ResponseEntity&lt;?&gt; validate(@RequestHeader(&quot;Authorization&quot;) String requestAccessToken) {
        if (!authService.validate(requestAccessToken)) {
            return ResponseEntity.status(HttpStatus.OK).build(); // 재발급 필요X
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); // 재발급 필요
        }
    }
    // 토큰 재발급
    @PostMapping(&quot;/reissue&quot;)
    public ResponseEntity&lt;?&gt; reissue(@CookieValue(name = &quot;refresh-token&quot;) String requestRefreshToken,
                                     @RequestHeader(&quot;Authorization&quot;) String requestAccessToken) {
        AuthDto.TokenDto reissuedTokenDto = authService.reissue(requestAccessToken, requestRefreshToken);

        if (reissuedTokenDto != null) { // 토큰 재발급 성공
            // RT 저장
            ResponseCookie responseCookie = ResponseCookie.from(&quot;refresh-token&quot;, reissuedTokenDto.getRefreshToken())
                    .maxAge(COOKIE_EXPIRATION)
                    .httpOnly(true)
                    .secure(true)
                    .build();
            return ResponseEntity
                    .status(HttpStatus.OK)
                    .header(HttpHeaders.SET_COOKIE, responseCookie.toString())
                    // AT 저장
                    .header(HttpHeaders.AUTHORIZATION, &quot;Bearer &quot; + reissuedTokenDto.getAccessToken())
                    .build();

        } else { // Refresh Token 탈취 가능성
            // Cookie 삭제 후 재로그인 유도
            ResponseCookie responseCookie = ResponseCookie.from(&quot;refresh-token&quot;, &quot;&quot;)
                    .maxAge(0)
                    .path(&quot;/&quot;)
                    .build();
            return ResponseEntity
                    .status(HttpStatus.UNAUTHORIZED)
                    .header(HttpHeaders.SET_COOKIE, responseCookie.toString())
                    .build();
        }
    }

    // 로그아웃
    @PostMapping(&quot;/logout&quot;)
    public ResponseEntity&lt;?&gt; logout(@RequestHeader(&quot;Authorization&quot;) String requestAccessToken) {
        authService.logout(requestAccessToken);
        ResponseCookie responseCookie = ResponseCookie.from(&quot;refresh-token&quot;, &quot;&quot;)
                .maxAge(0)
                .path(&quot;/&quot;)
                .build();

        return ResponseEntity
                .status(HttpStatus.OK)
                .header(HttpHeaders.SET_COOKIE, responseCookie.toString())
                .build();
    }
}</code></pre>
<ul>
<li><code>signUp</code>: 회원을 등록한다. 중복 이메일에 대한 검사가 추가적으로 필요하지만, 우선 단순하게 진행하겠다. 원래는 UserService에서 암호화하려고 했지만, 순환 참조 문제로 <code>BCryptPasswordEncoder</code>를 Controller단으로 가져왔다.</li>
<li><code>login</code>: <code>authService.login</code> 메서드를 실행하고 토큰을 발급받는다. RT를 HTTP-ONLY Secure Cookie로, AT를 Authorization Header에 담아 보낸다.</li>
<li><code>validate</code>: AT를 재발급받을 필요가 없다면 상태 코드 <code>OK(200)</code>을 반환하고, 재발급받아야 한다면 <code>401</code>을 반환한다.</li>
<li><code>reissue</code>: <code>validate</code> 요청으로부터 <code>UNAUTHORIZED(401)</code>을 반환받았다면, 프론트에서 Cookie와 Header에 각각 RT와 AT를 요청으로 받아서 <code>authService.reissue</code>를 통해 토큰 재발급을 진행한다. 토큰 재발급이 성공한다면 <code>login</code>과 마찬가지로 응답 결과를 보내고, 토큰 재발급이 실패했을때(<code>null</code>을 반환받았을 때) Cookie에 담긴 RT를 삭제하고 재로그인을 유도한다.</li>
<li><code>logout</code>: <code>authService.logout</code>을 진행한 후, Cookie에 담긴 RT를 삭제한다.</li>
</ul>
<h1 id="postman--redis">Postman &amp; Redis</h1>
<h3 id="회원가입">회원가입</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/ea663ac7-a23a-412a-b222-280777726c99/image.png" alt=""></p>
<h3 id="로그인">로그인</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/f3a9c8ae-d1a8-4699-a49f-44ac75398f93/image.png" alt=""></p>
<ul>
<li>Redis 
<img src="https://velog.velcdn.com/images/u-nij/post/8d5c23c4-e21f-4a7e-9ec7-9e55c4bb2684/image.png" alt=""></li>
</ul>
<h3 id="재발급">재발급</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/0be2b6dd-e984-4768-8cdd-dda4f6c7861b/image.png" alt=""></p>
<h3 id="로그아웃">로그아웃</h3>
<p><img src="https://velog.velcdn.com/images/u-nij/post/ed60fc3c-8fb6-4cd8-b569-3aa8419f6a5d/image.png" alt=""></p>
<ul>
<li>Redis
<img src="https://velog.velcdn.com/images/u-nij/post/078bb112-28d2-4caa-867c-cbbd531a1819/image.png" alt=""></li>
</ul>
<p>이상 Spring Boot에서 Redis와 Spring Security를 이용해 JWT를 구현해보고, Postman에서 테스트해보는 시간을 가졌다. 글을 쓰기까지 JWT에 대해 공부하고, 직접 구현하고, 디버깅해보면서 Spring Security의 Filter Chain과 Authentication Architecture가 돌아가는 방식에 대해 더 자세하게 알 수 있었다. 더 좋은 코드와 서비스를 위해 리팩토링할 부분이 남아 있지만, 성능을 위해 어떤 것을 고려하고 선택해야 하는지 조금이나마 공부해보았다. 이어서, OAuth2.0 OPEN API를 사용해보겠다.</p>
<h1 id="github">GitHub</h1>
<p><a href="https://github.com/u-nij/Authentication-Using-JWT">https://github.com/u-nij/Authentication-Using-JWT</a>
지금까지 제가 작성한 코드를 깃허브 저장소에 남겨두었습니다.(글로 작성된 Spring Boot 버전과 약간 다릅니다.) 도움이 되셨다면 Star를 눌러주세요!😁✨</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (6) - Dto(AuthDto), Service(UserService, AuthService)]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Feat.-Redis-6-DtoAuthDto-ServiceUserService-AuthService</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Feat.-Redis-6-DtoAuthDto-ServiceUserService-AuthService</guid>
            <pubDate>Tue, 25 Oct 2022 08:10:49 GMT</pubDate>
            <description><![CDATA[<h2 id="authdtoclass">AuthDto.class</h2>
<pre><code class="language-java">public class AuthDto {

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class LoginDto {
        private String email;
        private String password;

        @Builder
        public LoginDto(String email, String password) {
            this.email = email;
            this.password = password;
        }
    }

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class SignupDto {
        private String email;
        private String password;

        @Builder
        public SignupDto(String email, String password) {
            this.email = email;
            this.password = password;
        }

        public static SignupDto encodePassword(SignupDto signupDto, String encodedPassword) {
            SignupDto newSignupDto = new SignupDto();
            newSignupDto.email = signupDto.getEmail();
            newSignupDto.password = encodedPassword;
            return newSignupDto;
        }
    }

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class TokenDto {
        private String accessToken;
        private String refreshToken;

        public TokenDto(String accessToken, String refreshToken) {
            this.accessToken = accessToken;
            this.refreshToken = refreshToken;
        }
    }
}</code></pre>
<p>인증 과정에서 필요한 DTO를 작성했다. <code>LoginDto</code>와 <code>SignUpDto</code>는 비즈니스 니즈에 따라 필드들이 추가될 수 있다.
<code>encodePassword</code> 메서드는 Controller에서 <code>BCryptPasswordEncoder</code>를 통해 password를 암호화한 후에 DTO를 재생성할 예정이기 때문에 작성했다. </p>
<h2 id="userserviceclass">UserService.class</h2>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;

    @Transactional
    public void registerUser(AuthDto.SignupDto signupDto) {
        User user = User.registerUser(signupDto);
        userRepository.save(user);
    }
}</code></pre>
<p>회원을 DB에 등록하기 위한 메서드이다.</p>
<h2 id="authserviceclass">AuthService.class</h2>
<pre><code class="language-java">@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {

    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final RedisService redisService;

    private final String SERVER = &quot;Server&quot;;

    // 로그인: 인증 정보 저장 및 비어 토큰 발급
    @Transactional
    public AuthDto.TokenDto login(AuthDto.LoginDto loginDto) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject()
                .authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        return generateToken(SERVER, authentication.getName(), getAuthorities(authentication));
    }

    // AT가 만료일자만 초과한 유효한 토큰인지 검사
    public boolean validate(String requestAccessTokenInHeader) {
        String requestAccessToken = resolveToken(requestAccessTokenInHeader);
        return jwtTokenProvider.validateAccessTokenOnlyExpired(requestAccessToken); // true = 재발급
    }

    // 토큰 재발급: validate 메서드가 true 반환할 때만 사용 -&gt; AT, RT 재발급
    @Transactional
    public AuthDto.TokenDto reissue(String requestAccessTokenInHeader, String requestRefreshToken) {
        String requestAccessToken = resolveToken(requestAccessTokenInHeader);

        Authentication authentication = jwtTokenProvider.getAuthentication(requestAccessToken);
        String principal = getPrincipal(requestAccessToken);

        String refreshTokenInRedis = redisService.getValues(&quot;RT(&quot; + SERVER + &quot;):&quot; + principal);
        if (refreshTokenInRedis == null) { // Redis에 저장되어 있는 RT가 없을 경우
            return null; // -&gt; 재로그인 요청
        }

        // 요청된 RT의 유효성 검사 &amp; Redis에 저장되어 있는 RT와 같은지 비교
        if(!jwtTokenProvider.validateRefreshToken(requestRefreshToken) || !refreshTokenInRedis.equals(requestRefreshToken)) {
            redisService.deleteValues(&quot;RT(&quot; + SERVER + &quot;):&quot; + principal); // 탈취 가능성 -&gt; 삭제
            return null; // -&gt; 재로그인 요청
        }

        SecurityContextHolder.getContext().setAuthentication(authentication);
        String authorities = getAuthorities(authentication);

        // 토큰 재발급 및 Redis 업데이트
        redisService.deleteValues(&quot;RT(&quot; + SERVER + &quot;):&quot; + principal); // 기존 RT 삭제
        AuthDto.TokenDto tokenDto = jwtTokenProvider.createToken(principal, authorities);
        saveRefreshToken(SERVER, principal, tokenDto.getRefreshToken());
        return tokenDto;
    }

    // 토큰 발급
    @Transactional
    public AuthDto.TokenDto generateToken(String provider, String email, String authorities) {
        // RT가 이미 있을 경우
        if(redisService.getValues(&quot;RT(&quot; + provider + &quot;):&quot; + email) != null) {
            redisService.deleteValues(&quot;RT(&quot; + provider + &quot;):&quot; + email); // 삭제
        }

        // AT, RT 생성 및 Redis에 RT 저장
        AuthDto.TokenDto tokenDto = jwtTokenProvider.createToken(email, authorities);
        saveRefreshToken(provider, email, tokenDto.getRefreshToken());
        return tokenDto;
    }

    // RT를 Redis에 저장
    @Transactional
    public void saveRefreshToken(String provider, String principal, String refreshToken) {
        redisService.setValuesWithTimeout(&quot;RT(&quot; + provider + &quot;):&quot; + principal, // key
                refreshToken, // value
                jwtTokenProvider.getTokenExpirationTime(refreshToken)); // timeout(milliseconds)
    }

    // 권한 이름 가져오기
    public String getAuthorities(Authentication authentication) {
        return authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(&quot;,&quot;));
    }

    // AT로부터 principal 추출
    public String getPrincipal(String requestAccessToken) {
        return jwtTokenProvider.getAuthentication(requestAccessToken).getName();
    }

    // &quot;Bearer {AT}&quot;에서 {AT} 추출
    public String resolveToken(String requestAccessTokenInHeader) {
        if (requestAccessTokenInHeader != null &amp;&amp; requestAccessTokenInHeader.startsWith(&quot;Bearer &quot;)) {
            return requestAccessTokenInHeader.substring(7);
        }
        return null;
    }

    // 로그아웃
    @Transactional
    public void logout(String requestAccessTokenInHeader) {
        String requestAccessToken = resolveToken(requestAccessTokenInHeader);
        String principal = getPrincipal(requestAccessToken);

        // Redis에 저장되어 있는 RT 삭제
        String refreshTokenInRedis = redisService.getValues(&quot;RT(&quot; + SERVER + &quot;):&quot; + principal);
        if (refreshTokenInRedis != null) {
            redisService.deleteValues(&quot;RT(&quot; + SERVER + &quot;):&quot; + principal);
        }

        // Redis에 로그아웃 처리한 AT 저장
        long expiration = jwtTokenProvider.getTokenExpirationTime(requestAccessToken) - new Date().getTime();
        redisService.setValuesWithTimeout(requestAccessToken,
                &quot;logout&quot;,
                expiration);
    }
}</code></pre>
<p>Refresth Token(이하 RT)과 Access Token(이하 AT)를 다루기 위해 생성한 Service단 클래스이다. 코드를 이렇게 작성한 이유와 주석 외의 추가 설명이 필요하다고 생각되는 부분을 적어보겠다.</p>
<p>큰 틀은 이렇게 된다.</p>
<ul>
<li>요청 -&gt; AT 검사 -&gt; AT 유효 -&gt; 요청 실행</li>
<li>요청 -&gt; AT 검사 -&gt; AT 기간만 만료 -&gt; AT, RT로 재발급 요청 -&gt; RT 유효 -&gt; 재발급</li>
<li>요청 -&gt; AT 검사 -&gt; AT 기간만 만료 -&gt; AT, RT로 재발급 요청 -&gt; RT 유효X -&gt; 재로그인</li>
</ul>
<h3 id="변수">변수</h3>
<ul>
<li><code>SERVER</code>: RT를 생성한 후 Redis에 <strong><code>{key:RT({발급자}):{email}, value:{RT}}</code></strong> 형식으로 저장할 예정이다. OAuth2.0 OPEN API를 적용할 때 <code>발급자</code>에 Naver, Kakao 등 발급된 서버를 표시해두기 위해서이다. 자세한 내용은 나중에 OAuth2.0 OPEN API에 관련된 포스팅에서 다루겠다. 일단 우리 서버에서 발급된 RT는 <code>{key:RT(Server):{email}, value:{RT}}</code> 형식으로 Redis에 저장된다는 것만 기억하면 된다.</li>
<li><code>requestAccessTokenInHeader</code>: &quot;Bearer {AT}&quot;의 형식을 갖고 있다.<blockquote>
<p><code>resolveToken(String requestAccessTokenInHeader)</code> 메서드를 통해 &quot;Bearer {AT}&quot;로부터 AT를 추출할 예정이다. Controller단에서 추출해 Service단으로 가져와도 크게 상관 없을 것이다. 하지만, 나중에 OAuth2.0 OPEN API에서 사용할 때 서버(Naver, Kakao 등)별로 Controller를 분리하게 될텐데 그 때의 반복되는 코드 작성을 피하고, Controller단과 Service단의 기능을 분리하기 위해서 이처럼 작성하게 되었다. </p>
</blockquote>
</li>
</ul>
<h3 id="메서드">메서드</h3>
<ul>
<li><code>login</code>: Filter 과정을 거치고 생성된 <code>UsernamePasswordAuthenticationToken</code>으로부터 Authentication 객체를 생성해 <code>SecurityContextHolder</code>에 저장한다. <code>generateToken</code> 메서드를 통해 RT와 AT를 발급해 반환한다.<blockquote>
<p><strong>DB에 저장된 인코딩된 값과 입력된 비밀번호를 어떻게 비교하는지</strong></p>
<p><img src="https://velog.velcdn.com/images/u-nij/post/f5d8b95e-197b-4e25-a5b4-dd717ff2537c/image.png" alt=""></p>
</blockquote>
login 메서드에서 사용자로부터 입력받은 email과 password 값을 이용해 UsernamePasswordAuthenticationToken을 생성하게 되고, AuthenticationManagerBuilder를 통해 사용자 인증을 진행하게 됩니다. AuthenticationManagerBuilder는 자신이 가지고 있는 인코더로 사용자로부터 입력받은 password 값, 즉 Credential을 암호화합니다.<blockquote>
<p><img src="https://velog.velcdn.com/images/u-nij/post/992960da-9658-41f5-810d-da21123cb3e5/image.png" alt=""> 이 때, AuthenticationManagerBuilder의 defaultPasswordEncorder가 사용자가 회원가입할 때 사용했던 BCryptPasswordEncoder이기 때문에 입력된 비밀번호와 DB에 저장되어 있는 값과 비교가 가능하게 됩니다.</p>
</blockquote>
</li>
</ul>
<ul>
<li><p><code>validate</code>: 만료일자만 만료된 유효한 토큰인지 검사하고, 해당할 경우에 true를 리턴한다.</p>
</li>
<li><p><code>reissue</code>: 요청으로 받은 AT와 RT를 검사한 후, 토큰을 재발급하는 메서드이다. AT로부터 Authentication 객체를 가져온다. <code>SecurityContextHolder</code>에 객체를 저장하기 전, 다음의 두 과정을 통과해야 한다.</p>
<ul>
<li>Redis에 저장되어 있는 기존 Redis가 있어야 한다. 만약 없을 경우, 로그인이 만료되었다고 보고, <code>null</code>을 반환함으로써 재로그인을 요청한다.</li>
<li>요청으로 받은 RT가 유효한지 검사하고, Redis에 저장된 기존 RT와 같은지 비교한다. 만약 유효하지 않거나 기존 RT와 다르다고 판단되면 RT가 탈취되었다고 결론 내리고, <code>null</code>을 반환함으로써 재로그인을 요청한다.</li>
</ul>
<p>두 과정을 거치고 난 후, Authentication 객체를 저장한다. 그리고, AT와 RT를 재발급하고 발급된 RT를 Redis에 저장한다.</p>
</li>
<li><p><code>generateToken</code>: Redis에 기존의 RT가 이미 있을 경우, 삭제한다. AT와 RT를 생성하고, Redis에 새로 발급한 RT를 저장한다.</p>
</li>
<li><p><code>logout</code>: Redis에 저장되어 있는 RT를 삭제하고, Redis에 로그아웃 처리한 AT 저장해 해당 AT를 이용한 요청을 방지한다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (5) - 토큰 생성, 검증, 정보 추출(JwtTokenProvider)]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Feat.-Redis-5-JwtTokenProvider</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Feat.-Redis-5-JwtTokenProvider</guid>
            <pubDate>Tue, 25 Oct 2022 06:53:32 GMT</pubDate>
            <description><![CDATA[<h2 id="jwttokenproviderclass">JwtTokenProvider.class</h2>
<pre><code class="language-java">@Slf4j
@Component
@Transactional(readOnly = true)
public class JwtTokenProvider implements InitializingBean {

    private final UserDetailsServiceImpl userDetailsService;
    private final RedisService redisService;

    private static final String AUTHORITIES_KEY = &quot;role&quot;;
    private static final String EMAIL_KEY = &quot;email&quot;;
    private static final String url = &quot;https://localhost:8080&quot;;

    private final String secretKey;
    private static Key signingKey;

    private final Long accessTokenValidityInMilliseconds;
    private final Long refreshTokenValidityInMilliseconds;

    public JwtTokenProvider(
            UserDetailsServiceImpl userDetailsService,
            RedisService redisService,
            @Value(&quot;${jwt.secret}&quot;) String secretKey,
            @Value(&quot;${jwt.access-token-validity-in-seconds}&quot;) Long accessTokenValidityInMilliseconds,
            @Value(&quot;${jwt.refresh-token-validity-in-seconds}&quot;) Long refreshTokenValidityInMilliseconds) {
        this.userDetailsService = userDetailsService;
        this.redisService = redisService;
        this.secretKey = secretKey;
        // seconds -&gt; milliseconds
        this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds * 1000;
        this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds * 1000;
    }

    // 시크릿 키 설정
    @Override
    public void afterPropertiesSet() throws Exception {
        byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey);
        signingKey = Keys.hmacShaKeyFor(secretKeyBytes);
    }

    @Transactional
    public AuthDto.TokenDto createToken(String email, String authorities){
        Long now = System.currentTimeMillis();

        String accessToken = Jwts.builder()
                .setHeaderParam(&quot;typ&quot;, &quot;JWT&quot;)
                .setHeaderParam(&quot;alg&quot;, &quot;HS512&quot;)
                .setExpiration(new Date(now + accessTokenValidityInMilliseconds))
                .setSubject(&quot;access-token&quot;)
                .claim(url, true)
                .claim(EMAIL_KEY, email)
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .compact();

        String refreshToken = Jwts.builder()
                .setHeaderParam(&quot;typ&quot;, &quot;JWT&quot;)
                .setHeaderParam(&quot;alg&quot;, &quot;HS512&quot;)
                .setExpiration(new Date(now + refreshTokenValidityInMilliseconds))
                .setSubject(&quot;refresh-token&quot;)
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .compact();

        return new AuthDto.TokenDto(accessToken, refreshToken);
    }


    // == 토큰으로부터 정보 추출 == //

    public Claims getClaims(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(signingKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) { // Access Token
            return e.getClaims();
        }
    }

    public Authentication getAuthentication(String token) {
        String email = getClaims(token).get(EMAIL_KEY).toString();
        UserDetailsImpl userDetailsImpl = userDetailsService.loadUserByUsername(email);
        return new UsernamePasswordAuthenticationToken(userDetailsImpl, &quot;&quot;, userDetailsImpl.getAuthorities());
    }

    public long getTokenExpirationTime(String token) {
        return getClaims(token).getExpiration().getTime();
    }


    // == 토큰 검증 == //

    public boolean validateRefreshToken(String refreshToken){
        try {
            if (redisService.getValues(refreshToken).equals(&quot;delete&quot;)) { // 회원 탈퇴했을 경우
                return false;
            }
            Jwts.parserBuilder()
                    .setSigningKey(signingKey)
                    .build()
                    .parseClaimsJws(refreshToken);
            return true;
        } catch (SignatureException e) {
            log.error(&quot;Invalid JWT signature.&quot;);
        } catch (MalformedJwtException e) {
            log.error(&quot;Invalid JWT token.&quot;);
        } catch (ExpiredJwtException e) {
            log.error(&quot;Expired JWT token.&quot;);
        } catch (UnsupportedJwtException e) {
            log.error(&quot;Unsupported JWT token.&quot;);
        } catch (IllegalArgumentException e) {
            log.error(&quot;JWT claims string is empty.&quot;);
        } catch (NullPointerException e){
            log.error(&quot;JWT Token is empty.&quot;);
        }
        return false;
    }

    // Filter에서 사용
    public boolean validateAccessToken(String accessToken) {
        try {
            if (redisService.getValues(accessToken) != null // NPE 방지
                    &amp;&amp; redisService.getValues(accessToken).equals(&quot;logout&quot;)) { // 로그아웃 했을 경우
                return false;
            }
            Jwts.parserBuilder()
                    .setSigningKey(signingKey)
                    .build()
                    .parseClaimsJws(accessToken);
            return true;
        } catch(ExpiredJwtException e) {
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // 재발급 검증 API에서 사용
    public boolean validateAccessTokenOnlyExpired(String accessToken) {
        try {
            return getClaims(accessToken)
                    .getExpiration()
                    .before(new Date());
        } catch(ExpiredJwtException e) {
            return true;
        } catch (Exception e) {
            return false;
        }
    }

}</code></pre>
<h3 id="토큰-생성">토큰 생성</h3>
<ul>
<li>Secret Key 값을 사용하기 전 미리 초기화하기 위해 <code>InitializingBean</code> 인터페이스를 상속받고 <code>afterPropertiesSet</code>메서드를 오버라이딩해 사용하겠다. </li>
<li>생성자에서 <code>@Value</code> 어노테이션을 이용해 application.yml에서 미리 설정해둔 값을 가져와 사용한다. application.yml에 적어둔 토큰들의 유효 기간 값의 단위가 seconds이기 때문에, 1000을 곱해 milliseconds로 변경해준다.</li>
<li><code>createToken(String email, String authorities)</code>: 토큰 발급 메서드. <code>User.email</code>(Principal)값과 <code>User.role</code> 값을 매개변수로 받아 사용한다. 이전에 포스팅했던 claims의 종류들을 골고루 사용해보았다.<blockquote>
<p>Refresh Token에는 claims를 최소화했다. Access Token이든 Refresh Token이든 탈취되어 악용되었을 때 문제가 된다. 두 토큰이 같은 정보량을 가질 때, 비교적 짧은 시간 안에 유효기간이 만료되는 Access Token보다는 긴 유효기간을 가지는 Refresh Token이 탈취되었을 때 더 치명적이라는 생각이 들었다. 그리고, 사용자를 <strong>&quot;인증&quot;</strong>하는 용도로 사용되는 Access Token과 달리, Refresh Token은 Access Token의 &quot;재발급&quot;만을 위해 사용되기 때문에 claims를 최소화하는게 맞다고 생각했다.</p>
</blockquote>
<h3 id="토큰-검증">토큰 검증</h3>
</li>
<li><code>getClaims(String token)</code>: 토큰으로부터 Claims를 추출해 반환한다.</li>
<li><code>getAuthentication(String token)</code>: 토큰으로부터 인증 정보 객체인 <code>UsernamePasswordAuthenticationToken</code>을 반환한다.</li>
<li><code>getTokenExpirationTime(String token)</code>: 토큰으로부터 유효기간을 반환한다. <h3 id="토큰으로부터-정보-추출">토큰으로부터 정보 추출</h3>
</li>
<li><code>validateRefreshToken(String refreshToken)</code>: 토큰을 검증한다. 각 예외별로 log를 남기고 <code>false</code>를 반환한다.</li>
<li><code>validateAccessToken(String accessToken)</code>: Filter에서 AT 검증을 위해 쓰인다. 기간이 만료됐을 경우에도 <code>true</code>를 반환한다.</li>
<li><code>validateAccessTokenOnlyExpired(String accessToken)</code>: 유효기간만 만료된 유효한 토큰일 경우 <code>true</code>를 반환한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (4) - Redis 설정(RedisRepositoryConfig, RedisService)]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-4-Redis-%EC%84%A4%EC%A0%95RedisRepositoryConfig-RedisService</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-4-Redis-%EC%84%A4%EC%A0%95RedisRepositoryConfig-RedisService</guid>
            <pubDate>Tue, 25 Oct 2022 06:09:00 GMT</pubDate>
            <description><![CDATA[<h3 id="redisrepositoryconfigclass">RedisRepositoryConfig.class</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {

    private final RedisProperties redisProperties;

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

    // redis-cli 사용을 위한 설정
    @Bean
    public RedisTemplate&lt;String, Object&gt; redisTemplate() {
        RedisTemplate&lt;String, Object&gt; redisTemplate = new RedisTemplate&lt;&gt;();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}</code></pre>
<p>Redis의 연결을 정의하는 클래스이다. RedisConnectionFactory를 통해 내장 혹은 외부의 Redis와 연결한다. RedisTemplate를 통해 RedisConnection에서 넘겨준 byte 값을 객체 직렬화한다.</p>
<p><img src="https://velog.velcdn.com/images/u-nij/post/782e0649-5b44-49c4-93b1-b4387f4b7b56/image.png" alt=""> RedisTemplate을 열어보면 Hash, Set 등 다양한 방식을 사용해 Redis에 데이터를 저장하는 방법을 알 수 있다. </p>
<h3 id="redisserviceclass">RedisService.class</h3>
<pre><code>@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RedisService {

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

    @Transactional
    public void setValues(String key, String value){
        redisTemplate.opsForValue().set(key, value);
    }

    // 만료시간 설정 -&gt; 자동 삭제
    @Transactional
    public void setValuesWithTimeout(String key, String value, long timeout){
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MILLISECONDS);
    }

    public String getValues(String key){
        return redisTemplate.opsForValue().get(key);
    }

    @Transactional
    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
}</code></pre><p><img src="https://velog.velcdn.com/images/u-nij/post/d62eec1f-abd8-4084-b6af-d858cf628d70/image.png" alt=""></p>
<p>Redis를 편히 사용하기 위해 메서드화했다. </p>
<ul>
<li><code>setValues(String key, String value)</code>: <code>{key, value]</code> 값을 저장한다.</li>
<li><code>setValuesWithTimeout(String key, String value, long timeout)</code>: <code>{key, value]</code> 값을 유효시간(timeout)과 함께 저장할 수 있다. 단위는 토큰의 유효기간 단위와 동일하게 milliseconds로 지정했다.</li>
<li><code>getValues(String key)</code>: key 값을 사용해 value 값을 가져온다.</li>
<li><code>deleteValues(String key)</code>: key 값을 사용해 데이터를 삭제한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (3) - Filter 설정(JwtAuthenticationFilter) & 예외 처리(JwtAccessDeniedHandler, JwtAuthenticationEntryPoint)]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-3-JwtAuthenticationFilter-JwtAccessDeniedHandler-JwtAuthenticationEntryPoint</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-3-JwtAuthenticationFilter-JwtAccessDeniedHandler-JwtAuthenticationEntryPoint</guid>
            <pubDate>Tue, 25 Oct 2022 06:03:57 GMT</pubDate>
            <description><![CDATA[<h3 id="jwtauthenticationfilterclass">JwtAuthenticationFilter.class</h3>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

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

        // Access Token 추출
        String accessToken = resolveToken(request);

        try { // 정상 토큰인지 검사
            if (accessToken != null &amp;&amp; jwtTokenProvider.validateAccessToken(accessToken)) {
                Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.debug(&quot;Save authentication in SecurityContextHolder.&quot;);
            }
        } catch (IncorrectClaimException e) { // 잘못된 토큰일 경우
            SecurityContextHolder.clearContext();
            log.debug(&quot;Invalid JWT token.&quot;);
            response.sendError(403);
        } catch (UsernameNotFoundException e) { // 회원을 찾을 수 없을 경우
            SecurityContextHolder.clearContext();
            log.debug(&quot;Can&#39;t find user.&quot;);
            response.sendError(403);
        }

        filterChain.doFilter(request, response);
    }

    // HTTP Request 헤더로부터 토큰 추출
    public String resolveToken(HttpServletRequest httpServletRequest) {
        String bearerToken = httpServletRequest.getHeader(&quot;Authorization&quot;);
        if (bearerToken != null &amp;&amp; bearerToken.startsWith(&quot;Bearer &quot;)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}</code></pre>
<p><code>SecurityConfig.class</code>에서 <code>UsernamePasswordAuthenticationFilter</code> 이전에 통과할 Filter이다. 인증(Authentication)이 필요한 요청(Request)이 오면 요청의 헤더(Header)에서 Access Token을 추출하고 정상 토큰인지 검사한다. 
<code>jwtTokenProvider.validateToken(accessToken)</code> 메서드를 통해 <strong>유효 기간을 제외하고 정상적인</strong> Access Token인지 검사한다. 유효 기간만 제외하고 정상적인 토큰일 경우, 유저 모르게 Access Token을 재발급하고 로그인을 연장시킴과 동시에 다시 요청을 처리하도록 하기 위함이다.(<strong>Silent refresh</strong>) 이 작업을 진행하기 위해서는 프론트엔드에서 추가 작업이 필요하다. 재발급 API에 접근한 후, 응답에 따라 원래 처리할 요청 or 로그인으로 리다이렉트되게끔 해야 한다.
logout 처리된 Access Token은 더이상 사용하지 못하게 하기 위해 Redis에 저장할 예정이기 때문에 Redis에 logout 여부를 확인하는 로직을 추가했다.</p>
<h3 id="jwtaccessdeniedhandlerclass">JwtAccessDeniedHandler.class</h3>
<pre><code class="language-java">@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {

        response.setCharacterEncoding(&quot;utf-8&quot;);
        response.sendError(403, &quot;권한이 없습니다.&quot;);
    }
}</code></pre>
<p>SecurityConfig에 예외 처리를 위해 설정해놓은 클래스이다. 인증(Authentication)이 실패했을 때 실행된다. 예를 들자면, ROLE_ADMIN 권한이 있는 사용자가 필요한 요청에 ROLE_USER 권한을 가진 사용자가 접근했을 때 실행된다.</p>
<h3 id="jwtauthenticationentrypointclass">JwtAuthenticationEntryPoint.class</h3>
<pre><code class="language-java">@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

        response.setCharacterEncoding(&quot;utf-8&quot;);
        response.sendError(401, &quot;잘못된 접근입니다.&quot;);
    }
}</code></pre>
<p>인가(Authorization)가 실패했을 때 실행된다. 예를 들자면, 로그인 된 사용자가 필요한 요청에 로그인하지 않은 사용자가 접근했을 때 실행된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (2) - User, Role, UserRepository, UserDetailsImpl, UserdetailsServiceImpl, ]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2-User-Role-UserDetailsImpl-UserdetailsServiceImpl</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2-User-Role-UserDetailsImpl-UserdetailsServiceImpl</guid>
            <pubDate>Mon, 24 Oct 2022 18:38:30 GMT</pubDate>
            <description><![CDATA[<h3 id="userclass">User.class</h3>
<pre><code class="language-java">@Entity(name = &quot;users&quot;)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

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

    private String email; // Principal

    private String password; // Credential

    @Enumerated(EnumType.STRING)
    private Role role; // 사용자 권한

    // == 생성 메서드 == //
    public static User registerUser(AuthDto.SignupDto signupDto) {
        User user = new User();

        user.email = signupDto.getEmail();
        user.password = signupDto.getPassword();
        user.role = Role.USER;

        return user;
    }
}</code></pre>
<p>사용자 정보를 저장하거나 비즈니스 로직을 처리하기 위해서는 추가적인 필드, 메서드들을 구현해야 한다. 인증의 용도로 사용하는 UserDetails와 분리할 필요성을 느껴 별도로 클래스를 생성했다. <code>AuthDto</code>는 나중에 작성하겠다.</p>
<h3 id="roleclass">Role.class</h3>
<pre><code class="language-java">@Getter
@RequiredArgsConstructor
public enum Role {
    ADMIN(&quot;ROLE_ADMIN&quot;, &quot;관리자&quot;),
    USER(&quot;ROLE_USER&quot;, &quot;일반 사용자&quot;);

    private final String key;
    private final String title;
}</code></pre>
<p>DB에 <code>ROLE_</code>이라는 접두어를 붙여 저장하고 싶지 않을 때 이처럼 사용할 수 있다. <code>hasRole(&quot;ADMIN&quot;)</code>이라고 작성시 UserDetailsService에서 Authorities를 가져와 확인할 때 자동으로 <code>ROLE_</code>이라는 접두어를 붙여 확인한다. 
SecurityConfig에서 리소스의 권한을 설정할 때 <code>hasRole(&quot;ADMIN&quot;)</code> 대신에 <code>hasAuthority(&quot;ROLE_ADMIN&quot;)</code>으로 사용해도 무방하다.</p>
<h3 id="userrepositoryinterface">UserRepository.interface</h3>
<pre><code class="language-java">public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
    Optional&lt;User&gt; findByEmail(String email);
}</code></pre>
<p>DB로부터 email 값을 이용해 User 객체를 불러오기 위한 메소드이다.</p>
<h3 id="userdetailsimplclass">UserDetailsImpl.class</h3>
<pre><code class="language-java">public class UserDetailsImpl implements UserDetails {

    private final User user;

    public UserDetailsImpl(User user) {
        this.user = user;
    }

    @Override
    public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
        Collection&lt;GrantedAuthority&gt; authorities = new ArrayList&lt;&gt;();
        authorities.add(() -&gt; user.getRole().getKey()); // key: ROLE_권한
        return authorities;
    }

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

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


    // == 세부 설정 == //

    @Override
    public boolean isAccountNonExpired() { // 계정의 만료 여부
        return true;
    }

    @Override
    public boolean isAccountNonLocked() { // 계정의 잠김 여부
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() { // 비밀번호 만료 여부
        return true;
    }

    @Override
    public boolean isEnabled() { // 계정의 활성화 여부
        return true;
    }
}</code></pre>
<p>유저의 정보를 가져오는 UserDetails 인터페이스를 상속하는 클래스이다. Authentication을 담고 있다. <code>user.getRole().getKey()</code>를 통해 사용자의 권한(Authorities)를 부여해 가져올 수 있다. Principal과 Credential로 사용할 필드를 각각 <code>User.email</code>, <code>User.password</code>로 정해두었다. 세부 설정은 현재로써 필요하지 않기 때문에 true로 반환하게만 해두었다.</p>
<h3 id="userdetailsserviceimplclass">UserDetailsServiceImpl.class</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetailsImpl loadUserByUsername(String email) throws UsernameNotFoundException {
        User findUser = userRepository.findByEmail(email)
                .orElseThrow(() -&gt; new UsernameNotFoundException(&quot;Can&#39;t find user with this email. -&gt; &quot; + email));

        if(findUser != null){
            UserDetailsImpl userDetails = new UserDetailsImpl(findUser);
            return  userDetails;
        }

        return null;
    }
}</code></pre>
<p>DB에서 사용자의 정보를 직접 가져오는 인터페이스이다. <code>loadUserByUsername(String username)</code>을 오버라이드해 구현했다. 이 메소드를 사용해 UserDetails를 구현한 UserDetailsImpl 객체를 리턴해 인증 과정에 사용하게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT 구현하기(Feat. Redis) (1) - 개발환경 & Spring Security(build.gradle, application.yml, SecurityConfig.class)]]></title>
            <link>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1-Spring-Security</link>
            <guid>https://velog.io/@u-nij/JWT-JWT-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1-Spring-Security</guid>
            <pubDate>Mon, 24 Oct 2022 18:13:20 GMT</pubDate>
            <description><![CDATA[<h1 id="개발-환경">개발 환경</h1>
<p>Spring Boot와 React를 연동하기 위해 REST API 방식으로 구현할 예정이다. 사용자의 정보를 저장하기 위해 기본적으로 MySQL를 사용하고, Refresh Token을 저장하기 위해 Redis를 사용할 예정이다. Refresh Token을 가져오기 위해 토큰 검사가 필요할 때마다 DB에 쿼리를 날리는 것은 좋지 않다고 생각해 Redis를 사용하게 되었다.</p>
<blockquote>
<p>Spring Boot 2.6.5
React.js
java 11.0.9
Redis
MySQL
IDE Intellij</p>
</blockquote>
<h2 id="buildgradle">build.gradle</h2>
<pre><code class="language-java">dependencies {
    // spring boot
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    implementation &#39;org.projectlombok:lombok:1.18.20&#39;
    // DB
    runtimeOnly &#39;com.h2database:h2&#39;
    // spring security
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
    // Redis
    implementation &#39;org.springframework.boot:spring-boot-starter-data-redis-reactive&#39;
    // jwt
    implementation &#39;javax.xml.bind:jaxb-api&#39;
    implementation &#39;io.jsonwebtoken:jjwt-api:0.11.1&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.11.1&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.11.1&#39;
    // test
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    testImplementation &#39;io.projectreactor:reactor-test&#39;
    testImplementation &#39;org.springframework.security:spring-security-test&#39;
}</code></pre>
<h2 id="applicationyml">application.yml</h2>
<pre><code class="language-yaml">spring:
  redis:
    host: localhost
    port: 6379
  datasource: # local db
    url: jdbc:h2:tcp://localhost/~/test
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true

logging.level:
  org.hibernate.SQL: debug

jwt:
  secret: {임의의 문자열을 Base64로 인코딩한 값}
  refresh-token-validity-in-seconds: 1209600 # 14일
  access-token-validity-in-seconds: 43200 # 12시간</code></pre>
<p>Redis와 JWT를 사용하기 위한 값이다. JPA Debug, DB 설정과 같은 추가적인 값들은 따로 작성하지 않겠다.</p>
<ul>
<li><code>spring.redis</code>: Redis 연결을 위한 설정 값.</li>
<li><code>jwt.secret</code>: 서명에 사용할 시크릿 키 값. HS512 알고리즘을 사용하기 때문에, Secret Key는 64Byte 이상 되어야 한다.</li>
<li><code>jwt.refresh-token-validity-in-seconds</code>, <code>access-token-validity-in-seconds</code>: 각각 Refresh Token과 Access Token의 유효 시간을 초(second) 단위로 나타낸 값.</li>
</ul>
<h1 id="spring-security">Spring Security</h1>
<h3 id="인증authentication과-인가authorization">인증(Authentication)과 인가(Authorization)</h3>
<ul>
<li>인증: 해당 사용자가 본인이 맞는지 확인하는 과정</li>
<li>인가: 해당 사용자가 요청하는 자원을 실행할 수 있는 권한이 있는가를 확인하는 과정</li>
</ul>
<p>Spring Security는 기본적으로 인증 절차를 거친 후에 인가 절차를 진행하며, 인가 과정에서 해당 리소스에 접근 권한이 있는지 확인하게 된다. Spring Security에서는 이러한 인증과 인가를 위해 Principal(접근 주체)을 아이디로, Credential(비밀번호)을 비밀번호로 사용하는 Credential 기반의 인증 방식을 사용한다.</p>
<h2 id="authentication-architecture">Authentication Architecture</h2>
<p><img src="https://velog.velcdn.com/images/u-nij/post/c37adff6-d4fa-4e0f-a848-b2795a0d0671/image.png" alt=""></p>
<p>Spring Security는 &#39;인증&#39;과 &#39;권한&#39;에 대한 부분을 필터(Filter) 흐름에 따라 처리한다. 요청이 들어오면, 인증과 권한을 위한 필터들을 통하게 된다. 유저가 인증을 요청할때 필터는 인증 메커니즘과 모델을 기반으로 한 필터들을 통과한다. </p>
<blockquote>
<p>Client (request) → <strong>Filter</strong> → DispatcherServlet → <strong>Interceptor</strong> →  Controller</p>
</blockquote>
<p>Filter는 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받지만, (웹 컨테이너에서 관리)
Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점에서 적용 시기의 차이가 있다. (스프링 컨테이너에서 관리)</p>
<p>1. 사용자가 로그인 정보와 함께 인증 요청을 한다.
2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
3. AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다. 4. AuthenticationManager는 등록된 AuthenticationProvider를 조회하여 인증을 요구한다.
5. 실제 DB에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다.
6. 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다.
7. AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보를 비교한다.
8. 인증이 완료되면 <strong>권한 등의 사용자 정보를 담은 Authentication 객체</strong>를 반환한다.
9. 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다. 
10. Authenticaton 객체를 SecurityContext에 저장한다.</p>
<h2 id="security-filter-chain">Security Filter Chain</h2>
<p><img src="https://velog.velcdn.com/images/u-nij/post/1f56af8c-3d2f-41d6-bff8-5cf5e16656f5/image.png" alt=""></p>
<ul>
<li><strong>SecurityContextPersistenceFilter</strong> - 요청(request)전에, SecurityContextRepository에서 받아온 정보를 SecurityContextHolder에 주입합니다.</li>
<li><strong>LogoutFilter</strong> - 주체(Principal)의 로그아웃을 진행합니다. 주체는 보통 유저를 말합니다.</li>
<li><strong>UsernamePasswordAuthenticationFilter</strong> - (로그인) 인증 과정을 진행합니다.</li>
<li><strong>DefaultLoginPageGeneratingFilter</strong> - 사용자가 별도의 로그인 페이지를 구현하지 않은 경우, 스프링에서 기본적으로 설정한 로그인 페이지를 처리합니다.</li>
<li><strong>BasicAuthenticationFilter</strong> - HTTP 요청의 (BASIC)인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장합니다.</li>
<li><strong>RememberMeAuthenticationFilter</strong> - SecurityContext에 인증(Authentication) 객체가 있는지 확인하고 RememberMeServices를 구현한 객체의 요청이 있을 경우, Remember-Me(ex 사용자가 바로 로그인을 하기 위해서 저장 한 아이디와 패스워드)를 인증 토큰으로 컨텍스트에 주입합니다.</li>
<li><strong>AnonymousAuthenticationFilter</strong> - SecurityContextHolder에 인증(Authentication) 객체가 있는지 확인하고, 필요한 경우 익명 사용자로 Authentication 객체를 주입합니다.(Authentication이 Null인 것을 방지)</li>
<li><strong>SessionManagementFilter</strong> - 요청이 시작된 이후 인증된 사용자인지 확인하고, 인증된 사용자일 경우SessionAuthenticationStrategy를 호출하여 세션 고정 보호 메커니즘을 활성화하거나 여러 동시 로그인을 확인하는 것과 같은 세션 관련 활동을 수행합니다.</li>
<li><strong>ExceptionTranslationFilter</strong> - 필터 체인 내에서 발생(Throw)되는 모든 예외(AccessDeniedException, AuthenticationException)를 처리합니다.</li>
<li><strong>FilterSecurityInterceptor</strong> - HTTP 리소스의 보안 처리를 수행한다. 사용자가 요청한 request에 들어가고 결과를 리턴해도 되는 권한(Authorization)이 있는지를 검사합니다. 해당 필터에서 권한이 없다는 결과가 나온다면 위의 ExcpetionTranslationFilter필터에서 Exception을 처리해줍니다.</li>
</ul>
<h2 id="securityconfigclass">SecurityConfig.class</h2>
<p>Spring Security 5.70 이후부터 WebSecurityConfigurerAdapter를 상속 받는 방식은 deprecated되었기 때문에, <a href="https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter">공식 문서</a>를 참고해 SecurityConfig를 작성해보았다.</p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity // Spring Security 설정 클래스
@EnableGlobalMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public BCryptPasswordEncoder encoder() {
    // 비밀번호를 DB에 저장하기 전 사용할 암호화
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        // ACL(Access Control List, 접근 제어 목록)의 예외 URL 설정
        return (web)
                -&gt; web
                .ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()); // 정적 리소스들
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 인터셉터로 요청을 안전하게 보호하는 방법 설정
        http
                // jwt 토큰 사용을 위한 설정
                .csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 예외 처리
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) //customEntryPoint
                .accessDeniedHandler(jwtAccessDeniedHandler) // cutomAccessDeniedHandler

                .and()
                .authorizeRequests() // &#39;인증&#39;이 필요하다
                .antMatchers(&quot;/api/mypage/**&quot;).authenticated() // 마이페이지 인증 필요
                .antMatchers(&quot;/api/admin/**&quot;).hasRole(&quot;ADMIN&quot;) // 관리자 페이지
                .anyRequest().permitAll()

                .and()
                .headers()
                .frameOptions().sameOrigin();

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

}</code></pre>
<h3 id="api별-권한-제어-방법">API별 권한 제어 방법</h3>
<p><code>@EnableGlobalMethodSecurity(securedEnabled = true)</code>로 사용할 수 있다. Controller에 <code>@Secured(&quot;권한 이름&quot;)</code> 어노테이션으로 설정이 가능하다.</p>
<pre><code class="language-java">@Secured(&quot;ROLE_ADMIN&quot;)
@GetMapping(&quot;/api/admin&quot;)
public ResponseEntity&lt;String&gt; adminTest() { {
    // ...
}</code></pre>
<ul>
<li><p><code>UserDetailsImpl</code>에 <code>권한(Authority)</code> 정보를 담아줄 수 있다.</p>
<ul>
<li><p>1개 이상 설정 가능</p>
</li>
<li><p><code>“권한 이름”</code> 규칙</p>
</li>
<li><p><code>ROLE_</code>로 시작해야 한다 (ex. <code>ROLE_ADMIN</code>, <code>ROLE_USER</code>)</p>
<pre><code class="language-java">// 예시
public class UserDetailsImpl implements UserDetails {

@Override
public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
    Collection&lt;GrantedAuthority&gt; authorities = new ArrayList&lt;&gt;();
    authorities.add(() -&gt; user.getRole()); // key: ROLE_권한
    return authorities;
}

// ...
}</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="csrfcross-site-request-forgery">CSRF(Cross Site Request Forgery)</h3>
<p>사이트간 위조 요청으로, 정상적인 사용자가 의도하지 않은 위조 요청을 보내는 것을 의미한다. Spring Security는 CSRF protection 기능을 default로 설정한다. Spring Security는 이 기능을 사용해 GET 요청을 제외한, 상태를 변화시킬 수 있는 POST, PUT, DELETE 요청으로부터 CSRF를 보호한다. CSRF protection은 CSRF Token을 발급 후, 클라이언트로부터 요청을 받을 때마다 해당 요청을 검증하는 방식이다. HTML에 다음과 같은 CSRF Token이 포함되어야 요청을 받아들임으로써 위조 요청을 방지한다.</p>
<pre><code>    &lt;input type=&quot;hidden&quot; name=&quot;${_csrf.parameterName}&quot; value=&quot;${_csrf.token}&quot;/&gt;</code></pre><p><img src="https://velog.velcdn.com/images/u-nij/post/9780231e-e83b-4c9e-95a4-700df38b60c7/image.png" alt=""> <a href="https://docs.spring.io/spring-security/reference/features/exploits/csrf.html#csrf-when">Spring Security Documentation</a>에서는 non-broswer client가 사용하는 서비스라면 CSRF를 disable하여도 좋다고 한다. 이 이유는 <strong>REST API</strong>를 이용한 서버라면, session 기반 인증과는 달리 stateless하기 때문에, 서버에 인증 정보를 보관하지 않는다. REST API라면 클라이언트는 권한이 필요한 요청을 하기 위해서 요청에 필요한 인증 정보를 OAuth2.0, JWT 토큰 등에 포함시켜야 한다. 따라서, 서버에 인증 정보를 저장하지 않기 때문에 불필요한 CSRF 코드를 작성할 필요가 없다.
JWT 토큰을 사용할 것이기 때문에, <code>http.csrf().disable()</code>를 통해 Spring Security가 CSRF protection 기능을 사용하지 않게 했다.</p>
<h3 id="rest-api-사용을-위한-설정">Rest API 사용을 위한 설정</h3>
<ul>
<li><code>httpBasic().disable()</code>: Http basic Auth 기반의 로그인 인증 창. 비인증시 로그인폼 화면으로 리다이렉트한다. REST API 방식을 사용할 것이므로 사용하지 않는다.</li>
<li><code>formLogin().disable()</code> : 일반적인 로그인 방식, 즉 ID/Password 로그인 방식 사용을 의미한다. REST API 방식을 사용할 것이므로 사용하지 않는다.</li>
<li><code>sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)</code> : STATELESS로 세션 정책을 설정한다는 것은, 세션쿠키 방식의 인증 메커니즘 방식을 사용하지 않겠다는 것을 의미한다. 인증에 성공한 이후라도 클라이언트가 다시 어떤 자원에 접근을 시도할 경우, SecurityContextPersistenceFilter는 세션 존재 여부를 무시하고 항상 새로운 SecurityContext 객체를 생성하기 때문에 인증성공 당시 SecurityContext에 저장했던 Authentication 객체를 더 이상 참조 할 수 없게 된다.(<a href="https://www.inflearn.com/questions/34886">참고</a>)</li>
</ul>
<h3 id="예외-처리">예외 처리</h3>
<pre><code class="language-java">.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) //customEntryPoint
.accessDeniedHandler(jwtAccessDeniedHandler) // cutomAccessDeniedHandler</code></pre>
<ul>
<li><code>authenticationEntryPoint</code>: 401 에러 핸들링을 위한 설정</li>
<li><code>accessDeniedHandler</code>: 403 에러 핸들링을 위한 설정</li>
</ul>
<h3 id="리소스url의-권한-설정">리소스(URL)의 권한 설정</h3>
<pre><code class="language-java">.authorizeRequests() // &#39;인증&#39;이 필요하다
.antMatchers(&quot;/api/mypage/**&quot;).authenticated() // 마이페이지 인증 필요
.antMatchers(&quot;/api/admin/**&quot;).hasRole(&quot;ADMIN&quot;) // 관리자 페이지
.anyRequest().permitAll()</code></pre>
<p>우선 특정 리소스에 대해 권한을 설정할 수 있다.</p>
<ul>
<li><code>anyRequest()</code>: 그 외 나머지 리소스들을 의미한다.</li>
<li><code>authenticated()</code>: 인증을 완료해야 접근을 허용한다.</li>
<li><code>hasRole(&quot;권한&quot;)</code>: 특정 레벨의 권한을 가진 사용자만 접근을 허용한다.(SecurityContext에 저장했던 Authentication 객체의 Authorities를 검사한다.)</li>
<li><code>permitAll()</code>: 인증 절차 없이 접근을 허용한다.</li>
</ul>
<h3 id="참고">참고</h3>
<p><a href="https://siyoon210.tistory.com/32">Spring Security - Filter, FilterChain</a>
<a href="https://velog.io/@seongwon97/Spring-Security-Filter%EB%9E%80">[Spring Security] Filter란?</a>
<a href="https://dev-coco.tistory.com/174">Spring Security의 구조(Architecture) 및 처리 과정 알아보기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JWT] JWT와 JWT를 사용하는 이유]]></title>
            <link>https://velog.io/@u-nij/JWT</link>
            <guid>https://velog.io/@u-nij/JWT</guid>
            <pubDate>Fri, 14 Oct 2022 18:20:04 GMT</pubDate>
            <description><![CDATA[<h1 id="jwtjson-web-token">JWT(Json Web Token)</h1>
<p>JWT는 유저를 인증하고 식별하기 위한 토큰(Token) 기반 인증이다. 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함된다. JWT를 사용하면 RESTful과 같은 무상태(Stateless)인 환경에서 사용자 데이터를 주고받을 수 있게 된다. 세션(Session)을 사용하게 될 경우에는 쿠키 등을 통해 사용자를 식별하고 서버에 세션을 저장했지만, JWT와 같은 토큰을 클라이언트에 저장하고 요청시 HTTP 헤더에 토큰을 첨부하는 것만으로도 단순하게 데이터를 요청하고 응답을 받아올 수 있다.</p>
<h2 id="구조">구조</h2>
<p>JWT는 Header, Payload, Signature로 구성된다. 각 요소는 .으로 구분된다.</p>
<h3 id="header">Header</h3>
<p>JWT에서 사용할 타입(typ)과 해시 알고리즘(alg)의 종류가 담겨있다.</p>
<pre><code class="language-json">{
  &quot;typ&quot;: &quot;JWT&quot;,
  &quot;alg&quot;: &quot;HS512&quot;
}</code></pre>
<h3 id="payload">Payload</h3>
<p>서버에서 첨부한 사용자 권한 정보와 데이터인 클레임(Claim) 담겨있다. 클레임은 Key/Value 형태로 된 값을 가진다. 저장되는 정보에 따라 Registered Claims, Public Claims, Private Cliams로 구분된다.</p>
<blockquote>
<p><strong>등록된 클레임(Registered Claims)</strong>
iss: 토큰 발급자(issuer)
sub: 토큰 제목(subject)
aud: 토큰 대상자(audience)
exp: 토큰 만료 시간(expiration)
nbf: 토큰 활성 날짜(not before), 이 날이 지나기 전의 토큰은 활성화되지 않는다.
iat: 토큰 발급 시간(issued at)
jti: JWT 토큰 식별자(JWT ID), 중복 방지를 위해 사용하며, 일회용 토큰(Access Token) 등에 사용한다.</p>
</blockquote>
<pre><code class="language-json">{
  &quot;sub&quot;: &quot;subject&quot;,
  &quot;iss&quot;: &quot;abc&quot;,
}</code></pre>
<blockquote>
</blockquote>
<p><strong>공개 클레임(Public Claims)</strong>
공개 클레임들은 충돌이 방지된 이름을 가지고 있어야한다. 주로 URI 형식으로 짓는다.</p>
<pre><code class="language-json">{
  &quot;https://velog.io/jwt/public&quot;: true
}</code></pre>
<blockquote>
</blockquote>
<p><strong>비공개 클레임(Private Cliams)</strong>
클라이언트와 서버 협의하에 사용되는 클레임이다. 공개 클레임과는 달리 이름이 중복되어 충돌될 수 있기 때문에 유의해야 한다.</p>
<pre><code class="language-json">{
  &quot;username&quot;: &quot;kim&quot;
}</code></pre>
<h3 id="signature">Signature</h3>
<p>Header, Payload를 Base64 URL-safe Encode 한 이후, Header에 명시된 해시함수를 적용하고, 개인키(Private key)로 서명한 <strong><span style="background-color:yellow">전자서명</strong>이 담겨있다. 이는 Header, Payload가 변조되었는지 확인하기 위해 사용되는 중요 정보이며, JWT를 신뢰할 수 있는 토큰으로 사용할 수 있는 근거가 된다.</p>
<blockquote>
<p><strong>Base64 URL-safe Encode</strong>
웹으로 전송하기 위해 사용하는 문자가 Base64 형태의 문자이다. 일반적인 Base64 Encode 에서 URL 에서 오류없이 사용하도록 &#39;+&#39;, &#39;/&#39; 를 각각 &#39;-&#39;, &#39;_&#39; 로 표현한 것이다.</p>
</blockquote>
<h1 id="세션session">세션(Session)</h1>
<p>사용자가 어떤 서버에 로그인을 하기 위해 ID를 입력했다고 가정했을 때, 서버는 입력받은 ID에 대한 <strong><span style="background-color:yellow">&quot;인증&quot;</strong> 단계를 거치고, 서버 안의 세션 저장소에 해당 내용을 담은 세션을 저장한다. 이 저장된 것을 <strong><span style="background-color:yellow">&quot;세션ID&quot;</strong>라고 한다.서버는 저장된 세션ID를 HTTP Response의 Cookie 값에 세팅해 사용자에게 보내게 된다. 이후에 사용자는 권한이 필요한 요청을 서버에 보낼 때 세션ID를 보내게 되고, 서버는 해당 세션ID가 세션 저장소에 있는지 확인하고 <strong><span style="background-color:yellow">&quot;인가&quot;</strong>의 여부를 결정헌다.
예를 든다면, 서버가 세션을 사용할 때, 사용자는 로그인 상태를 유지하기 위해 세션ID를 쿠키 값으로 가지고 있어야 한다. 만약 로그인한 상태에서 쿠키 초기화를 하게 된다면, 로그인 상태를 유지하게 해주던 세션ID를 초기화한 것이 되기 떄문에 다시 로그인을 해야한다.</p>
<blockquote>
<p><strong>인증(Authentication)</strong>: 접근자가 누구인지 확인하는 절차
<strong>인가(Authorization)</strong>: 인증을 마친 접근자에게 권한을 허락하는 절차</p>
</blockquote>
<h3 id="세션의-문제점">세션의 문제점</h3>
<p>이러한 세션 방식은 여러 대의 서버를 사용하게 될 때 문제가 발생한다. 세션 저장소는 로드 밸런싱을 했을 때 공유되지 않는다. 인가 절차를 거칠 때, 다른 서버로 접근하게 되면 인가가 불가능하게 된다.
즉, 1번 서버에서 발급된 세션ID를 가지고 2번 서버에서 인가 과정을 시도하는 것이 불가능하다. DB를 사용해서 해결이 가능하겠지만, 접근하는 것 자체에서 네트워크와 하드 디스크 IO 비용이 발생한다는 문제가 있다.</p>
<h3 id="jwt와-세션의-차이점">JWT와 세션의 차이점</h3>
<p>JWT 토큰과 세션의 가장 큰 차이점은 <strong><span style="background-color:yellow">&quot;인가&quot;</strong>에 대한 정보를 누가 담고 있냐이다. 세션은 서버의 세션 저장소에 해당 인증 내용을 담지만, JWT 토큰은 토큰 안에 인증 내용을 담고 있다. 사용자가 요청을 했을 때 <strong><span style="color:red">토큰만 확인하면 되므로</strong> 세션 관리가 필요 없어진다. 따라서, JWT 토큰을 활용하면 각각 로드 밸런싱된 서버에 토큰 검증 클래스나 메소드만 있다면 인가 과정을 해결할 수 있다.</p>
<h3 id="참고">참고</h3>
<p><a href="https://pronist.dev/143">https://pronist.dev/143</a>
<a href="https://velog.io/@jjy5349/%EC%84%B8%EC%85%98%EA%B3%BC-JWT-%ED%86%A0%ED%81%B0-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-2%ED%8E%B8">https://velog.io/@jjy5349/%EC%84%B8%EC%85%98%EA%B3%BC-JWT-%ED%86%A0%ED%81%B0-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-2%ED%8E%B8</a>
<a href="https://koonsland.tistory.com/57">https://koonsland.tistory.com/57</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠키(Cookie) vs. 세션(Session)]]></title>
            <link>https://velog.io/@u-nij/%EC%BF%A0%ED%82%A4Cookie-vs.-%EC%84%B8%EC%85%98Session</link>
            <guid>https://velog.io/@u-nij/%EC%BF%A0%ED%82%A4Cookie-vs.-%EC%84%B8%EC%85%98Session</guid>
            <pubDate>Fri, 14 Oct 2022 17:53:39 GMT</pubDate>
            <description><![CDATA[<h1 id="http-protocol">HTTP Protocol</h1>
<p>기본적으로 HTTP 프로토콜 환경은 Connectionless, Stateless한 특성을 가지기 때문에, 서버는 클라이언트가 누구인지 매번 확인해야 한다. </p>
<ul>
<li><strong>Connectionless</strong>: 클라이언트가 요청을 한 후 응답을 받으면 연결을 끊어버린다.</li>
<li><strong>Stateless</strong>: 통신이 끝나면 상태 정보를 유지하지 않는다.</li>
</ul>
<p>세션과 쿠키는 이러한 특성을 해결하기 위해 사용된다. 만약 세션과 쿠키를 사용하지 않는다면, 사용자는 페이지를 이동할 때마다 로그인을 다시 해야 한다.</p>
<h1 id="쿠키cookie">쿠키(Cookie)</h1>
<p>쿠키는 클라이언트의 로컬에 저장되는 Key/Value 형태를 가진 작은 데이터이다. 클라이언트에 300개의 쿠키를 저장할 수 있으며, 하나의 도메인당 20개의 값만 가질 수 있다. 하나의 쿠키 값은 4KB까지 저장한다.
클라이언트가 요청을 보내면, 서버에서 쿠키를 생성해 HTTP Header에 쿠키를 포함해 응답한다. 브라우저가 종료되어도 쿠키의 만료 기간이 남아있다면 클라이언트에서 보관하고 있다. 클라이언트가 같은 요청을 보내게 될 경우 HTTP Header에 쿠키를 함께 보낸다. 서버에서 이전 상태 정보를 변경할 필요가 있을 때 쿠키를 업데이트하고, 변경된 쿠키를 HTTP Header에 포함해 응답을 보낸다.</p>
<h1 id="세션session">세션(Session)</h1>
<p>클라이언트가 서버에 접속하게 되면, 서버는 클라이언트를 구분하기 위해 고유한 <strong>세션ID</strong>를 부여하고, 이 세션ID를 <strong>서버 측에 저장</strong>한다. 클라이언트는 쿠키를 사용해 세션ID 데이터를 가지고 있고, 서버에 요청을 보낼 때 해당 데이터를 같이 서버에 전달한다. 서버는 전달받은 세션ID를 이용해 클라이언트 정보를 가져와 적절한 응답을 보낼 수 있게 된다. 또, 클라이언트가 서버에 접속해 브라우저를 종료할 때까지 인증 상태를 유지할 수 있다. 세션이 쿠키보다 보안면에서 우수하지만, 사용자가 많아질수록 서버 메모리를 많이 차지하게 된다.</p>
<h1 id="쿠키와-세션의-차이">쿠키와 세션의 차이</h1>
<table>
<thead>
<tr>
<th align="left"></th>
<th align="center">쿠키</th>
<th align="center">세션</th>
</tr>
</thead>
<tbody><tr>
<td align="left">저장 위치</td>
<td align="center">클라이언트</td>
<td align="center">웹 서버</td>
</tr>
<tr>
<td align="left">보안</td>
<td align="center">취약하기 때문에, 지워지거나 조작되어도 큰 지장이 없는 수준의 정보를 저장하는데 사용된다</td>
<td align="center">더 우수하기 때문에 노출되면 안되는 정보들을 다루는데 사용한다</td>
</tr>
<tr>
<td align="left">요청 속도</td>
<td align="center">서버의 처리가 필요하지 않기 때문에 더 빠르다</td>
<td align="center">정보가 서버에 있기 때문에 비교적 느리다</td>
</tr>
<tr>
<td align="left">라이프 사이클</td>
<td align="center">브라우저를 종료해도 유지 가능</td>
<td align="center">브라우저가 종료되면 삭제된다</td>
</tr>
</tbody></table>
<h3 id="참고">참고</h3>
<p><a href="https://interconnection.tistory.com/74">https://interconnection.tistory.com/74</a>
<a href="https://devuna.tistory.com/23">https://devuna.tistory.com/23</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 전역 예외 처리: @RestControllerAdivce 적용]]></title>
            <link>https://velog.io/@u-nij/Spring-%EC%A0%84%EC%97%AD-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-RestControllerAdivce-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@u-nij/Spring-%EC%A0%84%EC%97%AD-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-RestControllerAdivce-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Thu, 13 Oct 2022 09:28:41 GMT</pubDate>
            <description><![CDATA[<h1 id="전역-예외-처리">전역 예외 처리</h1>
<h3 id="controlleradvice">ControllerAdvice</h3>
<ul>
<li><p>Spring은 전역적으로 ExceptionHandler를 적용할 수 있는 <code>@ControllerAdvice</code>와 <code>@RestControllerAdvice</code> 어노테이션을 제공하고 있다.</p>
</li>
<li><p><code>@ControllerAdivce</code>는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다. 다음과 같이 에러를 핸들링하는 클래스를 만들어 어노테이션을 붙여주면 에러 처리를 위임할 수 있다.</p>
<ul>
<li>만약 특정 클래스에만 제한적으로 적용하고 싶다면, <code>@RestControllerAdvice</code>의 basePackages 등을 설정함으로써 제한할 수 있다</li>
</ul>
</li>
<li><p><code>@RestControllerAdvice</code>는 <code>@ControllerAdvice</code>와 달리 <code>@ResponseBody</code>가 붙어 있어 응답을 <strong>Json으로</strong> 내려준다.</p>
<h3 id="responseentityexceptionhandler-추상-클래스">ResponseEntityExceptionHandler 추상 클래스</h3>
</li>
<li><p>Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있다.</p>
</li>
<li><p>ResponseEntityExceptionHandler에는 <strong>스프링 예외에 대한 ExceptionHandler가 모두 구현</strong>되어 있으므로 <code>@ControllerAdvice</code> 클래스가 이를 <strong>상속</strong>받게 하면 된다.</p>
</li>
<li><p>이 추상 클래스를 상속받지 않는다면 스프링 예외들은 DefaultHandlerExceptionResolver가 처리하게 되는데, 그러면 예외 처리기가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받지 못하므로 ResponseEntityExceptionHandler를 상속시키는 것이 좋다.</p>
</li>
<li><p>또한 이는 기본적으로 에러 메세지를 반환하지 않으므로, 스프링 예외에 대한 에러 응답을 보내려면 handleExceptionInternal 메소드를 오버라이딩 해야 한다.</p>
<h3 id="적용-예시">적용 예시</h3>
<pre><code class="language-java">@RestControllerAdvice // 전역 예외 처리
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { // 추상 클래스 상속

  // NotFoundAccountException 발생시 에러 처리
  @ExceptionHandler(NotFoundAccountException.class)
  public ResponseEntity&lt;?&gt; handleNotFoundEntity(NotFoundException e) {
      return handleExceptionInternal(e.getExceptionCode());
  }
}</code></pre>
</li>
</ul>
<h3 id="handleexceptioninternal-메소드">handleExceptionInternal 메소드</h3>
<p>ResponseEntityExceptionHandler의 handleExceptionInternal() 메소드를 오버라이딩하여 응답을 커스터마이징할 수 있다.</p>
<h3 id="controlleradvice를-사용함으로써-얻을-수-있는-이점">ControllerAdvice를 사용함으로써 얻을 수 있는 이점</h3>
<ul>
<li>하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능하다.</li>
<li>직접 정의한 에러 응답을 일관성 있게 클라이언트에게 내려줄 수 있다.</li>
<li>별도의 try-catch문이 없어 코드의 가독성이 높아진다.</li>
</ul>
<h3 id="controlleradvice-사용시-주의해야-할-점">ControllerAdvice 사용시 주의해야 할 점</h3>
<ul>
<li>한 프로젝트당 하나의 ControllerAdivce만 관리하는 것이 좋다.</li>
<li>만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.</li>
<li>직접 구현한 Exception 클래스들은 한 공간에서 관리한다.</li>
</ul>
<h1 id="예외-처리-흐름">예외 처리 흐름</h1>
<ol>
<li><p>ExceptionHandlerExceptionResolver: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함</p>
<ol>
<li>예외가 발생한 컨트롤러 안에 적합한 <code>@ExceptionHandler</code>가 있는지 검사한다.</li>
<li>컨트롤러의 <code>@ExceptionHandler</code>에서 처리가 가능하다면 처리하고, 그렇지 않으면 ControllerAdivce로 넘어간다.</li>
<li>ControllerAdvice 안에 적합한 <code>@ExceptionHandler</code>가 있는지 검사하고, 없으면 다음 처리기로 넘어간다.</li>
</ol>
</li>
<li><p>ResponseStatusExceptionResolver: Http 상태 코드를 지정하는 <code>@ResponseStatus</code> 또는 ResponseStatusException를 처리함</p>
<ol>
<li><code>@ResponseStatus</code>가 있는지 또는 ResponseStatusException인지 검사한다.</li>
<li>맞으면 ServletResponse의 sendError()로 예외를 서블릿까지 전달하고, 서블릿이 BasicErrorController로 요청을 전달한다.</li>
</ol>
</li>
<li><p>DefaultHandlerExceptionResolver:  스프링 내부의 기본 예외들을 처리한다.</p>
<ol>
<li>Spring의 내부 예외인지 검사하여, 맞으면 에러를 처리하고 아니면 넘어간다.</li>
</ol>
</li>
<li><p>적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달되고, 서블릿은 SpringBoot가 진행한 자동 설정에 맞게 BasicErrorController로 요청을 다시 전달한다.</p>
</li>
</ol>
<h1 id="restcontrolleradvice-적용">@RestControllerAdvice 적용</h1>
<ul>
<li><p>View를 사용하지 않고 Rest API로만 사용하기 때문에 <code>@RestControllerAdvice</code>를 사용하겠다.</p>
<h3 id="예외-케이스-관리">예외 케이스 관리</h3>
</li>
<li><p>클라이언트에게 보내줄 에러 코드를 한 곳에서 관리할 수 있다.</p>
</li>
<li><p>에러 이름과 HTTP 상태 및 메세지를 가지고 있는 Enum Class이다.</p>
<pre><code class="language-java">@Getter
@RequiredArgsConstructor
public enum ErrorCode {

  /*
   * 400 BAD_REQUEST: 잘못된 요청
   */
  BAD_REQUEST(HttpStatus.BAD_REQUEST, &quot;Invalid request.&quot;),

  /*
   * 401 UNAUTHORIZED: 인증되지 않은 사용자의 요청
   */
  UNAUTHORIZED_REQUEST(HttpStatus.UNAUTHORIZED, &quot;Unauthorized.&quot;),

  /*
   * 403 FORBIDDEN: 권한이 없는 사용자의 요청
   */
  FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, &quot;Forbidden.&quot;),

  /*
   * 404 NOT_FOUND: 리소스를 찾을 수 없음
   */
  NOT_FOUND(HttpStatus.NOT_FOUND, &quot;Not found.&quot;),

  /*
   * 405 METHOD_NOT_ALLOWED: 허용되지 않은 Request Method 호출
   */
  METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, &quot;Not allowed method.&quot;),

  /*
   * 500 INTERNAL_SERVER_ERROR: 내부 서버 오류
   */
  INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, &quot;Server error.&quot;);

</code></pre>
</li>
</ul>
<pre><code>private final HttpStatus httpStatus;
private final String message;</code></pre><p>}</p>
<pre><code>### 에러 응답 형식 지정
- 실제 사용자에게 JSON 형식으로 보여주기 위한 에러 응답 형식을 지정한다.
``` java
@Getter
public class ErrorResponse {
    private final LocalDateTime timestamp = LocalDateTime.now();
    private final int statusCode;
    private final String error;
    private final String message;

    public ErrorResponse(ErrorCode errorCode) {
        this.statusCode = errorCode.getHttpStatus().value();
        this.error = errorCode.getHttpStatus().name();
        this.message = errorCode.getMessage();
    }
}</code></pre><h3 id="에러-코드-정의">에러 코드 정의</h3>
<ul>
<li><p>발생한 예외를 처리해줄 에러 클래스(Exception Class)를 추가한다.</p>
</li>
<li><p>RuntimeException을 상속받아 언체크 예외로 활용한다.</p>
<pre><code class="language-java">@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException {
  private final ErrorCode errorCode;
}</code></pre>
<h3 id="전역-예외-처리-1">전역 예외 처리</h3>
</li>
<li><p><code>@ExceptionHandler</code>: 발생한 특정 예외를 잡아 하나의 메소드에서 공통 처리할 수 있게 한다.</p>
</li>
<li><p><code>@RestControllerAdvice</code>: 프로젝트 전역에서 발생하는 모든 예외를 잡아준다.</p>
</li>
<li><p>모든 예외를 잡은 후에, Exception 종류별로 메소드를 공통 처리할 수 있다.</p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

  /*
   * Developer Custom Exception: 직접 정의한 RestApiException 에러 클래스에 대한 예외 처리
   */
  @ExceptionHandler(RestApiException.class)
  protected ResponseEntity&lt;ErrorResponse&gt; handleCustomException(RestApiException ex) {
      ErrorCode errorCode = ex.getErrorCode();
      return handleExceptionInternal(errorCode);
  };

  // handleExceptionInternal() 메소드를 오버라이딩해 응답 커스터마이징
  private ResponseEntity&lt;ErrorResponse&gt; handleExceptionInternal(ErrorCode errorCode) {
      return ResponseEntity
              .status(errorCode.getHttpStatus().value())
              .body(new ErrorResponse(errorCode));
  }
}</code></pre>
<h2 id="테스트">테스트</h2>
<p>Controller에서 무조건 BAD_REQUSEST 응답을 내리는 코드를 작성해보았다.</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api&quot;)
public class NaverLoginApiController {
  @GetMapping(&quot;/test&quot;)
  public ResponseEntity&lt;?&gt; test() {
      throw new RestApiException(ErrorCode.BAD_REQUEST);
  }
}</code></pre>
<p>Postman에서 GET &quot;<a href="http://localhost:8080/api/test&quot;">http://localhost:8080/api/test&quot;</a> 실행,
<img src="https://velog.velcdn.com/images/u-nij/post/0eabe3e5-ac3a-4df4-a831-ec96500cd941/image.png" alt=""></p>
</li>
</ul>
<h3 id="참고">참고</h3>
<p><a href="https://mangkyu.tistory.com/204">https://mangkyu.tistory.com/204</a>
<a href="https://bcp0109.tistory.com/303">https://bcp0109.tistory.com/303</a>
<a href="https://velog.io/@evelyn82ny/Exception-handling-using-RestControllerAdvice">https://velog.io/@evelyn82ny/Exception-handling-using-RestControllerAdvice</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Error & Exception ]]></title>
            <link>https://velog.io/@u-nij/Java-Error-Exception</link>
            <guid>https://velog.io/@u-nij/Java-Error-Exception</guid>
            <pubDate>Thu, 13 Oct 2022 08:47:50 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/u-nij/post/c592b290-e470-4157-9edd-193e98e536ed/image.png" alt=""></p>
<h1 id="에러error">에러(Error)</h1>
<ul>
<li>메모리가 부족하는 등과 같이 시스템이 비정상적인 상황인 경우에 사용</li>
<li>주로 JVM에서 발생시키기 때문에 애플리케이션 코드에서 잡아서는 안되며, 잡아서 대응할 수 있는 방법도 없다.</li>
<li>시스템 레벨에서 특별한 작업을 하는게 아니라면, 이러한 에러 처리는 하지 않아도 된다.</li>
</ul>
<h1 id="예외exception">예외(Exception)</h1>
<ul>
<li>Error와 달리 애플리케이션 코드에서 예외가 발생하였을 경우에 사용된다.</li>
<li>Exception은 다시 체크 예외(Check Exception)와 언체크 예외(Uncheck Exception)로 구분된다.</li>
<li><strong>스프링 프레임워크가 제공하는 선언적 트랜잭션(@Transactional)안에서 에러 발생 시, 체크 예외는 롤백(Rollback)이 되지 않고, 언체크 예외는 롤백이 된다.</strong></li>
</ul>
<h2 id="체크-예외check-exception">체크 예외(Check Exception)</h2>
<ul>
<li>RuntimeException 클래스를 상속받지 않은 예외 클래스들이다.</li>
<li>체크 예외는 <strong>복구 가능성이 있는</strong> 예외이기 때문에, <strong>반드시 예외를 처리하는 코드를 함께 작성</strong>해야 한다.<ul>
<li>try catch :해당 메소드에서 처리</li>
<li>throw: 호출한 곳에서 처리하도록 상위로 넘김</li>
</ul>
</li>
<li>대표적으로 IOException, SQLException, ClassNotFoundException 등이 있으며, 예외를 처리하기 위해서는 catch 문으로 잡거나 throws를 통해 메소드 밖으로 던질 수 있다. 만약 <strong>예외를 처리하지 않으면 컴파일 에러가 발생</strong>한다.</li>
</ul>
<h2 id="언체크-예외uncheck-exception">언체크 예외(Uncheck Exception)</h2>
<ul>
<li><strong>RuntimeException 클래스를 상속받는 예외 클래스들은 복구 가능성이 없는 예외</strong>들이므로 컴파일러가 예외처리를 강제하지 않는다. 그래서  언체크 예외라고 불리는데, 언체크 예외는 Error와 마찬가지로 에러를 처리하지 않아도 컴파일 에러가 발생하지 않는다.</li>
<li>즉, 런타임 예외는 예상치 못했던 상황에서 발생하는 것이 아니므로 굳이 예외 처리를 강제하지 않는다.</li>
<li>RuntimeException에는 대표적 NullPointerException이나 IllegalArgumentException 등과 같은 것들이 있다. </li>
</ul>
<h1 id="예외-처리-방법">예외 처리 방법</h1>
<h2 id="예외-복구">예외 복구</h2>
<ul>
<li>예외 상황을 파악하고, 문제를 해결해서 정상 상태로 돌려놓는 것이다.</li>
</ul>
<h2 id="예외-처리-회피">예외 처리 회피</h2>
<ul>
<li>예외 처리를 직접 처리하지 않고, 자신을 호출한 곳으로 던져버리는 것이다.</li>
<li>해당 예외를 처리하는 것이 자신이 해야될 일이 아니라고 느껴진다면, 다른 메소드에서 처리하도록 넘겨줄 때 사용한다.</li>
</ul>
<h2 id="예외-전환">예외 전환</h2>
<ul>
<li>예외 처리 회피와 마찬가지로, 예외를 복구할 수 없는 상황에 사용된다.</li>
<li>예외 처리 회피와 다르게, 적절한 예외로 변환하여 던진다.</li>
<li><strong>목적</strong><ul>
<li>의미 있고 추상화된 예외로 바꾸는 경우<ul>
<li>중복되는 ID로 회원 가입을 요청한 경우,  SQLException이 발생한다. 이 에러를 그대로 던지면, 서비스 계층에서는 왜 예외가 발생한건지 파악하기 힘들기 때문에, DuplicatedUserIdException과 같은 예외로 바꿔서 던져 상황에 따른 복구 작업을 시도할 수 있게 한다.</li>
</ul>
</li>
<li>런타임 예외로 포장하여(언체크 예외로 변경하여) 불필요한 처리를 줄여주는 경우<ul>
<li>복구하지 못할 예외라면 불필요하게 체크할 필요가 없기 때문에, 애플리케이션 로직 상에서 런타임 예외로 포장하여 던지고, 자세한 로그를 남기거나 알림을 주는 등의 방식으로 처리한다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="참고">참고</h3>
<p><a href="http://plus4070.github.io/nhn%20entertainment%20devdays/2017/01/22/Exception/">http://plus4070.github.io/nhn%20entertainment%20devdays/2017/01/22/Exception/</a>
<a href="https://mangkyu.tistory.com/152">https://mangkyu.tistory.com/152</a></p>
]]></description>
        </item>
    </channel>
</rss>