Spring Boot 회원가입 API 구현 중 생긴 궁금증 + Testcontainers 통합테스트 삽질 회고 (에러 로그까지)

오병택·2026년 1월 26일
post-thumbnail

오늘 목표는 “회원가입 API + 통합테스트(Testcontainers)”였는데, 구현보다 환경/설정/Gradle/IDE 에러 처리가 더 큰 비중을 차지했다. 결과적으로 다음을 정리했다.

  1. DTO/엔티티 설계 관점(왜 엔티티는 생성 제한하고 DTO는 덜 엄격한지)
  2. 정적 팩터리 네이밍(of/from)
  3. 테스트 전략(단위/슬라이스/WebMvcTest/통합테스트)
  4. Testcontainers + MySQL 세팅과 application-test.yml 정석
  5. 실제로 뜬 에러들(Import 안 뜸, ApplicationContext 실패, Enum 변환 실패 등)
  6. 실무형 이슈/PR/커밋 운영(스쿼시/머지 차이 포함)

1) DTO @Builder를 열어두면 “가짜 객체” 만들 수 있지 않나?

결론

DTO는 ‘도메인 무결성’을 지키는 목적이 아니고 “전송”이 목적이라 외부 생성 가능해도 보통 문제로 보지 않는다.

  • 클라이언트가 서버 DTO를 “직접 new 해서” 서버에 넣는 구조가 아님(그건 서버 내부 코드임)

  • 중요한 건 서버가 어떤 DTO를 만들어서 반환하느냐이고, DTO 자체를 잠그는 건 보통 우선순위가 낮다.

반대로 엔티티는 다르다.

2) 엔티티는 왜 외부 생성 막아?

엔티티는 단순 데이터 묶음이 아니라 도메인 규칙/무결성을 가진 객체라서, “아무나 new”를 허용하면 규칙이 깨진 상태로 퍼질 수 있다.

그래서 실무에서 많이 하는 패턴:

  • 생성자 protected/private + 정적 팩터리로만 생성

  • 생성 시점에 필수값 검증 / 초기 상태 강제 / 불변 조건 강제

즉, DTO는 느슨, 엔티티는 엄격이 기본 성향.

3) 정적 팩터리 네이밍: of vs from

오늘 질문했던 “of/from 언제 쓰냐” 정리:

  • of(...): 값들을 받아 그대로 조립해서 생성 (변환 뉘앙스 약함)

  • from(...): 다른 타입(객체/DTO/엔티티)에서 꺼내 변환해 생성 (변환 뉘앙스 강함)

ex)

UserRole.of("USER")

SignupResponse.from(user)

4) 테스트는 어떻게 나누는 게 현실적인가?

회원가입은 DB 저장이 핵심이라 통합테스트 가치가 크다.

  • 단위테스트: 빠르지만 DB 제약조건/실제 저장/트랜잭션은 검증이 약함

  • 통합테스트(@SpringBootTest + DB): 실제 흐름(서비스→레포→DB) 검증 가능

그리고 @WebMvcTest / 슬라이스 테스트도 얘기했는데:

  • 슬라이스 테스트 = 애플리케이션을 “일부 레이어만” 띄워 테스트

  • @WebMvcTest = MVC(컨트롤러) 슬라이스만 띄움 (서비스는 보통 Mock)

오늘은 “회원가입 저장까지”가 목적이라 통합테스트 중심으로 갔다.

5) Testcontainers를 추천한 이유 vs 로컬 MySQL

로컬 MySQL로도 되는데 굳이 Testcontainers?

로컬 DB 연결은 내 PC에서는 잘 되는데 아래가 흔히 문제다:

  • 팀원/다른 PC/CI 환경에서 DB가 없거나 설정이 달라서 깨짐

  • 환경변수/포트/계정 등 편차가 커서 “재현”이 어려움

Testcontainers는:

  • 테스트 실행 시점에 도커로 MySQL을 띄우고

  • 스프링이 그 DB에 붙게 만들어서

  • 어디서 돌려도 동일한 DB 환경으로 테스트가 돌아간다(특히 CI에서 강력)

즉, 실무에서 “내 컴퓨터에서만 되는 테스트”를 줄이려는 목적.

6) 오늘 실제로 겪은 에러들 정리 (원인 → 해결)

(1) IntelliJ에서 Cannot resolve symbol 'Testcontainers' / import 창이 아예 안 뜸

문제

  • @Testcontainers를 붙이려는데 import 자동완성이 안 뜸

  • 빨간줄 + 심볼 못 찾음

원인

  • Gradle 의존성 동기화가 안 됨

  • IntelliJ 인덱스/캐시 꼬임

해결

  • Gradle refresh(우측 Gradle 패널에서 Reload)

  • IntelliJ 재시작 / Invalidate Caches / Restart

  • 그래도 안 되면 ./gradlew clean test로 “Gradle 기준” 정상 여부부터 확인

(2) Gradle 테스트 실행 시 ClassNotFoundException: ...ApplicationTests

문제

  • ./gradlew test --tests ... 했는데 테스트 클래스를 못 찾음

원인

  • 프로젝트 경로에 한글이 들어가서 깨진 것 같음

해결

  • 프로젝트 경로에 한글이 안 들어가게 다른 폴더로 옮겼더니 바로 해결

(3) Failed to load ApplicationContext + Driver ... claims to not accept jdbcUrl, ${DB_URL}

문제

  • 기본 @SpringBootTest조차 컨텍스트 로딩 실패

  • 로그에 ${DB_URL} 그대로 찍힘

원인

  • application.yml에 DB URL을 ${DB_URL} 같은 환경변수 placeholder로 해놨는데,
    테스트 실행 시점에 그 환경변수가 안 들어와서 “문자 그대로” 남은 상태로 DB 연결 시도한 것.

해결 방향 2개 중 B방식 채택

  1. A) 테스트 프로필에서 DB를 “테스트용”으로 고정
  • src/test/resources/application-test.yml에 ${DB_URL} 같은 개발 환경처럼 환경변수 설정해주기
  1. B) Testcontainers의 JDBC 정보를 테스트에서 주입
  • @DynamicPropertySource로 container의 JDBC URL/계정을 스프링에 주입

  • 이 방식이 “환경변수 없이도” 돌아가서 CI에 특히 강하다.

(4) Docker Desktop 왜 켜야 해?

Testcontainers는 “컨테이너를 띄우는 테스트”라서 도커 엔진이 필요하다.

  • Docker Desktop이 꺼져 있으면 컨테이너를 못 띄워서 테스트가 실패한다.

  • “그냥 켜기만 하면 되나?”
    → 보통은 켜고, WSL2/엔진 정상 동작 상태면 충분. (이미지 pull이 필요할 수는 있음)

(5) No enum constant ... UserRole.user

문제

요청 role을 "user"로 넣었더니 enum 변환 실패

원인

핵심 버그:

return UserRole.valueOf(role); // role이 소문자면 바로 터짐

해결

  • 비교도 toUpperCase()로 했으면 valueOf에도 대문자로 넣어야 함
return UserRole.valueOf(role.toUpperCase());

(6) thenThrownBy(...).extracting(BaseException::getErrorCode)가 안 됨

문제

Cannot resolve method 'extracting(<method reference>)'

원인

  • AssertJ 버전/오버로드 차이

해결

thenThrownBy(() -> authService.signup(req2))
    .isInstanceOfSatisfying(BaseException.class, ex ->
        then(ex.getErrorCode()).isEqualTo(ErrorCode.USER_EMAIL_DUPLICATED)
    );
  • isInstanceOfSatisfying 방식 사용

7) open-in-view: false는 뭐였나?

간단히:

  • Open Session In View(OSIV)는 “웹 요청이 끝날 때까지 영속성 컨텍스트를 열어두는 옵션”

  • 켜져 있으면 컨트롤러/뷰까지 lazy loading이 가능해서 편하지만,

  • 트랜잭션 경계가 애매해지고 성능/쿼리 예측이 어려워져서 실무에선 false로 두는 팀이 많다.

8) @Transactional: 클래스에 기본 readonly 두는 게 정석?

정답은 “팀/서비스 성격 따라 둘 다 씀”.

조회 메서드가 많으면:

  • 클래스에 @Transactional(readOnly = true)

  • 쓰기 메서드만 @Transactional 따로

쓰기가 많거나 단순하면:

  • 클래스에 @Transactional

  • 조회 메서드만 readOnly = true

9) 이슈/PR/커밋 운영 + 스쿼시 머지

헷갈렸던 포인트:

  • “이슈 연결”은 보통 커밋보다 PR 본문에서 Closes #12로 연결하는 게 정석

  • 커밋에 (#12)를 다는 건 팀 컨벤션이면 OK지만 필수는 아님

  • Squash merge를 쓰면 PR 커밋 여러 개가 메인에 1개로 합쳐져 들어가서 메인이 깔끔해짐

profile
걱정하지 말고 일단 해봐!

0개의 댓글