[자바 웹 프로그래밍 Next-Step] 4주차 CH07: DB를 활용해 데이터를 영구적으로 저장하기

이호석·2023년 3월 7일
0
post-thumbnail

자바 웹 프로그래밍 Next-Step - 박재성 저자 책으로 스터디를 하며 진행했던 내용들을 기록하고 있습니다.

4주차에 진행했던 Chapter 07의 목표는 다음과 같습니다.

  • Chapter 07: JDBC API를 통해 DB를 적용하여 데이터를 영구저장 + JDBC API의 중복을 줄인 JdbcTemplate 만들기

모든 코드들은 다음 저장소에서 확인할 수 있습니다.
https://github.com/Java-web-programming-Next-Step/next-step-web-programming/tree/HiiWee/7
프로젝트명: jwp-basic-gradle

📌 4주차 7장

✅ 더 구체적인 Unchecked Exception을 던지자!

7장 미션을 진행 과정에서 SQL 코드를 작성할때 UserDao의 메소드에서 SQLException을 던지는 기존 코드들을 볼 수 있습니다.

SQLException은 Exception을 상속받는 Checked Exception이므로 반드시 예외처리를 해야합니다. 따라서 해당 dao를 사용하는 Service단에서 예외를 처리하거나
Controller에서 예외처리를 해야합니다. 만약 Controller에서 하게된다면 예외는 던져짐을 반복하므로 이는 좋지못한 구조가 됩니다.

좋지 못한 구조가 되는 이유에 대한 생각

  • Checked 예외를 던지고 던지게 되는 경우 문법적인 강제 구문을 작성해야 합니다.
    만약 특정 interface의 메소드를 구현하고 있는 클래스에서 던지게되면 결국 interface의 코드마저 변경을 강요하게 됩니다. → xxxx() throws XXXException
    따라서 이는 OCP를 위반하게 됩니다.
  • 범용적으로 발생하는 SQLException의 경우 insert, findByUserId에서 동일하게 발생하고 있습니다. 해당 예외가 발생하면 어떤 복구전략을 가져야 할지 생각해보면
    해당 예외 정보를 사용자에게 알려주고 다시금 입력을 유도하도록 하는것이 현실적입니다.

따라서 좀 더 구체적인 Unchecked Exception을 다시 발생시켜 정확한 정보를 전달하고 로직의 흐름을 끊는것이 바람직하다고 생각합니다.

💡 참고 블로그
Checked Exception을 대하는 자세 - Yun Blog | 기술 블로그

✅ 리팩토링 TIP

1. 변경되는 부분과 변경되지 않는 부분을 분리하자 (개발자가 담당 하는 부분과 라이브러리가 담당하는 부분을 구분)

✅ Generic Method vs Generic Type(Class or Interface)

JdbcTemplate을 Generic Class로 구현했다가 Generic Method로 변경하면서 알게된 이점과 생각을 정리해봤습니다!

Generic 을 사용하는 이유

  • Compile Time에서 강력한 유형 검사를 가능케한다.
    • 제네릭을 사용하면서 Runtime에서 발생할 수 있는 ClassCastException과 같은 상황이 발생하는것을 방지할 수 있다.
      • 예시 코드
        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);
        
                            }
                        });
            }
        }
        // 제네릭 사용후
  • 캐스트 제거
    • 타입추론: 자바 컴파일러가 각 메소드의 호출과 이에 대응하는 선언을 보고 호출을 적용할 유형 인수를 결정하는 기능이며, 가능한 경우에 할당되는 타입, 리턴 타입까지 결정한다.
    • 위와 같은 타입추론이 가능하기에 Object를 사용할때와 달리 캐스팅을 하지 않고 값을 직접 반환할 수 있다.
  • 다양한 제네릭 알고리즘 구현 가능
    • 서로 다른 타입이더라도 동일한 제네릭만 지정해주면 자유롭게 사용할 수 있다.

💡 출처: Oracle
Why Use Generics? (The Java™ Tutorials > Learning the Java Language > Generics (Updated))

JdbcTemplate 구현시 Generic Method? Type?

  • 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을 선언했지만, 해당 방법은 제네릭을 제대로 활용하지 못하고 있다고 판단했다.

  • 초기 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) {
            ...
        }
    }
  • Model마다 서로 다른 JdbcTemplate을 생성해서 사용해야 한다. 하나의 JdbcTemplate의 인스턴스가 생성되면 초기에 지정된 T 객체만을 조회할 수 있으며 해당 인스턴스로 다른 Model을 DB에서 조회할 수 없다.

JDBC를 유연하게 사용하고자 했지만, 실제로는 100% 제네릭을 활용해 유연하게 사용하고 있다고 말 할 수는 없었다.

Generic Method 사용하여 해결

  • 현재 JdbcTemplate 코드
    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

✅ 가변인자에 아무런 값도 넣지않는 경우 null값의 유무

가변인자는 항상 매개변수들 중 마지막에 지정되어야 한다. 이를 통해 최소 0개부터 n개까지 인자를 지정할 수 있는 이점을 얻을 수 있다.
여기서 최소 0개라면 마지막 매개인자에 아무것도 넘기지 않은 상태가 된다. 그렇다면 매개인자를 받는 입장에선 해당 가변인자의 값을 null로 둘까?

findAll() 메서드

위 메서드는 User의 모든 튜플들을 조회하는 메소드다. JdbcTemplate의 query를 호출하는데 해당 메소드를 들어가보면

다음과 같이 가변인자가 마지막 인수로 존재한다.

실제 가변인자를 넘기지 않은 상태에서 Null 체크 로그를 찍어보면 false 출력됨을 알 수 있다. (배열 자체를 확인하면 {}와 같이 빈 배열이 출력됨)

18:14:53.269 [DEBUG] [http-nio-8080-exec-1] [core.jdbc.JdbcTemplate] - valuesNullCheck=false

따라서 가변인자의 유무에 따른 Null 체크를 하지 않아도 자유롭게 사용할 수 있다.

✅ BasicDataSource과 Connectino Pool

JDBC에서 가장 큰 비용을 차지하는 부분은 바로 DataBase와의 Connection이다. DB와의 연결을 위한 연결 객체라고 생각하면 편하다.

Java에서는 이런 커넥션을 관리하기 위한 인터페이스로 javax.sql.DataSource 인터페이스를 제공한다. 이들을 구현한 객체로는 Commons DBCP1,2(apache), HikariCP 등이 존재한다.
따라서 우리는 단순히 DataSource의 인터페이스만 바라보고 getConnection()시 구현체가 어떤것이 오는지는 신경쓰지 않아도 된다.

Connection Pool

Connection은 Connection Pool이 관리하며 BasicDataSource는 Connection Pool을 관리하는 클래스이다.
Connection Pool은 기본적으로 다음과 같은 속성으로 관리되며 각 속성들을 변경할 수 있다.

  • BasicDataSource 클래스의 커넥션 개수 지정 속성

    위의 내용을 보면 짐작할 수 있듯 모든 커넥션은 초기에 생성되고 사용이 완료된 커넥션은 close를 통해 사용이 종료된다.
    사용이 종료된 커넥션은 사라지는것이 아니라 다시 커넥션 풀로 반환되는데 이때 커넥션 풀의 설정은 위의 속성을 조절하여 설정할 수 있다. 앞으로 보이는 테스트는 전부 기본값으로 테스트했습니다.


1. Connection Pool test 디버깅을 통해 간단한 Connection Pool 이해

  • ConnetionTest 코드
  • Commons DBCP2의 BasicDatsSource는 기본값으로 최대 8개의 커넥션을 동시에 사용할 수 있다.
  • 따라서 위의 테스트를 실행하게 되면 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 클래스로 감싸서 반환합니다. 해당 클래스를 디버깅 해보면 다음과 같은 코드가 존재합니다.
    해당 코드의 구조를 파악해봅시다!


2. Connection의 구현체가 왜 PoolGuardConnectionWrapper?

  • BasicDataSource의 getConnection() 코드는 return에서 다시한번 PoolingDataSource.getConnection()을 호출한다.
  • PoolingDataSource의 getConnection() 이곳에서 실제 _pool에서 최대 8개로 관리되는 커넥션을 꺼내와 PoolGuardConnectionWrapper로 래핑하여 반환한다.

    위의 코드에서 실질적인 getConnection의 구현체가 반환됩니다. 따라서 우리는 Connection 객체의 로그가 Wrapper 클래스로 나타남을 이해할 수 있습니다.

    borrowObject() 메소드는 내부적으로 이미 존재하는 커넥션이 있는지 파악하고 없다면 새로 create합니다. 이미 꽉차있다면 대기하게 됩니다.


3. PoolingDataSource 클래스의 borrowObject()의 내부 동작을 통해 더 자세히 Connection의 생성 알아보기

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 상태로 존재한다.


4. Connection의 반환은 어떻게 이루어질까?

  • Connection 테스트를 약간 변경해보자
    @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());
    
        }
  • 코드를 반복진행해 8개의 커넥션을 전부 할당하고 추가한 if 문의 조건이 충족된 상태,
    이때 할당을 해제할 connection2의 객체에 존재하는 커넥션의 hashCode값은 3565780이다.

  • DefaultPooledObject에서 현재 커넥션의 PooledObejctState 상태를 RETURNING(반환) 상태로 변경한다.
  • 이후 close를 하기위한 검사 및 코드들을 통과하고 나면 DefaultPooledObject 객체에서 dellocate()를 통해 RETURNING 상태를 다시 커넥션을 풀링할 수 있게 IDLE 상태로 변경한다.
  • 이후 실제 생성된 Connection Pool에서 hashCode 값이 3565780인 인스턴스의 State가 IDLE인걸 확인할 수 있다.

💡 참고
H2 connection pool
BasicDataSource (Commons DBCP 1.4 API)
Connection Pool과 DataSource
Commons DBCP 이해하기

profile
꾸준함이 주는 변화를 믿습니다.

0개의 댓글