알고리즘 문제를 풀다 LinkedHashMap 에 대해 확인하던 중, LRU cache 를 구현하는데 적합한 자료구조라는 것을 알게되었다. 아래 내용은 jdk 8 공식 홈페이지의 LinkedHashMap 일부 내용이다.

This kind of map is well-suited to building LRU caches.

LinkedHashMap 의 내부 로직이 어떻게 되길래 LRU cache 구현에 적합한지 궁금해서 찾아보게 되었다. 참고로 LinkedHashMap 은 jdk 1.4 에서 최초 추가되었다.

 

공식 홈페이지의 내용을 계속 읽어보니 removeEldestEntry 메서드를 통해 새로운 데이터가 들어왔을 때 제거 정책을 어떻게 할지 정할 수 있다고 한다.

The removeEldestEntry(Map.Entry) method may be overridden to impose a policy for removing stale mappings automatically when new mappings are added to the map.

자세한 로직을 확인하기 위해 최근 가장 많이 사용하는 버전인 jdk 11 내부를 분석해봤다.

 

LinkedHashMap 은 HashMap 을 상속하고 Map 을 구현한다. 따라서 내부 데이터를 저장하는 구조와 기존적인 데이터 저장, 삭제 같은 메서드는 HashMap 과 동일하다.  (LinkedHashMap 는 그외에 링크드 리스트 구조의 자료 형태를 유지하기 위한 변수와 메서드들이 존재한다.)

 

따라서 LinkedHashMap 에서 put 을 하게되면 HashMap 내부 putVal 메서드를 통해 데이터가 적재된다. 이때 putVal 메서드 내부 마지막에서 afterNodeInsertion 메서드를 호출한다. 이때 evict 이라는 변수를 매개변수로 전달하게 되는데 일반적으로 true 상태가 디폴트 값이다.

 

afterNodeInsertion 메서드는 LinkedHashMap 에서 다음과 같이 구현되어 있다.

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

removeEldestEntry 메서드의 기준에 따라 첫번째 데이터를 삭제하게 된다. removeEldestEntry는 오버라이딩하지 않으면 기본적으로 false 를 리턴하게 하여 기존의 데이터를 삭제하지 않도록 설계되어 있다.

따라서 LRU cache 로서 사용하고 싶다면 다음과 같이 조건을 주어서 삭제 정책을 적용해주어야 한다.

class ExpiringCache {
    private Map<String,Entry> map;
    private int MAX_ENTRIES = 200;
    
    ...
    
    ExpiringCache(long millisUntilExpiration) {
        this.millisUntilExpiration = millisUntilExpiration;
        map = new LinkedHashMap<>() {
            protected boolean removeEldestEntry(Map.Entry<String,Entry> eldest) {
              	return size() > MAX_ENTRIES;
            }
      	};
    }
}

자세한 이해를 돕기위해 실제 오픈소스에서 사용중인 예시를 가져왔다. 위 예시는 현재 LinkedHashMap 내부 데이터 크기가 MAX_ENTRIES 보다 크면 가장 첫번째 데이터를 삭제하는 예시로 java.io 의 ExpiringCacha 클래스이다.

 

그런데 아직 LinkedHashMap 가 LRU cache 로서 적합한지 확실하게 알 수 없다 왜냐면 가장 마지막으로 사용된 데이터가 삭제되는지 확인이 필요하기 때문이다. afterNodeInsertion 에서 맵의 첫번째 데이터를 삭제하는 것을 확인하였는데 첫번째 데이터는 어떻게 정해지는지 확인해보겠다.

 

putVal 메서드를 사용하여 데이터를 적재 할 때 신규 key로 데이터를 만드는 상황과 신규 key와 기존 key가 충돌나는 상황이 있다.

 

  • 신규 key로 데이터를 만드는 상황

먼저 데이터가 기존에 없으면 newNode 메서드로 생성을 하는데 이때 LinkedHashMap 에서는 linkNodeLast 라는 메서드를 추가로 호출하여 만들어진 데이터를 tail 값을 통해 리스트 가장 맨 끝으로 연결한다.

// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}

 

  • 신규 key와 기존 key 충돌 상황

데이터 충돌이 발생하였을 때는 afterNodeAccess 메서드를 추가로 호출한다. 해당 메서드는 매개변수로 들어온 Node 데이터를 리스트 가장 맨 끝으로 연결하고 있다.

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}

이런 동작에 따라 LinkedHashMap 자료구조 내부 리스트에서 가장 앞 데이터는 가장 오래된 데이터임을 확인할 수 있다.

 

결과적으로 put 을 했을 때 캐시가 허용 범위를 가득차면 가장 오래된 데이터를 삭제하고 기존 데이터에 대해 갱신이 되었을 때 최신 데이터로 위치를 조정하고 있어 LRU(Least Recently Used) cache 를 구현하는데 적합한 자료구조임을 확인할 수 있다.

 

참고

- https://docs.oracle.com/javase/8/docs/api/java/util/LinkedHashMap.html 

 

 

 

queryDsl 초기 설정 및 테스트 코드 작성

이번 게시글에서는 querydsl 을 springboot 프로젝트에 추가하고 간단한 테스트 코드를 통해 querydsl 사용 방법을 소개한다.

먼저 프로젝트 초기 build.gradle 에 아래의 의존성 코드를 추가해준다.

build.gradle

buildscript{
    ext {
        ...
        querydslPluginVersion = '1.0.10' // 플러그인 버전
    }

    repositories {
        ...
        maven { url "https://plugins.gradle.org/m2/" } // 플러그인 저장소
    }

    dependencies {
        // querydsl 플러그인 의존성 등록
        classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:${querydslPluginVersion}") 
    }
}

dependencies {
    ...
    // queryDsl 의존성 추가
    compile("com.querydsl:querydsl-core:4.2.1")
    compile("com.querydsl:querydsl-apt:4.2.1")
    compile("com.querydsl:querydsl-jpa:4.2.1")
    compile("com.querydsl:querydsl-collections:4.2.1")
    ...
    annotationProcessor("com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa") // querydsl JPAAnnotationProcessor 사용 지정
    annotationProcessor("jakarta.persistence:jakarta.persistence-api") // java.lang.NoClassDefFoundError(javax.annotation.Entity) 발생 대응
    annotationProcessor("jakarta.annotation:jakarta.annotation-api") // java.lang.NoClassDefFoundError (javax.annotation.Generated) 발생 대응

}

// querydsl 적용
apply plugin: "com.ewerk.gradle.plugins.querydsl" // Plugin 적용
def querydslSrcDir = 'src/main/generated' // QClass 생성 위치

querydsl {
    library = "com.querydsl:querydsl-apt"
    jpa = true
    querydslSourcesDir = querydslSrcDir
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

sourceSets {
    main {
        java {
            srcDirs = ['src/main/java', querydslSrcDir]
        }
    }
}

application.properties

실행 과정에서 "The bean 'jpaAuditingHandler', defined in null, could not be registered. A bean with that name has already been defined in null and overriding is disabled." 이런 에러 메시지가 발생하면 application.properties 에 다음 내용을 추가해주면 된다.

spring.main.allow-bean-definition-overriding=true

이후 QClass를 생성하려면 아래의 사진처럼 Gradle Project에서 compileJava를 실행시켜주면 src/main/generated 에 QClass가 생성된다.

Java Config

이제는 querydslconfig 클래스를 생성해준다.

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@EnableJpaAuditing
@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

이후 프로젝트 내부 다른 자바 클래스 어디에서도 JPAQueryFactory를 주입 받아 querydsl을 사용할 수 있다.

querydsl 적용하기

실제 프로젝트 내부 User 객체 클래스에 적용하려 한다. 먼저 User 클래스와 UserRepository 를 추가해준다.

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

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Builder
    public User(String name, String email, String picture, Role role) {
        this.name = name;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    ...
public interface UserRepository extends JpaRepository<User, Long> {

}

이제 querydsl를 지원하는 UserRepositorySupport 클래스를 추가해준다.

package com.jungwoo.book.springboot.domain.user;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;

import static com.jungwoo.book.springboot.domain.user.QUser.user;

import java.util.List;

@Repository
public class UserRepositorySupport extends QuerydslRepositorySupport {
    private final JPAQueryFactory queryFactory;

    public UserRepositorySupport(JPAQueryFactory queryFactory) {
        super(User.class);
        this.queryFactory = queryFactory;
    }

    public List<User> findByName(String name) {
        return queryFactory
                .selectFrom(user)
                .where(user.name.eq(name))
                .fetch();
    }

}

이때 findByName 함수에서 user를 인식하지 못하는 데, 아래의 방법을 통해 querydsl에서 QClass로 생성된 user를 인식하도록 해준다.

아까와 마찬가지로 아래의 사진처럼 gradle project 에 compileQuerydsl 을 눌러서 인식하도록 할수 있다.

querydsl 테스트 코드 작성하기

아래의 테스트 코드를 통해 기능이 제대로 되는지 확인할수 있다.

...
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

import static com.jungwoo.book.springboot.domain.user.Role.USER;
import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserRepositorySupport userRepositorySupport;

    @After
    public void down() throws Exception {
        userRepository.deleteAllInBatch();
    }

    @Test
    public void querydsl_기본_기능_확인() {
        //given
        String name = "jungwoo";
        String address = "jungwoo@gmail.com";
        String picture = "???";
        Role role = USER;

        userRepository.save(User.builder()
                                .name(name)
                                .email(address)
                                .picture(picture)
                                .role(role)
                                .build());

        //when
        List<User> result = userRepositorySupport.findByName(name);

        //then
        assertThat(result.get(0).getEmail().equals(address));
    }
}

 

ref

+ Recent posts