01. 연관 관계 - N:1 단방향 관계 처리
- -
JPA는 OR Mapping을 기반으로 동작한다. JPA에는 자바 객체와 DB의 테이블을 연결하기 위한 많은 장치들이 있는데 객체와 테이블의 차이를 잘 이해하지 못하고 사용하면 전~혀 생각하지 못했던 SQL이 동작하면서 우리의 프로젝트를 망가트린다.
지난 시간까지는 하나의 테이블을 어떻게 엔티티로 표현할 수 있는지 살펴봤는데 이번 시간에는 테이블의 연관관계를 어떻게 객체의 관계로 표현할 수 있는지, 주의해야 할 내용은 뭐가 있는지 살펴보자.
그 둘은 다르다!!
관계 구성 방식
먼저 DB를 구성하는 테이블이 관계를 맺기 위해서는 Foreign Key(이하 F.K)를 이용한다. 이때 F.K를 설정한 테이블은 관계의 소유자가 된다. Post와 Reply의 관계에서는 Reply 테이블에 F.K 제약사항을 설정하며 Reply는 관계의 소유자가 된다. 이때 Post와 Reply의 관계는 1:N이 된다.(하나의 Post에 여러개의 Reply가 달릴 수 있다.)
객체 간의 관계에서는 해당 타입의 멤버 변수를 선언해서 참조를 구성한다. 이때 1:1의 관계라면 단순 객체, 1:N의 관계라면 객체 Collection이 필요해진다. 따라서 Reply는 하나의 Post를 관리하기 위해 Post post가 필요하고 Post에는 모든 연관 Reply를 관리하기 위해 List<Reply> replies가 필요하다.
구분 | DB 테이블의 관계 | 프로그램 객체의 관계 |
관계 구성 |
외래키를 이용한 양방향 관계 구성 | 참조 값을 이용한 단방향 관계 구성 |
alter table Reply add constraint FK_RNO foreign key (pno) references Post(pno); |
양 방향을 위해서는 각자가 상대에 대한 참조 필요 |
관계의 차이
이렇게 구성된 관계의 차이는 '양방향이냐' 아니면 '단방향이냐'이다.
연관관계에 있는 테이블을 사용하기 위해서는 관계의 주인에게 F.K를 설정해주면 된다. 재밋는 점은 DB 테이블에서의 관계는 언제나 양방향 관계이기 때문에 REPLY가 POST에 접근할 수 있는 것 뿐 아니라 POST 역시 이 관계를 이용해서 REPLY를 조회할 수 있다는 점이다.
# reply와 post는 F.K로 관계를 맺고 있다.
alter table if exists reply add constraint FKr1bmblqir7dalmh47ngwo7mcs
foreign key (pno) references post(pno)
# reply -> post 조회 가능
select r.*, "|", p.* from reply r join post b on r.pno=p.pno;
# post -> reply 조회 가능
select p.*, "|", r.* from post p join reply r on p.pno=r.pno;
하지만 자바 코드에서의 객체 관계는 단방향이다. 즉 Post에서 Reply의 참조를 가지고 있다 하더라도 Reply를 통해서 Post를 알 수는 없다. 이를 위해 추가적인 방향의 설정이 필요해진다. 아래 코드를 살펴보면 Reply에서 Post를 가져온 후 Post를 통해서 소속 Reply를 가져올 수 없다는 점은 너무 당연하다.
public class Post extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long pno;
}
public class Reply extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
@ManyToOne // 관계의 소유
private Post post;
}
// reply를 통해 post 조회 가능. 하지만 반대는?
Post post = reply.getPost();
// Post를 통해 소속 Reply들을 알 수는 없다 - 양방향성 설정 추가 필요
이 점을 유념하면서 아래 내용들을 살펴보자.
N:1의 단방향관계 매핑
관련 annotation
여러 개의 Reply(N)는 하나의 Post(1)에 매핑된다. 이런 N:1 관계를 처리하기 위해서 @ManyToOne과 @JoinColumn이 사용된다.
@ManyToOne은 N:1관계에서 N 쪽(통상적으로 F.K를 잡는 쪽)에 설정하는 annotation으로 참조 대상 객체 타입의 멤버에 선언한다. ("나 여러 개가 너 하나에 연결된다.!")
@Target({METHOD, FIELD})
public @interface ManyToOne {
boolean optional() default true; // 연결 대상의 필수 여부
CascadeType[] cascade() default {}; // 연관 객체에 대한 동시 처리 설정
FetchType fetch() default FetchType.EAGER; // 데이터를 조회하는 시점 설정
}
관계를 맺으면 내부적으로 join 처리되는데 optional 속성 값에 의해 inner join, outer join이 결정된다. 이 값이 true이면 연관 관계에 해당하는 값이 없을 수도 있으니까 left outer join이 되고, false인 경우는 필수이므로 inner join으로 처리된다. default는 true 즉 outer join이다. 나머지 속성들은 차차 알아보자.
@JoinColumn은 SQL에서 테이블을 참조하는 컬럼을 정의하는데 사용된다. 가장 많이 사용되는 속성은 컬럼의 이름을 정의하는 name 속성인데 생략시 [field명_참조테이블PK컬럼명]을 컬럼 이름으로 사용한다.
@Target({METHOD, FIELD})
public @interface JoinColumn {
String name() default ""; // 매핑할 테이블의 칼럼명으로 생략시 field명_참조테이블PK컬럼명
boolean unique() default false;
boolean nullable() default true;
boolean insertable() default true;
boolean updatable() default true;
String columnDefinition() default "";
String table() default "";
}
활용 예
다음은 @ManyToOne과 @JoinColumn의 사용 예이다. 하나의 Post에는 여러 Reply가 있고 한명의 Member가 여러 Reply를 작성할 수 있다. 따라서 기존의 pno와 writer를 각각 Post와 Member로 대체하고 @ManyToOne 애너테이션을 추가한다. 이때 Reply가 없는 Post가 있을 수 있고 아직 댓글을 달지 않은 Member도 가능하다. 따라서 optional은 true가 적절하다.
package com.doding.board.post.model.entity;
import com.doding.board.common.entity.BaseEnttiy;
import com.doding.board.member.model.entity.Member;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString(callSuper = true, exclude = {"post", "writer"}) // 순환 참조 방지
@Entity
public class Reply extends BaseEnttiy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
//private Long pno;
@ManyToOne(optional = true) // 여러 개의 Reply가 하나의 Post에 연결됨(left join)
@JoinColumn(name="pno")
private Post post;
//private String writer;
@ManyToOne // 여러 개의 Reply를 하나의 Member가 등록함(left join)
@JoinColumn(name="writer")
private Member writer;
private String content;
}
관계가 생성되면서 @ToString에 신경써야 한다. Reply는 Post를 출력하고 나중에 Post를 통해 다시 연관 Reply를 출력할 예정이므로 그냥 두면 순환 참조가 발생한다. 이를 위해 @ToString을 구성할 때 post와 writer를 제외할 필요가 있다. @ToString의 exclude 속성을 이용하거나 아래처럼 @ToString.Exclude를 사용하면 순환 참조를 방지할 수 있다.
@ToString.Exclude
private Member writer;
N:1 관계의 활용
N:1 관계에서 객체 그래프를 탐색, 수정 삭제 해보자.
관계 조회
이제 Reply 정보를 조회하고 Reply가 속한 Post 정보를 찾아보자.
@Test
@DisplayName("관계의 주인인 Reply가 가지는 Post와 직접 조회한 Post는 같은 객체")
public void replyRelationTest(){
Reply reply = rrepo.findById(1L).get(); // Reply와 함께 Post, Member까지 영속화
Post post= reply.getPost();
Post selected = prepo.findById(post.getPno()).get(); // 추가적인 조회는 없음
Assertions.assertSame(selected, post);
}
먼저 1L에 해당하는 Reply를 검색하고 그 Reply의 Post를 참조한다. 다시 Post의 pno를 이용해서 검색했을 때 검색된 Post와 참조된 Post는 같은 객체(==)여야 한다. (JPA에서 Entity는 동일성을 갖는다.) 사실 Reply를 조회한 시점에 이미 함께 조회된 Post, Member까지 영속화 되었기 때문에 추가적인 조회는 발생하지 않는다.
이때 생성된 DDL은 아래와 같다.
Hibernate:
create table reply (
create_time timestamp(6),
pno bigint,
rno bigint generated by default as identity,
update_time timestamp(6),
writer bigint not null,
content varchar(255),
primary key (rno)
)
Hibernate:
alter table if exists reply
add constraint FKlgcjvgo1a0hyfyyxg1vohuog5
foreign key (pno)
references post
Hibernate:
alter table if exists reply
add constraint FKcpyprjmm16d5qut9wxasanqun
foreign key (writer)
references member
테스트에 사용된 SQL은 다음과 같다.
Hibernate:
select r1_0.rno,..,p1_0.pno,..,w1_0.mno,...
from reply r1_0
left join post p1_0 on p1_0.pno=r1_0.pno
left join (member w1_0 left join paid_member w1_1 on w1_0.mno=w1_1.mno) on w1_0.mno=r1_0.writer
where r1_0.rno=?
현재는 @ManyToOne의 optional 속성이 true 이므로 left join으로 처리되고 있다. 만약 다음 처럼 optional을 false로 설정하면 inner join이 발생한다.
@ManyToOne(optional = false) // inner join을 유발한다.
Member member;
당연하게도 Post를 통해서는 어떤 Reply들이 있는지 알 수 없다. 단방향이기 때문이다.
관계 수정
이번에는 Reply가 속한 Post를 변경해보자 (물론 댓글의 본글을 바꾼다는게 말은 안되지만..). setter를 통해 Reply에 할당된 Post를 변경하기만 하면 된다.
@Test
@DisplayName("관계의 수정")
public void updateRelationTest(){
Reply r1 = rrepo.findById(1L).get();
Assertions.assertEquals(r1.getPost().getPno(), 1L); // 원래는 1L에 등록된 Reply
r1.setPost(prepo.getReferenceById(2L)); // Post를 영속화 하지 않음
//r1.setPost(prepo.findById(2L).get()); // Post를 조회해서 영속화 함
rrepo.flush();
}
Reply가 속한 Post를 변경하기 위해서는 대상 Post가 필요한데 객체 입장에서는 엄밀히 말하면 그 Post의 P.K인 pno만 있으면 된다. 이때 이제까지 사용하던 findById()를 사용하면 실제로 Post를 조회해서 영속화 시키므로 살짝 부담스러울 수 있다. 이런 경우는 getReferenceById()를 사용할 수 있는데 이 메서드는 실제로 객체를 조회하지는 않는다.
물론 대상 객체가 없으면 예외(jakarta.persistence.EntityNotFoundException)가 발생하며 나중에 그 객체의 P.K 이외의 속성을 이용하면 실제로 조회한다.
Hibernate:
update reply
set content=?, pno=?, update_time=?, writer=?
where rno=?
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [reply0]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:BIGINT) <- [2]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:TIMESTAMP) <- [2024-10-27T20:06:51.910812]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:BIGINT) <- [1]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (5:BIGINT) <- [1]
관계 삭제
관계의 삭제는 Reply와 Post의 관계를 없애는 것을 의미한다. 객체의 삭제가 아니다. 즉 Reply의 소속 Post를 null로 만든다는 개념이다. 만약 무턱대고 Reply가 속해있는 Post를 삭제하려는 경우 당연히 오류가 발생한다.
@Test
@DisplayName("관계의 삭제")
public void relationDeleteTest(){
Reply r1 = rrepo.findById(1L).get();
r1.setPost(null); // 관계의 삭제: 이제 Reply는 어떤 Post에도 속해있지 않다.
Assertions.assertNull(r1.getPost());
rrepo.flush();
}
이때 생성되는 SQL은 아래와 같다.
Hibernate:
update reply
set content=?, pno=?, update_time=?, writer=?
where rno=?
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [reply0]
[TRACE] [o.h.orm.jdbc.bind.logNullBinding.35] binding parameter (2:BIGINT) <- [null]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:TIMESTAMP) <- [2024-10-27T20:17:18.040010]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:BIGINT) <- [1]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (5:BIGINT) <- [1]
결국 중요한 것은 참조키인 pno 항목을 null로 만들어주는 것이다. 그럼 댓글인데 원글에 대한 정보가 없는 형태이다. 이렇게 참조 대상이 없는 하위 객체를 고아객체(orphan object)라고 한다. 이에 대한 처리는 나중에 다시 다루기로 하자.
기존 테스트 수정
PersistenceContextTest.java
테이블간의 관계가 생기면서 Post를 삭제하기 위해서는 먼저 Reply와 Attachment를 먼저 삭제해야 한다. 따라서 기존에 Post를 delete 하는 코드는 오류를 발생시킨다. 오류에 대한 근본적인 처리를 위해 당분간 다음의 두 가지 테스트는 주석 처리하도록 하자.
//@Test
public void deleteTest(){
prepo.deleteById(1L); // selecte --> managed -> delete query
...
}
//@Test
public void mergeTest(){
Post p1 = prepo.findById(1L).orElse(null);
...
}
'Spring Model > 03. 연관관계 처리' 카테고리의 다른 글
06. 연관 관계의 관리 - 영속성 전이와 고아객체 관리 (0) | 2020.06.06 |
---|---|
05. 연관 관계 관리 - fetch 속성 (0) | 2020.06.05 |
04. 연관 관계 - M:N 관계 (0) | 2020.06.04 |
03. 연관 관계 - 1:1 관계 (0) | 2020.06.04 |
02. 연관 관계 - N:1 양방향 관계 처리 (0) | 2020.06.04 |
소중한 공감 감사합니다