[얼레벌레 인턴 홈페이지 개발] Refactoring, Error 해결 , 회고 (~ing)

Ogu·2024년 1월 12일
1

🧷 Refactoring

service, controller HTTP 메서드 순서 정렬

GET -> POST -> PUT -> DELETE 로 메서드 순서 정렬

왜 private 변수에 @Setter를 붙였니..

조회수 증가를 setView() -> article.invreaseViewCount() 로 변경

객체간 변환 메서드 클래스 분리

DTO는 데이터 전송의 역할만 하도록 그 외 로직은 분리하기로 결정하였습니다.

구글링과 깃허브에서 DTO가 DTO로서의 역할만 하는 코드를 찾아다녔는데, 전부 DTO안에 toEntity() 로직을 포함하고 있어 낙담하던 도중, 혜성과 같은 woowacourse-teams의 2021-pick-git 레포지토리를 참고하게 되었습니다.
https://github.com/woowacourse-teams/2021-pick-git

AddArticleRequest에서 toEntity() 를 분리하고,
service 로직의 findAll() 메서드 안에서 List<Article> <-> List<ArticleResponses> 객체 변환 로직을 포함했던 것을 분리하였습니다.

public class ArticleDTOFactory {

    private ArticleDTOFactory() {
    }

    public static List<ArticleResponse>  toArticleResponseFrom(List<Article> articles) {
        return articles.stream()
                .map(ArticleResponse::from)
                .toList();
    }

    public static Article toArticleFromAddRequest(AddArticleRequest addArticleRequest) {
        return Article.builder()
                .title(addArticleRequest.getTitle())
                .content(addArticleRequest.getContent())
                .view(1)
                .build();
    }

}

POST, PUT - 응답값에 변환된 데이터를 포함하는가? (~ing)

출근길에 커리어리에서 다음과 같은 포스팅을 보게 되었습니다.
POST 요청의 응답에 변경된 데이터를 포함하시나요? 라는 제목의 글이었습니다.
https://careerly.co.kr/qnas/5548

스프링부트 기초 도서를 참고해 프로젝트를 진행하고 있었는데, '어, 나는 POST, PUT 요청의 응답에 전부 포함했었는데?' 하고 떠올리며 또 깊은 생각과 고민에 빠졌습니다..

이것에 관한 이해는 참 여러 분야의 개념이 필요했습니다. 분산 DB, 동기/비동기, 지연로딩 등등..

주변분들에게 조언을 들으며 제가 내린 결론은 어디까지나 비즈니스 프로세스 위에서 필요로 하는 부분이지 현재 프로젝트와 같은 작은 모델에서 필수적인 방법은 아니다 라는 생각이 들었습니다.

findById() -> findByIdOrNull() 로 예외처리 깔끔하게 하기

기존의 코드는 다음과 같았습니다.

/* PUT) 게시글 상세 조회 및 조회수 1 증가 */
    @Transactional
    public Article findByIdAndIncreaseViewCount(long id) {
        Optional<Article> article = articleRepository.findById(id);
        if (article.isPresent()) {
            Article viewedArticle = article.get();
            viewedArticle.setView(viewedArticle.getView() + 1);
            articleRepository.save(viewedArticle);
            return viewedArticle;
        } else {
            throw new IllegalArgumentException("not found: " + id);
    }

어떻게 하면 예외 처리를 먼저 구성하고, 그 뒤에 본 로직이 나오게 할 수 있을까 고민하던 중 findById() -> findByIdOrNull() 를 통해 좀 더 깔끔하게 작성하도록 변경하기로 했습니다.

repository에 메서드 정의

default 키워드 공부 필요!

default Article findByIdOrNull(Long id) {
        Optional<Article> optionalArticle = findById(id);
        return optionalArticle.orElse(null);
    }

service 리팩토링 - service에서 예외처리 깔끔하게 하기

 /* PUT) 게시글 상세 조회 및 조회수 1 증가 */
    @Transactional
    public Article findByIdAndIncreaseViewCount(long id) {
		Article article = articleRepository.findByIdOrNull(id);

        if (article == null) {
            throw new IllegalArgumentException("Article with id " + id + " not found");
        }

        article.increaseView();
        return article;
    }

⛔ Error

조회수 증가

단일 게시글 조회시 조회수를 1 증가시키는 로직을 구현하게 되었습니다.
분명 조회의 성질을 띄는데, 수정으로 인한 데이터 변경점이 생깁니다.

그래서 @GetMapping 에서 @PutMapping 으로 변경을 하게 되었는데, 이렇게 변경했을 경우 에러가 발생했습니다.

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Ambiguous mapping. Cannot map 'articleApiController' method 
com.umust.umustbe.article.controller.ArticleApiController#updateArticle(long, UpdateArticleRequest)
to {PUT [/api/articles/{id}]}: There is already 'articleApiController' bean method
com.umust.umustbe.article.controller.ArticleApiController#findArticle(long) mapped.
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1768) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:596) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:518) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:973) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:950) ~[spring-context-6.0.15.jar:6.0.15]
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:616) ~[spring-context-6.0.15.jar:6.0.15]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) ~[spring-boot-3.1.7.jar:3.1.7]
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:746) ~[spring-boot-3.1.7.jar:3.1.7]
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:448) ~[spring-boot-3.1.7.jar:3.1.7]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:324) ~[spring-boot-3.1.7.jar:3.1.7]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1321) ~[spring-boot-3.1.7.jar:3.1.7]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1310) ~[spring-boot-3.1.7.jar:3.1.7]
	at com.umust.umustbe.UmustbeApplication.main(UmustbeApplication.java:10) ~[classes/:na]
Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'articleApiController' method 
com.umust.umustbe.article.controller.ArticleApiController#updateArticle(long, UpdateArticleRequest)
to {PUT [/api/articles/{id}]}: There is already 'articleApiController' bean method
com.umust.umustbe.article.controller.ArticleApiController#findArticle(long) mapped.
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry.validateMethodMapping(AbstractHandlerMethodMapping.java:667) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry.register(AbstractHandlerMethodMapping.java:633) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.registerHandlerMethod(AbstractHandlerMethodMapping.java:331) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.registerHandlerMethod(RequestMappingHandlerMapping.java:441) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.registerHandlerMethod(RequestMappingHandlerMapping.java:76) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.lambda$detectHandlerMethods$2(AbstractHandlerMethodMapping.java:298) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:721) ~[na:na]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.detectHandlerMethods(AbstractHandlerMethodMapping.java:296) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.processCandidateBean(AbstractHandlerMethodMapping.java:265) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.initHandlerMethods(AbstractHandlerMethodMapping.java:224) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.afterPropertiesSet(AbstractHandlerMethodMapping.java:212) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.afterPropertiesSet(RequestMappingHandlerMapping.java:225) ~[spring-webmvc-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1815) ~[spring-beans-6.0.15.jar:6.0.15]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1764) ~[spring-beans-6.0.15.jar:6.0.15]
	... 16 common frames omitted


종료 코드 1()로 완료된 프로세스

게시글 상세 조회 및 조회수 1 증가 로직

@Operation(summary = "게시글 상세 조회 및 조회수 1 증가", description = "id로 게시글을 상세 조회한다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ArticleResponse.class))),
            @ApiResponse(responseCode = "400", description = "Invalid ID supplied"),
            @ApiResponse(responseCode = "404", description = "Article not found")
    })
    @PutMapping("/articles/{id}")
    // URL 경로에서 값 추출
    public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id) {
        Article article = articleService.findByIdAndIncreaseViewCount(id);

        return ResponseEntity.ok()
                .body(ArticleResponse.from(article));
    }

게시글 수정 로직

@Operation(summary = "게시글 수정", description = "id로 게시글을 수정한다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = Article.class))),
            @ApiResponse(responseCode = "400", description = "Bad Request"),
            @ApiResponse(responseCode = "404", description = "Article not found"),
            @ApiResponse(responseCode = "500", description = "Internal server error",
                    content = {@Content(mediaType = "application/json",
                            schema = @Schema(implementation = ErrorResponse.class))})
    })
    @PutMapping("/articles/{id}")
    public ResponseEntity<ArticleIdResponse> updateArticle(@PathVariable long id,
                                                 @Valid @RequestBody UpdateArticleRequest request) {
        return ResponseEntity.ok(articleService.update(id, request));
    }

즉, 두 메소드가 동일한 URL을 매핑하는 경우가 되어버려서 오류가 생겼습니다.

1. HTTP 메소드 변경 - 조회와 조회수 증가를 GET으로 유지

조회 기능은 일반적으로 GET 메소드를 사용합니다. 따라서 조회와 조회수 증가 로직을 GET 메소드로 변경하고, 게시글 수정 로직은 PUT 메소드를 그대로 사용하는 것이 RESTful API의 관례에 가장 부합합니다. 그러나 이 경우, 조회 동작이 부수효과를 가지게 되므로 (조회수 증가) 순수한 GET 메소드의 성격에는 맞지 않습니다.

@GetMapping("/articles/{id}")
public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id) {
    // ...
}

@PutMapping("/articles/{id}")
public ResponseEntity<ArticleIdResponse> updateArticle(@PathVariable long id,
                                             @Valid @RequestBody UpdateArticleRequest request) {
    // ...
}

2. URL 변경

URL을 약간 변경하여 두 메소드가 서로 다른 URL을 매핑하도록 할 수 있습니다. 예를 들어, 조회수 증가 기능을 포함한 게시글 조회 URL에는 '/views'를 추가할 수 있습니다.

@PutMapping("/articles/{id}/views")
public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id) {
    // ...
}

@PutMapping("/articles/{id}")
public ResponseEntity<ArticleIdResponse> updateArticle(@PathVariable long id,
                                             @Valid @RequestBody UpdateArticleRequest request) {
    // ...
}

하지만 올바른 url 설계인가? 라는 관점에서 의문점이 생깁니다.

3. PATCH 메서드 사용

PATCH 메소드 사용: HTTP PATCH 메소드는 리소스의 부분적인 수정을 위해 설계되었습니다. 조회수 증가는 게시글 전체를 수정하는 것이 아니라 일부(조회수)만 수정하는 것이므로, PATCH 메소드를 사용하는 것도 고려해볼 만합니다.

@PatchMapping("/articles/{id}")
public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id) {
    // ...
}

@PutMapping("/articles/{id}")
public ResponseEntity<ArticleIdResponse> updateArticle(@PathVariable long id,
                                             @Valid @RequestBody UpdateArticleRequest request) {
    // ...
}

저는 이 세가지 방법 중, 단순 조회수 한 필드만 변경된다는 점을 고려하여 PATCH 메서드로 변경하기로 결정했습니다. GET, POST, PUT, DELETE 는 잘 쓰는데 PATCH는 잘 사용하지 않다 보니 쉽게 떠오르지 않는 것 같습니다.

profile
私はゲームと日本が好きなBackend Developer志望生のOguです🐤🐤

0개의 댓글