단점 테스트 코드를 통해 작성된 문서가 아니기 때문에 신뢰도가 상대적으로 떨어진다. 프로덕션 코드에 작성 코드가 포함된다. Map의 경우 직접적으로 설명을 작성하기가 어렵다. Swagger 어노테이션으로 대부분 작성하므로 학습 필요.
Spring Rest Docs와 Swagger은 장단점은 극명한 것 같습니다.
하지만 어떤 것이 좋을지는 환경과 비즈니스 요구사항에 맞춰서 대응해야 한다고 봅니다.
레거시 프로젝트가 있고, 많은 양의 API가 있는데 빠른 시간 내에 API 문서가 필요하다면? Spring Rest Docs를 적용하게 되면 전부 테스트 코드를 작성해줘야 하고 따라서 시간이 매우 소모되는 작업이므로 적합하지 않을 수 있습니다. 물론 둘 다 모르는 것에 학습은 해야 되지만 개인적으로는 Swagger가 훨씬 작성하기는 쉬워 보였습니다.
처음 시작하는 프로젝트이고 신뢰성 있는 API 문서가 필요하다면? Spring Rest Docs를 적용하게 되면 전부 테스트 코드 작성해야 하지만 검증된 신뢰성 있는 API 문서를 얻을 수 있습니다.
이외에도 프로덕션 코드에 코드 추가 여부나 작업자의 학습도, 역량에 따라서 달라질 수 있다고 봅니다.
하지만 장기적으로 봤을 때는 Spring Rest Docs를 적용하는 게 TDD 개발을 하는 곳이라면 낫지 않을까 싶습니다.
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 경로에 생성됩니다.
※ 개발 환경 : 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.ResourceServer프로젝트 구조
아래 이미지와 같은 구조를 가지게 됩니다.
- 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 관계로 정의한 코드, 메시지 값을 가져와
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 서버도 스타트합니다.