
프로그래밍을 처음 배울 때 리팩터링은 보통 “동작하는 코드를 더 깔끔하게 정리하는 일”이라고 배운다.
const n = appointment.patientName;
의미가 드러나는 이름으로 바꿀 수 있다.
const patientName = appointment.patientName;
두 코드의 결과는 같다. 변수 이름만 읽기 쉽게 바뀌었다.
리팩터링의 기본 개념을 이해하기에는 충분한 설명이다. 불분명한 이름을 바꾸고, 긴 함수를 나누고, 중복 코드를 줄이는 일은 여전히 중요하다.
하지만 실제 서비스를 개발하면 더 많은 판단이 필요하다.
리팩터링은 코드를 깔끔하게 정리하는 일이 아니라 현재 동작을 유지하면서 코드의 책임과 경계를 다시 설계해 다음 변경의 비용과 위험을 줄이는 작업이다.
크리스가 병원 예약 서비스를 개발한다고 생각해 보자.
환자가 진료 예약을 취소할 때 다음 함수가 실행된다.
async function cancelAppointment(
appointmentId: string,
patientId: string
) {
const appointment =
await appointmentRepository.findById(
appointmentId
);
if (!appointment) {
throw new Error("Appointment not found");
}
if (appointment.patientId !== patientId) {
throw new Error("Forbidden");
}
if (appointment.status !== "confirmed") {
throw new Error("Cannot cancel appointment");
}
const hoursUntilAppointment =
(appointment.startsAt.getTime() - Date.now()) /
(1000 * 60 * 60);
if (hoursUntilAppointment < 24) {
throw new Error("Cancellation period expired");
}
await appointmentRepository.update(
appointmentId,
{ status: "cancelled" }
);
await notificationService.sendCancellation(
appointment
);
}
이 코드는 동작할 수 있다. 그러나 한 함수가 여러 책임을 맡고 있다.
취소 수수료, 병원별 정책, 의사 알림과 환불 기능이 추가되면 이 함수는 더 많은 이유로 변경된다.
문제는 줄 수가 길다는 사실 자체가 아니다. 서로 다른 정책과 외부 작업이 한곳에 묶여 있어 하나를 바꿀 때 나머지 동작까지 영향을 받을 수 있다는 점이다.
리팩터링은 현재 코드를 보기 좋게 만드는 데서 끝나지 않는다. 다음 요구사항이 들어왔을 때 어디를 변경해야 하는지 명확하게 만드는 작업이다.
리팩터링은 외부에서 관찰할 수 있는 동작을 보존한다.
병원 예약 취소에서 보존해야 할 동작은 단순한 반환값만이 아니다.
| 입력 또는 상황 | 보존해야 할 동작 |
|---|---|
| 존재하지 않는 예약 | Appointment not found 오류 |
| 다른 환자의 예약 | 권한 오류 |
| 이미 완료되거나 취소된 예약 | 취소 거부 |
| 시작까지 24시간 미만 | 취소 거부 |
| 정상적인 취소 | 상태를 cancelled로 변경 |
| 저장 완료 후 | 취소 알림 발송 |
| 저장 실패 | 알림을 발송하지 않음 |
이 경계를 테스트로 고정할 수 있다.
it("does not send a notification when saving fails", async () => {
appointmentRepository.update.mockRejectedValue(
new Error("Database unavailable")
);
await expect(
cancelAppointment(
confirmedAppointment.id,
confirmedAppointment.patientId
)
).rejects.toThrow("Database unavailable");
expect(
notificationService.sendCancellation
).not.toHaveBeenCalled();
});
이 테스트는 데이터베이스 저장에 실패했을 때 취소 알림이 먼저 발송되지 않는다는 동작을 보호한다.
테스트가 모든 오류를 막아 주는 것은 아니다. 그러나 무엇을 유지해야 하는지 표현하지 않은 채 구조를 크게 바꾸는 것보다 훨씬 안전한 출발점을 제공한다.
테스트가 부족한 오래된 코드라면 현재 동작을 기록하는 테스트부터 추가할 수 있다. 현재 동작이 이상적으로 보이지 않더라도 우선 그대로 기록하고, 버그 수정이나 정책 변경은 별도의 작업으로 분리하는 편이 낫다.
크리스가 취소 제한을 24시간에서 12시간으로 바꾸면서 함수 구조까지 전부 변경한다고 생각해 보자.
변경 후 테스트가 실패하면 원인을 구분하기 어렵다.
따라서 두 종류의 변경을 나누는 편이 좋다.
1. 기존 동작을 테스트로 고정한다.
2. 동작을 유지한 채 구조를 개선한다.
3. 리팩터링된 코드에서 새 정책을 적용한다.
4. 새 정책에 맞춰 테스트를 수정하거나 추가한다.
이 순서는 구조 변경과 기능 변경을 각각 검증할 수 있게 한다.
실제 커밋도 같은 기준으로 나눌 수 있다.
refactor: extract appointment cancellation policy
feat: allow cancellation until 12 hours before appointment
첫 번째 커밋은 동작을 유지하며 책임을 이동한다. 두 번째 커밋은 사용자가 경험하는 정책을 변경한다.
두 작업을 반드시 서로 다른 배포로 나눠야 하는 것은 아니다. 다만 코드 검토와 실패 분석에서 어떤 변경이 동작을 바꾸는지 구분할 수 있어야 한다.
기존 함수에서 취소 가능 여부를 별도 함수로 옮길 수 있다.
function canCancelAppointment(
appointment: Appointment,
now: Date
) {
if (appointment.status !== "confirmed") {
return false;
}
const hoursUntilAppointment =
(appointment.startsAt.getTime() -
now.getTime()) /
(1000 * 60 * 60);
return hoursUntilAppointment >= 24;
}
이 함수는 예약 상태와 남은 시간을 이용해 취소 가능 여부를 계산한다.
단순히 코드 조각을 다른 위치로 옮긴 것처럼 보이지만 중요한 변화가 생긴다. 취소 정책이 데이터 조회와 알림 전송에서 분리되었다.
정책이 병원마다 달라진다면 시간 기준을 외부에서 받을 수 있다.
type CancellationPolicy = {
minimumNoticeHours: number;
};
function canCancelAppointment(
appointment: Appointment,
policy: CancellationPolicy,
now: Date
) {
if (appointment.status !== "confirmed") {
return false;
}
const hoursUntilAppointment =
(appointment.startsAt.getTime() -
now.getTime()) /
(1000 * 60 * 60);
return (
hoursUntilAppointment >=
policy.minimumNoticeHours
);
}
이제 취소 가능 여부는 병원별 정책을 명시적인 입력으로 받는다.
예약 조회는 저장소의 책임이고, 취소 가능 여부는 도메인 정책이며, 알림 발송은 외부 서비스와의 통신이다. 함수를 나누는 기준은 코드 길이가 아니라 서로 다른 책임과 변경 이유다.
다음 조건은 실행되지만 의미를 바로 파악하기 어렵다.
if (
appointment.status === "confirmed" &&
hours >= 24
) {
await cancel();
}
이 코드에서는 24가 무엇을 의미하는지, 조건 전체가 어떤 정책을 표현하는지 알기 어렵다.
이름을 통해 서비스의 언어를 드러낼 수 있다.
const cancellationPolicy = {
minimumNoticeHours: 24,
};
const isWithinCancellationPeriod =
canCancelAppointment(
appointment,
cancellationPolicy,
currentTime
);
if (isWithinCancellationPeriod) {
await cancelAppointment();
}
이 코드는 예약이 취소 가능 기간 안에 있는지를 판단한 뒤 취소 작업을 실행한다.
좋은 이름은 코드를 영어 문장처럼 보이게 만드는 장식이 아니다. 현실의 규칙이 코드에서 어떤 개념으로 표현되는지 보여주는 설계 도구다.
예약 취소와 일정 변경은 현재 같은 조건을 사용할 수 있다.
function canCancel(
appointment: Appointment,
now: Date
) {
return (
appointment.status === "confirmed" &&
hoursUntil(appointment.startsAt, now) >= 24
);
}
function canReschedule(
appointment: Appointment,
now: Date
) {
return (
appointment.status === "confirmed" &&
hoursUntil(appointment.startsAt, now) >= 24
);
}
두 함수의 구현은 현재 동일하다.
중복을 제거하기 위해 하나의 함수로 합치고 싶을 수 있다.
function canModifyAppointment(
appointment: Appointment,
now: Date
) {
return (
appointment.status === "confirmed" &&
hoursUntil(appointment.startsAt, now) >= 24
);
}
하지만 병원이 다음 달부터 취소는 24시간 전까지, 일정 변경은 12시간 전까지 허용할 수 있다.
현재의 코드 모양은 같지만 변경 이유는 다르다. 이런 중복을 성급하게 합치면 하나의 규칙을 수정할 때 관련 없는 정책까지 영향을 받는다.
리팩터링에서 중요한 질문은 “코드가 반복되는가?”만이 아니다.
이 코드들은 같은 이유로 함께 변경되는가?
변경 이유가 다르다면 약간의 중복이 잘못된 추상화보다 안전할 수 있다.
환자가 취소 요청을 보낼 때 예약 ID와 취소 이유가 외부에서 들어온다.
외부 입력은 검증 전까지 신뢰할 수 없다.
import { z } from "zod";
const CancelAppointmentSchema = z.object({
appointmentId: z.string().uuid(),
reason: z
.enum([
"schedule_conflict",
"feeling_better",
"other",
])
.optional(),
});
const command =
CancelAppointmentSchema.parse(request.body);
이 코드는 예약 ID의 형식과 허용된 취소 이유를 애플리케이션 경계에서 검증한다.
리팩터링 과정에서 타입이 이미 존재한다는 이유로 검증을 제거해서는 안 된다. TypeScript 타입은 실행 중 들어오는 JSON이 올바르다는 사실을 보장하지 않는다.
검증된 명령만 내부 로직으로 전달할 수 있다.
await appointmentCancellationService.cancel({
appointmentId: command.appointmentId,
patientId: currentUser.id,
reason: command.reason,
requestedAt: new Date(),
});
이 코드는 외부 입력 검증과 인증된 사용자 정보를 결합해 취소 서비스에 전달한다.
리팩터링은 경계를 없애는 일이 아니다. 어떤 데이터가 외부에서 들어오고, 어느 지점부터 내부에서 신뢰할 수 있는지를 더 명확하게 만드는 작업이다.
예약 가능 시간을 빠르게 표시하기 위해 availableSlots를 별도 배열로 저장하고 있다고 생각해 보자.
type DoctorSchedule = {
appointments: Appointment[];
availableSlots: Date[];
};
appointments와 availableSlots를 모두 수정해야 한다면 두 값이 서로 달라질 수 있다.
실제 예약이 Source of Truth라면 가능한 시간은 예약과 근무 시간에서 계산할 수 있다.
function calculateAvailableSlots(
workingHours: TimeRange[],
appointments: Appointment[]
) {
return workingHours.filter((slot) =>
appointments.every(
(appointment) =>
!overlaps(slot, appointment)
)
);
}
이 함수는 의사의 근무 시간과 현재 예약을 기준으로 가능한 시간을 계산한다.
계산 비용 때문에 결과를 캐시할 수는 있다. 그러나 캐시는 원본이 아니라 다시 만들 수 있는 파생 데이터라는 책임을 유지해야 한다.
리팩터링 중 데이터 필드를 제거하거나 계산 방식으로 바꾸기 전에는 다음 내용을 확인해야 한다.
코드 구조만 바꾸는 작업이 데이터 의미까지 바꾸기 시작하면 단순한 리팩터링의 범위를 넘어선다. 이때는 마이그레이션과 호환성을 별도로 설계해야 한다.
데이터베이스 저장, 현재 시간 확인, 이메일 발송처럼 외부 상태와 연결된 작업은 테스트와 변경을 어렵게 만들 수 있다.
기존 함수는 Date.now()를 직접 사용한다.
function canCancelAppointment(
appointment: Appointment
) {
const hoursUntilAppointment =
(appointment.startsAt.getTime() -
Date.now()) /
(1000 * 60 * 60);
return hoursUntilAppointment >= 24;
}
실행 시점마다 결과가 달라지므로 경계 시간의 테스트가 불안정해질 수 있다.
현재 시간을 입력으로 전달할 수 있다.
function canCancelAppointment(
appointment: Appointment,
now: Date,
minimumNoticeHours: number
) {
const hoursUntilAppointment =
(appointment.startsAt.getTime() -
now.getTime()) /
(1000 * 60 * 60);
return (
appointment.status === "confirmed" &&
hoursUntilAppointment >=
minimumNoticeHours
);
}
이제 같은 입력에는 같은 결과가 나온다. 24시간 바로 전과 직후 같은 경계 조건도 정확히 테스트할 수 있다.
핵심 정책은 가능한 한 입력을 받아 결과를 계산하도록 만들고, 데이터베이스와 알림 같은 부수 효과는 작업의 바깥 경계에서 조정하는 편이 이해하기 쉽다.
크리스가 취소 정책을 별도 객체로 분리했다고 생각해 보자.
class AppointmentCancellationPolicy {
constructor(
private readonly minimumNoticeHours: number
) {}
evaluate(
appointment: Appointment,
requestedAt: Date
): CancellationDecision {
if (appointment.status !== "confirmed") {
return {
allowed: false,
reason: "invalid_status",
};
}
if (
hoursUntil(
appointment.startsAt,
requestedAt
) < this.minimumNoticeHours
) {
return {
allowed: false,
reason: "notice_period_expired",
};
}
return { allowed: true };
}
}
이 객체는 취소 가능 여부뿐 아니라 거부 이유까지 비즈니스 의미로 반환한다.
그 결과 API는 정책을 다시 해석하지 않고 사용자에게 적절한 오류를 전달할 수 있다. 병원별 제한 시간도 생성 시점에 주입할 수 있다.
그러나 클래스를 만들었다는 사실만으로 설계가 좋아지는 것은 아니다. 다음 변경에서 실제로 확인해야 한다.
리팩터링의 결과는 변경 직후의 코드 모양보다 다음 변경을 얼마나 국소적으로 처리할 수 있는지에서 드러난다.
전체 예약 모듈을 한 번에 다시 작성하면 중간에 동작을 확인하기 어렵다.
대신 다음처럼 작은 단계로 진행할 수 있다.
1. 기존 취소 동작을 테스트로 기록한다.
2. 시간 계산을 순수 함수로 추출한다.
3. 취소 가능 조건을 정책 함수로 추출한다.
4. 오류 이유를 명시적인 타입으로 표현한다.
5. 저장과 알림 순서를 서비스에 남긴다.
6. 각 단계마다 테스트와 타입 검사를 실행한다.
7. 동작이 같음을 확인한 뒤 다음 단계로 이동한다.
각 단계는 작지만 함께 적용하면 책임 구조가 크게 달라진다.
작은 변경은 코드 검토도 쉽게 한다. 검토자는 수백 줄의 이전 코드와 새 코드를 추측으로 비교하는 대신 한 번에 하나의 의도를 확인할 수 있다.
리팩터링 도구가 이름 변경과 함수 이동을 자동화해도 결과 검증까지 대신해 주지는 않는다. 자동화된 변환 이후에도 테스트, 타입 검사와 실제 호출 경계를 확인해야 한다.
읽기 어려운 코드를 발견했다고 해서 즉시 전체를 개선해야 하는 것은 아니다.
리팩터링에는 시간과 위험이 따른다. 우선순위는 코드의 모양보다 변경 가능성과 영향 범위를 기준으로 정할 수 있다.
| 코드 상황 | 판단 |
|---|---|
| 자주 변경되고 장애가 반복됨 | 우선적으로 개선할 가치가 큼 |
| 다음 기능이 같은 영역을 수정함 | 기능 작업 전에 필요한 부분을 개선 |
| 여러 팀이 공통으로 사용함 | 계약과 영향 범위를 먼저 확인 |
| 곧 제거될 코드 | 큰 구조 개선보다 안전한 제거 준비 |
| 거의 변경되지 않는 안정된 코드 | 불편하다는 이유만으로 재작성하지 않음 |
| 장애 대응 중인 코드 | 우선 복구하고 별도 작업으로 개선 |
기능을 추가하다가 필요한 만큼 주변 코드를 개선하는 방식도 유용하다. 흔히 지나가는 곳을 조금 더 나은 상태로 남기는 접근이다.
다만 기능 변경과 관계없는 대규모 정리를 같은 작업에 포함하면 검토 범위가 커진다. 현재 요구사항을 안전하게 구현하는 데 필요한 범위를 중심으로 판단해야 한다.
테스트가 성공해도 실제 서비스의 모든 동작이 보존되었다고 단정할 수는 없다.
예약 취소 흐름에는 다음 검증이 함께 필요할 수 있다.
코드 커버리지 숫자가 높다는 사실보다 중요한 것은 중요한 동작과 실패 조건이 테스트되는지다.
리팩터링 후 오류율이나 처리 시간이 달라졌다면 외부 동작이 완전히 보존되지 않았을 수 있다. 이전 글에서 다룬 배포와 모니터링이 리팩터링의 안전장치로 다시 연결되는 이유다.
보존해야 할 동작이 명확하지 않으면 변경 후 무엇이 깨졌는지 알기 어렵다.
먼저 핵심 동작과 실패 조건을 테스트로 기록해야 한다.
테스트 실패와 운영 문제의 원인을 구분하기 어려워진다.
동작을 보존하는 단계와 동작을 변경하는 단계를 구분하는 편이 낫다.
현재 모양이 같아도 서로 다른 정책은 다른 시점에 변경될 수 있다.
중복 여부보다 변경 이유가 같은지 확인해야 한다.
작은 규칙마다 인터페이스와 클래스를 만들면 이동해야 할 파일만 늘어날 수 있다.
실제 책임과 변형 지점이 있을 때 추상화를 추가해야 한다.
기존 코드에 숨어 있던 예외 처리와 호환 동작을 놓치기 쉽다.
작은 변환을 반복하며 각 단계에서 동작을 확인하는 편이 안전하다.
TypeScript 타입은 외부 JSON이나 데이터베이스의 오래된 값까지 검증하지 않는다.
외부 입력과 신뢰할 수 없는 데이터의 런타임 검증은 유지해야 한다.
테스트는 통과하지만 API 계약, 데이터 저장 순서나 운영 지표가 달라질 수 있다.
서비스 수준의 테스트와 배포 후 관찰이 함께 필요하다.
입문 단계에서는 리팩터링을 변수 이름을 바꾸고 긴 함수를 나누며 중복을 제거하는 작업으로 이해해도 충분하다.
const patientName =
appointment.patientName;
이 코드는 n보다 값의 의미를 분명하게 전달한다.
그러나 실제 서비스에서는 코드가 보기 좋아졌다는 사실만으로 리팩터링의 성공을 판단할 수 없다.
리팩터링은 코드를 깔끔하게 정리하는 일이 아니다.
리팩터링은 현재 동작을 유지하면서 코드의 책임과 경계를 다시 설계해 다음 변경의 비용과 위험을 줄이는 작업이다.