https://www.youtube.com/watch?v=kJexMyaeHDs 

목차

  • 문제상황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> {

}
  •  CrewRepository Test 선언
class CrewRepositoryTest {
	@Autowired
    private CrewRepository crewRepository;
    
    private static final Crew PEPPER = new Crew("페퍼");
}
  • saveandCompare() 테스트 진행
@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 후의 영속상태는
            • 명백히 다른 Entity다.
      • 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);
    
    //[saveAndCompare] A의 비영속상태 여부 = true
    //[saveAndCompare] A의 ID 확인 = null
    
    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);
    
    //[find] A의 비영속상태 여부 = false
    //[find] A의 ID 확인 = 1
    
    Optional<Crew> findCrew = crewRepository.findById(crew.getId());
    
    assertThat(crew).isEqualTo(PEPPER);
}
  • saveAndCompare 메서드에서는
    • 비영속 상태이고, id가 없다.
  • find 메서드에서는 
    • 준영속 상태이고, id가 있다.
  • 즉, 준영속 상태였기에 새로 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(){
        	//given
            Crew pepper = crewRepository.save(new Crew("페퍼"));
            Long pepperId = pepper.getId();
            
            //when
            crewRepository.deleteById(pepperId);
            
            //then
            assertThat(crewRepository.findById(pepperId));
        }
      • findById를 통해 값을 조회할경우에는
      • @Test
        void removed(){
        	//given
            Crew pepper = crewRepository.save(new Crew("페퍼"));
            Long pepperId = pepper.getId();
            
            //when
            crewRepository.deleteById(pepperId);
            
            //then
            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(){
	//given
    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 메서드에 대해 알아보자.
      • 먼저 findAll을 한뒤 각각 delete를 진행한다. 
      • @Transactional
        @Override
        public void deleteAll(){
        	for(T element : findAll()){
            	delete(element);
            }
        }
      • deleteall명령이 들어오면 findAll을 통해 DB의 모든 값을 영속성 컨텍스트의 1차 캐시에 넣는다. 그리고 1차캐시의 ID값들을 통해 쓰기지연 SQL저장소에 delete app1, delete app2 를 적재한다. 
      • 그전에 flush 에 대해 좀더 알아보자.
        • flush가 발생하는 조건은
          • 1. 강제로 flush 명령할떄
          • 2. JPQL 쿼리가 실행될떄
          • 3. 트랜잭션이 커밋시
      • 문제상황으로 돌아오면, 우리의 생각으로는 deleteALl을 모두 한뒤 DB가 다 비어진뒤 insert를 함으로 unique Error가 발생하면 안된다.라고 생각햇다. 
      • 하지만, 
        • delete All 명령을 통해 delete 쿼리들이 SQL저장소에 적재가 되엇는데 flush 가 안된 상태에서 saveAll 명령이 들어가면서 saveAll에는 id가 없기 때문에 DB에 먼저 insert가 먼저 실행되고, UNIQUE Error가 뜹니다. 
      • 해결방안에 대해 알아보겠다.
        • 1. flush 직접 호출 : 이 방안은 테스트나 특정상황아니면 잘 사용하지 않는다.
        • public List<Crew> update(List<Crew> crews){
          	crewRepository.deleteAll();
              crewRepository.flush();
              return crewRepository.saveAll(crews);
          }
        • 2. JPQL 쿼리 사용 : JPQL 쿼리를 실행하면 처음에 쓰기 지연 저장소에 들어갓던 것이 더 flush 된 후에 saveAll이 실행된다.
        • public interface CrewRepository extends JpaRepository<Crew, Long>{
          	@Modifying
              @Query("delete from Crew")
              void deleteA();
          }
          
          public List<Crew> update(List<Crew> crews){
          	crewRepository.deleteA();
              return crewRepository.saveAll(crews);
          }
        • 3. 트랜잭션 커밋 : 트랜잭션을 2개로 나누어서 deleteAll을 한뒤 commit한뒤 다른 트랜잭션을 실행하는 방법이다. 
        • public List<Crew> update(List<Crew> crews){
          	EntityManager entityManager = entityManagerFactory.createEntityManager();
              enmtityManager.getTransaction().begin();
              crewRepository.deleteAll();
              enmtityManager.getTransaction().commit();
              
              enmtityManager.getTransaction().begin();
          	List<Crew> response = crewRepository.saveAll(crews);
              entityManager.getTransaction().commit();
              
              return response;
          }

문제상황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(){
	//given
    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));
    
    
    //when
    crewRepository.increaseAllAge();
    System.out.println("count = " + count);
    
    //then
    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(){
        	//given
            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));
            
            
            //when
            crewRepository.increaseAllAge();
            entityManager.flush();
            entityManager.clear();
            
            //then
            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(){
	//given
    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();
    
    
    //when
    List<Crew> crews = crewRepository.findAll();
    Collections.sort(crews);
    
    //then
    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);
}

+ Recent posts