스웨거를 사용하면 다음과 같이 어노테이션만 사용해도 자동으로 api명세를 해줍니다

로컬에서 스웨거에 접속해 명세를 확인한 모습

정상적인 케이스의 응답 형태뿐만 아니라 예외 관련 응답도 명세할 필요가 있었습니다.

스웨거에서는 `@ApiResponse`를 통해 다음과 같이 명세를 생성할 수 있었습니다.

하지만 위와 같은 방식은 2가지 문제가 존재합니다.

1. 실제 비즈니스 로직은 노란색 박스로 매우 적은데도 불구하고 명세와 관련한 코드가 훨씬 많은 비중을 차지합니다.

2. 로그인과 관련한 에러는 여러 api에서 공통으로 쓰일 것입니다. 만약 응답의 형태가 바뀐다면 전부 바꿔주어야 겠죠...?

 

이러한 문제를 해결하고자 커스텀 어노테이션을 만들어서, 여기에 예외 클래스를 인자로 전달하는 방식을 생각했습니다.

먼저, SwaggerExceptionResponse라는 스웨거의 예외 응답 전용 커스텀 어노테이션을 만들었습니다.

여기에는 예외 클래스의 배열을 인자로 전달받도록 했습니다.

 

스웨거는 OperationCustomizer 타입의 명세를 커스터마이징 할 수 있는 인터페이스를 제공하고 있습니다.

빈 설정 파일에서 위와 같이 OperationCustomizer를 커스텀해서 응답 정보들을 추가해 빈으로 등록하면 스웨거가 추가한 정보들에 맞게 명세 문서를 생성해 줍니다.

 

스웨거에서 명세를 하기 위한 객체의 그래프는 다음과 같이 설계되어 있습니다.

Operation -> ApiResponses -> ApiResponse

저희가 원하는 것은 예외 클래스에 있는 응답 정보들을 ApiResponse에 추가하는 것입니다.

 

setUpApiResponses 메서드에서는 각 예외 클래스마다 응답들로 ApiResponse를 생성해서 ApiResponses에 추가하도록 했습니다.

 

Exception에 들어있는 정보들을 추출하기 위해서는 Class타입으로 전달받은 예외 객체를 생성해주어야 합니다.

 

위 코드와 같이 extractExceptionFrom 메서드를 통해서 class로 전달받았던 예외 객체를 리플렉션을 통해 생성했습니다.

 

아래는 ApiResponse 객체에 응답 정보들을 추가하는 코드입니다.

Content를 생성해서 응답의 형태를 구성했습니다.

저희가 명세하고자 하는 응답의 형태는 message와 code입니다.

Exception에 담겨있는 정보를 꺼내서 다음과 같은 형태로 명세하고자 했습니다

message: ""

code: ""

ExceptionSituation은 응답의 형태이고

ExceptionMapper에서 Map<Class<? extends Exception>, ExceptionSituation>의 형태로 Class와 그에 해당하는 정보를 매핑하고 있습니다.

 

이제 커스터마이징이 완료됐습니다.

다음과 같이 어노테이션에 Exception class들을 인자로 전달만 하면 자동으로 예외 명세가 생성됩니다.

스웨거에서 생성된 결과를 확인해 보겠습니다.

자동으로 생성하면서도 훨씬 더 자세하고 깔끔한 모습입니다.

 

http 상태 코드뿐만 아니라 저희만의 에러 코드도 명세했습니다.

 

각 빨간색 박스는 코드에서 설정했던 name, description, ObjectSchema입니다.

 

이렇게 스웨거를 커스텀해서 자동으로 예외 응답을 생성하도록 만들어 봤습니다.

자동으로 생성하면서도 더 자세히 명세할 수 있었고, 예외 클래스를 통해 명세하므로 더 편하고 변경에도 영향을 받지 않도록 만들었습니다.

※ 이 글은 개인의 주관이 많이 섞여 있습니다.

장바구니 웹 어플리케이션을 구현하면서 다음과 같은 요구사항이 존재했다.

  1. 사용자의 장바구니의 모든 상품을 주문한다
  2. 장바구니의 상품을 주문하면 사용자의 장바구니의 상품을 모두 삭제한다

따라서 다음과 같이 주문을 하는 OrderService의 save 메서드를 구현했다.

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를 만족 하고, 서비스간의 결합도를 줄일 수 있었다.

 

참고 자료

- https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events 

- https://www.baeldung.com/spring-events

-https://ko.wikipedia.org/wiki/%EC%98%B5%EC%84%9C%EB%B2%84_%ED%8C%A8%ED%84%B4


장바구니 미션을 진행하면서 작성한 테스트 중에서 애플리캐이션 컨텍스트를 이용하는 테스트를 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.

 

즉, 컨텍스트 설정을 같게 만들어주면 캐싱된 컨텍스트를 계속 재사용하여 컨텍스트 로딩 횟수를 줄이고, 테스트 작동 시간을 줄일 수 있다.

코드를 확인하며 컨텍스트 설정인 빈을 설정하는 부분을 설정 파일로 분리하고 이를 공유한다면 같은 컨텍스트를 사용할 수 있을 것이다.

@WebMvcTest(CartItemsController.class)  
class CartItemsControllerTest {  
    @MockBean  
    private CartService cartService;  
    @MockBean  
    AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver;  
    @MockBean  
    BasicAuthInterceptor basicAuthInterceptor;  
    ...
}


위 CartItemsControllerTest 에서는 테스트할 컨트롤러와 여러 의존성을 @MockBean으로 선언하여 컨텍스트를 설정하고 있다. 

@WebMvcTest(ProductsController.class)  
class ProductsControllerTest {  
	@MockBean  
	private ProductService productService;  
	@MockBean  
	AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver;  
	@MockBean  
	BasicAuthInterceptor basicAuthInterceptor;
	...
}


ProductsController 또한 @MockBean을 통해 컨텍스트 설정을 하고 있다.

이렇게 컨텍스트 설정이 달라서 컨텍스트를 새로 만드는데 시간이 많이 소요되고 있었다.

이러한 빈 설정을 설정 파일에서 모아서 한꺼번에 하고 이 설정 파일을 로드한다면 같은 컨텍스트를 공유할 수 있을 것이다.

다음과 같이 컨트롤러와 관련된 빈은 MvcConfig 에, 인증과 관련된 빈은 AuthorizationConfig 에 선언을 했다.

@ComponentScan(basePackages = "cart.controller")  
@TestConfiguration  
public class MvcConfig {  
	@MockBean  
	CartService cartService;  
	@MockBean  
	ProductService productService;
	...
}
@TestConfiguration  
public class AuthorizationConfig {  
	@MockBean  
	AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver;  
	@MockBean  
	BasicAuthInterceptor basicAuthInterceptor;  
	@MockBean  
	MemberService memberService;  
}


이 설정들을 @ContextConfiguration 을 통해 로드하고 @MockBean으로 설정했던 빈들을 @Autowired를 통해 주입받아서 사용하면 된다.

@ContextConfiguration(classes = {AuthorizationConfig.class, MvcConfig.class})  
@WebMvcTest  
class CartItemsControllerTest {  
	@Autowired  
	private CartService cartService;  
	@Autowired  
	AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver;  
	@Autowired  
	BasicAuthInterceptor basicAuthInterceptor;
	...
}



이러한 방식으로 @WebMvcTest를 사용하는 테스트들끼리 같은 설정 파일을 만들어 공유하도록 하고, @JdbcTest도 설정 파일을 공유하도록 하여 (@SpringBootTest는 원래 같은 설정으로 구현했음) 컨텍스트 생성을 총 3번으로 줄이도록 개선했다.

이에 따른 성능 개선 결과는 다음과 같다.


우측의 테스트의 로직을 수행하는 시간은 5.9초로 비슷하지만 좌측의 profiler로 컨텍스트 로딩 시간까지 포함한 시간은 23초로 처음 43초에 비해서 크게 개선된 것을 확인할 수 있다.

참고 자료

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.testing

https://bperhaps.tistory.com/entry/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%97%AC%ED%96%89%EA%B8%B0-1

PSA(Portable Service Abstraction)

자바에서 사용할 수 있는 라이브러리와 프레임워크 중 사용 방법과 형식은 다르지만 기능과 목적이 유사한 기술이 여럿 존재한다. 스프링은 다른 라이브러리나 프레임워크에 대한 추상화를 제공함으로써 결합을 줄이고 코드의 이식성을 높인다.

트랜잭션 구현하기

우리가 사용하는 @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를 통해 트랜잭션 경계를 설정하는 등의 트랜잭션 관련 코드를 숨겨 트랜잭션을 쉽게 사용하고 비즈니스 로직에 집중할 수 있다.

참고 자료

  • 이일민, 『토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리』, 에이콘

정의

스프링의 @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를 사용하지 않고 메모리에 객체를 저장한다 하더라도 서비스 로직은 변경할 필요가 없다. 구현체만 바꿔주면 된다.

다음은 DAO를 사용하여 repository를 구현한 예시이다.

@Override  
public RacingGame save(RacingGame racingGame) {  
    gameDao.updateGame(RacingGameMapper.mapToGame(racingGame));  
    playerDao.insertPlayer(RacingGameMapper.mapToPlayer(racingGame));

    return racingGame;  
}

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나 구현체에 의존하지 않기 때문에 변경에 강하고 테스트를 쉽게 진행할 수 있다.

+ Recent posts