🍐 [이화톤] 배울림꽃 - 이화 청원 프로그램 리팩토링( 2 ) 청원 게시, 상세 조회, 상태 변화 자동화

2024. 8. 31. 23:57개발/🍐 배울림꽃

 

 

 

우선 내가 맡은 청원 게시, 청원 상세 조회, 청원 상태(투표중, 논의중, 논의완료) 변화 자동화 부분 코드다

 

1.  청원(Post), 첨부파일(Url) Entity

청원 게시 및 조회 기능에서 주의했던 부분은 첨부 파일 부분이다

Url 엔티티를 따로 분리하고 일대다 형태로 연결했기 때문에

Post Entity class 내에 첨부 파일 삽입 및 조회할 때 사용할 메서드를 작성했다

 

 

Post Entity

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Post extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id", updatable = false)
    private Long postId;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @Enumerated(EnumType.STRING)
    private Categrory categrory;

    @Builder.Default
    @Column(name = "vote_count", nullable = false)
    private Integer voteCount = 0;

    @Builder.Default
    @Enumerated(EnumType.STRING)
    private Status status = Status.VOTING;

    @Column(nullable = false)
    private String email;

    @Column(name = "info_agree", nullable = false)
    private Boolean infoAgree;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", updatable = false)
    private Member writer;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    @Builder.Default
    private List<Url> postUrlList = new ArrayList<>();



// 첨부 파일 삽입
    public void updateUrlList(List<String> urls) {
        Set<String> uniqueUrls = new HashSet<>(urls); // 중복 제거
        
        // url 비어있는 경우 - 그냥 return
        if (uniqueUrls.isEmpty()) { // 
            return;
        }

        postUrlList.clear();

        List<Url> newUrlList = uniqueUrls.stream()
                .map(url -> Url.builder()
                        .url(url)
                        .post(this)
                        .build()) // Url 객체 생성
                .collect(Collectors.toList());

        postUrlList.addAll(newUrlList);
    }
	
// 첨부파일 조회
    public List<String> fetchUrlList() {
        List<String> urlLists = new ArrayList<>();
        for (Url url : postUrlList) {
            if (url.getUrl() != null && !url.getUrl().isBlank()) {
                urlLists.add(url.getUrl());
            }
        }
        return urlLists;
    }


// 청원 상태 업데이트
    public void updateStatus(Status status){
        this.status = status;
    }
    
}
💡 알게 된 부분 

1.   @Entity : 스프링에서 JPA를 사용할 때 데이터베이스 테이블과 매핑되는 클래스에 사용되는 어노테이션
- 엔티티 클래스는 데이터베이스의 레코드를 객체로 표현한 것이며, 따라서 엔티티 클래스의 인스턴스는 데이터베이스 테이블의 행(row) 하나에 해당
- 이런 이유로 엔티티 클래스는 애플리케이션 실행 중에 여러 개의 인스턴스가 생성될 수 있음
2.  @Getter : 롬복 라이브러리에서 제공하는 어노테이션으로, getter함수를 일일히 작성하지 않아도 자동 생성함
3.  @Builder : 롬복 라이브러리에서 제공하는 어노테이션으로. 엔티티 클래스 내의 필드를 선택적으로 매개변수로 사용하는 생성자 만들 수 있도록 도움
4. @NoArgsContructor : 매개변수가 없는 기본 생성자 자동 생성함
-> access = AccessLevel.PROTECTED 는 생성자의 접근 제어자를 protected로 설정
5. @AllArgsContructor : 엔티티 클래스 내의 필드를 전부 매개변수로 사용하는 생성자 자동 생성함
6.  @Column : 칼럼의 세부 설정
7.  @Builder.Default : 칼럼 값을 디폴트값으로 저장


8. @ManyToOne : 일대다 관계 설정할 때 '다'에 해당하는 엔티티 내부에 작성 / Member와 Post가 일대다 관계이기 때문에 Post Entity 내부에 작성함
9. @OneToMany : 일대다 관계에서 '일'에 해당하는 엔티티 내부에 작성하며, '다'가 삭제됐을 때 '일'에 아무런 지장이 없도록 함 / Post와 Url이 일대다 관계이며, Url이 여러개일 때 Url 하나가 삭제될 때 해당 Url과 연결된 Post가 삭제되지 않도록 Post Entity 내부에 작성


10.  Post 객체가 생성 및 불려올 때마다 필드값은 물론 updateUrlList(), fetchUrlList(), updateStatus() 함수들도 함께 딸려오기 때문에 해당 객체에 알맞게 함수를 적용할 수 있음
-> updateUrlList()에서 map으로 String 배열인 uniqueUrls을 Url 객체 배열인 newUrlList로 매핑할 때 builder를 사용하여 Url 객체를 생성하는데 이때 Url 객체와 연결될 Post 객체를 this를 통해 설정한다 (Post Entity 내에 updateUrlList()가 있기 때문에 this를 사용하면 해당 객체를 필드로 설정 가능하다)
-> fetchUrlList()는 Post 객체를 조회할 때 url 리스트를 가져오도록 하는 함수다. 이때 Post Entity의 필드인 postUrlList를 사용한다(Post Entity 내에 fetchUrlList()가 있기 때문에 필드를 쉽게 가져올 수 있다)
-> updateStatus()는 Post의 status를 설정하는 함수다.(Post Entity 내에 updateStatus()가 있기 때문에 필드를 쉽게 가져올 수 있다)

 

Category Enum

@Getter
@RequiredArgsConstructor
public enum Categrory {
    STUDENT_SUPPORT(0, "학생지원"),
    STUDENT_ACTIVITY(1, "학생활동"),
    LIVING_SUPPORT(2, "생활지원"),
    ADMINISTRATIVE_SUPPORT(3,"행정지원"),
    COLLEGE(3, "대학"),
    ETC(4,"기타");

    private final Integer Id;
    private final String title;
}

 

Status Enum

@Getter
@RequiredArgsConstructor
public enum Status {
    VOTING(0,"진행중","투표 진행중"),
    DISCUSSION(1,"논의중","학생회 논의중"),
    CONCLUSION(2,"완료","완료 및 종료");

    private final Integer Id;
    private final String title;
    private final String description;
}

 

Url Entity

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Builder
@AllArgsConstructor
public class Url {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "url_id", updatable = false)
    private Long urlId;

    @Column(nullable = false)
    private String url;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", updatable = false)
    private Post post;
}

 

 

PostRequestDTO

public class PostRequestDto {
    @Getter
    public static class PostCreateDto {
        private String email;
        private Boolean infoAgree;
        private String title;
        private String content;
        private Categrory categrory;
        List<String> urlList;
    }
}
청원 게시물을 작성할 때 사용자가 입력해야 하는 데이터를 필드로 작성

 

 

PostResponseDTO

public class PostResponseDto {

    @Builder
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class PostDto {
        private Long postId;
        private Member writer;

        private String email;

        private Status status;
        private String title;
        private String content;
        private Categrory categrory;
        private Integer voteCount;

        private LocalDateTime createdDate;
        private LocalDateTime deadline;
        private Duration dDay;

        List<String> urlList;
    }
}
청원 게시물을 상세 조회할 때 나타나야 하는 데이터들을 필드로 작성

 

 

PostConverter

@Component
@RequiredArgsConstructor
public class PostConverter {
    public Post toPostEntity(PostRequestDto.PostCreateDto request) {

        //작성자 정보 불러오기(이름만 필요)

        Post post = Post.builder()
                //.writer(writer)
                .email(request.getEmail())
                .infoAgree(request.getInfoAgree())
                .title(request.getTitle())
                .content(request.getContent())
                .categrory(request.getCategrory())
                .build();

        post.updateUrlList(request.getUrlList());
        return post;
        }


    public PostResponseDto.PostDto toPostDto(Post post) {

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime createdDate = post.getCreatedDate();
        LocalDateTime deadline = createdDate.plusDays(10);
        Duration dDay = Duration.between(deadline, now);

        PostResponseDto.PostDto postDto = PostResponseDto.PostDto.builder()
                .postId(post.getPostId())
                .status(post.getStatus())
                .writer(post.getWriter())
                .email(post.getEmail())
                .title(post.getTitle())
                .content(post.getContent())
                .categrory(post.getCategrory())
                .voteCount(post.getVoteCount())
                .createdDate(createdDate)
                .deadline(deadline)
                .dDay(dDay)
                .urlList(post.fetchUrlList())
                .build();

        return postDto;
    }
}
Converter 클래스는 1. DTO를 Entity로  2. Entity를 DTO로 변경할 때 사용
Post Entity 클래스와 PostRequestDTO, PostResponseDTO 클래스는 모두 @Builder와 @Getter를 갖고 있기 때문에
getter로 필드값을 받아서 builder로 저장하도록 한다
Post Entity를 매개변수로 받아서 PostResponseDTO로 변경하는 toPostDto()는 청원 게시물을 상세 조회할 때마다 호출되며 D-day 데이터를 보여주기 위해 함수 내에서 LocalDateTime 사용한다

작성자 정보 불러오기 필요

 

 

PostService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
    private final PostConverter postConverter;
    private final PostRepository postRepository;
    
    @Transactional
    public Post createPost(PostRequestDto.PostCreateDto request) {
        // 이메일 작성하지 않은 경우 예외 처리
        if (request.getEmail() == null || request.getEmail().isEmpty()) {
        //GeneralException -> EMAIL_REQUIRED
        }

        // 동의하지 않은 경우 예외 처리
        if (request.getInfoAgree() == null || !request.getInfoAgree()) {
        //GeneralException -> INFOAGREE_REQUIRED
        }

        Post post = postConverter.toPostEntity(request);
        postRepository.save(post);

        return post;
    }

    public Post findById(Long postId){
        return postRepository.findById(postId).orElseThrow(()->new RuntimeException());
        // 해당 게시물 없을 경우 에러처리 -> GeneralException(POST_NOT_FOUND)
    }

    public PostResponseDto.PostDto getPostDetail(Long postId){
        Post post = findById(postId);
        PostResponseDto.PostDto postDto = postConverter.toPostDto(post);
        return postDto;
    }
}
PostService class에는 1. 청원 게시  2. 청원 상세 조회 기능을 넣었다
✅ 이메일 작성 안 했을 때 예외 처리 필요
✅ 정보 제공 동의 안 했을 때 예외 처리 필요

 

 

PostRepository

public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findAllByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);
}
PostRepository에 작성한 메서드 findAllByCreatedDateBetween은 스프링 데이터 JPA의 쿼리 메서드 기능을 활용한 것

JPA의 쿼리 메서드(Query Method)
: JpaRepository 인터페이스를 확장하여 작성된 메서드 이름을 기반으로 스프링 데이터 JPA가 자동으로 쿼리를 생성해 주는 방식

findAllByCreatedDateBetween()
-> SELECT p FROM Post p WHERE p.createdDate BETWEEN :startDate AND :endDate

 

PostStatusScheduler

    @Transactional
    @Scheduled(cron = "0 0 0 * * ?") // 매일 자정에 실행
    public void updatePostStatuses() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime startDate = now.minusDays(10).toLocalDate().atStartOfDay(); // 10일 전 00:00
        LocalDateTime endDate = startDate.plusDays(1).minusNanos(1); // 10일 전 23:59:59

        List<Post> posts = postRepository.findAllByCreatedDateBetween(startDate, endDate);


        for (Post post : posts) {
            if (post.getVoteCount() >= 1000) {
                post.updateStatus(Status.DISCUSSION);
            } else {
                post.updateStatus(Status.CONCLUSION);
            }
            postRepository.save(post);
        }
    }
}
@Scheduled 를 사용해서 updatePostStatus 클래스가 매일 자정에 실행되도록 했다
JpaRepository를 사용해서 10일 전에 작성된 게시물들을 모두 불러온 후
투표 수가 1000 이상이 것들은 status를 논의중(DISCUSSION)으로, 아닌 것들은 논의완료(CONCLUSION)으로 변경했다
그런 후 save()를 사용해서 바뀐 레코드 정보를 저장했다

 

 

🍀 Springboot의 객체 관리

객체는 두가지가 있다
Post 객체와 같이 인스턴스 하나하나 각자 저장되어야 하는 객체,
PostConverter, PostController, PostService, PostRepository와 같이 프로젝트 내에 하나씩만 존재해야 하는 객체

인스턴스 하나하나 저장돼야 하는 Post는 @Entity를 붙여 Jpa의 엔티티 매니저에 관리되도록 한다
이때 Post 인스턴트는 데이터베이스 레코드 하나하나 매핑되어 저장된다
이 인스턴스들은 JPA의 영속성 컨텍스트에 의해 관리된다

PostConverter, PostController, PostService, PostRepository는
각각 @Component, @RestController, @Service 어노테이션과 JpaRepository를 상속받음으로써
스프링 컨테이너 내에 싱글톤 형식으로 저장되어 관리된다

@Component : 가장 기본적인 스프링 빈으로 등록할 때 사용하는 어노테이션 / @Component이 붙은 클래스는 스프링 컨테이너에서 관리되는 빈이 됨
@Service : @Component의 특별한 형태로, 비즈니스 로직을 처리하는 서비스 레이어에서 주로 사용 /
기능적으로는 @Component와 동일하지만, 코드 가독성과 설계 측면에서 서비스 역할을 명확히 하기 위해 사용
@RestController : @Component의 또 다른 특별한 형태로, RESTful 웹 서비스의 컨트롤러를 정의할 때 사용 / @Controller와 @ResponseBody를 합친 형태로, REST API의 엔드포인트를 제공하는 클래스에서 사용

 

 

 


 

이렇게 구현하고 main branch에 merge했는데 갑자기 이런 에러가 떴다...

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: org.hibernate.bytecode.spi.BytecodeProvider: org.hibernate.bytecode.internal.bytebuddy.BytecodeProviderImpl Unable to get public no-arg constructor

 

ChatGPT한테 물어보니까

build.gradle에 이런 dependency를 추가하라고 했다

implementation 'net.bytebuddy:byte-buddy:1.14.7'

이 라이브러리가 뭘 하는 앤지 살펴보니까

동적으로 생성된 클래스가 클래스 로더에서 다른 클래스와 이름 충돌 없이 로드될 수 있도록 보장하는 역할을 한다고 한다..

사실 아직은 느낌이 잘 안 오는데 이거 추가하니까 단번에 해결됐답..

 


 

으아아아아 한동안 놓고 있었는데

다시 시작해보자자자자자ㅏ자자자ㅏ자자

파이티이이이이잉🔥🔥🔥🔥🔥🔥