Step1 즐겨찾기 기능 완성
3주차 미션을 시작하면서 한가지 깨달은 사항이 있었다.
2주차 까지는 restassured 요청시 공통 코드를 http method 기준으로 분리하였다.
// AcceptanceTest
protected ExtractableResponse<Response> get(String path, int statusCode, Map<String, ?> queryParams, Object... pathParams) {
RequestSpecification requestSpecification = RestAssured.given().log().all();
if (queryParams != null && !queryParams.isEmpty()) {
queryParams.forEach(requestSpecification::queryParam);
}
return requestSpecification
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().get(path, pathParams)
.then().log().all()
.statusCode(statusCode)
.extract();
}
protected ExtractableResponse<Response> post(String path, Object body, int statusCode, Object... pathParams) {
// 코드 생략
}
protected ExtractableResponse<Response> put(String path, Object body, int statusCode, Object... pathParams) {
// 코드 생략
}
protected ExtractableResponse<Response> delete(String path, int statusCode, Map<String, ?> queryParams, Object... pathParams) {
// 코드 생략
}
API마다 path variable, quey parameter 존재 여부가 다르다. 3주차에서 header 정보까지 추가됨에 따라 메소드의 인자에 header 값을 추가해야 했다. 문제는 의존하고 있는 모든 API를 수정해야 했고, header 값이 필요 없는 API는 null 혹은 default 값을 전달해야 했다.
따라서, http mehtod가 아닌 도메인별로 rest assured를 분리 하였다.
public class FavoriteSteps {
public static FavoriteRequestBuilder 즐겨찾기_요청을_구성한다() {
return new FavoriteSteps().new FavoriteRequestBuilder();
}
public class FavoriteRequestBuilder {
private RequestSpecification spec;
private String accessToken;
private FavoriteCreateRequest body;
private int statusCode = OK.value();
public FavoriteRequestBuilder() {
this.spec = RestAssured.given().log().all();
}
public FavoriteRequestBuilder 로그인을_한다(String accessToken) {
this.accessToken = accessToken;
return this;
}
public FavoriteRequestBuilder Response_HTTP_상태_코드(int statusCode) {
this.statusCode = statusCode;
return this;
}
public FavoriteRequestBuilder 즐겨찾기_생성_정보를_설정한다(Long source, Long target) {
this.body = new FavoriteCreateRequest(source, target);
return this;
}
public ExtractableResponse<Response> 즐겨찾기_생성_요청을_보낸다() {
setAuthorization();
return this.spec.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(this.body)
.when().post("/favorites")
.then().log().all()
.statusCode(statusCode)
.extract();
}
public ExtractableResponse<Response> 즐겨찾기_조회_요청을_보낸다() {
setAuthorization();
return this.spec.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().get("/favorites")
.then().log().all()
.statusCode(statusCode)
.extract();
}
public ExtractableResponse<Response> 즐겨찾기_삭제_요청을_보낸다(String uri) {
setAuthorization();
return this.spec.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().delete(uri)
.then().log().all()
.statusCode(statusCode)
.extract();
}
private void setAuthorization() {
if (this.accessToken != null && !this.accessToken.isEmpty()) {
this.spec.header(AUTHORIZATION, "Bearer " + this.accessToken);
}
}
}
}
builder를 이용하여 필요한 데이터만 세팅할 수 있도록 구성하였다.
아래와 같이 사용하면 된다.
private ExtractableResponse<Response> 즐겨찾기_생성_요청(int statusCode, long source, long target) {
return 즐겨찾기_요청을_구성한다()
.로그인을_한다(accessToken)
.Response_HTTP_상태_코드(statusCode)
.즐겨찾기_생성_정보를_설정한다(source, target)
.즐겨찾기_생성_요청을_보낸다();
}
@Test
void 실패_즐겨찾기_삭제시_즐겨찾기가_존재하지_않을_경우_즐겨찾기를_삭제할_수_없다() {
String message = 즐겨찾기_요청을_구성한다()
.로그인을_한다(accessToken)
.즐겨찾기_삭제_요청을_보낸다("/favorites/1")
.as(ExceptionResponse.class).getMessage();
assertThat(message).isEqualTo("즐겨찾기가 존재하지 않습니다.");
}
수정하고 나니 rest assured 문법은 중복해서 사용하지만, 재사용 측면에서 훨씬 좋아졌고 의존성도 줄일 수 있어 나름 뿌듯하다. 😊
Service간의 순환 참조를 막기 위해 의존성 흐름을 Service -> Repository와 같이 단방향으로 구성했다.
public class FavoriteService {
private final FavoriteRepository favoriteRepository;
private final FavoriteRepository favoriteRepository;
private final MemberRepository memberRepository;
아래와 의견을 나눈 전문이다.
💡
리뷰어님: MemberService 보다 Repository 를 참조하시는 이유가 있을까요? 😁
개인적인 견해로는 즐겨찾기에 대한 요구사항이 증가할 수록 Repository와 Service 의 중복로직이 생길 가능성이 많고 MemberService 를 참조할 일이 생기면 순환참조의 가능성도 있을거 같은데 어떻게 생각하시나요? 🤔
나: Service간의 순환 참조를 하지 않는다는 규칙을 가지고 로직을 구현했습니다!! 만약에 중복 로직이 생긴다면 그때 Service와 Repository 간의 계층을 하나 더 만들어서 처리하려고 했습니다.
그래서 이번 단계에서 PathFinder를 만든 이유이기도 합니다.FavoriteService에서도 Path 객체가 필요하여FavoriteService --> PathFinder, PathService --> PathFinder위와 같이 Path 객체를 가져와서 메서드를 사용하고 있습니다.
처음부터 계층을 나눌까 하다가 클래스가 너무 많이 늘어나는 것 같아서 처음부턴 계층을 나누진 않았습니다!!
현성님은 어떻게 처리하시는지 궁금합니다!!
리뷰어님: PathFinder 를 만드신 건 잘하신 것 같습니다! 👍
제가 우려했던 점은 Repository를 다른 Service에서 사용할 수록 의존성이 높아지는 점과 서비스간의 순환참조는 피해야되는 점이지만 즐겨찾기의 요구사항 변화에 따라서 MemberRepository 의 변경이 일어나는 경우는 없어야 된다고 생각해서 말씀드렸습니다.
저 역시도 중복로직이 많아진다면 클래스를 분리하는 편입니다!
PahFinder를 만든 이유는 다음과 같다.
FavoriteService에서 데이터 검증을 위해 Path 객체가 필요 했다. Path는 PathService를 통해서 조회 할 수 있었다.
FavoriteService에서 PathService를 참조하면 빨강색 화살표와 같이 순환 참조의 위험성이 존재한다.
따라서 아래와 같이 풀었다.
Path 객체를 구하는 메소드를 PathFinder 안으로 옮겼다. 그리고 FavoriteService와 PathService는 PathFinder에서 Path 객체를 구할 수 있다. PathFinder는 Repository를 의존한다.
즉, Service와 Repository 사이에 계층을 하나더 만들어서 해결하였다.
리뷰어님의 의견은 FavoriteService와 MemberService에서 MemberRepository를 참조하고 있을때, FavoriteService의 변경사항에 따라서 MemberRepository에 변경이 일어나는 경우를 우려하신 것 같다.
그렇다면 MemberRepository가 아닌 MemberService를 의존해야 하는데, 순환참조 가능성이 생기는게 아닌가? 라는 의구심이 들었다.
나의 생각은 Service 간의 의존은 피해야 하며 만약, 다른 Service에 의존할 경우가 생기면 계층을 하나더 만들어서 풀면 되지 않을까 생각한다.
repository 계층에서 데이터가 없으면 바로 예외를 던지도록 구현했다.
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
default Favorite getBy(Long id) {
return findById(id).orElseThrow(() -> new ApplicationException(
"즐겨찾기가 존재하지 않습니다."));
}
아래와 같은 피드백을 받았다.
아래와 의견을 나눈 전문이다.
💡
리뷰어님: 예외 처리를 잘해주셨네요! 하지만 예외에 대한 처리는 Repository 에서 해야되는 역할인지는 생각해 봐야될 것 같습니다. 😁
나: 초기에는 Service에서 예외처리를 처리하였습니다! 그런데 Service에서 로직의 흐름을 직관적으로 보기 위해 예외처리는 repository로 숨겼습니다! Service와 Repository 사이에 계층을 하나더 만들어(FavoriteQuery 등) 처리하는게 좋았을까요?
리뷰어님은 어느 레이어에서 예외처리를 하시는지 궁금합니다!
리뷰어님: 제가 생각하기에는 비즈니스 로직상의 에러처리는 Service 에서 명시적으로 해야 흐름을 파악하기 더 쉽다고 생각했는데요! 다른 로직 상의 에러처리 (예를 들면 동일한 구간이 존재하면 구간 등록을 하지 못한다) 는 서비스에 존재할 수 밖에 없는 경우가 있어서 에러처리가 혼재 될 수도 있을거 같습니다. 제 개인적인 견해이니 참고해주시면 감사하겠습니다 😁
당시에는 서비스단에서 예외처리 로직을 repository로 숨겨 흐름을 파악하는데 용이함이 목적이였다.
하지만, 지금 생각해보면 서비스 계층에 Optional로 넘기고 Service 구간에서 필요시 예외처리를 하는게 로직의 유연함이 증가하는 것 같다. 만약 조회하지 못했을 경우 예외처리가 아닌 빈값을 반환해야 하는 경우는 어떻게 할 것인가? 위와 같은 상황으로는 Optional을 넘겨주는 메서드를 별도로 만들어야 한다.
하지만 서비스마다 예외처리를 진행하면 예외처리가 흩어져 있어 메시지를 모든 곳에서 변경을 해줘야 한다.
커스텀 예외 클래스를 별도로 만들어서 해당 클래스 내에서 관리하던가 혹은 별도의 enum을 만들어 예외처리 메세지를 관리하는 것도 방법 인것 같다.
깃허브 로그인 API를 인수 테스트를 진행해야 했다.
클래스 혹은 API 호출 라이브러리를 모킹할 수 있지만, 인수 테스트의 목적에 맞게 mock 서버 역할을 하는 controller를 직접 개발하여 구현하였다.
프로덕션과 테스트 내 흐름은 다음과 같다.
프로덕션
프로덕션에서 사용하는 yml에 실제 깃허브 API URL을 정의한다.
깃허브 외부 API 호출 정보는 스프링의 yml 파일을 읽어와 처리해준다.
테스트
테스트에선 직접 만든 컨트롤러를 호출해야 한다. 테스트에서 사용하는 yml에 url을 로컬로 정의한다.
테스트 내에서 스프링 컨텍스트를 하나더 띄어야 하기 때문에 포트를 프로덕션과 다른 포트를 정의해 준다.
server:
port: 8081
github:
client:
id: test_id
secret: test_secret
url:
access-token: http://localhost:8081/github/login/oauth/access_token
email: http://localhost:8081/github/email
application-test.yml
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
public class GithubClientTest {
@Autowired
private GithubClient githubClient;
test.yml에 정의한 포트대로 실행하기 위해 DEFINED_PORT로 정의한다.
@RestController
public class GithubTestController {
@PostMapping("/github/login/oauth/access_token")
public ResponseEntity<GithubAccessTokenResponse> accessToken(@RequestBody GithubAccessTokenRequest tokenRequest) {
GithubResponses responses = findByCode(tokenRequest.getCode());
GithubAccessTokenResponse response = new GithubAccessTokenResponse(responses.getAccessToken(), "", "", "");
return ResponseEntity.ok(response);
}
@PostMapping("/github/email")
public ResponseEntity<List<GithubEmailResponse>> user(@RequestHeader("Authorization") String authorization) {
String accessToken = authorization.split(" ")[1];
GithubResponses responses = findByAccessToken(accessToken);
GithubEmailResponse response = new GithubEmailResponse(responses.getEmail(), true, true, "");
return ResponseEntity.ok(List.of(response));
}
}
테스트내에 직접 정의한 컨트롤러다. 요청값에 맞춰 반환값을 던지도록 구현하면 테스트를 쉽게 작성할 수 있다.
요약하면 다음과 같다.
1. 테스트에선 프로덕션 포트를 다르게 정의하고 깃허브 API URL을 로컬 혹은 mock 서버의 URL로 등록
2. 외부 API 호출하는 테스트에선 @SpringBootTest의 port 옵션을 DEFINED_PORT로 정의
3. mock 역할을 하는 controller을 테스트내에서 만들기
이번 단계 요구사항은 모듈간 의존성을 제거하는 것이였다..
의존성을 이렇게도 제거할 수 있구나 있구나 라는 것을 깨달았다.
패키지 구조는 아래와 같이 되어 있다.
auth 모듈에선 토큰 생성이나 깃허브 로그인을 수행 하고, member 모듈에선 회원 디비조회를 조회하거나 생성하는 역할을 수행한다.
따라서 TokenService -> MemberService를 의존하고 있다.
이번 요구사항은 auth -> member 의존성을 제거하는 것이였다.
인터페이스와 다형성을 적용하여 다음과 같이 패키지를 구성하였다.
auth모듈의 TokenService와 member 모듈의 구현체 모두 인터페이스를 의존하고 있다.
이는 구체화에 의존하지 않고 추상화에 의존하는 DIP(의존성 역전 원칙)을 적용한 것이다.
( member 모듈은 의존성을 받고 있다가 의존성이 역전되었다.)
대략적인 코드는 아래와 같다.
1. auth 패키지에 인터페이스를 만들어 준다. UserDetails를 독립된 클래스로 만들었는데, UserDetails를 인터페이스로 두고 Member를 구현체로 하는 식으로 해도 된다.
public interface UserDetailsService {
UserDetails findMemberByEmail(String email);
UserDetails findOrCreate(String email);
}
public class UserDetails {
private Long id;
private String password;
private String email;
}
2. member 구현체에서 UserDetailsService를 구현하는 UserDetailsServiceImpl을 만들어 준다.
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails findMemberByEmail(String email) {
}
@Override
public UserDetails findOrCreate(String email) {
}
}
3. TokenService는 같은 모듈의 UserDetailsService를 의존하여 원하는 데이터를 받는다
public class TokenService {
private final UserDetailsService userDetailsService;
public TokenResponse createToken(String email, String password) {
UserDetails userDetails = userDetailsService.findMemberByEmail(email);
패키지 의존성 해결에 관련해서 추가적인 내용은 아래 영상을 추천한다
https://www.youtube.com/watch?v=dJ5C4qRqAgA&t=2941s
'외부활동 > ATDD, 클린 코드 with Spring 8기' 카테고리의 다른 글
ATDD, 클린 코드 with Spring 8기 종료 (모든 미션 수료) (0) | 2024.03.06 |
---|---|
인수 테스트와 리팩터링 회고 (0) | 2024.03.05 |
인수 테스트와 TDD 회고 (0) | 2024.03.05 |
인수 테스트와 E2E 테스트 회고 (0) | 2024.03.05 |
ATDD, 클린 코드 with Spring 8기 시작! (2) | 2024.01.23 |