자바 웹 프로그래밍 Next-Step - 박재성 저자 책으로 스터디를 하며 진행했던 내용들을 기록하고 있습니다.
4주차에 진행했던 Chapter 07의 목표는 다음과 같습니다.
모든 코드들은 다음 저장소에서 확인할 수 있습니다.
https://github.com/Java-web-programming-Next-Step/next-step-web-programming/tree/HiiWee/7
프로젝트명: jwp-basic-gradle
7장 미션을 진행 과정에서 SQL 코드를 작성할때 UserDao
의 메소드에서 SQLException을 던지는 기존 코드들을 볼 수 있습니다.
SQLException은 Exception을 상속받는 Checked Exception이므로 반드시 예외처리를 해야합니다. 따라서 해당 dao를 사용하는 Service단에서 예외를 처리하거나
Controller에서 예외처리를 해야합니다. 만약 Controller에서 하게된다면 예외는 던져짐을 반복하므로 이는 좋지못한 구조가 됩니다.
xxxx() throws XXXException
따라서 좀 더 구체적인 Unchecked Exception을 다시 발생시켜 정확한 정보를 전달하고 로직의 흐름을 끊는것이 바람직하다고 생각합니다.
JdbcTemplate을 Generic Class로 구현했다가 Generic Method로 변경하면서 알게된 이점과 생각을 정리해봤습니다!
public class GenericTest {
class A {
@Override
public String toString() {
return "A class";
}
}
class B {
@Override
public String toString() {
return "A class";
}
}
@Test
@DisplayName("제네릭 사용전: ClassCastException 발생")
void throwException_noGeneric() {
// given
List list = new ArrayList();
// when
list.add(new A());
list.add(new A());
list.add(new A());
list.add(new B());
// then 런타임 오류 발생
assertThatThrownBy(() -> {
for (Object a : list) {
A castA = (A) a;
System.out.println(castA);
}
}
).isInstanceOf(ClassCastException.class);
}
@Test
@DisplayName("제네릭 사용후: 컴파일 타임에서 오류 발생")
void noException_withGeneric() {
// given
List<A> list = new ArrayList();
// when
list.add(new A());
list.add(new A());
list.add(new A());
// list.add(new B()); // 컴파일 타임 오류 발생!!!
// then
assertThatNoException()
.isThrownBy(() -> {
for (Object a : list) {
A castA = (A) a;
System.out.println(castA);
}
});
}
}
// 제네릭 사용후
타입추론
: 자바 컴파일러가 각 메소드의 호출과 이에 대응하는 선언을 보고 호출을 적용할 유형 인수를 결정하는 기능이며, 가능한 경우에 할당되는 타입, 리턴 타입까지 결정한다.💡 출처: Oracle
Why Use Generics? (The Java™ Tutorials > Learning the Java Language > Generics (Updated))
Generic Type
: 제네릭 형식에 따라 매개변수화 되는 Generic Class or Interface를 의미/**
* Generic version of the Box class.
* @param <T> the type of the value being boxed
*/
public class Box<T> {
// T stands for "Type"
private T t;
public void set(T t) { this.t = t; }
public T get() { return t; }
}
Generic Method
: Generic Type과 비슷하지만 제네릭 매개변수의 범위를 해당 메소드로 제한시킨다.public class Util { // 더불어 클래스를 제네릭 클래스를 이용해도 별도의 타입으로 제네릭 메소드를 구현할 수 있음 ex) public class Util<T> { 동일 }
public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
return p1.getKey().equals(p2.getKey()) &&
p1.getValue().equals(p2.getValue());
}
}
Generic Class의 한계
초기 JdbcTemplate을 구현할때는 Generic Class로 JdbcTemplate을 선언했지만, 해당 방법은 제네릭을 제대로 활용하지 못하고 있다고 판단했다.
public class JdbcTemplate<T> {
public void update(final String query, final PreparedStatementSetter preparedStatementSetter) {
...
}
public void update(final String query, final Object... values) {
...
}
public List<T> query(final String query, final RowMapper<T> rowMapper) {
...
}
public T queryForObject(
final String query,
final RowMapper<T> rowMapper,
final PreparedStatementSetter preparedStatementSetter) {
...
}
public T queryForObject(
final String query,
final RowMapper<T> rowMapper,
final Object... values) {
...
}
private PreparedStatementSetter createPreparedStatementSetter(final Object[] values) {
...
}
}
JDBC를 유연하게 사용하고자 했지만, 실제로는 100% 제네릭을 활용해 유연하게 사용하고 있다고 말 할 수는 없었다.
Generic Method 사용하여 해결
public class JdbcTemplate {
public void update(final String query, final PreparedStatementSetter preparedStatementSetter) {
...
}
public void update(final String query, final Object... values) {
...
}
public <T> List<T> query(
final String query,
final RowMapper<T> rowMapper,
final PreparedStatementSetter preparedStatementSetter) {
...
}
public <T> List<T> query(final String query, final RowMapper<T> rowMapper, Object... values) {
...
}
public <T> T queryForObject(
final String query,
final RowMapper<T> rowMapper,
final PreparedStatementSetter preparedStatementSetter) {
...
}
public <T> T queryForObject(
final String query,
final RowMapper<T> rowMapper,
final Object... values) {
...
}
private PreparedStatementSetter createPreparedStatementSetter(final Object[] values) {
...
}
}
특정 타입에 대한 추론을 클래스 레벨이 아닌 메소드로 제한시켜 JdbcTemplate이 하나의 Model이 아닌 여러 Model을 조회할 수 있는 유연성을 얻을 수 있다.
💡 참고
Generic Methods
Java Generic Interface vs Generic Methods, and when to use one
가변인자는 항상 매개변수들 중 마지막에 지정되어야 한다. 이를 통해 최소 0개부터 n개까지 인자를 지정할 수 있는 이점을 얻을 수 있다.
여기서 최소 0개라면 마지막 매개인자에 아무것도 넘기지 않은 상태가 된다. 그렇다면 매개인자를 받는 입장에선 해당 가변인자의 값을 null로 둘까?
위 메서드는 User의 모든 튜플들을 조회하는 메소드다. JdbcTemplate의 query를 호출하는데 해당 메소드를 들어가보면
다음과 같이 가변인자가 마지막 인수로 존재한다.
실제 가변인자를 넘기지 않은 상태에서 Null 체크 로그를 찍어보면 false 출력됨을 알 수 있다. (배열 자체를 확인하면 {}와 같이 빈 배열이 출력됨)
18:14:53.269 [DEBUG] [http-nio-8080-exec-1] [core.jdbc.JdbcTemplate] - valuesNullCheck=false
따라서 가변인자의 유무에 따른 Null 체크를 하지 않아도 자유롭게 사용할 수 있다.
JDBC에서 가장 큰 비용을 차지하는 부분은 바로 DataBase와의 Connection이다. DB와의 연결을 위한 연결 객체라고 생각하면 편하다.
Java에서는 이런 커넥션을 관리하기 위한 인터페이스로 javax.sql.DataSource 인터페이스를 제공한다. 이들을 구현한 객체로는 Commons DBCP1,2(apache), HikariCP 등이 존재한다.
따라서 우리는 단순히 DataSource의 인터페이스만 바라보고 getConnection()
시 구현체가 어떤것이 오는지는 신경쓰지 않아도 된다.
Connection은 Connection Pool이 관리하며 BasicDataSource는 Connection Pool을 관리하는 클래스이다.
Connection Pool은 기본적으로 다음과 같은 속성으로 관리되며 각 속성들을 변경할 수 있다.
BasicDataSource 클래스의 커넥션 개수 지정 속성
위의 내용을 보면 짐작할 수 있듯 모든 커넥션은 초기에 생성되고 사용이 완료된 커넥션은 close를 통해 사용이 종료된다.
사용이 종료된 커넥션은 사라지는것이 아니라 다시 커넥션 풀로 반환되는데 이때 커넥션 풀의 설정은 위의 속성을 조절하여 설정할 수 있다. 앞으로 보이는 테스트는 전부 기본값으로 테스트했습니다.
따라서 위의 테스트를 실행하게 되면 8개의 로그가 찍히고 다음 커넥션을 받기 위해 테스트가 대기하고 있습니다.
이때 default로 다음 커넥션을 기다리는 시간은 무한대이며 이는 BasicDataSource
에서 확인할 수 있습니다.
// 8개의 로그
22:07:23.827 [INFO ] [main] [ConnectionTest] - connection : 1003206025, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=1003206025
22:07:23.846 [INFO ] [main] [ConnectionTest] - connection : 30699728, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=30699728
22:07:24.848 [INFO ] [main] [ConnectionTest] - connection : 756936249, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=756936249
22:07:24.848 [INFO ] [main] [ConnectionTest] - connection : 1221981006, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=1221981006
22:07:25.854 [INFO ] [main] [ConnectionTest] - connection : 1474957626, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=1474957626
22:07:25.854 [INFO ] [main] [ConnectionTest] - connection : 181252244, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=181252244
22:07:26.860 [INFO ] [main] [ConnectionTest] - connection : 1914108708, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=1914108708
22:07:26.861 [INFO ] [main] [ConnectionTest] - connection : 544386226, URL=jdbc:h2:~/jwp-basic, UserName=SA, H2 JDBC Driver, class = class org.apache.commons.dbcp2.PoolingDataSource$PoolGuardConnectionWrapper, hashCode=544386226
... waiting ...
기본 값은 -1로 maxWaitMills 값이 0 이하라면 무한대의 시간을 커넥션을 받는데 기다립니다.
BasicDataSource.setMaxWaitMills() 메소드를 통해 시간을 지정할 수 있는데 예시로 3000mills을 입력하면 위의 쓰레드 시간(4초) + 3초뒤인 7초후에 예외가 발생하며 종료됨을 알 수 있습니다.
(반면에 아무런 설정을 해주지 않는다면 테스트는 종료되지 않고 무한정 새로운 커넥션을 기다립니다.)
그렇다면 커넥션을 계속 닫아주게되면 100개의 로그가 찍힐까요?
Thread.sleep이후 connection1, 2를 close하게 되면 8개의 커넥션 중에서 사용하지 않는 커넥션이 2개씩 반환되므로 100개가 찍힙니다.
❗️ 여기서 의문점이 있습니다. 커넥션은 8개만 생성하고 재사용하는데 왜 받아오는 커넥션들의 해쉬값이 같은것이 없을까? (connection : [hashCode])
로그가 찍히는 Connection 객체를 보면 실질적인 클래스는
다음과 같은 PoolingDataSource$PoolGuardConnectionWrapper
입니다. 즉, 이미 생성된 커넥션을 Wrapper 클래스로 감싸서 반환합니다. 해당 클래스를 디버깅 해보면 다음과 같은 코드가 존재합니다.
해당 코드의 구조를 파악해봅시다!
PoolingDataSource의 getConnection() 이곳에서 실제 _pool에서 최대 8개로 관리되는 커넥션을 꺼내와 PoolGuardConnectionWrapper로 래핑하여 반환한다.
위의 코드에서 실질적인 getConnection의 구현체가 반환됩니다. 따라서 우리는 Connection 객체의 로그가 Wrapper 클래스로 나타남을 이해할 수 있습니다.
borrowObject() 메소드는 내부적으로 이미 존재하는 커넥션이 있는지 파악하고 없다면 새로 create합니다. 이미 꽉차있다면 대기하게 됩니다.
public T borrowObject(long borrowMaxWaitMillis) throws Exception {
assertOpen();
AbandonedConfig ac = this.abandonedConfig;
if (ac != null && ac.getRemoveAbandonedOnBorrow() &&
(getNumIdle() < 2) &&
(getNumActive() > getMaxTotal() - 3) ) {
removeAbandoned(ac);
}
PooledObject<T> p = null;
// Get local copy of current config so it is consistent for entire
// method execution
boolean blockWhenExhausted = getBlockWhenExhausted();
boolean create;
long waitTime = System.currentTimeMillis();
while (p == null) {
create = false;
if (blockWhenExhausted) {
p = idleObjects.pollFirst();
if (p == null) {
p = create(); // 이곳에서 새로 커넥션을 생성함
if (p != null) {
create = true;
}
}
if (p == null) {
if (borrowMaxWaitMillis < 0) {
p = idleObjects.takeFirst();
} else {
p = idleObjects.pollFirst(borrowMaxWaitMillis,
TimeUnit.MILLISECONDS);
}
}
if (p == null) {
throw new NoSuchElementException(
"Timeout waiting for idle object");
}
if (!p.allocate()) {
p = null;
}
} else {
p = idleObjects.pollFirst();
if (p == null) {
p = create();
if (p != null) {
create = true;
}
}
if (p == null) {
throw new NoSuchElementException("Pool exhausted");
}
if (!p.allocate()) { // 이곳에서 IDLE -> ALLOCATED(할당) 상태로 변경됨: 사용자가 커넥션을 요구했으므로
p = null;
}
}
if (p != null) {
try {
factory.activateObject(p);
} catch (Exception e) {
try {
destroy(p);
} catch (Exception e1) {
// Ignore - activation failure is more important
}
p = null;
if (create) {
NoSuchElementException nsee = new NoSuchElementException(
"Unable to activate object");
nsee.initCause(e);
throw nsee;
}
}
if (p != null && (getTestOnBorrow() || create && getTestOnCreate())) {
boolean validate = false;
Throwable validationThrowable = null;
try {
validate = factory.validateObject(p);
} catch (Throwable t) {
PoolUtils.checkRethrow(t);
validationThrowable = t;
}
if (!validate) {
try {
destroy(p);
destroyedByBorrowValidationCount.incrementAndGet();
} catch (Exception e) {
// Ignore - validation failure is more important
}
p = null;
if (create) {
NoSuchElementException nsee = new NoSuchElementException(
"Unable to validate object");
nsee.initCause(validationThrowable);
throw nsee;
}
}
}
}
}
updateStatsBorrow(p, System.currentTimeMillis() - waitTime);
return p.getObject();
}
p = create()
하게되면 DriverConnectionFactory 인스턴스에서 실제 uri와 _props를 가지고 Connection 객체를 생성합니다. _driver.connect()
: 순수 커넥션이 생성된다. (connection을 관리하는 인덱스도 지정됨) 이후 DefaultPooledObejct
라는 객체로 감싸져서 반환되고 해당 객체를 초기화 하는 모습을 보면 PooledObjectState라는 enum 상태값이 IDLE
로 초기화 되는것을 볼 수 있습니다.
IDLE 상태는 커넥션을 사용할 수 있는 상태입니다.(생성직후이므로)
!p.allocate()
를 통해 아래와 같이 IDLE 상태가 ALLOCATED(할당된) 상태가 된다.
PoolingDataSource
에서 PoolGuardConnectionWrapper
로 다시 커넥션을 래핑하여 반환한다. 따라서 우리가 보는 Connection 객체의 구현체는 해당 Wrapper 클래스가 된다.
반환된 PoolGuardConnectionWrapper를 살펴보면 다음과 같이 _pool 안에 allObjects
라는 ConcurrentHashMap
에 생성된 커넥션이 ALLOCATED
상태로 존재한다.
@Test
void dataSourceConnectionPool() throws SQLException, InterruptedException {
BasicDataSource ds = new BasicDataSource();
ds.setDriverClassName(DB_DRIVER);
ds.setUrl(DB_URL);
ds.setUsername(DB_USERNAME);
ds.setPassword(DB_PW);
for (int i = 0; i < 100; i++) {
useDataSource(ds);
}
log.info("active connection num : {}", ds.getNumActive());
}
private void useDataSource(final BasicDataSource ds) throws SQLException, InterruptedException {
Connection connection1 = ds.getConnection();
Connection connection2 = ds.getConnection();
// 추가!!
if (ds.getNumActive() == 8) { // 만약 8개의 커넥션이 전부 연결됐다면 connection2에 할당된 커넥션을 close() 즉, 반환한다.
connection2.close();
}
log.info("connection : {}, class = {}, hashCode={}", connection1, connection1.getClass(),
connection1.hashCode());
log.info("connection : {}, class = {}, hashCode={}", connection2, connection2.getClass(),
connection2.hashCode());
}
3565780
이다. DefaultPooledObject
에서 현재 커넥션의 PooledObejctState 상태를 RETURNING(반환)
상태로 변경한다. dellocate()
를 통해 RETURNING
상태를 다시 커넥션을 풀링할 수 있게 IDLE
상태로 변경한다. 3565780
인 인스턴스의 State가 IDLE인걸 확인할 수 있다. 💡 참고
H2 connection pool
BasicDataSource (Commons DBCP 1.4 API)
Connection Pool과 DataSource
Commons DBCP 이해하기