지난 게시물에 이어 Service, Controller 각 계층과 Exception Handling에 대해 정리해보자.
위 TagService는 태그의 생성, 삭제, 조회와 관련된 비즈니스 로직을 담당한다.
태그의 생성, 수정, 삭제와 같은 "조회가 아닌" 연산을 제어하는 메서드는 @Transactional를 붙여, 메서드 내에서 발생하는 모든 데이터베이스 연산을 하나의 트랜잭션으로 묶여서 원자적으로 처리한다. 메서드 실행이 성공하면 트랜잭션은 커밋되고, 예외가 발생하면 트랜잭션은 롤백되어 데이터의 일관성을 보장한다.
조회 메서드의 경우 @Transactional(readOnly=true)을 붙여 의도하지 않은 엔티티의 생성/수정/삭제를 막을 수 있고 조회 성능을 개선할 수 있다.
데이터의 유효성(중복 여부 확인 등)을 검사하는 로직은 따로 validateXXX 형태의 메서드로 따로 분리하는 것이 바람직하다.
기본적으로 Repository 내부 findById 메서드의 반환 객체는 Optional<>로 감싸지며, 이는 데이터가 없을 때 NullPointerException을 발생시키지 않고 Optional.empty()를 반환한다.
반복되는 findBy 로직은 많은 orElseThrow()가 중복되므로 따로 Service 내부 private 메서드로 분리하는 것이 바람직할 것이다.
ArticleRepository 내부 메서드에 위처럼 페이징을 적용할 수 있다.
이후 클라이언트에게 전달할 Dto Page 객체를 이와 같은 형태로 반환할 수 있다.
물론 다른 페이징 방법도 있다. new PageImpl<>을 통해서도 페이징 처리가 가능하다.
JPA를 이용한 페이징으로 데이터를 가져오는 것은 한 번에 많은 데이터를 한 번에 가져올 때 발생할 수 있는 성능 문제를 해결 가능하지만, 작은 페이지를 여러 번 요청하게 되면, 각 페이지를 가져오기 위한 데이터베이스 연결 리소스가 추가적으로 소모된다. 이로 인해 전체적인 데이터 로딩 시간이 증가할 수 있다.
반면 한 번에 큰 단위의 데이터를 가져온 뒤 new PageImpl<> 등으로 메모리에서 페이징하는 방식은 한 번의 연결로 데이터를 가져올 수 있기 때문에 연결 리소스가 줄어든다. 그러나 데이터 양이 많을수록 메모리 사용량이 증가하여 이는 시스템 성능에 영향을 끼친다. Redis 등을 통한 캐싱이 필요한 부분이다.
따라서 Trade-Off이므로 상황에 따라 두 방식 중 하나를 채택하는 것이 바람직하다. 물론 @BatchSize 등을 활용한 최적화는 항상 필수다.
1편에서 언급했다시피 단순 for문보다는 Java 8의 stream() 메서드를 활용해 Entity to Dto를 보다 깔끔하게 구현할 수 있다. 위 메서드는 User와 Tag의 다대다 관계로 인한 중간 테이블인 UserTag에서 특정 태그를 구독하는 유저 목록을 추출 기능을 수행한다. stream().map()을 통해 UserTag에서 User를 추출한 뒤, 이를 Dto로 수정하여 .collect()를 통해 컬렉션으로 변환하고 있다.
@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 통신 상황에서 발생하는 응답 코드 및 메시지를 정의한다. 열거형은 각 상황에 대한 응답 코드, 성공 여부, 메시지를 포함하고 있다.
이러한 구조를 채택하면 다음과 같은 강점이 있다.
성공 상태 코드는 보통 2XX이다.
아래부턴 실패 상태 코드이다.
@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의 인자에 관련된 메시지를 담아 예외처리하도록 했다.
@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 요청에 대한 응답 처리를 표준화하고 일관성을 높이며, 코드의 유지보수성을 향상시킬 수 있다.
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;
}
}
위 커스텀 응답 객체를 사용하면 다음과 같은 장점을 얻는다.
물론 단점도 있다.
@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());
}
// 작성한 게시물 조회 (페이징)
@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());
}
// 새 태그 생성
@PostMapping("/create")
public ApiResponse<Long> createTag(@RequestBody CreateTagDto createTagDto) {
return ApiResponse.success(tagService.createTag(createTagDto.getTagName()), ResponseCode.TAG_CREATED.getMessage());
}
(뭔가 코드만 있으면 좀 칙칙해 보여서 넣어봤다.)
방학 동안 진행한 3개의 프로젝트(하나는 UMC 협업)에서 배운 내용을 요약해 보았다 개인적으로 캐싱을 위한 Redis를 적용해보고 싶었는데, 그러지 못해 참 아쉽다.
앞으로도 이렇게 Springboot의 주요 기술 지식을 정리해나갈 예정이다.
(틀린 부분 피드백 언제나 환영하고 감사드립니다.)