본문으로 바로가기

[JPA] 연관 관계

category Programming/JPA 2021. 5. 29. 18:36

연관 관계

  • 엔티티들은 대부분 다른 엔티티와 연관 관계가 있다.
  • 객체는 참조를 사용해서 관계를 맺고 엔티티는 키를 이용해 관계를 갖는다.
방향 : 단방향, 양방향 존재 -> 객체 관계에만 존재하고 테이블 관계는 항상 양방향
다중성 : 다대일, 일대다, 일대일, 다대다
연관관계의 주인 : 객체를 양방향 연관 관계로 만들면 연관관계의 주인을 정해야함
  • 객체 A에 만 B를 참조 하고 있을때 A -> B는 가능하지만 B -> A는 불가능
  • 테이블은 A Join B / B Join A 외래키만 알고 있다면 가능 (양방향)

다대일, 일대다 연관 관계

  • 역과 노선이 있다.
  • 지하철역은 하나의 노선에만 소속될 수 있다.
    • 환승역은 고려하지 않는다.
  • 지하철역과 노선은 다대일(N:1) 관계다.
  • 노선과 지하철역은 일대다(1:N) 관계
@Entity
@Table(name = "station")
public class Station {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @ManyToOne // (1)
    @JoinColumn(name = "line_id") // (2)
    private Line line; // (3)

    public void setLine(final Line line) { // (4)
        this.line = line;
    }
}
  1. @ManyToOne : 다대일(N:1) 관계라는 매핑 정보
  2. @JoinColumn : 컬럼 이름과 외래 키가 참조할 컬럼을 직접 지정하지 않는다면 굳이 선언하지 않아도 됨
  3. 지하철역 객체는 line 필드로 노선 객체와 연관 관계를 맺는다.
    • 지하철 역과 노선은 단방향 관계
package subway.domin;

import javax.persistence.*;

@Entity
@Table(name = "line")
public class Line {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    @Column(nullable = false)
    private String name;
}

실행 결과

Hibernate: 

    create table line (
       id bigint generated by default as identity,
        name varchar(255) not null,
        primary key (id)
    )
Hibernate: 

    create table station (
       id bigint generated by default as identity,
        name varchar(255) not null,
        line_id bigint,
        primary key (id)
    )
Hibernate: 

    alter table station 
       add constraint FKklalypfiiahjy57wtapf4w92 
       foreign key (line_id) 
       references line
  • 외래키가 생성

예제

package subway.domin;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
class LineRepositoryTest {
    @Autowired
    private LineRepository lines;

    @Autowired
    private StationRepository stations;

    @Test
    void saveWithLine() {
        Station expected = new Station("잠실역");
        expected.setLine(new Line("2호선"));
        Station actual = stations.save(expected);
        stations.flush();
    }
}

실행결과

Hibernate: 
    insert 
    into
        station
        (id, line_id, name) 
    values
        (null, ?, ?)
2021-05-29 17:09:42.321  INFO 10640 --- [    Test worker] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@4982f82c testClass = LineRepositoryTest, testInstance = subway.domin.LineRepositoryTest@4a38ebab, testMethod = saveWithLine@LineRepositoryTest, testException = org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : subway.domin.Station.line -> subway.domin.Line; nested exception is java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : subway.domin.Station.line -> subway.domin.Line, mergedContextConfiguration = [MergedContextConfiguration@34979333 testClass = LineRepositoryTest, locations = '{}', classes = '{class subway.Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@72629207, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@318197f, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@ddfa6b7d, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@198799da, [ImportsContextCustomizer@7611d4d1 key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@2c4b20fd, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@f27f658, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]

org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : subway.domin.Station.line -> subway.domin.Line; nested exception is java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : subway.domin.Station.line -> subway.domin.Line
  • Station 을 저장하기 위해선 연관된 Line 은 영속정인 상태여야함

예제

@Test
void saveWithLine() {
    Station expected = new Station("잠실역");
    expected.setLine(lines.save(new Line("2호선")));
    Station actual = stations.save(expected);
    stations.flush();
}

실행결과

Hibernate: 
    insert 
    into
        line
        (id, name) 
    values
        (null, ?)
Hibernate: 
    insert 
    into
        station
        (id, line_id, name) 
    values
        (null, ?, ?)

Station을 저장하기 위해선 line_id가 필요하기 때문에 Line에 접근하기 때문에

Line의 데이터가 영속화된 상태여야한다.

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.

실행전 data.sql 을 생성하면 초기화를 해준다
  • resources/data.sql
INSERT INTO line (id, name) VALUES (1, '3호선')
INSERT INTO station (id, line_id, name) VALUES (1, 1, '교대역')

예제

@Test
void findByNameWithLine() {
    Station actual = stations.findByName("교대역");
    assertThat(actual).isNotNull();
    assertThat(actual.getLine().getName()).isEqualTo("3호선");
}

실행결과

Hibernate: 
    select
        station0_.id as id1_1_,
        station0_.line_id as line_id3_1_,
        station0_.name as name2_1_ 
    from
        station station0_ 
    where
        station0_.name=?
Hibernate: 
    select
        line0_.id as id1_0_0_,
        line0_.name as name2_0_0_ 
    from
        line line0_ 
    where
        line0_.id=?
  • JPA는 객체가 그래프를 그리는 것을 보장해준다.

UPDATE

예제

@Test
void updateWithLine() {
    Station expected = stations.findByName("교대역");
    expected.setLine(lines.save(new Line("2호선")));
    stations.flush();
}

실행결과

Hibernate: 
    select
        station0_.id as id1_1_,
        station0_.line_id as line_id3_1_,
        station0_.name as name2_1_ 
    from
        station station0_ 
    where
        station0_.name=?
Hibernate: 
    select
        line0_.id as id1_0_0_,
        line0_.name as name2_0_0_ 
    from
        line line0_ 
    where
        line0_.id=?
Hibernate: 
    insert 
    into
        line
        (id, name) 
    values
        (null, ?)
Hibernate: 
    update
        station 
    set
        line_id=?,
        name=? 
    where
        id=?

java 코드로 쭉 작성하면

jvm 메모리에 떠있는 객체와 객체들을 가지고 실제로 쿼리를 만들어서 flush를 해준다.

연관관계 제거

예제

  • null을 넣어준다.
@Test
void removeWithLine() {
    Station expected = stations.findByName("교대역");
    expected.setLine(null);
    stations.flush();
}

실행결과

Hibernate: 
    select
        station0_.id as id1_1_,
        station0_.line_id as line_id3_1_,
        station0_.name as name2_1_ 
    from
        station station0_ 
    where
        station0_.name=?
Hibernate: 
    select
        line0_.id as id1_0_0_,
        line0_.name as name2_0_0_ 
    from
        line line0_ 
    where
        line0_.id=?
Hibernate: 
    update
        station 
    set
        line_id=?,
        name=? 
    where
        id=?

양방향

  • Line 에서도 Station 을 조회하기 위해 Station을 참조해준다.
@Entity
@Table(name = "line")
public class Line {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    protected Line() {
    }

    @Column(nullable = false)
    private String name;

    @OneToMany // (1)
    private List<Station> stations = new ArrayList<>();

    public Line(String name) {
        this.name = name;
    }

    public Long getId() {
        return Id;
    }

    public String getName() {
        return name;
    }
}
  • (1) 과 같이 1:N 관계를 설정.
  • 외래키가 설정이 없어 line 과 station 의 키를 담고 있는 테이블을 생성함

실행결과

Hibernate: 

    create table line (
       id bigint generated by default as identity,
        name varchar(255) not null,
        primary key (id)
    )
Hibernate: 

    create table line_station (
       line_id bigint not null,
        station_id bigint not null
    )
Hibernate: 

    create table station (
       id bigint generated by default as identity,
        name varchar(255) not null,
        line_id bigint,
        primary key (id)
    )
Hibernate: 

    alter table line_station 
       add constraint UK_m5y884wyh5p4r41lmd04bla0c unique (station_id)
Hibernate: 

    alter table line_station 
       add constraint FK78y8i0a0kum6n7s2qss4wi6i9 
       foreign key (station_id) 
       references station
Hibernate: 

    alter table line_station 
       add constraint FKawlxkihpbfvr39jka1t9jbbxu 
       foreign key (line_id) 
       references line
Hibernate: 

    alter table station 
       add constraint FKklalypfiiahjy57wtapf4w92 
       foreign key (line_id) 
       references line
  • line에 외래키가 어떤 키인지 알려주는 설정을 한다
  • mappedBy
@OneToMany(mappedBy = "line")
private List<Station> station = new ArrayList<>();-

station 에 line 이 선언되어 있고 실제로 line 에는 Line_id라는 외래키가 명시되어 있다

@ManyToOne
@JoinColumn(name = "line_id")
private Line line;

실행결과

Hibernate: 

    create table line (
       id bigint generated by default as identity,
        name varchar(255) not null,
        primary key (id)
    )
Hibernate: 

    create table station (
       id bigint generated by default as identity,
        name varchar(255) not null,
        line_id bigint,
        primary key (id)
    )
Hibernate: 

    alter table station 
       add constraint FKklalypfiiahjy57wtapf4w92 
       foreign key (line_id) 
       references line
  • 지하철역과 노선은 양방향 관계다.

연관 관계의 주인

  • 엄밀히 이야기하면 객체에는 양방향 연관 관계라는 것이 없다.
  • 서로 다른 단방향 연관 관계 2개를 양방향인 것처럼 보이게 할 뿐이다.
    • 지하철역과 노선은 다대일(N:1) 관계다.
    • 노선과 지하철역은 일대다(1:N) 관계다.
  • 연관 관계의 주인만이 데이터베이스 연관 관계와 매핑되고 외래 키를 등록, 수정, 삭제할 수 있다.
  • 주인이 아닌 쪽은 읽기만 할 수 있다.
  • 외래키의 관리자

예제

@Entity
@Table(name = "line")
public class Line {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    protected Line() {
    }

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = "line")
    private List<Station> stations = new ArrayList<>();

    public Line(String name) {
        this.name = name;
    }

    public Long getId() {
        return Id;
    }

    public String getName() {
        return name;
    }

    public void addStation(Station station) {
        stations.add(station);
    }
}
@Test
void save() {
    Line expected = new Line("2호선");
    expected.addStation(new Station("잠실역"));
    lines.save(expected);
    lines.flush(); 
}

실행결과

Hibernate: 
    insert 
    into
        station
        (id, line_id, name) 
    values
        (null, ?, ?) << line_id 에 null 이 들어감.
Hibernate: 
    insert 
    into
        line
        (id, name) 
    values
        (null, ?)
  • line 에서는 외래키를 등록 할수 없음
public void addStation(Station station) {
    stations.add(station);
    station.setLine(this);
}
  • station에 직접 set을 해줘야함.
@Test
void save() {
    Line expected = new Line("2호선");
    expected.addStation(new Station("잠실역"));
    lines.save(expected);
    lines.flush(); 
}

실행결과

Hibernate: 
    insert 
    into
        station
        (id, line_id, name) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        line
        (id, name) 
    values
        (null, ?)
Hibernate: 
    update
        station 
    set
        line_id=?,
        name=? 
    where
        id=?

주의

  • line.java
public void addStation(Station station) {
    stations.add(station);
    station.setLine(this);
}
  • station.java
public void setLine(Line line) {
    this.line = line;
    line.addStation(this);
}

양방향 관계를 가지려다 무한 루프에 빠짐.

연관관계의 주인인 line 에서는 addStation() 을 자제하거나 방어적인 코딩을 해야함

일대다로 조회하는 경우는 양방향으로 만든다. 단, 일대다 양방향 조회를 쓰되, 연관관계의 주인이 아닌 객체에서 연관관계의 매핑에 관한 것들을 수정하는 것을 지양한다.

일대다 단방향 매핑의 단점

  • 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다.
  • 연관 관계 처리를 위한 UPDATE SQL을 추가로 실행해야 한다.
  • 일대다 단방향 매핑보다는 다대일 양방향 매핑을 권장한다.

일대일 관계의 에서는 '다'가 될 수 있는 확률이 큰 쪽에 외래키를 보관한다.

다대다 연관 관계

  • 관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다.
  • 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 연결 테이블을 사용한다.
  • 연결 테이블에 필드가 추가되면 더는 사용할 수 없다.

'Programming > JPA' 카테고리의 다른 글

[JPA] Spring Data JPA  (0) 2021.05.29