Project/ClassFlix

[ClassFlix] EP 15. 리팩토링 (Spring Data JPA 적용)

목차 (클릭시 해당 목차로 이동)


     

     

     

    지금까지는 스프링 데이터 JPA를 적용하지 않고 순수 JPA로 개발했습니다.

     

    기존의 순수한 JPA코드들을 차근차근 스프링 데이터 JPA로 바꿔가 보겠습니다.

     

     

     

    계획

     

    • 각 리포지토리에서 사용중인 기능들을
      1. 공통/쿼리 메소드로 해결가능한 기능과
      2. 사용자정의 리포지토리로 풀어야 하는 기능을
      구분해 스프링 데이터 JPA를 적용합니다.
    • ToOne관계에서 fetch join이 필요한 부분도 풀어냅니다.
    • Auditing을 이용할 것인데 BaseEntity, BaseTimeEntity를 둘 다 만들지만 적용은 BaseEntity만 합니다. 수정,등록자는 인증, 세션을 도입한 후 실제 사용자의 아이디를 받아 넣을 것입니다.

     

     

     

     

    MemberRepository

     

    현재 구현된 메서드들

      공통메서드 쿼리메소드
    save o  
    findById o  
    findByName   o
    findAll o  

     

     

    스프링 데이터 JPA가 적용된 MemberRepository interface

     

    공통메서드는 이미 구현이 되어있고, userName으로 조회하는 쿼리메소드만 하나 추가해주면 됩니다.

    package dongho.classflix.repository;
    
    import dongho.classflix.domain.Member;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.List;
    
    public interface MemberRepository extends JpaRepository<Member, Long> {
        List<Member> findByUserName(String memberName);
    }
    

     

     

     

    스프링 데이터 JPA가 적용된 MembeService

     

    기존에 사용하던 리포지토리의 메서드들을 스프링 데이터 JPA가 적용된 리포지토리로 바꾸어주었습니다.

     

    findById는 Optional로 반환이 되기 때문에 null일 경우 orElseThrow로 적당한 예외를 반환하도록 했습니다.

    package dongho.classflix.service;
    
    import dongho.classflix.domain.Member;
    import dongho.classflix.repository.MemberRepository;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.util.List;
    
    @Service
    @Transactional
    @RequiredArgsConstructor
    public class MemberService {
    
        private final MemberRepository memberJpaRepository;
    
    
        /**
         * 회원가입
         */
        public Long join(Member member) {
            validateDuplicateMember(member);
            memberJpaRepository.save(member);
            return member.getId();
        }
    
        // 회원 전체 조회
        @Transactional(readOnly = true)
        public List<Member> findMembers() {
            return memberJpaRepository.findAll();
        }
    
        // 회원 하나 조회
        @Transactional(readOnly = true)
        public Member findOne(Long memberId) {
            return memberJpaRepository.findById(memberId).orElseThrow();
        }
    
        @Transactional(readOnly = true)
        public List<Member> findByName(String memberName) {
            if (memberName == null) {
                throw new NullPointerException("회원 이름이 입력되지 않았습니다.");
            }
            return memberJpaRepository.findByUserName(memberName);
        }
    
        private void validateDuplicateMember(Member member) {
            List<Member> findMembers = memberJpaRepository.findByUserName(member.getUserName());
            if (!findMembers.isEmpty()) {
                throw new IllegalStateException("이미 존재하는 회원입니다.");
            }
        }
    }
    

     

     

     

     

     

    Test

    테스트 로직도 약간의 수정이 있었습니다.

    원래 순수 Jpa로 짰던 코드의 반환값이 스프링 데이터 JPA와 달랐기 때문에 이 부분을 수정 해주었고, Optional로 반환되는 findById에 대한 테스트도 orElseThrow를 통해 해결하도록 했습니다.

     

     

     

    MemberRepositoryTest

    package dongho.classflix.repository;
    
    import dongho.classflix.domain.Gender;
    import dongho.classflix.domain.Member;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.annotation.Transactional;
    
    @SpringBootTest
    @Transactional
    class MemberRepositoryTest {
    
    
        @Autowired
        MemberRepository memberRepository;
    
        @Test
        public void 회원저장조회() throws Exception {
            //given
            Member member = new Member("dongho", 25, Gender.MALE);
            memberRepository.save(member);
    
            //when
            Member savedMember = memberRepository.findById(member.getId()).orElseThrow();
    
            //then
            Assertions.assertThat(member).isEqualTo(savedMember);
        }
    }

     

     

     

    MemberSeriveTest

    package dongho.classflix.service;
    
    import dongho.classflix.domain.Gender;
    import dongho.classflix.domain.Member;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.annotation.Transactional;
    
    import javax.persistence.EntityManager;
    
    import static org.junit.jupiter.api.Assertions.assertThrows;
    
    @SpringBootTest
    @Transactional
    class MemberServiceTest {
    
        @Autowired
        EntityManager em;
    
        @Autowired
        MemberService memberService;
    
    
        @Test
        public void 중복회원예외() throws Exception {
            //given
            Member member1 = new Member("dongho", 25, Gender.MALE);
            Member member2 = new Member("dongho", 28, Gender.MALE);
    
            //when
            memberService.join(member1);
    
            //then
            assertThrows(IllegalStateException.class, () -> {
                memberService.join(member2);
            });
        }
    }

     

     

     

     

     

    모든 테스트가 잘 동작합니다.

     

     

     

    MemberController에서 사용하던 MemberService도 패키지 경로를 다시 설정해주었습니다.

    Controller는 Service만 보고 있기 때문에 Service의 코드만 잘 수정했으면 컨트롤러에는 문제가 생기지 않다고 예상했고 예상대로였습니다.

     

     

     

    애플리케이션에서 회원등록, 강의등록도 잘 동작하고 강의정보 페이지에서도 회원이 잘 불러와 지는 것을 확인하였습니다.

     

     

     

     

     

    BaseTimeEntity

     

    모든 엔티티에서 쓰일 BaseTimeEntity를 하나 만들어주었고 Member에 적용했습니다.

    package dongho.classflix.domain;
    
    import lombok.Getter;
    import org.springframework.data.annotation.CreatedDate;
    import org.springframework.data.annotation.LastModifiedDate;
    import org.springframework.data.jpa.domain.support.AuditingEntityListener;
    
    import javax.persistence.Column;
    import javax.persistence.EntityListeners;
    import javax.persistence.MappedSuperclass;
    import java.time.LocalDateTime;
    
    @EntityListeners(AuditingEntityListener.class)
    @MappedSuperclass
    @Getter
    public class BaseTimeEntity {
    
        @CreatedDate
        @Column(updatable = false)
        private LocalDateTime createdDate;
    
        @LastModifiedDate
        private LocalDateTime lastModifiedDate;
    }
    

     

     

     

    Member table을 생성할때 Jpa가 등록시간, 수정시간을 담는 컬럼을 생성해줍니다.

     

     

     

    Member가 생성될때 생성시간, 수정시간이 들어가야하는데 NULL이 들어갔습니다...?

     

    2021.06.01 - [Web/JPA] - [Data JPA] EP 6. 나머지 기능들

     

    [Data JPA] EP 6. 나머지 기능들

    목차 (클릭시 해당 목차로 이동) Projections close projection : 가져오고 싶은 부분만 딱 맞춰서 쿼리날려서 가져오기 open projection : 일단 엔티티를 다 가져와서 애플리케이션에서 골라내기 인터페이스

    ksabs.tistory.com

     

     

     

     

    Application에 @EnableJpaAuditing를 안넣어줬었네요.

     

    넣고 다시 실행

     

     

    등록시간, 수정시간이 잘 들어가 있습니다.

     

     

     

    MemberRepository 스프링 데이터 JPA로 리팩토링 완료!

     

    이제 한번 진행해봤으니 lecture, review는 쉬울거라고 기대 해보겠습니다.

     

     

     

     

    LectureRepository

     

    현재 구현된 메서드들

      공통메서드 쿼리메소드
    save o  
    findById o  
    findByName   o
    findAll o  

     

     

     

    findByName은 특별하게 lectureName, teacherName 두개로 같이 조회합니다.

        public List<Lecture> findByName(String lectureName, String teacherName) {
            return em.createQuery("select l from Lecture l " +
                    "where l.lectureName = :lectureName and l.teacherName = :teacherName", Lecture.class)
                    .setParameter("lectureName", lectureName)
                    .setParameter("teacherName", teacherName)
                    .getResultList();
        }

     

     

    그래서 쿼리메소드의 findBylectureNameAndteacherName 을 이용해 두개의 이름으로 조회하는 메서드를 만들었습니다.

    List<Lecture> findByLectureNameAndTeacherName(String lectureName, String teacherName);

     

     

     

     

    스프링 데이터 JPA가 적용된 LectureRepository interface

     

    공통메서드를 제외하고 쿼리메서드 하나만 추가되었습니다.

    package dongho.classflix.repository;
    
    import dongho.classflix.domain.Lecture;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.List;
    
    public interface LectureRepository extends JpaRepository<Lecture, Long> {
        List<Lecture> findByLectureNameAndTeacherName(String lectureName, String teacherName);
    }
    

     

     

     

     

     

    스프링 데이터 JPA가 적용된 LectureService

    package dongho.classflix.service;
    
    import dongho.classflix.domain.Lecture;
    import dongho.classflix.repository.LectureJpaRepository;
    import dongho.classflix.repository.LectureRepository;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.web.multipart.MultipartFile;
    
    import java.io.File;
    import java.io.IOException;
    import java.time.LocalDate;
    import java.util.List;
    
    @Transactional
    @Service
    @RequiredArgsConstructor
    @Slf4j
    public class LectureService {
    
        private final LectureJpaRepository lectureJpaRepository;
        private final LectureRepository lectureRepository;
    
        // 파일파싱
        @Transactional(readOnly = true)
        public FileInfo fileSaveAndParsing(MultipartFile multipartFile) throws IOException {
    
            FileInfo fileInfo = new FileInfo();
    
            if (multipartFile.isEmpty()) {
                fileInfo.setFileName("");
                fileInfo.setFilePath("");
                fileInfo.setFileSize(0);
                return fileInfo;
            }
    
            String[] strArray = multipartFile.getOriginalFilename().split("\\.");
            log.info("origin type : {}", strArray[strArray.length-1]);
            String fileName = "" + LocalDate.now() + System.nanoTime() + "." + strArray[strArray.length-1];
    
            String absolutePath = new File("").getAbsolutePath() + "/src/main/resources/static/images/represent/";
            String path = "/images/represent/" + fileName;
    
            log.info("type : {}, name : {}, path : {}", multipartFile.getContentType(), fileName, path);
    
            File file = new File(absolutePath + fileName);
    
            if (!file.exists()) {
                file.mkdirs();
            }
    
            multipartFile.transferTo(file);
    
            fileInfo.setFileName(fileName);
            fileInfo.setFileSize(multipartFile.getSize());
            fileInfo.setFilePath(path);
            return fileInfo;
        }
    
        // 조인
        public Lecture join(Lecture lecture) {
            validateDuplicateLecture(lecture);
    //        return lectureJpaRepository.save(lecture);
            return lectureRepository.save(lecture);
        }
    
        private void validateDuplicateLecture(Lecture lecture) {
    //        List<Lecture> findLectures = lectureJpaRepository.findByName(lecture.getLectureName(), lecture.getTeacherName());
            List<Lecture> findLectures = lectureRepository.findByLectureNameAndTeacherName(lecture.getLectureName(), lecture.getTeacherName());
            if (!findLectures.isEmpty()) {
                throw new IllegalStateException("이미 존재하는 강의입니다.");
            }
        }
    
        // 업데이트
        public void update(Long id, LectureDto lectureDto) {
    //        Lecture findLecture = lectureJpaRepository.findById(id);
            Lecture findLecture = lectureRepository.findById(id).orElseThrow();
            findLecture.changeLectureData(lectureDto.getLectureName(), lectureDto.getTeacherName(), lectureDto.getContent(),
                    lectureDto.getRepresentImagePath(), lectureDto.getRepresentImageSize(), lectureDto.getRepresentImageName(),
                    lectureDto.getSiteName(), lectureDto.getUri());
        }
    
        // 조회
        @Transactional(readOnly = true)
        public List<Lecture> findAll() {
    //        return lectureJpaRepository.findAll();
            return lectureRepository.findAll();
        }
    
        public Lecture findById(Long id) {
            return lectureRepository.findById(id).orElseThrow();
        }
    
        // review refresh
        public void refreshAverageRating(Long lectureId) {
            Lecture findLecture = lectureRepository.findById(lectureId).orElseThrow();
            findLecture.calculateAverageRating();
        }
    
        // delete review
        public void deleteReview(Long lectureId, Long reviewId) {
            Lecture findLecture = lectureRepository.findById(lectureId).orElseThrow();
            findLecture.removeReview(reviewId);
        }
    }
    

     

     

     

     

     

     

     

     

    Test

     

    각 테스트도 잘 동작합니다.

     

     

    강의도 정상적으로 잘 등록이 됩니다.

     

     

     

     

     

     

    BaseTimeEntity

     

    등록시간, 수정시간도 잘 적용됩니다.

     

     

     

     

     

     

     

    ReviewRepository

     

     

    현재 구현된 메서드들

      공통메서드 쿼리메소드 사용자정의메서드
    save     o
    findById o    
    findAll o    
    findAllWithLecture   o  
    delete o    

     

     

     

     

     

    스프링 데이터 JPA가 적용된 ReivewRepository interface

     

    쿼리메서드 하나와 사용자 정의 메서드 하나가 필요합니다.

     

    쿼리메서드 (강의에 달린 리뷰 조회)

     

    lecture_id가 같은 리뷰를 조회하는 쿼리를 날려야합니다. 

    스프링 데이터 JPA의 쿼리메서드에 _id를 이용해서 해결했습니다.

    List<Review> findAllByLecture_Id(Long lectureId);

     

     

    사용자정의 메서드 (리뷰등록-해당강의의 리뷰에 추가)

     

    리뷰는 항상 어떤 강의에 등록이 되어있습니다.

    그래서 리뷰를 save할때 해당 강의의 리뷰컬렉션에 리뷰를 add해주어야 하는 로직이 필요합니다.

    그렇기 때문에 review의 save메서드는 스프링 데이터 JPA의 save를 사용할 수 없습니다.

     

    새로운 saveWithLecture 라는 사용자 정의 메서드를 만들었습니다.

    package dongho.classflix.repository;
    
    import dongho.classflix.domain.Review;
    
    public interface ReviewRepositoryCustom {
        Review saveWithLecture(Review review);
    }
    
    package dongho.classflix.repository;
    
    import dongho.classflix.domain.Lecture;
    import dongho.classflix.domain.Review;
    import lombok.RequiredArgsConstructor;
    
    import javax.persistence.EntityManager;
    
    @RequiredArgsConstructor
    public class ReviewRepositoryImpl implements ReviewRepositoryCustom{
    
        private final EntityManager em;
    
        @Override
        public Review saveWithLecture(Review review) {
            em.persist(review);
            Lecture lecture = review.getLecture();
            lecture.addReview(review);
            return review;
        }
    }
    
    package dongho.classflix.repository;
    
    import dongho.classflix.domain.Review;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.List;
    
    public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRepositoryCustom {
        List<Review> findAllByLecture_Id(Long lectureId);
    }
    

     

     

     

    스프링 데이터 JPA가 적용된 ReviewService

     

     

    1. save -> saveWithLecture
    2. findById 에는 orElseThrow를 추가
    3. findAllWithLecture -> findAllWithLecture_Id

     

    package dongho.classflix.service;
    
    import dongho.classflix.domain.Review;
    import dongho.classflix.repository.ReviewRepository;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.util.List;
    
    @Service
    @Transactional
    @RequiredArgsConstructor
    public class ReviewService {
    
        private final ReviewRepository reviewRepository;
        private final LectureService lectureService;
    
        // 리뷰 등록
        public Long create(Review review) {
            reviewRepository.saveWithLecture(review);
            return review.getId();
        }
    
        // 하나 조회
        @Transactional(readOnly = true)
        public Review findById(Long reviewId) {
            return reviewRepository.findById(reviewId).orElseThrow();
        }
    
    
        // 전체 조회
        @Transactional(readOnly = true)
        public List<Review> findAll() {
            return reviewRepository.findAll();
        }
    
        // 강의에 달린 리뷰 조회
        @Transactional(readOnly = true)
        public List<Review> findByLecture(Long lectureId) {
            return reviewRepository.findAllByLecture_Id(lectureId);
        }
    
        // 리뷰 수정
        public Long update(Long reviewId, Long lectureId, String content, Integer rating) {
            Review findReview = reviewRepository.findById(reviewId).orElseThrow();
            findReview.changeContentAndRating(content, rating);
            lectureService.refreshAverageRating(lectureId);
            return reviewId;
        }
    
        // 리뷰 삭제
        public Long delete(Long reviewId, Long lectureId) {
            lectureService.deleteReview(lectureId, reviewId);
            reviewRepository.delete(reviewRepository.findById(reviewId).orElseThrow());
            return reviewId;
        }
    
    }
    

     

     

     

     

     

     

     

     

     

    Test

     

    각 테스트도 잘 동작합니다.

     

     

     

     

     

    리뷰의 등록, 수정, 삭제가 다 잘 동작합니다.

     

     

     

     

     

    BaseTimeEntity

     

    기존에 강의, 리뷰에 LocalDate 필드가 있었는데 스프링 데이터 JPA의 Auditing 기능으로 다 교체하는 작업을 해주었습니다.

     

     

     

    강의, 리뷰필드의 LocalDateTime -> extends BaseTimeEntity