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

 

전체 코드 중 주요 기능으로 잡은 에어비엔비 Room CRUD 부분을 정리해보려 한다.

+ 추가기능 ( 페이징 처리, 검색어 입력, S3, 게시글 좋아요, 비회원처리 ) 

 

Dto, Entity 등은 제외하고 Controlle / Repository / Service 부분만 정리


Room Controller

1. 숙소등록 부분

  • 등록 부분이기 때문에 @PostMapping 어노테이션 사용
  • RoomService 의존성 주입
  • Json형식과 Form Data 형식 같이 받아오기
    • (Value="/이름", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}})
    • @RuquestPart로 Json 형식인 RoomRequestDto와 MultipartFile 형태인 이미지를 받아온다.
    • (value = "data"),(value = "file")을 통해 Json, FormData 각각 key값을 명시해 준다.
  • @AuthenticationPrincipal 스프링시큐리티를 이용해 인증된 유저정보를 가져온다.

 

2. 숙소 정보 조회 부분

  • 조회 부분이기 때문에 @GetMapping 어노테이션 사용
  • Pageable을 이용하여 페이징처리 기능을 구현
  • 비회원인 경우 토큰값이 필요 없기 때문에 @AuthenticationPrincipal 유저정보를 매개 값에서 빼준 형식으로 작성
  • 키워드 조회의 경우 쿼리문에 사용할 String keword값이 추가된다.

 

3. 숙소정보 수정 / 삭제 부분 

  • @PathVariable로 게시글 고유 id 값을 조회하여 해당 게시글을 불러온다
  • 작성과 마찬가지로 @RequestPart 어노테이션을 사용하여 Json 형식과 FormData 형식을 불러온다.
  • @CrossOrigin은 CORS오류를 해결하는 방법 중 하나인데, 사실 이번프로젝트는 config 파일에서 CORS에러처리를 해주었기 때문에 필요는 없지만 참고용으로 달아보았다.
  • 삭제 부분은 Request가 따로 필요없기 때문에 유저정보와 해당 글을 불러 올 @Pathvariable만 필요하다.

 

4. 숙소 좋아요 취소기능 부분

 

Repository

1. RoomRepository

  • @Repository 어노테이션으로 빈에 등록해주기.
  • JpaRepository<>를 상속받고 있다.
  • 윗 쿼리문은 전체 조회시 사용 된다.
  • 아래 쿼리문은 검색어 입력후 조회시 사용 된다.

 

2. RoomLikeRepository

  • 가장 윗 쿼리문은 해당 게시글 좋아요 추가 기능에 사용된다.
  •  아래 쿼리문은 해당 게시글 좋아요 취소 기능에 사용된다.
  • 가장 아래 쿼리문은 게시글 삭제 부분에서 좋아요도 삭제해주기 위해 사용된다.

 

3. ImageFileRepository

  • 위의 쿼리문은 게시물을 수정하고, 게시글을 삭제하 때  삭제할 이미지 파일을 조회하면서 하나씩 DB에서 불러오는 데 사용된다.
  • 아래 쿼리문은 위의 쿼리문으로 불러운 이미지를 삭제할 때 사용된다.

 

Service

1. 의존성 주입

 

2. 숙소 정보 작성 (Create)

  • 먼저 Room테이블에 작성자로부터 받은 requestDto와 유저 정보를 저장
  • 이미지 파일이 있다면, S3Service부분에 만들어준 upload메소드를 이용하여 S3와 이미지 테이블에 입력받은 데이터 저장
  • return은 프론트엔드와 메시지 그리고 Status Code로 맞췄기 때문에 미리 만들어준 StatusMsgCode enum을 이용하여 반환

 

 3. 숙소 정보 전체 조회 (로그인 했을 경우 - 토큰유효)

  • Spring Data JPA 에서 제공하는 Pageable 인터페이스를 이용해 페이지처리를 해준다.
  • Room테이블에 저장되어 있는 게시글들을 찾아서 불러오고
  • RoomResponseDto로 되어있는 빈 배열을 하나 만들어준다.
  • 테이블에서 불러온 Room테이블을 바깥 for문에서 돌려주면서 엔티티를 디티오 타입으로 변환시켜 준다.
  • 그리고 이중 for문을 사용하여 각 게시글에 달려있는 이미지파일들을 String 타입으로 변화시켜 path 라고 지정한 url 값만 새로 만들어준 imageFileList배열에 넣어준다.
  • 최종적으로 roomResponseDto에 담겨진 room 정보와, 그에 딸린 좋아요 여부/ 좋아요 수, 그리고 이미지 파일까지 같이 return해준다.

 

4. 비회원일 경우 전체 조회

  • 로그인 후 전체조회와 구조는 같지만, 매개값에 user 정보만 빠져있는 형태이다.
  • 참고로 이전에는 이미지 파일의 path(url의미)를 객체타입으로 줬는데, 프론트엔드에서 String 타입이 더 가공하기 쉽다고 하여 아랫 부분에 String 타입으로 다시 배열을 만들어 주었다.

 

5. 키워드 검색

  • 전체 조회 부분에 keyword 매개 값이 추가된 형태이다.
  • Containing 쿼리문을 이용하여 roomrepository에서 관련된 키워드만 꺼내 올 수 있다.

 

6. 숙소 정보글 수정 

  • @Transactional 어노테이션을 까먹지 말자!
  • 가장 먼저 해당하는 게시글이 있는지 여부를 파악하고 없다면 만들어둔 CustomException을 던져준다.
  • 이어서 해당 게시글의 작성자와 수정하려는 자가 일치하는지 파악하고 다르다면 또한 예외처리를 던져주었다.
  • 이어서 받아온 Json타입의 request들을 먼저 update메소드를 통해 업데이트 해준다.
  • 다음, 해달 게시글에 이미지파일이 있다면(if 문), 해당 글에 달려있던 이미지 파일을 조회해서 먼저 S3에서 삭제를 해준다.
  • 이어서 DB에 저장되어있는 파일도 삭제해준다.
  • 그리고 새로 받은 이미지 파일을 upload메소드를 통해 S3와 DB에 업로드 한다.
  • try- catch 문을 사용하여 과정에 문제가 있다면 예외처리를 해주고, 없다면 return 값으로 StatusMsgCode를 반환한다.

 

7. 숙소 글 삭제

  • @Transactional 어노테이션을 까먹지 말자!
  • 게시글과 작성자 정보를 조회하는 것까지 수정 부분과 동일하다.
  • 가장 먼저 게시글에 달려있는 좋아요를 삭제한다.
  • 다음으로 이미지 파일을 삭제한다. 순서는 해당 글의 이미지 파일을 DB에서 조회하고, S3에서 먼저 삭제해 준 다음 DB에서도 삭제한다.
  • 마지막으로 Room테이블에서 해당 글을 삭제해 준다.
  • 수정글과 마찬가지로 try-catch문을 사용하여 예외처리를 해주고 return값을 주었다.

 

8. 좋아요 추가 / 삭제

  • @Transactional 어노테이션을 까먹지 말자!
  • roomLikeRepository에서 user정보와 게시글 정보를 조회하여 해당 like를 조회한다.
  • boolean 타입으로 해당 글의 좋아요 여부를 확인한다. 좋아요가 추가된 상태인 경우 true를 반환 한다.

 

  • 먼저 해당 게시글이 존재하는지 체크하고 없을 경우 예외를 날려준다.
  • 위에서 만들어둔 checkLike 메소드를 이용해, 만약 값이 true라면 (즉, 이미 좋아요를 한 상태라면) 만들어둔 StatusMsgCode 를 이용해 상태코드와 함께 "이미 좋아요를 추가했습니다." 라는 예외를 날려 준다.
  • 예외처리가 되지 않은 경우 saveAndFlush 메소드를 통해 좋아요 추가 정보를 저장해준다.

 

  • 먼저 해당 게시글이 존재하는지 체크하고 없을 경우 예외를 날려준다.
  • 위에서 만들어둔 checkLike 메소드를 이용해, 만약 값이 false라면 (즉, 이미 좋아요를 취소한 상태라면) 만들어둔 StatusMsgCode 를 이용해 상태코드와 함께 "이미 좋아요를 삭제했습니다." 라는 예외를 날려 준다.
  • 예외처리가 되지 않은 경우 deleteByRoomIdAndUserId 쿼리를 통해 좋아요를 DB에서 삭제해 준다.

※ Config 파일 : 스프링 시큐리티 인증/ 인가를 다루는 파일

 

import 부분

 

@EnableGlobalMethodSecurity

  • Use EnableMethodSecurity instead. Enables Spring Security global method security similar to the <global-method-security> xml support. More advanced configurations may wish to extend GlobalMethodSecurityConfiguration and override the protected methods to provide custom implementations.

 

@EnableWebSecurity

  • is used for spring security java configuration. Add this annotation with @configuration on top of your security java class that extends WebSecurityConfigurerAdapter. Override the configure(WebSecurity web) & configure(HttpSecurity http) . WebSecurityConfigurerAdapter를 상속받은 config 클래스에 @EnableWebSecurity 어노테이션을 달면SpringSecurityFilterChain이 자동으로 포함됩니다.

 

passwordEncoder / securityFilterChain

 

  • passwordEncoder : 비밀번호 암호화
  • antMatchers() 로 지정할 수 있는 항목
    • hasRole() or hasAnyRole() : 특정 권한을 가지는 사용자만 접근할 수 있습니다.
    • hasAuthority() or hasAnyAuthority() : 특정 권한을 가지는 사용자만 접근할 수 있습니다.
    • hasIpAddress() : 특정 아이피 주소를 가지는 사용자만 접근할 수 있습니다.
    • permitAll() or denyAll() : 접근을 전부 허용하거나 제한합니다.
    • rememberMe() : 리멤버 기능을 통해 로그인한 사용자만 접근할 수 있습니다.
    • anonymous() : 인증되지 않은 사용자가 접근할 수 있습니다.
    • authenticated() : 인증된 사용자만 접근할 수 있습니다.

 

corsConfigurationSource

  • corsConfigurationSource : CORS 에러 해결을 위한 부분

 

CORS란?

- HTTP 요청은 기본적으로 Cross-Site HTTP Requests가 가능하다. Simple 하게 다른 도메인의 Resource를 사용하는것을 의미한다. 하지만 Cross-Site HTTP Requests는 Same Origin Policy를 적용 받기 때문에 요청이 불가하다. 즉 프로토콜, 호스트명, 포트가 같아야만 요청이 가능하다.

 

 

SPA(Single Page Application) 개발이 보편적으로 이루어 지고있어서 Front , Back사이에 도메인이 달라지는 경우가 많다 이경우에는 CORS 허용 정책이 필요하다.

 

서비스 배포 후 최종 버전

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig {
    private final JwtUtil jwtUtil;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf().disable();

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.httpBasic().disable()
                .authorizeRequests()
                .antMatchers("/api/users/**").permitAll()
                .antMatchers(HttpMethod.GET, "/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthFilter(jwtUtil),UsernamePasswordAuthenticationFilter.class);
        http.cors();

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource(){

        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("http://localhost:3000"); // 프론트엔드 로컬서버
        config.addAllowedOrigin("http://the-greatest-minkyu.s3-website.ap-northeast-2.amazonaws.com/"); // 프론트엔드 S3서버
        config.addExposedHeader(JwtUtil.AUTHORIZATION_HEADER);
        config.addAllowedMethod("*");
        config.addAllowedHeader("*");
        config.setAllowCredentials(true);
        config.validateAllowCredentials();
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return source;
    }

}

[ 참고자료 ]

 

+ Recent posts