문서화

유요한·2023년 1월 4일
0

Spring Boot

목록 보기
8/25
post-thumbnail

REST API 명세를 문서화는 방법 - Swagger or REST Docs

보통 Spring 에서 문서화를 할 때, SwaggerRestdocs를 사용하게 됩니다. Swagger는 마치 Postman처럼(직접 요청하듯이) API를 테스트해 볼 수 있는 화면을 제공하여 동작 테스트하는 용도에 조금 더 특화되어있습니다.

그렇다면 Swagger는 문서화도 되고 테스트도 가능하니 더 좋은 것이 아닌가라고 생각할 수 있습니다. 하지만 Swagger를 사용할 경우 명확한 단점이 존재합니다.

  1. 로직에 애노테이션을 통해 명세를 작성하게 되는데 지속적으로 사용하게 된다면 명세를 위한 코드들이 많이 붙게되어 전체적으로 가독성이 떨어진다.

  2. 테스트 기반이 아니기에 문서가 100% 정확하다고 확신할 수 없다.

  3. 모든 오류에 대한 여러 가지 응답을 문서화할 수 없다.

아래 코드는 swagger를 적용한 코드인데 해당 애노테이션들이 계속 붙게되면서 코드가 가독성이 매우 떨어집니다

// swagger 예시
public class SignupForm {

    @ApiModelProperty(value = "카카오 id", required = true, example = "1")
    private Long id;

    @ApiModelProperty(value = "카카오 image url", required = true, example = "\"http://k.kakaocdn.net\"")
    private String imageFileUrl;
}

반면에 REST Docs를 사용하면 다음과 같은 이점이 있습니다.

  1. 테스트 기반으로 문서가 작성되어 테스트 코드가 일치하지 않으면 테스트 빌드가 실패하게 되기 때문에 문서를 신뢰할 수 있다.

  2. 테스트 코드에서 명세를 작성하기 때문에 비즈니스 로직의 가독성에 영향을 미치지 않는다.

Swagger

  • OAS(Open Api Specification)를 위한 프레임워크
  • API 문서화를 어노테이션을 이용해서 데이터를 직접 입력하고 API 응답 결과를 받아볼 수 있음
  • Swagger 문서 경로 : http://localhost:8080/swagger-ui/index.html

build.gradle

implementation 'io.springfox:springfox-boot-starter:3.0.0'

Swagger 설정을 정의한 코드입니다.

  • .consume()과 .produces()는 각각 Request Content-Type, Response Content-Type에 대한 설정입니다.(선택)
  • .apiInfo()는 Swagger API 문서에 대한 설명을 표기하는 메소드입니다. (선택)
  • .apis()는 Swagger API 문서로 만들기 원하는 basePackage 경로입니다. (필수)
  • .path()는 URL 경로를 지정하여 해당 URL에 해당하는 요청만 Swagger API 문서로 만듭니다.(필수)

config아래에 SwaggerConfig를 생성

package com.example.Belog.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

@Configuration
public class SwaggerConfig {
    @Bean
    public Docket api() {
        return  new Docket(DocumentationType.OAS_30)
                // ApiSelectorBuilder 생성
                .select()
                // api 문서화를 할 패키지 경로
                .apis(RequestHandlerSelectors.basePackage("com.example.Belog.controller"))
                // 어떤 API URL에 대해서 문서화할지
                // PathSelectors.any() : 모든 API에 대한 URL을 문서화
                // PathSelectors.ant("/member/**") : 특정 URL을 지정해서 문서화
                .paths(PathSelectors.any())
                .build();
    }
}

.select()를 들어가면 아래와 같이 나온다.

이대로 실행하면 에러가 발생하는데 Spring Boot 2.6 버전 이후에 spring.mvc.pathmatch.matching-strategy 값이 ant_path_matcher에서 path_pattern_parser로 변경되면서 몇몇 라이브러리에 오류가 발생할 수 있다.

그래서 properties나 yml에 spring.mvc.pathmatch.matching-strategy=ant_path_matcher 설정을 해주면 오류가 발생하지 않습니다.

이제 들어가보자!

Swagger 문서 경로에 들어가면 아래와 같은 창이 뜹니다.

이 부분도 수정할 수 있습니다.

  private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("API 문서")
                .description("API에 대해서 설명해주는 문서입니다.")
                .version("1.0")
                .build();
    }

이렇게 private로 작성한 다음 Docket api()에 추가 해줍니다.

.apiInfo(apiInfo())

이렇게 변하는 것을 볼 수 있습니다.

그리고 예를들어, user-controller에 들어가면

이렇게 뜨는데 기본적으로 제공되는 기능입니다. 이것을 끌 수 있습니다.
Docket api() 메소드에 추가해줍니다.

 .useDefaultResponseMessages(false);

없어지는 것을 볼 수 있습니다.

swagger 장점인 문서화 페이지에서 실행할 수 있습니다.

Try it out 클릭하면

이 버튼이 뜨는데

결과 값이 나오는 것을 볼 수 있습니다.

만약 직접 입력받는 것을 끄려고한다면

이렇게 하면 입력받는 창이 사라집니다.

여기는 security에 관련되어 보입니다.

설정

  • Spring boot 2.6.4
  • jjwt 0.11.5
  • Swagger 3.0.0

swagger에서 request header에 인증 토큰을 넣고 싶은 경우

Swagger에서는 기본적으로 request header값을 설정하는 부분이 없다. @configuration이 달린 Swagger 설정 클래스에서 이를 설정해주어야 한다.

   // 인증 방식 설정
    private SecurityContext securityContext(){
        return SecurityContext.builder()
                .securityReferences(defaultAuth())
                .build();
    }
    
     private List<SecurityReference> defaultAuth(){
        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
        authorizationScopes[0] = authorizationScope;
        return Arrays.asList(new SecurityReference("Authorization", authorizationScopes));
    }
    
    // 버튼 클릭 시 입력 값 설정
    private ApiKey apiKey(){
        return new ApiKey("Authorization", "Bearer", "header");
    }   

Docket api() 메소드에 추가해줍니다.

.securityContexts(Arrays.asList(securityContext()))
.securitySchemes(Arrays.asList(apiKey()))

여기까지 해주면

자물쇠표시가 생겼습니다.
이제 클릭을 하면

이런 창이 나오는데 이 부분에 토큰을 설정을 해서 보내줄 수 있습니다.
Swagger 전역에서 jwt 토큰 값을 설정할 때 Bearer jwt토큰값과 같이 토큰 값 앞에 Bearer를 붙여줘야 하는 점도 주의해야 합니다.

제대로 치고 들어가면


정보가 나옵니다.

본격적으로 swagger를 꾸미겠습니다.

@Tag & @Operation

package com.example.Belog.controller;

import com.example.Belog.domain.UserDTO;
import com.example.Belog.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.List;
import java.util.Map;
import java.util.Optional;

// API 그룹 설정
// name : 태그의 이름
// description : API 그룹에 대한 설명
@Tag(name = "user check", description = "API 상태 체크")
@RestController
@Log4j2
@AllArgsConstructor
@RequestMapping("/user")
public class UserController {

    private UserService userService;


    // 모든 회원 정보를 가져오는 API
    @GetMapping("/")
    @Tag(name = "user check")
    @Operation(summary = "전체 불러오기 API", description = "모든 유저들을 불러오는 API입니다.")
    public ResponseEntity<List<UserDTO>> getAllUser() {
        List<UserDTO> userDTOList = userService.getAllUser();
        return ResponseEntity.status(HttpStatus.OK).body(userDTOList);
    }

    // 회원 정보를 가져오는 API
    @GetMapping("/{userEmail}")
    @Tag(name = "user check")
    @Operation(summary = "불러오기 API", description = "특정 유저들을 불러오는 API입니다.")
    public ResponseEntity<?> getUser(@PathVariable String userEmail) {
        Optional<UserDTO> userDTO = userService.getUser(userEmail);
        if (userDTO.isPresent()) {
            return ResponseEntity.status(HttpStatus.NO_CONTENT).body(userDTO);
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
        }
    }


    /**
     * 회원 가입 API
     *
     * @return ResponseEntity<UserResponse> 201 Created, 가입된 회원의 정보
     */
    @PostMapping("/")
    @Tag(name = "user check")
    @Operation(summary = "회원가입 API", description = "회원가입하는 API입니다.")
    public ResponseEntity<String> signUp(@Validated @RequestBody UserDTO userDTO, Errors errors, HttpServletResponse resp) {


        // post요청시 넘어온 user 입력값에서 Validation에 걸리는 경우
        if (errors.hasErrors()) {
            // 유효성 통과 못한 필드와 메시지를 핸들링
            // 회원가입 실패시 message 값들을 모델에 매핑해서 View로 전달
            Map<String, String> validatorResult = userService.validateHandling(errors);
            // map.keySet() -> 모든 key값을 갖고온다.
            // 그 갖고온 키로 반복문을 통해 키와 에러 메세지로 매핑
            for (String key : validatorResult.keySet()
            ) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(validatorResult.get(key));
            }
        }
        if (userService.signUp(userDTO)) {
            log.info("result : " + userDTO.getUserId());
            log.info("result : " + userDTO.getUserEmail());

            Cookie cookie = new Cookie("userEmail", userDTO.getUserEmail());
            // 30분
            cookie.setMaxAge(1800);
            resp.addCookie(cookie);
        }
        return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공했습니다.");
    }

    // 로그인
    @PostMapping("/loginUser")
    @Tag(name = "user check")
    @Operation(summary = "로그인 API", description = "로그인하는 API입니다.")
    public String login(@RequestBody UserDTO userDTO, HttpSession session) {
        UserDTO loginUser = userService.login(userDTO.getUserEmail(), userDTO.getUserPw());
        if (loginUser != null) {
            session.setAttribute("userId", loginUser.getUserId());
            session.setAttribute("userEmail", loginUser.getUserEmail());
            return "로그인에 성공했습니다.";
        }
        return "아이디가 없습니다.";
    }

    @GetMapping("/logOut")
    @Tag(name = "user check")
    @Operation(summary = "로그아웃 API", description = "로그아웃 하는 API입니다.")
    public String logOut(HttpServletRequest req) {
        req.getSession().invalidate();
        return "로그아웃 하셨습니다";
    }

    // 회원 정보 수정
    @PutMapping("/")
    @Tag(name = "user check")
    @Operation(summary = "수정 API", description = "유저 정보를 수정하는 API입니다.")
    public ResponseEntity<?> update(@RequestBody UserDTO userDTO, HttpSession session) {
            userService.update(userDTO);
            return ResponseEntity.status(HttpStatus.OK).body(userDTO);

    }

    // 회원 탈퇴(삭제) API
    // 204 : NO_CONTENT
    @DeleteMapping("/{userId}")
    @Tag(name = "user check")
    @Operation(summary = "삭제 API", description = "유저를 삭제하는 API입니다.")
    public ResponseEntity<Object> delete(@PathVariable Long userId) {
        userService.delete(userId);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).body(null);
    }

    // 중복체크
    @PostMapping("/user/email-check")
    @Tag(name = "user check")
    @Operation(summary = "중복체크 API", description = "userEmail이 중복인지 체크하는 API입니다.")
    // ajax를 쓸 때는 반드시 @ResponseBody를 써야한다.
    public @ResponseBody int emailCheck(@RequestParam("userEmail") String userEmail) {
        log.info("userEmail : " + userEmail);
        return userService.emailCheck(userEmail);
    }


}

@Schema

@Schema에 대해 알아 보겠습니다. example은 예시 값이고 required는 필수 여부 입니다. description은 필드에 대한 설명입니다. @Schema는 각 항목들에 대한 설명 또는 한글항목명이나 body에 설정될 기본 값, 허용가능한 입력값 enum 등을 정의할 수 있습니다. description에는 한글항목명을 쓰면 되고, Swagger상에 기본적으로 보여줄 Example Value의 기본값은 defaultValue에 정의합니다. 만일, 특정 값들만 허용하는것을 Swagger문서상으로 보여주고 싶다면 allowableValues에 설정해주면 됩니다.

@Schema(description = "사용자")
@Getter @Setter
public class UserValue {

	@Pattern(regexp = "[0-2]")
	@Schema(description = "유형", defaultValue = "0", allowableValues = {"0", "1", "2"})
	private String type;

	@Email
	@Schema(description = "이메일", nullable = false, example = "abc@jiniworld.me")
	private String email;

	@Schema(description = "이름")
	private String name;

	@Pattern(regexp = "[1-2]")
	@Schema(description = "성별", defaultValue = "1", allowableValues = {"1", "2"})
	private String sex;

	@DateTimeFormat(pattern = "yyMMdd")
	@Schema(description = "생년월일", example = "yyMMdd", maxLength = 6)
	private String birthDate;

	@Schema(description = "전화번호")
	private String phoneNumber;

	@Schema(description = "비밀번호")
	private String password;

}
package com.example.Belog.domain;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

@Getter
@ToString
@NoArgsConstructor
public class UserDTO {

    @Schema(description = "유저 번호")
    private Long userId;

    @Schema(description = "이메일", nullable = false, example = "abc@jiniworld.me", required = true)
    @NotBlank(message = "이메일은 필수 입력사항 입니다.")
    // 이메일 형식이여야 함
    @Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String userEmail;

    @Schema(description = "비밀번호", required = true, nullable = false)
    @NotBlank(message = "비밀번호는 필수 입력 값입니다.")
//    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\\\d)(?=.*[~!@#$%^&*()+|=])[A-Za-z\\\\d~!@#$%^&*()+|=]{8,16}$\\n",
//            message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 16자의 비밀번호여야 합니다.")
    private String userPw;

    @Schema(description = "이름", nullable = false, required = true)
    @NotBlank(message = "이름은 필수 입력사항 입니다.")
    private String userName;

    @Schema(description = "주소", nullable = false, required = true)
    @NotBlank(message = "주소는 필수 입력사항 입니다.")
    private String userAddr;

    @Schema(description = "상세 주소", nullable = false, required = true)
    @NotBlank
    private  String userAddrDetail;

    @Schema(description = "그 외 주소", nullable = false, required = true)
    @NotBlank
    private String userAddrEtc;


    @Builder
    public UserDTO(
            String userEmail,
            String userPw,
            String userName,
            String userAddr,
            String userAddrDetail,
            String userAddrEtc
            ) {
        this.userEmail = userEmail;
        this.userPw = userPw;
        this.userName = userName;
        this.userAddr = userAddr;
        this.userAddrDetail = userAddrDetail;
        this.userAddrEtc = userAddrEtc;
    }

}

description에 한글명을 작성하고, defaultValue를 통해 기본값을 제공할 수 있습니다. allowableValues 설정을 하면 Schema 정보에서 리스트 형태로 들어갈 수 있는 데이터 정보를 볼 수 있습니다.

@ApiResponse

  • responseCode : http 상태코드
  • description : response에 대한 설명
  • content : Response payload 구조
    • schema : payload에서 이용하는 Schema
      • hidden : Schema 숨김여부
      • implementation : Schema 대상 클래스

@ApiResponse를 설정하지 않으면 Swagger UI에서는 상태코드 200과 비어있는 response body를 보여줍니다. 무늬뿐인 response 구조가 아닌, api 조회 성공 및 실패시 발생될 상태코드 및 Response 구조를 설정하고자 한다면 @ApiResponse를 설정하면 됩니다.

예를들어, 회원 조회 API에서는 200, 404 상태코드가 설정되어 있습니다. 조회가 성공되었을 시, 200 상태코드와 CommonResponse<User> reseponseBody가 반환되고
조회 실패시엔, 404 상태코드와 ErrorResponse responseBody가 반환됩니다.

여러개를 사용하고자 할 때는 다음과 같이 합니다.

@GetMapping("/{id}")
@ApiResponses(value = {
    @ApiResponse(responseCode = "200", description = "회원 조회 성공", content = @Content(schema = @Schema(implementation = UserResponse.class))),
    @ApiResponse(responseCode = "404", description = "존재하지 않는 리소스 접근", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) })
@Operation(summary = "회원 조회", description = "id를 이용하여 user 레코드를 조회합니다.")
public ResponseEntity<? extends BasicResponse> select(
    @PathVariable("id") long id) {
  ...
}

@Parameter

  • name : 파라미터 이름
  • description : 파라미터 설명
  • in : 파라미터 위치
    • query : query string 방식으로 전달하는 경우
  • header : header에 담겨 전달하는 경우
  • path : pathvariable 방식으로 전달하는 경우
  • cookie : cookie에 담겨 전달하는 경우

@ApiResponse 와 마찬가지로 @Parameters 애너테이션에 @Parameter 리스트를 담아 api 메서드에 설정할 수 있고, @Operation 애너테이션의 parameters 요소에 설정할 수 있습니다. 그리고, 파라미터의 경우, api method의 인자값에 붙여 명시적으로 설정할 수도 있습니다.

@GetMapping("/posts/{id}")
public PostsResponseDto findById(@Parameter(name = "id", description = "posts 의 id", in = ParameterIn.PATH)
                                        @PathVariable Long id) {
    return postsService.findById(id);
}

path로부터 들어올 파라미터인 id에 대한 설정을 추가하였다. @PathVariable 설정 앞에 @Parameter 어노테이션 설정을 추가했는데, 메서드의 인자앞에 직접 설정할 경우에는 name을 생략할 수 있다. 만일 @Parameters 나 @Operations에 파라미터를 설정할 경우에는 어떤 파라미터에 대한 설정인지 알 수 없기 때문에 반드시 name을 설정해 줘야 합니다.

@Parameter의 Target은 ANNOTATION_TYPE, FIELD, METHOD, PARAMETER이렇게 4가지로 명시되어 있지만, PARAMETER에 작성하면 name 설정을 해줄 필요가 없어 주로 PARAMETER에 작성하여 명시적으로 설정하는 것을 선호하는 것 같았습니다.

public PostResponseDto findById(
		@Parameter(description = "게시글 의 id", in = ParameterIn.PATH) 
									@PathVariable Long id) {

REST Docs란?

  • 테스트 코드 기반으로 Restful API 문서를 돕는 도구입니다.

  • Asciidoctor를 이용해서 HTML 등등 다양한 포맷으로 문서를 자동으로 출력할 수 있습니다.

  • RestDocs의 가장 큰 장점은 테스트 코드 기반으로 문서를 작성한다는 점입니다.

  • API Spec과 문서화를 위한 테스트 코드가 일치하지 않으면 테스트 빌드를 실패하게 되어 테스트 코드로 검증된 문서를 보장할 수 있습니다.

  • 테스트 코드가 강제

profile
발전하기 위한 공부

0개의 댓글