전체 코드 중 주요 기능으로 잡은 에어비엔비 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;
    }

}

[ 참고자료 ]

 


1️⃣  롤링페이퍼 작성이 한번만 되는 에러

  • 원인) 작성시 닉네임과 롤링페이퍼 내용이 requestDto로 설정되어 있는데, 닉네임이 유니크키로 설정되었기 때문에 하나 이상의 롤링페이퍼를 작성할 수 없었음.
  • 해결) 닉네임 유니크키 해제 후 정상 작동

기본키와 유니크키 차이점

더보기
  • 기본키는 즉, 프라이머리 키는 해당 테이블의 식별자 역할을 합니다. 바로 이 제약조건으로 테이블에 하나만 지정할 수 있습니다. 예를 들면, 사람을 식별할 때는 사람의 이름 대신 주민등록번호를 사용합니다. 그 이유는, 바로 유일한 성질을 가지고 있기 떄문입니다. 이렇게 중복성이 없는 유일성을 가진 성질을 의미합니다. 그 중 사용자가 선택한 것을 기본키라고 할 수 있습니다.
  • 유니크 키는 유일성을 가지기 위해 설정한 것입니다. 따라서 지정이 되면 중복이 되는 것을 제어하는 역할을 하게 됩니다. 에를 들면 회원 이름을 중복으로 설정하지 않게 하는 것입니다. 중복되는 이름의 경우에는 뒤에 숫자를 붙여서 최초의 한 사람만 기입할 수 있도록 하는 것입니다. 결국, 프라이머리 키는 유니크 키의 성질을 포함하는 것을 알 수 있습니다. 그 중에, 설계자가 기본적으로 선택한 키라고 할 수 있습니다. 유니크키는 하나의 테이블에 각각 컬럼마다 지정이 가능합니다. 그러나 프라이머리키는 오직 하나만 설정할 수 있습니다.  

 

2️⃣  댓글 좋아요 수만 오르고 좋아요 취소는 되지 않는 에러*

  • 원인) 쿼리문에서 findByUserIdAndUsername 이라고 명시했지만, ()안 매개변수값 순서를 거꾸로 입력
  • 해결) 올바르게 순서 정렬 후 정상 작동

 

3️⃣  유효성 검사가 정상적으로 수행되지않지만 에러없이 통과*

  • 원인) Build.gradle 파일 내부 dependencies 설정에서 상위버전 valid dependency와 하위버전이 동시에 존재할 경우 dependency간 충돌이 있어 애플리케이션은 에러는 없지만 유효성 검사가 진행되지 않음
  • 해결) 사용되지 않거나 중복으로 설정되어진 dependency를 주석처리 또는 삭제하는 것으로 정상적으로 유효성 검사 수행

 

4️⃣  AWS S3 서버 이미지 업로드 불가

  • 문제) S3에 이미지가 로컬에만 저장되고 S3로 convert 되지 않음
  • 원인) 추측 - ACL(액세스 제어 목록) 권한설정 문제
  • 해결) 추측 - 모든 사람(퍼블릭 액세스) 부분에 객체에 나열 권한을 부여

 

5️⃣  예외처리 부분*

  • 문제) 모든 에러의 상태코드가 200번으로 반환됨
  • 원인) MsgResponseDto로 customException 부분의 메세지부분만 return이 되고 Httpstatus 코드는 반환이 안되고 있었기 때문에 발생함
  • 해결) 반환타입을 ResponseEntity로 감싸고 HttpStatus 상태코드도 같이 반환하는 방식으로 변경

 

6️⃣  CORS 에러*

  • 문제) api사용시 cors 에러
  • 발생원인) CORS설정을 하지않아 프론트 3000포트에서 백 8080포트의 자원요청 불가
  • 해결) 서버에서 CorsConfigurationSource를 통한 CORS 설정으로 해결

 

(추가 해결 방안)

  • @CrossOrigin 어노테이션 사용 컨트롤러 또는 사용할 메소드에 어노테이션을 추가
  • WebMvcConfigurer 설정스프링 프로젝트를 생성하면 @SpringBootApplication 어노테이션이 적용된main함수에서WebMvcConfigurer 을 @Bean으로 등록하여 Cors매핑을 추가

 

7️⃣  토큰이 삭제되지 않아 토큰 만료 메세지가 계속 뜨는 에러

  • 문제)만료된 토큰을 계속 들고 있어서 만료되었습니다 메세지가 계속 뜸
  • 원인)토큰 값이 만료되면 자동적으로 토큰 값을 삭제 해줘 야 되는데 계속 남아있음
  • 해결)localStorage.removeItem("token")을 이용하여 status 401 이 뜰 때 if문을 사용하여 제거함

 

8️⃣  json 에러

  • 문제)서버로 데이터를 전송할 때 json문자열로 보내야 하는데 변환되지 않는 에러
  • 원인)"Content-Type": "application/json”이 담긴 config를 헤더 값에 넣어주지 못함
  • 해결)config 만 해준 곳에 {headers : config} 를 넣어 직접 명시를 해줌으로써 해결

프론트엔드 리엑트 코드 부분

 

※ 그 외 트러블슈팅 해결 방법 - 프론트엔드 리엑트 코드는 잘 몰라서, 아래와 같이 스프링에 System.out.pringln 을 사용하여 들어가는 값을 확인해 주면서 에러 체크를 하기도 했다.

 

※ 아쉬웠던 부분 / 정리 💡

  • 협업은 의사소통이 정말 중요하다. 특히 API 명세를 잘 작성하고 수정이 될 때마다 업데이트를 해줘야 한다.
  • 각 파트가 서로의 기능이 완성이 될 때마다 공유를 하며 맞춰가는 과정이 필요했는데 나중에 한꺼번에 합치려다보니 더 힘들었던 것 같다. 완성된 기능끼리는 바로바로 합치는 과정이 필요하다.

사전 세팅

  • 먼저 AWS에서 S3 버킷을 만들고 IAM에서 사용자를 만들어 access key와 secret ket를 발급받는 작업이 필요하다. 
  • 참고 링크 : https://jojoldu.tistory.com/300
 

SpringBoot & AWS S3 연동하기

안녕하세요? 이번 시간엔 SpringBoot & AWS S3 연동하기 예제를 진행해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와

jojoldu.tistory.com

 

구현 코드 (1) S3 부분

먼저, S3 라는 패키지를 만들어 이미지를 S3에 업로드하고 삭제하는 부분은 이곳에 구현을 했다.

 

1. AmazonS3Config 파일

@Configuration
public class AmazonS3Config {

    @Value("${cloud.aws.credentials.accessKey}")                        //S3 accessKey
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")                        //S3 secretKey
    private String secretKey;

    @Value("${cloud.aws.region.static}")                                //S3 region
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);

        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }
}

 

2. S3Controller 파일

사실 이 파일은 전체 서비스와 합칠 때 필요한 부분은 아니지만, 그 전에 업로드가 되는지 보기위해 테스트용으로 있는 컨트롤러이다.

@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class S3Controller {
    private final S3Service s3Uploader;

    @GetMapping("/images")
    public String getImage(@AuthenticationPrincipal UserDetailsImpl userDetails)throws IOException{
        return s3Uploader.getThumbnailPath("img.png");
    }

    @PostMapping("/images")
    public String delete(@RequestParam(required = true) String fileName){
         s3Uploader.deleteFile(fileName);
        return "success";
    }
}

 

3. S3Service 부분

@Slf4j
@Component
@RequiredArgsConstructor
@Service
public class S3Service {

    private final AmazonS3Client amazonS3Client;

    private final ImageFileRepository imageFileRepository;
    @Value("${cloud.aws.s3.bucket}")                                                        //bucket 이름
    public String bucket;

    public void upload(MultipartFile multipartFile, String dirName, Board board, User user) throws IOException {
        File uploadFile = convert(multipartFile).orElseThrow(() -> new IllegalArgumentException("파일 전환 실패"));

        ImageFile imageFile = new ImageFile(upload(uploadFile, dirName), user, board);
        imageFileRepository.save(imageFile);
//        return upload(uploadFile, dirName);
    }

    // S3로 파일 업로드하기
    private String upload(File uploadFile, String dirName) {
        String fileName = dirName + "/" + UUID.randomUUID(); // S3에 저장된 파일 이름
        String uploadImageUrl = putS3(uploadFile, fileName); // s3로 업로드
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

    // S3로 업로드
    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    // 로컬에 저장된 이미지 지우기
    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("File delete success");
            return;
        }
        log.info("File delete fail");
    }

    private Optional<File> convert(MultipartFile multipartFile) throws IOException {
        File convertFile = new File(System.getProperty("user.dir") + "/" + multipartFile.getOriginalFilename());
        // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
                fos.write(multipartFile.getBytes());
            }
            return Optional.of(convertFile);
        }

        return Optional.empty();
    }

    // find image from s3
    public String getThumbnailPath(String path) {
        return amazonS3Client.getUrl(bucket, path).toString();
    }

    //remove s3 object
    public void deleteFile(String fileName){
        DeleteObjectRequest request = new DeleteObjectRequest(bucket, fileName);
        amazonS3Client.deleteObject(request);
    }
}

 

 

코드 구현 (2) 게시판 코드에 적용하기

1. ImageFile 엔티티를 추가해 주었다.

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

    @Column(nullable = false)           // image 경로
    private String path;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "userid", nullable = false)
    private User user;                  // userid

    @OneToOne
    @JoinColumn(name = "boardid", nullable = false)
    private Board board;

    public ImageFile(String path, User user, Board board){
        this.path = path;
        this.user = user;
        this.board = board;
    }
}

 

 

2. BoardController

  • formdata 형식이기 때문에 POST 부분을 @RequestBody 가 아니라 @RequestPart 로 바꿔주어야 한다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class BoardController {

    // Connect to boardservice
    private final BoardService boardService;

    // DB save
    @PostMapping(value = "/boards", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
    public ResponseEntity<BoardResponseDto> createBoard(@RequestPart BoardRequestDto requestDto,
                                                        @RequestPart(required = false) MultipartFile image,
                                                        @AuthenticationPrincipal UserDetailsImpl userDetails) throws IOException{
        return ResponseEntity.ok(boardService.createBoard(requestDto, userDetails.getUser(), image));
    }

    // DB select all
    @GetMapping("/boards")
    public ResponseEntity<List<BoardResponseDto>> getBoards(){return ResponseEntity.ok(boardService.getBoards());}

    // DB select one
    @GetMapping("/boards/{id}")
    public ResponseEntity<BoardResponseDto> getBoard(@PathVariable long id){return ResponseEntity.ok(boardService.getBoard(id));}

    // DB update
    @PatchMapping("/boards/{id}")
    public ResponseEntity<BoardResponseDto> updateBoard(@PathVariable Long id,
                                                        @RequestBody BoardRequestDto requestDto,
                                                        @AuthenticationPrincipal UserDetailsImpl userDetails){
        return ResponseEntity.ok(boardService.update(id,requestDto, userDetails.getUser()));
    }

    // DB delete
    @DeleteMapping("/boards/{id}")
    public ResponseEntity<BoardDeleteResponseDto> deleteBoard(@PathVariable Long id,
                                                              @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(boardService.deleteBoard(id, userDetails.getUser()));
    }
}

 

3. BoardService 

 



@Slf4j
@Service
@RequiredArgsConstructor
public class BoardService {

    private final BoardRepository boardRepository;                                                      // board repo connect
    private final CommentRepository commentRepository;                                                  // comment repo connect
    private final CommentLikeRepository commentLikeRepository;                                          // commentLike repo connect
    private final BoardLikeRepository boardLikeRepository;                                              // boardLike repo connect
    private final ImageFileRepository imageFileRepository;                                              // ImageFile repo connect
    private final S3Service s3Uploader;

    @Value("${cloud.aws.s3.bucket}")
    public String bucket;

    // Board Create function
    public BoardResponseDto createBoard(BoardRequestDto requestDto, User user,MultipartFile image) throws IOException {

        // 1. create board Object and insert DB
        Board board = new Board(requestDto, user);                                                      // DTO -> Entity
        boardRepository.save(board);

//        if(!image.isEmpty()) {
        if (image != null){
            s3Uploader.upload(image, "static", board, user);
        }

        return new BoardResponseDto(board,user.getUsername(), "");                                          // return Response  Entity -> DTO

    }

    // Get Boards from DB (all)
    public List<BoardResponseDto> getBoards() {
        // 1. find boardList
        List<Board> BoardList = boardRepository.findAllByOrderByModifiedAtAsc();                            // Select All
        List<BoardResponseDto> BoardResponseDtoList = new ArrayList<>();

        // 2. find commentList and add ResponseDto
        for(Board board: BoardList){
            List<Comment> comments = commentRepository.findAllByBoardOrderByIdAsc(board);
            List<CommentResponseDto> commentList = new ArrayList<>();

            Optional<ImageFile> Img = imageFileRepository.findByBoard(board);
            String Img_path = "";

            if(Img.isEmpty()){
                Img_path = "";
            }
            else{
                Img_path = Img.get().getPath();
            }

            for (Comment comment : comments){
                commentList.add(new CommentResponseDto(comment));
            }

            if(comments.isEmpty()){
                BoardResponseDtoList.add(new BoardResponseDto(board,board.getUser().getUsername(), Img_path));
            }else{
                BoardResponseDtoList.add(new BoardResponseDto(board,board.getUser().getUsername(), commentList, Img_path));
            }
        }
        return BoardResponseDtoList;
    }

    // Get memo from DB (one)
    public BoardResponseDto getBoard(long id){
        // 1. find board
        Board board = boardRepository.findById(id).orElseThrow(()->                                        // Select one
                new CustomException(ErrorCode.NO_POST_FOUND)
        );

        // 2. find comment
        List<Comment> comments = commentRepository.findAllByBoardOrderByIdAsc(board);
        List<CommentResponseDto> commentList = new ArrayList<>();

        Optional<ImageFile> Img = imageFileRepository.findByBoard(board);
        String Img_path = "";

        if(Img.isEmpty()){
            Img_path = "";
        }
        else{
            Img_path = Img.get().getPath();
        }

        // 3. add ResponseDto
        for (Comment comment : comments){
            commentList.add(new CommentResponseDto(comment));
        }
        if(comments.isEmpty()){
            return new BoardResponseDto(board,board.getUser().getUsername(), Img_path);                               // Entity -> DTO
        }else{
            return new BoardResponseDto(board, board.getUser().getUsername(),commentList, Img_path);
        }
    }

    // DB update function
    @Transactional
    public BoardResponseDto update(Long id, BoardRequestDto requestDto, User user) {
        // 1. 유저 권한 GET
        UserRoleEnum userRoleEnum = user.getRole();
        Board board;

        // 2. 유저 권한에 따른 동작 방식 결정
        if(userRoleEnum == UserRoleEnum.USER){
            board = boardRepository.findById(id).orElseThrow(                                               // find memo
                    () -> new CustomException(ErrorCode.NO_POST_FOUND)
            );

            if(board.getUser().getId().equals(user.getId())){                                               // 자기 자신이 작성한 게시물이면
                board.update(requestDto);                                                                   // DB Update
            }else{
                throw new CustomException(ErrorCode.NO_MODIFY_POST);
            }
        }else{                                                                                              // 관리자 권한일 때,
            board = boardRepository.findById(id).orElseThrow(                                               // find board
                    () -> new CustomException(ErrorCode.NO_POST_FOUND)
            );
            board.update(requestDto);
        }
        return new BoardResponseDto(board,user.getUsername(), "");
    }

    // DB delete function (data delete)
    public BoardDeleteResponseDto deleteBoard(Long id, User user) {
        // 1. 유저 권한 GET
        UserRoleEnum userRoleEnum = user.getRole();
        Board board;

        // 2. 유저 권한에 따른 동작 방식 결정
        if(userRoleEnum == UserRoleEnum.USER){
            board = boardRepository.findById(id).orElseThrow(                                               // find memo
                    () -> new CustomException(ErrorCode.NO_ROLLING_FOUND)
            );

            if(board.getUser().getId().equals(user.getId())){                                               // 자기 자신이 작성한 게시물이면
                boardLikeRepository.deleteAllByBoard(board);                                                // DB DELETE (선행)
                List<Comment> commentList = commentRepository.findAllByBoardOrderByIdAsc(board);
                for(Comment comment: commentList){
                    commentLikeRepository.deleteAllByComment(comment);
                }

                Optional<ImageFile> imageFile = imageFileRepository.findByBoard(board);

                if (imageFile.isPresent()){
                    String path =  imageFile.get().getPath(); //이미지 파일 path값 가져오기
                    String filename = path.substring(58); // path값에서 키값 추출
                    s3Uploader.deleteFile(filename); //키값으로 s3 삭제

                    imageFileRepository.deleteAllByBoard(board);
                }

                commentRepository.deleteAllByBoard(board);
                boardRepository.deleteById(id);                                                             // 해당 게시물 삭제
            }else{
                System.out.println("Error");
                throw new CustomException(ErrorCode.NO_DELETE_POST);

            }
        }else{                                                                                              // 관리자 권한일 떄
            board = boardRepository.findById(id).orElseThrow(                                               // find board
                    () -> new CustomException(ErrorCode.NO_POST_FOUND)
            );
            boardLikeRepository.deleteAllByBoard(board);
            List<Comment> commentList = commentRepository.findAllByBoardOrderByIdAsc(board);
            for(Comment comment: commentList){
                commentLikeRepository.deleteAllByComment(comment);
            }

            commentRepository.deleteAllByBoard(board);
            boardRepository.deleteById(id);
        }
        return  new BoardDeleteResponseDto("삭제 성공", HttpStatus.OK.value());
    }

}

1. 프로젝트 이름 : Grow Together 🌱

 

 

2. 프로젝트 소개  :항해99 10기 b반 커뮤니티 사이트

  • 카테고리는 롤링페이퍼작성 / 정보공유 게시판 두 개로 나눠진다
  • 롤링페이퍼 작성 페이지에선, 크루원의 이름을 클릭하면 롤링페이퍼를 작성할 수 있다.
  • 정보공유 게시판은 각자 알게된 꿀팁과 정보들을 공유하는 공간이다.

 

3. 와이어 프레임

 

4. API 명세 링크 :

https://www.notion.so/d85b6b77cdaa4ce28fbfdb77af51ae0b?v=4bb451dd18e84cc4b8323f9537dd027f

 

5. ERD

 

 

6. 역할 분담

🔰차이진

더보기
  • 롤링페이지 전체조회 ( 반 명단 조회)
  • 롤링페이지 상세조회/ 작성 / 수정 / 삭제
  • S3
  • 로그인/ 회원가입 기능 추가 ( 소셜 - 시간이 된다면)

🔰황지성

더보기
  • 정보공유 게시글 작성/조회/수정/삭제
  • 정보공유 게시글 좋아요
  • 로그인 / 회원가입 / 회원 탈퇴
  • AWS 배포
  • S3

🔰김재영

더보기
  • 정보공유 상세페이지 댓글 작성 / 수정 / 삭제
  • 정보 공유 상세페이지 좋아요 기능 추가
  • 상세페이지 대댓글 작성 / 수정 / 삭제( 가능하면 )
  • S3

 

7.  주요 기능 / 기술 스택

  • JWT이용한 로그인
  • 로그인 유효성 검사
  • Spring Security이용한 인증/ 인가
  • 게시글 CRUD
  • 댓글 CRUD + 댓글 좋아요 기능
  • 롤링페이퍼 CURD
  • AWS S3이용한 이미지 업로드/조회/삭제

  • Spring Boot
  • Spring Security
  • JWT
  • AWS (S3, RDS, EC2)
  • MySQL
  • Github

 

8. 깃헙 주소

https://github.com/myrollingpaper/BE

 

GitHub - myrollingpaper/BE

Contribute to myrollingpaper/BE development by creating an account on GitHub.

github.com

 

+ 추가 

프론트엔드와 협업 후 완성된 모습 ( 팀원 하차로 스코프를 게시글 CRUD로 줄여서 완성)

 

 

+ Recent posts