https://www.youtube.com/watch?v=kJexMyaeHDs
VIDEO
목차
문제상황1
문제상황2
문제상황3
문제상황4
Proxy 사용시 주의해야할점( equals & hashcode )
문제 상황1
id와 name 을 변수로하고있는 Crew Entity 생성
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Crew {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
Spring data jpa를 이용하여 Crew Repository 정의
public interface CrewRepository extends JpaRepository <Crew, Long> {
}
class CrewRepositoryTest {
@Autowired
private CrewRepository crewRepository;
private static final Crew PEPPER = new Crew ("페퍼" );
}
@Test
void saveAndCompare () {
Crew crew = crewRepository.save(PEPPER);
assertThat(crew).isEqualTo(PEPPER);
}
Entity의 2개가 보장이 되어 테스트가 통과한다.
@Test
void find () {
Crew crew = crewRepository.save(PEPPER);
Optional<Crew> findCrew = crewRepository.findById(crew.getId());
assertThat(findCrew).hasValue(PEPPER);
}
@Test
void saveAndCompare () {
Crew crew = crewRepository.save(PEPPER);
assertThat(crew).isEqualTo(PEPPER);
}
@Test
void find () {
Crew crew = crewRepository.save(PEPPER);
Optional<Crew> findCrew = crewRepository.findById(crew.getId());
assertThat(findCrew).hasValue(PEPPER);
}
두번쨰 find method에서 오류가 났다.
테스트 격리가 되지않아 실패한 것같은데, 왜 동시성이 보장이 되지 않는가?
JPA Entity 생명주기에 대해 알아본다.
예시와 함꼐 알아보자.
친구A 가 있다. 우아한테크코스에서 4기를 모집한다.
친구A 지원한다. 우아한 테크코스에서 출입증을 받는다. 10번 지각하여 경비원에게 출입증을 뻇길 수 있다.
11월이 되서 우아한테크코스에 수료했다.
우테코에서 5기를 다시 모집한다.
친구 A가 다시 출입증을 받아 다시 5기에 들어와야한다.
스토리에서 Entity 생명주기로 보면,
처음에 친구A가 출입증이 없는 상태가 비영속 상태이다.
친구A가 우테코에 출입증을 받아서 persist()하여 출입증을 받아서 영속이 되는 상태다.
경비원에게 출입증을 뻇길뻔한 상태를 삭제엔티티 remove() <-> persist()
수료를 한 상태를 준영속 엔티티라고한다. detach(), clear(), close() <-> merge() (다시 들어오는것)
Spring DATA JPA에서 save내부로직을 보자.
EntityInformation.isNew를 통하여 들어온 엔티티가 비영속 상태인지 혹은 준영속 상태인지 확인한다.
비영속상태라면, persist를 통해 영속상태로 한다.
만약 준영속 상태라면, merge를 통해 영속 상태로 들어온다.
처음에 들어온 영속 엔티티와, 준영속상태에서 merge를 통해 영속으로 들어온 Entity는 명백하게 다르다.
merge 이전의 준영속 상태와
merge 후의 영속상태는
public class SimpleJpaRepository <T, ID> implements JpaRepositoryImplementation <T, ID> {
@Transactional ;
@Override
public <S extends T > S save (S entity) {
Assert.notNull(entity, "Entity must not be null." );
if (entityInformation.isNew(entity)){
em.persist(entity);
return entity;
}else {
return em.merge(entity);
}
}
}
실패한 테스트코드로 돌아가서 EntityInformation의 도움을 받아 save Method가 실행되기 전의 Entity 상태를 확인해보자.
@PersistenceContext
private EntityManager entityManager;
private JpaEntityInformation<Crew, ?> entityInformation;
@BeforeEach
void setUp () {
entityInformation = JpaEntityInformationSupport.getEntityInformation(Crew.class, entityManager);
}
@Test
void saveAndCompare () {
System.out.println("[saveAndCompare] A의 비영속상태 여부 = " + entityInformation.isNew(PEPPER));
System.out.println("[saveAndCompare] A의 ID 확인 = " + PEPPER.getId()));
Crew crew = crewRepository.save(PEPPER);
assertThat(crew).isEqualTo(PEPPER);
}
@Test
void find () {
System.out.println("[find] A의 비영속상태 여부 = " + entityInformation.isNew(PEPPER));
System.out.println("[find] A의 ID 확인 = " + PEPPER.getId()));
Crew crew = crewRepository.save(PEPPER);
Optional<Crew> findCrew = crewRepository.findById(crew.getId());
assertThat(crew).isEqualTo(PEPPER);
}
saveAndCompare 메서드에서는
find 메서드에서는
즉, 준영속 상태였기에 새로 id가 생겨서 안되었던 것이다.
올바르게 엔티티 동일성을 보장하기 위해서는, 아래와 같이 작성할경우 통과한다.
@PersistenceContext
private EntityManager entityManager;
private JpaEntityInformation<Crew, ?> entityInformation;
@BeforeEach
void setUp () {
entityInformation = JpaEntityInformationSupport.getEntityInformation(Crew.class, entityManager);
}
@Test
void saveAndCompare () {
Crew pepper = new Crew ("페퍼" );
Crew crew = crewRepository.save(PEPPER);
assertThat(crew).isEqualTo(PEPPER);
}
@Test
void find () {
Crew crew = crewRepository.save(new Crew ("페퍼" ));
Optional<Crew> findCrew = crewRepository.findById(crew.getId());
assertThat(findCrew).hasValue(crew);
}
비영속, 영속, 준영속에 대한 것은 조금 알겠지만 삭제에 대해서 추가로 알아보자.
공식문서에는 Removed entity instances have a persistent identity, are associated with a persistent context, and are scheduled for removal from the data store
삭제 상태의 엔티티는 ID값이 있고 영속성 컨텍스트와 연결되어있으며 DB에서 제거되도록 예약되어있다.
save메서들르 통해 영속상태 Entity를 만들고 이 entity를 삭제하고 다시 조회해보자.
테스트가 통과는하지만, deleteById 쿼리만 나간다.
이 Entity가 삭제를 했을떄에도 아직 영속성 컨텍스트에 남아있기에 findById 쿼리가 나가지 않는다.
@Test
void removed () {
Crew pepper = crewRepository.save(new Crew ("페퍼" ));
Long pepperId = pepper.getId();
crewRepository.deleteById(pepperId);
assertThat(crewRepository.findById(pepperId));
}
findById를 통해 값을 조회할경우에는
@Test
void removed () {
Crew pepper = crewRepository.save(new Crew ("페퍼" ));
Long pepperId = pepper.getId();
crewRepository.deleteById(pepperId);
assertThat(crewRepository.findById(pepperId).isPresent());
}
실패를 한다.
Optional value but empty 값이 비어있다는 오류가 나온다.
삭제 Entity가 findById로 영속성 컨텍스트에 존재하지만, isPresent()로 값을 가져오지는 못한다.
DB에서 제거되도록 예약되어있기에 값을 가져오지는 못하는것이다.
문제상황2
아래의 테이블에 entity의 name을 unique 설정을 추가한 상태이다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(uniqueConstraints = {@UniqueConstraint(name = "unique_crew_name", columnNames = {"name"}}})
public class Crew{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
db에 있는 deleteAll()을 통해 모두 삭제한뒤 crews를 모두 저장하는 서비스코드이다.
@RequiredArgsConstructor
@Transactional
@Service
public class CrewService {
private final CrewRepository crewRepository;
public List<Crew> update (List<Crew> crews) {
crewRepository.deleteAll();
return crewRepository.saveAll(crews);
}
}
@Test
void update () {
crewRepository.save(new Crew ("app1" , "app1@hello.com" ));
crewRepository.save(new Crew ("app2" , "app2@hello.com" ));
List<Crew> crew_List = List.of(
new Crew ("app3" , "app3@hello.com" );
new Crew ("app4" , "app4@hello.com" );
new Crew ("app5, " app5@hello .com");
new Crew(" app1", " app1@hello .com");
);
//when
List<Crew> registered_List = crewService.update(crew_list);
//then
assertThat(registered_list).hasSize(crew_list.size());
}
테스트시 실패를한다. 실패 이유는 unique값 설정 원인으로 sql violation 이 생긴다.
문제해결책을 알아보기전에
1차캐시에 대한 개념이 필요하다.
Entity를 DB에 저장해달라고 요청할때, 처음에 DB로 바로가는것이 아닌 1차 캐시에 저장된다.
@Id값이 없는 Entity가 들어갈때는 Id값을 모르기에 바로 DB에 먼저 넣은뒤 ID값이 자동생성되고 그 값을 통해 ID값을 알아낸뒤 1차캐시로 가져온다. (즉, Insert가 일단 DB에 먼저 들어가는것이다. )
@Id값이 있는 Entity가 들어갈때는 Id값을 알고있기에 1차캐시에 바로 들어간뒤 해당 쿼리를 쓰기지연 SQL저장소에 적재(INSERT App1, INSERT APP2) 된 뒤 flush()로 DB에 저장된다.
deleteAll 메서드에 대해 알아보자.
문제상황3
전체 crew들을 조회한뒤 한명씩 age를 update 한다.
update 쿼리들을 다 한개씩 하면 비효율적이다.
@Service
@RequiredArgsConstructor
public class CrewService {
private final CrewRepository crewRepository;
public void updateAllAge () {
List<Crew> crews = crewRepository.findAll();
for (Crew crew : crews){
crew.updateAge();
}
}
}
public interface CrewRepository extends JpaRepository <Crew, Long>{
@Modifying
@Query("update Crew c set c.age = c.age + 1")
int increaseAllAge () ;
}
@Service
@RequiredArgsConstructor
public class CrewService {
private final CrewRepository crewRepository;
public int updateAllAge () {
return crewRepository.increaseAllAge();
}
}
@Test
void increaseAgeOfEveryone () {
Crew A = crewRepository.save(new Crew ("A" , 20 ));
Crew B = crewRepository.save(new Crew ("B" , 21 ));
Crew C = crewRepository.save(new Crew ("C" , 22 ));
Crew D = crewRepository.save(new Crew ("D" , 23 ));
crewRepository.increaseAllAge();
System.out.println("count = " + count);
assertAll(
() -> assertThat(A.getAge()).isEqualTo(21 ),
() -> assertThat(A.getAge()).isEqualTo(22 ),
() -> assertThat(A.getAge()).isEqualTo(23 ),
() -> assertThat(A.getAge()).isEqualTo(24 ),
);
}
테스트가 실패하는 이유를 알아본다.
변경감지(Dirty Checking)에 대해 알아보자.
Crew( "A")가 존재한다고 하자.
Crew("A")는 ID값이 없기에 DB에서 가져와서 Crew(1, "A") 상태로 1차 캐시에 들어온다.
1차 캐시에 들어오는데, @Id, Entity에는 저장되고 이떄 스냅샷으로 저장되는 시점의 Entity를 함께 저장한다.
이제 name = "B"로 바꿔보자.
1차 캐시에서 "B"로 바뀌고 쓰기지연SQL저장소에 Update name = "B"가 들어온다.
그리고 findAll()과 같이 DB와 직접 호출하는 메서드들이 호출될떄 flsuh()되어 쓰기지연SQL저장소에서 DB로 나간다.
이떄 기억해야할점은 스냅샷에는 여전히 이름은 "A"이다.
flush는 언제 발생할까?
1. 강제로 flush 명령시
2. JPQL 쿼리 실행시
3. 트랜잭션 커밋시
위의 3가지 방법중에 2번 JPQL쿼리 실행시에 실행되게했다.
JPQL 쿼리가 실행되면 바로 db로 가는데 이떄 쓰기지연SQL저장소에 남아있는 SQL을 모두 flush 한뒤 JPQL쿼리가 발생한다.
근데 이 flush가 JPQL 이 발생하면 무조건 발생할까? 아니다. 그렇기 떄문에 Update가 되지 않는다.
JPQL에서 연관된 엔티티들 중에서 쓰기지연SQL저장소에 있는 엔티티만 flush 된다.
문제상황을 다시보자.
영속성 컨텍스트 안에 있는 Entity들은 JPQL 쿼리가 나갈떄 어떤 상태가 될까? 아무런 변화가 일어나지 않아 데이터 정합성이 꺠지기에 테스트가 실패한다.
해결방안은,
JPQL을 활용하지 않고 쓰기지연을 활용한다.
@Service
@RequiredArgsConstructor
public class CrewService {
private final CrewRepository crewRepository;
public int updateAllAge () {
List<Crew> crews = crewRepository.findAll();
for (Crew crew : crews){
crew.updateAge();
}
}
}
만약 JPQL을 활용한다고한다면,
@Test
void increaseAgeOfEveryone () {
Crew A = crewRepository.save(new Crew ("A" , 20 ));
Crew B = crewRepository.save(new Crew ("B" , 21 ));
Crew C = crewRepository.save(new Crew ("C" , 22 ));
Crew D = crewRepository.save(new Crew ("D" , 23 ));
crewRepository.increaseAllAge();
entityManager.flush();
entityManager.clear();
assertAll(
() -> assertThat(crewRepository.findById(A.getId()).get().getAge()).isEqualTo(21 ),
() -> assertThat(crewRepository.findById(B.getId()).get().getAge()).isEqualTo(22 ),
() -> assertThat(crewRepository.findById(C.getId()).get().getAge()).isEqualTo(23 ),
() -> assertThat(crewRepository.findById(D.getId()).get().getAge()).isEqualTo(24 ),
);
}
Spring JPA에서 Modifying annotation의 clearautomatically로 flush와 clear를 잘 관리한다.
public interface CrewRepository extends JpaRepository <Crew, Long>{
@Modifying(clearAutomatically = true)
@Query("update Crew c setc.age = c.age + 1")
int increaseAllAge () ;
}
문제상황4
Crew에게 Team 연관관계를 만들어준다.
Crew를 Team 이름별로 정렬을 하고싶어 Comparable<Crew>를 implements한다.
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
public int compareTo (final Team o) {
return title.compareTo(o.title);
}
}
@Entity
public class Crew implements Comparable <Crew>{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
@Override
public int compareTo (final Crew o) {
return team.compareTo(o.team);
}
}
아래의 테스트는 실패한다.
team의 이름을 읽을 수 없다는 null pointer Exception이 발생한다.
우리는 Crew와 Team을 @ManyToOne을 Lazy 관계 (지연로딩) 로 연결했다.
즉, Team은 진짜 Entity가 아닌 Entity를 상속,가리키는 가짜객체로 Proxy 객체이다. 실제 Entity 값이 필요할떄 Entity를 가져온다.
1. getTitle()로 Proxy에 요청한다.
2. Entity 초기화요청을 영속성 컨텍스트에 요청
3. DB에서 조회
4. 실제 Entity를 생성
5. target.getTitle을 하여 proxy값이 채워진다.
@Test
void sortByTeamName () {
Team teamA = teamRepository.save(new Team ("teamA" ));
Team teamB = teamRepository.save(new Team ("teamB" ));
Team teamC = teamRepository.save(new Team ("teamC" ));
crewRepository.save(new Crew ("crewA" , teamA));
crewRepository.save(new Crew ("crewB" , teamB));
crewRepository.save(new Crew ("crewC" , teamB));
crewRepository.save(new Crew ("crewD" , teamC));
entityManager.flush();
entityManager.clear();
List<Crew> crews = crewRepository.findAll();
Collections.sort(crews);
List<String> orderedCrewNames = crews.stream()
.map(Crew::getName)
.collect(Collectors.toList());
assertThat(orderedCrewNames).containsExactly("crewA" , "crewB" , "crewC" , "crewD" );
}
해결방안은, 우선 오류코드는 아래와 같다.
public int compareTo (final Team o) {
return title.compareTo (o.title);
}
위의 코드를 아래와 같이 수정한다. 이렇게 직접 getTitle()로써 호출해야한다.
public int compareTo (final Team o) {
return getTitle().compareTo(o.getTitle());
}
이미 Crew의 title은 호출된 entity이기에 아래와 같이도 가능하다. 파라미터로 받은 Team이 Proxy객체다.
public int compareTo (final Team o) {
return title.compareTo(o.getTitle());
}
프록시 사용중 유의해야할점
Equals & hashcode
Proxy 사용시 Proxy와 진짜 Entity는 동일성을 보장하지 않기에, equals와 hashcode를 그대로 override하여 사용하면 오류가난다. class 가 같다는 부분을 삭제하여 사용한다.
@Override
public boolean equals (final Object o) {
if (this == o){
return true ;
}
if (o == null || getClass() != o.getClass()){
return false ;
}
final Team team = (Team) o;
return Objects.equals(title, o.getTitle());
}
@Override
public int hashCode () {
return Objects.hash(title);
}
아래와 같이 class가 같다는 부분을 삭제함으로써 객체의 참조 동일성 검사를 하지 않는다.
@Override
public boolean equals (final Object o) {
if (this == o){
return true ;
}
if (o == null ){
return false ;
}
final Team team = (Team) o;
return Objects.equals(title, o.getTitle());
}
@Override
public int hashCode () {
return Objects.hash(title);
}