반응형

1. 개요

 다이나믹 프록시를 공부하던 중 리플렉션이라는 개념이 두둥등장하였다. 간단하게 개념만 짚고 넘어가려했으나, Spring DI의 동작원리와 밀접하고, 프레임워크를 이해하는데 중요한 개념이라고 판단되어 자세히 알아보았다.

 


2. Reflection이 뭔가요?

 

2.1. 사전적 의미

 많은 영상이나 글에서 리플렉션에 대해 설명할 때 사전적 의미를 짚고 넘어간다. 사전적 의미와 유사한 기능을 하는 개념들이 많은데, 리플렉션 또한 이와 같기도 하고, 개념을 이해하기가 쉬운 편은 아니라 그런 것 같다. 사전적 의미는 다음과 같다.

 

1. (거울에 비친) 상, 모습
2. 반사

 

이제 이 사전적 의미를 기술적 의미와 함께 이해해보자.

 

2.2. 기술적 의미

런타임 단계에서 클래스의 정보를 분석해내는 자바 API로 클래스의 메서드, 타입, 필드, 어노테이션 등의 정보를 접근하거나 수정할 수 있다.

 

 정리하면 리플렉션이란 클래스의 정보 통해 '거울에 비친 상'과 같이 똑같은 형태를 만들고 이를 통해 메서드, 타입, 필드, 어노테이션과 같은 자원에 접근하거나 수정할 수 있는 자바 API이다.

 그렇다면, 실제 클래스와 똑같은 형태를 가진 정보는 대체 어디서 얻어오는 걸까??

 

리플렉션을 보고있는 클래스

 

 

2.3. 어디서? JVM에서!

 정확히는 JVM의 메모리 영역에서 가져온다.

 어플리케이션을 실행하면 작성한 자바 코드컴파일러에 의해 .class 형태의 바이트 코드로 변환되고, 이 정보들은 클래스 로더를 통해 JVM 메모리 영역에 저장된다. 그리고 클래스 정보를 통해 객체가 생성된다면 이는 JVM 힙 영역에 저장된다. 즉, JVM의 메모리영역에서 클래스의 정보를 가져올 수 있다.

런타임 시 JVM 의 동작과정 (출처 : 우아한 테크 파랑, 아키의 리플렉션)

 

2.4. 리플렉션이란?!

 다시! 리플렉션이란, 어플리케이션이 실행되어 JVM 메모리 영역에 클래스 정보들이 저장된 시점인 '런타임' 시에 이 영역에 접근하여 클래스의 정보를 분석, 수정하는 작업을 하는 API바로 자바 리플렉션이다!

 


3. 리플렉션 실습

 리플렉션 API를 테스트하는 간단한 실습을 해보자.

 

3.1. 초간단 Human 클래스 생성

public class Human {

    private String name;

    public Human(String name){
        this.name = name;
    }

    private Human(){

    }

    public void goRestRoom(){
        System.out.println(name +"이 화장실로 갑니다.");
    }

    public void offPants(){
        System.out.println(name +"이 바지를 내립니다.");
    }

    public void doWork(){
        System.out.println(name + "이 볼일을 봅니다.");
        poopOut();
    }

    private void poopOut(){
        System.out.println("똥이 나왔습니다.");
    }
}

 화장실에서 볼일을 보는 Human 클래스를 생성하였다. name 파라미터를 받는 생성자 메서드private 접근 제어자를 가진 기본 생성자 메서드를 생성하였다. poopOut 메서드는 외부에서 호출되는 것을 막기 위해 private 접근 제어자로 설정하였다.

 

3.2. 클래스 정보 조회하기

먼저 JVM에 저장될 클래스 정보를 조회하는 코드이다. 아래와 같이 크게 세가지 방법이 있다.

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = human.getClass();
Class<?> class2 = Human.class;
Class<?> class3 = Class.forName("org.example.reflection.Human");

 

3.3. 생성자 조회 및 호출

 이제 리플렉션 기능을 사용해보자. 먼저 클래스의 생성자 정보를 가져오고 이를 호출해보도록 하겠다.

 

3.3.1. getConstructor()

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");

// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getConstructor(); // NoSuchMethodException !!
Constructor<?> constructor2 = class1.getConstructor(String.class);

// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance();
Object human2 = constructor2.newInstance("승갱이");

 위 코드를 실행시키면 메서드 호출 시 NoSuchMethodException이 발생한다. 리플렉션 기능을 통해 생성자 메서드 정보를 가져오려 시도하였으나, 기본 생성자의 접근 제어자가 private 라 메서드를 찾지 못해 발생했다. 접근 제어자에 관계 없이 클래스 정보를 가져오려면 getConstructor() 대신 getDeclaredConstructor() 메서드를 사용하면 된다.

 

getXXX와 getDeclaredXXX의 차이 이해하기

더보기

getXXX vs getDeclaredXXX


리플렉션에서 호출하는 대부분의 메서드는 getXXX, getDeclaredXXX 처럼 쌍을 이루고 있다. 아래의 특징을 숙지하여 상황에 맞게 사용해야 한다.

getXXX
 상위 클래스와 상위 인터페이스에서 상속한 메서드를 포함하여 public인 값들을 가져온다. private와 같은 메서드를 조회할 경우 NoSuchMethodException 예외가 발생한다.

getDeclaredXXX
 접근 제어자와 관계 없이 상속한 메서드들을 제외하고 직접 클래스에서 선언한 값들을 가져온다.

 

3.3.2. getDeclaredConstructor()

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");

// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getDeclaredConstructor();
Constructor<?> constructor2 = class1.getDeclaredConstructor(String.class);

// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance(); // IllegalAccessException !!
Object human2 = constructor2.newInstance("승갱이");

 

 이로써 private로 선언된 생성자 정보는 가져왔으나, 생성자를 통해 객체 생성 시 IllegalAccessException이 발생했다. 이유는 접근 제어자가 private 이기 때문에 외부 호출이 불가능하기 때문이다. 앞서 발생한 예외는 클래스의 정보에서 기본 생성자 메서드를 찾지 못해 발생했고, 이번 예외는 해당 메서드를 호출하지 못해 발생한 것이다.

이를 해결하기 위해서 Human 클래스의 기본 생성자를 public으로 수정하여야 할까? 아니다. 리플렉션을 통해 private 메서드에도 접근할 수 있도록 조작하면 된다. 

 

3.3.3. setAccessible(true)

 setAccessible(true) 메서드를 통해 해당 생성자에 접근할 수 있도록 설정하였다. 여기서 중요한 점은 클래스를 수정하지 않고, 리플렉션을 통해 클래스의 생성자 정보를 조작한 후 호출까지 했다는 점이다.

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");

// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getDeclaredConstructor();
Constructor<?> constructor2 = class1.getDeclaredConstructor(String.class);

constructor1.setAccessible(true); // 해당 생성자에 접근할 수 있도록 설정

// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance();
Object human2 = constructor2.newInstance("승갱이");

 

기본 생성자를 통해 생성된 Human 객체

 

3.4. 멤버필드 조회하기

 다음은 리플렉션 기능을 사용하여 클레스의 멤버필드를 조회해보자.

 

3.4.1. getFields()

Class<?> class1 = Class.forName("org.example.reflection.Human");

for(Field field : class1.getFields()){
    System.out.println(field);
}

 Human 클래스에 name 멤버필드가 있지만 콘솔에 조회되지 않았다. 클래스 정보를 조회했더니 name은 찾을 수 없어 조회가 되지 않았다. 이유는 name의 접근제어자가 private이기 때문이다. getDeclaredFields() 메서드를 사용해야 한다.

 

3.4.2. getDeclaredFields()

Class<?> class1 = Class.forName("org.example.reflection.Human");

for(Field field : class1.getDeclaredFields()){
    System.out.println(field); // private java.lang.String org.example.reflection.Human.name
}

 private 접근제어자를 가진 필드 정보도 조회됨을 알 수 있다.

 

 3.4.3. 객체에 대한 멤버필드 조회하기

 이제 위 리플렉션 기능을 사용하여 객체를 생성하고, 해당 객체의 필드 정보를 조회해보자.

 호출한 생성자의 접근제어자는 public이므로 setAccessible 메서드를 사용하지 않았다. 하지만 객체의 name 필드는 접근 제어자가 private이므로 field.get(human) 메서드를 호출하기전 리플렉션에 대한 접근 설정을 true로 설정하였다.

Class<?> class1 = Class.forName("org.example.reflection.Human");

Constructor constructor = class1.getDeclaredConstructor(String.class);
Object human = constructor.newInstance("승갱이");

for(Field field : class1.getDeclaredFields()){
    System.out.println(field);
    field.setAccessible(true);
    System.out.println("value : "+ field.get(human));
}

출력결과

 

3.4.4. 객체에 대한 멤버필드 수정하기

 단순히 조회 뿐 아니라 멤버필드의 값도 수정할 수 있다. Setter 메서드가 없어도, 접근제어자가 private라도 이를 무시하고 값을 바꿔버릴 수 있는 아주 강력한 녀석임을 알 수 있다.

Class<?> class1 = Class.forName("org.example.reflection.Human");

Constructor constructor = class1.getDeclaredConstructor(String.class);
Object human = constructor.newInstance("승갱이");

for(Field field : class1.getDeclaredFields()){
    System.out.println(field);
    field.setAccessible(true);
    field.set(human, "변경된 승갱이"); // human 객체의 field 값 변경
    System.out.println("value : "+ field.get(human));
}

출력결과

 

3.5. 메서드 조회 및 호출하기

 이번엔 메서드의 정보를 조회하고 호출해보자.

Class<?> class1 = Class.forName("org.example.reflection.Human");

Constructor<?> constructor = class1.getConstructor(String.class);
Object human = constructor.newInstance("승갱이");

Method goRestRoomMethod = class1.getDeclaredMethod("goRestRoom");
Method offPantsMethod = class1.getDeclaredMethod("offPants");
Method doWorkMethod = class1.getDeclaredMethod("doWork");
Method poopOutMethod = class1.getDeclaredMethod("poopOut");

poopOutMethod.setAccessible(true);
poopOutMethod.invoke(human);
goRestRoomMethod.invoke(human);
offPantsMethod.invoke(human);
doWorkMethod.invoke(human);

 

 poopOut 메서드만 접근제어자가 private이므로 invoke 메서드 호출 전에 setAccessible(true) 메서드를 호출해주었다. 필자의 의도는 poopOut 메서드의 접근 제어자를 private 로 생성하여 바지를 내리기 전에 똥을 싸거나, 화장실에 들어가기 전에 똥을 싸는 불상사를 막으려 했는데, 리플렉션을 사용하니 똥을 먼저 싸버리는 걸 볼 수 있다.

다시한번 리플렉션의 강력함(?)을 느낄 수 있는 부분이다.

출력결과

 


4. 어디서 사용하나요?

 근데 이런 기능들을 대체 어디서 사용할까? 필자가 이 글을 쓰는 이유인 '다이나익 프록시' 라는 API에서도 사용하나, 대부분의 프레임워크나 라이브러리에서도 리플렉션 기능을 사용한다. 프레임워크나 라이브러리에서는 들어오는 클래스의 정보를 모르기 때문이다.

 코드를 작성한 개발자는 당연히 내가 작성한 클래스의 정보를 알 수 있지만, 프레임워크 입장에서 보면 모르는게 당연하다. 이때 리플렉션을 통해 런타임 시 클래스의 정보를 얻고 이를 기반으로 하여 프레임워크나 라이브러리가 지원하는 기능을 수행하는 것이다. 스프링의 주요 기능인 DI도 리플렉션의 원리가 들어있다.

 


5. 리플렉션을 통한 DI 프레임워크 구현해보기

 DI를 지원하는 초간단 프레임워크를 구현해보았다.

 

5.1. SSKAutowired

먼저 커스텀 어노테이션을 구현하였다. 특정 클래스의 멤버필드에 @SSKAutowired 어노테이션이 붙어있을 경우 해당 리플렉션을 통해 객체를 생성하기 위함이다.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SSKAutowired {
}

 

5.2. Robot

 기본 생성자를 갖는 간단한 Robot 를 구현하였다. 특정 클래스의 멤버필드로 사용되며, DI를 위해 @SSKAutowired를 붙여줄 예정이다.

public class Robot {

    public void fight(){
        System.out.println("로봇이 싸웁니다.");
    }

    public void clean(){
        System.out.println("로봇이 청소합니다.");
    }

    private void destroy(){
        System.out.println("로봇이 파괴됩니다.");
    }
}

 

5.3. TestService

 @SSKAutowired가 붙은 robot 멤버필드를 갖고, robot의 기능을 추상화한 메서드를 갖는 클래스이다. 테스트 단계에서 robot 객체가 주입됐는지 확인하기 위해 getRobot 메서드도 추가하였다.

public class TestService {

    @SSKAutowired
    private Robot robot;

    public Robot getRobot(){
        return robot;
    }
    public void start(){
        robot.fight();
        robot.clean();
    }
}

 

5.4. CustomApplicationContext

 특정 클래스를 스캔하여 필요한 의존성을 주입해주는 클래스이다. getInstance(TestService.class) 메서드를 호출할 경우 @SSKAutowired 멤버필드에 대한 의존성이 주입된 TestService 객체를 생성 및 리턴한다.

public class CustomApplicationContext {

    /**
     * 클래스의 멤버필드 중 SSKAutowired가 붙어있을 경우 의존성 주입
     * @param clazz - 스캔 클래스
     * @return - 의존주입이 완료된 스캔 클래스
     * @throws Exception
     */
    public static <T> T getInstance(Class<T> clazz) throws Exception{

        T instance = createInstance(clazz);
        Arrays.stream(clazz.getDeclaredFields()).forEach(field -> {
            if(field.getAnnotation(SSKAutowired.class) != null){ // SSKAutowired가 붙은 멤버필드일 경우
                try {
                    Object fieldInstance = createInstance(field.getType()); // 멤버필드에 대한 객체 생성
                    field.setAccessible(true);
                    field.set(instance, fieldInstance); // 생성된 객체를 instance에 셋팅 (DI)
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        });
        return instance;
    }

    /**
     * 리플렉션 기본 생성자를 통해 객체 생성
     * @param clazz - 클래스 타입
     * @return 클래스 객체
     * @throws Exception
     */
    private static <T> T createInstance(Class<T> clazz) throws Exception{
        Constructor<T> constructor = clazz.getDeclaredConstructor(); // 리플렉션을 통해 클래스의 기본생성자 정보 조회
        constructor.setAccessible(true);
        return constructor.newInstance(); // 객체 생성
    }
}

 

5.5. Test

 CustomApplicationContext의 테스트 코드이다. TestService를 파라미터로 한 getInstance 메서드를 호출하면 의존성이 주입된 TestService 객체를 리턴받고, 확인하는 메서드이다. testService.start()를 통해 콘솔에 출력도 해보았다.

@Test
void getInstance() throws Exception {

    TestService testService = CustomApplicationContext.getInstance(TestService.class);

    assertNotNull(testService.getRobot());
    testService.start();
}

출력결과

 


6. 강력한 단점

 강력한 리플렉션, 그만큼 단점도 강력하기 때문에 사용 시 굉장한 주의를 요구한다.

 

6.1. 일반 메서드 호출보다 성능이 떨어진다.

 리플렉션은 동적으로 클래스를 생성하기 때문에 JVM 컴파일러가 최적화 할 수 없다. 컴파일 시에는 타입이 정해지지 않았기 때문이다. 해당 클래스의 타입이 맞는지, 생성자가 존재하는지 등의 벨리데이션 과정을 런타임 시 처리해야하기 때문에 성능이 떨어진다.

 

6.2. 컴파일 시 타입 체크가 불가능하다.

 리플렉션은 런타임 시점에 클래스의 정보를 알게 되므로 컴파일 시점에 타입 체크가 불가능하다.

 

6.3. 추상화를 파괴한다.

 리플렉션을 사용하는 모든 클래스의 정보를 알 수 있다. 외부로 노출시키지 않기 위해 private 접근제어자를 사용해도 접근할 수 있다. 즉, 추상화가 파괴된다.

 


7. 참고

파랑, 아키의 리플렉션 - https://www.youtube.com/watch?v=67YdHbPZJn4 

자바 리플렉션 - https://roadj.tistory.com/7

리플렉션의 단점 - https://middleearth.tistory.com/72

반응형
반응형
반응형

1. 개요

 다이내믹 프록시에 대해 이해한다.

 


2. 프록시란?

 먼저 프록시가 뭘까? 프록시에 대해 알아보기 전 이전 스터디때 배웠던 UserService의 트랜잭션 기능을 회고해보았다.

 

2.1. UserService의 트랜잭션 기능 회고

 비지니스 로직에 대해 얼마의 처리 시간이 걸렸는지에 대한 로깅처리, 트랜잭션 처리와 같은 부가 기능을 추가하기 위해 인터페이스를 통한 추상화 방법을 사용할 수 있다. 앞선 스터디에서 UserService에 트랜잭션 기능을 추가하기 위해 인터페이스를 통한 추상화 방법을 적용하였고, 아래와 같이 부가기능과 핵심기능을 분리하는 구조가 되었다.

UserService에 대한 Transaction 기능 추가 추상화

 

 

2.2. '핵심인 척' 하는 '부가기능'

 이러한 구조가 가지는 중요한 특징이 있는데, 부가 기능 수행 후 핵심 기능을 가진 클래스로 요청을 위임해줘야 한다는 것과, 핵심기능은 부가기능을 가진 클래스의 존재 자체를 몰라야 한다는 것이다. 존재 자체를 모르는 것은 런타임시 DI를 통한 추상화 때문이다.

 클라이언트도 인터페이스를 통해 서비스를 호출하기 때문에 실제로 어떤 구현체를 사용하는지 모른다. 핵심기능을 호출한다고 생각할 뿐이다. 부가기능(UserServiceTx)을 통해 핵심기능(UserServiceImpl)을 사용하고 있는데 말이다. 즉, 부가기능 클래스(UserServiceTx)는 의도치않게 클라이언트를 속여 '핵심인 척' 하고있다.

 

2.3. 그래서 프록시가 뭔가요?

 이렇듯 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 요청을 받아주는 오브젝트를 프록시라고 한다. 추가로 프록시를 통해 최종적으로 요청을 위임받아 핵심 기능을 처리하는 오브젝트를 타겟이라고 한다. 앞서 구성했던 Client, UserServiceTx, UserServiceImpl 구조를 아래와 같이 프록시와 타겟으로 표현할 수 있다.

프록시 관점으로 본 UserService 구조

 

2.4. 프록시의 목적

 프록시의 목적은 크게 두가지이다. 첫째는 클라이언트가 타겟에 접근하는 방법을 제어하기 위해서, 두 번째는 타겟에 부가적인 기능을 부여하기 위해서이다. 각각의 목적에 따라 디자인 패턴에서는 다른 패턴으로 구분한다.

 

2.5. 데코레이터 패턴

 데코레이터 패턴은 타겟에 부가적인 기능을 런타임 시 다이나믹하게 부여하기 위해 프록시를 사용하는 패턴이다. 핵심 기능은 그대로 두고 부가 기능, 즉 데코레이션만 추가하는 것이다.

 부가기능은 하나일수도, 여러개일수도 있다. 때문에 이 패턴에서는 프록시가 한 개로 제한되지 않는다.

 UserService 인터페이스를 구현한 타겟인 UserServiceImpl에 트랜잭션 부가기능을 제공하는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것이다.

 데코레이터 패턴은 인터페이스를 통해 위임하는 방식이기 때문에 어느 데코레이터에서 타겟으로 연결될지는 코드 레벨에선 알 수 없다. DI 설정에 따라 다이나믹하게 구성되게 때문이다.

 이 패턴은 타겟 코드의 수정도 없고, 클라이언트 호출 방법도 변경하지 않은 채 새로운 기능을 추가할 때 유용한 방법이다.

 

2.5. 프록시 패턴

 프록시 패턴의 프록시는 타겟에 대한 접근 방법을 제어하는 의미의 프록시이다. 즉, 클라이언트가 타겟에 접근하는 방식을 변경해주는 패턴이다.

 클라이언트에게 타겟에 대한 래퍼런스를 넘길 때 실제 타겟이 아닌 프록시를 넘겨주는 것이다. 그리고 프록시의 메서드를 통해 타겟을 사용하려고 시도하면, 그때 프록시가 타겟 오브젝트를 생성하고 요청을 위임해준다.

 이 방식의 장점은 해당 객체가 메모리에 존재하지 않아도 프록시를 통해 정보를 참조할 수 있고, 타겟이 반드시 필요한 시점까지 타겟 객체의 생성을 미뤄 메모리를 사용 시점을 늦출 수 있다.

 

2.6. 프록시는 어디에 쓰나요?

 프록시는 기존 코드에 영향을 주지 않으면서 기능을 확장하거나 접근 방법을 제어한다. 그럼에도 불구하고 많은 개발자는 타겟 코드를 고치고 말지 번거롭게 프록시를 만들지는 않는다고 한다.

 그 이유는 프록시를 만드려면 부가 기능을 추가하고자 할때마다 새로운 클래스를 정의해야 하고, 인터페이스의 구현 메서드가 많다면 모든 메서드에 일일히 구현하고 위임하는 코드를 넣어야 한다.

 UserService가 아닌 ProductService가 있다고 가정하고, 여기에 트랜잭션 부가기능을 처리하는 프록시를 통해 구성한다고 하자. ProductServiceTx 클래스를 만들고, 구현해야할 메서드마다 트랜잭션 로직과 핵심 기능을 담당하는ProductServiceImpl를 만들어 위임하는 로직을 넣어야 한다. 메서드 양이 많아지면 작업량도 많아지고, 중복코드도 많아진다.

 이러한 문제점들을 해결하여 좀더 간단하게 프록시를 구성하는 방법이 있을까? 있다! 그게 바로 다이나믹 프록시이다.

 


3. 다이나믹 프록시

3.1. 다이나믹 프록시란?

 다이니믹 프록시는 런타임 시점프록시를 자동으로 만들어서 적용해주는 기술이다. 자바에서는 리플렉션 기능을 사용해서 프록시를 만드는 JDK 다이나믹 프록시를 사용한다.

 

 

3.2. 리플렉션이란?

 리플렉션에 대한 내용은 따로 포스팅하였다. 다이나믹 프록시의 기반 기술이기도 하지만 스프링 프레임워크의 기반 기술이기도 하기에 이 개념에 대해 잘 모른다면 이해해보길 권장한다.

 

https://tlatmsrud.tistory.com/112

 

String name = "Spring";

 

 위 문자열의 길이를 알고 싶다면 어떻게 할까? 단순히 length 메서드를 호출해도 되지만 리플렉션 기능을 사용해도 된다.

 

Method lengthMethod = String.class.getMethod("length");

 

 리플렉션을 통해 String 클래스의 length라는 메서드 정보를 가져온다. 메서드를 실행시키려면 Method.invoke() 메서드를 사용하면 된다.

int length = lengthMethod.invoke(name); // == name.length()

 

 아래는 간단한 리플렉션 테스트이다.

@Test
public void invokeMethod() throws Exception{
    String name = "Spring";

    assertThat(name.length()).isEqualTo(6);

    Method lengthMethod = String.class.getMethod("length");
    assertThat((Integer)lengthMethod.invoke(name)).isEqualTo(6);

    assertThat(name.charAt(0)).isEqualTo('S');
    Method charAtMethod = String.class.getMethod("charAt", int.class); // method name, parameterType ...
    assertThat((Character) charAtMethod.invoke(name,0)).isEqualTo('S');
}

 

결론은 리플렉션을 사용하면 런타임시에 클래스에 대한 정보를 가져올 수 있고, 클래스의 메서드를 호출할 수 있는 것이다. 그럼 이 클래스의 메서드를 호출하기 전, 후에 부가기능을 추가한다면 앞서 배웠던 프록시 클래스를 만들어 적용했던 것과 동일하게 동작시킬수도 있는 것이다.

 

 

3.3. 프록시 클래스

 프록시 객체를 이용한 방법과 다이나믹 프록시를 이용한 방법의 차이를 느끼기 위해 먼저 데코레이터 패턴을 적용해보자. 패턴을 만들기 위해 타겟 클래스와 인터페이스, 부가기능 클래스를 정의했다.

 

3.2.1. Hello.java

 프록시 인터페이스 클래스이다.

public interface Hello {
    String sayHello(String name);
    String sayHi(String name);
    String sayThankYou(String name);
}

 

3.2.2. HelloTarget.java

 핵심 기능을 처리하는 타겟 오브젝트이다.

public class HelloTarget implements Hello{

    @Override
    public String sayHello(String name) {
        return "Hello "+name;
    }

    @Override
    public String sayHi(String name) {
        return "Hi "+name;
    }

    @Override
    public String sayThankYou(String name) {
        return "Thank You "+name;
    }
}

 

3.2.3. Test.java

 간단한 테스트 코드이다.

@Test
public void simpleProxy(){
    Hello hello = new HelloTarget(); // 타깃은 인터페이스를 통해 접근
    assertThat(hello.sayHello("Sim")).isEqualTo("Hello Sim");
    assertThat(hello.sayHi("Sim")).isEqualTo("Hi Sim");
    assertThat(hello.sayThankYou("Sim")).isEqualTo("Thank You Sim");
}

 

일단은 부가기능은 넣지 않고 타겟클래스와 인터페이스만 사용하였다. 이제 문자열을 대문자로 치환하는 부가기능을 추가한 프록시를 만들어보자.

 

3.2.4. HelloUppercase.java

public class HelloUppercase implements Hello{

    private final Hello hello;
    public HelloUppercase(Hello hello){
        this.hello = hello;
    }

    @Override
    public String sayHello(String name) {
        return hello.sayHello(name).toUpperCase();
    }

    @Override
    public String sayHi(String name) {
        return hello.sayHi(name).toUpperCase();
    }

    @Override
    public String sayThankYou(String name) {
        return hello.sayThankYou(name).toUpperCase();
    }
}

 멤버 필드로 정의된 Hello는 타겟 클래스로 HelloTarget을 참조하도록 해야한다.

 

3.2.5. Test.java

@Test
public void simpleProxy(){
    Hello hello = new HelloTarget(); // 타겟 오브젝트 생성
    Hello proxyHello = new HelloUppercase(hello); // 프록시 오브젝트 생성 및 의존성 주입
    assertThat(proxyHello.sayHello("Sim")).isEqualTo("HELLO SIM");
    assertThat(proxyHello.sayHi("Sim")).isEqualTo("HI SIM");
    assertThat(proxyHello.sayThankYou("Sim")).isEqualTo("THANK YOU SIM");
}

 이로써 데코레이터 패턴을 적용한 프록시 구조가 만들어졌다. 부가 기능을 하는 HelloUppercase 코드를 보면 프록시의 문제점인 부가기능(대문자로 치환)과 위임(hello.method) 코드가 중복되는 것을 알 수 있다.

 이제 다이나믹 프록시를 적용하여 이 문제를 해결해보자.

 

3.3. 다이나믹 프록시 적용

 다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트이다. 다이나믹 프록시의 오브젝트는 타겟의 인터페이스와 같은 타입으로 자동으로 만들어진다. 다이나믹 프록시는 오브젝트를 타겟 인터페이스를 통해 사용할 수 있다.

 부가기능 제공 코드는 InvocationHandler를 구현한 오브젝트로 생성해야 한다.

 

3.3.1. UppercaseHandler.java

 타겟 메서드를 실행한 결과 값이 String이거나 해당 메서드명이 say로 시작할 경우 대문자로 변환하는 부가기능을 추가하였다.

public class UppercaseHandler implements InvocationHandler {

    private Object target;

    public UppercaseHandler(Object target){
        this.target = target;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object ret = method.invoke(target, args); // invoke를 통해 target 오브젝트의 method 실행

        if(ret instanceof String && method.getName().startsWith("say")){ // 문자열 타입이거나 say로 시작하는 메서드일 경우
            return ((String)ret).toUpperCase();
        }
        return ret;
    }
}

 

3.3.2. Test.java

 Hello 인터페이스에 대한 부가기능을 UppercaseHandler로 설정함과 동시에 타겟 오브젝트를 HelloTarget으로 설정하였다. 데코레이터 패턴에서 부가기능을 처리하기 위해 구현했던 HelloUppercase 클래스가 필요하지 않게 되었고, 이 클래스가 안고 있던 부가기능 로직과 위임 로직의 중복이 모두 해결되었다.

@Test
public void dynamicProxy(){
    Hello proxyHello = (Hello) Proxy.newProxyInstance(
            getClass().getClassLoader(), // 다이나믹 프록시를 정의하는 클래스 로더
            new Class[] {Hello.class}, // 다이나믹 프록시가 구현해야할 인터페이스
            new UppercaseHandler(new HelloTarget())); // 부가기능 및 위임 코드를 담는 InvocationHandler 구현 클래스

    assertThat(proxyHello.sayThankYou("Sim")).isEqualTo("THANK YOU SIM");
}

 


4. 트랜잭션 기능 리팩토링

 이제 트랜잭션 기능을 넣기 위해 사용했던 전략 패턴 대신 다이나믹 프록시를 사용하도록 변경해보자. 먼저 부가기능을 처리하는 InvocationHandler 구현체 클래스를 구현하자.

 

4.1. TransactionHandler.java

public class TransactionHandler implements InvocationHandler {
    private Object target;
    private PlatformTransactionManager transactionManager;
    private String pattern;

    public void setTarget(Object target){
        this.target = target;
    }

    public void setTransactionManager(PlatformTransactionManager transactionManager){
        this.transactionManager = transactionManager;
    }

    public void setPattern(String pattern){
        this.pattern = pattern;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // 메서드 명이 pattern 으로 시작할 경우
        if(method.getName().startsWith(pattern)){
            return invokeInTransaction(method, args); // 트랜잭션 기능과 함께 메서드 실행
        }
        return method.invoke(target, args); // 트랜잭션 기능 없이 메서드 실행
    }

    public Object invokeInTransaction(Method method, Object[] args) throws Throwable {

        // 트랜잭션 생성
        TransactionStatus status = this.transactionManager
                .getTransaction(new DefaultTransactionDefinition());

        try{
            // 메서드 실행
            Object ret = method.invoke(target, args);

            // 트랜잭션 commit
            this.transactionManager.commit(status);
            return ret;
        } catch (InvocationTargetException e) {

            // 타겟 메서드 실행 중 예외 발생 시 트랜잭션 rollback
            this.transactionManager.rollback(status);
            throw e.getTargetException();
        }
    }
}

 메서드 명이 pattern으로 시작할 경우 트랜잭션 기능 사이에 타겟 메서드를 호출하는 invokeInTransaction() 메서드를 호출한다. 내부에서는 리플렉션을 사용하여 타겟 오브젝트에 대한 메서드를 실행한다. 예외가 발생할 경우 롤백이, 발생하지 않을 경우 커밋이 된다.

 그리고 다이나믹 프록시를 통한 타겟 메서드 호출 시 예외가 발생할 경우 InvocationTargetException 예외 안에 타겟 메서드에서 발생한 예외가 포장되므로 이에 맞게 예외 처리 로직을 수정한다.

 

4.2. 테스트

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
public class DynamicProxyUserServiceTest {

    @Autowired
    private UserServiceImpl userService;

    @Autowired
    private PlatformTransactionManager transactionManager;

    @SpyBean
    private IUserDao userDao;

    @SpyBean
    private DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

    private final List<User> users = Arrays.asList(
            new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
            new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
            new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
            new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
            new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
    );
    @BeforeEach
    void setUp(){
        userService.setUserDao(userDao);
        userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy);

        given(userDao.getAll()).willReturn(users);

        willThrow(new RuntimeException()).given(userLevelUpgradePolicy).upgradeLevel(users.get(3));
    }

    @Test
    void upgradeAllOrNothingWithNoProxy(){

        // 테이블 데이터 초기화
        userDao.deleteAll();

        // 테이블에 유저 정보 insert
        users.forEach(user -> userDao.add(user));

        // 유저 레벨 업그레이드 메서드 실행 및 예외 발생 여부 확인 (setUp 메서드에 4번째 유저 업그레이드 처리 시 예외 발생하도록 스터빙 추가)
        assertThatThrownBy(() -> userService.upgradeLevels())
                .isInstanceOf(RuntimeException.class);

        // DB
        assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
        assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.SILVER); // 트랜잭션이 적용되지 않아 BASIC 레벨로 롤백되지 않음.
        assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);

    }

    @Test
    void upgradeAllOrNothingWithProxy(){

        // 부가기능 핸들러 객체 생성
        TransactionHandler txHandler = new TransactionHandler();
        txHandler.setTarget(userService);
        txHandler.setTransactionManager(transactionManager);
        txHandler.setPattern("upgradeLevels");

        // 다이나믹 프록시 생성
        UserService proxyUserService = (UserService) Proxy.newProxyInstance(
                getClass().getClassLoader() // 다이나믹 프록시 클래스의 로딩에 사용할 클래스 로더
                ,new Class[] {UserService.class} // 구현할 인터페이스
                ,txHandler // 부가 기능과 위임 코드를 담은 InvocationHandler
        );

        // 테이블 데이터 초기화
        userDao.deleteAll();

        // 테이블에 유저 정보 insert
        users.forEach(user -> userDao.add(user));

        assertThatThrownBy(() -> proxyUserService.upgradeLevels())
                .isInstanceOf(RuntimeException.class);

        assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
        assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.BASIC); // 트랜잭션이 적용되지 않아 BASIC 레벨로 롤백됨.
        assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);
    }
}

 users 필드에 설정한 테스트 픽스처에 대해 두가지 테스트를 진행하도록 하였다.

upgradeAllOrNothingWithNoProxy 테스트는 다이나믹 프록시를 적용하지 않은 케이스로 UserService의 구현체 클래스를 직접 호출하고 있어 트랜잭션이 적용되지 않는다. upgradeAllOrNothingWithProxy는 다이나믹 프록시를 적용한 케이스로 트랜잭션이 적용되어 있다.

 

  setUp 메서드에서  4번째 유저의 업그레이드 시 예외가 발생하도록 스터빙 처리하였기에, 첫번째 테스트 케이스에서는 예외가 발생해도 이전 업그레이드 처리 된 유저에 대해서 롤백이 되지 않는지를 체크했고, 두번째 테스트 케이스에서는 예외가 발생할 경우 롤백이 되는지를 체크했다. 테스트 결과는 성공적이었다.

테스트 결과

 

반응형
반응형

1. 개요

 Redis를 활용하여 사전순으로 조회되는 자동완성 기능을 구현했으나 검색빈도에 따라 자동완성 리스트를 뿌려주는 것이 사용자 입장에서 유용할 것 같다는 생각이 들었다. 또한 사용자 검색한 새로운 키워드도 데이터 셋에 추가해야 트렌드에 맞는 자동완성 리스트를 제공할 수 있을 것 같아 이를 적용하는 작업을 진행하였다.

 


2. 분석

 현재 redis에 들어가 있는 자동완성 데이터는 Score가 모두 0인 Sorted Set 형식이다. 또한 사용자가 검색한 단어를 prefix로 갖고 있는 완성된 단어를 뿌려주기 위해 필자가 정한 규칙에 맞게 데이터가 들어가 있는 상황이다. 예를들어 '대', '대한', '대한민', '대한민국' 이라는 키워드 입력시 자동완성 리스트에 '대한민국' 이라는 단어가 나오도록 하기 위해 score를 모두 0으로하여  '대', '대한', '대한민', '대한민국', '대한민국*' 값을 sorted Set 형식의 데이터 셋에 저장시킨 상태이다.

 

 비지니스 로직은 다음과 아래와 같이 구현되어 있다.

 1) 검색어와 일치하는 단어의 index 조회(zrank 명령어 사용) 

 2) index 번째부터 index + 100번째까지의 데이터 조회(zrange 명령어 사용)

 3) '*' 문자를 포함하는 완전한 단어를 필터링

 4) 검색어의 prefix와 일치하는 완전한 단어를 필터링 및 limit

 

위에 대한 자세한 내용은 이전 포스팅에 기재되어있다.

https://tlatmsrud.tistory.com/106

 

[Redis] Redis를 활용한 자동완성 구현 / 자동완성 데이터 셋 만들기

1. 개요 Redis를 활용하여 자동완성 기능을 구현해보았다. 2. Redis를 선택한 이유 2.1. 속도 Redis는 인 메모리에 데이터를 저장하고 조회하기 때문에 디스크에 저장하는 DB보다 훨씬 빠르다. 속도가

tlatmsrud.tistory.com

 

 어쨌든 검색빈도 순으로 조회시키기 위해서는 반드시 검색횟수를 관리하는 데이터셋이 필요했다. 먼저 기존 데이터셋을 활용하는 쪽으로 시도하였으나 실패했다.

 


3. 기존 테이터 셋 활용

 기존 데이터 셋의 score를 활용할 경우 사용자가 단어를 입력 후 '검색' API를 호출했을 때 검색한 단어가 데이터 셋에 추가되어야 하고, score가 1이 올라가야 한다. 이때 두가지 케이스로 나눌 수 있는데, 검색한 단어에 대해서만 score를 올리는 케이스, 검색한 단어와 연관된 데이터들의 score를 올리는 케이스이다.

 

3.1. 검색한 단어에 대해서만 Score 증가

 전자의 경우 score가 올라가는 완성된 단어들은 데이터셋의 하단부에 위치하게 되고, 나머지 데이터들은 상단부에 위치하게 된다. 원래 나머지 데이터들은 자동완성 단어를 index 기반으로 빠르게 조회하기 위해서 사용했었으나, 자동완성 단어의 score가 올라가며 index 규칙이 깨지게 되었다.

 아까 말한대로 자동완성들은 하단부, 나머지 데이터는 상단부에 위치하게 되니 결국 index부터 끝까지 모든 값을 조회해야하는 상황이 발생했다. 지금은 데이터 셋이 적어서 큰 문제가 되지 않을테지만, 시간 복잡도에 따라 데이터 양이 증가할수록 속도가 느려질 것이다. (zrange의 시간복잡도는 O(log(n)+m)이다. m은 반환된 결과의 수이다.) 아래는 그 예이다.

 

AS-IS의 경우 '대'에 대한 zrange 시 index 기준 +4에 대한민국*이 위치한다. 즉, 가까운 곳에 자동완성 단어들이 위치하고 있기 때문에 굳이 데이터셋의 끝까지 조회할 필요가 없다. 필자의 경우 가중치를 두어 index ~ index+1000 까지만 조회하도록 했다.

index score value
n 0
n+1 0 대한
n+2 0 대한민
n+3 0 대한민국
n+4 0 대한민국*

 

TO-BE의 경우 '대'에 대한 zrange 시 index와 먼 곳에 자동완성 단어들이 위치한다. 데이터 셋의 끝까지 조회해야 할 필요성이 느껴진다.

index score value
n 0
n+1 0 대한
n+2 0 대한민
n+3 0 대한민국
... ... ..
m 1 대한민국*

 

3.1. 검색한 단어와 연관된 데이터들의 Score 증가

 후자의 경우는 자동완성 단어 누락을 야기할수 있다. '대만'이라는 데이터를 추가하여 예를 들어보겠다. 

index score value
n 0
n+1 0 대만
n+2 0 대만*
n+3 0 대한
n+4 0 대한민
n+5 0 대한민국
n+6 0 대한민국*

 

score가 0일때는 위와 같이 사전순으로 정렬된다. 사용자가 '대한민국'이라는 검색 API를 통해 검색하는 순간 관련된 데이터인 '대', '대한', '대한민', '대한민국', '대한민국*'의 score가 1씩 올라갈것이다. 그리고 데이터는 아래와 같이 조회된다.

 

index score value
n-2 0 대만
n-1 0 대만*
n 1
n+1 1 대한
n+2 1 대한민
n+3 1 대한민국
n+4 1 대한민국*

 

이때 '대'에 대한 자동완성 단어들은 index 기준 앞, 뒤에 위치해있다. 기존에는 자동완성 단어들을 찾기위해 한쪽 방향으로 조회했다면, 이제는 양쪽 방향으로 조회하며 찾아야한다.

 또한 새로운 단어들이 추가될 경우 score에 따라 데이터들이 뒤죽박죽 섞여버린다. 섞여버린 데이터들에서 자동완성 단어를 찾으려면 무조건 처음부터 끝까지 풀서치를 할수밖에 없는 상황이 되었다.

index score value
n-? 0 대만
n-? 0 대만*
n-? 100 필라델피아*
... ...  
n 500
... ...  
n+? 1000 대한민국*
... ...  
n+? 20000 삼성전자 주가*

 

결국 기존 데이터셋은 그대로 유지하고, 검색 횟수를 관리하는 새로운 데이터셋을 추가하기로 했다.

 


4. 새로운 데이터 셋 도입

 검색 단어들을 관리하는 새로운 데이터 셋을 만들었다. value는 검색어, score는 검색횟수이다. 기존 데이터셋은 자동완성 데이터를 조회하기 위해 기존과 동일하게 사용하고, 새로운 데이터셋은 기존 데이터셋에서 조회한 자동완성 단어에 대해 score(검색횟수)를 조회하는 용도로 사용했다. 이후 비지니스 로직을 통해 score 기준으로 내림차순 정렬처리 하였다.

자동완성 데이터 셋 검색 횟수 데이터셋
score value score value
0 0 대만
0 대만 0 대한민국
0 대만*    
0 대한    
0 대한민    
0 대한민국    
0 대한민국*    

 

검색 횟수 데이터셋이 추가됨에 따라 기존 로직에서 아래 빨간 부분에 대한 로직을 추가하였다.

 

 1) 검색어와 일치하는 단어의 index 조회(zrank 명령어 사용)

 2) index 번째부터 index + 100번째까지의 데이터 조회(zrange 명령어 사용)

 3) '*' 문자를 포함하는 완전한 단어를 필터링

 4) 검색어의 prefix와 일치하는 완전한 단어를 필터링 및 limit

 5) 필터링된 데이터의 score 조회 (zscore)

 6) score를 기준으로 정렬

 

* 참고로 zscore를 사용한 이유는 시간복잡도가 O(1)이기 때문이다. 시간이 지날수록 데이터 양이 많아지는 데이터 특성 상 처리 속도를 일정하게 유지하는 것이 효율적이라고 판단하여 이 방식을 채택했다.

 

이제 실제로 구현해보자.

 


5. 구현

5.1. AutocompleteController.kt

@RestController
@RequestMapping("/api/autocomplete")
class AutocompleteController (
    private val autocompleteService : AutocompleteService
){

    @GetMapping("/{searchWord}")
    @ResponseBody
    fun getAutocompleteList(@PathVariable searchWord : String) : ResponseEntity<AutocompleteResponse> {

        return ResponseEntity.ok(autocompleteService.getAutocomplete(searchWord))
    }
}

 기존과 동일하다.

 

5.2. AutocompleteService.kt

@Service
class AutocompleteService (
    private val redisTemplate : RedisTemplate<String, String>,
    @Value("\${autocomplete.limit}") private val limit: Long,
    @Value("\${autocomplete.suffix}") private val suffix : String,
    @Value("\${autocomplete.key}") private val key : String,
    @Value("\${autocomplete.score-key}") private val scoreKey : String
){
    fun getAutocomplete(searchWord : String) : AutocompleteResponse {

        val autocompleteList = getAutoCompleteListFromRedis(searchWord)
        return sortAutocompleteListByScore(autocompleteList)
    }

    fun addAutocomplete(searchWord : String ){

        val zSetOperations = redisTemplate.opsForZSet()
        zSetOperations.incrementScore(scoreKey, searchWord, 1.0)

        zSetOperations.score(key, searchWord)?:let {
            for(i in 1..searchWord.length){
                zSetOperations.add(key, searchWord.substring(0,i),0.0)
            }
            zSetOperations.add(key, searchWord+suffix,0.0)
        }

    }

    fun getAutoCompleteListFromRedis(searchWord : String) : List<String> {

        val zSetOperations = redisTemplate.opsForZSet()
        var autocompleteList = emptyList<String>()

        zSetOperations.rank(key, searchWord)?.let {
            val rangeList = zSetOperations.range(key, it, it + 1000) as Set<String> // 가중치 1000
            autocompleteList = rangeList.stream()
                .filter { value -> value.endsWith(suffix) && value.startsWith(searchWord) }
                .map { value -> StringUtils.removeEnd(value, suffix) }
                .limit(limit)
                .toList()
        }

        return autocompleteList
    }

    fun sortAutocompleteListByScore(autocompleteList : List<String>) : AutocompleteResponse{
        val zSetOperations = redisTemplate.opsForZSet()

        val list = arrayListOf<AutocompleteResponse.Data>()
        autocompleteList.forEach{word ->
                zSetOperations.score(scoreKey, word)?.let {
                    list.add(AutocompleteResponse.Data(word, it))
                }
        }
        list.sortByDescending { it.score }
        return AutocompleteResponse(list)

    }
}

 

 

1) getAutoCompleteListFromRedis 메서드를 통해 Redis의 자동완성 데이터셋에서 자동완성 데이터를 가져온다. 가중치는 1000, limit는 10으로 설정하였다. 

 

2) sortAutocompleteListByScore 메서드를 통해 검색횟수 데이터 셋에 값이 있는지 확인한다. 값이 있을 경우 score 가져와 list에 추가하고, sortByDescending 메서드를 사용해 score 기준으로 내림차순 정렬한다.

 

3) addAutocomplete는 검색 로직에서 사용하는 메서드로, 자동완성 데이터셋에 검색어가 없을 경우 규칙에 맞게 추가하고, 검색횟수 데이터 셋에 score를 추가 및 증가시킨다.

 

 * 참고로 검색횟수 데이터 셋 score를 증가시킬 때 사용한 incrementScore는 데이터 셋에 값이 없을경우 자동으로 값을 추가해준다.

 

 

5.3. SearchService.kt

    fun searchProductList(searchWord: String, pageable: Pageable): ProductListResponse? {
        autocompleteService.addAutocomplete(searchWord)
        return productRepository.selectProductListBySearchWord(searchWord,pageable)
    }

 검색관련 비지니스 로직이다.(자동완성 로직이 아니다.) 여기서 autocompleteService를 DI받아 addAutocomplete를 호출하고 있다. 

 

 

5.4. AutocompleteResponse.kt

data class AutocompleteResponse(
    val list : List<Data>
){
    data class Data(
        val value: String,
        val score: Double
    )
}

 자동완성 응답 DTO 클래스이다.

 


6. 테스트

 게임 기종 관련하여 자동완성 데이터셋과 검색횟수 데이터셋을 생성한 후 테스트를 진행하였다.

 

6.1. 자동완성 리스트 조회

 '닌'이라는 단어를 입력했을 때 조회되는 자동완성 리스트들이다. value에는 자동완성 단어, score에는 검색횟수가 조회되고 있다.

'닌'에 대한 자동완성 리스트 #1

 

6.2. 검색 후 자동완성 리스트 조회

 이제 제일 하단에 조회되는 '닌텐도 DS'라는 단어를 검색 API를 통해 검색한 후 '닌'에 대한 자동완성 리스트를 다시 조회해보자. 아래와 같이 닌텐도 DS라는 단어가 최상단에 조회됨을 확인할 수 있다.

'닌'에 대한 자동완성 리스트 #2

 

6.3. 새로운 단어 검색 후 조회

 검색 API를 통해 '닌텐도 new 3DS'라는 새로운 단어를 두번 검색 후 다시 조회해보자. 아래와 같이 '닌텐도 new 3DS' 새로 추가됨과 동시에 최상단에 위치해있는 걸 확인할 수 있다.

'닌'에 대한 자동완성 리스트 #3

 


7. 회고

 기존 데이터셋을 활용하는게 메모리적와 속도면에서 훨씬 효율적일 것 같아 여러 시도를 해보았지만, 결국 새로운 데이터셋을 추가하는 쪽으로 구현하게 되었다.

 새로운 데이터 셋 추가를 꺼려했던 이유는 속도 때문이었다. 처음엔 메모리도 신경쓰였으나 이 경우 기존 데이터셋보다 10분의 1도 안될 것이었기때문에 큰 걱정은 되지 않았다. 하지만 Redis를 한번 더 거쳐야 하고, 거기서 많은 자원을 사용하는 로직이 포함될 경우 처리 속도가 너무 느려지지 않을까하는 걱정이 앞섰다.

 때문에 redis 문법에 대한 처리 속도와 redis 라이브러리에서 제공하는 여러 메서드들을 하나하나 찾아가며 어떤게 더 효율적일지를 고민하게 되었고, 값에 대한 score를 조회한 후 비지니스 로직에서 정렬하는 방식을 채택하게 되었다.

 score 조회 명령어의 시간복잡도와, 비지니스 로직에서 정렬하는 데이터의 개수(10개)를 고려했을 때 처리속도에 큰 영향을 끼칠만큼의 복잡도가 아니라고 생각했기 때문이다.

 글로 남기지 않은 많은 시행착오가 있었지만 결국 나름 괜찮은(?) 자동완성 API를 구현하게 된것 같다. 혹시 포스팅 관련하여 수정할 내용이나 피드백이 있다면 꼭! 꼭! 알려주시길 바란다 :)

 


8. 참고

1) RedisTemplate 공식문서 - https://docs.spring.io/spring-data/redis/docs/current/api/org/springframework/data/redis/core/RedisTemplate.html

2) Redis 명령어 - http://redisgate.kr/redis/command/zrange.php

반응형
반응형

1. 개요

 Redis를 활용하여 자동완성 기능을 구현해보았다.

네이버에서 사용하는 자동완성

 


2. Redis를 선택한 이유

2.1. 속도

 Redis는 인 메모리에 데이터를 저장하고 조회하기 때문에 디스크에 저장하는 DB보다 훨씬 빠르다. 속도가 중요한 이유는 사용자가 단어를 한글자씩 입력할때마다 자동완성 값을 빠르게 뿌려줘야 하기 때문이다.

 네이버에서 '가, 나, 다, 라' 단어를 입력하면 아래와 같이 '가, 가나, 가나다, 가나다라'에 대한 자동완성 조회 API로 통신한다. 프론트에 값이 미리 저장되어 있는 것처럼 즉시적으로 나오는데 서버 응답 값이다. 속도가 빠를수록 자동완성 리스트를 더 빨리 제공할 수 있으며, 조회의 성격이 강한 데이터이므로 Redis를 선택했다.

 여담으로 네이버의 경우 입력한 검색어를 한번 더 입력할 경우 서버와 통신하지 않고 자동응답 값을 가져오고 있다. 서버에서 값을 조회했을 때 해당 검색어와 응답 데이터셋을 프론트에 저장해두고 가져다 쓰는 것 같다.

네이버 자동완성 기능

 

2.2. Sorted Set

 Redis는 데이터를 저장하기 위한 다양한 자료구조를 제공한다. 그 중 Sorted Set은 문자열을 Score와 함께 관리하고, Score로 정렬되는 자료구조인데, 동일한 점수를 갖는 경우 '사전순'으로 정렬한다. 

Redis 자료구조 - https://meetup.nhncloud.com/posts/224

Redis 정렬 세트는 연관된 점수로 정렬된 고유한 문자열(구성원)의 모음입니다. 둘 이상의 문자열이 동일한 점수를 갖는 경우 문자열은 사전순으로 정렬됩니다. - https://redis.io/docs/data-types/sorted-sets/

 일반적으로 검색어와 prefix가 일치하는 단어들을 자동완성 리스트로 뿌려야하는데, 사전순으로 정렬이 되어있다면 prefix가 일치하는 단어들은 검색어보다 뒤에 위치한다. 즉, 검색어의 index를 안다면 prefix가 일치하는 리스트를 뽑아낼 수 있다. 그리고 이 리스트가 자동완성 단어가 된다. 이러한 성격을 활용하면 어렵지 않게 자동완성 기능을 구현할 수 있다.

 


3. 관련 명령어

 Sorted Set을 사용하기 위한 주요 Redis 명령어들이다. 이해를 위해 아래 명령어는 숙지하는 게 좋다.

 

3.1. ZADD

 데이터 셋에 데이터를 Score와 함께 추가한다.

 [ZADD key score value]

 요청 > ZADD mydataset 1 한국

 요청 > ZADD mydataset 2 미국

 요청 > ZADD mydataset 3 러시아

 요청 > ZADD mydataset 4 프랑스

 요청 > ZADD mydataset 4 북한

 요청 > ZADD mydataset 4 가나

 

3.2. ZRANGE

  주어진 범위 내에 데이터 셋을 반환한다. Score가 적은 것부터 조회하며, 동일한 Score를 가진 데이터가 여러개 있을 경우 사전순으로 조회된다.

 [ZRANGE key startIndex endIndex]

 요청 > ZRANGE mydataset 0 -1

 응답 >

 1) 한국

 2) 미국

 3) 러시아

 4) 가나

 5) 북한

 6) 프랑스

 

3.3. ZRANK

 정렬된 기준이 오름차순이라고 가정하고 검색한 데이터의 index를 반환한다.

 [ZRANK key value]

 요청 > ZRANK mydataset 한국

 응답 > 0

 요청 > ZRANK mydataset 미국

 응답 > 1

 


4. 분석

 이제 Reids의 Sorted Set을 활용해 어떻게 구현할지 분석해보자.

 

4.1. 자동완성 단어 조회 매커니즘

 기본적인 매커니즘은 어떤 값을 조회하면, 그 값을 prefix로 갖는 단어들을 조회하는 것이다. 

 '안' 이라는 값을 입력하면 '안경', '안경점'을 포함하는 리스트가 조회되어야 한다.

 '안경' 이라는 값을 입력하면 '안경', '안경점'을 포함하는 리스트가 조회되어야 한다.

 Sorted Set을 활용하면 이러한 구조를 쉽게 구현할 수 있다. 먼저 zadd를 통해 데이터 셋을 추가해주는데, '안경점' 이라는 문자를 추가하고자 할 경우, 이를 구성하는 [안, 안경] 이라는 문자열도 데이터 셋에 추가해줘야한다. 

 

요청 > zadd mylist 0 안경점

요청 > zadd mylist 0 안경

요청 > zadd mylist 0 안

 

안경점 이라는 자동완성 단어를 추가하기 위해 Sorted Set에 0 Score로 '안경점', '안경', '안' 을 zadd 하였다. range mylist 0 -1로 모든 리스트를 조회하면 아래와 같이 사전순으로 '안', '안경', '안경점'이 조회된다.

 

요청 > zrange mylist 0 -1

응답 >

1) 안

2) 안경

3) 안경점

 

 여기서 앞서 언급했던 매커니즘을 예시에 적용하면 ['안'을 조회하면, '안'을 prefix로 갖는 단어들이 조회되어야 한다.]이다. 그런데 사전순으로 정렬되어 있다보니 '안'을 prefix로 갖는 단어들은 '안'의 index를 포함하여 아래 위치한 값들이 된다. 마찬가지로 '안경'을 조회하면 '안경'을 prefix로 갖는 단어들은 '안경'이 갖는 index를 포함하여 아래 위치한 값들이다. 결국 검색어에 대한 index를 찾는다면 자동완성 단어를 추출할 수 있다. 이 index는 zrank 명령어로 찾으면 된다.

 정리하면 zadd를 통해 자동완성 단어에 대한 데이터 셋을 만들고,  zrank를 사용해 검색어에 대한 index를 찾고, zrange를 통해 index 부터 조회하여 연관 단어를 찾는 것이다.

 

4.2. 완전한 단어

 '플레이스테이션' 이라는 단어로 예를 더 들어보겠다. 이 단어를 자동완성 단어로 사용하기  '플', '플레', '플레이' ... '플레이스테이션' 데이터들을 추가했다.

 

요청 > zadd mylist 0 플

요청 > zadd mylist 0 플레

요청 > zadd mylist 0 플레이

요청 > zadd mylist 0 플레이스

요청 > zadd mylist 0 플레이스테

요청 > zadd mylist 0 플레이스테이

요청 > zadd mylist 0 플레이스테이션

 

그리고 '플레이' 라는 단어에 입력했다고 가정하고 index를 구했다.

 

요청 > zrank mylist 플레이

응답 > 2

 

마지막으로 prefix를 가진 값들을 조회하기 index 부터 값을 조회했다.

 

요청 > zrange mylist 2 - 0

응답 >

1) 플레이

2) 플레이스

3) 플레이스테

4) 플레이스테이

5) 플레이스테이션

 

여기서 플레이스, 플레이스테, 플레이스테이와 같은 완전한 단어가 아니다. 이러한 값들은 자동완성 단어에 적합하지 않다. 하지만 없어서는 안된다. index를 찾아야만 완전한 단어를 검색할 수 있기 때문이다.

 결국, 완전한 단어를 나타내는 데이터와 그 단어를 검색하기 위한 데이터를 구분해야한다. 이는 완전한 단어의 suffix에 '*'와 같은 구분자를 붙어주면 된다.

 

요청 > zadd mylist 0 플레이스테이션*

 

이 상태에서 다시 zrange를 하게 된다면 아래와 같이 값들이 조회될것이다.

 

요청 > zrange mylist 2 - 0

응답 >

1) 플레이

2) 플레이스

3) 플레이스테

4) 플레이스테이

5) 플레이스테이션

6) 플레이스테이션*

 

 여기서 자동완성에 쓸 데이터는 완전한 단어인 '*'가 붙은 문자열들만 추출한 후 사용하면 된다. 이렇게 될 경우 플레이, 플레이스, 플레이스테이, 플레이스테이션을 검색해도 자동완성 리스트에 조회되는 결과는 플레이스테이션이라는 완전한 단어만이 조회될 것이다.

 '*' 문자를 포함하는 데이터만을 추출하는 작업은 redis에서 지원하는 문법이 없는 관계로 비지니스 로직에서 처리하면 된다.

 


5. 데이터 셋 만들기

 결국 위 내용에 따라 자동완성 데이터 셋들을 만들어야 한다. '플레이스테이션'이라는 단어에 대해서는 8개의 데이터를 redis에 넣어줘야한다. 엥? 필자는 많은 단어들을 넣어야하는데... 생각해보니 너무 오래걸릴 것 같아서 이 부분은 넘어가도록 하겠다.

 

 는 대학생 과제 제출 시 마인드였고, 미래를 생각했을때 데이터 셋을 자동으로 생성해주는 뭔가가 있지 않을까 해서 구글링을 하였다. 마침 AWS 공식 블로그에서 Redis 자동완성 관련 내용을 다룬 게시글이 있었고 파이썬으로 데이터 셋을 만들어 redis에 넣는 코드가 있었다.

 코드 내용을 분석하여 나에게 맞게 수정한 후 웹에서 파이썬 코드를 컴파일할 수 있는 https://replit.com/ 사이트에서 코드를 실행하였다. 여기 접속해서 아래 아래 코드를 실행시키면 Redis 서버로 위와 같은 데이터셋을 추가할 수 있다.

더보기

Redis Connection 에러 발생시!!

 웹사이트에서 실행되는거라 그런지 host에 로컬 IP를 입력할 경우 redis connection refused 에러가 발생했다. 필자의 경우 포트포워딩 설정 후 host에 외부 IP를 입력해주니 connection 문제가 해결되었다.

 

https://replit.com/ 

 

Replit: the collaborative browser based IDE

Run code live in your browser. Write and run code in 50+ languages online with Replit, a powerful IDE, compiler, & interpreter.

replit.com

 

아래 코드에서 host, port을 입력하고 완전한 단어들이 들어있는 autocomplete.txt 파일을 생성해준다. text 파일은 왼쪽 파이썬 코드와 같은 레벨에 생성해주면 된다.

예제 코드 작성 방법

#-*- coding:utf-8 -*-
import redis

r = redis.StrictRedis(host='host 입력', port=port입력, db=0)

f = open("autocomplete.txt","rt", encoding="utf-8")
for line in f:
  n = line.strip()
  for l in range(len(n)+1):
    prefix = n[0:l]
    print(prefix)
    r.zadd('autocomplete2',{prefix : 0})
  r.zadd('autocomplete2',{n+"*" : 0})
  print(n+"*")
else:
  exit

 

여기서 또 여담하나 풀도록...(엣헴)

위 코드를 실행하고 redis에 들어간 데이터를 redis-cli로 확인해봤더니 아래와 같이 한글이 깨져서 들어갔다.

 

뀨?

 닌텐도와 닌텐도 DS라는 단어였는데 영어는 잘들어가고 한글은 깨져서 들어갔다. 이왕 사용할거 코드가 간단하기도 하고 스프링 기반으로 내가 직접 만들어보자라는 생각에 파이썬 코드를 분석하여 스프링 기반으로 만들었다.

 나중에 알고보니 저렇게 깨지는건 필자가 언젠가 Redis 설정을 건들면서 발생한 문제로 한글이 깨져 보였던 것이었고, 재설치하니 잘 조회되더라. 참고로 저렇게 보여도 잘 들어간게 맞았습니다... (네?)

 그렇다고 뻘짓을 한건 절대 아니다. 웹 사이트에서 실행 시 redis로 데이터가 들어가는 속도가 많이 느렸기 때문이다. git 잔디도 심었으니 오히려좋아... 

 


6. 구현

 코틀린 기반으로 로직을 구현했다. 설정 관련 코드와 주요 비지니스 로직이 담긴 Service 코드만 설명하겠다.

 

6.1. RedisConfig.kt

@Configuration
class RedisConfig(
    @Value("\${spring.redis.port}") private val port: Int,
    @Value("\${spring.redis.host}") private val host: String,
) {

    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory? {
        return LettuceConnectionFactory(host, port)
    }
    @Bean
    fun redisTemplate(): RedisTemplate<String, String> {
        val template = RedisTemplate<String, String>()
        redisConnectionFactory()?.let { template.setConnectionFactory(it) }
        template.keySerializer = StringRedisSerializer()
        template.valueSerializer = StringRedisSerializer()
        return template
    }
}

 keySerializer와 valueSerializer를 모두 StringRedisSerializer로 설정하였다. 객체 형태의 value 값을 관리할 때에는 valueSerializer를 GenericJackson2JsonRedisSerializer 형식으로 설정하는데, 자동완성에 적용하니 큰따옴표(")로 인한 이슈가 발생했다.

 

데이터 셋

'닌텐도', '닌텐도 DS' 문자로 데이터 셋을 만들면 위와 같이 문자열이 생성되는데 GenericJackson2JsonRedisSerializer 이 설정된 상태로 Redis에 데이터를 넣게 된다면 문자열마다 큰따옴표가 들어가게 된다. 아래와 같이 말이다.

 

GenericJackson2JsonRedisSerializer 사용 시 조회 데이터

 

의도했던 정렬 순서로

[닌, 닌텐, 닌텐도, 닌텐도*, 닌텐도(공백), 닌텐도 D, 닌텐도 DS, 닌텐도 DS*] 가 되어야 하는데 저 큰따옴표가 들어가는 바람에 사전순으로 정렬할 시 (공백)문자보다 큰따옴표를 나타내는 \" 문자가 뒤로 밀려

[닌, 닌텐, 닌텐도(공백), 닌텐도 D, 닌텐도 DS, 닌텐도 DS*, 닌텐도, 닌텐도*] 순으로 정렬이 되버렸다.

 이 상태에서 '닌텐도 DS'를 검색한다면 '닌텐도 DS'와 '닌텐도' 가 조회되게 된다. 이 이슈로 인해 영향도를 분석 후 valueSerializer를  GenericJackson2JsonRedisSerializer 에서 StringRedisSerializer로 변경하였고, 큰따옴표로 인한 문제는 해결되었다.

 

StringRedisSerializer 사용 시 조회 데이터

 

 그런데 또다른 문제가 발생했다. 이번에는 완전한 단어를 나타내기 위해 넣었던 '*' 로 인해 발생했다. 현재 정렬되는 데이터 순서는 [닌, 닌텐, 닌텐도, 닌텐도(공백), 닌텐도 D, 닌텐도 DS, 닌텐도 DS*, 닌텐도*] 였기 때문에 '닌텐도 DS'를 검색한다면 위와 마찬가지로 '닌텐도 DS'와 '닌텐도'가 여전히 조회된다.

 

이에 대해 총 두가지 방법을 생각해냈다. 첫번째 방법은 검색어가 redis에서 추출한 데이터의 시작부분에 포함되어 있는지를 비지니스 로직에서 확인하는 것이고, 두번째 방법은 완전한 단어를 나타내는 문자 앞에 공백을 추가하여 '*'에서 ' *'로 변경하는 방법이었다.

 

 결국은 첫번째 방법을 선택했는데, 그 이유는 이 과정이 필수적으로 들어가야 했기 때문이다. 플레이스테이션1, 플레이스테이션2 에대한 데이터 셋이 있을 때 '플레이스테이션'을 검색할 경우 [플레이스테이션1, 플레이스테이션2] 가 조회되어 정상으로 보이지만, '플레이스테이션1'을 검색할 경우 [플레이스테이션1] 뿐 아니라 [플레이스테이션 2]도 같이 조회되게 된다. 이 이유는 아래와 같이 플레이스테이션1보다 플레이스테이션2가 가나다 순으로 더 뒤에 위치하기 때문이다. 

 

1)플

2) 플레

3) 플레이

...

x) 플레이스테이션1*

x+1) 플레이스테이션2

x+2) 플레이스테이션2*

 


6.2. AutocompleteService.kt

@Service
class AutocompleteService (
    private val redisTemplate : RedisTemplate<String, String>,
    @Value("\${autocomplete.limit}") private val limit: Long,
    @Value("\${autocomplete.suffix}") private val suffix : String,
    @Value("\${autocomplete.key}") private val key : String
){
    fun getAutocomplete(searchWord : String) : AutocompleteResponse {
        val zSetOperations = redisTemplate.opsForZSet()
        var autoCompleteList  = emptyList<String>()

        zSetOperations.rank(key, searchWord)?.let {

            val rangeList = zSetOperations.range(key, it, it+1000) as Set<String>

            autoCompleteList =  rangeList.stream()
                .filter { value -> value.endsWith(suffix) && value.startWith(searchWord)}
                .map { value -> StringUtils.removeEnd(value,suffix) }
                .limit(limit)
                .toList()
        }
        return AutocompleteResponse(autoCompleteList)
    }
}

sortedSet에 대한 작업을 수행할 수 있는 zSetOperation 객체를 생성하고, 위 매커니즘에서 언급했던 작업들을 구현하였다.

 

1) zSetOperations.rank(key, searchWord)?.ley{} : zrank를 명령어를 통해 검색어(searchWord) 데이터의 index를 구한다.

 

2) zSetOperations.range(key, it, it+1000) : zrange 명령어를 통해 index ~ index+1000 까지의 데이터를 조회한다. 사전순으로 정렬되어 있으니 index 와 가까운 위치에 자동완성 데이터가 존재할 것이기 때문이다. 이에 대한 가중치를 1000으로 설정하였기에 it+1000까지 조회하였다.

 

3) filter{value -> value.endsWith(suffix) && value.startWith(searchWord)} : 조회된 문자열 중 마지막이 suffix(=='*')로 끝나고,  검색어로 시작하는값들을 필터링한다.

 

4) map{ value -> StringUtils.removeEnd(value,suffix) } : 필터링 된 데이터들에 포함된 suffix를 제거한다.

 

5) limit(limit).toString() : 최대 limit개로 제한하여 리스트로 만든다.

 


7. 테스트

 원하는 값들이 정상적으로 나옴을 확인할 수 있다. 지금은 URL에 일일이 입력하여 처리하고 있지만, 프론트 단에서는 검색란에 값을 입력하는 순간 위 API를 태우도록 구현하면 된다.

테스트


8. 회고

 Sorted Set의 자료구조와 Redis 명령어들을 이해하기 위해 공식문서 위주로 보며 실습을 진행하였다. 어느정도 정리가 되니 어떻게 구현 방법을 계획했고, 실행에 옮겼다. 물론 이 과정에서 value 직렬화나, Redis 한글깨짐, 의도하지 않은 정렬 등 여러 이슈들을 마주했지만 그것들이 발생한 원인을 분석하고 해결해나가면서 배운 것들이 매우 의미있다고 생각한다.

 지금은 정렬된 순서 그대로 자동완성 단어들을 뽑아내고 있지만, 단어의 검색 량에 따라 조회되는 우선순위를 달리하여 자동완성 단어들을 뽑아낼 수 있도록 수정해도 좋을 것 같다.

 


9. 참고

Redis Sorted Set - https://redis.io/docs/data-types/sorted-sets/

Redis Autocomplete - https://aws.amazon.com/ko/blogs/database/creating-a-simple-autocompletion-service-with-redis-part-one-of-two/

Redis Command - http://redisgate.kr/redis/command/zrange.php

 

반응형
반응형

1. 개요

 국토 교통부에서 제공하는 법정동 코드를 다운받아 DB 테이블에 밀어 넣고, JPA를 통해 주소를 검색하는 API를 구현하였다. 하지만 데이터의 양이 많아 응답까지 1초 ~ 3초정도가 소요되는 것을 보고 Redis의 캐싱 기능을 도입하게 되었다. 그 과정을 정리한다.

 

 


2. Cache 설정

2.1. RedisCacheConfig.kt

@Configuration
@EnableCaching
class RedisCacheConfig {

    @Bean
    fun redisCacheManager(cf: RedisConnectionFactory?): CacheManager? {
        val redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    GenericJackson2JsonRedisSerializer()
                )
            )
            .entryTtl(Duration.ofDays(1))
        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf!!)
            .cacheDefaults(redisCacheConfiguration).build()
    }
}

 

 TTL은 하루로 설정하였고, 직렬화 방식은 key는 String, value는 GenericJackson2JsonRedisSerializer를 사용했다. 이 형식을 사용한 이유는 캐싱할 value 값이 List<Object> 형태였기 때문이다.

 

2.2. Applicatoin.kt

@SpringBootApplication
@EnableCaching
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

 캐시를 사용하기 위해 Application 실행파일에 @EnableCaching을 추가하였다.

 

2.3. Service.kt

@Service
class AddressService (
    private val addressRepository: AddressRepository
){

    @Cacheable(value = ["Address"], key = "#searchWord", cacheManager = "redisCacheManager")
    fun searchAddress(searchWord : String) : List<AddressResponse> {
        return addressRepository.findBySearchWordLike(searchWord)
    }

}

 캐시를 적용하고자 하는 메서드에 @Cacheable 을 설정한다. [value]는 redis에 저장되는 Key의 prefix, [key]는 suffix 이다. 만약 동일한 키를 가진 캐싱 데이터가 있을 경우 cache hit가 발생하여 캐싱된 데이터를 조회하고, 없을 경우 cache miss가 발생하여 DB에서 데이터를 조회한 후 데이터를 캐싱할 것이다.

 cacheManage에는 RedisCacheConfig 에서 생성한 Bean 이름을 넣어준다.

 

2.4. Controller.kt

@Controller
@RequestMapping("/api/address")
class AddressController (
  private val addressService : AddressService
) {
...
    @GetMapping("/search")
    fun searchAddress(@RequestParam("searchWord") searchWord : String) : ResponseEntity<List<AddressResponse>> {

        val list = addressService.searchAddress(searchWord)
        return ResponseEntity.ok(list)
    }
...
}

 Controller에서 캐싱 메서드를 호출한다.


3. 테스트

3.1. 최초 검색

 최초 검색시 cache miss가 발생함에 따라 DB 조회 및 redis 에 데이터를 캐싱하는 과정을 거치게 된다. IDE에 찍힌 로그를 보면 JPA 쿼리 결과가 조회되는데, 이는 DB를 조회하여 데이터를 가져왔다는 사실을 알수있다.

 캐싱된 데이터는 redis-cli 를 실행 후 keys, get 명령어를 통해 확인할 수 있다. 시간은 1400ms가 소요되었다.

postman 테스트 결과 #1
JPA 쿼리 실행 결과 #1

 

3.2. 두번째 요청

postman 테스트 결과 #2

  동일한 값으로 요청하니 cache hit가 발생하여 DB를 조회하지 않고 캐싱된 데이터를 조회하고 있다. DB를 조회했다면 JPA 쿼리 결과가 콘솔에 찍힐테지만, 아무 로그도 찍히지 않고 있다. 시간은 72ms가 소요되었다.

 

3.3. 새로운 키워드로 요청

 캐싱된 값이 없으니 cache miss가 발생하고 3.1. 최초검색과 비슷한 시간이 소요됨을 확인하였다.


4. 문제

4.1. 건당 Redis 메모리 사용량

redis-cli 의 info memory 명령어를 통해 redis 메모리 사용량을 확인할 수 있다. 확인해보니 시, 도 별로 검색할 경우 한 건당 메모리가 약 1M 정도 사용되었다. 결코 적은 양이 아니다. 만약 시, 도별 다른 키워드로 검색을 한다면 cache miss가 발생하여 데이터를 캐싱할 것이고, 1M 정도가 추가로 사용될 것이었다.

 TTL을 하루로 설정하였기에 RAM 용량이 8GB라면 각기 다른 키워드로 8000번 호출 시 redis 서버가 다운될 가능성이 있다.

 

4.2. 주소 검색의 특성

주소 검색 특성상 많은 사람들이 똑같은 키워드보다는 본인이 사는 동이나, 지번으로 검색할 확률이 높다. 새로운 키워드가 들어올 확률이 높다는 것이다. cache miss가 빈번하게 발생할 것이고, 응답 시간은 DB를 단독으로 사용하는 것보다 느린 케이스도 빈번할 것이다. redis 메모리 사용량도 빠르게 늘어날 것이다.

 


5. 개선

 주소 검색 시 선 작업으로 모든 주소를 조회하는 로직을 추가하였다. 그 후 조회한 리스트에서 검색어에 대한 주소 값을 추출하는 방식을 채택했다.

 캐싱은 모든 주소를 조회하는 부분에 적용하였다. cache hit시 리스트에서 필터링하는 시간과 비용만 소비하면 된다. 물론, 첫번째 방식 사용 시보다 응답 속도가 느린 케이스도 있다. TTL이 만료되는 24시간 후 cache miss가 발생할 때 데이터를 가져올때나 동일 키워드로 여러번 검색할때이다. 일단 개선 로직 구현 후 이에 대한 트레이드 오프를 분석할 예정이다.

 

5.1. 개선 AddressController.kt

...
    @GetMapping("/search")
    fun searchAddress(@MemberAuthentication authenticationAttributes: AuthenticationAttributes
                      , @RequestParam("searchWord") searchWord : String) : ResponseEntity<List<AddressResponse>> {
        val allAddressList = addressService.searchAllAddress()
        val findAddressList = addressService.searchAddress(allAddressList, searchWord)

        return ResponseEntity.ok(findAddressList)
    }
...

 addressService.searchAllAddress() 메서드를 통해 캐싱되어있는 모든 주소리스트를 조회하고, searchAddress() 메서드를 통해 키워드를 포함하는 요소를 찾아 리턴한다.

 searchAddress() 메서드 안에서 searchAllAddress()를 호출할 수 도 있지만, AOP를 사용하는 캐싱 메서드의 특성 상 Self-invocation 이슈가 있어 Controller에서 따로 호출하였다.

 

더보기

* Self-invocation 으로 인한 이슈

 AOP 기반으로 호출되는 캐싱 메서드의 특성 상 같은 클래스 내 위치한 특정 메서드에 의해 캐싱 메서드가 호출될 경우 캐싱 기능이 동작하지 않는다. 이에따라 컨트롤러에서 캐싱 메서드를 호출하여 캐싱 처리하고, 응답받은 값을 통해 서치하는 메서드를 호출하는 방식을 채택하였다.

 

5.2. 개선 AddressService.kt

@Service
class AddressService (
    private val addressRepository: AddressRepository
){
    ...

    @Cacheable(value = ["AllAddress"], key = "", cacheManager = "redisCacheManager")
    fun searchAllAddress() : List<AddressResponse> {
        val list = addressRepository.findAll()
        return list.stream()
            .map { e -> AddressResponse(e.id, e.addr) }
            .collect(Collectors.toList())

    }

    fun searchAddress(list : List<AddressResponse>, searchWord: String): List<AddressResponse> {
        return list.stream()
            .filter { address -> address.name.contains(searchWord) }
            .collect(Collectors.toList())
    }
}

 searchAllAddress() 메서드는 DB에서 모든 주소 값을 읽어와 List<AddressResponse> 형태로 리턴하며 캐싱한다. cache miss가 발생 시 시간이 다소 소요된다. searchAddress는 list에 대해 필터링하는 메서드이다.


6. 개선 테스트

 

6.1. 최초 검색

 1차 테스트와 동일한 검색어로 검색한 결과 cache miss 발생 시 응답속도가 1400 ms 에서 2170 ms로 0.67초 느려진 것을 확인하였다. 모든 주소 값을 읽고, 캐싱하는 부분으로 인한 시간이라 생각한다.

개선 로직에 대한 postman 테스트 결과 #1

 

6.2. 두번째 검색

 동일한 키워드로 두번째 검색을 하였다. 전자의 경우 '시'라는 키워드에 대한 결과가 미리 캐싱되어있었기에 소요되는 시간이 조금 더 걸릴것으로 예상했으나, 72ms에서 92ms로 생각보다 시간 차이가 얼마 나지 않음을 알 수 있었다. 몇번 더 테스트해봐도 100ms 안밖이었다.

 cache miss가 발생하지 않았기에 사용되는 메모리는 증가하지 않았다. 참고로 약 9MB 정도를 사용중이다. 이제 다른 키워드로 검색해보자.

개선 로직에 대한 postman 테스트 결과 #2
Redis 메모리 사용량 #1

6.3. 새로운 키워드로 검색

 서울, 강원 등 여러 키워드로 검색해보았다. 그 결과 걸린 시간은 모두 100ms 안팎임을 확인하였다. 메모리 사용량도 변함이 없다.

개선 로직에 대한 postman 테스트 결과 #3
개선 로직에 대한 postman 테스트 결과 #4
Redis 메모리 사용량 #2


7. 장단점

 개선 방식으로의 변경을 통해 확인한 장단점을 정리해보았고, 트레이드 오프를 고려했을 때 개선된 방식이 훨씬 더 효율적이라는 결론을 내렸다.

 

* 장점

 1. Redis 메모리 부족 위험에 대해 완전히 벗어날 수 있다.

 2. 새로운 키워드로 검색해도 cache miss가 발생하지 않아 속도가 빠르다. (1400 ms > 100ms로 개선)

 3. 동일한 키워드로 검색해도 이전 방식과 속도차이가 거의 나지 않는다. (약 20ms 차이)

 

* 단점

 1. cache miss 발생 시 이전보다 더 많은 시간이 소요된다. (1400 ms > 2170 ms 로 증가)

 2. 데이터 정합성 문제 발생 확률이 이전보다는 높다. (하지만 쓰기 작업을 연마다 하는 특성 상 큰 문제는 되지 않을 것이라 판단하였다)


8. 회고

 데이터 수정이 없는 주소 데이터 특성에 의해 무작정 캐싱 도입을 하였으나, 큰 성능향상은 얻지 못했고, 오히려 Redis 메모리에 대한 잠재적인 문제와, cache miss 시 속도문제를 안게 되었다. 이건 캐싱에 대한 이해도가 부족해 발생한 것이라 생각하여 개념, 용어, 방법, 전략들을 공부하였다.

 

 전략을 선택할 때 가장 중요한 건 캐싱할 데이터의 성격을 분석하는 것이라 생각한다. 메모리 용량은 많아봤자 32GB로 제한적이다. 많은 데이터를 캐싱할 수록 메모리의 부담은 커지고, 메모리 부담을 덜기 위해 무작정 TTL을 낮춘다면, 잦은 DB 혹은 API 통신으로 시간이 더 걸릴 수 있다. 만약 특정 키워드로 반복적인 읽기가 많은 작업이었다면 캐싱을 적용하기 전보다 성능이 낮아질수도 있다.

 조회 작업이 많은지, 쓰기 작업이 많은지, 쓰기 작업 없진 않는지, 있다면 그 빈도는 어떤지, 조회만 하는지 등을 분석하여 메모리 사용량을 낮추고, 속도는 비교적 높일 수 있는 캐싱 전략을 세워야 하는데, 이는 캐싱 데이터의 성격에 따라 달라진다.

 주소 검색의 경우 쓰기 전략을 Write Around를, 읽기 전략은 Look Aside로 설정하고 코드를 개선해나갔다. 주로 읽기 작업이고, 쓰기작업은 연 주기로 공공기관을 통해 데이터를 다운받아 DB에 밀어넣는 것 하나이기 때문이다. 또한 쓰기 작업이 되어 새로 등록된 주소는 cache miss가 발생할 때 조회해도 서비스 운영에는 큰 문제가 발생하지 않고, 즉시 조회를 해야 한다고 해도 캐시를 수동으로 만료시키는 방안도 있었다.

 이렇게 데이터의 성격을 파악하고 전략을 수립한 상태에서 리팩토링을 하니 속도와 메모리 효용성을 향상시킬 수 있는 방법을 구상하고 적용할 수 있게 되었다. 캐싱을 적용하는 것은 어렵지 않다. 다만 데이터의 성격을 분석하여 캐싱을 왜 적용하는지, 어느 부분에 적용해야 좋은지, 적용을 통해 얻을 수 있는 장단점은 어떻고, 적절한 트레이드 오프인지를 생각하는 것이 중요하다고 생각한다.

반응형
반응형

1. 개요

 - 토비의 스프링 책에 나온 계산기 예제 코드에 대해 템플릿/콜백 패턴을 적용한다.

 - 패턴을 도출해내는 과정을 이해하기 위함이다.


2. 템플릿/콜백 패턴이란?

 - 전략 패턴에 사용되는 인터페이스 구현체 클래스 대신 익명 내부 클래스가 사용되는 패턴이다. (전략패턴 + 익명 내부 클래스)

 - 코드 내에 고정된 부분(템플릿)과 변경되는 부분이 있을 때 고정된 부분은 클래스 또는 메서드로 분리하여 템플릿 역할을 갖게하고, 변경되는 부분 콜백(익명 클래스의 메서드)으로 처리한다.

 - 콜백으로 처리하는 이유는 변경되는 부분이 여러 클래스에서 재사용되는게 아닌 특정 클래스의 메서드 내에서 한번만 사용되기 때문이다. 만약, 여러 클래스에서 사용된다면 템플릿/콜백 메서드보다는 전략 패턴을 사용하는게 더 유리하다.

 


3. 템플릿/콜백 패턴 적용

 

3.1. 예제코드

public class Calculator{

    public Integer calcSum(String path) throws IOException {
        BufferedReader br = new BufferedReader(new FileReader(path));

        Integer sum = 0;
        String line = null;

        while((line = br.readLine()) != null){
            sum += Integer.valueOf(line);
        }

        br.close();
        return sum;
    }
}

 - path 경로에 있는 txt 파일을 읽어 각 라인에 적혀있는 숫자들을 연산하는 예제이다.

 

3.2. 코드 분석 및 템플릿 설계

 - 코드를 분석하여 어떤 전략을 사용해 템플릿을 설계할지 생각해본다. 일단 예제의 목적 자체가 템플릿/콜백 패턴 구현이므로 이에 초점을 맞추었다. 현재  'calcSum' 이라는 더하기 연산 처리 메서드만 있지만 곱하기, 나누기, 빼기의 메서드가 추가된다고 가정하고 분석하였다.

 

아래 사항들을 생각하며 분석하였다.

 1) 공통부분과 바뀌는 부분이 있는지

 2) 공통부분은 어떤 레벨로 분리할지

 3) 바뀌는 부분은 타 클래스 혹은 메서드에서 재사용할 가능성이 있는지

 

3.3. 분석결과

3.3.1. 공통부분

- path에 대한 BufferedReader를 생성하는 부분

- 반복문을 통해 BufferedReader에서 라인을 읽는 부분

- BufferedReader를 close 하는 부분 (+ try, catch, finally 로 처리)

 

3.3.2. 바뀌는 부분

- 값을 연산하는 부분

 

3.3.3. 정리

- 공통 부분은 BufferedReader의 생명주기와 관련있고, Integer형태의 결과값을 리턴하는 부분이므로 다른 클래스에서도 충분히 재사용할 수 있다고 판단. 클래스로 분리한다.

- 더하기, 빼기, 곱하기, 나누기 연산은 메서드마다 유연하게 바뀌어야 하므로 템플릿/메서드 패턴보다는 전략패턴을 고려하였으나, 각 연산 처리는 다른 클래스에서 재사용되지 않고, Calculator 클래스에 생성될 메서드에 한번만 사용될 것이기 때문에 일회성 성격을 지닌 내부 익명 클래스로 구현한다. 익명 클래스는 콜백 오브젝트 역할을 수행할 것이며, 이를 통해 템플릿/콜백 패턴을 적용한다.

 

3.4. 1차 코드수정

3.4.1. Calculator2.java

public class Calculator2 {

	// 공통 부분을 구현한 Template 클래스
    private BufferedReaderTemplate bufferedReaderTemplate;

	// 외부로부터 DI받기위한 수정자 메서드
    public void setBufferedReaderTemplate(BufferedReaderTemplate bufferedReaderTemplate){
        this.bufferedReaderTemplate = bufferedReaderTemplate;
    }

	// 더하기 메서드
    public Integer calcSum(String path) throws IOException {

		// Template의 withCalculatorCallback 를 호출 시 CalculatorCallback에 대한 내부 익명클래스 구현 및 전달
        return bufferedReaderTemplate.withCalculatorCallback(path, new CalculatorCallback() {
            @Override
            public Integer calculate(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        });
    }
    
    // 곱하기 메서드
    public Integer calcMultiply(String path) throws IOException {
		// Template의 withCalculatorCallback 를 호출 시 람다식을 활용하여 내부 익명클래스 구현 및 전달
        return bufferedReaderTemplate.withCalculatorCallback(path, (line, value) -> value * Integer.valueOf(line));
    }
}

 - 여러 클래스에서 공통으로 사용될 여지가 있는 BufferedReaderTemplate은 외부(ObjFactory.java)로부터 DI 받는다.

 - 메서드 호출 시 템플릿/콜백 패턴이 적용된 bufferedReaderTemplate.withCalculatorCallback() 메서드를 호출하며, 이때 두번째 파라미터인 CalculatorCallback() 인터페이스에 대한 구현체를 내부 익명클래스로 구현한다.

 - 곱하기 메서드는 람다식을 사용하여 간단하게 구현했다. 사실상 더하기 메서드와 동일하다. 

 

3.4.2. BufferedReaderTemplate.java

public class BufferedReaderTemplate {

	// CalculatorCallback을 함께 받는 메서드 정의
    public Integer withCalculatorCallback(String path, CalculatorCallback callback) throws IOException {
        BufferedReader br = null ;
        try{
            br = new BufferedReader(new FileReader(path));
            String line;

            Integer res = 0;
            while((line = br.readLine()) != null) {
                res = callback.call(line, res); // 콜백 오프젝트의 call 메서드 호출
            }

            return res;
        }catch(FileNotFoundException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(br != null){
                br.close();
            }
        }

    }
}

 - CalculatorCallback 구현체를 파라미터로 받는 withCalculatorCallback 메서드를 구현하였다. 만약 계산기가 아닌 다른 목적으로 이 클래스를 사용한다면 그에 맞게 메서드를 만들수 있으므로 확장성이 보장된다.

 - 중간 부분에 callback.call(line,res); 구문을 통해 콜백 오프젝트의 call 메서드를 호출하여 연산 처리가 되도록 하였다.

 

3.4.3. CalculatorCallback.java

public interface CalculatorCallback {

    public Integer call(String line, Integer value);
}

 - 템플릿에 사용될 콜백 오브젝트를 정의하였다. 목적은 파라미터로 들어온 line의 값과 value 의 값을 목적에 맞게 연산하기 위함이다.

 

3.4.4. ObjFactory.java

@Configuration
public class ObjFactory {

    @Bean
    public Calculator2 calculator2(){
        Calculator2 calculator2 = new Calculator2();
        calculator2.setBufferedReaderTemplate(bufferedReaderTemplate());
        return calculator2;
    }

    @Bean
    public BufferedReaderTemplate bufferedReaderTemplate(){
        return new BufferedReaderTemplate();
    }
}

 - DI 처리를 위한 Factory 클래스이다. BufferedReaderTemplate Bean을 만들고 Calculator2에 DI 하고있다.

 

3.5. 1차 테스트

 - Junit 코드 작성 후 테스트를 진행하였다. DI 정보는 ApplicationContext에서 읽어와 멤버필드에 넣어주었다.

 - numbers.txt 파일은 test 패키지의 resource 경로에 넣어두고, getResource().getPath() 메서드를 통해 파일 경로를 읽어 멤버필드에 넣어주었다.

 - numbers.txt 파일 내용은 아래와 같다.

number.txt

1
2
3
4
5

 

3.5.1. 테스트 코드

public class PracticeCalculatorTest {

    private Calculator2 calculator;
    private String filePath;

    @BeforeEach
    void setUp(){
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(ObjFactory.class);
        this.calculator = applicationContext.getBean("calculator2", Calculator2.class);
        this.filePath = getClass().getResource("/numbers.txt").getPath();
    }
    @Test
    public void sumOfNumbers() throws IOException {

        int sum = calculator.calcSum(filePath);

        assertThat(sum).isEqualTo(15);
    }

    @Test
    public void multiplyOfNumbers() throws IOException {

        int sum = calculator.calcMultiply(filePath);

        assertThat(sum).isEqualTo(120);
    }
}

 

3.5.5. 테스트 결과

 - 더하기는 문제가 없으나, 곱하기에서 실패한다. 원인은 템플릿 메서드인  withCalculatorCallback에 있었다. 최초 res 값이 0일 경우 어떤 값을 곱해도 0이 나오기 때문이다. 곱하기일 경우 변하는 값에 최초 res값이 있다는 사실을 놓쳤다.

1차 테스트 결과

 

3.6. 2차 코드수정

 - 최초 res 값을 처리할 부분을 생각해보자. 최초 값을 CalculatorCallback의 파라미터로 넣는 방법이 있고, withCalculatorCallback의 파라미터로 넣는 방법이 있다.

- CalculatorCallback는 String 형태로 들어온 line 값과 두번째 파라미터인 현재 결과 값을 연산하는 메서드이다. 만약 이 부분에 최초 응답 값이 있을 경우 분기하는 로직이 들어갈 것으로 예상되기에 withCalculatorCallback 메서드의 파라미터로 넘기는 방법을 선택했다.

 

3.6.1. Calculator2.java

public class Calculator2 {

    private BufferedReaderTemplate bufferedReaderTemplate;

    public void setBufferedReaderTemplate(BufferedReaderTemplate bufferedReaderTemplate){
        this.bufferedReaderTemplate = bufferedReaderTemplate;
    }

    public Integer calcSum(String path) throws IOException {

        return bufferedReaderTemplate.withCalculatorCallback(path, new CalculatorCallback() {
            @Override
            public Integer call(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        },0); // 최초 값인 0 추가
    }
    public Integer calcMultiply(String path) throws IOException {

		// 최초 값인 1 추가
        return bufferedReaderTemplate.withCalculatorCallback(path, (line, value) -> value * Integer.valueOf(line),1);
    }
}

 - withCalculatorCallback에 최초 값에 대한 파라미터를 전달하였다. 더하기는 0, 곱하기는 1이다.

 

3.6.2. BufferedReaderTemplate.java

public class BufferedReaderTemplate {
	
    // initVal 추가
    public Integer withCalculatorCallback(String path, CalculatorCallback callback, Integer initVal) throws IOException {
        BufferedReader br = null ;
        try{
            br = new BufferedReader(new FileReader(path));
            String line;

            Integer res = initVal; // initVal을 res값에 대입
            while((line = br.readLine()) != null) {
                res = callback.call(line, res);
            }

            return res;
        }catch(FileNotFoundException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(br != null){
                br.close();
            }
        }

    }
}

 - 메서드에 대한 시그니처를 수정하고, res = initVal을 넣어 최초 값을 설정해주었다.

 

3.7. 2차 테스트

 

3.7.1. 테스트 결과

 - 더하기와 곱하기 테스트가 성공함을 확인할 수 있다.

2차 테스트 결과

 


4. 회고

 토비의 스프링 3장 템플릿 부분 중 템플릿/콜백 패턴의 사용을 도출해내는 과정이 잘 이해되지 않아 코드 분석 및 적용 패턴 도출 과정을 근거와 함께 생각해보며 구현해보았다. 패턴 적용이 완료된 코드를 한번 작성해본터라 그 코드를 따라가는 느낌도 없지않아 있었지만, 코드를 분석하고, 패턴을 사용하는 근거를 생각하는 과정을 통해 템플릿/패턴을 사용하는 이유를 보다 잘 이해하게 되었다.

 스프링에서 제공하는 많은 클래스들은 이러한 패턴들의 조합으로 이루어져있다고 한다. 여러 패턴들을 소비해보고 그 원리를 이해하려고 노력하는 게 스프링을 제대로 사용하는 것이 아닐까라는 생각이 문뜩 드는 하루였다. ㅎㅎ

반응형
반응형

1. 개요

 @Column 옵션 중 nullable = false를 적용하는 순간 ConstraintViolationException: could not execute statement 예외가 발생하였다.


2. 배경

 nullable = false 옵션은 null은 허용하지 않는다는 의미이다. null을 허용할 경우 연관관계가 맺어진 테이블 조회 시 left outer join을 하게되어 성능상 이슈를 가져올 수 있으나, 허용하지 않을 경우 inner join을 하여 성능상 이점을 가져올 수 있기에 해당 옵션을 추가하였다. 그런데 옵션을 추가하자마자 아래와 같은 에러가 발생하였다. null을 허용하지 않는 컬럼에 null이 들어가 SQL을 실행할 수 없다는 에러였다.

 

ConstraintViolationException 예외 발생


3. 원인 분석

 CLASS_ID는 Student 엔티티와 다대일 관계에 있는 Classroom 엔티티의 Join Column이다. 메인 메서드에서 Student 엔티티를 생성한 후 Classroom 설정을 하지 않았는지 확인해보았다.

 

1) main 메서드

 - main 메서드에서는 Student의 수정자 메서드와 classroom의 addStudent 메서드를 통해 양방향으로 주입 가능하도록 메서드를 구현했었다. 어쨌든 student1과 student2는 이를 사용하여 classroom을 set하는 것을 확인하였다. 그후 메서드 구현부분을 차례로 확인해보았다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);

        Student student2 = new Student();
        student2.setName("홍길동");
        em.persist(student2);

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom); // student 1에 classroom set
        classroom.addStudent(student2); // student 2에 classroom set

        List<Student> list = classroom.getStudentList();
        for(Student student : list){
            System.out.println("========================="+student.getName());
        }

 

2) Student.java

@Entity
@Getter
@Setter
public class Student {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "CLASS_ID", nullable = false)
    private Classroom classroom;

    public void setClassroom(Classroom classroom){
        if(this.classroom != null){
            this.classroom.getStudentList().remove(this);
        }
        this.classroom = classroom;
        classroom.getStudentList().add(this);
    }
}

 Student의 경우 setClassroom 시 Student.classroom에 set 함과 동시에 객체 상태의 classroom에도 Student를 반영해주기 위해 classroom.getStudentList().add(this)구문을 추가해주었다. 어쨌든 classroom이 'this.classroom = classroom' 코드에 의해 설정되므로 여기서 null을 발생시키진 않는 것으로 판단하였다.

 

3) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

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

    private String name;

    @OneToMany(mappedBy = "classroom")
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 addStudent 메서드도 studentList에 대한 순수 객체 상태도 엔티티와 동일하게 가져가기 위해 요청으로 들어온 Student가 속한 classroom이 있을 경우, 해당 classroom의 StudentList에서 요청으로 들어온 student를 remove해주는 코드를 넣었다. 중요한 부분은 Student 엔티티의 classroom이 set 되냐 마냐인데 student.setClassroom(this)를 통해 요청으로 들어온 Student 엔티티에도 classroom이 설정되는 것을 확인하였다.

 

 결론은 문제의 원인이 될만한 곳을 발견하지 못했다. 디버깅을 해봐도 student1과 student2 엔티티 모두 classroom을 갖고 있는 것을 확인하였다. 그럼 뭐가 문제일까를 고민하다가 자연스레 JPA의 동작원리를 생각해보았다.

 

 엔티티 매니저의 persist 메서드를 사용하면 엔티티 매니저가 관리하는 영속성 컨텍스트로 엔티티가 관리된다. 동시에 1차 캐시에 엔티티 id와 엔티티가 key, value 형식으로 저장되고 쓰기지연 SQL에 엔티티에 대한 insert 쿼리가 생성되어 쌓인다. 후에 EntityTransaction혹은 EntityManager에 의해 flush() 메서드가 호출되면 쓰기지연 SQL에 있는 쿼리가 실제 DB에서 실행되어 데이터가 저장된다.

 여기서 쓰기지연 SQL에 들어간 insert쿼리가 어떤 순서로 실행됐는지를 확인해보기 위해 실행된 쿼리를 확인해보았다.

예외 발생 쿼리

 그 결과 가장 먼저 실행된 쿼리는 student에 대한 INSERT 쿼리였다. 이 시점에서는 CLASS_ID가 할당되지 않는다. 왜냐하면 student에 classroom을 set 하기 전에 em.persist를 호출했고, 이때 쓰기지연 SQL에는 CLASS_ID가 할당되지 않은 Student의 INSERT 쿼리가 적재되기 때문이다.

 더 나아가면 아래 Student 엔티티에 대한 setClassroom을 하는 시점에 UPDATE 쿼리가 적재되게 된다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);
        
        ..
        
        student1.setClassroom(classroom)

 


4. 해결방안

 student1, 2에 classroom을 set한 이후 em.persist를 호출하는 쪽으로 em.persist 메서드 위치를 변경하였다. 이제 Student에 대한 쓰기지연 SQL에는 classroom이 할당된 상태의 INSERT 쿼리가 나가게 되었다. 

	Student student1 = new Student();
        student1.setName("심심심");
        
        Student student2 = new Student();
        student2.setName("홍길동");
        
        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom);
        classroom.addStudent(student2);

        em.persist(student1);
        em.persist(student2);

  

 결론은 em.persist의 순서였는데, 순서로 인한 이런 에러는 추후에도 발생할 여지가 충분히 있다고 생각되었다. 만약 널을 허용하지 않는 옵션이 일반 Column이었다면 실제로 set을 하지 않아 발생한 것이므로 바로 원인을 찾았을 것이다. 하지만 위 케이스의 경우 classroom이라는 부모 엔티티를 수정자 메서드를 통해 설정해줬음에도 불구하고 에러가 발생하기 때문에 원인 찾기가 힘들 수 있다고 생각되었다.

 어쨌든간에 null을 허용하지 않는 자식, 부모 관계에 있는 엔티티를 자식, 부모 순서로 persist 하여 발생하였는데, 부모 엔티티만 persist해도 연관된 엔티티들이 함께 영속상태로 들어가는 영속성 전이를 사용하면 더 깔끔하게 해결 가능하다.

 부모 엔티티의 연관관계 어노테이션에 cascade 옵션을 PERSIST로 설정하면 되며, 자식객체를 persist하는 코드 생략과 앞서 발생했던 문제들을 예방하는 효과를 얻을 수 있었다.

 참고로 영속성 전이는 em.persist()를 실행할 때가 아닌 flush()를 호출할 때 발생한다.

 

1) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

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

    private String name;

    @OneToMany(mappedBy = "classroom", cascade = CascadeType.PERSIST) // 영속성 전이 옵션
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 

2) main 메서드

	Student student1 = new Student();
        student1.setName("심심심");

        Student student2 = new Student();
        student2.setName("홍길동");

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        
        student1.setClassroom(classroom); // 연관관계 추가
        classroom.addStudent(student2); // 연관관계 추가
        
        em.persist(classroom); // classroom 영속화
        
        tx.commit();

5. 회고

 코드상 원인은 em.persist의 순서였지만, 근본 원인은 JPA에 대한 매커니즘을 생각하지 않고 코드를 작성한 나 자신이었다 :(  JPA는 프레임워크라는 사실을 잊지말자. 스프링도 마찬가지라고 생각하는데 프레임워크를 사용하기 위해서는 매커니즘과 그 성격을 이해하고 사용하는 것이 정말 중요하다고 다시한번 느끼게되었다.

반응형
반응형

1. 개요

 - JPQL의 내부, 외부, 세타 조인에 대해 알아보자.


2. 내부 조인

 - 연관 관계가 맺어진 엔티티들에 대한 Inner Join을 말한다.

 - JPQL 작성 시 INNER JOIN의 INNER 는 생략 가능하다.

SELECT m FROM Member m [INNER] JOIN m.team t

3. 외부 조인

 - 연관 관계가 맺어진 엔티티들에 대한 Left Outer Join을 말한다.

 - JPQL 작성 시 LEFT OUTER JOIN의 OUTER는 생략 가능하다.

SELECT m FROM Member m LEFT [OUTER] JOIN m.team t

4. 세타 조인

 - 엔티티들에 대한 조인을 말한다.

 - 연관 관계와 상관 없기에 엔티티 명을 정확히 기입해야 한다.

SELECT count(m) FROM Member m, Team t WHERE m.username = t.name

5. 조인 예제

 - 테스트를 위해 teamA와 teamB를 생성하였다. 멤버 0부터 9까지는 teamA, 멤버 10부터 19까지는 teamB에 속하도록 하였고, 멤버 20부터 29까지는 팀이 없도록 테스트 데이터를 세팅하였다. 데이터 셋 코드는 다음과 같다.

    Team teamA = new Team();
    teamA.setName("teamA");
    em.persist(teamA);

    Team teamB = new Team();
    teamB.setName("teamB");
    em.persist(teamB);

    for(int i =0 ; i< 10 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        member.changeTeam(teamA);
        em.persist(member);
    }

    for(int i =10 ; i< 20 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        member.changeTeam(teamB);
        em.persist(member);
    }

    for(int i =20 ; i< 30 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        em.persist(member);
    }

 

5.1. 내부 조인 예제

 - 아래의 JPQL을 실행하여 내부 조인 시 team이 존재하는 Member 정보만을 리턴한다.

    String innerJoinQuery = "select m from Member m inner join m.team t";
    List<Member> list = em.createQuery(innerJoinQuery,Member.class)
    	.getResultList();

    for(Member member : list){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

	// 출력 결과
    Member{id=3, username='멤버0', age=0}
    Team{id=1, name='teamA'}
    Member{id=4, username='멤버1', age=1}
    Team{id=1, name='teamA'}
    ...
    Member{id=21, username='멤버18', age=18}
    Team{id=2, name='teamB'}
    Member{id=22, username='멤버19', age=19}
    Team{id=2, name='teamB'}

 

5.2. 외부 조인 예제

 - 아래의 JPQL을 실행하여 외부 조인 시 team이 존재하지 않는 Member 정보도 함께 리턴한다.

    String leftJoinQuery = "select m from Member m left join m.team t";
    List<Member> list2 = em.createQuery(leftJoinQuery,Member.class)
        .getResultList();

    for(Member member : list2){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

	// 출력 결과
    Member{id=3, username='멤버0', age=0}
    Team{id=1, name='teamA'}
    Member{id=4, username='멤버1', age=1}
    Team{id=1, name='teamA'}
    ...
    Member{id=31, username='멤버28', age=28}
    null
    Member{id=32, username='멤버29', age=29}
    null

 

5.3. 세타 조인 예제

 - 테스트를 위해 member 이름이 teamA인 멤버를 생성하였다. 실제 실행된 쿼리 확인 결과, 두 테이블을 크로스 조인 후 조건에 해당하는 값만을 조회한다.

    Member thetaMember = new Member();
    thetaMember.setUsername("teamA");
    thetaMember.changeTeam(teamA);
    em.persist(thetaMember);

    String thetaJoinQuery = "select m from Member m, Team t where m.username = t.name";
    List<Member> list3 = em.createQuery(thetaJoinQuery,Member.class)
        .getResultList();

    for(Member member : list3){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

    // 출력 결과
    Member{id=33, username='teamA', age=0}
    Team{id=1, name='teamA'}

6. 조인 대상 필터링

 - SQL에서 사용하던 on과 동일하게 사용한다. 내부 조인, 외부 조인도 동일한 방식으로 사용 가능하다.

    // 내부 조인에 대한 필터링 - 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인
    JPQL : SELECT m, t FROM Member m LEFT JOIN m.team t ON t.name = 'A'
    SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.team_id = t.id and t.name = 'A'

    // 외부 조인에 대한 필터링 - 회원의 이름과 팀 이름이 같은 대상 외부조인
    JPQL : SELECT m, t FROM Member m LEFT JOIN Team t ON m.username = t.name
    SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name

7. 회고

 - JPQL에서 사용하는 조인과 SQL에서 사용하는 조인은 조인의 개념만 잘 알고있다면 어렵지 않게 적용함을 알았다. 모든 조인에 세타 조인을 사용해도 되나, 크로스 조인으로 인한 성능 저하를 고려해봤을 때 테이블의 연관관계를 확실히 이해하고 그에 따른 조인 전략을 구상하는 게 중요함을 느꼈다.

 


8. 참고

 - JAVA ORM 표준 JPA 프로그래밍 - 김영한

반응형
반응형

1. 개요

 - 프로젝션과 JPA 페이징 API에 대한 개념을 정리한다.

 


2. 프로젝션이란?

 - SELECT 절에 조회할 대상을 지정하는 것을 말한다.

 - 조회된 대상은 모두 영속성 컨텍스트에서 관리된다.


3. 조회할 대상 (프로젝션 대상)

 - 조회할 대상은 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자, 등 기본 데이터 타입)이 있다. 각 타입의 의미는 예제를 통해 쉽게 이해 가능하다.

	SELECT m FROM Member m // Member 엔티티를 조회하는 엔티티 프로젝션

	SELEECT m.team FROM Member m // Member 엔티티와 관계를 맺고 있는 Team 엔티티를 조회하는 엔티티 프로젝션

	SELECT m.address FROM Member m // Member 엔티티에 임베디드 타입인 address를 조회하는 임베디드 타입 프로젝션

	SELECT m.username, m.age FROM Member m // 기본 데이터 타입들을 조회하는 스칼라 타입 프로젝션

4. 여러 값 조회하기

 - 한 로우에 여러 값을 조회한다는 것은 반환 값이 명확하지 않다는 뜻이다. 예를들어 위의 스칼라 타입 프로젝션 같은 경우 username이라는 String과, age라는 int형을 조회하는데 두 값을 한번에 받을 수 있는 타입이 존재하지 않기 때문이다.

 - 이처럼 반환 값이 명확하지 않을 경우 1차적으로 Query 타입으로 조회하게 된다.

 - getResultList 사용 시에는 결과를 ArrayList<Object>로, getSingleResult 사용 시에는 결과를 Object 형태로 리턴받는다.

 - 조회한 로우의 각 속성들을 조회하고 싶다면 Object를 Object[]로 캐스팅하여 result[0], result[1]과 같이 조회해야한다.

Query.getResultList() 결과

 - 이러한 매커니즘을 활용하여 여러 값을 조회하는 방법은 크게 3가지가 있다.

 1) 앞서 언급한 Query 타입으로 조회하는 방법

 2) 반환 값을 Object[]로 명확히 하여 TypeQuery 타입으로 조회하는 방법

 3) new 명령어로 조회하는 방법

 

4.1. Query 타입 조회

// Query 타입 조회 방법
	Object result2 = 
		em.createQuery("select m.username, m.id from Member m where m.id = 1L").getSingleResult();

	Object[] objects = (Object[])result2;
	System.out.println(objects[0]); // username
	System.out.println(objects[1]); // id

 - Query 타입으로 조회 시 Object로 받게 되며, 각 속성에 접근하기 위해 반드시 Object 배열로 캐스팅해야하고 배열 번호로 접근해야 하는 단점이 있다.

 

4.2. TypedQuery 타입 조회(= Object[] 조회)

	// TypedQuery 타입 조회 방법 == Object[] 타입 조회
	Object[] result3 = 
		em.createQuery("select m.username, m.id from Member m where m.id = 1L",Object[].class).getSingleResult();
        System.out.println(result3[0]); // username
 	System.out.println(result3[1]); // id

  - TypedQuery 타입으로 조회 시 createQuery 메서드에서 Object[] 로 캐스팅 작업을 먼저 하기때문에 추가적인 캐스팅 코드를 작성하지 않는다. 하지만 배열 번호를 통한 접근은 그리 좋지 않아보인다.

 

4.3. new 생성자를 통한 조회

	// new 생성자를 통한 조회
	MemberDto result3 = 
		em.createQuery("select new jqpl.MemberDto(m.id, m.username) from Member m where m.id = 1L",MemberDto.class).getSingleResult();
        System.out.println(result3.getId()); // username
        System.out.println(result3.getUsername()); // id

	
	...
	// MemberDto.java
	public class MemberDto {

    	private Long id;

    	private String username;

    	public MemberDto(Long id, String username) {
        	this.id = id;
        	this.username = username;
    	}
		...
		// getter, setter
	}

 - new 생성자를 사용할 경우 JPQL의 조회 형태에 맞는 생성자를 가진 DTO 클래스를 생성해야 한다.

 - 배열의 번호를 통한 접근이 아니고 캐스팅 코드가 없는 깔끔한 조회 방식이나 JPQL에 DTO에 대한 풀 패키지 경로를 입력해야하는 단점이 있다. 하지만 이는 쿼리 DSL에서 극복되었으므로 이 방식을 사용하는 것이 권장된다.

 


5. 페이징 API

 - JPA는 페이징 처리 시 setFirstResult, setMaxResults라는 메서드로 추상화한다.

setFirstResult(int startPosition) // startPosition : 조회할 시작 위치
setMaxResults(int maxResult) // maxResult : 조회할 데이터 수

 - 내부적으로 동작되는 쿼리는 JPA에 설정한 Database 방언에 맞게 실행된다.

 


6. 페이징 API 예제

 - 나이 오름차순으로 조회한 멤버 리스트들 중 0번째부터 시작해 10개의 데이터를 조회하는 예제이다. setFirstResult(0), set MaxResults(10)으로 하여 간단히 조회 가능하다.

    for(int i =0 ; i< 100 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        em.persist(member);
    }

	// 0번째부터 시작해서 10개의 데이터를 조회한다.
    List<Member> list = 
		em.createQuery("select m from Member m order by m.age asc",Member.class)
            .setFirstResult(0)
            .setMaxResults(10)
            .getResultList();

    for(Member member : list){
        System.out.println(member.toString());
    }

	//실행결과
	Member{id=1, username='멤버0', age=0}
	Member{id=2, username='멤버1', age=1}
	Member{id=3, username='멤버2', age=2}
	Member{id=4, username='멤버3', age=3}
	Member{id=5, username='멤버4', age=4}
	Member{id=6, username='멤버5', age=5}
	Member{id=7, username='멤버6', age=6}
	Member{id=8, username='멤버7', age=7}
	Member{id=9, username='멤버8', age=8}
	Member{id=10, username='멤버9', age=9}

7. 참고

 - JAVA ORM 표준 JPA 프로그래밍 - 김영한

반응형
반응형

1. 개요

 - JPQL의 개념, 문법에 대해 알아보자.

 


2. 개념

 - JPQL은 Java Persistence Query Language의 약자로 자바에서 사용하는 객체 지향 쿼리 언어이다. 객체 지향이라는 말에 맞게 쿼리를 작성할 때 테이블을 대상으로 작성하는게 아닌 엔티티를 대상으로 작성한다.

 - JPQL은 결국에 SQL로 변환되며, 특정 데이터베이스에 의존하지 않는다.

 


3. JPQL 기본 문법

 - JPQL은 SQL 문법과 거의 동일하지만 다른 점이 몇가지 있다.

 1) 엔티티와 속성은 대소문자를 구분하므로 엔티티 클래스에 정의한 대로 사용해야 한다.

 2) SELECT, FROM, WHERE 과 같은 JPQL 키워드는 대소문자를 구분하지 않는다.

 3) SQL에서는 테이블 명을 입력했다면 JPQL에서는 테이블 명이 아닌 엔티티 명을 사용해야 한다.

 4) 별칭이 필수이다. (ex. select m from Member m)

 


4. TypeQuery와 Query

 - TypeQuery는 반환 값이 명확할 때, Query는 불명확할 때 사용한다.

	// 반환 타입이 Member 클래스로 명확
	TypedQuery<Member> typedQuery = 
		em.createQuery("select m from Member m", Member.class);
    
	// 반환 타입이 username, id로 불명확
	Query query = 
		em.createQuery("select m.username, m.id from Member m");

 


5. 결과 조회

 - 결과 조회 시 Query 및 TypeQuery의 메서드인 getResultList(), getSingleResult() 중 하나를 사용한다.

 

5.1. getResultList

 - 결과가 하나 이상일 때 리스트를 반환하고, 결과가 없을 때 빈 리스트를 반환한다.

 - null 체크 할 필요가 없다.

 

5.2. getSingleResult

 - 결과가 정확히 하나일 때 단일 객체를 반환하고, 없을 때 NoResultException, 둘 이상일 때 NonUniqueResultException 예외를 발생시킨다.

 - 이처럼 예외가 발생할 수 있기에 try, catch를 통한 핸들링이 필요하다. Spring Data JPA에서는 값이 없을 경우 예외를 발생시키는 부분을 개선하였으며 null 혹은 Optional 객체를 리턴하도록 구현되어 있다.

	TypedQuery<Member> list = 
		em.createQuery("select m from Member m", Member.class);

	// 결과가 하나 이상일 것을 예상하여 getResultList를 통해 반환받음
	List<Member> memberList = list.getResultList();
    

	TypedQuery<Member> single = 
		em.createQuery("select m from Member m where m.id = 1L", Member.class);

	// 결과가 하나일 것을 예상하여 getSingleResult를 통해 반환받음.
	// 만약 결과가 2개 이상이거나 없을 경우 예외가 발생함.
    Member singleMember = single.getSingleResult();

 


6. 파라미터 바인딩

 - 이름 기준과 위치 기준으로 바인딩 하는 방법이 있다.

 - 이름 기준으로 사용 시 쿼리에 ":" 구문을 사용하며, 위치 기준으로 사용 시 "?번호" 구문을 사용한다.

 - 위치 기반 바인딩은 쿼리 중간에 조건이 하나 더 추가될 경우 쿼리에 사용한 번호가 밀려 에러를 유발할 수 있다. 이에따라 위치 기반보다는 이름 기준 바인딩이 권장된다.

 

 * TypedQuery와 Query는 메서드 체이닝을 지원하기에 한번에 처리 가능하다.

	// 이름 기준 파라미터 바인딩
	TypedQuery<Member> query 
		= em.createQuery("select m from Member m where m.id = :id and m.username = :username", Member.class);
    query.setParameter("username", "심승경");
    query.setParameter("id", 1L);

    Member singleMember = query.getSingleResult();

	// 메서드 체이닝 방식
	Member singleResult 
		= em.createQuery("select m from Member m where m.id = :id and m.username = :username", Member.class)
            .setParameter("username", "심승경")
            .setParameter("id", 1L)
            .getSingleResult();
	
	// 위치 기준 파라미터 바인딩
	singleResult = 
		em.createQuery("select m from Member m where m.id = ?1 and m.username = ?2", Member.class)
            .setParameter(1, 1L)
            .setParameter(2, "심승경")
            .getSingleResult();

7. 참고

 - 자바 ORM 표준 JPA 프로그래밍 - 김영한

반응형

+ Recent posts