최근 몇년간 대부분에 책들은 종이책보다는 전자책으로 구매하고 있는데요.

종이책 보다는 전자책과 강의 비중이 늘어난 것 같습니다.

종이책은 보관도 마땅하지 않기때문에 왠만하면 사지 않는 편입니다.

서론은 줄이고 코딩 샘플이나 토이 프로젝트 외에도 코딩 테스트의 준비도 어느정도 필요할 것 같아서

프로그래머스 같은 사이트를 이용하다가 조금 체계적으로 다시 정리가 필요할 것 같아 책을 찾게되어

소개해봅니다.

일단 국내에서 코딩테스트 유형 별로 가장 잘 정리가 잘된 책이 아닌가 싶습니다.

파이썬 위주로 예제나 설명이 진행되지만 github에 가면 Java, C++ 코드도 있다.

현재 한달정도 다른 일들과 병행하면 러프하게 한바퀴 읽어보았고

마지막 파트의 유형별 기출문제를 풀어보고 있습니다.

쉽게 쉽게 풀리는 것도 있고 아직 유형에 익숙하지 않은 문제들은 어려움이 있는 편입니다.

전부는 아니지만 풀었던 문제들에 대해 중간중간 정리해서 글을 작성할 예정입니다.

저자가 운영하고 있는 github 주소는 아래 주소이며, 해당 책에서 소개하는 예제 들에 대해 살펴볼수있습니다.

https://github.com/ndb796/python-for-coding-test

이번 글에서는 Spring InterCeptor에 대해 Demo를 제작하면서 알아보겠습니다.

모든 소스는 Github에서 확인가능합니다.

Demo의 내용은 Interceptor를 통해 Request를 가로채서 URI 패턴에 따른 응답 처리를 해보겠습니다.

소스 설명에 들어가기에 앞서 간단하게 Spring MVC Request life cycle에 대해 이미지로 확인을 해보면

사용자(Clinet)가 요청이 어떤 단계를 거치게 되는지 알 수가 있습니다.

 

 

여기서 눈여겨볼 부분은 HandlerInterceptor 부분인데 이 부분에 대해 실제 구현을 한다고 보면 됩니다.

구조를 보고 Filter 영역에서 처리하면 되는 것이 아닌지에 대해 의문을 갖는 분이 계실 것 같은데

차이점을 설명하자면 실행되는 시점이 다르기때문에 예외 처리에 대한 부분이 다릅니다.

Filter 같은 경우 예외가 발생하면 예외 처리 페이지로 보낸다던지 직접 처리가 어려운 반면

interceptor의 경우에는 예외에 대해 세부적으로 직접 핸들링이 가능합니다.

 

※ 개발환경 :  JDK 11, STS 4.8.1,  Spring Boot 2.6.3, Maven 4

 

1. Demo 프로젝트 생성

프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.

의존성 설정은 아래의 pom.xml 파일을 참고해주세요.

 

2. Demo 프로젝트 구조

아래 이미지와 같은 구조를 가지게 됩니다.

3.  pom.xml 파일 설정

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.3</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>SpringbootHttpInterceptor</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>SpringbootHttpInterceptor</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

 

4. HttpInterceptor 설정 및 추가

4.1  HttpInterceptor 설정 

src/main/java > com.example.demo.config 패키지를 생성하고 HttpInterceptorConfig 클래스를 작성합니다.

package com.example.demo.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class HttpInterceptorConfig implements WebMvcConfigurer {

	@Autowired
	@Qualifier("httpInterceptor")
	private HandlerInterceptor handlerInterceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(handlerInterceptor)
		.addPathPatterns("/demo/user/**")
		.addPathPatterns("/demo/visitor")
		.excludePathPatterns("/demo/product/**");
		
	}
	
}

 

  • addInterceptors 메서드
    사용할 handlerInterceptor를 추가하고, addPathPatterns을 사용하여 가로챌 URI 패턴에 대해 정의한다.
    excludePathPatrrerns를 사용해서 가로채지 않을 URI 패턴에 대해 정의한다.

 

4.2 HttpInterceptor 

src/main/java > com.example.demo.common 패키지를 생성하고

HandlerIntercpetor를 구현하는 클래스를 작성합니다.

package com.example.demo.common;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import com.fasterxml.jackson.databind.ObjectMapper;

@Component
public class HttpInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		
		System.out.println("[preHandle]");
		
		try {
		 	String reqUri= request.getRequestURI();
		 	if (reqUri.startsWith("/demo/user/")) {
		 		throw new Exception("사용이 중지된 API 입니다.");
		 	}
		 	
		} catch(Exception e) {
		 	String result = getResponse(e.getMessage(), HttpStatus.OK);
			response.setCharacterEncoding("UTF-8");
			response.setContentType("application/json;charset=utf-8");
			response.getWriter().write(result);
			return false;
		} 
		return true;
	}
	
	@Override
	public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
		
		System.out.println("[postHandle]");
		
		HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
	}
	
	@Override
	public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
			throws Exception {
		System.out.println("[afterCompletion]");
		HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
	}
	
	
	private String getResponse(String msg, HttpStatus httpStatus) throws Exception {
	 	ResponseEntity<?> responseEntity = new ResponseEntity<>(msg, httpStatus);
	 	ObjectMapper objectMapper = new ObjectMapper();
		return objectMapper.writeValueAsString(responseEntity);
	}
	
}
  • preHandle 메서드
    Handler를 실행하기 전 실행되는 메서드.
    특정 URI 패턴을 확인하여 컨트롤러 상에 작성된 Request URI와 맵핑되기 전에
    요청을 가로채서 처리합니다.
  • postHandle 메서드
    Handler 실행 후 실행되는 메서드.
  • afterComletion 메서드
    View를 렌더링한 후에 실행되는 메서드.

 

5. DemoController 

src/main/java > com.example.demo.controller 패키지를 생성하고 DemoController 클래스를 작성합니다.

package com.example.demo.controller;

import java.util.HashMap;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DemoController {
	
	   @GetMapping("/demo/user/{id}")
	   public ResponseEntity<?> getUserInfoById(@PathVariable("id") String id){
		   HashMap<String, String> map = new HashMap<>();
		   map.put("id", id);
		   map.put("name", "jack");

	       return new ResponseEntity<>(map, HttpStatus.OK);
	   }
	   
	   @GetMapping("/demo/user/{id}/phone")
	   public ResponseEntity<?> getUserPhoneInfo(@PathVariable("id") String id){
		   HashMap<String, String> map = new HashMap<>();
		   map.put("id", id);
		   map.put("phone", "010-1234-1234");

	       return new ResponseEntity<>(map, HttpStatus.OK);
	   }
	   
	   @GetMapping("/demo/product/{id}")
	   public ResponseEntity<?> getUserProductInfo(@PathVariable("id") String id){
		   HashMap<String, String> map = new HashMap<>();
		   map.put("id", id);
		   map.put("name", "ABC");
		   map.put("price", "1000");

	       return new ResponseEntity<>(map, HttpStatus.OK);
	   }
	   
	   @GetMapping("/demo/visitor")
	   public ResponseEntity<?> getSitemap(){
		   HashMap<String, String> map = new HashMap<>();
		   map.put("vistor", "100");
		   
	       return new ResponseEntity<>(map, HttpStatus.OK);
	   }
}

데모 테스트를 위한 API로 서비스, DAO 레이어는 생략했습니다.

  • /demo/user/{id}
    유저 정보를 조회하여 반환하는 API. 
    Interceptor 설정을 통해 등록된 URI 패턴입니다.
  • /demo/user/{id}/phone
    유저 전화번호를 조회하여 반환하는 API. 
    Interceptor 설정을 통해 등록된 URI 패턴입니다.

  • /demo/product/{id}
    상품 정보를 조회하여 반환하는 API.
    Interceptor 설정을 통해 제외 등록된 URI 패턴입니다.

  • /demo/visitor
    방문자 수를 조회하여 반환하는 API.
    Interceptor 설정을 통해 등록된 URI 패턴입니다.

6. 테스트

Spring Boot Dashboard에서 start 합니다.

POST MAN 같은 API 툴을 사용하여 API를 호출합니다.

 

/demo/visitor 호출 결과입니다.

정상적으로 응답이 오고 Interceptor에 모든 메서드에 로그가 출력된 것을 확인 가능합니다.

post man 결과
콘솔 창

/demo/user/{id}, /demo/user/{id}/phone 호출 결과입니다.

/demo/user/** 으로 설정했기 때문에 /demo/user/ 으로 시작하는 패턴은 모두 가로채 집니다.

post man 결과
콘솔 창

/demo/product/{id} 의 호출 결과입니다.

콘솔 창에는 아무것도 출력되지 않습니다. 가로채기가 제외된 URI 패턴이기 때문입니다.

 

7. 마치며

간단한 데모를 통하여 Interceptor에 대한 설정과 사용법에 대해 알아봤습니다.

해당 모듈은 공통 처리가 필요한 경우 아주 적절하다고 봅니다.

예를 들어 일부를 제외한 모든 API에 인증 토큰을 검사해야 한다고 한다면 공통 처리하지 않는 경우

해당되는 API에 어떤 식으로든 토큰 검사하는 모듈이 작성되어야 합니다.

만약 수백 개에 이른다고 하면 인터셉터에서 URI 패턴과 preHandler 메서드를 사용하여

비교적 쉽고 빠른 시간 안에 처리 가능하며, 추후에 유지보수도 용이합니다.

 

※ 참고

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-handlermapping-interceptor
https://meetup.toast.com/posts/151

'Spring Boot Framework' 카테고리의 다른 글

Spring RestTemplate (1)  (0) 2022.05.23
Spring Rest Docs 와 Swagger (2)  (0) 2022.02.12
Spring Rest Docs 와 Swagger (1)  (0) 2022.02.11
Spring Boot Oauth 2.0 과 JWT Demo (5)  (0) 2022.02.08
Spring Boot Oauth 2.0 과 JWT Demo (4)  (0) 2022.02.06

기억에 남는 인증서 유효성 에러를 정리해보려고 합니다.

오류가 발생했다는 보고를 받고 디버깅을 해보니 해당 에러가 발생합니다.

 

1.  내용

pkix apth building failed ... unable to find valid certfication path to requested target

 

2. 원인

인증서 유효성 에러인데 여러 가지 원인이 있을 수 있어서 다양한 방식으로 접근해봤습니다.

 

2.1 인증서 유효성 확인

사용하는 API 테스트 툴에는 POST MAN 테스트 툴 세팅 옵션에 SSL Certificate verification 있는데

이 옵션을 활성화하면 유효하지 않은 인증서의 경우 통신이 되지 않습니다.

어느 정도까지 체크가 가능하냐면 중개 인증서를 잘못 설정한 경우까지도 잡아내는 것을 확인했습니다.

그런데 해당 옵션으로 체크하고 검증해봐도 문제가 없었습니다.

 

2.2 open ssl 명령어로 확인

테스트 서버 상에서 open ssl 명령어를 통해서 체크를 해봐도 verification code가 정상으로 확인됩니다.

인증서 자체는 유효한 것으로 보이는데 어떤 과정에서 에러가 발생했을지 생각을 해봅니다.

 

2.3 유효한 인증서 목록 확인

브라우저 or 서버 든 유효한 CA 목록을 가지고 있습니다.

SSL Handshake 과정에서 인증서의 CA는 유효한지 체크를 하는데요.

만약 어떤 식으로 업데이트 환경이 아닌 경우 예를 들어서 브라우저는 업데이트되면서

목록이 갱신되겠지만 가끔씩 내부망 환경을 가진 클라이언트 환경에서 업데이트되지 않는 케이스를 확인했습니다.

그러면 서버 to 서버 통신 환경일 경우 당연히 서버상의 유효한 인증서 목록에 없다면 에러가 발생할 수밖에 없습니다.

keytool 유효한 인증서 목록을 확인해보니 해당 서버에서 보내는 인증서의 CA가 목록에 없습니다.

여기서 에러가 발생하게 됩니다.

여담으로 아주 드문 경우이기는 하지만 CA 목록에서 사고를 쳐서 제외되는 경우도 있습니다. 

 

2.4 해결 

2.4.1 인증서 목록에 수동 추가

가장 간단한 방법은 인증서 목록에 인증서를 추가해주면 해결됩니다.

명령어를 잘 모르겠으면 보통은 인증서 업체에 문의하면 대부분 가이드가 있고

어떻게 수동 추가하는지 안내를 해주는 편입니다.

 

2.4.2 통신 모듈을 유효성 체크하지 않게 수정

인증서 유효성 체크를 무시하게 통신 모듈을 수정해줄 수 있지만

당연히 인증서 유효성 체크가 불가능하므로 추천하는 방법은 아닙니다.

 

2.4.3 JDK 업데이트 

JAVA 서비스 환경이라면 JDK 업데이트하면서 목록에 추가될수 있습니다.

다만 앞에서 이야기한 것처럼 원래 CA 목록에 있었는데 빠지는 경우도 있기때문에

업데이트 버전에 따른 CA 목록을 확인해봐야합니다.

 

keytool CLI 환경에서 추가하는 명령어는 아래 링크를 확인해주세요.

https://www.lesstif.com/java/java-keytool-keystore-20775436.html

 

HTTPS 통신 관련해서 상세 설명은 아래 링크를 확인해주세요.

https://opentutorials.org/course/228/4894

'장애처리로그' 카테고리의 다른 글

SameSite Cookie 그리고 Proxy Error  (0) 2022.04.21

1부에 이어서 2부 Swagger에 대해서 Demo을 제작해보고 장단점을 비교해보겠습니다.

모든 소스는 Github에서 확인 가능합니다.

 

※ 개발환경 :  JDK 11, STS 4.8.1,  Spring Boot 2.6.3, Maven 4

 

1. Spring Swagger Demo 프로젝트 생성

프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.

의존성 설정은 아래의 pom.xml 파일을 참고해주세요.

 

2. Spring Swagger  Demo 프로젝트 구조

아래 이미지의 구조를 가지게 됩니다.

3. pom.xml 파일 설정

원래는 springfox-boot-starter 3.0.0 버전을 의존성 설정해야 하지만

Spring Boot 2.6.x 버전에서 컴파일 에러는 아니지만 런타임 에러가 발생했고

찾아보니 2.6.x 버전에서 매우 많은 버그가 있다고 알려져 있어 거의 유사한 사용 방식을 가진

spring-docs-openapi 1.6.4로 의존성으로 마이그레이션을 했습니다.

실제로도 springfox-boot-starter → spring-docs-openapi 의존성을 옮겼을 때

Swagger 설정 빼고는 변경한 내용이 없었습니다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.3</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>SpringbootSwagger2Demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>SpringbootSwagger2Demo</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<!--  <dependency>
        	<groupId>io.springfox</groupId>
        	<artifactId>springfox-boot-starter</artifactId>
        	<version>3.0.0</version>
        	</dependency>-->
        	<dependency>
    			<groupId>org.springdoc</groupId>
    			<artifactId>springdoc-openapi-ui</artifactId>
    			<version>1.6.4</version>
			</dependency> 
         
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</project>

 

4. Configuration - OpenApiConfig

OpenAPI 설정에 대한 커스텀 설정을 합니다.

다양한 설정이 가능하나 여기서는 문서가 생성될 때 도메인에 대해 설정합니다.

src/main/java > com.example.demo.config 패키지를 생성하고 OpenApiConfig 클래스를 작성합니다.

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

//import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
//import io.swagger.v3.oas.models.info.Info;
//import io.swagger.v3.oas.models.info.License;
//import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;

@Configuration
public class OpenApiConfig {
	
	@Bean
	public OpenAPI customOpenAPI() {
		return new OpenAPI().addServersItem(new Server().url("http://localhost:8080"));
				//.components(new Components().addSecuritySchemes("basicScheme",
						//new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("basic")))
						//.info(new Info().title("Spring API").version("V1")
						//.license(new License().name("Apache 2.0").url("http://springdoc.org")));
	}
}

 

5. Entitiy - User 

src/main/java > com.example.demo.entity 패키지를 생성하고 User 클래스를 작성합니다.

DemoController에서 응답 객체로 HashMap을 사용하는데 현재로서는 HashMap이 포함하는 변수들에 대한
상세 설명을 직접 작성할 수 있는 방법을 찾을 수 없었습니다.

따라서 User 클래스를 작성하고 문서 상 설명을 보여줄 수 있게 작성합니다.

package com.example.demo.entity;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "유저")
public class User{
	
	@Schema(description = "유저 고유번호")
	private String id;
	
	@Schema(description = "유저 명")
	private String name;
	
	@Schema(description = "유저 이메일")
	private String email;

	public String getId() {
		return id;
	}

	public void setId(String id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}
}

 

 

6. DemoController 

src/main/java > com.example.demo.controller 패키지를 생성하고 DemoController 클래스를 작성합니다.

package com.example.demo.controller;

import java.util.HashMap;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.entity.User;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;

@RestController
public class DemoController {

	   @Operation(summary = "getUserInfoById")
	   @ApiResponses(value = {
			   @ApiResponse(responseCode ="200", description="유저 정보 조회 성공", 
					   content = { @Content(mediaType = "application/json", schema = @Schema(implementation = User.class)) }),
	   })
	   @GetMapping("/demo/user/{id}")
	   public ResponseEntity<?> getUserInfoById(@Parameter(description="유저 고유번호") @PathVariable("id") String id){

		   HashMap<String, String> map = new HashMap<>();
		   map.put("id", id);
		   map.put("name", "jack");
		   map.put("email", "jack@abc.com");

	       return new ResponseEntity<>(map, HttpStatus.OK);
	   }

	   @Operation(summary = "registUserInfo")
	   @ApiResponses(value = {
			   @ApiResponse(responseCode ="200", description="유저 등록 성공", 
					   content = { @Content(mediaType = "application/json", schema = @Schema(implementation = User.class)) }),
	   })
	   @PostMapping("/demo/user")
	   public ResponseEntity<?> registUserInfo(@Parameter(schema = @Schema(implementation = User.class)) @RequestParam HashMap<String, String> map) {
		   return new ResponseEntity<>(map, HttpStatus.OK);
	   }
}

 

  • @Opertation
    HTTP 메서드에 대해 설명.
  • @ApiResponse
    응답에 대해 나타냅니다.

  • @Content
    특정 미디어 유형에 대한 스키마 및 예시를 제공.

  • @Scheme
    입력 및 출력 데이터의 정의.

  • @Parameter
    단일 매개변수에 대해 나타냅니다.

어노테이션에 대한 상세 설명과 예시는 아래를 링크를 참고해주세요.

Swagger 2.x wiki
Migrating from SpringFox

 

 

7. 테스트

Spring Boot Dashboard에서 Start 합니다.

정상적으로 Start 됐으면 브라우저에서 http://localhost:8080/swagger-ui.html 주소로 접근하여

아래와 같이 API 문서를 확인 가능합니다.

실제 서비스에서는 해당 경로에 대한 접근 보안 설정은 필수로 추가해야 합니다.

그렇지 않을 경우 누구나 접근 가능한 경로가 됩니다.

정의된 API를 클릭하여 어노테이션 코드로 작성한 설명들과 예시를 볼 수 있으며,

POST MAN 같은 API 툴처럼 값을 입력하여 API 테스트도 가능합니다.

 

8. 마치며

Spring Rest Docs와 Swagger Demo를 작성해봤습니다.

Swagger Demo를 작성하면서 느낀 장단점은 아래와 같습니다.

  • 장점
    테스트 코드의 작성 필요가 없다.
    API 테스트가 가능.

  • 단점
    테스트 코드를 통해 작성된 문서가 아니기 때문에 신뢰도가 상대적으로 떨어진다.
    프로덕션 코드에 작성 코드가 포함된다.
    Map의 경우 직접적으로 설명을 작성하기가 어렵다.
    Swagger 어노테이션으로 대부분 작성하므로 학습 필요.

Spring Rest Docs와 Swagger은 장단점은 극명한 것 같습니다.

하지만 어떤 것이 좋을지는 환경과 비즈니스 요구사항에 맞춰서 대응해야 한다고 봅니다.

 

  • 레거시 프로젝트가 있고, 많은 양의 API가 있는데 빠른 시간 내에 API 문서가 필요하다면?
    Spring Rest Docs를 적용하게 되면 전부 테스트 코드를 작성해줘야 하고
    따라서 시간이 매우 소모되는 작업이므로 적합하지 않을 수 있습니다.
    물론 둘 다 모르는 것에 학습은 해야 되지만 개인적으로는 Swagger가 훨씬 작성하기는 쉬워 보였습니다.

  • 처음 시작하는 프로젝트이고 신뢰성 있는 API 문서가 필요하다면?
    Spring Rest Docs를 적용하게 되면 전부 테스트 코드 작성해야 하지만 
    검증된 신뢰성 있는 API 문서를 얻을 수 있습니다.

  이외에도 프로덕션 코드에 코드 추가 여부나 작업자의 학습도, 역량에 따라서 달라질 수 있다고 봅니다.

  하지만 장기적으로 봤을 때는 Spring Rest Docs를 적용하는 게 TDD 개발을 하는 곳이라면 낫지 않을까 싶습니다.

 

※ 참고

https://springdoc.org/#migrating-from-springfox

https://github.com/springdoc/springdoc-openapi/issues/89

https://springdoc.org/#demos

https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations

https://github.com/swagger-api/swagger-core/issues/3080

https://www.baeldung.com/spring-rest-openapi-documentation

https://github.com/springfox/springfox-demos

http://springfox.github.io/springfox/docs/current/#springfox-spring-mvc-and-spring-boot

'Spring Boot Framework' 카테고리의 다른 글

Spring RestTemplate (1)  (0) 2022.05.23
Spring Boot Interceptor  (0) 2022.02.18
Spring Rest Docs 와 Swagger (1)  (0) 2022.02.11
Spring Boot Oauth 2.0 과 JWT Demo (5)  (0) 2022.02.08
Spring Boot Oauth 2.0 과 JWT Demo (4)  (0) 2022.02.06

개인 프로젝트는 토이 프로젝트가 많다 보니 아무래도 필요성이 조금 떨어지기는 하지만

현업에서 드는 생각은 API 문서는 필수라는 겁니다.

WEB API를 개발하는 웹 개발자는 본인이 개발하기 때문에 어떤 요청 변수가 필요한지

어떤 응답이 갈지 다 이해하고 있지만 만약 프론트-엔드 파트가 나눠졌다면

프론트-엔드 개발자는 정확히 알기가 어렵습니다.

또한 요새는 B2C 서비스 같은 경우 모바일 서비스는 필수에 가깝기때문에

상황에 따라 AOS/IOS 개발자가 모바일 파트를 담당할 수 있고 개발을 진행하려면 당연히 API 문서가 필수입니다.

API 부재로 구두로 소통하다 보면 당연히 파편화된 기능 개발이 될 수밖에 없습니다.

그리고 시간이 경과함에 따라 인간의 기억은 한계가 있고, 매번 소스를 다시 보면서

API 동작을 파악하는 것도 어렵고 힘든 일입니다.

하지만 매번 API 문서를 작성하는 건 시간적 비용이 상당하고 이미 기 개발된 소스의 경우 더욱 어렵습니다.

문득 자동화할 수 없을까라는 생각이 들어 스쳐 지나가면서 들은 JAVA 생태계에서

대표적인 Spring Rest Docs와 Swagger에 대해 파악해보는 글입니다.

데모를 만들어보고 장단점을 비교해보려고 합니다.

 

1부는 Spring Rest Docs Demo 

2부는 Swagger Demo 

 

모든 소스는 Github에서 확인 가능합니다.

 

※ 개발환경 :  JDK 11, STS 4.8.1,  Spring Boot 2.6.3, Maven 4

 

Spring Rest Docs는 아래 요건을 충족해야합니다. 

해당 환경보다 하위 환경에서는 동작하지 않습니다.

  • 자바 8
  • 스프링 프레임워크 5(5.0.2 이상)

1. Spring Rest Docs Demo 프로젝트 생성

프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.

의존성 설정은 아래의 pom.xml 파일을 참고해주세요.

 

2. Spring Rest Docs Demo 프로젝트 구조

아래 이미지와 같은 구조를 가지게 됩니다.

 

3.  pom.xml 파일 설정

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.3</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>SpringbootRestDocDemo2</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>SpringbootRestDocDemo2</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.restdocs</groupId>
			<artifactId>spring-restdocs-mockmvc</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.asciidoctor</groupId>
				<artifactId>asciidoctor-maven-plugin</artifactId>
				<version>1.5.8</version>
				<executions>
					<execution>
						<id>generate-docs</id>
						<phase>prepare-package</phase>
						<goals>
							<goal>process-asciidoc</goal>
						</goals>
						<configuration>
							<backend>html</backend>
							<doctype>book</doctype>
						</configuration>
					</execution>
				</executions>
				<dependencies>
					<dependency>
						<groupId>org.springframework.restdocs</groupId>
						<artifactId>spring-restdocs-asciidoctor</artifactId>
						<version>2.0.6.RELEASE</version>
					</dependency>
				</dependencies>
			</plugin>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
			 <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <executions>
                    <execution>
                        <id>copy-resources</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>
                                ${project.build.outputDirectory}/static/docs
                            </outputDirectory>
                            <resources>
                                <resource>
                                    <directory>
                                        ${project.build.directory}/generated-docs
                                    </directory>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
		</plugins>
	</build>
</project>

 

  •  spring-restdocs-mockmvc 의존성 추가
     MockMvc, WebTestClient, REST Assured 중 MVC 패턴의 API 문서를 위한 것

  • spring-restdocs-asciidoctor 의존성을 추가
    snippet이 사용할 속성으로 .adoc을 가리키도록 자동 구성됩니다.

  • maven-resources-plugin의 prepare-package 추가
    리소스 플러그인을 통해  jar 파일에 /generated-docs 생성된 파일을 포함하게 설정합니다.

4. DemoController 

src/main/java > com.example.demo.controller 패키지를 생성하고 DemoController 클래스를 작성합니다.

package com.example.demo.controller;

import java.util.HashMap;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DemoController {

	   @GetMapping("/demo/user/{id}")
	   public ResponseEntity<?> getUserInfoById(@PathVariable("id") String id){

		   HashMap<String, String> map = new HashMap<>();
		   map.put("id", id);
		   map.put("name", "jack");
		   map.put("email", "jack@abc.com");

	       return new ResponseEntity<>(map, HttpStatus.OK);
	   }

	   @PostMapping("/demo/user")
	   public ResponseEntity<?> registUserInfo(@RequestParam HashMap<String, String> map) {
		   return new ResponseEntity<>(map, HttpStatus.OK);
	   }
}

 

  • /demo/user/{id}
    user 정보를 식별자(id)에 따라 조회하는 API입니다.
    Demo 테스트 용으로  id, name, email 값을 고정값을 세팅하여 리턴합니다.

  • /demo/user
    Demo 테스트 용으로 user 정보를 등록하는 API로 map 포맷으로 요청 변수를 받아서 리턴합니다.


5. DemoTest

src/test/java > com.example.demo.controller 패키지를 생성하고 DemoTest 클래스를 작성합니다.

Junit 5 기반으로 작성했으며, 해당 링크를 보시면 Junit 4 코드도 확인 가능합니다.

package com.example.demo.controller;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

//import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
//import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
//import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.*;

@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@SpringBootTest
public class DemoTest {
		
     private MockMvc mockMvc;

     @BeforeEach
     public void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(documentationConfiguration(restDocumentation))
                //.alwaysDo(document("{method-name}/{class-name}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
                .build();
     }
    
     @Test
	 public void getUserInfoById() throws Exception{
	       this.mockMvc.perform(RestDocumentationRequestBuilders.get("/demo/user/{id}", "user1"))
	       			.andDo(print())
			       .andExpect(status().isOk())
			       .andDo(document("get-user-info-by-Id", 
			    		   pathParameters( 
								parameterWithName("id").description("유저 고유번호") 
			               ),
			    		   responseFields(
			                    fieldWithPath("id").description("유저 고유번호"),
			                    fieldWithPath("name").description("유저 명"),
			                    fieldWithPath("email").description("유저 이메일")
			               )
			       ));
	  }
     
 	@Test
	 public void registUserInfo() throws Exception{
	      this.mockMvc.perform(post("/demo/user")
	    		  .param("id", "user2")
	    		  .param("name","paul")
	    		  .param("email","paul@gmail.com")
	    		  )
	      		  .andDo(print())
	              .andExpect(status().isOk())
	              .andDo(document("regist-user-info",
	                       responseFields(
	                               fieldWithPath("id").description("유저 고유번호"),
	                               fieldWithPath("name").description("유저 명"),
	                               fieldWithPath("email").description("유저 이메일")
	                              )
	              ));
	  }
}

 

  • setup 메서드
    mockMvc 인스턴스를 사용하여 구성. 
    스니펫은 기본적으로 6개 curl, http-request, http-resonse, httpie-request, request-body, resonse-body를 생성.
    /target/generated-snippets/~ 에서 생성된 스니펫 확인 가능합니다.

  • getUserInfoById 메서드
    DemoController의 /demo/user/{id} API의 테스트 코드입니다.
    pathParameter는 @Pathvariable로 받는 id 값의 테스트를 위한 설정인데
    사용할 경우 mockMvcBuilder를 사용하면 컴파일 타임에서 괜찮지만 mvc install 과정에서 Exception이 발생합니다.

    urlTemplate not found.
    If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?

    메시지 권고대로 mockMvcBuilder 대신 RestDocumentationRequestBuilders를 사용하면 에러가 발생하지 않습니다.

  • registUserInfo 메서드
    DemoController의 /demo/user API의 테스트 코드입니다.

  • 기타
    @ExtendWith는 Junit5 설정이다. spring rest docs 공식 레퍼런스에도 잘 설명되어있습니다.
    Junit4와 설정 차이가 좀 있으니 서두에서 이야기한 Junit4 Demo와 비교해보기를 바랍니다.

6. api-docs.adoc

/src/main/asciidoc 경로에 파일을 생성합니다.

해당 파일을 통해 html 파일을 생성합니다.
여기서는 api-docs.html 파일이 /target/generated-docs 경로에 생성됩니다.

 Demo Project API 명세 (Spring REST Docs)

== getUserInfoById
​
Curl request

include::{snippets}/get-user-info-by-id/curl-request.adoc[]
​
HTTP request
​
include::{snippets}/get-user-info-by-id/http-request.adoc[]

Path Parameter

include::{snippets}/get-user-info-by-id/path-parameters.adoc[]

HTTP Response
​
include::{snippets}/get-user-info-by-id/http-response.adoc[]
​
Response Fields

include::{snippets}/get-user-info-by-id/response-fields.adoc[]
​
Request Body
​
include::{snippets}/get-user-info-by-id/request-body.adoc[]
​
Response Body
​
include::{snippets}/get-user-info-by-id/response-body.adoc[]


== registUserInfo
​
Curl Request
​
include::{snippets}/regist-user-info/curl-request.adoc[]
​
HTTP Request
​
include::{snippets}/regist-user-info/http-request.adoc[]


HTTP Response
​
include::{snippets}/regist-user-info/http-response.adoc[]

Response Fields

include::{snippets}/regist-user-info/response-fields.adoc[]
​
Request Body
​
include::{snippets}/regist-user-info/request-body.adoc[]
​
Response Body
​
include::{snippets}/regist-user-info/response-body.adoc[]

 

7. 테스트

mvn install을 수행하여 테스트를 정상 통과하면 빌드가 완료됩니다.

만약 테스트 실패 시 api-docs.html 파일은 생성되지 않으며,

api-docs.adoc의 작성에 따라 알아보기 힘든 포맷의 파일이 생성되기도 하니 주의해야 합니다.

아래 이미지는 빌드 완료 후 생성된 파일 정보입니다.

 

taget folder

 

이제 생성된 api-docs.html을 브라우저에서 열어보면 아래 이미지와 같이 API 정보가 내용을 확인할 수 있습니다.

 

7. 마치며

Spring Rest Docs Demo를 만들고 결과물까지 확인해봤습니다.

Demo를 작성하면서 느낀 Spring Rest Docs의 장단점을 써보겠습니다.

 

  • 장점
    테스트 코드를 통해 작성된 문서이기 때문에 신뢰도가 높다.
    프로덕션 코드는 건들지 않는다.
  • 단점
    Rest Docs, Junit, Asciidoc의 학습이 필요.
    만약 TDD 환경이 아닌 곳은 테스트 코드 작성 필요.

 

다음 2부에서는 Swagger Demo를 제작하고 장단점을 비교해보겠습니다.

 

※ 참고

https://docs.spring.io/spring-restdocs/docs/2.0.6.RELEASE/reference/html5

https://www.baeldung.com/spring-rest-docs

이전 글에 이어서 Resource Server를 Spring Security Oauth 없이 구현해보겠습니다.

전체 소스는 Github에 있습니다.

 

※ 개발 환경 : STS 4.8.1, JDK 1.8, Spring Boot 2.1.6, Mybatis 2.1, MySQL Community 5.6, Maven 4.0.0

 

1. Resource Server 구현을 위한 Spring Boot 프로젝트 생성 및 의존성 설정

프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.

의존성 설정은 아래의 pom.xml 파일을 참고해주세요.

 

2. Resource Server 프로젝트 구조

아래 이미지와 같은 구조를 가지게 됩니다.

- com.codjecd.common : Custom Exception 등 Custom 패키지, MessageProperties 등 공통 컴포넌트 패키지

- com.codejcd.config : 각종 설정 파일들의 패키지

- com.codejcd.dao : DB 액세스를 파일들을 위한 패키지

- com.codejcd.service : 서비스 레이어 패키지

- com.codejcd.controller : 컨트롤러 레이어 패키지

- com.codejcd.util : 유틸 파일 패키지

 

3. Message Source 설정

Exception 등 서버에서 처리할 메시지를 정의하고 맵핑하는 설정.
/src/main/resources/messages/ message.*.properties 파일들과 맵핑됩니다.

Locale을 KOREA로 설정했기떄문에 message.ko.properties 가 기본 메시지 프로퍼티로 사용됩니다.

 

3.1 Message Source Configuration

package com.codejcd.config;

import java.util.Locale;

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;

/**
 * 메시지 설정
 */
@Configuration
public class MessageSourceConfiguration {

	@Bean
	public MessageSource messageSource() {
		ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();

	    source.setBasename("classpath:/messages/message");
	    source.setDefaultEncoding("UTF-8");
	    source.setCacheSeconds(10);
	    source.setUseCodeAsDefaultMessage(true);
	    Locale.setDefault(Locale.KOREA);
	   
	    return source;
	}


}

 

3.2 Message Source Properties

src/main/resources/messages 폴더를 생성하고 message_ko.properties 파일을 생성합니다.

code와 message 값을 정의합니다.
프로젝트의 크기에 따라 code 의 범위는 적절하게 조절하세요.
여기서는 데모 앱이므로 범위가 작습니다.

error.code.common.success=000
error.message.common.success=Process Success

error.code.common.occured.exception=999
error.message.common.occured.exception=Process Fail

error.code.userId.invalid=000
error.message.userId.invalid=User ID is invalid

error.code.userPassword.invalid=001
error.message.userPassword.invalid=User Password is invalid

error.code.token.expiration.invalid=010
error.message.token.expiration.invalid=token is invalid

error.code.token.expiration=011
error.message.token.expiration=token is expired

error.code.token.issuer.invalid=012
error.message.token.issuer.invalid=issuer is invalid

error.code.token.audience.invalid=013
error.message.token.audience.invalid=audience is invalid

error.code.token.id.invalid=014
error.message.token.id.invalid=token ID is invalid

error.code.authorization.invalid=020
error.message.authorization.invalid=authorization is invalid

 

3.3 Message Properties

MessageSoruceAware를 상속받는 클래스.

3.2에 정의한 properties 파일에서 key, vaule 관계로 정의한 코드, 메시지 값을 가져와 

정의한 메서드 통해 사용할 수 있다.

 

예시) MessageProperties.prop("error.code.common.occured.exception") 

package com.codejcd.common;

import java.text.MessageFormat;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;

/**
 * Message Component
 * 커스터마이징 메시지 
 */
@Component
public class MessageProperties implements MessageSourceAware{
	
    private static MessageSource messageSource;

    public void setMessageSource (MessageSource messageSource) {
        MessageProperties.messageSource = messageSource;
    }

    public static String prop(String key) {
        return messageSource.getMessage(key, null, Locale.KOREA);
    }

    public static String prop(String key,  HttpServletRequest request) {
        return MessageProperties.msg(key, request);
    }

    public static String prop(String key, Locale locale) {
        return MessageProperties.msg(key, locale);
    }

    public static String propFormat(String key, HttpServletRequest request, Object...objects) {
        return MessageFormat.format(MessageProperties.msg(key, request), objects);
    }

    public static String propFormat(String key, Locale locale, Object...objects) {
        return MessageFormat.format(MessageProperties.msg(key, locale),objects);
    }

     public static String msg(String key, HttpServletRequest request){
         return messageSource.getMessage(key, null, Locale.KOREA);
     }

     public static String msg(String key, Locale locale){
         return messageSource.getMessage(key, null, locale);
     }

}

 

4. application.properties

서버 포트를 위한 설정만 있습니다.

server.port=8096

 

5. jwt.properties 

JWT 설정 값들을 관리하는 파일입니다.

JWT Access Token 과 Refresh Token 복호화에 필요한 값들.

jwt.secretKey=1523adjk@#
jwt.expireDay=3
jwt.issuer=codejcd
jwt.audience=codejcd_client
jwt.subject=access_token

 

6. Custom Exception 

Custom Exception을 처리하기 위한 클래스입니다.

Exception 클래스를 상속 받고 error code, message, description을 처리하기 위한 멤버 변수를 가집니다.

 

package com.codejcd.common;

/**
 * Customize Exception 
 * 커스터마이징 예외 처리 
 */
public class CustomException extends Exception {
	private static final long serialVersionUID = -3642478745758696786L;
	private String errorCode;
	private String errorMessage;
	private String errorDescription;
	
	public CustomException() {
	
	}
	
	public CustomException(String errorCode, String errorMessage) {
		this.errorCode = errorCode;
		this.errorMessage = errorMessage;
	}
	
	public CustomException(String errorCode, String errorMessage, String errorDescription) {
		this.errorCode = errorCode;
		this.errorMessage = errorMessage;
		this.errorDescription = errorDescription;
	}
	
	public String getErrorCode() {
		return errorCode;
	}
	public void setErrorCode(String errorCode) {
		this.errorCode = errorCode;
	}
	public String getErrorMessage() {
		return errorMessage;
	}
	public void setErrorMessage(String errorMessage) {
		this.errorMessage = errorMessage;
	}
	public String getErrorDescription() {
		return errorDescription;
	}
	public void setErrorDescription(String errorDescription) {
		this.errorDescription = errorDescription;
	}
}

 

7. Custom Response 

응답을 위한 Response 객체를 구현.

응답 코드(responseCode), 응답 메시지(responseMessage), 응답 결과 객체(result)를 멤버 변수로 가진다.

package com.codejcd.common;

/**
 * Customize Response
 * 커스터마이징 응답 객체
 */
public class Response {

	private String responseCode;
	private String responseMessage;
	private Object result;
	public String getResponseCode() {
		return responseCode;
	}
	public void setResponseCode(String responseCode) {
		this.responseCode = responseCode;
	}
	public String getResponseMessage() {
		return responseMessage;
	}
	public void setResponseMessage(String responseMessage) {
		this.responseMessage = responseMessage;
	}
	public Object getResult() {
		return result;
	}
	public void setResult(Object result) {
		this.result = result;
	}
}

 

8. Date Utill 

날짜, 시간 계산을 편하게 하기 위한 유틸 클래스.

메서드 설명은 이전 글을 참고해주시기 바랍니다.

package com.codejcd.util;

import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

public class DateUtil {

	public static final String YYYYMMDDHHmm = "yyyyMMddHHmm";
	
    public static Date getDate(String dateString, String format) throws Exception{
    	SimpleDateFormat formatter = new SimpleDateFormat(format);
    	ParsePosition pos = new ParsePosition(0);
    	return formatter.parse(dateString, pos);
    }
	
	public static String getNow(String fromFormat) {
		Calendar cal = Calendar.getInstance();
		Date today = cal.getTime();
		SimpleDateFormat formatter = new SimpleDateFormat(fromFormat);
		return formatter.format(today);
	}
	
	public static Date getNowDate() {
		Calendar cal = Calendar.getInstance();
		return cal.getTime();
	}
	
    public static String addDate(String dateString, String format, int days){
    	Calendar cal = Calendar.getInstance();
    	SimpleDateFormat formatter = new SimpleDateFormat(format);
        ParsePosition pos = new ParsePosition(0);
        cal.setTime(formatter.parse(dateString, pos));
        cal.add(Calendar.DATE,days);

		return formatter.format(cal.getTime());
    }
    
    public static boolean isBefore(Date from, Date to) {
		if (from == null || to == null) {
			return false;
		}
		if (to.before(from)) {
			return false;
		}
		return true;
	}

}

 

9. Resource Controller

RestController 어노테이션을 통해 Bean 등록을 하고 JSON 포맷으로 데이터를 리턴합니다.

package com.codejcd.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.common.Response;
import com.codejcd.service.TokenService;

/**
 * 리소스 컨트롤러
 */
@RestController
public class ResourceController {

	@Autowired
	private TokenService tokenService;
	
	/**
	 * 제한된 자원의 접근 API
	 * @param bearerToken
	 * @return
	 */
    @RequestMapping("/resource")
    public Response resource(@RequestHeader(value="Authorization") String bearerToken) {
    	Response response = new Response();
    	
    	try {
    		if (tokenService.checkAccessToken(bearerToken)) { // token 검증, token 검증은 각 API에 Aspect나 Intercepter를 활용하여 공통 처리도 가능. 
    			// 리소스 접근
            	response.setResponseCode(MessageProperties.prop("error.code.common.success"));
        		response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
    		}
    	} catch (CustomException e) {
    		response.setResponseCode(MessageProperties.prop(e.getErrorCode()));
    		response.setResponseMessage(MessageProperties.prop(e.getErrorMessage()));
    	} catch (Exception e) {
            response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
            response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
            e.printStackTrace();
    	} finally {
    		
    	}
    	return response;
    }
}

 

  • /resource
    RequestHeader 어노테이션을 사용하여 header로 전송하는 Authorization 값을 핸들링합니다.
    TokenService 의 메서드를 호출하여 토큰 값을 검증하고 검증이 정상 처리될 경우
    정상 응답 값을 세팅하여 리턴합니다.

10. Token Service, Token Service Impl

Token 서비스 레이어 입니다.

package com.codejcd.service;

public interface TokenService {
	
	/**
	 * token 검증
	 * @param accessToken
	 * @return
	 * @throws Exception
	 */
	public boolean checkAccessToken(String accessToken) throws Exception;
}
package com.codejcd.service;

import java.util.Date;
import javax.xml.bind.DatatypeConverter;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Service;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.util.DateUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;

@Service
@PropertySource("classpath:jwt.properties")
public class TokenServiceImpl implements TokenService {
	
	@Value("${jwt.secretKey}")
	private String SECRET_KEY;
	
	@Value("${jwt.issuer}")
	private String ISSUER;
	
	@Value("${jwt.audience}")
	private String AUDIENCE;
	
	@Value("${jwt.subject}")
	private String SUBJECT;
	
	@Override
	public boolean checkAccessToken(String accessToken) throws Exception {
		if (null == accessToken || "".equals(accessToken)) {
			throw new CustomException(MessageProperties.prop("error.code.token.invalid")
					, MessageProperties.prop("error.message.token.invalid"));
		}
		
		String[] bearerToken = accessToken.split("Bearer "); 
		if (null == bearerToken[1]) {
			throw new CustomException(MessageProperties.prop("error.code.token.not.bearer")
					, MessageProperties.prop("error.message.token.not.bearer"));
		}
		
		boolean result = false;
		byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(SECRET_KEY.getBytes()));
		Claims claims = null;
		claims = Jwts.parser()
					.setSigningKey(secretKeyBytes)
					.parseClaimsJws(bearerToken[1]).getBody();
		
		Date expitration;
		Date now = DateUtil.getNowDate();
		String issuer;
		String audience;
		
		
		if (null == claims.getExpiration()) {
			throw new CustomException(MessageProperties.prop("error.code.token.expiration.invalid")
					, MessageProperties.prop("error.message.token.expiration.invalid"));	
		}
		expitration = claims.getExpiration();
		 
		if (DateUtil.isBefore(expitration, now)) {
			throw new CustomException(MessageProperties.prop("error.code.token.expiration")
					, MessageProperties.prop("error.message.token.expiration"));
		}
		
		if (null == claims.getIssuer() ||  "".equals(claims.getIssuer())) {
			throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
					, MessageProperties.prop("error.message.token.issuer.invalid"));		
		}
		issuer = claims.getIssuer();
		
		if (!ISSUER.equals(issuer)) {
			throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
					, MessageProperties.prop("error.message.token.issuer.invalid"));
		}
		
		if (null == claims.getAudience() ||  "".equals(claims.getAudience())) {
			throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
					, MessageProperties.prop("error.message.token.audience.invalid"));		
		}
		audience = claims.getAudience();
		
		if (!AUDIENCE.equals(audience)) {
			throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
					, MessageProperties.prop("error.message.token.audience.invalid"));
		}
		
		if (null == claims.getId() ||  "".equals(claims.getId())) {
			throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
					, MessageProperties.prop("error.message.token.id.invalid"));		
		}
		result = true;
		//claims.getIssuedAt();
		//claims.getSubject();
		
		return result;
	}
	
}

 

  • checkAccessToken 메서드
    Bearere Token에 대한 null, type 등 기본적인 유효성 체크 이후에
    jwt.properties에 미리 정의해둔 secret key를 통해 디코딩하여 Claims 객체를 만듭니다.
    Claims에 정의된 메서드를 통해 속성에 접근하여 토큰의 유효성을 체크하고 결과를 리턴합니다.

11. 테스트

테스트 툴로는 POSTMAN을 사용했습니다.
이전 글에 미리 만든 Authorization 서버를 스타트하고 Resource 서버도 스타트합니다.

 

http://localhost:8095/oauth2/tokens API를 저번 글에서

미리 생성해둔 Client 정보와 User 정보를 세팅하고 호출하여 토큰 값을 얻습니다.

http://localhost:8096/resource API의 header에 발급받은 Bearer Token Type에 Access Token을 세팅하고 

호출하면 정상적으로 접근하는 것을 확인할 수 있다.

 

12. 마치며

1부 - Oauth 2.0과 JWT 개념 

2부~3부 - Spring Security Oauth Passsword Credential Demo 구현

4~5부 - Spring Boot Oauth Passsword Credential Demo 구현

 

1~5부에 걸쳐서 Oauth 개념과 인증과 리소스 서버 데모를 간단하게 구현해봤습니다.

기본 흐름이 어떻게 흘러가는지는 이해하셨을 거라 생각됩니다.

1부에서 개념을 소개하면서 직접 실무에서 사용을 했다고 했는데요.

사용하면서 느낀 점을 한번 적어보겠습니다.

데모 앱에서는 HTTP 프로토콜을 사용하고 따라서

토큰의 경우 클라이언트가 접근 가능한 환경이면 비교적 쉽게 탈취 가능합니다.

base64 encoding 외에 별다른 보안 장치가 없기 때문에

토큰에 중요한 값을 실어 보낸다면 탈취 시 문제가 될 수도 있습니다.

따라서 실제 상용 환경이라면 통신 구간 암호화를 위해 HTTPS 프로토콜을 당연히 사용해야 합니다.

Access Token은 만료 주기를 짧게 해서 탈취에 대한 대비를 어느 정도 할 수도 있습니다.

다만 주기가 짧아지는 만큼 빈번하게 토큰을 발급해야 합니다.

Refresh Token을 탈취당할 것을 대비하여 DB에 저장하고 어느 정도 제어를 할 수 있는

방법은 있지만 Refresh Token을 계속 저장하고 관리해야 하는 부담이 있습니다.

서비스가 커질수록 부담은 늘어납니다.

보안이 엄격하게 유지되어야 하는 서비스에서는 클라이언트에 저장되는 부분이라던지

토큰 탈취에 대한 신경을 많이 쏟아야 하는 구조입니다.

물론 Cookie나 Session을 사용한다고 해서 탈취 위험이 전혀 없는 것은 아닙니다.

비즈니스 상황에 가장 적합하게 분석하여 적용해야 한다고 봅니다.

 

 

 

 

 

 

 

 

 

+ Recent posts