부서 이동을 하다

2018년 말미, 결제/정산 파트에서 주문중계 파트로 부서 이동하게 되었습니다. 인사 발령을 받고 나서 팀 이동을 하게 되면 누구나 직면하게 되는 상황이 발생하는데요. 그것은 바로 레거시 코드의 인수 인계입니다.
처음 레거시 코드를 보고는 이런 생각이 들었습니다. (저만 그런건 아니겠죠?)

“건드리고 싶지 않아.”
“잘 운영 되던 거니까 버그는 없겠지?”
“새로운 기능요청이 오면 어쩌지?”

레거시 코드를 건드리게 되다

레거시 코드를 보통 이해할 수 없고 수정하기도 힘든 코드를 지칭하는 것으로 인식되고 있지만, 여기서는 레거시 코드란 다른 누군가로부터 이어받은 코드로 정의하도록 하겠습니다.

인계받은 레거시 코드에는 몇가지 문제점이 있었습니다.

  1. 테스트 코드가 있지만, 핵심 로직에 대한 테스트 코드가 없었다.
  2. 코드에 대한 가독성이 떨어졌다.
  3. 코드의 복잡도가 높아 설계개선 및 기능추가를 진행할 때 영향도의 파악이 어려웠다.

이러한 와중에 신규 기능 추가에 대한 요구사항이 발생하였습니다.
그리고 다음과 같은 의식의 흐름으로 개발을 진행하였습니다.

소스를 분석하고 이해한다 -> 변경한다 -> 기능을 확인한다 -> 배포한다 -> 기도한다

개발하는 동안 테스트를 진행하고 또 수정의 반복을 몇번 거치면서 기능이 완성되어 가는 동안 이상하게 편안함을 느낄 수가 없었습니다.

“나는 분명 요구사항에 맞는 개발을 하였고, 손수 테스트도 완료했어도 찜찜한 이 기분은 뭘까?”

레거시 코드 활용 전략을 만나다

어떻게 하면 기존 동작을 변경하지 않고 레거시 코드를 테스트 코드 기반으로 잘 관리 할 수 있을까 고민하던 차에 저를 구원해줄 것 같은 책을 발견하게 되었습니다.

책의 제목은 이렇습니다. 레거시 코드 활용 전략 [손대기 두려운 낡은 코드, 안전한 변경과 테스트 기법]

“정말?”


내용이 현재 있는 팀에서도 유용할 것 같아 스터디를 진행하였고, 이 책을 읽으면서 공감이 많이 되었던 부분을 정리하고 실제로 적용해 보고 싶었습니다.

레거시코드 활용전략의 전반적인 주장은 다음과 같습니다.

소프트웨어를 변경할 때 가장 최소한의 안전망인 테스트 코드를 배치해야 한다

또한, 이러한 주장을 뒷받침할 만한 근거도 다음과 같이 제시하고 있습니다.

테스트 코드 없이 대규모의 수정작업을 시도하면 커다란 어려움에 직면하게 된다. 테스트 코드가 있는 경우에 비해 작업하는데 시간이 더 걸린다. 아무리 개발실력이 좋다고 하더라도 기존의 오래된 코드를 깨끗하게 만드는 데는 많은 시간이 걸리고 완전무결하게 만드는 것 또한 어렵다.

이어받은 레거시 코드에 테스트코드가 존재한다면 다음의 장점이 존재합니다.

  • 소프트웨어의 동작 대부분을 고정하고, 변경하고자 하는 부분만을 확인할 수 있고
  • 변경된 코드로부터 오류 위치 파악을 빠르게 확인할 수 있다.

테스트코드 없이 일반적으로 개발 완료하고 확인하고, 버그 찾고 고치고 확인하고 하는 과정으로 진행한다면 개발하는 데에 있어서 효율이 많이 떨어질 수밖에 없습니다.

테스트 코드가 있으면 개발 흐름은 이렇게 될 것입니다.
테스트 코드를 배치한다 (feat. 소스 분석, 이해) -> 변경한다 -> 테스트 코드로부터 피드백을 받는다 -> 리팩토링 -> 확신에 찬 배포 -> 나의 성과!

혹시 QA 단계에서 진행하는 통합테스트에서 피드백을 받으면서 개발을 해도 되지 않느냐는 반발심이 든다면, 예전에 어느 팀장님으로부터 들었던 명언 중에 이런 말이 생각나곤 합니다.

QA가 진행되는 동안 버그가 발견되는 것은 개발자의 수치다.

QA를 진행하는 와중에 발견되는 버그는 디버깅을 진행해야 하고, 수정된 코드에 대한 재확인 하는 데에 시간이 걸릴 뿐만 아니라 의존성이 걸려 있는 다른 팀에게 피해를 끼칠 수 있기에 나온 말일 것입니다.

그래서 테스트 코드를 이용함으로써 개발자 스스로가 빠르게 확인하여 코드의 품질도 개선할 수 있고, 더 안정적인 리팩토링의 기회를 얻을 수 있다고 생각합니다.

그런데도 불구하고 레거시 코드를 감수해야 한다면?

레거시 코드에 신규 기능 개발 요청이 들어왔을 때, 레거시 코드의 의존관계가 많이 존재하고 테스트 코드가 아직 준비되지 않은 상황에서 보통 레거시 코드를 변경하는 순서는 다음과 같을 것입니다.

  1. 신규 기능을 반영할 지점을 판별한다.
  2. 테스트 코드를 작성할 위치를 찾는다.
  3. 의존 관계를 제거한다.
  4. 테스트 코드를 작성한다.
  5. 변경 및 리팩토링을 수행한다.

여기서 중요한 점은 기존의 동작에 영향을 미치지 않고 유지해야 한다는 전제조건이 매우 위험 부담을 갖게 되는데요. 왜냐하면 코드를 변경할 때 어떤 동작에 영향을 미칠지 모르는 블랙박스이기 때문입니다. 그렇다고 기존 레거시 코드 안에서 어떻게든 해결하려고 하면 기존의 메소드, 클래스는 매우 비대해져서 나중에는 걷잡을 수 없는 상황이 초래될 수 있습니다.

그나마 덜 위험도를 가질 수 있는 레거시코드를 포장하는 방법에 대하여 적용해보았던 것을 설명하겠습니다.

레거시 메소드를 포장하자.

실제로 운영하는 코드를 보여드릴 수는 없어서 아래처럼 주문을 생성하는 레거시 코드가 있다고 가정합니다. 기능은 간단하게 주문이 생성되는 메소드 안에 추상화 단계가 같은 여러 메소드가 존재합니다. 새로운 주문에 대한 주문번호를 생성하고 해당 주문에 대한 정보를 조회하여 결제를 진행합니다. 그리고 결제 결과에 따라서 주문에 대한 처리가 완료인지 실패인지를 리턴해주는 메소드입니다.

// 레거시코드 : 온라인 결제를 진행한 주문을 생성한다.
public void createOrder(){
	String orderNo = createOrderNo();
	Order order = createOrder(orderNo);
	PayResult payResult = pay(order);
	
	if(isPaid(payResult))
		orderComlete(order);
	else
		orderFail(order);
}

public PayResult pay(Order order){
	// do something..
}

기존에는 온라인 결제만 가능한 레거시 코드였으나 오프라인 결제도 가능하도록 해달라는 신규 요구사항이 발생하였습니다.

아래는 온라인/오프라인 결제 기능 추가 요구사항을 반영한 코드입니다.

// 신규 요구사항 적용 코드 : 온라인 결제만 진행하던 것을 오프라인 결제도 진행할 수 있다.
public void createOrder(){
	String orderNo = createOrderNo();
	Order order = createOrder(orderNo);
	PayInfo payInfo = order.getPayInfo();
	PayResult payResult = pay(payInfo); // 변경 포인트: 신규 pay() 메소드.
	
	if(isPaid())
		order.complete();
	else
		order.fail();
}

// 추가된 로직 : 결제를 온라인 결제와 오프라인 결제 중 선택 할 수 있다.
public PayResult pay(PayInfo payInfo){
	if(isOnlinePay(payInfo))
		return payOnline(); //원래 pay() 메소드.
	else
		return payOffline();
}

위와 같은 코드는 다음의 과정으로 나올 수 있겠습니다.

1 . 신규 기능을 반영할 지점을 판별한다.

결제방법을 온라인으로 할 것인지 오프라인으로 할 것인지에 대한 선택에 따라 결제가 이루어져야 합니다. 이 신규 기능이 들어가야 할 포인트는 pay() 메소드로 보입니다. 기존 메소드에 추가된 기능을 포장하여 신규 메소드로 구현하였고 이는 기존 로직에 영향을 주지 않기 때문입니다.

2 . 테스트 코드를 작성할 위치를 찾는다.

현재 작성 가능한 테스트 코드는 기존메소드를 신규메소드로 변경한 pay()가 됩니다. 나머지 로직에 대한 테스트 코드는 영향범위를 당장 파악할 수 없기 때문에 현재 기능이 잘 운영되고 있다면, 믿어야 하겠습니다.

3 . 의존 관계를 제거한다.

pay()에서는 Order클래스에 대한 의존성이 있었습니다. Order클래스의 얻어진 항목들이 모두 pay() 에 필요한 항목이 아님에도 불구하고 Order클래스가 없으면 안 되게끔 구현되어 있어 실질적으로 pay() 에 필요한 항목들에 필요한 파라미터만 별도의 객체로 정리하였습니다.

4 . 테스트 코드를 작성한다.

이제 전달받은 입력 파라미터 항목은 정리되어 어떠한 상황이든 결제에 필요한 값만 들어오게 되었고, pay() 의 결과는 결제가 되었는지 안되었는지만 확인하면 되므로, 결제시 발생할 수 있는 모든사항에 대한 테스트 코드를 작성할 수 있게 되었습니다.

5 . 리팩토링을 수행한다.

더 좋은 설계구조가 도출될 수 있도록 작성된 테스트 코드를 바탕으로 안정된 리팩토링을 계속 수행할 수 있을 것으로 기대됩니다. 물론 더 멀리 나아가 나머지 레거시 코드들에 대해서 영향범위를 파악해서 테스트코드 작성을 한 뒤에 리팩토링도 진행되어야 하겠죠.

레거시 클래스를 포장하자.

주문을 관리하는 Order클래스가 있습니다. 아래 코드는 신규로 들어온 주문에 대해 접수처리를 하는 로직입니다.

public class Order {

	public Order(String orderNo){
		createNewOrder(orderNo);
	}

	// 생략...

	public void receitOrder(String orderNo){
		OrderStatus orderStatus = findOrderStatus(orderNo);

		if(orderStatus.isWait()){
			acceptOrder(orderStatus);
		} else {
			throw new Exception("접수할 수 없는 주문입니다.");
		}	
	}

	public void acceptOrder(OrderStatus orderStatus){
		orderStatus.changeOrderStatus("accept");
	}

	// 생략...
}

Order클래스에서 주문접수 데이터를 로깅하여 로깅시스템으로 보내야 하는 신규 요구사항이 발생하였습니다.

아래 LoggingOrder클래스는 레거시 클래스인 Order클래스를 인자로 전달받아 덮어쓰면서 기존의 acceptOrder()기능과 동시에 로그데이터를 보내는 신규기능도 추가된 새로운 클래스입니다.

public class LoggingOrder {
	private Order order;
	
	public LoggingOrder(Order order) {
		this.Order = order;
	}

	// 생략...

	public void acceptOrder() { 
		order.acceptOrder();
		submitLoggingSystem(order); //신규기능 추가: 주문접수 데이터를 로깅시스템으로 보낸다.
	}

	public void submitLoggingSystem(Order order){
		// do something..
	}

	// 생략...
}

위 2개의 클래스를 포장하는 법은 다음과 같습니다. 최종적으로는 데코레이터 패턴의 형태가 도출됩니다.

// 사용
LoggingOrder order = new LoggingOrder(new Order("Test1234"));

order.acceptOrder();

위와 같은 코드는 다음의 과정으로 나올 수 있겠습니다.

1 . 신규 기능을 반영할 지점을 판별한다.

주문접수 데이터의 로깅처리를 위하여 acceptOrder()에 로깅데이터 전달 기능이 추가되면 좋을 것 같다는 생각이 듭니다. 그리고 로깅시스템으로 보내는 신규 메소드를 추가하면 요구사항은 만족하게 됩니다.

하지만 여기서 고려해야 할 부분이 있습니다. 이 Order 클래스는 레거시 코드이며, 이 클래스 내부에 신규 기능을 추가하여 오염시키기 보다는 새로운 클래스로 만들어 레거시 코드의 영향범위를 늘리고 싶지 않았습니다. 새로운 클래스로 만든다는 것은 새로운 책임과 기존의 책임을 분리 한다는 의미이며, 이후 코드의 변경이 일어나는 시점은 새로운 클래스부터 적용되고 레거시 코드는 독립적으로 남아있게 되어 이후 벌어질 수 있는 리스크는 최소화가 될 수 있습니다.

2 . 테스트 코드를 작성할 위치를 찾는다.

새로운 클래스가 도출되고 기존 레거시 클래스를 덮어쓰게 되면 테스트 코드는 명료해집니다. 기존 레거시 클래스의 기능은 이상없이 잘 운영되고 있다는 전제하에 submitLoggingSystem()에 대한 테스트코드를 우선 제일 먼저 만들 수 있습니다.

3 . 의존 관계를 제거한다.

현재 LoggingOrder클래스에서는 의존 관계라고 보이는 것은 레거시인 Order클래스 입니다. 하지만 레거시 임으로 지금 당장 제거하기 보다는 현상 유지하고, 이후를 기약하며 잠시 욕심을 내려놓습니다.

4 . 테스트 코드를 작성한다.

현재는 submitLoggingSystem()에 대한 테스트 코드만 작성할 수 있습니다.

5 . 변경 및 리팩토링을 수행한다.

Order클래스의 레거시 코드를 그냥 레거시 코드로 둘수는 없습니다. Order클래스 또한 테스트 코드를 추가하며 리팩토링 해야합니다. 그렇지만 현 상황에서는 레거시코드를 건드리지 않는 선에서 신규 기능에 대한 개발은 급한 불은 끈 셈입니다.

이렇게 하면 얻는 것은 무엇인가?

  • 신규 기능이 추가되더라도, 기존 코드로직에 영향을 주지 않게 됩니다.
  • 신규 코드가 레거시코드와 섞이지 않기 때문에 잘 운영되고 있는 레거시 코드를 변경할 일이 적어집니다.
  • 최소 신규코드에 한해서는 테스트 코드를 작성할 수 있고 신규코드가 레거시 코드가 되더라도 안정감을 느낄수 있습니다.

결국엔 TDD를 해야 하는가?

레거시 프로젝트에서 변경작업을 할 때 가장 먼저 알아야 할 것은 변경을 진행할 코드 주위에 테스트 코드를 배치하는 것이 언제나 안전성을 높여준다는 점입니다. 꽤 길고 복잡한 메소드의 변경작업을 진행한다고 했을 때, 테스트 코드가 존재하고 어떤 변경작업을 진행할 때마다 테스트 코드를 수행하며 성공하고 실패하는 과정에서 개발자 스스로가 리팩토링하고 변경하는 작업에 대하여 확신이 들고 자신감이 상승할 수 있겠죠. 또한 이후에 작업을 진행할 개발자에게 최대한의 배려가 아닐까 합니다.

아 비어 있는 테스트 코드여…

하지만 부족한 테스트 코드를 손대는 순간 레거시 코드의 전반적인 구조가 변경되어야 하는 경우가 많기 때문에 쉽게 테스트 코드를 추가하기가 쉽지 않기도 합니다. 개발 일정도 빠듯하고 갑자기 없던 테스트 코드를 만들어야 하는 상황에서 오는 충격은 두렵고 불편한 마음이 생길 수 있습니다. 그래서 메소드와 클래스를 포장하는 방법을 응용하여 레거시 코드의 큰 변경 없이 신규기능 추가를 할수 있습니다. 하지만 테스트코드를 계속 추가해가며 레거시코드를 더 나은 코드로 변경해 간다면 레거시코드에 대한 두려움이 사라질 것임은 확실합니다. 그것은 강력한 의지와 노오력을 한다면 해낼 수 있습니다. (…)

테스트 코드 없이 감수 할수 있겠냐고 물었습니다, 어머니.

어쨌든, 무조건 TDD를 추구해야 한다고 주장하기보다는 테스트코드가 필요하다는 결론이 최종적으로 TDD로 이어지는 게 아닐까 싶습니다. 그래야 코드가 안전하게 유지될 수 있을 테니까 말입니다. (도망)