티스토리 뷰

Spring

[Spring] TDD로 멤버십 삭제 및 포인트 적립 API 구현 예제 - (5/5)

망나니개발자 2021. 8. 29. 10:00
반응형

이번에는 멤버십 삭제 및 포인트 적립 API를 개발해보도록 하겠습니다.

 

 

 

1. 멤버십 삭제 API 구현


[ 요구사항 확인 ]

  • 나의 멤버십 삭제 API
    • 기능: 나의 멤버십을 삭제합니다.
    • 요청: 사용자 식별값, 멤버십 번호
    • 응답: X

 

[ Repository 계층 개발 ]

이번에도 다른 API와 마찬가지로 Repository 계층부터 테스트를 작성하도록 하자.

엔티티 객체의 삭제를 위해 JPARepository는 deleteById라는 메소드를 제공하고 있다. 1개의 엔티티를 먼저 추가하고, 이 엔티티를 삭제하는 테스트 코드를 작성해보도록 하자. 멤버십 번호로 멤버십 객체를 찾기 위해 findById도 사용해야 하지만, deleteById와 유사하므로 넘어가도록 하자.

@Test
public void 멤버십추가후삭제() {
    // given
    final Membership naverMembership = Membership.builder()
            .userId("userId")
            .membershipType(MembershipType.NAVER)
            .point(10000)
            .build();

    final Membership savedMembership = membershipRepository.save(naverMembership);

    // when
    membershipRepository.deleteById(savedMembership.getId());

    // then
}

 

 

컴파일 에러가 발생하지 않기 때문에 테스트 코드를 바로 실행할 수 있고, 해당 테스트 코드를 실행하면 테스트가 성공함을 확인할 수 있다. 더 이상 작성할 테스트가 없으므로 서비스 계층으로 넘어가도록 하자. (TDD로 개발하고 있으므로 더 이상 작성할 '테스트'가 없는 것이다.)

 

 

 

[ Service 계층 개발 ]

서비스 계층에서는 해당 멤버십 번호로 멤버십을 조회하고, 사용자가 요정자와 일치하는 경우에 삭제를 진행해야 한다. 멤버십 삭제에 대한 실패 케이스는 멤버십이 없는 경우, 멤버십이 있는데 본인이 아닌 경우가 있다. 순서대로 실패 테스트를 먼저 작성하고 성공하는 테스트를 작성하도록 하자.

private final Long membershipId = -1L;
    
@Test
public void 멤버십삭제실패_존재하지않음() {
    // given
    doReturn(Optional.empty()).when(membershipRepository).findById(membershipId);

    // when
    final MembershipException result = assertThrows(MembershipException.class, () -> target.removeMembership(membershipId, userId));

    // then
    assertThat(result.getErrorResult()).isEqualTo(MembershipErrorResult.MEMBERSHIP_NOT_FOUND);
}
    
@Test
public void 멤버십삭제실패_본인이아님() {
    // given
    final Membership membership = membership();
    doReturn(Optional.of(membership)).when(membershipRepository).findById(membershipId);
    
    // when
    final MembershipException result = assertThrows(MembershipException.class, () -> target.removeMembership(membershipId, "notowner"));

    // then
    assertThat(result.getErrorResult()).isEqualTo(MembershipErrorResult.NOT_MEMBERSHIP_OWNER);
}
    
@Test
public void 멤버십삭제성공() {
    // given
    final Membership membership = membership();
    doReturn(Optional.of(membership)).when(membershipRepository).findById(membershipId);

    // when
    target.removeMembership(membershipId, userId);

    // then
}

 

 

역시 컴파일 에러가 발생하고, 이를 해결하기 위해 다음과 같은 프로덕션 코드를 추가해야 한다.

@Service
@RequiredArgsConstructor
public class MembershipService {

    private final MembershipRepository membershipRepository;

    ... 생략

    public void removeMembership(final Long membershipId, final String userId) {

    }
}

@Getter
@RequiredArgsConstructor
public enum MembershipErrorResult {

    NOT_MEMBERSHIP_OWNER(HttpStatus.BAD_REQUEST, "Not a membership owner"),
    MEMBERSHIP_NOT_FOUND(HttpStatus.NOT_FOUND, "Membership Not found"),
    DUPLICATED_MEMBERSHIP_REGISTER(HttpStatus.BAD_REQUEST, "Duplicated Membership Register Request"),
    UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception"),
    ;

    private final HttpStatus httpStatus;
    private final String message;

}

 

 

그러면 컴파일 에러가 해결되었다. 하지만 테스트 코드를 실행하면 실패하게 되므로, 테스트 코드를 통과하기 위해 removeMembership 내부 로직을 다음과 같이 작성해보도록 하자.

@Service
@RequiredArgsConstructor
public class MembershipService {

    private final MembershipRepository membershipRepository;

    ... 생략

    public void removeMembership(final Long membershipId, final String userId) {
        final Optional<Membership> optionalMembership = membershipRepository.findById(membershipId);
        final Membership membership = optionalMembership.orElseThrow(() -> new MembershipException(MembershipErrorResult.MEMBERSHIP_NOT_FOUND));
        if (!membership.getUserId().equals(userId)) {
            throw new MembershipException(MembershipErrorResult.NOT_MEMBERSHIP_OWNER);
        }

        membershipRepository.deleteById(membershipId);
    }
}

 

 

그리고 테스트를 실행하면 테스트를 통과하게 되고, 서비스 계층의 개발이 마무리되었음을 확인할 수 있다. 이제 컨트롤러 개발로 넘어갈 차례이다.

 

 

 

[ Controller 계층 개발 ]

이제는 멤버십을 삭제하는 API를 개발해야 한다. 이번에도 테스트 코드를 먼저 작성해고자 하는데, 다른 때와 마찬가지로 사용자 식별값이 없으면 실패하는 경우와 삭제 API가 성공하는 경우를 작성해보도록 하자. Delete Method의 경우 No Content로 응답하는게 표준이므로, Http Status 204, No content로 성공 테스트의 응답을 작성하도록 하자.

@Test
public void 멤버십삭제실패_사용자식별값이헤더에없음() throws Exception {
    // given
    final String url = "/api/v1/memberships/-1";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.delete(url)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

@Test
public void 멤버십삭제성공() throws Exception {
    // given
    final String url = "/api/v1/memberships/-1";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.delete(url)
                    .header(USER_ID_HEADER, "12345")
    );

    // then
    resultActions.andExpect(status().isNoContent());
}

 

 

이제는 404 Not Found 에러가 발생하는 것에 익숙해졌을 것이다다. 그러므로 다음과 같은 삭제 API를 작성해주도록 하자.

@DeleteMapping("/api/v1/memberships/{id}")
    public ResponseEntity<Void> removeMembership(
        @RequestHeader(USER_ID_HEADER) final String userId,
        @PathVariable final Long id) {

    membershipService.removeMembership(id, userId);
    return ResponseEntity.noContent().build();
}

 

 

그리고 테스트를 실행하면 테스트가 통과하게 되고, 이를 통해 멤버십 삭제 API 구현이 마무리 되었음을 확인할 수 있다.

이제 마지막남은 API인 멤버십 포인트 적립 API를 구현해보도록 하자.

 

 

 

 

 

2. 멤버십 포인트 적립 API


[ 요구사항 확인 ]

  • 멤버십 포인트 적립 API
    • 기능: 나의 멤버십 포인트를 결제 금액의 1%만큼 적립합니다.
    • 요청: 사용자 식별값, 멤버십 ID, 사용 금액을 입력값으로 받습니다.
    • 응답: X
    • 기타: 고정 금액 적립 방식으로의 확장이 유연하도록 구현합니다.

 

[ Repository 계층 개발 ]

멤버십 포인트 적립을 위해 레포지토리 계층에 추가해야 하는 코드는 없다. JpaRepository의 save는 저장과 수정을 동시에 처리하고 있기 때문이다. 그러므로 리포지토리 계층은 넘어가주도록 하자.

 

 

[ Service 계층 개발 ]

이번에는 멤버십을 적립하기 위한 로직을 개발해야 한다. 이를 구현하기 위해 적립금에 대한 계산이 필요한데, 이는 MembershipService의 책임이 아니다. 그러므로 이에 대한 책임은 새로운 PointService를 만들어 맡기도록 하자. (이름이 썩 마음에 들지는 않을 수 있으므로 각자 원하는 취향에 맞추어 개발하도록 하자)

포인트 적립은 현재 1% 비율로 적립하는 방식이지만 추후에 다른 방식으로 적립될 가능성을 고려하여 개발해보도록 하자.

우선 10000원을 기준으로 적립해야 하는 금액에 대한 테스트 코드를 구현하도록 하자. 적립해야 하는 포인트를 계산하는 과정에는 TDD의 가짜 구현과 삼각 측량 방법을 이용하여 구현해보도록 하자.

@ExtendWith(MockitoExtension.class)
public class RatePointServiceTest {

    @InjectMocks
    private RatePointService ratePointService;

    @Test
    public void _10000원의적립은100원() {
        // given
        final int price = 10000;

        // when
        final int result = ratePointService.calculateAmount(price);

        // then
        assertThat(result).isEqualTo(100);
    }

}

 

 

위의 테스트 코드는 컴파일 에러가 발생하고 있다. 그러므로 다음과 같은 프로덕션 코드를 작성해주도록 하자.

@Service
public class RatePointService {
    
    public int calculateAmount(final int price) {
        return 0;
    }
    
}

 

 

현재 1%라는 계산을 어떻게 하면 좋을지 모른다는 가정 하에, 변수를 사용하지 않고 무조건 0을 반환하도록 가짜 구현을 해주었다.

테스트 코드를 실행하면 테스트는 실패한다. 가장 빠르게 테스트를 통과하여 초록막대를 보기 위해 무조건 100을 반환하도록 가짜 구현을 수정하도록 하자.

@Service
public class RatePointService {

    public int calculateAmount(final int price) {
        return 100;
    }

}

 

 

하지만 이에 대한 테스트 케이스가 부족해보인다. 삼각 측량 방법을 적용하기 위해 다음과 같이 2가지 또 다른 테스트 케이스를 추가하도록 하자. 참고로 이러한 부분은 Junit5의 @ParameterizedTest를 사용하면 매우 유용하지만, 여기서는 넘어가도록 하자.

@Test
public void _20000원의적립은200원() {
    // given
    final int price = 20000;

    // when
    final int result = ratePointService.calculateAmount(price);

    // then
    assertThat(result).isEqualTo(200);
}

@Test
public void _30000원의적립은300원() {
    // given
    final int price = 30000;

    // when
    final int result = ratePointService.calculateAmount(price);

    // then
    assertThat(result).isEqualTo(300);
}

 

 

그리고 테스트를 실행하면 새로 작성한 두 가지 케이스는 실패한다. 왜냐하면 가장 빠르게 초록 막대를 보기 위해 가짜 구현을 했기 때문이다. 작성된 3가지 테스트를 모두 통과할 수 있도록 삼각 측량 방법을 이용해 일반화하도록 하자.

@Service
public class RatePointService {

    private static final int POINT_RATE = 1;

    public int calculateAmount(final int price) {
        return price * POINT_RATE / 100 ;
    }

}

 

 

(위의 함수를 구현하는 과정이 사람에 따라 더욱 세분화될 수 있다. 현재 포스팅에서는 이정도로 하고 마무리하도록 하자.)

그리고 테스트를 실행하면 성공한다. 하지만 이렇게 구현을 마무리하면 추후에 고정 금액으로 포인트를 적립하는 코드를 작성하기가 까다로워 질 것이다. 그러므로 적립해야하는 포인트를 계산하는 인터페이스인 PointService를 두고, 이를 RatePointService가 구현하도록 리팩토링을 하자.

public interface PointService {

    int calculateAmount(final int price);

}

@Service
public class RatePointService implements PointService {

    private static final int POINT_RATE = 1;

    @Override
    public int calculateAmount(final int price) {
        return price * POINT_RATE / 100 ;
    }

}

 

 

그러면 우리는 추후에 고정 금액 포인트 적립방식을 구현하기 위해 FixPointService를 만들고 PointService를 구현하도록 하면 될 것이다.

그러면 이제 이 PointService를 이용해 실제로 포인트를 적립하는 로직을 MembershipService에 구현해보도록 하자. 물론 이번에도 테스트 코드를 먼저 작성할 것이, 실패 케이스부터 작성해보도록 하자.

@Mock
private RatePointService ratePointService;
    
@Test
public void 멤버십적립실패_존재하지않음() {
    // given
    doReturn(Optional.empty()).when(membershipRepository).findById(membershipId);

    // when
    final MembershipException result = assertThrows(MembershipException.class, () -> target.accumulateMembershipPoint(membershipId, userId, 10000));

    // then
    assertThat(result.getErrorResult()).isEqualTo(MembershipErrorResult.MEMBERSHIP_NOT_FOUND);
}

@Test
public void 멤버십적립실패_본인이아님() {
    // given
    final Membership membership = membership();
    doReturn(Optional.of(membership)).when(membershipRepository).findById(membershipId);

    // when
    final MembershipException result = assertThrows(MembershipException.class, () -> target.accumulateMembershipPoint(membershipId, "notowner", 10000));

    // then
    assertThat(result.getErrorResult()).isEqualTo(MembershipErrorResult.NOT_MEMBERSHIP_OWNER);
}

@Test
public void 멤버십적립성공() {
    // given
    final Membership membership = membership();
    doReturn(Optional.of(membership)).when(membershipRepository).findById(membershipId);

    // when
    target.accumulateMembershipPoint(membershipId, userId, 10000);

    // then
    
}

 

 

역시 컴파일 에러가 발생할 것이고, 다음과 같이 membershipService를 구현해주도록 하자. 포인트는 값이 변해야 하므로 Membership Entity 클래스의 amount 변수에만 @Setter를 추가해주도록 하자. (철저하게 OCP 원칙을 지켜주는 것이다.)

@Entity
@Table
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Membership {

    ... 생략 

    @Setter
    @Column(nullable = false)
    @ColumnDefault("0")
    private Integer point;

}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MembershipService {

    private final PointService ratePointService;
    private final MembershipRepository membershipRepository;

    @Transactional
    public void accumulateMembershipPoint(final Long membershipId, final String userId, final int amount) {
        final Optional<Membership> optionalMembership = membershipRepository.findById(membershipId);
        final Membership membership = optionalMembership.orElseThrow(() -> new MembershipException(MembershipErrorResult.MEMBERSHIP_NOT_FOUND));
        if (!membership.getUserId().equals(userId)) {
            throw new MembershipException(MembershipErrorResult.NOT_MEMBERSHIP_OWNER);
        }

        final int additionalAmount = ratePointService.calculateAmount(amount);

        membership.setPoint(additionalAmount + membership.getPoint());
    }

}

 

membershipService의 구현에서 주목할 점이 크게 2가지 있다.

  • @Transactional 어노테이션 처리
  • PointService를 주입받는 변수의 이름

 

 

@Transactional 어노테이션 처리

클래스 레벨으로는 @Transactional(readOnly = true) 어노테이션, 추가/수정/삭제 메소드에는 @Transasctional 어노테이션을 추가해주었다. 클래스 레벨에 적용된 트랜잭션 어노테이션은 메소드에 그대로 적용되고, 만약 메소드에 별도의 트랜잭션 어노테이션이 있으면 메소드의 어노테이션이 적용된다. 그러므로 쓰기 또는 변경 작업이 있는 메소드에는 @Transactional을 붙여 readOnly 속성이 있는 @Transactional을 덮어 씌워주도록 하자.

(다른 addMembership이나 removeMembership 함수에도 붙여주어야 한다.)

 

클래스 레벨에 @Transactional을 붙여준 이유는 크게 3가지가 있다.

  1. 서비스 계층과 트랜잭션 경계와의 일치
  2. 읽기 전용 어노테이션을 통한 최적화 및 읽기 작업에 대한 안정성 확보
  3. JPA 내부 동작 방식과의 결합

 

우선 트랜잭션이 중구난방으로 적용되는 것은 좋지 않다. 대신 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 좋은데, 일반적으로 비지니스 로직을 담고 있는 서비스 계층은 다른 서비스 계층에 참조되는 경우가 많으므로 서비스 계층의 메소드와 결합시키는 것이 좋다.

두 번째 이유는 클래스 레벨에 적용되는 읽기전용 트랜잭션 어노테이션은 클래스에 선언하고, 추가나 삭제 또는 수정과 같은 작업에는 쓰기가 가능한 @Transactional을 붙여줌으로써 읽기 작업에 성능의 최적화를 더해주고, 읽기 작업에서 데이터가 변경되는 실수들을 방지하는 안정성을 확보할 수 있다.

마지막 이유는 JPA의 내부 동작 방식 때문인데, MemeberService의 accumulateMembershipPoint 메소드에서 시작된 트랜잭션은 MemberRepository에서 Membership 객체를 조회하는 순간을 포함해 accumulateMembershipPoint 메소드가 종료될 때 까지 유지된다. 그러면 Entity는 내부적으로 1차 캐시를 관리하는데, 동일한 트랜잭션 상이므로 별도의 save()를 호출하지 않고도 처리되도록 도와준다. (자세히 설명하기에는 내용이 길어지므로, 동일한 트랜잭션 상에서 DB에서 조회한 엔티티를 수정하면 별도의 update 쿼리가 필요 없이 트랜잭션 종료 시점에 객체가 변했을 경우에만 update 쿼리가 자동으로 수행된다고 기억하면 된다.)

 

 

PointService를 주입받는 변수의 이름

두 번째로 주목할 부분은 PointService를 주입받는 변수의 이름이 ratePointService라는 것이다. 스프링에서 생성자 주입을 할 때에는 @Autowired가 동작한다. (이에 대한 이해가 부족하면 여기를 참고하기 바란다.)

여기서 @Autowired는 주입할 빈을 먼저 클래스 타입으로 찾은 다음에, 여러 개의 빈이 있으면 빈의 이름으로 찾는다. 별도로 지정을 해주지 않으면 빈의 이름은 소문자로 시작하는 클래스 이름이 된다.

만약 우리가 주입받는 변수를 그냥 pointService로 해주면 추후에 FixPointService가 추가되었을 때 PointService 타입의 빈이 2개이고, pointService라는 이름의 빈은 존재하지 않으므로 에러가 발생하게 된다. 그러므로 미래에 이러한 문제를 방지하기 위해 변수의 이름을 ratePointService로 해준 것이다. 이 외에도 @Primary 또는 @Qualifer 등을 통해 처리할 수도 있다. (이에 대한 이해가 부족하다면 여기를 참고하기 바란다.) 하지만 여기서 다루고자하는 핵심 내용은 이것이 아니니 간단히 변수 이름으로 처리하도록 하자.

 

 

그러면 이제 포인트 적립에 대한 로직의 개발이 완료되었으니 컨트롤러로 넘어가도록 하자.

 

 

 

[ Controller 계층 개발 ]

이제 마지막 단계인 포인트 적립을 위한 컨트롤러의 테스트 코드를 작성해보도록 하자.

이번의 테스트 케이스는 사용자 식별 헤더가 없어서 실패하는 경우와 멤버십이 없어서 실패하는 경우, 그리고 0보다 작은 적립 금액이 요청으로 오는 경우가 있다. 요청을 보내는 클래스는 MembershipAddRequest를 사용해보도록 하자.

@Test
public void 멤버십적립실패_사용자식별값이헤더에없음() throws Exception {
    // given
    final String url = "/api/v1/memberships/-1/accumulate";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .content(gson.toJson(membershipRequest(10000)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

@Test
public void 멤버십적립실패_포인트가음수() throws Exception {
    // given
    final String url = "/api/v1/memberships/-1/accumulate";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(-1, MembershipType.NAVER)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

@Test
public void 멤버십적립성공() throws Exception {
    // given
    final String url = "/api/v1/memberships/-1/accumulate";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(10000)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isNoContent());
}
private MembershipRequest membershipRequest(final Integer point) {
    return MembershipRequest.builder()
        .point(point)
        .build();
}

 

 

그리고 테스트를 실행하면 아직 API를 개발하지 않았으므로 404 에러가 발생하게 된다. 컨트롤러에 다음과 같이 API를 추가해주도록 하자.

@PostMapping("/api/v1/memberships/{id}/accumulate")
public ResponseEntity<Void> accumulateMembershipPoint(
        @RequestHeader(USER_ID_HEADER) final String userId,
        @PathVariable final Long id,
        @RequestBody @Valid final MembershipRequest membershipRequest) {

    membershipService.accumulateMembershipPoint(id, userId, membershipRequest.getPoint());
    return ResponseEntity.noContent().build();
}

 

 

그러면 테스트를 실행하면 성공하는 듯 싶었지만 멤버십적립성공 테스트가 통과를 하지 못했다. 그 이유를 파악하기 위해 실패 로그를 살펴보면 다음과 같다.

20:01:13.934 [Test worker] DEBUG org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver - Resolved [org.springframework.web.bind.MethodArgumentNotValidException: 
Validation failed for argument [2] in public org.springframework.http.ResponseEntity<java.lang.Void> com.mang.atdd.membership.app.membership.controller.MembershipController.accumulateMembershipPoint
(java.lang.String,java.lang.Long,com.mang.atdd.membership.app.membership.dto.MembershipAddRequest): [Field error in object 'membershipAddRequest' on field 'membershipType': rejected value [null]; 
codes [NotNull.membershipAddRequest.membershipType,NotNull.membershipType,NotNull.com.mang.atdd.membership.app.enums.MembershipType,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [membershipAddRequest.membershipType,membershipType]; 
arguments []; default message [membershipType]]; default message [must not be null]] ]

 

 

원인을 분석해보니 MembershipType이 Null이면 안된다는 것인데, 멤버십 등록할 때 사용된 MembershipRequest를 재사용하여 membershipType의 @NotNull 때문에 문제가 발생한 것이다.

이를 해결하기 위한 많은 방법들이 있지만, 이번에는 @Validated를 이용해 처리해보고자 한다. @Validated는 유효성 검사를 특정 그룹단위로 하게 해주는 어노테이션이다. (@Validated에 대한 이해가 부족하면 여기를 참고해주세요)

MembershipType이 NotNull인 속성은 새로운 멤버십을 등록하는 경우에만 적용되어야 한다. 그러므로 새로운 멤버십을 등록함을 표시하는 마커 인터페이스를 만들고, 해당 경우에만 유효성 검사가 진행되도록 수정을 해주자.

@NoArgsConstructor
public final class ValidationGroups {

    public interface MembershipAddMarker {

    }
    public interface MembershipAccumulateMarker {

    }

}

@Getter
@Builder
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class MembershipRequest {

    @NotNull(groups = {MembershipAddMarker.class, MembershipAccumulateMarker.class})
    @Min(value = 0, groups = {MembershipAddMarker.class, MembershipAccumulateMarker.class})
    private final Integer point;

    @NotNull(groups = {MembershipAddMarker.class})
    private final MembershipType membershipType;

}

@RestController
@RequiredArgsConstructor
public class MembershipController {

    private final MembershipService membershipService;

    @PostMapping("/api/v1/memberships")
    public ResponseEntity<MembershipAddResponse> addMembership(
            @RequestHeader(USER_ID_HEADER) final String userId,
            @RequestBody @Validated(MembershipAddMarker.class) final MembershipRequest membershipRequest) {

        final MembershipAddResponse membershipResponse = membershipService.addMembership(userId, membershipRequest.getMembershipType(), membershipRequest.getPoint());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(membershipResponse);
    }

    @PostMapping("/api/v1/memberships/{id}/accumulate")
    public ResponseEntity<Void> accumulateMembershipPoint(
            @RequestHeader(USER_ID_HEADER) final String userId,
            @PathVariable final Long id,
            @RequestBody @Validated(MembershipAccumulateMarker.class) final MembershipRequest membershipRequest) {

        membershipService.accumulateMembershipPoint(id, userId, membershipRequest.getPoint());
        return ResponseEntity.noContent().build();
    }

}

 

 

(개인적으로는 @Validated를 사용하면 코드가 엉키면서 복잡해지고, 가독성이 떨어져서 @Validated보다 별도의 DTO 클래스를 만드는 것을 선호합니다! 하지만 현재는 실 서비스를 구축하는 것이 아니므로 지식의 전달을 목적으로 사용하였습니다.)

 

 

 

그리고 테스트를 실행하면 테스트가 통과되어 초록막대를 보게 되고, 모든 요구 사항을 개발하게 된 것이다.

 

 

 

지금까지 총 5개의 API를 TDD 방식으로 구현하여 보았고, 이제 어느정도 TDD의 리듬(빨간막대 -> 초록막대 -> 리팩토링)에 익숙해졌을 것 같습니다. 물론 포스팅에서 정리했던 내용들과 개인적으로 맞지 않는 부분들도 충분히 있을 수 있습니다. 누군가는 컨트롤러부터 테스트를 작성하는 것이 좋다고 생각할 수도 있고, 테스트를 더 작은 단위로 진행해야 한다고 얘기할 수도 있을 것 같습니다. 저 역시도 저의 스타일에 맞게 저만의 규칙 및 플로우를 잡고 진행한 것이므로, 여러분도 각자의 스타일에 맞게 TDD를 진행하면 좋을 것 같습니다!

 

 

 

 

 

위에서 작성한 내용들은 클린코드나 이펙티브 자바, 테스트 주도 개발 By Example 등의 책과 실무 경험 등을 종합하여 개인적인 생각을 정리한 글입니다. 옳고 그른 얘기들 및 부족한 점 충분히 있을 수 있는데, 피드백이나 커멘트 등은 언제나 환영합니다:)

코드는 깃허브에 공개되어 있습니다! 코드를 확인하시려면 여기를 참고해주세요:)

 

 

관련 포스팅

  1. 단위 테스트와 TDD(테스트 주도 개발) 프로그래밍 방법 소개 - (1/5)
  2. TDD 연습문제 소개와 요구사항 분석 및 SpringBoot 프로젝트 설정 - (2/5)
  3. TDD로 멤버십 등록 API 구현 예제 - (3/5)
  4. TDD로 멤버십 전체/상세 조회 API 구현 예제 - (4/5)
  5. TDD로 멤버십 삭제 및 포인트 적립 API 구현 예제 - (5/5)

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함