“고치고 싶은 부분은 알고 있습니다. 하지만 손을 댔다가 다른 곳이 망가지면 어쩌나 싶어서 손을 댈 수가 없어요”── 테스트가 없는 업무 애플리케이션을 인수받은 분들에게서 자주 듣는 말입니다.
VB6나 .NET Framework, Access로 작성된 업무 애플리케이션 대부분에는 자동 테스트가 없습니다. 사양서도 갱신이 멈춰 있어 “코드가 유일한 사양서”인 상태입니다. 그래도 업무는 계속되고 있으며, 소비세율 변경, 장표 레이아웃 수정, 거래처 추가 같은 개선 요청은 기다려 주지 않습니다.
당 블로그에서는 “VB6 애플리케이션은 언제까지 동작할까 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법“에서 기존 시스템을 “동작하는 사양서”로 삼아 출력을 대조하며 이전을 진행하는 사고방식을 소개했습니다. 이 글은 그 사고방식을 이전이 아니라 “지금 동작 중인 코드에 그 자리에서 손을 대는” 상황에 적용합니다. 중심이 되는 도구는 특성화 테스트(characterization test)입니다. 테스트가 없는 코드라도, 앞으로 망가뜨릴 수도 있는 동작을 먼저 테스트로 고정해 두면 리팩터링도 기능 추가도 훨씬 안전해집니다.
가정하는 독자와 전제 조건은 다음과 같습니다. 자동 테스트 경험은 필요 없으며, 테스트를 한 번도 작성해 본 적이 없는 상태에서도 시작할 수 있는 내용으로 구성했습니다. 전제로 필요한 것은 (1) 대상 애플리케이션을 직접 빌드할 수 있을 것(소스 코드와 빌드가 통과하는 개발 환경이 손에 있을 것), (2) 소스 코드에 변경을 가해도 되는 입장일 것, 이 두 가지뿐입니다. 코드 예제는 C#(.NET Framework / .NET 어느 쪽에서도 동작하는 작성법)으로 제시하지만, 사고방식 자체는 언어를 가리지 않습니다. 반대로 소스가 없거나 빌드할 수 없는 경우에는 이 글의 기법을 그대로 쓸 수 없으므로, 그 이전 단계의 정리부터 필요합니다.
1. 먼저 결론
- 곧바로 고치지 마세요. 먼저 현재 상태의 동작을 테스트로 고정합니다. 사양서가 없더라도, 지금 동작 중인 코드의 출력 그 자체가 사양입니다.
- 그를 위한 도구가 특성화 테스트입니다. “올바른 동작”이 아니라 “현재 동작”을 기록하는 테스트로, 장표・CSV・계산 결과 등의 출력을 그대로 기댓값으로 저장하고 변경 전후로 차분(diff) 비교합니다(골든 마스터 기법).
- 테스트를 끼워 넣을 수 없는 구조(UI 이벤트 핸들러에 직접 작성,
DateTime.Now나 파일 경로의 직접 참조)에는 메서드 추출과 인터페이스 끼워 넣기라는 최소한의 변경으로 “이음매(seam)”를 만듭니다. 대대적인 개조는 필요 없습니다. - 리팩터링과 기능 추가를 같은 커밋에 섞지 마세요. 리팩터링은 “차분 제로”, 기능 추가는 “의도한 차분만”이 합격 조건이며, 섞으면 차분의 의미를 판별할 수 없게 됩니다.
- 테스트를 어디까지 정비할지는 개선 규모 × 시스템 잔존 연수 × 장애 시 영향으로 결정합니다. 전부에 유닛 테스트를 붙이는 것이 항상 정답은 아니며, “특성화 테스트만” “손대지 않는다”가 정답인 경우도 있습니다.
- CI가 없어도 시작할 수 있습니다. 테스트 프로젝트 하나와 기댓값 파일 폴더만 있으면, 수동으로 돌리는 것만으로도 안전성은 크게 달라집니다.
2. 레거시 코드는 왜 “손을 대면 망가지는가”
레거시 코드의 개선이 두려운 것은 코드가 오래되었기 때문이 아닙니다. 변경한 결과가 옳은지를 확인할 수단이 없기 때문입니다.
Michael Feathers는 저서 『Working Effectively with Legacy Code』에서 레거시 코드를 “단순히 오래된 코드”가 아니라 “테스트가 없는 코드”로 정의했습니다.1 테스트가 없으면 코드가 좋아지고 있는지 나빠지고 있는지, 변경할 때마다 빠르게 확인할 방법이 없다는 것이 그 이유입니다. 이 정의를 따르면 어제 작성된 코드라도 테스트가 없으면 레거시 코드입니다.
테스트가 없는 코드에서는 다음과 같은 악순환이 돌기 시작합니다.
- 테스트가 없으므로 변경의 영향 범위를 알 수 없어 두렵다
- 두려우니 기존 구조는 고치지 않고 최소한의 복사·붙여넣기와 조건 분기 추가로 때운다
- 임시방편의 수정이 쌓이면서 코드는 더욱 읽기 어렵고 부서지기 쉬워진다
- 부서지기 쉬워졌으니 더욱 두려워진다(1로 돌아감)
이 악순환을 끊는 입구는 “용기를 내어 대규모 리팩터링을 하는” 것이 아닙니다. 순서는 반대로, 먼저 안전망(테스트)을 치고, 두려움의 원인을 제거한 뒤에 고치는 것입니다. 다만 여기에는 닭과 달걀 문제가 있습니다. 테스트를 작성하려면 테스트 가능한 구조가 필요합니다. 하지만 테스트 가능한 구조로 만들려면 코드를 변경(리팩터링)해야 합니다. 결국 테스트가 없는 코드를, 테스트 없이 변경하게 되어 버립니다.
이 모순을 풀기 위해 레거시 코드 개선은 다음 순서로 진행합니다.1
- 변경할 부분 주변만 현재 동작을 바깥쪽에서 고정한다(특성화 테스트)
- 그 안전망 안쪽에서 망가뜨릴 위험이 극히 낮은 최소한의 변경(메서드 추출 등)을 하여 테스트를 끼워 넣을 자리를 만든다
- 세밀한 테스트를 작성할 수 있는 구조가 되면, 본래 하고 싶었던 변경(리팩터링・기능 추가)에 착수한다
이후 장에서는 이 1과 2를 구체적으로 살펴봅니다.
3. 특성화 테스트 ── “현재 동작”을 기록하다
3.1 일반 테스트와 무엇이 다른가
일반 테스트는 “사양상 이래야 한다”는 올바른 동작을 검증합니다. 특성화 테스트는 다릅니다. 지금 코드가 실제로 어떻게 동작하는지를, 옳고 그름의 판단은 보류한 채 기록합니다.
예를 들어 단수 처리가 반올림인지 버림인지 사양서에 적혀 있지 않다고 합시다. 현재 코드가 버림으로 동작하고 있고, 그 상태로 업무가 10년째 돌아가고 있다면, 적어도 “버림이다”라는 것이 사실상의 사양입니다. 특성화 테스트는 이를 “현재 출력은 ◯◯이다”라는 형태로 그대로 고정합니다. 설령 그것이 버그였다 하더라도, 일단은 고정합니다. 동작을 바꾸는 것(버그 수정)은 안전망이 만들어진 뒤에 의도한 변경으로서 별도로 수행합니다.
3.2 골든 마스터 기법의 절차
출력 단위가 큰 레거시 코드에는 골든 마스터 기법이 가장 비용 대비 효과가 높은 특성화 테스트입니다. 절차는 소박합니다.
- 변경 대상 기능이 생성하는 출력(장표 텍스트, CSV, 계산 결과 목록 등)을 특정한다
- 대표적인 입력 데이터를 준비하고, 현재 코드를 실행해 출력을 얻는다
- 그 출력을 기댓값 파일(골든 마스터)로 그대로 저장하여 저장소(repository)에 넣는다
- 이후 코드를 변경할 때마다 테스트를 실행하여 출력과 기댓값 파일의 차분이 제로인지 확인한다
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가 회귀(regression)를 감지하지 못한 채 그린 상태로 통과해 버립니다. 최초 기록은 위 코드처럼 후보 파일(.candidate)을 출력한 뒤 명시적으로 실패시키고, 사람이 검토한 뒤에 기댓값으로 커밋하는 일방통행으로 만듭니다.
차분이 발생했을 때 Assert.Equal의 메시지만으로는 파악하기 어려우므로, 실무에서는 실패 시 실제 출력을 billing-actual-202606.txt와 같은 별도 파일로 저장해 두고, WinMerge 같은 차분 도구로 기댓값과 비교할 수 있게 해 두면 조사 속도가 빨라집니다.
참고로 이 코드 예제는 “기존 로직인 BillingReport.Generate를 테스트 프로젝트에서 그대로 호출할 수 있다”는 것을 전제로 합니다. 레거시 현장에서 가장 먼저 막히는 지점이 대개 여기이므로, 연결 방법은 3.4절에 정리했습니다.
이 기법은 문헌에 따라 부르는 이름이 달라, 골든 마스터 테스트 외에 승인 테스트(approval testing), 스냅샷 테스트(snapshot testing)라고도 불립니다. 같은 사고방식을 라이브러리화한 것도 있어, .NET에서는 ApprovalTests.Net이나 Verify가 대표적입니다. 기댓값 파일의 명명 규칙, 차분 도구의 자동 실행, 기댓값 승인 조작 등, 위 코드에서 직접 구현한 부분을 대신해 줍니다. 먼저 위와 같은 소박한 구현으로 시작하고, 기댓값 파일이 늘어나 관리가 번거로워지는 단계에서 라이브러리 도입을 검토하는 순서로도 충분합니다. 검색할 때는 “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)이 부여되어 있어야 합니다. 본체가 강력한 이름을 가진 경우에는
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
이음매를 만들 때 지켜야 할 원칙은 하나입니다. 이음매를 만드는 변경 자체는 동작을 1밀리미터도 바꾸지 않을 것. 메서드 추출과 인터페이스 끼워 넣기는 둘 다 컴파일러와 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
어느 것을 사용할지는 환경에 맞추면 됩니다. 중요한 것은 “커밋 전에 반드시 한 번 실행한다”는 약속 쪽입니다.
테스트 실행 결과와 애플리케이션 쪽 출력을 대조할 때는, 로그가 잘 정비되어 있을수록 원인 조사가 빨라집니다. 로그에 무엇을 남겨야 하는지는 “자체 제작 logger를 피할 수 없을 때, 정말 필요한 최소 요건은 무엇인가: 실무 요건과 통합 테스트 관점“에서 다루고 있습니다.
7. 정리
- 레거시 코드란 “테스트가 없는 코드”이며,1 손대면 망가지는 진짜 원인은 변경 결과를 확인할 수단이 없다는 것입니다. 고치기 전에 현재 동작을 테스트로 고정합니다.
- 특성화 테스트는 “올바른 동작”이 아니라 “현재 동작”을 기록하는 테스트입니다. 장표・CSV・계산 결과를 그대로 기댓값 파일에 저장해 차분 비교하는 골든 마스터 기법(승인 테스트 / 스냅샷 테스트)이라면, 소박한 C# 코드만으로 시작할 수 있습니다.
- 첫 번째 관문은 “테스트 프로젝트에서 기존 EXE의 코드를 호출할 수 있게 만드는” 것입니다. EXE 프로젝트도 프로젝트 참조가 가능하며,
internal상태 그대로 테스트하고 싶다면 본체 쪽에InternalsVisibleTo를 한 줄 추가합니다(3.4절).2 - 테스트를 끼워 넣을 수 없는 구조에는 메서드 추출과 인터페이스 끼워 넣기로 이음매(seam)를 만듭니다.
DateTime.Now와 같은 의존성을 감싸는 기법은 Microsoft의 유닛 테스트 지침에서도 제시하는 정석입니다.43 - 어디까지 정비할지는 개선 규모 × 잔존 연수 × 장애 시 영향으로 결정합니다. “특성화 테스트만” “손대지 않는다”도 훌륭한 판단입니다.
- 운영에서는 리팩터링(차분 제로가 합격)과 기능 추가(의도한 차분만이 합격)를 섞지 않을 것,5 기댓값 갱신은 반드시 차분 검토를 거치는 것을 지킵니다. CI가 없어도 로컬에서 테스트를 돌리는 약속만으로 안전성은 크게 달라집니다.
관련 글
- 어디까지를 유닛 테스트로 검증하고 어디부터를 결합 테스트로 검증해야 하는가 - 경계를 긋는 방법과 실무 판단표
- VB6 애플리케이션은 언제까지 동작할까 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법
- VB6 / Access 업무 애플리케이션의 연명과 이전 ── 남기다·감싸다·교체하다 판단표
- 자체 제작 logger를 피할 수 없을 때, 정말 필요한 최소 요건은 무엇인가: 실무 요건과 통합 테스트 관점
- Windows 데스크톱 앱의 UI 자동 테스트 ── UI Automation의 원리와 FlaUI로 만드는 잘 깨지지 않는 테스트
관련 상담 영역
합동회사 코무라소프트에서는 테스트가 없는 기존 업무 애플리케이션에 대한 특성화 테스트 도입, 테스트 가능한 구조로의 단계적 리팩터링, 개선과 이전 중 어디에 투자해야 할지 판단을 정리하는 일을 다루고 있습니다.
참고 링크
-
Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). 레거시 코드를 “테스트가 없는 코드”로 정의하고 있다는 점, 특성화 테스트(characterization test)로 현재 동작을 기록한 뒤 변경에 착수하는 절차, 테스트를 끼워 넣기 위한 이음매(seam) 개념에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, InternalsVisibleToAttribute Class. 보통은 동일한 어셈블리 내에서만 보이는 형식・멤버를, 지정한 프렌드 어셈블리에서 보이도록 하는 속성이라는 점, 대상은
internal/protected internal/private protected이며private은 포함되지 않는다는 점, 현재 어셈블리와 프렌드 어셈블리는 둘 다 서명되지 않았거나 둘 다 강력한 이름을 가져야 한다는 점, 강력한 이름을 가진 경우에는 공개 키 토큰이 아니라 완전한 공개 키를 지정해야 하며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 애플리케이션은 언제까지 동작할까요. VB6 런타임의 지원 정책(Windows 11도 지원 대상)과 IDE 지원은 이미 종료되었다는 비대칭적인 현황을 정리하고, 전면 재작성・자동 변환・단계적 이전의 판단표, 이전 전 자산 파악, VB6와 ....
DLL・COM 인터페이스의 하위 호환성 ── 어떤 변경이 호출 측을 망가뜨리는지의 판단표
DLL이나 COM 컴포넌트의 어떤 변경이 호출 측을 망가뜨리는지. 바이너리 호환・소스 호환・동작 호환의 3계층을 정리하고, 변경 내용별 판단표, COM 인터페이스 불변의 철칙, semver 운용까지를 실무 가이드로 정리합니다.
업무 앱의 DB 스키마를 버전 관리하는 법 ── 「거래처마다 DB가 다르다」를 막는 마이그레이션 실천
거래처마다 흩어져 있는 업무 앱의 DB 스키마를 버전 관리하는 실천 가이드. PRAGMA user_version과 전진 마이그레이션의 C# 구현, EF Core Migrations・DbUp・직접 구현의 판단표, 2단계 릴리스까지 정리합니다.
소스 코드도 사양서도 없는 시스템을 인계받았다면 ── 멈추지 않고 운용·보수하기 위한 실무 절차
소스 코드도 사양서도 없는 업무 시스템의 운용·보수를 시작하는 실무 절차를 정리합니다. 가동 중인 환경의 보전과 백업, 실행 파일·DB의 목록화, 동작으로부터의 사양 복원, 연명·감싸기·재구축의 판단까지 설명합니다.
Windows의 프로세스 간 통신을 어떻게 선택할까 ── 네임드 파이프 / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱 사이의 연동 수단을 어떻게 선택할지 정리합니다. 네임드 파이프, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM 각각의 강점과 함정을 판단표로 정리하고, UI+서비스 분리・32bit/64bit 브리지・권한 경계 같은 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
ActiveX 이관
COM / ActiveX / OCX 자산을 유지할지, 감쌀지, 교체할지의 단계적 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 특성화 테스트(characterization test)란 무엇인가요?
- '올바른 동작'이 아니라 '현재 동작'을 그대로 기록하는 테스트입니다. 사양서가 남아 있지 않은 레거시 코드에서는 무엇이 올바른지 확인할 수단이 없는 경우가 많기 때문에, 먼저 지금 동작 중인 코드의 출력(장표, CSV, 계산 결과 등)을 기댓값으로 저장하고, 변경 전후로 출력이 바뀌지 않았는지 기계적으로 확인합니다. 동작을 고정하는 안전망을 친 뒤에 리팩터링이나 기능 추가로 나아가는 것이 기본적인 사용법입니다.
- 테스트가 전혀 없는 레거시 코드는 어디서부터 손을 대야 하나요?
- 앞으로 변경할 부분 주변으로만 범위를 좁혀 특성화 테스트를 작성하는 것이 현실적입니다. 시스템 전체에 테스트를 치는 것은 공수 면에서 성립하지 않는 경우가 대부분이고, 그럴 필요도 없습니다. 먼저 변경 대상 기능이 생성하는 출력(장표, CSV, DB에 기록되는 내용 등)을 특정하고, 대표적인 입력에서의 출력을 파일로 저장해 고정합니다. 그 안전망 안쪽에서 메서드 추출 같은 작은 리팩터링을 수행해 로직을 테스트 가능한 형태로 분리한 뒤, 본래의 변경에 착수합니다.
- 리팩터링과 기능 추가를 같은 커밋에 섞으면 안 되는 이유는 무엇인가요?
- 출력의 차분이 발생했을 때 원인을 구분할 수 없게 되기 때문입니다. 리팩터링은 '동작이 바뀌지 않는 것'을, 기능 추가는 '의도한 부분만 동작이 바뀌는 것'을 확인하는 작업으로, 검증의 합격 조건이 정반대입니다. 섞어 버리면 골든 마스터와의 차분이 '의도한 변경'인지 '망가뜨린 것'인지 판별할 수 없습니다. 리팩터링 커밋에서는 차분 제로, 기능 추가 커밋에서는 의도한 차분만, 이렇게 나누어 확인하는 것이 안전합니다.
- 골든 마스터(기댓값 파일)는 언제 갱신하나요?
- 의도적으로 동작을 바꿨을 때, 즉 기능 추가나 결함 수정 커밋의 타이밍뿐입니다. 갱신할 때는 변경 전후의 출력 차분을 눈으로 검토하여 의도한 변경만 포함되어 있는지 확인한 뒤, 새 출력으로 기댓값을 교체합니다. 테스트가 레드가 되었다고 해서 기계적으로 기댓값을 덮어쓰면, 회귀(의도하지 않은 동작 변화)를 그대로 '정답'으로 받아들이게 되어 안전망으로서의 의미가 없어집니다.