DB는 애플리케이션 개발에서 가장 중요한 부분 중 하나이다. Java에서는 어떻게 DB와 연동을 할까? 클라이언트가 애플리케이션 서버에 요청을 보내면 서버는 DB에 연결하여 해당 요청을 처리할 것이다. 그러면 Java에서는 어떻게 여러 종류의 DB와 연동을 하는지 보자.
일반적으로 애플케이션 서버와 DB는 이렇게 이루어져 있을 것이다.
위의 그림을 보면 클라이언트는 웹 서버에 HTTP 요청을 보내면 웹서버는 커넥션 풀에서 쓰레드를 배정하여 애플리케이션에 요청할 것이다. 애플리케이션은 비즈니스 로직을 실행할 것이고 해당 과정에서 DB와의 연동이 이루어질 것이다.
그럼 애플리케이션 서버와 DB 사이를 자세히 살펴보자. 애플리케션서버는 DB와 커넥션을 연결할 것이다. 이때 주로 TCP/IP를 사용해서 연결한다. 그리고 서버는 클라이언트 요청에 해당하는 비즈니스 로직을 수행하면서 SQL을 DB에 전달한다. DB는 SQL을 수행하고 그 결과를 서버에 전달하게된다.
위의 그림처럼 서버와 DB는 연결과정을 맺는다. 이때 문제가 되는 것이 DB마다 연결과정, SQL을 전달하는 방법, 결과를 응답받는 방법이 모두 다르다는 점이다. 그래서 JDBC 이전에는 MySQL이면 MySQL, Oracle이면 Oracle에 해당하는 로직을 작성해야 했다. 이 때문에 개발자는 개발하는 DB에 따라 해당 DB에 관한 공부를 해야만 했고 서버가 DB에 의존하게되는 문제점이 있었다.
이러한 문제점을 해결하기 위해 탄생한 것이 JDBC(Java Database Connectivity)이다. JDBC는 자바에서 DB에 접속할 수 있도록하는 자바 API이다. JDBC는 드라이버가 존재하는 DB에게서 커넥션을 얻고, SQL을 전달하고, 응답 결과를 받을 수 있도록 표준화한 기술이다.
JDBC를 이용하면 개발자는 어떤 DB이든 드라이버만 존재하면 표준 인터페이스만 사용해서 개발이 가능해졌다.
위의 그림처럼 JDBC 인터페이스를 각각의 DB에 맞게 구현되어있는 라이브러리(JDBC 드라이버)를 활용하여 개발을 진행할 수 있게 되었다. 만약 내가 MySQL을 이용한 애플리케이션을 개발 중에 Oracle DB로 변경해야 한다면 JDBC를 사용하지 않았다면 모든 DB와 관련된 코드를 변경해야했을 것이다. 하지만 JDBC를 이용한다면 드라이버 변경과 몇몇 설정을 제외하곤 코드를 수정해야할 부분과 노력은 훨씬 비용적으로 절감이 가능해졌다.
이제 자바 코드를 보면서 JDBC가 어떻게 DB와 커넥션을 맺고 SQL을 전달하고 응답결과를 받는지 알아보자.
서버와 DB가 연동할 때는 크게 3단계가 있다고 했다. 첫 번째는 DB와 커넥션을 맺는 것이다. DB의 커넥션 풀에서 커넥션을 획득하여 세션을 유지해야 한다. 이를 위해서 JDBC Driver Manager와 DB를 연결해야 한다.
public abstract class ConnectionConst {
public static final String URL = "jdbc:mysql://localhost:3306/jdbc_schema";
public static final String USERNAME = "username";
public static final String PASSWORD = "password";
}
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);
}
}
}
ConnectionConst는 DB와 연결할 때 필요한 설정이다. URL, Username, Password를 상수로 선언하여 JDBC DriverManager를 통해서 연결을 시도한다. 이때 연결을 맺는 과정에서 오류가 발생할 수 있으니 try-catch 문으로 감싸 예외처리를 해야한다.
이렇게 되면 라이브러리에 있는 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션을 반환해주게 된다. 클라이언트는 이 반환된 커넥션을 통해 SQL을 전달할 수 있게 되었다.
이제 비지니스 로직에 맞는 SQL 문을 커넥션을 이용하여 DB에 전달해야 할 차례이다. 코드를 보자.
public class ImageDaoWithJDBC {
...
private Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
일단 커넥션을 얻어야 SQL을 전달할 수 있기 때문에 DriverManager로부터 커넥션을 얻어야한다. 그래서 전에 static으로 선언했었던 DBConnectionUtil 클래스의 getConnection을 통해 커넥션을 얻는다. 이 부분은 어떤 SQL을 전달하던지 항상 필요하기 때문에 메서들를 만들어 재활요이 가능하도록 하였다.
public Image save(Image image) throws SQLException {
String sql = "insert into image(name, url) values (?, ?)";
Connection connection = null;
PreparedStatement preparedStatement = null;
connection = getConnection();
try {
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, image.getName());
preparedStatement.setString(2, image.getUrl());
int count = preparedStatement.executeUpdate();
return image;
} catch (SQLException e) {
log.error("DB error", e);
throw e;
} finally {
close(connection, preparedStatement, null);
}
}
/**
select, update, delete는 생략
참조 코드 확인
**/
코드과 굉장히 긴데 자세히 살펴보면 try-catch 부분 때문에 그렇지 실질적인 코드는 반복되는 부분이 많고 복잡하지 않다. save() 메서드를 보자. 일단 DB에 전달할 SQL을 작성한다. 그리고 getConnection()을 통해서 커넥션을 획득한다.(먼저 null이라고 선언한 부분은 try-catch 때문에 어쩔 수 없다.) 획들한 Connection에 PreparedStatement를 지정해주고 SQL을 전달한다. 이때 위의 SQL문에서 ?로 지정해주었던 부분을 setString을 통해서 값을 지정해준다. 문자면 setString, Int형이면 setInt()처럼 각 타입에 따라 지정해준다. 그 후 executeUpdate()를 실행한다. 이는 Statement를 통해 준비된 SQL을 커넥션을통해 DB에 전달한다. 이때 executeUpdate()는 int를 반환하는데 이는 영향을 받은 Row 수를 반환해준다.
이렇게 정상적으로 로직이 수행이 되면 좋겠지만 만약 SQL을 작성하는 부분이나 커넥션을 통해 SQL을 전달하는 과정에서 오류가 발생하여 서버가 다운되는 일을 막기위해 예외처리를 잘해주어야 한다.
마지막으로 가장 중요한 부분 중 하나가 리소스 정리이다. 리소스를 정리를 해야 또 다른 요청이 들어왔을 때 커넥션을 얻고 SQL을 전달할 수 있기 때문이다. 만약 리소스를 정리하지 않으면 커넥션이 계속 유지되어 커넥션 수가 부족하여 장애로 이어질 수 있기 때문이다. 리소스를 정리하는 코드를 보자.
private void close(Connection connection, PreparedStatement preparedStatement, ResultSet resultSet) {
if (resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {
log.error("error", e);
}
}
if (preparedStatement != null) { // 오류 발생시 connection을 닫을 수 없기 때문에 null 검사 후 try-catch로 잡아서 connection도 닫아줘야함
try {
preparedStatement.close();
} catch (SQLException e) {
log.error("error", e);
}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
log.error("error", e);
}
}
}
close() 메서드를 통해 리소스를 정리하였다. close()는 Connection, PreparedStatement, ResultSet을 매개변수로 받는다. 이때 역순으로 리소스를 정리해주어야 하는데 ResultSet은 SQL 실행 후 결과를 DB로 부터 전달받은 객체이다. PreparedStatement는 SQL을 커넥션에 통해 전달할 때 사용하는 객체이고 Connection은 DB 커넥션 객체이다. 따라서 역순으로 ResultSet -> PreparedStatement -> Connection 순으로 close()를 해준다.
close()를 try-catch-finally 중에 finally에 선언한 이유는 만약 try 속 코드 실행 중에 오류가 발생하여 리소스를 정리하지 못하는 경우 리소스 누락이 발생할 수 있기 때문이다. 그래서 오류가 발생하여 catch 부분이 실행되어도 finally를 통해 무조건 실행하여 리소스를 정리할 수 있도록 해주어야 한다.
class ImageDaoWithJDBCTest {
ImageDaoWithJDBC imageDaoWithJDBC = new ImageDaoWithJDBC();
@AfterEach
void truncateImageTable() throws SQLException {
imageDaoWithJDBC.truncate();
}
@Test
void saveImageTest() throws SQLException {
//given
Image image = new Image(1, "url1", "이름1");
//when
Image saveImage = imageDaoWithJDBC.save(image);
//then
assertThat(image).isEqualTo(saveImage);
}
@Test
void findImageByIdTest() throws SQLException {
//given
Image image = new Image(1, "url1", "이름1");
imageDaoWithJDBC.save(image);
//when
Image foundImage = imageDaoWithJDBC.findById("1");
//then
assertThat(image).isEqualTo(foundImage);
}
@Test
void updateImageTest() throws SQLException {
//given
Image image = new Image(1, "url1", "이름1");
imageDaoWithJDBC.save(image);
Image newImage = new Image(1, "이름2", "url2");
//when
Image updatedImage = imageDaoWithJDBC.updateImage(newImage);
//then
assertThat(updatedImage).isEqualTo(newImage);
}
@Test
void deleteImageTest() throws SQLException {
//given
Image image = new Image(1, "url1", "이름1");
imageDaoWithJDBC.save(image);
//when
imageDaoWithJDBC.delete(String.valueOf(image.getId()));
//then
assertThatThrownBy(() -> imageDaoWithJDBC.findById(String.valueOf(image.getId())))
.isInstanceOf(NotFoundImageException.class);
}
}
이렇게 작성한 비즈니스 로직에 따라 실제 DB와 연결해서 테스트 코드를 작성해보았다. JDBC를 통해 DB와 연결하여 CRUD를 작성하고 검증해 보았다. 이 테스트는 반복적으로 수행해도 똑같은 결과를 도출해야 하기 때문에 매 테스트 후에 truncate를 통해 데이터를 삭제했다.
JDBC의 등장으로 개발자는 더 이상 DB에 따라 코드를 수정하지도 않아도 되고 DB를 공부하지 않아도 되었다. 하지만 이러한 JDBC도 한계가 존재했다. DB마다 사용하는 SQL이 아무리 표준화하려고 했지만 결국 DB마다 SQL 사용법이 약간씩은 차이가 존재했고 이를 표준화하는 것은 어려웠다. 또한 위의 예시 코드를 보면 알 수 있듯이 반복되는 코드가 많이 존재할 수 밖에 없고 예외처리도 복잡하다.
이러한 한계를 극복하기 위해서 등장한 기술들이 SQL Mapper, ORM이다. SQL Mapper는 JDBC를 편리하게 이용할 수 있도록 도와주는 기술이다. 반복되는 코드를 제거하고 SQL 응답결과를 객체로 편리하게 변환시켜준다. 이 SQL Mapper는 JDBC의 코드 작성의 불편함을 극복해주는 기술로 Spring의 JDBCTemplate과 MyBatis 등이 있다. 또다른 기술은 ORM(Object Relational Mapping)이다. ORM 기술은 관계형 데이터베이스(RDBMS) 테이블과 매핑해주는 기술이다. ORM은 자동으로 SQL을 동적으로 작성해주고 DB마다 다른 SQL 문제도 해결해주어 개발자를 매우 편리하게 만들어준다. 이를 통해 매우 생산성이 높아지지만 SQL Mapper는 SQL만 알면 러닝커브가 낮지만 ORM 기술은 러닝 커브가 매우 높아 따로 편리한만큼 공부해야할 양이 많다. 대표적인 ORM 기술로는 JPA가 있다.
결국 ORM 기술도 SQL Mapper도 JDBC를 기반으로 동작하는 기술들이다. JDBC가 어떻게 동작하고 어떻게 자바에서 DB를 이용하는지 이 글을 통해 알게되었으니 ORM, SQL Mapper를 이용하여 코드를 작성해도 비교적 이해가 잘될것 같다.
참고