BackEnd/JPA

[JPA] 일급 컬렉션

샤아이인 2022. 7. 17. 11:41

 

1. 일급 컬렉션?

일급 컬렉션은 Collection객체를 감싸면서 다른 필드가 없는 클래스를 의미합니다.
특정 클래스에 List나 Set 같은 Collection 필드로 가지고 있을 때, 이들을 하나의 클래스로 만들어서 사용할 수 있습니다!

 

일급 컬렉션이라는 단어는 소트웍스 엔소롤지의 객체지향 생활체조 파트에서 언급이 되었습니다.

규칙 8: 일급 콜렉션 사용

이 규칙의 적용은 간단하다.
컬렉션을 포함한 클래스는 반드시 다른 멤버 변수가 없어야 한다.

각 콜렉션은 그 자체로 포장돼 있으므로 이제 컬렉션과 관련된 동작은 근거지가 마련된 셈이다.

필터가 이 새 클래스의 일부가 됨을 알 수 있다. 필터는 또한 스스로 함수 객체가 될 수 있다.

또한 새 클래스는 두 그룹을 같이 묶는다든가 그룹의 각 원소에 규칙을 적용하는 등의 동작을 처리할 수 있다.

이는 인스턴스 변수에 대한 규칙의 확실한 확장이지만 그 자체를 위해서도 중요하다.
컬렉션은 실로 매우 유용한 원시 타입이다.

더 자세한 글은 향로님의 글 을 읽어보길 권장합니다.

 

요약하면, 일급 컬렉션을 사용하면 다음과 같은 장점들이 생기게 됩니다.

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

 

2. 사용 예시

2-1) 해결해야 하는 상황

우리는 다음과 같은 도메인이 있다고 가정해 봅시다.

 

우리의 목표는 마지막 구간(건대역 - 성수역)을 삭제하는 것이 목표입니다.

이때 다음과 같은 조건을 가지게 됩니다.

 

  • 지하철 노선에 등록된 역(하행 종점역)만 제거할 수 있다. 즉, 마지막 구간만 제거할 수 있다.
  • 지하철 노선에 상행 종점역과 하행 종점역만 있는 경우(구간이 1개인 경우) 역을 삭제할 수 없다.

 

이를 기반으로 테스트 코드를 먼저 작성하면 다음과 같습니다.

1) 2호선을 만들고

2) 구간을 2개 등록한 다음 (강남역 - 건대입구역), (건대입구역 - 성수역)

3) 마지막 구간을 삭제하면, 즉 (건대입구역 - 성수역) 구간을 삭제하면

4) 남은 구간의 사이즈가 1이 될 것입니다. (강남역 - 건대입구역)만 남음

 

이제 Line(노선), Section(구간), Station(역)이라는 class를 만들어보도록 하겠습니다.

 

▶ Line.class

@Entity
public class Line {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String color;

    @OneToMany(mappedBy = "line", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
    private List<Section> sections = new ArrayList<>();

    public Line() {
    }

    public Line(Long id, String name, String color) {
        this.id = id;
        this.name = name;
        this.color = color;
    }
    
    public void addSection(Section section) { sections.add(section); }
    
    // 마지막 구간 삭제 메서드
    public void deleteLastSection(Station station) {
        if (!isValidSize()) {
            throw new SectionException(ErrorCode.CAN_NOT_DELETE_SECTION_EXCEPTION);
        }
        
        Section lastSection = sections.get(getLastIndex());
        
        if (!lastSection.hasDownStation(station)) {
            throw new IllegalArgumentException();
        }
        
        sections.remove(lastSection);
    }
    // 일부 생략...
}

Line에는 Section을 추가하는 메서드와, 마지막 Section을 삭제하는 메서드가 존재합니다.


▶ Section.class

@Entity
public class Section {

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

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "line_id")
    private Line line;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "up_station_id")
    private Station upStation;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "down_station_id")
    private Station downStation;

    private int distance;

    public Section() {

    }

    public Section(Line line, Station upStation, Station downStation, int distance) {
        this.line = line;
        this.upStation = upStation;
        this.downStation = downStation;
        this.distance = distance;
    }

    // 일부 생략...
}

Section은 자신의 상행구간 역(upStaion)과 하행 구간 역(downStation)에 대하여 저장하고 있습니다.

 

▶ Station.class

@Entity
public class Station {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    public Station() {
    }

    public Station(String name) {
        this.name = name;
    }
    
    public Station(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    
    // 일부 생략...
}

Station은 단순하게 역과 관련된 정보만을 저장하고 있습니다.

 

Line은 여러 개의 Section을 가질 수 있습니다. 따라서 List<Section>이라는 자료구조를 통해 Section을 저장하는 상황입니다.

또한 하나의 Section에서는 상행 역, 하행 역 2개를 저장할 수 있습니다.

 

Line class 코드를 보면, 내부에서 검증하는 비즈니스 로직을 처리한 후, 마지막 Section을 삭제하고 있습니다.

public void deleteLastSection(Station station) {
    // 사이즈 검증
    if (!isValidSize()) {
        throw new SectionException(ErrorCode.CAN_NOT_DELETE_SECTION_EXCEPTION);
    }

    Section lastSection = sections.get(getLastIndex());

    // 마지막 하행종점역이 인자로 받은 역과 일치하는지 검증
    if (!lastSection.hasDownStation(station)) {
        throw new IllegalArgumentException();
    }

    // 구간 삭제
    sections.remove(lastSection);
}

여기서 몇 가지 문제점이 있다고 생각됩니다.

 

1. 마지막 구간을 삭제하기 위한 검증이 과연 Line에서 수행할 일인가?

2. 만약 다른 곳에서 Section을 삭제하는 조금은 다른 메서드가 있다면, 검증 로직이 중복되어 들어가야 한다.

 

2-2) 해결 과정

아래와 같이 관련된 로직을 포함하고 있는 자료구조를 만들면 위에서 언급한 문제들이 모두 해결됩니다.

그리고 이런 클래스를 우린 일급 컬렉션이라고 부릅니다.

 

List<Section>을 Sections라는 일급객체로 만들어보겠습니다.

@Embeddable
public class Sections {

    private static final int FIRST_SECTION_INDEX = 0;
    private static final String NOT_EXIST_SECTIONS_EXCEPTION = "삭제할 Sections이 존재하지 않습니다.";
    private static final String NOT_SAME_DOWN_STATION_EXCEPTION = "마지막 구간의 하행종점역이 삭제할 하행종점역과 일치하지 않습니다";

    @OneToMany(mappedBy = "line", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
    private List<Section> sections = new ArrayList<>();

    public void add(Section section) {
        this.sections.add(section);
    }

    public void deleteLastSection(Station station) {
        if (isInValidSize()) {
            throw new IllegalStateException(NOT_EXIST_SECTIONS_EXCEPTION);
        }
        Section lastSection = getLastSection();
        if (lastSection.dontHasDownStation(station)) {
            throw new IllegalStateException(NOT_SAME_DOWN_STATION_EXCEPTION);
        }
        sections.remove(lastSection);
    }

    private boolean isInValidSize() { return sections.size() <= 1; }
    private Section getLastSection() { return sections.get(getLastIndex()); }
    private int getLastIndex() { return sections.size() - 1; }
}

또한 일급컬렉션을 만들 때 @Embeddable을 사용한 것을 확인할 수 있습니다.

 

이를 사용하는 Line은 다음과 같이 코드가 변경되었습니다.

일급컬렉션을 생성하는 것이 전부입니다. 이때 사용하는 쪽 에서는 @Embedded를 사용하면 됩니다.

이렇게 @Embeddable, @Embedded 사용한다고 해서 테이블의 구조가 변경되지는 않습니다.

 

즉, @Embedded를 통해서 동일한 테이블 구조를 가지는 동시에 객체를 분리할 수 있게 됩니다.
@Embeddable을 가진 클래스(Sections)의 멤버는 @OneToMany 등의 어노테이션과 관계없이 @Embbeded에 의해 값 타입이 됩니다.

@OneToMany(mappedBy = "line", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
private List<Section> sections = new ArrayList<>();

 

즉 테이블의 관점에서는 사실상 아래와 동일하다 생각할 것입니다.

@Entity
public class Line {

    @OneToMany(mappedBy = "line", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
    private List<Section> sections = new ArrayList<>();
    
}

 

기존에 Line에서 마지막 Section삭제를 위한 모든 검증 로직이 Sections(일급 컬렉션) 내부로 이동하게 되었습니다.

따라서 Line에서의 삭제는 다음과 같이 일급 컬렉션에게 메시지만 전달하면 됩니다.

 

Sections라는 일급컬렉션을 만들게되면서 List<Section>에 관한 모든 로직들을 Sections Class로 이동시킬 수 있게 되었습니다.
SOLID원칙 중 하나인 단일책임원칙을 만족할 수가 있습니다.

 

이제 Section 리스트와 관련된 모든 로직은 Sections에서 관리하게 된 것이죠!

 

또한 위에서 만든 테스트를 실행할 경우 정상적으로 삭제되는 것을 확인할 수 있습니다.

여기까지 오면 다음 3가지 조건을 충족시켰다고 할 수 있습니다.

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

단순 List<Section>이 아닌 Sections이라는 이름이 있는 컬렉션이 되었으며,

Sections 안에서 상태와 행위를 전부 관리하게 되었고,

Section의 비즈니스와 관련된 로직들을 전부 모아서 비즈니스에 종속적인 자료구조를 만들었습니다.

 

마지막으로 불변객체인지만 확인하면 될 것 같습니다.

우리의 Sections를 다시 한번 살펴봅시다.

이 클래스는 add()와 deleteLastSection() 외에 다른 메서드가 없습니다.


즉, 이 클래스의 사용법은 구간을 추가하거나, 구간을 삭제할 뿐인 거죠.

(private 메서드들은 어차피 외부에서 사용이 불가능하니까 상관없습니다)

직접적으로 List<Section> sections에 접근할 수 있는 방법이 없게되었습니다!!


List라는 컬렉션에 접근할 수 있는 방법이 없기 때문에 값을 변경/추가가 안됩니다.

이렇게 일급 컬렉션을 사용하면, 불변 컬렉션을 만들 수 있습니다.