Devin.KR

책임 분리와 변경 검토

75분 안팎

학습 목표

저장소 인터페이스와 단위 테스트로 CRUD 동작을 유지합니다.

개념

호출하는 코드와 저장 매체를 분리합니다

파일 형식과 메모리 목록이 준비되었지만 업무 코드마다 파일 경로와 Writer를 직접 다루면 HTTP API를 붙일 때 같은 처리가 반복됩니다. BookRepository는 도서 저장과 조회 역할을 표현하는 인터페이스입니다. add, find, all, rename, delete만 노출합니다. MemoryRepository와 FileRepository가 같은 계약을 구현하므로 호출자는 변수 타입을 BookRepository로 둘 수 있습니다. 이번에는 실제로 메모리와 파일이라는 두 구현이 있으므로 인터페이스를 도입하는 목적이 분명합니다.

계약은 메서드 이름보다 넓습니다

add는 양수 ID와 null이 아닌 도서를 받아 등록하며 중복 ID를 IllegalArgumentException으로 거부합니다. find는 없는 ID에 Optional.empty를 반환합니다. rename과 delete는 없는 ID에 NoSuchElementException을 발생시킵니다. all은 등록 순서를 유지하는 맵 복사본이고 조회된 Book도 복사본입니다. 동일한 제목의 다른 ID는 허용하며 제목 양끝 공백과 대여 상태를 보존합니다. 파일 구현은 TSV 형식 때문에 제목의 탭·줄바꿈을 추가로 거부합니다. 이 차이는 숨기지 않고 저장 경계의 제한으로 문서화합니다.

인터페이스가 있다고 두 구현의 모든 동작이 자동으로 같아지는 것은 아닙니다. 같은 입력에서 같은 결과를 보이는 공통 계약 테스트가 필요합니다. sameContract는 메모리와 파일 구현에 같은 등록·조회·수정·삭제를 수행합니다. 파일만 갖는 재로딩과 저장 실패는 별도 테스트로 검증합니다. 테스트 목적을 구분하면 파일 I/O 문제를 업무 규칙 실패와 혼동하지 않습니다. 인터페이스 메서드를 한 줄 추가할 때는 호출자, 두 구현, 계약 테스트를 같이 검토합니다.

파일 저장 성공 후 메모리를 바꿉니다

FileRepository.change는 current의 복사본을 next에 만들고 요청한 변경을 next에만 적용합니다. BookFile.write가 성공하면 current를 next로 바꿉니다. 저장 전에 current.rename을 직접 호출하면 저장이 실패한 뒤 조회는 새 제목, 재실행은 옛 제목인 모순이 생깁니다. 복사한 후보를 먼저 만드는 이유는 실패한 변경을 현재 목록에 섞지 않기 위해서입니다. 중복이나 부재 오류는 후보 변경에서 발생하고 파일 쓰기는 실행되지 않습니다.

I/O 실패는 UncheckedIOException으로 감싸 도서 저장 실패라는 문맥과 원인 IOException을 유지합니다. 호출자가 성공으로 착각할 반환값을 만들지 않습니다. 파일 구현 생성자와 reload는 IOException을 그대로 선언합니다. 조회 중 파일을 매번 읽는 방식과 메모리 스냅샷을 사용하는 방식 중 이번에는 후자를 택했습니다. 다른 프로세스가 파일을 바꾸면 reload 전까지 반영되지 않습니다. 이 구현은 동시 작성과 자동 변경 감지가 없는 단일 작성자 모델이라는 점을 README에 적습니다.

복원도 전체 성공 후 교체합니다

reload는 BookFile.read가 반환한 맵을 새 MemoryRepository에 옮긴 뒤 current를 교체합니다. 잘못된 두 번째 행을 읽다가 예외가 나면 기존 current는 유지됩니다. 호출 전에 clear하고 한 줄씩 추가하면 부분 복원으로 목록이 바뀝니다. 기존 목록이 유지된다는 것은 현재 메모리에 대한 약속입니다. 사용자가 직접 파일을 손상시킨 상황에서 디스크 원본까지 자동 복구했다는 뜻은 아닙니다. 손상 파일 복구와 백업 정책은 뒤 운영 모듈에서 다룹니다.

저장 실패를 재현하는 테스트를 읽습니다

FileRepositoryTest.ioFailureKeepsMemory는 정상 도서를 먼저 저장하고 목적 파일을 비어 있지 않은 디렉터리로 바꿉니다. 같은 위치로 새 파일을 교체할 수 없어 IOException이 나며 메모리 제목은 이전 값을 유지해야 합니다. 임의의 chmod 테스트는 OS 권한과 사용자 권한에 따라 결과가 달라질 수 있으므로 여기서는 테스트가 관리하는 임시 폴더 구조로 실패를 만듭니다. 실제 실습 코드가 저장 성공으로 진행하지 않는지 assertThrows와 후속 제목 확인을 같이 사용합니다.

이 테스트는 임시 파일 정리도 확인합니다. 저장 실패가 난다고 테스트 폴더의 모든 내용을 지우지 않습니다. 목적 디렉터리와 그 안의 child는 오류 재현용이고 정리할 대상은 이번 쓰기가 만든 임시 파일뿐입니다. 완료된 테스트에서는 @TempDir가 전체 폴더를 정리합니다. 파일 수 기대값이 실패하면 이동 실패 뒤 임시 파일이 남았는지 확인합니다. 이런 검증은 성공 CRUD를 여러 번 반복하는 것보다 새로운 실패 상태의 의미를 확인하는 데 도움이 됩니다.

기존 도서 규칙을 유지하며 삭제를 구현합니다

starter는 delete 메서드가 비어 있습니다. 메모리 구현의 삭제는 있지만 파일 구현에서 change를 통해 호출해야 디스크에 반영됩니다. next.delete(id)를 change에 넘기면 없는 ID는 기존 계약대로 거부되고 성공한 삭제는 파일 저장 후 current에 반영됩니다. 삭제 뒤 같은 객체의 all만 확인하면 메모리에서만 지운 오류를 놓칠 수 있습니다. 새 FileRepository를 같은 경로로 생성해 다시 읽고 삭제된 ID가 없는지 검증합니다. 이것이 재실행해도 동일한 결과라는 요구를 코드로 표현한 방식입니다.

제목 수정은 기존 Book.rename을 사용해 빈 제목을 거부하고 대여 상태를 유지합니다. 파일 레슨에서 저장한 true 값도 다시 읽어 borrow 상태로 복원합니다. 저장소의 add가 Book을 새로 만든다는 이유로 앞 모듈의 대여 규칙을 삭제하지 않습니다. 현재 저장소 인터페이스는 대여·반납 영속화 메서드를 제공하지 않습니다. 조회 복사본을 대여하고 저장되었다고 기대하지 않으며, 대여 API와 트랜잭션 요구는 뒤 모듈에서 추가합니다.

변경 검토에는 의도를 적습니다

starter와 solution의 src 폴더를 diff -ru로 비교합니다. diff는 차이가 있으면 종료 상태 1을 반환할 수 있으므로 테스트 실패와 혼동하지 않습니다. 파일이 다르다는 확인만으로 검토를 끝내지 않습니다. 삭제가 어떤 경로로 저장되는지, 저장 예외가 메모리 반영을 막는지, Book의 이전 계약이 유지되는지 검토 설명에 적습니다. 로컬 수정 범위와 테스트 이름을 연결하면 다음 작성자가 바뀐 이유를 이해하기 쉽습니다. 기능과 관계없는 이름 변경이나 테스트 삭제를 끼워 넣지 않습니다.

미션은 실행 간의 CRUD를 증명합니다

미션 starter는 앞 모듈 미션의 완성 코드와 33개 테스트를 포함한 상태에서 이번 저장소 코드를 더한 것입니다. BookCli는 파일 경로, 명령, ID, 제목을 받아 BookRepository를 호출합니다. add 뒤 JVM을 종료하고 get을 다른 JVM에서 실행해도 값이 남아야 합니다. rename과 delete도 별도 실행으로 확인합니다. 목록·ID 조회·추가·수정·삭제 명령을 README의 순서대로 실행하고 입력 제목에 공백이 있으면 셸에서 따옴표로 묶습니다. 이 CLI는 문서의 올바른 인자 개수를 전제로 하며 HTTP 입력 검증은 다음 단계에서 다룹니다.

통과 증거와 한계를 함께 남깁니다

레슨 저장소 실습은 누적 49개, 미션은 CLI 왕복 테스트까지 50개를 실행합니다. Failures, Errors, Skipped가 모두 0인지 확인합니다. 프로그램이 뜨는 것만으로 완료하지 않고 중복 ID·없는 ID·깨진 파일·저장 실패의 결과를 설명합니다. README에는 단일 작성자, 같은 폴더 원자 이동 지원, 부모 폴더 사전 준비, 정전 내구성 보장 범위도 적습니다. 다음 HTTP 모듈의 호출자가 저장 방식의 세부를 몰라도 계약을 사용할 수 있도록 메서드와 오류의 의미를 남기는 것이 이번 완료 기준입니다.

따라하기

실습 경로와 실패 테스트 읽기

저장소 실습 starter 폴더에서 삭제 후 재로딩 테스트의 실패를 확인합니다. FileRepository.delete를 호출한 뒤 새 저장소로 읽었을 때 ID가 남아 있는지 살펴봅니다. 메모리 조회와 파일 재로딩의 차이를 실패 보고서에 기록합니다.

chmod +x mvnw
./mvnw test

TODO를 계약에 맞게 구현

README와 src/test/java/lab의 테스트를 읽습니다. FileRepository.delete에서 change(next -> next.delete(id))를 호출합니다. 기존 Book과 테스트를 유지하고 전체 테스트를 다시 실행합니다.

./mvnw test

독립 관찰 예제 실행

수정 후 빌드된 target/classes에서 제공 관찰 프로그램을 실행합니다. 파일 예제는 임시 폴더를 사용하고 실행이 끝나면 정리합니다.

java -cp target/classes lab.RepositoryDemo

실행 결과

수정 제목
0

누적 테스트 결과 확인

전체 테스트를 실행하고 바로 종료 상태를 확인합니다. target/surefire-reports에서 제공 테스트 49개와 Failures·Errors·Skipped가 모두 0인지 확인합니다. 새 테스트를 추가했다면 개수가 늘어납니다.

./mvnw -q test
echo $?

실행 결과

0

변경 차이와 검토 설명 작성

starter와 solution 압축을 각각 같은 부모 폴더에 풀고 그 부모 폴더에서 명령을 실행합니다. diff 종료 상태 1은 차이가 있다는 뜻일 수 있습니다. FileRepository.delete가 후보 변경과 저장 경로를 타는지 확인하고, README에 인터페이스 계약·저장 후 반영 순서·정상 및 실패 테스트 근거를 작성합니다. 실제 본인 수정본과 원본 starter도 같은 방식으로 비교합니다.

diff -ru starter/src solution/src

확인 문제

실습

메모리 저장소를 파일 저장소로 바꾸고 diff와 리뷰 설명을 작성합니다. starter의 TODO를 완성하고 README에 정상·경계·실패 경로와 실패 뒤 원본이 보존되는 이유를 작성합니다. 제공 테스트와 앞 모듈의 도서 규칙을 유지합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

49개 테스트 실행, 실패·오류·건너뜀 0, 종료 코드 0입니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 저장소 인터페이스가 있어도 공통 계약 테스트가 필요한 이유는 무엇인가요?
  • 파일 저장 성공 후 메모리 상태를 교체하는 순서가 필요한 이유는 무엇인가요?
  • 삭제가 파일에 반영되었는지 어떻게 검증하나요?