BackEnd/JPA

[JPA] 일급 컬렉션

샤아이인 2022. 7. 17.

 

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라는 컬렉션에 접근할 수 있는 방법이 없기 때문에 값을 변경/추가가 안됩니다.

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

댓글