본문 바로가기
spring

스프링 DB 1편 - 1

by 오우지 2022. 11. 2.

1. JDBC의 이해 

먼 과거의 DB 접근법은 두 가지 문제가 있었다.

1. 데이터베이스를 다른 종류의 데이터베이스로 변경하면 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 함께 변경해야 한다.

2. 개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 전달, 응답 받는 방법을 새로 학습해야 한다.

 

그래서 JDBC(Java Database Connectivity)(1997)가 등장했다.

대표적으로 3가지 기능을 표준 인터페이스로 제공한다.

 

java.sql.Connecition

java.sql.Statement

java.sql.ResultSet

이렇게 만들어진 인터페이스를 각각의 벤더에서 구현해서 라이브러리로 제공한다.

 

JDBC의 등장으로 공통적인 부분들에 대해선 편해졌지만 각각의 데이터베이스마다 SQL, 타입 등의 사용법이 다르다는 단점이 있었다. 또한, JDBC로만 어플리케이션을 만들려면 사용하는 방법 자체도 복잡하다 그래서 SQL Mapper가 등장했다.

 

SQL Mapper

JdbcTemplate도 매퍼인지는 처음알았넹

장점: JDBC를 편리하게 사용하도록 도와준다.

- SQL 응답 결과를 객체로 편리하게 변환해준다.

- JDBC의 반복 코드를 제거해준다.

 

단점: 개발자가 직접 SQL을 작성해줘야 한다.

물론 그 당시엔 최선이었을 것이다.

 

ORM

객체를 관계형 데이터베이스 테이블과 매핑해주는 기술로 ORM 기술이 개발자 대신 SQL을 동적으로 만들어 실행해준다.

ORM도 자바 진영의 ORM 표준 인터페이스이고, 이걸 구현한 하이버네이트와 이클립스 링크 등의 구현 기술이 있다.

 

결국 모든 기술들은 내부에서 JDBC를 사용한다.

 


DB 커넥션

커넥션의 로직에 대해 알아보자.

최초 접속시 어플리케이션은 url, user, password 정보를 가지고 getConnection()을 호출하게 된다.

getConnection() 내부 로직을 살펴보자

 

@CallerSensitive
public static Connection getConnection(String url,
    String user, String password) throws SQLException {
    java.util.Properties info = new java.util.Properties();

    if (user != null) {
        info.put("user", user);
    }
    if (password != null) {
        info.put("password", password);
    }

    return (getConnection(url, info, Reflection.getCallerClass()));
}

java.sql.connection의 getConnection을 따라가보면 user, password, 리플렉션의 callerClass를 이용해 내부 로직을 추가로 태운다.

 

private static Connection getConnection(
    String url, java.util.Properties info, Class<?> caller) throws SQLException {
    /*
     * When callerCl is null, we should check the application's
     * (which is invoking this class indirectly)
     * classloader, so that the JDBC driver class outside rt.jar
     * can be loaded from here.
     */
    ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
    if (callerCL == null || callerCL == ClassLoader.getPlatformClassLoader()) {
        callerCL = Thread.currentThread().getContextClassLoader();
    }

    if (url == null) {
        throw new SQLException("The url cannot be null", "08001");
    }

    println("DriverManager.getConnection(\"" + url + "\")");

    ensureDriversInitialized();

    // Walk through the loaded registeredDrivers attempting to make a connection.
    // Remember the first exception that gets raised so we can reraise it.
    SQLException reason = null;

    for (DriverInfo aDriver : registeredDrivers) {
        // If the caller does not have permission to load the driver then
        // skip it.
        if (isDriverAllowed(aDriver.driver, callerCL)) {
            try {
                println("    trying " + aDriver.driver.getClass().getName());
                Connection con = aDriver.driver.connect(url, info);
                if (con != null) {
                    // Success!
                    println("getConnection returning " + aDriver.driver.getClass().getName());
                    return (con);
                }
            } catch (SQLException ex) {
                if (reason == null) {
                    reason = ex;
                }
            }

        } else {
            println("    skipping: " + aDriver.getClass().getName());
        }

    }

    // if we got here nobody could connect.
    if (reason != null)    {
        println("getConnection failed: " + reason);
        throw reason;
    }

    println("getConnection: no suitable driver found for "+ url);
    throw new SQLException("No suitable driver found for "+ url, "08001");
}

for문 안의 isDriverAllowed를 타고 현재 연결된 드라이버들이 해당 정보를 통해 연결할 수 있는지 확인한다. 이렇게 커넥션 구현체가 클라이언트를 통해 반환된다.

 

save()

@Slf4j
public class MemberRepositoryV0 {

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values (?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }

    }

    private void close(Connection con, Statement stmt, ResultSet rs) {

        if (rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }

        if (con != null) {
            try {
                con.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
    }

    private Connection getConnection() {
        return DBConnectionUtil.getConnection();
    }
}

@Slf4j
public class DBConnectionUtil {
    public static Connection getConnection() {
        try {
            Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
            log.info("get connection = {}, class = {}", connection, connection.getClass());
            return connection;
        } catch (SQLException e) {
            throw new IllegalStateException(e);
        }
    }
}

이렇게 jdbc를 이용해 save로직을 짤 수 있다.

 

Connection은 가장 아래의 DBConnectionUtil을 통해서 만든 커넥션을 가져오고 PreparedStatement를 이용해 쿼리를 완성한다. 참고로 Statement와 PreparedStatement는 Statement를 extends해 파라미터 바인딩 기능을 추가한 것이다. 이는 SQL Injection도 방지해준다.

 

PreparedStatement는 영향받은 DB row수를 반환해준다.

 

DB 커넥션을 사용한 이후엔 꼭 close()를 해줘야 하는데 이때 순서는 PreparedStatement 생성의 역순으로 해줘야 한다.

 

find()

조회 기능

public Member findById(String memberId) throws SQLException {
    String sql = "select * from member where member_id = ?";

    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        con = getConnection();

        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);

        rs = pstmt.executeQuery();
        if (rs.next()) {
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        } else {
            throw new NoSuchElementException("member not found memberId=" + memberId);
        }
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally {
        close(con, pstmt, rs);
    }
}

데이터를 변경할 때는 executeUpdate(), 조회할 때는 executeQuery()를 사용한다. 결과는 ResultSet에 담아서 반환한다.

 

ResultSet은 내부의 cursor를 이동해 다음을 참조할 수 있고 rs.next()를 최초 한번은 호출해야 첫번째 데이터에 도달할 수 있다.

 

while(rs.next()) {}

이렇게 하면 데이터를 다 뽑을 수 있겠지.

 

update(), delete()

public void update(String memberId, int money) throws SQLException {
    String sql = "update member set money=? where member_id=?";

    Connection con = null;
    PreparedStatement pstmt = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setInt(1, money);
        pstmt.setString(2, memberId);
        int resultSize = pstmt.executeUpdate();
        log.info("resultSize={}", resultSize);
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally {
        close(con, pstmt, null);
    }
}

public void delete(String memberId) throws SQLException {
    String sql = "delete from member where member_id = ?";

    Connection con = null;
    PreparedStatement pstmt = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);
        pstmt.executeUpdate();
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally {
        close(con, pstmt, null);
    }
}

update()와 delete()를 이렇게 만들고

 

@Slf4j
class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

    @Test
    void crud() throws SQLException {
        // save
        Member member = new Member("memberV4", 10000);
        repository.save(member);

        // findById
        Member findMember = repository.findById(member.getMemberId());
        log.info("findMember = {}", findMember);
        assertThat(findMember).isEqualTo(member);

        // update: money 10000 -> 20000
        repository.update(member.getMemberId(), 20000);
        Member updatedMember = repository.findById(member.getMemberId());
        assertThat(updatedMember.getMoney()).isEqualTo(20000);

        // delete
        repository.delete(member.getMemberId());
        Assertions.assertThatThrownBy(() -> repository.findById(member.getMemberId()))
                .isInstanceOf(NoSuchElementException.class);
    }
}

테스트 코드를 짤 수 있다.

Assertions.assertThatThrownBy()를 이용해서 Exception도 검증할 수 있다.

 

마지막에 회원을 삭제하기 때문에 테스트가 정상 수행되므로 반복 실행할 수 있다 하지만 테스트 중간의 오류가 생기는 변수도 고려하면 트랜잭션을 이용해야 한다.

 


2. 커넥션풀과 DataSource의 이해

 

데이터베이스 커넥션 획득 과정

 

1. DB 드라이버를 통해 커넥션을 조회한다.(getConnection)

2. DB 드라이버에서 TCP/IP 3way handshake를 통해 커넥션을 연결한다.

3. DB 드라이버는 TCP/IP 커넥션이 연결되면 ID, PW등을 DB에 전달한다.

4. DB는 내부 인증을 하고 사용자 정보를 기반으로 세션을 생성한다.

5. 커넥션 생성 완료 응답을 보낸다.

6. DB 드라이버가 커넥션 객체를 생성해 클라이언트에 반환한다.

 

매 요청마다 커넥션을 만들면 사용자 경험에 안좋은 영향을 주게 된다. 따라서, 커넥션을 미리 생성해두고 사용하는 커넥션 풀이 존재한다.

 

커넥션 풀

1. 커넥션 풀은 기본값이 10 정도로 적절한 커넥션 수는 서버와 DB 스펙에 따라 다르기 때문에 성능테스트를 통해 정해야 한다.

2. 개념적으로 단순하기 때문에 직접 구현할 수도 있지만 편리하고 좋은 오픈소스 커넥션 풀이 많기 때문에 오픈소스를 사용하는 것이 좋다. commons-dbcp2, tomcat-jdbc pool, HikariCp 등이 있다.

3. 최근에는 hikariCP를 주로 사용하고 부트2.0 부터는 해당 풀을 기본으로 제공한다.

 

 

어플리케이션을 기준으로 봤을 때 요청시마다 커넥션을 만들기도 하고 hikariCP를 쓰기도 하고, 다른 오픈소스도 공통 코드로 쓴다고 생각하면 커넥션을 획득하는 방법을 추상화해서 공통으로 쓰는 방법이 가장 효율적일 것이다.

 

자바에서는 이런 문제를 해결하기 위해 javax.sql.DataSource라는 인터페이스를 제공한다.

DataSource는 커넥션을 획득하는 방법을 추상화하는 인터페이스로 핵심 기능은 커넥션 조회 하나이다.

 

public interface DataSource {
	Connection getConnection() throws SQLException;
}

하지만 구현한 DriverManager에서는 인터페이스를 사용하지 않았었는데

 

@Slf4j
public class DBConnectionUtil {
    public static Connection getConnection() {
        try {
            Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
            log.info("get connection = {}, class = {}", connection, connection.getClass());
            return connection;
        } catch (SQLException e) {
            throw new IllegalStateException(e);
        }
    }
}

스프링은 DriverManager도 DataSource를 통해서 사용할 수 있게 DriverManagerDataSource라는 클래스를 제공하기 때문에 DataSource만 사용하면 된다.

 

기존의 커넥션 접근 방식이 다음과 같았다면

@Test
void driverManager() throws SQLException {
    Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
    Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
    log.info("connection={}, class={}", con1, con1.getClass());
    log.info("connection={}, class={}", con2, con2.getClass());
}

 

DriverManager에서 dataSource를 접근할 수 있게 해주는 DriverManagerDataSource를 이용한 사용 방법은 다음과 같다.

@Test
void dataSourceDriverManager() throws SQLException {
    // DriverManagerDataSource - 항상 새로운 커넥션을 획득
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    // 스프링이 제공한다.
    useDataSource(dataSource);
}

private void useDataSource(DataSource dataSource) throws SQLException {
    Connection con1 = dataSource.getConnection();
    Connection con2 = dataSource.getConnection();
    log.info("connection={}, class={}", con1, con1.getClass());
    log.info("connection={}, class={}", con2, con2.getClass());
}

 

특징은 설정과 사용이 분리되어 있어서 객체를 설정하는 부분과 사용하는 부분을 좀 더 명확하게 분리할 수 있다. 애플리케이션의 설정은 한 곳에서, 사용은 수 많은 곳에서 하기 때문에 객체를 설정하는 부분과 사용하는 부분의 분리가 필요하다.

 

가장 많이 사용하는 커넥션 풀인 히카리 풀을 사용해보자.

 

@Test
void dataSourceConnectionPool() throws SQLException, InterruptedException {
    // 커넥션 풀링
    HikariDataSource dataSource = new HikariDataSource();
    dataSource.setJdbcUrl(URL);
    dataSource.setUsername(USERNAME);
    dataSource.setPassword(PASSWORD);
    dataSource.setMaximumPoolSize(5);
    dataSource.setPoolName("MyPool");

    useDataSource(dataSource);
    Thread.sleep(1000);
}

22:10:43.109 [MyPool housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - Pool stats (total=2, active=2, idle=0, waiting=0)

커넥션 풀은 별도의 스레드를 사용해서 커넥션 풀에 커넥션을 채운다. 따라서 메인 스레드가 끝나면 커넥션 풀이 생성되기 전에 로그가 끝나서 sleep()을 해줘야 한다.

 

 

DataSource 적용

기존의 코드에서 dataSource를 적용해보자.

 

DataSource를 생성자 주입으로 넣어주고

@Slf4j
public class MemberRepositoryV1 {

    private final DataSource dataSource;

    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    private void close(Connection con, Statement stmt, ResultSet rs) {

        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);
    }
    
    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection = {}, class={}");
        return con;
    }
}

close를 JdbcUtils에서 지원해주는 close를 사용한다.

getConnection() 부분도 직접 만들었던 부분에서 dataSource에서 구현한 getConnection을 이용한다.

 

22:40:56.492 [main] INFO hello.jdbc.repository.MemberRepositoryV1 - get connection = HikariProxyConnection@1063737662 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection
22:40:56.494 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn1: url=jdbc:h2:tcp://localhost/~/test user=SA
22:40:56.495 [main] INFO hello.jdbc.repository.MemberRepositoryV1 - get connection = HikariProxyConnection@762809053 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection
22:40:56.497 [main] INFO hello.jdbc.repository.MemberRepositoryV1 - get connection = HikariProxyConnection@1260467793 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection

또한 커넥션 풀을 이용하면 커넥션이 재사용된 것을 확인할 수 있다.

 

DataSource는 아까 위에서 봤듯 인터페이스이다. 따라서 DriverManagerDataSource를 통해서 사용하다가 커넥션 풀을 사용해도 로직은 변하지 않아도 된다. DI + OCP의 장점이라고 할 수 있다.

'spring' 카테고리의 다른 글

스프링 DB 1편 - 3  (0) 2022.12.12
스프링 DB 1편 - 2  (0) 2022.11.30
mvc2 - 타입컨버터  (0) 2022.03.02
API 예외 처리  (0) 2022.03.01
mvc 1 - 스프링 MVC  (0) 2022.02.24