JPA 온보딩

후니팍·2023년 7월 9일
0
post-thumbnail
post-custom-banner

실험 환경

  • java 17
  • springboot 3.1.1
  • h2 database

실험할 엔티티

지하철 역과 노선 사이의 관계를 실험할 예정입니다.
각각의 엔티티 정보는 아래와 같습니다.

Station

@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Entity
public class Station {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

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

Line

@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Entity
public class Line {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

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

StationLine은 N:1 관계로 설정할 예정입니다. 하나의 노선 안에 여러 역이 있다고 가정하고 연관 관계를 매핑해보겠습니다.


연관 관계 매핑

먼저 Station 엔티티에 Line 필드를 추가해보았습니다.

영속성 엔티티는 속성 타입으로 될 수 없다는 메시지가 나오네요.
그렇다면 String 타입으로 하면 정상적으로 동작될까요?

빨간 줄이 생기지 않았고, table 또한 정상적으로 생성되었습니다.

자 그럼 이제 StationLine을 연관 관계 매핑해보도록 하겠습니다.


정상적으로 table이 생긴 것을 볼 수 있습니다.
foreign key로 line_id가 지정된 것을 확인할 수 있네요.

만약 @JoinColumn 어노테이션을 이용해서 name을 바꾸어주면 어떻게 될까요?


join column의 이름이 line_number로 설정된 것을 확인할 수 있습니다.

여기서 모르고 있던 한가지를 배웠습니다.
@JoinColumn이 없어도 line_id 라는 이름으로 연관 관계를 맺어준다는 사실을 배웠는데요. 생각보다 스프링은 더 편리해서 놀랐습니다. 하지만 명시적으로 보이는 것도 중요하기 때문에 저는 @JoinColumn(name = "line_id")로 설정할 예정입니다.

단방향 관계

지금까지 작성한 코드를 보면 StationLine에 접근 가능하지만, LineStation에 접근하지 못합니다. LineStation 관련 필드가 없기 때문이죠.
이걸 스프링 JPA에서는 단방향 관계라고 합니다.

만약 새로운 Station을 DB에 저장하는데, Line이 영속상태가 아니라면 어떻게 될까요?

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


예외가 발생합니다. 메시지를 읽어보면, flush를 하기 전에 일시적인 인스턴스를 save하라고 나와있습니다.
자바 객체를 영속성 컨텍스트에 저장해놓지 않으면 DB에 반영할 수 없다는 것을 알 수 있습니다.

Line을 먼저 저장하고 Station을 저장하면 정상적으로 동작하는 것을 확인할 수 있습니다.

	// no error
    @Test
    void saveWithLine() {
        final Station expected = new Station("선릉역");
        expected.setLine(lineRepository.save(new Line("2호선")));
        final Station actual = stationRepository.save(expected);
        stationRepository.flush();
    }

만약 update인 경우는 어떨까요?

    @Test
    void update() {
        final Station station = new Station("교대역");
        station.setLine(lineRepository.save(new Line("3호선")));
        stationRepository.save(station);
        stationRepository.flush();

        Station changedStation = stationRepository.findByName("교대역");
        changedStation.setLine(new Line("2호선"));
        stationRepository.save(changedStation);
        stationRepository.flush();
    }

같은 예외가 발생했습니다.

만약 Station과 연관 관계를 맺고 있는 Line을 DB에서 delete 하면 어떨까요?
이 경우는 DBMS 관점에서 문제가 생깁니다. 해당 Line을 참조한 모든 데이터의 연관 관계를 지워야만 삭제할 수 있습니다.
만약 참조한 데이터가 많다면 이 모든 데이터의 연관 관계를 끊어줘야 하는데요. 굉장히 노가다스러워 보입니다.

JPA는 이를 위한 것도 제공해주고 있습니다.
바로 양방향 연관 관계를 이용하는 건데요.


양방향 연관 관계

위에서 StationLine을 알 수 있었는데요. LineStation의 목록을 알게 하면 그것이 양방향 연관 관계가 됩니다.

@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Getter
@Entity
public class Line {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

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

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

여기서 @OneToMany 어노테이션이 사용되었는데요. 이는 1대다를 의미하고 있습니다. 속성에 mappedBy는 연관 관계 주인의 field 이름을 뜻합니다. Station 객체에서 Line의 필드 이름을 line으로 두었기 때문에 mappedBy = "line" 으로 두었습니다.
여기서 '연관 관계 주인'은 보통 1대다 관계에서 다 쪽이 가지게 됩니다.

만약 이 외래키를 관리하는 속성인 mappedBy를 없애면 어떻게 될까요?

새로운 table인 line_stations이 생기는 것을 확인할 수 있습니다. 이렇게 관계를 직접 명시해주지 않으면 불필요한 table이 생성되는 것을 확인할 수 있습니다.


이제 Line을 추가하는 테스트를 해보겠습니다.
먼저 Line 객체에 역 추가 메서드를 만들었습니다.

    public void addStation(final Station station) {
        station.setLine(this);
        stations.add(station);
    }

그 후에 테스트를 진행합니다.

    @Test
    void save() {
        final Line line = new Line("2호선");
        line.addStation(stationRepository.save(new Station("선릉역")));
        lineRepository.save(line);
        lineRepository.flush();
    }

테스트는 통과하지만 아래와 같이 연관 관계가 설정되지 않은 것을 확인할 수 있습니다.

연관 관계 주인에게 Line에 대한 정보를 제공하지 않았기 때문에 발생한 문제입니다.

    public void addStation(final Station station) {
        station.setLine(this);
        stations.add(station);
    }

연관 관계 주인인 Station에도 Line을 세팅했습니다.
그 결과 정상적인 쿼리가 생성되었습니다.

추가적으로 Station 객체의 setter에도 Line 객체를 호출하여 자신을 추가하겠습니다.

    public void setLine(Line line) {
        this.line = line;
        line.getStations().add(this);
    }

하지만 이때 문제가 발생합니다.
자칫 잘못하면 순환 참조가 발생하게 됩니다.
따라서 아래와 같이 순환 참조를 막아주었습니다.

    public void setLine(Line line) {
        this.line = line;
        final List<Station> stations = line.getStations();
        if (!stations.contains(this)) {
            line.getStations().add(this);
        }
    }

마지막으로 아까 살펴보았던 참조된 연관 관계 모두 끊어 삭제하는 JPA의 기능을 알아보겠습니다.
연관 관계의 주인에서 cascade 옵션을 remove로 걸면 됩니다.
그러면 참조된 것이 모두 제거되는 것을 확인할 수 있습니다.

@OneToMany(mappedBy = "line", cascade = CascadeType.REMOVE)
private List<Station> stations = new ArrayList<>();

하지만 이 방법은 참조된 모든 데이터를 제거합니다. 관계만 끊는게 아니라 싹다 삭제해버리기 때문에 위험해보입니다. 의도하지 않은 데이터도 삭제될 수 있으니까요.

관계만 끊는 방법은 조금 더 공부하고 오도록 하겠습니다.

profile
영차영차
post-custom-banner

0개의 댓글