[Springboot] 스프링 초보 실전 가이드 (2): Service, Controller, Exception 구현 시 주의점

winluck·2023년 8월 24일
0

Springboot

목록 보기
5/18

지난 게시물에 이어 Service, Controller 각 계층과 Exception Handling에 대해 정리해보자.

Service

@Transactional

위 TagService는 태그의 생성, 삭제, 조회와 관련된 비즈니스 로직을 담당한다.

태그의 생성, 수정, 삭제와 같은 "조회가 아닌" 연산을 제어하는 메서드는 @Transactional를 붙여, 메서드 내에서 발생하는 모든 데이터베이스 연산을 하나의 트랜잭션으로 묶여서 원자적으로 처리한다. 메서드 실행이 성공하면 트랜잭션은 커밋되고, 예외가 발생하면 트랜잭션은 롤백되어 데이터의 일관성을 보장한다.

  • 원자적 처리: OS의 critical section을 떠올리면 쉽다. 특정 작업을 수행하고 있는 동안 다른 작업이 끼어들 수 없게 한다.

조회 메서드의 경우 @Transactional(readOnly=true)을 붙여 의도하지 않은 엔티티의 생성/수정/삭제를 막을 수 있고 조회 성능을 개선할 수 있다.

데이터의 유효성(중복 여부 확인 등)을 검사하는 로직은 따로 validateXXX 형태의 메서드로 따로 분리하는 것이 바람직하다.

orElseThrow 중복 제거

기본적으로 Repository 내부 findById 메서드의 반환 객체는 Optional<>로 감싸지며, 이는 데이터가 없을 때 NullPointerException을 발생시키지 않고 Optional.empty()를 반환한다.

반복되는 findBy 로직은 많은 orElseThrow()가 중복되므로 따로 Service 내부 private 메서드로 분리하는 것이 바람직할 것이다.

페이징

ArticleRepository 내부 메서드에 위처럼 페이징을 적용할 수 있다.
이후 클라이언트에게 전달할 Dto Page 객체를 이와 같은 형태로 반환할 수 있다.

물론 다른 페이징 방법도 있다. new PageImpl<>을 통해서도 페이징 처리가 가능하다.

JPA를 이용한 페이징으로 데이터를 가져오는 것은 한 번에 많은 데이터를 한 번에 가져올 때 발생할 수 있는 성능 문제를 해결 가능하지만, 작은 페이지를 여러 번 요청하게 되면, 각 페이지를 가져오기 위한 데이터베이스 연결 리소스가 추가적으로 소모된다. 이로 인해 전체적인 데이터 로딩 시간이 증가할 수 있다.

반면 한 번에 큰 단위의 데이터를 가져온 뒤 new PageImpl<> 등으로 메모리에서 페이징하는 방식은 한 번의 연결로 데이터를 가져올 수 있기 때문에 연결 리소스가 줄어든다. 그러나 데이터 양이 많을수록 메모리 사용량이 증가하여 이는 시스템 성능에 영향을 끼친다. Redis 등을 통한 캐싱이 필요한 부분이다.

따라서 Trade-Off이므로 상황에 따라 두 방식 중 하나를 채택하는 것이 바람직하다. 물론 @BatchSize 등을 활용한 최적화는 항상 필수다.

stream() 연산

1편에서 언급했다시피 단순 for문보다는 Java 8의 stream() 메서드를 활용해 Entity to Dto를 보다 깔끔하게 구현할 수 있다. 위 메서드는 User와 Tag의 다대다 관계로 인한 중간 테이블인 UserTag에서 특정 태그를 구독하는 유저 목록을 추출 기능을 수행한다. stream().map()을 통해 UserTag에서 User를 추출한 뒤, 이를 Dto로 수정하여 .collect()를 통해 컬렉션으로 변환하고 있다.

Exception

ResponseCode 열거형

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum ResponseCode {

    // 400 Bad Request
    BAD_REQUEST(HttpStatus.BAD_REQUEST, false, "잘못된 요청입니다."),
    NOTICE_TYPE_WRONG(HttpStatus.BAD_REQUEST, false, "잘못된 알림 타입입니다."),

    // 403 Forbidden
    FORBIDDEN(HttpStatus.FORBIDDEN, false, "권한이 없습니다."),

    // 404 Not Found
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."),
    NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, false, "알림을 찾을 수 없습니다."),
    ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, false, "게시글을 찾을 수 없습니다."),
    COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "댓글을 찾을 수 없습니다."),
    LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, false, "좋아요를 찾을 수 없습니다."),
    TAG_NOT_FOUND(HttpStatus.NOT_FOUND, false, "태그를 찾을 수 없습니다."),

    // 405 Method Not Allowed
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, false, "허용되지 않은 메소드입니다."),

    // 409 Conflict
    UNSUBSCRIBE_TAG(HttpStatus.BAD_REQUEST, false, "이미 구독하지 않은 태그입니다."),
    TAG_ALREADY_SUBSCRIBED(HttpStatus.CONFLICT, false, "이미 구독한 태그입니다."),
    TAG_ALREADY_EXISTS(HttpStatus.CONFLICT, false, "이미 존재하는 태그입니다."),
    USER_ALREADY_EXISTS(HttpStatus.CONFLICT, false, "이미 존재하는 사용자입니다."),
    LIKE_ALREADY_EXISTS(HttpStatus.CONFLICT, false, "이미 좋아요를 누른 게시글입니다."),
    LIKE_ALREADY_CANCELED(HttpStatus.CONFLICT, false, "이미 좋아요를 취소한 게시글입니다."),

    // 500 Internal Server Error
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."),

    // 200 OK
    USER_LOGINED(HttpStatus.OK, true, "로그인 되었습니다."),
    USER_FOUND(HttpStatus.OK, true, "사용자를 조회하였습니다."),
    TAG_FOUND(HttpStatus.OK, true, "태그를 조회하였습니다."),
    ARTICLE_FOUND(HttpStatus.OK, true, "게시글을 조회하였습니다."),
    COMMENT_FOUND(HttpStatus.OK, true, "댓글을 조회하였습니다."),
    LIKE_FOUND(HttpStatus.OK, true, "좋아요를 조회하였습니다."),
    NOTICE_FOUND(HttpStatus.OK, true, "알림을 조회하였습니다."),

    // 201 Created
    USER_CREATED(HttpStatus.CREATED, true, "사용자가 생성되었습니다."),
    TAG_CREATED(HttpStatus.CREATED, true, "태그가 생성되었습니다."),
    ARTICLE_CREATED(HttpStatus.CREATED, true, "게시글이 생성되었습니다."),
    COMMENT_CREATED(HttpStatus.CREATED, true, "댓글이 생성되었습니다."),
    LIKE_CREATED(HttpStatus.CREATED, true, "좋아요가 생성되었습니다."),
    NOTICE_CREATED(HttpStatus.CREATED, true, "알림이 생성되었습니다."),

    // 204 No Content
    TAG_SUBSCRIBED(HttpStatus.CREATED, true, "태그 구독이 완료되었습니다."),
    TAG_UNSUBSCRIBED(HttpStatus.CREATED, true, "태그 구독이 취소되었습니다."),
    NOTICE_READ(HttpStatus.CREATED, true, "알림을 읽었습니다."),
    USER_UPDATED(HttpStatus.CREATED, true, "사용자 정보가 수정되었습니다."),
    ARTICLE_UPDATED(HttpStatus.CREATED, true, "게시글이 수정되었습니다."),
    COMMENT_UPDATED(HttpStatus.CREATED, true, "댓글이 수정되었습니다."),
    TAG_UPDATED(HttpStatus.CREATED, true, "태그가 수정되었습니다."),
    ARTICLE_DELETED(HttpStatus.NO_CONTENT, true, "게시글이 삭제되었습니다."),
    COMMENT_DELETED(HttpStatus.NO_CONTENT, true, "댓글이 삭제되었습니다."),
    LIKE_DELETED(HttpStatus.NO_CONTENT, true, "좋아요가 취소되었습니다."),
    NOTICE_DELETED(HttpStatus.NO_CONTENT, true, "알림이 삭제되었습니다."),
    TAG_DELETED(HttpStatus.NO_CONTENT, true, "태그가 삭제되었습니다."),
    USER_DELETED(HttpStatus.NO_CONTENT, true, "사용자 정보가 삭제되었습니다."),
    USER_LOGOUT(HttpStatus.NO_CONTENT, true, "사용자가 로그아웃 되었습니다.");

    private final HttpStatus httpStatus;
    private final Boolean success;
    private final String message;

    public int getHttpStatusCode() {
        return httpStatus.value();
    }

}

ResponseCode 열거형을 도입하여, 다양한 API 통신 상황에서 발생하는 응답 코드 및 메시지를 정의한다. 열거형은 각 상황에 대한 응답 코드, 성공 여부, 메시지를 포함하고 있다.

이러한 구조를 채택하면 다음과 같은 강점이 있다.

  • 일관된 응답: 모든 API 응답에서 명확하고 표준화된 응답 형태를 유지한다.
  • 응답 코드 중앙 통제: 모든 응답 코드를 하나의 클래스에서 통제하므로 유지보수에 용이하다.
  • 가독성과 유지보수성 향상: 코드가 무엇을 의미하는지 알기 쉽고, 그냥 클래스에 Line만 추가하면 되기에 확장 및 변경이 쉽다.

Http Status Code 정리

성공 상태 코드는 보통 2XX이다.

  • 200 OK: 요청이 성공적으로 처리되었고 적절한 응답을 제공하였다.
  • 201 Created: 요청이 성공적으로 처리되어 새로운 리소스가 생성되었다. (주로 POST)
  • 204 No Content: 요청이 성공적으로 처리되었으나 응답으로 전달할 데이터는 없다. (주로 DELETE)

아래부턴 실패 상태 코드이다.

  • 400 Bad Request: 클라이언트의 요청이 잘못되었거나 유효하지 않음. 보통 요청 데이터의 형식이나 내용이 올바르지 않을 때 나타난다.
  • 403 Forbidden: 클라이언트에 요청한 리소스에 대한 권한이 없을 때 나타난다. 서버가 요청을 이해했으나, 게시물 삭제 요청 등 클라이언트가 해당 리소스에 접근할 권한이 있어야만 할 때 사용한다.
  • 404 Not Found: 요청한 리소스를 서버에서 찾을 수 없음을 나타내며, 클라이언트가 존재하지 않은 리소스에 접근할 때 발생한다.
  • 405 Method Not Allowed: 클라이언트가 요청한 http 메서드가 해당 리소스에서 허용되지 않음을 의미한다. 예를 들어 GET 요청만 가능한 리소스에 POST 요청을 보낼 때가 해당된다.
  • 409 Conflict: 서버가 요청을 처리하던 중 상태 충돌이 발생했음을 의미한다. 예를 들어 이미 좋아요를 누른 게시물에 다시 좋아요를 누르려 한다거나, 이미 구독을 취소한 태그에 다시 구독을 취소하려는 시도 등이 해당된다.
  • 500 Internal Servor Error: 서버에서 처리 중 예상치 못한 오류가 발생하여 요청을 처리할 수 없는 경우에 나타난다.

BaseException 객체와 상속

@AllArgsConstructor
@Getter
public class BaseException extends RuntimeException {

    private final ResponseCode responseCode;

    @Override
    public String getMessage() {
        return responseCode.getMessage();
    }
}


// 이를 상속한 TagException
public class TagException extends BaseException {
    public TagException(ResponseCode responseCode) {
        super(responseCode);
    }
}

BaseException을 도입하여 ResponseCode 변수를 가지고 있도록 하였다.

이후 각 패키지마다 BaseException을 상속한 새로운 Exception을 두어, 어떤 패키지에서 어떤 문제가 발생했는지 알아보기 쉽도록 처리했다.

위 메서드를 살펴보자. Tag 패키지 내부 TagService 비즈니스 로직 실행 중 문제가 발생하면 TagException의 인자에 관련된 메시지를 담아 예외처리하도록 했다.

GlobalExceptionHandler

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ArticleException.class)
    public ApiResponse<Void> handleArticleException(ArticleException e) {
        log.info("ArticleException: {}", e.getMessage());
        return ApiResponse.fail(e.getResponseCode());
    }

    @ExceptionHandler(UserException.class)
    public ApiResponse<Void> handleUserException(UserException e) {
        log.info("UserException: {}", e.getMessage());
        return ApiResponse.fail(e.getResponseCode());
    }

    @ExceptionHandler(NoticeException.class)
    public ApiResponse<Void> handleNoticeException(NoticeException e) {
        log.info("NoticeException: {}", e.getMessage());
        return ApiResponse.fail(e.getResponseCode());
    }

    @ExceptionHandler(TagException.class)
    public ApiResponse<Void> handleTagException(TagException e) {
        log.info("TagException: {}", e.getMessage());
        return ApiResponse.fail(e.getResponseCode());
    }
}

GlobalExceptionHandler를 통해 커스터마이징된 주요 Exception을 전역적으로 처리하여 해당 Exception에 대한 응답을 반환하는 로직을 작성할 수 있고, 여러 Controller에서 발생할 수 있는 Exception Handling를 중앙에서 담당할 수 있다.

이를 통해 API 요청에 대한 응답 처리를 표준화하고 일관성을 높이며, 코드의 유지보수성을 향상시킬 수 있다.

Controller

API 커스텀 응답 객체

Springboot에서는 기본적으로 ResponseEntity<> 응답 객체를 활용하여 클라라이언트에게 응답 객체를 전송할 수 있다.

다만 아래와 같이 커스텀 응답 객체(ApiResponse)를 구현할 수도 있다.

ApiResponse.class

@Getter
@RequiredArgsConstructor
public class ApiResponse<T> {

    private ApiHeader header;
    private ApiBody body;

    private static final int SUCCESS = 200;

    public ApiResponse(ApiHeader header, ApiBody body) {
        this.header = header;
        this.body = body;
    }

    public ApiResponse(ApiHeader header) {
        this.header = header;
    }

    public static <T> ApiResponse<T> success(T data, String message) {
        return new ApiResponse<T>(new ApiHeader(SUCCESS, "SUCCESS"), new ApiBody(data, message));
    }

    public static <T> ApiResponse<T> fail(ResponseCode responseCode) {
        return new ApiResponse(new ApiHeader(responseCode.getHttpStatusCode(), responseCode.getMessage()), new ApiBody(null, responseCode.getMessage()));
    }
}

ApiHeader.class

public class ApiHeader {
    private int code;
    private String message;

    public ApiHeader(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public ApiHeader() {
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

ApiBody.class

public class ApiBody<T> {
    private final T data;
    private final T msg;

    public ApiBody(T data, T msg) {
        this.data = data;
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public T getMsg() {
        return msg;
    }
}

위 커스텀 응답 객체를 사용하면 다음과 같은 장점을 얻는다.

  • 구조화된 응답: ApiResponse, ApiHeader, ApiBody 클래스를 이용하여 응답 데이터를 구조화하여, 응답 데이터와 에러 메시지를 명확하게 나눌 수 있다.
  • 응답 코드와 메시지 분리: ApiHeader에서 응답 코드와 메시지를 관리하므로, 응답의 성공 여부와 상태에 대한 정보를 명시적으로 다룰 수 있다.
  • 확장성: 필요에 따라 ApiHeader, ApiBody 등을 확장하여 더 다양한 응답 정보를 추가할 수 있다.

물론 단점도 있다.

  • 표준과의 불일치: Springboot에서 흔히 사용되는 ResponseEntity와는 다른 구조이므로, 팀원들이나 다른 개발자들이 코드를 이해하고 사용하기 어려울 수 있다.
  • 응답 포맷 제한: 커스텀 응답 객체를 사용하는 경우, API 응답 포맷을 명시적으로 제한하게 되고, 이는 특정 API 클라이언트와의 호환성을 고려하지 않는 한, 다양한 응답 포맷을 지원하기 어렵게 만들 수 있다. 즉 유연성이 떨어질 수 있다.

Controller 내부 주요 어노테이션

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {

    private final UserService userService;
    private final NoticeService noticeService;

    // 회원정보 조회
    @GetMapping("/{userId}")
    public ApiResponse<ResponseUserDto> getUser(@PathVariable Long userId) {
        return ApiResponse.success(userService.getUser(userId), ResponseCode.USER_FOUND.getMessage());
    }
  • @RequestMapping을 통해 controller 내부 API 메서드들이 공통적으로 가지고 있는 링크를 병합할 수 있다.
  • @PathVariable을 통해 URL에서 제공하는 변수를 사용할 수 있다. 즉, 회원정보 조회 API의 url은 다음과 같다.
    baseURL/api/user/{userId}
  • 이후 ApiResponse.success() 메서드를 통해 클라이언트에게 Dto 객체를 반환한다.
// 작성한 게시물 조회 (페이징)
@GetMapping("/{userId}/articles/writes")
public ApiResponse<Page<ResponseSimpleArticleDto>> getArticles(@RequestParam(defaultValue = "0") int page,
                                                               @RequestParam(defaultValue = "10") int size, @PathVariable Long userId) {
  return ApiResponse.success(userService.getUserArticles(userId, PageRequest.of(page, size)), ResponseCode.ARTICLE_FOUND.getMessage());
}
  • @RequestParam을 통해 쿼리스트링을 사용할 수 있다. 위 메서드의 size는 10인데, 2페이지를 확인하고 싶다면 defaultValue가 0이므로 1을 쿼리스트링에 넣는다.
    따라서 실제 접근해야 할 url은 다음과 같다.
    baseURL/api/user/{userId}/articles/writes?page=1&size=10
  • 이후 ApiResponse.success() 메서드를 통해 클라이언트에게 Page 객체를 반환한다.
// 새 태그 생성
    @PostMapping("/create")
    public ApiResponse<Long> createTag(@RequestBody CreateTagDto createTagDto) {
        return ApiResponse.success(tagService.createTag(createTagDto.getTagName()), ResponseCode.TAG_CREATED.getMessage());
    }
  • 클라이언트에게 Dto 객체를 받기 위해서는 @RequestBody를 통해 이를 받아 처리할 수 있다. 단, Dto 객체 내부에 사진 같은 MultiPart 데이터가 존재할 경우 반드시 @RequestBody를 제거해야 한다. @RequestBody는 기본적으로 JSON 데이터가 서버로 넘어온다고 가정하기 때문이다. @RequestPart와 같은 어노테이션을 대신 사용하는 것도 방법이다.

(뭔가 코드만 있으면 좀 칙칙해 보여서 넣어봤다.)

방학 동안 진행한 3개의 프로젝트(하나는 UMC 협업)에서 배운 내용을 요약해 보았다 개인적으로 캐싱을 위한 Redis를 적용해보고 싶었는데, 그러지 못해 참 아쉽다.

앞으로도 이렇게 Springboot의 주요 기술 지식을 정리해나갈 예정이다.
(틀린 부분 피드백 언제나 환영하고 감사드립니다.)

profile
Discover Tomorrow

0개의 댓글