안녕하세요

오늘은 Java 개발자라면 많이 들어보셨을 JIT 컴파일러에 대해서 소개하려고 합니다.

 

먼저 Java 프로그램이 실행되는 과정을 설명하겠습니다.

 

Java 코드를 자바 컴파일러가 바이트코드로 컴파일합니다. 그 결과물은 클래스파일입니다.

그리고 JVM이 실행을 하면서 해당 바이트코드를 한 줄씩 읽으면서 기계어로 해석(interpret)합니다.

 

JIT 컴파일러는 자주 호출되는 메소드나 자주 실행되는 코드 블럭을 미리 기계어로 번역하여 캐싱을 해둡니다.

그리고 이 자주 실행되는 부분을 'HotSpot'이라고 합니다.

 

그럼 JIT 컴파일은 언제 수행될까요?

컴파일 타임에 수행될까요? 런타임에 수행될까요?

 

런타임에 실행됩니다.

 

보통 '컴파일'이라고 하면 말 그대로 컴파일 타임에 수행이 됩니다.

하지만 JIT 컴파일러는 런타임에 수행됩니다.

 

왜 런타임에 수행하는 것일까요?

 

그 전에 컴파일 방식과 인터프리터 방식의 장단점에 대해서 살펴보겠습니다.

 

컴파일 방식의 단점

컴파일 방식의 단점은 시작 시간이 오래 걸린다는 것입니다. 프로그램의 성능은 좋아지겠지만 시작 시간이 너무 오래 걸립니다.

실행을 하려면 컴파일(기계어로의 컴파일) 과정을 거쳐야 합니다. 개발자가 코드를 한 줄만 수정해도 다시 컴파일을 해야 하기 때문에 프로그램이 시작 시간이 느리다는 단점이 있습니다.

('개발자가 코드를 한 줄만 수정해도 자바 컴파일러의 컴파일(javac)은 해야 하는데 그럼 이거는?'이라고 생각하실 수 있습니다. 하지만 Java 코드를 바이트코드로 컴파일하는 것은 아주 빠르게 수행됩니다. 또한 자바 컴파일러는 수정된 부분의 코드만 다시 컴파일을 하고 기존 코드는 재사용하기 때문에 일반적인 컴파일(기계어로의 컴파일)보다 훨씬 빠릅니다.)

 

컴파일 방식의 장점

하지만 컴파일을 한 번만 하고 실행을 많이 한다면 컴파일 방식이 유리할 것입니다.

예를 들어, 어떤 프로그램을 CD에 담아서 대량 생산을 한다면 컴파일 방식이 유리할 것입니다.

한 번만 컴파일 해놓으면 그 실행파일을 CD에 담기만 하면 되기 때문입니다. 해당 프로그램은 실행 시간 중에 기계어로 번역하는 작업을 하지 않고 이미 번역되어 있는 기계어를 바로 실행하기 때문에, 컴파일을 할 때는 오래 걸려서 답답할 수도 있겠지만 사용자는 아주 빠른 성능의 프로그램을 사용할 수 있을 것입니다.

즉, 컴파일 방식의 장점은 실행 속도가 빠르다는 것입니다.

이러한 장점 때문에 대규모 계산이 필요한 고성능 컴퓨팅 분야에서는 여전히 Fortran과 C언어를 사용하는 것입니다.

(참고로 Fortran은 최초의 프로그래밍 언어입니다😉)

 

그렇다면 인터프리터 방식은 어떨까요?

인터프리터 방식은 실행하기 전에 전체 코드를 기계어로 번역하는 작업(컴파일)을 하지 않아도 되므로 시작 시간은 컴파일 방식에 비해서 빠를 것입니다. 하지만 실행 중에 코드를 기계어로 번역하는 작업을 해야 하기 때문에 실행 속도는 컴파일 방식보다 느릴 것입니다.

 

예를 들어서 설명하겠습니다.

예를 들어 A라는 메소드가 있습니다.

컴파일 방식일 때, A 메소드는 컴파일하는데 1초가 걸리고 실행하는데 1초가 걸린다고 합시다.

인터프리트 방식일 때, A 메소드는 인터프리터가 한 줄씩 기계어로 해석했을 때 총 해석시간이 1초이며 한 줄씩 실행할 때 총 실행 시간이 1초가 걸린다고 합시다.

(사실 이렇지 않습니다. 컴파일은 코드 전체를 번역하면서 최적화 작업을 진행하기 때문에 컴파일러가 기계어로 번역을 하는 시간은 인터프리터가 기계어로 한 줄씩 해석하는 총 시간보다 오래 걸릴 확률이 높고, 최적화가 되었기 때문에 인터프리터가 실행 중에 기계어로 해석하는 시간을 제외하더라도 실행시간은 인터프리트 방식보다 컴파일 방식이 더 빠를 확률이 높습니다. 이해를 돕기 위해, 계산을 쉽게 하기 위해 1초로 정한 점 양해 바랍니다.)

 

A 메소드가 딱 1번 호출된다면

컴파일 방식 인터프리트 방식
기계어로 번역 (1초) 기계어로 해석 (총 1초)
실행 (1초) 실행 (1초)
총 2초 총 2초

컴파일 방식이든 인터프리트 방식이든 총 시간은 2초로 똑같습니다.

 

A 메소드가 1000번 호출된다면

컴파일 방식 인터프리트 방식
기계어로 번역 (1초) 기계어로 해석 (총 1초)
실행 (1초) 실행 (1초)
실행 (1초) 기계어로 해석 ( 1초)
실행 (1초) 실행 (1초)
실행 (1초) 기계어로 해석 ( 1초)
실행 (1초) 실행 (1초)
실행 (1초) 기계어로 해석 ( 1초)
실행 (1초) 실행 (1초)
실행 (1초) 기계어로 해석 ( 1초)
실행 (1초) 실행 (1초)
실행 (1초) 기계어로 해석 ( 1초)
.
.
.
.
.
.
총 1001초 총 2000초

실행 중에 인터프리트 방식으로 해석을 하면 해당 코드를 기계어로 해석하는 똑같은 작업을 1000번 하는 것입니다.

따라서 컴파일 방식은 1001초, 인터프리트 방식은 2000초가 걸릴 것입니다.

그리고 이 차이는 A 메소드가 많이 호출될수록 점점 커질 것입니다.

 

따라서 자주 실행되는 코드를 미리 기계어로 컴파일 해두면 실행시간을 훨씬 줄일 수 있을 것입니다.

 

그래서 JIT 컴파일러가 런타임에 동작하는 이유가 뭔데?

이러한 단점을 극복하기 위해서 Java 1.3 HotSpot VM부터 JIT 컴파일러를 도입하여 자주 실행되는 메소드는 실행 중에 기계어로 번역을 하는 방법을 채택합니다. 이 방법으로는 시작 시간은 여전히 빠르면서, 실행 중에 자주 실행되는 코드를 기계어로 번역을 하여 인터프리터 방식의 실행 속도가 느리다는 단점을 어느 정도 극복할 수 있습니다.

 

JIT 컴파일러가 런타임에 실행됨으로써 얻을 수 있는 장점은 이 뿐만이 아닙니다.

아래의 코드를 봅시다.

for (int i = 0; i < 1000; i++) {
	// 코드 생략
}

위의 반복문을 보면 해당 반복문이 1000번 실행될 것이라는 것을 컴파일 시점에 알 수 있습니다.

 

int count = scanner.nextInt(); // 사용자의 입력을 받음
for (int i = 0; i < count; i++) {
	// 코드 생략
}

하지만 이 코드는 반복문이 몇 번 실행될지 예측을 할 수 없습니다.

사용자의 입력값에 따라 반복문의 실행 횟수가 달라지기 때문입니다.

 

또한 서버 애플리케이션의 경우, 클라이언트가 어떤 API를 많이 호출하는지에 따라 특정 메소드의 실행 횟수가 달라지기 때문에 컴파일 시점에 더욱 더 예측을 하기가 어렵습니다.

 

하지만 런타임에는 이러한 정보들을 알 수 있습니다.

 

이렇게 런타임에 컴파일을 하게 되면 동적 최적화를 할 수 있습니다. 컴파일 타임에는 알 수 없었던 정보들을 알 수 있기 때문에 프로그램의 실행 패턴 등 더 많은 정보를 기반으로 최적화를 할 수 있다는 장점이 있습니다.

 

따라서 자주 실행되는 코드를 미리 기계어로 컴파일 해두면 실행시간을 훨씬 줄일 수 있을 것입니다.

하지만 이 메소드가 자주 호출되는지 어떻게 아냐고!!

 

여기에서 JIT 컴파일러가 런타임에 수행됨으로써 얻을 수 있는 장점이 드러납니다.

 

그러면 특정 메소드가 어느 정도로 자주 호출되어야 JIT 컴파일러가 컴파일을 하게 되는 것일까요?

메소드가 몇 번 호출되어야 JIT 컴파일러가 컴파일을 할지 그 임계값은 -XX:CompileThreshold  옵션으로 지정할 수 있습니다.

만약 Test.class라는 클래스파일을 실행시킬 때, 메소드가 5000번 이상 호출되었을 때 JIT 컴파일을 하고 싶다면

java -XX:CompileThreshold=5000 Test

이렇게 해주면 됩니다.

Java 8 이상의 HotSpot JVM에서 CompileThreshold 옵션의 기본값은 10,000입니다.

따라서 아무런 설정을 해주지 않는다면 특정 메소드가 10,000번 호출되었을 때 JIT 컴파일러가 기계어로 번역을 해서 따로 캐싱해두는 것입니다.

 

위에서 말한 것처럼 JIT 컴파일러가 런타임에 동작하기 때문에 얻을 수 있는 장점이 많이 있습니다. 하지만 단점도 있습니다. JIT 컴파일을 한 후에는 성능이 빨라지겠지만 컴파일을 런타임에 하기 때문에 JIT 컴파일러가 컴파일을 하는 동안에는 응답시간이 지연될 수도 있습니다. 따라서 컴파일을 빠르게 하는 것도 중요합니다.

 

최적화를 잘 하면 컴파일 후의 코드는 성능이 매우 좋아지겠지만 컴파일 시간이 오래걸립니다.

최적화를 엄청 잘 하지는 않으면 컴파일 시간은 덜 걸려서 실행 중에 지연 시간은 덜 발생하겠지만 컴파일 후의 코드가 성능이 그렇게 좋아지지는 않을 것입니다.

 

지연 시간도 줄이고 최적화도 잘하면 매우 좋겠지만 하나를 개선하려면 하나를 포기해야 합니다.

 

JVM은 두 가지의 장점을 최대한 살릴 수 있도록 Tiered 컴파일이라는 방식을 채택합니다.

 

Tiered 컴파일러

Tiered 컴파일러는 한마디로 다단계 컴파일러라고 할 수 있습니다.

최적화가 아주 잘 된 코드를 '짱 좋은 코드', 그럭 저럭 최적화가 되어 컴파일된 코드를 '보통 코드'라고 하겠습니다.

(여기에서 '코드'는 기계어(machine code)를 말합니다)

특정 메소드가 CompileThreshold 만큼 호출되어 JIT 컴파일러가 실행될 때 JIT 컴파일러는 바로 짱 좋은 코드로 컴파일하지 않습니다. 일단은 보통 코드로 컴파일합니다. 그러면 짱 좋은 코드로 컴파일 하는 것보다는 실행 중에 지연 시간이 덜 발생하겠죠?

그러다가 그 메소드가 더 많이 호출되어 일정 횟수를 초과하면 그 때 짱 좋은 코드로 다시 최적화를 하는 것입니다.

바이트코드에서 바로 짱 좋은 코드로 컴파일 하는 것보다 보통 코드에서 짱 좋은 코드로 최적화 하는 것이 시간이 훨씬 덜 소요됩니다.

이렇게 하면 지연시간을 최소화하면서 많이 호출되는 코드를 최적화가 아주 잘 된 코드로 컴파일 할 수 있는 것입니다.

 

Tiered 컴파일러에서 보통 코드로 컴파일하는 컴파일러를 C1, 짱 좋은 코드로 컴파일하는 컴파일러를 C2라고 합니다.

 

Java 9 이전에는 서버 컴파일러, 클라이언트 컴파일러라는 개념이 있었습니다.

Tiered 컴파일러로 따지면 보통 코드로 컴파일하는 C1 컴파일러가 클라이언트 컴파일러, 짱 좋은 코드로 컴파일하는 C2 컴파일러가 서버 컴파일러입니다.

실행을 할 때 서버 컴파일러 또는 클라이언트 컴파일러를 지정할 수 있었습니다.

 

하지만 Java 9부터 Tiered 컴파일러가 기본 옵션이 되었고 명시적으로 서버 컴파일러만을, 또는 클라이언트 컴파일러만을 사용할 수 없습니다.

(-XX:TieredCompilation 옵션으로 Tiered 컴파일러를 비활성화하여 C2 컴파일러(서버 컴파일러)만을 사용하도록 할 수는 있습니다.)

 

Code Cache

그러면 JIT 컴파일러가 이렇게 컴파일한 기계어를 어디에 저장할까요?

JVM 안의 Code Cache라는 곳에 저장됩니다.

Code Cache의 크기는 -XX:ReservedCodeCacheSize 옵션을 통해 지정할 수 있습니다.

Code Cache의 크기를 256MB로 설정하고 싶다면 -XX:ReservedCodeCacheSize=256m이라고 하면 됩니다.

만약에 코드 캐시 영역이 꽉 차면 더 이상 JIT 컴파일러가 동작하지 않습니다. 컴파일을 하더라도 컴파일된 기계어를 저장할 공간이 없기 때문입니다.

따라서 Code Cache 영역의 크기를 적절히 설정하는 것이 중요합니다.

 

그러면 GC가 돌 때 Stop-the-world가 발생하는 것처럼 JIT 컴파일러가 실행될 때 Stop-the-world가 발생하지 않을까요?

그렇지 않습니다. GC가 동작할 때는 살아있는 객체를 판별하기 위해서는 일단 애플리케이션의 실행을 멈춰야 합니다.

인구 조사를 예로 들어봅시다.

예를 들어 총 20층이고 각 층에는 10개의 가구가 있는 아파트가 있습니다.

인구 조사 담당자는 1층부터 20층까지 순차적으로 각 집을 돌면서 인구 조사를 합니다.

그런데 1층을 이미 조사하고 2층을 조사하고 있는 중에 1층에서 아기가 태어난다면 정확한 인구 조사를 못 하겠죠?

그렇기 때문에 GC가 돌 때는 애플리케이션의 실행을 멈추고 지금 살아있는 객체를 알아내는 것이고,

그래서 Stop-the-world가 발생하는 것입니다.

 

하지만 JIT 컴파일러는 상황이 다릅니다. 굳이 애플리케이션 실행을 중단할 필요가 없습니다.

따라서 백그라운드 스레드로 애플리케이션 스레드와 동시에 실행됩니다.

 

https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html

https://www.geeksforgeeks.org/just-in-time-compiler/

https://www.baeldung.com/jvm-tiered-compilation

https://www.baeldung.com/jvm-code-cache

https://www.oreilly.com/library/view/java-performance-2nd/9781492056102/ch04.html

안녕하세요

오늘은 Java 사용자라면 자주 쓰실 enum에 대해서 알아보겠습니다.

 

enum 타입은 아래와 같이 enum이라는 키워드를 사용하여 만들 수 있습니다.

public enum Color {
    RED, GREEN, BLUE;
}

 

enum 타입을 만들 때는 class 키워드가 아니라 enum 키워드로 만들기 때문에 클래스가 아니라고 생각하실 수도 있는데요,

enum도 클래스이고, 다른 클래스들과 다를 것 없이 Object 클래스를 상속받습니다.

 

이렇게 enum 키워드를 통해 만들어진 모든 enum 타입은 java.lang.Enum 클래스를 상속받게 됩니다.

extends Enum을 안 써줘도 말이죠. (enum에는 extends를 쓸 수 없습니다.)

컴파일을 하면서 컴파일러가 extends Enum을 추가해줍니다.

이것이 바로 enum 타입에는 extends를 사용할 수 없는 이유입니다.

Java는 다중 상속을 지원하지 않기 때문에 enum 타입이 다른 클래스를 상속하고 있으면 extends Enum을 추가해줄 수 없기 때문입니다.

 

java.lang.Enum 클래스

그럼 모든 enum 타입의 부모 클래스인 java.lang.Enum 클래스에 대해서 알아보겠습니다.

Enum 클래스는 Java가 처음 나왔을 때부터 enum이 있었던 것이 아닙니다. JDK1.5부터 추가된 클래스입니다.

먼저 인스턴스 변수로 어떤 것이 있는지 보겠습니다.

name과 ordinal을 볼 수 있는데요, 두 변수 모두 final로 선언되어 있습니다.

 

그리고 이제 생성자를 봅시다.

생성자는 딱 하나가 있습니다.

유일한 생성자. 프로그래머들은 이 생성자를 호출할 수 없다.enum 클래스를 정의하면 컴파일러가 어떤 코드를 추가해주는데, 그 코드가 호출하는 생성자이다.
public enum Color {
    RED, GREEN, BLUE;
}

이 enum 클래스의 경우 생성자가 호출되면서 name과 ordinal이 어떤 값으로 세팅되는지 디버거를 통해 직접 봐볼까요?

RED, GREEN, BLUE 순으로 선언했었는데, 이 순서대로 ordinal의 값으로 0부터 순서대로 저장되는 것을 볼 수 있습니다.

그리고 name에는 "RED", "GREEN", "BLUE"와 같이 enum 상수값이 문자열로 저장되었습니다.

ordinal에는 정의한 enum 상수 순서대로 0번부터 차례대로 저장됩니다.

equals() 메소드

equals() 메소드가 final로 선언되어 있는 것을 볼 수 있는데요,

final로 선언되어 있기 때문에 저희는 equlas() 메소드를 오버라이딩할 수 없습니다.

그런데 equals() 메소드 안을 보면 그냥 this == other 이렇게 == 연산자를 통해 비교하고 있습니다.

즉, enum 상수들끼리 비교를 할 때는 equals()로 비교를 하나 ==으로 비교를 하나 똑같다는 것입니다.

그런데 equals() 메소드는 같은 객체(동일성)인지를 비교하는 것이 아니라 내용이 같은지(동등성)를 비교하도록 오버라이딩 해야 하는 거 아닌가? 하는 생각이 드셨을 수도 있습니다.

네 equals() 메소드는 동등성을 비교해야 하는 것이 맞습니다. 하지만 enum 클래스는 이렇게 == 연산자를 통해 비교를 하는 것이 동등성을 비교하는 것과 결과가 같습니다.

 

https://docs.oracle.com/javase/specs/jls/se7/html/jls-8.html#jls-8.9

위는 Java 공식 문서인데요,

각각의 enum 상수의 인스턴스는 딱 1개이기 때문에 비교를 하려는 두 객체 중 적어도 하나가 enum 상수라면 equals() 대신 == 연산자로 비교해도 된다.

enum 상수는 인스턴스가 단 하나만 생성됩니다. (싱글톤)

따라서 어차피 같은 enum 상수라면 같은 인스턴스이기 때문에 굳이 equals() 메소드로 비교하지 않고 == 연산자를 통해서 비교를 하면 되는 것입니다.

clone() 메소드

CloneNotSupportedException을 던집니다. 이는 enum의 '싱글톤' 상태에 필수적인 '절대 복제되지 않음'을 보장합니다.

clone 메소드는 호출하면 무조건 예외가 발생합니다.

위의 equals() 메소드에서 설명했던 것처럼, enum은 인스턴스가 딱 1개인 싱글톤이기 때문에 객체를 복사할 수 없습니다.

 

toString(), hashCode(), clone() 메소드

Object 클래스의 메소드인 equals(), toString(), hashCode(), clone() 메소드 중 toString()을 제외하고 모두 final로 선언되어있습니다.

toString()을 제외한 equals(), hashCode(), clone() 메소드는 저희가 재정의할 수 없습니다.

그리고 clone() 메소드는 아예 clone을 하지 못 하게 예외를 던지는 것을 볼 수 있습니다.

enum 상수들의 목록을 가져오려면 어떻게 해야 할까?

public enum Color {
    RED, GREEN, BLUE;
}

만약 이 Color enum 타입의 상수들인 RED, GREEN, BLUE들을 배열로 가져오고 싶으면 어떻게 해야 할까요?

values() 라는 static 메소드를 사용하면 됩니다.

예를 들어 Color.values() 이렇게 말입니다.

public class EnumDemo {

    public static void main(String[] args) {
        Color[] colors = Color.values();
        for (Color color : Color.values()) {
            System.out.println(color);
        }
    }

}

위 코드를 실행하면

이렇게 Color enum 타입에 있는 상수들이 모두 출력되는 것을 볼 수 있습니다.

 

그런데 java.util.Enum 클래스에 들어가보면 values()라는 메소드는 없습니다.

그렇다면 도대체 values() 메소드는 어디에 정의되어 있는 것일까요?

 

자, 생각을 해봅시다.

자식은 부모를 알지만 부모는 자식을 모릅니다.

이것은 당연합니다.

java.lang.Enum 클래스를 상속받아서 만들어지는 enum 타입이 무엇일지 알지 못합니다.

Color라는 enum 타입을 만들 수도 있고 Grade라는 enum 타입을 만들 수도 있고, Category라는 enum 타입을 만들 수도 있고, Season이라는 enum 타입을 만들 수도 있고.... 가능한 enum 타입은 무한할 것입니다.

뿐만 아니라 Color 클래스의 enum 상수로 RED, YELLOW, GREEN이 올지, PINK, ORANGE, BLUE, PURPLE이 올지 등등 또한 무한할 것입니다.

따라서 java.lang.Enum 클래스는 자식을 모르기 때문에 values() 메소드를 구현할 수 없습니다.

 

그럼 도대체 values()는 어떻게 쓸 수 있는 것일까요?

https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html

공식 튜토리얼 문서를 보면 values() 메소드는 컴파일러가 enum을 만들 때 추가해준다고 합니다.

 

원소들이 enum 상수들로만 이루어져있다면 어떤 자료구조를 사용하는 것이 좋을까?

Set 자료구조를 사용하고 싶은데 element들이 Enum 상수들이라면 EnumSet을 사용하는 것이 좋습니다.

Map 자료구조를 사용하고 싶은데 element들이 Enum 상수들이라면 EnumMap을 사용하는 것이 좋습니다.

EnumSet과 EnumMap에 대한 내용은 다른 글에서 자세히 다루겠습니다.

 

참고

https://docs.oracle.com/javase/specs/jls/se7/html/jls-8.html#jls-8.9

https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html

https://www.baeldung.com/java-enumset

https://www.baeldung.com/java-enum-map

안녕하세요

오늘은 제가 개발을 하면서 겪은 문제와, 그 문제를 해결하기 위해 어떤 방법을 시도했고 어떻게 해결했는지 공유드리려고 합니다.

 

우선 문제 상황부터 공유드리겠습니다.

 

🤨 문제 상황

이 화면은 필터를 적용하여 상품을 조회하는 화면입니다.

이 화면에 따르면

상품 조회 API의 Request DTO에는 

  • 편의점
  • 카테고리
  • 행사 유형
  • 최소 가격
  • 최대 가격

Response DTO에는

  • 상품 이미지
  • 상품 이름
  • 상품 가격
  • 평점
  • 리뷰 개수
  • 해당 상품을 판매하는 편의점 + 해당 편의점의 행사 정보
  • 현재 사용자의 좋아요 여부
  • 현재 사용자의 북마크 여부

가 있어야 합니다.

 

여기에서 문제는 바로 빨간색으로 표시한 판매 편의점+행사 정보입니다.

 

response DTO 클래스는 아래와 같습니다.

public class ProductResponseDto {

    private final Long productId; // 상품 상세 조회 화면으로 넘어가기 위해 필요
    
    private final String productName;
    
    private final Integer productPrice;
    
    private final String productImageUrl;
    
    private final String manufacturerName;
    
    private final Boolean isLiked;
    
    private final Boolean isBookmarked;
    
    private final Integer reviewCount;
    
    private final Double reviewRating;
    
    private List<ConvenienceStoreEventDto> cvsEvents;
    
}

response body에는 List<ProductResponsesDto>가 담길 것입니다.

따라서 response는 다음과 같을 것인데요,

[
    {
        "productId": 2,
        "productName": "비요뜨",
        "productPrice": 1800,
        "productImageUrl": "https://blahblah/product/비요뜨.jpg",
        "manufacturerName": "서울우유",
        "isLiked": true,
        "isBookmarked": false,
        "reviewCount": 8,
        "reviewRating": 4.5,
        "cvsEvents": [
            {
                "name": "CU",
                "eventType": "2+1"
            },
            {
                "name": "GS25",
                "eventType": "2+1"
            },
            {
                "name": "세븐일레븐",
                "eventType": null
            },
            {
                "name": "이마트24",
                "eventType": null
            },
            {
                "name": "미니스톱",
                "eventType": null
            }
        ]
    },
    {
        "productId": 7,
        "productName": "틈새라면큰컵",
        "productPrice": 1350,
        "productImageUrl": "https://blahblah/product/틈새라면큰컵.jpg",
        "manufacturerName": "유어스",
        "isLiked": false,
        "isBookmarked": false,
        "reviewCount": 1,
        "reviewRating": 5.0,
        "cvsEvents": [
            {
                "name": "GS25",
                "eventType": null
            }
        ]
    }
]

판매 편의점과 해당 편의점의 행사 정보를 담은 cvsEvents만 배열인 것을 볼 수 있습니다.

 

Java의 클래스에서는 하나의 객체에 List를 담을 수 있지만 데이터베이스에서는 그것이 불가능합니다. flat한 row들을 조회할 수 있을 뿐입니다. 이것이 바로 객체와 관계형 데이터베이스의 큰 차이점이자 저희를 머리 아프게 만드는 부분입니다.

 

또한 여기에서 하나의 리스트가 필요한 것처럼 보이지만 사실 두 개의 리스트가 필요한데요, 이해를 위해 먼저 ERD를 보여드리겠습니다.

 

아래는 ERD의 일부인데요,

각각의 테이블을 간단히 소개하자면

  • product: 상품
  • category: 카테고리
  • convenience_store: 편의점
  • manufacturer: 제조사
  • event: 행사 정보 (어떤 편의점에서 어떤 상품이 어떤 행사를 하는지)
  • sell_at: 어떤 상품이 어떤 편의점에 파는지
  • user: 사용자
  • review: 리뷰
  • product_like: 어떤 사용자가 어떤 상품에 좋아요 했는지
  • product_bookmark: 어떤 사용자가 어떤 상품에 북마크 했는지

입니다.

 

여기에서 event 리스트와 sell_at 리스트, 이렇게 두 개의 리스트가 필요한 것입니다.

 

화면에서 판매 편의점 정보와 해당 편의점의 행사 정보를 묶어서 보여주고 있기 때문에 sellAt 리스트와 event 리스트를 하나의 리스트로 합쳐서 response body에 넣어줄 뿐이고, 저희는 두 개의 리스트를 조회해야 합니다.

 

자 이제 문제 상황은 공유를 드렸으니 제가 어떤 방식으로 해결을 시도했는지 알아보겠습니다.

 

참고로 저는 필터 적용을 위해 동적 쿼리를 만들어야 해서 QueryDSL을 사용했습니다.

(사실 QueryDSL은 JPQL에 비해 훨씬 편리하고 컴파일 시점에 오류가 나서 실수도 줄일 수 있어서 꼭 동적쿼리가 아니라도 저는 QueryDSL을 잘 사용합니다. 한 번도 사용해보지 않으셨다면 꼭 한 번 사용해보세요! 아주 편리합니다👍)

 

첫번째 시도: Product 엔티티 안에 OneToMany로 List 두 개를 만들고 fetch join하여 Product 엔티티 자체를 조회하자

제가 처음으로 시도했던 방법은 Product 엔티티 안에 OneToMany로 List<Event> events, List<SellAt> sellAtList를 만들고 Product 엔티티를 sellAtList, events와 fetch join 하여 조회하는 방법이었습니다.

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    private String name;

    private Integer price;

    private String imageUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manufacturer_id")
    private Manufacturer manufacturer;

    @OneToMany(mappedBy = "product")
    private List<Event> events = new ArrayList<>(); // 추가

    @OneToMany(mappedBy = "product")
    private List<SellAt> sellAtList = new ArrayList<>(); // 추가

}

이렇게 OneToMany로 가져오고 싶은 두 리스트를 Product 엔티티에 추가했습니다.

 

그리고 이렇게 Product 엔티티를 조회하는 메소드를 작성했습니다.

public List<Product> searchByFilter(User user, ProductSearchRequestDto filter, Pageable pageable) {
    return queryFactory
        .selectFrom(product)
        .leftJoin(product.sellAtList, sellAt).fetchJoin() // sellAtList fetch조인
        .leftJoin(product.events, event).fetchJoin() // events fetch조인
        .where(
            convenienceStoreEq(filter.getConvenienceStoreIds()),
            categoryEq(filter.getCategoryIds()),
            eventEq(filter.getEventTypes()),
            priceLessOrEqual(filter.getHighestPrice()),
            priceGreaterOrEqual(filter.getLowestPrice())
        )
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
}

where문에 있는 메소드들은 필터 적용을 위해 동적 쿼리를 만드는 메소드를 호출한 것입니다.

private BooleanExpression convenienceStoreEq(List<Long> convenienceStoreIds) {
    return convenienceStoreIds != null && !convenienceStoreIds.isEmpty() ? sellAt.convenienceStore.id.in(convenienceStoreIds) : null;
}
private BooleanExpression categoryEq(List<Long> categoryIds) {
    return categoryIds != null && !categoryIds.isEmpty() ? product.category.id.in(categoryIds) : null;
}

private BooleanExpression eventEq(List<EventType> eventTypes) {
    return eventTypes != null && !eventTypes.isEmpty() ? event.eventType.in(eventTypes) : null;
}

private BooleanExpression priceLessOrEqual(Integer highestPrice) {
    return highestPrice != null ? product.price.loe(highestPrice) : null;
}

private BooleanExpression priceGreaterOrEqual(Integer lowestPrice) {
    return lowestPrice != null ? product.price.goe(lowestPrice) : null;
}

이렇게 코드를 작성하고 돌려보았습니다!!

예외가 발생했습니다....

MultipleBagFetchException이 발생하면서 동시에 두 개의 bag을 fetch할 수 없다고 하는데 도대체 bag이 뭔데..?🤨

찾아보니 두 개 이상의 to-many 컬렉션을 fetch join하면 안 된다고 합니다.

List 대신 Set으로 바꾸면 해결된다는 글들도 많아서 해보니 됐지만, 좋은 방법이 아닌 것 같았고 다른 방법을 찾기로 했습니다.

(그리고 하나의 리스트만 있어서 MultipleBagFetchException이 나지 않는다 하더라도 이렇게 컬렉션을 fetch join하는 것은 매우 큰 문제가 있기 때문에 사용하지 않는 것이 좋습니다. 위 이미지의 WARN 로그에 힌트가 숨어있습니다ㅎㅎ)

 

두번째 시도: SellAt을 기준으로 조회를 한 뒤, 그 데이터를 가공하자

제가 두 번째로 시도했던 것은 첫 번째 시도의 실패를 하고 머리를 굴려서 생각해낸 방법입니다.

product를 기준으로 조회를 하지 말고 sell_at을 기준으로 조회를 한 후, product를 기준으로 grouping을 하는 것입니다.

public List<ProductQueryDto> searchByFilter(User user, ProductSearchRequestDto filter,
    Pageable pageable) {
    List<ProductQueryDto> results = queryFactory
        .select(Projections.constructor(ProductQueryDto.class,
            product.id, product.name, product.price,
            sellAt.convenienceStore.name, product.manufacturer.name,
            event.eventType, product.category.name, productLike, productBookmark))
        .from(product)
        .leftJoin(sellAt).on(sellAt.product.eq(product))
        .leftJoin(event).on(event.product.eq(product))
        .leftJoin(productLike).on(productLike.product.eq(product))
        .leftJoin(QUser.user).on(productLike.user.eq(user))
        .leftJoin(productBookmark).on(productBookmark.product.eq(product))
        .leftJoin(QUser.user).on(productBookmark.user.eq(user))
        .where(
            convenienceStoreEq(filter.getConvenienceStoreIds()),
            categoryEq(filter.getCategoryIds()),
            eventEq(filter.getEventTypes()),
            priceLessOrEqual(filter.getHighestPrice()),
            priceGreaterOrEqual(filter.getLowestPrice())
        )
        .fetch();
    return results;
}

일단 이렇게 조회를 하면 row 수가 더 많은 sellAt을 기준으로 가져오기 때문에 아래와 같은 결과가 나올 것입니다.

그런데 이것은 저희가 원하는 데이터 형식이 아닙니다. 그래서 Service layer에서 후가공을 하는 것입니다.

public List<ProductResponseDto> getProductList(User user,
    ProductSearchRequestDto request, Pageable pageable) {
    List<ProductQueryDto> result = productRepository.searchByFilter(user, request, pageable);
    Map<ProductTempDto, List<ConvenienceStoreEventDto>> productMap = result.stream()
        .collect(Collectors.groupingBy(product -> ProductTempDto.builder() // <- key
                .productId(product.getProductId())
                .productName(product.getProductName())
                .productPrice(product.getProductPrice())
                .manufacturerName(product.getManufacturerName())
                .categoryName(product.getCategoryName())
                .isBookmarked(product.getIsBookmarked())
                .isLiked(product.getIsLiked())
                .build(),
            Collectors.mapping( //                                         <- value
                product -> new ConvenienceStoreEventDto(product.getConvenienceStoreName(),
                    product.getEventType()), Collectors.toList()
            ))
        );

    return productMap.entrySet().stream()
        .map(entry -> ProductResponseDto.builder()
            .productId(entry.getKey().getProductId())
            .productName(entry.getKey().getProductName())
            .productPrice(entry.getKey().getProductPrice())
            .manufacturerName(entry.getKey().getProductName())
            .categoryName(entry.getKey().getCategoryName())
            .isLiked(entry.getKey().getIsLiked())
            .isBookmarked(entry.getKey().getIsBookmarked())
            .cvsEvents(entry.getValue())
            .build()
        ).toList();
}

stream의 groupingBy() 메소드를 이용하여 key로는 상품 정보(상품 ID, 상품 이름, 카테고리, 제조사 등)를 담고 있는 ProductTempDto를,

value로는 해당 제품을 판매하는 편의점 + 해당 편의점의 이벤트 정보 List를 가지는 Map을 만들어줍니다.

(Map의 key는 equals()와 hashCode() 메소드가 모두 같아야 하기 때문에 두 메소드를 꼭 오버라이딩 해줘야 합니다. 그러지 않으면 grouping을 한 후에도 11행일 것입니다.)

그리고 해당 Map을 순회하면서 하나의 entry를 하나의 객체로 만들어주었습니다.

아까 11행이었던 데이터를 이렇게 3행으로 만든 것입니다.

실제로 실행을 하여 로그를 찍어보면

이렇게 그룹핑을 잘 하는 것을 볼 수 있습니다.

 

와 드디어 문제를 해결했다!! 하고 기뻐하고 있었는데....

이렇게 되면 페이징이 불가능하다는 것을 깨달았습니다.

 

처음에 SELECT문으로 조회를 할 때 sellAt을 기준으로 가져오기 때문에 페이지네이션이 불가능한 것입니다.

예를 들어 페이지 size가 10일 때,

여기에서 10개를 잘라서 주면 안 됩니다.

 

이렇게 가공한 뒤에 여기에서 10개를 잘라서 줘야 합니다.

그런데 문제는 가공한 뒤에 product가 총 몇 개일지 예측이 불가능하다는 것입니다.

만약 sell_at을 기준으로 50개씩 잘라서, 가공한 뒤의 product 개수는 매번 다르게 response로 준다고 해도

이렇게 같은 상품의 중간에 잘릴 수 있기 때문에 문제가 발생합니다. 이 상태에서 가공을 하면 잘못된 결과가 나올 것입니다.

 

그래서 저는 다른 방법을 찾아야 했습니다.

 

세번째 시도: default_batch_fetch_size 를 늘리고 엔티티를 조회하자

events와 sellAtList를 fetch join을 하지 않고 product 엔티티를 조회하는 것입니다. (두 번째 방법에서는 엔티티를 조회하지 않고 조회할 항목을 지정해서 DTO로 받아왔었습니다.)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    private String name;

    private Integer price;

    private String imageUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manufacturer_id")
    private Manufacturer manufacturer;

    @OneToMany(mappedBy = "product")
    private List<Event> events = new ArrayList<>();

    @OneToMany(mappedBy = "product")
    private List<SellAt> sellAtList = new ArrayList<>();

}

(아까 위에서 본 Product 클래스와 똑같습니다. 다시 위로 스크롤하려면 귀찮으실테니 추가했습니다🙂)

OneToMany는 기본 fetch 전략이 LAZY이기 때문에 상품 엔티티를 조회하면 sellAtList와 events는 가져오지 않을 것입니다.

하지만 product.getEvents() 또는 product.getSellAtList()를 하는 순간 N+1 문제가 발생할 것입니다.

상품 조회 쿼리 SELECT * FROM product;가 1번 나갔는데 조회된 product마다 각각 event와 sell_at을 조회하기 위해 쿼리가 6번(N*2번) 더 나갔습니다.

지금은 조회 결과가 3개이니 6번 더 나간 것이지,

만약 조회 결과가 20개였다면 추가로 40번의 쿼리가 나갔을 것입니다. 정말 끔찍하죠?😂

하지만 batch_fetch_size라는 것이 있습니다.

batch size는

properties 파일의 경우

spring.jpa.properties.hibernate.default_batch_fetch_size=1000

yml 파일의 경우

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

이렇게 정해줄 수 있습니다.

조회 결과가 N개일 때 N개에 대해 각각 1번씩 조회 쿼리를 날리는 것이 아니라 위와 같이 batch size만큼씩 한꺼번에 날리는 것입니다.

batch size를 정하지 않은 경우 조회 결과가 N개일 때 2 * N번 추가 쿼리가 나가서 총 1 + N * 2번 쿼리가 나갔겠지만

batch size를 정해준다면 조회 결과가 N개일 때 조회 결과의 개수와 상관없이 추가 쿼리가 딱 2번 나가서 총 1 + 2번의 쿼리가 나가게 됩니다. (batch size가 조회 결과의 개수보다 큰 경우에 한합니다. 조회 결과의 개수가 batch size보다 큰 경우 1 +  ceil(N / batch size)번의 쿼리가 나갈 것입니다.)

아까 to-many 관계는 fetch join을 2개 해줬더니 MultipleBagFetchException이 났었죠?

위는 Hibernate 공식 문서인데요, to-one 관계는 fetch join을 여러 개 해도 완전히 안전하다고 나와있습니다.

따라서 to-one 관계인 category와 manufacturer는 fetch join으로 가져옵시다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    private String name;

    private Integer price;

    private String imageUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category; // fetch join으로 가져오자

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manufacturer_id")
    private Manufacturer manufacturer; // fetch join으로 가져오자

    @OneToMany(mappedBy = "product")
    private List<Event> events = new ArrayList<>();

    @OneToMany(mappedBy = "product")
    private List<SellAt> sellAtList = new ArrayList<>();

}

다시 보는 Product 클래스입니다

 

public List<Product> searchByFilter(User user, ProductSearchRequestDto filter, Pageable pageable) {
    return queryFactory
        .selectFrom(product)
        .leftJoin(product.category, category).fetchJoin() // <- to-one 관계는 feth join
        .leftJoin(product.manufacturer, manufacturer).fetchJoin() // <- to-one 관계는 feth join
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
}

이렇게 가져오면 1번의 쿼리로 가져오고, 저렇게 얻은 products에서

products.stream().map(product -> ProductResponseDto.builder()
    .productId(product.getId())
    .productName(product.getName())
    .productPrice(product.getPrice())
    .categoryName(product.getCategory().getName())
    .manufacturerName(product.getManufacturer().getName())
    .events(product.getEvents())                       <- 여기에서 조회 쿼리 나감
    .convenienceStores(product.getConvenienceStores()) <- 여기에서 조회 쿼리 나감
    .build()
).toList();

이렇게 해주면 이 순간 조회 쿼리가 나갈 것인데 저희는 batch size를 지정해줬기 때문에 딱 2번 더 쿼리가 나갈 것입니다.

 

그래서 저도 이 방법을 쓰면 되겠다! 했으나 저의 상황에서는 이 방법을 쓸 수 없었습니다.

이 화면에서 사용자가 request로 주는 필터 정보는

  • 어느 편의점에서 파는 상품인지 (sell_at 테이블)
  • 어떤 카테고리의 상품인지 (category 테이블)
  • 어떤 행사를 하는 상품인지 (event 테이블)
  • 가격이 얼마 이상 얼마 이하의 상품인지 (product 테이블)

입니다.

이 정보들은 where절에 들어가야 합니다.

따라서 애초에 product를 조회를 할 때 sell_at 테이블, event 테이블이 필요합니다.

 

그래서 저는 where절에 서브쿼리를 써서 해결해보기로 했습니다.

 

네 번째 시도: WHERE절에 서브쿼리를 사용하여 해결해보자

제가 두 번째로 시도했던 방법의 쿼리를 서브쿼리로 넣는 것입니다.

두 번째로 시도한 방법은 sell_at 목록을 조회하는 것이었는데, sell_at 테이블에는 product_id 컬럼이 있기 때문에

SELECT 상품 정보들
FROM product
WHERE product.id IN (
	SELECT product_id
    FROM sell_at
    WHERE 필터 조건들
)

이런 식으로 하면 될 것 같았습니다.

public List<SearchProductQueryDto> searchByFilter(User user,
    ProductSearchRequestDto filter, Pageable pageable) {
    return queryFactory.select(new QSearchProductQueryDto(
            product.id,
            product.name,
            product.price,
            product.imageUrl,
            product.category.id,
            manufacturer.name,
            productLike,
            productBookmark,
            review.count(),
            review.rating.avg()))
        .from(product)
        .leftJoin(productLike).on(productLike.product.eq(product).and(eqProductLikeUser(user)))
        .leftJoin(productBookmark).on(productBookmark.product.eq(product).and(eqProductBookmarkUser(user)))
        .leftJoin(review).on(review.product.eq(product))
        .leftJoin(manufacturer).on(product.manufacturer.eq(manufacturer))
        .where(
            product.in(
                selectDistinct(sellAt.product)
                    .from(sellAt)
                    .leftJoin(event).on(sellAt.product.eq(event.product))
                    .where(
                        convenienceStoreEq(filter.getConvenienceStoreIds()),
                        eventTypeEq(filter.getEventTypes()),
                        categoryEq(filter.getCategoryIds()),
                        priceLessOrEqual(filter.getHighestPrice()),
                        priceGreaterOrEqual(filter.getLowestPrice())
                    ))
        )
        .groupBy(product)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
}

이렇게 product들을 조회한 뒤,

이렇게 조회한 product들의 id로

public List<ConvenienceStoreEventQueryDto> findConvenienceStoreEventsByProductIds(
    List<Long> productIds) {
    return queryFactory
        .select(new QConvenienceStoreEventQueryDto(
            sellAt.product.id,
            sellAt.convenienceStore.name,
            event.eventType))
        .from(sellAt)
        .leftJoin(event).on(sellAt.product.eq(event.product).and(sellAt.convenienceStore.eq(event.convenienceStore)))
        .where(sellAt.product.id.in(productIds))
        .fetch();
}

판매 편의점 + 이벤트 정보를 가져오는 쿼리를 따로 작성했습니다.

sell_at 테이블과 event 테이블을 조인해서 아래 화면처럼 보여줄 수 있게 두 정보를 하나로 합쳐서 조회하는 쿼리입니다.

이렇게 총 2번의 쿼리로 상품 정보와 판매 편의점+이벤트 정보까지 다 가져왔습니다.

필요한 데이터는 다 가져왔고, 이제 Service layer에서 가공을 해주면 됩니다.

이렇게 판매 편의점+이벤트 정보까지 가져온 뒤 productId 기준으로 grouping을 해서 List를 만들어줍니다.

그 다음에 아까 조회한 product 목록을 돌면서 해당 정보를 product에 넣어줍니다.

    public List<ProductResponseDto> getProductList(User user, ProductSearchRequestDto request,
        Pageable pageable) {
        // 상품 조회 - 첫 번째 쿼리
        List<SearchProductQueryDto> products = productRepository.searchByFilter(user, request,
            pageable);
        List<Long> productIds = products.stream().map(SearchProductQueryDto::getProductId).toList();
        
        // 조회한 product들의 ID로 편의점+이벤트 정보 조회 - 두 번째 쿼리
        List<ConvenienceStoreEventQueryDto> convenienceStoreEvents = productRepository.findConvenienceStoreEventsByProductIds(productIds);
        
        // 편의점+이벤트 정보를 product id 기준으로 그룹핑
        Map<Long, List<ConvenienceStoreEventQueryDto>> productCvsEventsMap = convenienceStoreEvents.stream()
            .collect(Collectors.groupingBy(ConvenienceStoreEventQueryDto::getProductId));

        return products.stream().map(p -> ProductResponseDto.builder()
        	.productId(p.getProductId())
        	.productName(p.getProductName())
        	.productPrice(p.getProductPrice())
        	.productImageUrl(p.getProductImageUrl())
        	.categoryId(p.getCategoryId())
        	.manufacturerName(p.getManufacturerName())
        	.isLiked(p.getIsLiked())
        	.isBookmarked(p.getIsBookmarked())
        	.reviewCount(p.getReviewCount())
        	.reviewRating(p.getAvgRating())
        	.cvsEvents(
                productCvsEventsMap.get(p.getProductId()).stream() // <- product id로 그룹핑해놓은 편의점+이벤트 리스트를 product id로 가져오기
                    .map(c -> ConvenicenceStoreEventDto.of(c.getConvenienceStoreName(), c.getEventType()))
                    .toList()).build()).toList();
    }

이렇게 최종적으로 2번의 쿼리로 완성할 수 있게 되었습니다.

 

이상 저의 삽질과 해결 방법이었습니다.

감사합니다.

 

참고

https://docs.jboss.org/hibernate/orm/6.2/userguide/html_single/Hibernate_User_Guide.html#hql-explicit-fetch-join

안녕하세요

트랜잭션 격리 수준이 무엇인지 알고 계신가요?

트랜잭션 격리는 트랜잭션의 특징 ACID 중 I(Isolation)에 해당합니다.

모든 트랜잭션끼리는 격리되어야 한다는 것입니다.

모든 데이터베이스가 ACID를 모두 완벽하게 만족시키면 좋지만 트랜잭션이 완전히 격리가 되면 그만큼 동시처리 능력도 떨어져 성능이 저하됩니다.

따라서 DBMS는 4가지 트랜잭션 격리 수준을 제공하여 상황에 맞게 선택할 수 있도록 하고 있습니다.

트랜잭션 격리 수준은 4가지가 있는데요, 레벨이 높아질수록 격리성(고립성)은 높아지고 동시 처리 능력은 떨어지게 됩니다.

그럼 이제

  • 트랜잭션 격리 수준 4가지
  • 읽기 이상 현상 3가지 - dirty read, non-repeatable read, phantom read
  • non-repeatable read, phantom read의 차이점
  • REPEATABLE READ에서 있던 row가 사라질 수 있나? (대부분의 블로그에 잘못 써있는 사실)
  • SERIALIZABLE 격리 수준과 SELECT ... FOR UPDATE문으로 lock을 거는 것과의 차이는?
  • 그래서 트랜잭션 격리 수준을 어떻게 지정해줄 수 있는데?

등에 대해 알아보겠습니다.

🥑 READ UNCOMMITTED (레벨 0)

READ UNCOMMITTED는 다른 트랜잭션의 커밋되지 않은 데이터에 접근할 수 있는 격리 수준입니다.

이 격리 수준은 심각한 문제가 있기 때문에 일반적인 데이터베이스에서는 거의 사용되지 않습니다.

어떤 문제가 있을지 예시를 통해 알아봅시다.

 

김땡땡의 계좌에는 13만원이 있었습니다.

오늘은 김땡땡의 통신비 5만원이 자동이체 되는 날입니다.

1. 통신사의 출금 시스템에서 김땡땡의 통신비를 출금하는 트랜잭션이 시작되었습니다.

2. UPDATE account SET balance=80000 WHERE user_name="김땡땡";

3. 그리고 김땡땡이 온라인 뱅킹 앱으로 통장 잔고를 확인합니다. READ UNCOMMITTED에서는 커밋되지 않은 데이터를 확인할 수 있기 때문에 김땡땡은 계좌에 8만원이 있는 것을 확인합니다.

4. 그 후 자동이체 트랜잭션이 어떠한 이유로 트랜잭션이 롤백되었습니다.

5. 조금 뒤 김땡땡은 다시 잔고를 확인했는데 계좌의 잔고는 13만원입니다.

DB에서 김땡땡의 잔고가 8만원이 된 적도 없는데 19시 25분 3초에 온라인 뱅킹 잔액 조회 트랜잭션은 커밋되지 않은 데이터인 8만원을 읽어갔습니다. 실제 잔액은 13만원인데 말이죠.

이것이 바로 dirty read입니다.

 

읽기만 하면 그나마 다행인데 만약 이렇게 읽은 8만원을 가지고 UPDATE를 했다면 아주 큰 문제가 발생할 것입니다.

김땡땡의 잔고는 15만원이어야 하는데 10만원이 되는 문제가 발생했습니다.

이처럼 READ UNCOMMITTED 격리수준은 큰 문제를 가지고 있어서 거의 사용되지 않습니다.

🥑 READ COMMITED (레벨 1)

READ COMMITTED는 커밋되지 않은 데이터도 읽어왔던 READ UNCOMMITTED와 달리, 커밋된 데이터만 읽어옵니다.

이 격리 수준은 Orable, PostgreSQL 등 많은 RDBMS의 default 격리 수준입니다.

READ UNCOMMITTED 격리 수준에서 발생했던 문제인 dirty read 문제가 발생하지 않습니다.

트랜잭션이 시작되기 전에 COMMIT된 데이터만 조회할 수 있습니다.

하지만 READ COMMITTED도 문제가 있습니다. non-repeatable read 문제가 발생합니다.

non-repeatable read 문제란 무엇일까요?

 

잔액 조회 트랜잭션에서

SELECT balance FROM account WHERE user_name='김땡땡';

이라는 똑같은 쿼리를 두 번 실행했을 때 첫 번째는 130000을, 두 번째는 80000을 받게 됩니다.

이렇게 같은 트랜잭션 내에서 같은 쿼리를 여러 번 실행했을 때 같은 row의 값이 서로 다른 것을 non-repeatable read라고 합니다.

🥑 REPEATABLE READ (레벨 2)

REPEATABLE READ는 한 트랜잭션 내에서 한 번 읽은 데이터는 바뀌지 않는다는 것을 보장하는 격리 수준입니다.

따라서 READ COMMITTED에서 발생했던 non-repeatable read 문제가 발생하지 않습니다.

REPEATABLE READ는 MySQL(MariaDB)의 default 격리 수준입니다.

별도로 격리 수준을 변경하지 않은 MariaDB에서 격리 수준을 조회해봤습니다. REPEATABLE READ입니다.

REPEATABLE READ 격리 수준에서는 여러 번 읽어도 같은 결과가 나오는 것을 보장하는 것은 아니지만 읽은 row들은 변하지 않는 것을 보장합니다.

그럼 어떤 경우에 같은 결과임을 보장하지 못하나요?

새로운 row가 추가될 때입니다.

읽었던 row들은 변하지 않지만 새로운 row가 추가되면 처음 읽었을 때는 없었던 새로운 row도 읽어옵니다.

따라서 처음 읽었을 때는 row 수가 10개였는데 다시 읽었을 때는 row 수가 11이 되는 현상이 발생할 수 있습니다.

이렇게 유령처럼 없던 row가 생기는 것을 phantom read라고 합니다.

그럼 있던 row가 사라지는 경우도 발생하나요? 🤔

아니요!!!!!

없던 row는 새로 생길 수 있지만 있던 row는 사라지지도, 값이 변경되지도 않습니다.

있던 row가 사라지거나 없던 row가 생긴다고 적은 글들이 많은데 이는 잘못된 정보입니다.

 

대부분의 RDBMS는 REPEATABLE READ 수준에서 phantom read 현상이 발생하지만 MySQL(MariaDB)의 InnoDB 엔진에서는 SNAPSHOT을 이용하기 때문에 phantom read 현상이 발생하지 않는다고 합니다. 

 

🥑 SERIALIZABLE (레벨 3)

SERIALIZABLE 격리 수준은 모든 트랜잭션 작업이 순차적으로 진행되는 격리수준입니다.

따라서 REPEATABLE READ 수준에서 발생했던 Phantom Read 문제가 발생하지 않습니다.

그럼 SELECT ... FOR UPDATE문으로 lock을 거는 것과는 뭐가 다른걸까요?

SELECT ... FOR UPDATE문은 특정 row들에만 lock을 거는 반면 SERIALIZABLE에서는 전체에 lock을 건다는 점입니다.

(물론 RDBMS마다 차이가 있어서 모든 RDBMS가 SERIALIZABLE 수준일 때 전체 lock을 거는 것은 아닙니다.)

 

그럼 트랜잭션 격리 수준을 변경하려면 어떻게 해야 할까요?

MySQL 기준

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED

이렇게 하면 트랜잭션 격리 수준을 변경할 수 있습니다.

 

만약 현재 세션에서만 격리 수준을 변경하고 싶다면

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED

이렇게 해주면 됩니다.

 

 

참고

https://learn.microsoft.com/en-us/sql/odbc/reference/develop-app/transaction-isolation-levels?view=sql-server-ver16 

https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html

https://www.sqlshack.com/dirty-reads-and-the-read-uncommitted-isolation-level

https://www.postgresql.kr/docs/13/transaction-iso.html

https://dotnettutorials.net/lesson/non-repeatable-read-concurrency-problem/

https://nesoy.github.io/articles/2019-05/Database-Transaction-isolation

https://www.zghurskyi.com/lost-update/

https://en.wikipedia.org/wiki/Isolation_(database_systems) 

https://stackoverflow.com/questions/11043712/what-is-the-difference-between-non-repeatable-read-and-phantom-read

https://stackoverflow.com/questions/4034976/difference-between-read-commited-and-repeatable-read-in-sql-server

https://stackoverflow.com/questions/16957638/serializable-transactions-vs-select-for-update

'데이터베이스' 카테고리의 다른 글

[DB] Optimistic Locking vs Pessimistic Locking  (0) 2023.03.13

안녕하세요

일단 저는 Java 17이 나온 이후로는 Java 버전을 17로 설정하여 프로젝트를 만들고 있습니다. (LTS 버전이기도 하고 Java 17의 stream 메소드 toList()가 너무 편리하더라고요?ㅎㅎ collect(Collectors.toList()) 안 쓰고 저거 쓰니까 아주 신세계...👍)

그런데 매번 오류가 났습니다. 왜냐하면 제 컴퓨터에는 JDK 11이 기본으로 설정되어 있거든요.. (환경변수 설정만 바꿔주면 되는데 말이죠...... 그게 귀찮아서.........)

 

그래서 저는 당연히

'프로젝트를 생성할 때 Java 버전을 17로 만들었는데 내 컴퓨터의 default JDK 버전이 11이니까 당연히 JDK 17이 없다고 오류가 나겠지'

하면서 항상 인텔리제이의 Settings에 들어가서 JDK 17로 바꿔주고 실행을 했었습니다.

 

그런데 얼마 전 블로그를 작성하다가, 예제 코드를 깃허브에 올려두려고 프로젝트를 다시 만들었습니다.

제 블로그를 읽으시는 분들이 JDK 17이 설치가 안 되어 있을 수도 있으니 Java 11로 만들어야겠다! 하고 Spring Boot 3.0.4, Java 11로 프로젝트를 만들었습니다.

 

그런데 여전히 똑같은 오류가 나는 것입니다!!!

아니 Java 11로 프로젝트를 만들었고 내 컴퓨터에 설치된 JDK도 11 버전인데 왜 오류가 나는거지??

 

검색을 해보니 Spring Boot 3.0부터는 Java 17 이상만 지원한다고 합니다.

 

이 사실을 몰랐을 때부터 매번 무심코 해줬던 설정인데,

Spring Boot 3.0 이상부터는 Java 17부터만 된다는 사실을 알게 된 기념(?)으로

같은 문제로 어려움을 겪고 계신 분들께 조금이나마 도움이 되기를 바라며 제가 해결한 방법을 공유하고자 합니다.

✅ 실행 환경

일단 저의 환경을 공유해드리자면 시스템의 환경변수로 등록되어 있는 것은 JDK 11입니다.

 

하지만 제 컴퓨터에는 JDK 8, JDK 11, JDK 17이 설치되어 있습니다.

스프링 부트 버전은 3.0.4입니다. 

✅ 문제

스프링 부트 3.0 이상의 프로젝트를 열었더니 이런 오류가 발생합니다.

A problem occurred configuring root project '프로젝트명'.
> Could not resolve all files for configuration ':classpath'.
   > Could not resolve org.springframework.boot:spring-boot-gradle-plugin:3.0.4.
     Required by:
         project : > org.springframework.boot:org.springframework.boot.gradle.plugin:3.0.4
      > No matching variant of org.springframework.boot:spring-boot-gradle-plugin:3.0.4 was found. The consumer was configured to find a runtime of a library compatible with Java 11, packaged as a jar, and its dependencies declared externally, as well as attribute 'org.gradle.plugin.api-version' with value '7.6.1' but:
          - Variant 'apiElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'javadocElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a runtime of a component, and its dependencies declared externally:
              - Incompatible because this component declares documentation and the consumer needed a library
              - Other compatible attributes:
                  - Doesn't say anything about its target Java version (required compatibility with Java 11)
                  - Doesn't say anything about its elements (required them packaged as a jar)
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'mavenOptionalApiElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.4 declares a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'mavenOptionalRuntimeElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.4 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'runtimeElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'sourcesElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a runtime of a component, and its dependencies declared externally:
              - Incompatible because this component declares documentation and the consumer needed a library
              - Other compatible attributes:
                  - Doesn't say anything about its target Java version (required compatibility with Java 11)
                  - Doesn't say anything about its elements (required them packaged as a jar)
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

 

✅ 해결 방법

1. Settings에 들어갑니다.(MacOS이신 분들은 Preferences)

2. Build, Execution, Deployment > Build Tools > Gradle에 들어갑니다.

JDK 11이 적용되어 있는 것을 볼 수 있습니다.

 

3. JDK 17로 변경

17 버전의 JDK를 선택한 후 OK 또는 Apply 버튼을 누릅니다.

4. Gradle Reload를 해줍니다.

코끼리 모양의 버튼 또는 오른쪽 Gradle 탭의 Reload 버튼을 눌러 Refresh 해줍니다.

5. 빌드 성공

 

참고

https://jojoldu.tistory.com/698

안녕하세요

얼마 전 프로젝트를 진행하던 중 request로 받은 파라미터들이 @ModelAttribute 객체에 바인딩이 되지 않는 이슈를 겪고 @ModelAttribute에 대해 자세히 알아보게 되었습니다.

왜 그런 것이고 어떻게 해결해야 하는지 알아보겠습니다.

 

먼저 직접 DTO와 컨트롤러를 만들어 테스트해보겠습니다.

전체 프로젝트 코드는 여기에서 보실 수 있습니다.

🥑 NoArgsAndAllArgsDto

매개변수가 없는 생성자와, 모든 필드가 다 매개변수로 있는 생성자 이렇게 2개의 생성자가 있는 DTO 클래스입니다.

@Getter
public class NoArgsAndAllArgsDto {

    private String nickname;

    private Integer count;

    public NoArgsAndAllArgsDto() {
    }

    public NoArgsAndAllArgsDto(String nickname, Integer count) {
        this.nickname = nickname;
        this.count = count;
    }
}

🥑AllArgsDto

모든 필드가 다 매개변수로 있는 생성자만 있는 DTO 클래스입니다.

@Getter
public class AllArgsDto {

    private String nickname;

    private Integer count;

    public AllArgsDto(String nickname, Integer count) {
        this.nickname = nickname;
        this.count = count;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

🥑NoArgsAndSetterDto

매개변수가 없는 생성자와 각각의 필드를 세팅할 수 있는 Setter가이 있는 DTO 클래스입니다.

@Getter
public class NoArgsAndSetterDto {

    private String nickname;

    private Integer count;

    public NoArgsAndSetterDto() {
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

🥑 TestController.java

테스트를 하기 위한 컨트롤러입니다.

@RestController
@RequestMapping("/test")
public class ModelAttributeTestController {

    @PostMapping("/no-args-and-all-args")
    public NoArgsAndAllArgsDto test1(@ModelAttribute NoArgsAndAllArgsDto requestDto) {
        return requestDto;
    }

    @PostMapping("/all-args-only")
    public AllArgsDto test2(@ModelAttribute AllArgsDto requestDto) {
        return requestDto;
    }

    @PostMapping("/no-args-and-setter")
    public NoArgsAndSetterDto test3(@ModelAttribute NoArgsAndSetterDto requestDto) {
        return requestDto;
    }

}

 

이제 테스트를 해봅시다.

🥑 NoArgsConstructor와 AllArgsConstructor가 있을 때

@Test
@DisplayName("NoArgsConstructor와 AllArgsConstructor가 있을 때 값이 할당이 잘 되는지 테스트")
void test1() throws Exception {
    NoArgsAndAllArgsDto requestDto = new NoArgsAndAllArgsDto("feelcoding", 100);

    mvc.perform(post("/test/no-args-and-all-args")
            .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)));

}

테스트 코드입니다.

테스트에 실패했습니다.

nickname 필드와 count 필드 모두 값이 할당되지 않고 null인 것을 볼 수 있습니다.

 

🥑 AllArgsConstructor만 있을 때

@Test
@DisplayName("AllArgsConstructor만 있을 때 값이 할당이 잘 되는지 테스트")
void test2() throws Exception {
    AllArgsDto requestDto = new AllArgsDto("feelcoding", 100);

    mvc.perform(post("/test/all-args-only")
                    .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)))
            .andDo(print());
}

테스트 코드입니다.

테스트에 성공했습니다.

 

🥑 NoArgsConstructor와 Setter가 있을 때

@Test
@DisplayName("NoArgsConstructor와 Setter가 있을 때 값이 할당이 잘 되는지 테스트")
void test3() throws Exception {
    NoArgsAndSetterDto requestDto = new NoArgsAndSetterDto();
    requestDto.setNickname("feelcoding");
    requestDto.setCount(100);

    mvc.perform(post("/test/no-args-and-setter")
                    .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)))
            .andDo(print());
}

테스트 코드입니다.

테스트에 성공했습니다.

 

✅ 테스트 결과

  • AllArgsConstructor만 있을 때는 분명히 값이 잘 할당되었지만 NoArgsConstructor와 AllArgsConstructor가 모두 있으면 값이 할당되지 않았습니다.
  • NoArgsConstructorSetter가 있을 때는 할당이 잘 되었습니다.

 

왜 그런 것일까요? 🤔

 

그것은 @ModelAttrubute가 어떻게 동작하는지 내부 구조를 살펴보면 알 수 있습니다.

 

🥑 @ModelAttribute의 동작 원리를 알아보자

전체 검색 (Windows는 ctrl + shift + f, MacOS는 cmd + shift + f)으로 ModelAttribute.class를 검색하고 Scope All Place로 해주면 ModelAttributeMethodProcessor.java 파일이 보입니다.

이 파일에 한 번 들어가보겠습니다.

ModelAttributeMethodProcessor

HandlerMethodArgumentResolver를 구현한 클래스네요. (HandlerMethodArgumentResolver에 대해서는 이전 글을 참고해주세요)

request를 어떻게 가공해서 Controller의 매개변수로 넣어줬는지 resolveArgument() 메소드를 확인해봅시다.

핵심 로직을 살펴보면 createAttribute()라는 메소드를 호출하여 attrubute에 할당한 후 attribute를 리턴하는 것을 볼 수 있습니다

그렇다면 createAttribute() 메소드에 가봅시다.

Reflection을 사용해서 해당 매개변수가 어느 클래스 타입인지 알아내고 해당 클래스를 getResolvableConstructor() 메소드의 인자로 넘겨 생성자를 받습니다.

그 생성자로 객체를 만들고 리턴하는 것을 볼 수 있습니다.

그러면 아마 범인은 저 getResolvableConstructor() 메소드일 것 같습니다. 해당 메소드로 가봅시다.

저희가 테스트했던 NoArgsAndAllArgsConstructor는 생성자가 2개이기 때문에 위의 if문과 else if문은 타지 않을 것입니다.

그렇다면 try 문을 실행하게 될텐데요, 위에 주석으로 '생성자가 여러 개면 기본 생성자로 하자' 라고 써있습니다.

(사실 엄격하게 말하면 제가 만든 생성자는 기본 생성자가 아닙니다. 단지 매개변수 없는 생성자입니다. 기본 생성자는 제가 아무런 생성자도 만들지 않았을 때 자동으로 만들어준 생성자를 말하는 것입니다.)

저 getDeclaredConstructor를 타고 들어가보면 너무 복잡하기 때문에 내부 구조를 파헤치는 것은 여기까지 하는 것으로 합시다..ㅎㅎ

(catch문에 Giving up... 이라고 써있는 거 너무 웃기네요..ㅎㅎ)

 

✅ 디버거를 통해 확인

디버거를 통해 정말 해당 로직을 타는지 확인해봅시다.

NoArgsAndAllArgsDto의 생성자가 2개라서 if와 else if 문을 안 타고 try문으로 오는 것을 확인할 수 있습니다.

getResolvableConstructor() 메소드 호출로 리턴 받은 생성자가 매개변수 없는 생성자임을 알 수 있다

getResolvableConstructor()의 호출하여 얻은 ctor가 매개변수 없는 생성자 NoArgsAndAllArgsDto()인 것을 볼 수 있습니다.

그러니까 AllArgsConstructor가 있는데도 NoArgsConstructor로 객체를 생성하고, Setter가 없어서 값을 세팅하지 못 한 것입니다.

 

✅ 정리

  • @ModelAttribute는 생성자가 1개면 그 생성자를 통해 객체를 생성합니다.
  • 생성자가 2개 이상이면 매개변수 없는 생성자를 통해 객체를 생성하고 Setter로 값을 세팅합니다.

✅ 결론

@ModelAttribute로 바인딩하려는 클래스에 NoArgsConstructor가 있다면 반드시 Setter를 만들어주세요!

아니면 NoArgsConstructor를 없애고 AllArgsConstructor만 두세요!

 

 

참고

https://steady-coding.tistory.com/489

안녕하세요

오늘은 Optimistic Locking과 Pessimistic Locking에 대해 알아보겠습니다.

 

일단 상황을 가정해보겠습니다.

 

김땡땡이라는 사용자는 1000 포인트가 있었습니다.

김땡땡은 물건을 구매하면서 1000 포인트를 씁니다.

그리고 김땡땡은 며칠 전에 응모했던 이벤트에 당첨이 되어 500포인트를 지급받게 됩니다.

원래 1000포인트가 있었고, 1000 포인트를 쓰고 500 포인트를 받았으니 최종적으로 500포인트가 있어야 합니다.

하지만 김땡땡의 포인트는 1500포인트였습니다.

🤔 왜 이런 일이 생긴걸까요?

Transaction 1과 Transaction 2가 있습니다. (앞으로는 편의상 T1과 T2로 부르겠습니다.)

T1에서는 1000 포인트를 차감하는 일이 수행됩니다.

T2에서는 500 포인트를 증가하는 일이 수행됩니다.

🕐 11:30:05.051

T1이 id가 1인 사용자를 조회했습니다. 이 사용자의 포인트는 1000포인트입니다.

🕑 11:30:05.085

T2가 id가 1인 사용자를 조회했습니다. 이 사용자의 포인트는 1000포인트입니다.

🕒 11:30:05.138

T1이 해당 사용자의 포인트를 1000 포인트 차감합니다. 1000 - 1000 = 0이므로 해당 사용자의 포인트를 0으로 UPDATE 하고 커밋합니다.

🕓11:30:05.376

T2가 해당 사용자의 포인트를 500 포인트 증가시킵니다. 1000 + 500 = 1500이므로 해당 사용자의 포인트를 1500으로 UPDATE 하고 커밋합니다.

 

김땡땡의 포인트는 500 포인트가 있어야 하는데 1500포인트가 된 것입니다.

 

이것이 바로 동시성 문제입니다.

데이터에 Lock을 거는 것은 이러한 동시성 문제를 해결하기 위한 방법 중 하나입니다.

🔓 Optimistic Locking

Optimisitc Locking은 말그대로 낙관적으로 보는 방법입니다.

DB 트랜잭션을 사용하지 않고 Application 단에서 처리를 합니다.

일단 동시에 해당 데이터에 접근하는 경우가 없을 것이라고 생각하고(낙관적이죠?) lock을 걸지 않고 일단 해당 데이터에 접근하여 조회를 하고 UPDATE를 시도합니다. 그 사이에 데이터가 변경되었다면 UPDATE에 실패합니다.

🤔 그렇다면 데이터가 변경되었는지는 어떻게 알 수 있을까요?

테이블에 데이터 변경 확인을 위한 컬럼을 하나 추가하는 것입니다.

수정 시각 timestamp를 남길 수도 있고

수정을 할 때마다 version을 증가시킬 수도 있고

checksum이나 hash를 사용할 수도 있습니다.

만약 이것을 확인했을 때, 조회했을 때와 다르다면 UPDATE에 실패를 하는 것입니다.

🙂 아까 상황에 Optimistic Locking을 적용해봅시다.

저희는 버전을 이용해서 변경 체크를 하도록 하겠습니다.

Optimistic Locking에는 트랜잭션이 없기 때문에 T1과 T2의 T가 Application의 서로 다른 Thread라고 생각하면 될 것 같습니다.

초기 상태는 이렇습니다.

1. T1이 조회를 합니다. version은 0, 포인트는 1000입니다.

2. T2가 조회를 합니다. version은 0, 포인트는 1000입니다.

3. T1이 김땡땡의 포인트를 0으로, version은 1로 UPDATE를 합니다.

UPDATE user SET point=0, version=1 WHERE id=1 AND version = 0

version이 아까 조회했을 때와 동일하게 0이기 때문에 정상적으로 처리가 됩니다.

이제 DB의 상태는 위와 같아졌습니다.

4. T2가 김땡땡의 포인트를 0으로 UPDATE를 합니다.

UPDATE user SET point=1500, version=1 WHERE id=1 AND version = 0

내 버전은 0인데 현재 버전은 1로, 버전이 다릅니다.

id=1이고 version=0인 row가 없기 때문에 UPDATE에 실패를 합니다.

 

아까와 같은 문제가 발생하지 않았습니다.

하지만 이대로라면 500 포인트를 추가하는 로직이 실행되지 않았기 때문에 재시도를 해야 합니다.

 

🔃 재시도

1. T2가 다시 조회를 합니다. version은 1, 포인트는 0입니다.

2. T2가 포인트를 500으로 UPDATE합니다.

UPDATE user SET point=500, version=1 WHERE id=1 AND version = 1

조회했을 때의 version은 1, 지금의 버전도 1로 같으므로 정상적으로 처리가 됩니다.

 

재시도는 마법처럼 자동으로 되는 것이 아니기 때문에 Application에서 실패시 재시도를 하는 로직을 직접 구현을 해주어야 합니다.

재시도시에도 또 충돌이 발생할 수 있기 때문에 그것을 고려하여 로직을 작성해야 합니다. (ex. 몇 번 이상 시도 후 그래도 성공하지 못하면 에러를 응답하는 등)

 

Optimistic Lock은 트랜잭션을 이용하지 않고 Lock을 걸지도 않습니다.

 

🔒 Pessimistic Locking

Pessimistic Locking은 충돌이 발생할 것이라고 비관적으로 생각하고 처리하는 방법입니다. 

Pessimistic Locking이 바로 우리가 흔히 알고 있는 Lock으로, 실제로 데이터에 Exclusive Lock 또는 Shared Lock을 거는 방법입니다.

트랜잭션이 시작할 때 Lock을 걸고 시작합니다.

Lock을 획득하지 못하면 UPDATE를 할 수 없습니다.

다른 트랜잭션이 끝나고 Lock이 해제되면 그 때, Lock을 얻은 후 정상 처리가 됩니다.

🙂 아까 상황에 Pessimistic Locking을 적용해봅시다.

저희는 조회한 포인트를 기반으로 포인트를 수정하기 때문에 Exclusive Lock을 걸어야 합니다.

1. T1이 Lock을 획득하고 조회를 합니다.

SELECT point FROM user where id=1 FOR UPDATE
2. T2가 Lock 획득을 시도하지만 실패합니다.

3. T1이 UPDATE를 하고 COMMIT을 합니다.

4. T2가 Lock 획득을 성공하고 조회 후 UPDATE, COMMIT을 합니다.

 

여러 테이블에 Pessimistic Lock을 거는 경우 데드락이 발생할 수 있습니다.

불필요한 경우에도 Lock을 걸기 때문에 성능이 저하될 수 있습니다.

✅ 정리

충돌이 빈번하게 발생하지 않는 경우 Optimistic Locking을 사용하는 것이 좋습니다.

만약 티켓팅, 수강신청과 같은 상황에서 Optimistic Locking을 사용한다면 재시도 로직이 엄청나게 많이 발생할 것입니다.

Pessimistic Lock은 성능 저하 및 데드락이 발생할 수 있습니다.

 

참고

https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking

https://en.wikipedia.org/wiki/Optimistic_concurrency_control

'데이터베이스' 카테고리의 다른 글

[DB] 트랜잭션 격리 수준에 대해 알아보자  (0) 2023.03.27

안녕하세요
오늘은 Spring에서 제공하는 정말 편리한 기능인 HandlerMethodArgumentResolver에 대해 알아보겠습니다.
HandlerMethodArgumentResolver는 무엇이고 어떤 경우에 사용하는 것이 좋을까요?
예제를 보며 함께 알아보는 시간을 가져봅시다.

❓ 대부분의 API에 필요한 공통적인 로직이 있다면 어떻게 처리하는 것이 좋을까?

공통적인 로직이 있다면 스프링에서는 interceptor, filter, AOP 등 다양한 방법으로 처리할 수 있습니다.
하지만 공통적인 로직을 처리하여 어떤 결과값을 컨트롤러에 넘겨줘야 한다면 어떻게 하는 것이 좋을까요?
 
예를 들어, 클라이언트 측에서 header에 토큰을 담아서 request를 보내는 경우가 있다고 가정해봅시다.
그리고 아래와 같이 거의 모든 API에서 해당 요청을 보낸 사용자가 누구인지 사용자 정보가 필요합니다.

package com.feelcoding.argumentresolverdemo.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        // 이 API에서는 사용자 정보가 필요합니다!
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }
}

 
이 경우, 사용자의 정보를 알아내는 코드가 여러 곳에서 중복될테니 토큰으로부터 사용자 정보를 알아내 User 객체를 리턴해주는 메소드를 따로 뽑는 것이 나을 것 같습니다.
 

🤨 중복되는 로직을 메소드로 만들어보자

public User getLoginUser(String token) {
    return userRepository.findByEmail(token).orElseThrow();
}

토큰으로부터 사용자 정보를 알아내서 User 객체를 리턴하는 메소드를 이렇게 만들었습니다!
(원래는 토큰을 파싱해서 사용자 정보를 알아내야 하는데 여기에서 토큰에 대한 설명까지 하면 토큰에 대한 글이 될 것 같아 토큰 부분에 이메일을 헤더에 담아 보내는 것으로 했습니다😅)
 

package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.dto.SignUpRequestDto;
import com.feelcoding.argumentresolverdemo.dto.SignUpResponseDto;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    private final AuthService authService;

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

}

메소드로 따로 뺐기 때문에 그냥 중복되는 코드를 여러 번 작성하는 것보다는 낫겠지만,
사용자 정보가 필요한 컨트롤러마다 이렇게 코드가 반복되어 들어갔습니다.
 

🤔 @LoginUser 어노테이션만 달면 컨트롤러 메소드에 User 객체를 넣어주면 얼마나 좋을까?

헤더에 있는 토큰을 읽어서 User 객체를 반환해주는 이런 공통적인 부분을 어딘가에서 처리해서 컨트롤러의 매개변수로 User 객체를 짠! 하고 넘겨주면 얼마나 좋을까요? 이렇게 말입니다.

@GetMapping("/test1")
public void test1(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@PostMapping("/test2")
public void test2(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@GetMapping("/test3")
public void test3(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@GetMapping("/test4")
public void test4(@LoginUser User user) {
    // 어쩌구 저쩌구
}

 
 
HandlerMethodArgumentResolver를 사용하면 이것이 가능해집니다!
 

❗ HandlerMethodArgumentResolver를 사용하여 사용자 정보를 쉽게 받아오자

위와 같이 @LoginUser와 같은 어노테이션 하나로 컨트롤러에서 쉽게 User 객체를 얻어오려면 세 가지를 해야 합니다.

  1. @LoginUser 어노테이션을 만들어줍니다.
  2. HandlerMethodArgumentResolver를 구현한 클래스를 만들어야 하고
  3. 이렇게 구현한 HandlerMethodArgumentResolver를 등록해주어야 합니다.

하나씩 해볼까요?
 

1️⃣ LoginUser 어노테이션을 만들어줍니다.

 
LoginUser라는 어노테이션 이름은 제가 임의로 정한 것이고 원하는대로 변경하셔서 사용하시면 됩니다.

package com.feelcoding.argumentresolverdemo.util;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

어노테이션을 만들 때는 interface 앞에 @를 붙이면 됩니다.
@Target(ElementType.PARAMETER)은 해당 어노테이션이 매개변수에 쓰일 것이기 때문에 PARAMETER로 해주었습니다.
@Retention(RetentionPolicy.RUNTIME)은 해당 어노테이션이 런타임까지도 쓰일 것이기 때문에 RUNTIME으로 해주었습니다.
 

2️⃣ HandlerMethodArgumentResolver를 구현한 클래스를 만들어줍니다.

 
HandlerMethodArumentResolver를 구현한 클래스를 만들면 메소드를 구현하라고 이렇게 에러가 뜹니다.

이제 두 메소드를 구현해봅시다.

✅ supportsParameter

Whether the given method parameter is supported by this resolver.

supportsParameter 메소드는 해당 메소드의 매개변수가 해당 resolver가 지원하는지를 체크하는 것입니다.
true를 반환하면 지원한다는 것이고 false를 반환하면 지원하지 않는다는 것입니다.

@Override
public boolean supportsParameter(MethodParameter parameter) {
    boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
    boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
    return hasLoginUserAnnotation && isUserType;
}

저는 매개변수에 LoginUser라는 어노테이션이 있고 해당 매개변수의 타입이 User 타입인지를 체크해주었습니다.
 

✅ resolveArgument

Resolves a method parameter into an argument value from a given request.

resolveArgument 메소드는 매개변수로 넣어줄 값을 제공하는 메소드입니다.
저희에게는 User 객체가 되겠죠?
User 객체를 반환하는 resolveArgumentParemeter 메소드를 구현해봅시다.

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
    String token = request.getHeader("Authorization");
    if (token == null) {
        return null;
    }
    return authService.getLoginUser(token);
}

request의 헤더에서 토큰을 꺼내고 토큰을 통해 얻은 User 객체를 반환하였습니다.

returns: the resolved argument value, or null if not resolvable

공식 문서의 resolveArgument 메소드에 대한 설명을 보면 이렇게 resolve할 수 없으면 null을 반환하라고 나와있습니다.

따라서 토큰이 없다면 null을 반환했습니다.

 

아래는 LoginUserArgumentResolver 전체 코드입니다.

package com.feelcoding.argumentresolverdemo.util;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
@RequiredArgsConstructor
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthService authService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
        boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginUserAnnotation && isUserType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        String token = request.getHeader("Authorization");
        if (token == null) {
            return null;
        }
        return authService.getLoginUser(token);
    }
}

LoginUserArgumentResolver를 @Component를 이용하여 빈으로 등록해준 이유는 뒤에서 설명하겠습니다.
 

3️⃣ 구현한 HandlerMethodArgumentResolver를 등록해줍니다.

 
이제 저희가 만든 LoginUserArgumentResolver를 등록해봅시다.
HandlerMethodArgumentResolver 등록을 위해서는 WebMvcConfigurer를 구현한 클래스가 필요합니다.
(저는 WebConfig라는 이름으로 클래스를 만들었습니다.)
그리고 addArgumentResolvers()라는 메소드를 구현해야 합니다.
이 메소드에서 아까 저희가 만든 LoginUserArgumentResolver를 추가해주시면 됩니다.

package com.feelcoding.argumentresolverdemo.config;

import com.feelcoding.argumentresolverdemo.util.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}

이렇게 해주시면 됩니다.
아까 LoginUserArgumentResolver를 빈으로 등록한 이유가 바로 여기에서 주입받아 사용하기 위함이었습니다.
 
드디어 완성이 되었습니다.
이제 컨트롤러에서 User 객체를 받아볼까요?
 

🔥컨트롤러에서 현재 로그인한 사용자의 User 객체를 매개변수로 받아보자

 컨트롤러의 매개변수로 User 정보를 잘 받아오는지 확인해봅시다.
 
우선 매개변수에 @LoginUser 어노테이션과 User 매개변수를 추가해줍니다.
그리고 사용자 정보를 잘 가져오는지 확인하기 위해 로그를 남겨봅시다.

package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.util.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@LoginUser User user) {
        log.debug("여기는 test1, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @PostMapping("/test2")
    public void test2(@LoginUser User user) {
        log.debug("여기는 test2, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @GetMapping("/test3")
    public void test3(@LoginUser User user) {
        log.debug("여기는 test3, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @GetMapping("/test4")
    public void test4(@LoginUser User user) {
        log.debug("여기는 test4, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

}

 
이제 Postman을 통해 API들을 호출해봅시다.

이렇게 헤더에는 Authorization이라는 이름으로 사용자의 이메일을 넣어주고
GET /test1, POST /test2, GET /test3, GET /test4 API들을 차례로 호출해보겠습니다.
 

이렇게 사용자 정보를 모두 다 잘 가져온 것을 볼 수 있습니다.
 
이렇게 HandlerMethodArgumentResolver를 사용하면 여러 곳에서 사용되는 공통적인 로직을 줄이고, 매개변수로 필요한 정보를 손쉽게 가져올 수 있습니다.
예시를 로그인으로 들었지만, 필요할 때 여러 방법으로 활용하시면 될 것 같습니다.
 
읽어주셔서 감사합니다 :)
 

출처: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/method/support/HandlerMethodArgumentResolver.html

안녕하세요

오늘은 Java의 equals() 메소드에 대해 알아보겠습니다.

 

equals()는 내용이 같은지를 비교하는 것이고 ==은 같은 객체인지 확인하는 것이라는 말을 많이 들었을 것입니다.

정말 그럴까요?

 

우선 Java에서 ==은 같은 객체인지 아닌지를 비교하는 연산자입니다. (primitive 타입의 경우 값 비교)

 

우선 Object 클래스의 equals() 메소드를 한 번 살펴봅시다.

Object 클래스의 equals() 메소드는 ==으로 비교하는 것을 볼 수 있습니다.

즉, Object 클래스는 ==으로 비교하는 것과 equals() 메소드로 비교하는 것이 똑같다는 것입니다.

정말 그런지 한 번 확인해볼까요?

public class ObjectTest {

    public static void main(String[] args) {
        Object o1 = new Object();
        Object o2 = o1;
        Object o3 = new Object();
        Object o4 = new Object();
        System.out.println((o1 == o2) + " " + o1.equals(o2));
        System.out.println((o1 == o3) + " " + o1.equals(o3));
        System.out.println((o1 == o4) + " " + o1.equals(o4));
        System.out.println((o2 == o3) + " " + o2.equals(o3));
        System.out.println((o2 == o4) + " " + o2.equals(o4));
        System.out.println((o3 == o4) + " " + o3.equals(o4));
    }

}

o1과 o2는 같은 객체입니다.

 

이 코드를 실행해보면

==으로 비교한 것과 equals() 메소드로 비교한 결과가 모두 같습니다.

또한 같은 객체인 o1과 o2만 true가 나온 것을 볼 수 있습니다.

 

그럼 이제 제가 클래스를 하나 만들어보겠습니다.

 
public class Person {

    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

}

이름과 나이를 상태로 가지는 Person이라는 클래스를 만들었습니다.

 

public class EqualsTest {
    public static void main(String[] args) {
        Person p1 = new Person("김땡땡", 20);
        Person p2 = new Person("김땡땡", 20);
        Person p3 = p2;
        Person p4 = new Person("이땡땡", 25);
        System.out.println(p1.equals(p2));
        System.out.println(p1.equals(p3));
        System.out.println(p1.equals(p4));
        System.out.println(p2.equals(p3));
        System.out.println(p2.equals(p4));
        System.out.println(p3.equals(p4));
    }
}

p1과 p2는 이름과 나이가 모두 같고, p2와 p3는 같은 객체입니다.

p4는 p1, p2, p3과 다른 객체이고 내용도 다릅니다.

 

그리고 이 코드를 실행하면 어떤 결과가 나올까요?

.

.

.

.

.

.

.

.

.

.

.

.

.

.

결과는 이렇습니다.

같은 객체인 p2와 p3의 비교에서만 true를 리턴했습니다.

 

p1과 p2는 나이와 이름이 모두 같은데 왜 equals()로 비교를 했을 때 다르다고 나왔을까요?

 

사실 이는 당연한 결과입니다. Object 클래스의 equals()는 ==으로 비교한 것과 똑같은 것을 확인했었죠?

제가 만든 Person 클래스는 Object 클래스를 상속받기 때문에 Object의 equals()와 동일합니다.

따라서 Person 객체들을 ==으로 비교한 것과 결과가 똑같이 같은 객체인 경우에만 true를 반환했던 것입니다.

 

그런데 저는 같은 객체일 때가 아니라 이름과 나이가 모두 동일할 때 true를 반환하고 싶습니다.

그러면 어떻게 해야 할까요?

 

이름과 나이가 같으면 true를 반환하도록 equals() 메소드를 오버라이딩(재정의) 해주면 됩니다.

@Override
public boolean equals(Object o) {
    if (o instanceof Person) {
        Person p = (Person) o;
        if (p.getName() != null && p.getName().equals(this.name) && p.getAge() == this.age) {
            return true;
        } else {
            return false;
        }
    } else {
        return false;
    }
}

저는 이렇게 작성해보았습니다.

 

equals() 메소드를 오버라이딩 했으니 이제 아까 코드를 다시 실행해볼까요?

public class EqualsTest {
    public static void main(String[] args) {
        Person p1 = new Person("김땡땡", 20);
        Person p2 = new Person("김땡땡", 20);
        Person p3 = p2;
        Person p4 = new Person("이땡땡", 25);
        System.out.println(p1.equals(p2));
        System.out.println(p1.equals(p3));
        System.out.println(p1.equals(p4));
        System.out.println(p2.equals(p3));
        System.out.println(p2.equals(p4));
        System.out.println(p3.equals(p4));
    }
}
이제 실행결과가 어떻게 나올까요?

이렇게 이름과 나이가 같은 p1과 p2의 비교에서 true를 리턴한 것을 볼 수 있습니다.
또한 p3는 p2와 동일한 객체이기 때문에 p1과 p3의 비교에서도 true가 나왔습니다.

 

그런데 equals() 메소드를 오버라이딩할 때 주의할 점이 있습니다.

이것은 Object 클래스의 equals() 메소드에 달린 주석입니다.
다른 객체가 이것과 같은지 아닌지를 나타냅니다.
equals() 메소드는 null이 아닌 참조 객체에 대해 비교를 수행합니다.
재귀적이다. null이 아닌 참조 타입 객체 x가 있을 때 x.equals(x)는 true를 반환해야 한다.
대칭적이다. null이 아닌 참조 타입 객체 x. y가 있을 때 y.equals(x)가 true일 때만 x.equals(y)가 true여야 한다.
전이적이다. null이 아닌 참조 타입 객체 x, y, z가 있을 때, x.equals(y)가 true이고 y.equals(z)가 true이면 x.equals(z)는 true를 반환해야 한다.
일관적이다. null이 아닌 참조  타입 객체 x, y가 있을 때 x.equals(y)를 여러 번 호출해도 일관적으로 계속 true를 반환하거나 일관적으로 계속 false를 반환해야 한다.
null이 아닌 참조 타입 객체 x가 있을 때, x.equals(null)은 false를 리턴해야 한다.

(의역이 있을 수 있습니다.)

equals() 메소드를 오버라이딩 할 때는 위 규칙들을 반드시 지키며 재정의해야 합니다.

 
또한 equals() 메소드를 오버라이딩할 땐 hashCode() 메소드도 오버라이딩하는 것이 좋습니다.
그렇지 않으면 HashSet이나 HashMap을 사용할 때 원치 않는 결과를 얻을 수 있습니다.

무슨 말인지 아직 와 닿지 않으시죠? 한 번 예를 들어보겠습니다.

아까 제가 구현한 Person 클래스를 key로 가지는 HashMap을 만들어보겠습니다.

import java.util.HashMap;
import java.util.Map;

public class HashMapTest {
    public static void main(String[] args) {
        Map<Person, Integer> map = new HashMap<>();
        Person p1 = new Person("김땡땡", 20);
        Person p2 = new Person("김땡땡", 20);
        map.put(p1, 1);
        map.put(p2, 2);
        System.out.println(map.size());
    }
}
아까 저희는 이름과 나이가 같으면 true를 리턴하도록 equals() 메소드를 재정의했습니다.

이름과 나이가 같은 객체 p1과 p2를 만들고 map에 한 번은 p1을 key로, 한 번은 p2를 key로 엔트리를 추가했습니다.

이 코드의 결과는 어떻게 될까요?
1을 출력할까요? 2를 출력할까요?
.
.
.
.
.
.
.
.
.
.
.
.
.
.
2를 출력합니다.
이상하지 않나요?
key 값이 같으니 덮어써질 것 같은데, 왜 다른 key로 인식하는 것일까요?
그 이유는 hashCode()를 재정의하지 않았기 때문입니다.
 
Object 클래스의 equals() 메소드에 달린 주석
같은 객체는 같은 해시코드를 가져야 한다는 hashCode() 메소드의 일반적인 규칙을 유지하기 위해서 equals() 메소드를 오버라이딩 했을 때는 일반적으로 hashCode()를 반드시 오버라이딩해야 한다.
위에 써있는 것처럼 equals() 메소드를 오버라이딩 했다면 equals() 메소드 비교에서 true를 반환하는 두 객체는 hashCode() 메소드에서도 같은 해시코드를 반환해야 합니다.
HashMap은 equals() 메소드와 hashCode() 메소드 모두 같아야 동일한 key로 인식합니다.
따라서 위 코드의 실행 결과 1을 출력하기를 원한다면 hashCode()를 오버라이딩하여 hashCode()의 리턴값까지 같게 만들어줘야 합니다.
한 번 hashCode() 메소드를 오버라이딩해보겠습니다.
@Override
public int hashCode() {
    int result = name != null ? name.hashCode() : 0;
    result = 31 * result + age;
    return result;
}
이렇게 hashCode() 메소드까지 오버라이딩 했으니 다시 아까 코드를 실행해볼까요?
import java.util.HashMap;
import java.util.Map;

public class HashMapTest {
    public static void main(String[] args) {
        Map<Person, Integer> map = new HashMap<>();
        Person p1 = new Person("김땡땡", 20);
        Person p2 = new Person("김땡땡", 20);
        map.put(p1, 1);
        map.put(p2, 2);
        System.out.println(map.size());
    }
}

이제 1을 출력하는 것을 볼 수 있습니다.

 

모든 클래스가 가지고 있는 equals() 클래스와 주의점에 대해서 알아보았습니다.

읽어주셔서 감사합니다.

안녕하세요

 

오늘은 logback을 통해 로그를 관리하는 방법을 알아보겠습니다.

목차

- 로그 색상 바꾸기

- 프로필에 따라 로그 레벨 다르게 설정하기

- 로그 파일을 분할해서 저장하기

- JPA SQL을 로그 파일에 남기기

 

 

로그 출력 테스트를 하기 위해서 일단 테스트 컨트롤러를 하나 만들어볼까요?

 

일단 build.gradle에 아래 3개의 의존성을 추가하고

// @RestController에 필요
implementation 'org.springframework.boot:spring-boot-starter-web'
    
// @Slf4j에 필요
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

 

테스트 컨트롤러를 만들고 다음과 같이 다섯가지 레벨의 로그를 찍어봅시다.

package com.feelcoding.logbackdemo.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
public class TestController {

    @GetMapping("/test")
    public void test() {
        log.trace("TRACE!!");
        log.debug("DEBUG!!");
        log.info("INFO!!");
        log.warn("WARN!!");
        log.error("ERROR!!");
    }
}

 

이 상태에서 실행을 하면 아래와 같이 로그가 찍히는 것을 볼 수 있습니다.

 

콘솔창을 clear한 후 아까 만들고 로그를 찍어 둔 API를 호출해보면

분명 로그를 5개 찍었는데 trace, debug레벨의 로그는 보이지 않고 info, warn, error레벨의 로그만 출력되는 것을 확인할 수 있습니다.

왜 그런 걸까요?

 

그 이유는 뒤에서 알아보도록 하고, 일단 제가 개발하면서 디버깅용으로 찍은 debug 레벨의 로그를 콘솔에서 보고 싶기 때문에 로그 레벨을 debug로 바꿔보겠습니다.

 

resources 폴더에 logback-spring.xml 파일을 만들고 아래와 같이 설정해줍니다.

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="DEBUG">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

"CONSOLE"이라는 이름의 ConsoleAppender를 하나 만들고 기본 세팅(root)을 DEBUG 레벨로 지정하고 해당 appender를 사용한 것입니다.

Appender에는 ConsoleAppender, FileAppender, RollingFileAppender, SMTPAppender, DBAppender 등이 있습니다.

지금 저희가 쓸 ConsoleAppender는 로그를 콘솔에 출력하겠다는 것입니다.

 

이렇게 logback 설정을 해주고 난 뒤 다시 실행을 시켜보면 아까와 달리 debug 레벨의 로그들이 찍히는 것을 볼 수 있습니다.

API를 호출해봐도

이렇게 debug 레벨의 로그가 잘 출력됩니다.

 

그런데 불편한 점이 몇 가지 있습니다.

1. 내 프로젝트의 debug 레벨 로그만 보이는 것이 아니라 다른 프레임워크나 라이브러리에서 찍은 debug 레벨의 로그까지 다 보여서 내 로그를 보기가 힘듭니다.

2. 로그에 색깔이 없어서 보기가 힘들고 안 예쁩니다.

 

이제 이 불편한 점들을 하나씩 해결해볼까요?

 

우선 1번부터 해결해보겠습니다.

다른 라이브러리의 로그는 info 레벨로 보고 싶고 내 프로젝트의 로그만 debug 레벨로 조정하고 싶다면

위와 같이 <root> 태그의 level 속성을 "DEBUG"에서 "INFO"로 바꿔서 기본 로그 레벨을 info레벨로 변경해주고

아래 코드를 추가해줍니다. ("내 프로젝트 패키지명" 부분은 본인 프로젝트의 패키지명을 적어주시면 됩니다.)

<logger name="내 프로젝트 패키지명" level="DEBUG" />

기본 로그 레벨은 info 레벨로 설정하지만 내 프로젝트 패키지에 해당하는 로그만 debug 레벨로 조정하겠다는 것입니다.

이렇게 하면 다른 라이브러리의 DEBUG 레벨 로그는 출력되지 않고 내 프로젝트의 debug 레벨 로그만 출력됩니다.

정말 그런지 확인해볼까요?

아까와 달리 스프링 프레임워크의 DEBUG 레벨의 로그는 보이지 않는 것을 확인할 수 있고

 

아까와 달리 org.springframework의 debug 레벨 로그는 출력되지 않고 제 프로젝트 패키지인 com.feelcoding.logbackdemo 패키지에 있는 로거에서만 debug 레벨의 로그가 출력되는 것을 볼 수 있습니다.

 

그럼 1번 문제점은 해결했고 2번 문제점을 해결해볼까요?

 

공식 문서에 따르면 

%black", "%red", "%green", "%yellow", "%blue", "%magenta", "%cyan", "%white", "%gray", "%boldRed", "%boldGreen", "%boldYellow", "%boldBlue", "%boldMagenta", "%boldCyan", "%boldWhite" and "%highlight"

이렇게 17개의 색상을 지원한다고 합니다.

그럼 색깔을 바꿔볼까요?

예시로 보여주기 위해 최대한 다양한 색깔을 사용해 보았습니다. 날짜는 초록색, 스레드명은 보라색, 로거는 청록색, 로그 메시지에는 노란색, 로그 레벨은 로그 레벨에 따라 다른 색상을 출력해주는 highlight를 적용했습니다.

%green(%d{yyyy-MM-dd HH:mm:ss.SSS}) %magenta([%thread]) %highlight(%5level) %cyan(%logger) - %yellow(%msg%n)

%색상명() 이렇게 감싸주면 됩니다.

아까와 달리 로그가 알록달록해졌습니다.

 

그런데 INFO,  WARN, ERROR 등 로그 레벨을 나타내는 글자 색은 아까 아무런 설정을 해주지 않았을 때의 색이 더 예쁜 것 같은데... 바꿀 수 없을까요?

아무 설정을 해주지 않았을 때의 로그 색상

그럼 기본 설정이 어떻게 되어 있는지, 기본 설정이 되어 있는 곳을 찾아가봅시다.

Windows 사용자는 ctrl + n, Mac OS 사용자는 cmd + o를 누르고 우측 상단에 범위를 Files 탭에서 base.xml을 검색해봅시다. (base.xml이라고 입력하면 자동으로 범위가 All Places로 바뀔 것인데, 만약 나오지 않는다면 우측 상단의 범위를 All Places로 수정해보세요.)

해당 파일에 들어가보면

defaults.xml 파일을 include 했다는 것을 알 수 있습니다.

그러면 다시 검색을 해서 defaults.xml 파일에 가봅시다.

defaults.xml을 보면 

여기에 clr이라는 이름으로 conversionRule이 선언되어 있고

defaults.xml

로그 레벨을 지정하는 부분을 clr이라는 색으로 지정한 것을 볼 수 있습니다.

 

저는 로그 레벨을 해당 색으로 변경하고 logger만 cyan 색으로 하고 나머지는 그냥 기본 색으로 바꾸겠습니다.

 

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n</pattern>
        </encoder>
    </appender>
    <logger name="com.feelcoding.logbackdemo" level="DEBUG" />
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

 

로그 파일에 로그 기록하기

로그를 콘솔에만 출력하면 로그가 많이 찍혔을 때는 위쪽 로그가 보이지 않게 되기도 하고, 기록이 남지 않아 나중에 확인하기가 어려운 등 불편한 점이 많습니다.

그러면 이제 로그 파일에 저장을 해봅시다.

logback-spring.xml 파일에 FileAppender를 하나 추가해봅시다.

<appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>./log/testFile.log</file>
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n</pattern>
    </encoder>
</appender>

"FILE"이라는 이름으로 FileAppender를 하나 만들고

<root level="INFO">
    <appender-ref ref="CONSOLE" />
    <appender-ref ref="FILE" />
</root>

해당 appender를 root 레벨에 추가해줍니다.

프로젝트 디렉토리 아래 log라는 디렉토리 하위에 testFile.log라는 파일에 로그를 출력하겠다는 것입니다.

그리고 로그 출력 패턴은 아래와 같이 콘솔 appender와 동일하게 해주겠습니다.

%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n

그런데 이렇게 동일한 문자열이 두 곳에서나 쓰였습니다. 해당 패턴을 복사해서 여러 곳에서 쓰기 보다는 해당 문자열을 변수로 뽑아볼까요?

<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n" />

이렇게 LOG_PATTERN이라는 이름으로 변수를 뽑았습니다.

사용할 때는

${LOG_PATTERN}

이렇게 사용하면 됩니다.

그리고 실행을 해보면

아까 지정해준 log 폴더의 testFile.log 파일에 가보면 이렇게 로그가 파일로 저장되어 있는 것을 볼 수 있습니다.

애플리케이션을 종료했다가 다시 실행하면

이렇게 기존 파일 내용의 마지막 부분에 이어서 써지는 것을 볼 수 있습니다.

여기서 잠깐!! 이렇게 로그를 파일에 저장할 때는 gitignore에 log 파일을 꼭 추가해주세요. 그렇지 않으면 불필요한 로그 파일들이 깃허브에 올라가게 됩니다.

 

그런데 ESC[32m, ESC[0;39m, ESC[36m과 같은 이상한 문자가 보입니다. 이것은 뭘까요?

https://stackoverflow.com/questions/53298918/strange-symbols-in-log-output

 

Strange symbols in log-output

I started to see strange output in my log files: 2018-11-14 14:04:21,180 [main] [34mINFO[0;39m com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... 2018-11-14 14:04:21,186...

stackoverflow.com

색깔을 나타내기 위한 이스케이프 문자라고 합니다.

 

글자 색이 있는 로그를 파일에 저장을 하면 이렇게 이스케이프 문자가 보여 로그를 읽기가 어려우니, 파일에 저장할 땐 글자 색깔을 없애볼까요?

기존 LOG_PATTERN을 CONSOLE_LOG_PATTERN으로 이름을 바꾸고 FILE_LOG_PATTERN이라는 이름으로 글자 색 없는 로그 패턴 변수를 추가했습니다.

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />

    <property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n" />
    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n" />

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>./log/testFile.log</file>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <logger name="com.feelcoding.logbackdemo" level="DEBUG" />
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE" />
    </root>
</configuration>

그러고 나서 다시 실행을 해보면

이렇게 이스케이프 문자 없이 로그가 잘 찍혀있는 것을 볼 수 있습니다.

 

여러 파일에 나눠서 로그 기록하기

그런데 한 파일에만 계속 쓰다 보면 파일의 크기가 점점 커질 것이고 나중에 검색하기가 힘들어질 것입니다.

이럴 때 쓰는 것이 RollingFileAppender입니다.

RollingFileAppender는 FileAppender와 마찬가지로 파일에 로그를 쓰는 appender인데요,

FileAppender와 다른 점은 로그를 여러 파일에 나눠서 쓴다는 것입니다.

한 번 직접 사용해보면서 알아볼까요?

 

"FILE" 이라는 이름으로 지정했던 appender의 class를 FileAppender에서 RollingFileAppender로 변경하고

<file> 태그를 지우고 아래와 같이 <rollingPolicy> 태그를 추가해봅시다.

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <encoder>
        <pattern>${FILE_LOG_PATTERN}</pattern>
    </encoder>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>./log/%d{yyyy-MM-dd}.%i.log</fileNamePattern>
        <maxFileSize>100MB</maxFileSize>
        <maxHistory>30</maxHistory>
    </rollingPolicy>
</appender>

로그를 /log 디렉토리 아래에 날짜.순번.log 라는 이름으로 로그 파일을 저장하고,

파일이 100MB가 넘으면 다음 파일에 저장하고,

로그 파일이 만들어진지 30일이 지나면 해당 파일을 삭제하겠다는 것입니다.

예를 들어 2023-02-13.0.log 파일에 로그가 많이 써져서 100MB가 넘으면 2023-02-13.1.log 파일에 기록을 하고, 그 다음 2023-02-13.2.log, 2023-02-13.3.log, ... 이런 식으로 파일 이름을 짓겠다는 것입니다. 그리고 30일 후인 2023년 3월 15일에 이 파일은 삭제될 것입니다.

한 번 실행해볼까요?

(저는 테스트를 위해 100MB를 10KB로 바꿔서 실행했습니다.)

이렇게 여러 파일에 나눠서 로그가 저장된 것을 볼 수 있습니다.

그럼 정말 10KB씩 나눠졌는지 확인해볼까요?

이렇게 10KB가 넘으면 파일이 나눠진 것을 확인할 수 있습니다.

 

프로필별 로그 관리하기

로컬 환경에서는 로그 레벨을 debug 레벨로 콘솔에만 출력하고 싶고,

dev와 staging 환경에서는 로그 레벨을 info 레벨로 설정하고 파일에 출력하고 싶고,

운영 환경에서는 로그 레벨을 error 레벨로 설정하고 싶다면 어떻게 해야 할까요?

프로필에 따라 다르게 로그를 관리하는 방법을 알아봅시다. 

 

일단 logback-spring.xml 파일을 이렇게 바꿔줍니다.

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />

    <property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n" />
    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n" />

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>./log/%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>

    <springProfile name="local">
        <logger name="com.feelcoding.logbackdemo" level="DEBUG" />
        <root level="INFO">
            <appender-ref ref="CONSOLE" />
        </root>
    </springProfile>
    <springProfile name="dev|stg">
        <root level="INFO">
            <appender-ref ref="CONSOLE" />
            <appender-ref ref="FILE" />
        </root>
    </springProfile>
    <springProfile name="prod">
        <root level="ERROR">
            <appender-ref ref="CONSOLE" />
            <appender-ref ref="FILE" />
        </root>
    </springProfile>
</configuration>

딱 보면 감이 오시죠?

프로필이 local일 땐 debug 레벨로 콘솔에 출력하고

프로필이 dev나 stg일 땐 info 레벨로 콘솔과 파일에 출력하고

프로필이 prod일 땐 error 레벨로 콘솔과 파일에 출력하겠다는 것입니다.

 

로그 패턴 설정하기

맨 처음에 아무런 logback 설정을 하지 않고 실행했을 때 아래와 같이 출력되었는데요, 뭔가 이상한 점 없으신가요?

LogbackDemoApplication의 패키지는 com.feelcoding.logbackdemo인데, c.f.logbackdemo라고 출력된 것을 볼 수 있습니다.

뿐만 아니라, logback 설정을 해주고 난 뒤의 로그와 비교를 해보니

org.springframework.boot.web.embedded.tomcat도 o.s.b.w.embedded.tomcat으로,

org.apache.catalina.core.ContainerBase도 o.a.c.c.C로 나오는 등 패키지명과 클래스명이 생략된 것을 볼 수 있습니다.

왜 이렇게 출력되는 것일까요?

아까 봤던 defaults.xml 파일에 가봅시다.

%-40.40logger{39}

라고 되어 있는 것을 볼 수 있습니다.

최대 39자까지 출력하고 39자를 넘으면 축약하겠다는 것입니다.

 

 

출처

https://logback.qos.ch/manual/index.html

https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.logging

https://oingdaddy.tistory.com/257

+ Recent posts