📂 목차
📚 본문
HikariCP
데이터베이스와 어플리케이션을 통신할 때, Connection 이라는 추상화 된 연결시켜주는 인터페이스를 통해 통신을 할 수 있음을 볼 수 있었다. 내부적인 구현은 DriverManager 을 통해서 외부의 인스턴스를 들고와서 구현하였음을 알 수 있다.
외부의 인스턴스를 들고오기 위해 mysql-connector 라는 것을 사용했다.
여기서 Connection 이라는 하나의 객체를 생성하면 해당 클래스로만 요청을 할 수 있는데, 이는 요청할 수 있는 입구가 하나뿐임을 뜻한다. 그러면 요청이 더 많이 들어올 때마다 Connection 을 생성시켜서 요청을 받을 수 있게 하면 되지만, Connection 을 새로 생성하는 비용이 매우 커서, 이를 서버 실행 이전에 미리 다 만들어두고 사용하는 방식을 채택하게 된다.
이것이 바로 Connection Pool 이며, 자바 진영에서는 이 이론을 토대로 가장 빠르고 가벼운 커넥션 풀 구현체 중에 Hikari Connection Pool 을 사용하게 된다.
HikariCP 의존성 추가
// https://mvnrepository.com/artifact/com.zaxxer/HikariCP
implementation 'com.zaxxer:HikariCP:7.0.2'
이는 보통 Spring Boot 2.0 이후에 전부 내장되어 있기 때문에 따로 추가해주지 않아도 기본값이 hikari cp 를 사용하도록 되어 있다.
Hikari Configuration
커넥션 풀은 여러 개의 스레드를 통해 커넥션의 수를 정할 수 있다.
이런 커넥션 수는 properties
파일로 환경 변수처럼 미리 설정하여 HikariCP
모듈에게 전달할 수 있다.
# resources/hikari.properties
dataSourceClassName=com.mysql.cj.jdbc.MysqlDataSource
dataSource.url=jdbc:mysql://localhost:3306/library?serverTimezone=Asia/Seoul&useSSL=false&allowPublicKeyRetrieval=true
dataSource.user=lion
dataSource.password=1234
# Statement 캐시 관련 설정
dataSource.cachePrepStmts=true
dataSource.prepStmtCacheSize=250
dataSource.prepStmtCacheSqlLimit=2048
# 풀 사이즈 & 타임아웃
maximumPoolSize=4
minimumIdle=4
connectionTimeout=30000
idleTimeout=600000
maxLifetime=1800000
poolName=MyHikariPool
이는 HikariCP 만의 독립적인 설정 방식이고, Spring 에서는 위 설정과는 또 다르게 입력해줘야 한다.
Spring 에서의 HikariCP 설정
spring.datasource.url=jdbc:mysql://localhost:3306/testdb
spring.datasource.username=testuser
spring.datasource.password=testpass
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.connection-timeout=30000
- maximum-pool-size:
- DB 서버가 가용하는 코어 수 * 스레드로 경험적으로 정함
- 쿼드 코어 & 멀티 스레드 = 4 * 2 = 8
- minimum-idle: 최소 유지 커넥션 수(같게 두면 모든 커넥션이 돌아감)
- idle-timeout: 유휴 커넥션 제거 밀리 초
- 보통 10분으로 둠(600000 으로 설정 = 10 분)
- max-lifetime: 커넥션 최대 생존 밀리 초
- 1800000 = 30 분
- connection-timeout: 커넥션 최대 대기 밀리초
- 30000: 30 초
HikariConfig
HikariConfig class 를 뜯어보자.
HikariConfig Member Var.
@SuppressWarnings({"SameParameterValue", "unused"})
public class HikariConfig implements HikariConfigMXBean { ... }
Hikari 를 뜯어보면 최상위에 HikariConfig
가 있음을 볼 수 있다.
private static final char[] ID_CHARACTERS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
private static final long CONNECTION_TIMEOUT = SECONDS.toMillis(30);
private static final long VALIDATION_TIMEOUT = SECONDS.toMillis(5);
private static final long SOFT_TIMEOUT_FLOOR = Long.getLong("com.zaxxer.hikari.timeoutMs.floor", 250L);
private static final long IDLE_TIMEOUT = MINUTES.toMillis(10);
private static final long MAX_LIFETIME = MINUTES.toMillis(30);
private static final long DEFAULT_KEEPALIVE_TIME = MINUTES.toMillis(2);
private static final int DEFAULT_POOL_SIZE = 10;
멤버 변수로는 기본값들이 들어 있음을 볼 수 있다.
// Properties changeable at runtime through the HikariConfigMXBean
//
private volatile String catalog;
private volatile long connectionTimeout;
private volatile long validationTimeout;
private volatile long idleTimeout;
private volatile long leakDetectionThreshold;
private volatile long maxLifetime;
private volatile int maxPoolSize;
private volatile int minIdle;
private final AtomicReference<Credentials> credentials = new AtomicReference<>(Credentials.of(null, null));
그 밑은 우리가 properties
에 입력했던 값들이 들어가는 변수들을 선언했다. volatile
이어서 변수 수준에서 원자적인 연산을 수행함도 알 수 있다(수정과 삭제에 있어 data concurrent 하다).
생성자 부분
var systemProp = System.getProperty("hikaricp.configurationFile");
if (systemProp != null) {
loadProperties(systemProp);
}
생성자 에는 기본적으로 Hikari
에서 제공하는 상수들을 기본 값으로 초기화하는 것을 볼 수 있고, 그 이후에 위의 코드를 볼 수 있다.
해당 부분은 System.getProperty
라는 것을 통해 property
라는 설정 사항을 추상화 해놓은 Property
인스턴스를 얻어옴을 볼 수 있다. 만약 null 이라면 그냥 지나치며, 아니라면 loadProperties
로 넘어가게 된다.
HikariDataSource
HikariDataSource
를 보기 전에 구현하고 있는 인터페이스인 DataSource
를 보자.
원래 DataSource
는 DB 를 연결하여 객체를 얻는 방법을 추상화한 인터페이스인데, 자바에서는 DriverManager.getConnection
으로 매번 직접 커넥션을 작성해야 했다. 하지만 이거는 다음과 같은 단점이 있다:
- 매번 새로운 커넥션을 만들 때 비용이 큼
- 커넥션 풀링(pooling) 이나 트랜잭션 관리 같은 고급 기능을 붙이기 어려움
- 설정 정보(URL, 사용자, 비밀번호) 가 코드에 박혀서 재사용성이 떨어지며, 보안적으로도 문제가 있음
이런 문제를 해결하기 위해 JDBC 2.0
에서 javax.sql.DataSource
가 도입된다.
Connection getConnection() throws SQLException
위와 같은 인터페이스가 있으며 우리는 설정 정보만 다른 파일에 입력해주고, dataSource.getConnection()
만 호출한다면 실제 구현체에 따라 적절한 커넥션을 반환받게 된다.
HikariDataSource
는 DataSource
를 구현하는 구현체이다. 뜯어보면, HikariConfig
를 확장하고 있고, 이 말은 HikariConfig
가 가지고 있는 모든 설정 정보들을 가져올 수 있다는 의미이다.
public class HikariDataSource extends HikariConfig implements DataSource, Closeable
{
private static final Logger LOGGER = LoggerFactory.getLogger(HikariDataSource.class);
private final AtomicBoolean isShutdown = new AtomicBoolean();
private final HikariPool fastPathPool;
private volatile HikariPool pool;
여기서 얻어갈 것들은 자바 프로그래밍에서의 다양한 기법들을 얻을 수 있다. 나머지는 그냥 제공해주는 것을 이용하면 되는 부분이며, 그 안에 코드를 어떻게 짜서 넣었는지를 보자.
Initialization-on-demand Holder Idiom
class Foo {
private static class Holder {
public static final Foo INSTANCE = new Foo();
}
public static Foo getInstance() {
return Holder.INSTANCE;
}
}
싱글턴을 가져가는 코드 방식 중 하나이다. 여기서는 nested class
를 썼는데, 이때 객체가 클래스 로더 단위로 하나만 생성되게 된다.
또한 Holder 내부에서 초기화를 딱 한 번만 하게 되며, new Foo()
방식으로 초기화하기 때문에 지연 초기화 기능도 가져가게 된다. 당연하게도 static final
하기에 스레드 안전이 자동으로 보장되게 된다.
- 코드가 깔끔
- 스레드 안전 + 지연 초기화 자동 보장
- synchronized 사용이 없어 성능이 최적화 됨
Double-Checked Locking
위 글을 보면 J2SE 5.0 부터 volatile
키워드는 메모리 배리어(memory barrier
)를 만들도록 정의되었는데, 이를 통해 여러 스레드가 싱글턴 인스턴스를 올바르게 다루도록 보장하는 코딩을 칠 수 있게 되었다.
// Works with acquire/release semantics for volatile in Java 1.5 and later
// Broken under Java 1.4 and earlier semantics for volatile
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = helper;
if (localRef == null) {
synchronized (this) {
localRef = helper;
if (localRef == null) {
helper = localRef = new Helper();
}
}
}
return localRef;
}
// other functions and members...
}
Java 1.5 이상에서의 코드에서 안전하게 동작하는 코드이며, Helper
라는 volatile
(최신 값을 읽을 수 있는, 가시성을 보장하는) 로 선언되었고, 초기화 과정에서 reordering 을 방지하기 때문에 수정할 때 다른 스레드가 접근하지 못하게 한다.
이때 Helper
를 반환하고 싶은 메서드가 있을 때(지연 초기화를 하고 싶을때), localRef
로 helper
의 주소값을 참조해온다. 이는 멀티 스레드 환경에서 성능을 최적화하기 위해서 임시로 가져오는 것이다(volatile
필드를 여러 번 읽는 것보다 로컬 변수에 복사 후 사용하는게 효율이 좋음).
그 후 helper
가 초기화되지 않았다면, 해당 객체를 기준으로 동기화 블록을 시작한다. 즉, 여러 스레드가 동시에 들어왔을 때, 한 번만 객체를 생성하도록 보장한다. 이때 localRef
를 통해 helper
값을 다시 한 번 확인하는데, 이유는 critical criteria 안에 들어오기 전에 다른 스레드가 이미 helper 를 초기화 했을 수도 있기 때문에 두 번 체크하게 되는 것이다.
따라서 첫 번째는 전역 스레드에서 값을 체크, 두 번째는 동기화 블럭 안에서 오로지 체크가 되게 된다. 이렇게 되면 synchronized
에 들어오기 전에 첫번째 localRef == null
검사 때 통과를 하였더라도 synchronized
에서 두 번째 초기화가 안되게 걸러지게 된다.
그 이후 helper = localRef = new Helper();
를 통해 값을 동시에 할당시키면 된다.
이를 통해 성능이 최대 40% 까지 오른다고 한다.
위 두가지 방식은 둘 다 괜찮은 방법이지만, 유지보수성이나 코드 가독성 측면에서는 nested class 를 사용하는 Initialization-on-demand Holder Idiom 이 훨씬 좋아보인다.
FastList
그 다음으로 모듈 내에서 가져갈 수 있는 최적화 방식은 FastList
가 있었다.
@Override
public boolean add(T element)
{
if (size < elementData.length) {
elementData[size++] = element; // 범위 체크 없이 바로 추가
}
else {
// 배열 크기 확장
final var oldCapacity = elementData.length;
final var newCapacity = oldCapacity << 1;
@SuppressWarnings("unchecked")
final var newElementData = (T[]) Array.newInstance(clazz, newCapacity);
System.arraycopy(elementData, 0, newElementData, 0, oldCapacity);
newElementData[size++] = element;
elementData = newElementData;
}
return true;
}
- System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length): 복사할 원본 배열 src 에, 원본 배열에서 복사 시작 위치 srcPos 부터 복사할 대상 배열 dest 을 복사해서 넣고, 대상 배열에서 붙여넣기 시작 위치 destPos 에서 부터 복사할 요소 수 length 만큼 복사하게 된다.
- 결국엔 둘은 같게 된다. 하지만 dest 가 현재 더 많은 메모리를 할당받을 수 있게 했기 때문에 메모리 용량만 다르다.
요소를 제거할 때도 해당 리스트는 굉장히 빠른데, 다음과 같다.
@Override
public T remove(int index) {
final T old = elementData[index];
final var numMoved = size - index - 1;
if (numMoved > 0) {
System.arraycopy(elementData, index + 1, elementData, index, numMoved);
}
elementData[--size] = null;
return old;
}
중간 요소 제거 시에 실제 제거를 하지 않고 System.arraycopy 를 통해 덮어쓰는 연산을 수행하고, 해당 함수는 JVM 내부에서 최적화된 네이티브 코드(C/C++) 로 구현되어 있어 빠를 수 있는 것이다.
Effectively Final
자바 8 이후에 도입된 개념으로, 명시적으로 final 을 붙이지 않아도 사실상 final 처럼 동작하는 변수를 effectively final
이라고 한다.
즉, 한 번 초기화 된 이후 값이 바뀌지 않으면 effectively final
이며, 이는 람다 또는 익명 클래스에서 사용할 수 있는 값이 되게 된다.
✒️ 용어
var
var 예약어는 Java 10 에서 처음 도입되었고, 지역 변수를 선언할 때 컴파일러가 타입을 추론하게 해주는 키워드인데, 개발자가 명시적으로 타입을 적지 않아도 컴파일러가 초기값을 보고 타입을 결정하게 된다.
잘못된 예
var x; // ❌ 컴파일 에러, 초기값이 필요함
class Test {
var field = 10; // ❌ 컴파일 에러, 지역 변수가 아님
}
잘된 예
var map = new HashMap<String, List<Integer>>();
// 대신에 HashMap<String, List<Integer>> map = new HashMap<>(); 라고 써야 함
- 코드 간결성 증가
- 타입 길거나 복잡할 때 코드 가독성 개선
주의 사항
- 타입 추론은 컴파일 타임에 결정되기 때문에 런타임에는 타입이 바뀌지 않음
- null 로 초기화하면 타입을 추론할 수 없어 사용할 수 없음.