JAVA/JPA 학습기록

[JPA] 양방향 연관관계에서 JPA 내부 작동 원리_영속성 컨텍스트의 이해

kth990303 2022. 6. 23. 00:37
반응형

JPA 작동 원리를 이해하지 못해 영속성 컨텍스트의 흐름을 알지 못할 경우 발생할 수 있는 문제이다. 블로그에 따로 포스팅이 돼있지 않아서 JPA 스터디를 하는 겸 기록해보려고 한다.

 

들어가기 전에

  • 스프링 환경에서 JPA ORM을 이용하고 있다.
  • Member : Team이 N:1 양방향 연관관계를 가지고 있다.
  • 'wooteco' 팀에 'kth990303'을 넣으려고 하는 예제를 이용할 것이다.

결론부터 말하자면?

  • JPA의 내부 작동 흐름에 대해 이해하는 것이 정말 중요하다. (영속성 컨텍스트의 흐름에 대한 이해가 부족하면 문제 발생 확률이 높아지고, 문제 원인도 못찾을 수 있다.)
  • 양방향 연관관계에서는 객체의 값을 INSERT할 때, 안전하게 두 객체 모두에게 반영해주자.
    • 두 객체의 메서드를 사용하거나,
    • 연관관계 편의 메서드를 사용하자. 이 때에는, 두 객체에서 모두 삽입하지 않도록 주의하자. 두 번 INSERT될 수 있다.

Main 예제

아래 예제에서 1번과 2번에서 어떠한 결과를 출력할지 예측해보자.

// team insert
Team team = new Team("wooteco");
em.persist(team);

// member 생성자로 team 정보 입력
Member member = new Member("kth990303", team, RoleType.ADMIN);
em.persist(member);

System.out.println("=================================");
List<Member> findMembers = team.getMembers();
for (Member findMember : findMembers) {
    System.out.println(findMember.getName());	// 1
}
System.out.println("=================================");

Team findTeam = em.find(Team.class, team.getId());
List<Member> teamMembers = findTeam.getMembers();
teamMembers.get(0).setName("kth990202");

System.out.println(member.getName());	// 2
transaction.commit();

참고로 Member의 생성자에서는 아래처럼 초기화 작업밖에 해주지 않는다.

 

Member Constructor

public Member(String name, Team team, RoleType roleType) {
    this.name = name;
    this.team = team;
    this.roleType = roleType;
}

어떤 일이 일어날까?

1번에서 설마 "kth990303"이 바로 일어날 것이라 생각했다면 출제자(?)의 페이크에 낚인 것이다. 잘 보면 1번에서는 EntityManager에서 find를 하지도 않은, 맨 처음 team insert할 때의 team 인스턴스에서 getMembers()를 하였다.

따라서 1번의 정답은 '아무것도 출력되지 않는다'이다.

 

2번에서는 em.find를 해 주었으니 kth990303을 이름으로 가진 객체를 찾아내어 이름을 성공적으로 변경하여 "kth990202"가 출력될 것처럼 보이지 않는가? 만약 그렇게 생각했다면 JPA의 흐름을 놓친 것이다.

 

아래의 행위를 해주지 않는다면 db에 쿼리가 날라가지 않아 db에 반영이 바로 되지 않음을 꼭 기억하자.

  • entitymanager에서 flush를 해준다.
  • transaction commit을 해준다.
  • JPQL을 사용한다.

 

2번 위에서 별도로 flush를 해주지도, JPQL을 사용하지도 않았다. 현재 코드에선 하나의 트랜잭션 코드로 입력했으므로 commit이 이루어지지도 않았다. 따라서 db에는 값이 INSERT되지 않았으며, 영속성 컨텍스트의 1차캐시(first level cache)의 값을 반환한다. 즉, 1번에서 와 마찬가지로 맨 처음 team insert할 때의 team 인스턴스를 가져온 것이다! 따라서 2번의 결과는 IndexOutOfBoundsException 발생이다.


JPA 흐름을 어느정도 이해하는 데에 도움이 됐는가?

이제 앞의 예제보다 어려운 경우도 살펴보자.

Main 예제

// Team INSERT
Team team = new Team("wooteco");
em.persist(team);

// Member INSERT
Member member = new Member("kth990303", team, RoleType.ADMIN);
em.persist(member);

// flush 작업 -> db에 INSERT
em.flush();
em.clear();

// kth990303을 kth990202로 변경
member.setName("kth990202");
Member findMember = em.find(Member.class, member.getId());

System.out.println(findMember.getName());	// Result
transaction.commit();

Result는 어떻게 출력됐을까? 플러시까지 제대로 해줬으니 db에 값은 들어갔을테고, JPA 에서는 setName의 결과로 엔티티에 반영될테니 이번에야말로 "kth990202"가 잘 들어갔을 것 같은가? 좋은 답안이 될 뻔했다. 정답은 "kth990303"이 출력된다.

db에서도 kth990303의 값이 들어가 있다.

flush를 해주어 Team 과 Member를 db에 INSERT해준 것까진 이해가 될 것이다. 그러나 문제는 em.clear()에서 발생한다. flush를 한다고 해서 영속성 컨텍스트가 비워지지는 않지만, clear를 해주게 되면 영속성 컨텍스트가 비워지게 된다. 그렇기 때문에 member.setName()을 해봤자 JPA 입장에서는 변경이 일어난건지 알 턱이 없다. 당연히 INSERT를 해준 것도 아니기 때문에 1차 캐시에는 member가 존재하지 않는다. 따라서 em.find를 할 때, 1차 캐시에 값이 존재하지 않아 db에서 값을 찾게 되며, Result로 "kth990303"이 나오게 된다.

 

실제로 em.clear()를 해주지 않으면 Result 결과는 우리가 예측한대로 영속성 컨텍스트의 1차 캐시 스냅샷 덕분에 "kth990202"가 올바르게 나오게 된다.


두 객체 모두에게 변경을 반영해주자

위와 같이 중간중간에 flush를 별도로 해주지 않으면 하나의 객체에만 값이 들어가버리는 경우가 존재하게 된다. 아무래도 연관관계 주인인 Member를 생성하기 전에 Team을 생성해주어야 하므로, Team에 해당 Member의 정보가 없는 경우가 대부분일 것이다.

 

따라서 아래와 같이 작업해주어 두 객체 모두에게 변경이 반영되게 해주자. 둘 중 편한 방법을 이용하면 된다.

 

1) Member 생성자에 연관 관계 편의 기능 추가하기

public Member(String name, Team team, RoleType roleType) {
    this.name = name;
    this.team = team;
    this.roleType = roleType;
    team.addMember(this);	// 연관관계 편의 메서드
}

 

2) 두 객체에게 모두 변경을 반영해주기

Member member = new Member("kth990303", team, RoleType.ADMIN);
team.addMember(member);
em.persist(member);

 

주의할 점은, 양 객체 모두에게 편의 기능을 작성할 경우 중복되게 값이 들어갈 수 있다. 따라서 연관관계 편의 기능(또는 메서드)을 작성했다면 하나의 객체에만 넣어주자.


트랜잭션 단위의 중요성

아까 문제가 됐던 코드를 다시 살펴보자.

// team insert
Team team = new Team("wooteco");
em.persist(team);

// member 생성자로 team 정보 입력
Member member = new Member("kth990303", team, RoleType.ADMIN);
em.persist(member);

System.out.println("=================================");
List<Member> findMembers = team.getMembers();
for (Member findMember : findMembers) {
    System.out.println(findMember.getName());	// 1
}
System.out.println("=================================");

Team findTeam = em.find(Team.class, team.getId());
List<Member> teamMembers = findTeam.getMembers();
teamMembers.get(0).setName("kth990202");

System.out.println(member.getName());	// 2
transaction.commit();

1번이야 단순 페이크니까 그렇다 치자.

2번의 경우는 한 쪽 객체(Member)에만 변경을 반영했다는 문제점도 있고, flush를 해주지 않았다는 문제점도 존재한다. 이는 트랜잭션 단위를 지켰다면 충분히 예방할 수 있는 버그였다.

 

트랜잭션은 db를 다루는 작업의 단위인데, 위 코드는 create 책임과 함께 update의 책임까지 가지고 있다. 트랜잭션 단위를 Create와 Update로 분리했다면 위 문제는 충분히 방지할 수 있었을 것이다. 문제가 발생한다 하더라도, 어디에서 문제가 발생했는지 상대적으로 쉽게 알아챌 수 있을 것이다. 테스트 코드를 작성했다면 더더욱.

 

따라서 개발할 때는 책임과 관심사를 최대한 분리하여 객체지향적으로 짜고, 테스트 코드도 잘 작성해주자.


그 전에는 JPA 작동원리를 이 정도로 이해하진 않았는데, 역시나 여러 번 반복해서 공부하는 것이 중요한 듯하다.

또, 지난번에는 트랜잭션에 대한 이해가 부족했었지만 이번에는 트랜잭션의 단위 관점으로도 접근해볼 수 있어서 더 많이 공부가 된 느낌이었다.  

반응형