삭제 코드를 옮길 때는 실행 책임까지 옮겨야 한다
목차
파일 정리 함수를 새 모듈로 옮겼는데도 예전 서비스가 여전히 삭제 후보를 고르고 삭제 여부를 판단한다면, 무엇이 달라진 걸까. 파일 위치는 바뀌었지만 실제 실행 책임은 남아 있다.
이번 주에는 업로드 뒤 늦게 남은 파생 파일을 정리하는 코드를 모듈로 옮겼다. 단건 복구와 전체 순회가 대상이었다. 기존 CLI는 그대로 쓸 수 있어야 했고, 현재 사용 중인 파일을 보호하는 검사도 유지해야 했다.
삭제 코드는 반환값이 같다는 이유만으로 안전하다고 보기 어렵다. 같은 개수의 객체를 지워도 그중 하나가 현재 사용 중인 파일의 객체라면 실패다. 그래서 이번 이동에서는 코드의 위치보다 누가 후보를 고르고 누가 소유권을 확인하고 누가 삭제를 실행하는지부터 정리했다. 아래 이름과 코드는 구조를 설명하기 위해 재구성했다.
늦게 남은 파일과 버려진 파일은 다르다
본문 처리나 변환 작업이 요청보다 늦게 끝나면 원래 작업이 종료된 뒤에도 파생 객체가 남을 수 있다. 그렇다고 특정 확장자나 디렉터리에 있는 파일을 모두 지워도 되는 것은 아니다. 같은 위치에 정상 업로드가 있거나 현재 파일이 그 객체를 참조할 수 있다.
단건 정리의 중요한 순서는 다음과 같았다.
정리할 원본 위치 확인
→ 현재 살아 있는 참조인지 검사
→ 후보를 찾고 소유권으로 필터링
→ 실제 삭제 직전에 소유권 재확인
→ 남은 정확한 key만 삭제이 순서는 단순한 준비 코드가 아니다. 삭제 권한을 판단하는 과정이다. 새 모듈을 만들었다는 이유로 이 검사를 호출자에게 흩어 놓으면, 다음 호출자가 하나를 빠뜨릴 때 안전 조건이 달라진다.
전체 순회에도 별도 계약이 있었다. 허용한 bucket과 key만 대상으로 삼고 목록의 다음 marker가 전진하지 않으면 실패로 처리했다. 삭제 실패와 제한된 결과 표본도 기존 방식대로 유지했다. 대량 순회가 끝나지 않거나 일부 실패를 성공으로 숨기는 문제는 모듈 이동 중에도 그대로 방어해야 했다.
호환 계층은 남기고 실행은 한곳으로 모았다
기존 CLI가 사용하는 함수와 결과 타입을 모두 바꾸면 코드 이동과 호출자 수정이 한 diff에 섞인다. 이번에는 기존 진입점을 남기고 구현만 새 모듈로 위임했다.
축약하면 다음 구조다.
// 기존 패키지: 호출 형태와 결과 타입을 유지한다.
type CleanupResult = content.CleanupResult
func Cleanup(ctx context.Context, key string) (CleanupResult, error) {
return content.Cleanup(ctx, key)
}이 코드는 실행 예제가 아니라 의존 방향을 나타내는 예시다. 별도 wrapper 결과를 만들어 복사하지 않고 Go의 type alias로 같은 타입을 가리켰다. 기존 CLI는 같은 진입점을 호출하지만 후보 조회와 소유권 판단, 삭제 호출은 새 모듈이 맡는다. 스토리지별 실제 삭제 전략은 기존 조립 계층에서 주입했다. 저장소 구현까지 전부 새로 옮긴 것은 아니다.
이번 단계에서는 호환 함수의 존재보다 그 안에 후보 선정과 삭제 판단이 남아 있는지를 봤다. 전체 전환에서 호환 계층을 언제 제거할지는 별도 과제다. 단순 위임이면 호출자 전환 시기를 나눌 수 있다. 반대로 wrapper가 후보를 추가로 조회하거나 삭제 조건을 바꾸면 책임은 다시 두 군데로 갈라진다.
새 모듈이 예전 서비스 패키지를 다시 import하는 것도 막았다. 기존 계층이 새 모듈을 호출하고 새 모듈은 자기 구현을 실행하는 방향이 유지돼야 한다.
동작 검사와 구조 검사는 다른 질문에 답한다
기존 테스트를 통과하면 이동 전후 동작을 비교할 근거가 생긴다. 하지만 그것만으로 구현이 올바른 계층에 있는지까지 알 수는 없다. 모든 실행을 예전 함수로 되돌려도 결과만 보는 테스트는 통과할 수 있기 때문이다.
그래서 구조 검사도 남겼다. 기존 호환 계층에서 쓰던 특정 DB 조회·스토리지 삭제 메서드가 다시 들어오지 않았는지, 새 모듈이 예전 패키지를 역으로 참조하지 않는지 확인하는 검사다. 모든 가능한 우회 호출을 증명하는 검사는 아니다.
이 검사는 이미 정리한 경계를 다시 흐리지 못하게 하는 장치다. 모든 함수 이름이나 파일 줄 수를 고정할 필요는 없다. 실제로 금지하려는 의존 방향과 실행 호출만 검사하면 된다. 검사 범위가 너무 넓으면 다음 리팩터링의 정상 변경까지 막는다.
동작 검사는 무엇을 지우고 무엇을 남겼는지 확인한다. 구조 검사는 그 결정을 어느 계층이 맡는지 확인한다. 둘 중 하나로 다른 하나를 대신하지 않았다.
이동을 검증한 범위
해당 이동의 집중 테스트는 통과했다. 전체 검증의 첫 실행은 사용하지 않는 alias 때문에 실패했다. 이를 제거한 뒤 전체 lint를 다시 통과시켰고 실제 로컬 DB와 객체 저장소를 사용하는 정리 통합 테스트도 통과했다.
이 기록을 전체 리팩터링 완료로 해석하지는 않았다. 특정 정리 경로의 이동과 로컬 검증만 확인했다. 배포된 여러 인스턴스가 동시에 파일을 만들고 지우는 상황이나 운영 저장소의 전체 정리는 별도 증거가 필요하다.
삭제 직전 재검사도 원자적 잠금은 아니다. 검사 뒤 삭제 사이에 다른 writer가 객체를 만들거나 참조를 갱신할 수 있다. 모듈 이동은 이 경쟁을 자동으로 해결하지 않는다. 운영에서 전체 정리를 실행하려면 writer 정지나 generation·claim 같은 별도 실행 경계, 실행 후 잔여 후보 확인 같은 조건을 따로 검토해야 한다.
다음 코드 이동에서 확인할 것
다른 기능을 모듈로 옮길 때도 기존 공개 함수를 하나 골라 끝까지 따라가 보면 된다. 진입점 아래에서 누가 저장소를 읽고 권한이나 소유권을 판단하고 실제 변경을 실행하는지 표시한다.
새 폴더가 생겼는데 이 세 작업이 예전 계층에 남아 있다면 실행 책임은 아직 그대로다. 기존 호출자가 새 모듈까지 도달하고 되돌아오는 의존이 없으며 기존 안전 검사가 그 경로 안에 남아 있는지 확인한다. 모듈의 경계는 디렉터리 이름보다 이 호출 흐름에 더 선명하게 드러난다.