본문 바로가기

.Net Technology/.NET TDD

(16) TDD를 위한 객체지향 - 실전! 솔리드 원칙들의 도입

지금까지 살펴본 내용을 실제 예제를 통해서 실전 감각을 익혀보도록 하겠다. 먼저 아래와 같은 프로그램을 설계하고 개발해야 된다고 가정해보자. 
 
편의점 카운터에서 물건을 스캔한 뒤에 고객에게 최종 가격을 알려주는 프로그램이 필요합니다. 물건들의 가격은 데이터 베이스에 있으며, 한가지 특별한 것은 현재 모든 물건을 2개 사면 하나 더 주는 이벤트를 하고 있기 때문에 이 로직을 반영해주세요.
 
위의 요청 사항을 보다 간단히 생각하고 구현해 보도록 하겠다. 우리는 하나의 메서드를 제공해 줄 것이다. 즉, 실행 프로그램은 List<string>배열로 이루어진 각 물건 코드들의 리스트를 전달하면 각 물건의 가격들을 합산한 가격을 알 수 있으면 되는 것이다. 단, 같은 코드가 3개가 되면 하나의 물건 가격은 제외되어야 한다.
 
먼저 필자의 코드를 바로 확인하기 전에 자신이 직접 위의 문제를 생각해보고 직접 코드를 작성해보고 코드를 비교하는 것을 추천하고 싶다. 그럼 먼저 TDD를 고려하지 않은 전혀 객체지향적이지 않은 코드를 먼저 보여주고 한 스텝씩 그 코드를 고쳐 나가 보도록 하겠다.
 
먼저 실제 물건의 가격을 가져오는 역할을 담당하는 PriceRepository를 아래와 구현할 것이다. 
 
public class PriceRepository
{
    public decimal GetPrice(string code)
    {
        //From Database
        return 40.0m;
    }
}
 
이제 이 리파지터리를 이용해서 실제 로직을 구현하면 되는데 CheckOutManager라는 클래스를 만들어서 ScanAll 이라는 기능을 통해서 가격을 합산할 것이고 Total 이라는 프로퍼티를 만들어 합산된 가격을 반환할 수 있게 만들어 볼 것이다.
 
public class CheckoutManager
{
    private decimal _total;
    public decimal Total {
        get { return _total; }
        set { _total = value; }
    }

    public void ScanAll(List<string> arrItems)
    {
    }
}
 
구조가 이렇게 만들어졌으면 이제 ScanAll 이라는 메서드를 아래와 같이 한번 구현해 보도록 하겠다. 
 
public void ScanAll(List<string> arrItems)
{
    var priceRepo =new PriceRepository();
    var dicItemCount =new Dictionary<string, int>();
    foreach (var code in arrItems)
    {
        //가격 계산
        decimal price = priceRepo.GetPrice(code);
        _total += price;
        //2개 이상 살 경우 1개 무료 확인
        if (!dicItemCount.ContainsKey(code))
        {
            dicItemCount.Add(code,1);
        }
        //3번째는 무료 아이템
        if (dicItemCount[code] == 3)
        {
            dicItemCount[code] = 0;
            _total -= price;
        }
    }
}
 
여기서 먼저 PriceRepository를 생성하였다. 그리고 dicItemCount 라는 컬렉션을 생성하여 실제 같은 아이템을 몇개 구입하는지 저장하여 만약 3개가 되면 그 가격을 할인 해주고 다시 카운트를 0으로 리셋시키는 코드를 아래에 구현하였다. 
 
만약 필자가 객체지향에 대한 개념과 필요성에 대해서 큰 인지가 없었다면 이런 코드가 충분히 생산되었을 것이다. 이 코드는 앞에서 살펴본 다섯가지 규칙들에서 세 가지를 벗어나고 있다. 그럼 하나씩 살펴보면서 수정해 보도록 하자. 
 
 
하나의 기능으로 구성하기
 
먼저 얼핏 보기에는 PriceRepository를 별도로 생성하여 지원했기 때문에 각 기능별로 나눈 것처럼 보일 수 있다. 하지만 ScanAll 메서드에서 우리는 두 개의 기능을 동시에 사용하고 있다. TDD로 예를 들자면 우리는 두 가지 기능을 한 메서드에서 테스트 해야한다는 것이다.
 
하나는 값이 잘 저장되는지를 테스트 해야 하는 것이고 다른 하나의 요소는 실제 가격을 할인해주는 로직을 테스트 해야 하는 것이다. 뿐만 아니라 향후에 다른 할인 로직이 추가될 가능성이 다분히 높다. 만약 3개를 사면 1개를 공짜로 주는 로직으로 변경된다고 할 경우에 이 로직이 분리되지 않는다면 다른 기능에 영향을 줄 수도 있을 뿐더러 향후 문제가 있을 경우에 어디서 문제가 발생했는지 정확하게 말해주는 것이 어렵다. 
 
위의 문제를 해결하기 위해서 DiscountManager를 만들어서 위의 기능을 분리해야 한다. 필자는 아래와 같이 클래스와 메서드를 구현했다. 
 
public class DiscountManager
{
    public Dictionary<string, int> dicItemCount = new Dictionary<string, int>();
    public decimal GetDiscount(string code, decimal price)
    {
        if (!dicItemCount.ContainsKey(code))
        {
            dicItemCount.Add(code, 1);
        }
        //3번째는 무료 아이템
        if (dicItemCount[code] == 3)
        {
            dicItemCount[code] = 0;
            return price;
        }
        return 0;
    }
}
 
GetDiscount 로직은 이미 ScanAll 메서드에서 구현한 내용을 그대로 옮겼을 뿐이다. 다만 dicItemCount라는 전역 변수로 구분한 부분이 다를 수 있다. 이 메서드에서는 할인되는 가격을 반환하게 된다. 때문에 아무 할인이 없는 경우에는 0을 반환하게 된다. 그럼 ScanAll 함수를 변경해 보도록 하자.
 
public void ScanAll(List<string> arrItems)
{
    var priceRepo =new PriceRepository();
    var disManager = new DiscountManager();
    foreach (var code in arrItems)
    {
        //구매
        decimal price = priceRepo.GetPrice(code);
        _total += price;
        //할인 적용
        _total += disManager.GetDiscount(code, price);

    }
}
 
코드가 보다 간결해졌다. 이유는 기능을 두가지로 구분해서 사용하고 있기 때문이다. 하나는 PriceRepository에서 값을 가져오고 할인은 모두 DiscountManager에서 진행하고 있기 때문이다. 하지만 여전히 개선할 사항들이 많이 있다. 그럼 다음 원칙으로 넘어 가도록 하자. 
 
<팁: 시작 – double과 decimal의 차이 >
기본 프로그래밍의 경험이 있는 사람은 돈을 관리할때 당연히 decimal 타입을 이용해야 한다는 것 정도는 알 것이다. 그렇다면 왜 double이 아니라 decimal일까? 그 이유는 double은 최대 길이를 가지고 있기 때문이다. 예를 들면 은행에서 굉장히 큰 이율을 계산할때 정말 작은 소수점자리의 값을 저장해야 되는 경우가 있는데 double은 그런 부분에서 약간의 오차가 생기기 마련이다. 하지만 decimal은 문자열 기반으로 작성되기 때문에 자리수와 상관없이 값을 보다 정교하게 저장하고 불러오는 것이 가능하다.
<팁: 끝>
 
 
인터페이스의 분리와 의존성 제거
 
먼저 각각의 클래스들을 인터페이스를 이용해서 기능을 분리하는 작업을 진행할 필요가 있다. 이유는 기능별로 필요한 기능만 할당해 주는 것일 수도 있지만 여기서는 의존성을 제거해주기 위한 목적이 더 크다. 필자는 아래와 같은 인터페이스들을 추가하였다. 
 
public interface IPriceRepository
{
    decimal GetPrice(string code);
}
public interface IDiscountManager
{
    decimal GetDiscount(string code, decimal price);
}
 
그리고 각각의 클래스들은 위의 인터페이스를 상속 받았다. 
 
public class DiscountManager : IDiscountManager
{
}
public class PriceRepository:IPriceRepository
{
}
 
이제 마지막으로 ScanAll 메서드에서 참조하는 로직을 수정해야 한다. 먼저 기존의 코드에서는 다음과 같이 메서드 안에서 객체를 초기화 하였다.
 
public void ScanAll(List<string> arrItems)
{
    var priceRepo = new PriceRepository();
    var disManager = new DiscountManager();
}
 
우리가 인터페이스를 사용한다고 하더라도 실제로 객체초기화를 내부 로직에서 하게 된다면 여전히 객체들의 의존성은 존재하게 된다. 때문에 우리는 이 부분을 전역변수로 선언함과 동시에 생성자를 이용해서 해결해 보도록 하겠다. 필자는 아래와 같이 다음 함수들을 전역 변수로 선언하였고 이 변수들을 생성자를 통해서 초기화 하였다.
 
public IPriceRepository priceRepo;
public IDiscountManager disManager;

public CheckoutManager(IPriceRepository priceRepo,IDiscountManager disManager )
{
    this.priceRepo = priceRepo;
    this.disManager = disManager;
}
 
이렇게 하면 우선적으로 솔리드 원칙들에 대해서는 어느정도 도입이 끝나게 된다. 최종 코드는 아래와 같다. 
 
public interface IPriceRepository
{
    decimal GetPrice(string code);
}

public interface IDiscountManager
{
    decimal GetDiscount(string code, decimal price);
}

public class DiscountManager : IDiscountManager
{
    public Dictionary<string, int> dicItemCount = new Dictionary<string, int>();

    public decimal GetDiscount(string code, decimal price)
    {
        if (!dicItemCount.ContainsKey(code))
        {
            dicItemCount.Add(code, 1);
        }
        //3번째는 무료 아이템
        if (dicItemCount[code] == 3)
        {
            dicItemCount[code] = 0;
            return price;
        }
        return 0;
    }
}

public class PriceRepository : IPriceRepository
{
    public decimal GetPrice(string code)
    {
        //From Database
        return 40.0m;
    }
}

public class CheckoutManager
{
    public IPriceRepository priceRepo;
    public IDiscountManager disManager;

    public CheckoutManager(IPriceRepository priceRepo, IDiscountManager disManager)
    {
        this.priceRepo = priceRepo;
        this.disManager = disManager;
    }

    private decimal _total;

    public decimal Total
    {
        get { return _total; }
        set { _total = value; }
    }

    public void ScanAll(List<string> arrItems)
    {
        foreach (var code in arrItems)
        {
            //구매
            decimal price = priceRepo.GetPrice(code);
            _total += price;
            //할인 적용
            _total += disManager.GetDiscount(code, price);

        }
    }
}
 
위의 코드를 살펴보면 아직도 부족한 부분이 많이 있다. 즉, 솔리드 컨셉에서 다듬어 줄 수 없는 것들을 보안해 보도록 하자.
 
접근 제한자의 설정
 
public private와 같은 접근제한자들은 거의 프로그래밍 처음 시작 단계에서 배우는 개념이다. 하지만 실제 프로그래밍에서 그 중요성이 많이 느끼지 못해서 그런지 허술하게 적용하게 되는 경우가 많다. 실제로 모든 변수들을 public으로 설정해도 큰 문제가 없이 프로그램을 개발할 수 있기 때문이다. 하지만 프로그램에서 접근제한자는 복잡한 컴포넌트를 만들거나 로직을 만들 때 그 복잡성을 줄여주는데 큰 역할을 한다. 즉, 큰 규모에서의 소프트웨어를 만든다고 가정할 때 각 팀별로 만든 로직을 이용하는데 있어서 객체 내부적으로만 수정되어야 하는 값을 노출시켰고 그 변수를 접근하게 된다면 전혀 의도하지 않은 의존성이 만들어 지게 되는 것이다. 
 
접근 제한자는 모든 객체지향 프로그램의 기본이라고 강조해도 지나치지 않는다. 그럼 먼저 ChckoutManager에서 잘못 정의된 접근제한자를 다시 설정해 보도록 하자. 
public class CheckoutManager
{
    private IPriceRepository priceRepo;
    private IDiscountManager disManager;
}
 
DiscountManager 클래스에서 역시 수정해 보도록 하자.
public class DiscountManager : IDiscountManager
{
    private Dictionary<string, int> dicItemCount = new Dictionary<string, int>();
}
 
 
readonly의 정의
 
우리는 객체가 단일 성격의 역할을 하게 될 경우를 위해서 readonly 설정을 도입할 필요가 있다. 혹시나 다른 로직에 의해서 변경될 수 있는 가능성을 없애는 것이다. 먼저 CheckoutManager에서 생성자에 의해서 초기화 되는 IPriceRepository와 IDiscountManager는 한번 대입된 뒤에 다시 변경되어지면 안된다. 만약 변경되어 사용하려면 다른 객체를 생성하는 것이 객체지향 성격을 유지할 수 있다. 필자는 아래와 같이 정의를 다시 변경하였다. 
 
public class CheckoutManager
{
    private readonly IPriceRepository priceRepo;
    private readonly IDiscountManager disManager;
}
 
추가적으로 우리는 Total 이라는 프로퍼티도 외부에서 변경이 되지 않게 막아줘야 할 필요가 있다. 때문에 설정했던 setter 기능을 제거해 보겠다. 
 
public decimal Total
{
    get { return _total; }
}
 
이렇게 하면 코드들이 어느정도 자리가 잡히게 된다.
 
네이밍 규칙의 도입
 
네이밍 규칙에 대해서는 개인마다 또한 회사마다 원하는 표준을 정의하여 진행하게 되는 경우가 많다. 필자도 그 규칙들에 따라서 스타일을 많이 변경했지만 필자가 가장 선호하는 것은 닷넷 프레임워크가 사용하는 네이밍 규칙을 따라가는 것이다. 이유는 닷넷 개발자들이 가장 많이 사용하는 프레임워크이기 때문에 그만큼 익숙한 패턴이기 때문이다. 네이밍의 이유는 가독성과 생산성을 목표로 한다. 누구나 쉽게 이해해야 하는 것이다.
 
먼저 아래 [표-1]은 실제 MS의 개발팀에서 활동하고 있는 페페 브라운(Pepe Brown)이 밝히고 있는 내부 네이밍 규칙을 정리한 것이다. 
 
 
타입코딩 규칙적용예
클래스,
구조체,
컬렉션
언더바로 시작하는 것이 아닌 항상 대문자로 시작하며 두 개 이상의 단어가 결합될 경우에 각 단어들은 항상 대문자로 시작하는 파스칼 케이스를 이용한다. 단, I로 시작할 경우에 인터페이스 규칙과 혼란을 주기 때문에 이 경우에만 소문자 i 로 시작한다. 또한 주의할 것은 절대 약어를 이용하지 않는다.InstanceManager
XmlDocument
MainForm
델리게이트 클래스클래스와 같은 규칙을 이용하지만 단 뒤에 Delegate라는 단어를 추가한다.WidgetCallbackDelegate
예외 클래스클래스와 같은 규칙을 이용하지만 단, 뒤에 Exception이라는 단어를 추가한다.InvalidTransactionException
애트리뷰트 클래스클래스와 같은 규칙을 이용하지만 단, 뒤에 Attribute라는 단어를 추가한다.WebServiceAttribute
인터페이스클래스와 같은 규칙을 이용하지만 항상 I라는 알파벳을 단어 앞에 추가한다.IWidget
열거형(enum)클래스와 같은 규칙을 이용한다.
.
SearchOptions
AcceptRejectRule
메서드클래스와 같은 규칙을 이용한다. 단, 주의할 것은 많은 개발자들이 메서드를 약어를 이용해서 줄이는 경우가 많은데 메서드 또한 약어를 이용해서는 안된다.public void DoSomething()
프로퍼티,
public 변수
역시 클래스와 같은 파스칼 케이스를 이용한다.public int ItemCode
메서드의 파라메터첫 글자만 소문자로 쓰는 카말 케이스를 이용한다. 역시 이 타입도 약어로 줄여서는 안된다.string itemCode
메서드 안에 선언된 지역 변수메서드처럼 카말 케이스를 이용하지만 줄여 표현하는 것이 가능하다.string strName;
클래스 안에서 정의된 private 나 protected 전역변수이 경우에 실제 마이크로소프트 팀에서도 사용 표준이 조금 다르다. 카말 클래스를 이용하지만 이름 앞에 언더바(_)를 붙이는 경우도 있으며 m_ 을 붙이는 경우도 있다. 둘다 썩 명쾌한 방법은 아니지만 필자는 _ 언더바를 이용하는 것을 더 선호한다.private int _itemCode;
 
[표1] 마이크로소프트의 네이밍 기준
 
 
이제 위와 같은 표준을 적용하여 다시 코드를 적용해 보도록 하겠다. 아래 코드는 네이밍 규칙까지 적용한 최종적인 코드를 보여주고 있다. 
 
public interface IPriceRepository
{
    decimal GetPrice(string code);
}

public interface IDiscountManager
{
    decimal GetDiscount(string code, decimal price);
}

public class DiscountManager : IDiscountManager
{
    private Dictionary<string, int> _itemCountList = new Dictionary<string, int>();

    public decimal GetDiscount(string code, decimal price)
    {
        if (!_itemCountList.ContainsKey(code))
        {
            _itemCountList.Add(code, 1);
        }
        //3번째는 무료 아이템
        if (_itemCountList[code] == 3)
        {
            _itemCountList[code] = 0;
            return price;
        }
        return 0;
    }
}

public class PriceRepository : IPriceRepository
{
    public decimal GetPrice(string code)
    {
        //From Database
        return 40.0m;
    }
}

public class CheckoutManager
{
    private readonly IPriceRepository _priceRepo;
    private readonly IDiscountManager _disManager;

    public CheckoutManager(IPriceRepository priceRepo, IDiscountManager disManager)
    {
        this._priceRepo = priceRepo;
        this._disManager = disManager;
    }

    private decimal _total;

    public decimal Total
    {
        get { return _total; }
    }

    public void ScanAll(List<string> items)
    {
        foreach (var code in items)
        {
            //구매
            decimal price = _priceRepo.GetPrice(code);
            _total += price;
            //할인 적용
            _total += _disManager.GetDiscount(code, price);

        }
    }
}
 
이렇게 하여 리펙토링이 모두 끝났다. TDD를 이용할 경우에 설계만 진행하고 테스트 코드를 먼저 작성한 뒤에 실제 코드를 구현하게 되지만 이번 예제에서는 객체지향적인 코들르 개발하는 것이 목적이었기 때문에 테스트 코드는 작성하지 않았다. 지금은 이 코드가 잘 익숙하지 않다고 하더라도 테스트 코드를 적용하다 보면 자연스럽게 이러한 객체지향 개념을 도입하게 될 것이다. 
 
 
정리
 
이번 장에서는 솔리드 디자인 원칙들에 대해서 살펴보았다. 다섯가지의 규칙들을 다시 정리하자면 먼저 객체는 단 하나의 기능을 가지고 있어야 한다. 그리고 그 객체의 기능에 있어서는 항상 확장이 가능하게 오픈되어야 하지만 수정에 의해서는 폐쇄시켜야 한다. 그리고 객체들은 항상 최소한의 기능을 가지고 있어서 파생된 자식들에 대해서 대입이 가능해야 한다. 즉, 자신이 필요없는 기능임에도 불구하고 자식 클래스들을 위해서 가지고 있어서는 안된다는 것이다. 이러한 문제는 인터페이스의 분리를 통해서 제거할 수 있다. 마지막으로 우리는 객체들끼리 가지고 있는 강한 참조를 인터페이스를 통해서 느슨한 결합으로 변경하는 것을 살펴보았다. 
 
실제 편의점의 체크아웃 예제를 통해서 솔리드 디자인 컨셉을 어느정도 적용하는 예제를 살펴보았다. 단계별로 구조를 변경해 보았으며 추가적으로 솔리드 디자인 컨셉 이외의 규칙들도 적용해 보았다.