새소식

반응형
Back-end/Spring

[Spring] Part 1-5. 나만의 MVC 프레임워크 만들기 | JDBC 프로그래밍(+CRUD)

2023.01.16
  • -
반응형

1. JDBC 개념 소개

JDBC(Java Database Connectivity)란 자바 애플리케이션에서 DB(Database) 프로그래밍을 할 수 있도록 도와주는 표준 인터페이스입니다.

 

표준 인터페이스이기 때문에 DBMS 종류와 상관이 없습니다.

 

JDBC 인터페이스들을 구현한 구현체들은 각 데이터베이스 벤더 사들이 제공해주고 있으며, 이를 'JDBC Driver'라고 합니다. 

 

자바 코드에서는 보통 JDBC 인터페이스에만 의존하기 때문에 DB를 변경하더라도 코드를 변경하지 않아도 됩니다.

 

위 이미지가 앞서 설명한 내용을 나타내는 것입니다. 이에 대해 더 자세한 내용은 실습을 통해 진행하도록 하겠습니다.

 

 

2. DB 커넥션 풀 개념 소개

2-1. DBCP (Database Connection Pool)

DBCP라고 불리는 데이터베이스 커넥션 풀은 미리 일정량의 DB 커넥션을 생성해서 풀에 저장해 두고 있다가 HTTP 요청에 따라 필요할 때 풀에서 커넥션을 가져다 사용하는 기법입니다.

 

참고로 Spring Boot(스프링 부트) 2.0 부터는 Default Connection Pool로 HikariCP 라는 것이 설정되어 있습니다.

 

DBCP 라이브러리 종류로는 방금 말씀드린 HikariCP, Apache Commoms DBCP, Tomcat JDBC 등이 존재합니다.

저희가 뒤에서 실습을 진행하면서 사용할 라이브러리는 HikariCP입니다.

 

2-2. 커넥션 풀 사용시 유의 사항

먼저 커넥션의 사용 주체는 WAS(Web Application Server) 스레드이기 때문에 커넥션 개수WAS 스레드 수와 함께 고려해야 한다는 점입니다.

여기서 말하는 WAS 스레드는 이전 시간에 만들었던 Http 웹 서버에서의 스레드를 의미합니다.

해당 내용은 아래에서 확인할 수 있습니다.

 

[Spring] Part 1-4. 웹 애플리케이션 이해 (+계산기 프로그램 실습)

1. 목표 이번 시간에는 계산기 프로그램을 웹 애플리케이션으로 만들어보면서 웹 애플리케이션에 대해 이해를 해보도록 하겠습니다. 계산기 프로그램을 총 세 단계로 나누어서 개발해 볼 것인

cdragon.tistory.com

 

또 유의해야 할 점으로는 DB 접근을 필요로 하는 동시 접속자 수가 너무 많은 경우에, 한정된 커넥션 개수로 인해 커넥션이 반납될 때까지 기다려야 할 수 있는데, 그렇다고 커넥션 풀에 너무 많은 커넥션을 미리 생성해 놓는다면 커넥션 또한 객체이기 때문에 메모리를 많이 차지하게 된다는 점입니다.

 

즉, 커넥션 수를 크게 설정하게 되면 메모리 소모는 큰 대신 동시접속자 수가 많아지더라도 사용자 대기 시간이 상대적으로 줄어들게 되고,
반대로 커넥션 개수를 작게 설정하면 메모리 소모는 적은 대신 그만큼 대기시간이 길어질 수 있다는 trade-off가 존재합니다.


따라서 자신이 처한 환경에 맞게 적정량의 커넥션 객체를 생성해 두면 되겠습니다.

 

2-3. DataSource

커넥션 풀을 적용하기 위해서 자바에서 제공하는 DataSource를 사용할 것입니다.

DataSource란 커넥션을 획득하기 위한 표준 인터페이스로 HikariCP의 DataSource를 사용하여 실습을 진행할 것입니다.

 

3. JDBC 프로그래밍 실습

실습에 앞서 필요한 의존성들을 추가해 주겠습니다. 실습을 진행하면서 HikariCP와 jdbc 관련 의존성은 언제 사용되었고 어떤 기능을 하는지 설명해 드리도록 하겠습니다.

implementation('com.zaxxer:HikariCP:5.0.1')
implementation('org.springframework:spring-jdbc:5.3.22')

implementation('ch.qos.logback:logback-classic:1.2.11')

testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'

testImplementation('org.assertj:assertj-core:3.22.0')

testImplementation('com.h2database:h2:2.1.214')

코드의 가독성을 위한 assertj와 in-memory DB인 h2database를 추가해 주었습니다.

위 의존성들은 반드시 똑같을 필요없이 해당 기능을 수행하는 비슷한 라이브러리를 사용해도 상관없습니다.

 

의존성을 추가했다면 TDD 방식으로 진행하기 위해 UserDaoTest라는 test 파일을 먼저 하나 만들어 주겠습니다.

 

이전에 테스트 코드를 작성했던 것과 다른 점은 Alt + Insert 키를 누른뒤 나온 옵션들 중 test가 아니라 setup method를 선택해 준다는 점입니다.

 

@BeforeEach 라는 annotation이 붙은 setup 메소드는 테스트코드를 실행하기 앞서 수행해야하는 작업이 있는 경우 해당 메소드에 작성하면 테스트 코드 전에 작업이 수행하는 역할입니다.

public class UserDaoTest {
    @BeforeEach
    void setUp() {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        populator.addScript(new ClassPathResource("db_schema.sql"));
        DatabasePopulatorUtils.execute(populator, ConnectionManger.getDataSource());
    }
}

DatabasePopulatorUils라는 클래스의 execute 메소드는 populator 객체와 DataSource 객체를 인자로 받는데 DataSource 객체를 받아오기 위해 ConnectionManager 라는 클래스를 만들어 주도록 합니다.

 

public class ConnectionManager {

    public static DataSource getDataSource() {
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setDriverClassName("org.h2.Driver");
        hikariDataSource.setJdbcUrl("jdbc:h2:mem://localhost/~/jdbc-practice;MODE=MySQL;DB_CLOSE_DELAY=-1");
        hikariDataSource.setUsername("sa");
        hikariDataSource.setPassword("");

        return hikariDataSource;
    }
}

자세한 설정은 DB Connection Pool에 적용할 때 하고 일단은 위 코드와 같이 설정을 해 줍니다.

 

그 다음 과정은 앞선 test 코드에서 ClassPathResource의 인자로 넣었던 db_schema.sql을 만들도록 하겠습니다.

DROP TABLE IF EXISTS USERS;

CREATE TABLE USERS (
                       userId          varchar(12)    NOT NULL,
                       password       varchar(12)       NOT NULL,
                       name          varchar(20)       NOT NULL,
                       email         varchar(50),

                       PRIMARY KEY               (userId)
);

해당 파일은 test 디렉터리 밑의 resource 디렉터리에 만들었습니다.

위 sql 파일은 User 테이블을 만든 것으로 userId, password, name, email을 column을 가집니다.

그리하여 테스트 코드를 수행하기 전에 위와 같은 script를 읽어서 테이블을 만들어 준 것입니다.

 

그러고 나서 테스트 코드를 작성해 줍니다.

@Test
void createTest() {
    UserDao userDao = new UserDao();

    userDao.create("cdragon", "password", "name", "email");


    User user = UserDao.findByUserId("cdragon");
    assertThat(user).isEqualTo(new User("cdragon", "password", "name", "email"));
}

해당 코드에서 UserDao라는 객체를 하나 만들게 되는데 여기서 Dao라는 워드는 Data Access Object 라는 의미입니다. 그래서 앞으로 DB 작업을 수행할 때 이 Dao에게 위임을 할 것입니다.

userDao에게 User 정보에 대한 것을 저장해달라고 요청하고(create), userDao에게 다시 방금 저장한 Id에 해당하는 User를 조회하여 해당 정보가 DB에 앞서 저장한 정보와 일치하는지 확인하는 코드를 작성하였습니다.

 

테스트 코드를 통해 생성해야 될 클래스와 메소드들을 전부 구현을 해 주고 테스트 코드를 실행해 보면 당연히 실패하는 것을 볼 수 있을 것입니다.

 

이는 메소드들의 body 부분을 아직 구현하지 않았기 때문입니다. 이제 그 안을 채워보도록 하겠습니다.

 

먼저 JDBC 프로그래밍의 날 형태로 만들어보고 리팩토링을 통해 수정하는 형태로 진행하도록 하겠습니다.

public class UserDao {
    private Connection getConnection() {
        String url = "jdbc:h2:mem://localhost/~/jdbc-practice;MODE=MySQL;DB_CLOSE_DELAY=-1";
        String id = "sa";
        String pa = "";

        try {
            Class.forName("org.h2.Driver");
            return DriverManager.getConnection(url, id, pa);

        } catch (Exception e) {
            return null;
        }
    }

    public void create(User user) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            String sql = "INSERT INTO USERS VALUES (?, ?, ?, ?)";
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, user.getUserId());
            pstmt.setString(2, user.getPassword());
            pstmt.setString(3, user.getName());
            pstmt.setString(4, user.getEmail());

            pstmt.executeUpdate();
        } finally {
            if (pstmt != null) {
                pstmt.close();
            }

            if (con != null) {
                pstmt.close();
            }
        }
    }

    public User findByUserId(String userId) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = getConnection();
            String sql = "SELECT userId, password, name, email FROM USERS WHERE userId = ?";
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, userId);

            rs = pstmt.executeQuery();

            User user = null;
            if (rs.next()) {
                user = new User(
                        rs.getString("userId"),
                        rs.getString("password"),
                        rs.getString("name"),
                        rs.getString("email")
                );
            }
            return user;
        } finally {
            if (rs != null) {
                rs.close();
            }

            if (pstmt != null) {
                pstmt.close();
            }

            if (con != null) {
                con.close();
            }
        }
    }
}

메소드마다 connection을 사용하기 위해 해당 클래스 내에 getConnection 메소드를 만들어 h2 DB와 연결을 하는 기능을 구현하였고,

create 메소드에서는 User 객체를 넘겨주었을 때 해당 column에 들어갈 값들을 하나씩 할당해 준 후 자원을 해제합니다.

 

사실 자바의 try with resource 라는 것을 통해서 자동적으로 자원을 해제 할 수도 있지만 일단을 위와 같이 구현하도록 하겠습니다.

 

또한 참고로 자원해제는 생성했던 순서와 거꾸로 돌아가면서 해 주면 됩니다.

 

findByUserId 메소드에서는 userId를 넘겨 받아 해당 userId의 User 객체를 sql 쿼리문을 통해 DB에서 찾아 새로운 User 객체를 만들어 이를 반환해 주는 코드를 구현하였습니다.

 

앞서 Spring jdbc 의존성은 @BeforeEach에서 테스트 코드 실행 전에 테이블을 만들어주기 위해서 사용한 것이었고 hikariCP는 DataSource를 가져올 때 hikariCP의 DataSource를 가져오기 위해서 사용한 것입니다.

 

이제 테스트 코드를 실행하면 정상적을 통과가 될 것이지만 UserDao 클래스의 코드는 개선될 여지가 많이 남아 있습니다.

 

4. 실습한 JDBC 코드 리팩토링 및 DB 커넥션 풀 적용

먼저 UserDao의 connection을 받아오던 getConnection 메소드를 ConnectionManager 클래스 쪽으로 넘겨 주고 해당 부분은 굳이 객체를 생성할 필요가 없기 때문에 static 키워드를 추가해 줍니다.

 

hikariDataSource에는 max pool size를 정하는 setMaxPoolSize 메소드가 존재하여 커넥션 수를 설정할 수 있습니다. 앞서서 커넥션 수를 크게 설정하면 메모리 소모가 크다고 했기 때문에 적정량의 커넥션 객체를 생성하는 것이 좋다고 했습니다.

커넥션 풀을 적용할 때 사용되는 설정들은 때에 따라서 굉장히 중요하게 작용하기 때문에 하나하나 공부를 해 두면 좋겠습니다.

// ConnectionManager
public class ConnectionManager {
    private static final String DB_DRIVER = "org.h2.Driver";
    private static final String DB_URL = "jdbc:h2:mem://localhost/~/jdbc-practice;MODE=MySQL;DB_CLOSE_DELAY=-1";
    public static final int MAX_POOL_SIZE = 10;

    private static final DataSource ds;

    static {
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setDriverClassName(DB_DRIVER);
        hikariDataSource.setJdbcUrl(DB_URL);
        hikariDataSource.setUsername("sa");
        hikariDataSource.setPassword("");
        hikariDataSource.setMaximumPoolSize(MAX_POOL_SIZE);
        hikariDataSource.setMinimumIdle(MAX_POOL_SIZE);

        ds = hikariDataSource;
    }

    public static Connection getConnection() {
        try {
            return ds.getConnection();
        } catch (SQLException e) {
            throw new IllegalStateException(e);
        }
    }

    public static DataSource getDataSource() {
        return ds;
    }

}

위 코드에서 리팩토링 된 부분은 다음과 같습니다.

1. 문자열을 변수에 그대로 주지 않고 상수화 하여 private static final 필드로 따로 빼서 사용. (Ctrl + Alt + c 커맨드로 쉽게 할 수 있음)

2.  DataSource를 설정하는 부분을 ds로 통합 -> 커넥션 풀을 하나만 가지도록 

 

//UserDao
con = ConnectionManager.getConnection();

다른 클래스에서 getConnection 메소드로 커넥션을 가져오기 위해서 ConnectionManager로부터 위 코드를 통해 가져올 수 있게 되었습니다.

 

여기까지 하고 테스트 코드를 돌려보면 성공적으로 테스트가 통과할 것입니다.

 

 

이제 UserDao에서 preparedStatement와 connection 받아오는 부분을 깔끔하게 가져오도록 리팩토링 해 보겠습니다.

// JdbcTemplate
public class JdbcTemplate {
    public void executeUpdate(User user, String sql, PreparedStatementSetter pss) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = ConnectionManager.getConnection();
            pstmt = con.prepareStatement(sql);
            pss.setter(pstmt);

            pstmt.executeUpdate();
        } finally {
            if (pstmt != null) {
                pstmt.close();
            }

            if (con != null) {
                pstmt.close();
            }
        }
    }

1. 먼저 JdbcTemplate이라는 클래스를 하나 만들도록 합니다. 앞으로 이 클래스는 라이브러리와 같이 사용할 것입니다. 그러기 위해서 UserDao의 create 메소드 부분을 모두 JdbcTemplate으로 옮겨 줍니다. 이 때 메소드 명은 create이 아니라 executeUpdate를 하는 것이 목적이기 때문에 executeUpdate로 이름을 바꾸어 줍니다.

 

2. 그 안에서 임의로 하드코딩한 sql은 외부로부터 가져온 값을 사용해야 합니다. (인자에 추가)

3. 또한 preparedstatement 값 pstmt로 쿼리 값을 설정하였는데 이 값 또한 외부로부터 가져올 수 있기 때문에 인자에 추가합니다.

하지만, 여기서 문제가 생길 것입니다. pstmt는 가져온 connection 객체를 기반으로 만들어지기 때문에 (pstmt = con.preparedStatement(sql);) pstmt가 인자로부터 오기 위해서 이 메소드를 호출한 곳(외부)에서는 connection에 대한 정보가 필요하고 이는 사실상 불가능하기 때문입니다.

 

그렇기 때문에 pstmt 자체를 받아오는 것이 아니라 pstmt를 세팅하는 form만 받아오면 됩니다. 따라서 PreparedStatementSetting이라는 인터페이스를 두어 컨트롤 할 수 있을 것입니다.

// PreparedStatementSetter
public interface PreparedStatementSetter {
    void setter(PreparedStatement pstmt) throws SQLException;
}

그리하여 인자에 PreparedStatement pstmt 대신 PreparedStatementSetter pss 가 들어가게 된 것입니다.

 

호출한 쪽에 preparedStatement를 세팅하여 보내면 JdbcTemplate의 executeUpdate 메소드 내부에서 setter를 호출하게끔하고 자신이 생성한 preparedStatement를 전달하도록 해서 세팅이 되도록 합니다.

 

그렇다면 executeUpdate를 사용하던 create 메소드에서는 JdbcTemplate을 생성하여 그 안의 메소드인 executeUpdate를 사용하게끔 하면 됩니다.

// UserDao
public void create(User user) throws SQLException {
    JdbcTemplate jdbcTemplate = new JdbcTemplate();

    String sql = "INSERT INTO USERS VALUES (?, ?, ?, ?)";
    jdbcTemplate.executeUpdate(user, sql, pstmt -> {
        pstmt.setString(1, user.getUserId());
        pstmt.setString(2, user.getPassword());
        pstmt.setString(3, user.getName());
        pstmt.setString(4, user.getEmail());
    });
}

위 코드에서 pstmt -> { ... } 부분은 앞선 PreparedStatementSetting 의 setter 메소드 부분을 인자로 사용해야 했기 때문에 이를 람다로 구현한 것입니다.

그렇기 때문에 이를 받는 JdbcTemplate의 executeUpdate 메소드에서 pss.setter(pstmt); 라는 코드를 작성하면 connection을 통해 만든 PreparedStatement 를 setting 시켜 pss에 저장하게 됩니다.

 

 

그 다음은 findByUserId 즉, select를 통해 userId에 해당하는 정보를 가져오던 부분도 똑같이 간결화해 보도록 하겠습니다.

// 수정 전 UserDao의 findByUserId
public User findByUserId(String userId) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = getConnection();
            String sql = "SELECT userId, password, name, email FROM USERS WHERE userId = ?";
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, userId);

            rs = pstmt.executeQuery();

            User user = null;
            if (rs.next()) {
                user = new User(
                        rs.getString("userId"),
                        rs.getString("password"),
                        rs.getString("name"),
                        rs.getString("email")
                );
            }
            return user;
        } finally {
            // resource 해제
    }

위 코드는 수정 전 findByUserId 메소드입니다.

 

해당 코드 또한 JdbcTemplate으로 가져와 executeQuery라는 이름으로 사용해 수정하도록 하겠습니다.

public Object executeQuery(String sql, PreparedStatementSetter pss, RowMapper rowMapper) throws SQLException {
    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        con = ConnectionManager.getConnection();
        pstmt = con.prepareStatement(sql);
        pss.setter(pstmt);

        rs = pstmt.executeQuery();

        Object obj = null;
        if (rs.next()) {
            return rowMapper.map(rs);
        }
        return obj;
    } finally {
        // resource 해제
    }
}

1. 해당 코드 안의 sql 변수는 외부에서 가져와야 하는 것은 똑같고 PreparedStatementSetter 또한 바깥으로 부터 가져오기 위해 인자에 추가해 줍니다.

2. 똑같이 pss.setter(pstmt)로 pstmt를 세팅합니다.

3. 새로운 User 객체를 만들어 해당 객체를 반환하였는데 이를 사용하는 쪽에서 전달받도록 하기 위해 이를 중간에서 컨트롤 하는 RowMapper 인터페이스를 만들어줍니다. + 이 RowMapper 도 인자로부터 받아오도록 추가합니다.

4. RowMapper 인터페이스 작성

public interface RowMapper {
    Object map(ResultSet resultSet) throws SQLException;
}

이 부분에서는 어떠한 클래스가 반환이 될 지 모르기 때문에 Object를 반환하고 ResultSet을 인자로 받는 map 메소드를 구현하였습니다.

 

그러면 UserDao의 findByUserId 코드는 JdbcTemplate을 가져와 사용함으로써 굉장히 간단해 집니다.

public User findByUserId(String userId) throws SQLException {
    JdbcTemplate jdbcTemplate = new JdbcTemplate();
    String sql = "SELECT userId, password, name, email FROM USERS WHERE userId = ?";
    return (User) jdbcTemplate.executeQuery(sql,
            pstmt -> pstmt.setString(1, userId),
            resultSet -> new User(
                    resultSet.getString("userId"),
                    resultSet.getString("password"),
                    resultSet.getString("name"),
                    resultSet.getString("email")
            ));
}

모든 것은 사용하는 곳에서 작성하여 보내기만 하면 그 뒤 호출되는 곳에서는 그 값을 받아 사용하기만 하면 됩니다.

앞서서 executeQuery는 Object를 반환하도록 했기 때문에 위 코드에서 (User)로 타입캐스팅(Type-casting)을 해 준 모습입니다.

 

이를 통해 호출자마다 다를 것이기 때문에 변경 되는 사항은 호출자에서 자기 요구사항에 맞게 메소드 인자에 던지는 형태로 구현하였습니다.

 

마지막으로 테스트 코드의 성공으로 안정적인 리팩토링이 성공하였음을 체크하면서 마무리 합니다.

 

이번 포스팅에서 중요하게 생각할 점은 어떻게 리팩토링 해야하는 지가 아니라 hikariCP(hikariDataSource)를 사용해서 Connection Pool을 적용할 수 있으며 여기서 Pool의 size는 모니터링을 통해서 적정량의 크기로 지정할 수 있어야 함을 강조하고 싶었습니다.(WAS 스레드 수와 함께 고려)

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.