이번 스터디 때 Swagger를 이용해서 API를 구현해보았습니다. 해당 내용을 까먹지 않기 위해서 정리 겸 포스팅해보려고 합니다!
먼저 예제로는 간단히 회원가입 API를 구현해볼 것입니다. (소셜로그인은 배제한 일반 회원 가입 형식)
먼저 MemberRequestDTO와 MemberResponseDTO를 생성해준다.
엔티티와 별개로 DTO를 만드는 이유에는 크게 4가지의 이유가 있다.
1) 관심사의 분리
@Entity
@Getter
@DynamicUpdate
@DynamicInsert
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Store extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String address;
@ColumnDefault("0")
private Float score;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "region_id")
private Region region;
@OneToMany(mappedBy = "store", cascade = CascadeType.ALL)
private List<Mission> missionList = new ArrayList<>();
@OneToMany(mappedBy = "store", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();
@Override
public String toString() {
return "Store{" +
"id=" + id +
", name='" + name + '\'' +
", address='" + address + '\'' +
", score=" + score +
", region=" + (region != null ? region.getName() : "N/A") + // region의 이름 출력
'}';
}
public void setRegion(Region region) {
if(this.region != null)
region.getStoreList().remove(this);
this.region = region;
region.getStoreList().add(this);
}
}
public class StoreRequestDTO {
@Getter
public static class addStoreDTO {
@NotNull
Long regionId;
@NotBlank
String name;
@Size(min = 5, max = 12)
String address;
}
}
2) Validation 로직 및 불필요한 코드 등과의 분리
3) API 스펙의 유지
4) API 스펙의 파악이 용이
DTO 분리의 이유에 설명은 이쯤에서 마무리하겠다. 이후에 적혀진 건 회원가입 API에 필요한 DTO 코드이다.
public class MemberRequestDTO {
@Getter
public static class JoinDto{
String name;
Integer gender;
Integer birthYear;
Integer birthMonth;
Integer birthDay;
String address;
String specAddress;
List<Long> preferCategory;
}
}
public class MemberResponseDTO {
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class JoinResultDTO{
Long memberId;
LocalDateTime createdAt;
}
}
Converter
란? 스프링을 사용해 애플리케이션을 개발할 때 문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것처럼 타입을 변환할 때 사용하는 도구
member
객체를 JoinResultDTO
객체로 변환하기 위한 Converter를 생성해보자. public class MemberConverter {
public static MemberResponseDTO.JoinResultDTO toJoinResultDTO(Member member){
return MemberResponseDTO.JoinResultDTO.builder()
.memberId(member.getId())
.createdAt(LocalDateTime.now())
.build();
}
}
해당 코드는 POST /members
URL로 회원가입 요청을 받도록 설계된 API이다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberRestController {
private final MemberCommandService memberCommandService;
@PostMapping("/")
public ApiResponse<MemberResponseDTO.JoinResultDTO> join(@RequestBody @Valid MemberRequestDTO.JoinDto request){
return null;
}
}
Spring Data JPA 를 사용할 것이기 때문에 인터페이스로 만든다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
@Service
@RequiredArgsConstructor
public class MemberCommandServiceImpl implements MemberCommandService{
private final MemberRepository memberRepository;
@Override
public Member joinMember(MemberRequestDTO.JoinDto request) {
return null;
}
}
public class MemberConverter {
public static MemberResponseDTO.JoinResultDTO toJoinResultDTO(Member member){
return MemberResponseDTO.JoinResultDTO.builder()
.memberId(member.getId())
.createdAt(LocalDateTime.now())
.build();
}
public static Member toMember(MemberRequestDTO.JoinDto request){
Gender gender = null;
switch (request.getGender()){
case 1:
gender = Gender.MALE;
break;
case 2:
gender = Gender.FEMALE;
break;
case 3:
gender = Gender.NONE;
break;
}
return Member.builder()
.address(request.getAddress())
.specAddress(request.getSpecAddress())
.gender(gender)
.name(request.getName())
.memberPreferList(new ArrayList<>())
.build();
}
}
memberPreferList(new ArrayList<>())
처럼 초기화해줘야한다!원래 이메일을 소셜 로그인에서 처리한 후, 나머지 정보를 기입 받는 것이 맞는 순서이나, 소셜 로그인 없이 구현하기 때문에 domain 내에서 이메일은 nullable로 바꾸고 진행한다.
따로 처리해야하므로 먼저 서비스부터 작성해보겠다.
@Service
@RequiredArgsConstructor
public class MemberCommandServiceImpl implements MemberCommandService{
private final MemberRepository memberRepository;
@Override
public Member joinMember(MemberRequestDTO.JoinDto request) {
Member newMember = MemberConverter.toMember(request);
return null;
}
}
앞서 MemberRequestDTO
를 생성할 때 선호하는 음식 카테고리 정보를List<Long>
으로 받아왔다. 이는 프론트엔드에서 카테고리를 조회하는 API를 호출한 후, 이후에 다시 음식 카테고리의 ID 값을 프론트엔드가 넘겨준다고 가정했기 때문이다.
@Getter
public static class JoinDto {
String name;
Integer gender;
Integer birthYear;
Integer birthMonth;
Integer birthDay;
String address;
String specAddress;
List<Long> preferCategory;
}
해당 로직에 필요한 FoodCategoryRepository
도 생성해준다.
public interface FoodCategoryRepository extends JpaRepository<FoodCategory, Long> {
}
음식 선호 카테고리도 처리가 가능해졌으니, 이제 회원가입을 위한 서비스를 마저 구현해보자.
@Service
@RequiredArgsConstructor
public class MemberCommandServiceImpl implements MemberCommandService{
private final MemberRepository memberRepository;
private final FoodCategoryRepository foodCategoryRepository;
@Override
@Transactional
public Member joinMember(MemberRequestDTO.JoinDto request) {
Member newMember = MemberConverter.toMember(request);
List<FoodCategory> foodCategoryList = request.getPreferCategory().stream()
.map(category -> {
return foodCategoryRepository.findById(category).orElseThrow(() -> new FoodCategoryHandler(ErrorStatus.FOOD_CATEGORY_NOT_FOUND));
}).collect(Collectors.toList());
List<MemberPrefer> memberPreferList = MemberPreferConverter.toMemberPreferList(foodCategoryList);
memberPreferList.forEach(memberPrefer -> {memberPrefer.setMember(newMember);});
return memberRepository.save(newMember);
}
}
FOOD_CATEGORY_NOT_FOUND
라는 ErrorStatus도 추가해줘야한다! public void setMember(Member member) {
if (this.member != null)
member.getMemberPreferList().remove(this);
this.member = member;
member.getMemberPreferList().add(this);
}
public void setFoodCategory(FoodCategory foodCategory) {
this.foodCategory = foodCategory;
}
MemberPrefer
코드에 해당 set 기능도 추가해준다. DTO에서 사용자 선호 음식을 저장하는 MemberPrefer 엔티티로 변환하기 위해 MemberPreferConverter
를 만들어준다.
public class MemberPreferConverter {
public static List<MemberPrefer> toMemberPreferList(List<FoodCategory> foodCategoryList) {
return foodCategoryList.stream()
.map(foodCategory ->
MemberPrefer.builder()
.foodCategory(foodCategory)
.build()
).collect(Collectors.toList());
}
}
회원가입을 위한 비즈니스 로직이 완성되었기 때문에 controller를 완성해준다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberRestController {
private final MemberCommandService memberCommandService;
@PostMapping("/")
public ApiResponse<MemberResponseDTO.JoinResultDTO> join(@RequestBody @Valid MemberRequestDTO.JoinDto request){
Member member = memberCommandService.joinMember(request);
return ApiResponse.onSuccess(MemberConverter.toJoinResultDTO(member));
}
}
swagger
를 통해 api가 잘 작동되는지 확인해보자.
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI UMCstudyAPI() {
Info info = new Info()
.title("UMC Server WorkBook API")
.description("UMC Server WorkBook API 명세서")
.version("1.0.0");
String jwtSchemeName = "JWT TOKEN";
// API 요청헤더에 인증정보 포함
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
// SecuritySchemes 등록
Components components = new Components()
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP) // HTTP 방식
.scheme("bearer")
.bearerFormat("JWT"));
return new OpenAPI()
.addServersItem(new Server().url("/"))
.info(info)
.addSecurityItem(securityRequirement)
.components(components);
}
}
이제 http://localhost:8080/swagger-ui/index.html#/ 로 접속을 해보면 이 곳에서 테스트가 가능하다!
⁉️null인 값에 대해 그냥 null을 insert 해버려서 생기는 문제가 발생한다면
Member 엔티티에 @DynamicUpdate
, @DynamicInsert
를 추가해준다.
이 두 개는 insert와 update 시 null 인 경우는 그냥 쿼리를 보내지 않도록 해준다.
🏖️ 출처:
https://s-y-130.tistory.com/362
Copyright © 2023 최용욱(똘이) All rights reserved.
Copyright © 2024 김준환(제이미) All rights reserved.