안녕하세요. 오늘은 코드를 작성하던 중 바보같은 생각을 했던 경험을 공유하려고 합니다.
일급 컬렉션은 Collection객체를 감싸면서 다른 변수가 없는 클래스입니다.
특정 클래스에 List
나 Set
같은 Collection
변수를 가지고 있을 때, 이들을 하나의 클래스로 만들어서 사용합니다.
일급 컬렉션에 대한 자세한 내용은 이곳을 참고하시기 바립니다.
일급 컬렉션을 만듦으로서 다음과 같은 이점을 가지게 됩니다.
- 비즈니스에 종속적인 자료구조
- Collection의 불변성 보장
- 상태와 행위를 한곳에서 관리
- 이름이 있는 컬렉션
하나씩 검증하도록 하겠습니다.
예를들어 여러 지하철역을 가지는 노선에서 마지막 역을 구하는 테스트를 해보겠습니다.
마지막역을 구할 때는 다음과 같은 조건을 가집니다.
- 노선의 역의 개수는 3개 이상이어야 한다
- 강남역을 꼭 가져야 한다
테스트를 하기 위해 노선을 가르키는 Line
과 지하철 역을 가르키는 Station
을 만들어 보겠습니다.
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Line {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@OneToMany(mappedBy = "line", cascade = PERSIST)
private List<Station> stations = new ArrayList<>();
public Station getLastStation() {
validSize();
hasValidName();
return stations.get(stations.size() - 1);
}
private void validSize() {
if(stations.size() < 3) {
throw new IllegalArgumentException("역의 길이는 3보다 작을 수 없습니다");
}
}
private void hasValidName() {
if(!hasValidStation()) {
throw new IllegalArgumentException("강남역을 반드시 포함해야 합니다");
}
}
private boolean hasValidStation() {
return stations.stream()
.anyMatch(station -> station.getName().equals("강남역"));
}
}
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Station {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@ManyToOne
private Line line;
private String name;
public Station(String name) {
this.name = name;
}
}
Line
은 여러개의 Station
을 가질 수 있습니다.
그렇기 때문에 List
라는 자료구조를 통해 표현할 수 있었습니다.
마지막 역을 구하는 getLastStation
메소드에는 앞서 말한 조건들을 검증하고 있습니다.
테이블 구조를 보면 다음과 같습니다.
Line
은 Station
의 line_id
외래키로 참조되어 있습니다.
JPA가 익숙하지 않다면 왜 Line은 컬럼에 Station을 가지지 않지? 라는 의문을 가질 수 있습니다.
이는 테이블을 객체의 관점에서 보는 착각에서 부터 비롯됩니다.
객체는 Line
이 sections를 참조를 통해 Section
객체들을 찾을 수 있습니다.
하지만 테이블은 서로 참조를 가지지 않는 대신 외래키를 통해 조인하여 테이블을 가져올 수 있습니다.Station
이 외래키인 line_id
로 Line
의 기본키인 id
값을 가지고 있습니다.
이 외래키로 조인하여 원하는 Line
테이블을 찾을 수 있습니다.
저는 이 패러다임을 잊어버리고, 왜 Line
에는 Section
의 아이디가 없지 라는 오류를 계속 범하고 있었습니다.Station
처럼 다(Many) 를 가르키는 테이블은 외래키를 가지고 있습니다.
그래서 Station
객체는 @ManyToOne
를 가지게 되고 연관관계의 주인이 됩니다.
반대로 Line
은 일(One) 을 가르키므로 아무런 참조를 가지지 않습니다.
그러므로 테이블에 Station
과 관련된 내용이 없는 것은 당연하게 됩니다.
이 사실을 기억하면서 테스트를 해보겠습니다.
테스트 코드는 다음과 같습니다.
@DisplayName("지하철 노선 관련 테스트")
@DataJpaTest
class LineTest {
@Autowired
private EntityManager em;
@DisplayName("지하철 노선 마지막 역 조회 테스트")
@Test
void getLastStation() {
Station 강남역 = new Station("강남역");
Station 양재역 = new Station("양재역");
Station 판교역 = new Station("판교역");
Line 신분당선 = new Line(Arrays.asList(강남역, 양재역, 판교역));
em.persist(신분당선);
assertThat(신분당선.getLastStation()).isEqualTo(판교역);
}
}
테스트가 잘 동작함을 알 수 있습니다.
@Embedded로 리펙터링
이제 List<Station>
을 Stations
라는 일급객체로 만들어보겠습니다.
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Line {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Embedded
private Stations stations;
public Station getLastStation() {
return stations.getLastStation();
}
public Line(Stations stations) {
this.stations = stations;
}
}
@Embeddable
@NoArgsConstructor
@Getter
public class Stations {
@OneToMany(mappedBy = "line", cascade = PERSIST)
private List<Station> stations = new ArrayList<>();
public Station getLastStation() {
validSize();
hasValidName();
return stations.get(stations.size() - 1);
}
private void validSize() {
if(stations.size() < 3) {
throw new IllegalArgumentException("역의 길이는 3보다 작을 수 없습니다");
}
}
private void hasValidName() {
if(!hasValidStation()) {
throw new IllegalArgumentException("강남역을 반드시 포함해야 합니다");
}
}
private boolean hasValidStation() {
return stations.stream()
.anyMatch(station -> station.getName().equals("강남역"));
}
public Stations(List<Station> stations) {
this.stations = stations;
}
}
Stations
라는 일급객체를 만듦으로서 List<Station>
에 관한 모든 로직들을 Stations
클래스로 넣을 수 있게 되었습니다.
객체지향 5원칙 중 하나인 모든 클래스는 하나의 책임을 가진다를 만족할 수가 있습니다.
이제 Station
리스트와 관련된 모든 로직은 Stations
에서 관리할 수 있습니다.Line
처럼 멤버 수가 늘어나고 로직이 복잡해질 것이 예상되는 경우에 가독성을 높힐 수 있는 방법이 됩니다.
다시 테스트를 돌려보면 같은 결과를 얻을 수 있습니다.
그렇다면 데이터베이스에는 변화가 있을까요?
데이터베이스 확인
리펙토링 전과 후가 같습니다. 즉, @Embedded
를 통해서 동일한 테이블 구조를 가지는 동시에 객체를 분리할 수 있게됩니다.@Embbedable
을 가진 클래스의 멤버는 @OneToMany
등의 어노테이션과 관계없이 @Embbeded
에 의해 값타입이 됩니다.
정리를 하면 테이블의 관점에서 보았을 때 아래는 같다고 볼 수 있습니다.
@Entity
public class Line {
@OneToMany(mappedBy = "line", cascade = PERSIST)
private List<Station> stations = new ArrayList<>();
}
@Entity
public class Line {
@Embedded
private Stations stations;
}
@Embeddable
public class Stations {
@OneToMany(mappedBy = "line", cascade = PERSIST)
private List<Station> stations = new ArrayList<>();
}
정리
Jpa에서도 @Embeddable
과 @Embedded
를 이용하여 일급객체를 다룰 수 있는 것을 보았습니다.