코드를 추가하는 깔끔한 방법
코드변경을 위해 의존 관계를 제거하고 테스트 루틴을 작성하는 작업은 처음에는 많은 시간을 낭비하는 것처럼 보일 수 있지만 결국에는 추가 개발 시간과 코드 변경에 따른 시행착오를 줄여줍니다.
특히 테스트코드 작성이 본 코드 작성보다 길어지는 경우, 시간낭비처럼 느껴질 수 있지만 오히려 그 시간 동안 꼼꼼히 추가 / 변경된 코드를 감지 했다는 점에서, 잠재적인 장애 상황을 회피하고 추가시간을 들이지 않았다는 점에서 오히려 더 시간을 절약했다고 볼 수도 있습니다.
또한 테스트 코드가 테스트 대상 코드의 내용을 설명하는 일종의 문서로도 활용 될 수 있어 오랜시간 뒤에 해당 코드를 수정해야하는 상황에 닥치더라도 해당 코드를 금방 이해하고 또한 수정에 대해 안정성을 보장 받을 수 있기 때문에, 마찬가지로 시간을 절약했다고 볼 수 있습니다.
대부분의 작업에서 부족한 일정으로 인해 테스트코드의 작성과 리팩토링 등을 미루고 작업을 하는 경우가 많은데, 나중에 하겠다고 하더라도 실제로 그러한 미뤄둔 일을 하게 되는 경우가 많지 않기 때문에, 최대한 바로바로 작업하는 것이 좋습니다.
Sprout Method
발아메소드는 시스템에 새로운 기능을 추가해야 하는데, 이 기능을 새로운 코드로 표현할 수 있다면, 새로운 메소드로서 이 기능을 구현한 후 이 메소드를 필요한 위치에서 호출하는 방법을 사용할 있습니다.
독립된 한개의 기능으로서 코드를 추가하는 경우나 메소드의 테스트 루틴이 아직 준비되지 않은 경우에 발아메소드 사용이 적절합니다.
클래스의 의존 관계가 너무 복잡해서 많은 수의 생성자 인수를 모방하지 않으면 인스턴스 생성이 어려운 경우가 있습니다. 이럴 땐 null값을 전달하면 됩니다.
작성 방법
- 어느부분에 코드 변경이 필요한지 식별합니다.
- 메소드 내의 특정 위치에서 일련의 명령문으로서 구현할 수 있는 변경이라면 필요한 처리를 수행하는 신규 메소드를 호출하는 코드를 작성한 후 주석 처리 합니다.
- 호출되는 메소드가 필요로 하는 지역 변수를 확인하고, 이 변수들을 신규 메소드 호출의 인수로서 전달합니다.
- 호출하는 메소드에 값을 반호나해야 하는지 여부를 겨렂ㅇ합니다. 값을 반환해야 한다면, 반환 값을 변수에 대입하도록 호출 코드를 변경합니다.
- 새롭게 추가되는 메소드를 테스트 주도 개발 방법을 사용해 작성합니다.
- 앞서 주석 처리했던 신규 메소드르 호출 코드의 주석을 제거합니다.
장점
- 기존 코드와 새 코드가 명확하게 분리되어 테스트를 진행하기 용이합니다.
단점
- 원래 메소드에 대해 개선이 이루어지지 않습니다.
Sprout Class
앞서 언급한 발아메소드를 적용하기에 의존관계가 복잡한 경우에 사용합니다. 변경에 필요한 기능을 별도의 클래스로 분리한 후 이 클래스를 기존 클래스에서 사용하게 합니다.
따라서 기존 클래스에 새로운 기능을 추가하고 싶거나 기존 클래스에 약간의 기능을 추가하고 싶지만 해당 클래스를 테스트 코드에서 테스트 할 수 없는 경우에 사용할 수 있습니다.
다만 새로운 기능이 기존 클래스의 기능과 얼마나 동떨어져 있는지도 고려가 필요합니다.
작업 방법
- 어느 부분의 코드를 변경해야 하는지 식별합니다.
- 메소드 내의 특정 위치에서 일련의 명령문으로서 변경을 구현할 수 있다면, 변경을 구현할 적당한 클래스 이름을 생각합니다. 그리고 해당 위치에 새 클래스의 객체를 생성하는 코드를 삽입하고 메소드를 호출 하는 코드를 작성합니다.
- 호출 메소드의 지역 변수 중에 필요한 것을 결정하고, 이 변수들을 클래스의 생성자가 호출될 때 파라메터로 만듭니다.
- 발아클래스가 호출 메소드에 결과 값을 반환 해야할지 판단합니다. 값을 반환해야하는 경우에는 그 값을 제공할 메소드를 클래스에 추가합니다. 그리고 해당 메소드를 호출 해 반환값을 받아오는 코드를 추가합니다.
- 새로운 클래스를 테스트 주도 개발로 작성합니다.
- 앞서 주석 처리했던 주석을 제거하고, 객체 생성과 호출을 활성화합니다.
장점
- 코드를 직접 재작성하는 경우보다 확신을 가지고 변경 작업을 진행할 수 있습니다.
- C++ 환경에서는 기존 헤더파일을 건드리지 않고 변경작업을 수행할 수 있고 신규 클래스의 헤더 파일은 기존 클래스의 구현파일에 포함시킬 수 있습니다. 따라서 컴파일의 부담을 줄일 수 있습니다.
단점
- 클래스가 분리된 만큼 매커니즘이 복잡해집니다.
Wrap Method
기존 메소드에 동작을 추가하는 것은 간단한 일이지만 실제로는 옳지 않은 경우가 많이 있습니다. 처음 메소드가 작성될 때는 해당 기능을 위해서만 설계되었기 때문입니다.
따라서 기존 메소드와 동일한 이름을 가지는 메소드를 추가하고 주요 기능은 원래메소드에 위임하고 새로운 메소드에 새로운 기능을 추가하는 포장메소드 패턴으로 해소 할 수 있습니다.
작성 방법
- 변경해야 할 메소드를 식별합니다.
- 변경이 메소드 내의 특정 위치에서 일련의 명령문으로 구현이 가능하면, 메소드 이름을 바꾸고 기존 메소드와 동일한 이름과 시그니처를 가지는 메소드를 생성합니다.
- 새로운 메소드에서 기존의 메소드를 호출합니다.
- 새로운 기능을 위한 메소드를 TDD로 개발하고 2번에서 만든 메소드를 호출합니다.
그리고 기존 메소드의 이름을 유지하지 않아도 되는 경우에는 다음과 같이 작성 할 수 있습니다.
- 변경해야 할 메소드를 식별합니다.
- 변경이 메소드 내의 특정 위치에서 일련의 명령문으로 구현이 가능하면, 변경을 구현할 메소드를 테스트 주도 개발에 의해 새로 작성합니다.
- 새 메소드와 기존 메소드를 호출하는 별도의 메소드를 작성합니다.
장점
- 호출을 수행하는 코드가 테스트코드를 작성하기 쉽지 않을 때, 테스트를 마친 코드를 추가하기에 용이합니다.
- 포장메소드는 기존 코드를 유지합니다.
- 신규 기능이 기존 기능과 독립적으로 만들어집니다. 따라서 다른코드와 섞이지 않습니다.
단점
- 기존 메소드의 이름을 바꾸면서 기존의 역할과 맞지 않거나 혹은 억지스러운 이름으로 바꾸게 되어 코드의 가독성이 저하될 수 있습니다.
Wrap Class
포장메소드를 클래스 레벨로 확장한 개념입니다. 데코레이터 패턴의 포장클래스를 만들어 기존 클래스를 감싸 기능을 확장합니다.
경우에 따라서는 여러개의 포장 클래스를 중첩해 안전하게 여러개의 기능을 추가할 수도 있습니다.
작성 방법
- 어느 부분의 코드가 변경이 필요한지 확인합니다.
- 변경이 특정 워치에서 일련의 명령문으로 구현될 수 있다면, 포장 대상 클래스를 작성자의 인수로서 받는 클래스를 작성합니다. 기존 클래스를 포장하는 크래스를 테스트 코드에서 생성하기 어려울 경우에는 구현체 추출 혹은 인터페이스 추출 기법으로 생성하기 쉽게 변경합니다.
- TDD를 활용하여 포장 클래스에서 새로운 처리를 수행하는 메소드를 작성합니다. 이 메소드에서 신규 메소드와 포장된 클래스의 원래 메소드를 호출합니다.
- 새로운 동작이 수행될 위치에서 포장 클래스의 인스턴스를 생성합니다.
Summary
위에서 언급한 4가지 방법을 통해 기존 코드에 테스트코드를 추가하지 않고 새로운 기능을 추가하는 방법을 알 수 있었습니다. 많은 경우에 이러한 기법을 이용해 새로운 책임과 기존의 책임을 분리할 수 있게 됩니다.
Sprout method vs Wrap method
기존 메소드에서 새로운 기능을 호출해야하는 경우에는 발아 메소드를 사용하고 새로운 메소드에서 신규 기능과 원래 기능을 같이 호출 할 때는 포장 메소드를 사용할 수 있습니다.
혹은 새로운 기능이 기존 기능 중간에 추가되어야 하는 경우에는 발아메소드, 새로운 기능이 메소드의 시작 / 끝에서 적용이 가능한 경우라면 포장메소드를 고려해 볼 수 있습니다.
Wrap class
포장 클래스를 사용 하는 경우는 다음과 같습니다.
- 추가하려는 동작이 완전히 독립적이며, 구현에 의존적인 동작이나 관련없는 동작으로 기존 클래스를 변경하고 싶지 않은 경우
- 클래스가 이미 커서 더 이상 확장하고 싶지 않을 때, 추후 변경에 대해 대비하는 용이하도록
2번의 경우 미리 협업자와의 토의를 통해 방향성을 공유하는 것이 좋습니다.
작업시간이 길어지는 이유
프로젝트를 진행하면서 코드의 양은 꾸준히 늘어납니다. 그로인해 코드 전체를 이해하기가 어려워지고 그로 인해 새로운 기능을 구현하는 데에 있어 걸림돌이 됩니다. 이는 꾸준히 관리는 시스템도 마찬가지인데, 특히나 레거시라면 전체 코드를 완전히 파악하기는 매우 어렵고 변경자체도 어렵습니다.
따라서 처음 코드를 작성하는 과정에서 적당한 크기를 유지하고 역할에 맞는 적당한 이름을 가지며 제대로 모듈화 되도록 코드를 작성할 필요가 있습니다.
Lag Time
지연 시간은 변경을 수행한 시점과 그 변경을 수행한 시점과 그 변경에 대한 실질적인 피드백을 받을 때까지의 시간을 말합니다. 가장 간단한 예로 테스트 코드를 실행하고 성공여부를 확인하기 위해 걸리는 컴파일 시간을 들 수 있습니다. 따라서 우리는 빌드 횟수를 줄이기 위해 변경 대상들을 붙여 빌드합니다.
이러한 컴파일 시간을 줄이기 위한 가장 이상적인 방법은 최대한 독립적인 모듈로 분리하여 개별 테스트코드 별로 빌드할 수 있게 하는 것입니다.
아무리 간단한 테스트라도 지연시간이 길 경우 실제 업무에서 테스트 코드를 실행하지 않게 됩니다. 따라서 지연시간을 줄이는 것도 상당히 중요하다고 할 수 있습니다.
앞에서도 언급했지만 필연적으로 컴파일 언어를 사용하는 경우에 발생하게 됩니다. 따로 분리가 이루어지지 않았다면 전체코드에 대해 컴파일을 진행하기 때문입니다.
Breaking Dependencies
의존관계 확인
객체 지향 시스템에서 빠른 빌드가 필요한 모듈이 있는 경우 해당 모듈이 어떤 종류의 의존관계를 가지는지 확인이 필요합니다.
만약 각 객체간의 의존관계가 있어 재컴파일 되는 경우에는 의존관계의 클래스로부터 인터페이스를 추출해야 합니다. 이러한 인터페이스 추출기능은 IDE에서 지원하기 때문에, 손으로 직접하는 것 보다는 툴을 사용하는 것을 권장합니다.
클래스 집합을 테스트 코드로 보호한 후에는 빌드가 용이하도록 프로젝트의 물리적 구조를 분리하는 방법도 있습니다. 말 그대로 클래스 집합을 별도의 새 패키지나 라이브러리 형태로 분리하여 구조를 변경할 수도 있습니다. 이로 인해 전체 빌드시간은 늘어날 수 있지만 개별 패키지의 빌드에 대해서는 시간이 많이 줄어 들기 때문에, 개발 과정에서 테스트코드를 실행하여 피드백을 받는데에 부담이 줄고 그로인해 버그의 감소와 개발 속도 개선이라는 장점을 얻을 수 있습니다.
Summary
변경을 빠르고 유연하게 하기 위해서는 앞에서 언급한대로 테스트코드의 실행을 용이하게 정리할 필요가 있습니다.
의존관계를 줄이는 것이 단순히 코드를 깔끔하게 만드는것 외에 컴파일을 빠르게 해주는 부가적인 효과를 얻을 수 있기 때문에 꼭 레거시를 처리할 때 뿐만 아니라 코드를 새로 작성하는 데에 있어서도 깊게 고민해봐야하는 부분입니다.
코드를 추가하는 깔끔한 방법
기능을 추가할 땐 여러가지 방법이 있지만 설계 방식이나 제약 조건과 무관하게 적용할 수 있는 방식이 있습니다.
레거시코드를 다룰 때 가장 중요한 사항은 테스트코드가 없다는 점입니다. 게다가 테스트코드를 추가하기 어려운 경우도 있습니다. 이럴 때 앞서 이야기 했던 발아메소드, 포장메소드 등의 패턴을 적용할 수 도 있는데, 아무래도 기존의 코드에는 별다는 개선이 이루어지지 않기 때문에, 장기적으로 개선이라고 보기 어렵습니다.
따라서 아래의 기법들을 통해 레거시코드도 마찬가지로 개선되는 방향으로 작업하는 것이 바람직합니다.
Test-Driven Development (TDD)
TDD는 가장 강력한 기능추가 기법으로 볼 수 있습니다. 문제를 해결하는 메소드를 상상하고 이에 대해 실패하는 테스트코드를 작성하고 이를 성공할 수 있도록 실제 코드를 구현하여, 앞으로 개발할 코드가 실제로 어떤 기능을 하는지 이해하게 됩니다.
순서
- 실패하는 테스트 케이스를 작성합니다.
- 컴파일 합니다.
- 테스트를 통과합니다.
- 중복을 제거합니다.
- 반복합니다.
1에서 계산을 수행하는 메소드는 아직 존재하지 않더라도 해당 메소드를 테스트하는 테스트 케이스는 작성 할 수 있습니다.
그리고 4번의 중복제거는 매우 중요한 작업입니다. 코드의 복사 붙여넣기를 통해 쉽고 빠르게 기능을 추가할 수 있지만, 중복 코드로 인한 잠재적인 버그와 이를 관리하기 위한 관리비용이 커질 수 있습니다.
Programming by Difference
객체지향 시스템에서는 다른 방식의 기능추가 기법을 적용할 수도 있습니다. 바로 상속입니다. 최근에는 상속의 부작용으로 인해 많이 사용되지는 않는 기법이지만 그럼에도 불구하고 간단한 기능확장에 있어서는 유용한 기법입니다.
기능추가를 원하는 클래스가 있다면 해당 클래스를 상속하여 변경이 필요한 메소드를 오버라이딩하여 새로운 기능으로 덮어쓰는 형태로 구현됩니다.
아래는 그에 대한 예제입니다.
1 | class BaseTarget { |
위의 예제처럼 기존 클래스에 기능을 확장할 수 있도록 상속한 클래스를 생성하고 원래 테스트코드에서는 BaseTarget 객체의 생성을 ReturnTenBaseTarget 객체의 생성으로 교체해 별다른 추가 코드의 수정 없이 반영한 것을 확인할 수 있습니다.
만약 여기서 targetMethod의 결과를 param1의 10배의 값을 리턴해야하는 기능을 추가하고 싶다면 마찬가지로 BaseTarget 클래스를 상속하는 클래스를 만들어 ReturnTenBaseTarget처럼 구현합니다.
다만 두 클래스의 기능을 합치고 싶다면 어떻게 해야할까요?
홀수일 때는 param1 의 10배, 짝수일 때는 10, 0일 때는 1을 리턴한다고 한다면 지금과 같은 구조에서는 클래스를 하나 더 추가하는 방향으로 가야합니다.
이런식으로 되면 코드의 재사용적인 측면에서 좋지 않을 수 있고 불필요한 클래스가 많이 늘어난다는 문제점도 있습니다.
따라서 이런 경우가 쌓일 때 원본 코드를 리팩토링하여 여러 역할을 할 수 있도록 개선하는 것이 좋습니다.
원본 코드를 리팩토링하여 데이터를 새로운 테스트코드에 적용하여 테스트를 통과하는지 확인하고, 기능이 머지된 자식클래스는 제거하는 순서로 원래 클래스에 기능을 추가하는 방향으로 가면 문제를 해소 할 수 있습니다.
리스코프 치환 원칙
리스코프 치환 원칙은 특정 기능을 구현하는 클래스가 다른 자식클래스로 교체되어도 기존의 기능을 유지해야한다는 원칙입니다.
다만 위 예제의 경우 기능별로 자식클래스가 나뉘기 때문에, 이를 지킨다고 보기 어렵습니다. 다만 이를 최대한 지키기 위한 원칙은 다음과 같습니다.
- 가급적 implementation 클래스를 상속하지 않습니다.
- 불가피하게 실체 메소드를 재정의하는 경우 그 메소드 내에서 재정의 대상 메소드를 호출 할 수 있는지 확인합니다.
2번에서 언급한대로 만약 실체 메소드를 재정의하는 경우 원래 의도했던 동작을 안 하게 되어 전반적인 동작이 안될 수도 있습니다.
따라서 재정의가 필요한 메소드의 경우 abstract 메소드 혹은 인터페이스를 통해 정의하고 이를 상속 혹은 구현하여 기능을 확장하는 정규화된 계층구조를 마련하여 항상 이 구조를 잘 유지하는지 체크하는 것이 좋습니다.
Summary
위 기법들을 사용하면 테스트코드를 보호받는 모든 코드에 새로운 기능을 추가할 수 있게 됩니다. 다만 무작정 작성하는 것 보다는 여러 객체지향 규칙을 지키는지 검토하면서 진행할 필요가 있습니다.
출처
이 글은 레거시 활용 전략의 6-8장의 내용의 내용을 정리했습니다.