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를 만족 하고, 서비스간의 결합도를 줄일 수 있었다.
장바구니 미션을 진행하면서 작성한 테스트 중에서 애플리캐이션 컨텍스트를 이용하는 테스트를 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나 구현체에 의존하지 않기 때문에 변경에 강하고 테스트를 쉽게 진행할 수 있다.