TDD로 개발하기 - Java

anvel·2025년 7월 12일

항해 플러스

목록 보기
30/39
post-thumbnail

TDD로 개발하기

드디어 시작된 백엔드 과정에서는 지난번 프론트엔드의 3챕터였던 테스트과 TDD를 학습했습니다.
지난번 작성했던 Node.js의 테스트 과정과 크게 다른 점은 없었습니다.
오랜만에 써보는 Java 문법과 처음 제대로 사용해보는 IntelliJ 환경이 살짝 어색했지만,
다행히도 하루 정도 치다보니 익숙해졌습니다.

다만 아쉬웠던 점은 아직 TDD를 준수하며 테스트를 우선 작성하는 방법을 잘 진행했지만,
테스트 툴에 대한 이해도, 실제 Service, Domain을 설계하는 역량, 책임을 분리하는 원칙 등에서 부족함을 보였습니다.

Test Scope 선정

  • 단위 테스트 Unit Test: UserPoint의 비즈니스 로직 메서드 PointService에 대한 테스트를 대상으로 선정했습니다.
  • 통합 테스트 Integration Test: API를 호출하는 것으로 Controller -> Service -> Record 전체 흐름을 블랙박스 관점에서 검증하는 것으로 테스트를 작성했습니다.

Test Double 사용

TDD 핵심 사이클

"Red → Green → Refactor"

  • Red (실패):
    • 아직 구현되지 않은 기능에 대해 실패하는 테스트를 작성한다.
    • 이 단계에서는 테스트가 실패해야 정상이다.
  • Green (성공):
    • 테스트가 통과할 수 있도록 최소한의 구현 코드를 작성한다.
    • 설계나 성능은 고려하지 않는다. 테스트 통과가 목적이다.
  • Refactor (리팩터링):
    • 테스트가 통과한 상태에서, 구현 코드를 리팩토링한다.
    • 리팩토링 후에도 테스트가 통과해야 하며, 기능은 바뀌지 않아야 한다.
  • 호출할 빈 함수를 선언하는 것을 제외하고, TDD 개발 기법을 준수하기 위해 노력하였습니다.

    • PointService 작성, PointServiceImpl 작성(삭제)
  • 요구사항 별 PointService 테스트 RED -> GREEN -> REFACTOR (커밋순)

    순번요구사항REDGREENREFACTOR
    1-1포인트_충전_성공()4b92656d674e9b
    2-1포인트_사용_성공()4b926562394a78
    2-2포인트_사용_실패()4b926562394a78
    3포인트_조회()11e1402acf5b36
    4포인트_히스토리_조회()c1d682716a5e90
    1-2포인트_충전_실패_0보다_작은_금액()71d36417ece964
    1-3포인트_충전_실패_최대_제한_금액()fa9c0423ce3d9d
  • 요구사항 별 PointController 테스트 (커밋순)

    • 주어진 PointControllerreturnnew UserPoint(0, 0, 0)이 고정되어있어, 충전 테스트를 먼저 작성하였으나 isOk가 통과가 되는 현상을 확인했습니다.
    • 충전 GREEN 코드 작성 이전에 조회 RED -> GREEN을 진행하였습니다.
    • 조회 GREEN 수행 이후에 충전 RED에 종속된 결과 조회의 id는 조회되나 Controller 미구현으로 point는 0인 것을 확인 후 GREEN 진행하였습니다.
    순번요구사항REDGREENREFACTOR
    1-1포인트_충전_정상_동작()f9fb924658fd79
    2포인트_조회_동작()50488a90d3f66b
    1-2포인트_충전_실패_0보다_작은_금액()d084247(즉시 통과)
    1-3포인트_충전_실패_최대_제한_금액()d084247(즉시 통과)
    3-1포인트_사용_성공()f0140ebb1ecac4
    3-2포인트_사용_실패_포인트_부족()e6cce35(즉시 통과)
    4포인트_히스토리_조회()4973f8dd112070
    • 실패 케이스를 작성하였으나, 앞서 성공 케이스에서 바로 Service 코드로 연결되어 즉시 통과되었습니다.

Test Refactoring

  • 초반에는 REFACTOR를 단순 코드 정리 수준으로만 생각하고 커밋을 남겼습니다.

  • 이후에는 테스트 코드 작성의 관점에서 반복되는 세팅 함수들의 재사용까지를 포함하기 위해 별도로 전체 REFACTOR를 수행하였습니다.

    순번파일명대상REFACTOR
    1PointServiceTest.java충전(), 사용()438ae4a
    2PointServiceTestWithMockito.java조회_응답_설정(), 저장_응답_설정()5d81377
    3PointControllerTest.java충전(), 사용()46765eb
  • 주요 로직 코드와 검증 코드는 가독성을 위해 그대로 두어 크게 코드량이 줄지는 않았습니다.

  • 다만, 추후 테스트 코드를 작성할 때, 더 유용하게 사용할 수 있을 것 같다고 느꼈습니다.

동시성

동시성 문제가 발생하는 원인

동시성 문제는 여러 스레드, 혹은 여러 요청이 공유자원(해당 프로젝트에선 UserPoint)에 동시에 접근할 때,
과정중에 읽고 > 연산하고 > 쓰는 과정이 분리된 상태에서 Lock 없이 진행될 때 발생합니다.

  • 동시에 한 userId로 요청을 보내는 경우 읽는 과정에서 동일한 데이터를 확인하고, 연산 후 쓰는 과정에서 하나가 다른 하나를 덮어쓰는 갱신 손실을 발생시키는 문제가 있습니다.
  • 다만, 현재 JUnit 테스트 환경에서는 단일 함수들이 단일 스레드에서 직렬적으로 실행되기 때문에 동시성 문제가 감춰질 수도 있습니다.

동시성 문제를 해결하기 위한 이론

동시성 문제가 발생하는 근본적인 원인이 공유 자원에 대한 비원자적인 접근에 의한 것이기 때문에,
Lock 또는 원자적 연산으로 임계 구역을 보호해야 합니다.

  • 임계 구역(Critical Section) 보호: 여러 프로세스에 의해 공유되는 데이터 또는 자원에 대하여 한 프로세스만 사용하도록 제한하는 방법을 말합니다.
  • 상호 배제(Mutual Exclusion): 공유된 자원에 대하여 하나의 프로세스만 진입할 수 있도록 하는 것을 의미합니다.
  • 상호 배제 3대 조건
    • 상호 배제(Mutual Exclusion): 하나의 프로세스만 임계 구역에 진입할 수 있어야 한다.
    • 진행(Progress): 임계 구역이 비어있는 경우, 대기중인 프로세스 중 하나가 언젠가 진입해야 한다.
    • 유한한 대기(Bounded Waiting): 한 프로세스가 무한히 대기하지 않도록 보장해야 한다.(기아상태 방지)

동시성 문제를 해결하기 위한 방법

  1. synchronized, ReentrantLock (Java 동기화 키워드/클래스) 👈🏻 현재 적용한 방법(@Synchronized)
  • 장점
    • JVM 내부에서 가장 간단한 동기화 수단
    • 사용하기 쉽고 코드 수정만으로 적용 가능
  • 단점
    • 단일 인스턴스 환경에서만 유효
    • 모든 요청 직렬 처리 → 성능 저하 가능
  1. 데이터베이스 원자 연산
  • 예: UPDATE user_point SET point = point + ? WHERE id = ?
  • 장점
    • 조회 없이 한 줄로 처리 → 빠르고 안전
    • 정합성 보장 + 트랜잭션과 함께 사용 가능
  • 단점
    • 복잡한 조건/비즈니스 로직 표현 어려움
  1. 트랜잭션 + SELECT FOR UPDATE
  • 장점
    • 정합성 철저히 보장
    • 복잡한 연산이나 조건을 트랜잭션 안에서 안전하게 처리 가능
  • 단점
    • 락 경합 시 대기 시간 증가, 데드락 발생 가능성 존재
    • 트랜잭션 유지 비용 큼
  1. 분산 락 (Redis, ZooKeeper 등)
  • 장점
    • 다중 서버(WAS) 간에도 임계 구역 보호 가능
    • 마이크로서비스, 수평 확장 시스템에 적합
  • 단점
    • 락 획득/해제 실패 시 정합성 무너질 수 있음
    • 네트워크 병목, 락 설정 오류로 시스템 불안정 가능
  1. 메시지 큐 기반 직렬화 (Kafka, RabbitMQ 등)
  • 장점
    • 모든 요청을 순서대로 처리 → 동시성 문제 원천 제거
    • 정합성 강력 보장
  • 단점
    • 실시간 처리에는 부적합 (지연 발생)
    • 설계, 운영 복잡도 높음
  1. 낙관적 락 (Optimistic Lock)
  • 예: 버전 번호(version)를 기반으로 충돌 검출
  • 장점
    • 락 없이 병렬 처리 가능 → 성능 우수
    • 충돌이 드문 경우 이상적
  • 단점
    • 충돌 시 예외 발생 + 재시도 로직 필요
    • 충돌이 많으면 오히려 성능 저하
  1. CQRS + 이벤트 소싱
  • 읽기/쓰기 모델을 분리하고, 상태를 이벤트로만 저장
  • 장점
    • 확장성 우수
    • 변경 이력 추적, 감사 로깅 가능
  • 단점
    • 도입/설계 난이도 높음
    • 일관성 유지 복잡

KPT 회고

KEEP

  • 금주부터 다시 시작한 항해99에 집중하는 습관을 들이는 것을 목표로 하였고, 잘 지킨 것 같습니다.
  • 적어도 하루에 2시간은 프로젝트 코드를 계속 들여다 보고, 작성하며, 더 나은 방향을 찾기 위해 노력한 것 같습니다.

PROBLEM

  • 입사 이후 간만에 사용하는 Java와 처음 사용해보는 IntelliJ 환경이 약간의 어색함이 들었었지만,
  • 과제 집중도를 높히기 위한 예제코드와 왜 많이 사용하는 지 알 것 같은 IDE의 편리함 덕분에 금새 극복할 수 있던 것 같습니다.
  • 이번에 동시성에 대하여 다시 공부하면서 회사 코드에 빈틈이 얼마나 많을까 고민을 하게 되었습니다.
  • 여태까지 문제가 없었던건 그냥 사용자가 많지 않아서 였다는 것을 다시한번 깨달았습니다.

TRY

  • 하루 2시간 코딩 습관
  • 학습을 위한 구체적인 커밋 기록
  • 커밋과 함께 코드에 주석 기록 열심히 하기
  • 나중에 복습해도 커밋만으로 이해할 수 있도록 노력하기
  • PR을 더 구체적으로 작성해서 그대로 기록이 되도록 구성하기

Feedback

  1. 검증 도구 사용 미숙

    • assertAll, assertThat, ParameterizedTest 등 Junit5 + AssertJ 조합을 적극 활용하지 않음.
    • 결과적으로 테스트의 표현력과 유지보수성이 떨어짐.
  2. 테스트 설계의 구조 부족

    • PointServiceTest가 직접적으로 new를 통해 의존 객체를 생성함.
    • Mock을 활용하지 않아, 의존성이 추가될 경우 테스트가 깨지기 쉬운 구조.
  3. 역할과 책임의 불분명한 분배

    • 잔액 증가/감소 같은 비즈니스 로직을 단순히 Service에 작성 → 도메인의 책임 분리 원칙에 위배.
    • 예: UserPoint 객체에 increase()나 decrease() 같은 책임을 부여했어야 함.
  4. 예외 처리 및 입력 유효성 검증의 부족

    • 예외 처리를 단순하게 처리하고 있음.
    • 입력 유효성을 더 구조화해서 다룰 수 있음.
    • 입력값 검증을 도메인 레이어, 서비스 레이어로 적절히 나누는 고민 부족.
  5. 도메인 중심 사고 부족

    • UserPoint는 단순한 DTO처럼 동작함.
    • 도메인 로직을 가질 수 있는데 무시되고 있음.
    • 객체지향적으로 모델링되어 있지 않음.
  6. 테스트 중복 및 과도한 상세 검증

    • JSON 필드별 검증이 과도함.
    • size 체크로 충분한 검증이 가능한 상황에서는 간결성을 추구할 수 있음.

마치며

과제를 제출하기 전부터 TDD보다 나에게 부족한 점들이 느껴졌었습니다.
그런 내용들을 과제 피드백에서 문장으로 받으니 내가 공부해야 할 것들에 대하여 정리를 할 수 있게 되었습니다.

완성한 코드가 아니라 개발 과정의 커밋을 보신 것 같긴했지만,
실제로 생각만하고 적용을 안한 부분들도 있고, 스스로 생각하기에 완전히 미숙한 개념들도 있어
다음 과제부터 완성된 커밋을 바탕으로 PR을 작성해야겠다는 생각을 하였습니다.

0개의 댓글