Intellij) JDBC 프로젝트 (8) DAO 인터페이스와 클래스

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

(7) Entity 클래스와 롬복(Lombok)