Spring REST API - 구현편
Spring REST API - 기본편에서 학습한 내용을 토대로 실제로 구현해 보겠습니다.
개발 환경
-
Spring boot 2.6.3
-
Spring Data JPA
-
H2
- springdoc-openapi v1.6.6
- gradle
Rest API 문서 도구로 무엇을 사용할까?
springdoc과 springfox는 둘다 swagger ui를 제공해주며 프로덕트 코드에 Annotaion을 추가 해주면 손쉽게 swagger-ui 웹 문서도 볼 수 있고 postman 처럼 테스트도 가능합니다. 단점은 프로덕트 코드에 Annotaion가 너무 많이 들어가서 코드의 가독성이 떨어지는 점과 문서코드와 프로덕트 코드가 시간이 지남에 따라 달라 질 수 있다는 점입니다. (주석 처럼요..) springdoc과 springfox의 설정 방법이나 제공되는 기능이 다르지만 swagger-ui를 사용하는 부분은 동일합니다. 사실 springfox가 한동안 업데이트 되지 않을 때 springfox를 보안하여 나온 것이 springdoc이기 때문에 springdoc이 좀 더 낫다고 생각됩니다.
(참고 https://springdoc.org/modules.html)
Spring REST Docs는 테스트 코드 기반의 문서 작성 툴인데, 테스트가 성공하면 문서 파일이 프로젝트 내에 생성됩니다. 따라서 프로덕트 코드에 문서 Annotation이 포함되지 않기 때문에 코드의 가독성이 좋습니다. 또한 테스트 코드와 긴밀하게 연결될 수 있어 시간이 지남에 따라 프로덕트 코드와 달라지지 않을 가능성이 높습니다.( 테스트 코드를 잘 관리한다면요…) 단점은 springdoc이나 springfox에 비해 적용하기가 어려운 부분이 있고 export되는 문서가 swagger에 비해 퀄리티가 떨어집니다. 또한 swagger에서 제공되는 테스트 툴이 없기 때문에 클라이언트 개발자는 postman을 별도로 사용해야 됩니다.
우선 이번 글에서는 springdoc을 이용하여 Rest API 데모 버전을 포스팅하며 데모 버전의 소스코드는 Github에서 확인하실 수 있습니다.
Rest API 설정
build.gradle
dependencies {
...
implementation 'org.springdoc:springdoc-openapi-ui:1.6.6'
implementation 'org.springdoc:springdoc-openapi-hateoas:1.6.6'
compileOnly 'org.springframework.boot:spring-boot-starter-hateoas:2.6.4'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'com.h2database:h2'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
open api 설정
@Configuration
public class OpenApiConfig {
@Bean
public GroupedOpenApi memberApi() {
return GroupedOpenApi.builder()
.group("회원 API v1")
.pathsToMatch("/api/v1/member/**")
.build();
}
@Bean
public GroupedOpenApi productApi() {
return GroupedOpenApi.builder()
.group("상품 API v1")
.pathsToMatch("/api/v1/product/**")
.build();
}
@Bean
public OpenAPI openAPI(@Value("${springdoc.version}") String apiVersion) {
return new OpenAPI().info(new Info().title("API Demo")
.version(apiVersion)
.description("API Demo Document"));
}
}
Controller
@RestController
@RequiredArgsConstructor
@Tag(name = "회원", description = "회원 API")
@ApiResponse(responseCode = ResponseCode.ERROR_400, description = "올바르지 않는 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = ResponseCode.ERROR_500, description = "서버 내 오류 발생", content = @Content(schema = @Schema(hidden = true)))
@ApiResponse(responseCode = ResponseCode.ERROR_503, description = "네트워크 문제 또는 서버 이용 불가", content = @Content(schema = @Schema(hidden = true)))
@RequestMapping(value = "/api/v1/member", produces = MediaTypes.HAL_JSON_VALUE)
public class MemberController {
private final MemberService memberService;
@Operation(summary = "회원 목록 조회", description = "모든 회원 정보를 조회합니다.", responses = {
@ApiResponse(responseCode = ResponseCode.SUCCESS_200,
description = "조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MemberModel.class))),
links = {
@Link(name = ResponseLinkName.SELF, description = "현재", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
}),
@Link(name = ResponseLinkName.CREATE, description = "등록", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.POST)
})
})
})
@GetMapping
public ResponseEntity<CollectionModel<MemberModel>> getAllMember() {
return ResponseEntity.ok()
.body(this.memberService.findAllMember());
}
@Operation(summary = "회원 조회", description = "회원 id로 회원 정보를 조회합니다.", responses = {
@ApiResponse(responseCode = ResponseCode.SUCCESS_200, description = "조회 성공",
content = @Content(schema = @Schema(implementation = MemberModel.class)),
links = {
@Link(name = ResponseLinkName.SELF, description = "현재", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri/{id}"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
}),
@Link(name = ResponseLinkName.UPDATE, description = "수정", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri/{id}"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.PUT)
}),
@Link(name = ResponseLinkName.DELETE, description = "삭제", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri/{id}"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.DELETE)
}),
@Link(name = ResponseLinkName.LIST, description = "목록", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
})
}),
@ApiResponse(responseCode = ResponseCode.NO_CONTENT_204, description = "조회된 정보가 없음", content = @Content(schema = @Schema(hidden = true)))
})
@GetMapping("/{id}")
public ResponseEntity<MemberModel> findMember(@Parameter(description = "회원 ID") @PathVariable Long id) {
return ResponseEntity.ok()
.body(this.memberService.findMember(id));
}
@Operation(summary = "회원 등록", description = "신규 회원을 등록합니다.", responses = {
@ApiResponse(responseCode = ResponseCode.SUCCESS_201, description = "등록 성공",
content = @Content(schema = @Schema(implementation = MemberModel.class)),
links = {
@Link(name = ResponseLinkName.SELF, description = "회원 조회", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri/{id}"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
}),
@Link(name = ResponseLinkName.LIST, description = "회원 목록", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
})
}),
@ApiResponse(responseCode = ResponseCode.ERROR_409, description = "이미 등록된 회원", content = @Content(schema = @Schema(hidden = true)))
})
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<MemberModel> createMember(@RequestBody @Valid MemberDto memberDto, Errors errors) {
if (errors.hasErrors()) {
throw new InvalidParameterException("입력 값이 올바르지 않습니다.", errors);
}
MemberModel createdMember = this.memberService.saveMember(memberDto);
return ResponseEntity.created(createdMember.getRequiredLink(IanaLinkRelations.SELF).toUri())
.body(createdMember);
}
@Operation(summary = "회원 수정", description = "회원 정보를 수정합니다.", responses = {
@ApiResponse(responseCode = ResponseCode.SUCCESS_200, description = "수정 성공",
content = @Content(schema = @Schema(implementation = MemberModel.class)),
links = {
@Link(name = ResponseLinkName.SELF, description = "회원 조회", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri/{id}"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
}),
@Link(name = ResponseLinkName.LIST, description = "회원 목록", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
})
}),
})
@PutMapping("/{id}")
public ResponseEntity<MemberModel> updateMember(@Parameter(description = "회원 ID") @PathVariable Long id,
@RequestBody @Valid MemberDto memberDto, Errors errors) {
if (errors.hasErrors()) {
throw new InvalidParameterException("입력 값이 올바르지 않습니다.", errors);
}
return ResponseEntity.ok()
.body(this.memberService.updateMember(id, memberDto));
}
@Operation(summary = "회원 삭제", description = "회원 정보를 삭제합니다.", responses = {
@ApiResponse(responseCode = ResponseCode.SUCCESS_200, description = "삭제 성공",
content = @Content(schema = @Schema(implementation = LinkFormat.class)),
links = {
@Link(name = ResponseLinkName.LIST, description = "회원 목록", parameters = {
@LinkParameter(name = ResponseLinkParameter.HREF, expression = "$api.uri"),
@LinkParameter(name = ResponseLinkParameter.TYPE, expression = HttpMethod.GET)
})
}),
})
@DeleteMapping("/{id}")
public ResponseEntity<org.springframework.hateoas.Link> deleteMember(@Parameter(description = "회원 ID") @PathVariable Long id) {
return ResponseEntity.ok()
.body(this.memberService.deleteMember(id));
}
컨트롤러의 문서 Annotation보니간… Spring Rest Docs로 바꿔야되나 생각될 정도로… 가독성이 떨어집니다. IDE에서 @Operation 부분을 접어 놓으면 그나마 좋긴한데.. 아무튼 추후 버전에서는 이런 부분이 개선되었으면 하는 바램입니다.
Service
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberModelAssembler memberModelAssembler;
private final MemberRepository memberRepository;
/*
회원 목록 조회
*/
@Transactional(readOnly = true)
public CollectionModel<MemberModel> findAllMember() {
return memberModelAssembler.toCollectionModel(this.memberRepository.findAll());
}
/*
회원 조회
*/
@Transactional(readOnly = true)
public MemberModel findMember(Long id) {
return memberModelAssembler.toModelDetail(this.memberRepository.findById(id)
.orElseThrow(NotFoundDataException::new));
}
/*
회원 등록
*/
@Transactional
public MemberModel saveMember(MemberDto memberDto) {
if (this.memberRepository.existsByNameAndBirthDay(memberDto.getName(), memberDto.getBirthDay()))
throw new ConflictDataException("이미 등록된 회원입니다.");
return memberModelAssembler.toModelUpdate(this.memberRepository.save(memberDto.toEntity()));
}
/*
회원 수정
*/
@Transactional
public MemberModel updateMember(Long id, MemberDto memberDto) {
Member member = this.memberRepository.findById(id)
.orElseThrow(() -> new EmptyResultDataAccessException("존재하지 않는 회원입니다.", 1));
member.updateAll(memberDto);
return memberModelAssembler.toModelUpdate(this.memberRepository.save(member));
}
/*
회원 삭제
*/
@Transactional
public Link deleteMember(Long id) {
Member member = this.memberRepository.findById(id)
.orElseThrow(() -> new EmptyResultDataAccessException("존재하지 않는 회원입니다.", 1));
this.memberRepository.delete(member);
return memberModelAssembler.toModelDelete();
}
}
Model 생성 관련 Class (HATEOAS)
@Component
public class MemberModelAssembler implements RepresentationModelAssembler<Member, MemberModel> {
private Errors errorsDummy;
public MemberModelAssembler() {
this.errorsDummy = new BeanPropertyBindingResult(new Object(), "dummy");
}
@Override
public MemberModel toModel(Member member) {
return member.toModel()
.add(getSelfLink(member.getId()));
}
public MemberModel toModelDetail(Member member) {
return member.toModel()
.add(getSelfLink(member.getId()))
.add(getUpdateLink(member.getId()))
.add(getDeleteLink(member.getId()))
.add(getListLink(false));
}
public MemberModel toModelUpdate(Member member) {
return member.toModel()
.add(getSelfLink(member.getId()))
.add(getListLink(false));
}
public Link toModelDelete() {
return getListLink(false);
}
@Override
public CollectionModel<MemberModel> toCollectionModel(Iterable<? extends Member> entities) {
return RepresentationModelAssembler.super.toCollectionModel(entities)
.add(getListLink(true))
.add(getCreateLink());
}
private Link getSelfLink(Long id) {
return linkTo(methodOn(MemberController.class)
.findMember(id))
.withSelfRel()
.withType(HttpMethod.GET);
}
private Link getListLink(boolean self) {
return self
? linkTo(methodOn(MemberController.class)
.getAllMember())
.withSelfRel()
.withType(HttpMethod.GET)
: linkTo(methodOn(MemberController.class)
.getAllMember())
.withRel(ResponseLinkName.LIST)
.withType(HttpMethod.GET);
}
private Link getCreateLink() {
return linkTo(methodOn(MemberController.class)
.createMember(null, this.errorsDummy))
.withRel(ResponseLinkName.CREATE)
.withType(HttpMethod.POST);
}
private Link getUpdateLink(Long id) {
return linkTo(methodOn(MemberController.class)
.updateMember(id, null, this.errorsDummy))
.withRel(ResponseLinkName.UPDATE)
.withType(HttpMethod.PUT);
}
private Link getDeleteLink(Long id) {
return linkTo(methodOn(MemberController.class)
.deleteMember(id))
.withRel(ResponseLinkName.DELETE)
.withType(HttpMethod.DELETE);
}
link를 생성할 때 2가지 방식으로 사용할 수 있는데
- linkTo(MemberController.class).slash(id).withSelfRel()
- linkTo(methodOn(MemberController.class).getMember(id)).withSelfRel()
이중 methodOn 사용하는 이유는?
프록시 메서드를 호출하기 때문에 실제 메서드를 호출하지 않지만, 메서드의 파라메터가 올바르게 입력되는지 type-safe됩니다. 또한 메서드 url이 변경 되면 자동으로 url이 업데이트 되므로 관리측면에서 좋습니다. 단점은 POST, PUT과 같은 파라메터와 검증 객체를 입력받는 메서드는 사용하기 힘들며 linkTo만 사용하는 방식에 비해 성능이 떨어집니다. 또한 각 컨트롤의 메서드가 다르기 때문에 각 모델마다 Assembler Class를 작성해야 하는 번거러움이 있습니다.
Model
@Getter
@Setter
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
@Builder
@Schema(description = "회원")
public class MemberModel extends AbstractRepresentationModel<MemberModel> {
@Schema(description = "회원번호", example = "2")
private Long id;
@Schema(description = "이름", example = "홍길동", maxLength = 30)
private String name;
@Schema(description = "생년월일", example = "901023", maxLength = 6)
private String birthDay;
@Schema(description = "이메일", example = "example@gmail.com", maxLength = 50)
private String email;
@Schema(description = "주소", nullable = true, example = "서울시 서대문구")
private String address;
}
Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@DynamicUpdate
public class Member implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String birthDay;
private String email;
private String address;
@Builder
public Member(Long id, String name, String birthDay, String email, String address) {
this.id = id;
this.name = name;
this.birthDay = birthDay;
this.email = email;
this.address = address;
}
public void updateAll(MemberDto memberDto) {
this.name = memberDto.getName();
this.birthDay = memberDto.getBirthDay();
this.email = memberDto.getEmail();
this.address = memberDto.getAddress();
}
public MemberModel toModel() {
return MemberModel.builder()
.id(this.id)
.name(this.name)
.birthDay(this.birthDay)
.email(this.email)
.address(this.address)
.build();
}
}
Spring boot 실행 후 http://localhost:8080/swagger-ui.html 로 접속하면 근사한 API 웹 문서 페이지가 보입니다. 또한 http://localhost:8080/v3/api-docs.yaml 주소를 입력하면 api 설정 파일을 다운로드 받을 수 있습니다.
회원 조회를 클릭하면 상세 정보와 함께 테스트 기능도 수행할 수 있습니다.
그외에 Response 200일 때 전달 받는 schma와 제공받는 links 정보도 표시됩니다.
다음에 시간이 나면 https://dezang.net/blog/2021/08/22/spring-rest-docs-oas-01 블로그 글 처럼 Spring Rest Docs를 적용해보고 싶네요..
참고
https://spring.io/guides/tutorials/rest/
댓글남기기