Redis란?

백엔드 공부를 하면서 성능을 더 높이기 위해 빠르게 조회용으로 쓰는 NoSQL에 대해서 많이 들어봤습니다. 그 중에서 대표적인 몽고 DB와 Redis가 유명한데 Redis가 무엇인지 궁금하여 찾아봤습니다.

1. Redis의 특징

Redis는 In-Memory 기반의 키-값 형식의 비정형 데이터 구조를 가졌습니다. 따라서 별도의 쿼리 없이도 데이터를 간단히 조회할 수 있습니다.

Redis는 크게 String, Set, Sorted Set, Hash, List 자료구조를 지원하고, 서비스의 특성에 따라서 캐시로도 사용가능하고, Persistence Data Storage로 사용할 수도 있습니다.

2. 캐시 사용의 이점

서비스 요청이 증가할수록, 특히 DB에서 데이터를 조회하는 경우에는 많은 부하를 줄 수 있습니다. 이런 상황에서 나중에 요청된 결과를 미리 저장해두었다가 빨리 제공하기 위해 캐시를 사용합니다.

Redis는 메모리 기반이기때문에 디스크에 비해서 용량은 적지만 접근 속도는 빠릅니다.

3. 캐시 사용 방법

Untitled Diagram (2)

  • Look aside cache 방식

    • (1) 웹 서버는 클라이언트 요청을 받아서, 데이터가 존재하는지 캐시를 먼저 확인합니다.
    • (2) Cache에 데이터가 있으면 조회를 하고, 만약 없으면 DB에서 읽어서 캐시에 저장 후 클라이언트한테 데이터를 전달합니다.
  • Write Back

    • 데이터를 캐시에 전부 먼저 저장해놓았다가 특정 시점마다 한번씩 캐시 내 데이터를 DB에 INSERT 하는 방법입니다.
    • Insert를 1개씩 500번 수행하는 것보다 500개를 한번에 삽입하는 동작이 훨씬 빠르고, write back 방식도 성능면에서는 뒤쳐지지 않다고 합니다.
    • 단점은 데이터를 일정 기간 동안은 유지하고 있어야 하고, 이때 이걸 유지하고 있는 Storage는 메모리 공간이므로 서버 장애 상황에서 데이터가 손실될 우려가 있습니다. 그래서 재생 가능한 데이터나, 극단적으로 무거운 데이터에서 write back 방식을 많이 사용합니다.

4. Redis의 특징

Redis는 Collection을 제공합니다. 주로 게임에서 사용자가 많은 랭킹을 산출할 경우에도 많이 사용하고, Sorted Set을 사용하면 랭킹 서버를 쉽게 구현가능합니다.

Redis Transaction은 한번의 딱 하나의 명령만 수행할 수 있습니다. 이에 더하여 single-thread 특성을 유지하고 있기 때문에 다른 스토리지 플랫폼보다는 이슈가 덜합니다. 하지만 이러한 특징때문에 더블클릭 같은 동작으로 같은 데이터가 2번씩 들어가게 되는 불상사는 막을 수 없기 때문에 별도 처리가 필요합니다.

주로 인증 토큰을 저장하거나 유저 API limit을 두는 상황 등에서 Redis를 많이 사용하고 있습니다.

5. Spring Boot와 Redis 연계하기

간단하게 어떻게 Redis를 설치하고 Spring Boot와 연계하는지 실습을 해봤습니다.

5.1 Docker 설치

먼저, Redis를 Docker를 사용하여 설치하였습니다. 도커는 URL을 통해 설치하시면 됩니다.

Docker 설치하기

5.2 Redis 설치

Redis 설치 및 실행 방법

5.3 의존성(dependency) 추가

api("org.springframework.boot:spring-boot-starter-data-redis")

application.yml 설정 파일에 연동할 Redis의 접속정보를 기술합니다.

spring:  
  redis:
    host: localhost
    port: 6379

스프링 부트는 기본적으로 Redis 포트를 6379로 설정하고 있습니다.

6. Redis 실습

6.1 StringRedisTemplate

저는 Kotlin을 사용하여 Redis의 명령어들을 사용하였습니다. 그 중 strings 타입의 데이터를 저장하는 StringRedisTemplate을 Spring Boot에서 사용하는 예제입니다.

먼저, StringRedisTemplate을 사용하기 위해 Bean을 정의해줍시다.

StringRedisTemplate
@Component
class RedisRunner(val redisTemplate: StringRedisTemplate): ApplicationRunner {

    override fun run(args: ApplicationArguments?) {
        val values = redisTemplate.opsForValue();
        values.set("junyoung", "30")
        values.set("hobby", "coding study")
        values.set("dream", "take a walking with my puppy")
    }
}

StringRedisTemplate을 의존성으로 주입받아서 set으로 key, value를 저장하였습니다.

opsForValue() 메서드는 value관련된 operation들을 제공하는 객체를 받아옵니다.

DokotlinApplication

@EnableJpaAuditing
@SpringBootApplication
class DokotlinApplication(var boardRepository: BoardRepository) {

fun main(args: Array<String>) {
    runApplication<DokotlinApplication>(*args)
}

스프링 부트 어플리케이션을 실행하면 아래와 같이 key-value가 들어간것을 Redis Client에서 확인이 가능합니다.

실행 결과

image

keys * 명령어는 Redis에 저장된 key 값들을 전부 조회하는 명령어로 테스트를 위해서 사용하였지만, 실무에서는 사용하지 않을 것을 권고하고 있습니다.

get hobby 명령어를 실행하면 hobby에 매칭되는 value인 "coding study"가 나오는 것을 확인할 수 있었습니다.

6.2 RedisEntity 실습

이번에는 Entity를 Redis에 저장하는 예제를 살펴보겠습니다.

@RedisHash(value = "fund")
class Fund(
    @Id
    val id: Long? = null

) {
    val name = "name-$id"
}

interface FundRedisRepository: CrudRepository<Fund, Long>

Redis 엔티티 Fund를 정의하였습니다.
@RedisHash("fund")으로 해당 엔티티가 Redis 엔티티임을 명시하였습니다.

@Id 어노테이션이 적용된 맴버변수에 값이 실제 hash_id가 됩니다. 만약 null 값을 주게 된다면 Redis 내부적으로 random한 값을 넣어주게 됩니다.

앞으로 Fund라는 엔티티 데이터들을 Redis에 무수히 저장이 될텐데, 이 엔티티들만 보관하는 하나의 Hash 키 값이 @RedisHash("fund") 이고, 이 Hash 공간에서 각 엔티티들이 fund:hash_id라는 키 값을 가지게 됩니다.

Untitled Diagram drawio (5)

fund:hash_id Key와 하나 이상의 Field/Element 값으로 저장할 수 있으며, Value에는 기본적으로 strings 데이터를 저장할 수 있습니다.

즉, HashMap<String,HashMap<String,Fund>> 구조로 구성되어 있습니다.

이제 테스트 코드를 작성하여 실제 Fund 엔티티들을 Redis에 저장해봤습니다.

@SpringBootTest
class FundRedisRepositoryTests {

    @Autowired
    lateinit var fundRedisRepo: FundRedisRepository

    @Test
    @DisplayName("펀드 엔티티들을 Redis에 저장한다")
    fun saveFundWithRedis() {

        val funds = (1..10).map {
            Fund(
                id = it.toLong()
            )
        }
        fundRedisRepo.saveAll(funds)
    }
}

Redis를 GUI로 쉽게 접근할 수 있는 Medis라는 Tool을 사용해서 결과를 확인해봤습니다.

실행 결과

image

짜잔... Fund 엔티티들이 Redis에 저장되었습니다. 아래 이미지를 보면 Redis에 총 11개의 key 값이 존재합니다.

image

fund는 위에서 Redis 엔티티를 정의할 때 작성한 @RedisHash("fund")를 의미합니다. 그 외에 뒤에 붙은 1 ~ 10까지의 숫자들은 hash_id를 의미합니다.

image

타입을 살펴보면 fund key는 Set 타입이고,
fund:hash_id key는 Hash 타입입니다.

6.3 Redis Hash 타입 명령어

Hash 타입의 데이터를 처리할 때는 hmset, hget, hgetall, hkey, hlen 명령어를 사용합니다.

대표적으로 아래 3개의 명령어를 찾아봤습니다.

  • hget hash_id field: 해당 key에 대한 필드의 value를 검색

  • hgetall hash_id: 해당 key에 대한 모든 field와 value들을 검색

  • hexists hash_id field: 해당 key에 대한 field가 존재하는지 여부 확인

image

참조 사이트: https://velog.io/@max9106/Spring-Boot-Redis,https://coding-start.tistory.com/130, https://cheese10yun.github.io/redis-getting-started/

'SpringFramework > Spring Boot' 카테고리의 다른 글

Spring RestDocs  (0) 2021.05.11
멀티 모듈에서 QueryDSL 설정 방법  (0) 2021.05.11
HandlerMethodArgumentResolver란?  (0) 2019.12.16
인터셉터란?  (0) 2019.12.07
JWT 토큰 기반 인증  (0) 2019.12.06

Spring Rest Docs란?

Spring Rest Docs는 테스트 코드를 기반으로 자동으로 API 문서를 작성할 수 있게 도와주는 프레임워크입니다.

그렇기 때문에 반드시 Test가 통과되어야 문서가 작성 된다는 장점이 있습니다.

Test 통과과 전제조건이기 때문에 API Spec이 변경되거나 추가/삭제 되는 부분에 대해 항상 테스트 코드를 수정해야되고, API 문서가 최신화 될 수 있도록 해줍니다.

처음에는 마크다운이 저에게 익숙하기 때문에 마크다운으로 API 문서를 작성하려고 했지만, 설정을 하는 부분도 번잡하고, 대부분의 큰 서비스 회사에서도 asciidoc을 채택하는 것 같아서 asciidoc을 사용하여 문서를 작성하기로 했습니다. asciidoc은 마크다운과 비슷하게 html를 작성할 수 있는 언어입니다.

1. Spring Rest Docs 설정 방법

일단, 의존성 라이브러리로 JUnit, MockMvc, asciidoc을 선택하여 최소한의 라이브러리로 구현하였습니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.2.2.RELEASE'
    id 'io.spring.dependency-management' version '1.0.8.RELEASE'
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
    id "org.asciidoctor.convert" version "1.5.9.2" // (1)
}

group 'com.kakaopay'
version '1.0-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.querydsl:querydsl-jpa'
    implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.8'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // (5)
}

test {
    useJUnitPlatform()
}

// (2)
asciidoctor {
    dependsOn test  
}

// (3)
bootJar {
    dependsOn asciidoctor
    from("${asciidoctor.outputDir}/html5") {
        into "BOOT-INF/classes/static/docs"
    }
}

// (4)
task copyDocument(type: Copy) {
    dependsOn asciidoctor
    from file("build/asciidoc/html5")
    into file("src/main/resources/static/docs")
}

// (6)
build {
    dependsOn copyDocument
}

def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

dependsOn은 종속 테스크를 지정할 때 사용하는 기능입니다.

테스크 순서

  1. (1), (2)는 asciidoc 파일을 컨버팅하고 Build 디렉토리로 복사하기 위한 플러그인 입니다. asciidoctor Task를 통해 html 문서로 processing 되어 build/asciidoc/html5 하위에 html 문서로 생성이 됩니다.
  2. (3)은 gradle build 시에 asciidoc -> bootJar 순으로 수행이 되고, (4)은 실제 배포 시, BOOT-INF/classes가 classpath가 되기 때문에 아래와 같이 파일을 복사해야 합니다.
  3. (5)는 mockMvc를 restdocs에 사용할 수 있게 하는 라이브러리 입니다.
  4. (6)은 build 테스크를 수행하기 전에 소스 코드에 html파일을 복사하는 테스크 작업입니다.

2. RestDocs 예제 코드

ApiDocumentUtils 클래스

import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;

public interface ApiDocumentUtils {
    static OperationRequestPreprocessor getDocumentRequest() {
        return preprocessRequest(
                modifyUris() // (1)
                        .scheme("http")
                        .host("investment.api.com")
                        .port(8080),
                prettyPrint());
    }

    static OperationResponsePreprocessor getDocumentResponse() {
        return preprocessResponse(prettyPrint());
    }
}

위의 ApiDocumentUtils 클래스의 getDocumentRequest() 메서드는 문서상 uri를 기본 값인 http://localhost:8080에서 http://investment.api.com으로 변경하기 위해 사용합니다.

prettyPrint()는 문서의 request를 보기좋게 출력하기 위해 사용합니다.
getDocumentResponse() 메서드 역시 문서의 response를 보기 좋게 출력하는 용도로 사용합니다.

InvestmentRestControllerTests 클래스

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class InvestmentRestControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @Rule
    public JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation();

    @Autowired
    WebApplicationContext context;

    @Before
    public void setUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }

    @DisplayName("회원이 특정 투자상품에 투자 요청을 정상적으로 처리한다.")
    @Test
    public void 투자결과_리턴_테스트() throws Exception {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("itemId", 1);
        jsonObject.put("investingAmount", 100000);
        String jsonBody = jsonObject.toString();

        ResultActions result = mockMvc.perform(post("/api/investment")
                .content(jsonBody)
                .header("X-USER-ID", 1)
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON));

        result.andExpect(status().isOk())
                .andExpect(jsonPath("$.result", is("success")))
                .andDo(document("investment",
                        getDocumentRequest(),
                        getDocumentResponse(),
                        requestFields(
                                fieldWithPath("itemId").type(JsonFieldType.NUMBER).description("투자상품"),
                                fieldWithPath("investingAmount").type(JsonFieldType.NUMBER).description("투자 금액")
                        ),
                        responseFields(
                                fieldWithPath("result").type(JsonFieldType.STRING).description("투자 결과")
                        )
                ));
    }
}

기본적으로 MockMvc 기반으로 테스트를 하기 전에 setUp()에서 두 가지 방식으로 진행해야 하는데, 저는 webAppContextSetup을 이용하여 WebApplicationContext의 인스턴스로 작동하도록 셋업을 하였습니다. 이렇게 하면 스프링 컨트롤러는 물론 의존성까지 로드되기 때문에 완전한 통합 테스트를 할 수 있습니다.

나머지 하나는 아래 코드처럼 standaloneSetup()을 사용하여 한 컨트롤러에 집중하여 테스트 하는 용도로만 사용한다는 점에서 유닛 테스트와 유사합니다.

this.mockMvc = MockMvcBuilders
            .standaloneSetup
            .apply(documentationConfiguration(restDocumentation))
            .build()

JUnitRestDocumentation는 JUnit 프레임워크로 RestDocs를 실행하기 때문에 필요한 객체 입니다.

requestFields, responseFields 메서드는 사용자 정의 API 스펙에 따라서 요청 필드, 응답 필드를 정의할 수 있습니다.

requestFields(fieldWithPath), responseFields(fieldWithPath())는 request-fields asciidoc, response-fields asciidoc 파일들을 생성 합니다.

위 테스트 코드가 성공하면 build/generated-snippets/investment 디렉토리 하위에 adoc 파일이 생성되는 것을 확인할 수 있습니다.

image

3. Snippet 문서

RestDocs에 대한 명세내역을 코드에 작성한 후 테스트 케이스가 정상적으로 실행이 되었다면 RestDocs의 내용을 기준으로 Snippet 문서가 생성되는데, Gradle 기준으로 build/generated-snippets 디렉토리 하위에 생성이 됩니다.

이 Snippet이란 문서의 조각을 의미합니다. Gradle 빌드 스크립트에서 asciidoctor로 직접 작성한 문서와 Spring MVC 테스트를 통해 자동 생성된 스니펫을 결합해서 웹 문서를 만듭니다.`

4. Snippet 종류

  • curl-request.adoc : 호출에 대한 curl 명령을 포함 하는 문서
  • httpie-request.adoc : 호출에 대한 http 명령을 포함 하는 문서
  • http-request.adoc : http 요청 정보 문서
  • http-response.adoc : http 응답 정보 문서
  • request-body.adoc : 전송된 http 요청 본문 문서
  • response-body.adoc : 반환된 http 응답 본문 문서
  • request-parameters.adoc : 호출에 parameter 에 대한 문서
  • path-parameters.adoc : http 요청시 url 에 포함되는 path - parameter 에 대한 문서
  • request-fields.adoc : http 요청 object 에 대한 문서
  • response-fields.adoc : http 응답 object 에 대한 문서

이중에서 RestDocs 문서를 만들기 위해 사용될때 가장 우선순위가 높은 문서는 (curl-request.adoc, request-parameters.adoc, path-parameters.adoc, request-fields.adoc, response-fields.adoc) 가 되는데 이 외의 스니펫 문서는 필요에 따라 선택적으로 포함 시켜 줍니다.

5. asciidoc html 파일 작성

이제 Snippet 문서를 html 문서로 생성하기 위해 src/docs/asciidoc/api-guide.adoc 문서를 작성하면 됩니다.

image

참고로 Gradle에서는 Maven이랑 다르게 src/docs/asciidoc 디렉토리 하위에 adoc 문서를 생성해야 합니다. 그렇지 않으면 에러가 발생합니다.

ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: investment
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response

[[resources]]
= Resources

[[resources-investment]]
== Investment

[[resources-investment]]
=== 정보 가져 오기
include::{snippets}/investment/http-request.adoc[]
include::{snippets}/investment/http-response.adoc[]
include::{snippets}/investment/response-fields.adoc[]
include::{snippets}/investment/request-fields.adoc[]

저 같은 경우에는 외부 API를 사용하기 위해서 정말 필요하다고 생각되는 snippet만 adoc 파일에 포함시켰습니다. include::~ 이 부분이 실제 html 태그안에 삽입하는 부분입니다.

이제 Gradle로 build 테스크를 수행하면 위 스크립트에서 작성한 static/docs/api-guide.html 파일이 생성되는 것을 확인할 수 있고, http://localhost:8080/docs/api-guide.html로 접근이 가능하면 아래와 같은 웹 문서가 렌더링 됩니다.

실행 결과

스크린샷 2021-04-12 오후 3 21 27

참조 사이트: https://jogeum.net/16, https://jaehun2841.github.io/2019/08/04/2019-08-04-spring-rest-docs/#userkt, 우아한 형제 기술 블로그

'SpringFramework > Spring Boot' 카테고리의 다른 글

Spring Boot + Spring Data Redis 연동  (0) 2022.07.10
멀티 모듈에서 QueryDSL 설정 방법  (0) 2021.05.11
HandlerMethodArgumentResolver란?  (0) 2019.12.16
인터셉터란?  (0) 2019.12.07
JWT 토큰 기반 인증  (0) 2019.12.06

멀티 모듈에서 QueryDSL 설정 방법

최근에 Spring Boot를 사용하여 멀티 모듈을 구성하였고, 하위 모듈별로 꼭 필요한 의존성만 설정하도록 했습니다.

기술 스펙은 아래와 같습니다.

  • Spring Boot 2.3
  • Gradle 6.3

1. 멀티모듈 구성 화면

image

core 모듈은 여러 하위 모듈에서 공통으로 사용하는 도메인 계층을 관리합니다.

settings.gradle

gradle이 빌드 과정에서 settings.gradle 파일을 참조하기 때문에 반드시 root 모듈과 sub 모듈을 include 해야합니다.

settings.gradle 파일

rootProject.name = 'yolo'
include 'yolo-core'
include 'yolo-api'

2. QueryDSL 설정 방법

저같은 경우에는 core 모듈에서 도메인 영역인 Entity, Repository를 가지고 있기 때문에 core 모듈에서 QueryDSL 플러그인과 의존성을 가지고 있도록 설정했습니다.

core - build.script

plugins {
    // (1) QueryDSL 플러그인 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

bootJar {
    enabled = false
}

jar {
    enabled = true
}

dependencies {
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.8'
    // (2) QueryDSL 의존성 추가
    compile('com.querydsl:querydsl-jpa') 
}

// (3) QClass 생성 위치
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

// (4) QClass 소스 위치를 지정합니다.
sourceSets {
    main.java.srcDir querydslDir
}
// (5) gradle 5.0 설정
configurations {
    querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

(1) 플러그인을 사용할 수 있도록 설정합니다.
(2) QueryDSL 의존성 라이브러리를 추가합니다.
(3) QueryDSL를 사용하기 위해서는 QClass라는 도메인의 meta model이 필요한데 QClass를 생성하고 빌드 후 generaged/querydsl 디렉토리 하위로 파일이 생성됩니다.

아래 코드는 QueryDSL 플러그인 저장소 url를 repositories에 선언하여 의존성을 받아오도록 설정한 코드입니다.

repositories {
    mavenCentral()
    maven {
        url "https://plugins.gradle.org/m2/"
    }
}

dependency 추가

만약 멀티 모듈을 사용할 경우, 저처럼 core 상위 모듈에서 QueryDSL을 사용할 경우 테스트 코드는 api 하위 모듈에서 작성하게 됩니다. 그러면 테스트 코드에서 JPAQueryFactory를 주입받아서 repository 계층에 대해서 테스트를 진행하게 되는데, 이 경우에 테스트는 문제없이 성공하지만 build를 실행할 경우 JPAQueryFactory does not exist라는 에러 문구가 나오면서 build가 실패합니다.

여기서 많은 삽질을 하였였는데, 실패 사유는 아래와 같이 build.script에서 implementation으로 선언해서 발생한 문제였습니다. implementation으로 의존성이 등록되면, 하위 프로젝트들은 해당 의존성을 가질 수 없습니다.

즉, 아래와 같이 api 하위 모듈이 core 상위 모듈을 의존하고 있다면 api는 implementation으로 선언된 com.querydsl:querydsl-jpa을 사용할 수가 없습니다. 이렇게 함으로써 멀티 모듈에서의 의존관계에 개방/폐쇄를 적용할 수가 있습니다.

core 모듈 build.script 수정 전

implementation('com.querydsl:querydsl-jpa') 

그래서 저는 아래와 같이 api 모듈에서도 사용할 수 있도록 compile로 변경했습니다.

core 모듈 build.script 수정 후

compile('com.querydsl:querydsl-jpa') 

3. QueryDSL 상세 설정

jpa

아래 설정에서 jpa는 QClass를 자동으로 생성할지 결정합니다.
기본값은 false 입니다.

만약, true일 경우 com.querydsl.apt.jpa.JPAAnnotationProcessor가 추가되면서 프로젝트에 사용됩니다.

JPAAnnotationProcessor는 meta model(QClass)를 생성합니다.

false일 경우 build 타임에 QCalss들이 생성되지 않게 됩니다.

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

querydslSourceDir

  • 어디에 QClass를 생성할지 설정합니다.
  • 기본값은 src/querydsl/java 입니다.
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

sourceSet

sourceSets {
    main.java.srcDir querydslDir
}
  • QClass가 생성된 위치를 나타냅니다.
  • 위에서 선언한 querydslSourceDir가 위치가 같아야 합니다.
  • 기본적으로 자바 소스와 리소스 파일의 그룹을 나타냅니다.
  • 기본적으로 java 플러그인은 src/main/javasrc/test/java를 기본 소스 디렉토리로 인식합니다.
  • main.java.srcDirbuild/generated/querydsl이 되게 됩니다.
  • 나중에 User라는 도메인을 생성하면 해당 위치에 QUser라는 QueryDSL용 도메인이 생성되고 사용하게 됩니다.

4. gradle 5.0 설정

configurations {
    querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

gradle 5+ 버전을 사용할 경우 위와 같은 설정 값을 추가해야 합니다.

configuration

  • QueryDSL의 컴파일된 클래스 패스 경로를 지정합니다.

compileQuerydsl

  • querydsl-apt의 annotation processor 경로를 지정합니다.
  • gradle 5 버전에서는 자체 annotation processor를 사용합니다
  • querydsl-apt의 annotation processor와 충돌

'SpringFramework > Spring Boot' 카테고리의 다른 글

Spring Boot + Spring Data Redis 연동  (0) 2022.07.10
Spring RestDocs  (0) 2021.05.11
HandlerMethodArgumentResolver란?  (0) 2019.12.16
인터셉터란?  (0) 2019.12.07
JWT 토큰 기반 인증  (0) 2019.12.06

Spring HandlerMethodArgumentResolver

대부분 웹 어플리케이션에서 사용자들이 로그인 인증 후 서버에서 세션을 생성하고 발급을 해주게 됩니다. 그때마다 컨트롤러에서 세션정보를 요구하는 특정 메소드를 수행하게 되면 아래 코드처럼 중복적인 코드를 작성해야 되는 불필요한 상황이 발생하게 됩니다.

@Controller
public class indexController{

    @GetMapping("/")
    public String index(Model model, HttpSession httpSession){
        // session 값을 추출해야 하는 코드
        User user = (User) httpSession.getAttribute("user");
        if(user != null){
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
} 

이러한 불필요한 코드를 작성하는 상황을 해결하기 위해서
HandlerMethodArgumentResolver 인터페이스에 대해서 알게 되었습니다.

HandlerMethodArgumentResolver

HandlerMethodArgumentResolver는 컨트롤러 메소드에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩 해주는 인터페이스입니다.

스프링에서는 Controller에서 @RequestBody 어노테이션을 사용해 Request의 Body 값을 받아올 때, @PathVariable 어노테이션을 사용해 Request의 Path Parameter 값을 받아올 때 이 HandlerMethodArgumentResolver를 사용해서 값을 받아옵니다.

HandlerMethodArgument 구현 예제

먼저, 어노테이이션 기반으로 세션 객체를 바인딩할 수 있도록 @LoginUser 클래스 어노테이션을 작성합니다.

1. 어노테이션 작성

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {


}

@interface

  • 이 파일을 어노테이션 클래스로 지정합니다.
  • LoginUser라는 이름을 가진 어노테이션이 생성되었다고 보면 됩니다.

@Target(ElementType.PARAMETER)

  • 이 어노테이션이 생성될 수 있는 위치를 지정합니다.
  • PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있습니다.

@Retention(RetentionPolicy.RUNTIME)

  • 어노테이션 유지 정책을 설정합니다. RUNTIME은 바이트 코드 파일까지 어노테이션 정보를 유지하면서 리플렉션을 이용해서 런타임 시에 해당 어노테이션의 정보를 얻을 수 있습니다.

Customizing 한 Session 객체 작성

import com.junyoung.book.springboot.domain.user.User;
import lombok.Getter;
import java.io.Serializable;

@Getter
public class SessionUser implements Serializable {

    private String name;
    private String email;
    private String picture;


    public SessionUser(User user){
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}

도메인 모델을 직접 세션에 저장하면 다른 엔티티와 연관관계를 가질 때 대상에 자식들이 포함되어 성능이슈, 부수 효과가 발생할 확률이 있어 Dto로 직렬화를 구현해서 세션에 저장하는게 좋습니다.

resolver 작성

이제 가장 중요한 HandlerMethodArgumentResolver를 상속받은 LoginUserArgumentResolver를 작성합니다.

HandlerMethodArgumentResolver를 상속받은 객체는 아래 두개의 메소드를 구현해야 합니다.

public boolean supportsParameter(MethodParameter parameter);

public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception;
  • supportsParameter 메소드는 파라미터가 Resolver에 의해 수행될 수 있는 타입인지 true / false를 리턴합니다. 만약 true를 리턴한다면 resolveArgument() 메소드를 실행합니다.

  • resolveArgument 메소드는 실제로 파라미터와 바인딩을 할 객체를 리턴합니다.
    NativeWebRequest를 통해 클라이언트 요청이 담긴 파라미터를 컨트롤러보다 먼저 받아서 작업을 수행할 수 있습니다.

import com.junyoung.book.springboot.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        boolean isLoginUserAnnotation =  parameter.getParameterAnnotation(LoginUser.class) != null;

        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}

LoginUserArgumentResolver의 supportsParameter(MethodParameter parameter) 메소드는 컨트롤러의 메소드의 파라미터를 가져와서 LoginUser.class 어노테이션이 적용 유무와, 실제로 해당 세션 객체의 타입이 SessionUser.class 인지 검증 후 둘다 조건이 맞다면 true를 리턴하여 resolveArgument() 메소드를 수행하도록 작성하였습니다.

resolver 등록

마지막으로 작성한 LoginUserArgumentResolver를 스프링에 등록합니다.

import com.junyoung.book.springboot.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;


@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;


    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}

spring Boot에서 WebMvcConfigurer는 자동 구성된 Spring MVC 구성을 큰 변경없이 추가적인 조작을 하기 위해서 구현합니다. 이를 통해서 자동 구성된 Spring MVC를 개발자가 완벽히 제어할 수 있습니다.

컨트롤러 작성

import com.junyoung.book.springboot.config.auth.LoginUser;
import com.junyoung.book.springboot.config.auth.dto.SessionUser;
import com.junyoung.book.springboot.service.PostsService;
import com.junyoung.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import javax.servlet.http.HttpSession;

@Controller
@RequiredArgsConstructor
public class IndexController {

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user){

        if(user != null){
            model.addAttribute("userName", user.getName());
        }

        return "index";
    }
}

@LoginUser SessionUser user를 세션정보가 필요한 컨트롤러의 메소드 파라미터로 넣어주면서 기존에 (User) HttpSession.getAttribute("user")로 가져오던 ㅔ쎤 정보 값이 개선 되었습니다.

이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보를 가져올 수 있게 되었습니다.

'SpringFramework > Spring Boot' 카테고리의 다른 글

Spring RestDocs  (0) 2021.05.11
멀티 모듈에서 QueryDSL 설정 방법  (0) 2021.05.11
인터셉터란?  (0) 2019.12.07
JWT 토큰 기반 인증  (0) 2019.12.06
스프링 시큐리티 개념  (0) 2019.11.16

인터셉터란?

Interceptor는 가로채는 것(요격기)을 의미합니다. 클라이언트(브라우저)에서 요청 Url을 Controller라는 표현계층에 전송을 하면 해당 요청 Url과 매핑되는 메소드를 실행되기 전 후에 어떤 작업을 수행하기 위해서 Url을 가로채는 주체라고 생각하면 됩니다.

대표적으로 Url Mapping 된 Controller를 거치는 전, 후 처리를 할 수 있도록 도와주는 요소를 말합니다. 주로 세션 검증, 로그 처리 같은 행위가 간단한 예시 입니다.

Interceptor는 Spring-WebMvc에 포함되어 있습니다. Spring Boot에서 gradle을 빌더로 사용할 때 build.gradle 파일에 dependency에 spring-boot-starter-web을 가져옴으로 해결할 수 있습니다.

dependencies{
  implementation'org.springframework.boot:spring-boot-starter-web'
}

Intercpetor 예제 코드

기본 인터페이스는 HandlerInterceptor 이고, 이를 구현해서 Interceptor class를 작성해도 되고, HandlerInterceptorAdapter를 구현할 수도 있습니다. 하지만 Java 1.8 이상부터는 HandlerInterceptor의 메소드가 default로 선언되어 있기 때문에 필요한 메소드만 선택해서 구현할 수 있기 때문에 굳이 HandlerInterceptorAdapter를 이용하지 않아도 됩니다.

@Component
public class HttpInterceptor extends HandlerInterceptor {

    private static final Logger logger = Logger.getLogger(HttpInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        logger.info("================ Before Method");
        return true;
    }

    @Override
    public void postHandle( HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler,
                            ModelAndView modelAndView) {
        logger.info("================ Method Executed");
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response, 
                                Object handler, 
                                Exception ex) {
        logger.info("================ Method Completed");
    }
}
  • preHandle(): Controller의 메소드를 실행하기 전에 처리해주는 메소드입니다.
    주로 세션 검증에서 많이 사용합니다.

  • postHandle(): Controller의 메소드를 실행 후에 처리를 해주는 메소드입니다.
    세션 검증 후 로그인 인증이 되어있다면 해당 사용자 정보를 세션에 저장하는 로직을 넣을 수도 있습니다. 또한 view에 전달할 ModelAndView 객체를 이용해서 특정 작업을 수행도 할 수 있습니다.

  • afterCompletion(): Controller의 메소드를 실행 후 view를 클라이언트에게 전송 후에 처리를 해주는 메소드입니다.

Config

Interceptor를 등록하기 위해서 WebMvcConfigurer를 이용합니다. Interceptor를 등록한 후 적용할 경로, 제외할 경로를 지정해줄 수 있습니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    //HandlerInterceptor를 구현한 개발자가 작성한 Intercepotor 클래스
    private final JwtInterceptor jwtInterceptor;

    private static final String[] EXCLUDE_PATHS = {
            "/member/**",
            "/error/**"
    };

    // 개발자가 구현한 Interceptor를 regisry 객체를 통해서 Interceptor 추가
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(EXCLUDE_PATHS);
    }
}

Spring Boot에서 WebMvcConfigurer는 자동 구성된 Spring MVC 구성을 큰 변경없이 추가적인 조작을 하기 위해서 구현합니다. 이를 통해서 자동 구성된 Spring MVC를 개발자가 완벽히 제어할 수 있습니다.

boot 2.0 + Java 1.8 + spring 5.0 이상의 버전을 사용하면서 WebMvcConfigurer의 대두분 메서드에 default로 선언 되었습니다. 덕분에 모든 메서드를 구현해야하는 강제력이 사라졌습니다. 필요한 메서드만 선택해서 오버라이드하면 됩니다.

Controller

@Slf4j
@Controller
public class HelloController {

    @RequestMapping("/memberInfo")
    public void memberInfo(Model model){
        log.info("postHandle() 메소드 수행 전");
        String id = "sa1341";
        model.addAttribute("userName", id);
    }
}

실행 결과

스크린샷 2019-12-07 오후 9 50 19

JWT 토큰을 사용자에게 발급하여 이 토큰을 통해서 사용자의 권한 및 API 호출을 제어할 수 있고 서버에서 세션을 관리할 필요가 없기 때문에 확장성 측면에서 많은 이점을 누릴 수 있기 때문에 JWT를 사용하기전에 토큰 기반의 인증 방식에 대한 내용과 JWT 기본 개념에 대해서 포스팅을 하였습니다.

토큰(Tocken)기반 인증에 대한 소개

토큰(Tocken)기반 인증은 모던 웹서비스에서 정말 많이 사용되고 있습니다. 웹 서비스를 개발한다면 토큰을 사용하여 유저들의 인증작업을 처리하는것이 가장 좋다고 합니다.

토큰은 Stateless 서버입니다. Stateful 서버와 다르게 세션 값으로 클라이언트 상태 유지를 하지 않습니다. 상태정보를 저장하지 않으면, 서버는 클라이언트 측에서 들어오는 요청으로만 작업을 처리합니다. 이렇게 상태가 없는 경우 클라이언트와 서버의 연결고리가 없기 때문에 서버의 확장성(Scalaability)이 높아집니다.

모바일 어플리케이션에 적합합니다.

만약에 Android/iOS 모바일 어플리케이션을 개발 한다면, 안전한 API를 만들기 위해선 쿠키같은 인증시스템은 이상적이지 않습니다. 토큰 기반 인증을 도입한다면, 더욱 간단하게 이 번거로움을 해결 할 수 있습니다.

인증 정보를 다른 어플리케이션으로 전달

대표적인 예로 OAuth가 있습니다. 페이스북/구글 같은 소셜 계정들을 이용하여 다른 웹 서비스에서도 로그인 할 수 있게 할 수 있습니다.

보안

토큰 기반 인증 시스템을 사용하여 어플리케이션의 보안을 높일 수 있습니다. 단, 이 토큰 기반 인증을 사용한다고 해서 무조건 해킹의 위험에서 벗어나는건 아닙니다.

토큰 기반 인증 시스템을 사용하는 이유

토큰 기반 인증 시스템이 어떻게 동작하고, 또 이로 인하여 얻을 수 있는 이득에 대하여 알아보기전에, 이 토큰 기반 인증 시스템이 나온 이유에 대해서 살펴보는게 가장 이해가 빠를거 같습니다.

스크린샷 2019-12-05 오후 7 38 57

서버 기반 인증

과거 인증 시스템은 서버측에서 유저들의 정보를 기억하고 있어야 합니다. 이 세션을 유지하기 위해서는 여러 가지 방법이 사용됩니다. 메모리 / 디스크 / 데이터베이스 시스템에 이를 담곤 했습니다.

서버 기반 인증 시스템 흐름

Untitled Diagram

하지만 이 방식은 서버를 확장하기가 어려워졌습니다.

서버 기반 인증의 문제점

세션

유저가 인증을 할 때, 서버는 이 기록을 서버에 저장을 해야합니다. 이를 세션이라고 부릅니다. 대부분의 경우엔 메모리에 이를 저장하는데, 로그인 중인 유저의 수가 늘어난다면 서버의 램에 많은 과부화가 걸리게됩니다. 이를 피하기 위해서는 세션을 데이터베이스 시스템에 저장하는 방식도 있지만, 이 또한 유저의 수가 많으면 데이터베이스의 성능에 무리를 줄 수 있습니다.

확장성

세션을 사용하면 서버를 확장하는 것이 어려워집니다. 여기서 서버의 확장이란, 단순히 서버의 사양을 업그레이드 하는것이 아니라, 더 많은 트래픽을 감당하기 위하여 여러개의 프로세스를 돌리거나, 여러대의 서버 컴퓨터를 추가 하는것을 의미합니다. 세션을 사용하려면 분산된 시스템을 설계하는건 불가능한것은 아니지만 과정이 매우 복잡해집니다.

토큰 기반 시스템의 작동 원리

토큰 기반 시스템은 Stateless 합니다. 즉 상태유지를 하지 않습니다. 이 시스템에서는 더 이상 유저의 인증 정보를 서버나 세션에 담아두지 않습니다. 이 개념 하나만으로도 위에서 서술한 서버에서 유저의 인증 정보를 서버측에 담아둠으로서 발생하는 많은 문제점들이 해소됩니다.

세션이 존재하지 않으니, 유저들이 로그인 되어있는지 안되어있는지 신경을 1도 쓰지 않기 때문에 서버를 손쉽게 확장 할 수 있습니다.

토큰 기반 시스템의 구현 방식은 시스템마다 크고 작은 차이가 있겠지만, 대략적으로 아래 절차와 같습니다.

  1. 유저가 아이디와 비밀번호를 입력하여 로그인을 합니다.

  2. 서버측에서 해당 계정정보를 검증합니다.

  3. 계정정보가 정확하다면, 서버측에서 유저에게 signed 토큰을 발급해줍니다.

    signed의 의미는 해당 토큰이 서버에서 정상적으로 발급된 토큰임을 증명하는 signature를 지니고 있다는 것을 의미합니다.

  4. 클라이언트 측에서 전달받은 토큰을 저장해두고, 서버에 요청을 할 때 마다, 해당 토큰을 함께 서버에 전달합니다.

  5. 서버는 토큰을 검증하고, 요청에 응답합니다.

토큰 기반 시스템 처리 과정

Untitled Diagram (1)

웹서버에서 토큰을 서버에 전달 할 때에는, HTTP Request 헤더에 토큰값을 포함시켜서 전달합니다.

토큰의 장점

무상태(stateless)이며 확장성(scalability)입니다.

이 개념은 토큰 기반 인증 시스템의 중요한 속성입니다. 토큰은 클라이언트 사이드에 저장하기 때문에 완전히 stateless하며, 서버를 확장하기에 매우 적합한 환경을 제공합니다. 만약에 세션을 서버측에 저장하고 있고, 서버를 여러대를 사용하여 요청을 분산하였다면, 어떤 유저가 로그인 했을땐, 그 유저는 처음 로그인했었던 해당 서버에만 요청을 보내도록 설정을 해야합니다. 하지만 토큰을 사용한다면 어떤 서버로 요청이 들어가던 상관이 없습니다.

보안성

클라이언트가 서버에 요청을 보낼 때, 더 이상 쿠키를 전달하지 않음으로 쿠키를 사용함으로 인해 발생하는 취약점이 사라집니다. 토큰을 사용하는 환경에서도 취약점이 존재할 수있으니 언제나 취약점에 대비해야합니다.

확장성

토큰을 사용하여 다른 서비스에서도 권한을 공유 할 수 있습니다. 예를 들어서, 스타트업 구인구직 웹서비스인 로켓펀치에서는 Facebook, LinkedIn, GitHub, Google 계정으로 로그인을 할 수 있습니다. 토큰 기반 시스템에서는, 토큰에 선택적인 권한만 부여하여 발급을 할 수 있습니다. (예를들어서 로켓펀치에서 페이스북 계정으로 로그인을 했다면, 프로필 정보를 가져오는 권한은 있어도, 포스트를 작성 할 수 있는 권한은 없습니다.)

여러 플랫폼 및 도메인

서버 기반 인증 시스템의 문제점을 다룰때 어플리케이션과 서비스 규모가 커지면, 우리는 여러 디바이스를 호환 시키고, 더 많은 종류의 서비스를 제공하게 됩니다. 토큰을 사용한다면, 그 어떤 디바이스나 도메인에서도 토큰만 유효하다면 요청이 정상적으로 처리됩니다. 서버측에서 어플리케이션의 응답 부분에 다음 헤더만 포함시켜주면 됩니다.

Access-Control-Allow-Origin: *

이런 구조라면, assets 파일들(이미지, css, js, html 파일 등)은 모두 CDN에서 제공을 하도록 하고, 서버측에서는 오직 API만 다루도록 하도록 설계 할 수도 있습니다.

JWT는 웹 표준 RFC 7519에 등록이 되어있습니다. 따라서 여러 환경에서 지원이 되며 수많은 회사의 인프라스트럭쳐에서 사용 되고 있습니다.

JWT(JSON Web Token)을 이용한 API 인증

JWT는 Claim 기반의 토큰입니다. Claim이라는 사용자에 대한 프로퍼티나 속성을 이야기 합니다. 토큰자체가 정보를 가지고 있는 방식인데, JWT는 이 Claim을 JSON을 이용해서 정의합니다. 다음은 Claim을 JSON으로 서술한 예입니다. JSON 자체를 토큰으로 사용하는 것이 Claim 기반의 토큰 방식입니다.

ex) Claim 기반의 토큰 정보

{
    "id":"junyoung",
    "role":["admin", "user"],
    "company":"pepsi"
}

이러한 Claim 방식의 토큰의 장점은 토큰을 이용해서 요청을 받는 서버나 서비스 입장에서는 이 서비스를 호출한 사용자에 대한 추가 정보는 이미 토큰안에 다 들어가 있기 때문에 다른 곳에서 가져올 필요가 없다는 것입니다.

"사용자 관리" 라는 API 서비스가 있다고 가정합니다.
아 API는 관리자(admin) 권한을 가지고 있는 사용자만이 접근이 가능하며, "관리자" 권한을 가지고 있는 사용자는 그 관리자가 속해 있는 회사(company)의 사용자 정보만 관리할 수 있다고 정의해봅시다. 이 시나리오에 대해서 일반적인 스트링 기반의 토큰과 JWT와 같은 Claim 기반의 토큰이 어떤 차이를 가질 수 있는지 알아보겠습니다.

OAuth 토큰의 경우

Untitled Diagram (2)

  1. API 클라이언트가 Authorization Server(토큰 발급서버)로 토큰을 요청합니다.
    이때, 토큰 발급을 요청하는 사용자의 계정과 비밀번호를 넘기고, 이와 함께 토큰의 권한(용도)를 요청합니다. 여기서는 일반 사용자 권한(basic)과 관리자 권한(admin)을 같이 요청하였습니다.

  2. 토큰 생성 요청을 받은 Authorization Server는 사용자 계정을 확인한 후, 이 사용자에게 요청된 권한을 부여해도 되는지 계정 시스템등에 물어본 후, 사용자에게 해당 토큰을 발급이 가능하면 토큰을 발급하고, 토큰에 대한 정보를 내부(토큰 저장소)에 저장해놓습니다.

  3. 이렇게 생성된 토큰은 API 클라이언트로 저장됩니다.

  4. API 클라이언트는 API를 호출할때 이 토큰을 이용해서 Resource Server(API 서버)에 있는 API를 호출합니다.

  5. 이때 호출되는 API는 관리자 권한을 가지고 있어야 사용할 수 있기 때문에, Resource Server가 토큰 저장소에서 토큰에 관련된 사용자 계정, 권한 등의 정보를 가지고 옵니다. 이 토큰에 (관리자)admin 권한이 부여되어 있기 때문에, API 호출을 허용합니다. 위에 정의한 시나리오에서는 그 사용자가 속한 “회사”의 사용자 정보만 조회할 수 있습니다. 라는 전제 조건을 가지고 있기 때문에, API 서버는 추가로 사용자 데이타 베이스에서 이 사용자가 속한 “회사” 정보를 찾아와야합니다.

  6. API서버는 응답을 보낸다.

JWT와 같은 Claim 기반의 토큰 흐름

Untitled Diagram

  1. 토큰을 생성 요청하는 방식은 동일합니다. 마찬가지로 사용자를 인증한다음에, 토큰을 생성합니다.

  2. 다른 점은 생성된 토큰에 관련된 정보를 별도로 저장하지 않는다는 것입니다. 토큰에 연관되는 사용자 정보나 권한등을 토큰 자체에 넣어서 저장합니다.

  3. API를 호출하는 방식도 동일합니다.

  4. Resource Server (API 서버)는 토큰 내에 들어 있는 사용자 정보를 가지고 권한 인가 처리를 하고 결과를 리턴합니다.

결과적으로 차이점은 토큰을 생성하는 단계에서는 생성된 토큰을 별도로 서버에서 유지할 필요가 없으며 토큰을 사용하는 API 서버 입장에서는 API 요청을 검증하기 위해서 토큰을 가지고 사용자 정보를 별도로 계정 시스템 등에서 조회할 필요가 없다는 것입니다.

JWT에 대한 소개

Claim 기반의 토큰에 대한 개념을 대략적으로 이해했다면, 그러면 실제로 JWT가 어떻게 구성되는지에 대해서 살펴보겠습니다.

Claim (메시지) 정의
JWT는 Claim을 JSON 형태로 표현하는 것인데, JSON은 "\n" 등 개행문자가 있기 때문에, REST API 호출 시 HTTP Header등에 넣기가 매우 불편합니다. 그래서 이 Claim JSON 문자열을 BASE64 인코딩을 통해서 하나의 문자열로 변환합니다.

{
    "id":"junyoung",
    "role":["admin", "user"],
    "company":"pepsi"
}

문자열을 BASE64로 인코딩 한 결과

ew0KICAiaWQiOiJ0ZXJyeSINCiAgLCJyb2xlIjpbImFkbWluIiwidXNlciJdDQogICwiY29tcGFueSI6InBlcHNpIg0KfQ0K

BASE64 인코딩이란?

  • 2진 데이터를 ASCII 형태의 텍스트로 표현 가능
  • 웹 인증 중 기본인증에 사용
  • 끝 부분의 Padding(==)으로 식별 가능
  • 64개의 문자 사용 (영문 대, 소문자, 숫자 , + , / )
  • 데이터를 6bit 단위로 표현

변조 방지

위의 Claim 기반의 토큰을 봤다면, 첫번째 들 수 있는 의문이 토큰을 받은 다음에 누군가 토큰을 변조해서 사용한다면 어떻게 막느냐 입니다. 이렇게 메시지가 변조 되지 않았음을 증명하는 것을 무결성(Integrity)라고 하는데, 무결성을 보장하는 방법 중 많이 사용되는 방법이 서명(Signature)이나 HMAC 사용하는 방식입니다. 즉 원본 메세지에서 해쉬값을 추출한 후, 이를 비밀 키를 이용해서 복호화 시켜서 토큰의 뒤에 붙입니다. 이게 HMAC 방식인데, 누군가 이 메세지를 변조했다면, 변조된 메시지에서 생성한 해쉬값과 토큰뒤에 붙어있는 해쉬값이 다르기 때문에 메세지가 변조되었음을 알 수 있습니다. 다른 누군가가 메세지를 변조한 후에, 새롭게 HMAC 값을 만들어내려고 하더라도, HMAC은 앞의 비밀키를 이용해서 복호화 되었기 때문에, 이 비밀키를 알 수 없는 이상 HMAC를 만들어 낼 수 없습니다.

앞의 JSON 메세지에 대해서 SHA-256이라는 알고리즘을 이용해서 비밀키를 “secret” 이라고 하고, HMAC을 생성하면 결과는 다음과 같습니다.

i22mRxfSB5gt0rLbtrogxbKj5aZmpYh7lA82HO1Di0E

JWT의 기본 구조

Header . Payload . Signature

Header

JWT 웹 토큰의 헤더 정보

  • typ: 토큰의 타입, JWT만 존재
  • alg: 해싱 알고리즘.(HMAC SHA256 or RSA) 헤더를 암호화 하는게 아닙니다. 토큰 검증시 사용합니다.
{
    "alg" : "HS256",
    "typ" : "JWT"
}

위의 내용을 BASE64로 인코딩합니다. => eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
BASE64는 암호화된 문자열이 아닙니다. 같은 무자열에 대해서는 항상 같은 인코딩 문자열을 반환합니다.

Payload

실제 토큰으로 사용하려는 데이터가 담기는 부분. 각 데이터를 Claim이라고 하며 다음과 같이 3가지 종류가 있습니다.

  • Reserved claims: 이미 예약된 Claim. 필수는 아니지만 사용하길 권장합니다. key는 모두 3자리 String입니다.

    • iss(String): issuer, 토큰 발행자 정보

    • exp(Number): expiration time, 먼료일

    • sub(String): subject, 제목

    • and(String): audience

  • Public claims: 사용자 정의 Claim
    • Public이라는 이름처럼 공개용 정보
    • 충돌 방지를 위해 URI 포맷을 이용해 저장합니다.
  • Private Claims: 사용자 정의 Claim

    • Public claims과 다르게 사용자가 임의로 정한 정보

    • 아래와 같이 일반 정보를 저장합니다.

        {
            "name" : "hak",
            "age"  : 26
        }

Signature

Header와 Payload의 데이터 무결성과 변조 방지를 위한 서명
Header + Payload를 합친 후, Secret 키와 함께 Header의 해싱 알고리즘으로 인코딩

HMACSHA256( 
    base64UrlEncode(header) + "." + 
    base64UrlEncode(payload), 
    secret)

JWT 구조

JWT는 [Header Payload Signature] 각각 JSON 형태의 데이터를 base 64 인코딩 후 합칩니다.
아래와 같은 순서로 . 을 이용해 합칩니다.
최종적으로 만들어진 토큰은 HTTP 통신 간 이용되며, Authorization 이라는 key의 value로서 사용됩니다.

250AC0505861FCE02E

JWT 인증 과정

2268544E5861FD0F13

JWT의 단점 & 도입시 고려사항

  • Self-contained: 토큰 자체에 정보가 있다는 사실은 양날의 검이 될수 있습니다.

    • 토큰 길이: 토큰 자체 payload에 Claim set을 저장하기 때문에 정보가 많아질수록 토큰의 길이가 늘어나 네트워크에 부하를 줄 수있습니다.

    • payload 암호화: payload 자체는 암호화 되지 않고 base64로 인코딩한 데이터입니다. 중간에 payload를 탈취하면 디코딩을 통해 데이터를 볼 수 있습니다.
      JWE를 통해 암호화하거나, payload에 중요 데이터를 넣지 않아야 합니다.

    • Stateless: 무상태성이 때론 불편할 수 있습니다. 토큰은 한번 만들면 서버에서 제어가 불가능합니다. 토큰을 임의로 삭제할 수 있는 방법이 없기 때문에 토큰 만료시간을 꼭 넣어주는게 좋습니다.

    • tore Token: 토큰은 클라이언트 side에서 관리해야하기 때문에 토큰을 저장해야 합니다.

참고 사이트 : https://velopert.com/2350, https://bcho.tistory.com/999, https://sanghaklee.tistory.com/47

+ Recent posts