ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [만들면서 배우는 클린 아키텍처] 정리.
    Software Development/Backend 2023. 2. 25. 14:52

    이 글은 만들면서 배우는 클린 아키텍처 책을 읽고 간단히 정리했습니다. 자세한 내용은 책을 통해서 확인하실 수 있습니다.

    http://www.yes24.com/Product/Goods/105138479

     

    만들면서 배우는 클린 아키텍처 - YES24

    우리 모두는 낮은 개발 비용으로 유연하고 적응이 쉬운 소프트웨어 아키텍처를 구축하고자 한다. 그러나 불합리한 기한과 쉬워보이는 지름길은 이러한 아키텍처를 구축하는 것을 매우 어렵게

    www.yes24.com

    1. 계층형 아키텍처의 문제는 무엇일까?

    계층형 아키텍처는 데이터베이스 주도 설계를 유도한다

    웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터베이스에 의존하게 된다

    데이터베이스 중심적인 아키텍처가 만들어지는 가장 큰 원인은 ORM 때문이다.

    ORM과 계층형 아키텍처가 만나면 비즈니스 규칙을 영속성 관점과 섞일 수 있다.

    테스트하기 어려워진다

    단 하나의 필드를 조작하는 것에도 도메인 로직을 웹 계층에 구현하게 된다.

    웹 계층 테스트에서 도메인 계층 뿐만 아니라 영속성 계층도 모킹해야 한다.

    2. 의존성 역전하기

    단일 책임 원칙

    컴포넌트를 변경하는 이유는 오직하나뿐이어야 한다.

    단일 책임 원칙 -> 단일 변경 이유 원칙으로 해석하는게 적절.

    컴포넌트 변경 사유가 하나라면, 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다.

    안타깝지만, 변경할 이유는 컴포넌트 간의 의존성을 통해 너무도 쉽게 전파된다.

    의존성 역전 원칙

    계층형 구조는 계층 간 의존성은 항상 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할 때 상위 계층들이 하위 계층들에 비해 변경할 이유가 더 많다.

    영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다.

    도메인 코드는 애플리케이션의 핵심인데, 영속성 바뀔때 마다 도메인 바꾸는 것은 옳지 않다.

    이를 제거하기 위해서 DIP을 쓴다.

    코드상의 어떤 의존성이든 그 방향을 바꿀(역전시킬) 수 있다.

    의존성이 양쪽 코드 모두 제어할 수 있을 때만 의존성을 역전시킬 수 있다. 서드파티는 의존성 역전이 안됨.

    영속성 코드가 도메인 코드에 의존하고, 도메인 코드를 '변경할 이유'의 개수를 줄이면 된다.

    1. 엔티티를 도메인 객체를 표현하고 도메인 코드는 이 엔티티들의 상태를 변경하는 일을 중심으로 하기 때문에 엔티티를 도메인 계층으로 올린다.

    2. 도메인 계층에 리포지토리에 대한 인터페이스를 만들고, 실제 리포지토리는 영속성 계층에서 구현.

    클린 아키텍처

    도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함의 의미.

    DIP로 모든 의존성이 도메인 코드를 향하도록 설계.

    클린 아키텍처는 대가가 따른다. 각 계층이 철저하게 분리되기 위해서 애플리케이션 엔틴티에 대한 모델을 각 계층에서 유지보수 해야한다.

    즉 도메인과 영속성 계층이 데이터를 주고받을 때, 두 엔티티를 서로 변환해야 한다.

    3. 코드 구성하기

    아키텍처적으로 표현력 있는 패키지 구조

    아래 리포지토리의 구조 참고

    https://github.com/wikibook/clean-architecture

     

    GitHub - wikibook/clean-architecture: 《만들면서 배우는 클린 아키텍처》 예제 코드

    《만들면서 배우는 클린 아키텍처》 예제 코드. Contribute to wikibook/clean-architecture development by creating an account on GitHub.

    github.com

    의존성 주입의 역할

    애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 안도록 하는 것

    4. 유스케이스 구현하기

    유스케이스 둘러보기

    유스케이스가 하는일

    1. 입력을 받는다.

    2. 비즈니스 규칙을 검증.

    3. 모델 상태를 조작.

    4. 출력을 반환.

    유스케이스는 인커밍 어댑터로부터 입력을 받는다.

    유스케이스는 비즈니스 규칙을 검증할 책임을 가진다.  도메인 엔티티와 이 책임을 공유한다.

    입력 유효성 검증

    입력 모델에 있는 유효성 검증 코드를 통해 유스케이스 구현체 주의에 오류 방지 계층을 생성.

    빌더 패턴보다는 생성자가 좋다.

    빌터패턴은 빌더를 호출하는 코드에서 새로운 필드를 추가하는 것을 잊을 수 있다.

    비즈니스 규칙 검증

    가장 좋은 방법은 비즈시느 규칙을 도메인 엔티티안에 넣는 것이다.

    여의치 않으면 유스케이스 코드에서 도메인 엔티티 사용하기 전에 구현.

    5. 웹 어댑터 구현하기

    의존성 역전

    어댑터는 애플리케이션 서비스에 구현된 인터페이스인 전용 포트를 통해 애플리케이션 계층과 통신.

    어댑터와 유스케이스 사이에 간접 계층을 넣어서 외부 세계와 통신하는 명세를 남기기 때문에 유지보수 측면헤서 소중한 정보이다.

    웹 어댑터의 책임

    1. HTTP 요청을 자바 객체로 매핑

    2. 권한 검사

    3. 입력 유효성 검증

    4. 입력을 유스케이스의 입력 모델로 매핑

    5. 유스케이스 호출

    6. 유스케이스의 출력을 HTTP로 매핑

    7. HTTP 응답을 반환

    컨트롤러 나누기

    너무 적은 것보다는 너무 많은게 낫다. 각 컨트롤러가 가능한 좁고 다른 컨트롤러와 가능한 적게 공유하는 웹 어댑터 조각을 구현해야 한다.

    컨트롤러에 코드가 많으면. 테스트도 많아지고 코드 파악이 어렵다.

    6. 영속성 어댑터 구현하기

    의존성 역전

    애플리케이션 서비스에서 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다. 이 포트는 실제로 영속성 작업을 수행하고 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현된다.

    영속성 어댑터는 아웃고잉 어댑터다. 호출될 뿐이다.

    영속성 어댑터의 책임

    1. 입력을 받는다.

    2. 입력을 데이터베이스 포맷으로 매핑

    3. 입력을 데이터베이스로 보낸다.

    4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑.

    5. 출력 반환.

     

    핵심은 영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있지 않고, 애플리케이션 코어에 있다.

    영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다.

    포트 인터페이스 나누기

    서비스를 구현하면서 생기는 의문은 데이터베이스 연산을 정의하고 있는 포트 인터페이스를 어떻게 나눌 것인가다.

    필요없는 화물을 운반하는 무언가에 의존하고 있으면 예상하지 못했던 문제가 생길 수 있다.

    인터페이스 분리 원칙(ISP)은 이 문제의 해결 책이다.

    이 원칙은 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다.

    각 서비스는 실제로 필요한 메서드에만 의존. 포트의 이름이 포트의 역할을 명확하게 잘 표현. 매우 좁은 포트를 만드는 것은 코딩을 플러그 앤드 플레이 경험으로 만든다.

    7. 아키텍처 요소 테스트하기

    육각형 아키텍처에서의 테스트 전략에 대해 이야기한다.

    테스트 피라미드

    단위테스트는 피라미드의 토대에 해당한다.

    일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다.

    테스트중에 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 목으로 대체한다.

    통합테스트는 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작하는지 검증한다.

    시스템 테스트는 애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다.

    단위 테스트로 도메인 엔티티 테스트하기

     Account의 상태는 과거 특정 시점의 계좌 잔고와 그 이후의 입출금 내역으로 구성. withdraw 메서트가 기대한 대로 동작하는지 검증하는 코드.

    class AccountTest {
    	@Test
        void withdrawalSucceeds() {
        	AccountId accountID = new AccountId(1L);
            Account account = defaultAccount()
            	.withAccountId(accountId)
                .withBaselineeBalance(Money.of(555L))
                .withActivityWindow(new ActivityWindow(
                	defaultActivity()
                    	.withTargetAccount(accountId)
                        .withMoney(Money.of(999L)).build(),
                    defaultActivity()
                    	.withTargetAccount(accountId)
                        .withMoney(Money.of(1L)).build()))
                .build();
            boolean success = account.withdraw(Money.of(555L), new AccountId(99L));
            
            assertThat(success).isTrue();
            assertThat(account.getActivityWindow().getActivities()).hasSize(3);
            assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L));
            
        }
    }

    위의 단위 테스트는 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 가장 적절한 방법이다.

    단위 테스트로 유스케이스 테스트하기

    SendMoney 유스케이스는 출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 락(lock)을 건다. 출금 계좌에서 돈이 출금되고 나면 똑같이 계좌에 락을 걸고 돈을 입금시킨다. 그러고 나서 두 계좌에서 모두 락을 해제한다.

     

    다음 코드는 트랜잭션이 성공했을 때 머든 것이 기대한 대로 동작하는지 검증한다.

    class SendMoneyServiceTest {
    
    	@Test
        void transactionSucceeds() {
        	Account sourceAccount = givenSourceAccount();
            Account targetAccount = givenTargetAccount();
            
            givenWithdrawalWillSucceed(sourceAccount);
            givenDepositWillSucceed(targetAccount);
            
            Money money = Money.of(500L);
            
            SendMoneyCommand command new SendMoneyCommand(
            	sourceAccount.getId(),
                targetAccount.getId(),
                money);
               
            boolean success = sendMoneyService.sendMoney(command);
            
            assertThat(success).isTrue();
            
            AccountId sourceAccountId = sourceAccount.getId();
            AccountId targetAccountId = targetAccount.getId();
            
            then(accountLock).should().loackAccount(eq(sourceAccountId));
            then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
            then(accountLock).should().releaseAccount(eq(sourceAccount));
            
            then(accountLock).should().lockAccount(eq(targetAccountId));
            then(targetAccount).should().deposit(eq(money), eq(sourceAccountId));
            then(accountLock).should().releaseAccount(eq(targetAccountId));
            
            thenAccountHaveBeenUpdated(sourceAccountId, targetAccountId);

    통합 테스트로 웹 어댑터 테스트하기

    웹 어댑터는 JSON 문자열 등의 형태로 HTTP를 통해 입력을 받고, 입력에 대한 유효성 검증을 하고, 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달 후, 유스케이스의 결과는 JSON으로 매핑하고 HTTP 응답을 통해 클라이언트에 반환한다.

    @WebMvcTest(controllers = SendMoneyController.class)
    class SendMoneyControllerTest {
    
    	@Autowired
    	private MockMvc mockMvc;
    
    	@MockBean
    	private SendMoneyUseCase sendMoneyUseCase;
    
    	@Test
    	void testSendMoney() throws Exception {
    
    		mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
    				41L, 42L, 500)
    				.header("Content-Type", "application/json"))
    				.andExpect(status().isOk());
    
    		then(sendMoneyUseCase).should()
    				.sendMoney(eq(new SendMoneyCommand(
    						new AccountId(41L),
    						new AccountId(42L),
    						Money.of(500L))));
    	}

    스프링 부트 프레임워크에서 SendMoneyController라는 웹 컨트롤러를 테스트하는 표준적인 통합 테스트 방법이다.

    통합 테스트로 영속성 어댑터 테스트하기

    어댑터에는 Account 엔티티를 데이터베이스로부터 가져오는 메서드 하나와 새로운 계좌 활동을 데이터베이스에 저장하는 메서드까지 총 2개의 메서드가 있었다.

    @DataJpaTest
    @Import({AccountPersistenceAdapter.class, AccountMapper.class})
    class AccountPersistenceAdapterTest {
    
    	@Autowired
    	private AccountPersistenceAdapter adapterUnderTest;
    
    	@Autowired
    	private ActivityRepository activityRepository;
    
    	@Test
    	@Sql("AccountPersistenceAdapterTest.sql")
    	void loadsAccount() {
    		Account account = adapterUnderTest.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0));
    
    		assertThat(account.getActivityWindow().getActivities()).hasSize(2);
    		assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
    	}
    
    	@Test
    	void updatesActivities() {
    		Account account = defaultAccount()
    				.withBaselineBalance(Money.of(555L))
    				.withActivityWindow(new ActivityWindow(
    						defaultActivity()
    								.withId(null)
    								.withMoney(Money.of(1L)).build()))
    				.build();
    
    		adapterUnderTest.updateActivities(account);
    
    		assertThat(activityRepository.count()).isEqualTo(1);
    
    		ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
    		assertThat(savedActivity.getAmount()).isEqualTo(1L);
    	}
    
    }

    @DataJpaTest 어노테이션으로 스프링 데이터 리포지토리들을 포함해서 데이터베이스 접근에 필요한 객체 네트워크를 인스턴스화해야 한다고 스프링에게 알려준다.

     

    @Import 어노테이션을 추가해서 특정 객체가 이 네트워크에 추가됐다는 것을 명확하게 표현할 수 있다.

     

    loadAccount() 메서드에 대한 테스트에서는 SQL 스크립트를 이용해 데이터베이스를 특정 상태로 만든다.

    시스템 테스트로 주요 경로 테스트하기

    @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
    class SendMoneySystemTest {
    
    	@Autowired
    	private TestRestTemplate restTemplate;
    
    	@Autowired
    	private LoadAccountPort loadAccountPort;
    
    	@Test
    	@Sql("SendMoneySystemTest.sql")
    	void sendMoney() {
    
    		Money initialSourceBalance = sourceAccount().calculateBalance();
    		Money initialTargetBalance = targetAccount().calculateBalance();
    
    		ResponseEntity response = whenSendMoney(
    				sourceAccountId(),
    				targetAccountId(),
    				transferredAmount());
    
    		then(response.getStatusCode())
    				.isEqualTo(HttpStatus.OK);
    
    		then(sourceAccount().calculateBalance())
    				.isEqualTo(initialSourceBalance.minus(transferredAmount()));
    
    		then(targetAccount().calculateBalance())
    				.isEqualTo(initialTargetBalance.plus(transferredAmount()));
    
    	}
    
    	private Account sourceAccount() {
    		return loadAccount(sourceAccountId());
    	}
    
    	private Account targetAccount() {
    		return loadAccount(targetAccountId());
    	}
    
    	private Account loadAccount(AccountId accountId) {
    		return loadAccountPort.loadAccount(
    				accountId,
    				LocalDateTime.now());
    	}
    
    
    	private ResponseEntity whenSendMoney(
    			AccountId sourceAccountId,
    			AccountId targetAccountId,
    			Money amount) {
    		HttpHeaders headers = new HttpHeaders();
    		headers.add("Content-Type", "application/json");
    		HttpEntity<Void> request = new HttpEntity<>(null, headers);
    
    		return restTemplate.exchange(
    				"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
    				HttpMethod.POST,
    				request,
    				Object.class,
    				sourceAccountId.getValue(),
    				targetAccountId.getValue(),
    				amount.getAmount());
    	}
    
    	private Money transferredAmount() {
    		return Money.of(500L);
    	}
    
    	private Money balanceOf(AccountId accountId) {
    		Account account = loadAccountPort.loadAccount(accountId, LocalDateTime.now());
    		return account.calculateBalance();
    	}
    
    	private AccountId sourceAccountId() {
    		return new AccountId(1L);
    	}
    
    	private AccountId targetAccountId() {
    		return new AccountId(2L);
    	}

    @SpringBootTest 어노테이션은 스프링이 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다. 랜덤포트로 이 애플리케이션을 띄우도록 설정한다.

     

    test 메서드에서는 요청을 생성해서 애플리케이션에 보내고 응답 상태와 계좌의 새로운 잔고를 검증한다.

     

    TestRestTemplate을 이용해서 요청을 보낸다. 테스트를 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 한다.

    얼마만큼의 테스트가 충분할까?

    마음 편하게 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼으면 된다.

    처음 몇 번의 배포에는 믿음의 도약이 필요하다.

    그렇지만 프로덕션의 버그를 수정하고 이로부터 배우는 것을 우선순위로 삼으면 제대로 가는 것이다.

    각각의 프로덕션 버그에 대해서 "테스트가 이 버그를 왜 잡지 못했지?"를 생각하고 이에 대한 답변을 기록하고, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다.

    • 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
    • 유스케이스를 구현할 때는 단위 테스트로 커버하자
    • 어댑터를 구현할 때는 통합 테스트로 커버하자
    • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자

    8. 경계 간 매핑하기

    매핑 찬성자: 두 계층 간에 매핑을 하지 않으면 양 계층에서 같은 모델을 사용해야 하는데 이렇게 하면 두 계층이 강하게 결합된다.

    매핑 반대자: 두 계층 간에 매핑을 하게 되면 보일러플레이트 코드를 너무 많이 만들게 된다. 많은 유스케이스들이 오직 CRUD만 수행하고 계층에 걸쳐 모델을 사용하기 때문에 계층 사이의 매핑은 과하다.

     

    모든 계층이 정확히 같은 구조의, 정확히 같은 정보를 필요로한다면 매핑반대자의 의견도 인정.

     

    어떤 매핑 전략이을 선택하든 나중에 언제든 바뀔 수 있음.

     

    References

    [1] https://github.com/wikibook/clean-architecture

    댓글

Designed by Tistory.