crossorigin="anonymous"> $(function(){ $('.article_view').find('table').each(function (idx, el) { $(el).wrap('
') }); $('img[alt="N"]').each(function(){ $(this).replaceWith('

N

') }); });

새소식

고수만/필드에서 써먹은 것들

Unit Test와 DDD 과 도입 리뷰 -보통

  • -

책보고 있는 필자를 감찰하는 견공들

단위 테스트와 DDD의 도입 배경

 

새로운 프로젝트는 자체 개발한 프레임워크에 맞추어 개발을 하였다.

새로운 프레임워크는 클린 아키택처를 기반으로  만들어졌다. 이유는 Test Code의 도입을 하기 위해서이다.

완전한 TDD는 아니지만 Test Code의 도입을 위해서는 기존 설계들과는 다르게 비즈니스 로직의 응집도를 높일

필요가 있었다. 왜냐하면 기존 방식의 Monolithic page의 설계 패턴은 단위 테스트를 하기에 Unit 별로 나누기엔 너무 많은 것들이 서로 영향을 받기 때문이었다.

 

예를 들어 MVC 패턴 Repository 패턴을 사용해 Monolithic page를 구현했다고 가정해보자. View와 Controller로 서로의 관심사를 분리하고 Repository 패턴으로 데이터 조작과 비즈니스를 분리하였다고 하여도 각각의 컴포넌트들이 (예를 들어

View와 Controller)  서로 긴밀히 연관되어 있을 수 있다. 또한 Repository 패턴은 데이터 베이스나 데이터를 가져오는 외부 

시스템에 의존적인 경우가 많다. 따라서 시스템이 커지면 커질수록 많은 기능이 얽힐 가능성이 높다.

 

단위 테스트를 도입하려면 기능을  최대한 작은 단위로 구현해야 했다.

따라서 새로운 프로젝트는 단위 테스트가 적용 되기 쉽게 새로운 프레임 워크를 만들어 그 설계를 따르도록 했다. 

 

단위 테스트를 더 효과적으로 도입하기 위해서는 다양한 설계 방법이 필요로 한다.

이러한 방법들은 대체로 코드의 모듈성을 높이고, 의존성을 관리 가능하게 하며, 테스트를 위해 격리가 용이해아

한다. 이는 DI를 잘 활용하며 SOLID 원칙을 잘 지킨 설계가 필요했고 이러한 원칙들을 잘 살린 설계 방법이

클린 아키택처가 있었다.

 

(만약 클린아키택처에 대해 궁금하다면 이 글을 참고 해도 좋다.)

https://dogfootsleep.tistory.com/42

 

클린 아키택처(Clean Architecture)의 개념과 사용법

클린 아키택처란? 클린 아키텍처(Clean Architecture)는 로버트 C. 마틴(Robert C. Martin : 엉클 밥 이라도 불리는 ) 이 제안한 소프트웨어 설계 원칙 중 하나이다. 클린 아키텍처의 주요 목표는 소프트웨어

dogfootsleep.tistory.com

 

 

 

클린 아키택처와 DDD는 중심에 비즈니스를 둔다는 점과 유연성의 공통점으로 같이 예제로 많이 쓰인다.

하지만 기존 설계가 Repository 패턴에 익숙했기에 클린 아키택처만 우선 도입하고

DDD는 추후에 도입하도록 하였다. (단위 테스트의 도입이 우선이었기 때문)

 

클린 아키택처는 비즈니스 로직이 한 곳에 집중이 되어야 했고, 테스트 코드를 짜야 했기 때문에

처음에는 어플리케이션 레이어에 비즈니스 로직이 모두 모일 수 있도록 설계되었다.

그리고 테스트 코드는 어플리케이션의 기능 단위인 handler 단위로 짜여졌다.

그러나 어플리케이션 레이어의 복잡도 증가와 코드의 재사용성 등 여러 이유로 DDD로의 전환의 필요성이

느껴졌다.

 

TDD란 무엇인가?

 

TDD(Test-Driven Development)는 소프트웨어 개발 프로세스 중 하나로, 실제 코드를 작성하기 전에 테스트 케이스를 먼저 작성하는 방법론이다. TDD의 기본 원칙은 'Red-Green-Refactor' 사이클을 따르는 것으로, 다음과 같은 순서로 진행된다:

  1. Red: 실패하는 테스트 케이스를 먼저 작성한다. 이 테스트는 새로 추가하거나 개선하고자 하는 기능의 요구사항을 반영해야 하며, 이 시점에서는 해당 기능의 코드가 작성되지 않았기 때문에 테스트가 실패한다.
  2. Green: 테스트를 통과하기 위한 최소한의 코드를 작성한다. 이 단계의 목표는 빠르게 테스트를 통과하는 것이며, 코드의 품질보다는 테스트 통과에 집중한다.
  3. Refactor: 코드를 재구성한다. 테스트를 통과한 후, 코드를 정리하고 중복을 제거하며 설계를 개선하여 가독성, 유지보수성 및 확장성을 높인다.

TDD는 개발 초기부터 테스트를 중심으로 진행하기 때문에, 결함을 빠르게 발견하고 수정할 수 있다. 또한, 설계를 개선하고 리팩토링하는 과정에서도 테스트가 지속적으로 코드의 정확성을 보장해주기 때문에 안정적인 소프트웨어 개발을 촉진한다.

단위 테스트(Unit Testing)는 소프트웨어 개발에서 사용되는 테스트의 한 형태로, 소프트웨어의 가장 작은 단위(보통 함수나 메소드)가 의도대로 정확히 작동하는지 확인하는 테스트이다. 단위 테스트는 일반적으로 개발자에 의해 작성되며, 개별적인 함수나 메소드의 기능을 독립적으로 검증하기 위해 사용된다.

 

테스트의 유형

테스트의 유형에는 다양한 것들이 있다. 예를들면 시스템 테스트 , 부하 테스트, 스트레스 테스트, 성능 테스트 등등

많은 테스트가 존재하는데 단위 테스트와 통합 테스트에 대해 집중하도록 하겠다.

  1. 단위 테스트 (Unit Testing):
    • 가장 기본적인 테스트 수준이며, 개별적인 함수나 메소드가 올바르게 동작하는지 확인한다.
    • 가장 작은 코드 단위를 대상으로 하며, 각 기능이 제대로 동작하는지 검증한다.
  2. 통합 테스트 (Integration Testing):
    • 두 개 이상의 모듈이나 서비스가 서로 올바르게 상호작용하는지 확인한다.
    • API, 데이터베이스, 네트워크 등 서로 다른 시스템 컴포넌트 간의 인터페이스와 통신을 테스트한다.

단위 테스트의 예시

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class CalculatorTest {

    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        assertEquals(5, calculator.add(2, 3));
    }
}

 

위 코드는 JUnit을 사용한 테스트 케이스이다. 위 코드는 코드 테스트를 실행하여 체크를 하는 부분이다.

실제 기능을 구현하면 아래와 같다.

 

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }
}

 

원하는 기능에 대한 테스트를 먼저 생각해 테스트(검증) 할 케이스를 작성하고 다음 그 기능을 구현한다.

그리고 기능을 변경해보며 테스트를 돌려 기능 구현을 검증한다.

 

실제 프로젝트에선 Mock을 만들고 In memory에 DB 값을 넣어 Test를 검증하였지만 이 곳에선 자세하게 다루지 

않겠다.

 

 

DDD란 무엇인가?

 

도메인 주도 설계(DDD, Domain-Driven Design)에서 가장 중요한 것은 바로 도메인이다. 도메인의 개념이 이해하기 어려울 수 있기에 잘 숙지해야 한다.

 

DDD(Domain-Driven Design)는 복잡한 요구사항을 가진 소프트웨어 프로젝트에 초점을 맞춘 소프트웨어 설계 접근 방식이다. 이 방법론은 소프트웨어의 복잡성을 관리하는 데 중점을 두며, 핵심적으로 도메인과 도메인 로직의 중요성을 강조한다. 도메인은 소프트웨어가 해결하려는 문제 영역이며, DDD는 이 도메인을 모델링하여 프로젝트의 구조와 언어를 정의한다.

예를 들어, 은행 소프트웨어에서 도메인은 계좌 관리, 대출 처리, 거래 등과 같은 금융 활동일 수 있다. 의료 소프트웨어의 경우 도메인은 환자 관리, 진료 기록, 의약품 처방 등이 될 수 있다.

 

DDD는 개발자들이 도메인 전문가들과 긴밀하게 협력하여, 문제를 해결하는 데 필요한 모델을 함께 발전시키도록 권장한다. 이 모델은 소프트웨어 내에서 복잡한 비즈니스 로직을 표현하는 데 사용되며, 모든 팀 구성원이 공유하는 '유비쿼터스 언어'를 통해 소통한다.

 

도메인 주도 설계는 다음과 같은 핵심 개념들을 포함한다:

  1. 엔티티(Entity): 독특한 식별자를 가지며, 시간에 따라 변화하는 도메인 객체다.
  2. 값 객체(Value Object): 식별자를 갖지 않고, 불변의 속성을 표현한다.
  3. 집합(Aggregate): 연관된 엔티티와 값 객체의 클러스터로, 하나의 루트 엔티티를 통해 관리된다.
  4. 리포지토리(Repository): 엔티티의 영속성을 관리하며, 도메인 모델의 컬렉션으로의 접근을 추상화한다.
  5. 서비스(Service): 도메인 로직을 담지 않는 특정 도메인 연산을 수행한다.

여기서 엔티티는 굉장히 중요한 개념이다.

"엔티티"는 도메인 내에서 식별 가능하며 지속적인 생명주기를 가진다. 각 엔티티는 고유한 식별자로 구별되며, 이는 ID 또는 고유번호와 같은 형태를 가진다. 시간이 흐름에 따라 엔티티의 상태는 변할 수 있다. 사용자, 주문, 제품과 같은 것들이 엔티티의 예가 된다. 은행 시스템의 경우, "계좌"가 하나의 엔티티가 될 수 있다. 계좌는 고유한 계좌 번호를 가지고 있고, 잔액, 소유자, 거래 내역과 같은 상태가 시간에 따라 변경될 수 있다.

DDD를 하게 된다면 데이터 베이스의 테이블도 그에 맞게 설계 되어야 하는데 엔티티는 주로 테이블로 표현될 수 있다.

 

DDD는 도메인의 복잡성을 효과적으로 캡슐화하고, 변화하는 비즈니스 요구사항에 대응하기 쉬운 유연한 설계를 만들어낸다. DDD는 특히 도메인 로직이 복잡하고, 비즈니스 규칙이 소프트웨어의 주요 부분을 차지하는 시스템에서 유용하다.

 

그렇기에 궁극적으론 DDD를 지향하며 설계를 리팩토릭 할 수 밖에 없었다.

 

 

DDD 예시 코드

public class Account {
    private String id;
    private BigDecimal balance;

    public Account(String id, BigDecimal initialBalance) {
        this.id = id;
        this.balance = initialBalance;
    }

    public void deposit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        this.balance = this.balance.add(amount);
    }

    // ... 기타 필드와 메소드, getter, setter
}

 

위 코드는 비즈니스 로직이 들어가는 엔티티 부분이다.

그리고 아래는 엔티티를 활용하여 비즈니스 로직을 사용하는 서비스 부분이다.

핵심 비즈니스의 코어는 Account 에 들어가 있고 그 Account 엔티티를 활용하여 deposit을 계산하는 것을 볼수 있다.

 

 

public class AccountService {
    private final AccountRepository accountRepository;

    public AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    public void depositToAccount(String accountId, BigDecimal amount) {
        Account account = accountRepository.findById(accountId);
        if (account == null) {
            throw new IllegalArgumentException("Account not found");
        }
        account.deposit(amount);
        accountRepository.save(account);
    }
    // ... 기타 서비스 메소드
}

 

아래는 코드는 인터페이스로 이뤄신 Repository 부분이다.

엔티티를 가져오는 부분은 인터페이스를 사용하여 가져오고  서비스가 엔티티를 가져오는 방식에 대한

제어를 역전시킨다(IOC). 덕분에 결합도가 감소하기 때문에 엔티티를 DB가 아닌 외부 API로 가져오는 걸로 바뀌더라도

DI를 구성하는 부분만 수정하면 변경이 가능하다.(확장성과 유연성)

public interface AccountRepository {
    Account findById(String id);
    void save(Account account);
    // ... 기타 필요한 메소드
}

 

 

 

단위 테스트를 도입하면서 느낀점

 

 

우선 단위 테스트 도입의 장단점을 아래와 같이 정리하였다.

  •  장점
    • 리팩토링에서의 사이드 이펙트에 대한 부담이 줄어듬
    • 테스트 코드를 짜면서 불필요한 코드나 중복코드, 잘못 된 로직에 대해 찾을 수 있음
  • 단점
    • 테스트 코드를 짜는데 시간이 많이 걸리고 짜기가 싫음
    • 코드가 바뀌면 테스트 코드도 다 바뀔 가능성이 큼

 

장점

 

1. 리팩토링에서의 사이드 이펙트에 대한 부담이 줄어듬

 

처음 Test Code를 도입할 때는 devops 배포의 빌드 과정에서 버그 발견 정도의 기능만이 유용할 것이라 생각했었다.

그런데 DDD를 진행하면서 느낀 테스트 코드의 가장 큰 장점은 바로 리팩토링에서의 유지보수에 대한 비용의 절감이었다. 어플리케이션에서 구현한 코드가 Test Code를 통과하였기에 해당 로직이 도메인으로 내려간다 하더라도 최종적으로

나오는 기대 값은 같아야 했다.

 

이미 Test Code들을 촘촘하게 짜놨던터라 리팩토링 과정에서 공통화의 오류가 있거나, 변수가 바뀌거나 하는 오류가

테스트 코드의 실행 과정에서 모두 걸러져 나올 수 있었다.

또한 예상된 결과 값이 이미 정해져 있고 중간 중간의 함수 호출의 기대 값도 체크하기 때문에 테스트 코드를 돌려보면

문제가 확실히 보였다. 기대 값에 대한 케이스도 여러가지로 준비되어 있기 때문에 특정 케이스만 통과하는 버그를

놓치지 않게 되었다.

 

코드 100줄을 기준으로 리팩토링을 진행한다고 하였을 때, 수정 후 빌드 확인을 10번 이상 하던 것을 5번 이하로 낮출 수 있었고 확인 후 수정까지 시간 또한 채감상 절반 이하로 줄어들었다.

물론 테스트 코드에 대한 숙련도에 따라 시간과 횟수가 달라지겠지만, 과거 빌드의 오류를 보고 직접 코드의 흐름을 머리 속에서 확인 할 때 혹은 직접 값을 하나 하나 넣어보고 나온 결과 값을 본 뒤 문제를 찾아낼 때와는 비교도 안되게 빠르게 확인 후 수정이 가능하였다.

 

그리고 문제점을 확실하게 찾아내 줄 수 있는 수단이 존재하니 코드 수정에 대한 피드백도 빠르게 가져갈 수 있었다.

리펙토링 과정에서 기존 코드를 쉽게 바꾸기 어려웠던 이유가 코드 수정 후 바뀐 로직으로 기대 값이 달랐을 경우 원인을 찾고 사이드 이펙트를 찾는데 시간이 오래 걸렸기 때문이다. 그래서 한번 코드를 수정하는 것이 조심스러웠다.

테스트 코드를 돌려보면 기존 로직의 상황 별 기대 값이 이미 정해져 있기 때문에 수정 후의 피드백이 빨랐고, 빠르게

문제 수정을 할 수 있어서 기존 코드를 수정하고 공통화하는 작업을 진행의 부담이 줄어들었다.

 

2. 테스트 코드를 짜면서 불필요한 코드나 중복 코드, 잘못 된 로직에 대해 빠르게 찾을 수 있음

 

이 부분은 1번의 장점과도 연관이 있는데 테스트 코드를 짜면 코드에 대한 이해도가 더욱 상승한다.

테스트 코드를 짜기 위해선 기존 로직을 들여다 보고 모든 케이스에 대한 가정을 하나하나 정리할 수 밖에 없다.

모든 케이스를 정리하고 하나하나의 테스트 케이스를 만들어 테스트 코드를 만들다 보면 기본 로직에 대한 이해도가

높아지면서 코드에 대한 문제점을 더욱 정확히 파악하게 된다. 이해도가 높아지니 불필요한 코드나 중복 코드를 쉽게

찾아내게 되었다.

그리고 기존에는 기본 기능 위주로 코드를 보았는데 테스트 코드를 만들다 보니 기대 값을 위주로 코드를 보게 되었다.

그덕분에 놓치고 있던 로직의 문제와 버그 들을 테스트 케이스를 만들면서 찾아 낼 수 있었다.

 

단점

 

1. 테스트 코드를 짜는데 시간이 많이 걸리고 짜기가 귀찮음

말 그대로 테스트 코드 짜는데 시간이 오래 걸린다.

모든 케이스를 다시 생각해야 되며 예외 케이스를 모두 고려해 테스트 케이스를 준비해야 되기 때문에 시간이 걸린다.

또한 이미 기능이 구현된 코드를 다시 의심하면서 케이스를 만드는 작업이 귀찮고 하기 싫다.

시간이 많이 걸리는 문제는 익숙해 질수록 테스트 코드의 작성 속도가 빨라져 해결되었고

짜기 귀찮은 이유는 QA까지 완료하고 나서 문제가 없다고 생각한 코드를 보며 테스트 코드를 만들려고 하다 보니 더욱 그런 것 같았다.

만약 TDD로 처음부터 시작하고 진행하였다면 달랐겠지만... 아직 클린 아키택처와 단위테스트의 도입에 급급하기에

적응이 되어야 제대로 된 DDD 그리고 추후에 TDD까지 도입 후 적응할 수 있을 것으로 보인다.

TDD가 적응된다면 충분히 개발 러닝타임에 문제가 없이 적용이 가능할 것이라 생각한다.

2. 코드가 바뀌면 테스트 코드도 다 바뀔 가능성이 큼

TDD가 아닌 방법으로 개발을 하였기에 기본 기능이 완성 된 후 테스트 코드를 짜기 시작했다.

때문에 객체나 파라미터들이 바뀌면 테스트 코드 전체가 바뀌는 경우가 많았다. 또한 기획이 바뀌는 경우가 생기면 기존 코드 뿐만 아니라 테스트 코드까지 바꿔야 되는 문제가 생겼다. 이는 코드를 수정하는데 드는 리소스가 늘어나는 문제가 만들었다.

이 부분은 코드의 캡슐화나 공통화, 테스트 코드의 파라미터 전역화 등 다양한 방법으로 테스트 코드의 수정 범위도 줄일 수 있을 것으로 보인다. 설계의 영향도 많이 받기에 때문에 초기의 기능 설계에서부터 테스트 코드를 염두하여 설계하는 것도 필요해 보인다.(TDD)

 

테스트 코드의 도입의 성과에 대한 의견

 

첫 기능을 구현하고 그 기능에 대한 테스트 코드를 작성하였을 때에는 유용성에 대해 크게 느낀 바가 없었다.

그러나 리팩토링을 진행하고 코드를 수정하는 과정에서 테스트 코드의 중요성을 느끼게 되었다.

분명 테스트 코드를 작성하고 구현하는데 리소스가 발생하지만 리팩토링과 유지보수의 장점이 너무도 효율적이었다. 부가적으로 빌드 배포 과정에서의 오류 또한 잡아낼 수 있다는 것도 코드에 대한 신뢰성을 높일 수 있을 것으로 보인다.

프로젝트가 크고 로직이 복잡하거나 확장 가능성이 농후하다면 테스트 코드의 효용성이 더욱 높아질 것이라고 생각이 든다.

Contents