본문 바로가기

Spring

JPA OneToMany 단방향 맵핑의 단점 이해하기 삽질기

JPA OneToMany 단방향 맵핑의 단점 이해하기 삽질기

JPA의 연관관계 설정에는 크게 4가지가 있습니다.

  • OneToOne

  • OneToMany

  • ManyToOne

  • ManyToMany

프로젝트를 진행하던 중 OneToMany에 관해 고민하게 되는 일이 있었고, 여러가지 글을 참고하기도 했습니다. 그 과정에서 겪었던 시행착오를 공유해볼까합니다.

OneToMany 단방향이 안 좋다고요?

구글에 OneToMany 단점을 검색만 해봐도 많은 자료들이 나옵니다.

OneToMany 단점 검색 결과

정리하자면 다음과 같습니다.

  • 엔티티가 관리하는 외래 키가 다른 테이블에 있음 => 작업한 Entity가 아닌 다른 Entity에서 쿼리문이 나가는 경우가 있어 헷갈림

  • 불필요한 쿼리문이 발생(update 등)

  • (join table 문제도 있지만 이 글에서는 다루지 않겠습니다.)

이것뿐아니라 다른 글들을 보면 다른 기타 의견에 대해서도 많습니다.

이번 포스팅에서는 불필요한 쿼리문 발생에 대해서 심도있게 확인해보려고 합니다.

진짜 그런가? 얼마나 안좋은데? update쿼리문 추가 발생?🤔

객체 저장시 update쿼리문 추가발생

먼저 Member와 Team간에 OneToMany관계를 설정해보겠습니다. 이때 @JoinColumn을 사용하여 JoinTable문제는 스킵합니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @Column(name = "team_id")
    private Long teamId;

    @Builder
    public Member(String name) {
        this.name = name;
    }
}
@Getter
@NoArgsConstructor
@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @OneToMany
    @JoinColumn(name = "team_id")
    private List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }

    public void addMember(Member member) {
        this.members.add(member);
    }
}

보시다시피 Team:Memeber의 관계는 One:Many의 관계입니다.

지금의 상태에서 하나의 팀에 두개의 멤버를 저장해보겠습니다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class MemberRepositorySupportTest {
    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private TeamRepository teamRepository;

    @Autowired
    private EntityManager entityManager;

    @Test
    @Transactional
    public void oneToManyTest() {
        Member member1 = Member.builder()
                .name("member1")
                .build();
        Member member2 = Member.builder()
                .name("member2")
                .build();
        memberRepository.save(member1);
        memberRepository.save(member2);

        Team team = new Team("team");
        team.addMember(member1);
        team.addMember(member2);
        teamRepository.save(team);

	//flush를 해주어야 영속성 컨텍스트에 변경된 사항들이 db에 적용이 됩니다.
        entityManager.flush();
    }
}

 

member에 팀을 셋팅해주는 2번의 update 쿼리가 추가로 발생하네요.
만약 ManyToOne의 관계였다면! 그래서 애초에 member객체가 team을 가지고 있는 구조였다면! 발생하지 않았을 일입니다.

 

객체 삭제시 update쿼리문 추가발생

이번에는 삭제할때 update쿼리문이 추가로 발생하다는데 한번 확인해보도록 하죠

위의 테스트 코드는 setUp으로 두고 member2를 삭제해보도록 하겠습니다.

@Test
@Transactional
public void oneToManyTest() {
	System.out.println("테스트 시작");

	memberRepository.deleteById(1L);
	entityManager.flush();
}

하지만 왠걸? update쿼리문이 나가지 않습니다.

생각해보면 당연한 결과입니다.  우리는 member만 삭제했으니 말이죠.

제대로된 작업의 형태는 Team에 있는 List<member>에서 삭제할 member를 remove도 해주고,repository로 member를 삭제해야합니다.

@Test
@Transactional
public void oneToManyTest() {
	System.out.println("테스트 시작");
	Team saved = teamRepository.findById(1L).get();
	List<Member> members = saved.getMembers();
	members.removeIf(member -> member.getId().equals(1L));
	memberRepository.deleteById(1L);
	entityManager.flush();
    
    //member가 삭제되었는지
    memberRepository.findById(1L).ifPresent(member -> {
            throw new IllegalArgumentException("삭제되지 않았습니다");
        });	
    //team에서 member가 삭제되었는지
    assertThat(findTeam.getMembers()).hasSize(1);

}

위와 같이 할 경우 테스트를 통과하면서 다음과 같이 쿼리문이 날라갑니다.

update쿼리문이 날라가긴했네요. 어떻게 보면 당연한 결과라고 생각이 들 수 있습니다.

members에서 member를 제거 했으니 해당 member에 team_id를 null로 만드는 update쿼리가 날라가는게 당연하고, memberRepository로 member를 제거했으니 delete 쿼리가 날라가는게 당연하죠.

하지만 OneToMany 단방향이 아닌 양방향이라면 어떻게 될까요?

OneToMany 단방향을 양방향으로!

Member와 Team을 다음과 같이 앙뱡향 맵핑으로 수정하였습니다.

@Getter
@NoArgsConstructor
@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }

    public void addMember(Member member) {
        this.members.add(member);
    }
}
@Getter
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    @Builder
    public Member(String name) {
        this.name = name;
    }
}

그리고 바로 위의 테스트를 다시 돌려보겠습니다.

당연히 있어야 할 update 쿼리가 사라졌습니다.

 

이 상태에서 코드를 조금 수정해보도록하겠습니다.

Team의 @OneToMany에 orphanRemoval과 cascade설정을 다음과 같이 해줍니다.

public class Team {
...
    @OneToMany(mappedBy = "team", orphanRemoval = true, cascade = CascadeType.ALL)
    private List<Member> members = new ArrayList<>();

...
}

그러면 테스트코드를 아래와 같이 수정할 수 있습니다.

    @Test
    @Transactional
    public void oneToManyTest() {
        System.out.println("테스트 시작");
        Team findTeam = teamRepository.findById(1L).get();
        List<Member> members = findTeam.getMembers();
        members.removeIf(member -> member.getId().equals(1L));
//        memberRepository.deleteById(1L);
        entityManager.flush();

        memberRepository.findById(1L).ifPresent(member -> {
            throw new IllegalArgumentException("삭제되지 않았습니다");
        });
        assertThat(findTeam.getMembers()).hasSize(1);
    }
}

members에서 member를 삭제해주기만해도 memberRepository와 연동되어 자동으로 member를 삭제해줍니다.

 

마치며..

다른 블로그들을 보면서 사실 가장 많이 의아했던 부분이 members 리스트에서 member를 삭제하는데 왜 update쿼리가 날라갈까 하는 의문이었는데 하나하나 단계를 거치면서 직접 해보니 이해가 한결 쉬웠던 것 같습니다.

한편으로는 아직은 JPA에 대해 많이 이해가 부족하다는 것을 깨닫는 시간이기도 했구요.

혹시 저와 비슷한 상황을 겪으신 분이 계신다면 도움이 되셨으면 좋겠습니다.

 

 

반응형