값 타입의 실제 인스턴스를 그대로 공유하는 것은 매우 위험하다.

 

객체를 넘기면 값 자체를 넘기는 것이 아니라 참조값을 넘기기 때문에 이런 식으로 객체를 공유하면 한 엔티티의 속성을 변경했는데 객체를 공유하던 다른 엔티티의 속성까지 함께 변해버리는 문제가 발생할 수 있다.

 

이렇게 두 엔티티가 하나의 값을 공유하도록 하는 것은 위험하다

 

만약 같은 값을 공유해야 하는 경우가 있다면 이러한 문제를 방지하기 위해 항상 값 타입의 실제 객체를 직접 공유하지 말고 값만 복사해서 사용해야 한다. 아래와 같은 모양처럼.

 

 

 

혹시라도 실수로 참조값을 공유하여 문제가 발생하는 것을 막기 위해서 항상 값 타입 객체는 불변 객체(immutable object)로 만들어야 한다.

 

뭐 대단한 건 아니고 그냥 setter를 만들지 않거나 private으로 만들어서 속성의 변경을 막아버리는 것이다.

 

그리고 만약 값을 공유하거나 혹은 일부 속성만 변경하고 싶은 경우에는 아래처럼 완전히 새로운 객체를 만들어서 갈아끼워넣는 방식이 바람직하다.

 

Address address = new Address("city", "street", "10000");
            
Member member = new Member();
member.setHomeAddress(address);
em.persist(member);
            
// city만 변경하고 싶더라도 객체 자체를 새로 갈아끼워넣는다
member.setHomeAddress(new Address("NewCity", address.getStreet(), address.getZipcode()));
            

 

 

 

JPA의 값 타입 중에는 임베디드 타입이란 것이 존재한다.

 

 

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    private LocalDateTime startDate;
    private LocalDateTime endDate;
}

똑같은 날짜 타입으로 두 가지 컬럼이 존재한다.

 

'날짜'라는 공통점이 있기에 하나로 묶어 관리한다면 더 편하게 쓸 수 있을 듯 하다.

 

 

@Embeddable
public class Period {

    private LocalDateTime startDate;
    private LocalDateTime endDate;
}
.
.
.
@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    @Embedded
    private Period workPeriod;
}

날짜를 한 번에 관리하는 클래스를 만들고 그 클래스를 속성으로 가져올 수 있다.

 

임베디드 타입을 선언하는 클래스에는 @Embeddable, 사용하는 쪽에는 @Embedded를 달아주면 된다.

 

물론 임베디드 타입 클래스 내부에서도 @Column으로 특정 컬럼과 매핑해줄 수 있다. 기본적으로는 임베디드 클래스의 모든 멤버변수들이 컬럼으로 만들어진다.

 

또한, 임베디드 클래스 내부에 메서드를 생성하여 좀 더 객체지향스러운 사용도 가능하다.

 

 

 

 

 

 

 

JPA는 데이터 타입을 크게 둘로 분류한다.

 

1. 엔티티 타입

@Entity로 테이블에 매핑하는 객체

 

2. 값 타입

primitive type, wrapper class 등 식별자(PK)없이 값 자체만 존재하는 데이터

 

값 타입은 다시 셋을 분류된다.

1. 기본값 타입 (primitive type, wrapper class, String)

2. 임베디드 타입 (= 복합 값 타입)

3. 컬렉션 값 타입

 

기본적으로 엔티티 타입이 값 타입을 포함하는 형태가 구성되기 때문에 값 타입은 그 생명주기를 엔티티에 의존하게 된다.

 

 

 

 

 

 

(Cascade 영속성 전이)

RDB에서의 cascade 기능을 JPA에서도 사용할 수 있다.

 

Member와 Team은 N:1 관계이다. Team은 여러 Member를 가질 수 있는데, member 객체가 하나 생성될 때마다 일일히 em.persist(member) 해주기 귀찮을 수 있다.

 

굳이 귀찮음의 문제가 아니더라도 member 중심이 아닌 team 중심적 프로그래밍을 하고 싶을 수도 있다.

 

이때 cascade 속성을 사용해서 멤버들을 포함한 team 하나만 persist() 함으로써 모든 멤버들을 한 번에 persist() 할 수 있다.

 

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
.
.
.
@Entity
public class Team {

    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    private List<Member> members = new ArrayList<>();
}

 이렇게 하면 연관관계의 주인인 team이 persist 될 때 cascade 하게 그 안의 모든 member들도 함께 처리된다.

 

 

보통 CascadeType.ALL, CascadeType.PERSIST 둘 중 하나를 선택하여 사용한다.

(완전히 같이 가도록 두든지 아님 삭제를 막기 위해 저장에 대해서만 허용하든지)

 

 

주의할 점은, Member처럼 Team이라는 단일 엔티티에만 종속된 경우에는 cascade로 함께 관리해도 무방하지만 만약 Member를 참조하는 다른 엔티티가 또 존재한다면 이 경우엔 cascade를 쓰지 않고 따로 관리하는 것이 좋다. 

(이 경우에도 cascade를 쓰면 운영이 너무 어려워진다.

예를 들어, Member를 가지는 다른 엔티티가 존재하는데 Team에서 Member에 CascadeType.ALL을 해놓으면? 

Team이 지워질 때 Member도 함께 지워지고, 그 Member를 참조하던 다른 엔티티에 문제가 생길 수 있다) 

 

 

(고아 객체)

부모와 참조관계를 형성한 자식 객체가 존재한다.

이 부모가 사라진다면 자식은 고아가 된다. 고아가 된 객체를 자동적으로 처리하는 CascadeType.REMOVE 와 같은 동작을 수행하도록 하는 옵션이 있다.

@OneToMany(mappedBy = "관계의 주인", orphanRemoval = true)

orphanRemoval도 Cascade와 같은 이유로 인해 단일 참조 관계일 경우에만 사용해야 한다.

 

 

 

 

 

 

 

cascade = CascadeType.ALL + orphanRemoval = true   ->  자식의 생명주기를 부모가 관리???

 

 

 

 

 

Member와 Team처럼 참조 관계로 엮여있을 경우 Member객체를 조회하면 조인 쿼리로 조회하여 Team객체까지 한 번에 찾아온다.

 

그런데 참조 관계로 엮여있기는 하지만 Team을 별로 사용하지 않는 비즈니스 로직일 경우에는 조인으로 Team까지 한 번에 가져오는 것이 낭비가 될 수 있다.

 

이를 막기 위해 프록시를 사용한 지연 로딩이 존재한다.

 

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @ManyToOne(fetch =  FetchType.LAZY)
    private Team team;
}

 

참조 관계 매핑 어노테이션의 fetch 속성을 통해 이를 조작할 수 있다.

즉시 로딩의 경우엔 Member를 조회하면 조인 쿼리로 Team까지 함께 가져온다.

(@ManyToOne, @OneToOne은 디폴트가 FetchType.EAGER으로 즉시 로딩이다.)

 

FetchType.LAZY를 사용할 경우 지연 로딩 방식을 사용하는데, 이 경우 조인 쿼리를 사용하지 않고 오롯이 Member객체만 조회한다.

이때 Member객체 안에 있는 Team 객체는 Team을 상속한 프록시 객체가 대체하게 된다.

(@OneToMany, @ManyToMany는 디폴트가 지연 로딩)

 

그러다가 m.getTeam.getName() 등으로 team객체의 기능을 직접 사용해야 하는 순간이 왔을 때 DB에 Team의 조회 쿼리를 날려 프록시 객체를 초기화해준다.

 

 

 

가급적 지연 로딩만 사용하는 것이 좋다.

엔티티들이 관계가 복잡해져 조인이 얽히고 섥힐수록 조인을 통해 한 번에 모든 객체를 조회하기 위한 낭비가 커진다.

 

기본적으로 설정은 LAZY로 해놓고 참조 객체들이 한  번에 필요한 경우에는 JPQL fetch join, 엔티티 그래프 등을 사용해서 한 번에 가져오도록 명시해주는 것이 좋다.

 

 

 

상속은 객체지향 프로그래밍에서 굉장히 중요하다.

하지만 관계형 데이터베이스에는 상속 개념이 존재하지 않는다

 

RDB에는 상속 개념과 매우 유사한 슈퍼-서브 타입이라는 관계 개념이 존재하는데, JPA가 패러다임의 불일치 해소라는 그 목적성에 맞게 객체지향의 상속을 RDB의 슈퍼-서브 타입으로 잘 매핑해준다.


RDB에서 슈퍼-서브 타입을 표현하는 방식은 세 가지가 있는데 JPA는 이 방법을 모두 지원한다.

 

 

1. 조인 전략 (가장 많이 사용됨)

 

가장 바람직한(?) 모델로 평가받는 조인을 이용한 방식이다.

조인 전략이 구조적으로도 객체지향의 상속을 가장 잘 나타내지 않나 싶다.

 

부모 클래스의 테이블이 공통속성을 가지고 자식 테이블들은 각자의 고유한 컬럼들을 따로 갖는다.

부모의 PK를 자식의 PK이자 FK로 사용했는데, JAVA에서 자식 객체를 검색할 경우 이를 통해 조인 쿼리를 날려서 찾아낸다. 

 

테이블이 정규화 되어있기 때문에 효율적인 구조라 할 수 있으며 쓸 데 없이 낭비되는 저장공간이 없다는 장점이 있다.

 

단점으로는 조회할 때 조인 사용량이 높아 성능이 저하될 수 있다는 부분, 새로운 데이터 저장 시 부모, 자식 테이블에 따로따로 insert문을 날려야 한다는 점이 있다.

 

@Entity
@Inheritance(strategy = InheritanceType.JOINED)  // 상속 매핑 전략 선택
@DiscriminatorColumn  // 자식 객체를 구분하기 위한 Dtype
public abstract class Item {
    // 부모 테이블은 공통된 속성에 대한 컬럼만을 갖는다
    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;
}

 

 

 

2. 단일 테이블 전략 (디폴트)

 

테이블 하나에다 모든 자식의 정보를 때려넣는 방식이다.

상속 매핑 전략을 따로 설정하지 않으면 이 방식이 기본으로 적용된다.

(다른 자식의 속성 컬럼에 대해선 NULL을 갖는다)

 

Dtype 컬럼의 존재가 필수적이다. 조인 전략에서는 굳이 Dtype이 없어도 조인을 통해 알아낼 수는 있지만 단일 테이블 전략에서는 Dtype이 없으면 객체를 구분할 수가 없다.

이러한 이유로 @DiscriminatorColumn을 넣지 않아도 자동적으로 Dtype 컬럼이 추가된다.

 

하나의 테이블에 모든 정보가 다 담겨있기 떄문에 조인 없이 바로 select가 가능하여 조회 성능이 빠르다는 장점이 있다.

 

단점으로는 다른 자식들에 대한 고유 속성 컬럼은 NULL값으로 가지고 있어야하므로 자식 엔티티의 컬럼들은 전부 다 NULL이 허용되며, 테이블이 너무 커져버릴 수 있다는 점이 있다.

 

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)  // 전략 선택 안 하면 디폴트로 싱글 테이블 적용
@DiscriminatorColumn  // 자식 객체를 구분하기 위한 Dtype (싱글 테이블에선 명시하지 않아도 자동 생성됨)
public abstract class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;
}

 

 

 

3. 테이블 퍼 클래스 전략 (사용하지 말 것!)

 

 

부모 클래스에 대한 테이블은 존재하지 않는다.

각 자식 클래스 별로 자신만의 테이블을 갖는데, 이때 부모가 갖고 있어야 할 고유한 속성들을 모든 자식 클래스가 자신의 테이블에 갖고 있는 방식이다.

 

서브 타입(자식)에 대한 구분이 명확하다는 것과 싱글 테이블 전략과는 다르게 NOT NULL 조건을 사용할 수 있다는 장점이 있다.

 

단점으로는 하나의 부모에 자식이 여럿일 경우 조회 성능이 굉장히 느려지게 된다는 점이 있다.

각각의 자식 테이블 중에 찾고자 하는 객체가 어느 곳에 있는지 알 수 없기 때문에 모든 테이블을 검사해야 한다.

이를 위해서 UNION 을 사용해 모든 테이블을 합치고 그것을 조회한다.

 

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)  // 웬만하면 사용을 자제해야 하는 전략
@DiscriminatorColumn  // 자식 객체를 구분하기 위한 Dtype
public abstract class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;
}

 

 

 

위 세 가지 방법 모두 부모 클래스는 abstract로 생성해야 한다.

 

그렇지 않으면 JPA가 부모 클래스의 개별 테이블까지 함께 만들어버린다.

(Item 클래스는 공통 속성을 한 번에 관리하기 위함일 뿐, Item에 대한 개별 테이블은 필요 없다)

 

 

 

 

 

 

 

다대다 관계의 경우 그대로 사용하지 못하고 반드시 정규화를 통해 중간 테이블을 만들어줘야 한다.

 

이러한 관계가 있을 때 반드시 중간 중간 테이블을 두어 일대다+다대일 형태로 변형해줘야 한다.

 

 

JPA에서는 @ManyToMany를 통해 연관관계를 매핑할 경우 하이버네이트가 위와 같은 중간 테이블을 알아서 만들어서 처리해준다.

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToMany
    @JoinTable(name = "새로 만들어줄 중간 테이블 이름")
    private List<Product> products = new ArrayList<>();
}
.
.
.
@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToMany(mappedBy = "products")
    private List<Member> members = new ArrayList<>();
}

위와 같이 @ManyToMany로 양방향 매핑 되어있을 때 (굳이 양방향이어야 할 필요는 없음. 단방향이어도 괜찮다) Member의 @JoinTable(name = "새로운 테이블 명")을 통해 중간 테이블을 따로 만들어준다.

 

하지만 이러한 방식은 실무에서 절대로 사용하면 안 된다.

 

중간테이블을 만들고 PK, FK 쌍을 알아서 매핑해주는 것 까지는 문제가 없는데, 실무 레벨에서는 이러한 테이블 매핑에 필요한 필수적인 정보들 외에도 중간 테이블이 가져야하는 여러 가지 컬럼들이 있을 수 있다.

(예를들어 멤버 - 오더 - 상품 이렇게 되어있을 경우 오더가 발생한 시간이라든가 하는 정보들)

 

하이버네이트에 의해 생성된 중간 테이블은 관계 설정에 필수적으로 필요한 정보들만 담겨있을 뿐 이러한 비즈니스 로직상 필요한 정보들은 담기지 않는다.

 

따라서, 실무 단계에서는 @ManyToMany는 절대 사용하지 말아야 한다.

 

다대다 관계를 사용하고 싶은 경우라면 중간 테이블에 대한 클래스를 직접 만들어서 @ManyToOne과 @OneToMany의 조합을 만들어 사용해야 한다.

 

 

 

중간 테이블을 직접 만들어서 관계를 매핑하면 아래와 같은 형태가 된다.

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}
.
.
.
@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}
.
.
.
@Entity
public class MemberProduct {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;
}

Member(ONE) - (MANY)MemberProduct(MANY) - (ONE)Product

 

MemberProduct는 Order 정도의 역할이 되겠다.

 

 

 

중간 테이블을 하나의 엔티티 개념으로 사용하면 (MemberProduct -> Order) 이러한 형태를 가질 수 있게 된다.

(ORDER_ID는 Generated Value로 주어진 비즈니스적 의미를 갖지 않는 값)

 

 

 

 

 

이전에 말했듯이 JPA는 객체와 DB의 패러다임 불일치로 인한 불편을 없애고 객체를 마치 Collections에 저장하는 것처럼 DB에 저장할 수 있게 하기 위해서 등장했다.

 

개발을 하다보면 필연적으로 연관관계를 가진 테이블들이 존재할 수밖에 없게 되는데 이에 대해선 어떻게 처리해야 할까?

 

N : 1의 관계로 이루어진 Member와 Team이 있다고 하자. 하나의 Team에 여러 Member가 소속될 수 있고 이러한 관계를 나타내기 위해 Member가 Team의 PK를 FK로 가지게 될 것이다. 

이러한 방식을 따라 클래스를 설계하면 아래와 같은 형태가 된다.

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String name;

    @Column(name = "TEAM_ID")
    private Long teamId;
}

 

그런데 이는 DB에 종속적으로 맞춘 설계이다. 즉, 객체 지향적인 설계라고 볼 수 없다.

 

위와 같이 설계를 한다면 em.find(memberId) 로 member를 찾고, member.getTeamId()를 통해 teamId를 찾고 다시 그걸 이용해 em.find(teamId)로 해당 멤버가 속한 팀을 찾아야 한다.

 

하지만 여태껏 자바 프로그래밍을 해왔던 대로라면 Member 안에 Team 객체에 대한 변수를 두어 참조 관계를 형성하고 member에서 바로 Team 객체에 접근하도록 하는 것이 우리가 지금까지 해왔던 객체지향적인 설계이다.

 

앞서 언급했다시피 JPA는 이러한 패러다임 불일치를 없애고 객체 지향적인 설계에 집중할 수 있도록 도와주는 도구이다.

 

아래와 같은 방법으로 객체 지향적인 모델링을 할 수 있다.

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

 

참조 관계처럼 Member가 Team team을 직접 소유한다. 그리고 @ManyToOne을 통해 Member : Team의 N : 1 관계를 표현한 후 @JoinColumn을 통해 DB 내에서 Member 테이블이 FK로 가질 Team 테이블의 PK를 지정해준다.

 

위와 같은 방식의 연관관계 매핑을 '단방향 연관관계'라고 한다. Member는 참조 변수 team을 통해 자신이 속한 Team에 접근이 가능하지만 Team에선 자신에게 속한 Member들에 접근이 불가하기 때문에 단방향이라는 이름이 붙었다.

 

 

그런데 일반적인 관계형 데이터베이스에서는 항상 양방향 연관관계를 지원한다. 조인된 테이블을 조회할 때를 생각해보자면.. Member는 자신이 FK로 teamId를 사용해 Team을 조회할 수 있고, Team은 자신의 PK를 FK로 가진 Member들을 검색하는 방식으로 자신에 속한 Member들에 접근이 가능하다.

 

이러한 양방향 연관관계는 아래와 같이 표현할 수 있다.

@Entity
public class Team {

    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

 

Member의 List를 참조변수로 둠으로써 양방향 연관관계를 나타냈다.

(이때 new ArrayList<>()로 굳이 초기화를 해준 이유는 관례 상 대부분 그렇게 하기 때문)

 

주의할 것은, 이번에는 Member가 아닌 Team 기준이기 때문에 @ManyToOne이 아니라 @OneToMany를 사용해야 한다는 것이다. 이때 속성으로 주어진 mappedBy에는 Member에서 자신을 참조하고 있는 참조변수의 이름을 넣어주는 것이다. (Team team)

 

 

사실 위와 같은 경우를 편의상 양방향 연관관계라고 말하기는 하지만 진정한 양방향 연관관계라고 볼 수는 없다.

단방향 연관관계 두개를 두어 양방향 연관관계처럼 사용하도록 한 것이다.

 

 

 

그런데 여기까지 왔다면 몇 가지 의문점이 생긴다.

1. 왜 Member에서는 @JoinColumn(name = "TEAM_ID")로 관계를 표현하고 Team에서는 @OneToMany(mappedBy = "team")관계를 표현하는가?  (물론 Member에도 @ManyToOne이 있긴 하지만)

2. 두 테이블을 잇는 TEAM_ID라는 컬럼은 Member, Team 중 어느 쪽에서 관리해야 하는가?

 

위와 같은 문제는 양방향 연관관계에서 항상 발생하게 되는데 이것을 이해하기 위해 "연관관계의 주인(Owner)" 이라는 것을 정해야 한다. 양방향 관계를 완성하는 두 개의 단방향 관계 중 하나의 관계를 주인으로 지정하는 것이다.

 

일반적으로 FK를 가진 곳을 관계의 주인으로 잡는다. 즉, 1 : N 관계의 경우 N쪽으로 주인으로 하는 것이다.

(일반적으로라고 했지만 항상 이를 따르자) 

 

그리고 이에 따라 아래와 같은 규칙들이 정해진다.

1. FK는 연관관계의 주인만이 관리한다.

2. 주인이 아닌 나머지 쪽은 FK에 대해 읽기만 수행할 수 있다.

3. 주인이 아닌 쪽에서 mappedBy 속성을 사용한다. (속성의 이름부터가 수동적인 느낌이라 주인에게는 어울리지 않는다. mappedBy = "관계의 주인") 

 

 

+ Recent posts