Member 엔티티와 GroupTab 엔티티가 있습니다. 멤버와 모임으로 칭하겠습니다.
(GroupTable인데 group이 예약어라서 table을 줄여서 엔티티를 생성하였습니다.)
1. 글 작성 이유
@ManyToMany의 문제점과 이 관계를 일대다 다대일 관계로 풀며 중간 테이블을 추가하는 과정과
그리고 멤버가 모임을 만들면 모임안에는 만든 멤버가 바로 들어가는 로직을 구현하면서 만난 에러를 해결 하게 되어 글을 쓰게 되었습니다.
멤버와 모임은 OneToMany, ManyToOne 관계입니다.
하나의 멤버는 여러 그룹을 생성할 수 있습니다.
그러면 당연히 연관관계의 주인은 FK를 가지고 있는 그룹이 됩니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GroupTab extends AuditingFields {
@ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "member_id")
private Member member;
}
이런 식으로 연관관계가 잡힙니다. ToOne 관계는 fetch 전략이 즉시 로딩이므로 LAZY 로딩으로 설정해줍니다.
여기서 모임이 있으면 여러 명의 멤버를 받을 수 있어야 합니다.
이것을 @ManyToMany로 연관관계를 잡으면 다음과 같이 됩니다.
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "MemberInGroupTab",
joinColumns = {@JoinColumn(name = "member_id")},
inverseJoinColumns = {@JoinColumn(name = "group_id")})
@ToString.Exclude
private Set<Member> members = new HashSet<>();
MemberInGroupTab이 테이블 이름이 되고 member_id와 group_id를 fk로 받아와 테이블이 생성됩니다.
이렇게 @ManyToMany로 관계를 잡아 버리면 다음과 같은 문제점이 생깁니다.
- 간 테이블을 생성해주긴 하지만 묵시적으로 생성해주기 때문에 자기도 모르는 복잡한 조인의 쿼리가 발생하는 경우가 생길 수 있다.
- 우리는 중간 테이블에 두 테이블의 기본키를 기본키이자 외래 키로 들고 와서 추가로 필요한 칼럼이 존재할 확률이 크지만, 중간 테이블에 필요한 추가 칼럼을 사용할 수 없다. (두 테이블에 추가된 칼럼에 대해 매핑이 되지 않기 때문이다.)
우리가 모임에 가입했는데 언제 가입했는지, 내 등급은 어떤지, 모임에 가입된 사람들의 이름은 무엇인지 알 수 있는 방법이 없습니다.
2. 그래서 어떻게 해야 하나요?
생각해보면 다대다 관계는 멤버 입장에서는 일대다, 모임 입장에서도 일대다 관계로 풀고 중간 테이블을 엔티티로 승격시켜 양쪽에 다대일로 풀면 되는 간단한(?) 문제입니다.
그래서 위에서 작성한 @ManyToMany 관계를 없애준 후 MemberInGroupTab이라는 엔티티를 생성해줍니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberInGroupTab extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "group_id")
private GroupTab groupTab;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
private Long grade;
private MemberInGroupTab(GroupTab groupTab, Member member, Long grade) {
this.groupTab = groupTab;
this.member = member;
this.grade = grade;
}
public static MemberInGroupTab of(GroupTab groupTab, Member member, Long grade) {
return new MemberInGroupTab(groupTab, member, grade);
}
public void addGroupTab(GroupTab groupTab) {
this.groupTab = groupTab;
}
public void updateGrade(Long grade) {
this.grade = grade;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if(!(o instanceof MemberInGroupTab)) return false;
MemberInGroupTab that = (MemberInGroupTab) o;
return getId() != null && getId().equals(that.getId());
}
@Override
public int hashCode() {
return Objects.hash(getId());
}
}
이렇게 풀면 끝납니다.. 근데 지금은 다 문제를 해결한 뒤에 쓰는 글이라 간단하지만 JPA에 대한 이해가 부족한 탓에 만난 에러를 지금부터 작성해보도록 하겠습니다.
위와 같이 MemberInGroupTab 에는 @JoinCoulmn으로 외래 키 이름을 명시해주었습니다.
김영한 님의 JPA 책을 보게 되면
@JoinColumn: 조인 칼럼은 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다. 회원과 팀 테이블은 TEAM_ID 외래 키로 연관관계를 맺으므로 이 값을 지정하면 된다. 이 어노테이션은 생략할 수 있다.
그래서 @JoinColumn어노테이션을 지우고 하이버네이트 ddl-auto: create로 다시 엔티티를 생성하니
alter table member_in_group_tab
drop
foreign key FKkceodpc7 sii2 kytdbs5 lko3 dw" via JDBC Statement
다음과 같은 에러가 발생하였습니다.
대충 foreign key 어쩌고저쩌고... 그래서 MemberInGroupTab 테이블을 확인해보니깐
하이버 네이트에서 자동으로 생성한 이름으로 fk를 잡고 있는 모습입니다. 원래는 모임 테이블에서 PK를 가져와서
@JoinColumn(name="group_id")를 통해 group_id라는 칼럼 명으로 fk를 가지고 있었으나
현재는 @JoinColumn 어노테이션을 지우게 되어서 group_id와 다르게 group_tab_group_id라는 fk가 생성되면서 생기는 에러였습니다.
Caused by: java.sql.SQLSyntaxErrorException: Can't DROP 'FKg3 jlisb85 dp2 lrscoka2 e3 exv'; check that column/key exists 그래서 콘솔에도 있는지 확인하라는 문구가 뜹니다. 이것은 그래서 이렇게 해결!
그래도 임의로 생성되는 칼럼 이름보단 명시해주는 것이 더 좋은 방법 같습니다.
연관관계 주인에게 값을 매핑하기
일반적인 모임이라면 내가 만들었으면 내가 모임장으로 모임에 가입되어있고 모임을 조회하게 되면 모임에 가입되어있는 사람들을 조회할 수 있어야 합니다.
그래서 모임 엔티티에서도 읽기 전용으로 연관관계를 맺어 주어야 합니다.
public class GroupTab extends AuditingFields {
@ToString.Exclude
@OrderBy("grade asc")
@OneToMany(mappedBy = "groupTab", cascade = CascadeType.ALL)
private final List<MemberInGroupTab> membersInGroupTab = new ArrayList<>();
}
그래서 모임 엔티티에서도 다음과 같이 연관관계를 맺어 줍니다.
@ToString. Exclude : 이렇게 양방향 연관관계를 맺어 줄 때는 toString에서 한쪽은 제외시켜주어야 합니다.
이유는 컬렉션에서 MemberInGroupTab을 toString 해서 갔는데 MemberInGroupTab에서는 groupTab을 toString 하고
이런 식으로 무한루프를 돌게 되어 서버가 뻗게 됩니다.
@OrderBy("grede desc") : 등급이 grade를 내림 차순으로 정렬합니다.
@OneToMany(mappedBy = "groupTab") : 이게 제일 중요합니다. 읽기 전용으로 설정한다는 뜻입니다.. 여기서 groupTab 은 MemberInGroupTab의 변수명입니다. '이 관계의 주인은 groupTab이다' 라는 뜻입니다.
만약에 여기서 mappedBy를 쓰지 않는다면 또다른 ManyToMany 관계가 맺어주면서 새로운 테이블이 생겨 GroupTabId와 MemberInGroupTabId를 fk로 사용하는 테이블이 생성됩니다.
이렇게 되면 모임을 조회했을때 모임에 가입되어있는 멤버들을 조회해올수 있게 됩니다.
이제 서비스 코드를 보겠습니다.
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class GroupTabService {
private final GroupTabRepository groupTabRepository;
private final MemberRepository memberRepository;
private final MemberInGroupTabRepository memberInGroupTabRepository;
public void saveGroupTab(GroupTabDto dto) {
Member member = memberRepository.getReferenceById(dto.getMemberDto().getMemberId());
GroupTab saveGroupTab = groupTabRepository.save(dto.toEntity(member));
MemberInGroupTabDto memberInGroupTabDto = MemberInGroupTabDto.of(saveGroupTab.getId(), dto.getMemberDto(), 0L);
memberInGroupTabRepository.save(memberInGroupTabDto.toEntity(saveGroupTab, member));
}
}
처음에는 이런식으로 작성하였습니다.
코드 흐름은 이렇습니다.
모임의 정보를 담은 객체 GroupTabDto에 인증된 사용자의 정보도 같이 있어서 dto를 받아와 save 해준 후 저장한 모임의 id와 멤버의 id를 MemberInGroupDto에 넘겨준 후 다시 MemberInGroupTabRepository 의 save해줍니다.
누가봐도 JPA스럽지 않고 객체지향적이지 않고..그냥 이상합니다..
MemberInGroupTabRepository 의존성을 추가하여 직접 테이블에 save해주는데 이렇게하면 JPA를 왜쓰냐 ORM을 왜쓰냐 하는 말 듣기 딱 좋을것같습니다.
그래서 다음과 코드를 수정하였습니다.
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class GroupTabService {
private final GroupTabRepository groupTabRepository;
private final MemberRepository memberRepository;
private static final long GROUP_MASTER = 0L;
public void saveGroupTab(GroupTabDto dto) {
Member member = memberRepository.getReferenceById(dto.getMemberDto().getMemberId());
GroupTab saveGroupTab = groupTabRepository.save(dto.toEntity(member));
MemberInGroupTabDto memberInGroupTabDto = MemberInGroupTabDto.of(saveGroupTab.getId(), dto.getMemberDto(), GROUP_MASTER);
saveGroupTab.addMemberInGroupTab(memberInGroupTabDto.toEntity(saveGroupTab, member));
}
}
코드를 보게 되면 MemberInGroupTabRepository 와의 의존성은 없애주고 GroupTab 객체인 saveGroupTab의
addMemberInGroupTab 이라는 메소드를 호출하고 있고 여기에 MemberInGroupTabDto 의 toEntity메소드를 호출해 넘겨주는 코드로 변경되었습니다.
public MemberInGroupTab toEntity(GroupTab groupTab, Member member) {
return MemberInGroupTab.of(
groupTab,
member,
grade
);
}
일단 toEntiy 메소드는 위와 같습니다. GroupTab과 Member를 받아 MemberInGroupTab 객체를 리턴해줍니다.
public class GroupTab extends AuditingFields {
@ToString.Exclude
@OrderBy("grade asc ")
@OneToMany(mappedBy = "groupTab", cascade = CascadeType.ALL)
private final List<MemberInGroupTab> membersInGroupTab = new ArrayList<>();
public void addMemberInGroupTab(MemberInGroupTab memberInGroupTab) {
memberInGroupTab.addGroupTab(this);
membersInGroupTab.add(memberInGroupTab);
}
}
public class MemberInGroupTab extends AuditingFields {
public void addGroupTab(GroupTab groupTab) {
this.groupTab = groupTab;
}
}
GroupTab 엔티티와 MemberInGroupTab 엔티티의 추가된 메소드들 입니다.
addMemberInGroypTab 메소드를 보면 파라미터로 받은 MemberInGroupTab을 addGroupTab메소드를 호출해 넣어준후
자기자신의 MemberInGroupTab 리스트에 add 해주고 있습니다.
이렇게 편의 메서드를 통하여 연관관계의 주인에 값을 매핑시켜 주면 인서트 쿼리가 두번나가는것을 알 수 있습니다.
3.정리
jpa를 공부하면서 블로그에 정리도 했었고 그냥 예제를 따라하다보면 꽃길만 걸으니깐 아~이렇게 되는거구나 하면서 했는데 직접 설계하면서 이걸 어떻게 풀지 하고 코드를 치니깐 확실히 이해가 되는것 같고 단방향매핑만 잘 해놓은 상태라면 양방향 매핑을 그냥 코드 몇줄만 넣으면 끝이 나는데 단방향 부터 잘 설계하고 봐야겠다고 생각했습니다.
reference
https://techjisu.tistory.com/28
https://techjisu.tistory.com/29
'JPA' 카테고리의 다른 글
JPA 에서 Where In 절 대신 array_contains를 사용하는 이유 (2) | 2023.10.17 |
---|---|
[JPA] 에러 TransientPropertyValueException: object references an unsaved transient instance (0) | 2022.08.11 |
find vs get (네이밍 컨벤션과 JPA에서의 내부 동작 차이) (0) | 2022.08.03 |
[JPA] 값 타입 (0) | 2022.06.19 |
[JPA] 프록시와 연관 관계 관리 (0) | 2022.06.19 |
댓글