레거시 시스템의 결합도를 낮추는 방법
Table of contents
- 레거시 코드 개선 연습
- 문제점
- 커플링: 유일무이한 소프트웨어 설계 문제
- 시스템 디커플링
- 1 — 후행 테스트 작성
- 2 — 기존 기능을 커버하기 위한 테스트 작성
- 3 — 클래스명이 실제 개념에 일대일 대응되지 않음
- 4 — 클래스가 싱글턴 패턴을 사용함
- 싱글턴 패턴: 모든 악의 근원
- 5 — 모든 메서드에 동일한 매개변수를 사용함
- 누드 모델 - 파트 I: 세터(Setters)
- 날짜(Date)가 불변해야 한다는 것은 만고의 진리일까요?
- 6 — 디자인 패턴 찾기
- 7 — 교체 가능한 동작의 또 다른 패턴 고려하기
- 8 — Null 제거하기
- NULL: 10억 달러 짜리 실수
- 9 — 기본값 매개변수 제거하기
- 10 — 하드코딩된 상수 제거하기
- 11 — 로그 분리하기
- 12 — 객체 실체화하기(reify)
- 13 — 테스트 커버리지 완료
- 요약
- 결론
레거시 코드 개선 연습
좋은 설계를 위한 방법과 따라야 할 규칙을 설명하는 글들은 많습니다. 이 글에서는 레거시 설계를 개선하는 구체적인 예제를 살펴보고자 합니다.
문제점
기존 시스템의 다수는 결합(coupling) 문제가 있습니다. 이로 인해 유지보수성이 떨어집니다. 결합도가 높은 시스템을 변경하면 그 파급 효과 역시 큽니다.
커플링: 유일무이한 소프트웨어 설계 문제
다음과 같은 기존 프로세스를 가정해 보겠습니다.
다양한 알고리즘을 적용하여 지도 학습 모델의 하이퍼파라미터를 추론하는 시스템이 있습니다.
여기에 아래의 요구 사항이 새로 추가됩니다.
프로덕션 환경에서 각 전략의 성능 데이터를 실시간으로 확인할 수 있어야 합니다.
시스템 디커플링
먼저 프로세스 진입점을 살펴보겠습니다.
<?
StrategySupervisedHelper::getInstance()->optimize($processId);
… 지도 학습 클래스는 다음과 같습니다.
class StrategySupervisedHelper extends Singleton {
//..
}
그리고 호출된 메서드는 다음과 같습니다.
<? private function optimize($processId);
운영 중인 시스템을 개선하려는 경우, 먼저 현재 시스템의 테스트 커버리지를 파악해야 합니다. 이 시스템은 일련의 자동화된 단위 테스트와 기능 테스트를 갖추고 있습니다.
테스트 커버리지 측정을 위해 뮤테이션 테스트(Mutation Testing) 기법을 사용하겠습니다.
<?
private function optimize($processId) {
throw new Exception('Testing coverage');
}
불행히도 하나의 테스트가 실패하며, 해당 프로세스가 충분히 커버되지 않았다는 사실을 발견했습니다. 슬프게도 마이클 페더스(Michael Feathers)의 격언이 적용되는 상황입니다.
"레거시 시스템이란 테스트가 없는 시스템이다"
레거시 시스템을 리팩토링하는 전략은 변경을 가하기 전에 기존 기능을 충분히 커버하는 것입니다.
1 — 후행 테스트 작성
테스트를 작성하다보면 객체 간 좋은 설계 인터페이스가 드러납니다. 그러나 현재 솔루션과 결합 구조로 인해 테스트를 작성하는 것이 매우 어렵습니다.
그렇다고 이전 테스트를 작성하지 않은 채로 테스트 작성을 위한 리팩토링을 진행할 수는 없습니다. 악순환에 빠진 것 같네요.
이 교착 상태에 대한 가능한 해결책은 테스트를 선언적으로 작성하는 것입니다. 그렇게 하여 더 나은 인터페이스를 생성할 수 있습니다.
결합도가 해소될 때까지 테스트를 수동으로 실행합시다.
2 — 기존 기능을 커버하기 위한 테스트 작성
xUnit 계열의 툴을 사용하여 (항상 실패하는) 거짓 단언(false assertion) 테스트를 작성할 수 있습니다.
function testOptimizationIsGoodEnough(){
$this->assertTrue(false);
}
function testOptimizationBelowThreshold(){
$this->assertTrue(false);
}
...
필요한 경우들을 (현재로서는 수동으로) 커버한 후 리팩토링을 시작할 수 있습니다.
3 — 클래스명이 실제 개념에 일대일 대응되지 않음
헬퍼(Helper)는 현실 세계에 존재하지 않을 뿐더러 어느 계산 가능한 모델에도 존재해서는 안됩니다.
MAPPER에 따라 이름을 부여할 때, 클래스가 담당하는 역할을 생각해 봅시다.
<?
class SupervisedLearningAlgorithm extends Singleton {
}
현재로서는 클래스명이 적절합니다. 현실 세계에서 인스턴스가 어떤 책임을 가지는지에 대한 개념을 잘 전달하고 있습니다.
4 — 클래스가 싱글턴 패턴을 사용함
싱글턴 패턴을 사용할 타당한 이유가 없습니다. 싱글턴 패턴이 야기하는 여러 문제들에 대해 다음 글에서 설명하고 있습니다.
싱글턴 패턴: 모든 악의 근원
싱글턴 패턴은 getInstance()
로 호출을 강제하여 매우 구현중심적이며, 선언적이지 않습니다…
<?
SupervisedLearningAlgorithm::getInstance()->optimize($processId);
위 코드를 아래와 같이 변경할 것입니다.
?>
(new SupervisedLearningAlgorithm())->optimize($processId);
클래스 정의는 다음과 같습니다.
<?
class SupervisedLearningAlgorithm{
}
여기서 중요한 설계 규칙은 다음과 같습니다.
구현 클래스는 상속하지 않습니다.
만약 언어가 이를 허용한다면, 다음과 같이 명시적으로 선언하세요.
<?
final class SupervisedLearningAlgorithm{
}
5 — 모든 메서드에 동일한 매개변수를 사용함
객체가 생성되면 최적화할 프로세스의 식별자를 설정하는 매직 매개변수를 사용하게 됩니다. 마치 마법처럼 모든 메서드를 여행하죠.
이러한 코드는 코드 스멜(code smell)로, 해당 매개변수와 프로세스간 응집도를 확인해야 함을 시사합니다.
<?
final class SupervisedLearningAlgorithm{
public function calculate($processId){}
private function analize($processId){}
private function executeAndGetData($processId, bool $isUsingFastMethod = null){}
//... 등 등 등
}
여기서 일대일 대응을 살펴 보았을 때, 프로세스 없이 알고리즘이 존재할 수 없다는 결론을 내릴 수 있습니다. 이를 변경하기 위해 세터(setter)가 있는 클래스를 사용하고 싶지는 않습니다.
누드 모델 - 파트 I: 세터(Setters)
따라서 객체를 생성할 때 모든 필수 속성을 전달하도록 만들 것입니다.
어떤 속성이 필수적인지 판단하기 위해서는 해당 객체와 관련된 모든 책임을 제거해보면 됩니다. 만일 객체가 더 이상 맡은 책임을 수행할 수 없다면, 그 속성이 최소 속성 집합에 속한다고 볼 수 있습니다.
?>
final class SupervisedLearningAlgorithm{
public function __construct($processId){}
}
이러한 방식으로 객체를 본질적으로 불변하게 만들 수 있고 이에 따른 여러 장점을 얻을 수 있습니다.
날짜(Date)가 불변해야 한다는 것은 만고의 진리일까요?
6 — 디자인 패턴 찾기
이 프로세스는 현실 세계의 프로세스와 일대일 대응하도록 모델링합니다. 예제의 경우는 커맨드(Command) 패턴이 잘 맞아 보입니다.
그러나 사실은 알고리즘의 다른 단계들을 모델링하는, 순서대로 실행되는 메서드 객체에 더 가까운 것 같습니다.
7 — 교체 가능한 동작의 또 다른 패턴 고려하기
객체에 부여한 이름에서 알 수 있듯이 이 프로세스는 다른 다형적 전략들과 경쟁할 실행 전략을 모델링합니다. 전략(Strategy) 패턴을 의도한 것입니다.
?>
final class SupervisedLearningStrategy{
}
8 — Null 제거하기
Null을 사용하는 것에 타당한 이유는 없습니다. 현실 세계에는 Null이 존재하지 않습니다.
Null 사용은 일대일 대응 원칙을 위반하고, 함수 호출자와 인수간의 결합을 발생시킵니다. 또한 Null은 어떤 객체와도 다형성을 공유하지 않기 때문에 불필요한 if 문을 사용하게 합니다.
<?
private function executeAndGetData($processId, $isUsingFastMethod = null){
}
private function executeAndGetData($processId, bool $isUsingFastMethod = false){
}
위 코드를 아래와 같이 변경하여 인수값이 없는 경우를 boolean 값으로 처리할 수 있습니다.
NULL: 10억 달러 짜리 실수
9 — 기본값 매개변수 제거하기
이전 예제의 private 함수에는 기본값 매개변수를 포함하고 있습니다.
기본값 매개변수는 결합도를 높이고 파급 효과를 일으킬 수 있습니다. 게으른 프로그래머를 위한 것이죠. 특히 private 함수인 경우는 대체 범위 동일한 클래스 내로 제한됩니다. 따라서 모든 호출에서 기본값 매개변수 대신 명시적으로 값을 전달하도록 수정하겠습니다.
<?
private function executeAndGetData($processId, bool $isUsingFastMethod = false){
}
private function executeAndGetData($processId, bool $isUsingFastMethod){
}
10 — 하드코딩된 상수 제거하기
<?
final class SupervisedLearningStrategy {
const CONFIDENDE_INTERVAL_THRESHOLD=0.9;
private function executeAndGetData($processId, bool $isUsingFastMethod){
//...
if ($estimatedError <= self::CONFIDENDE_INTERVAL_THRESHOLD) {}
//..
}
}
위 예제처럼 상수가 코드 내부에 하드코딩되어 결합된 경우 '시간을 조작하는' 좋은 테스트를 작성하기 어렵습니다.
테스트는 전체 환경 제어 하에 수행되어야 합니다. 시간은 전역적이고 테스트와 일치시키기 쉽지 않습니다.
이제부터는 이러한 상수를 객체 생성 시 필수적으로 전달해야 하는 매개변수로 만들 것입니다. (매개변수를 추가하는 리팩토링은 현대 IDE에서 쉽고 안전하게 수행할 수 있는 작업입니다.)
11 — 로그 분리하기
로그는 운영 환경에서 전략 실행에 대한 관련 정보를 저장합니다. 일반적으로는 싱글턴 패턴을 사용하여 전역 참조로 처리됩니다.
이러한 결합은 테스트를 어렵게 만듭니다. 여기서 싱글턴은 제어권이 없는 다른 모듈에 속해 있기 때문에 래핑(wrapping) 기법을 사용해 해결해 보겠습니다.
function logInfo(array $infoToLog) {
SingletonLogger::info($infoToLog);
}
문제는 싱글턴뿐만 아니라, 로그가 정적 클래스 메시지를 사용한다는 것에 있습니다.
<?
SingletonLogger::info($infoToLog);
다음 사항을 명심하세요.
클래스는 (Solid의 S에 해당하는) 단일 책임의 원칙에 따라 자신의 단일 책임과 관련된 프로토콜 즉, 인스턴스를 생성하는 책임만 포함해야 합니다.
정적 메서드 참조의 경우, 클래스 호출을 다형성으로 대체할 수 없습니다. 대신 익명 함수를 사용하겠습니다.
<?
function logInfo(array $infoToLog) {
$loggingFunction = function() use ($infoToLog) {
SingletonLogger::info($infoToLog);
};
$loggingFunction($infoToLog);
}
이렇게 처리하여 로그에 대한 참조를 분리하고 클래스에서 추출해 낼 수 있습니다. 결합도를 낮추어 전략의 응집도를 높이고 테스트를 더 용이하게 만들 수 있습니다.
<?
final class SupervisedLearningAlgorithm{
public function __construct($processId, closure $logging function){
}
}
운영 환경에서의 호출은 다음과 같습니다.
<?
$loggingFunction = function() use ($infoToLog) {
SingletonLogger::info($infoToLog);
};
new SupervisedLearningAlgorithm{$processId, $loggingFunction);
그리고 테스트 환경에서의 호출입니다.
<?
$loggingFunction = function() use ($infoToLog) {
$this->loggedData[] = $infoToLog;
};
new SupervisedLearningAlgorithm{$processId, $loggingFunction);
$this->assertEquals([...],$this->loggedData);
12 — 객체 실체화하기(reify)
지금까지의 리팩토링 과정에서 지속적인 데이터를 다루는 경우를 발견할 수 있었습니다. 이러한 데이터는 응집적으로 움직이므로 현실 세계의 책임을 갖는 하나의 객체로 간주하는 것이 합리적입니다.
<?
private function getDataToPersist($runTime, $isClustering) {
return [
'processId' => $this->processId,
'date' => new Timestamp(),
'runTime' => $runTime,
'isClustering' => $isClustering
];
}
새 개념을 도입하여 객체를 생성할 때, 빈약한(anemic) 모델을 구축할 위험성이 있습니다. 데이터와 관련된 숨겨진 책임을 찾아 봅시다.
?>
final class LearningAlgorithmRunData{
////응집 데이터와 연관된 책임 찾기
}
13 — 테스트 커버리지 완료
처음에 작성하지 못했던 테스트를 잊지 않고 프로그래밍합니다. 설계의 결합도가 훨씬 낮아졌기 때문에 작성하기 훨씬 쉬워졌을 것입니다.
<?
function testOptimizationIsGoodEnough(){
///
$this->assertEquals($expected, $real);
}
function testOptimizationBelowThreshold(){
///
$this->assertEquals($expected, $real);
}
그리고 시스템은 처음에 비해 훨씬 덜 '레거시'해 졌습니다.
요약
짧은 단계들을 거치며, 반복적이고 점진적인 작업 끝에 다음과 같은 측면에서 더 나은 해결책을 얻을 수 있었습니다.
결합도 감소
불변성
최선의 명명
세터·게터 없음
If 문 없음
Null 없음
싱글턴 없음
기본값 매개변수 없음
테스트 커버리지 개선
새로운 다형성 알고리즘 추가할 수 있도록 (Solid의 O에 해당하는) 개방·폐쇄 원칙을 준수
(Solid의 S에 해당하는) 단일 책임 원칙을 준수
클래스에 프로토콜 과부하하지 않음
결론
명확한 설계 원칙을 준수하고 작은 단계들을 밟아 나가는 과정을 통해, 기존 시스템의 설계를 개선하여 수정할 수 있습니다. 우리는 이러한 변경을 감행할 수 있는 전문적 책임감과 용기를 가져야 하며, 처음보다 훨씬 나은 해결책을 남겨야 합니다.
이번 연재의 목표 중 하나는 소프트웨어 설계에 대한 토론과 논의의 장을 마련하는 것입니다.
글에 대한 여러분의 의견과 제안을 고대하겠습니다.
Subscribe to my newsletter
Read articles from Ahra Yi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by