항해99 플러스 백엔드 Chapter2(3,4,5주차) 회고 및 WIL
3,4,5주차에 걸쳐 Chapter2를 진행했다.
이번 회고에서는 주차별로 어떤 과정을 거쳤고, 어떤 문제점을 겪고 해결했는지를 정리해 보려고 한다.
3주차
3주차에는 서비스 시나리오를 선택하는 과정부터 시작했다. 나는 콘서트 예약 서비스를 선택했는데, 회사에서 Redis를 쓰지 않는 상황이고, Redis를 토이프로젝트로만 경험해보았기 때문에 이번 기회에 대기열 시스템에 대해 학습 겸, Redis를 제대로 공부해보고 싶어서 이 시나리오를 골랐다.
시나리오를 정한 후 프로젝트의 MileStone을 구축했다. 마일스톤을 구축하기 위해 Github Roadmap 기능을 활용했는데, 이처럼 유용한 기능이 숨어있었다니.. 이제야 알게 된 것이 너무 아쉬웠다. (회사에서는 애석하게도 엑셀로 WBS를 작성한다.. 나도 지라 쓰고 싶다.. )
위처럼 설정할 수 있는 로드맵 기능 덕분에 프로젝트 진행을 시각적으로 정리하기 편리했다. 항플 끝나고 토이프로젝트를 하게 될 경우에 깃허브 마일스톤으로 기깔(?)나게 짜봐야 겠다.
다음으로는 시퀀스 다이어그램을 작성했다.
학부 시절에 한번 만들어본게 전부였는데, 이번에 다시 그려보니 생각보다 익숙하게 느껴졌다. 앞으로 실무에서도 시퀀스 다이어그램을 자주 그려봐야 겠다고 생각했다. 개발할 때 도움이 많이 될 것 같다.
3주차의 마지막으로는 DB설계(ERD작성) 과 API 명세 작업, MockAPI 구현도 진행했다. 이것도 마찬가지로 학부시절 잠깐 경험해본 정도였지만, 이번에는 직접 ERD를 작성하고 프로젝트에 적용했다. 오랜만에 하는 작업이라 기억을 더듬으면 진행했는데, 꽤 어렵게 다가왔다. 공부 시간을 많이 투자했음에도 아직 어색한 부분이 많았다. 처음 사용해본 머메이드라는 툴은 정말 유용했다. 마크다운에 삽입할 수도 있고, 무려 ERD, FlowChart, Sequence Diagram 등등 많은 것을 코드로 작성할 수 있었다.
3주차는 외부 도구에 익숙해지면서 데이터베이스와 시나리오를 설계하는 시간을 가질 수 있어서 매우 유익했고, 실무에서 바로 응용해볼 수 있을 것 같아 좋았다.
4주차
4주차에는 각 시나리오를 개발하는 작업에 집중했다. Swagger를 활용해 API문서를 작성했고, 각 비즈니스 유스케이스를 개발하고 통합 테스트를 작성하는 것이 주요 과제였다.
Swagger는 기존에는 컨트롤러에 @Operation같은 어노테이션들이 덕지덕지 붙어있었는데, 같이 공부하는 어떤 고마우신 분(?)께서 인터페이스를 만들어 어노테이션들만 따로 분리시킬 수 있다는 꿀팁을 주셨다!!
그래서 바로 적용을 해 보았는데 역시나, 너무 깔끔하다!!!
비즈니스 유스케이스를 개발 중, 6기 사람들은 정말 다양한 방식으로 개발하는 것을 보았다.
한 분은 CQRS패턴으로, Command와 Query로 구분하여 서비스를 분리하신 분도 계셨고, 퍼사드 패턴을 사용하지 않으시고 서비스를 application계층으로 사용하신 분도 계셨다.
그리고, 3주차 때의 코치님이신 R코치님께서는 실무에서 퍼사드 계층을 사용하지 않으시고, 서비스를 application계층으로 사용하시면서, 유스케이스별로 서비스를 만들어, dto를 해당 서비스의 inner class로 구현하신다고 하셨다.
내가 사용한 방법은 퍼사드 패턴을 사용해서 이를 application계층으로 사용하고, service가 도메인 계층에 존재하면서, Jpa Entity를 도메인 객체로 사용하는 방법이다.
(2주차 때는 심각한? 실수를 해버려서 fail을 받았다.. Jpa Entity를 도메인 객체로 사용하면서, Entity가 infra계층에 존재하게 했으니.. fail받을 만 하다..)
낙관적 락에 대한 추가적인 공부
이번 주차에 동시성도 구현을 했는데, 좌석 조회에는 낙관적 락을, 유저의 잔액 조회에는 비관적 락을 적용했다.
그런데 의아한 점이 하나 있었다.
처음에는 Entity에 @Version과 조회 쿼리에 @Lock(LockModeType.OPTIMISTIC)를 둘다 넣어서 낙관적 락을 구현하였는데, 테스트 용으로 @Lock(LockModeType.OPTIMISTIC)을 제거했는데도 동시성 대응이 된다는 것이었다.
https://velog.io/@lsb156/JPA-Optimistic-Lock-Pessimistic-Lock
JPA의 낙관적 잠금(Optimistic Lock), 비관적 잠금(Pessimistic Lock)
요청이 많은 서버에서 여러 트랜잭션이 동시에 같은 데이터에 업데이트를 발생시킬 경우에 일부 요청이 유실되는 경우가 발생하여 장애로 이어질 수 있습니다. 이를 위해 동시 읽기/업데이트
velog.io
이 두개는 적용 범위와 방식이 다르다고 한다.
@Version은 엔터티 레벨에서 버전을 관리하면서 자동으로 동작하는데, @Lock은 쿼리 실행 시점에 락 모드를 명시적으로 지정한다는 것이다.
그래서 JPA의 낙관적 락에 대해 공부를 해 보았는데, 아래와 같이 요약할 수 있었다.
- @Version : 단순히 데이터 충돌만 방지하고 싶다면 @Version만 사용한다. 엔터티의 변경 시점에 알아서 버전 충돌을 처리한다.
- @Lock(OPTIMISTIC_WRITE) : 특정 쿼리에서 명확히 락을 걸어야 하는 상황에서 사용. 예를 들어 동시성이 높은 서비스에서 데이터 충돌 가능성이 높을 때 조기에 쓰기 락을 걸고 싶을때 활용한다.
결론적으로는 단순한 엔터티 변경 충돌 관리에는 @Version, 특정 쿼리에서 동시성을 더 세밀하게 제어하고 싶다면 @Lock을 함께 사용한다고 한다.
추가적으로, 낙관락에 재시도 매커니즘까지 적용하면 더욱 좋은 성능을 확보할 수 있다고 한다. 이 부분에 대해서 시도하려고 했지만 4주차는 시간이 너무 부족했다.. ㅠㅠ 남은 주차 중에 시간이 된다면 꼭 한번 시도해보고 싶다.
4주차는 정말 힘든 한 주였다. 개발에 몰두하다 보니 밤을 새우는 날이 많았고, 밤새서 회사 출근한 적도 두번 있었다. 클린 아키텍처를 적용하면서 개발하는 것이 생각만큼 쉽지 않았다. 퍼사드 계층을 제대로 활용한 것도 처음이었고, Layer별로 DTO를 구현해서 사용하는 것도 처음이었고, DIP를 준수하는 방식으로 개발한 것도 이번이 처음이었다. 이를 통해 그동안의 개발 방식이 얼마나 비체계적이고 비효율적이었는지 확실히 깨닫게 되었다.. 이전에는 그저 되는대로 동작만 하면 되는거지 라는 생각으로 개발했다는 반성도 하게 되었다.
5주차
5주차에는 대기열 검증 로직을 인터셉터로 이관했다. 이와 함께 전역적인 예외 처리와 로깅 필터 처리도 구현했다.
그 전에, K코치님께서 4주차 피드백을 해주셨는데, 피드백 내용은 요약하자면 아래와 같았다.
첫 번째 피드백은 스케줄러가 끝났을 때 성공 로그를 나타냄으로서 해결하였고,
두 번째 파사드 계층 분리 피드백은 아래와 같이 분리하여 해결하였다.
세 번째 피드백은 파사드에서 여러 개의 서비스를 역할별로 사용하도록 나누어 구현하였고,
네 번째 피드백은 유저를 찾지 못했을 경우에는 error로그만 나타내고, Exception처리하지 않도록 코드를 수정하여 해결하였다.
ContentCachingWrapper 문제
로깅 필터에서는 이번 주차 멘토링때, 코치님께서 ContentCaching(Request/Response)Wrapper를 알려주셔서 그 부분에 대해 적용을 해보았다.
이러한 로깅 필터를 구현 중에 한가지 문제점에 직면했다. 바로 RequestBody/ResponseBody가 로깅되지 않는다는 점이었다.
이에 대해 분명히 ContentCaching(Request/Response)Wrapper의 동작 방식을 내가 이해 못하고 있다고 판단해서, 관련 블로그와 레퍼런스를 뒤져보았다.
https://velog.io/@0_0_yoon/ContentCachingRequestWrapper-ContentCachingResponseWrapper-wxndicpk
ContentCachingRequestWrapper, ContentCachingResponseWrapper
문제상황 인터셉터에서 로깅 할 때 Requset, Response 의 Body 가 출력되지 않았다. 원인 먼저 ContentCachingRequestWrapper, ContentCachingResponseWrapper 를 사용한 이유는 요청, 응답의 본문 내용(In
velog.io
https://velog.io/@dlwlrma/%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90-ContentCachingRequestWrapper
알고 쓰자 - ContentCachingRequestWrapper
아마도 Spring boot 환경에서 Request, Response Logging을 하기 위해 Google 검색을 한다면 대부분의 글에서 InputStream은 1번만 읽을 수 있으니 이미 캐싱 기능이 만들어져있는 ContentCachingRequestWrapper를
velog.io
기존에 사용하던 InputStream, OutputStream으로 사용하던 경우에는, 한번 읽고 쓰게되면 다시는 읽을 수 없게 된다는 점이 있기 때문에 ContentCaching~~ 이 기능을 사용하게 되었다고 한다.
위 레퍼런스에 따르면, ContentCachingRequestBody가 호출되기 전에 사용(dofilter)되어버리면, 더이상 읽을 수 없다고 한다.
그리고, 이미 응답해 버리면, ContentCachingRequsetBody에서 더 이상 읽을 수 없다고 한다.. (왜 이렇게 복잡한 건지..)
그래서 로깅하는 메서드와, 응답 내용을 클라이언트로 전달하는 메서드의 위치를 적절히 조절해보았다.
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(
(HttpServletRequest) request);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(
(HttpServletResponse) response);
try {
chain.doFilter(wrappedRequest, wrappedResponse);
} finally {
loggingRequest(wrappedRequest);
loggingResponse(wrappedResponse);
// 응답 내용을 클라이언트로 전달해야 한다. 전달 이후에는 본문을 확인할 수 없다는 것이 특징.
wrappedResponse.copyBodyToResponse();
하지만 웬걸.. 이번에는 Response Header가 안뜨게 된다..
그래서 또 다른 레퍼런스들을 찾아보던 결과. 응답 헤더는 클라이언트로 응답한 이후에 읽을 수 있다고 한다...
그래서 아래와 같은 배열이 완성되었다 !
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(
(HttpServletRequest) request);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(
(HttpServletResponse) response);
try {
chain.doFilter(wrappedRequest, wrappedResponse);
} finally {
loggingRequest(wrappedRequest);
loggingResponse(wrappedResponse);
// 응답 내용을 클라이언트로 전달해야 한다. 전달 이후에는 본문을 확인할 수 없다는 것이 특징.
wrappedResponse.copyBodyToResponse();
// 응답 헤더는 전달 이후에 확인 가능하다.
loggingResponseHeaders(wrappedResponse);
}
}
Header를 로깅하는 메서드를 분리했다. 그래서 응답 이후에 헤더를 로깅하도록 구현했다.
이 문제를 정리하자면 다음과 같다.
ContentCachingRequestWrapper와 ContentCachingResponseWrapper는 각각 InputStream과 OutputStream을 재사용할 수 있도록 도와주지만, 적절한 시점에 적용되지 않으면 Body또는 헤더를 읽을 수 없다.
확실히 무언가를 쓰려면 알고 써야한다는게 맞는 말인것 같다..
인터셉터에서 어떻게 @Pathvariable 값을 가져올까
다음으로 직면했던 문제는 인터셉터에서 어떻게 @Pathvariable 값을 가져올 것이냐였다.
이 또한 레퍼런스를 찾아본 결과 아래와 같이 해결하였다.
@Override
@SuppressWarnings("unchecked")
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
log.info("QueueValidationForPayInterceptor preHandle processing...");
String queueToken = request.getHeader(QUEUE_TOKEN_HEADER);
// api로 도달 전에 검증해야 한다.
if (queueToken == null) {
throw new CustomGlobalException(ErrorCode.INVALID_QUEUE_TOKEN);
}
// 토큰 검증, reservationId를 가지고 올 수 있는 방법 찾아야 함.
// PathVariable 값 추출(SupressWarnings로 unchecked 경고제거..!)
Map<String, String> pathVariables = (Map<String, String>) request
.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
// API에서 체크하지만, 인터셉터에서 검증이 필요하므로 여기에서 먼저 체크헤야 한다.
if (pathVariables == null) {
throw new CustomGlobalException(ErrorCode.BAD_REQUEST);
}
Long reservationId = Long.parseLong(pathVariables.get("reservationId"));
queueFacade.queueValidationForPay(VerifyQueueForPay.builder()
.queueToken(queueToken)
.reservationId(reservationId)
.build()
);
return true;
}
또 중요한 작업이었던 동시성 통합 테스트도 작성했다. 동시성 테스트를 진행하면서 이 작업이 얼마나 중요한지 실감할 수 있었다.
실무에서는 테스트 코드를 자주 작성하지 않았는데, 이번 경험을 통해 꾸준히 테스트 코드를 작성하는 습관을 들여야겠다는 생각이 들었다.
남은 5주 동안도 배운 것들을 꼼꼼하게 적용하고, 더 열심히 노력해야겠다고 생각했다.