Swagger란?

API에 대한 정보를 전달하기 위해 일일이 문서화하는 것은 매우 번거로운 작업이다. 매번 Rest API를 개발하고 수정하면서 API문서를 변경하는 것은 개발자의 생산성 또한 떨어뜨린다.

Swagger는 이러한 API문서를 자동으로 생성하여 HTML로 만들어주는 오픈 소스 프레임워크이다.

 

build.gradle

// Swagger
implementation 'io.springfox:springfox-swagger2:3.0.0'
implementation 'io.springfox:springfox-swagger-ui:3.0.0'
implementation 'io.springfox:springfox-boot-starter:3.0.0'

 

SwaggerConfig

package com.cloneweek.hanghaebnb.util.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
@EnableAsync
@EnableWebMvc
public class SwaggerConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

    @Bean
    public Docket swagger() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo())
                .useDefaultResponseMessages(false);
    }

    private ApiInfo apiInfo() {
        ApiInfo apiInfo =
                new ApiInfo("항해bnb API", "항해 10기 B반 클론프로젝트 3조 API 명세서 입니다", "진짜 최종 ver", "https://github.com/hanghaebnb/BE", "contact", "3조 항해bnb 노션", "https://www.notion.so/eunsolan/3-bnb-a8edbe218a684cd2977937a5fc45fc7f");
        return apiInfo;
    }
}

 

application 실행 후 아래 링크에서 API명세 조회 가능 :) 

http://localhost:8080/swagger-ui/index.html#/


[ 참고 자료 ]

 

build.gradle

// 이메일 인증을 위한 설정
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

EmailConfig

package com.cloneweek.hanghaebnb.util.email;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;

import java.util.Properties;

@Configuration
@PropertySource("classpath:application-email.properties")
public class EmailConfig {

    @Value("${mail.smtp.port}")
    private int port;
    @Value("${mail.smtp.socketFactory.port}")
    private int socketPort;
    @Value("${mail.smtp.auth}")
    private boolean auth;
    @Value("${mail.smtp.starttls.enable}")
    private boolean starttls;
    @Value("${mail.smtp.starttls.required}")
    private boolean startlls_required;
    @Value("${mail.smtp.socketFactory.fallback}")
    private boolean fallback;
    @Value("${AdminMail.id}")
    private String id;
    @Value("${AdminMail.password}")
    private String password;

    @Bean
    public JavaMailSender javaMailService() {
        JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
        javaMailSender.setHost("smtp.gmail.com");
        javaMailSender.setUsername(id);
        javaMailSender.setPassword(password);
        javaMailSender.setPort(port);
        javaMailSender.setJavaMailProperties(getMailProperties());
        javaMailSender.setDefaultEncoding("UTF-8");
        return javaMailSender;
    }
    private Properties getMailProperties()
    {
        Properties pt = new Properties();
        pt.put("mail.smtp.socketFactory.port", socketPort);
        pt.put("mail.smtp.auth", auth);
        pt.put("mail.smtp.starttls.enable", starttls);
        pt.put("mail.smtp.starttls.required", startlls_required);
        pt.put("mail.smtp.socketFactory.fallback",fallback);
        pt.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
        return pt;
    }
}

 

EmailController

package com.cloneweek.hanghaebnb.util.email;

import com.cloneweek.hanghaebnb.dto.RequestDto.DupliCheckDto;
import com.cloneweek.hanghaebnb.dto.ResponseDto.ResponseBoolDto;
import com.cloneweek.hanghaebnb.util.exception.StatusMsgCode;
import com.cloneweek.hanghaebnb.dto.ResponseDto.ResponseMsgDto;
import com.cloneweek.hanghaebnb.dto.RequestDto.SignupRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.Map;

import static com.cloneweek.hanghaebnb.util.exception.StatusMsgCode.EXIST_NICK;
import static com.cloneweek.hanghaebnb.util.exception.StatusMsgCode.NICKNAME;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users/email")
public class EmailController {
    private final EmailService emailService;

    // 입력한 이메일로 인증코드 발송
    @PostMapping("/confirm")
    public ResponseEntity<?> emailConfirm(@RequestBody EmailConfirmDto emailConfirmDto) throws Exception {
        emailService.sendSimpleMessage(emailConfirmDto.getEmail());
        return ResponseEntity.ok(new ResponseMsgDto(StatusMsgCode.EMAIL_CONFIRM));
    }

    // 인증코드 발송받은 이메일과 인증번호 입력
    @PostMapping("/verifycode")
    public ResponseEntity<ResponseMsgDto> verifyCode(@RequestParam String issuedCode, String email) {
//        return emailService.verifyCode(issuedCode, email);
        return ResponseEntity.ok(emailService.verifyCode(issuedCode, email));
    }

    // 회원가입 로직
    @PostMapping("/signup")
    public ResponseEntity<ResponseMsgDto> emailSignup(@RequestBody @Valid SignupRequestDto dto) {
        emailService.emailSignup(dto);
        return ResponseEntity.ok(new ResponseMsgDto(StatusMsgCode.SIGN_UP));
    }
}

 

EmailEntity

package com.cloneweek.hanghaebnb.util.email;

import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;

@Getter
@Entity
@NoArgsConstructor
public class Code {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String randomCode;

    public Code(String randomCode, String email) {
        this.randomCode = randomCode;
        this.email = email;
    }
}

 

EmailConfirmDto

package com.cloneweek.hanghaebnb.util.email;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class EmailConfirmDto {
    private String email;
}

 

EmailRepository

package com.cloneweek.hanghaebnb.util.email;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface CodeRepository extends JpaRepository<Code, Long> {
    List<Code> findByEmail(String email);
    Code findByEmailAndId(String email, Long id);
}

 

EmailService

package com.cloneweek.hanghaebnb.util.email;

import com.cloneweek.hanghaebnb.dto.ResponseDto.ResponseBoolDto;
import com.cloneweek.hanghaebnb.dto.ResponseDto.ResponseMsgDto;
import com.cloneweek.hanghaebnb.util.exception.CustomException;
import com.cloneweek.hanghaebnb.dto.RequestDto.SignupRequestDto;
import com.cloneweek.hanghaebnb.entity.User;
import com.cloneweek.hanghaebnb.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.util.List;
import java.util.Random;

import static com.cloneweek.hanghaebnb.util.exception.StatusMsgCode.*;

@Service
@RequiredArgsConstructor
public class EmailService {
    private final JavaMailSender emailSender;
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final CodeRepository codeRepository;


    // 랜덤하게 만든 인증코드 메일로 발송하는 메서드
    public String sendSimpleMessage(String email) throws Exception {
        if (userRepository.findByEmail(email).isPresent()) {
            throw new CustomException(EXIST_USER);
        }
        String randomCode = "";
        StringBuffer key = new StringBuffer();
        Random rnd = new Random();

        // 인증코드 랜덤하게 만들기. 인증 요청될 때마다 새롭게만들어 메일로 보냄
        for (int i = 0; i < 8; i++) { // 인증코드 8자리
            int index = rnd.nextInt(3); // 0~2 까지 랜덤

            switch (index) {
                case 0:
                    key.append((char) ((int) (rnd.nextInt(26)) + 97));
                    //  a~z  (ex. 1+97=98 => (char)98 = 'b')
                    break;
                case 1:
                    key.append((char) ((int) (rnd.nextInt(26)) + 65));
                    //  A~Z
                    break;
                case 2:
                    key.append((rnd.nextInt(10)));
                    // 0~9
                    break;
            }
        }
        randomCode = key.toString();

        // 메세지 내용 작성
        MimeMessage message = emailSender.createMimeMessage();

        message.addRecipients(MimeMessage.RecipientType.TO, email);
        message.setSubject("hanghaebnb 회원가입 이메일 인증");
        String msgg = "";
        msgg += "<div style='margin:100px;'>";
        msgg += "<h1> 안녕하세요 hanghaebnb입니다. </h1>";
        msgg += "<br>";
        msgg += "<p>아래 코드를 회원가입 창으로 돌아가 입력해주세요<p>";
        msgg += "<br>";
        msgg += "<p>감사합니다!<p>";
        msgg += "<br>";
        msgg += "<div align='center' style='border:1px solid black; font-family:verdana';>";
        msgg += "<h3 style='color:blue;'>회원가입 인증 코드입니다.</h3>";
        msgg += "<div style='font-size:130%'>";
        msgg += "CODE : <strong>";
        msgg += randomCode + "</strong><div><br/> ";
        msgg += "</div>";
        message.setText(msgg, "utf-8", "html");//내용
        message.setFrom(new InternetAddress("hanghaebnb@gmail.com", "test"));//보내는 사람

        // 메세지 발송
        try {
            emailSender.send(message);
        } catch (MailException es) {
            es.printStackTrace();
            throw new IllegalArgumentException();
        }

        // 만들었던 랜덤코드 DB에 저장하기
        Code code = new Code(randomCode, email);
        codeRepository.save(code);
        return randomCode;
    }

    // 회원가입
    public void emailSignup(SignupRequestDto dto) {
        String email = dto.getEmail();
        String password = passwordEncoder.encode(dto.getPassword());
        String nickname = dto.getNickname();

        // DB에 이메일과 닉네임 중복 있는지 체크
        if (userRepository.findByEmail(email).isPresent()) {
            throw new CustomException(EXIST_USER);
        }
        if (userRepository.findByNickname(nickname).isPresent()) {
            throw new CustomException(EXIST_NICK);
        }

        // DB에 회원가입할 데이터 저장. builder 패턴으로 적용해봄.
        // User user = new User(email, password, nickname);
        User user = User.builder().email(email)
                .kakaoNickname(nickname)
                .password(password)
                .build();                              // 생성자 호출

        userRepository.save(user);
    }

    // 이메일로 받은 랜덤코드 대조
    public ResponseMsgDto verifyCode(String issuedCode, String email) {

        // 리파지토리에서 해당 이메일로 저장된 모든 랜덤코드 불러오기
        // 리파지토리에서 불러온 랜덤코드 중 가장 최신순으로 발행된 코드 찾기
        List<Code> codeList = codeRepository.findByEmail(email);
        long id = 0L;
        for (Code code : codeList) {
            if (code.getId() > id) {
                id = code.getId();
            }
        }

        // 해당 email로 보낸 가장 최신 랜덤코드 불러오기
        Code code = codeRepository.findByEmailAndId(email, id);
        Boolean result = code.getRandomCode().equals(issuedCode);
        return new ResponseBoolDto(result? MATCH_CODE : UNMATCH_CODE, result);
    }
}

 

Email Properties

mail.smtp.auth=true
mail.smtp.starttls.required=true
mail.smtp.starttls.enable=true
mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
mail.smtp.socketFactory.fallback=false
mail.smtp.port=465
mail.smtp.socketFactory.port=465

#admin ?? ??
AdminMail.id= 계정 이메일 주소
AdminMail.password= 계정 이메일 비밀번호

[ 참고 자료 ]

 

https://hyunmin1906.tistory.com/276

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

https://javaju.tistory.com/100

https://badstorage.tistory.com/38

 

Controller

 

//숙소 전체 조회
    @GetMapping("/rooms") // size '/api/rooms?page=0&size=3'
    public ResponseEntity<List<RoomResponseDto>> getRooms(@AuthenticationPrincipal UserDetailsImpl userDetails,
                                                          @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
                                                          @RequestParam(required = false, defaultValue = "-1") int minPrice,
                                                          @RequestParam(required = false, defaultValue = "-1") int maxPrice,
                                                          @RequestParam(required = false) String type) {
        return ResponseEntity.ok(roomService.getRooms(userDetails.getUser(), pageable, minPrice, maxPrice, type));
    }

    // 비회원 숙소 전체 조회
    @GetMapping("/rooms/main")
    public ResponseEntity<List<UnClientResponseDto>> getnoclientRooms(@PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
                                                                      @RequestParam(required = false, defaultValue = "-1") int minPrice,
                                                                      @RequestParam(required = false, defaultValue = "-1") int maxPrice,
                                                                      @RequestParam(required = false) String type) {
        return ResponseEntity.ok(roomService.getnoclientRooms(pageable, minPrice, maxPrice, type));
    }

    //숙소 키워드 조회
    @GetMapping("/rooms/search") // '/api/rooms/search?keyword=제목&page=0&size=2'
    public ResponseEntity<List<UnClientResponseDto>> search(@PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
                                                            String keyword){
        return ResponseEntity.ok(roomService.search(keyword, pageable));
    }

 

  • @PageableDefault 어노테이션으로 글작성 순서에 따라 페이지를 sorting 해준다. 기본 설정 페이지는 10개의 게시글이다.
  • @RequestParam 어노테이션을 이용해 최젓가, 최댓가, 숙소타입을 필터링 할 수 있도록 설정하였다.
  • required = false 를 적용하여 Service 단에서 다양한 필터링 경우를 if문을 통해 사용하도록 하였다.
  • defaultValue = -1 로 설정한 이유는 최젓가와 최댓가가 0원부터 시작할 수 있기 위함이다. (서비스단 if문 참조)
  • 키워드 검색 부분엔 기본 조회 기능에서 String keword 인자를 추가해 주었다.

 

Repository

 

@Repository
public interface RoomRepository extends JpaRepository<Room, Long> {
    Page<Room> findByTitleContaining(String keyword, Pageable pageable);          // 키워드 검색
    Page<Room> findByType(String type, Pageable pageable);                        // 타입별 필터링
    Page<Room> findByPriceBetween(int minPrice, int maxPrice, Pageable pageable); // 가격별 필터링
    @Query(countQuery = "select count(*) from room r where (r.price between :minPrice and :maxPrice) and r.type = :type", nativeQuery = true)
    Page<Room> findByPriceBetweenAndType(@Param("minPrice") int minPrice,
                                         @Param("maxPrice") int maxPrice,
                                         @Param("type") String type,
                                         Pageable pageable);                      // 타입+가격별 필터링
}

 

  • 키워드 검색은 Cotaining()을 사용하였다. title 컨테이닝이기 때문에 키워드검색은 제목 부분만 해당된다.
  • 타입+가격별 필터링은 Spring JPA Data 에서 제공하는 쿼리문만으로는 적용되지 않아, nativeQuery를 사용하였다. 
  • 구글링으로 찾은 블로그를 참조하여 그 중에서 countQuery를 사용하였는데, count(*) 부분의 동작 원리는 잘 모르겠다...^^
  • 기술 매니저님이  QueryDSL을 사용해 보라는 피드백을 주셨다 ! 

 

Service

 

//숙소 페이징, 필터링
    @Transactional(readOnly = true)
    public Page<Room> addFilter(Pageable pageable, int minPrice, int maxPrice, String type) {
        // pageable은 필수, type, price(기본값 -1)별 필터링
        Page<Room> roomList = roomRepository.findAll(pageable);         // RequestParam page, size만 있을 때
        if (type != null && minPrice == -1 && maxPrice == -1) {         // RequestParam type만 있을 때
            roomList = roomRepository.findByType(type, pageable);
        } else if (type == null && minPrice != -1 && maxPrice != -1) {  // RequestParam price만 있을 때
            roomList = roomRepository.findByPriceBetween(minPrice, maxPrice, pageable);
        } else if (type != null && minPrice != -1 && maxPrice != -1) {  // RequestParam type, price 둘 다 있을 때
            roomList = roomRepository.findByPriceBetweenAndType(minPrice, maxPrice, type, pageable);
        }
        return roomList;
    }

    //숙소 정보 전체 조회
    @Transactional(readOnly = true) //회원 전체 조회
    public List<RoomResponseDto> getRooms(User user, Pageable pageable, int minPrice, int maxPrice, String type) {
        List<RoomResponseDto> roomResponseDto = new ArrayList<>();
        for (Room room : addFilter(pageable, minPrice, maxPrice, type)) {
            List<String> imageFileList = new ArrayList<>();
            for (ImageFile imageFile : room.getImageFileList()) {
                imageFileList.add(imageFile.getPath());
            }
            roomResponseDto.add(new RoomResponseDto(
                    room,
                    (checkLike(room.getId(), user)),
                    imageFileList));
        }
        return roomResponseDto;
    }

    @Transactional(readOnly = true) //비회원 전체 조회
    public List<UnClientResponseDto> getnoclientRooms(Pageable pageable, int minPrice, int maxPrice, String type) {
        List<UnClientResponseDto> unClientResponseDto = new ArrayList<>();
        for (Room room : addFilter(pageable, minPrice, maxPrice, type)) {

            // path를 객체로 받아올 경우 주석부분 사용,
//            List<ImageFileResponseDto> imageFileResponseDtoList = new ArrayList<>();
//            for (ImageFile imageFile : room.getImageFileList()) {
//                imageFileResponseDtoList.add(new ImageFileResponseDto(imageFile));
//            }

            // path를 String 타입으로 받올 경우
            List<String> imageFileList = new ArrayList<>();
            for (ImageFile imageFile : room.getImageFileList()) {
                imageFileList.add(imageFile.getPath());
            }
            unClientResponseDto.add(new UnClientResponseDto(room, imageFileList));
        }
        return unClientResponseDto;
    }

    //숙소 키워드 검색
    @Transactional(readOnly = true)
    public List<UnClientResponseDto> search(String keyword, Pageable pageable) {
        Page<Room> roomList = roomRepository.findByTitleContaining(keyword, pageable);

        List<UnClientResponseDto> roomResponseDtos = new ArrayList<>();
        for (Room room : roomList) {
            List<String> imageFileList = new ArrayList<>();
            for (ImageFile imageFile : room.getImageFileList()) {
                imageFileList.add(imageFile.getPath());
            }
            roomResponseDtos.add(new UnClientResponseDto(room, imageFileList));
        }

        return roomResponseDtos;
    }

 

  • 필터링 기능은 if문을 사용하여 구현하였다.
  • 처음에 이미지파일 url (path라는 변수 사용)을 프론트쪽으로 객체타입으로 보내주었는데, String 타입이 더 가공하기 편하다고 하여 주석처리하고 List<String>을 이용하여 반환타입을 문자열로 바꿔주었다.
  • 참고로, UnClientResponseDto는 비회원 전용 Dto이다.

[ 참고자료 ]

User Entity

  • 일반 로그인 회원가입 엔티티에 kakaoId 추가

 

  • 그리고 아래에 카카오로그인과 업데이트에 사용할 생성자도 추가해 준다.

 

UserController

  • kakaoService 의존성 주입

 

  • kakao login Controller 부분

 

UserRepository

 

KakaoUserInfoDto

 

KakaoService

build.gradle

// s3 설정 관련
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

1. Amazon3SConfig

  • S3의 accessKey와 seceretKey, region, bucket 이름은 properties에 저장되어 있다.
  • 이 정보들이 깃헙에 유출되지 않도록 gitignore 설정에 properties를 추가해 주는 것을 잊지 말자! 

 

2. AmazonS3Controller

  • 이미지 업로드 확인을 위한 컨트롤러이기 때문에, 실제 서비스에선 사용하지 않는 파일이다. (그래서 빨간줄이 뜸)

 

3. AmazonS3Service

  • 1개 이상의 이미지가 담겨있는 multipartFileList를 for문을 돌려 이미지가 있을 시 convert 메소드를 통해 파일을 전환시켜 준다.
  • 그리고, new ImagFile 안에서 아래의 upload 메소드를 사용해 이미지 파일과, 폴더명, 유저 정보, 룸 정보를 담아준다. 
  • 다음 imageFileRepository에 저장한다.

 

  • 위에서 사용된 upload 메소드 부분이다. 
  • S3에 저장될 파일이름을 fileName 에 담아준다.
  • 다음, 이미지 파일과 파일 이름을 아래 putS3 메소드를 이용하여 S3에 업로드해준다.
  • 다음 아아래에 있는 removeNewFile 메소드를 이용해 로컬에 저장된 이미지파일을 삭제한다.

 

  • S3로 이미지 파일을 업로드하는 메소드이다.
  • putObject 메소드를 이용하여 지정된 버켓에 파일이름과 이미지파일을 저장해주고
  • return값으로 이미지 url을 String 타입으로 반환하다.

 

  • 위에 선언된 removeNewFile 메소드 부분이다.
  • 로컬에 저장된 파일을 삭제하는 역할을 한다.

 

  • 파일을 전환하는 convert 메소드 부분이다.

 

  • 이 부분은 RoomService에서 숙소정보글을 삭제할 때, 해당 글의 이미지 파일을  삭제해주는  메소드이다.

 

게시글 등록 부분

S3를 반영한 게시글 CRUD 부분은 아래를 참조해주세요 : )

 

Airbnb Clone Coding (Main CRUD)

 

Airbnb Clone Coding (Main CRUD)

전체 코드 중 주요 기능으로 잡은 에어비엔비 Room CRUD 부분을 정리해보려 한다. + 추가기능 ( 페이징 처리, 검색어 입력, S3, 게시글 좋아요, 비회원처리 ) Dto, Entity 등은 제외하고 Controlle / Repository

leejincha.tistory.com

 

+ Recent posts