JPA 연관 관계 설정(1) - N:1, 1:N

GEONNY·2024년 8월 19일
0

Building-API

목록 보기
23/28
post-thumbnail

JPA 에서 객체간 연관관계를 설정하는 방법을 알아보겠습니다. 이 전에 알아두어야할 3개의 키워드 부터 확인하고 가겠습니다.

📌중요 키워드 3개

📍방향 (Direction)

한쪽 Entity 에서만 참조하면 단방향,
양쪽 Entity 에서 서로 참조하면 양방향 관계라고 합니다.
Database Table 은 항상 양방향 관계이고, 객체를 사용하는 JPA 에서만 단방향 참조가 존재합니다. 정확히 얘기하면 JPA 에서는 Entity 간 서로 단방향 관계를 맽어 양방향 처럼 보이게 합니다.

📍다중성 (Multiplicity)

객체간 관계성을 나타내며, 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:N) 이 있습니다.
JPA 에서는 @ManyToOne, @OneToMany, @OneToOne, @ManyToMany 를 사용하여 Entity 간 관계성을 표현합니다.

📍연관관계의 주인 (Owner)

객체간 양방향 관계를 설정하기 위해서는 연관관계의 주인(owner)을 설정해야 합니다.
JPA에서는 mappedBy 속성을 사용하여 연관관계의 주인을 설정하며, 항상 외래 키(FK)가 설정된 테이블의 Entity 를 연관관계의 주인으로 설정합니다. 연관관계의 주인으로 설정한다는 의미는 주인을 수정할 때만 FK 필드를 변경하겠다는 의미 입니다.

📌다중성

📍@ManyToOne (N:1) 단방향

이전에 생성했던 Member - Authority 간 관계를 예로 들어보겠습니다.

Member 테이블에 authority_cd 를 FK로 갖고 있으므로 회원은 하나의 권한을 갖을 수 있습니다. 회원들(Many)은 하나(One)의 권한코드를 공유하게 되므로 Member 에서 authority 참조 시 @ManyToOne 을 사용하여 매핑하게 됩니다.

🎈insertable = false, updatable = false 설정 이유

    @Column(name = "authority_cd")
    private String authorityCode;

    @ManyToOne
    @JoinColumn(name = "authority_cd", insertable = false, updatable = false)
    private Authority authority;

위와 같이 authorityCode 와 authority를 모두 필드로 사용하려면 어느 한쪽에든 inserable = false, updatable = false 속성을 설정해야 합니다. (의미상 맞도록 참조하는 필드에 추가하도록 합니다.) 필드의 insertable, updatable 속성의 default 값이 true 이기 때문에 두 필드의 값을 각각 다른 값으로 설정할 경우 데이터 정합성이 깨지게 됩니다. 그래서 하나의 필드값이 바뀌면 한쪽은 참조만 되도록 설정하는 것 입니다.

설정하지 않을 경우 org.hibernate.MappingException 발생하게 됩니다.

Column 'authority_cd' is duplicated in mapping for entity 'com.geonlee.api.entity.Member' (use '@Column(insertable=false, updatable=false)' when mapping multiple properties to the same column)

만약 authorityCode 없이 authority 만 사용한다면 데이터 정합성이 깨질 위험이 없기 때문에 속성을 필수로 설정하지 않아도 됩니다.

    @ManyToOne
    @JoinColumn(name = "authority_cd")
    private Authority authority;

중복된 값을 가지고 있을 필요가 없기 때문에 authorityCode 는 제거하고 authority 만 남겨두도록 하겠습니다.
entity.Member

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table
@Entity(name = "member")
public class Member extends BaseEntity implements Persistable<String> {

    @Id
    @Column(name = "member_id")
    private String memberId;

    @Column(name = "member_pw")
    private String password;

    @Column(name = "member_nm")
    private String memberName;

    @Column(name = "use_yn")
    private String useYn;

    @ManyToOne
    @JoinColumn(name = "authority_cd")
    private Authority authority;

    @Override
    public String getId() {
        return this.memberId;
    }

    @Override
    public boolean isNew() {
        return getCreateDate() == null;
    }

    public void updateFromRecord(MemberModifyRequest parameter) {
        this.memberName = parameter.memberName();
        this.useYn = parameter.useYn();
    }
}

📍@OneToMany (1:N) 단방향

위와는 반대로 Authority 입장에서는 하나의 권한이 여러 Member를 갖을 수 있습니다. 이런 경우 @OneToMany 설정을 하여 Collection 으로 관계를 설정합니다.

    @OneToMany
    @JoinColumn(name = "authority_cd")
    private Set<Member> members = new HashSet<>();

1:N 단방향 매핑의 경우 FK 가 다른 테이블에 존재하게 됩니다. 이런 경우 Member entity 는 Authority Entity 를 모르기 때문에 Member entity 저장 시 authority_cd 가 저장되지 않습니다. 대신 Authority Entity 를 저장할 때 members의 참조 값을 확인해서 Member entity 의 authorityCode를 update 해주어야 합니다. 이런 귀찮음 때문에 1:N 단방향 매핑보다는 N:1 양방향 매핑을 권장합니다.

🎈Many 연관관계 설정 시 어떤 Collection 을 사용해야 할까?

이는 Collection 의 특성에 따라 선택합니다.
Entity 간 순서가 중요하고 중복이 허용되는 경우 List 를, 순서가 없고 중복을 방지하고 싶다면 Set 을 사용합니다. 위의 Authority 의 Member 는 순서가 중요하지 않고 중복 방지를 위해 Set 를 사용했습니다.

🎈연관관계 객체 초기화

위에서 Member 에서 Authority의 연관관계를 설정할 때는 초기화하지 않았습니다.

	@ManyToOne
	@JoinColumn(name = "authority_cd")
	private Authority authority; //초기화 안함

그런데 Authority 에서 Member의 연관관계 설정할 때는 new HashSet<>() 으로 초기화를 했습니다.

    @OneToMany
    @JoinColumn(name = "authority_cd")
    private Set<Member> members = new HashSet<>(); // new HashSet<>() 으로 초기화

단일 객체의 경우 초기화를 하지 않았고, Collection 의 경우 초기화를 했습니다. 왜 그럴까요?
전자의 경우 연관관계가 없을 경우 당연히 null 이 되는게 맞죠. new 를 사용해서 빈 객체로 초기화 할 이유가 없습니다. 하지만 Collection 의 경우 초기화 하지 않고 접근 시 NPE 이 발생할 가능성이 있습니다. NPE를 방지하고 즉시 사용할 수 있도록, 안정성과 편의성을 위해 Collection은 초기화 하는 것 입니다.

📍@ManyToOne(N:1) - @OneToMany(1:n) 양방향

Member 에서는 @ManyToOne 으로 Authority 를 단방향 설정했고, Authority 에서는 @OneToMany 로 Member를 단방향 설정했습니다. 지금과 같은 설정 시 각각 조회할 때는 아무런 문제가 되지 않습니다. 하지만 추가/수정/삭제 시에는 생각해봐야 할 부분이 있습니다. Member의 Authority 를 변경해야 할 경우, Database 에서는 FK 인 authority_cd 만 변경해주면 됩니다. 하지만 객체의 경우 Member 의 Authority 를 변경 할 수도, Authority 의 Members의 Authority 를 변경할 수도 있습니다. 이처럼 양방향이 되면서 두 경우를 모두 신경써야 하기 때문에 둘 중 하나를 외래키로 관리해야 합니다.

양방향 매핑의 규칙은 연관관계의 주인(Owner)을 지정하는 것 입니다. 연관관계의 주인만이 외래 키를 관리(등록 수정)할 수 있고, 주인이 아니면 읽기만 가능하게 됩니다.

🎈누구를 주인으로 설정해야 하나?

양방향 연관관계의 주인은 항상 FK 가 있는 테이블의 Entity 입니다. 이는 JPA가 데이터베이스에서 관계를 관리하고, 외래 키를 정확히 업데이트할 수 있도록 하기 위한 필수 규칙입니다. 이를 따르지 않으면, JPA가 어떤 엔티티를 기준으로 외래 키를 관리해야 할지 혼란스러워질 수 있으며, 데이터 일관성 문제가 발생할 수 있습니다.

Member - Authority 관계의 경우 FK(Authority_cd) 가 설정된 Member 가 주인이 되니, Authority 에서 mappedBy 속성을 추가하여 Member 가 주인이라는 것을 명시해주어야 합니다. mappedBy의 속성 값은 반대쪽 매핑의 필드 명(authority)으로 설정합니다. mappedBy 속성을 통해서 이미 관계성을 지정했기 때문에 @JoinColumn 은 삭제합니다.

    @OneToMany(mappedBy = "authority") // Member 에 필드명으로 설정
    private Set<Member> members = new HashSet<>();

이제 Member 가 Authority 와의 연관관계의 주인이기 때문에 Member 에서 authority 는 변경할 수 있지만, Authority 에서 members는 읽기만 가능한 상태가 됩니다.

entity.Authority

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table
@Entity(name = "authority")
public class Authority extends BaseEntity {

    @Id
    @Column(name = "authority_cd")
    private String authorityCode;

    @Column(name = "authority_nm")
    private String authorityName;

    @OneToMany(mappedBy = "authority")
    private Set<Member> members = new HashSet<>();
}

🎈양방향 연관관계의 저장

앞에서 설명한 것과 같이 Member - Authority 연관관계의 주인은 Member 입니다. FK도 Member 가 관리하기 때문에 Authority 에 members 에 add 를 하지 않아도 Database에 FK 가 정상으로 등록됩니다. 테스트를 해보겠습니다.
기존 MemberCreateRequest 에 authorityCode 를 추가합니다.

domain.record.MemberCreateRequest

@Schema(description = "회원 추가 요청 Record")
public record MemberCreateRequest(
        @NotEmpty(message = "id 는 필수 입니다.")
        @Schema(description = "회원 id (최대 30자)", example = "member01")
        String memberId,
        
        @Schema(description = "password")
        String password,
        
        @Schema(description = "사용 여부", example = "Y")
        String useYn,
        
        @Pattern(regexp = "^[가-힣a-zA-Z]+$"
               , message = "이름은 한글/영문만 가능합니다."
               , groups = MemberValidationGroup.User.class)
        @Schema(description = "회원 명", example = "이건")
        String memberName,
        
        @Pattern(regexp = "^[A-Z_]+$", message = "권한 코드가 올바르지 않습니다.")
        @Schema(description = "권한 코드", example = "ROLE_ADMIN")
        String authorityCode // 추가
) {
}

Member Entity 에 setAuthority method 를 추가합니다.

    public void setAuthority(Authority authority) {
        this.authority = authority;
    }

Member 추가 시 Authority 를 추가할 수 있도록 createMember method 를 수정합니다.
domain.member.MemberServiceImpl.createMember

	@Override
    public MemberCreateResponse createMember(MemberCreateRequest parameter) {
        if (memberRepository.existsById(parameter.memberId())) {
            throw new EntityExistsException(
            	"이미 존재하는 ID 입니다. -> " + parameter.memberId());
        }
		Authority authorityEntity = authorityRepository.findById(
        	parameter.authorityCode())
                .orElseThrow(() -> new EntityNotFoundException(
                                "권한코드가 존재하지 않습니다. -> " + 
                                	parameter.authorityCode()
                        )
                );
        Member newMember = memberMapper.toEntity(parameter);
        newMember.setAuthority(authorityEntity); // Member의 권한 설정
        memberRepository.save(newMember);
        return memberMapper.toCreateRecord(newMember);
    }

회원 추가 호출 시 정상적으로 FK 가 등록됩니다. 그럼 Authority 의 members 에 add 를 하면 어떻게 될까요? createMember method 를 수정해서 결과를 보도록 하죠.

        Member newMember = memberMapper.toEntity(parameter);
        memberRepository.save(newMember);
        authorityEntity.getMembers().add(newMember); // 연관관계의 주인이 아니기 때문에 무시
        authorityRepository.save(authorityEntity);
        return memberMapper.toCreateRecord(newMember);

다시 호출해보면 FK 가 정상적으로 설정되지 않는 것을 확인할 수 있습니다. Member 가 Authority 와의 연관관계의 주인이기 때문에 Member 에서 authority 는 변경할 수 있지만, Authority 에서 members는 변경 할 수 없으니 주의 해야 합니다.
그럼 한쪽에만 설정하는게 맞을까요? 위의 정상 코드에서 디버깅을 해보면 save method 전과 후에 authorityEntity 의 members size에는 변함이 없습니다. 해당 권한의 Member 를 추가했지만 Authority 의 members 에는 바로 반영이 되지 않습니다. 이런 문제를 해결하기 위해서는 양쪽 모두 관계를 맺어줘야 합니다.

		Member newMember = memberMapper.toEntity(parameter);
        newMember.setAuthority(authorityEntity);
        authorityEntity.getMembers().add(newMember);
        memberRepository.save(newMember);
        return memberMapper.toCreateRecord(newMember);

🎈연관관계 편의 메서드

양방향 연관관계의 Entity 저장 시에는 모두 관계를 맺어주도록 신경써야 합니다. 실수로 하나만 맽을 경우 양방향이 깨지는 문제가 발생하게 되죠. 양방향 관계에서 두 코드는 하나인 것처럼 사용하는게 안전합니다. 그래서 Entity 에 연관관계 편의 메서드를 생성하여 처리 되도록 합니다.

    public void setAuthority(Authority authority) {
    	if(this.authority != null) {
        	this.authority.getMembers().remove(this);
        }
        this.authority = authority;
        authority.getMembers().add(this);
    }

위에서 추가했던 Member entity 의 setAuthority method 를 수정하여 연관관계 편의 메서드를 만들었습니다. 기존 authority 가 있을 경우 authority 의 members 에서 현재 Member를 제거하고 Member에 authrotiy 를 설정한 후 authority 의 members 에 현재 Member 를 추가해 주었습니다. 기존의 authority 에서 현재 Member 를 제거하는 이유는 같은 transaction 내에서 authority 를 변경하는 경우 이전 authority 의 members 에 현재 member 가 죄회될 수 있기 때문입니다. 따라서 위와 같이 연관관계 변경에 앞서 관계를 제거하는 것이 안전합니다.

📚참고

📕매핑 어노테이션의 속성

mappedBy

@OneToMany, @ManyToMany, @OneToOne 에서 설정 가능하며, 앞에서 알아본 것과 같이 mappedBy 속성은 Owner 설정에 사용됩니다. mappedBy 가 있다? FK 는 반대 방향에 있다! 하고 인식하시면 됩니다.

@OneToMany(mappedBy = "authority")

📖cascade

모든 연관관계 에서 설정 가능하며, 부모 엔티티의 상태 변화에 따라 자식 엔티티에 자동으로 수행될 작업을 지정합니다. 설정 값으로는 CascadeType.ALL, CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE, CascadeType.REFRESH, CascadeType.DETACH 가 있습니다. 이 부분은 나중에 자세히 다루도록 하겠습니다.

@ManyToOne(cascade = CascadeType.REMOVE)

📖fetch

모든 연관관계 에서 설정 가능하며, 연관된 Entity 를 언제 로드할지 설정합니다. 기본 값은 Many로 끝나는 경우엔 FetchType.LAZY, One 으로 끝나는 경우엔 FetchType.EAGER 입니다.
@OneToMany, @ManyToMany -> FetchType.LAZY
@ManyToOne, @OneToOne -> FetchType.EAGER
FetchType.LAZY 로 설정할 경우 Entity 가 실제로 사용될 때까지 로딩을 지연시킵니다.
FetchType.EAGER 는 즉시 로딩

@ManyToOne(fetch = FetchType.LAZY)

📖orphanRemoval

@OneToMany, @OneToOne 에서 설정 가능하며, 부모 엔티티와의 관계가 제거된 자식 엔티티를 자동으로 삭제할지 여부를 지정합니다. true 설정 시 객체간 연관관계가 끊어지면 삭제되게 됩니다. default 는 false

@OneToMany(orphanRemoval = true)

📖optional

@ManyToOne, @OneToOne 에서 설정 가능하며, 연관관계가 선택적인지 필수적인지 지정합니다. default 는 true 로 연관된 Entity 가 없을 수도 있음을 의미합니다. false 설정 시 반드시 존재해야 합니다.

@ManyToOne(optional = false)
profile
Back-end developer

0개의 댓글