수정 이력(5건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대한 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 테스트 프로젝트에서 기존 앱을 호출할 수 있게 하기까지의 절차를 새로 넣었습니다. 아울러 차이 검토를 읽는 실례, dotnet test 이외의 실행 수단, 대상 독자와 전제를 추가했습니다.
- 본문 속 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을, 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174264)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「테스트 없는 레거시 업무 앱을 안전하게 수정하기 ── 특성화 테스트와 리팩터링 실무」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/characterization-test-legacy-refactoring/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22174264
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22174265
“어디를 고쳐야 하는지는 알고 있습니다. 그런데 손댔다가 다른 곳이 깨질까 봐 손을 못 대겠어요”──테스트 없는 업무 앱을 넘겨받은 분에게서 자주 듣는 말입니다.
VB6나 .NET Framework, Access로 만든 업무 앱 상당수에는 자동 테스트가 없습니다. 명세서 갱신도 멈춰 있어 “코드가 유일한 명세서”인 상태입니다. 그래도 업무는 이어지고, 소비세율 변경, 보고서 레이아웃 수정, 거래처 추가 같은 수정 요청은 기다려 주지 않습니다.
이 블로그에서는 “VB6 앱은 언제까지 동작하는가 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법“에서, 기존 시스템을 “동작하는 명세서”로 두고 출력을 대조하며 이전을 진행하는 생각을 소개했습니다. 이 글은 그 생각을 이전이 아니라 “지금 돌아가는 코드에 그 자리에서 손을 대는” 장면에 적용합니다. 중심이 되는 도구는 특성화 테스트(characterization test)입니다. 테스트가 없는 코드라도, 앞으로 깨뜨릴지도 모르는 동작을 먼저 테스트로 고정해 두면 리팩터링도 기능 추가도 훨씬 안전해집니다.
가정하는 독자와 전제는 다음과 같습니다. 자동 테스트 경험은 필요 없고, 테스트를 한 번도 써 본 적 없는 상태에서 시작할 수 있게 구성했습니다. 전제로 필요한 것은 (1) 대상 앱을 직접 빌드할 수 있을 것(소스와, 빌드가 통과하는 개발 환경이 손에 있을 것), (2) 소스 코드에 변경을 가해도 되는 입장일 것, 이 두 가지뿐입니다. 코드 예는 C#(.NET Framework / .NET 어느 쪽에서든 동작하는 작성법)으로 보이지만, 생각은 언어를 가리지 않습니다. 반대로 소스가 없거나 빌드할 수 없는 경우에는 이 글의 기법을 그대로 쓸 수 없으므로, 그 앞 단계의 정리부터 필요합니다.
1. 먼저 결론
- 바로 고치지 마세요. 먼저 현재 동작을 테스트로 고정합니다. 명세서가 없어도, 지금 돌아가는 코드의 출력 그 자체가 스펙입니다.
- 그 도구가 특성화 테스트입니다. “올바른 동작”이 아니라 “현재 동작”을 기록하는 테스트로, 보고서·CSV·계산 결과 등의 출력을 그대로 기댓값으로 저장하고 변경 전후를 비교합니다(골든 마스터 기법).
- 테스트를 끼워 넣을 수 없는 구조(UI 이벤트 핸들러에 직접 작성,
DateTime.Now나 파일 경로의 직접 참조)에는 메서드 추출과 인터페이스 삽입이라는 최소한의 변경으로 “이음매(seam)”를 만듭니다. 대대적인 개조는 필요 없습니다. - 리팩터링과 기능 추가를 같은 커밋에 섞지 마세요. 리팩터링은 “차이 없음”, 기능 추가는 “의도한 차이만”이 합격 조건이며, 섞으면 차이의 의미를 판별할 수 없게 됩니다.
- 테스트를 어디까지 갖출지는 수정 규모 × 시스템 잔존 연수 × 장애 시 영향으로 정합니다. 전부에 유닛 테스트를 다는 것이 항상 정답은 아니며, “특성화 테스트만”, “손대지 않는다”가 정답인 장면도 있습니다.
- CI가 없어도 시작할 수 있습니다. 테스트 프로젝트 하나와 기댓값 파일 폴더만 있으면, 로컬에서 돌리는 것만으로도 안전성은 크게 달라집니다.
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 19건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 레거시 코드는 왜 “손대면 깨지는가”
레거시 코드 수정이 두려운 이유는 코드가 오래되어서만이 아닙니다. 변경 결과가 맞는지를 확인할 수단이 없기 때문입니다.
Michael Feathers는 저서 『레거시 코드 개선 가이드』에서 레거시 코드를 “단지 오래된 코드”가 아니라 “테스트가 없는 코드”로 정의했습니다.1 테스트가 없으면 코드가 나아지고 있는지 나빠지고 있는지를, 변경할 때마다 빠르게 확인할 방법이 없다는 것이 그 이유입니다. 이 정의를 따르면 어제 쓴 코드라도 테스트가 없으면 레거시 코드입니다.
테스트가 없는 코드에서는 다음 악순환이 돌기 시작합니다.
- 테스트가 없어서 변경의 영향 범위를 알 수 없고 두렵다
- 두려우니 기존 구조는 고치지 않고 최소한의 복사·붙여넣기와 조건 분기 추가로 때운다
- 그때그때 땜질이 쌓이며 코드는 더 읽기 어렵고 더 잘 깨지게 된다
- 잘 깨지니 더 두려워진다(1로 돌아간다)
이 악순환을 끊는 입구는 “용기를 내어 대규모 리팩터링을 하는 것”이 아닙니다. 순서는 반대입니다. 먼저 안전망(테스트)을 치고, 두려움의 원인을 없앤 뒤에 고칩니다. 다만 여기에는 닭과 달걀 문제가 있습니다. 테스트를 쓰려면 테스트 가능한 구조가 필요합니다. 그런데 테스트 가능한 구조로 만들려면 코드를 변경(리팩터링)해야 합니다. 테스트 없는 코드를 테스트 없이 바꾸게 됩니다.
이 모순을 풀기 위해 레거시 코드 수정은 다음 순서로 진행합니다.1
- 바꿀 곳 주변만, 현재 동작을 바깥에서 고정한다(특성화 테스트)
- 그 안전망 안에서 깨질 위험이 극히 낮은 최소한의 변경(메서드 추출 등)을 해, 테스트를 끼워 넣을 입구를 만든다
- 세분된 테스트를 쓸 수 있는 구조가 되면, 본래 하고 싶었던 변경(리팩터링·기능 추가)에 착수한다
이후 장에서 이 1과 2를 구체적으로 살펴봅니다.
3. 특성화 테스트 ── “현재 동작”을 기록한다
3.1 일반 테스트와 무엇이 다른가
일반 테스트는 “스펙상 이래야 한다”는 올바른 동작을 검증합니다. 특성화 테스트는 다릅니다. 지금 코드가 실제로 어떻게 동작하는지를, 맞는지 틀린지의 판단은 보류한 채 기록합니다.
예를 들어 단수 처리가 반올림인지 버림인지 명세서에 없다고 합시다. 현행 코드가 버림으로 돌아가고, 그걸로 업무가 10년 돌아왔다면, 적어도 “버림이다”가 사실상의 스펙입니다. 특성화 테스트는 이를 “현재 출력은 ○○이다”라는 형태로 그대로 고정합니다. 설령 그것이 버그였더라도 먼저 고정합니다. 동작을 바꾸는 일(버그 수정)은 안전망이 생긴 뒤에, 의도한 변경으로 따로 합니다.
3.2 골든 마스터 기법의 절차
출력 단위가 큰 레거시 코드에는 골든 마스터 기법이 비용 대비 효과가 가장 높은 특성화 테스트입니다. 절차는 소박합니다.
- 변경 대상 기능이 만드는 출력(보고서 텍스트, CSV, 계산 결과 목록 등)을 특정한다
- 대표적인 입력 데이터를 준비하고, 현행 코드를 실행해 출력을 얻는다
- 그 출력을 기댓값 파일(골든 마스터)로 그대로 저장해 저장소에 넣는다
- 이후 코드를 바꿀 때마다 테스트를 실행하고, 출력과 기댓값 파일의 차이가 없는지 확인한다
C# 구현은 특정 라이브러리에 의존하지 않는, 다음과 같은 소박한 것으로 충분합니다.
[Fact]
public void 月次請求一覧_ゴールデンマスター()
{
// 1. 대표적인 입력(운영에서 마스킹해 뽑아 낸 데이터 등)을 읽는다
var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));
// 2. 기존 로직을 그대로 호출해 출력 문자열을 얻는다
string actual = BillingReport.Generate(input);
// 3. 기댓값 파일이 없는 것은 "테스트 환경이 깨진 경우"이거나 "최초 실행"이다.
// 어느 쪽이든 조용히 통과시키지 말고, 기록만 남긴 채 반드시 실패시킨다
string expectedPath = TestDataPath("billing-expected-202606.txt");
if (!File.Exists(expectedPath))
{
File.WriteAllText(expectedPath + ".candidate", actual);
Assert.Fail("기댓값 파일이 없습니다. .candidate 내용을 검토한 뒤, " +
"문제없으면 기댓값으로 커밋하세요.");
}
// 4. 저장된 동작과 완전히 일치하는지 검증한다
string expected = File.ReadAllText(expectedPath);
Assert.Equal(expected, actual);
}
기댓값 파일을 찾지 못했을 때 현재 출력을 그대로 기댓값으로 저장하고 테스트를 성공시켜 버리는 구현은 피하세요. 기댓값 커밋 누락이나 테스트 환경 배치 실수가 있으면, CI가 회귀를 감지하지 못한 채 초록으로 통과합니다. 최초 기록은 위처럼 후보 파일(.candidate)을 출력한 뒤 명시적으로 실패시키고, 사람이 검토한 다음에 기댓값으로 커밋하는 일방통행으로 만듭니다.
차이가 났을 때 Assert.Equal 메시지만으로는 쫓기 어려우므로, 실무에서는 실패 시 실제 출력을 billing-actual-202606.txt 같은 별도 파일로 써 두고 WinMerge 같은 diff 도구로 기댓값과 비교할 수 있게 해 두면 조사가 빨라집니다.
이 코드 예는 “기존 로직의 BillingReport.Generate를 테스트 프로젝트에서 그대로 호출할 수 있다”는 전제입니다. 레거시 현장에서 맨 처음 막히는 곳이 대개 여기이므로, 연결 방법은 3.4절에 정리했습니다.
이 기법은 문헌마다 이름이 달라, 골든 마스터 테스트 외에 승인 테스트(approval testing), 스냅샷 테스트(snapshot testing)라고도 합니다. 같은 생각을 라이브러리로 만든 것도 있고, .NET에서는 ApprovalTests.Net이나 Verify가 대표적입니다. 기댓값 파일 명명 규칙, diff 도구 자동 실행, 기댓값 승인 조작처럼 위 코드에서 직접 만든 부분을 대신해 줍니다. 우선 위와 같은 소박한 구현으로 시작하고, 기댓값 파일이 늘어 관리가 번거로워진 단계에서 라이브러리 도입을 검토하는 순서로 충분합니다. 검색할 때는 “golden master”보다 “approval testing”, “snapshot testing” 쪽이 정보가 잘 나옵니다.
3.3 입력을 고르는 방법과 출력 정규화
입력은 “대표 + 경계”로 고릅니다. 보통 케이스를 1~2건, 여기에 월말 마감·0건·음수·특정 거래처 예외 처리처럼 코드를 읽고 찾은 분기를 지나는 입력을 더합니다. 운영 데이터를 마스킹해 쓸 수 있다면, 그것이 실제 분기를 가장 잘 탑니다.
출력에 섞이는 비결정적 값은 비교 전에 정규화합니다. 인쇄 일시, 처리 시간, GUID, 자동 채번은 실행할 때마다 바뀌므로 그대로 두면 매번 차이가 납니다. 출력을 만든 뒤, 정규 표현식으로 印刷日時: 2026/07/17 16:00을 印刷日時: <DATE>로 치환하는 전처리를 끼운 다음 비교합니다.
어떤 출력이 골든 마스터에 맞는지 기준을 정리합니다.
| 출력 종류 | 적합성 | 보충 |
|---|---|---|
| CSV·고정 길이 파일 | ◎ | 그대로 저장·비교할 수 있다. 맨 먼저 노릴 대상 |
| 보고서(텍스트·인쇄 미리보기의 원본 데이터) | ◎ | PDF화 직전의 문자열을 잡는다. PDF 바이너리 비교는 피한다 |
| 계산 결과 목록(금액, 재고 수 등) | ◎ | 결과를 CSV 등으로 내보내는 테스트 전용 메서드를 더해도 된다 |
| DB에 쓰는 내용 | ◯ | 기록 후 테이블 내용을 SELECT해 CSV로 만들어 비교한다 |
| 화면 표시 그 자체 | △ | 문자열로 떨어뜨릴 수 있으면 가능. 화면 조작 자동화는 “Windows 데스크톱 앱의 UI 자동 테스트“에서 다룬 다른 도구가 필요하다 |
| 외부 시스템으로의 송신 | △ | 보내기 직전 데이터를 잡는 이음매(다음 장)가 필요하다 |
3.4 테스트 프로젝트에서 기존 앱을 호출할 수 있게 하기
기존 WinForms / WPF 앱은 EXE 프로젝트입니다. “테스트 프로젝트를 더했는데, 거기서 본체의 클래스가 보이지 않는다”에서 멈추는 것이 레거시 수정의 첫 관문이므로, 연결 방법을 정리합니다.
1. 테스트 프로젝트를 하나 추가한다. 기존 솔루션에 새 테스트 프로젝트를 더합니다. .NET Framework 그대로여도 MSTest / NUnit / xUnit 모두 쓸 수 있습니다. 테스트 프로젝트의 대상 프레임워크는 본체와 맞추는 것이 기본입니다(본체가 .NET Framework 4.8이면 테스트 프로젝트도 4.8). 여기가 어긋나면 참조를 넣는 시점에 경고나 로드 오류가 납니다.
2. 본체 프로젝트에 대한 참조를 추가한다. 연결 방법은 두 가지이며, 원칙은 전자입니다.
| 연결 방법 | 쓰는 장면 | 방법 |
|---|---|---|
| 프로젝트 참조(권장) | 본체 소스가 있고, 같은 솔루션에서 빌드할 수 있다 | 테스트 프로젝트를 오른쪽 클릭 → 참조 추가 → 프로젝트 → 본체 EXE 프로젝트를 고른다. EXE 프로젝트도 어셈블리이므로 참조할 수 있습니다(“EXE라서 참조할 수 없다”는 오해입니다) |
| DLL / EXE 파일 참조 | 본체를 솔루션에 넣을 수 없거나, 빌드된 바이너리만 손에 있다 | 참조 추가 → 찾아보기 → 본체의 bin에 있는 EXE / DLL 파일을 직접 지정한다. 다만 본체를 다시 빌드할 때마다 참조 대상이 낡지 않도록 주의가 필요하다 |
참조 방향은 테스트 → 본체 한 방향뿐입니다. 본체에서 테스트를 참조하면 순환 참조가 됩니다.
3. internal인 채로 테스트하려면 InternalsVisibleTo를 쓴다. 4장에서 보듯, 메서드 추출로 로직을 잘라 낼 때 internal로 두고 싶은 장면이 많습니다(공개 API를 늘리지 않고 테스트할 수 있기 때문입니다). 그때는 본체 쪽 어셈블리에 다음 특성을 한 줄 더합니다.2
// 본체 쪽 AssemblyInfo.cs 또는 임의의 소스 파일 맨 앞에 둔다
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("MyApp.Tests")]
주의할 점이 두 가지입니다.2
- 본체와 테스트의 서명 상태를 맞춘다. 둘 다 미서명이거나, 둘 다 strong name이어야 합니다. 본체가 strong name인 경우에는
InternalsVisibleTo("MyApp.Tests, PublicKey=0024...")처럼 완전한 공개 키(공개 키 토큰이 아닙니다)를 적습니다. 공개 키는sn -p와sn -tp로 꺼낼 수 있습니다. private는 보이지 않습니다.InternalsVisibleTo가 먹는 것은internal/protected internal/private protected뿐입니다.private메서드를 테스트하고 싶어졌다면, 그것은 “그 클래스가 너무 크다”는 신호이므로 4장의 메서드 추출로 잘라 내는 편이 정직합니다.
4. 데이터 파일을 둘 위치를 확인한다. 테스트 실행 시 현재 디렉터리는 테스트 출력 폴더(bin\Debug\...)가 됩니다. 기댓값 파일과 입력 CSV는 TestData 폴더에 두고, 속성의 “출력 디렉터리에 복사”를 “새 버전인 경우 복사”로 두면 3.2절의 TestDataPath를 무리 없이 쓸 수 있습니다. 본체 쪽이 app.config나 설정 파일을 읽는 경우에도, 테스트 프로젝트 쪽에 동등한 설정이 필요할 수 있습니다.
여기까지 되면 3.2절의 골든 마스터 테스트를 그대로 쓸 수 있습니다.
4. 테스트를 끼워 넣는 “이음매(seam)” 만드는 법
골든 마스터를 쓰려다 보면 많은 레거시 코드에서 벽에 부딪힙니다. 로직이 UI 이벤트 핸들러에 바로 쓰여 있어 화면을 띄우지 않으면 실행할 수 없는 것입니다. 여기서 필요한 것이, 테스트 코드에서 동작을 갈아 끼우거나 관측할 수 있는 자리, Feathers가 말하는 이음매(seam)입니다.1
4.1 메서드 추출로 로직을 UI에서 떼어 내기
전형적인 Before는 이렇습니다. 계산·DB 접근·시각 의존·화면 갱신이 한 이벤트 핸들러에 같이 있습니다.
// Before: 전부가 이벤트 핸들러에 직접 작성
private void btnCalc_Click(object sender, EventArgs e)
{
var rows = LoadRowsFromDb(); // DB 직접 접근
var now = DateTime.Now; // 현재 시각에 의존
decimal total = 0;
foreach (var row in rows)
{
if (row.SalesDate.Year == now.Year &&
row.SalesDate.Month == now.Month) // 당월분만 집계
{
total += Math.Floor(row.Amount * 1.1m); // 단수 처리라는 업무 규칙
}
}
lblTotal.Text = total.ToString("N0"); // 화면에 직접 반영
}
이대로는 당월분 집계 로직을 테스트하려면 화면과 DB와 “오늘의 날짜”가 필요합니다. 최소한의 변경으로 테스트 가능하게 하려면, 계산 부분만 메서드로 추출하고, 외부 의존(DB 결과와 현재 시각)을 인자로 바꾸는 것이 정석입니다. Visual Studio의 메서드 추출 리팩터링(Ctrl+R, M)을 쓰면 손으로 고치다 생기는 실수도 줄어듭니다.3
// After: 계산만 추출하고, "DB 결과"와 "현재 시각"을 인자로 받는다
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
decimal total = 0;
foreach (var row in rows)
{
if (row.SalesDate.Year == now.Year &&
row.SalesDate.Month == now.Month)
{
total += Math.Floor(row.Amount * 1.1m);
}
}
return total;
}
private void btnCalc_Click(object sender, EventArgs e)
{
var rows = LoadRowsFromDb();
lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}
이벤트 핸들러 쪽은 “읽기 → 계산 → 표시” 세 줄이 되고, 추출한 메서드는 임의의 행 데이터와 임의의 날짜로 테스트할 수 있습니다. 월말·월초·윤년처럼 시각이 걸린 경계도 new DateTime(2028, 2, 29) 같은 날짜를 넘기는 것만으로 재현할 수 있습니다.
4.2 인터페이스 삽입으로 의존을 갈아 끼울 수 있게 하기
인자화로 끝나지 않는 규모의 의존(여기저기에서 DateTime.Now를 참조하거나, 파일 경로가 바로 적혀 있는 경우 등)에는, 의존을 인터페이스로 감싸 끼워 넣습니다. Microsoft Learn의 .NET 유닛 테스트 모범 사례에서도 DateTime.Now에 대한 직접 의존은 테스트에서 제어할 수 없는 전형으로 보고, 인터페이스로 감싸 이음매(seam)를 도입하는 방법을 소개합니다.4
public interface IClock
{
DateTime Now { get; }
}
public sealed class SystemClock : IClock
{
public DateTime Now => DateTime.Now;
}
// 테스트 쪽에서는 고정 시각을 반환하는 구현을 끼운다
public sealed class FixedClock : IClock
{
private readonly DateTime _fixed;
public FixedClock(DateTime value) => _fixed = value;
public DateTime Now => _fixed;
}
기존 클래스의 생성자에 IClock을 추가하면 호출 쪽을 전부 고쳐야 하므로, 이행기에는 “인자 없는 생성자는 SystemClock을 쓴다”는 기본값 생성자를 같이 두고, 호출 쪽을 단계적으로 고치는 편이 현실적입니다. 파일 경로나 DB 연결 문자열을 바로 적은 경우도 같은 요령으로, “읽고 쓰는” 조작만의 작은 인터페이스로 감쌉니다.
Visual Studio에는 기존 클래스에서 인터페이스를 추출하는 리팩터링(Extract Interface)이 들어 있어, 이런 변경을 기계적으로 할 수 있습니다.3
이음매를 만들 때 지킬 원칙은 하나입니다. 이음매를 만드는 변경 자체는 동작을 조금도 바꾸지 말 것. 메서드 추출과 인터페이스 삽입은 둘 다 컴파일러와 IDE 지원으로 기계적으로 할 수 있는, 동작 보존성이 높은 조작입니다. 이 단계에서 “겸사겸사” 로직을 고치고 싶어지지만, 그것은 안전망이 쳐진 뒤의 일입니다.
5. 어디까지 할지 판단표
특성화 테스트와 이음매 만들기에도 공수는 듭니다. 모든 레거시 코드에 같은 수준의 테스트를 갖추는 일은 중소 규모 현장에서는 현실적이지 않고, 그럴 필요도 없습니다. 판단 축은 세 가지입니다.
- 수정 규모: 몇 줄의 버그 수정인지, 기능 추가인지, 구조 변경이 따르는지
- 시스템의 잔존 연수: 앞으로 1~2년 안에 이전·폐지 예정인지, 5년 이상 쓸지
- 장애 시 영향: 보고서 모양이 깨지는 정도인지, 청구 금액이나 재고 수를 틀리는지
| 수정 규모 | 잔존 연수 | 장애 시 영향 | 권장 수준 |
|---|---|---|---|
| 경미(몇 줄·설정값 변경) | 짧음(~2년) | 작음(표시가 깨지는 정도) | 특성화 테스트만. 해당 출력을 고정하고 변경, 차이 확인으로 끝 |
| 경미~중 | 짧음 | 큼(금액·재고를 다룸) | 특성화 테스트만을 두껍게. 입력 패턴을 경계까지 늘린다 |
| 중(기능 추가·로직 변경) | 김(5년~) | 작음~중간 | 특성화 테스트+변경 지점 주변만 유닛 테스트 정비(이음매를 만든다) |
| 중~대 | 김 | 큼 | 특성화 테스트+유닛 테스트 정비+릴리스 단위를 잘게 나눈다 |
| 대(구조 쇄신이 필요) | 짧음 | ─ | 손대지 않는다. 수정하지 않고 운영으로 우회하며, 공수는 이전·교체에 돌린다 |
| ─(수정 요청 자체가 없음) | ─ | ─ | 손대지 않는다. 돌아가는 코드를 예방적으로 리팩터링하지 않는다 |
아래 두 줄의 “손대지 않는다”는 소극적 선택이 아니라 적극적 판단입니다. 잔존 연수가 짧은 시스템의 내부 품질에 투자해도 회수하지 못합니다. 그 공수는 “VB6 / Access 업무 앱의 연장과 이전 판단표“에서 정리한 이전 판단과, 이전 대상 설계에 써야 합니다.
또한 “유닛 테스트 정비까지” 가는 경우에도, 무엇을 유닛 테스트에 쓰고 무엇을 통합 테스트(실제 DB·실제 파일을 쓰는 테스트)에 남길지의 선이 필요합니다. 이 선은 “유닛 테스트와 통합 테스트의 경계를 어떻게 그을까“에서 판단표로 정리해 두었으니 함께 보세요. 유닛 테스트가 fast / isolated / repeatable이어야 한다는 성질상,4 DB나 파일을 건드리는 특성화 테스트는 유닛 테스트와 다른 프로젝트·다른 실행 단위로 나눠 두는 것이 무난합니다.
6. 운영 규칙 ── 안전망을 깨지 않으려면
특성화 테스트는 쓴 뒤의 운영을 잘못하면 쉽게 유명무실해집니다. 최소한의 규칙을 세 가지로 줄입니다.
6.1 리팩터링과 기능 추가를 같은 커밋에 섞지 않는다
리팩터링이란, 동작을 바꾸지 않고 코드를 이해하기 쉽고 유지보수하기 쉽게 하는 변경입니다.5 즉 합격 조건은 골든 마스터와의 차이가 없는 것입니다. 한편 기능 추가·버그 수정의 합격 조건은 의도한 차이만 나는 것입니다. 이 둘을 한 커밋에 섞으면, 차이가 났을 때 “의도한 변경”인지 “깨뜨린 것”인지 판별할 수 없게 됩니다.
| 변경 종류 | 골든 마스터 취급 | 합격 조건 |
|---|---|---|
| 리팩터링(구조 변경) | 갱신하지 않는다 | 차이 없음 |
| 버그 수정·기능 추가(동작 변경) | 차이 검토 후 갱신한다 | 의도한 차이만 |
| 이음매 만들기(메서드 추출·인터페이스 삽입) | 갱신하지 않는다 | 차이 없음 |
| 기댓값 정규화 규칙 변경 | 다시 생성한다 | 변경 이유를 커밋 메시지에 명시 |
릴리스 단위에서도 같습니다. “리팩터링만의 릴리스”는 동작이 바뀌지 않아야 하므로, 장애가 나면 곧바로 리팩터링을 의심할 수 있습니다. 섞어 버리면 이 가르기가 통하지 않습니다.
6.2 기댓값 갱신은 “차이 검토 → 덮어쓰기” 순으로
동작을 의도적으로 바꿨다면 골든 마스터도 갱신합니다. 절차를 고정하세요.
- 변경 후 출력을 만들고, 현재 기댓값과의 차이를 눈으로 검토한다
- 차이가 의도한 변경만인지 확인한다(의도하지 않은 행이 한 줄이라도 바뀌었으면 조사)
- 새 출력으로 기댓값 파일을 덮어쓰고, 코드와 같은 커밋에 넣어 이력에 남긴다
위험한 것은 “테스트가 실패했으니 기댓값을 덮어써 초록으로 만드는” 운영입니다. 이렇게 하면 회귀가 그대로 “정답”으로 기록되어, 안전망이 안전망이 아니게 됩니다.
판단은 실제 차이를 보는 것이 가장 빠르므로 예를 듭니다. “소비세율 10% 상품에 경감세율 8%를 추가한다”는 수정을 했다고 하고, 기댓값과의 차이가 이렇게 났다고 합시다.
2026/06/30,A商事,事務用品, 10000, 1000, 11000
- 2026/06/30,A商事,飲料(軽減), 5000, 500, 5500
+ 2026/06/30,A商事,飲料(軽減), 5000, 400, 5400
2026/06/30,A商事,小計, 15000, 1500, 16500
- 2026/06/30,B工業,機械部品, 200000, 20000, 220000
+ 2026/06/30,B工業,機械部品, 200000, 20001, 220001
위 두 줄(경감세율 행의 세액이 500→400으로 바뀐 것)은 의도한 변경입니다. 수정의 목적 그 자체이므로 기댓값을 갱신해도 됩니다. 그러나 아래 두 줄, 경감세율과 관계없을 B공업의 세액이 1엔 어긋난 것은 회귀입니다. 단수 처리 공통 함수에 손을 댄 것으로 보입니다. 여기서 “고작 1엔이니” 하고 기댓값을 덮어쓰면, 이후 이 1엔 어긋남이 “올바른 동작”으로 고정됩니다.
운영 규칙은 한 문장으로 정리됩니다. 차이의 한 줄 한 줄에 대해 “왜 이 행이 바뀌었는지”를 설명할 수 없다면, 기댓값을 갱신해서는 안 됩니다. 설명할 수 없는 행이 하나라도 있으면, 원인이 나올 때까지 조사합니다. 참고로 위 예에서는 小計 행이 갱신되지 않은 것에도 눈치챌 수 있습니다(경감세율 행이 바뀌면 소계도 바뀌어야 합니다). 바뀌어야 하는데 바뀌지 않은 행도, 차이 검토에서 찾아야 할 대상입니다.
6.3 CI가 없어도, 로컬에서 돌아가는 최소 구성을 만든다
CI 서버가 없는 현장에서도, 다음 최소 구성이면 오늘부터 시작할 수 있습니다.
- 솔루션에 테스트 프로젝트를 하나 추가한다(.NET Framework 그대로여도 MSTest / NUnit / xUnit 모두 동작합니다. 연결 방법은 3.4절)
- 기댓값 파일과 입력 데이터는
TestData폴더에 두고, 코드와 함께 버전 관리한다 - 커밋 전에 테스트를 손으로 실행하는 것을 팀의 약속으로 한다
- 실행 결과 확인을 잊지 않도록, 릴리스 절차서에 “테스트 실행과 차이 없음 확인”을 한 줄 넣는다
실행 수단은 dotnet test만이 아닙니다. 구형식(비 SDK 스타일) csproj가 섞인 솔루션에서는 dotnet test가 기대한 대로 동작하지 않을 수 있습니다. 그때의 선택지는 두 가지입니다.
- Visual Studio의 테스트 탐색기. 빌드하면 테스트가 자동으로 검색되고, GUI에서 실행할 수 있습니다. 테스트 경험이 없는 멤버에게는 이쪽이 도입이 쉽습니다.
vstest.console.exe. 빌드된 테스트 DLL을 직접 지정해 실행하는 커맨드라인 도구로, Developer Command Prompt에서 쓸 수 있습니다.6
vstest.console.exe MyApp.Tests\bin\Debug\MyApp.Tests.dll /logger:trx
무엇을 쓸지는 환경에 맞춰도 됩니다. 중요한 것은 “커밋 전에 반드시 한 번 돌린다”는 약속 쪽입니다.
테스트 실행 결과나 앱 쪽 출력을 대조할 때는, 로그가 갖춰져 있을수록 원인 조사가 빨라집니다. 로그에 무엇을 남길지는 “자체 로거의 최소 요건과 통합 테스트 체크리스트“에서 다룹니다.
7. 정리
- 레거시 코드란 “테스트가 없는 코드”이며,1 손대면 깨지는 진짜 원인은 변경 결과를 확인할 수단이 없다는 것입니다. 고치기 전에, 현재 동작을 테스트로 고정합니다.
- 특성화 테스트는 “올바른 동작”이 아니라 “현재 동작”을 기록하는 테스트입니다. 보고서·CSV·계산 결과를 그대로 기댓값 파일에 저장해 비교하는 골든 마스터 기법(승인 테스트 / 스냅샷 테스트)이라면, 소박한 C# 코드만으로 시작할 수 있습니다.
- 첫 관문은 “테스트 프로젝트에서 기존 EXE의 코드를 호출할 수 있게 하는 것”입니다. EXE 프로젝트도 프로젝트 참조할 수 있고,
internal인 채로 테스트하려면 본체 쪽에InternalsVisibleTo를 한 줄 더합니다(3.4절).2 - 테스트를 끼워 넣을 수 없는 구조에는 메서드 추출과 인터페이스 삽입으로 이음매(seam)를 만듭니다.
DateTime.Now같은 의존을 감싸는 기법은 Microsoft의 유닛 테스트 지침에도 나오는 정석입니다.43 - 어디까지 갖출지는 수정 규모 × 잔존 연수 × 장애 시 영향으로 정합니다. “특성화 테스트만”, “손대지 않는다”도 엄연한 판단입니다.
- 운영에서는 리팩터링(차이 없음이 합격)과 기능 추가(의도한 차이만 합격)를 섞지 않는 것,5 기댓값 갱신은 반드시 차이 검토를 거치는 것을 지킵니다. CI가 없어도, 로컬에서 테스트를 돌리겠다는 약속만으로 안전성은 크게 달라집니다.
관련 글
- 유닛 테스트와 통합 테스트의 경계를 어떻게 그을까
- VB6 앱은 언제까지 동작하는가 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법
- VB6 / Access 업무 앱의 연장과 이전 ── 남기기·감싸기·교체의 판단표
- 자체 로거의 최소 요건과 통합 테스트 체크리스트
- Windows 데스크톱 앱의 UI 자동 테스트
관련 상담 영역
합동회사 고무라 소프트에서는, 테스트 없는 기존 업무 앱에 특성화 테스트를 도입하는 일, 테스트 가능한 구조로의 단계적 리팩터링, 수정과 이전 중 어디에 투자할지 판단을 정리하는 일을 다룹니다.
참고 링크
-
Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). 일본어 번역 『레거시 코드 개선 가이드』(翔泳社). 레거시 코드를 “테스트가 없는 코드”로 정의한 점, 특성화 테스트(characterization test)로 현재 동작을 기록한 뒤에 변경에 착수하는 절차, 테스트를 끼워 넣기 위한 이음매(seam) 개념에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, InternalsVisibleToAttribute Class. 보통은 같은 어셈블리 안에서만 보이는 형식·멤버를, 지정한 friend assembly에서 보이게 하는 특성이라는 점, 대상은
internal/protected internal/private protected이며private는 포함되지 않는다는 점, 현재 어셈블리와 friend assembly는 둘 다 미서명이거나 둘 다 strong name이어야 한다는 점, strong name인 경우 공개 키 토큰이 아니라 완전한 공개 키를 지정해야 하며sn -p와sn -tp로 얻을 수 있다는 점에 대해. ↩ ↩2 ↩3 -
Microsoft Learn, Extract and inline refactorings (Visual Studio). Visual Studio의 C# / Visual Basic용 메서드 추출(Ctrl+R, M) 및 인터페이스 추출(Extract Interface) 리팩터링 조작 절차에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Unit testing best practices for .NET. 좋은 유닛 테스트의 성질(fast / isolated / repeatable / self-checking / timely),
DateTime.Now처럼 제어할 수 없는 의존을 인터페이스로 감싸 이음매(seam)를 도입하는 기법, 인프라 의존을 유닛 테스트에 들이지 말고 통합 테스트로 나눠야 한다는 점에 대해. ↩ ↩2 ↩3 -
Microsoft Learn, Refactor code (Visual Studio). 리팩터링이란, 동작을 바꾸지 않고 코드를 유지보수·이해·확장하기 쉽게 바꾸기 위한 프로세스라는 정의에 대해. ↩ ↩2
-
Microsoft Learn, VSTest.Console.exe command-line options. VSTest.Console.exe가 테스트를 실행하는 커맨드라인 도구라는 점, 테스트 파일(DLL)을 직접 지정해 실행할 수 있다는 점, Developer Command Prompt에서 이용할 수 있다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
VB6 앱은 언제까지 동작하는가 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법
VB6 앱은 언제까지 동작할까요. 런타임은 Windows 11에서도 동작 대상이고 IDE는 지원이 종료된 현황을 정리하고, 전면 재작성·자동 변환·단계 이전의 판단표, 이전 전 자산 파악, VB6와 .NET의 비호환까지 정리합니다.
DLL・COM 인터페이스의 하위 호환성 ── 어떤 변경이 호출 측을 깨뜨리는지의 판단표
DLL이나 COM 컴포넌트의 어떤 변경이 호출 측을 깨뜨리는지. 바이너리 호환・소스 호환・동작 호환의 3계층을 정리하고, 변경 내용별 판단표, COM 인터페이스 불변의 철칙, semver 운용까지를 실무 가이드로 정리합니다.
업무 앱의 DB 스키마를 버전 관리한다 ── 「고객사마다 DB가 다르다」를 막는 마이그레이션 실무
고객사마다 흩어진 업무 앱의 DB 스키마를 버전 관리하는 실무 가이드. PRAGMA user_version과 전진 마이그레이션의 C# 구현, EF Core Migrations·DbUp·자체 구현 판단표, 2단계 릴리스까지 정리합니다.
소스 코드도 명세서도 없는 시스템을 인수인계받았다면 ── 멈추지 않고 운영·유지보수하기 위한 실무 절차
소스 코드도 명세서도 없는 업무 시스템의 운영·유지보수를 시작하는 실무 절차를 정리합니다. 가동 중인 환경의 보존과 백업, 실행 파일·DB 인벤토리, 동작을 바탕으로 한 명세 복원, 수명 연장·래핑·재구축 판단까지 설명합니다.
Windows의 프로세스 간 통신을 어떻게 고를까 ── Named Pipe / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱끼리의 연동 수단을 어떻게 고를지 정리합니다. Named Pipe, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM의 강점과 함정을 판단표로 정리하고, 정석 구성과 Named Pipe 구현 예까지 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
ActiveX 이관
COM / ActiveX / OCX 자산을 유지할지, 감쌀지, 교체할지의 단계적 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 특성화 테스트(characterization test)란 무엇인가요?
- '올바른 동작'이 아니라 '현재 동작'을 그대로 기록하는 테스트입니다. 명세서가 남아 있지 않은 레거시 코드에서는 무엇이 맞는지를 확인할 수단이 없는 경우가 많기 때문에, 먼저 지금 돌아가는 코드의 출력(보고서, CSV, 계산 결과 등)을 기댓값으로 저장하고, 변경 전후에 출력이 바뀌지 않았는지를 기계적으로 확인합니다. 동작을 고정하는 안전망을 친 뒤에 리팩터링이나 기능 추가로 나아가는 것이 기본적인 사용법입니다.
- 테스트가 전혀 없는 레거시 코드는 어디서부터 손을 대면 되나요?
- 앞으로 바꿀 곳 주변만 골라 특성화 테스트를 작성하는 것이 현실적입니다. 시스템 전체에 테스트를 깔아 두는 일은 공수 면에서 성립하지 않는 경우가 대부분이고, 그럴 필요도 없습니다. 먼저 변경 대상 기능이 만드는 출력(보고서, CSV, DB에 쓰는 내용 등)을 특정하고, 대표적인 입력에서의 출력을 파일로 저장해 고정합니다. 그 안전망 안에서 메서드 추출 같은 작은 리팩터링으로 로직을 테스트 가능한 형태로 잘라 낸 다음, 본래의 변경에 착수합니다.
- 리팩터링과 기능 추가를 같은 커밋에 섞으면 안 되는 이유는 무엇인가요?
- 출력에 차이가 났을 때 원인을 가를 수 없게 되기 때문입니다. 리팩터링은 '동작이 바뀌지 않는 것'을, 기능 추가는 '의도한 곳만 동작이 바뀌는 것'을 확인하는 작업이라 검증의 합격 조건이 정반대입니다. 섞어 버리면 골든 마스터와의 차이가 '의도한 변경'인지 '깨뜨린 것'인지 판별할 수 없습니다. 리팩터링 커밋에서는 차이 없음, 기능 추가 커밋에서는 의도한 차이만, 이렇게 나눠 확인하는 것이 안전합니다.
- 골든 마스터(기댓값 파일)는 언제 갱신하나요?
- 의도적으로 동작을 바꿨을 때, 즉 기능 추가나 결함 수정 커밋의 타이밍뿐입니다. 갱신할 때는 변경 전후 출력의 차이를 눈으로 검토해 의도한 변경만 들어 있는지 확인한 뒤, 새 출력으로 기댓값을 바꿉니다. 테스트가 실패했다고 해서 기계적으로 기댓값을 덮어쓰면, 회귀(의도하지 않은 동작 변화)를 그대로 '정답'으로 받아들이게 되어 안전망으로서의 의미가 없어집니다.