Problem


로봇이 존재합니다.

로봇은 3 개의 명령어만 듣습니다.

  • G: 앞으로 한칸 이동
  • L: 왼쪽으로 90 도 회전
  • R: 오른쪽으로 90 도 회전

로봇이 수행해야하는 일련의 명령어 리스트인 instructions 이 주어졌을 때, 이 로봇이 영원히 같은 위치를 순회하는 지 판단하는 문제입니다.



Solution

로봇을 움직이는건 굉장히 단순합니다.

(0, 0) 좌표에서 시작하여 처음 방향은 0 으로 설정합니다.

모든 행동을 완료한 후에 로봇의 상태만 확인하면 됩니다.

  1. 행동을 완료했을 때 (0, 0) 위치에 있다면 몇번을 해도 같은 자리를 반복한다.
  2. 행동을 완료했을 때 (0, 0) 위치는 아니지만 다른 방향을 보고 있다.

1 번은 너무 당연한 겁니다.

2 번은 example 3 에도 나와 있습니다.

다른 위치에 도달했을 때, 다른 방향을 보고 있다면 두번째 instructions 은 그 위치와 방향에서 새로 시작합니다.

그렇기 때문에 최대 4 번을 반복하면 원래 위치로 돌아옵니다.

반복문을 4 번 돌리면서 (0, 0) 위치에 돌아왔는지 확인해도 되지만, 위와 같은 방식으로 쉽게 확인할 수 있습니다.



Java Code

class Solution {
    public boolean isRobotBounded(String instructions) {
        int[] dx = {-1, 0, 1, 0};
        int[] dy = {0, 1, 0, -1};
        int x = 0, y = 0, dir = 0;

        for (char instruction : instructions.toCharArray()) {
            if (instruction == 'G') {
                x += dx[dir];
                y += dy[dir];
            } else if (instruction == 'L') {
                dir = (dir + 1) % 4;
            } else {
                dir = (dir + 3) % 4;
            }
        }

        // 1. 싸이클 완료후에 (0, 0) 에 있는지
        // 2. 또는 위치가 바꼈더라도 초기 방향과 다른 방향을 보고 있다면 
        //    4번 순회했을 시 원래 자리로 돌아옴
        return x == 0 && y == 0 || dir != 0;
    }
}

Problem


주어진 배열을 3 등분 합니다.

나뉘어진 각 부분배열들의 모든 합이 똑같게 되도록 3등분 할 수 있는지 묻는 문제입니다.



Solution

아이디어를 알면 쉽게 풀 수 있는데 모르면 굉장히 복잡해집니다.

가장 중요한 포인트는 다음 세개입니다.

  1. non-empty 배열. 즉, 부분 배열에는 최소 하나의 원소가 존재
  2. n 등분이 아니라 3 등분으로 숫자가 고정되어 있음
  3. 모든 부분 배열의 합은 같음

위 조건들로 우리는 몇 가지 사실을 알 수 있습니다.

  1. 주어진 배열의 합이 정확히 3 으로 나누어 떨어져야 함
  2. 부분 배열의 합은 sum / 3 으로 고정되어 있음

부분 배열의 합을 미리 알고 있기 때문에 좀더 수월하게 답을 구할 수 있습니다.

각 부분 배열의 누적합 partition 이 목표값인 goal 에 도달하면 갯수를 하나씩 증가시킵니다.

count 가 3 이 되는 순간 조건이 성립합니다.

혹시 남은 원소가 있더라도 부분 배열의 합이 무조건 goal 이기 때문에 남은 원소의 합은 0 이 될 겁니다.



Java Code

class Solution {
    public boolean canThreePartsEqualSum(int[] arr) {
        int sum = 0;
        for (int a : arr) { sum += a; }

        // 3등분 했을 때 모두 같아야 하기 때문에 한 파트의 합은 무조건 sum/3 이다.
        int goal = sum / 3; 

        if (sum % 3 != 0) return false;

        int partition = 0, count = 0;
        for (int a : arr) {
            partition += a;

            if (partition == goal) {
                partition = 0;
                count++;
            }

            if (count == 3) {
                return true;
            }
        }

        return false;
    }
}

Problem


2진수 두개를 받아서 합한 결과를 리턴하는 문제입니다.



Solution

처음에는 2진수 -> 10진수 변환 후 더하려고 했는데 2진수 길이가 너무 길어서 그런지 NumberFormatException 이 발생했습니다.

그래서 그냥 1 의 자리부터 더한 다음에 뒤집는 방식으로 구현했습니다.

Stack 자료구조를 써도 됩니다.



Java Code

class Solution {
    public String addBinary(String a, String b) {
        int aIdx = a.length() - 1;
        int bIdx = b.length() - 1;

        int sum = 0;
        StringBuilder sb = new StringBuilder();

        while (aIdx >= 0 || bIdx >= 0) {
            if (aIdx >= 0) sum += a.charAt(aIdx--) - '0';
            if (bIdx >= 0) sum += b.charAt(bIdx--) - '0';

            sb.append(sum % 2);
            sum /= 2;
        }

        if (sum == 1) sb.append(sum);
        return sb.reverse().toString();
    }
}

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 로 직접 짜자

+ Recent posts