Overview

Spring Boot 로 REST API 를 테스트 하다가 이상한 이슈에 직면했습니다.

클라이언트에서 @RequestBody 로 요청을 받기 위한 DTO 클래스를 만들고 값을 입력 받았는데 null 값이 입력되는 겁니다.

처음에는 오타가 있거나 잘못 만든 건 줄 알았는데 변수명을 바꾸니 잘 동작했습니다.

예를 들어, 변수명이 aCount 일 때는 동작하지 않았는데 aaCount 로 바꾸니 제대로 값이 들어왔습니다.

그래서 이것저것 바꿔가면서 테스트를 하였고 Jackson, Lombok 에 대해서 알게 된 사실을 정리합니다.

쓰다보니 글이 장문이 되었는데 결론과 해결법만 알고 싶으면 마지막만 보면 됩니다.


1. Jackson

Spring 은 JSON 데이터를 매핑하기 위한 Message Converter 로 Jackson 을 사용합니다.

(Http Message Converters with the Spring Framework - Baeldung 참고)

위에서 제시한 문제의 원인은 Lombok 이었지만 Jackson 의 JsonMessageConverter 의 동작에도 원인이 숨겨져 있습니다.

이를 확인하기 위해서는 Jackson 의 DTO <-> Json 과정이 어떻게 이루어지는 지 먼저 파악이 필요합니다.


1.1. Jackson 은 Getter 의 이름을 기반으로 Json Key 값을 만든다

Jackson 에는 한 가지 재미있는 사실이 있습니다.

Object -> Json 으로 변환 하면 해당 Object 의 필드명을 기준으로 될거라고 생각했는데 사실 Getter의 이름 기준으로 바뀝니다.


public class JacksonDto {
    private String name;

    public String getNameChange() {
        return name;
    }
}
  • 필드명은 name 이지만 Getter 이름은 getNameChange() 입니다.

public class DtoTest {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Test
    void test_jackson_dto() throws Exception {
        JacksonDto jacksonDto = new JacksonDto("my name");
        String content = objectMapper.writeValueAsString(jacksonDto);

        // 출력 = Jackson : {"nameChange":"my name"}
        System.out.println("Jackson : " + content);
    }
}
  • 필드명 대신 Getter 의 이름으로 Json Key 값이 설정되었습니다.
  • Getter 의 이름은 당연하게도 필드명과 동일하게 지어와서 지금까지 눈치 채지 못했습니다.

1.2. Jackson 이 Json Key 이름을 변환하는 데는 일정한 규칙이 있다

Object 의 필드명을 Getter 로 바꿀 때 일반적으로 맨 앞 글자를 대문자로 바꿔줍니다.

ex) name -> getName()

Jackson 은 Getter 를 기준으로 변환시키기 때문에 Jackson 내부적으로도 나름의 기준을 갖고 변홥합니다.

기본적으로는 JavaBeans 규약을 따르지만 다른 부분이 있었습니다.

먼저 JavaBeans 규약을 먼저 알아봅니다.


2. JavaBeans 규약

JavaBeans 는 메서드 이름에서 필드명을 추출할 때 일정한 규칙이 존재합니다.

stack overflow 의 Naming convention for getters/setters in Java 의 답변을 보면 Java Bean 규약을 첨부한 답변이 있습니다.

여기서 8.8 Capitalization of inferred names 챕터를 보면 아래와 같습니다.


When we use design patterns to infer a property or event name, we need to decide what rules to follow for capitalizing the inferred name.
If we extract the name from the middle of a normal mixedCase style Java name then the name will, by default, begin with a capital letter.
Java programmers are accustomed to having normal identifiers start with lower case letters.
Vigorous reviewer input has convinced us that we should follow this same conventional rule for property and event names.

Thus when we extract a property or event name from the middle of an existing Java name, we normally convert the first character to lower case.
However to support the occasional use of all upper-case names, we check if the first two characters of the name are both upper case and if
so leave it alone. So for example,

“FooBah” becomes “fooBah”
“Z” becomes “z”
“URL” becomes “URL”

We provide a method Introspector.decapitalize which implements this conversion rule.


간단히 요약하면 클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 때문에 첫 번째 글자를 소문자로 변환한다는 겁니다.

다만, 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이런 경우는 예외로 둔다고 합니다.

그리고 예외 케이스를 판별하기 위해 첫 두 문자가 모두 대문자인지를 확인합니다.

그리고 java.beans 패키지에 있는 Introspector 클래스를 확인해보면 실제로 어떤 로직이 들어가있는 지 알 수 있습니다.


public class Introspector {
    // ...

    public static String decapitalize(String name) {
        if (name == null || name.length() == 0) {
            return name;
        }
        if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
                        Character.isUpperCase(name.charAt(0))){
            return name;
        }
        char chars[] = name.toCharArray();
        chars[0] = Character.toLowerCase(chars[0]);
        return new String(chars);
    }

    // ...
}
  • 맨 앞 두개가 전부 대문자라면 그대로 리턴하고 아니라면 맨 앞 문자 하나만 소문자로 바꿔서 리턴합니다.

3. 그렇다면 Jackson 에서는?

Jackson 도 JavaBeans 규약을 따르지만 다른 점이 하나 있습니다.

테스트로 알아본 Jackson 의 규칙은 다음과 같습니다.

  1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
  2. 나머지 모든 케이스에서는 맨 앞 글자만 소문자로 바꿔준다.

JavaBeans 규약과 다른 부분은 1 번입니다.

JavaBeans 규약에서는 앞 두 글자가 대문자인 경우 그대로 사용한다고 했으나 Jackson 은 맨 앞부터 이어진 대문자를 모두 소문자로 변경합니다.

예제를 통해서 확인해보겠습니다.


3.1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.

사실 JavaBeans 규약과 다른 게 이 부분입니다.

Jackson 에서는 맨 앞 두글자가 대문자라면 이어진 모든 대문자를 소문자로 변경합니다.

  • AAaa -> aaaa : 앞 두 글자가 대문자라서 소문자로 변경
  • BBBb -> bbbb : 앞 두 글자가 대문자라서 이어진 세번째 문자까지 소문자로 변경
  • CCcC -> cccC : 앞 두 글자를 소문자로 변경하지만 맨 뒤의 대문자는 이어져 있지 않아서 그대로 사용
  • DDDD -> dddd : 앞 두 글자부터 이어진 대문자를 모두 소문자로 변경

3.1.1. DTO 정의

@ToString
@NoArgsConstructor
public class OneDto {
    private String AAaa;
    private String BBBb;
    private String CCcC;
    private String DDDD;

    public String getAAaa() {
        return AAaa;
    }

    public String getBBBb() {
        return BBBb;
    }

    public String getCCcC() {
        return CCcC;
    }

    public String getDDDD() {
        return DDDD;
    }
}

3.1.2. Controller 작성

@RestController
public class HelloController {

    @PostMapping("/one")
    public ResponseEntity<OneDto> postOne(@RequestBody OneDto dto) {
        System.out.println("----- Request POST /one ------");
        System.out.println(dto);

        return ResponseEntity.ok(dto);
    }
}
  • 실제로 요청이 왔을 때 값이 어떻게 들어오는 지 확인합니다.
  • 받은 @RequestBody 값을 그대로 다시 Response 로 내려줍니다.

3.1.3. Request

POST http://localhost:8080/one
Content-Type: application/json

{
  "AAaa": "a",
  "BBBb": "b",
  "CCcC": "c",
  "DDDD": "d"
}
  • IntelliJ 에서 제공하는 http request tool 을 사용했습니다.

3.1.4. Log

----- Request POST /one ------
OneDto(AAaa=null, BBBb=null, CCcC=null, DDDD=null)
  • Controller 에서 찍어둔 print 입니다.
  • 값이 전부 null 로 들어옵니다.

3.1.5. Response

{
  "aaaa": null,
  "bbbb": null,
  "cccC": null,
  "dddd": null
}
  • 예측한 대로 나오는 걸 확인할 수 있습니다.
  • 요청으로 들어온 OneDto 값을 그대로 리턴했을 뿐인데 Message Converter 에 의해 요청값과 응답값의 Json Key 값이 바꼈습니다.

3.2. 맨 앞 두글자가 대문자가 아니면 맨 앞 글자만 소문자로 바꿔준다

이거는 그냥 단순하게 1 번을 제외한 모든 케이스에서는 맨 앞글자만 소문자로 바꿔줍니다.

뒤에 오는 대문자나 소문자는 신경쓰지 않습니다.


3.2.1. DTO 정의

@NoArgsConstructor
public class TwoDto {
    private String aaaa;
    private String bbbB;

    private String Cccc;
    private String DddD;

    private String eEee;
    private String fFfF;

    public String getAaaa() {
        return aaaa;
    }

    public String getBbbB() {
        return bbbB;
    }

    public String getCccc() {
        return Cccc;
    }

    public String getDddD() {
        return DddD;
    }

    public String geteEee() {
        return eEee;
    }

    public String getfFfF() {
        return fFfF;
    }
}
  • DTO 를 정의하구 Controller 코드는 OneDto 와 동일하게 실행합니다.

3.2.2. Request

POST http://localhost:8080/two
Content-Type: application/json

{
  "aaaa": "a",
  "bbbB": "b",
  "Cccc": "c",
  "DddD": "d",
  "eEee": "e",
  "fFfF": "f"
}

3.2.3. Log

----- Request POST /two ------
TwoDto(aaaa=a, bbbB=b, Cccc=null, DddD=null, eEee=e, fFfF=f)
  • Cccc, DddD 를 제외한 나머지는 전부 값이 제대로 들어옵니다.

3.2.4. Response

{
  "aaaa": "a",
  "bbbB": "b",
  "cccc": null,
  "dddD": null,
  "eEee": "e",
  "fFfF": "f"
}
  • 예측한 대로 잘 나옵니다.
  • 맨 앞 글자가 대문자였던 CcccDddD 만 바뀌고 나머지는 그대로입니다.
  • 중요하게 볼 점은 TwoDto 의 필드명과 달라진 애들은 값이 제대로 들어오지 않는다는 사실입니다.

3.3. Jackson 결론

우리는 지금까지의 테스트를 통해서 한 가지 사실을 알았습니다.

DTO 의 필드명이 대문자로 시작하면 Request 요청 시 값이 제대로 들어오지 않습니다.

필드명이 대문자로 시작하면 Getter 도 대문자로 시작하는 수밖에 없습니다.

Jackson 의 규칙에 따라서 get 이후가 대문자로 시작하면 최소한 첫 글자는 항상 소문자로 바뀝니다.

따라서 필드명과 일치하지 않아 데이터가 들어가지 않는 현상입니다.

필드명을 대문자로 시작하는 경우는 많이 없지만 URL 처럼 모두 대문자로 사용했다가 안될 가능성도 있습니다.


4. Lombok 은 무슨 관계일까?

Lombok 은 개발자들이 일일히 만들어야 하는 반복적인 코드를 줄일수 있게 도와주는 라이브러리입니다.

그 중에서도 @Getter 어노테이션은 거의 모든 Object 에 필수적으로 사용됩니다.

제가 이슈를 겪었던 DTO 오브젝트도 롬복을 사용했습니다.

그렇다면 롬복의 문제점은 무엇일까요?


4.1. Lombok 의 Getter 생성 규칙

Lomobk 의 @Getter 어노테이션을 붙이면 클래스의 Getter 메소드를 자동으로 생성해줍니다.

그런데 @Getter 의 생성 규칙은 굉장히 단순합니다.

get 다음에 무조건 필드명의 맨 앞 글자를 대문자로 바꿔서 만들어줍니다.

lombok 의 Github Issue 에도 이 내용에 대한 문의가 있습니다.

제가 문제를 겪었던 필드명도 aCount 였습니다.

Lombok 이 getACount 로 생성해주고 Jackson 을 거치니 acount 가 되어서 필드명이 일치하지 않아 문제가 발생했었습니다.

반면 aaCountgetAaCount 가 되고 Jackson 을 거쳐도 aaCount 가 되어서 정상적으로 값이 들어오죠.


4.2. 인텔리제이 Generator 의 Getter 생성 규칙

public class CountDto {
    private int aCount;

    public int getaCount() {
        return aCount;
    }
}

Lombok 대신 인텔리제이에서 제공하는 제네레이터로 Getter 를 만들면 위 이슈를 회피할 수 있습니다.

getACount 대신에 getaCount 로 만들어주기 때문에 Jackson 을 거쳐도 aCount 라는 필드명과 일치합니다.


Conclusion

지금까지 정리한 내용을 요약하면 아래와 같습니다.

  1. Spring 의 Json Message Converter 는 Jackson 라이브러리를 사용
  2. lombok 의 Getter 는 필드명 맨 앞을 항상 대문자로 만듬
  3. Jackson 라이브러리는 Getter 의 맨 앞 두글자가 전부 대문자인 경우 필드명과 Json key 값이 달라짐
  4. aCount 라는 필드명을 lombok 을 사용해서 Getter 를 만들면 getACount() 가 되기 때문에 이슈가 발생

위 문제를 해결하려면 필드명을 작성할 때 첫 번째는 소문자, 두 번째는 대문자인 케이스로 만들지 않으면 됩니다.

그래도 꼭 사용해야 한다면 lombok 의 @Getter 대신 직접 Getter 를 만들거나, @JsonProperty 를 사용하면 됩니다.


Reference

Problem


주어진 배열의 원소 하나만 바꿔서 Non-decreasing Array 를 만드는 문제입니다.

문제에서 정의하는 Non-decreasing Array 란 그냥 오름차순 배열을 의미합니다.



Solution

생각보다 실수하기 쉬운 문제입니다.

우선 for 문을 돌면서 increasing 구간을 찾습니다.

하나도 없으면 true 를 리턴하고, 두 개 이상 나온다면 false 를 리턴합니다.

만약 increasing 구간이 한 개라면 nums[i]nums[i + 1] 중 하나를 바꿔줘야 합니다.

  • nums[i] 만 바꿨을 때의 반례 : [5,7,1,8]
  • nums[i + 1] 만 바꿨을 때의 반례 : [4,2,3]

그래서 nums[i] 를 바꾼 경우의 배열nums[i + 1] 를 바꾼 경우의 배열 두 가지를 각각 만들어서 체크해야 합니다.


하지만 배열을 두개나 만들어서 또 체크하는 건 시간적으로나 공간적으로 비효율적입니다.

우선 두 가지 사실을 생각해야 합니다.

  1. nums[i] 값은 다음 차례에서는 확인하지 않는다.
  2. nums[i] 값은 nums[i -1] 보다 커야한다.

1 번을 예를 들면 nums[0]nums[1] 을 비교하고 난 후에는 nums[1]nums[2] 만 비교합니다.

nums[0] 은 바뀌든 말든 의미가 없기 때문에 굳이 바꿔줄 필요가 없습니다.

2 번은 nums[i] 값과 nums[i + 1] 값 중 어느 값을 바꿔야 할지 결정할 수 있게 해줍니다.

결국 두 값 중 하나를 바꿔줘야 하는데 이걸 비교하는 시점에서는 nums[i] > nums[i + 1] 조건이 보장된 상태입니다.

만약 nums[i - 1] > nums[i + 1] 인데 nums[i] = nums[i + 1] 처리를 해버리면 무조건 false 상태가 됩니다.

nums = [10, 15, 5]

nums[i - 1] = 10
nums[i] = 15
nums[i + 1] = 5

// 설명
nums[i] 와 nums[i + 1] 중 하나를 무조건 바꿔줘야 하는데
nums[i] 를 5 로 바꿔버리면 nums[i - 1] 보다 작아진다.



Java Code

class Solution {
    public boolean checkPossibility(int[] nums) {
        boolean modified = false;

        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                if (modified) return false;
                modified = true;

                // 다음에 비교할 nums[i + 1] 값만 바꿔주면 됨
                if (i > 0 && nums[i - 1] > nums[i + 1]) {
                    nums[i + 1] = nums[i];
                }
            }
        }

        return true;
    }
}

Problem


각 돌들의 무게가 배열로 주어집니다.

가장 무거운 돌 하나와 두 번째로 무거운 돌 하나를 선택해서 둘을 부딪힙니다.

둘의 무게가 같다면 둘다 사라지고 한쪽이 더 크다면 작은 쪽은 사라지고 큰 놈은 두 돌의 무게의 차이 만큼 남습니다.

돌이 하나만 남을 때까지 반복했을 때 마지막으로 남은 돌의 무게는 얼마인지 구하는 문제입니다.



Solution 1 - 우선순위 큐

우선순위 큐를 사용합니다.

일반적으로 PriorityQueue<Integer> 는 작은 숫자가 먼저 나오기 때문에 Collections.reverseOrder() 를 사용하여 큰 수가 먼저 나오도록 선언합니다.

우선순위 큐가 비거나 size 가 1 이 될 때까지 두번씩 poll 하며 돌의 무게를 비교합니다.

둘이 같은 경우는 사라져야 하기 때문에 pq 에 추가하지 않습니다.

만약 돌의 무게가 다르다면 먼저 뽑은 y 가 더 큰 돌이므로 y - x 값을 pq 에 넣습니다.

pq 에 하나만 남는다면 무게를 리턴하고 아니면 0 을 리턴합니다.


Java Code 1

class Solution {
    public int lastStoneWeight(int[] stones) {
        PriorityQueue<Integer> pq = new PriorityQueue<>(Collections.reverseOrder());

        for (int stone : stones) {
            pq.add(stone);
        }

        while (pq.size() > 1) {
            int y = pq.poll();
            int x = pq.poll();

            if (y != x) {
                pq.add(y - x);
            }
        }

        return pq.isEmpty() ? 0 : pq.poll();
    }
}



Solution 2 - 정렬

방법 자체는 단순하게 두 돌을 구한 뒤 한쪽은 없애고 차이만 남기면 됩니다.

그렇다면 문제는 돌 두개를 어떻게 고르느냐 하는 건데 전 그냥 정렬을 사용했습니다.

정렬하면 가장 끝에 있는 두 돌이 가장 무거운 돌들입니다.

정렬 후 : [..., x, y]

xy 를 부딪히면 x 는 같거나 작기 때문에 무조건 없어지고 y = y - x 가 됩니다.

y - x 값을 배열에 다시 넣고 Arrays.copyOf 으로 마지막 인덱스를 없애줍니다.

이런식으로 쭉 반복하면 마지막에는 돌 하나만 남게됩니다.


Java Code 2

class Solution {
    public int lastStoneWeight(int[] stones) {
        for (int i = stones.length - 1; i > 0; i--) {
            Arrays.sort(stones);
            stones[i - 1] = stones[i] - stones[i - 1];
            stones = Arrays.copyOf(stones, i);
        }

        return stones[0];
    }
}

Problem


주어진 문자열을 섞어서 "balloon" 을 몇 개 만들 수 있는지 구하는 문제입니다.



Solution

String 을 순회하면서 balloon 각 문자의 갯수를 카운트 하면 됩니다.

문자 한 세트가 있어야 한 단어가 만들어지므로 가능한 balloon 의 갯수는 b, a, l, o, n 의 갯수의 최소값입니다.

lo 는 두 번씩 나와야 단어가 완성되므로 나누기 2 해줍니다.



Java Code

class Solution {
    public int maxNumberOfBalloons(String text) {
        int[] count = new int[26];
        int min = Integer.MAX_VALUE;

        for (char c : text.toCharArray()) {
            count[c - 'a']++;
        }

        count['l' - 'a'] /= 2;
        count['o' - 'a'] /= 2;

        for (char c : "balloon".toCharArray()) {
            min = Math.min(min, count[c - 'a']);
        }

        return min;
    }
}

사실 그냥 단순하게 풀어도 됩니다.

class Solution {
    public int maxNumberOfBalloons(String text) {
        int b = 0, a = 0, l = 0, o = 0, n = 0;

        for (char ch : text.toCharArray()) {
            if (ch == 'b') b++;
            else if (ch =='a') a++;
            else if (ch == 'l') l++;
            else if (ch == 'o') o++;
            else if (ch == 'n') n++;
        }

        return Math.min(Math.min(b, a), Math.min(Math.min(l / 2, o / 2), n));
    }
}

Overview

토이 프로젝트를 하면서 개발 초반에 작성했던 코드를 보았습니다.

JPA 메서드로 조건을 걸어서 가져오는 로직인데 실제로 쿼리가 날라가는 걸 확인해보니 제가 생각한 것과 다르게 동작하는 걸 알게 되었습니다.


1. Entity

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column
    private String email;

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();
}


@Entity
public class Post {

    @Id @GeneratedValue
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Column
    private String content;
}

일반적인 두 개의 Entity 가 존재합니다.

사용자 Member 는 여러 개의 게시글 Post 를 작성할 수 있으므로 1:N 관계입니다.


2. Repository

public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findAllByMember(Member member);
    List<Post> findAllByMemberId(Long memberId);
}

이제 post 테이블에서 post.member_id 를 기준으로 일치하는 데이터들을 가져오는 메서드를 작성합니다.

가능한 메서드는 두 종류가 있습니다.

  1. Member 라는 엔티티를 조건으로 검색하는 findAllByMember
  2. memberId 라는 필드값을 조건으로 검색하는 findAllByMemberId

결론부터 말하자면 1 번 메서드를 사용해야 합니다.


3. Test

두 메서드를 사용했을 때 실제로 쿼리가 어떻게 날라가는 지 확인해보겠습니다.


3.1. Member ID 로 조회

@Test
void testFindPosts() {
    Long memberId = 2L;
    postRepository.findAllByMemberId(memberId);
}

제가 처음에 작성했던 코드입니다.

  • 이유는 단순하게 memberId 값은 이미 알고 있는 데 굳이 Member 엔티티를 한번 조회해야 할 필요가 있을까??
  • Post 에도 member_id 라는 필드가 있으니까 바로 조회하자!

라는 생각으로 사용했습니다.

SELECT * FROM post WHERE post.member_id = ? 를 기대했지만 실제로 날라가는 쿼리는 달랐습니다.


select
    post0_.post_id as post_id1_5_,
    post0_.content as content5_5_,
    post0_.member_id as member_i8_5_,
from
    post post0_ 
left outer join
    member member1_ 
        on post0_.member_id=member1_.member_id 
where
    member1_.member_id=?

예상과는 달리 LEFT OUTER JOIN 쿼리가 발생합니다.


3.2. Member 엔티티로 조회

@Test
void testFindPosts() {
    Long memberId = 2L;
    Member member = memberRepository.findById(memberId).get();
    postRepository.findAllByMember(member);
}

Member 를 한번 조회한 후에 조건으로 엔티티를 넣어봅니다.


select
    post0_.post_id as post_id1_5_,
    post0_.content as content5_5_,
    post0_.member_id as member_i8_5_,
from
    post post0_ 
where
    post0_.member_id=?

우리가 원하던 쿼리가 정상적으로 날라갑니다.


3.3. Query 를 직접 짜서 조회

@Query(value = "SELECT p FROM Post p WHERE p.member.id = :memberId")
List<Post> findAllByMemberIdQuery(@Param("memberId") Long memberId);

만약 극한의 성능 최적화를 해야 한다면 이렇게 직접 쿼리를 짜는 방법도 있습니다.

정상적으로 동작합니다.


Conclusion

처음에는 단순하게 memberId 정보를 내가 갖고 있고 Post 엔티티에도 memberId 필드가 존재하는데 바로 조회하면 되지 않을까?

하는 생각에서 코드를 짰었습니다.

그런데 실제로 쿼리가 날라가는 걸 확인하니 비효율적으로 동작하고 있었던 걸 알 수 있었네요.

보통 Member 데이터에 대해 검증을 하기 위해 조회를 한번 하니 쿼리 한번 아깝다고 생각하지 말고 엔티티로 조건을 거는 게 좋아보입니다.

JPA 에서 @ManyToOne 인 필드로 조건을 걸 때는 항상 Entity 를 사용하자

극한의 성능 최적화가 필요하다면 JPQL 이나 QueryDSL 로 직접 짜자

Overview

토이 프로젝트로 인스타그램 클론 코딩을 진행 중인데 여기서 1 + N 문제를 만났습니다.

인스타그램에 접속했을 때 사용자에게 보여주는 첫 화면은 내가 팔로우 중인 사람들의 최신 게시글 목록입니다.

내가 팔로우 중인 사람은 여러명이고 또 그 여러명이 여러개의 게시글을 작성할 수 있으므로 1 + N 문제가 발생합니다.


1. Domain

사용 중인 Entity 를 간단히 정의합니다.

자잘한 건 생략하고 Member, Post, Follow 세 개의 엔티티만 정의하겠습니다.


1.1. Member

@Table(name = "member")
@Entity
@Getter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String email;

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();

    @OneToMany(mappedBy = "fromMember")
    private List<Follow> followings = new ArrayList<>();

    @OneToMany(mappedBy = "toMember")
    private List<Follow> followers = new ArrayList<>();
}
  • email 정보만 갖고 있는 간단한 Member 엔티티입니다.
  • 멤버는 여러 개의 게시글을 가질 수 있고, 여러명을 팔로우 할 수 있으며 반대로 여러 명에게 팔로우 당할 수 있으므로 전부 @OneToMany 로 매핑해줍니다.

1.2. Follow

@Table(name = "follow")
@Entity
@Getter
@NoArgsConstructor
public class Follow {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "follow_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "from_member_id")
    private Member fromMember;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "to_member_id")
    private Member toMember;
}
  • Follow 에는 팔로우 하는 사람 (from) 과 팔로우 대상 (to) 의 Member ID 만 존재합니다.
  • 사용자는 팔로우라는 액션에 대해서 N:M 관계입니다.
  • 그래서 Follow 라는 관계 테이블을 하나 생성해서 @ManyToOne 으로 매핑해줍니다.

1.3. Post

@Table(name = "post")
@Entity
@Getter
@NoArgsConstructor
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @Column(columnDefinition = "text")
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}
  • content 정보만 갖고 있는 Post 엔티티입니다.
  • 멤버 한명은 여러 개의 게시글을 작성할 수 있기 때문에 @ManyToOne 으로 매핑해줬습니다.


2. 명세

제가 구하고자 하는건 인스타 피드입니다.

로그인 한 내 정보를 갖고 있고, 내가 팔로우한 사용자들을 follow 테이블에서 가져와야 합니다.

그리고 그 사용자들의 member_id 와 일치하는 데이터들을 post 테이블에서 뽑아야 합니다.

하지만 내가 팔로우 하는 대상이 엄청 많고 또 그 사람들이 작성한 글도 엄청 많다면 어떻게 될까요?

클라이언트에게 데이터를 내려주는 것은 물론이고 DB 에서 조회하는 것부터 오래걸릴 겁니다.

따라서 모든 데이터를 한번에 내려주지 말고 당장 필요한 데이터만 짤라서 전달해주는 페이지네이션 기법이 필요합니다.

인스타는 페이지 형식이 아니라 무한 스크롤 형식이므로 lastPostId 를 받아와서 offset 으로 사용하고 Post 데이터는 최대 5 개만 내려주도록 구현하려고 합니다.


2.1. 단순하게 Entity Collection 호출

public List<Post> getFeeds(Long lastPostId) {
    Member currentMember = getCurrentMember();

    List<Post> posts = new ArrayList<>();

    // 내가 팔로우 하는 "모든" 대상들이 작성한 "모든" 게시글을 가져옴
    currentMember.getFollowings()
            .stream()
            .map(Follow::getToMember)
            .forEach(member -> posts.addAll(member.getPosts()));

    return posts.stream()
            .filter(post -> post.getId() < lastPostId)
            .sorted((a, b) -> (int) (b.getId() - a.getId()))
            .limit(5L)
            .collect(Collectors.toList());
}
  • 가장 심플하게 조회하는 방법입니다.
  • getCurrentMember() 는 로그인 된 내 정보를 가져옵니다.
  • 내가 팔로우 하고 있는 대상들을 getFollowings() 메서드로 구합니다.
  • 멤버 정보를 조회하여 getPosts() 메서드로 게시글들을 가져옵니다.
  • 구한 모든 게시글들을 필터링 하고 최신순으로 만큼만 잘라서 리턴합니다.

-- 팔로우 하는 모든 대상 구하기
SELECT * FROM follow WHERE follow.from_member_id = ?

-- 첫 번째 팔로우 유저의 정보를 가져와서 게시글 가져오기
SELECT * FROM member WHERE member.member_id = ?
SELECT * FROM post WHERE post.member_id = ?

-- 두 번째 팔로우 유저의 정보를 가져와서 게시글 가져오기
SELECT * FROM member WHERE member.member_id = ?
SELECT * FROM post WHERE post.member_id = ?

-- 팔로우 대상들 만큼 반복..
-- ...
  • 내가 팔로우 중인 대상의 수 * 2 만큼 추가 쿼리가 나갑니다.

2.2. Fetch Join

1 + N 문제를 해결하기 위한 가장 일반적인 방법입니다.

여러 개의 쿼리를 날리지 않고 쿼리 한번에 데이터를 가져옵니다.


2.2.1. 사용

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {

    @Query(value = "SELECT p" +
            " FROM Post p" +
            " JOIN FETCH p.member m" +
            " JOIN FETCH m.followers f" +
            " WHERE f.fromMember.id = :memberId AND p.id < :lastPostId")
    List<Post> findByFetchJoin(@Param("memberId") Long memberId, @Param("lastPostId") Long lastPostId, Pageable pageable);
}
  • 쿼리를 날리는 PostRepository 입니다.
  • 인스타 피드 조건에 맞는 데이터를 쿼리로 한번에 뽑아오기 때문에 추가 쿼리가 발생하지 않습니다.
  • Fetch Join 은 연관된 테이블끼리만 사용 가능한데, FollowPost 는 직접적인 연관 관계가 없기 때문에 Member 까지 같이 조인해줘야 합니다.

public List<Post> getFeeds(Long lastPostId) {
    Member currentMember = getCurrentMember();
    PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id").descending());

    return postRepository.findByFetchJoin(currentMember.getId(), lastPostId, pageRequest);
}
  • PageRequest.of(0, 5) 을 사용해서 일정한 사이즈 만큼의 데이터를 가져옵니다.
  • 무한 스크롤은 lastPostId 를 사용해서 데이터를 필터링 하기 때문에 무조건 0 번째 페이지를 가져오도록 설정했습니다.

2.2.2. Fetch Join Paging 결과가 실제로는 어떻게 나올까?

o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • Fetch Join 과 Pageable 을 함께 사용하면 나오는 로그입니다.
  • 둘을 같이 사용하면 LIMIT 쿼리가 제대로 적용되지 않습니다.
  • DB 에서 Fetch Join 한 결과물을 모두 가져온 후 애플리케이션 메모리에서 직접 골라내기 때문에 데이터 수가 많다면 OutOfMemory 에러가 발생할 가능성이 높아집니다.

SELECT *
FROM post
INNER JOIN member ON post.member_id = member.member_id
INNER JOIN follow ON member.member_id = follow.to_member_id
WHERE follow.from_member_id = ? AND post.post_id < ?
ORDER BY post.post_id DESC
  • 실제로 날라가는 쿼리입니다.
  • 분명 Pageable 을 사용했음에도 불구하고 LIMIT 조건이 추가되지 않는 것을 볼 수 있습니다.
  • 왜 이런 결과가 발생하는지는 여기서 너무 깊게 들어가면 내용이 길어지기 때문에 생략하겠습니다.
  • 어쨌든 Fetch Join 과 Pageable 은 함께 사용 불가능합니다.

2.3. Batch Size

1 + N 문제의 또다른 해결법으로 알려진 Batch Size 는 어떨까요?


spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100   # 전역으로 배치 사이즈 적용
  • 100 정도만 적용해서 2.1. 번의 Collection 호출하는 코드를 다시 실행시켜 보겠습니다.

-- 내가 팔로우 한 사람들 목록 조회 (member.getFollowings())
SELECT * FROM follow WHERE follow.from_member_id = ?

-- 내가 팔로우 한 사람들의 정보들을 IN 쿼리로 조회 (follow.getToMember())
SELECT * FROM member WHERE member.member_id IN (?, ?, ...)

-- 내가 팔로우 한 사람들의 게시글 조회 (member.getPosts())
SELECT * FROM post WHERE post.member_id IN (?, ?, ...)
  • 쿼리의 갯수가 줄어들어 N + 1 문제가 해결되었습니다.
  • 그러나 LIMIT 쿼리를 넣지 못해서 모든 데이터를 애플리케이션 레이어로 가져온 후에 처리합니다.

  • 영한님에게 한번 질문했었는데 OOM 이슈가 있기 때문에 적절한 해결방법이 아닙니다.
  • 만약, 데이터가 일정 개수 이하라는 보장이 있다면 Batch Size 적용만으로도 충분할 것 같습니다.

2.4. IN 쿼리로 조회

post 테이블에 member_id 가 존재하는데 굳이 테이블 조인을 해야하나?

해당하는 member_id 리스트를 구한 다음에 IN 쿼리를 쓰면 조인할 필요도 없고 간단하지 않을까?

하는 생각에서 시도해봤습니다.


@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findByIdLessThanAndMemberIn(Long lastPostId, List<Member> members, Pageable pageable);
}
  • JPA 메서드로 한번에 작성했습니다.
  • ByIdLessThan : lastPostId 보다 Post ID 값이 작은 게시글들만 가져옵니다.
  • MemberIn : post.member_id 기준으로 IN 쿼리를 추가합니다.

public List<Post> getFeeds(Long lastPostId) {
    Member currentMember = getCurrentMember();

    List<Member> followings = currentMember.getFollowings()
            .stream()
            .map(Follow::getToMember)
            .collect(Collectors.toList());

    PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id").descending());
    return postRepository.findByIdLessThanAndMemberIn(lastPostId, followings, pageRequest);
}
  • getFollowings() 로 팔로우 대상들을 먼저 가져옵니다.
  • post 테이블을 조회할 때 대상 ID 들을 IN 쿼리에 넣어줍니다.

-- 팔로우 대상들 가져오기
SELECT * FROM follow WHERE follow.from_member_id = ?

-- IN 쿼리를 사용해서 팔로우 대상들이 작성한 게시글들 전부 가져오기
SELECT * 
FROM post 
WHERE post.post_id < ? AND post.member_id IN (?, ?, ...) 
ORDER BY post.post_id DESC 
LIMIT ?
  • 쿼리 한 두번에 데이터를 모두 가져오고 LIMIT 쿼리도 정상적으로 날라갑니다.
  • IN 쿼리 조건 컬럼에 인덱스만 걸려있다면 성능도 보장됩니다.

쿼리만 보면 성능적으로 많이 개선되었습니다.

그러나 여기엔 한 가지 함정이 있습니다.

만약 followings 의 사이즈가 엄청나게 많다면??

IN 쿼리는 분명 효율적이긴 하지만 갯수가 1000 이 넘어가면 역시 성능적인 문제를 피할 수 없습니다.

그래도 IN 쿼리를 사용하겠다면 임의로 1000 개씩 자른 후 나누어서 호출해야 합니다.


2.5. 일반 JOIN + LIMIT JPQL

위 코드에서 말했던 것처럼 IN 쿼리를 사용하기 위해 구한 followings 값 역시 엄청나게 큰 값이 될 수 있습니다.

따라서 다른 방법을 생각해봐야 하는데, 제가 실무에서 JPA 를 사용하지 않으니 어떤 방법으로 해야 할 지 감이 오지 않았습니다.

그래서 인프런에서 JPA 권위자이신 김영한 님에게 질문을 드렸고 답변을 받았습니다.

솔루션은 JPQL 로 Join, Limit 조건을 작성해서 직접 post 테이블을 조회하는 거였습니다.

JPA 에서 Fetch Join 은 Pageable 사용이 불가능하지만 일반 Join 은 사용할 수 있습니다.

사실 생각해보면 단순한 거였는데 바보 같이 네이티브 쿼리를 짜지 않는 방법만 찾다 보니 멀리 돌아왔습니다.


@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @Query(value = "SELECT p" +
            " FROM Post p" +
            " JOIN Follow f" +
            " ON p.member.id = f.toMember.id" +
            " WHERE f.fromMember.id = :memberId AND p.id < :lastPostId")
    List<Post> findByJoinFollow(@Param("memberId") Long memberId, @Param("lastPostId") Long lastPostId, Pageable pageable);
}
  • JPQL 로 직접 짰는데 QueryDSL 을 적용하면 좀더 이쁘게 나올 것 같네요.

public List<Post> getFeeds(Long lastPostId) {
    PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id").descending());
    return postRepository.findByJoinFollow(getCurrentMember().getId(), lastPostId, pageRequest);
}
  • 쿼리는 다 짜놓았기 때문에 호출만 하면 됩니다.

SELECT *
FROM post
INNER JOIN follow ON post.member_id = follow.to_member_id
WHERE post.post_id < ? AND follow.from_member_id = ?
ORDER BY post.post_id DESC
LIMIT ?
  • 단 하나의 쿼리로 원하는 데이터를 가져옵니다 !

Conclusion

@OneToMany 조건이 걸려있는 컬렉션을 호출할 때는 성능상 문제가 없는 지 고민이 필요합니다.

만약 데이터가 너무 많다면 Limit 조건을 추가해서 DB 에서 가져오는 데이터 크기를 조절해야 합니다.

이런 경우 1 + N 문제의 일반적인 해결책인 Fetch Join 이나 Batch Size 를 사용할 수 없었고 결국 네이티브 쿼리로 해결했습니다.

네이티브 쿼리를 짜지 않고 Entity 내에서 해결하는 것이 JPA 스럽게 작성하는 거라고 생각했는데 잘못 생각하고 있었던 것 같습니다.

성능 최적화를 위해선 복잡한 쿼리도 작성할 수 밖에 없다는 점을 깨달았네요.

JPQL 로 짜고 보니 QueryDSL 의 필요성을 느끼게 되었고, 귀찮은 질문에도 친절하게 답변해주신 영한님의 강의를 구매했습니다.

1. Java Exception

Java 에는 Checked ExceptionUnchecked Exception 이 존재합니다.

이 둘은 헷갈리기 쉽지만 사실 큰 차이가 존재합니다.

Checked Unchecked
예외 처리 필수 필수 아님
트랜잭션 롤백 안됨 기본값으로 들어있어서 진행
검증 컴파일 단계 런타임 단계

1.1. Checked Exception

  • 예외 처리 필수
    • try catch 로 잡아서 예외를 처리하거나 상위 메소드로 넘겨줘야함
  • Transaction 기본 롤백 대상이 아니라서 롤백 처리하려면 추가 처리 필요
  • 컴파일 단계에서 체크

1.2. Unchecked Exception (RuntimeException)

  • 예외 처리 필수 아님
    • 예측할 수 없는 예외라서 필수 처리 불가능
  • Transaction 롤백 대상
    • @Transactional rollbackFor 에 기본 옵션으로 들어가있기 때문에 예외 발생 시 롤백 처리됨
  • 런타임 단계에서 체크



2. Spring Exception 의 HTTP Status

Spring 에는 HTTP Status 응답 처리를 위한 여러가지 방법이 있습니다.


2.1. @ResponseStatus

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Data Not Found")
public class DataNotFoundException extends RuntimeException { }

Spring 3 부터는 HTTP Status 와 Response 를 제공하는 @ResponseStatus 어노테이션이 생겼습니다.

개발자가 정의한 Exception 이 발생하면 해당 Status 와 Message 를 전달합니다.

테스트를 위해 Controller 에서 강제로 Exception 을 발생 시켜보겠습니다.


2.1.1. @ResponseStatus 없이 그냥 NotFoundException("No Data")

{
  "timestamp": "2021-03-13T07:23:00.732+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "No Data",
  "path": "/auth/signup"
}

2.1.2. @ResponseStatus 정의된 Custom Exception 사용

{
  "timestamp": "2021-03-13T07:30:38.299+00:00",
  "status": 404,
  "error": "Not Found",
  "message": "Data Not Found",
  "path": "/auth/signup"
}

2.1.3. 한계점

@ResponseStatus 에 정의한 대로 잘 나오는 걸 확인할 수 있습니다.

이 방법은 별다른 설정 없이 어노테이션 추가만으로 간단하게 Custom Exception 을 만들 수 있지만, 한 가지 단점이 있습니다.

위에서 예시로 데이터가 없는 경우 DataNotFoundException 를 리턴하게 했는데, 이 Exception 은 항상 동일한 HTTP Status 와 Message 를 리턴합니다.

같은 Exception 이 발생하는 상황이더라도 다른 Message 를 보내는게 불가능합니다.


2.2. ResponseStatusException

ResponseStatusException@ResponseStatus 의 대체제로 Spring 5 에 등장했습니다.

RuntimeException 을 상속하며 마찬가지로 HTTP Status 와 Message 를 설정할 수 있습니다.

public ResponseStatusException(
  HttpStatus status, 
  @Nullable String reason, 
  @Nullable Throwable cause
) {}
  • status: HTTP Status
  • reason: HTTP response Message
  • cause: ResponseStatusException 을 발생시킨 Exception

스프링에서는 HandlerExceptionResolver 가 모든 exception 을 가로채서 처리합니다.

이 중에서 ResponseStatusExceptionResolver 라는 클래스가 ResponseStatusException 또는 @ResponseStatus 어노테이션이 붙은 Exception 을 찾아서 처리해줍니다.


다음과 같은 장점이 있습니다.

  • 비슷한 유형의 예외를 별도로 처리할 수 있고, 응답마다 다른 상태 코드를 세팅 가능합니다.
  • 불필요한 Exception 클래스 생성을 피할 수 있습니다.
  • Exception 처리를 추가적인 어노테이션 없이 코드 단에서 자연스럽게 처리할 수 있습니다.



3. Spring 전역으로 공통 Exception 처리하기

2 번에서 Exception 에 따라 HTTP Status 제공하는 여러 가지 방법을 알아봤는데, 토이 프로젝트를 하면서 만들어보니 위 설정대로 쓸 일이 없었습니다.


throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No Data");
  • Spring 5 부터 제공하는 ResponseStatusException 의 일반적인 사용입니다.
  • 생성자로 HTTP Status 와 String 만을 받습니다.
  • 만약 없는 데이터의 종류가 다르다면 ?? No Member Data, No Profile Data 등등.. 이렇게 표현해야 합니다.
  • 하지만 이렇게 명확한 규격 없이 String 으로만 받으면 여러 사람들이 작업할 때 중복된 응답을 주거나 이미 있는 응답을 새로 만들거나, 오타로 인해 실수할 가능성도 있습니다.

위와 같은 이유로 Spring Exception 처리는 다른 방법으로 하게 되었습니다.

앞으로 사용할 클래스들입니다.

  • ErrorCode : 핵심. 모든 예외 케이스를 이곳에서 관리함
  • CustomException : 기본적으로 제공되는 Exception 외에 사용
  • ErrorResponse : 사용자에게 JSON 형식으로 보여주기 위해 에러 응답 형식 지정
  • GlobalExceptionHandler : Custom Exception Handler
    • @ControllerAdvice : 프로젝트 전역에서 발생하는 Exception 을 잡기 위한 클래스
    • @ExceptionHandler : 특정 Exception 을 지정해서 별도로 처리해줌

3.1. Error 관련 properties 설정

단순한 설정입니다.

작성하지 않아도 상관 없으며 필요한 경우에 참고해서 설정하시면 됩니다.

# application.yml
server:
  error:
    include-exception: false      # Response 에 Exception 을 표시할지
    include-message: always       # Response 에 Exception Message 를 표시할지 (never | always | on_param)
    include-stacktrace: on_param  # Response 에 Stack Trace 를 표시할지 (never | always | on_param) on_trace_params 은 deprecated
    whitelabel.enabled: true      # 에러 발생 시 Spring 기본 에러 페이지 노출 여부 

3.2. ErrorCode

@Getter
@AllArgsConstructor
public enum ErrorCode {

    /* 400 BAD_REQUEST : 잘못된 요청 */
    INVALID_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰이 유효하지 않습니다"),
    MISMATCH_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰의 유저 정보가 일치하지 않습니다"),
    CANNOT_FOLLOW_MYSELF(BAD_REQUEST, "자기 자신은 팔로우 할 수 없습니다"),

    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */
    INVALID_AUTH_TOKEN(UNAUTHORIZED, "권한 정보가 없는 토큰입니다"),
    UNAUTHORIZED_MEMBER(UNAUTHORIZED, "현재 내 계정 정보가 존재하지 않습니다"),

    /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */
    MEMBER_NOT_FOUND(NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다"),
    REFRESH_TOKEN_NOT_FOUND(NOT_FOUND, "로그아웃 된 사용자입니다"),
    NOT_FOLLOW(NOT_FOUND, "팔로우 중이지 않습니다"),

    /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */
    DUPLICATE_RESOURCE(CONFLICT, "데이터가 이미 존재합니다"),

    ;

    private final HttpStatus httpStatus;
    private final String detail;
}
  • 에러 형식을 Enum 클래스로 정의합니다.
  • 응답으로 내보낼 HttpStatus 와 에러 메세지로 사용할 String 을 갖고 있습니다.
  • ResponseStatusException 과 비슷해 보입니다. 하지만 가장 큰 차이점은 개발자가 정의한 새로운 Exception 을 모두 한 곳에서 관리하고 재사용 할 수 있다는 점입니다.

3.3. CustomException

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;
}
  • 전역으로 사용할 CustomException 입니다.
  • RuntimeException 을 상속받아서 Unchecked Exception 으로 활용합니다.
  • 생성자로 ErrorCode 를 받습니다.

3.4. ErrorResponse

@Getter
@Builder
public class ErrorResponse {
    private final LocalDateTime timestamp = LocalDateTime.now();
    private final int status;
    private final String error;
    private final String code;
    private final String message;

    public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorCode errorCode) {
        return ResponseEntity
                .status(errorCode.getHttpStatus())
                .body(ErrorResponse.builder()
                        .status(errorCode.getHttpStatus().value())
                        .error(errorCode.getHttpStatus().name())
                        .code(errorCode.name())
                        .message(errorCode.getDetail())
                        .build()
                );
    }
}
  • 실제로 유저에게 보낼 응답 Format 입니다.
  • 일부러 500 에러 났을 때랑 형식을 맞췄습니다. status, code 값은 사실 없어도 됩니다.
  • ErrorCode 를 받아서 ResponseEntity<ErrorResponse> 로 변환해줍니다.

3.5. @ControllerAdvice and @ExceptionHandler

@ControllerAdvice 는 프로젝트 전역에서 발생하는 모든 예외를 잡아줍니다.

@ExceptionHandler 는 발생한 특정 예외를 잡아서 하나의 메소드에서 공통 처리해줄 수 있게 해줍니다.

따라서 둘을 같이 사용하면 모든 예외를 잡은 후에 Exception 종류별로 메소드를 공통 처리할 수 있습니다.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class})
    protected ResponseEntity<ErrorResponse> handleDataException() {
        log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE);
        return ErrorResponse.toResponseEntity(DUPLICATE_RESOURCE);
    }

    @ExceptionHandler(value = { CustomException.class })
    protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
        log.error("handleCustomException throw CustomException : {}", e.getErrorCode());
        return ErrorResponse.toResponseEntity(e.getErrorCode());
    }
}
  • View 를 사용하지 않고 Rest API 로만 사용할 때 쓸 수 있는 @RestControllerAdvice 를 사용합니다.
  • handleDataException 메소드에서는 hibernate 관련 에러를 처리합니다.
  • handleCustomException 메소드는 직접 정의한 CustomException 을 사용합니다.
  • Exception 발생 시 넘겨받은 ErrorCode 를 사용해서 사용자에게 보여주는 에러 메세지를 정의합니다.

3.6. Exception 사용

@RequiredArgsConstructor
@Service
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public boolean follow(Long memberId) {
        Member currentMember = getCurrentMember();

        // 팔로우할 상대방 정보가 없는 경우
        Member targetMember = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND));

        // 자기 자신을 팔로우 하려는 경우
        if (currentMember.equals(targetMember))  {
            throw new CustomException(CANNOT_FOLLOW_MYSELF);
        }

                // code...
    }
}
  • 토이 프로젝트의 일부 코드입니다.
  • 상대방의 Member ID 를 입력 받아서 팔로우 하는 기능입니다.
  • Exception 에 담겨지는 ErrorCode 만 보고도 어떤 종류의 문제가 발생한 건지 알 수 있습니다.
  • 또한 불필요하게 여러 Exception 을 만들지 않고 ErrorCode 만 새로 추가하면 사용 가능합니다.

3.6.1. MEMBER_NOT_FOUND 실제 응답

{
  "timestamp": "2021-03-14T03:29:01.878659",
  "status": 404,
  "error": "NOT_FOUND",
  "code": "MEMBER_NOT_FOUND",
  "message": "해당 유저 정보를 찾을 수 없습니다"
}

3.6.2. CANNOT_FOLLOW_MYSELF 실제 응답

{
  "timestamp": "2021-03-14T03:16:25.98361",
  "status": 400,
  "error": "BAD_REQUEST",
  "code": "CANNOT_FOLLOW_MYSELF",
  "message": "자기 자신은 팔로우 할 수 없습니다"
}

3.7. 결론

Spring 에는 프로젝트 전역에서 발생하는 Exception 을 한 곳에서 처리할 수 있다.

Enum 클래스로 ErrorCode 를 정의하면 Exception 클래스를 매번 생성하지 않아도 된다.

실제 클라에게 날라가는 응답에서 code 부분만 확인하면 어떤 에러가 발생했는지 쉽게 파악 가능하다.


Reference

Overview

Java 설치 방법과 여러 개의 버전을 사용할 때 어떤 식으로 변경하는지 알아봅시다.

여러 Java (JDK) 버전을 사용하는 경우 원하는 버전을 기본으로 설정할 수 있습니다.


1. Java (OpenJDK) 설치

1.1. adoptopenjdk/openjdk 저장소 추가

$ brew tap adoptopenjdk/openjdk

1.2. cask 가 없다면 설치

$ brew install cask

1.3. OpenJDK 8 과 11 을 설치

$ brew install --cask adoptopenjdk8
$ brew install --cask adoptopenjdk11
  • 하나의 버전만 사용한다면 하나만 설치하면 됩니다.

1.4. 설치 여부 확인

$ java -version
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_292-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.292-b10, mixed mode)
  • Java 버전 확인을 확인해서 잘 나오면 설치가 완료된 겁니다.

2. Java (JDK) 버전 변경

2.1. 설치된 JDK 버전 확인

$ /usr/libexec/java_home -V

Matching Java Virtual Machines (2):
    11.0.11 (x86_64) "AdoptOpenJDK" - "AdoptOpenJDK 11" /Library/Java/JavaVirtualMachines/adoptopenjdk-11.jdk/Contents/Home
    1.8.0_292 (x86_64) "AdoptOpenJDK" - "AdoptOpenJDK 8" /Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home
/Library/Java/JavaVirtualMachines/adoptopenjdk-11.jdk/Contents/Home
  • 저는 현재 JDK 11 과 JDK 8 버전이 설치되어 있습니다.

2.2. JDK 버전 변경

# 1.8 버전으로 변경
$ export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)

# 11 버전으로 변경
$ export JAVA_HOME=$(/usr/libexec/java_home -v 11)

2.3. JDK 변경 확인

$ java -version
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_292-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.292-b10, mixed mode)

$ echo $JAVA_HOME
/Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home
  • $JAVA_HOME 도 변경된 것을 확인 할 수 있습니다.

3. 기본 Java 버전 적용

프로젝트에 따라서 여러 개의 Java 버전을 사용해야 하는 경우가 있습니다.

자주 사용하는 Java 버전을 기본으로 세팅하고 싶다면 bash 를 사용하는 경우 ~/.bash_profile, zsh 를 사용하는 경우 ~/.zshrc 파일 가장 하단에 아래 코드를 한줄 추가해주면 됩니다.

JDK 버전 변경 때 사용했던 명령어와 동일합니다.

# 1.8 버전을 기본으로
$ export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)

# 11 버전을 기본으로
$ export JAVA_HOME=$(/usr/libexec/java_home -v 11)

+ Recent posts