반복 코드 131줄을 정규식으로 한 번에 바꿔도 괜찮았던 이유

혼자 운영하는 서비스는 점검을 자꾸 미루게 됩니다. 기능이 돌아가고 있으면 코드를 다시 들여다볼 이유가 생기지 않고, 그 사이에 같은 패턴이 계속 복사됩니다. 저도 얼마 전 하루를 잡고 운영 중인 서비스 코드를 전체 점검했는데, 관리자 API 라우트 파일 70개에 완전히 똑같은 인증 가드 한 줄이 131번 반복되고 있었습니다.

if (!(await requireAdmin())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

처음에는 그대로 둘까 했습니다. 동작에는 아무 문제가 없었고, 한 줄짜리 반복은 리팩토링 대상 중에서도 가성비가 낮은 편이라고 생각했습니다. 그런데 세어 보니 이미 균열이 있었습니다. 두 파일은 공용 헬퍼를 import하는 대신 똑같은 함수를 파일 안에 다시 정의해 두었고, 한 파일은 NextResponse.json이 아니라 Response.json으로 응답을 만들고 있었습니다. 로직이 틀린 건 아니지만, 401 응답의 모양을 바꿔야 하는 날이 오면 131곳을 찾아다녀야 한다는 뜻입니다. 반복은 시간이 지나면 이렇게 조금씩 부식됩니다.

그래서 응답까지 만들어 주는 헬퍼를 하나 추가했습니다.

export async function adminGuard(): Promise<NextResponse | null> {
return (await requireAdmin())
? null
: NextResponse.json({ error: "unauthorized" }, { status: 401 });
}

호출부는 두 줄이 됩니다.

const deny = await adminGuard();
if (deny) return deny;

문제는 70개 파일을 어떻게 고치느냐였습니다. 손으로 열어서 고칠 수는 없으니 perl 정규식 일괄 치환을 택했는데, 돌리기 전에 한 가지를 먼저 확인했습니다. 치환 대상이 정말 전부 같은 문자열인가. grep으로 세어 보니 한 줄 형태가 40곳, 나머지는 if 블록으로 감싼 세 줄 형태였고, 블록 안의 응답 문장은 전부 동일했습니다. 여기서 다르게 생긴 변형이 하나라도 나왔다면 일괄 치환은 접었을 겁니다.

치환 자체는 한 번에 됐는데, import 정리에서 헛짚었습니다. "파일에 requireAdmin이라는 단어가 더 이상 없으면 import 문을 adminGuard로 바꾼다"라는 조건을 걸었는데, import 문 자체에 requireAdmin이 들어 있으니 이 조건은 영원히 거짓입니다. 치환을 돌리고 세어 보니 본문은 다 바뀌었는데 import는 하나도 안 바뀌어 있었습니다. 조건을 단어 존재가 아니라 호출 형태인 requireAdmin()의 존재 여부로 바꾸고 나서야 제대로 돌았습니다. 정규식으로 코드 고칠 때 조건문이 자기 자신을 참조하고 있지 않은지, 저는 이번에 한 번 데이고 배웠습니다.

일부러 안 바꾼 곳도 있습니다. 세 개 라우트는 가드 다음 줄부터 session.user.id 같은 세션 정보를 써야 해서, true/false만 돌려주는 가드로는 대체할 수 없었습니다. 이런 파일까지 기계적으로 밀었다면 컴파일은 되는데 동작이 바뀌는 사고가 났을 겁니다. 일괄 치환은 예외를 미리 분류해 둔 만큼만 안전합니다.

마지막은 검증입니다. 타입체크는 치환 전에도 한 번 돌려서 기준선이 0 오류인 걸 확인해 뒀고, 치환 후에 다시 돌려 0 오류, 프로덕션 빌드까지 통과를 확인했습니다. 최종 diff는 73개 파일에 +382/-461이었습니다. 지운 줄이 더 많은 리팩토링은 오랜만이라 기분이 좋았습니다.

돌아보면 이번 작업이 무모하지 않았던 건 정규식이 정교해서가 아닙니다. 치환 대상이 전부 동일한 리터럴임을 먼저 세어서 확인했고, 예외가 되는 파일을 미리 골라냈고, 타입체크와 빌드라는 그물이 아래에 깔려 있었기 때문입니다. 셋 중 하나라도 없었다면 저는 70개 파일을 손으로 고치는 쪽을 택했을 겁니다.

같은 점검에서 겪은 다른 이야기:

중복처럼 보이는 코드를 함부로 합치면 안 되는 이유