지하철 역과 노선 사이의 관계를 실험할 예정입니다.
각각의 엔티티 정보는 아래와 같습니다.
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Entity
public class Station {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String name;
}
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Entity
public class Line {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
}
Station
과 Line
은 N:1 관계로 설정할 예정입니다. 하나의 노선 안에 여러 역이 있다고 가정하고 연관 관계를 매핑해보겠습니다.
먼저 Station
엔티티에 Line
필드를 추가해보았습니다.
영속성 엔티티는 속성 타입으로 될 수 없다는 메시지가 나오네요.
그렇다면 String
타입으로 하면 정상적으로 동작될까요?
빨간 줄이 생기지 않았고, table 또한 정상적으로 생성되었습니다.
자 그럼 이제 Station
에 Line
을 연관 관계 매핑해보도록 하겠습니다.
정상적으로 table이 생긴 것을 볼 수 있습니다.
foreign key로 line_id가 지정된 것을 확인할 수 있네요.
만약 @JoinColumn
어노테이션을 이용해서 name을 바꾸어주면 어떻게 될까요?
join column의 이름이 line_number로 설정된 것을 확인할 수 있습니다.
여기서 모르고 있던 한가지를 배웠습니다.
@JoinColumn
이 없어도 line_id 라는 이름으로 연관 관계를 맺어준다는 사실을 배웠는데요. 생각보다 스프링은 더 편리해서 놀랐습니다. 하지만 명시적으로 보이는 것도 중요하기 때문에 저는 @JoinColumn(name = "line_id")
로 설정할 예정입니다.
지금까지 작성한 코드를 보면 Station
이 Line
에 접근 가능하지만, Line
은 Station
에 접근하지 못합니다. Line
에 Station
관련 필드가 없기 때문이죠.
이걸 스프링 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는 이를 위한 것도 제공해주고 있습니다.
바로 양방향 연관 관계를 이용하는 건데요.
위에서 Station
만 Line
을 알 수 있었는데요. Line
도 Station
의 목록을 알게 하면 그것이 양방향 연관 관계가 됩니다.
@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<>();
하지만 이 방법은 참조된 모든 데이터를 제거합니다. 관계만 끊는게 아니라 싹다 삭제해버리기 때문에 위험해보입니다. 의도하지 않은 데이터도 삭제될 수 있으니까요.
관계만 끊는 방법은 조금 더 공부하고 오도록 하겠습니다.