728x90

개요

이번에 스크랩 프로젝트 개발을 진행하다가 DTO를 검증해야 하는 경우가 있었습니다. 이를 if문을 이용해서 validation을 진행하였습니다. 이를 별도의 검증 클래스로 만들어 사용할 수 있지만 간단한 검증의 경우에는 JSR 표준을 이용해 간결하게 처리할 수 있습니다.

// 요청한 값에 대한 validation 처리 필요
if(postJoinReq.getUsername() == null || postJoinReq.getUsername().isEmpty()){
    return new BaseResponse(JOIN_USERNAME_EMPTY);
}
if(postJoinReq.getPassword() == null || postJoinReq.getPassword().isEmpty()){
    return new BaseResponse(JOIN_PASSWORD_EMPTY);
}
if(postJoinReq.getName() == null || postJoinReq.getName().isEmpty()){
    return new BaseResponse(JOIN_NAME_EMPTY);
}
// 형식 확인
if(!RegexService.checkUsername(postJoinReq.getUsername())){
    return new BaseResponse(JOIN_USERNAME_INVALID);
}
if(!RegexService.checkPw(postJoinReq.getPassword())){
    return new BaseResponse(JOIN_PASSWORD_INVALID);
}
if(!RegexService.checkName(postJoinReq.getName())){
    return new BaseResponse(JOIN_NAME_INVALID);
}

implementation 추가

Spring에서는 일종의 어댑터인 LocalValidatorFactoryBean가 제약 조건 검증을 처리한다. 이를 이용하려면 LocalValidatorFactoryBean을 빈으로 등록해야 하는데, SpringBoot에서는 아래의 의존성만 추가해주면 해당 기능들이 자동 설정된다.

// validation 검사
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.7.1'

@Valid과 @Validated  차이

Spring에서는 메서드 레벨 유효성 검증을 위해 JSR-303의 @Valid 어노테이션을 사용합니다. 또한 유효성 검사를 위해 멤버 속성을 표시하는 데에도 이를 사용합니다. 그러나 @Valid 어노테이션은 그룹 유효성 검사를 지원하지 않습니다.

그룹은 유효성 검사 중에 적용되는 제약 조건을 제한하는 데 도움이 됩니다. 한 가지 특정 사용 사례는 UI 마법사입니다. 여기에서 첫 번째 단계에서 필드의 특정 하위 그룹이 있을 수 있습니다. 다음 단계에서 동일한 Bean에 속하는 다른 그룹이 있을 수 있습니다. 따라서 각 단계에서 이러한 제한된 필드에 제약 조건을 적용해야 하지만 @Valid는 이를 지원하지 않습니다.

이 경우 그룹 수준 의 경우 이 JSR-303의 @Valid 의 변형인 Spring의 @Validated 를 사용해야 합니다. 이것은 메서드 수준에서 사용됩니다. 그리고 멤버 속성을 표시하기 위해 @Valid 어노테이션을 계속 사용합니다.

또한 @Valid 어노테이션은 Controller 단에서만 유효성 검사가 가능하다. 일반적으로 파라미터에 대한 유효성 검증은 Controller 단에서 최대한 처리하는 것이 좋지만, 경우에 따라 다른 곳에서도 파라미터에 대한 검증이 필요합니다. 이런 경우에 AOP 기반으로 메서드의 요청을 가로채서 유효성 검증을 진행할 수 있는 것이 바로 @Validated 어노테이션이다.

@Valid 작성

@PostMapping("/join")
public BaseResponse join(@Validated(ValidationSequence.class) @RequestBody PostJoinReq postJoinReq){
		userService.join(postJoinReq);
		return new BaseResponse("회원가입에 성공했습니다");
}

@RequestBody 어노테이션 옆에 @Valid를 작성하면, RequestBody로 들어오는 객체에 대한 검증을 수행한다. 이 검증의 세부적인 사항은 객체 안에 정의를 해두어야 한다.

Request class 생성

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class PostJoinReq {

    @NotEmpty(message = "이메일을 입력해주세요", groups = NotEmptyGroup.class)
    @Email(message = "이메일 형식이 일치하지 않습니다", groups = EmailGroup.class)
    private String email; // 아이디

    @NotEmpty(message = "비밀번호를 입력해주세요", groups = NotEmptyGroup.class)
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\\\d)[A-Za-z\\\\d!?@#$%&*]{5,15}$", message = "비밀번호 형식이 일치하지 않습니다", groups = PatternGroup.class)
    private String password; // 비밀번호

    @NotEmpty(message = "이름을 입력해주세요", groups = NotEmptyGroup.class)
    @Pattern(regexp = "^(?=. *[a-zA-Z가-힣])[A-Za-z가-힣]{1,30}$", message = "이름 형식이 일치하지 않습니다", groups = PatternGroup.class)
    private String name; // 이름
}

위와 같이 PostJoinReq 객체를 정의한 후 각 필드에 맞는 어노테이션을 사용하면 된다.

ValidationGroup class 생성

public class ValidationGroup {
    // 인터페이스 선언. 이 인터페이스를 @NotEmpty같은 어노테이션에 적용시켜주면 됨.
    public interface NotEmptyGroup{}
    public interface NotNullGroup{}
    public interface PatternGroup{}
    public interface EmailGroup{}
}

ValidationSequence interface 생성

// 왼 -> 오 순서로 적용됨
@GroupSequence({Default.class, NotNullGroup.class, NotEmptyGroup.class, PatternGroup.class, EmailGroup.class})
public interface ValidationSequence {
}

@GroupSequence 어노테이션에 적용되어있는 왼쪽에서 오른쪽 순서로 적용된다.

 

validation 어노테이션 종류

@AssertTrue Boolean, boolean 값이 항상 True 여야 한다.  
@DecimalMax 실수 제외 숫자 클래스. 지정된 최대 값보다 작거나 같아야 하는 숫자이다. String : value (max 값을 지정한다.)
@DecimalMin 실수 제외 숫자 클래스. 지정된 최소 값보다 크거나 같아야하는 숫자이다. String : value (min 값을 지정한다.)
@Digits BigDecimalBigIntegerCharSequencebyte, short, int, long, 이에 대응하는 Wrapper 클래스 허용된 범위 내의 숫자이다. int : integer (이 숫자에 허용되는 최대 정수 자릿수)int : fraction (이 숫자에 허용되는 최대 소수 자릿수)
@Email null도 valid로 간주된다. 올바른 형식의 이메일 주소여야한다.  
@Future 시간 클래스 Now 보다 미래의 날짜, 시간  
@FutureOrPresent 시간 클래스 Now의 시간이거나 미래의 날짜, 시간  
@Max 실수 제외 숫자 클래스. 지정된 최대 값보다 작거나 같은 숫자이다. long : value (max 값을 지정한다)
@Min 실수 제외 숫자 클래스. 지정된 최소 값보다 크거나 같은 숫자이다. long : value (min 값을 지정한다)
@Negative 숫자 클래스 음수인 값이다.  
@NegativeOrZero 숫자 클래스 0이거나 음수인 값이다  
@NotBlank   null 이 아닌 값이다.공백이 아닌 문자를 하나 이상 포함한다  
@NotEmpty CharSequence,Collection, Map, Array null이거나 empty(빈 문자열)가 아니어야 한다.  
@NotNull 어떤 타입이든 수용한다. null 이 아닌 값이다.  
@Null 어떤 타입이든 수용한다. null 값이다.  
@Past 시간 클래스 Now보다 과거의 날짜, 시간  
@PastOrPresent 시간클래스 Now의 시간이거나 과거의 날짜, 시간  
@Pattern 문자열 지정한 정규식과 대응되는 문자열이어야한다. Java의 Pattern 패키지의 컨벤션을 따른다 String : regexp (정규식 문자열을 지정한다)
@Positive 숫자 클래스 양수인 값이다  
@PositiveOrZero 숫자 클래스 0이거나 양수인 값이다.  
@Size CharSequence,Collection, Map, Array 이 크기가 지정된 경계(포함) 사이에 있어야한다. int : max (element의 크기가 작거나 같다)int : min (element의 크기가 크거나 같다)

@NotNull, @NotEmpty, @NotBlank 차이

null "" " "
@NotNull Invalid Valid
@NotEmpty Invalid Invalid
@NotBlank Invalid Invalid

참조

https://mangkyu.tistory.com/174

 

[Spring] @Valid와 @Validated를 이용한 유효성 검증의 동작 원리 및 사용법 예시 - (1/2)

Spring으로 개발을 하다 보면 DTO 또는 객체를 검증해야 하는 경우가 있습니다. 이를 별도의 검증 클래스로 만들어 사용할 수 있지만 간단한 검증의 경우에는 JSR 표준을 이용해 간결하게 처리할 수

mangkyu.tistory.com

https://jyami.tistory.com/55

 

@Valid 를 이용해 @RequestBody 객체 검증하기

Springboot를 이용해서 어노테이션을 이용한 validation을 하는 방법을 적으려 한다. RestController를 이용하여 @RequestBody 객체를 사용자로부터 가져올 때, 들어오는 값들을 검증할 수 있는 방법을 소개한

jyami.tistory.com

 

728x90
728x90

예외란?

예외란 프로그램 실행 중 발생하는 이벤트로 프로그램 명령의 정상적인 흐름을 방해하는 것이다. 예외가 발생하게 될 경우 예외객체를 만들어 런타임 시스템에 전달한다. 예외객체는 오류발생 당시의 프로그램 상태와 오류정보가 포함되어있다. 이러한 과정을 예외 발생이라고 한다.

예외 요청 흐름

WAS는 스프링 부트가 등록한 에러 설정(/error)에 맞게 요청을 전달하는데, 이러한 흐름을 총 정리하면 다음과 같다.

WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러
-> 컨트롤러(예외발생) -> 인터셉터 -> 서블릿(디스패처 서블릿) -> 필터 -> WAS(톰캣)
-> WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러(BasicErrorController)

컨트롤러 하위에서 예외가 발생하였을 때, 별도의 예외 처리를 하지 않으면 WAS까지 에러가 전달된다. 그러면 WAS는 애플리케이션에서 처리를 못하는 예와라 exception이 올라왔다고 판단을 하고, 대응 작업을 진행한다.

Exception의 종류

자바에서의 예외는 전부 Throwable을 상속받는다.

Error의 경우는 개발자가 직접 사용할 일은 거의없다. 자바가 JVM이나 내부에 있는 라이브러리에서 문제가 될법한 상황에서 나온다. 즉, 애플리케이션이 정상적으로 동작하는데 심각한 문제가 있는 경우이다.

우리가 주로 사용하게 되는 에러는 Exception과 RuntimeException이다.

우리가 사용하는 예외는 크게 Checked Exception, Unchecked Exception 2종류로 분류 할 수 있다.

Exception -> Checked Exception
RuntimeException -> Unchecked Exception

Checked Exception

컴파일 시점에서 Exception을 catch하는지 검사하기 때문에 처리를 하지 않을경우 컴파일 에러가 발생한다. 현 위치에서 처리를 할경우 try/catch를 하여 해결한다. 현 위치에서 처리를 하지 않을 경우 throw 방식을 이용해여 나를 호출한 상위에 위임한다.

Unchecked Exception

Exception이 발생하는 메소드에서 throws 예약어를 이용하여 Exception을 호출 메소드에 전달해야 한다.

Runtime Time Exception 이라고 한다. 컴파일 시점에 Exception을 catch하는지 확인하지 않기 때문에 컴파일 시점에 Exception이 발생할 것인지의 여부를 판단할 수 없다. Exception이 발생하는 메소드에서 throws 예약어를 활용해 Exception을 처리할 필요가 없다. 하지만 처리해도 무방하다.

try,catch와 throw

예외 복구 : 예외가 발생하면 예외 상황에 대해 알맞게 처리하여 복구한다. ex) try, catch

예외 회피 : 예외를 직접 처리하지 않고 예외를 상위 메소드에 위임한다. ex) throw

스프링이 제공하는 다양한 예외처리 방법

Spring은 에러 처리라는 공통 관심사(cross-cutting concerns)를 메인 로직으로부터 분리하는 다양한 예외 처리 방식을 고안하였고, 예외 처리 전략을 추상화한 HandlerExceptionResolver 인터페이스를 만들었다. (전략 패턴이 사용된 것이다.) 대부분의 HandlerExceptionResolver는 발생한 Exception을 catch하고 HTTP 상태나 응답 메세지 등을 설정한다. 그래서 WAS 입장에서는 해당 요청이 정상적인 응답인 것으로 인식되며, 위에서 설명한 복잡한 WAS의 에러 전달이 진행되지 않는다.

예외가 던져지면 디스패처 서블릿까지 전달되는데, 적합한 예외 처리를 위해 HandlerExceptionResolver 구현체들을 빈으로 등록해서 관리한다. 그리고 적용 가능한 구현체를 찾아 예외 처리를 하는데, 우선순위대로 아래의 4가지 구현체들이 빈으로 등록되어 있다.

  • DefaultErrorAttributes: 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
  • ExceptionHandlerExceptionResolver: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
  • ResponseStatusExceptionResolver: Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
  • DefaultHandlerExceptionResolver:  스프링 내부의 기본 예외들을 처리한다.

Spring은 아래와 같은 도구들로 ExceptionResolver를 동작시켜 에러를 처리할 수 있다.

  1. ResponseStatus
  2. ResponseStatusException
  3. ExceptionHandler
  4. ControllerAdvice, RestControllerAdvice
@RestControllerAdvice
public class ExceptionResponseAdvice {
    /**
     * BaseException 예외처리 핸들러
     * @param e BaseException
     * @return BaseResponse
     */
    @ExceptionHandler(BaseException.class)
    @ResponseStatus(code = HttpStatus.BAD_REQUEST) //응답코드: 400 에러로 일단 통일
    public BaseResponse handlerBaseException(BaseException e){
        return new BaseResponse(e.getStatus());
    }

    /**
     * Exception 예외처리 핸들러
     * @param e Exception
     * @return BaseResponse - 서버 내부에서 에러 발생
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    public BaseResponse handlerException(Exception e){
        e.printStackTrace();
        return new BaseResponse(BaseExceptionStatus.SERVER_INTENER_ERROR);
    }

    /**
     * UnknownHostException 예외처리 핸들러
     * @param e Exception
     * @return BaseResponse - 링크가 잘못되었습니다.
     * @author 박현성
     */
    @ExceptionHandler({UnknownHostException.class, MalformedURLException.class})
    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    public BaseResponse unknownHostException(Exception e){
        return new BaseResponse(BaseExceptionStatus.DATA_NAME_INCORRECTION);
    }
			....생략
}

@ResponseStatus는 언제 사용하나?

어노테이션 이름에서 예측가능하듯이 @ResponseStatus는 에러 HTTP 상태를 변경하도록 도와주는 어노테이션이다.

회사 내규에 따라 다르겠지만 Exception이 발생하였을 경우에 Response Body에만 표시해줄지 아니면 Http status code에도 표시할지에 따라서 다를 수 있다. 만약 status code에도 어떤 에러인지 명시적으로 내어주고 싶을 경우에 @ResponseStatus를 사용할 수 있다.

@ExceptionHandler는 언제 사용하나?

@ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 기능이다. @ExceptionHandler는 다음에 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.

  • 컨트롤러의 메소드
  • @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드

@ExceptionHandler는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다. 만약 ExceptionHandler 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다. 또한 @ResponseStatus와도 결합가능한데,  만약 ResponseEntity에서도 status를 지정하고 @ResponseStatus도 있다면 ResponseEntity가 우선순위를 갖는다.ExceptionHandler는 @ResponseStatus와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연하다. 예를 들어 응답을 다음과 같이 정의해서 내려준다면 좋을 것이다.

  • code: 어떠한 종류의 에러가 발생하는지에 대한 에러 코드
  • message: 왜 에러가 발생했는지에 대한 설명
  • erros: 어느 값이 잘못되어 @Valid에 의한 검증이 실패한 것인지를 위한 에러 목록

Spring은 예외가 발생하면 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾는다. 예를 들어 NullPointerException이 발생했다면, 위에서는 NullPointerException 처리기가 없으므로 Exception에 대한 처리기가 찾아진다.

@ControllerAdvice와 @RestControllerAdvice

Spring은 전역적으로 @ExceptionHandler를 적용할 수 있는 @ControllerAdvice와 @RestControllerAdvice 어노테이션을 각각 Spring3.2, Spring4.3부터 제공하고 있다. 두 개의 차이는 @Controller와 RestController와 같이 @ResponseBody가 붙어 있어 응답을 Json으로 내려준다는 점에서 다르다. ControllerAdvice 어노테이션에는 @Component 어노테이션이 있어서 ControllerAdvice가 선언된 클래스는 스프링 빈으로 등록된다. 그러므로 우리는 다음과 같이 전역적으로 에러를 핸들링하는 클래스를 만들어 어노테이션을 붙여줌으로써 에러 처리를 위임할 수 있다.

예외 복구 범위를 정해보자

메소드 영역 : 메소드 영역은 종속된 복구 기능으로 단순히 try, catch 사용 하면 된다.

클래스 영역 : 클래스 내 공통 예외 복구는 @ExceptionHandler 사용할 수 있다.

전역 영역 : 여러 클래스의 공통 예외 복구는 @ControllerAdvice 사용할 수 있다.

ControllerAdvice는 전역적으로 적용되는데, 만약 특정 클래스에만 제한적으로 적용하고 싶다면 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다.

@ResponseStatus의 코드를 확인해보려면 HttpStatus 안으로 들어가보면 코드를 확인할 수 있다.

Scrap에서는 @ResponseStatus을 400으로 통일을 했습니다.

@Getter
public enum BaseExceptionStatus {

    TEST_ERROR(40401, "실패했어요"),
    SERVER_INTENER_ERROR(2002, "서버 내부적인 에러"),

    // 유저 관련은 3000번 에러
    DUPULICATE_USERNAME(3001, "아이디가 중복됩니다"),
    FAIL_ENCRYPT_PASSWORD(3002, "비밀번호 암호화에 실패했습니다"),
    LOGIN_USER_NOT_EXIST(3003, "해당하는 아이디 또는 비밀번호가 없습니다"),
    NOT_LOGIN_USER(3004,"로그인 하지 않은 유저입니다"),
    USER_NOT_EXIST(3005,"해당하는 유저가 존재하지 않습니다"),

    CATEGORY_NAME_NULL(4444, "카테고리 이름을 입력해주세요"),
    CATEGORY_NAME_LENGTH(4444, "카테고리 이름이 2~60 글자 사이"),
    DATA_NAME_INCORRECTION(44444, "링크가 잘못되었습니다."),
    MYPAGE_USER_NOT_FOUND(44444, "해당 사용자를 찾을 수 없습니다."),

    CATEGORY_NOT_EXIST(4444, "해당 카테고리자 존재하지 않습니다"),
    LINK_NOT_EXIST(4444, "해당 자료가 존재하지 않습니다"),
    LINK_AND_USER_NOT_CORRECT(4444, "해당 유저가 만든 자료가 아닙니다"),
    CATEGORY_AND_USER_NOT_CORRECT(4444, "해당 유저가 만든 카테고리가 아닙니다"),

	... 생략 ...
    
    private final int code;
    private final String message;

    private BaseExceptionStatus(int code, String msg){
        this.code = code;
        this.message = msg;
    }
}

BaseExceptionStatus Enum class

우리가 클라이언트에게 보내줄 에러 코드를 정의해야 한다. 기본적으로 에러 이름과 HTTP 상태 및 메세지를 가지고 있는 에러 코드 클래스를 만들었다. 발생할 수 있는 에러 코드를 위와 같이 정의했다.

Enum class란?

  • 클래스처럼 보이게 하는 상수
  • 서로 관련있는 상수들끼리 모아 상수들을 정의하는것
  • enum 클래스 형을 기반으로 한 클래스형 선언

Enum class 특징

  1. 열거형으로 선언된 순서에 따라 0부터 index 값을 가진다.(순차적으로 증가)
  2. enum 열거형으로 지정된 상수들은 모두 대문자로 선언한다.
  3. 열거형 변수들을 선언한 후 마지막에 세미콜론(;)을 찍지 않는다.
  4. 상수와 특정 값을 연결시킬경우 마지막에 세미콜론(;)을 붙여줘야한다.

상수와 특정값을 연결시켜놓은건데 특정값을 연결시키려면 해당 값들을 리턴할 수 있는 함수가 선언되어있어야한다.

@Getter
public class BaseException extends RuntimeException{

    private final BaseExceptionStatus status;

    public BaseException(BaseExceptionStatus status){
        super(status.getMessage());
        this.status = status;
    }
}

우리가 발생한 예외를 처리해줄 예외 클래스(Exception Class)를 추가해주어야 한다. 우리는 언체크 예외(런타임 예외)를 상속받는 예외 클래스를 다음과 같이 추가해줄 수 있다.

여기서 체크 예외가 아닌 언체크 예외를 상속받도록 한 이유가 있다. 왜냐하면 일반적인 비지니스 로직들은 따로 catch해서 처리할 것이 없므로 만약 체크 예외로 한다면 불필요하게 throws가 전파될 것이기 때문이다.

또한 Spring은 내부적으로 발생한 예외를 확인하여 언체크 예외이거나 에러라면 자동으로 롤백시키도록 처리한다. Spring에서 체크 예외만 롤백을 안하는 이유는 체크 예외는 처리가 강제되기 때문에 개발자가 무언가를 처리할 것이라는 기대 때문이다.

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"code", "message", "result"})
public class BaseResponse<T> {

    private int code;
    private String message;
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T result;

    // 기본 생성자 막아둠
    private BaseResponse(){}

    public BaseResponse(T result){
        this.code = 1000;
        this.message = "요청에 성공했습니다";
        this.result = result;
    }

    public BaseResponse(String msg, T result){
        this.code = 1000;
        this.message = msg;
        this.result = result;
    }

    public BaseResponse(String msg){
        this.code = 1000;
        this.message = msg;
    }

    public BaseResponse(int code, String msg){
        this.code = code;
        this.message = msg;
    }

    public BaseResponse(BaseExceptionStatus e){
        this.code = e.getCode();
        this.message = e.getMessage();
    }
}

클라이언트로 정해진 포맷의 에러를 던져주도록 하기 위해 위와 같이 에러 응답 클래스를 추가해주었다. 위의 클래스는 다양한 응답에 처리를 해주기 위해 오버로딩을 사용하였다. 또한 만약 errors가 없다면 응답으로 내려가지 않도록 @JsonInclude 어노테이션을 추가하였다.

참조

https://www.nextree.co.kr/p3239/

https://mine-it-record.tistory.com/204

https://mangkyu.tistory.com/204

https://mangkyu.tistory.com/205

728x90
728x90

개요

기존에 Jasypt 패키지를 이용해 YML 설정 파일 암호화를 진행하였지만 application.yml 파일 내 jasypt.encryptor.password가 노출되어있어 암호화는 해주었지만 암호화를 풀 수 있는 열쇠를 노출해 이와 같은 문제를 해결하는 방법으로 Github Repository secrets을 이용해 해결하였습니다.

떠오른 방법

  1. 암호화를 풀 수 있는 키를 우분투 서버 자체에 환경변수로 등록하여서 도커 컨테이너에 스프링 run 할 때 환경변수를 전달한다.
  2. Github Repository secrets를 이용한다.
  3. Spring Vault를 이용한다.

Spring Vault란?

Vault는 HashiCorp에 의해서 개발된 크로스 플랫폼 패스워드 및 인증 관리 시스템이다. 공개되면 안 되는 비밀번호, API 키, 토큰 등을 저장하고 관리한다.

 

선택한 방법

우선 저는 제일 간단한 방법으로 진행하기 위해 2번을 선택했습니다.

시간이 되면 1번 방법과 3번 방법으로 진행해 보겠습니다.

 

해결 방법

1. GitHub 해당 Repository에 들어가서 Settings에서 왼쪽 하단에 있는 Secrets의 Actions에 접속한다.

2. Secrets의 Actions에서 오른쪽 상단에 있는 New repository secret을 선택한다.

3. secret의 Name을 입력하고 Secret에는 암호화를 진행하고 싶은 내용을 입력하고 Add secret을 누르면 생성된다.

4. 현재 제가 만든 secret 목록입니다.

5. .github/workflows/gradle.yml 파일에 아래의 코드를 추가한다.

Repository secrets을 사용하면 GitHub Actions의 gradle.yml에만 환경변수가 적용되어 스프링에서 사용할 수 있게 변수를 넘겨주어야 한다.

- name: Set Yaml
  uses: microsoft/variable-substitution@v1
  with:
    files: ./src/main/resources/application-prod.yml 
  env:
    spring.datasource.url: ${{ secrets.DB_URL }} 
    spring.datasource.username: ${{ secrets.DB_USERNAME }} 
    spring.datasource.password: ${{ secrets.DB_PASSWORD }}

gradle.yml 전체 코드

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: <https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle>
name: Java CI with Gradle

on:
  push:
    branches: [ "main" ]
#   pull_request:
#       branches: [ "main" ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'
    - name: Set Yaml
      uses: microsoft/variable-substitution@v1
      with:
        files: ./src/main/resources/application-prod.yml 
      env:
        spring.datasource.url: ${{ secrets.DB_URL }} 
        spring.datasource.username: ${{ secrets.DB_USERNAME }} 
        spring.datasource.password: ${{ secrets.DB_PASSWORD }} 
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    - name: Build with Gradle
      run: ./gradlew build
    - name: Docker build
      run: |
       docker login -u ${{ secrets.USERNAME }} -p ${{ secrets.PASSWORD }}
       docker build -t spring-cicd2 .
       docker tag spring-cicd2 lusida0131/spring-cicd2:latest
       docker push lusida0131/spring-cicd2:latest
  deploy:
    needs: build  # build 후에 실행되도록 정의
    name: Deploy
    runs-on: [ self-hosted, label-go ] # AWS ./configure에서 사용할 label명
    steps:
      # 3000 -> 80 포트로 수행하도록 지정
      - name: Docker run
        run: |
          docker login -u ${{ secrets.USERNAME }} -p ${{ secrets.PASSWORD }}
          docker stop spring-cicd2 && docker rm spring-cicd2 && docker rmi lusida0131/spring-cicd2:latest
          docker run -d -p 8081:8081 --name spring-cicd2 --restart always lusida0131/spring-cicd2:latest

변수는 ${{ secrets.적용한이름 }}으로 불러올 수 있다. 위의 코드를 보면 ./src/main/resources/application-prod.yml의 파일에 spring.datasource.url를 ${{ secrets.DB_URL }} 변수로 덮어 씌운다. 실제 application-prod.yml의 값은 임의의 값을 주어도 상관없다.

datasource:
    url: ${url}
    username: ${username}
    password: ${password}
728x90
728x90
  1. numOfLink라는 해당 카테고리 안에 있는 자료의 count 개수를 세는 게 있는데 카테고리 개수만큼 select count 쿼리문이 날아간다. select count가 날아가는데 운영하는데 부하가 있지 않을까?
  2. 카테고리 순서 변경을 할 때 하나를 바꿀 때마다 업데이트 쿼리문이 날아가는데 사용자가 카테고리 순서를 한 번에 많이 바꿀 경우 select 쿼리와 update 쿼리가 계속 날아가고 프런트와 계속 api 통신을 해야 하는데 부하가 심하게 걸리지 않을까? 실무에서는 어떻게 통신하고 수정하는지 현재 저희는 변경할 때마다 api 통신을 한다.

 

728x90
728x90

개요

로컬과 서버의 결괏값이 다름

로컬에서와 서버에서 특정 링크(네이버 쇼핑몰)를 테스트했을 때 로컬에서는 문제가 없지만 서버에서는 og tag로 이미지 파일과 title이 null 값으로 동일한 URL인데 저장이 안 되는 문제가 발생했다.

2주 넘게 고민하다 이유를 모르겠어서 매주 주말에 참가하는 부천 모각코 모임에 여쭤봤더니 경력이 높으신 분이 문제를 접근하는 방법과 해결방법에 대해 말씀해주셨다. 이번 문제에 대해 접근하는 방법과 해결 과정 등 많은 것을 배울 수 있는 시간이었다.

로컬과 서버의 차이점

   1. 서버와 로컬에서 차이점은 서버는 앞단에 nginx가 하나 더 있음

   2. 결괏값 URL이 다름

로컬

baseURL = <https://search.shopping.naver.com/book/catalog/32490794178>
postDataSaveReq.getLink() = <https://msearch.shopping.naver.com/book/catalog/32490794178>
postDataSaveReq.getImgUrl() = <https://shopping-phinf.pstatic.net/main_3249079/32490794178.20220527093651.jpg>

서버

baseURL = <https://search.shopping.naver.com/book/catalog/32490794178>
postDataSaveReq.getLink() = <https://search.shopping.naver.com/book/catalog/32490794178>
postDataSaveReq.getImgUrl() = null

   3. 서버와 로컬의 ip 주소가 다르다.

이유

og tag 라이브러리를 보면 baseURL로 한번 더 요청을 보낸다. 아래와 같이 로컬에서는 curl 요청이 문제없이 되지만 ec2 서버에서 네이버 쇼핑몰로 curl request를 보내면 307 Temporary Redirect 상태 코드가 나온다. 307 상태 코드는 요청된 URL이 잠시 다른 URL로 바뀐 것을 알리는 상태 코드이다. (Location: header로 지정된) 바뀐 URL은 GET method로 접근해야 한다. 하지만 서버에서 요청 보낸 헤더 location을 보낸 URL을 보면 https://search.shopping.naver.com/blocked.html로 비정상적 요청이 감지되었습니다.라는 페이지가 나온다.

로컬에서 보낸 curl 요청

curl https://search.shopping.naver.com/book/catalog/32490794178

<!DOCTYPE html>
<html lang="ko">
<head><meta charSet="utf-8"/><meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<title>서울 시 : 네이버 도서</title><link rel="shortcut icon" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/img/favicon.ico"/>
<link rel="apple-touch-icon" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/img/favicon_60.png"/>
<meta property="og:title" content="서울 시 : 네이버 도서"/>
<meta property="og:description" content="네이버 도서 상세정보를 제공합니다."/>
<meta property="og:image" content="https://shopping-phinf.pstatic.net/main_3249079/32490794178.20220527093651.jpg"/>
<meta property="og:url" content="https://search.shopping.naver.com/book/catalog/32490794178"/>
<meta name="next-head-count" content="10"/>
<link rel="preconnect" href="https://static.nid.naver.com"/>
<link rel="preconnect" href="https://lcs.naver.com"/>
<link rel="preconnect" href="http://shopping.phinf.naver.net"/>
<link rel="preconnect" href="https://ssl.pstatic.net"/>
<link rel="preconnect" href="https://shopping-phinf.pstatic.net"/>
<link rel="preconnect" href="https://volts.shopping.naver.com"/>
<link rel="preload" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/css/c1c28aedd09b2c2d.css" as="style"/>
<link rel="stylesheet" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/css/c1c28aedd09b2c2d.css" data-n-g=""/>
<link rel="preload" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/css/05d5e4267a2caa1a.css" as="style"/>
<link rel="stylesheet" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/css/05d5e4267a2caa1a.css" data-n-p=""/>
<link rel="preload" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/css/bda4bc7d3c476bd4.css" as="style"/>
<link rel="stylesheet" href="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/css/bda4bc7d3c476bd4.css"/><noscript data-n-css="">
</noscript><script defer="" nomodule="" src="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/chunks/polyfills-5cd94c89d3acac5f.js">
</script><script defer="" src="https://ssl.pstatic.net/shoppingsearch/static/book-catalog/book-catalog-221016-124516/_next/static/chunks/315.75129e479e1f82a7.js">
</script>

서버에서 보낸 curl 요청

curl -i https://search.shopping.naver.com/book/catalog/32490794178

HTTP/2 307 
date: Sun, 16 Oct 2022 08:33:54 GMT
content-type: text/html; charset=utf-8
content-length: 164
location: https://search.shopping.naver.com/blocked.html
referrer-policy: unsafe-url
server: nfront

<html>
<head><title>307 Temporary Redirect</title></head>
<body>
<center><h1>307 Temporary Redirect</h1></center>
<hr><center>nginx</center>
</body>
</html>

https://search.shopping.naver.com/blocked.html 화면

해결 방법

  1. 네이버 쪽에서 접속이 차단되지 않은 ip 대역의 클라우드를 찾는다.
  2. 사용자가 있는 경우 프론트에서 처리를 해주어 request를 보낸다. 서버에서 request를 보내면 ec2 ip대역이라 차단이 되지만 사용자에서 요청을 보내면 각자 기기의 ip가 있어서 차단되지 않을 수 있다.
728x90
728x90

개요

GitHub의 프로젝트 레파지토리에 application.yml이나 application.properties 파일에 DB의 유저 정보와 비밀번호 또는 키값을 명시해두는 경우가 있습니다. 또한 yml이나 properties을. gitignore에 추가할 경우 aws에서 프로젝트를 실행하는 경우나 docker로 실행하는 경우 이와 같은 오류가 발생한다. "Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured. Reason: Failed to determine a suitable driver class” 위의 오류는 database 설정 파일을 찾지 못해서 나는 오류이다. 하지만 DB 정보를 명시해두는 경우 GitHub 특성상 오픈 소스이므로 정보가 노출되어 보안에 심각한 문제를 초래할 수 있다는 점입니다. 위와 같은 사항들을 해결하기 위해 보안에 민감한 값들을 암호화시켜 저장해야 한다.

 

암호화 방식

YML파일 암호화 방법으로는 여러 방법이 있지만 저는 Java의 Jasypt(Java Simplified Encryption) 패키지를 사용하여 설정 파일들의 암호화를 진행하겠습니다.

 

장점

Jasypt를 설정하려면 여러 코드들이 추가되지만 로컬,배포 환경의 설정 파일을 동일하게 사용할 수 있고 설정 파일이 외부 유출되어도 암호화되어있기에 비교적 안전하다는 장점이 있다.


암호화 진행 순서

1. build.gradle파일에 jasypt implementation을 작성한다.

// yml 설정 파일 암호화
implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3'

 

2. jasyptStringEncryptor로 Bean을 등록합니다. 이 이름은 application.yml의 jasypt bean으로 등록할 때 사용합니다. private String PASSWORD는 yml 설정 파일에서 jasypt.encryptor.password으로 읽어서 가져옵니다.

import org.jasypt.encryption.StringEncryptor;
import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JasyptConfig {

		@Value("${jasypt.encryptor.password}")
    private String PASSWORD;

    @Bean(name = "jasyptStringEncryptor")
    public StringEncryptor stringEncryptor() {
        PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
        SimpleStringPBEConfig config = new SimpleStringPBEConfig();
        config.setPassword(PASSWORD); // 암호화할 때 사용하는 키
        config.setAlgorithm("PBEWithMD5AndDES"); // 암호화 알고리즘
        config.setKeyObtentionIterations("1000"); // 반복할 해싱 회수
        config.setPoolSize("1"); // 인스턴스 pool
        config.setProviderName("SunJCE");
        config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator"); // salt 생성 클래스
        config.setStringOutputType("base64"); //인코딩 방식
        encryptor.setConfig(config);
        return encryptor;
    }
}

 

3. application.yml에 암호화된 값을 적어주기 전에 미리 해당 값들의 암호화된 값을 알기위해 Test코드를 작성해서 출력한다.

import org.assertj.core.api.Assertions;
import org.jasypt.encryption.pbe.StandardPBEStringEncryptor;
import org.junit.jupiter.api.Test;

class JasyptConfigTest {

    @Test
    void jasypt(){
        String url = "자신의 DB URL";
        String username = "자신의 DB USER";
        String password = "자신의 DB PASSWORD";

        String encryptUrl = jasyptEncrypt(url);
        String encryptUsername = jasyptEncrypt(username);
        String encryptPassword = jasyptEncrypt(password);

        System.out.println("encryptUrl : " + encryptUrl);
        System.out.println("encryptUsername : " + encryptUsername);
        System.out.println("encryptPassword : " + encryptPassword);

        Assertions.assertThat(url).isEqualTo(jasyptDecryt(encryptUrl));
    }

    private String jasyptEncrypt(String input) {
        String key = "암호화에 쓰일 키값";
        StandardPBEStringEncryptor encryptor = new StandardPBEStringEncryptor();
        encryptor.setAlgorithm("PBEWithMD5AndDES");
        encryptor.setPassword(key);
        return encryptor.encrypt(input);
    }

    private String jasyptDecryt(String input){
        String key = "복호화에 쓰일 키값";
        StandardPBEStringEncryptor encryptor = new StandardPBEStringEncryptor();
        encryptor.setAlgorithm("PBEWithMD5AndDES");
        encryptor.setPassword(key);
        return encryptor.decrypt(input);
    }
}

 

4. 위의 TestCode를 작성하면 암호화된 값이 출력된다.

 

5. application.yml에 암호화된 값과 jasyptStringEncryptor bean 설정

spring:
  datasource:
    url: ENC(o9FcC1LtWYlFw88yy/5Ilg==)
    username: ENC(cp/1/Ok8sPGkYEJi27Jknw==)
    password: ENC(9vlmkROOsAgMTk3rFkbiQxXFwFaQoew0)
jasypt:
  encryptor:
    bean: jasyptStringEncryptor
    password : 1234

 

jasyptStringEncryptor를 jasypt bean으로 등록하고, 각 속성값에 ENC( 암호화 값 ) 형식으로 입력해줍니다. 위와 같은 과정을 통해 application.yml 파일의 암호화를 설정해 주었습니다. 하지만, application.yml 파일 내 jasypt.encryptor.password가 노출되어있다면, 암호화는 해주었지만 암호화를 풀 수 있는 열쇠를 지어준 꼴이 된다. 이와 같은 문제를 해결하는 방법은 다음에 작성해 보겠습니다.

728x90
728x90

개요

Swagger를 서버에 배포했을 때 기존 코드로 API 명세서를 실행시키면 curl이 curl -x GET “https://127.0.0.1:8081/”로 실행이 되어서 상태 코드 200인데 안에 데이터가 비어있는 오류가 발생했다. 이를 해결하고자 Swgger 코드를 수정했다.

application.yml에 아래와 같은 코드 작성

servers:
  - url: https://server address/
    description: Production server

Swagger version 2.9.2에서 3.0으로 수정

//기존코드
//implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2'
//implementation group: 'io.springfox', name: 'springfox-swagger2', version: '2.9.2'

//수정 코드
implementation group: 'io.springfox', name: 'springfox-boot-starter', version: '3.0.0'

SwaggerConfig파일 수정

import springfox.documentation.service.Server;
import springfox.documentation.spi.DocumentationType;

@Configuration
public class SwaggerConfig {
	@Bean
	public Docket restAPI() {
        Server serverLocal = new Server("local", "http://localhost:8081", "for local usages", Collections.emptyList(), Collections.emptyList());
        Server testServer = new Server("test", "https://서버주소", "for testing", Collections.emptyList(), Collections.emptyList());
        return new Docket(DocumentationType.OAS_30)
                .servers(serverLocal, testServer)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("j2kb.ponicon.scrap")) // 특정 패키지경로를 API문서화 한다. 1차 필터
                .paths(PathSelectors.any()) // apis중에서 특정 path조건 API만 문서화 하는 2차 필터
                .build()
                .groupName("API 1.0.0") // group별 명칭을 주어야 한다.
                .useDefaultResponseMessages(false); // 400,404,500 .. 표기를 ui에서 삭제한다.
	}
	private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Spring Boot REST API test")
                .version("v0.0.1")
                .description("스크랩 JPA swagger api 입니다.")
                .build();
	}
}

Workaround 파일을 작성한다.

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.stereotype.Component;
import springfox.documentation.oas.web.OpenApiTransformationContext;
import springfox.documentation.oas.web.WebMvcOpenApiTransformationFilter;
import springfox.documentation.spi.DocumentationType;

import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;

@Component
public class Workaround implements WebMvcOpenApiTransformationFilter {

    @Override
    public OpenAPI transform(OpenApiTransformationContext<HttpServletRequest> context) {
        OpenAPI openApi = context.getSpecification();
        Server localServer = new Server();
        localServer.setDescription("local");
        localServer.setUrl("http://localhost:8081");

        Server testServer = new Server();
        testServer.setDescription("test");
        testServer.setUrl("https://serveraddress");
        openApi.setServers(Arrays.asList(localServer, testServer));
        return openApi;
    }

    @Override
    public boolean supports(DocumentationType documentationType) {
        return documentationType.equals(DocumentationType.OAS_30);
    }
}

servers에 보면 local과 서버 두 개가 추가된 것을 확인할 수 있다.

아래와 같이 curl이 서버 주소로 잘 실행되고 데이터 값도 잘 나오는 것을 확인할 수 있다.

728x90
728x90

개요

Postman Team 사용이 4명 이상일 경우 해당 기간 동안만 무료로 사용할 수 있어서 방안을 찾던 중 방안 중 하나인 API 명세서 만들어주는 Swagger를 설정 방법과 사용법에 대해 작성하려고 한다.

Swagger란?

Swagger를 사용하면 @어노테이션 코드 몇 줄 추가하여 간단하게 API별로 문서화 및 API테스트 가능한 UI 까지 제공하여 문서 작성 시간을 극도로 단축하여 개발에 집중할 수 있다.

Swagger 버전

Swagger는 버전별로 차이가 있다.

Swagger 3.x.x 이후 부터 접속 url 변경 등 2.x.x 와 3.x.x 는 차이점이 있으며 해당 프로젝트는 Swagger 2.9.2 사용한다.

Swagger 설정

  • Gradle에 Swagger 의존성을 추가해준다.
//swagger
	implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2'
	implementation group: 'io.springfox', name: 'springfox-swagger2', version: '2.9.2'
  • Swagger 버전 호환 이슈 해결
    1. org.springframework.context.ApplicationContextException: Failed to start bean 'documentationPluginsBootstrapper'; nested exception is java.lang.NullPointerException
    2. Caused by: java.lang.NullPointerException: null

Swagger 버전 호환 이슈로 위와 같은 오류가 발생했다.

해결법

spring:
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

application.yml이나 application.propertise에 위와 같은 코드를 작성한다.

  • Swagger config class 생성
@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket restAPI() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("j2kb.ponicon.scrap")) // 특정 패키지경로를 API문서화 한다.
                .paths(PathSelectors.any()) // apis중에서 특정 path조건 API만 문서화 하는 2차 필터
                .build()
                .groupName("API 1.0.0") // group별 명칭을 주어야 한다.
                .useDefaultResponseMessages(false); // 400,404,500 .. 표기를 ui에서 삭제한다.
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Spring Boot REST API test")
                .version("v0.0.1")
                .description("스크랩 JPA swagger api 입니다.")
                .build();
    }
}

useDefaultResponseMessages(false)를 사용하면 아래와 같은 화면이 출력되지 않는다.

적용 전

적용 후

private ApiInfo apiInfo()는 아래와 같은 Swagger 화면을 작성하는 메서드이다.

  • Swagger-ui 실행

현재 우리는 2.x.x 버전을 사용하므로 http://localhost:8081/swagger-ui.html

  • API에 Swagger @Annotation 추가
@Api(tags = "카테고리와 관련된 API") -> class
@ApiOperation(value = "카테고리 조회 API", notes = "UserId를 RequestParam으로 받아서 categoryService.categories 후 카테고리를 조회하는 역할을 합니다. /category/all?id=") -> Method
@ApiParam(value = "User의 id 값", example = "2") -> parameter
  • @Api tags : 해당하는 controller.java 에 대한 Title명칭 부여
  • @ApiOperation value : API에 대한 요약하여 작성 notes : API에 대한 자세한 설명 작성
  • @ApiParam value= 파라미터에 대한 설명 descriptionexample = 파라미터의 default 값이 있을 때 사용 required = true : 파라미터의 필수인지 표기 필수는 true, 아니면 false

위의 @Api 어노테이션을 통해 작성한 Swagger 화면

위의 @ApiOperation 어노테이션을 통해 작성한 Swagger 화면

위의 @ApiParam 어노테이션을 통해 작성한 Swagger 화면

Swagger 사용법

  • 위의 사진의 오른쪽 상단에 보면 Try it out이 있다. Try it out을 클릭한다.

  • 만약 Default값으로 값이 들어가 있다면 바로 Execute버튼을 클릭하면 실행이 된다. Default값 외의 다른 값으로 Test해보고 싶을 때는 다른 값을 넣어준다.

  • 위의 파라미터 값을 넣어서 실행한 화면이다.

단점

Swagger 코드가 들어가서 코드가 길어보이고 지저분해 보인다.

 

참고 블로그

https://velog.io/@dkatlf900/swagger

 

Swagger API DOC 구축부터 실행까지

Spring Boot REST API, Swagger 구축.

velog.io

 

728x90

+ Recent posts