개요
지난 3주간 팀원들과 함께 에브리타임 클론코딩, 일명 코어타임 백엔드 REST API 서버 구현 프로젝트를 진행했다. 첫 주는 에브리타임 기능 분석 및 DB 설계, REST API 명세 작성과 코딩 및 커밋 컨벤션 그리고 작업할 레포지토리를 생성했다. 이후 두 주간은 앞서 정한 내용들에 맞춰 개발을 진행했다. 개발해야 할 내용이 제법 많았지만, 팀원이 5명이나 되었고 모두 열심히 참여해준 덕에 당초 MUST HAVE로 기획한 기능들에 대해서는 구현이 거의 완료되었다. 본인은 비교적 기능이 명확한 Post(게시글) 도메인 관련 API 개발을 담당했고 최근 학교 캡스톤 디자인 과목에서 스프링 서버를 개발한 경험이 있으므로 쉽게 진행할 것이라 예상했다. 그러나 의외로 해맸던 부분이 많았고 매일 스크럼을 진행하며 다른 팀원들로부터 여러 정보를 얻는 둥 새로 알게된 내용이 굉장히 많은 과정이었다. 그러한 내용들에 맞춰 회고를 작성해보려 한다.
개발 회고
팀원 도움 - Exception Handling
// 405 : Method Not Allowed @ExceptionHandler(HttpRequestMethodNotSupportedException.class) protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException( HttpRequestMethodNotSupportedException e) { log.error(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } // 400 : MethodArgumentNotValidException @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException( MethodArgumentNotValidException e) { log.warn(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } // 400 : MethodArgumentType @ExceptionHandler(value = MethodArgumentTypeMismatchException.class) public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException( MethodArgumentTypeMismatchException e) { log.error(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_TYPE_VALUE); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } // 400 : Bad Request, ModelAttribute @ExceptionHandler(org.springframework.validation.BindException.class) public ResponseEntity<ErrorResponse> handleBindException(BindException e) { log.warn(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity<ErrorResponse> handleHttpMessageNotReadableException( HttpMessageNotReadableException e) { log.warn(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) { log.warn(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity<ErrorResponse> handleNoHandlerFoundException(NoHandlerFoundException e) { log.warn(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.NOT_FOUND); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); } @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity<ErrorResponse> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { log.warn(e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.MISSING_REQUEST_PARAMETER); return ResponseEntity.status(errorResponse.getStatus()).body(errorResponse); }
Global Exception Handler를 두어 Exception을 전역을 처리하는 방식은 내가 원래 즐겨 사용하던 Exception Handling 방식이었지만 팀원들과 함께 사용하며 배운 점이 제법 있다. 우선 개인적으로 사용할 때에는 Custom Exception 를 처리하는데 집중을 했기에 Jackson Mapping 실패 시 발생하는 예외나 파라미터가 Mapping되지 않을 때, Validated 어노테이션에서 Validation이 실패 했을 때 등의 경우에서 발생하는 에러를 따로 처리해주지 않았었다. 따라서 각 경우에 어떠한 Exception이 발생하는지 파악해보려 했으나 다른 여러 일들 때문에 미루고만 있는 상황이었다. 그러나 이번 프로젝트에서 팀원들이 해당 예외들에 대한 Exception Handler를 Global Exception Handler 내에 추가를 해주셨고, 이를 계기로 혼자 Exception을 찾아보며 공부하는 것에 비해 시간을 단축해 학습할 수 있었다.
팀원 도움 - AOP
@Slf4j @Aspect @Component @Order(2) @RequiredArgsConstructor public class PerformanceAspect { private final ObjectMapper objectMapper; private static final String PERFORMANCE_FORMAT = "Perform : {}"; private static final double MILLI_TO_SECOND_UNIT = 0.001; private static final double MAX_PERFORMANCE_TIME = 3; @Around("execution(* com.prgrms.coretime..controller.*Controller.*(..))") public Object printLog(ProceedingJoinPoint joinPoint) throws Throwable { HttpServletRequest request = ( (ServletRequestAttributes) Objects.requireNonNull( RequestContextHolder.getRequestAttributes()) ).getRequest(); long startTime = System.currentTimeMillis(); Object proceed = joinPoint.proceed(); long endTime = System.currentTimeMillis(); double elapsedTime = (endTime - startTime) * MILLI_TO_SECOND_UNIT; Map<String, Object> map = new LinkedHashMap<>(); map.put("method", request.getMethod()); map.put("uri", request.getRequestURI()); map.put("time", elapsedTime + "s"); String resultJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(map); if (elapsedTime > MAX_PERFORMANCE_TIME) { log.warn(PERFORMANCE_FORMAT, resultJson); return proceed; } log.info(PERFORMANCE_FORMAT, resultJson); return proceed; } }
@Slf4j @Aspect @Component @Order(2) @RequiredArgsConstructor public class LoggingAspect { private final ObjectMapper objectMapper; private static final String LOG_FORMAT = "REQUEST : {}"; private static final String ERROR_LOG_FORMAT = "API 로그를 생성하는 과정에서 문제가 발생하였습니다."; @Around("execution(* com.prgrms.coretime..controller.*Controller.*(..))") public Object printLog(ProceedingJoinPoint joinPoint) throws Throwable { HttpServletRequest request = ( (ServletRequestAttributes) Objects.requireNonNull( RequestContextHolder.getRequestAttributes()) ).getRequest(); log.info(LOG_FORMAT, geMetaData(request)); return joinPoint.proceed(); } private String geMetaData(HttpServletRequest request) throws JsonProcessingException { Map<String, Object> metaDataMap = new LinkedHashMap<>(); try { metaDataMap.put("ip", getClientIp(request)); metaDataMap.put("method", request.getMethod()); metaDataMap.put("uri", request.getRequestURI()); metaDataMap.put("params", getParams(request)); metaDataMap.put("body", getBody(request)); metaDataMap.put("time", new Date()); } catch (Exception e) { log.error(ERROR_LOG_FORMAT); } return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(metaDataMap); } private String getClientIp(HttpServletRequest request) throws JsonProcessingException { String ip = request.getHeader("X-Forwarded-For"); if (!StringUtils.hasText(ip) || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (!StringUtils.hasText(ip) || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (!StringUtils.hasText(ip) || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP-Client-IP"); } if (!StringUtils.hasText(ip) || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (!StringUtils.hasText(ip) || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } private String getParams(HttpServletRequest request) throws JsonProcessingException { return objectMapper.writeValueAsString(request.getParameterMap()); } private JsonNode getBody(HttpServletRequest request) throws IOException { ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request; return objectMapper.readTree(wrapper.getContentAsByteArray()); } }
이 역시 팀원들로부터 도움을 받은 내용인데, AOP를 배우기만 했었지 실제로 내 코드에 적용해 본 적은 없었다. 일단 Logging에 시간을 투자하지 않으려는 내 나쁜 습관 때문이었는데 이번 프로젝트에서 팀원분께서 잘 구현된 AOP 코드를 적용시켜 주셔서 AOP를 공부하는데 있어서 좋은 참고가 되었다.
개인 - 서브 쿼리 Pagination
@Query( value = "select p from Post p join fetch p.board join fetch p.user where p.id in (select distinct c.post.id from Comment c where c.user.id = :userId)", countQuery = "select count(p) from Post p where p.id in (select distinct c.post.id from Comment c where c.user.id = :userId)" ) Page<Post> findPostsThatUserCommentedAt(@Param("userId") Long userId, Pageable pageable);
‘내가 댓글 쓴 글 목록 보기’ API를 구현하기 위해 처음엔 위의 코드와 같이 서브쿼리를 이용했었다. 하지만 런타임 중 c.created_at 이 Invalid한 path라는 예외가 발생했는데, 원인을 추측하자면 설정한 pagination이 서브쿼리에도 적용이 되며 무엇인가 꼬여 발생한 에러 같았다. (pageable 객체에 sort가 created_at 기준으로 설정되어 있기 때문) 따라서 아래와 같이 쿼리를 두 번 날리는 것으로 문제를 해결했다.
@Query("select distinct c.post.id from Comment c where c.user.id = :userId") List<Long> findPostIdsThatUserCommentedAt(@Param("userId") Long userId); @Query( value = "select p from Post p join fetch p.board join fetch p.user where p.id in :postIds", countQuery = "select count(p) from Post p where p.id in :postIds" ) Page<Post> findPostsThatUserCommentedAt(@Param("postIds") List<Long> postIds, Pageable pageable);
다만, 서브쿼리 내에 comment 레코드에 대해서 sorting이 발생한다 하더라도 일반적이면 에러가 발생할 것 같지 않은데, comment의 created_at을 참조하는데 에러가 발생하는 이유에 대해서는 추가적인 원인 파악이 필요할 것 같다.
개인 - fetch join과 Pagination
toMany 관계에서 fetch join과 Pagination을 함께 사용하면 Pagination을 query 레벨에서가 아닌 어플리케이션 서버 레벨에서 진행하기에 성능이나 메모리 이슈가 생길 수 있다는 것을 지난 프로젝트를 통해 배웠으므로 이번에는 toOne 관계에서만 fetch join과 Pagination을 함께 사용하도록 유의하며 구현했다.
다만 이로 인해 Post 리스트 시 필요한 댓글 및 좋아요 수를 가져오기가 애매해졌는데, 이를 like_count 및 comment_count 칼럼을 추가해 해결했다. 그래도 정합성이 확실히 보장되지는 않기에 Join을 통해 좋아요 및 댓글 수를 가져올 수 있는 방법을 추가적으로 모색하면 좋을 것 같다.
개인 - Swagger
@Configuration public class SwaggerConfig { private final TypeResolver typeResolver = new TypeResolver(); @Bean public Docket apiV1() { return new Docket(DocumentationType.SWAGGER_2) // .alternateTypeRules(AlternateTypeRules.newRule(typeResolver.resolve(Pageable.class), // typeResolver.resolve(MyPageable.class))) .select() .apis(RequestHandlerSelectors.any()) .paths(PathSelectors.ant("/api/v1/**")) .build() .apiInfo(apiInfo()) .securitySchemes(List.of(apiKey())) .securityContexts(List.of(securityContext())); } private ApiInfo apiInfo() { return new ApiInfoBuilder() .title("Coretime API Docs") .description("Descriptions of Coretime API") .version("1.0") .build(); } private ApiKey apiKey() { return new ApiKey("JWT", "accessToken", "header"); } private SecurityContext securityContext() { return SecurityContext.builder().securityReferences(defaultAuth()).build(); } private List<SecurityReference> defaultAuth() { AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything"); AuthorizationScope[] authorizationScopes = new AuthorizationScope[1]; authorizationScopes[0] = authorizationScope; return List.of(new SecurityReference("JWT", authorizationScopes)); } }
이번 프로젝트에서 어쩌다 보니 스웨거 담당이 되었다. 그렇다고 엄청 디테일한 세팅까지 한 것은 아니고 간단한 Swagger 셋팅만을 진행했다. 여기서 두개의 이슈가 발생했다.
우선 다른 팀원분의 JWT 인증이 구현되고 나서 Swagger에서도 header에 access token을 실을 수 있어야 했는데 이를 구현하기 위해 defaultAuth, securityContext, apiKey 메소드를 구현해 주었다. 그럼에도 불구하고 header에 access token이 실리지 않는 문제가 발생했는데 이는 apiKey 생성자의 첫번째 파라미터인 name과 defaultAuth 내에 SecurityReference 생성자의 첫 번째 파라미터인 reference가 일치해야 했음을 간과했기 때문이었다. 이런 간단한 내용 때문에 상당히 많은 시간을 해맸는데 블로그나 레퍼런스의 코드를 대충 분석하고 그대로 적용한 내 잘못을 되돌아 보는 시간을 가지게 됐다.
다음으로 Swagger는 default로 controller 메소드의 파라미터로 있는 클래스(Pageable과 로그인 유저 정보를 포함해) 의 필드를 전부 입력할 수 있게 명세가 생성된다. 유저 정보 확인을 위한
JwtPrincipal
의 경우 값이 입력되어도 아무 영향이 없고 Pageable 의 경우 Interface이므로 실제 입력받는 필드값과 명세상 나와 있는 파라미터 값들이 다르다. 이러한 문제로 명세가 굉장히 혼잡해지고 오해를 불러 일으키기 쉬웠으므로 이를 해결할 방법을 찾아야 했다. 따라서 alternateTypeRules
를 이용해 특정 클래스를 내가 원하는 클래스로 바꿔 명세에 나타낼 수 있는 방법을 찾고 이를 적용시켰다. (위의 코드의 주석처리된 부분) 그러나 NumberFormatException이 발생했고 서버가 실행은 되었으나 명세에는 단순히 Pageable 이라고만 나오게 되는 (필드 수가 적어져서 혼란은 완화되긴 했지만) 문제가 생겼다. 이에 대해 원인을 분석하려 했으나 아직 밝혀내지 못했기에 계속 연구를 진행할 것 같다.개인 - S3 및 S3Mock
게시글에는 사진이 첨부될 수 있었기에 S3 와의 연동을 진행했다. S3 업로더 코드는 얻을 수 있는 자료도 많고 과정 역시 복잡하지 않아 쉽게 구현할 수 있었다. 다만 문제는 테스트에 있었는데, 매 테스트마다 실제 S3에 올라가는 것은 불필요하다 생각해 S3를 Mocking 할 수 있는 S3Mock 라이브러리를 찾고 이를 적용시키려 했다.
@TestConfiguration public class S3MockConfig { @Value("${cloud.aws.region.static}") private String region; @Value("${cloud.aws.s3.bucket}") private String bucket; @Bean public S3Mock s3Mock() { return new S3Mock.Builder().withPort(8001).withInMemoryBackend().build(); } @Bean(destroyMethod = "shutdown") @Primary public AmazonS3Client amazonS3Client(S3Mock s3Mock) { s3Mock.start(); AwsClientBuilder.EndpointConfiguration endpoint = new EndpointConfiguration("http://localhost:8001", region); AmazonS3Client client = (AmazonS3Client) AmazonS3ClientBuilder .standard() .withPathStyleAccessEnabled(true) .withEndpointConfiguration(endpoint) .withCredentials(new AWSStaticCredentialsProvider( new AnonymousAWSCredentials() )).build(); client.createBucket(bucket); return client; } }
다만 해당 Configuration을 작성하고 실제 코드에 적용했음에도 불구하고 실제 S3로 연동이 되는 것을 확인할 수 있었다.;;;;;;
main: allow-bean-definition-overriding: true
이 때 이미 Bean으로 등록되어 있는 Class를 Overriding하기 위해선 위와 같은 설정 추가가 필요했다.
개인 - MockMultipartFile
@Test @DisplayName("게시글 생성 및 상세 조회 테스트") public void testCreateAndGetPost(@Autowired AmazonS3Client amazonS3Client) { //Given List<MultipartFile> photos = List.of( new MockMultipartFile("test1", "test1.PNG", MediaType.IMAGE_PNG_VALUE, "test1".getBytes()), new MockMultipartFile("test2", "test2.PNG", MediaType.IMAGE_PNG_VALUE, "test2".getBytes()) ); PostCreateRequest createRequest = PostCreateRequest.builder() .content("내용") .title("제목") .isAnonymous(true) .photos(photos) .build(); PostIdResponse createResponse = postService.createPost(board1.getId(), user1.getId(), createRequest); Optional<Post> optionalPost = postRepository.findPostById(createResponse.postId()); assertThat(optionalPost.isPresent()).isTrue(); Post post = optionalPost.get(); for (int i = 0; i < 30; i++) { commentRepository.save(Comment.builder() .post(post) .content("댓글") .isAnonymous(true) .user(user2) .build()); } em.flush(); em.clear(); //When PostResponse response = postService.getPost(post.getId()); //Then assertThat(response.comments()).hasSize(20); assertThat(response.photos()).hasSize(2); amazonS3Client.shutdown(); }
이미지가 실제로 삽입되는지 테스트 하기 위해 MockMultipartFile을 이용했다.
@Test @DisplayName("게시글 생성 api 테스트") public void testCreatePost() throws Exception { //Given List<MockMultipartFile> photos = List.of( new MockMultipartFile("test1", "test1.PNG", MediaType.IMAGE_PNG_VALUE, "test1".getBytes()), new MockMultipartFile("test2", "test2.PNG", MediaType.IMAGE_PNG_VALUE, "test2".getBytes()) ); //When //Then mockMvc.perform(multipart("/api/v1/boards/{boardId}/posts", board.getId()) // .file("photos", photos.get(0).getBytes()) // .file("photos", photos.get(1).getBytes()) .param("title", "제목") .param("content", "내용") .param("isAnonymous", "true") .header("accessToken", accessToken) .contentType(MediaType.MULTIPART_FORM_DATA)) .andExpect(status().isCreated()) .andDo(print()); }
MockMvc를 이용한 컨트롤러 테스트에서는 사용할 수 없었는데 그 이유는 다음과 같다.
- multipart file을 첨부하는 file 메소드는 2개가 있다.
- mock multipart file 클래스 객체 하나를 파라미터로 받는 메소드
- string과 byte[]를 key value 파라미터로 받는 메소드
- 위의 메소드를 사용하면 create post request의 photos필드와 mapping이 되지 않기에 사용할 수 없었다.
- 아래의 메소드를 사용하면 file의 original name이 누락되어 s3 upload 과정에서 예외가 발생한다.
따라서 S3 uploader에서 original file name을 얻어올 수 없을 경우 random UUID를 생성하는 방법을 도입함으로써 컨트롤러 테스트에도 사용할 수 있게 수정했다.
결론
본론에 쓴 내용들 대로, 굉장히 많이 배울 수 있었던 프로젝트 경험이었다. 시간상 코드리뷰를 팀원들 간 원활하게 진행하지 못한 점은 아쉬웠지만 매일 진행했던 스크럼이 코드리뷰의 역할을 어느정도 대체해 준 것 같다. 앞서 언급했던 해결하지 못했던 내용들이나 다른 팀원들의 코드를 훑어보며 한번 더 복습을 진행하며 프로젝트를 잘 마무리할 생각이다.