Spring 구성과 의존성
85분 안팎
학습 목표
생성자 주입으로 컨트롤러·서비스·저장소를 연결합니다.
개념
기존 저장소를 웹에서 재사용합니다
앞 모듈에서 FileRepository는 BookRepository 계약을 구현했고 CLI와 파일 CRUD 테스트를 완성했습니다. 이제 웹 조회를 붙이기 위해 저장 로직을 컨트롤러에 복사하면 파일 형식이나 예외 정책을 두 군데에서 고쳐야 합니다. 이번에는 저장소를 빈으로 등록하고 서비스가 저장소 인터페이스를 받으며 컨트롤러가 서비스를 받도록 연결합니다. 경로를 처리하는 코드와 도서 조회를 처리하는 코드, 디스크를 처리하는 코드의 변경 이유를 구분할 수 있습니다.
빈과 구성의 역할
Spring 컨테이너가 관리하는 객체를 빈이라고 부릅니다. ApiConfiguration의 @Configuration은 구성을 표현하고 @Bean 메서드는 BookRepository를 반환합니다. 반환값은 기존 FileRepository이며 catalog.path 설정값으로 파일 경로를 받습니다. 설정이 없으면 books.tsv를 사용합니다. 이전 클래스에 파일 저장 방식을 바꾸는 애너테이션을 붙이지 않아도 구성에서 생성해 연결할 수 있습니다.
BookService의 @Service와 BookController의 @RestController는 컴포넌트 탐색 대상입니다. ApiApplication의 @SpringBootApplication이 있는 lab 패키지와 그 하위에 이 클래스를 둡니다. 탐색 범위 바깥에 컨트롤러를 옮기면 애너테이션이 있어도 기본 탐색에서 발견되지 않을 수 있습니다. 파일 경로의 부모 폴더는 앞 모듈 계약대로 사전 준비합니다. 없는 도서 파일은 빈 목록으로 시작하지만 잘못된 파일 행은 오류로 보고합니다.
생성자 주입을 코드로 읽습니다
BookService 생성자는 BookRepository를 받고 private final 필드에 저장합니다. BookController 생성자는 BookService를 받아 같은 방식으로 저장합니다. 이 클래스들에는 생성자가 하나이므로 Spring이 사용할 생성자를 따로 @Autowired로 지정하지 않습니다. final은 주입 후 참조를 바꾸지 않는 의도를 보여 주지만 저장소 객체 자체를 불변으로 만드는 것은 아닙니다.
이 연결에서는 의존성 그래프가 컨트롤러→서비스→저장소 순서입니다. 컨트롤러 내부에서 new FileRepository를 호출하면 설정과 생명주기를 컨트롤러가 직접 관리하게 되고 테스트가 준비한 저장소를 사용하지 않을 수 있습니다. 생성자를 통해 받으면 일반 Java 코드에서 MemoryRepository를 넣어 서비스 동작을 확인하는 것도 가능합니다. 인터페이스의 자세한 문법은 더 읽기로 보내고 이번에는 기존 타입을 어떻게 연결하는지에 집중합니다.
조회 결과를 JSON 계약으로 바꿉니다
BookRepository.all은 ID를 키로 하고 Book을 값으로 갖는 맵 복사본입니다. API가 이 맵을 그대로 반환하면 JSON 객체의 키 표현에 클라이언트가 의존하게 됩니다. 이번 계약은 배열이므로 각 entry를 BookView로 바꿔 리스트를 반환합니다. BookView는 id, title, borrowed를 가진 Java 17 record입니다. 저장소의 키와 Book의 속성을 합쳐 외부 응답 형태를 만드는 지점이 서비스입니다.
Book 자체의 getter를 JSON에 자동 노출하는 방식은 내부 메서드를 추가할 때 외부 응답이 바뀔 위험이 있습니다. DTO를 두면 응답 필드를 명시할 수 있습니다. BookView.of가 현재 제목과 대여 상태를 읽고 목록 순서는 기존 LinkedHashMap의 등록 순서를 유지합니다. 정렬·검색·페이지 기능은 이번 조회 계약에 없습니다. 빈 저장소는 빈 리스트이며 Spring의 JSON 변환기가 이를 []로 직렬화합니다.
컨트롤러의 조회 메서드
@RequestMapping의 /books와 @GetMapping을 결합하면 GET /books 요청이 list 메서드로 연결됩니다. @RestController에서는 반환 객체가 메시지 변환기를 통해 응답 본문에 쓰입니다. 문자열 뷰 이름을 반환하는 템플릿 컨트롤러와 다른 용도입니다. 조회가 정상 완료되면 이 구현은 200으로 응답하며 별도 HTML 파일을 작성할 필요가 없습니다.
starter의 list는 빈 리스트를 직접 반환합니다. 빈 저장소 테스트는 통과하지만 ID 7을 준비한 조회 테스트는 첫 원소를 찾지 못해 실패합니다. 이 테스트를 전체 결과를 하드코딩하는 방식으로 통과시키지 않습니다. service.list로 위임하면 저장소가 비어 있을 때와 값이 있을 때를 같은 경로로 처리합니다. HTTP 레슨 실습은 이 연결을 완성하는 한 가지 변경이지만 앞 모듈 테스트도 함께 실행해 기존 계약이 남아 있는지 확인합니다.
HTTP가 들어오면서 달라지는 실행 조건
CLI는 한 명령을 처리하고 종료했지만 웹 앱은 같은 서비스 객체를 여러 요청이 공유할 수 있습니다. 기존 파일 저장소는 자체적으로 스레드 안전하지 않습니다. 이 레슨의 서비스는 조회와 생성에 synchronized를 사용해 같은 서비스 빈을 통하는 접근을 직렬화합니다. 다음 추가 레슨에서 ID 계산과 저장을 같은 서비스 잠금 안에 둡니다. 파일 저장소의 내부 계약을 유지하면서 웹 요청이 부분 갱신을 보는 위험을 줄이는 범위입니다.
이 잠금은 다른 JVM이나 CLI의 접근을 막지 않습니다. 같은 경로에 두 앱을 띄우거나 실행 중인 앱의 파일을 CLI로 수정하지 않습니다. 요청 밖에서 저장소 빈을 직접 사용하는 작업도 서비스 잠금의 보호를 받지 않습니다. 다중 프로세스 정합성은 뒤 DB·동시성 모듈에서 확장합니다. Spring 싱글턴이라는 말은 인스턴스 수에 관한 기본 범위이며 자동 동시성 안전성을 의미하지 않습니다.
테스트가 연결과 응답을 확인하는 방법
ApiContractTest는 AnnotationConfigApplicationContext에 구성·서비스·컨트롤러를 등록하고 catalog.path를 @TempDir 아래 파일로 지정합니다. 이 컨텍스트에서 컨트롤러를 얻어 standaloneSetup으로 MockMvc를 만듭니다. 실제 빈의 생성자 연결을 사용한 컨트롤러가 MVC 요청을 처리합니다. 빈 배열 테스트와 저장소에 ID 7을 추가한 뒤 JSON 배열을 읽는 테스트가 각각 다른 경계를 검증합니다.
BootContextTest는 SpringApplication을 WebApplicationType.NONE으로 실행해 기본 컴포넌트 탐색과 자동 구성이 연결되는지도 확인합니다. 이 테스트는 TCP 서버를 열지 않습니다. standaloneSetup도 실제 HTTP 소켓·TLS·배포 필터 체인을 검증하지 않으므로 curl과 같은 검사를 대체했다는 주장을 하지 않습니다. 테스트마다 컨텍스트를 close하고 임시 파일을 정리해 다음 실행에 상태가 남지 않게 합니다.
설정 실패와 응답 실패를 구분합니다
No qualifying bean이나 UnsatisfiedDependencyException은 생성에 필요한 타입의 빈을 찾지 못하는 단서입니다. @Bean 반환 타입, 컴포넌트 위치, 생성자 인자를 확인합니다. 같은 인터페이스 구현을 두 개 등록해 후보가 겹치면 어떤 구현을 선택할지 명시해야 합니다. 파일을 읽는 IOException이 원인에 있으면 파일 경로·행 형식·권한을 점검합니다. 그런 설정 문제를 빈 목록으로 숨기지 않습니다.
반대로 컨텍스트는 생성되었는데 jsonPath의 $[0].id 검사가 실패하면 응답 본문을 먼저 확인합니다. 기대값 7인데 []라면 요청이 서비스와 저장소에 위임되는지 봅니다. 404라면 @GetMapping 경로와 테스트 요청이 같은지 봅니다. Maven 보고서에서 Failures는 기대 결과와 다른 계약이고 Errors는 실행 중 예외일 수 있습니다. 첫 줄의 BUILD FAILURE만 읽고 모두 같은 문제로 처리하지 않습니다.
누적 결과를 다음 레슨에 전달합니다
완성한 실습은 앞 모듈의 50개에 이번 조회 계약 2개와 Boot 구성 1개를 더한 53개 테스트를 실행합니다. report.py는 Surefire XML을 읽어 테스트·실패·오류·건너뜀 수를 출력하므로 종료 상태만 확인하는 것보다 누적 검증 범위가 분명합니다. Book, 대여 규칙, CLI와 파일 코드의 기존 테스트를 유지합니다. 다음 레슨은 같은 연결 위에서 요청 JSON과 상세 경로를 추가하고 성공 응답의 Location을 검증합니다. 이 단계의 완료는 저장 코드를 복사하지 않고 파일에 있는 도서를 JSON으로 보여 주는 것입니다.
따라하기
실패 테스트를 읽습니다
starter의 pom.xml 폴더에서 실행합니다. JDK 17을 사용하며 캐시가 없으면 본인 환경에서 의존성을 준비합니다. injectedFileRepository의 목록 기대값과 응답 []를 비교합니다.
./mvnw test구성과 생성자 연결을 찾습니다
ApiConfiguration의 @Bean 반환 타입, BookService와 BookController의 생성자를 읽습니다. README에 의존성 그래프와 catalog.path 역할을 적습니다. 서비스 내부에서 저장소를 새로 만들지 않습니다.
조회 위임을 구현합니다
BookController.list의 고정 빈 리스트 대신 아래 반환문을 씁니다. 주입된 서비스가 파일의 맵을 BookView 리스트로 변환합니다.
return service.list();전체 테스트와 집계 확인
먼저 ./mvnw test를 실행하고 성공한 뒤 아래 집계 명령을 실행합니다. output은 집계 명령의 실제 출력입니다.
수정 뒤 전체 테스트를 실행합니다. 다음 집계는 작성자가 solution에서 실제 실행한 결과입니다. report.py는 방금 생성된 Surefire XML을 읽습니다.
python3 report.py실행 결과
Tests=53 Failures=0 Errors=0 Skipped=0
통과 범위를 설명합니다
BootContextTest가 서버 없이 빈 탐색을 확인하고 ApiContractTest가 MVC 계약을 확인하는 이유를 README에 씁니다. curl이나 TLS 검증까지 통과했다고 적지 않습니다.
확인 문제
실습
BookController.list의 TODO를 주입된 서비스에 위임하도록 완성합니다. 기존 파일 저장소와 50개 테스트를 유지합니다. 생성자 그래프·구성 경로·MockMvc의 검증 범위를 README에 설명합니다. 테스트를 수정해 통과시키지 않습니다.
실행 명령
./mvnw test
기대 결과
53개 테스트, 실패·오류·건너뜀 0입니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 생성자 주입을 사용하면 의존성과 테스트 구성에 어떤 이점이 있나요?
- Spring 싱글턴 빈은 왜 자동으로 스레드 안전하지 않나요?