본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

유지보수: 변경 비용을 낮추는 소프트웨어 설계와 관리

유지보수(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)다.

  • 결합도가 높다: 한 모듈을 고치면 그것을 직접 참조하는 모듈도 함께 고쳐야 한다.
  • 응집도가 낮다: 하나의 변경 이유가 여러 모듈에 흩어져 있어 수정 지점이 많아진다.

변경을 안전하게 만드는 장치

  1. 자동화된 테스트: 변경 전후의 동작이 같은지 빠르게 확인한다.
  2. 명확한 경계: 인터페이스 뒤에 구현을 숨겨 변경을 내부에 가둔다.
  3. 작은 단위의 변경: 변경을 작게 쪼개면 원인 추적과 되돌리기가 쉽다.
  4. 관측 가능성(Observability): 로그, 지표, 추적으로 운영 중 문제를 분석할 수 있게 한다.

기술 부채의 구조

기술 부채는 원금(고쳐야 할 구조)과 이자(그 구조 때문에 이후 작업마다 추가로 드는 비용)로 비유된다. 이자가 큰 부분, 즉 자주 바뀌면서 구조가 나쁜 코드부터 갚는 것이 효율적이다.

4. 코드 예제

4-1. 결합도가 높은 코드를 테스트로 보호하며 개선하기

아래는 할인 정책이 함수 안에 하드코딩된 코드다. 정책이 추가될 때마다 이 함수를 직접 수정해야 한다.

python
# 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)를 작성한다.

python
# 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()

테스트가 통과하는 것을 확인한 뒤, 정책을 데이터로 분리한다. 새 정책은 함수 수정 없이 항목 추가만으로 가능하다.

python
# 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)

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)

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, 복잡도 같은 지표로 관찰한다.