일대다 단방향 적용기

Chocomilk
Dong-gle
Published in
20 min readSep 6, 2023

1. Trouble Shooting

JPA를 활용해 애플리케이션을 개발하던 중, “일대다 관계가 필요한 시점”에서 엔티티간의 참조 관계를 일대다 단방향으로 설정해야 하는지, 양방향으로 설정해야 하는지 의문이 들었습니다.

2. 양방향 매핑시 주의사항

김영한님 강의를 들어보면 일단 다대일로 엔티티간의 참조 관계를 모두 설정하고, One쪽에서 Many쪽으로 객체 참조가 있으면 좋다고 생각이 들 때에 양방향 매핑을 하면 된다는 것이 뇌리에 박혀있었습니다. 하지만 양방향 매핑을 하면 신경써야할 부분이 꽤 많다고 생각됩니다. 아래에는 양방향 매핑의 단점을 적어봤습니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board { // 게시글은
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToMany(mappedBy = "board")
private List<Tag> tags = new ArrayList<>();
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tag { // 여러 태그를 가질 수 있다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
private Board board;
}

2.1. 양방향 의존성

하지만 양방향 매핑의 단점은, 두 엔티티가 서로를 의존하고 있다는 것입니다. ‘의존한다’라는 말은 ‘한 쪽의 변경 사항이 다른 한 쪽에 영향을 준다’ 라고 해석될 수 있습니다. 그렇다면 양방향 의존성은 ‘한 쪽의 변경 사항이, 두 쪽 모두에게 영향을 준다’라고 해석될 수 있겠네요. 따라서 최대한 객체지향적인 관점에서 봤을 때 이를 지양해야 할 것으로 여겨지곤 합니다

2.2. 연관관계 편의 메서드

두 엔티티가 양방향으로 의존하고 있다면, 하나의 변경 사항이 두 엔티티에 모두 적용되어야만 데이터의 정합성이 맞춰질 것입니다. JPA를 사용하는 애플리케이션에서는 보통 이런 상황에서 두 엔티티에 변경 사항을 원자적으로 처리하기 위해서 연관관계 편의 메서드를 사용합니다. (’연관관계 편의 메서드’란 그저 원자적으로 변경값을 적용한다는 것을 의미하는 명칭이기에, 이외에도 다양하게 불릴 수 있을 것 같습니다)

문제는 개발자가 잊지 않고 이 연관관계 편의 메서드를 작성해주어야 한다는 데에 있습니다. 물론 실수를 안하면 되지! 라고 생각하면 되지만 양방향 의존관계를 가짐으로써, 개발자가 신경써주어야 할 포인트가 한 가지 늘어난다는 것은 단점이 될 수 있다고 생각합니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tag {
...

public setBoard(Board board) {
board.add(this); // board의 tag 정보도 변경
this.board = board; // tag의 board 정보도 변경
}
}

2.3. 연관관계의 주인이 아닌 엔티티에 변경

보통은 FK를 가지고 있는 Many쪽을 연관관계의 주인으로 두고, One쪽은 mappedBy 설정을 통해 read-only로 관리하곤 합니다. 이 때문에 엔티티의 값을 변경하기 위해서는 Many쪽에 있는 값을 수정해야만 합니다. One쪽에서는 아무리 값을 변경하더라도 read-only이기 때문에 JPA가 변경 감지를 하지 않고, 따라서 UPDATE 쿼리가 날라가지 않는 문제가 있습니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
...

@OneToMany(mappedBy = "board")
private List<Tag> tags = new ArrayList<>();

public setTag(Tag tag) { // 연관관계의 주인이 아니기 때문에, 변경감지 적용 안됨
tags.add(tag);
}
}

2.4. 무한 참조 가능성

양방향 의존관계를 가지고 있기에 무한 참조를 조심해야 합니다. 예를 들어, A와 B 엔티티가 서로 의존성을 가지고 있다고 가정할 때에, A와 B에서 모든 필드를 toString()에 명시한다면, 이 메서드가 호출될 때에 무한 참조가 발생할 것입니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
...

@Override
public String toString() {
return "Board{" +
"id=" + id +
", tags=" + tags + // 문제!
'}';
}
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tag {
...

@Override
public String toString() {
return "Tag{" +
"id=" + id +
", name='" + name + '\'' +
", board=" + board + // 문제!
'}';
}
}

3. 일대다 단방향

위에서는 양방향 매핑을 했을 때의 주의사항을 살펴봤습니다. 위와 같이 많은 주의사항이 있더라도, 강의에서는 양방향 매핑을 하는 것이 일대다 단방향을 사용하는 것보다 낫다고 설명합니다. 그렇다면 일대다 단방향은 어떤 문제가 있을까요? 간단하게 나열해보자면 다음과 같습니다.

  1. 데이터베이스에서는 Many쪽에 외래키가 존재할 수 밖에 없다.이런 상황에서 One쪽에서 연관관계의 주인이 된다면, Team에서 members 컬럼에 대한 수정이 Member 테이블에 영향을 미치게 된다.
    Team 객체를 수정했는데, 외래키 때문에 Member 테이블에 영향을 미치는 것이 헷갈린다.
  2. 객체와 테이블의 차이 때문에, 반대편 테이블의 외래키를 관리해야 하는 특이한 구조 발생하는 것이다.
    → 연관관계 관리를 위해 추가로 UPDATE SQL 실행해야 한다.
  3. @JoinColumn을 꼭 사용해야 한다. 그렇지 않으면 조인 테이블 방식을 사용한다.
    → 중간에 매핑 테이블을 하나 추가하는 것이다.

그렇다면 이제 위와 같은 단점을 단계적으로 개선해보겠습니다.

3.1. @JoinColumn 명시하지 않음

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team { // 팀에는
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String teamName;

@OneToMany // 일대다 단방향
private List<Member> members = new ArrayList<>();

public Team(String teamName, List<Member> members) {
this.teamName = teamName;
this.members = members;
}
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member { // 여러 멤버가 소속되어 있다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String memberName;

public Member(String memberName) {
this.memberName = memberName;
}
}

위처럼 일대다 단방향인데 @JoinColumn을 명시하지 않을 경우, Hibernate는 기본적으로 조인 테이블 방식을 사용합니다. TeamMember의 관계를 나타내는 데에 있어, 한 쪽에 외래키를 두는 것이 아니라 중간의 독립적인 테이블을 두어 어떤 Team의 id가 어떤 Member의 id와 매핑되는지 나타내는 것입니다.

아래에서는 테스트 케이스에 대해 어떤 쿼리가 나가는지 확인해보겠습니다.

@Rollback(false) // @SpringBootTest + @Transactional 환경에서 롤백되지 않도록 설정
@Test
void update() {
Member memberA = memberRepository.save(new Member("memberA"));
Member memberB = memberRepository.save(new Member("memberB"));
Team team = new Team("team");
team.add(memberA);
team.add(memberB);

teamRepository.save(team);
}
--- 1. memberA 저장
insert
into
member
(member_name,id)
values
('memberA',default)
--- 2. memberB 저장
insert
into
member
(member_name,id)
values
('memberB',default)
--- 3. team 저장
insert
into
team
(team_name,id)
values
('team',default)
--- 4. 조인 테이블에 관계를 저장
insert
into
team_members
(team_id,members_id)
values
(1,1)
--- 5. 조인 테이블에 관계를 저장
insert
into
team_members
(team_id,members_id)
values
(1,2)

이렇게 두 엔티티 간의 관계를 나타내기 위해 조인 테이블을 사용한다면, 조인 테이블의 값을 저장하는 데에 있어 저장하려는 Many 엔티티의 갯수만큼 추가 쿼리가 나가게 됩니다. 여기에서는 Many인 Member를 두 개 저장했기 때문에 추가적으로 두 개의 쿼리가 나갔지만, 만약 100개의 Member를 저장한다면 100개의 쿼리가 추가적으로 나갈 것입니다.

3.2. @JoinColumn 명시

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
...

@OneToMany
@JoinColumn(name = "team_id")
private List<Member> members = new ArrayList<>();
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
...
}

@JoinColumnname 속성에 값을 명시함으로써, Hibernate가 조인 테이블 방식이 아닌 Team 쪽에 외래키를 두어 테이블 간의 관계를 나타내도록 할 수 있습니다. 따라서 추가적으로 테이블이 생성되지 않을 것입니다. 그렇다면 아래의 테스트 코드에 대한 쿼리가 어떻게 나가는지 확인해보겠습니다.

@Rollback(false)
@Test
void update() {
Member memberA = memberRepository.save(new Member("memberA"));
Member memberB = memberRepository.save(new Member("memberB"));
Team team = new Team("team");
team.add(memberA);
team.add(memberB);

teamRepository.save(team);
}
--- 1. memberA 저장
insert
into
member
(member_name,id) // team_id는 저장하지 않음
values
('memberA',default)
--- 2. memberB 저장
insert
into
member
(member_name,id) // team_id는 저장하지 않음
values
('memberB',default)
--- 3. team 저장
insert
into
team
(team_name,id)
values
('team',default)
--- 4. member의 외래키 수정
update
member
set
team=1
where
id=1
--- 5. member의 외래키 수정
update
member
set
team=1
where
id=2

@JoinColumn의 속성에 name 값을 명시해줌으로써, 이제 조인 테이블 방식이 아닌 Many쪽에 외래키를 명시하는 쪽으로 전략이 바뀌었습니다. 따라서 이전보다 테이블을 추가로 생성하지 않는다는 데에서 이점이 있습니다.

하지만 위에서 볼 수 있듯이, 처음에 Member를 저장하는 데에 있어 Team에 관한 외래키를 지정해주어야 하는 시점에는 정작 Team이 영속화 되어있지 않았다는 문제가 있습니다. 즉, Member를 저장할 때에 Team의 PK값을 알지 못하는 문제가 있다는 것입니다. 따라서 Hibernate에서는 Member의 외래키인 team_id를 저장하지 않고, Team이 저장된 이후에서야 다시 Member의 외래키값을 UPDATE하도록 합니다. 이 과정에서 두 번의 UPDATE 쿼리가 나가게되므로, 쿼리 수만본다면 @JoinColumn을 사용하지 않을 때와 똑같다는 것을 확인할 수 있습니다.

3.3. updatable = false 설정

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
...

@OneToMany
@JoinColumn(name = "team_id", updatable = false)
private List<Member> members = new ArrayList<>();
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
...
}

UPDATE 쿼리가 추가로 발생하는 것이 문제라면, UPDATE를 할 수 없도록 updatable = false로 설정하면 Hibernate가 추가 쿼리를 날리지 않을 것 같아 실험해봤습니다.

@Rollback(false)
@Test
void update() {
Member memberA = memberRepository.save(new Member("memberA"));
Member memberB = memberRepository.save(new Member("memberB"));
Team team = new Team("team");
team.add(memberA);
team.add(memberB);

teamRepository.save(team);
}
--- 1. memberA 저장
insert
into
member
(member_name,id) // team_id는 저장하지 않음
values
('memberA',default)
--- 2. memberB 저장
insert
into
member
(member_name,id) // team_id는 저장하지 않음
values
('memberB',default)
--- 3. team 저장
insert
into
team
(team_name,id)
values
('team',default)

이전과 비교해 쿼리가 두 번 줄었고, UPDATE 쿼리가 나가지 않았습니다. 하지만 뭔가 이상한게 있습니다. member를 INSERT할 때에 team_id는 저장하지 않았는데, 괜찮은 것일까요? 데이터베이스를 확인해보겠습니다.

h2 콘솔에서 확인해보면, team_idnull로 되어있는 것을 확인할 수 있습니다. 이 방법은 사용하면 안될 것 같습니다.

3.4. updatable = false, nullable = false 설정

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
...

@OneToMany
@JoinColumn(name = "team_id", updatable = false, nullable = false)
private List<Member> members = new ArrayList<>();
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
...
}

그렇다면 nullable = false로 설정을 해주면, Hibernate가 초기 외래키를 null로 설정하지 않고 무언가 다른 정책을 적용하지 않을까요? 확인해보겠습니다.

@Rollback(false)
@Test
void update() {
Member memberA = memberRepository.save(new Member("memberA"));
Member memberB = memberRepository.save(new Member("memberB"));
Team team = new Team("team");
team.add(memberA);
team.add(memberB);

teamRepository.save(team);
}

네.. 이 방법도 사용할 수 없습니다. 컬럼에 대한 제약 조건으로 null값을 허용하지 않았는데, Member를 영속화하는 시점에는 Team의 PK를 알 수 없어서 null값이 들어갈 수 밖에 없고, 이때 DataIntegrityViolationException이 발생할 수 밖에 없습니다.

3.5. @OneToMany(cascade = CascadeType.PERSIST) 설정

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
...

@OneToMany(cascade = CascadeType.PERSIST)
@JoinColumn(name = "team_id", updatable = false, nullable = false)
private List<Member> members = new ArrayList<>();
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
...
}

그렇다면 영속성 전이 속성인 cascade를 사용하면 어떨까요? 영속성 전이는 하나의 엔티티를 영속화할 때에 다른 엔티티도 영속화하고 싶을 때 사용하곤 합니다. 현재 상황에서는 Team을 영속화할 때에 Member 또한 자동으로 영속화한다고 볼 수 있겠네요. 코드로 확인해보겠습니다.

@Rollback(false)
@Test
void save() {
Member memberA = new Member("memberA");
Member memberB = new Member("memberB");
Team team = new Team("teamA");
team.add(memberA);
team.add(memberB);

teamRepository.save(team);
}
--- 1.
insert
into
team
(team_name,id)
values
('teamA',default)
--- 2.
insert
into
member
(team_id,member_name,id)
values
(1,'memberA',default)
--- 3.
insert
into
member
(team_id,member_name,id)
values
(1,'memberB',default)

쿼리가 세 개만 나가는 것을 확인할 수 있습니다. 눈여겨볼 점은 team에 대한 INSERT 쿼리가 먼저 나가고, 이후에 memberAmemberB에 대한 INSERT 쿼리가 나갑니다. team은 이미 영속화되었기 때문에 PK값을 알 수 있고, 이에 따라 member를 저장할 때에는 이 값을 외래키로 같이 저장하는 것을 확인할 수 있습니다.

4. 결론

이제까지 @OneToMany 단방향에서 불필요한 UPDATE 쿼리가 나가는 것을 단계적으로 개선해봤습니다. 결과적으로는 영속성 전이와 updatable, nullable 설정을 통해 불필요한 UPDATE쿼리가 발생하지 않도록 하였습니다. 또한 Many를 One쪽에서 cacade 속성을 통해 관리하기 때문에, 값을 삽입할 때에도 여기서 관리하면 되고 추가로 orphanRemoval = true 속성을 통해 값을 손쉽게 삭제할 수도 있을 것입니다.

이제 남은 문제는 그저 객체와 테이블의 차이 때문에, 반대편 테이블의 외래키를 객체 세상에서 관리해야 하는 특이한 구조가 발생한다는 것인데.. 이 문제와 초반에 설명했던 양방향 매핑의 문제점을 비교해보면 저는 조금 헷갈리더라도 일대다 단방향을 선택할 것 같습니다.

생각보다 One쪽에서 Many쪽으로 객체 참조를 가지고 있으면 편한 상황들이 많았습니다. 동글 프로젝트를 진행할 때에도 하나의 Writing과 여러 개의 Block이 연관관계를 맺고 있는데, 사실상 Block이 어떤 Writing인지 필요한 경우는 없었고, Writing에 어떤 Block들이 있는지 필요한 경우가 많았습니다. 또한 Content가 가지고 있는 여러 개의 Style들을 참조할 때에도 One에서 Many쪽으로의 참조가 필요했습니다.

정리하면서보니 일대다 단방향이 우리에게 주는 장점은 극명했습니다. 양방향에서 신경써야할 포인트를 모두 없앨 수 있었고 객체 세상에서 One쪽만 객체를 가져오고 Many는 바로 객체 참조로 가져올 수 있는 편의성을 가져올 수 있었습니다. (필요하다면 페치조인을 사용하긴 해야 합니다)

결론적으로, 다대일 단방향이 걸려있는데 매우 코드가 복잡하다면 일대다 단방향을 추가해 양방향으로 사용하면 될 것 같습니다. 하지만 그게 아니라 개발 초기 단계이고, 일대다 단방향이 필요한데 그 반대는 아니라면 애초에 일대다 단방향 관계로 시작하는 것도 좋을 것 같습니다.

이제까지는 일대다 단방향 자체에 회의감이 있었는데, 위에서 설명한 방법을 사용한다면 불필요하게 많이 발생하는 UPDATE 쿼리를 잡을 수 있기에 사용하지 않을 이유가 없다는 생각이 들었습니다. 감사합니다 :)

이 글은 크루 져니 블로그 글을 보고, 우리의 서비스에 적용할 수 있도록 학습하고 팀원들에게 관련 기술을 공유하고 싶어 작성하게 되었습니다. 감사합니다 져니!!

--

--