전략 패턴의 핵심은 정책과 수행을 분리하여 정책을 유연하게 결정하는 것이다. 운영 체제를 듣다가 비슷한 개념을 발견했다. 바로 스케쥴링이다.
스케쥴링에서는 정책과 메커니즘을 분리한다. 정책이란 '무엇이 수행되어야 하는지'에 대한 것이고, 메커니즘은 '어떻게 수행되어야 하는지'에 대한 것이다.
응용 프로그램에서 자식의 스케쥴링 우선순위를 설정하고 싶을 수 있다. 이를 위해서 정책은 '유저 프로세스'에게 주어지고, 이를 실제로 수행하는 메커니즘은 '커널'에 의해 수행된다. 세부적으로는 스케쥴링 알고리즘을 파라미터화하는 방식으로 구현된다는 점만 알아두자.
아무튼, 객체지향의 세계에도 비슷한 개념이 있다. 최근에 발표했던 '빌더와 정적 팩토리 메서드'가 바로 그것이다. 한 가지 재밌는 점은, 여기서는 정책과 메커니즘이 의미하는 바가 다르다는 것이다. 객체지향의 세계에서의 '정책'은 '어떻게 수행되어야 하는지'에 대한 것이고, '메커니즘'은 '실제로 무엇을 수행하는지'에 대한 것이다. 이는 운영체제의 세계와 정반대이다.
위와 비슷하게, '정적 팩토리 메서드'는 객체 생성에 대한 '정책'을 결정한다. 어떻게? 메서드 이름을 통해서 결정한다. 메서드 이름으로 정책을 결정함으로써, 적절한 맥락만 인터페이스로 노출하고, 세부적인 구현은 안쪽으로 캡슐화할 수 있다. 이 정책이란 '빌더에 넘길 인자를 결정'하는 것이다.
그리고 '빌더'는 실제 객체 생성에 대한 '메커니즘'을 수행한다. 빌더는 어떤 인자가 들어오든 그것에 대해 고려하지 않는다. null이 들어오든 아니면 파라미터로 받지 않은 값을 내부적으로 만들어서 할당하든 신경쓰지 않는다. 그저 '정적 팩토리 메서드'의 인자와 메서드 이름에 따른 정책에 따라 결정된 값을 받아서 그 값으로 객체를 생성한다.
왜 정반대일까? 이는 생각해보면 당연하다. 정책과 메커니즘을 조금 더 추상화하여 구분해보자. 이를테면 '변하는 것'과 '변하지 않는 것'이다. 스케쥴링에서는 '변하는 것'이 메커니즘- 즉 스케쥴링 알고리즘이었고, '변하지 않는 것'은 스케쥴링에 의해 수행되는 로직이다. 객체지향의 세계에서는 어떨까. '변하는 것'은 유저가 제공하는 인자와 메서드 이름에 따라 결정되는 값- 정책이다. '변하지 않는 것'은 객체가 생성되는 방식(생성자, 빌더)- 메커니즘이다.
변하는 것과 변하지 않는 것이 있다. 이 둘이 한데 뭉쳐있는 곳에서 변하는 것들을 추출하여, 실제 변경에 대하여 유연하게 대처할 수 있게 만드는 것이 좋다. 이는 특정한 패러다임에만 해당되는 것이 아닌, 일반적인 원칙이라 할 수 있겠다.
대기 정보를 나타내는 wating 엔티티에 순서를 지정하는 order 칼럼이 존재한다고 하자. 새로운 엔티티를 생성하기 위해서는 A 테이블의 order 칼럼 중 가장 큰 값을 찾아서, 이 값이 1을 더한 뒤, 그 값을 order로 지정하여 저장해야 한다. 이를 위해서, 순서를 결정하는 determineOrder
메서드를 정의할 수 있다. 이 메서드는, 적절히 값을 찾아서 +1을 한 뒤 반환하는데, 값을 찾았다면 그대로 / 값을 찾지 못했다면 0을 반환하여 처음 추가되는 엔티티가 1번째 순서를 가지도록 하는 역할을 수행한다.
요약하자면,
- 적절한 값을 찾아와서
- 널이면 0으로 지정한 뒤
- 찾아온 값에 +1을 하여 반환한다.
이를 구현하기 위해서 두 가지 방법을 고려할 수 있다.
첫번째. QueryDSL을 사용하여 모든 로직(1, 2, 3)을 레포지터리 레이어에 몰아넣는 것이다.
// WaitingRepositoryImpl.java
@Override
public Integer determineNextOrder() {
return queryFactory.select(waiting.order
.coalesce(0) // null 이라면 0으로 지정
.max() // 최댓값을 찾고
.add(1)) // 1을 더함
.from(waiting)
.fetchOne();
}
두번째. 1은 레포지터리 레이어에서, 2, 3은 서비스 레이어에서 수행하는 것이다. 이때 1에는 복잡한 조회 로직이 없으므로 QueryDSL을 사용하지 않고, Spring Data JPA의 쿼리 메서드를 사용할 수 있다.
// WaitingService.java
private Integer determineNextOrder() {
return waitingRepository.findTopOrderByOrderDesc()
.map(Waiting::getOrder)
.map(order -> order + 1)
.orElseGet(() -> 1);
}
먼저 서비스 레이어와 레포지터리 레이어의 역할과 책임의 관점에서 생각해볼 수 있다. 레포지터리 레이어란 본래 도메인 레이어와 데이터 엑세스 레이어 사이에서 엔티티 객체를 사용하여 쿼리를 수행할 수 있는 인터페이스를 제공하는 역할을 한다. 이러한 맥락에서 봤을 때, 전자의 방식에서는 엔티티가 아닌 특정한 테이블의 필드를 쿼리해오기 때문에, 적절하지 않다고 할 수 있다.
비슷한 사례로 나오는 것이 DTO 직접 조회이다. DTO 직접 조회의 가장 큰 단점은 레포지터리의 메서드가 변경 가능성이 높은 DTO에 의존하게 된다는 것이다. 순서나 DTO나 결국은 엔티티가 아닌 엔티티의 일부를 조작했다는 점에서 비슷한 문제를 가진다. 다음 순서를 결정하는 로직이 SQL 문으로 결정할 수 없게 된다면 어떻게 바꿔야 할까? 후자의 방식보다 더 큰 변화가 발생할 것이다.
하지만 DTO 직접 조회가 하나의 방법이 될 수 있듯이 레포지터리 레이어에서 모든 로직을 수행하는 것이 아예 장점이 없는 것은 아니다. 가장 큰 장점이라 하면, '필요한 필드만 조회해온다'. 전자의 코드에서는 엔티티의 모든 필드를 조회해오지만, 후자의 코드에서는 순서를 결정하는 데 필요한 필드만 조회해온다. 이는 성능상의 이점을 가져올 수 있다.
또 가장 의견이 갈릴 수 있는 부분이 coalesce
와 add(1)
라고 생각한다. 둘 다 코드로 구현할 수도, QueryDSL을 사용하여 SQL로 구현할 수도 있다.
답은... 모르겠다. 특히 현재 상황에서는 어느 쪽을 선택하든 상관없다. 메서드 시그니쳐나 내부 구현이 변경될 가능성이 존재하면 엔티티를 베이스로 작업하는 쪽을 선호하고, 변경 가능성이 거의 없다면 SQL로 작업하는 쪽을 선택할 것 같다. 아니면 엔티티 사이즈가 너무 커서 SQL로 가져오는 쪽이 더 성능 상 이점이 있는 경우를 생각해볼 수도 있겠고.
되도록이면 엔티티를 사용하는 편이다. 열과 행으로 이루어진 데이터베이스의 값들을 유의미한 행위를 가지는 객체로 다루고 싶어서 나온 것이 ORM이다. 도메인 모델이 존재하는 영역인 서비스 레이어와 레포지터리 레이어는, 일종의 데이터베이스 세계 안에 ORM으로 구현된 객체지향의 세계와 같다. 이 객체지향 세계는 데이터베이스 세계를 전제로 하기 때문에 완전한 객체지향 세계에서 가능했던 것들이 불가능할 수 있다. 가령 일대다로 가져온 엔티티 리스트의 원소들은 연관관계의 주인이 아니기 때문에 수정해서는 안된다. 혹은, 엔티티의 크기가 너무 큰 경우에는 엔티티를 사용하는 것보다 일부 필드만 가져오는 것이 더 효율적일 수도 있다.
반대로 말하자면, 이러한 제약사항의 경계를 만나지 않는 한 우리는 객체지향의 세계에서 가능한 이점들을 극대화해야 할 필요가 있다. 필드의 수가 그리 많지도 않고 DTO를 직접 조회하는 것이 '극히 미미한' 수준의 성능만을 개선한다면, 데이터베이스 세계의 제약사항이라고 보기 어렵다. 충분히 객체로 다룰 수 있는 것이다. 그리고, 객체로 다루게 되는 순, 우리는 유지보수하기 쉽고, 변화에 유연하게 대응할 수 있는 코드를 만들 수 있게 된다.
+ 글을 쓰고 다른 사람들의 의견을 물어봤는데 이런 말들이 오고갔다.
- 조회에는 로직이 없어야 하므로 서비스 레이어가 맞다
- 변경 가능성이 없다면 레포지터리 레이어, 있다면 서비스 레이어
- 대부분 서비스 레이어 쪽을 골랐다.
이중 stream -> hashmap을 dto로 사용할 수 없을까?