NEXTSTEP에서 진행하는 교육과정 ATDD와 함께 클린 API로 가는 길 4기다. (글을 작성하는 시점에서는 과정명이 ATDD, 클린 코드 with Spring으로 변경되었다.)
4주차 교육 요약
교육은 지난주 피드백으로 의존성에 대한 이야기, 단위테스트 vs 인수테스트 내용이 있으며
단위 테스트에서는 통과가 되었는데, 인수 테스트에서 실패하는 상황이 발생한 경우, 언제 어떻게 발생하는지 언급하고(ex:인메모리와 DB 조회의 차이)
본 수업으로 테스트 환경과 도구 여러가지가 언급되지만 그중 @SpringBootTest, @WebMvcTest, @DataJpaTest 3가지를 간략히 언급하고 넘어간다.
@SpringBootTest - @SpringBootApplication의 실행하는 서버 환경과 동일한 설정, 스프링 빈 전체 테스트 가능, 대신 상대적으로 오래 걸림
@WebMvcTest - Web과 관련된 부분(Controller)의 기능을 검증 시 활용.
@DataJpaTest - Spring Data JPA 사용 시 Repository 관련 빈을 Context에 등록하여 테스트에 활용
문서 자동화를 사용하는 간단한 이유는 구현된 기능과 문서의 괴리감을 줄이기 위해서 코드에서 관리하는 거다.
많이 사용되는 도구는 Spring Rest Docs, Swagger가 있고 간단히 서로의 차이점을 알려주는데
Spring Rest Docs는 테스트 코드에 설정하여 프로덕션 코드에 영향이 미미하고
Swagger는 프로덕션 코드에 @애노테이션을 이용하기 때문에 프로덕션 코드에 오염이 발생한다고 한다.
교육에는 Spring Rest Docs를 사용한다.
Spring Rest Docs의 프로세스로 간략히 설명하자면
1. Tests는 개발자가 작성한 테스트 코드다.
2. Snippets이란 재사용 가능한 코드 혹은 텍스트의 작은 단위를 뜻하는 Snippet이 모인 것으로
build > generated-snippets 경로에 지정해둔 키워드로 만든 작은 문서다.
3. Template은 src > docs > asciidoc 경로에서 snippets을 이용해서 템플릿을 만든다.
4. Document는 Templete으로 작성된 결과물이다.
🚀 1단계 - 경로 조회 타입 추가
요구사항
- 경로 조회 시 최소 시간 기준으로 조회할 수 있도록 기능을 추가하세요.
- 노선추가 & 구간 추가 시 거리와 함께 소요시간 정보도 추가하세요.
- 인수 테스트 (수정) -> 문서화 -> 기능 구현 순으로 진행하세요.
- 개발 흐름을 파악할 수 있도록 커밋을 작은 단위로 나누어 구현해보세요.
4주차 1단계 미션은 경로 조회 타입 추가다.
기능 구현보다는 주 목표인 인수 테스트에 Spring Rest Docs를 사용하여 문서 자동화 위주로 알아본다.
다음은 제공하는 예제 코드다.
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(RestDocumentationExtension.class)
public class Documentation {
@LocalServerPort
int port;
protected RequestSpecification spec;
@BeforeEach
public void setUp(RestDocumentationContextProvider restDocumentation) {
RestAssured.port = port;
this.spec = new RequestSpecBuilder()
.addFilter(documentationConfiguration(restDocumentation))
.build();
}
}
테스트 설정을 확인해보면 이전까지 진행한 인수테스트와 흡사하지만
@ExtendWith(RestDocumentationExtension.class)로 선언되어 있는데, 문서와 관련된 환경을 구축하기 위해서다.
RequestSpecification 객체가 있는데 문서화 설정을 위한 객체라 생각하고 넘어가자.
public class PathDocumentation extends Documentation {
@Test
void path() {
RestAssured
.given().log().all()
.accept(MediaType.APPLICATION_JSON_VALUE)
.queryParam("source", 1L)
.queryParam("target", 2L)
.when().get("/paths")
.then().log().all().extract();
}
}
문서 설정을 상속받아 구현한 테스트 코드로 테스트를 실행하면
build > generated-snippets 에 snippet이 생성되면서 테스트에 대한 결과물을 볼 수 있다.
src > docs > asciidoc 여기서 adoc 파일형태로 snippet을 조합해서 templete을 만든다.
제공하는 index.adoc를 보면 operation::path를 통해 실행된 결과물을 가져와서 사용하는걸 알 수 있다.
build.gradle에 설정된 경로로 docs 문서가 생성이 된다.
여기까지가 제공된 예제 코드다.
미션 요구사항을 진행하면서 문서 자동화 중복도 제거하고, 커스터마이징으로 문서를 간단히 꾸며야한다.
실습 가이드를 보면 커스터마이징 관련 링크가 2가지 Request Parameters / Request and Response Fields 가 있다.
이를 참고하면서 문서 자동화를 진행한 코드다.
@DisplayName("경로 관리(문서화)")
public class PathDocumentation extends Documentation {
@MockBean
private PathService pathService;
@DisplayName("경로 찾기 요청 - 거리")
@Test
void pathByDistance() {
PathResponse pathResponse = new PathResponse(
Lists.newArrayList(
new StationResponse(1L, "강남역", LocalDateTime.now(), LocalDateTime.now()),
new StationResponse(2L, "역삼역", LocalDateTime.now(), LocalDateTime.now())
),
10,
7
);
when(pathService.findPath(anyLong(), anyLong(), any())).thenReturn(pathResponse);
ExtractableResponse<Response> 최단_경로_요청 = 최단_거리_경로_요청(spec, 1L, 2L);
assertThat(최단_경로_요청.statusCode()).isEqualTo(HttpStatus.OK.value());
}
@DisplayName("경로 찾기 요청 - 시간")
@Test
void pathByDuration() {
PathResponse pathResponse = new PathResponse(
Lists.newArrayList(
new StationResponse(1L, "강남역", LocalDateTime.now(), LocalDateTime.now()),
new StationResponse(2L, "역삼역", LocalDateTime.now(), LocalDateTime.now())
),
10,
7
);
when(pathService.findPath(anyLong(), anyLong(), any())).thenReturn(pathResponse);
ExtractableResponse<Response> 최단_경로_요청 = 최단_시간_경로_요청(spec, 1L, 2L);
assertThat(최단_경로_요청.statusCode()).isEqualTo(HttpStatus.OK.value());
}
}
public class PathSteps {
private static final String DISTANCE_TYPE = "DISTANCE";
private static final String DURATION_TYPE = "DURATION";
public static ExtractableResponse<Response> 최단_거리_경로_요청(RequestSpecification spec, Long source, Long target) {
return 최단_경로_요청(spec, source, target, DISTANCE_TYPE);
}
public static ExtractableResponse<Response> 최단_시간_경로_요청(RequestSpecification spec, Long source, Long target) {
return 최단_경로_요청(spec, source, target, DURATION_TYPE);
}
private static ExtractableResponse<Response> 최단_경로_요청(RequestSpecification spec, Long source, Long target, String type) {
return RestAssured
.given(spec).log().all()
.filter(document("path",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
getRequestFieldsSnippets(),
getResponseFieldsSnippet()))
.accept(MediaType.APPLICATION_JSON_VALUE)
.queryParam("source", source)
.queryParam("target", target)
.queryParam("type", type)
.when().get("/paths")
.then().log().all().extract();
}
private static ResponseFieldsSnippet getResponseFieldsSnippet() {
return responseFields(fieldWithPath("stations[].id").description("지하철 역 번호"),
fieldWithPath("stations[].name").description("지하철 역 이름"),
fieldWithPath("stations[].createdDate").description("생성 시각"),
fieldWithPath("stations[].modifiedDate").description("수정 시각"),
fieldWithPath("distance").description("총 거리"),
fieldWithPath("duration").description("총 소요시간"));
}
private static RequestParametersSnippet getRequestFieldsSnippets() {
return requestParameters(parameterWithName("source").description("출발역 ID"),
parameterWithName("target").description("도착역 ID"),
parameterWithName("type").description("검색 유형"));
}
}
1단계가 끝났으면 앞으로 진행될 4주차 미션의 큰 뼈대는 완성되었다고 보면 된다.
🚀 2단계 - 요금 조회
요구사항
- 경로 조회 결과에 요금 정보를 포함하세요.
- 기본운임(10㎞ 이내) : 기본운임 1,250원
- 이용 거리초과 시 추가운임 부과
- 10km초과∼50km까지(5km마다 100원)
- 50km초과 시 (8km마다 100원)
- 인수 테스트 (수정) -> 문서화 -> 기능 구현 순으로 진행하세요.
- 개발 흐름을 파악할 수 있도록 커밋을 작은 단위로 나누어 구현해보세요.
2단계는 요금이 추가되는데 거리에 따라 요금제가 변동이 된다.
요금 요구사항으로 구별할 수 있는 것은 거리와 금액이며, enum으로 구분하고 한번에 계산되게 했다.
public enum PathFare {
BASIC_DISTANCE_FARE(0, 10, 0, 1250),
SHORT_DISTANCE_FARE(11, 50, 5, 100),
LONG_DISTANCE_FARE(51, Integer.MAX_VALUE, 8, 100);
private int standardDistance;
private int maxDistance;
private int distanceUnit;
private int fare;
...
public static int extractFare(int distance) {
return Arrays.stream(PathFare.values())
.mapToInt(pathFare -> pathFare.calculateOverFare(distance))
.sum();
}
private int calculateOverFare(int distance) {
if (this == PathFare.BASIC_DISTANCE_FARE) {
return fare;
}
if (distance < standardDistance) {
return 0;
}
int overDistance = Integer.min(distance, maxDistance);
return (int) ((Math.ceil((overDistance - standardDistance) / distanceUnit) + 1) * fare);
}
}
요금 계산은 enum으로 금방 마무리되었다.
이후 3단계에서도 변경없이 사용되는 코드인데 calculateOverFare 함수를 보면 BASIC, SHORT, LONG 3가지 운임을 한번에 계산한다, 이 코드를 구분하면 2가지로 볼 수 있다.
private int calculateOverFare(int distance) {
// 1. 기본 요금제일 경우 기본요금 반환.
if (this == PathFare.BASIC_DISTANCE_FARE) {
return fare;
}
// 2. SHORT, LONG 단계의 금액을 계산한다.
// 1) 초과 운행거리가 아니면 0원 반환
// 2) 초과 거리가 있으면 아래 계산식으로 초과 운임비용 반환.
if (distance < standardDistance) {
return 0;
}
int overDistance = Integer.min(distance, maxDistance);
return (int) ((Math.ceil((overDistance - standardDistance) / distanceUnit) + 1) * fare);
}
주석없이 시간이 지나면 한눈에 파악할 수가 없을 것이다.
if 조건은 어떤 의미인지? 마지막 계산식은 도대체 어떤 의미일까?
헷갈리는 부분을 의미있는 함수로 분리한다.
private int calculateFare(int distance) {
if (isBasicFare()) {
return fare;
}
return exceedsDistanceFare(distance);
}
private int exceedsDistanceFare(int distance) {
if (isExceedsDistance(distance)) {
return 0;
}
int exceedsDistance = Integer.min(distance, maxDistance);
return (int) ((Math.ceil((exceedsDistance - standardDistance) / distanceUnit) + 1) * fare);
}
private boolean isExceedsDistance(int distance) {
return distance < standardDistance;
}
private boolean isBasicFare() {
return this == DistanceFare.BASIC_DISTANCE_FARE;
}
분리된 함수 덕분에 이전보다 파악하기 수월해졌다. ATDD 끝나고 수정한거라 반영은 안했다.
🚀 3단계 - 요금 정책 추가
요구사항
- 추가된 요금 정책을 반영하세요.
- 인수 테스트 변경 -> 문서화 변경 -> 기능 구현 순으로 진행하세요.
- 개발 흐름을 파악할 수 있도록 커밋을 작은 단위로 나누어 구현해보세요.
4주차 마지막 단계 요금 정책 추가다.
이전 단계에서 진행한 거리별 운임에서 더 나아간 형태로 신분당선처럼 노선에 추가요금이 생기거나 연령에 따라 금액할인이 추가된다.
추가 요금이 있는 노선을 이용 할 경우 측정된 요금에 추가
ex) 900원 추가 요금이 있는 노선 8km 이용 시 1,250원 -> 2,150원
ex) 900원 추가 요금이 있는 노선 12km 이용 시 1,350원 -> 2,250원
경로 중 추가요금이 있는 노선을 환승 하여 이용 할 경우 가장 높은 금액의 추가 요금만 적용
ex) 0원, 500원, 900원의 추가 요금이 있는 노선들을 경유하여 8km 이용 시 1,250원 -> 2,150원
로그인 사용자의 경우 연령별 요금으로 계산
청소년: 운임에서 350원을 공제한 금액의 20%할인
어린이: 운임에서 350원을 공제한 금액의 50%할인
- 청소년: 13세 이상~19세 미만
- 어린이: 6세 이상~ 13세 미만
요구사항은 단순해보이지만 마지막 단계에 어울리게 난이도가 가장 높다.
신경쓸 곳은 크게 2가지가 있다.
첫번째로 연령별 요금할인 구현이다. 이전에 진행했던 거리별 요금과 비슷하게 연령별 할인을 구현한다.
public enum AgeFare {
CHILD_FARE(6, 12, BigDecimal.valueOf(350), BigDecimal.valueOf(0.5)),
YOUTH_FARE(13, 18, BigDecimal.valueOf(350), BigDecimal.valueOf(0.2)),
GENERAL_FARE(19, 200, BigDecimal.ZERO, BigDecimal.ZERO);
private final int minAge;
private final int maxAge;
private final BigDecimal deductibleFare;
private final BigDecimal discountRate;
AgeFare(int minAge, int maxAge, BigDecimal deductibleFare, BigDecimal discountRate) {
this.minAge = minAge;
this.maxAge = maxAge;
this.deductibleFare = deductibleFare;
this.discountRate = discountRate;
}
public static AgeFare findAgeFareType(int age) {
return Arrays.stream(AgeFare.values())
.filter(ageFare -> ageFare.isAge(age))
.findAny()
.orElse(AgeFare.GENERAL_FARE);
}
public BigDecimal extractDiscountFare(BigDecimal fare) {
if (this == AgeFare.GENERAL_FARE) {
return fare;
}
BigDecimal subtract = fare.subtract(deductibleFare);
BigDecimal discountFare = subtract.multiply(discountRate);
return fare.subtract(discountFare);
}
private boolean isAge(int age) {
return minAge <= age && age <= maxAge;
}
}
요금 할인은 350원 공제하고 남은 금액에서 연령별로 20퍼, 50퍼로 할인액을 구한뒤 기존 요금에서 빼서 반환하면 된다. 예상보다 단순하다.
두번째로 로그인 여부 확인이다. 로그인에 따른 연령 파악으로 요금할인 여부를 확인해야한다.
@RestController
public class PathController {
private final PathService pathService;
public PathController(PathService pathService) {
this.pathService = pathService;
}
@GetMapping("/paths")
public ResponseEntity<PathResponse> findPath(@RequestParam Long source,
@RequestParam Long target,
@RequestParam PathType type,
@AuthenticationPrincipal(required = false) LoginMember loginMember) {
return ResponseEntity.ok(pathService.findPath(source, target, type, loginMember));
}
}
출발지와 도착지 경로를 확인할 때 거리와 요금이 나타나야하니 PathController.findPath에도 @AuthenticationPrincipal를 추가해준다.
required=false로 선언한 이유는 비로그인도 경로 조회 요청하기 때문이다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticationPrincipal {
boolean required() default true;
}
다른 곳에서는 기본 설정값은 로그인 필수로 한다.
이제 비로그인 어떻게 LoginMember를 넘길지 고민하면 된다. Null로 반환하는건 만악의 근원이니 해선 안될 짓이고, 넥스트스탭의 다른 교육에서 배웠던 Null 객체로 활용한다.
public class LoginMember implements UserDetails {
private final Long id;
private final String email;
private final String password;
private final Integer age;
public static LoginMember of(Member member) {
return new LoginMember(member.getId(), member.getEmail(), member.getPassword(), member.getAge());
}
...
}
public class NullLoginMember extends LoginMember {
private static final NullLoginMember instance = new NullLoginMember();
private NullLoginMember() {
this(null, null, null, 0);
}
private NullLoginMember(Long id, String email, String password, Integer age) {
super(id, email, password, age);
}
public static NullLoginMember getInstance() {
return instance;
}
@Override
public Integer getAge() {
return 0;
}
}
만든 Null객체를 주입할 수 있게 인증리졸버 코드를 수정하는데 로직은 단순하다.
@AuthenticationPrincipal(required=false)로 사용되면서 인증객체가 없을 경우 Null 객체로 반환하면 된다.
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
...
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean required = parameter.getParameterAnnotation(AuthenticationPrincipal.class)
.required();
if (!required && authentication == null) {
return NullLoginMember.getInstance();
}
if (authentication.getPrincipal() instanceof Map) {
return extractPrincipal(parameter, authentication);
}
return authentication.getPrincipal();
}
...
}
요구사항대로 작동하는 지 확인하면 마지막 단계도 끝이난다.
간단히 설명했지만, 위의 언급한 내용은 피드백까지 반영된 최종 결과물이다.
NullLoginMember를 싱글톤으로 사용하지 않고 매번 인스턴스를 생성해서 반환하는 거와 언급은 안했지만 단위테스트 Fixture 규모에 따른 복잡성 문제도 있었다.
피드백으로 문제점을 찾아 하나씩 해결하다보면 개선된 코드를 볼 수 있다.
후기
내가 이전까지 일했던 근무 환경에서 문서작업은 항상 수작업이였고 문서 자동화를 들어본 적도 없었다.
몇번 파견도 가봤지만, 대부분 워드나 엑셀로 작성했거나 아예 안한곳도 있었다. (메모장으로 작성한 곳도 봤다.)
문서작업이란 비효율적인 업무중 하나였는데, 변경이 생길 때 마다 연관된 문서를 다 찾아서 수정해야하는 문제가 있었고 추후엔 문서 관리를 포기할 정도까지 간 적도 있었다.
(TMI : 어떤 프로젝트는 특정 솔루션의 툴을 이용해서 화면 개발을 진행한적 있었는데, 제공하는 가이드가 전혀 안맞았다. 당시 파견나온 솔루션 관계자에게 물어보니 '가이드보고 왜 하냐? 가이드 맞는게 없어서 보면 안된다.'라는 귀를 의심할만한 소리도 들어봤다.)
이번 주차에서 배운 Spring Rest Docs는 문서자동화를 위해서 작성할 코드가 있긴하지만, 수작업으로 하는 것보다 매우 간단하고 테스트 코드를 통해 작성되기 때문에 기능 변경에도 유연하게 대처할 수 있었다.
정리할 내역
- 문서화 도구 SpringRestDock 정리 (Swagger도 맛보기?)
- Null 객체
'교육 및 인강 > ATDD, 클린 코드 with Spring' 카테고리의 다른 글
ATDD 5주차 - 마무리 (0) | 2022.07.25 |
---|---|
ATDD 3주차 - ATDD 기반 리팩토링 (0) | 2022.07.09 |
ATDD 2주차 - ATDD + TDD 후기 (0) | 2022.06.24 |
ATDD 1주차 - 인수테스트 후기📖 (0) | 2022.06.21 |
ATDD 시작하면서... (0) | 2022.06.09 |