Intellij) JDBC 프로젝트 (8) DAO 인터페이스와 클래스
그리고 Null로부터 안전한 Prepared Statement 만들기
(글 이동 목차 삽입 예정)
Insert 구현
우선 DAO 클래스를 만들고, INSERT문을 수행하는 메서드를 작성해 봅시다.
이때 우리는 User 테이블의 CRUD를 담당할 DAO 인터페이스와 DAO 클래스 한 쌍을 만들 겁니다.
인터페이스를 두는 이유는, 나중에 확장에 유리하기 때문입니다.
UserDao 인터페이스
- package:
com.example.app.auth.dao
package com.example.app.auth.dao;
import com.example.app.auth.domain.User;
public interface UserDao {
int insert(User user);
}
지금은 insert만 구현할 것이기 때문에 메서드도 하나만 만들었습니다. 인터페이스는 메서드가 모두 public
접근 지정자를 기본값으로 갖기 때문에 접근 지정자 작성을 생략할 수 있습니다.
PostgresqlUserDao 클래스
보통 인터페이스-클래스가 일대일 관계로 예상되거나, 인터페이스가 하나의 클래스와만 밀접할 것으로 예상할 때 선택하는 네이밍 컨벤션은 다음과 같습니다.
(suffix)
UserDaoImpl
: 오랫동안 선택되어 온 네이밍 관례입니다. 아직 채택율이 가장 높습니다.(prefix)
DefaultUserDao
: 마치 헝가리안표기처럼 '프로그래밍 신택스'와 관련한 표현을 네이밍 관례로 사용하는 것에 반발하는 사람들은-Impl
접미사보다Default-
접두사가 더욱 직관성에 맞는다고 주장합니다. 저 또한 이 방식을 더 권합니다.
이것은 알아만 둡시다.
우리는 조금 더 명확하게 이 DAO가 Postgresql용 SQL 신택스를 사용하고 있음을 명시하기 위해서 PostgersqlUserDao
라고 명명할 수 있습니다. (나중에는 SQL을 자바 소스 코드가 아닌 다른 파일에서 관리할 수도 있습니다. 이것을 자바 프로그램이 읽어서 실행할 수 있죠. 이번에는 코드에 바로 씁니다.)
INSERT SQL 작성
JDK 15 이상에서는 여러 줄 문자열을 작성할 수 있는 '텍스트 블록'을 제공합니다. insert
메서드 안에 다음처럼 SQL 문자열을 작성해 둡시다.
SQL 문자열의 끝에 세미콜론은 생략해야 합니다.
보통 콤마(,)를 빠뜨리거나 마지막에 더 넣는 등 실수를 많이 합니다. 오타 점검 시 참고하세요.
이 SQL 문자열 중 우리가 나중에 채울 데이터를 물음표(?) 기호로 사용합니다.
// 첫 줄은 """로 시작하고 우측을 비웁니다.
String sql = """
INSERT INTO "user" (
username,
password,
nickname,
status,
created_at,
updated_at
) VALUES (
?, -- username
?, -- password
?, -- nickname
?, -- status
?, -- created_at
? -- updated_at
)"""; // 마지막 줄은 """로 마칩니다.
// 전체 라인 중 가장 왼쪽에서 시작하는 것이 좌측 패딩(padding)의 기준이 됩니다.
// 의도적으로 띄어쓰기를 더 추가하는 경우 \s 기호로 추가할 수 있습니다.
DB 접속 및 INSERT 수행
이제 Connection을 만들고, 이 커넥션을 사용할 PreparedStatement라는 것을 만들 겁니다.
connection: DB에 접속하는 데 필요합니다. 이 커넥션을 통해 DB와 소통하게 됩니다.
prepared statement: SQL 구문을 다룹니다. 앞서 작성한 물음표를 채우는 등 작업도 이 객체로 수행합니다.
connection을 생성하는 코드는 매번 바로 떠오를 만큼 직관적이지는 않기 때문에, private 메서드를 만들어서 활용하겠습니다. getConnection()
메서드입니다.
public class PostgresqlUserDao implements UserDao {
private final String url;
private final String username;
private final String password;
public PostgresqlUserDao(String url, String username, String password) {
this.url = url;
this.username = username;
this.password = password;
}
// (intellij) Ctrl + I 키를 눌러서 메서드 목록을 자동완성 할 수 있습니다.
@Override
public int insert(User user) {
// 주의: SQL 끝에 세미콜론을 생략해야 합니다.
String sql = """
INSERT INTO "user" (
username,
password,
nickname,
status,
created_at,
updated_at
) VALUES (
?, -- username
?, -- password
?, -- nickname
?, -- status
?, -- created_at
? -- updated_at
)""";
// try~catch~resources 구문으로 close를 보장합니다.
try (
Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)
) {
// N 번째 물음표임을 나타냅니다. 1-based 인덱싱을 하기 때문에 nth(N 번째)로 명명했습니다.
int nth = 1;
statement.setString(nth++, user.username);
statement.setString(nth++, user.password);
statement.setString(nth++, user.nickname);
statement.setString(nth++, user.status.name());
Timestamp createdAt = Timestamp.from(user.createdAt);
statement.setTimestamp(nth++, createdAt);
// 지금은 그냥 사용하지만 이런 코드에서 오류가 발생할 예정입니다. (뒷부분 설명에서 고칠게요.)
Timestamp updatedAt = Timestamp.from(user.updatedAt);
statement.setTimestamp(nth, updatedAt);
// insert, update 등
// DB 데이터에 변경을 주는 작업은 executeUpdate로 사용합니다.
return statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private Connection getConnection() {
return DriverManager.getConnection(url, username, password);
}
}
중요한 것은, 원래 conneciton, statement 등을 사용 후 .close()
를 수행해 주는 것입니다. 이것을 원래는 finally
블록에서 작성하곤 했죠. 지금은 try catch resources 구문으로 close()
를 보장해 줍니다.
// 우리는 직접 작성하지 않아도 되도록 try~catch~resources를 사용했습니다.
connection.close();
statement.close();
Null 처리 대행자
앞서 주석 중에는 오류가 발생할 예정이라는 내용이 있었습니다. 원래는 실행해 본 다음 오류를 접하고, 그 메시지를 보면서 공부해 가는 것도 좋을 것입니다.
그래도 빠르게 따라해 보는 글의 취지상 스포를 해 보자면, 저때 우리가 접할 예외는 통상 NPE
라고 하는 NullPointerException
입니다. Timestamp.from(...)
함수는 파라미터로 null
을 받으면 NPE
를 띄웁니다.
참고: NullPointerException이 발생하는 경우들
NullPointerException은 null
인 객체를 통해 하위 속성에 접근하려고 할 때, 이미 null
포인터에서 접근을 시도하고 있다는 것을 알려 주는 예외입니다. 말이 어렵죠?
우리가 작성한 코드에서 NullPointerException이 발생한다면 다음 중 하나입니다.
(우리가 직접 작성한 코드에서 발생한다면) 점 앞에 온 객체가
null
인 경우입니다.// given: someNullObject = null; someNullObject.field; // NPE someNullObject.doSomething(); // NPE
(우리가 이용한 라이브러리의 함수를 이용할 때 발생한다면) 매개변수로 넘겨 준 것이
null
입니다.user.updatedAt = null; Timestamp updatedAt = Timestamp.from(user.updatedAt); // NPE
Null 처리 대행자 클래스
우리는 앞서 statement.setString(nth++, user.username);
같은 코드를 작성했습니다. 그런데 이 모든 과정에서 null
처리를 해 줄 필요가 있을 수 있습니다.
그래서 null
처리를 기본적으로 담당해 주는 클래스로 대체하려고 합니다.
setString, setTimestamp, setLong 등 우리가 자주 사용하는 것들만 구현해 주어도 됩니다.
저는 참고용으로 몇 가지 메서드를 더 추가했습니다. 메서드 하나하나는 비슷하게 생겼을 거예요.
각 함수가 하는 일은 결국
statement.set머시기(nth, 값);
형태입니다. 여기에 null safe 처리만 추가되어 있습니다.AutoClosable
인터페이스를 구현하였고,close()
를 오버라이딩 하여statement.close()
를 병행합니다.AutoClosable
인터페이스를 구현하면 try catch resources에 선언할 수 있습니다.setNullOrSkip
메서드는 삽입할 매개변수가null
인 경우null
을 그대로 넣고,null
이 아닌 경우는 그대로 종료되는 메서드입니다.null
을 넣었다면true
를 반환합니다.
package example.common.support.jdbc;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.Instant;
import java.util.UUID;
public class NullSafePreparedStatement implements AutoCloseable {
// delegation (위임)
private final PreparedStatement statement;
public NullSafePreparedStatement(PreparedStatement statement) {
this.statement = statement;
}
public void setLong(int parameterIndex, Long value) throws SQLException {
if (setNullOrSkip(parameterIndex, value, Types.BIGINT)) {
return;
}
statement.setLong(parameterIndex, value);
}
public void setString(int parameterIndex, String value) throws SQLException {
if (setNullOrSkip(parameterIndex, value, Types.VARCHAR)) {
return;
}
statement.setString(parameterIndex, value);
}
public void setInt(int parameterIndex, Integer value) throws SQLException {
if (setNullOrSkip(parameterIndex, value, Types.INTEGER)) {
return;
}
statement.setInt(parameterIndex, value);
}
public void setTimestamp(int parameterIndex, Instant value) throws SQLException {
if (setNullOrSkip(parameterIndex, value, Types.TIMESTAMP)) {
return;
}
Timestamp timestamp = Timestamp.from(value);
statement.setTimestamp(parameterIndex, timestamp);
}
public void setTimestamp(int parameterIndex, Timestamp value) throws SQLException {
if (setNullOrSkip(parameterIndex, value, Types.TIMESTAMP)) {
return;
}
statement.setTimestamp(parameterIndex, value);
}
public void setUuid(int parameterIndex, UUID value) throws SQLException {
if (setNullOrSkip(parameterIndex, value, Types.BINARY)) {
return;
}
statement.setObject(parameterIndex, value);
}
public ResultSet executeQuery() throws SQLException {
return statement.executeQuery();
}
public int executeUpdate() throws SQLException {
return statement.executeUpdate();
}
@Override
public void close() throws SQLException {
statement.close();
}
private boolean setNullOrSkip(int parameterIndex, Object value, int typesValue) throws SQLException {
boolean isNull = value == null;
if (isNull) {
statement.setNull(parameterIndex, typesValue);
}
return isNull;
}
}
어쩌다 보니 코드가 굉장히 길었지만, 이로써 안전하게 사용할 수 있게 되었습니다. 앞서 작성한 DAO의 코드를 다음처럼 안전하게 고쳐 봅시다.
try (
Connection connection = getConnection();
// 변수명 중복을 피하기 위해 preparedStatement로 변수명 변경
PreparedStatement preparedStatement = connection.prepareStatement(sql);
NullSafePreparedStatement statement = new NullSafePreparedStatement(preparedStatement)
) {
// 우리가 새로 만든 statement 사용
int nth = 1;
statement.setString(nth++, user.username);
statement.setString(nth++, user.password);
statement.setString(nth++, user.nickname);
statement.setString(nth++, user.status.name());
statement.setTimestamp(nth++, user.createdAt);
statement.setTimestamp(nth, user.updatedAt);
// ...
데이터 Insert 확인
package com.example.app;
public class MainApplicaiton {
public void run() {
UserDao userDao = new PostgresqlUserDao();
User user = User.builder()
.username("abc123")
.password("abc123!@")
.nickname("gildong")
.status(UserStatus.ACTIVE)
.createdAt(Instant.now())
.build();
userDao.insert(user);
}
}
이제 메인 함수를 한 번만 실행한 후 DBeaver 등 DB 접속 도구로 데이터가 삽입되었나 확인해 봅시다.
(데이터 확인은 DB 접속 정보를 기억한다면 금방 할 수 있습니다. 검색해 보세요! 나중에 이 글에 추가해 두겠습니다.)
< Prev