Swagger를 이용해서 API를 구현하기

동동주·2024년 11월 22일
1

이번 스터디 때 Swagger를 이용해서 API를 구현해보았습니다. 해당 내용을 까먹지 않기 위해서 정리 겸 포스팅해보려고 합니다!

먼저 예제로는 간단히 회원가입 API를 구현해볼 것입니다. (소셜로그인은 배제한 일반 회원 가입 형식)

1️⃣ DTO 생성

먼저 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);
    }
}
  • 하지만 DTO(Data Transfer Object)는 이름 그대로 데이터의 전달 입니다. DTO는 데이터만을 담고, 그 외의 기능 또는 동작 자체가 없어야 합니다.
public class StoreRequestDTO {
    @Getter
    public static class addStoreDTO {
        @NotNull
        Long regionId;
        @NotBlank
        String name;
        @Size(min = 5, max = 12)
        String address;
    }
}
  • 한 눈에 봐도 코드 길이의 차이가 보이죠? 이렇게 DTO에서는 원하는 데이터가 가져와서 담으면 된다.

2) Validation 로직 및 불필요한 코드 등과의 분리

  • @Valid 처리를 위해서는 @NotNull, @NotEmpty, @Size 등과 같은 어노테이션들을 필드에 붙여주어야 한다. (DTO 코드에 해당)
  • 반면에 JPA도 변수에 @Id, @Column 등과 같은 어노테이션들을 활용해 객체와 관계형 데이터베이스를 매핑해준다. (엔티티 코드에 해당)
    👉 즉, DTO와 엔티티를 분리하지 않는다면 두 어노테이션이 모두 담기게 되어 엔티티의 코드가 상당히 복잡해진다.

3) API 스펙의 유지

  • DTO를 이용해 분리하여 독립성을 높이고 변경이 전파되는 것 을 방지해야 한다.
  • 만약 우리가 응답을 위한 DTO 클래스를 활용하고 있으면, Entity 클래스의 변수가 변경되어도 API 스펙이 변경되지 않으므로 안정성을 확보할 수 있다.

4) API 스펙의 파악이 용이

  • DTO 코드를 통해 API 스펙, 즉 어떤 데이터를 담고 있는지 어느 정도 파악할 수 있다.

DTO 분리의 이유에 설명은 이쯤에서 마무리하겠다. 이후에 적혀진 건 회원가입 API에 필요한 DTO 코드이다.

  • MemberRequestDTO
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;
    }
}
  • MemberResponseDTO
public class MemberResponseDTO {

    @Builder
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class JoinResultDTO{
        Long memberId;
        LocalDateTime createdAt;
    }
}

2️⃣ Converter 생성

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();
    }
}

3️⃣ Controller 생성 (미완성)

해당 코드는 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;
    }
}
  • return null;은 서비스 구현 후 바꿀 예정이다.

4️⃣ Repository 생성

Spring Data JPA 를 사용할 것이기 때문에 인터페이스로 만든다.

public interface MemberRepository extends JpaRepository<Member, Long> {
}

5️⃣ Service 생성 (미완성)

@Service
@RequiredArgsConstructor
public class MemberCommandServiceImpl implements MemberCommandService{
    
    private final MemberRepository memberRepository;
    
    @Override
    public Member joinMember(MemberRequestDTO.JoinDto request) {
        
        return null;
    }
}
  • Member 객체를 만드는 작업은 서비스단에서 할 수도 있고 converter에서도 할 수 있다.(프로젝트마다 상이) 현재 서비스 코드에서는 오로지 비즈니스 로직만 작성할 예정이므로, Member 만드는 작업은 converter에서 할 예정이다.

6️⃣ Converter에서 멤버 생성

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로 바꾸고 진행한다.

🤔 유저 선호 음식 카테고리는 어떻게 처리해야할까?

따로 처리해야하므로 먼저 서비스부터 작성해보겠다.

1️⃣ Service 중간 완성

@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;
    }
}

2️⃣ DB 카테고리 생성 & Repository 생성

앞서 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> {
}

3️⃣ Service 이어서 (완성)

음식 선호 카테고리도 처리가 가능해졌으니, 이제 회원가입을 위한 서비스를 마저 구현해보자.

@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 기능도 추가해준다.

4️⃣ MemberPreferConverter 생성

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());
    }
}

5️⃣ Controller 완성

회원가입을 위한 비즈니스 로직이 완성되었기 때문에 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 세팅

swagger를 통해 api가 잘 작동되는지 확인해보자.

  • build.gradle 에 다음 의존성을 추가해 주자.
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
  • config 패키지를 만들고 하단에 있는 코드를 생성한다.
@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.

0개의 댓글