개발 slecs

확장자 목록 상수화로 공통 유틸 유지보수성 개선

목차

CommonUtil 내부 클래스에서 파일 확장자 화이트리스트·블랙리스트를 static final Set으로 상수화하는 리팩토링을 진행했다. 변경 파일은 내부 클래스 하나뿐이지만, 규모에 비해 얻는 게 꽤 있는 작업이었다.

왜 상수화인가

파일 업로드/다운로드 같은 기능을 구현하다 보면 확장자 검증 로직이 여기저기 흩어지기 쉽다. 처음엔 메서드 안에 ".jpg", ".png", ".gif" 같은 문자열 리터럴을 그냥 박아 넣는다. 빠르고 간단하니까. 그런데 시간이 지나고 비슷한 검증이 필요한 곳이 두 곳, 세 곳으로 늘어나면 같은 리스트가 조용히 복제된다. 이게 문제다.

나중에 허용 확장자 하나를 추가하거나 제거해야 할 때 여러 곳을 동시에 손대야 하는데, 그 중 하나를 빠뜨리면 업로드는 되는데 다운로드에서 막히거나, 한 엔드포인트에선 허용되고 다른 엔드포인트에선 거부되는 상황이 생긴다. 이런 불일치는 에러 로그도 안 남기고 그냥 동작이 달라지는 거라 디버깅하기 까다롭다.

단일 메서드 안에서만 쓰더라도 리터럴 나열 방식은 의도를 숨긴다.

// Before: 의도가 안 보이는 조건 나열
public boolean isAllowed(String ext) {
    return ext.equals(".jpg") || ext.equals(".png")
        || ext.equals(".pdf") || ext.equals(".xlsx");
}

public void validateUpload(String ext) {
    if (ext.equals(".exe") || ext.equals(".bat") || ext.equals(".sh")) {
        throw new IllegalArgumentException("업로드 불가 파일");
    }
}

이걸 읽는 사람은 "이게 화이트리스트야, 아니면 그냥 특수 케이스야?"라는 질문을 먼저 해야 한다. 확장자가 7~8개로 늘어나면 조건문 한 줄이 화면을 넘어가고, 그 시점부터 검토 자체가 귀찮아진다.

// After: 이름이 의도를 말해준다
private static final Set<String> ALLOWED_EXTENSIONS =
    Set.of(".jpg", ".png", ".pdf", ".xlsx");

private static final Set<String> BLOCKED_EXTENSIONS =
    Set.of(".exe", ".bat", ".sh");

public boolean isAllowed(String ext) {
    return ALLOWED_EXTENSIONS.contains(ext);
}

public void validateUpload(String ext) {
    if (BLOCKED_EXTENSIONS.contains(ext)) {
        throw new IllegalArgumentException("업로드 불가 파일");
    }
}

Set.of()로 만든 컬렉션은 불변이다. 외부에서 .add().remove()로 건드릴 수 없다. static final이어서 클래스 로딩 시 딱 한 번만 초기화되고, 이후엔 참조만 한다. contains() 연산이 리스트 대비 O(1)이라 성능도 나빠지지 않는다. 확장자 목록이 10개를 넘어가는 시점부터는 이 차이가 무시하기 어려운 수준이 된다.

내부 클래스에 상수를 두는 결정

변경 범위를 내부 클래스 하나로 좁힌 건 의도적인 선택이었다. 리팩토링은 필요한 곳만 건드려야 한다는 원칙이 있고, 확장자 검증이 해당 내부 클래스의 책임이라면 상수도 그 안에 두는 게 맞다. 공통 상수 클래스로 빼거나 public으로 올리는 건 실제로 다른 곳에서 참조해야 할 때 하면 된다. 지금 시점에서 범위를 키울 이유가 없었다.

변경 사항 요약:

항목 변경 전 변경 후
확장자 목록 위치 메서드 내 인라인 리터럴 static final Set 상수
불변성 보장 안 됨 Set.of() 불변 컬렉션
의도 명확성 조건문 나열 이름 붙은 집합
수정 지점 여러 메서드 분산 상수 선언 1곳
contains 성능 O(n) O(1)

동작은 바꾸지 않았다. 리팩토링 전후 같은 입력에 같은 결과가 나오는지 주요 케이스를 확인하고 커밋했다. 이 확인 과정을 빠뜨리면 리팩토링이 아니라 그냥 코드 변경이 된다. 두 개념은 다르다.

작은 리팩토링을 따로 커밋하는 이유

이번처럼 "기능 변경 없이 구조만 정리한" 커밋을 따로 분리하면 나중에 이점이 있다. 기능 추가와 리팩토링이 한 커밋에 뭉쳐 있으면, 버그 발생 시 "기능 변경 때문인가, 구조 변경 때문인가"를 가려내기가 귀찮아진다. git bisectgit blame을 쓸 때도 순수 리팩토링 커밋은 훨씬 건너뛰기 쉽다.

커밋 메시지도 "무엇을"보다 "왜"를 담으려고 노력하는 편이다. "확장자 상수화"보다 "확장자 목록 중복 수정 지점 제거 - 향후 변경 시 단일 위치만 수정"이 나중에 git log 흘려볼 때 훨씬 맥락이 살아 있다. 몇 달 뒤에 내가 봐도, 처음 보는 팀원이 봐도 판단이 쉽다.

파일 처리 관련 유틸은 보안과 연결되는 경우가 많다. 특히 블랙리스트 기반 확장자 차단을 쓰는 경우엔 화이트리스트 방식으로 전환을 고려할 만하다. 블랙리스트는 새로운 위험 확장자가 등장했을 때 항상 한 박자 늦게 반응한다. 화이트리스트는 정의되지 않은 것은 전부 거부하므로 기본적으로 더 보수적인 태도고, 실수로 허용하는 케이스가 줄어든다. 이번 작업에서 그 방향 전환까지 다루진 않았다. 단, 구조가 정돈되고 나면 그 다음 논의가 훨씬 쉬워진다. 어떤 확장자를 허용하고 어떤 걸 막는지가 코드에서 바로 눈에 들어오니까.

유틸 클래스는 건드릴 일이 없다가 갑자기 여러 기능 개발에서 동시에 수정 필요가 생기는 상황이 종종 있다. 그때 구조가 정돈돼 있으면 충돌이 줄고 머지가 빠르다. 코드 리뷰에서도 마찬가지다. "확장자 하나 추가해야 합니다"라는 요청에 "상수 선언부 한 줄 바꾸면 됩니다"와 "A 메서드, B 메서드, C 메서드 세 곳 다 바꿔야 합니다"는 팀 전체 속도에서 차이가 난다. 리뷰어도 영향 범위를 빠르게 파악할 수 있고, 빠뜨린 곳 없는지 검증도 그만큼 쉬워진다. 이번 리팩토링은 그 준비 작업 중 하나다.

댓글 0

첫 댓글 달아줘.