0. 들어가기 전에.. - 웹 어플리케이션 요청 응답 프로세스
자바를 베이스로 한 웹 어플리케이션을 포함한 모든 언어의 웹 어플리케이션의 대략적인 구조는 다음과 같다. 먼저 유저가 웹사이트에 접근해 정보를 요청하면 웹 서버에 탑재된 WAS(Web Application Server)는 응답을 주는데, 이 때 해당 요청이 DB 데이터를 필요로 하거나 데이터를 저장해야 한다면 DB에 접근한다.
자바에 한정해 좀 더 구체화해보자. 예를 들어 Web Application은 Spring, WAS은 Tomcat, DB는 MySQL을 사용한다고 가정해보자.
(1) Tomcat이 열려있는 웹서버 포트에 유저의 요청이 도착하면 (2) Tomcat은 HttpServletRequest와 HttpServletResponse객체를 생성하고 (3) Spring의 DispatcherServlet에 요청을 전달한다. (4) DispatcherServlet은 요청을 적절한 컨트롤러로 전달하고 (5) 요청을 전달받은 컨트롤러는 요청을 처리하기 위해 Service 계층과 상호 작용해 비지니스 로직을 수행하고 반환할 모델 데이터를 준비한다. (6) 이때 MySQL과 상호 작용이 필요하다면 MySQL이 열려있는 포트에 접속을 요청하고 작업을 수행한다. (7) 그리고 최종적인 결과를 다시 DispacherServlet이 적절한 형식으로 변환해 HttpServletResponse객체에 기록한 뒤 (8) Tomcat이 최종적으로 응답을 반환한다.
우리는 이중 여섯번째 프로세스인 DB와의 상호작용에 주목할 것이다.
1. Java와 Database의 상호작용 - Socket
자바 프로그램과 DB와의 상호작용을 더 디테일하게 이해하기 위해 본질적인 부분부터 살펴보자.
자바 프로그램이나 데이터베이스와 같은 프로세스가 데이터를 프로세스 바깥으로 내보내거나 받기 위해서는 소켓(Socket)이 필요하다. 소켓이란 일반적인 정의로 A파이프의 끝과 B파이프의 끝을 연결하기 위한 이음새를 말한다. 쉽게 말해, 어떤 다른 두 물체를 연결하기 위한 접점을 말한다. 이 정의를 네트워크 분야에서의 의미로 확장해보자. 네트워크 소켓(Network socket)은 컴퓨터 네트워크를 경유하는 프로세스 간 통신의 종착점이다. 즉 A 프로세스와 B 프로세스가 통신하기 위해서는 네트워크(파이프)를 거쳐야 하는데, 이 때 A, B프로세스가 네트워크에 연결하기 위한 각각의 통신 접점이 소켓이다.
결론적으로 모든 데이터베이스는 데이터를 받아들이고 보내는 소켓이 있다. 데이터베이스는 특정 포트(MySQL 기본 포트 : 3306, H2 기본 포트 : 8080 등..)에 바인딩하고 클라이언트로부터 들어오는 연결 요청을 기다린다. 그리고 연결이 수락되면 새로운 Socket 인스턴스를 만들어 클라이언트와의 통신을 수행한다. 이는 기본적인 서버-클라이언트 통신 방법이며, 간단한 자바 코드로 예제를 만들 수 있다.
Java - DB 연결 예제
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class SimpleExampleDB {
private static final int port = 9990;
public static void main(String[] args) {
// 특정 포트에 바인딩
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("서버 Listening Port = " + port);
// 클라이언트 요청 대기
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("클라이언트 연결됨");
new ClientHandler(clientSocket).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 클라이언트 요청 수신 시 새로운 소켓 스레드 생성 클래스
*/
private static class ClientHandler extends Thread {
private final Socket clientSocket;
public ClientHandler(Socket clientSocket) {
this.clientSocket = clientSocket;
}
// 스레드가 동작하기 위해 run 메서드 오버라이드
@Override
public void run() {
try (InputStream in = clientSocket.getInputStream();
OutputStream out = clientSocket.getOutputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
PrintWriter writer = new PrintWriter(out, true)) {
String message;
while ((message = reader.readLine()) != null) {
System.out.println("클라이언트로부터 수신받은 메시지: " + message);
writer.println("저장완료");
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
import java.io.*;
import java.net.Socket;
public class SimpleExampleJavaProgram {
static final String serverAddress = "localhost";
static final int port = 9990;
public static void main(String[] args) {
try (Socket socket = new Socket(serverAddress, port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
PrintWriter writer = new PrintWriter(out, true)) {
System.out.println("DB와 연결됨");
// DB로 메시지 보내기
String message = "DB야! memberA를 저장해줘";
writer.println(message);
// DB로부터 수신받은 메시지
String response = reader.readLine();
System.out.println("DB로부터 받은 메시지: " + response);
} catch (IOException e) {
e.printStackTrace();
}
}
}
로직은 다음과 같다.
(1) 서버를 모사한 SimpleExampleDB는 데이터를 수신할 ServerSocket을 만들고 9990 포트에 바인딩한다. (2) 이제 클라이언트를 모사한 SimpleExampleJavaProgram은 Socket을 만들고 두 프로세스 간의 논리적인 접속을 성립(establish)하기 위해 9990포트의 프로세스와 3-Way Handshake를 수행한다. (3) 서로 간의 논리적인 접속이 확인됐다면 SimpleExampleDB는 해당 클라이언트와 연결을 유지하기 위해 새로운 쓰레드인 ClientHandler를 생성하고 클라이언트로부터 수신을 기다린다. (4) 클라이언트인 SimpleExampleJavaProgram은 DB를 향해 전하고자 하는 메시지(여기서는 'DB야! memberA를 저장해줘')를 던지고, DB는 해당 메시지를 수신한 뒤 적절한 로직을 수행하고 다시 클라이언트에게 메시지를 전달한다.
간략한 예제지만 이는 자바와 데이터베이스가 통신하는 과정을 전체적으로 나타낸다. 메시지가 'DB야! memberA를 저장해줘'가 아니라 DB가 이해할 수 있는 메시지라면 DB는 적절한 로직을 수행해 자바 프로그램에게 적절한 결과를 전달해 줄 것이다.
2. JDBC의 도입
자바와 데이터 베이스 간의 통신은 위 절과 같은 방법으로 수행할 수 있다. 하지만 큰 문제점들이 있다. 위의 그림과 같이 데이터베이스 벤더는 너무 다양하다. 각 데이터베이스 벤더의 작동방식은 모두 제각각이고 보안 방식도 다르다. 따라서 자바 프로그램에서 특정 데이터베이스를 사용하기 위해서는 해당 데이터베이스가 요구하는 규격에 맞춰서 코드를 작성해야 했다. 그런데 만약 MySQL에서 PostgreSQL로 마이그레이션을 해야한다면..?
자바 진영은 이러한 문제를 고민했고, 1997년 JDK 1.1에서 Microsoft ODBC 방식을 차용한 JDBC를 소개했다. JDBC는 각 데이터베이스 벤더들이 통일된 양식을 따르도록 DB 연결 과정을 표준화한 API 규격이다.
Java가 JDBC를 이용해 문제를 해결한 방식은 다음과 같다.
'야 너네 다 다른 거 알겠어. 그니까 내가 기준만 줄게. 여기에 맞춰서 같은 방식으로 실행 가능하도록 만들어.'
이때부터 각 데이터베이스 벤더들은 Java의 기준인 JDBC 인터페이스를 자기들의 제품에 맞게 구현한 JDBC Driver를 만들었다. 이로써 각 벤더의 JDBC Driver 구현체는 전부 다르게 생겼지만 연결 방법이 통일되어 개발자들은 해당 제품에서 제공하는 JDBC Driver를 자바 코드에 추가만 해도 같은 코드로 데이터베이스와 연결할 수 있게 되었다.
JDBC Driver는 데이터베이스 벤더에서 대부분 지원한다. 예를 들면 MySQL은 Connector/J라는 이름으로 지원하고, Oracle은 제품 다운로드 시 /oraclexe/app/oracle/product/버전/server/jdbc/lib 안에 ojdbc버전.jar라는 이름으로 지원한다. H2도 /H2/bin 안에 h2-버전이름.jar가 있고, PostgreSQL도 pgJDBC라는 이름으로 JDBC를 지원한다. 그 밖의 데이터베이스도 데이터베이스 + JDBC라고 검색하면 된다.
이제 JDBC의 동작 흐름을 살펴보자.
3. JDBC API
JDBC API의 전체적인 동작 흐름은 다음과 같다. 앞서 말한 것처럼 JDBC API는 인터페이스이다. 실제로는 각 인터페이스를 구현한 각 벤더의 JDBC Driver 구현체를 통해 각 벤더의 데이터베이스에 일관적으로 접근할 수 있다.
위의 그림에서 보이는 인터페이스들을 간단히 설명하면, DriverManager는 각 벤더의 Driver 구현체를 등록하고 Driver로부터 DB와 전체적인 연결을 유지하는 Connection 인터페이스를 획득한다.
DB와 연결을 유지하는 Connection 인터페이스는 내부적으로 세션(Session)을 생성한다. 세션은 논리적인 개념으로 한 유저의 로그인 상태를 의미하며, 보통 Connection과 1:1 대응된다.
추후에 더 자세히 설명하겠지만, Connection이 단 하나만 존재한다고 가정하자. 이 때 하나의 유저가 DB와 상호작용 하기 위해 Connection을 차지하고 있다면, 다른 유저는 기존 유저가 작업을 끝낼 때까지 대기해야 한다. 이러한 문제점을 해결하기 위해 애플리케이션은 일반적으로 Connection을 다수 만들어 놓고 상호작용이 필요할 때마다 유저가 비어있는 Connection을 차지해 사용하는 커넥션 풀(Connection Pool)을 만들어 사용한다.
다음으로 Connection 인터페이스로부터 SQL 쿼리를 실행할 수 있는 Statement 인터페이스를 얻는다. Statement 인터페이스를 통해 SQL 쿼리문을 DB에 전달해 결과들의 컬렉션인 ResultSet 인터페이스를 얻거나 INSERT, UPDATE 등의 쿼리를 실행한다.
이제 주요 클래스인 DriverManager에 대해 좀 더 자세히 알아보고 실제 데이터베이스와 연결하는 예제를 알아보자.
4. JDBC 주요 클래스 - DriverManager
각 벤더의 JDBC Driver를 열어보면 META-INF\services\java.sql.Driver가 있다. 이를 열어보면 java.sql.Driver 인터페이스를 해당 벤더가 구현한 Driver 클래스의 위치가 기록되어 있다.
Driver의 구현 방법은 벤더마다 다르지만 공통적으로 클래스 로딩 시에 DriverManager의 registerDriver 메서드를 호출하도록 되어있다. 이는 JDBC의 규칙에 따른 것으로 애플리케이션 시작 시에 각 Driver가 registerDriver()를 호출하면 해당 Driver객체가 DriverManager의 registeredDrivers 리스트에 추가된다. 아래의 DriverManager 클래스 일부를 보자.
package java.sql;
public class DriverManager {
// List of registered JDBC drivers
private static final CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
/**
* Registers the given driver with the {@code DriverManager}.
* A newly-loaded driver class should call
* the method {@code registerDriver} to make itself
* known to the {@code DriverManager}. If the driver is currently
* registered, no action is taken.
*
* @param driver the new JDBC Driver that is to be registered with the
* {@code DriverManager}
* @param da the {@code DriverAction} implementation to be used when
* {@code DriverManager#deregisterDriver} is called
* @throws SQLException if a database access error occurs
* @throws NullPointerException if {@code driver} is null
* @since 1.8
*/
public static void registerDriver(java.sql.Driver driver,
DriverAction da)
throws SQLException {
/* Register the driver if it has not already been added to our list */
if (driver != null) {
registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
} else {
// This is for compatibility with the original DriverManager
throw new NullPointerException();
}
println("registerDriver: " + driver);
}
}
registerDriver 메서드 주석을 살펴보면 다음과 같은 설명이 있다.
Registers the given driver with the DriverManager. A newly-loaded driver class should call the method registerDriver to make itself known to the DriverManager.
즉, 해당 Driver를 DriverManager에 등록하는 메서드이며, 새롭게 로드되는 Driver 클래스는 DriverManager에게 자신을 알리기 위해 registerDriver()메서드를 호출해야 한다고 친절하게 설명해주고 있다. DriverInfo는 wrapper 클래스로 Driver.equals() 메서드의 노출을 막기 위해 사용한다.
그럼 H2 데이터베이스의 Driver 구현체는 어떻게 구현되어 있을까.
package org.h2;
public class Driver implements java.sql.Driver, JdbcDriverBackwardsCompat {
private static final Driver INSTANCE = new Driver();
private static boolean registered;
static {
load();
}
public static synchronized Driver load() {
try {
if (!registered) {
registered = true;
DriverManager.registerDriver(INSTANCE);
}
} catch (SQLException e) {
DbException.traceThrowable(e);
}
return INSTANCE;
}
}
보다시피 Driver 클래스 로딩 시에 static 키워드를 통해 load() 메서드를 실행하고, 다시 load()메서드에서 DriverManager.registerDriver(INSTANCE) 메서드를 실행하는 것을 알 수 있다. MySQL 데이터베이스는 어떻게 구현해놨을까?
package com.mysql.cj.jdbc;
import java.sql.SQLException;
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}
public Driver() throws SQLException {
// Required for Class.forName().newInstance().
}
}
구현방식은 조금 다르지만 어쨌든 java.sql.DriverManager.registerDriver(new Driver())를 호출해 규칙을 만족하고 있다.
5. JDBC 예제
import java.sql.*;
public class DBConnectionExample {
public static void main(String[] args) {
// tcp프로토콜을 통해 h2의 dbtest 데이터베이스에 접근
String jdbcUrl = "jdbc:h2:tcp://localhost/~/dbtest";
// 유저 이름
String username = "sa";
// 비밀번호
String password = "";
// Connection 객체 및 Statement 객체 획득
try (Connection conn = DriverManager.getConnection(jdbcUrl, username, password);
Statement stmt = conn.createStatement()) {
System.out.println("conn = " + conn.getClass());
// member 테이블 존재한다면 드랍
String dropTableSQL = "DROP TABLE IF EXISTS member";
stmt.execute(dropTableSQL);
// member 테이블 생성
String createTableSQL = "CREATE TABLE member (id INT PRIMARY KEY, name VARCHAR(255))";
stmt.execute(createTableSQL);
System.out.println("Table 'member' created.");
// member 넣기
for (int i = 1; i < 11; i++) {
String insertSQL = "INSERT INTO member (id, name) VALUES (" + i + ", 'member" + i + "')";
stmt.executeUpdate(insertSQL);
}
// PreparedStatement 객체 생성 및 미리 컴파일
PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM member WHERE name LIKE ?");
// 동적으로 sql문을 완성
pstmt.setString(1, "%member%");
ResultSet resultSet = pstmt.executeQuery();
while (resultSet.next()) {
System.out.println("id = " + resultSet.getInt("id") +
" name = " +
resultSet.getString("name"));
}
resultSet.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
conn = class org.h2.jdbc.JdbcConnection
Table 'member' created.
id = 1 name = member1
id = 2 name = member2
id = 3 name = member3
id = 4 name = member4
id = 5 name = member5
id = 6 name = member6
id = 7 name = member7
id = 8 name = member8
id = 9 name = member9
id = 10 name = member10
간단한 예제를 만들었다. 특징적인 것만 간단히 언급하면, DriverManager.getConnection()메서드에 jdbcUrl과 username, password를 파라미터로 넘겨 Connection 객체를 획득하고, Connection 객체로부터 실질적으로 SQL 쿼리문을 실행할 Statement객체를 획득한다. Statement객체의 execute()메서드를 사용해 SQL 쿼리문을 실행할 수 있다.
또 Connection 객체로부터 PreparedStatement 객체를 얻을 수 있는데, 이 객체는 SQL 쿼리문을 미리 컴파일 해두고 ?부분을 나중에 동적으로 채워넣을 수 있는 객체다. SQL Injection같은 공격을 차단할 수도 있고, 필요한 부분을 나중에 채워넣을 수 있으므로 여러모로 Statement보다 쓸모있는 객체다.
ResultSet객체는 조회한 결과 값을 순차적으로 접근할 수 있는 객체다. 물론 조회한 데이터를 전부 가지고 있는 게 아니라 인덱스 정보만을 가지고 있어서 next()메서드와 while문 조합을 통해 차례로 호출해 데이터를 뽑아내야 한다. 또한 위의 코드의 while문 안에서 next()메서드가 먼저 1회 호출되는 것을 볼 수 있다. 즉 next()메서드를 1회 호출하기 전까지는 최초 데이터의 위치에 자리 잡고 있지 않기 때문에 주의해야 한다.
사용하면 할수록 고대의 방식은 복잡하기 그지없다. 이제 조금 더 최신의 방식을 알아보도록 하자.
'프로그래밍 > Java' 카테고리의 다른 글
[Java DB] 2-2. MyBatis - 실제 사용하기 (활용편) (0) | 2024.06.17 |
---|---|
[Java DB] 2-1. MyBatis - SQL 쿼리를 재사용하자 (개념편) (0) | 2024.06.12 |
[Java] JDK 부수기 - (3) java.lang.Math (0) | 2023.12.20 |
[Java] JDK 부수기 - (4) java.lang.ClassLoader (0) | 2023.12.13 |
[Java] JDK 부수기 - (3) java.lang.Class (0) | 2023.12.04 |