4차산업혁명의 일꾼/백엔드

항해 Lite 백엔드 코스 1주차 WIL

르무엘 2025. 5. 24. 23:15

Chapter. 1 TDD
 
[챕터 목표]

  • 테스트 가능한 코드(Testable Code)의 의미를 명확히 이해하고, 다양한 종류의 테스트를 작성하며, TDD 기반의 요구사항 기능 개발을 학습합니다.
  • TDD(Test-Driven Development)의 개념과 프로세스(Red-Green-Refactor)를 학습하고, 실제 실무에서 적용할 수 있도록 연습합니다.
  • 상황에 따라 적절한 테스트를 작성하는 전략을 학습합니다.
  • 단순히 테스트를 작성하는 것을 넘어, 왜 테스트가 필요한지 근본적인 목적과 중요성을 이해합니다.
  • 주어진 과제를 분석하고, TDD 방식을 이용해 직접 기능을 구현하는 경험을 쌓습니다.

 
챕터 목표를 보니 유지보수성있고 견고한 코드를 짜기 위해, 실패하는 케이스를 비롯한..  여러가지 테스트 케이스를 남기기 위해 TDD를 한다.
 
TDD는 소프트웨어의 규모가 커지고 사용자 수가 많아짐에 따라 예측하기 어려운 장애가 자주 발생한다. 그래서 전통적인 개발 방식으로는 이러한 문제를 안정적으로 해결하기 어렵고, 지속적인 품질 유지와 빠른 변화 대응을 위해 자동화된 테스트와 TDD의 중요성이 더욱 강조되고 있다.
 
QA를 대신하여 빠른 결함 발견 및 문제 해결, 팀 내 코드 품질 및 협업 효율 향상, DEVOPS CI/CD 자동화를 통한 빠른 배포 사이클 확보를 목표로 한다.
 

결함률을 낮추고, 코드품질을 높여 협업에 용이하게 하고 자동화 배포에도 용이하게 하는 과정을 일련으로 하게 한다.
 
 
자 그러면 TDD 모키토를 암에 있어서 상세하게 알아야 할게 MockStub 이다.

 
Mock 예시 코드 (Mockito) 를 보면 객체의 행동을 검증하는데 제대로 수행했는지 검증한다.
registerUser 즉 유저 등록 과정(행동)이 정확히 1번 수행 되었는지 verify를 검증한다.

@Test
void userRepository_save_호출검증() {
    // given
    UserRepository mockRepository = mock(UserRepository.class);
    UserService userService = new UserService(mockRepository);
    User user = new User("test@example.com", "password123");

    // when
    userService.registerUser(user.getEmail(), user.getPassword());

    // then (저장 메서드가 정확히 1회 호출됐는지 검증)
    verify(mockRepository, times(1)).save(any(User.class));
}

Stub 예시 코드 (Mockito)
Stub은 객체의 상태를 반환하여 맞는지 본다. assertThat..

@Test
void userRepository_stub_응답제공() {
    // given
    UserRepository stubRepository = mock(UserRepository.class);
    UserService userService = new UserService(stubRepository);
    User user = new User("test@example.com", "password123");

    when(stubRepository.save(any(User.class))).thenReturn(new User(1L, user.getEmail(), user.getPassword()));

    // when
    long id = userService.registerUser(user.getEmail(), user.getPassword());

    // then
    assertThat(id).isEqualTo(1L);
}

 
TDD를 어설프게 배워서 그런지,, Mock과 Stub의 개념을 이번에 처음 명확히 구분해서 알았다. 대부분 Stub으로만 TDD하려했고, Mock의 형태로 하려 해본적이 없다.
 
왜냐하면... 기본적으로 정확히 동작했는지... 본다는 것은 사실 에러가 안났으면 정확히 동작했다고 당연히 간주해왔다... 이걸 궂이 TDD에서 궂이 확인하려면 정확하게 Mock 으로 verify 해서 확인할수 있다는 것을 알았다.
 
그리고 사실 객체의 행동보다 원하는 상태로 잘 변환 했는지 확인하는게 늘 더중요하게 봤다.
거기는 대부분 비즈니스로직이고 그렇기 때문에 위에서 when 과 thenReturn 이후에 일어나는 부분들이
assertThat 되었을 때 기본적으로 일단 작동하는 프로세스가 완성되는 것이다.
 

그러나 여기서 validation 체크를 해서 여러가지를 검증 하고 , 더나아가 동시성 상황에 대한 가정과 여러가지 부분에 대해서 생각하는 것은 재밌는 부분이다. 이게 바로 TDD를 통해서 할 수 있는 부분이다.

 
이렇게 정리하고 나니까 목표, 방향이 확 보이는데...  정리가 덜된 상태에서 어영부영하고 PR도 주중에 허둥지둥 내서 그런지 아쉬움이 남는다.
 
 
 
아래와 같이 확실히 결제와 같은 중요하고 핵심적인 부분은 그래도 확실히 Mock테스트를 통해 안전적인 호출을 확인하고 Stub로 기대하는 상태 결과값 반환을 기대하는것이 필요하다는 생각이 든다.

  • Test Double 활용과 강결합 해소 전략
    • 테스트 더블을 사용하여 외부 의존성을 제거하면 독립적이고 빠른 테스트가 가능합니다.
    • 외부 의존성이 강한 코드에서는 외부 환경에 따라 테스트 결과가 불안정하고 예측하기 어렵습니다.
    • 느슨한 결합을 통한 장점: 외부 시스템과의 결합도를 낮추면 코드가 유연해지고, 테스트가 빠르고 안정적이며 유지보수가 용이합니다.

 
아래 Mock과 Stub으로 하는 것을 보니 TDD가 좀더 재밌게 느껴진다. 호출 검증하고, 기대하는 결과값 보고 하는 것 말이다. 확실히 이런 부분은 TDD를 해놓으면 유지보수 이후까지 생각해 놨을 때 효율적이고 효과적이다.
 

/**
 * PaymentService는 PaymentGateway(외부 결제 서비스)에 직접적으로 의존합니다.
 * 이렇게 구현하면 실제 외부 시스템 상태(네트워크 문제, 서버 장애 등)에 따라 테스트가 영향을 받기 때문에,
 * 테스트가 불안정해지고 실행 시간이 길어질 수 있습니다.
 */
@Service
public class PaymentService {
    private final PaymentGateway paymentGateway;

    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public boolean processPayment(PaymentRequest request) {
        return paymentGateway.charge(request); // 실제 외부 시스템 호출 (강결합)
    }
}

/**
 * Mock을 사용한 예시입니다.
 *
 * Mock을 활용하여 외부 시스템 호출 여부(행동 검증)를 명확하게 확인할 수 있습니다.
 * 실제 외부 결제 서비스(PaymentGateway)에 접근하지 않고도 PaymentService가 
 * PaymentGateway의 charge 메서드를 정확히 1회 호출했는지 검증합니다.
 * 이를 통해 외부 시스템 상태에 의존하지 않는 독립적인 테스트가 가능합니다.
 */
@Test
void paymentService_mock을활용한결제요청호출검증() {
    // given: Mock 객체 생성 및 설정
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    PaymentService service = new PaymentService(mockGateway);
    PaymentRequest request = new PaymentRequest(1000);

    // 외부 시스템(PaymentGateway)의 charge 메서드 호출 시 무조건 true를 반환하도록 설정
    when(mockGateway.charge(request)).thenReturn(true);

    // when: 서비스의 결제 처리 메서드 실행
    boolean result = service.processPayment(request);

    // then: 외부 시스템의 메서드 호출 여부와 결과 값 검증
    verify(mockGateway, times(1)).charge(request);  // 호출 횟수(행동) 검증
}

/**
 * Stub을 사용한 예시입니다.
 *
 * Stub은 특정 입력 값에 대한 반환 값을 미리 설정하여, 외부 시스템의 상태를 완벽하게 통제합니다.
 * 이 예시에서는 PaymentGateway가 '결제 실패(false)' 상태를 반환하도록 명시하여,
 * 결제가 실패하는 상황에서도 PaymentService가 올바르게 동작하는지 확인합니다.
 * 실제 PaymentGateway를 사용하지 않고도 원하는 상태 조건 하에서의 동작을 정확히 테스트할 수 있습니다.
 */
@Test
void paymentService_stub을활용한반환값검증() {
    // given: Stub 객체 생성 및 상태(반환값) 설정
    PaymentGateway stubGateway = mock(PaymentGateway.class);
    PaymentService service = new PaymentService(stubGateway);
    PaymentRequest request = new PaymentRequest(1000);

    // Stub 설정: PaymentGateway의 charge 메서드가 특정 요청에 대해 false(실패)를 반환하도록 명시
    when(stubGateway.charge(request)).thenReturn(false);  // 결제 실패 상황 가정

    // when: 서비스의 결제 처리 메서드 실행
    boolean result = service.processPayment(request);

    // then: 반환된 결과가 미리 정의한 상태와 일치하는지 확인
    assertThat(result).isFalse();  // 명시적으로 설정한 반환 값(상태) 검증
}

 
자 TDD 검증을 받아본다.
 
https://github.com/MyoungSoo7/hhplus-tdd-jvm/pull/1#issuecomment-2906452783

 

1. 서비스로 TDD 4개 기능 구현 및  테스트 by MyoungSoo7 · Pull Request #1 · MyoungSoo7/hhplus-tdd-jvm

서비스로 TDD 4개 기능 구현 및 테스트 c6562a3 b69f995 775d8b9 332d4a4 TDD로 완성된 서비스 로직 구현은 적절한지? TDD로 만든 테스트 케이스가 TDD 방식이 맞는건지? 프로젝트 구조가 도메인 식으로 되어

github.com

 
역시 그 냥 TDD 통과하게 해놓고 Service단 만들었더니..
사실상 Mock 테스트와 기본적인 Stub 테스트를 한것이다.
그러나 Stub에서 좀더 다양한 테스트가 가능한듯 하다~!
 
자... 일단 기본적인 뼈대만 이어서 만들어놓고, 기능을 명확하게 정의하고 검증 가능한 코드로 구현하는 개발 사고방식을 좀더 만들기 위해서  유저포인트 조회, 유저포인트 충전, 유저포인트 사용, 유저포인트 히스토리 기본적인 4개 사항을 보는 것이다.
 여기에 대해서 서비스만 만들고 기본적으로 이었다...그리고 TDD형식만 만들었는데...

  •  

 
이렇게 피드백이 왔다.
 

 

  • 이번 챕터에서 내가 작성한 테스트 중, 명확하거나 부족했던 부분은 무엇이었나요?

TDD 질문중의 하나이다. 일단 이것에 대해서 잘한 사람의 것을 살펴보았다.
 

import static io.hhplus.tdd.point.TransactionType.CHARGE;
import static io.hhplus.tdd.point.TransactionType.USE;
import static org.junit.jupiter.api.Assertions.*;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.List;

import io.hhplus.tdd.service.PointService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(controllers = PointController.class)
class PointControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private PointService pointService;

    @Test
    @DisplayName("유저 포인트 조회 API")
    void getUserPointTest() throws Exception {

        //given
        long userId = 1L;
        long amount = 1000L;

        UserPoint userPoint = new UserPoint(userId, amount, System.currentTimeMillis());
        given(pointService.selectUserPoint(userId)).willReturn(userPoint);

        //when & then
        mockMvc.perform(get("/point/{id}", userId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("id").value(userPoint.id()))
                .andExpect(jsonPath("point").value(userPoint.point()))
                .andExpect(jsonPath("updateMillis").value(userPoint.updateMillis()));

    }

    @Test
    @DisplayName("유저의 포인트 충전/이용 내역 조회 API")
    void getHistoriesTest() throws Exception {
        //given
        long userId = 1L;
        long amount1 = 5000L;
        long amount2 = 3000L;

        PointHistory pointHistory1 = new PointHistory(1L, userId, amount1, CHARGE, System.currentTimeMillis());
        PointHistory pointHistory2 = new PointHistory(2L, userId, amount2, USE, System.currentTimeMillis());

        List<PointHistory> pointHistoryList = List.of(pointHistory1, pointHistory2);
        given(pointService.selectUserPointHistory(userId)).willReturn(pointHistoryList);

        // when & then
        mockMvc.perform(get("/point/{id}/histories", userId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(pointHistoryList.size()))
                .andExpect(jsonPath("$[0].id").value(pointHistory1.id()))
                .andExpect(jsonPath("$[0].userId").value(pointHistory1.userId()))
                .andExpect(jsonPath("$[0].amount").value(pointHistory1.amount()))
                .andExpect(jsonPath("$[0].type").value(pointHistory1.type().name()))
                .andExpect(jsonPath("$[0].updateMillis").value(pointHistory1.updateMillis()))
                .andExpect(jsonPath("$[1].id").value(pointHistory2.id()))
                .andExpect(jsonPath("$[1].userId").value(pointHistory2.userId()))
                .andExpect(jsonPath("$[1].amount").value(pointHistory2.amount()))
                .andExpect(jsonPath("$[1].type").value(pointHistory2.type().name()))
                .andExpect(jsonPath("$[1].updateMillis").value(pointHistory2.updateMillis()));

    }

    @Test
    @DisplayName("포인트 충전 API")
    void chargeTest() throws Exception {
        // given
        long userId = 1L;
        long amount = 1000L;
        String s_amount = "1000";

        UserPoint chargedUserPoint = new UserPoint(userId, amount, System.currentTimeMillis());
        given(pointService.chargeUserPoint(userId, amount)).willReturn(chargedUserPoint);

        // when & then
        mockMvc.perform(
                        patch("/point/{id}/charge", userId)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(s_amount)
                )
                .andExpect(status().isOk())
                .andExpect(jsonPath("id").value(chargedUserPoint.id()))
                .andExpect(jsonPath("point").value(chargedUserPoint.point()));
    }

    @Test
    @DisplayName("포인트 사용 API")
    void useTest() throws Exception {
        // given
        long userId = 1L;
        long amount = 5000L;
        long usePoint = 1000L;
        String s_usePoint = "1000";

        UserPoint result = new UserPoint(userId, amount - usePoint, System.currentTimeMillis());

        given(pointService.useUserPoint(userId, usePoint)).willReturn(result);

        // when & then
        mockMvc.perform(
                        patch("/point/{id}/use", userId)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(s_usePoint)
                )
                .andExpect(status().isOk())
                .andExpect(jsonPath("id").value(result.id()))
                .andExpect(jsonPath("point").value(result.point()));

    }

}

 
나는 사실 TDD를 체계적으로 배우기도, 실무적으로 많이 써보지 않아서... 사실 구현하면서 직접 시나리오를 디버깅하면서 테스트하는 편이다.
 
그래서 perform메서드를 모른다...  사실상 여기에 TDD를 구현하려는 시도는 나에게 새로운 어노테이션과 새로운 체계를 배우는 느낌이다. 그래서 낫설다. kotlin으로 코드를 짜라 이런 느낌과 비슷하다. TDD로 구현하는 것도 사실상 직관적으로 given 에서 이어지는 willReturn , perform 에서 이어지는 andExpect 이런 것들을 거의 본적이 없어서, 약간 자바의 신기능들 .. 예를 들면 Record 이런거 처음 생겼을 때 보는 그런 느낌이다.
 
위에서 말한것처럼 밸리데이션, 유효성 체크를 하는 것은 기본적으로 되어 있다.
첫 시작단추에서 이런 구도가 안나오니 아주 기본적인 것만 하고 그냥 내버려둔것 같다.
 
그렇다고 구글아저씨나 AI에게 일일이 물어보며 삽질을 궂이 경험하는 것보다.. 이렇게 우수작품을 벤치마킹하는게 더 효율적인 것 같다.
 

 
서비스 단에서 구현해 보는 저기의 anyLong() 같은 것도... 사실 잘모른다... 흠... 이런거 깔끔하게 정리된거 없나...
간단히  소스 구조상으로는 id가 들어가서 UserPoint가 나오지만...
 
테스트 해야 할 것은 그게 아니라. 포인트 조회에 있어서 정상적으로 작동하는지 테스트하는것이다.
그래서  assertThat으로 result와 대조해 본다.
 

  • 처음부터 실패하는 테스트를 먼저 작성했나요? 그 이유는 무엇이었나요?,

테스트를 하면서 실패하는 테스트를 생각하는데 있어서 조회는 너무 싶다. 입력값을 다르게 하거나 아이에 다른것과 매치시키면 되기 때문이다. 단순한 CRUD의 경우에 보통 이렇다. 좀더 복잡한 lock 기능 구현에 있어서 생각해보면 좋을 것 같다.

  • 기능 구현보다 테스트 작성이 더 어려웠던 경험이 있다면 어떤 점이었나요?,

확실히 기능구현은 하면되는데 테스트 작성은 anyLong, perform , willReturn , when , given, containsExactlyInAnyOrder 이런 식으로 코드를 구조화 해본적이 없으니 여전히 어렵다. 코틀린 쓰나 이 TDD작성하나.. Java에서 신기능 쓰나 모르는 메서드를 알아내는 부분은 TDD 전모를 찾아봐야 되는데, 이것에 관해 문서 찾아보러 갈 정도의 여정이 없어서 어렵나보다.

  • Mock과 Stub을 각각 어떤 상황에서 사용했고, 그 판단 기준은 무엇이었나요?,

Mock은 실행되었나이기 때문에 실제로 약간의 고난이도 아니면 필요없고, Stub은 난이도 보다 상태변화이기 때문에 그것에 맞추어서 하면 된다고 생각한다. 난이도 vs 상태변화

  • 테스트를 먼저 작성했더니 구현이 더 쉬워졌거나 설계가 명확해진 경험이 있었나요?

없다... 테스트를 작성하는 것은 인텔리제이를 처음 썼을 때 만큼이나 용기가 필요한 것 같다. 인텔리제이에 익숙해진 지금 다시 이클립스로 못달아갈 정도로 편하지만.. 지금 테스트 작성을 하면서.. .보통 맨땅에 해딩하는 식으로 부딪히고.. 에러나면 디버깅하고... 디버깅하면서 다각도로 보면서 개발해온 방식 때문인지... 기능에 대해 명확해 진다는것은 아래와 같이 포인트 히스토리를 작성했을때.. 거시적인 관점을 가질수 있다는 것이다.

 

  • 학습하며 "이건 나중에 실무에서 꼭 써먹고 싶다" 느낀 포인트는?

간단한 기능을 만들어놓고 로직을 작성하면서, 
[1] 포인트 충전,사용정책 추가
[2] 동시성 제어 추가
 
이런 부분을 추가하고 나서 테스트를 통과하나 이렇게 기록을 남기는 것이다.
 
그렇다면 추후 유지보수시에 기본적인 포인트충전, 사용정책은 넘어갈거고,
동시성 제어에서 문제가 나타나면 그 부분에서 어떤 부분을 테스트 했고,
어떤 부분을 더 테스트하지 않아서 문제가 나타났을까 로그를 보면서 상황파악을 해볼수 있을것이다.
 
마지막으로, 통합테스트까지 깔끔하게 하니 기분이좋다~!
 

 
 
 

LIST

'4차산업혁명의 일꾼 > 백엔드' 카테고리의 다른 글

코틀린과 tdd  (0) 2025.05.26
초급, 중급 , 고급과 특급에 대한 구분  (0) 2025.05.24
JPA 간단정리 2  (0) 2024.09.16
JPA 간단 정리 1  (0) 2024.09.12
백엔드 TDD 비디오가게편  (0) 2024.09.03