스트림의 forEach를 이용해서 로직을 수행하게 되면 가독성도 떨어지고 동시성을 보장할 수 없다.
또한, Stream은 부수 효과가 없는 함수형 프로그래밍을 위해 만들어진 것이므로 Stream의 의도와도 맞지 않다.
이펙티브 자바 아이템 46에서도 부작용 없는 순수 함수를 사용하라고 이야기하고 있다.
1. 기존 코드
문제의 코드를 보고 개선해보자.
private final Map<HandlerKey, HandlerExecution> handlerExecutions;
public void initialize() {
...
final var methods = clazz.getMethods();
Arrays.stream(methods)
.forEach(method -> putHandlerExecutions(handler, method));
}
private void putHandlerExecutions(final Object handler, final Method method) {
final var annotation = method.getDeclaredAnnotation(RequestMapping.class);
final var handlerExecution = new HandlerExecution(handler, method);
Arrays.stream(annotation.method())
.map(requestMethod -> new HandlerKey(annotation.value(), requestMethod))
.forEach(handlerKey -> handlerExecutions.put(handlerKey, handlerExecution));
}
putHandlerExecutions에서는 annotation.method() 배열의 원소 만큼
private final Map<HandlerKey, HandlerExecution> handlerExecutions;
public void initialize() {
final var methods = clazz.getMethods();
final var executionMap = createHandlerExecutionMap(handler, methods);
handlerExecutions.putAll(executionMap);
}
Map<HandlerKey, HandlerExecution>을 생성하는 createHandlerExecutionMap으로 메서드로 추출하여 해당 메서드에서 Map을 완성한다.
요소인 method마다 toHandlerExecutions(handler, method)를 통해 Map<HandlerKey, HandlerExecution>으로 만들어준다.
해당 Map이 여러개이기 때문에 flatMap으로 평탄화 하고 모든 Entry에 대한 스트림을 다시 만들어 준다.
결과의 key와 value를 다시 Map으로 collect 한다.
toHandlerExeutions는 다음과 같이 단순하게 파라미터를 통해 HandlerKey와 HandlerExecution을 만들어서 Map으로 맵핑해준다.
private Map<HandlerKey, HandlerExecution> toHandlerExecutions(
final Object handler, final Method method) {
final var annotation = method.getDeclaredAnnotation(RequestMapping.class);
final var handlerExecution = new HandlerExecution(handler, method);
return Arrays.stream(annotation.method())
.map(requestMethod -> new HandlerKey(annotation.value(), requestMethod))
.collect(Collectors.toMap(Function.identity(), handlerKey -> handlerExecution));
}
결론
스트림으로 만들기 어렵다고 무작정 forEach로 해결하거나 for-loop로 해결하기 보다 고민을 조금 해보면 충분히 해결할 수 있다. 위 방법은 여러개의 Map을 만들고 이를 합치는 과정을 통해 해결했다.
이 방식 이외에 method 원소 마다 createHandlerExecutionMap에서 Map을 생성하여 합치는 방식이 아닌 createHandlerExecutionMap에서 Stream<AbstractMap.SimpleEntry<HandlerKey, HandlerExecution>> 즉, 엔트리의 스트림을 반환하여 스트림의 중간 연산으로 활용할 수도 있다.
하지만 이러한 방법은 복잡도가 증가하고 Map의 key에 대한 중복 처리가 어떻게 될지 예상이 되지 않는다.
Map을 생성하여 합치는 방식보다 메모리가 효율적이기는 하나 그 정도까지 고려할 필요는 없다고 생각한다.
A persistence context is a set of entity instances in which for any persistent entity identity there is a unique entity instance. Within the persistence context, the entity instances and their lifecycle are managed.
1. 각각의 영속적인 엔티티 식별자에 대해 유일한 엔티티 인스턴스가 있는 엔티티 인스턴스의 집합이다. 2. 영속성 컨텍스트 내에서, 엔티티 인스턴스들과 그들의 생명주기가 관리된다.
1. 의 내용을 풀어서 말하면
컨텍스트 내에서 엔티티끼리 고유하게 식별하게 하는 id를 가지고, 동일한 id를 가진 엔티티가 존재할 수 없다.
JdbcTemplate을 사용하던 시절로 돌아가 다음 코드를 보자.
@Test
void findById() {
Member insert = memberDao.insert(new Member("maco", "pass"));
Member member1 = memberDao.findById(insert.getId()).get();
Member member2 = memberDao.findById(insert.getId()).get();
System.out.println(member1);
System.out.println(member2);
System.out.println(member1 == member2);
}
위 코드에서 member1과 member2는 같은 insert.getId()를 이용하여 DB를 조회했기 때문에 같은 데이터를 가진다.
public Order save(final Long memberId) {
final List<CartItem> cartItems = cartItemService.findAllByMemberId(memberId);
final Order order = Order.createFromCartItems(cartItems, memberId);
cartItemService.deleteByMemberId(memberId);
return orderRepository.save(order);
}
첫 줄의 사용자에 해당하는 장바구니 아이템을 불러오는 코드는 괜찮지만
cartItemService.deleteByMemberId는 cartItem의 데이터를 직접 변경시키기 때문에 OrderService의 책임을 벗어난다고 볼 수 있다.
OrderService는 주문을 하는 데에만 관심이 있고, 주문을 함으로써 일어나는 side-effect까지 OrderService가 책임지는 것은 당장은 괜찮아 보이지만 side-effect가 많아지면 이를 관리하기 어려울 수 있다.
예를 들어서, 주문을 했을 때 사용자의 장바구니에 담긴 상품도 삭제하고, 점주에게 알림 메시지를 보내고, 가게에 수수료를 부과한다고 가정해보자.
그렇다면 코드가 다음과 같이 변경될 것이다.
public Order save(final Long memberId) {
final List<CartItem> cartItems = cartItemService.findAllByMemberId(memberId);
final Order order = Order.createFromCartItems(cartItems, memberId);
cartItemService.deleteByMemberId(memberId);
shopService.sendOrderMessage(order); // 가게에 주문 메시지를 보냄
commissionService.charge(order); // 가게에 수수료를 부과함
return orderRepository.save(order);
}
이렇게 주문을 함으로써 일어나는 side-effect들이 절차지향적으로 쌓이고, 주문 로직이 변경되지 않더라도 OrderService가 변경되어야 할 것이다. (서비스간의 결합도가 높다는 문제도 있다.)
이는, OrderService의 변경의 원인이 하나가 아니게 되어 SRP를 위반하고,
주문 외 기능이 확장되었을 때 OrderService에 변경이 생긴다.
이 문제를 해결하기 위해 스프링의 이벤트를 사용할 수 있다.
이벤트란?
스프링에서 디자인 패턴의 옵저버 패턴을 구현한 하나의 방법.
이벤트를 발행하면(notify), 이를 구독하고 있는 Observer들이 이벤트를 전달받고, 각자의 로직을 수행한다.
스프링의 이벤트는 다음과 같이 3가지 요소로 이루어져 있다.
1. 이벤트를 발행하는 Publisher
2. 이벤트 발행시 전달되는 이벤트 객체
3. 이벤트를 전달받는 EventListener
이벤트 객체는 스프링 4.2 버전 이전에는 ApplicationEvent를 상속한 객체만 가능했지만 그 이후에는 모든 객체를 이벤트 객체로 사용할 수 있다.
하지만, 도메인 객체를 직접 이벤트 객체로 사용하기 보다 이벤트가 발생한 시점의 도메인 객체의 스냅샷을 이벤트 객체로 사용하는 것이 좋다. 다음 코드에서는 `OrderedEvent.from()`에서 Order의 스냅샷을 저장하여 이벤트를 발행하도록 했다.
public Order save(final Long memberId) {
final List<CartItem> cartItems = cartItemService.findAllByMemberId(memberId);
final Order order = Order.createFromCartItems(cartItems, memberId);
applicationEventPublisher.publishEvent(OrderedEvent.from(order)); // 이벤트 발행
return orderRepository.save(order);
}
OrderedEvent는 스냅샷을 저장하고 있는 DTO라고 볼 수 있다.
OrderService는 주문을 생성하여 저장하고, 주문을 완료했다는 이벤트만 발행하고 있다.
이 이벤트를 Listener를 구현하여 이벤트가 발행되면 그 이후의 연관되는 작업을 수행할 수 있다.
@Component
public class OrderedEventHandler {
private final CartItemService cartItemService;
public OrderEventHandler(CartItemService cartItemService) {
this.cartItemService = cartItemService;
}
@EventListener
public void deleteCartItems(OrderedEvent event) {
cartItemService.deleteByMemberId(event.getMemberId());
}
// 가게에 메시지를 보내는 리스너
// 가게에 수수료를 부과하는 리스너
}
주문이 발생했을 때 수행되는 추가적인 작업들을 이 Listener에 모두 구현하면 된다.
OrderService는 주문과 이벤트를 발행만 함으로써 주문에 따른 side-effect에 대한 책임을 eventListener에게 넘겨 SRP를 만족 하고, 서비스간의 결합도를 줄일 수 있었다.
이 글에서 작성한 코드는 Spring framework를 기반으로 작성했으며 디자인 패턴 중 composite 패턴에 대한 이해가 있어야 읽기 쉬움을 알립니다.
지하철 미션을 진행하면서 거리에 따라 다른 요금 정책을 적용해야 했다.
요금 계산 방법
기본운임(10㎞ 이내): 기본운임 1,250원
이용 거리 초과 시 추가운임 부과
10km~50km: 5km 까지 마다 100원 추가
50km 초과: 8km 까지 마다 100원 추가
if문으로 쉽게 구현할 수 있지만 다음과 같은 확장의 가능성을 생각하면 수정이 어려울 것이다.
거리별 요금 정책이 추가, 수정되는 경우
거리별 정책 이외에 노선별, 승객의 나이별 요금 정책이 추가되는 경우
확장 가능성을 고려하여 객체를 분리하여 요금을 계산해보도록 하자.
composite 패턴
모든 요금 정책을 적용해서 요금을 계산하는 FareCalculator가 각 정책을 의존하고 있다. 현재는 거리별 정책밖에 없기 때문에 DistancePolicy만 의존하고 있다.
DistancePolicy는 Leaf와 Composite의 공통 인터페이스인 Component이다
각 요금 정책을 가지고 있는 구현체들(BasicFarePolicy, FiveUnitFarePolicy, EightUnitFarePolicy)는 leaf로 DistancePolicy를 완성하는 부분이다.
DistanceFare는 DistancePolicy의 여러 구현체들을 가지고 전체를 구성하는 Composite이다.
코드로 작성해보자.
다음은 Component인 DistancePolicy이다. 각 leaf와 composite가 수행해야 하는 기능을 명세하고 있다.
public interface DistancePolicy {
int calculate(int distance);
}
각 요금 정책에 대한 구현체들이다.
@Component
public class BasicFarePolicy implements DistancePolicy {
private static final int BASIC_FARE = 1250;
@Override
public int calculate(final int distance) {
return BASIC_FARE;
}
}
기본 요금으로 무조건 1250원을 반환한다.
@Component
public class FiveUnitFarePolicy implements DistancePolicy {
private static final int UNIT = 5;
private static final int FARE = 100;
private static final int MIN_CALCULATE_RANGE = 10;
private static final int MAX_CALCULATE_RANGE = 50;
@Override
public int calculate(final int distance) {
if (distance >= MAX_CALCULATE_RANGE) {
return (int) ((Math.ceil((MAX_CALCULATE_RANGE - MIN_CALCULATE_RANGE - 1) / UNIT) + 1) * FARE);
}
if (distance <= MIN_CALCULATE_RANGE) {
return 0;
}
return (int) ((Math.ceil((distance - MIN_CALCULATE_RANGE - 1) / UNIT) + 1) * FARE);
}
}
5km의 거리마다의 요금을 계산하는 클래스이다. 거리가 10km~50km 사이인 부분만 계산한다.
@Component
public class EightUnitFarePolicy implements DistancePolicy {
private static final int UNIT = 8;
private static final int FARE = 100;
private static final int MIN_CALCULATE_RANGE = 50;
@Override
public int calculate(final int distance) {
if (distance <= MIN_CALCULATE_RANGE) {
return 0;
}
return (int) ((Math.ceil((distance - MIN_CALCULATE_RANGE - 1) / UNIT) + 1) * FARE);
}
}
8km 거리마다의 요금을 계산한다. 거리가 50km 초과인 부분만 계산한다.
다음은 Composite이다. Leaf인 DistancePolicy의 리스트를 가지고 있다. 위에서 정의했던 각 Policy들을 리스트에 초기화해준 다음 calculate에서 bulk연산을 수행한다.
public class DistanceFare implements DistancePolicy {
private final List<DistancePolicy> distancePolicies;
public DistanceFare(final List<DistancePolicy> distanceChains) {
this.distancePolicies = distanceChains;
}
@Override
public int calculate(final int distance) {
return distancePolicies.stream()
.mapToInt(distancePolicy -> distancePolicy.calculate(distance))
.sum();
}
}
각 Policy들이 자신의 거리에 해당하는 부분만 계산하고 있기 때문에 결과를 모두 더해주면 거리별 요금 정책이 적용된 결과를 얻을 수 있다.
다음은 모든 Composite를 초기화하는 Configuration클래스이다. Leaf들을 @Component로 빈으로 등록했기 때문에 생성자 주입으로 Leaf의 리스트를 초기화한다.
@Configuration
public class FareConfiguration {
private final List<DistancePolicy> distancePolicies;
public FareConfiguration(final List<DistancePolicy> distancePolicies) {
this.distancePolicies = distancePolicies;
}
@Bean
public DistancePolicy distancePolicy() {
return new DistanceFare(distancePolicies);
}
}
완성된 Composite를 빈으로 등록한다.
최종으로 모든 요금 정책을 적용하여 요금을 계산하는 FareCalculator에서는 빈으로 등록했던 Composite를 의존성 주입 받아서 사용한다.
@Component
public class FareCalculator {
private final DistancePolicy distancePolicy;
public FareCalculator(final DistancePolicy distancePolicy) {
this.distancePolicy = distancePolicy;
}
public int calculate(final int distance) {
return distancePolicy.calculate(distance);
}
}
결론
composite 패턴을 사용하여 거리별 요금 계산이라는 알고리즘을 추상화할 수 있었다.
bulk연산을 하는 Composite도 같은 DistancePolicy를 구현하기 때문에 bulk 연산까지 추상화하여 알고리즘을 감출 수 있다.
DistancePolicy 인터페이스에 의존하고 있기 때문에 거리별 요금 정책이 추가되거나 변경되더라도 기존의 코드는 변경이 없고, 각 Leaf를 수정하거나 추가해주면 되기 때문에 변경에 닫혀있고 확장에 열려있다.(OCP)
FareCalculator에서 DistancePolicy이외에 다른 요금 정책이 추가되더라도 해당 Componet를 추가하여 사용하면 쉽게 중첩으로 요금 정책을 적용할 수 있을 것이다.
장바구니 미션을 진행하면서 작성한 테스트 중에서 애플리캐이션 컨텍스트를 이용하는 테스트를 3가지 작성했다. Controller를 테스트하는 @WebMvcTest, Dao를 테스트하는 @JdbcTest, RestAssured를 이용하여 E2E 테스트를 진행하는 @SpringBootTest를 만들었다.
테스트를 돌려보면 intellij에 측정되는 테스트 수행 시간은 6초로 매우 짧다.
하지만 이는 컨텍스트 로딩을 제외한 시간이며 실제로는 6초보다 훨씬 많이 걸린다.
profiler를 통해 측정한 테스트 시간에서 컨텍스트 로딩을 포함한 시간은 43초나 된다. 로직을 수행하는 데 6초 정도밖에 걸리지 않지만 애플리케이션 컨텍스트를 로딩하는데 너무 많은 시간을 소요하고 있다. 당장은 기다릴만 하지만 애플리케이션이 커지고 테스트도 많아지면 이를 감당하기 힘들 것이며 변경이 반영될 때마다 테스트 코드를 실행하기 두려워질 것이다.
스프링 공식문서에서는 테스트가 같은 설정을 공유한다면 시간이 많이 소요되는 컨텍스트 로딩이 한 번만 발생한다고 설명하고 있다.
Spring’s test framework caches application contexts between tests. Therefore, as long as your tests share the same configuration (no matter how it is discovered), the potentially time-consuming process of loading the context happens only once.
즉, 컨텍스트 설정을 같게 만들어주면 캐싱된 컨텍스트를 계속 재사용하여 컨텍스트 로딩 횟수를 줄이고, 테스트 작동 시간을 줄일 수 있다.
코드를 확인하며 컨텍스트 설정인 빈을 설정하는 부분을 설정 파일로 분리하고 이를 공유한다면 같은 컨텍스트를 사용할 수 있을 것이다.
자바에서 사용할 수 있는 라이브러리와 프레임워크 중 사용 방법과 형식은 다르지만 기능과 목적이 유사한 기술이 여럿 존재한다. 스프링은 다른 라이브러리나 프레임워크에 대한 추상화를 제공함으로써 결합을 줄이고 코드의 이식성을 높인다.
트랜잭션 구현하기
우리가 사용하는 @Transactional은 AOP와 PSA 를 통해 구현되어 있다. 트랜잭션을 직접 구현하면서 PSA가 무엇인지 어떤 장점이 있는지 직접 느껴보도록 하자.
컨트롤러에서 RequestBody로 UserDto를 전달받아 서비스를 실행한다고 해보자.
@PostMapping("/user")
public void saveUser(@RequestBody UserDto userDto) {
userService.saveTwoUsers(userDto);
}
다음은 서비스의 saveTwoUsers()의 코드이다.
public void saveTwoUsers(UserDto userDto) {
User user1 = new User(userDto.getName(), userDto.getAge());
userDao.insert(user1);
User user2 = new User(userDto.getName(), userDto.getAge() + 1);
userDao.insert(user2);
}
위 메서드의 기능은 다음과 같다.
name과 age를 그대로 가지고 있는 user1을 저장한다.
name과 age + 1을 가지고 있는 user2를 저장한다.
만약 위 메서드에서 user1저장과 user2 저장이 atomic 해야 한다고 해보자. 쉽게 말해, user1과 user2가 모두 저장되거나, 하나라도 실패하면 롤백하여 모두 저장되지 않도록 한다. 이를 위해 트랜잭션으로 묶어줄 수 있다.
다음과 같이 Jdbc에서 제공하는 DataSourceUtils를 이용해 구현할 수 있다.
public void saveTwoUsers(UserDto userDto) throws Exception {
TransactionSynchronizationManager.initSynchronization();
Connection c = DataSourceUtils.getConnection(dataSource);
c.setAutoCommit(false);
try {
User user1 = new User(userDto.getName(), userDto.getAge());
userDao.insert(user1);
User user2 = new User(userDto.getName(), userDto.getAge() + 1);
userDao.insert(user2);
c.commit();
} catch (Exception e) {
c.rollback();
throw e;
} finally {
DataSourceUtils.releaseConnection(c, dataSource);
TransactionSynchronizationManager.unbindResource(dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
트랜잭션 경계를 설정하고, 커넥션을 받아와서 autoCommit을 하지 않도록 설정하고, 트랜잭션을 수행하고, 성공하면 커밋, 실패하면 롤백을 하고 있다.
이 예제에서는 쉽게 테스트하기 위해 Dao에서 전달받은 age가 20 이상이면 예외를 발생시키도록 설정했다.
따라서 age에 19를 넣으면 19와 20을 가진 user 모두 db에 저장될 것이다.
{
"name" : "maco",
"age" : "19"
}
db를 확인해 보면 user1과 user2 모두 저장된 것을 확인할 수 있었다.
다음으로 age에 20을 넣으면 age가 20인 user1 저장은 성공하고, age가 21인 user2 저장은 실패할 것이다. user2가 실패하면 트랜잭션이 롤백되어 db에 아무런 값도 저장되지 않을 것이다.
{
"name" : "maco",
"age" : "20"
}
트랜잭션이 롤백되어 db의 상태가 그대로인 것을 확인할 수 있다.
트랜잭션으로 묶어주는 데 성공하기는 했다. 코드를 조금 간략하게 다시 보자.
public void saveTwoUsers(UserDto userDto) throws Exception {
TransactionSynchronizationManager.initSynchronization();
Connection c = DataSourceUtils.getConnection(dataSource);
c.setAutoCommit(false);
try {
// 비즈니스 로직 수행
c.commit();
} catch (Exception e) {
c.rollback();
throw e;
} finally {
DataSourceUtils.releaseConnection(c, dataSource);
TransactionSynchronizationManager.unbindResource(dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
TransactionSynchronizationManager.initSynchronization() 을 실행하면 트랜잭션 동기화 기능을 사용할 때 필요한 초기화 작업을 수행한다.
이후에 직접 커넥션을 얻어와서 autoCommit(false)를 설정해주고 있다. 트랜잭션이 끝날 때에도 사용한 커넥션을 반환하고, 바인딩했던 리소스인 dataSource를 해제하고, 해당 트랜잭션과 관련된 동기화 객체를 제거한다.
이렇게 트랜잭션을 동기화하기 위한 여러 설정들을 개발자가 직접 컨트롤하는 것은 매우 어렵다.
이 작업을 추상화된 TransactionManager를 통해 더 쉽게 수행할 수 있다.
@Service
public class UserService {
private final UserDao userDao;
private final PlatformTransactionManager transactionManager;
public UserService(UserDao userDao, PlatformTransactionManager transactionManager) {
this.userDao = userDao;
this.transactionManager = transactionManager;
}
public void saveTwoUsers(UserDto userDto) {
TransactionStatus status =
transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
User user1 = new User(userDto.getName(), userDto.getAge());
userDao.insert(user1);
User user2 = new User(userDto.getName(), userDto.getAge() + 1);
userDao.insert(user2);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
}
직접 커넥션과 트랜잭션 동기화 작업을 해주는 것이 아니라, 트랜잭션을 얻어오고, 트랜잭션을 커밋 또는 롤백하고 있다. 트랜잭션 관리가 훨씬 쉬워진 것을 느낄 수 있다.
위에서 사용하고 있는 PlatformTransactionManager는 여러 TxMnagaer의 추상화된 인터페이스다. 따라서 런타임에 적절한 TxManager가 DI 될 것이고 사용하는 DB가 바뀌더라도 OCP를 지킬 수 있다.
다음은 TransactionManager의 계층 구조 이해를 돕기 위한 클래스 다이어그램이다.
결론
트랜잭션을 얻는 과정을 경계 설정, 커넥션 관리, 리소스 관리를 모두 개발자가 관리할 수 있다. 하지만 이런 과정은 트랜잭션을 관리한다는 목적이지만 구현하기 복잡하고 사용하는 기술마다 구현 방법이 다르다. 트랜잭션 관리를 추상화하여 TransactionManager으로 더 쉽게 트랜잭션을 관리할 수 있다. DB마다 다른 TransactionManager를 한 번 더 추상화한 PlatformTransactionManager을 사용하여 DB가 바뀔 때 마다 다른 구현체를 DI 받아 사용할 수 있다.
이렇게 스프링은 다른 api와의 경계선을 인터페이스로 추상화하여 Portable 하게 서비스를 교체해서 사용하도록 되어있다. 이를 PSA(Potable Service Abstraction)이라고 한다. 추상화된 인터페이스에 의존하고 DI를 사용함으로써 OCP를 지켜 유지보수를 향상시킨다.
스프링은 PSA를 통해 트랜잭션을 추상화하여 쉽게 사용하게 하고, 추상화된 트랜잭션 조차 AOP를 통해 트랜잭션 경계를 설정하는 등의 트랜잭션 관련 코드를 숨겨 트랜잭션을 쉽게 사용하고 비즈니스 로직에 집중할 수 있다.
스프링의 @Repository 의 주석을 확인해 보면 repository에 대한 정의를 에릭 에반스의 ddd에서 가져왔음을 알 수 있다. 따라서 이 정의를 이해하고 코드로 표현하면 될 것이다.
"a mechanism for encapsulating storage, retrieval, and search behavior which emulates a collection of objects".
객체의 컬렉션을 흉내내어 저장, 검색, 검색 동작을 캡슐화하는 매커니즘이다.
즉, 사용자 입장에서는 repository에 단순히 객체를 저장하고, 꺼낼 뿐 내부적으로 어떤 db에 저장을 하고, 꺼내는지, db가 쓰이는지 조차 신경 쓰지 않는다.
목적
repository는 결국 도메인 객체를 중심으로 사용하도록 설계되었다. 왜 이렇게 설계되어 있는지 생각해 볼 필요가 있다. 우리는 비즈니스 로직을 객체 지향의 핵심 원리인 객체들의 협력을 이용하여 구성하고자 한다. repository를 도메인 모델에 추가함으로써 비즈니스 로직에 영속성과 관련된 코드가 등장하지 않고 POJO를 지켜서 객체들의 협력으로 구성한다.
구현
다음 코드는 웹 자동차 경주 미션에서 구현한 서비스의 play메서드이다. 다른 코드는 신경 쓰지 말고 주석과 함께 흐름을 살펴보자.
@Transactional
public ResultResponseDto play(RequestDto requestDto) {
RacingGame racingGame = initGame(requestDto); // RacingGame 초기화
racingGame.moveCars(); // RacingGame 진행
RacingGame save = racingGameRepository.save(racingGame); // repository에 저장
return new ResponseDto(save);
}
도메인 객체인 RacingGame을 초기화하고, 진행하고, 저장하고 있다. 단순히 객체를 저장한다는 것만 나타내기 때문에 코드는 매우 간결하고 쉽다. 서비스가 의존하고 있는 RacingGameRepository 인터페이스는 다음과 같다.
public interface RacingGameRepository {
RacingGame save(RacingGame racingGame);
}
repository를 사용하는 service에서는 도메인 객체인 RacingGame을 저장할 뿐 내부 구현은 캡슐화되어있기 때문에 DB를 사용한다는 사실조차 알 수 없다.
이렇게 사용하면 DB가 바뀌거나 심지어 DB를 사용하지 않고 메모리에 객체를 저장한다 하더라도 서비스 로직은 변경할 필요가 없다. 구현체만 바꿔주면 된다.
repository의 구현체가 DAO를 사용한다는 코드를 확인하고 나서야 DB에 데이터를 저장한다는 사실을 알 수 있다. 위 코드에서는 RacingGame에 해당하는 데이터를 RacingGameMapper를 이용해 Game 엔티티와 Player 엔티티로 각각 변환해서 저장하고 있다.
다음은 DB에 저장하지 않고 메모리상에서만 저장되고 지워지는 repository를 구현한 예이다.
public class InMemoryRacingGameRepository implements RacingGameRepository {
private static final List<RacingGame> racingGames = new ArrayList<>();
@Override
public RacingGame save(RacingGame racingGame) {
racingGames.add(racingGame);
return racingGame;
}
}
이렇게 추상화된 repository를 사용하면 테스트가 쉽다는 장점도 있다. 실제 웹 애플리케이션에서는 위에서의 dao를 사용하는 repository를 사용하도록 하고, 테스트에서는 메모리에서만 동작하는 repository를 사용한다면 db에 의존하지 않고 쉽고 빠르게 테스트를 진행할 수 있다.
결론
repository는 도메인 객체를 저장하고 꺼내는 객체의 컬렉션처럼 사용한다. 이러한 메커니즘에 의해 도메인 중심으로 데이터를 저장하고 꺼낼 수 있으며 이 방법은 비즈니스 로직을 객체들의 협력으로 구성하도록 한다. 또한, 특정 DB나 구현체에 의존하지 않기 때문에 변경에 강하고 테스트를 쉽게 진행할 수 있다.