@Bean
fun redisConnectionFactory(): RedisConnectionFactory =
LettuceConnectionFactory(RedisStandaloneConfiguration("server", 6379))
@Bean
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, String> {
val template = RedisTemplate<String, String>()
template.setConnectionFactory(redisConnectionFactory)
return template
}
server 위치에 레디스 서버 주소를 기입하여 RedisConnectionFactory를 만듭니다.
추후 RedisTemplate에서 ConnectionFactory를 지정할 때 사용해 주어야 합니다.
AWS Elasticache를 사용하는 경우 “server” 위치에 reader가 아닌 기본 엔드포인트를 기재합니다.
read 목적인 경우 reader로 사용해도 되기는 하지만 write도 같이 사용하기 위해서는 기본 엔드포인트를 사용합니다
Simple Lettuce RW
@Repository
class StudentRedisRepository(
private val redisTemplate: RedisTemplate<String, String>,
private val objectMapper: ObjectMapper,
) {
fun save(student: Student) {
println("deserialize: ${objectMapper.writeValueAsString(student)}")
redisTemplate.opsForValue().set(student.id, objectMapper.writeValueAsString(student))
}
fun getStudent(id: String): Student {
val studentJson = redisTemplate.opsForValue().get(id)
?: throw IllegalArgumentException("존재하지 않는 학생 정보입니다.")
// Serialize 하여 데이터 로딩
return objectMapper.readValue<Student>(studentJson)
}
}
데이터를 저장할 때는 opsForValue()의 set, 불러올 때는 get 메소드 사용
opsForValue뿐 아니라 opsForList, opsForSet 등 다양한 연산 제공
Redis의 K-V 쌍에 Key에는 student의 id, Value에는 Student 객체를 저장
동시에 하나의 데이터에 여러명의 사용자가 접근하는 경우, 문제가 생길 여지가 있습니다. 이러한 문제의 대표적인 예시가 싱글톤에서 상태를 가지고 있는 경우입니다.
하나의 싱글톤 인스턴스를 동시에 여러 사용자가 상태를 변경하게 되면, 정합성이 맞지 않는 경우가 존재합니다. 다음 예시를 살펴보겠습니다.
class Wallet {
public static final Wallet INSTANCE = new Wallet();
private long bill;
private Wallet() {
this.bill = 0;
}
public void putInto(long bill) {
this.bill += bill;
}
}
두명의 사용자가 동시에 각각 putInto 메소드를 실행시킨다고 가정해 봅시다. 결과적으로 bill 필드의 값은 두 사용자의 putInto 메소드로 인한 변화중 하나만 적용되게 될 것입니다. 왜냐하면 따로 동기화 처리가 되어있지 않기 때문에 메소드를 실행시키는 시점엔 같은 필드값을 읽어들이기 때문입니다.
테스트
그렇다면, 이러한 테스트는 어떻게 해볼 수 있을까요? 두개의 쓰레드를 동시에 putInto() 메소드를 실행하도록 하면 될 것 같습니다. 코드는 다음과 같습니다.
예상과는 다르게, 3000원이 나오지 않고 0원이 나오는 문제가 발생했습니다. 무엇이 문제였을까요? 테스트에서 assertThat을 호출하는 시점이 쓰레드의 작업이 모두 끝난 시점이 아닐수도 있기 때문입니다. 따라서 쓰레드의 작업이 모두 끝난 다음에 테스트를 하도록 바꿔주어야 합니다.
그렇다면 이러한 문제는 어떻게 해결할 수 있을까요? 이 때 사용하는 것이 CountdownLatch입니다. CountdownLatch의 countDown() 메서드와 await() 메서드를 사용하여 위의 문제를 간단하게 해결할 수 있습니다.
CountdownLatch
생성자에 원하는 count를 명시하고, countDown() 메서드로 count를 하나씩 감소시킵니다. await() 메서드는 CountdownLatch가 0이 되기만을 기다리는 메소드입니다. 따라서 각각의 쓰레드에서 countdownLatch를 하나씩 감소시키고, 테스트 쓰레드(Test worker)에서는 countdownLatch가 0이 되기를 기다리도록 작성하면 될 것 같습니다.
그럼 CountdownLatch를 사용하여 원하는 결과가 나오도록 테스트를 수정해 보겠습니다.
JPAQueryFactory의 경우는 따로 Configuration에서 Bean으로 등록하여 사용하겠습니다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Autowired vs PersistenceContext
위 예제에서 보면 EntityManager를 주입받아 줄 때, Autowired가 아닌 다른 어노테이션으로 주입을 받는 것을 볼 수 있습니다. 다른 사람들의 코드를 보니 PersistenceContext로 주입을 받기는 하는데.. 개인적으로 생각해봤을 때는 Autowired와 다를게 없다고 생각해서 차이점을 좀 찾아보았습니다.
Autowired는 스프링 빈을 주입받는데 사용하는 어노테이션입니다.
PersistenceContext는 JPA 스펙에서 영속성 컨텍스트를 주입하는 표준 어노테이션입니다.
테스트해본 결과, 둘 다 정상적으로 동작합니다. 아무래도 QueryDsl은 JPA와 연관이 높아 보이니 이번 예제에서는 PersistenceContext로 주입받겠습니다.
데이터 가져오기 예시
jpaQueryFactory를 의존성 주입받아 줍니다.
예시로는 이름으로 Professor를 조회해 옵니다.
이 때, 일반 클래스가 아닌 사전에 만들어 두었던 Q 클래스를 사용하여 조회하거나 조건을 명시합니다.
참고 사항
JPAQuery vs JPAQueryFactory
개인적인 생각으로는.. 취향차인거 같습니다. 성능은 차이가 없다고 합니다.
실제로 JPAQueryFactory 내부를 들어가도.. JPAQuery를 사용하고 있습니다. 직접 만드는것과 Factory를 거쳐서 만드는 것의 차이입니다.
실제로 두 방법을 모두 사용해서 작성해 보겠습니다.
@BeforeEach
void setUp() {
professorRepository.save(new Professor(null, "덕배"));
}
@Test
@DisplayName("한명의 professor를 조회한다.")
void queryDslNormalSelect() {
QProfessor professor = QProfessor.professor;
JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager);
final Professor targetProfessor = queryFactory.selectFrom(professor)
.fetchOne();
System.out.println(targetProfessor.getName());
assertThat(targetProfessor.getName()).isEqualTo("덕배");
}
@Test
@DisplayName("vs JPAQuery")
void JPAQueryNormalSelect() {
JPAQuery<Professor> jpaQuery = new JPAQuery<>(entityManager);
QProfessor professor = QProfessor.professor;
final Professor professor1 = jpaQuery.select(professor)
.from(professor)
.fetchOne();
assertThat(professor1.getName()).isEqualTo("덕배");
}
두 방법 모두 정상적으로 동작하는걸 볼 수 있습니다. 단, QueryDSL을 사용하는 레포지토리가 따로 추가되는 문제점이 생겼습니다. 하나의 레포지토리로 QueryDSL로 작성한 기능 + JpaRepository의 기능을 모두 제공할 방법은 없을까요?
레포지토리가 여러개 생기는 문제점 해결
하나는 QueryDSL 전용 레포지토리, 하나는 Spring Data JPA 전용 레포지토리 총 두개의 레포지토리를 받아서 사용해야만 할까요?
이러한 문제를 해결하기 위해 Spring Data JPA에서는 커스텀 저장소를 구현할 수 있습니다.
public interface QueryDslProfessorRepository {
Professor findByName(String name);
}
해당 인터페이스를 구현한 구현 클래스를 만듭니다. 단, 클래스 이름은 인터페이스명 + impl로 설정해 두어야 합니다. Postfix 변경을 원한다면 @EnableJpaRepositories 어노테이션에서 설정할 수 있습니다.
@Repository
public class QueryDslProfessorRepositoryImpl implements QueryDslProfessorRepository {
private final JPAQueryFactory jpaQueryFactory;
public QueryDslProfessorRepositoryImpl(final JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Professor findByName(final String name) {
final Professor professor = jpaQueryFactory.selectFrom(QProfessor.professor)
.where(QProfessor.professor.name.eq(name))
.fetchOne();
return professor;
}
...
}
기존 레포지토리에서 사용자 정의 레포지토리 인터페이스를 상속받도록 처리합니다.
하나의 인터페이스로 QueryDSL 기능과 JpaRepository의 기능을 모두 사용할 수 있습니다.
@DisplayName("QueryDsl로 만들어진 쿼리를 테스트한다.")
@Test
void queryDslQueryTest() {
final Professor professorA = professorRepository.save(new Professor(null, "professorA"));
final Lecture lecture = lectureService.makeLecture(new Lecture(null, professorA, "lectureA"));
entityManager.flush();
entityManager.clear();
final Professor professor = professorRepository.findByName("professorA");
assertThat(professor.getName()).isEqualTo("professorA");
}
JPA를 처음 배울 때, JpaRepository라는 인터페이스를 상속받기만 해주면 기본적으로 제공하는 save나 findById같은 메소드들이 신기했던 경험이 있습니다. findById같은 경우는 기본적으로 제공한다고 치고.. 그럼 Query 어노테이션이 붙은 메소드들은 과연 어떻게 처리될까요?
지금부터는 정확한 내용이 아닐수도 있습니다 😅 중간중간 생략된 메소드들이 있을 수 있습니다. 또한 AOP 관련된 로직들은 모두 뛰어넘겠습니다.
실험 메소드
해당 메소드를 실행시켰을 때, 어떠한 과정을 거쳐서 실행되는지 한번 쭉 살펴보겠습니다.
CrudMethodMetadataPostProcessor
기존에 저장되어 있던 구현 목록에 존재하는 메소드를 호출하는지 확인하고, 존재하지 않는다면 따로 처리합니다.
implementations 내부에는 아래와 같은 메소드들이 저장되어 있습니다.
현재 예제에서는, 사전에 등록된 메소드들이 아닌 저희가 직접 Query로 정의하였으므로 조건식에서 걸러지게 됩니다.
completeTransactionAfterThrowing에서는 설정에 따라 예외가 발생한 경우 트랜잭션을 롤백하거나 커밋시켜 줍니다.
cleanupTransactionInfo는 ThreadLocal의 정보를 리셋시켜 줍니다. 실제 동작은 oldTransaction으로 변경시켜 주는데, 현재 트랜잭션은 종료시키고 이전 트랜잭션으로 바꿔주는것 같다고 느껴졌습니다. 참고로 ThreadLocal을 통해 트랜잭션이 관리됩니다.
commitTransactionAfterReturning 메소드에서 트랜잭션이 종료된 이후 커밋을 담당합니다.
참고로 가운데에 있는 Vavr 관련 처리는 Vavr은 조금 다르게 처리해줘야 해서 따로 처리해준 것 같습니다.
프로젝트를 진행하던 중, 모임을 참여하는 기능에서 동시에 참여하는 경우 제한 인원수에 비해 더 많은 인원이 참가하게 되는 문제점이 발생하였습니다. Transactional을 걸어줬으니까 당연히 동시성 문제는 생기지 않았겠지.. 하는 안일함이 불러온 참사였습니다.
실제로 여러명의 크루들과 함께 테스트를 해 보니, 여러명의 인원이 동시에 모임을 참여하는 경우 동시성 문제가 생기는 것을 알 수 있었습니다. 왜 이런 문제가 생기게 된것일까요? 모임을 참가하는 로직을 가져와 보겠습니다.
대충 봤을땐 문제가 없어보이지만, 사실 네모친 칸이 문제입니다. 두 사용자가 동시에 참여를 한다고 가정했을 때, 최대 인원수가 5명이고, 현재 인원수가 4명인 경우 두 사용자 시점에서는 모두 4명으로 처리되어 둘 다 참여가 되기 때문입니다.
그렇다면, 이러한 문제는 어떻게 해결할 수 있을까요? 자바에서는 아주 적절한 기능을 제공합니다. 바로 synchronized 키워드입니다. 한번에 한 사용자만이 참여를 하고, 나머지 사용자들은 대기하도록 메소드에 Synchronized 키워드를 붙여주면 됩니다.
이제 해결됬으니 끝났을까요? 아닙니다. 저희 서비스는 아쉽게도(?) 로드밸런싱을 도입하게 되었습니다. 하나의 서버에서는 한명만이 보장되겠지만, 여러대의 서버에서 한명이 보장되지는 않았습니다. 따라서 저희는 Synchronized보다 락의 필요성을 느끼게 되었습니다. 그렇다면 락이란 무엇일까요?
데이터베이스 락
락이란 데이터베이스의 일관성을 보장하기 위한 방법입니다. 락의 종류에는 여러 락이 있는데, 이번에는 낙관적 락과 비관적 락에 대해서 학습해 보겠습니다.
낙관적 락이란?
트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법입니다.
애플리케이션 단에서 Version 관리 기능을 사용합니다.
버전 정보를 비교하여 가져온 버전 정보와 변경할 때의 버전 정보가 다른 경우 예외가 발생합니다.
예시 쿼리는 다음과 같습니다.
update board
set title=?,version = 버전 + 1
where id = ? and version = 버전;
변경할 때의 버전이 초기에 읽어들인 버전이 아니라면 update 쿼리의 실행 결과 행의 수가 0개일 것이므로, 예외가 발생하는 방식입니다. 그렇다면 실제로 어떻게 쿼리가 날라가는지 한번 테스트해 보겠습니다.
우선 도메인은 다음과 같습니다. 버전 관리를 위한 필드를 하나 추가해 주어야 합니다.
public class Raccoon {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
@Version
private Long version;
private String name;
}
이후 Repository에서 낙관적 락을 걸어줍니다. Version 필드가 존재하는 경우, OPTIMISTIC을 생략한다면 데이터가 변경되었을 때만 Version 필드가 업데이트 됩니다.
원하던 대로 select for update 구문으로 조회 쿼리가 나가는 모습을 볼 수 있습니다. select for update 쿼리로 조회한 데이터는 트랜잭션이 끝나기 전까지 다른 트랜잭션에서 수정하거나 select for update 쿼리 또는 for share로 조회할 수 없습니다.
장/단점
데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있습니다.
데이터베이스 단에서 락을 걸기 때문에 동시성 문제를 해결하기 위한 Version 필드가 따로 필요하지 않으며, 개발자는 로직 자체에 더 집중할 수 있습니다.
데이터베이스 단에서 락을 걸기 때문에 성능이 비교적 좋지 않습니다.
프로젝트에서는?
프로젝트에서는 비관적 락을 적용했는데, 이유는 동시에 접근하는 충돌이 많이 일어날 것이라고 예상했기 때문입니다. 만일 낙관적 락을 사용한다면 예외처리를 하는데 많은 시간이 들 것 같았습니다. 따라서 비관적 락을 사용하여 동시성 문제를 해결하였습니다.
Synchronization is built around an internal entity known as the intrinsic lock or monitor lock (The API specification often refers to this entity simply as a "monitor.")
CAS(Compare-and-Swap)을 활용하여 데이터 무결성을 보장합니다. CAS는 세개의 피연산자에서 작동합니다. 하나는 작동할 메모리 위치, 하나는 변수의 기존 기대값, 마지막 하나는 설정해야 하는 새 값입니다.
즉, CAS는 메모리 위치에 있는 값을 새 값으로 업데이트하는 방법입니다. 단, 이전 값이 변수의 기존 기대값과 같아야 합니다.
이번 예시에서는 AtomicInteger의 accumulateAndGet() 메서드 구조를 통해 어떻게 동시성을 유지하는지 확인해 보겠습니다.
public final int accumulateAndGet(int x,
IntBinaryOperator accumulatorFunction) {
int prev = get(), next = 0;
for (boolean haveNext = false;;) {
if (!haveNext)
next = accumulatorFunction.applyAsInt(prev, x);
if (weakCompareAndSetVolatile(prev, next))
return next;
haveNext = (prev == (prev = get()))
weakCompareAndSetVolatile(expected, newValue) 메서드는 현재 값이 매개변수로 전달된 expectedValue와 같은 경우 AtomicReference에 대한 값을 원자적으로 newValue로 설정하는데 사용됩니다. 즉, 현재 값이 prev인 경우에만 next를 리턴하게 됩니다.
haveNext는 기존 값이 변경되었는지의 여부입니다.
(prev == (prev = get())) 에서 기존 값과 실제 저장되어 있는 데이터를 비교하며 실제 저장되어 있는 데이터로 prev를 갱신합니다.
haveNext가 False인 경우가 실제 저장된 데이터와 현재 데이터가 다른 것이므로, accumulatorFunction.applyAsInt 메서드의 결과값을 새롭게 next에 넣어줍니다.
결국 최신 값으로 prev가 설정이 되고 나서야 weakCompareAndSetVolatile 메서드에 의해 next로 값이 설정되고 리턴됩니다.
Vector 인스턴스가 동기화되는 것이 아니라 작업이 동기화되는 것이기 때문에 동시성을 보장하지 않습니다.
한 쓰레드가 Vector을 for문으로 반복하고, 다른 쓰레드가 Vector의 데이터를 수정한다면 ConcurrentModificationException 예외가 발생합니다.
즉, 서로 다른 두 작업이 동시에 수행될 수 있는 문제가 있습니다. 동시에 수행될 수 있다면 차라리 ArrayList를 쓰거나 Collections.synchronizedList() 메서드로 감싸주는 것이 더 좋습니다.
Collections.synchronizedList()는 각 메소드에서 mutex로 동기화를 처리하여 한 메서드가 실행중일 때 다른 메서드가 접근할 수 없습니다.
1. ArrayList
데이터를 배열에 저장합니다.
리스트가 꽉 차게 되면 배열을 늘려줍니다.
배열의 크기는 이전 배열 크기의 1.5배로 늘어납니다.
oldCapacity >> 1 ⇒ oldCapacity * 0.5
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity
: hugeCapacity(minCapacity);
}
따로 동시성이 처리되지는 않습니다.
2. LinkedList
List 뿐만 아니라 Queue를 구현합니다.
add() 메서드 호출시 마지막 노드에 추가 노드를 삽입한다.
노드의 경우는, 양방향 노드입니다. prev, next를 저장합니다.
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
get() 메서드 호출시 ArrayList의 경우는 배열의 index로 접근하지만, LinkedList의 경우는 첫번째에서부터 n번 이동하여 데이터를 가져옵니다. 이로 인해 조회 속도가 ArrayList에 비해선 느릴 수 있습니다.
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
public boolean contains(Object o) { return map.containsKey(o); }
2. TreeSet
내부적으로 NavigableMap을 가지고 있습니다. 기본으로 사용하는 구현체는 TreeMap입니다.
데이터를 추가하거나 데이터에 값을 가지고 있는지 유무는 HashSet과 마찬가지로 Map의 메서드를 사용합니다.
TreeMap의 성질로 인해 데이터가 정렬되어 있습니다. 이로 인해 compareTo 메서드를 잘 재정의 해두어야 합니다.
Key를 기준으로 오름차순, 내림차순으로 정렬된 Map입니다.
NavigableMap
3. LinkedHashSet
값이 들어간 순서대로 출력 가능합니다.
Map
키-값 형태의 데이터를 저장합니다.
1. HashMap
해시를 기준으로 키-값을 저장합니다. 데이터 저장 로직인 putVal() 메서드의 코드를 하나하나 분석해 보겠습니다.
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
초기 테이블 설정이 되어있지 않은 경우 테이블을 설정합니다. resize() 메서드는 테이블 크기를 초기화하거나 2배로 재조정하며 초기 테이블이 설정되어 있지 않은 경우 입력된 초기 크기로 설정하거나 초기값을 16으로 설정합니다.
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
기존에 동일한 해시값으로 데이터가 들어오지 않았다면 newNode() 메서드로 새로운 노드를 만들어서 넣어줍니다. 존재한다면 두 분기점이 존재합니다. equals()와 hashcode가 모두 같은 경우는 같은 데이터가 들어온 것으로 간주합니다. 타입이 TreeNode인 경우는 테이블이 아닌 트리구조인 형태이며, TreeNode가 아닌 경우, 체이닝으로 되어있으므로 다음 링크로 이동해 나가면서 같은 데이터가 있는지 찾고, 없다면 맨 뒤에 데이터를 넣어줍니다.
단, 여기서 TREEIFY_THRESHOLD - 1 이상인 경우 테이블 구조에서 트리구조로 변경합니다. TREEIFY_THRESHOLD는 8이므로, 동일한 해쉬값인 데이터가 8개 들어오는 순간 트리구조로 변환할 것 같지만, 그렇지 않습니다.
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
사실상, 테이블의 크기가 MIN_TREEIFY_CAPACITY(64) 아래인 경우에는 resize() 메서드를 호출하여 테이블의 크기를 늘려주기만 하고, 기존에 있는 데이터를 다시 테이블에 넣어주기만 합니다. 크기가 64 이상인 경우에만 트리구조로 변경시켜 줍니다. 왜 이런 구조가 되었을까요?
왜냐하면 트리를 사용하는건 최대한 뒤로 미루어야 하기 때문입니다. 해시 테이블의 경우는 이론상 O(1)의 시간이 걸리나, 트리를 사용한 탐색의 경우 O(log n)의 시간이 걸리기 때문입니다. 일반적으로 작은 테이블 크기의 경우 확장을 통해 해시 충돌을 해결할 가능성이 높습니다.
그렇다면 왜 트리를 사용하게 될까요? 이 부분은 개인적인 생각입니다. 해시 충돌이 잦은 경우를 생각해 봅시다. 충분하게 테이블 크기를 늘렸음에도 불구하고 해시 충돌이 매우 잦게 일어나게 되면 테이블에 데이터가 균일하게 들어가지 않고, 하나의 버킷에 데이터가 몰리는 현상이 일어나게 됩니다. 이러한 경우 결국 해시 테이블을 사용했지만 검색 속도가 O(n)에 가깝게 될겁니다. 따라서 트리를 사용하게 되면 O(log n)의 시간이 걸리니 오히려 트리가 더 빠르게 동작할 수도 있을 것 같습니다.
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
마지막으로, 이미 존재한 경우에 대해서 처리하고 저장된 데이터의 수가 임계치보다 커진다면 크기를 더 확장시켜 줍니다.
2. TreeMap
정렬된 키값 순으로 데이터가 조회됩니다.
Red-Black Tree를 사용합니다. 적절한 위치에 데이터를 삽입한 다음 Red-Black 후처리를 진행합니다. Red-Black Tree에 대해선 추후에 다시 다룰 예정입니다.
내부에 Head, Tail을 따로 두어 Map이 변경될때마다 같이 조절하여 저장 순서를 유지합니다. 구현 로직은 HashMap의 메서드들을확장해서 사용합니다.
put 메서드 기준으로 살펴보겠습니다. HashMap의 putVal메서드에서 사용하는 newNode 메서드를 재정의해서 사용합니다.
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<>(hash, key, value, e);
linkNodeLast(p);
return p;
}
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;
}
}
linkNodeLast(p)라는 메서드가 살짝 낀걸 볼 수 있습니다. 이 친구는 무엇을 하는 역할일까요? Tail의 링크에 p를 연결시켜주고 Tail을 p로 설정해주는걸 알 수 있습니다. 따라서 Head에서 시작해서 링크를 따라 Tail로 이동하면 데이터를 넣은 순서대로 조회가 가능합니다.
다음은 LinkedkeySet의 forEach 메서드의 구현 코드입니다.
public final void forEach(Consumer<? super K> action) {
if (action == null)
throw new NullPointerException();
int mc = modCount;
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
action.accept(e.key);
if (modCount != mc)
throw new ConcurrentModificationException();
}
실제로 head에서 시작해서 null이 될 때 까지(tail의 after은 null입니다.) 이동하면서 action을 실행시켜 주는걸 볼 수 있습니다.
프로젝트를 진행하던 중 N+1 문제를 만난 적이 있었습니다. 그당시는 짧은 검색을 통해 join fetch를 하면 해결된다로 넘어갔었는데, 이번 기회에 여러 해결 방법과 함께 알아보면 좋을 것 같았습니다. N+1 문제는 일반적으로 지연 로딩으로 인해 발생합니다. 연관된 데이터를 가져오기 위해 N개의 추가 쿼리문이 실행되는 문제입니다. 말로만 설명하기엔 너무 어려운거 같군요. 예시를 들어보겠습니다.
배경
데이터베이스는 H2 데이터베이스를 사용했으며, 쿼리 조회와 DB 설정은 다음 사이트를 참고했습니다.
예시로는 라쿤과 라쿤 그룹이 있습니다. 각각의 라쿤 집단에는 라쿤이 있습니다. 각각의 라쿤 그룹에 라쿤이 몇마리가 있는지 확인해 보겠습니다. 예제에서는 count() 쿼리가 아닌, 리스트를 가져와서 size() 메서드로 마리수를 측정한다고 가정하겠습니다. 전체 라쿤 그룹과 그룹에 속해있는 라쿤들을 모두 조회하는 쿼리의 구조는 다음과 같을 것입니다.
SELECT {라쿤 그룹의 속성들}, {라쿤의 속성들}
FROM raccoon_group g inner join raccoon r on g.id = r.group_id;
위 쿼리의 실행 결과를 엔티티에 맞추어서 넣어주면 한번만 쿼리를 날리면 될 것 같습니다. 하지만 Lazy Loading 으로 연관관계를 설정해준다면 생각한 대로 동작하지 않습니다. 다음은 예제 코드와 실행 결과입니다.
쿼리가 총 3개, 맨 위에서부터 전체 그룹 조회, A 그룹 조회, B 그룹 조회 쿼리가 날라가는 것을 볼 수 있습니다. 그렇다면 왜 이렇게 쿼리가 날라갈까요? 그 이유는 연관관계에 걸어준 Lazy Loading 때문입니다.
Lazy Loading
연관관계 설정된 객체를 프록시 객체로 가지고 있는 방식입니다. 객체를 실제로 사용하는 시점에서야 데이터베이스에서 해당 객체를 조회합니다. 앞서 raccoonGroup 내부에 있는 raccoon들은 실제 그룹을 조회하는 시점(getRaccoons)이 되서야 데이터베이스에서 조회가 된 것이었습니다. 실제로 일반적인 Collection이 아닌 프록시 객체인 PersistentBag으로 변환되어 저장되는 것을 볼 수 있습니다.
실제로 PersistentBag의 get() 메서드를 들어가 보면 컬렉션에서 데이터를 조회하기 전에 read() 메서드로 데이터를 조회해 오는 것을 볼 수 있습니다. 참고로 bag은 저희가 Entity의 필드를 선언할 때 써준 List라고 보시면 됩니다.
그렇다면 왜 데이터 조회 시 프록시 객체를 사용할까요? 이유는 필요하지 않은 많은 데이터를 가져올 수 있기 때문입니다. 예시로 저희는 라쿤이 몇마리인지에만 관심이 있지 각각의 라쿤이 무슨 먹이를 좋아하는지는 관심이 없습니다. 이러한 상황에서 먹이 데이터까지 불러오면 불필요한 데이터까지 DB에서 불러오기 때문에 메모리 낭비가 있을 수 있습니다.
앞서서 N+1 문제가 무엇인지와 왜 발생하는지에 대해서 살펴보았습니다. 이제 N+1을 해결하는 방법에 대해서 알아보겠습니다.
Join Fetch
이전에는 전체 객체를 조회하고, 객체와 연관관계로 묶여있는 다른 객체를 하나씩 조회하기 때문에 N+1 문제가 발생했었습니다.
Join Fetch 방법은 JPQL을 사용하여 명시적으로 Join으로 연관된 데이터까지 모두 영속화시키는 방법입니다. 추후 연관관계로 묶여 있는 엔티티를 사용할 때, 이미 영속화되어 있으므로 추가 쿼리가 발생하지 않습니다.
참고로 OneToMany 관계이므로 조인으로 인해 그룹이 중복되어 조회될 수도 있으므로 distinct를 사용해서 중복되지 않도록 처리하였습니다.
public interface GroupRepository extends JpaRepository<RaccoonGroup, Long> {
@Query("select distinct g from RaccoonGroup g join fetch g.raccoons")
List<RaccoonGroup> findAllByFetch();
}
Join Fetch 방법의 단점으로는 페이지네이션과 함께 사용할 경우 limit이 정상적으로 걸리지 않아 원하는 결과가 나오지 않을 수 있으며 쿼리 결과를 모두 메모리에 적재한 다음 페이지네이션을 하기 때문에 성능 상 치명적으로 좋지 않다는 문제점이 있습니다.
N+1 문제가 조회해온 전체 엔티티의 각각의 연관관계를 하나씩 조회해오기 때문에 생기는 문제이므로, select의 in절을 이용하여 한번에 여러 연관관계 엔티티를 조회해오는 방식입니다. 이번 예제에서는 한번에 두개의 라쿤 그룹에 속해있는 라쿤들을 조회해오도록 작성해 보겠습니다.
게시하는 객체에서는 ApplicationEventPublisher을 따로 주입받고 ApplicationEventPublisher의 publishEvent() API를 사용합니다.
Listener
Listener은 빈이어야 합니다.
빈의 public 메소드에 @EventListener 어노테이션을 붙여서 사용합니다.
파라메터 타입에 맞는 이벤트가 발행한다면 해당 메소드가 실행됩니다.
기본적으로 EventListener은 동기화 되어 있습니다. 예시로 트랜잭션 내에서 Event를 발행하면, Event를 수신하기를 기다리며 수신 측에서 예외가 터진다면 Transaction이 롤백됩니다.
커밋이 된 이후에 이벤트를 처리하도록 하려면 TransactionalEventListener을 사용하면 됩니다.
그럼 언제 이벤트를 사용할까요?
다음과 같은 경우를 생각해 봅시다.
게임 플랫폼이 게임에 의존하고 있는 형태입니다. 스팀 비슷하게 생각하면 될 거 같습니다. 사용자는 게임 플랫폼을 통해 게임을 접속하게 됩니다. 그렇다면 한가지 케이스에 대해 이야기해보겠습니다. 게임을 점검해야 하는 기능이 도입된다면 어떨까요? 게임 측에서 본인이 점검중이라고 게임 플랫폼에게 알려주어야 할 것입니다.
private GameRepository gameRepository;
private GamePlatformService gamePlatformService;
void maintenance(Long gameId) {
final Game game = gameRepository.findById(gameId);
gamePlatformService.unableGame(gameId);
...
}
위와 같이 작성하는 순간 의존성이 양방향으로 바뀌게 됩니다. 게임에서도 결국 게임 플랫폼을 알고 있어야 하기 때문입니다. 그렇다면 결국 양방향이 됩니다. 양방향은 썩 좋지 않은 구조입니다. A가 변경되면 B도 변경되고, 반대로 B가 변경되도 A를 변경시켜 주어야 하기 때문입니다. 또한 양방향이 좋지 않은 구조인 이유는 자칫 잘못하면 메소드를 순환 호출할 위험이 생기고 양쪽 상태를 맞추어 주어야 하는 작업이 필요합니다.
그렇다면, 이러한 양방향을 어떻게 해결해 줄 수 있을까요? 여러 방법이 있겠지만 위에서 설명한 이벤트를 사용한다면 해결 가능합니다. 게임이 점검 상태라고 이벤트를 publish 하면 게임 플랫폼에서 이를 받아서 상태를 바꾸도록 바꿔서 구현하면 될 것 같습니다.
Game
private ApplicationContext applicationContext;
void maintenance(Long gameId) {
final Game game = gameRepository.findById(gameId);
applicationContext.publishEvent(new GameMaintenanceEvent(this, game.getId()));
}
GamePlatform
@EventListener
public void unableGame(final GameMaintenanceEvent event) {
Long gameId = event.getGameId();
...
}
이로써 Game이 GamePlatform을 더이상 의존하지 않아도 되어 단방향으로 되었습니다.
이벤트의 단점은?
동기화되는 이벤트의 경우는 오류처리가 가능할 수 있지만, 비동기 이벤트의 경우 오류 처리가 어렵습니다.
2022년 10월 31일 월요일, 저는 백엔드 이프와 함께 자바를 학습하고 있었습니다. 그러던 중 이프가 흥미로운 주제를 던졌습니다. Final 키워드가 실제로 성능이 향상된다는 점이었습니다.
처음 들었을 때는, 말도 안되는 소리라고 생각했지만.. 학습해 보니 성능이 향상될 수도 있다는 점을 알게 되었습니다.
우선, Final 키워드를 붙인 코드입니다.
@Test
public void sum1() {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
final String a = "a";
final String b = "b";
String c = a + b;
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
다음으로, Final 키워드를 붙이지 않은 코드입니다. 동일한 코드를 사용하며 정말 final 키워드만 빼두었습니다.
@Test
void test2() {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
String a = "a";
String b = "b";
String c = a + b;
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
생각보다 위 코드의 경우는 성능 차이가 나타나는 것을 볼 수 있습니다. 그렇다면 왜 이런 결과가 나타났을까요? 컴파일 시 최적화가 일어나기 때문입니다. 실제로 생성된 바이트코드를 살펴보겠습니다.
우선 Final 키워드를 사용한 코드의 바이트코드를 살펴보겠습니다.
예상은 + 연산을 해줄 것이라고 생각했지만, 실제 결과는 따로 연산을 수행하는 것이 아닌 “a”와 “b”가 더해진 결과인 “ab”를 곧바로 넣어줍니다.
다음으로, Final 키워드를 사용하지 않은 코드의 바이트코드를 살펴보겠습니다.
Final 키워드를 사용하지 않으니 우리가 생각한 결과인 a와 b를 불러오고, a와 b를 makeConcatWithConstants 메서드를 통해 더해주는 모습을 볼 수 있었습니다.
결론
즉, 결론적으로 모든 경우에서 무조건 성능이 좋아진다고 보기는 어렵지만, 위와 같은 경우 Final 키워드를 사용하니 컴파일러가 최적화 해주는 모습을 볼 수 있었습니다.