스웨거를 사용하면 다음과 같이 어노테이션만 사용해도 자동으로 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입니다.

 

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

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

 

우아한테크코스의 MVC미션을 진행하던 중 다음과 같은 리뷰를 받았다.

스트림의 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() 배열의 원소 만큼

handler와 method로 Map<HandlerKey, HandlerExecution>의 Entry를 생성해서

인스턴스 변수인 handlerExecutions에 put을 하고 있다.

 

2. 개선된 코드

먼저 완성될 결과를 보자.

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을 완성한다.

이를 handlerExecutions.putAll로 맵의 모든 엔트리를 삽입해주었다.

 

핵심인 createHandlerExecutionMap을 살펴보자.

private Map<HandlerKey, HandlerExecution> createHandlerExecutionMap(final Object handler,
        final Method[] methods) {
    return Arrays.stream(methods)
            .map(method -> toHandlerExecutions(handler, method))
            .flatMap(map -> map.entrySet().stream())
            .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
}

요소인 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를 조회했기 때문에 같은 데이터를 가진다.

그렇다면 member1과 member2는 같은 객체인가?

출력 결과를 확인해보자.

cart.entity.Member@194c64c9
cart.entity.Member@3a64b5d
false

DB에서 id를 가진 row를 조회하여 같은 데이터를 가지고 있으나 DAO가 객체를 생성할 때 new 오퍼레이션을 사용하여 생성했기 때문에 객체가 동일하지는 않다.

 

1차 캐시

영속성 컨텍스트는 1차 캐시를 제공하여 위 예시에서 설명한 동일성 문제를 해결함과 동시에 성능 최적화를 지원한다.

  1. 같은 객체를 반환하여 동일성 보장하고 식별자 마다 유일한 엔티티를 보장
  2. 캐싱으로 같은 조회라면 쿼리를 실행하지 않아 성능을 최적화한다.

1차 캐시를 이해하기 위해 다음 그림을 보자.

영속성 컨텍스트에서 조회 요청이 들어왔을 때, 1차 캐시에 캐싱된 엔티티가 있다면 DB를 조회하지 않고 바로 캐싱된 엔티티를 반환한다. 

캐시는 데이터베이스 기본키의 값을 키로, 엔티티를 값으로 매핑하여 저장하고 있다. 엔티티의 식별자인 기본 키를 키로 매핑하기 때문에 식별자마다 엔티티의 유일함을 보장한다.

 

엔티티 생명 주기

글의 맨 처음 영속성 컨텍스트의 정의 부분에서 영속성 컨텍스트는 엔티티의 생명 주기를 관리한다고 했다.

엔티티에는 4가지 상태가 존재한다.

  • 비영속 : 영속성 컨텍스트와 관계가 없는 상태
  • 영속 : 영속성 컨텍스트에 저장된 상태
  • 준영속 : 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제 : 삭제된 상태

영속성 컨텍스트는 엔티티 매니저를 통해 이러한 엔티티의 생명 주기를 관리한다.

코드를 보며 이해해보자.

@Transactional
@Rollback(value = false)
@SpringBootTest
class UserTest {

    @PersistenceContext
    private EntityManager entityManager;

    @Test
    void test() {
        User user = new User();    // 비영속
        user.setUsername("maco");    // 비영속
        entityManager.persist(user);    // 영속
    }
}

 

엔티티 매니저를 의존성 주입 받기 위해 @SpringBootTest로 작성했다.

또한, 트랜잭션 내에서 관리해야 하기 때문에 @Transactional을 붙여주고, DB에 데이터가 플러시 되는 것을 직접 확인하기 위해 @Rollback(value = false)로 설정했다.

위 코드에서 엔티티 매니저의 persist를 호출하기 전에 user는 비영속 상태였다가 persist를 호출하면 영속성 컨텍스트에서 관리하기 때문에 영속되었다고 말한다.

위에서 봤던 1차 캐시 그림에서 데이터를 조회하면 영속성 컨텍스트의 1차 캐시 안에 저장되었다.

즉, 조회도 영속성 컨텍스트가 엔티티를 관리하기 때문에 조회를 하면 그 엔티티는 영속 상태라고 한다.

    @Test
    void test() {
        User user = entityManager.find(User.class, 15L);	// 영속
        System.out.println("user = " + user);
    }

 

entityManager.find()를 통해 조회한 user는 영속성 컨텍스트에서 관리하고 있다.

준영속 상태에 대해서도 알아보자.

    @Test
    void test() {
        User user = entityManager.find(User.class, 15L);    // 영속
        entityManager.detach(user);    // 준영속
        System.out.println("user = " + user);
    }

 

준영속 상태는 영속성 컨텍스트에 저장되어 있다가 분리된 상태를 말한다. 위와 같이 직접 엔티티 매니저의 detach()를 호출하거나 close()로 영속성 컨텍스트를 닫거나 clear()로 영속성 컨텍스트를 비우면 관리하던 엔티티가 준영속 상태가 된다.

준영속 상태는 객체의 데이터가 지워지는 것이 아니라 영속성 컨텍스트에서 관리되지 않는 것 뿐이기 때문에 위에서 출력에 사용한 것 처럼 엔티티를 계속 사용할 수 있다.

flush()를 호출하는 것은 DB에 쿼리문만 실행하고
여전히 영속성 컨텍스트에서 엔티티를 관리하고 있기 때문에 엔티티가 준영속 상태가 되지는 않는다.

삭제는 remove를 호출하여 할 수 있으며, 영속성 컨텍스트에서 관리되지 않음과 동시에 DB에 delete쿼리를 실행한다.

    @Test
    void test() {
        User user = entityManager.find(User.class, 15L);
        entityManager.remove(user);
        System.out.println("user = " + user.getUsername());
    }

 

삭제 또한 컨텍스트에서 관리되지 않고 DB에 데이터가 없을 뿐 user 엔티티 자체에는 데이터가 있는 것을 확인할 수 있다.

user = maco

 

정리

영속성 컨텍스트의 정의였던 아래의 내용을 살펴 보았다.

1. 각각의 영속적인 엔티티 식별자에 대해 유일한 엔티티 인스턴스가 있는 엔티티 인스턴스의 집합이다.
2. 영속성 컨텍스트 내에서, 엔티티 인스턴스들과 그들의 생명주기가 관리된다.

1차 캐시를 통해 엔티티의 동일성을 보장하고, 성능을 최적화하였고, 엔티티를 영속시켜 엔티티의 생명 주기를 관리했다.

영속성 컨텍스트가 엔티티를 관리하면 다음과 같은 장점들이 있다.

  • 1차 캐시
  • 동일성 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지
  • 지연 로딩

이번 포스팅에서 1차 캐시와, 동일성 보장에 대해서 다뤘으니 다음 포스팅에서 나머지 특징들에 대해서 알아보자.

 

참고 문서

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

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

  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

이 글에서 작성한 코드는 Spring framework를 기반으로 작성했으며 디자인 패턴 중 composite 패턴에 대한 이해가 있어야 읽기 쉬움을 알립니다.

지하철 미션을 진행하면서 거리에 따라 다른 요금 정책을 적용해야 했다.


요금 계산 방법

  • 기본운임(10㎞ 이내): 기본운임 1,250원 
  • 이용 거리 초과 시 추가운임 부과
    • 10km~50km: 5km 까지 마다 100원 추가
    • 50km 초과: 8km 까지 마다 100원 추가

 

if문으로 쉽게 구현할 수 있지만 다음과 같은 확장의 가능성을 생각하면 수정이 어려울 것이다.

  1. 거리별 요금 정책이 추가, 수정되는 경우
  2. 거리별 정책 이외에 노선별, 승객의 나이별 요금 정책이 추가되는 경우


확장 가능성을 고려하여 객체를 분리하여 요금을 계산해보도록 하자.


composite 패턴


모든 요금 정책을 적용해서 요금을 계산하는 FareCalculator가 각 정책을 의존하고 있다.
현재는 거리별 정책밖에 없기 때문에 DistancePolicy만 의존하고 있다.

 

  1. DistancePolicy는 Leaf와 Composite의 공통 인터페이스인 Component이다
  2. 각 요금 정책을 가지고 있는 구현체들(BasicFarePolicy, FiveUnitFarePolicy, EightUnitFarePolicy)는 leaf로 DistancePolicy를 완성하는 부분이다.
  3. 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.

 

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

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

@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