본문 바로가기
Programming/Database

[JPA] 일급 컬렉션에서 @ManyToOne 사용하기

by peter paak 2021. 3. 13.
728x90

안녕하세요. 오늘은 코드를 작성하던 중 바보같은 생각을 했던 경험을 공유하려고 합니다.

일급 컬렉션은 Collection객체를 감싸면서 다른 변수가 없는 클래스입니다.
특정 클래스에 ListSet같은 Collection 변수를 가지고 있을 때, 이들을 하나의 클래스로 만들어서 사용합니다.
일급 컬렉션에 대한 자세한 내용은 이곳을 참고하시기 바립니다.
일급 컬렉션을 만듦으로서 다음과 같은 이점을 가지게 됩니다.

  1. 비즈니스에 종속적인 자료구조
  2. Collection의 불변성 보장
  3. 상태와 행위를 한곳에서 관리
  4. 이름이 있는 컬렉션

하나씩 검증하도록 하겠습니다.

예를들어 여러 지하철역을 가지는 노선에서 마지막 역을 구하는 테스트를 해보겠습니다.
마지막역을 구할 때는 다음과 같은 조건을 가집니다.

  1. 노선의 역의 개수는 3개 이상이어야 한다
  2. 강남역을 꼭 가져야 한다

테스트를 하기 위해 노선을 가르키는 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메소드에는 앞서 말한 조건들을 검증하고 있습니다.

테이블 구조를 보면 다음과 같습니다.

LineStationline_id외래키로 참조되어 있습니다.
JPA가 익숙하지 않다면 왜 Line은 컬럼에 Station을 가지지 않지? 라는 의문을 가질 수 있습니다.
이는 테이블을 객체의 관점에서 보는 착각에서 부터 비롯됩니다.

객체는 Line이 sections를 참조를 통해 Section객체들을 찾을 수 있습니다.
하지만 테이블은 서로 참조를 가지지 않는 대신 외래키를 통해 조인하여 테이블을 가져올 수 있습니다.
Station이 외래키인 line_idLine의 기본키인 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를 이용하여 일급객체를 다룰 수 있는 것을 보았습니다.

728x90