이 글의 코드와 정보들은 [실전! 스프링 부트와 JPA 활용 2] 강의를 들으며 정리한 내용을 토대로 작성하였습니다.
주문 + 배송정보 + 회원을 조회하는 API를 만들면서 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결하는 과정을 정리했다.
OrderSimpleApiController
package jpabook.jpashop.api;
/**
* xToOne(ManyToOne, OneToOne) - 성능 최적화
* Order
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리 * - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
}
return all;
}
}
엔티티를 직접 노출하는 것은 좋지 않다. (이러한 이유에 대해서는 이전글에서 이미 설명했음)
order -> member, order -> delivery는 지연 로딩으로 실제 엔티티 대신에 프록시가 존재하게 된다.
Order - Member 엔티티는 다대일 관계, Order와 Delivery는 일대일 관계)jackson 라이브러리는 기본적이로 이 프록시 객체를 json으로 어떻게 생성하는지 모르기 때문에 예외가 발생한다.
No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->jpabook.jpashop.domain.Order["member"]->jpabook.jpashop.domain.Member$HibernateProxy$5vbd87AL["hibernateLazyInitializer"])따라서 Hibernate5Module을 스프링 빈으로 등록하면 해결된다.
build.gradle 에서 다음 라이브러리를 추가한다.implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'그런 다음, JpashopApplication 에 다음 코드를 추가해준다.
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정 (사실 아래에 있는 건 쓰면 안됨)
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,true);
return hibernate5Module;
}
}
@JsonIgnore 옵션을 두개의 엔티티중 한곳에 주어야 한다.정리: 해당 방법은 강제로 지연 로딩을 설정해서 에러를 해결했으나, 이전에 말했듯이 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다. 따라서 Hibernate5Module를 사용하기 보다는 DTO로 변환해서 하는 것이 좋은 방법이다.
OrderSimpleApiController - 추가
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
// ORDER 2개
// N + 1 -> 1 + N(2): 첫번째 쿼리의 결과로 n번만큼 query가 추가 실행되는 게 N + 1이라는 의미임.
// N + 1 -> 1 + 회원 N + 배송 N
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders
.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // LAZY 초기화
}
}
}
V1과 다르게 V2에서는 엔티티를 DTO로 변환한 일반적인 방법이다.
하지만 V2에서는 실제 쿼리를 실행했을 때 N+1 문제가 생긴다.
현재 로직상 order와 연관된 member와 delivery에서 지연 로딩으로 인해 조회가 N번 실행된다.
그래서 쿼리가 총 1 + N + N번 실행된다. (이는 V1에서의 쿼리 수 결과와 같다)
자세하게 설명하면, order 조회에서 1번, order -> member 지연 로딩 조회에서 N번, order -> delivery 지연 로딩 조회에서 N번이 실행된다.
만약 같은 회원이였으면 N번이 아닌 1번인데, 서로 다른 회원이므로 N번이 된 것이다.
지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
따라서 이러한 N+1 문제를 해결하기 위해 페치 조인(fetch join)을 통해 성능을 최적화하는 방법을 사용한다.
OrderSimpleApiController - 추가
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
}
OrderRepository - 추가 코드
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
// 재사용성 O
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).getResultList();
}
}
엔티티를 페치 조인(fetch join)을 사용해서 5번이였던 쿼리를 1번만 조회할 수 있는 효과를 볼 수 있다.
페치 조인으로 인해 order -> member, order -> delivery는 이미 조회된 상태이므로 지연로딩이 되지 않는다.
실제 쿼리를 돌려보면 아래와 같이 조회 결과가 나오게 된다.
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
2023-10-19 14:56:18.992 INFO 48324 --- [nio-8080-exec-5] p6spy : #1697694978992 | took 0ms | statement | connection 9| url jdbc:h2:tcp://localhost/~/jpashop
select order0_.order_id as order_id1_6_0_, member1_.member_id as member_i1_4_1_, delivery2_.delivery_id as delivery1_2_2_, order0_.delivery_id as delivery4_6_0_, order0_.member_id as member_i5_6_0_, order0_.order_date as order_da2_6_0_, order0_.status as status3_6_0_, member1_.city as city2_4_1_, member1_.street as street3_4_1_, member1_.zipcode as zipcode4_4_1_, member1_.name as name5_4_1_, delivery2_.city as city2_2_2_, delivery2_.street as street3_2_2_, delivery2_.zipcode as zipcode4_2_2_, delivery2_.status as status5_2_2_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id
select order0_.order_id as order_id1_6_0_, member1_.member_id as member_i1_4_1_, delivery2_.delivery_id as delivery1_2_2_, order0_.delivery_id as delivery4_6_0_, order0_.member_id as member_i5_6_0_, order0_.order_date as order_da2_6_0_, order0_.status as status3_6_0_, member1_.city as city2_4_1_, member1_.street as street3_4_1_, member1_.zipcode as zipcode4_4_1_, member1_.name as name5_4_1_, delivery2_.city as city2_2_2_, delivery2_.street as street3_2_2_, delivery2_.zipcode as zipcode4_2_2_, delivery2_.status as status5_2_2_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id;
OrderSimpleApiController - 추가
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
private final OrderSimpleQueryRepository orderSimpleQueryRepository; // 의존관계 주입(V4)
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDto();
}
}
OrderSimpleQueryRepository 조회 전용 리포지토리
package jpabook.jpashop.repository.order.simplequery;
/**
* 성능 최적화 관련 레포지토리 경우
* 따로 패키지를 만들어서 그 안에 관련 클래스를 만든다.
*/
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
// 재사용 X, 유지보수성 O
// 이런 식으로 성능 최적화에 대한 것은 따로 패키지를 분리하는 것을 권장함
public List<OrderSimpleQueryDto> findOrderDto() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회
package jpabook.jpashop.repository.order.simplequery;
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this. orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회하는 방법이다.
new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 반환한다.
SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 면에서 에플리케이션 네트워트 용량을 최적화할 수 있지만 생각보다 미비하다.
실제 쿼리를 돌려보면 아래와 같이 조회 결과가 나오게 된다.
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
2023-10-19 15:28:50.970 INFO 48765 --- [nio-8080-exec-3] p6spy : #1697696930970 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/~/jpashop
select order0_.order_id as col_0_0_, member1_.name as col_1_0_, order0_.order_date as col_2_0_, order0_.status as col_3_0_, delivery2_.city as col_4_0_, delivery2_.street as col_4_1_, delivery2_.zipcode as col_4_2_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id
select order0_.order_id as col_0_0_, member1_.name as col_1_0_, order0_.order_date as col_2_0_, order0_.status as col_3_0_, delivery2_.city as col_4_0_, delivery2_.street as col_4_1_, delivery2_.zipcode as col_4_2_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id;
사실 V3와 V4간의 성능 차이가 크게 나진 않는다.
V4의 경우, DTO에 직접 조회하기 때문에 리포지토리 재사용성이 떨어지고, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점이 존재한다.
반면에 V3의 경우, 엔티티로 조회해서 리포지토리 재사용성이 좋고 개발도 단순하다.
엔티티를 DTO로 변환하거나(V3), DTO로 바로 조회하는(V4) 두 가지 방법이 각각 장단점이 존재하기 때문에, 아래와 같은 쿼리 방식을 선택할 때 권장하는 순서를 정리했다.
쿼리 방식 선택 권장 순서
(4번의 경우, 이 강의에서는 다루지 않아서 추후 실제 개발하면서 문제가 생기는 경우, 그때 가서 다시 정리할 예정이다)
조회하는 부분에서 쿼리를 실행했을 때 N+1 문제가 발생하는 점과 그러한 문제를 페치 조인(fetch join)으로 해결하는 방법도 알게되었다.
현재 혹은 미래에 내가 진행하는 프로젝트에서 위와 비슷한 문제가 생겼을 때, 페치 조인(fetch join)으로 성능을 최적화하는 걸 적용해보도록 하자.
(해당 강의에서는 예시로 만든 자료이고, 예시에 있는 데이터가 적기도 해서 최적화하는 방법이 간단했지만, 실제 업무에서는 최적화를 적용하기가 어려울 것이라 생각한다)
이 글의 코드와 정보들은 [실전! 스프링 부트와 JPA 활용 2] 강의를 들으며 정리한 내용을 토대로 작성하였습니다.
실무에서는 하나의 도메인에 대한 엔티티를 만들고(ex. 회원), 해당 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요구사항을 담기 어렵다.
그리고 엔티티가 변경되면 API 스펙이 변한다.
결론: 그렇기 때문에 API 기능 요구사항에 맞춰서 엔티티가 아닌 별도의 DTO를 생성하여 파라미터로 받아야 한다.
참고로, 원래라면 DTO도 클래스로 생성해야 하지만, 여기서는
이해를 우선시하기 때문에 Controller 클래스안에 DTO에 대한 코드도 담겨있다는 걸 참고하길 바란다.
V1의 경우, 엔티티를 생성해서 회원에 대한 등록 API를 기능 구현한 예시다.
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
// 실제 애플리케이션에서는 엔티티를 직접 반환해서는 안된다.
@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@Data
static class CreateMemberRequest {
@NotEmpty
private String name;
}
@Data
static class CreateMemberResponse {
private Long id;
public CreateMemberResponse(Long id) {
this.id = id;
}
}
}
이런 경우, 위에서 말했던 것 처럼 나중에 엔티티가 변경되면 API 스펙도 같이 변해야 한다.
그래서 해결책으로 엔티티를 대체하는 DTO를 생성하여 API 기능을 구현하면 된다.
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
// 엔티티 노출하거나 받지 말고 api 스펙에 맞는 DTO를 만들고, 그걸 활용해야 한다.
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
Member member = new Member();
member.setName(request.getName());
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@Data
static class CreateMemberRequest {
@NotEmpty
private String name;
}
@Data
static class CreateMemberResponse {
private Long id;
public CreateMemberResponse(Long id) {
this.id = id;
}
}
}
이를 통해 다음과 같은 장점을 가질 수 있다.
이를 통해 엔티티와 프리젠테이션(Controller 클래스와 같은) 계층을 위한 로직을 분리할 수 있다.
엔티티와 API 스펙을 명확하게 분리할 수 있다.
엔티티가 변해도 API 스펙이 변하지 않는다.
참고: 엔티티를 외부에 노출해선 안된다.
실무에서는 기능에 따라 필요한 API가 증가하게 된다. API 마다 필요한 데이터가 다르기 때문에 API 스펙에 맞는 별도의 DTO를 노출해야 한다.
이 글은 우리FISA 1기 굿프렌즈팀의 기술 블로그에 게시된 글 입니다.
Amazon CloudWatch가 무엇이고 왜 도입해야 하는지 알고 넘어가려고 합니다.
모니터링 없이 시스템을 운영하게 된다면 마치 계기판을 보지 않고 자동차를 운전하는 것과 같다는 이야기가 있습니다. 시스템 장애는 곧바로 서비스 중지로 이어질 수 있기 때문에 시스템 모니터링은 그만큼 중요합니다. 따라서 시스템 관리자는 지속적으로 시스템의 상태를 모니터링하고 분석하여 미래에 발생할 수 있는 예기치 않는 장애에 대비해야 합니다.
시스템 관리자는 시스템의 다음 요소들을 모니터링할 필요가 있습니다.
Amazon CloudWatch은 AWS 리소스와 온프레미스 서버에서 CPU 사용률, 디스크 I/O, 네트워크 부화 등과 같은 기본 지표를 수집하여 모니터링 서비스를 제공하고 있습니다.
현재 굿프렌즈의 백엔드, 프론트 EC2 인스턴스 유형은 t2.micro입니다. 1GB 메모리, 8GB 디스크 사용량으로 아주 작은 사이즈입니다.
간혹 서비스를 운영하다보면 특히 비즈니스 로직이 많이 들어있는 백엔드 EC2 서버 디스크 용량이 50% 이상 넘길 수도 있습니다.
그래서 인스턴스의 디스크 사용량이 전부 차기전에 미리 알아야 하기 때문에, EC2 인스턴스의 사용중인 용량이 4GB 넘어가면 (50% 이상) Amazaon Cloudwatch를 통해 모니터링을 하고 알림을 받을 수 있도록 설정을 하려고 합니다.
아래 글은 백엔드 기준, EC2 인스턴스의 디스크 사용량을 체크하기 위해서 CloudWatch 적용하는 방법을 소개해드리고자 합니다.
굿프렌즈 백엔드 EC2 애플리케이션 및 OS 이미지는 아래와 같습니다.

```java import java.util.Arrays; import java.util.Comparator;
class Solution {
public static String[] solution(String[] files) {
이 글은 우리FISA 1기 굿프렌즈팀의 기술 블로그에 게시된 글 입니다.
예외 처리는 애플리케이션을 만드는데 있어서 매우 중요한 부분을 차지합니다.
Spring 프레임워크는 다양한 에러 처리 방법을 제공하는데, 굿프렌즈팀은 기존에 어떻게 처리했고, 이후에 어떻게 개선했는지 과정을 설명해보겠습니다.
이전 굿프렌즈팀(~23.09.04)에서는 비즈니스 로직에 필요한 예외 처리를 exception 이라는 패키지를 만들어서 아래와 같이 처리했습니다.
@ResponseStatus은 스프링 프레임워크에서 사용하는 어노테이션으로 처리된 요청에 대한 HTTP 응답 상태 코드와 이에 따른 메시지를 설정하는데 사용됩니다.
package woorifisa.goodfriends.backend.auth.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class NotFoundTokenException extends RuntimeException {
public NotFoundTokenException(final String message) {
super(message);
}
public NotFoundTokenException() {
this("존재하지 않는 Token 입니다.");
}
}
예를 들어, 토큰에 대한 값을 찾지 못할 경우 위와 같이 @ReponseStatus을 통해 HTTP 응답 상태 코드인 404 Not Found와 “존재하지 않는 Token 입니다”라는 메시지를 받게 됩니다.
해당 어노테이션을 이용하여 간단하게 상태 코드를 지정할 수 있습니다.
즉, 클라이언트 입장에서 에러 코드가 무엇인지 확인할 수 있다는 장점을 가집니다.
하지만 애플리케이션에 필요한 기능들을 개발하다 보면, 다양한 예외 처리를 만들어야 합니다. 각 예외 처리 클래스마다 위에 @ResponseStatus을 붙여서 상태 코드를 넣어주는 것은 좋지만, 한 눈에 볼 수 없습니다.
그리고 시간이 지날 수록 본인이 지정한 예외 처리 클래스가 어떤 상태 코드 값인지 제대로 기억하기 쉽지 않습니다.
또한 외부에서 정의한 Exception 클래스에는 @ResponseStatus를 붙여줄 수 없습니다.
Spring5에는 이러한 @ResponseStatus의 프로그래밍적 대안으로 손쉽게 에러를 반환할 수 있는 ResponseStatusException이 추가되었습니다.
ResponseStatusException은 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있습니다.
ResponseStatusException를 사용하면 다음과 같은 이점을 누릴 수 있습니다.
하지만 그럼에도 불구하고 ResponseStatusException는 다음과 같은 한계점을 지니고 있습니다.
API 기능을 개발하다보면 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 합니다.
그리고 같은 예외라고 하더라도 어떤 API에서 발생했는가에 따라 다른 예외 응답을 내려주어야 하는 세밀한 작업이 필요하다.
이러한 API 예외 처리를 해결하기 위해 최근에는 스프링에서 제공하는 @ExceptionHandler 어노테이션을 사용하는 방식이 많아졌습니다.
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같습니다.
ExceptionHandlerExceptionResolver -> (우선 순위가 가장 높다)
ResponseStatusExceptionResolver -> HTTP 응답 코드 변경
DefaultHandlerExceptionResolver -> 스프링 내부 예외 처리(우선 순위가 가장 낮다)
이때 1번인 ExceptionHandlerExceptionResolver가 @ExceptionHandler을 처리합니다.
@ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 어노테이션입니다. @ExceptionHandler은 다음에 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있습니다.
@ExceptionHandler는 @ResponseStatus와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연합니다. 예를 들어 응답을 다음과 같이 정의해서 내려주면 좋을 것 같습니다.
@RestControllerAdvice
public class ControllerAdvice {
private static final Logger log = LoggerFactory.getLogger(ControllerAdvice.class);
@ExceptionHandler({ // 클라이언트 에러: 404
NotFoundUserException.class,
// ...
})
public ResponseEntity<ErrorResponse> handleNotFoundData(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
}
@ExceptionHandler의 실행 흐름
애플리케이션 실행 중에 컨트롤러나 서비스 메서드 등에서 특정 예외가 발생할 때, NotFoundUserException 와 일치하는 @ExceptionHandler 어노테이션이 선언된 메서드가 호출됩니다. (여기서는 handleNotFoundData 메소드가 호출됩니다)
예외를 처리하는 메서드(handleNotFoundData)에서는 해당 예외에 대한 응답을 생성합니다.
위의 코드에서는 ErrorResponse 객체를 생성하고, 해당 응답을 HTTP 상태 코드 404(NOT_FOUND)와 함께 반환합니다.
생성된 응답을 클라이언트에게 전송됩니다. 이 경우, HTTP 상태 코드는 404로 설정되어 클라이언트에게 해당 자원이 발견되지 않았음을 알려줍니다.
이렇게 @ExceptionHandler 어노테이션을 사용하면 특정 예외에 대한 처리 로직을 일괄적으로 정의할 수 있으며, 중복 코드를 피하고 통일된 에러 응답을 생성할 수 있습니다.
위와 같이 NOT_FOUND와 같은 HTTP 표준 상태와 같이 가독성이 좋은 값을 사용하는 것이 클라이언트의 입장에서도 대응하기 좋고, 유지보수하는 입장에서도 좋습니다.
또한 각 기능별로 예외 처리에 대한 클래스를 @ExceptionHandler을 통해 한 곳에 관리할 수 있습니다.
@ExceptionHandler를 사용 시에 주의할 점은 @ExceptionHandler에 등록된 예외 클래스와 파라미터로 받는 예와 클래스가 동일해야 한다는 것입니다.
만약 값이 다르다면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킵니다.
java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...]
HandlerMethod details: ...
@ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 합니다.
@ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용됩니다. (글로벌 적용)
Spring은 전역적으로 @ExceptionHandler를 적용할 수 있는 @ControllerAdvice와 @RestControllerAdvice 을 각각 Spring3.2, Spring4.3부터 제공하고 있습니다.
두 어노테이션의 차이점은 @RestControllerAdvice는 @ResponseBody 가 붙어 있어 응답을 Json 형식으로 내려준다는 점입니다.
(@Controller , @RestController 의 차이와 같습니다)
아래와 같이 @ControllerAdvice와 @RestControllerAdvice의 구현의 일부 입니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
//...
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice {
//...
}
굿프렌즈팀에서는 @RestControllerAdvice 어노테이션을 적용한 ControllerAdvice 클래스를 만들어서 메서드 위에 @ExceptionHandler를 적용했습니다.
대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("com.goofriends.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
(스프링 공식 문서 참고 - https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice)
스프링 공식 문서 예제에서 보는 것 처럼 특정 어노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수도 있습니다.
패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 됩니다.
그리고 특정 클래스를 지정할 수 있습니다.
만약 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용됩니다.
굿프렌즈팀은 400, 401, 403, 404, 405, 500 를 사용하였고, 이에 따라 에러 클래스를 정의해봤습니다.
400 BAD_REQUEST
설명: 서버는 클라이언트의 오류로 인해 요청을 처리할 수 없거나 하지 않을 것입니다.
예시) InvalidUserException.class : 잘못된 회원 정보입니다.
401 UNAUTHORIZED
설명: 403 Forbidden과 유사하지만 구체적으로 인증이 필요하며 실패하거나 제공되지 않은 경우에 사용됩니다.
예시) EmptyAuthorizationHeaderException.class: Header에 Authorization이 존재하지 않습니다. / InvalidTokenException.class : 유효하지 않은 토큰입니다.
인증되지 않은(unauthenticated) 요청이 들어왔을 때 사용합니다.
클라이언트에서 해당 요청을 보내기 위해서는 자신이 누구인지 알려주는 인증 정보가 필요한데, 인증 정보가 누락된 경우에 사용합니다.
403 FORBIDDEN
설명: 인가되지 않은(unauthorization) 요청이 들어왔을 때 사용합니다.
예시) AuthorizationException.class : 권한이 없습니다. / InactiveUserAccessException.class : 비활성화 상태인 유저로 해당 페이지에 접근이 불가능합니다.
서버는 요청을 이해했지만 권한이 없어 이를 거부합니다. 요청이 거부된 이유를 공개하려는 서버는 응답에서 해당 이유를 설명할 수 있습니다.
=> 필요한 권한을 갖고 있지 않는 경우에 사용됩니다.
404 NOT_FOUND
설명: 서버에서 요청한 리소스를 찾을 수 없습니다.
예시) NotFoundProductException.class : 존재하지 않는 물품입니다. / NotFoundOrderException : 주문서가 존재하지 않습니다.
405 METHOD_NOT_ALLOWED
설명: 요청 라인에서 수신한 메서드는 원본 서버에서 알고 있지만 대상 리소스에서는 지원되지 않습니다.
예시) HttpRequestMethodNotSupportedException.class : 지원하지 않는 HTTP 메서드 요청입니다.
500 INTERNAL_SERVER_ERROR
설명: 서버 측에서 오류가 발생했음을 나타냅니다. 이는 클라이언트의 요청을 처리하는 동안 서버가 예기치 않은 상황에 직면하여 요청을 완료할 수 없는 경우에 사용됩니다.
예시) Exception.class : 서버에서 예상치 못한 에러가 발생했습니다. / OAuthException.class : Oauth 서버와의 통신 과정에서 문제가 발생했습니다.
아래의 ControllerAdvice는 여러 컨트롤러에 적용하기 때문에 굿프렌즈팀의 global - error 패키지 안에 만들었습니다.
@RestControllerAdvice
public class ControllerAdvice {
private static final Logger log = LoggerFactory.getLogger(ControllerAdvice.class);
@ExceptionHandler(MethodArgumentNotValidException.class) // 클라이언트 에러: 400
public ResponseEntity<ErrorResponse> handleMethodArgumentException(BindingResult bindingResult) {
String errorMessage = bindingResult.getFieldErrors()
.get(0)
.getDefaultMessage();
ErrorResponse errorResponse = new ErrorResponse(errorMessage);
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler({ // 클라이언트 에러: 400
InvalidNicknameException.class,
InvalidUserException.class,
InvalidDescriptionException.class,
ReportException.class
})
public ResponseEntity<ErrorResponse> handleInvalidData(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler({ // 클라이언트 에러: 401
EmptyAuthorizationHeaderException.class,
InvalidTokenException.class,
})
public ResponseEntity<ErrorResponse> handleInvalidAuthorization(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
@ExceptionHandler({ // 클라이언테 에러: 403
NotFoundProfile.class,
NotAccessThisProduct.class,
AuthorizationException.class,
NotAccessProduct.class,
AlreadyExitPhoneProfile.class,
OwnProductException.class,
NotOwnProductException.class
})
public ResponseEntity<ErrorResponse> handleForbidden(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}
@ExceptionHandler({ // 클라이언트 에러: 404
NotFoundAdminException.class,
NotFoundOAuthTokenException.class,
NotFoundTokenException.class,
NotFoundProductException.class,
NotFoundImageFileException.class,
NotFoundUserException.class,
NotFoundOrderException.class
})
public ResponseEntity<ErrorResponse> handleNotFoundData(final RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) // 클라이언트 에러: 405
public ResponseEntity<ErrorResponse> handleNotSupportedMethod() {
ErrorResponse errorResponse = new ErrorResponse("지원하지 않는 HTTP 메서드 요청입니다.");
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse);
}
@ExceptionHandler(OAuthException.class) // 서버 에러: 500
public ResponseEntity<ErrorResponse> handleOAuthException(final RuntimeException e) {
log.error(e.getMessage(), e);
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
return ResponseEntity.internalServerError().body(errorResponse);
}
@ExceptionHandler(Exception.class) // 서버 에러: 500
public ResponseEntity<ErrorResponse> handleUnexpectedException(final Exception e,
final HttpServletRequest request) {
ErrorReportRequest errorReportRequest = new ErrorReportRequest(request, e);
log.error(errorReportRequest.getLogMessage(), e);
ErrorResponse errorResponse = new ErrorResponse("서버에서 예상치 못한 에러가 발생했습니다.");
return ResponseEntity.internalServerError().body(errorResponse);
}
}
ControllerAdvice 이점 및 주의할 점ControllerAdvice는 전역적으로 적용되는데, 지금과 같이 특정 클래스에만 제한적으로 적용하기 위해 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있습니다.
이러한 ControllerAdvice를 이용함으로써 다음과 같은 이점을 누릴 수 있습니다.
하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음
별도의 try-catch문이 없어 코드의 가독성이 높아짐
이러한 이유로 API에 의한 예외 처리를 하는 경우 ControllerAdvice 클래스를 이용하면 됩니다.
하지만 ControllerAdvice 클래스를 사용할 때는 항상 주의해야할 점이 있습니다.
여러 ControllerAdvice가 있는 경우 @Order 어노테이션 으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice를 임의의 순서로 처리할 수 있기 때문에 일관된 예외 응답을 처리하기 위해서는 이러한 점을 주의해야 합니다.
한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋습니다.
만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 합니다.
직접 구현한 Exception 클래스들은 한 공간에서 관리합니다.

Spring은 매우 다양한 예외 처리 방법을 제공하고 있어 어떻게 에러를 처리하는 것이 최선의 방법인지 파악하기 어려울 수 있습니다. 여러 스프링의 다양한 예외 처리 방법들 중에 현 프로젝트에 ControllerAdvice를 이용하는 것이 가장 좋은 방식인 것 같아 적용해보았습니다. 이후에 팀원들과 프로젝트를 진행하면서 혹시 이보다 더 좋은 방안이 떠오른다면, 다른 포스팅으로 찾아뵙겠습니다.
지금까지 읽어주셔서 감사합니다. 😌