목차 (클릭시 해당 목차로 이동)
지금까지는 스프링 데이터 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
- save -> saveWithLecture
- findById 에는 orElseThrow를 추가
- 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


'Project > ClassFlix' 카테고리의 다른 글
[ClassFlix] EP 17. QueryDSL 도입 (페이징, 정렬) (0) | 2021.07.14 |
---|---|
[ClassFlix] EP 16. 리팩토링 계획 (0) | 2021.06.17 |
[ClassFlix] EP 14. 리팩토링과 성능최적화 - 3 (0) | 2021.05.21 |
[ClassFlix] EP 13. 리팩토링과 성능최적화 - 2 (사진업로드, 출력) (0) | 2021.05.18 |
[ClassFlix] EP 12. 리팩토링과 성능최적화 - 1 (0) | 2021.05.14 |
- 계획
- MemberRepository
- 현재 구현된 메서드들
- 스프링 데이터 JPA가 적용된 MemberRepository interface
- 스프링 데이터 JPA가 적용된 MembeService
- Test
- BaseTimeEntity
- LectureRepository
- 현재 구현된 메서드들
- 스프링 데이터 JPA가 적용된 LectureRepository interface
- 스프링 데이터 JPA가 적용된 LectureService
- Test
- BaseTimeEntity
- ReviewRepository
- 현재 구현된 메서드들
- 스프링 데이터 JPA가 적용된 ReivewRepository interface
- 스프링 데이터 JPA가 적용된 ReviewService
- Test
- BaseTimeEntity