유지보수: 변경 비용을 낮추는 소프트웨어 설계와 관리
유지보수(Maintenance)는 소프트웨어가 출시된 이후 결함 수정, 환경 적응, 기능 개선, 구조 개선을 지속하는 활동이다. 이 글은 유지보수의 네 가지 유형과 변경 비용이 커지는 원리를 설명하고, 결합도와 테스트를 통해 변경 용이성을 높이는 방법을 코드로 보여준다. 기술 부채 관리와 리팩터링 시 주의점도 다룬다.
By 송민성
1. 개념
유지보수(Maintenance)는 소프트웨어가 인도·배포된 뒤에 수행하는 모든 변경 활동을 말한다. ISO/IEC/IEEE 14764는 유지보수를 네 가지 유형으로 분류한다.
| 유형 | 영문 | 목적 | 예시 |
|---|---|---|---|
| 수정 | Corrective | 발견된 결함 고치기 | 결제 금액 반올림 오류 수정 |
| 예방 | Preventive | 잠재 결함이 드러나기 전에 제거 | 만료 임박한 인증서 갱신 자동화 |
| 적응 | Adaptive | 환경 변화에 맞추기 | Node.js 버전, DB 버전, 외부 API 변경 대응 |
| 완전 | Perfective | 기능·성능 개선 | 신규 필터 추가, 쿼리 최적화 |
리팩터링(Refactoring)은 외부 동작을 바꾸지 않고 내부 구조를 개선하는 활동으로, 주로 예방·완전 유지보수의 수단으로 쓰인다.
유지보수성(Maintainability)은 변경을 얼마나 쉽고 안전하게 할 수 있는지를 나타내는 품질 특성이다. ISO/IEC 25010은 이를 모듈성, 재사용성, 분석성, 변경성, 시험성의 하위 특성으로 나눈다.
2. 왜 사용하는가
소프트웨어 생애주기에서 개발 기간보다 운영 기간이 훨씬 긴 경우가 많고, 코드는 작성되는 횟수보다 읽히는 횟수가 훨씬 많다. 따라서 유지보수성을 관리하는 목적은 다음과 같다.
- 변경 비용 억제: 같은 기능을 추가하는 데 드는 시간이 시간이 지나도 급증하지 않게 한다.
- 회귀(Regression) 방지: 수정이 기존 기능을 깨뜨리는 일을 줄인다.
- 지식 이전: 작성자가 떠나도 다른 사람이 코드를 이해하고 고칠 수 있게 한다.
- 기술 부채(Technical Debt) 통제: 당장의 속도를 위해 택한 임시 구조가 이자처럼 누적되는 것을 막는다.
3. 동작 원리
변경 비용이 커지는 이유
코드 변경의 비용은 변경이 파급되는 범위에 비례한다. 파급 범위를 결정하는 것은 모듈 간 결합도(Coupling)와 모듈 내부의 응집도(Cohesion)다.
- 결합도가 높다: 한 모듈을 고치면 그것을 직접 참조하는 모듈도 함께 고쳐야 한다.
- 응집도가 낮다: 하나의 변경 이유가 여러 모듈에 흩어져 있어 수정 지점이 많아진다.
변경을 안전하게 만드는 장치
- 자동화된 테스트: 변경 전후의 동작이 같은지 빠르게 확인한다.
- 명확한 경계: 인터페이스 뒤에 구현을 숨겨 변경을 내부에 가둔다.
- 작은 단위의 변경: 변경을 작게 쪼개면 원인 추적과 되돌리기가 쉽다.
- 관측 가능성(Observability): 로그, 지표, 추적으로 운영 중 문제를 분석할 수 있게 한다.
기술 부채의 구조
기술 부채는 원금(고쳐야 할 구조)과 이자(그 구조 때문에 이후 작업마다 추가로 드는 비용)로 비유된다. 이자가 큰 부분, 즉 자주 바뀌면서 구조가 나쁜 코드부터 갚는 것이 효율적이다.
4. 코드 예제
4-1. 결합도가 높은 코드를 테스트로 보호하며 개선하기
아래는 할인 정책이 함수 안에 하드코딩된 코드다. 정책이 추가될 때마다 이 함수를 직접 수정해야 한다.
# before.py
def calc_price(base: int, user_type: str) -> int:
if user_type == "vip":
return int(base * 0.8)
elif user_type == "student":
return int(base * 0.9)
return base먼저 현재 동작을 고정하는 특성화 테스트(Characterization Test)를 작성한다.
# test_pricing.py
import unittest
from before import calc_price
class TestCalcPrice(unittest.TestCase):
def test_vip(self):
self.assertEqual(calc_price(10000, "vip"), 8000)
def test_student(self):
self.assertEqual(calc_price(10000, "student"), 9000)
def test_default(self):
self.assertEqual(calc_price(10000, "guest"), 10000)
if __name__ == "__main__":
unittest.main()테스트가 통과하는 것을 확인한 뒤, 정책을 데이터로 분리한다. 새 정책은 함수 수정 없이 항목 추가만으로 가능하다.
# after.py
from typing import Callable
DiscountRule = Callable[[int], int]
RULES: dict[str, DiscountRule] = {
"vip": lambda base: int(base * 0.8),
"student": lambda base: int(base * 0.9),
}
def calc_price(base: int, user_type: str) -> int:
rule = RULES.get(user_type)
return rule(base) if rule else base
if __name__ == "__main__":
assert calc_price(10000, "vip") == 8000
assert calc_price(10000, "student") == 9000
assert calc_price(10000, "guest") == 10000
print("모든 동작이 유지됨")test_pricing.py의 import를 after로 바꿔 같은 테스트를 돌려도 통과해야 한다. 이것이 "외부 동작은 그대로, 내부 구조만 개선"의 실제 모습이다.
4-2. 인터페이스로 변경을 가두기 (TypeScript)
// notifier.ts
interface Notifier {
send(userId: string, message: string): Promise<void>;
}
class EmailNotifier implements Notifier {
async send(userId: string, message: string): Promise<void> {
console.log(`[email] ${userId}: ${message}`);
}
}
class SlackNotifier implements Notifier {
async send(userId: string, message: string): Promise<void> {
console.log(`[slack] ${userId}: ${message}`);
}
}
class OrderService {
constructor(private readonly notifier: Notifier) {}
async complete(userId: string): Promise<void> {
// 주문 처리 로직...
await this.notifier.send(userId, "주문이 완료되었습니다.");
}
}
async function main() {
await new OrderService(new EmailNotifier()).complete("u1");
await new OrderService(new SlackNotifier()).complete("u2");
}
main();알림 수단이 바뀌어도 OrderService는 수정하지 않는다. 변경이 Notifier 구현체 안에 갇힌다.
4-3. 변경 빈도가 높은 파일 찾기 (Shell)
# 최근 6개월간 가장 자주 수정된 파일 상위 10개
git log --since="6 months ago" --name-only --pretty=format: \
| grep -v '^$' | sort | uniq -c | sort -rn | head -10자주 바뀌는 파일은 리팩터링의 이득이 큰 후보다. 여기에 복잡도 지표까지 겹쳐 보면 우선순위를 더 잘 정할 수 있다.
5. 시간 복잡도 또는 성능 특성
유지보수는 알고리즘이 아니므로 점근적 복잡도 대신 측정 가능한 지표로 다룬다.
| 지표 | 의미 | 비고 |
|---|---|---|
| 순환 복잡도(Cyclomatic Complexity) | 독립적인 실행 경로의 수 | 분기가 많을수록 필요한 테스트 케이스가 늘어난다 |
| 변경 리드 타임(Lead Time for Changes) | 커밋부터 운영 배포까지 걸린 시간 | DORA 지표 중 하나 |
| 변경 실패율(Change Failure Rate) | 배포 후 장애·롤백이 발생한 비율 | DORA 지표 중 하나 |
| 평균 복구 시간(MTTR) | 장애 발생부터 복구까지 평균 시간 | 관측 가능성 수준을 반영 |
| 코드 변동률(Churn) | 일정 기간 내 파일의 수정 빈도 | 복잡도와 함께 보면 핫스팟을 찾기 좋다 |
리팩터링 자체의 성능 영향은 구조에 따라 다르다. 인터페이스나 간접 호출 계층이 추가되면 호출 비용이 미세하게 늘 수 있으나, 대부분의 웹 서비스에서는 I/O 비용에 비해 무시할 수준이다. 성능이 의심되면 추측하지 말고 프로파일러로 측정한다.
6. 실무 사용 사례
- 레거시 점진 교체: 기존 시스템 앞에 새 시스템을 두고 기능 단위로 트래픽을 옮기는 스트랭글러 패턴(Strangler Fig Pattern)을 쓴다. 전면 재작성보다 위험이 작다.
- 의존성 업데이트 자동화: Dependabot, Renovate 같은 도구로 라이브러리 업데이트를 작은 PR로 나눠 받는다. 보안 패치 지연이라는 적응 유지보수 부담을 줄인다.
- 코드 리뷰와 컨벤션: 린터(Linter), 포매터(Formatter), CI 검사를 통해 스타일 논쟁을 자동화하고 리뷰를 설계 논의에 집중시킨다.
- 아키텍처 결정 기록(ADR): 왜 그렇게 설계했는지를 문서로 남겨 후임자의 분석 시간을 줄인다.
- 기능 플래그(Feature Flag): 배포와 릴리스를 분리해 문제가 생기면 코드 롤백 없이 기능을 끈다.
- 장애 후 회고(Postmortem): 개인 비난 없이 원인과 재발 방지책을 기록해 예방 유지보수로 연결한다.
7. 주의할 점
- 테스트 없는 리팩터링: 동작 보존을 확인할 수단이 없으면 리팩터링이 아니라 무작위 수정에 가깝다. 먼저 테스트를 붙인다.
- 기능 변경과 구조 변경의 혼합: 한 커밋·PR에 둘을 섞으면 리뷰와 원인 추적이 어렵다. 분리한다.
- 빅뱅 재작성(Big Bang Rewrite): 기존 코드에 암묵적으로 담긴 예외 처리와 비즈니스 규칙을 놓치기 쉽다. 가능하면 점진적으로 교체한다.
- 성급한 추상화: 변경 가능성을 추측해 미리 계층을 만들면 오히려 이해 비용만 늘어난다. 같은 변경이 반복되는 것을 확인한 뒤 추상화한다.
- 주석으로 나쁜 코드 설명: 이름과 구조로 의도를 드러내는 것이 우선이며, 주석은 "왜"를 보충할 때 쓴다.
- 문서 방치: 코드와 어긋난 문서는 없는 것보다 해롭다. 문서는 코드 가까이에 두고 변경 시 함께 갱신한다.
- 기술 부채의 비가시화: 부채 목록을 추적하지 않으면 우선순위를 정할 수 없다. 이슈로 등록하고 정기적으로 일정에 반영한다.
8. 핵심 정리
- 유지보수는 수정, 예방, 적응, 완전의 네 유형으로 나뉘며 출시 후 비용의 큰 비중을 차지한다.
- 변경 비용은 파급 범위에 비례하고, 낮은 결합도와 높은 응집도가 이를 줄인다.
- 리팩터링은 외부 동작을 유지한 채 내부 구조를 개선하는 것이며, 자동화된 테스트가 전제 조건이다.
- 변경 빈도와 복잡도가 모두 높은 코드부터 개선하면 투자 대비 효과가 크다.
- 기능 변경과 구조 변경은 분리하고, 큰 교체는 점진적으로 진행한다.
- 유지보수성은 감이 아니라 리드 타임, 변경 실패율, MTTR, 복잡도 같은 지표로 관찰한다.