-

9주차에서는 그동안 Postman이나 Swagger를 통해 직접 확인하던 API 테스트를 자동으로 검증하도록 만드는 테스트 자동화(Test Automation)와, 로컬이 아닌 어떤 환경에서도 동일하게 서버가 돌아갈 수 있도록 Docker 컨테이너 가상화 배포를 진행하는 과정을 다룬다.
이번 주차 공부 내용
- 단위 테스트(Service, Controller 계층) 및 통합 테스트(Integration) 작성 + 가짜 객체(Mock) 주입 및 Given-When-Then 패턴의 이해
- Dockerfile 작성 및 이미지 빌드
- 로컬(Mac)과 가상 환경(Docker) 간의 네트워크 연동 및 포트 충돌 해결
기능 구현 Flow (코드 로직)
Step 1. Service 단위 테스트: 순수 비즈니스 로직만 빠르게 검증하기
지금까지는 서버를 켜고, DB에 값이 잘 들어가는지 전체를 다 확인해야 했다. 하지만 단위 테스트(Unit Test)는 DB를 아예 켜지 않고 가짜 객체(Mock)를 띄워, 내가 짠 PostService의 로직(예외 처리, 데이터 가공)만 0.1초 만에 빠르게 검증한다.
- DTO & Entity Mocking: 실제 DB를 다녀오지 않도록, when().thenReturn()을 이용해 "DB에 이런 요청이 오면 이런 가짜 데이터를 반환해 줘!"라고 미리 조작해 둔다.
- Given-When-Then: 테스트 코드를 '준비(Given) - 실행(When) - 검증(Then)' 3단계로 명확히 나누어 작성한다.
// PostServiceTest.java 중 일부 @ExtendWith(MockitoExtension.class) class PostServiceTest { @InjectMocks private PostService postService; // 진짜 테스트할 대상 @Mock private PostRepository postRepository; // 가짜 레포지토리 (DB 역할) @Test @DisplayName("테스트: 정상적으로 게시글이 저장된다 (Happy Path)") void createPost_Success() { // given (준비) PostWithCommentRequestDto request = new PostWithCommentRequestDto("제목", "내용", "댓글입니다"); Post mockPost = Post.builder().id(1L).title("제목").content("내용").build(); // 가짜 DB가 어떻게 행동할지 미리 조작 (Stubbing) when(postRepository.save(any(Post.class))).thenReturn(mockPost); // when (실행) Long savedPostId = postService.createPostWithComment(request, "test@test.com"); // then (검증) assertEquals(1L, savedPostId); verify(postRepository, times(1)).save(any(Post.class)); // save가 1번 호출되었는지 검증 } }Step 2. 통합 테스트 (Integration Test): DB까지 전체 흐름 팩트 체크
가짜 객체가 아닌 실제 스프링 서버를 통째로 띄우고 진짜 MySQL DB까지 연결하여 클라이언트의 요청부터 DB 저장까지의 전체 사이클이 완벽하게 맞물려 돌아가는지 확인한다.
- Client Request: MockMvc를 사용해 Postman처럼 서버에 가짜 HTTP POST 요청을 날린다.
- Rollback: 테스트가 끝나면 DB에 들어간 테스트용 쓰레기 데이터들을 깔끔하게 지워버리기 위해 @Transactional을 얹어준다.
// IntegrationTest.java 중 일부 @SpringBootTest @AutoConfigureMockMvc @Transactional // 테스트 종료 후 DB 롤백 class IntegrationTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; // 객체 -> JSON 변환기 @Test @DisplayName("통합 테스트: 클라이언트 요청이 DB 저장까지 완벽하게 성공한다") void createPost_IntegrationFlow() throws Exception { // given PostWithCommentRequestDto request = new PostWithCommentRequestDto("통합 테스트 제목", "통합 테스트 내용", "댓글"); // when & then mockMvc.perform(post("/api/posts/with-comment") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) // 200 응답 확인 .andExpect(jsonPath("$.isSuccess").value(true)); // JSON 결과 확인 } }Step 3. Docker 배포: 내 코드를 어디서든 똑같이 실행하기
테스트를 통과한 코드를 실제 서비스 환경으로 내보내기 위해 도커(Docker)를 도입했다. 내 맥북의 자바 버전이나 OS 환경에 구애받지 않고, 리눅스(Linux)라는 독립된 가상 방(컨테이너) 안에서 서버를 실행하도록 Dockerfile을 작성했다.
# Dockerfile # 1. 자바 21 버전의 깨끗한 리눅스 환경을 가져온다. FROM eclipse-temurin:21-jdk # 2. 내 프로젝트에서 빌드된 완성품(.jar)을 복사해온다. ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar # 3. 컨테이너가 켜지면 "java -jar /app.jar" 명령어를 실행하여 서버를 켠다. ENTRYPOINT ["java", "-jar", "/app.jar"]
코드에 사용된 문법(어노테이션) 및 객체 정리
🔍 주요 어노테이션 및 객체 분석 (테스트 계층)
- @ExtendWith(MockitoExtension.class): 스프링 부트의 무거운 설정(DB 연결 등)을 켜지 않고, 가짜 객체(Mock)들만 띄우는 가벼운 환경을 만들어준다.
- @Mock & @InjectMocks: @Mock은 가짜 부품(Repository)을 만들고, @InjectMocks는 이 가짜 부품들을 조립해서 진짜 테스트할 대상(Service)에 꽂아 넣어주는 역할을 한다.
- @SpringBootTest: 통합 테스트의 핵심. 실제 서버를 켤 때처럼 모든 스프링 설정(Bean, DB 연동)을 전부 다 로드한다. 무겁지만 가장 확실한 테스트 방법이다.
- MockMvc: 서버를 켜지 않은 상태에서, 스프링의 MVC 동작을 흉내 내어 클라이언트(브라우저, Postman)가 HTTP 요청을 보내는 것처럼 만들어주는 테스트 객체이다. perform(post(...)) 메서드로 사용한다.
실행 결과
🟢 1. 테스트 코드 자동화 검증 (IntelliJ)
- 결과: 인텔리제이에서 재생 버튼(▶️)을 누르거나 gradlew test를 실행했다.
- 검증 포인트: 콘솔 창에 에러 없이 모든 Service, Controller, Integration 테스트 항목 옆에 초록색 체크(✅)가 나타났다. 이는 로직에 구멍이 없으며, 내가 짠 코드가 예상한 대로 완벽히 동작함을 기계가 보증해 주었다는 뜻이다.
🟢 2. 도커 컨테이너 가상 환경 서버 실행
- 결과: 터미널에서 docker run -p 8081:8080 my-spring-app 명령어를 통해 이미지를 컨테이너로 띄웠다.
- 검증 포인트: 브라우저에서 http://localhost:8081/swagger-ui/index.html 로 접속했을 때 Swagger 화면이 정상적으로 렌더링 되었고, API를 호출했을 때 실제 로컬 MySQL DB에 데이터가 적재되는 것을 확인했다.
트러블 슈팅 (Troubleshooting)
Issue 1. 테스트 코드 오류와 cannot find symbol 에러
- [현상] 테스트 코드를 작성했는데, 파일 내의 거의 모든 글자(PostRepository, Optional, DTO 등)에 빨간 줄이 쳐지며 cannot find symbol (기호를 찾을 수 없음) 컴파일 에러가 무더기로 발생했다.
- [원인 분석] 코드가 틀린 것이 아니라, 파일 최상단에 package 경로 선언이 누락되었고, 사용하려는 클래스들의 주소를 알려주는 import 문이 통째로 빠져있었기 때문이다. 인텔리제이가 "얘가 대체 어디 사는 누구인지 모르겠다"며 뱉어낸 에러였다.
- [해결 방안 및 배운 점] 파일 맨 윗줄에 package com.study.springbootstudy.service; 등을 명시하고, 빨간 글씨에 마우스를 올린 뒤 Option(⌥) + Enter (Import class)를 눌러 필요한 주소를 모두 연결해 주었다. 나중에는 인텔리제이 설정에서 Auto Import를 켜서 툴이 알아서 해결하도록 최적화했다.
Issue 2. Lombok @Builder의 함정과 id() 해결 불가
- [현상] 가짜 객체를 만들기 위해 Post.builder().id(1L).title("제목").build(); 를 작성했는데, id 메서드를 찾을 수 없다는 에러가 났다.
- [원인 분석] 실제 Post 엔티티 클래스에서 @Builder를 클래스 전체가 아닌 특정 생성자(title, content, member만 받는 생성자)에만 붙여두었기 때문이다. 빌더가 id의 존재 자체를 몰랐던 것이다.
- [해결 방안 및 배운 점] Post 클래스의 빌더 생성자 파라미터에 Long id를 추가하여 해결했다. 코드를 수정했는데도 인텔리제이가 인식을 못 할 때는 당황하지 않고 IntelliJ 빌드(망치 아이콘, Cmd+F9)를 눌러 새로고침을 해주거나, Gradle의 clean을 통해 이전 찌꺼기를 날려야 한다는 중요한 디버깅 루틴을 배웠다.
Issue 3. 도커 컨테이너 DB 연결 거절 (Connection Refused)
- [현상] 도커 이미지를 구워서 실행했더니, 스프링 서버가 켜지다가 Communications link failure 및 Connection refused 에러를 뿜으며 사망했다.
- [원인 분석] 로컬(IDE)에서 돌릴 때는 application.yml의 DB 주소를 localhost:3306으로 설정했다. 하지만 도커 컨테이너 안에서 localhost는 내 Mac이 아니라 '컨테이너 자기 자신'을 가리킨다. 도커 안에는 MySQL이 없으니 당연히 연결이 거절된 것이다.
- [해결 방안 및 배운 점] 스프링 컨테이너 안에서 바깥에 있는 Mac의 MySQL을 찾을 수 있도록, DB 주소를 맥(Mac) 환경 도커 전용 주소인 host.docker.internal로 우회 변경하여 완벽하게 연결에 성공했다. 가상 환경의 네트워크 분리 개념을 몸으로 체감한 순간이었다.
Issue 4. 포트 충돌 (Port 8080 Already in Use)
- [현상] 인텔리제이에서 테스트 코드를 돌리거나 서버를 켜려고 하니 Web server failed to start. Port 8080 was already in use. 라며 서버가 켜지질 않았다.
- [원인 분석] 도커 컨테이너를 8080:8080 포트로 켜둔 상태에서, 인텔리제이 로컬 서버도 똑같이 내 맥북의 8080 포트를 차지하려고 싸우면서 튕겨 나간 것이다. (포트는 1인실이다!)
- [해결 방안 및 배운 점] 목적을 명확히 분리했다. "테스트 코드를 돌릴 땐 IDE만 쓰고 도커를 끈다", "도커 배포를 테스트할 땐 IDE 서버를 끈다". 정 두 개를 같이 켜야 한다면 도커 실행 명령어를 docker run -p 8081:8080으로 바꿔 외부 포트를 분리하는 식으로 포트 충돌을 관리하는 법을 익혔다.
학습 회고
이번 9주차를 통해 사람이 직접 하는 게 아닌 자동화된 테스트 코드로 시스템의 안정성을 보장받는 든든함을 경험했고, 환경 충돌로 고통받지 않기 위해 Docker를 만들어 배포하는 인프라 지식까지 확장했다. 특히 마주친 수많은 cannot find symbol과 Connection refused 에러들을 하나씩 해결해 나가면서, 단순히 코드를 치는 것을 넘어 내가 짠 코드가 어떤 환경(IDE, OS, Container)에서 실행되는지를 폭넓게 바라볼 수 있게 되었다.
'Study > SpringBoot' 카테고리의 다른 글
[SpringBoot] 10주차 AWS 클라우드 배포, CI/CD 자동화 및 HTTPS 무중단 보안 적용 (0) 2026.06.10 [SpringBoot] 8주차 외부 API 연동, Redis 연결, 그리고 TTL(Time-To-Live) 설정 (0) 2026.05.28 [SpringBoot] 7주차 게시글 + 댓글 동시 저장 서비스 작성, 예외 케이스 적용, 인덱스 생성 (0) 2026.05.20 [SpringBoot] 6주차 페이지네이션(Pagination), N+1 문제 해결 (0) 2026.05.07 [SpringBoot] 5주차 JWT 인증/인가(회원가입 및 로그인) API 구현 (0) 2026.04.30