[스프링 DB 1편] JDBC 이해
포스트
취소

[스프링 DB 1편] JDBC 이해

프로젝트 생성

  • 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
    • 프로젝트 선택
      • Project
        • Gradle - Groovy Project
      • Language
        • Java
      • Spring Boot
        • 3.x.x
    • Project Metadata
      • Group
        • hello
      • Artifact
        • jdbc
      • Name
        • jdbc
      • Package name
        • hello.jdbc
      • Packaging
        • Jar (주의!)
      • Java
        • 17 또는 21
    • Dependencies
      • JDBC API
      • H2 Database
      • Lombok

테스트를 위한 추가 설정

  • 테스트 코드에서도 롬복을 사용할 수 있게 아래 코드를 추가해두자.
//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

H2 데이터베이스 설정

  • H2 데이터베이스는 개발 및 테스트 용도로 사용하기 좋은 가볍고 편리한 DB다.
  • SQL을 실행할 수 있는 웹 화면을 제공한다.

설치 방법

  1. 공식 사이트로 이동한다.
  2. 사용하는 스프링 부트 버전에 맞게 H2 DB 설치 파일을 다운로드 받는다.
    • 스프링 부트 2.x를 사용하면 1.4.200 버전을 다운로드 받으면 된다.
    • 스프링 부트 3.x를 사용하면 2.1.214 버전 이상 사용해야 한다.
    • 참고
  3. 다운로드 받은 파일을 실행해서 H2 DB를 설치한다.

실행 방법

  • 경로를 따로 변경하지 않았다면 C:\Program Files (x86)\H2\bin 경로에 h2.bat이라는 파일이 있을텐데 그걸 실행시키면 된다.
  • 윈도우에서는 그냥 윈도우 단축키를 누른 다음에 h2라고 검색해서 H2 Console을 실행하면 된다.

데이터베이스 파일 생성 방법

  1. 사용자명은 sa를 입력한다.
  2. JDBC URL에 본인이 사용할 파일의 경로를 입력한다.
    • H2 DB는 파일 DB이기 때문에 파일로 관리된다.
  3. 연결을 눌러서 데이터베이스 파일을 생성한다.
  4. 파일 경로에 들어가서 파일이 잘 만들어졌나 확인해보자.
    • 만약에 JDBC URL이 jdbc:h2:~/test라면 C:\Users\사용자명 경로에 test.mv.db라고 생성됬을 것이다.
    • 만약 Mac이나 Linux라면 home 디렉토리에 파일이 생성된다.

테이블 생성하기

  • 테이블 관리를 위해 프로젝트 루트에 sql 폴더를 만든 후 schema.sql 파일을 생성하자.
  • 그런 다음에 아래 쿼리를 추가해주자.
drop table member if exists cascade;
create table member (
    member_id varchar(10),
    money integer not null default 0,
    primary key (member_id)
);
insert into member(member_id, money) values ('hi1',10000);
insert into member(member_id, money) values ('hi2',20000);
  • 그리고 해당 쿼리를 H2 콘솔에 붙여넣고 실행해주자.
  • 마지막으로 select * from member;를 실행해서 저장한 데이터가 잘 나오는지 확인하자.

JDBC 이해

애플리케이션을 개발할 때 중요한 데이터는 대부분 데이터베이스에 보관한다.

클라이언트가 애플리케이션 서버를 통해 데이터를 저장하거나 조회하면,
애플리케이션 서버는 데이터베이스를 사용한다.

애플리케이션 서버와 DB

  • 일반적인 사용법
  1. 커넥션 연결
    • 주로 TCP/IP를 사용해서 커넥션을 연결한다.
  2. SQL 전달
    • 애플리케이션 서버는 DB가 이해할 수 있는 SQL을 연결된 커넥션을 통해 DB에 전달한다.
  3. 결과 응답
    • DB는 전달된 SQL을 수행하고 그 결과를 응답한다. 애플리케이션 서버는 응답 결과를 활용한다.

DB를 변경하는 경우

  • 실무를 하다보면 비용이나 버전 변경 등의 이유로 DB를 변경할 때가 있다.
  • 그러나 각 DB마다 다른 점이 있는데 관계형 DB의 종류는 수십 개나 된다.
    • 커넥션을 연결하는 방법
    • SQL을 전달하는 방법
    • 결과를 응답 받는 방법
  • 게다가 2가지 큰 문제가 잇다.
    • 기존 DB를 다른 DB로 변경하면 애플리케이션 서버에 개발된 코드도 해당 DB에 맞는 코드로 변경해야 한다.
    • 개발자가 각각의 DB에 맞는 커넥션 연결 방법, SQL 전달 방법, 결과 응답을 받는 방법을 새로 학습해야 한다.

그래서 이런 문제를 해결하기 위해 JDBC라는 자바 표준이 등장한다.

JDBC 표준 인터페이스

  • JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 표준 API다.
  • JDBC는 데이터베이스에서 자료를 쿼리하거나 업데이트하는 방법을 제공한다.
  • JDBC는 아래 3가지 기능을 표준 인터페이스로 정의해서 제공한다.
    • java.sql.Connection
      • 연결
    • java.sql.Statement
      • SQL을 담은 내용
    • java.sql.ResultSet
      • SQL 요청 응답
  • 이렇게 정의된 JDBC 인터페이스를 바탕으로 각각의 DB를 개발한 회사가 자사의 DB에 맞도록 구현해서 라이브러리로 제공한다.
    • 이를 JDBC 드라이버라고 한다.
    • XXX DB에 접근할 수 있다면 XXX JDBC 드라이버라고 한다.

JDBC 등장으로 인한 문제 해결

  • JDBC의 등장으로 2가지의 큰 문제가 해결되었다.
    • 기존 DB를 다른 DB로 변경해도 애플리케이션 서버에 개발된 코드도 해당 DB에 맞는 코드로 변경하지 않아도 된다.
      • 각각의 DB를 개발한 회사가 구현한 라이브러리만 변경하면 된다.
      • 왜냐하면 인터페이스를 구현한 것이기 때문이다.
    • 개발자가 각각의 DB에 맞는 커넥션 연결 방법, SQL 전달 방법, 결과 응답을 받는 방법을 새로 학습하지 않아도 된다.
      • 개발자는 JDBC 표준 인터페이스 사용법만 학습하면 된다.
      • 한 번 배워두면 수많은 DB에 모두 동일하게 적용할 수 있다.
      • 물론 DB를 사용하는 방법이 동일한 거지 DB마다 사용하는 문법이 다른 건 별도의 이야기니 감안해야 한다.

표준화의 한계

  • JDBC를 통해 많은 점이 편리해졌다.
  • 하지만 각각의 데이터베이스마다 SQL 문법이나 데이터 타입 등의 일부 사용법이 다르다.
    • ANSI SQL이라는 표준이 있기는 하지만 일반적인 부분만 공통화했기 때문에 한계가 있다.
    • 대표적인 예시로 페이징 문법이 해당한다.
  • 그래서 DB를 변경하면 JDBC 코드만 안 바뀔 뿐이지 쿼리는 결국 바꿔야 한다.
  • ` JPA(Java Persistence API)`라는 기술을 사용하면 각각의 DB마다 다른 SQL 문법을 사용하는 문제도 “대부분”은 해결할 수 있다.
    • “대부분”을 강조했듯이 100% 해결 가능한 것은 아니다.

JDBC와 최신 데이터 접근 기술

  • JDBC는 1997년에 출시될 정도로 오래된 기술이고, 사용하는 방법도 복잡하다.
  • 그래서 최근에는 JDBC를 직접 사용하기 보다는 JDBC를 편리하게 사용하는 다양한 기술이 존재한다.
    • 대표적으로 SQL Mapper와 ORM 기술로 나눌 수 있다.

SQL Mapper

  • SQL을 XML같은 별도 파일이나 Java 코드에 문자열로 전달해서 쿼리를 실행하는 방법이다.
  • 장점
    • JDBC를 편리하게 사용하도록 도와준다.
    • SQL 응답 결과를 객체로 편리하게 변환해준다.
    • JDBC의 반복 코드를 제거해준다.
  • 단점
    • 개발자가 SQL을 직접 작성해야한다.
  • 대표 기술
    • 스프링 JdbcTemplate
    • MyBatis

ORM

  • ORM은 객체를 관계형 DB의 테이블과 매핑해주는 기술이다.
    • 이 기술 덕분에 개발자는 반복적인 SQL을 직접 작성하지 않고, ORM 기술이 개발자 대신에 SQL을 동적으로 만들어 실행해준다.
    • 추가로 각각의 DB마다 다른 SQL을 사용하는 문제도 중간에서 해결해준다.
  • 장점
    • 반복적인 쿼리를 줄여주기 때문에 작성해야 하는 쿼리의 양이 많이 줄어든다.
    • 객체를 관계형 DB의 테이블과 매핑해준다는 뜻은 즉 객체지향적으로 관리할 수 있다는 뜻이다.
    • 다양한 기능 제공을 통해 애플리케이션 자체의 성능도 올려줄 수 있다.
  • 단점
    • 배우고 나면 쓰기 매우 좋은 것은 사실이지만, 쉬운 기술은 아니라서 배우기가 매우 어렵다.
    • 모든 DB에 존재하는 모든 쿼리 문법에 100% 대응되는 것은 아니기 때문에 쿼리도 알아야 한다.
      • JPA는 일반 쿼리와 다르게 테이블명이 아닌 객체명을 명시한다.

함정?은 아니긴 한데…

  • 사실 ORM도 결국은 내부에서는 모두 JDBC를 사용한다.
  • 그리고 JPA 쓰다보면 가끔 최적화때문에 스프링 JdbcTemplate을 쓸 때가 있다.
  • 그러니 자바 개발자라면 JDBC는 필수로 알아두자.

데이터베이스 연결

DB 실행

  • 관계형 DB는 보통 서버 컴퓨터가 켜져있으면 따로 종료하지 않는 이상은 계속 실행되어 있다.
  • 그런데 H2는 구조가 달라서 컴퓨터를 키고 DB도 실행해야 한다.
  • H2 DB를 쓸 때는 까먹지 말고 DB를 미리 실행시켜두자.

DB 접속 정보 만들기

  • 기본 정보를 관리하기 편하게 상수로 정의해두자.
package hello.jdbc.connection;

public abstract class ConnectionConst {
  public static final String URL = "jdbc:h2:tcp://localhost/~/test";
  public static final String USERNAME = "sa";
  public static final String PASSWORD = "";
}

실제로 DB에 연결하는 코드를 작성해보자.

  • getConnection을 통해서 DB 접속 정보를 생성해서 가져오자.
package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;

@Slf4j
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);
    }
  }
}

테스트

  • 편리한 단축키인 Ctrl + Shift + T를 통해서 테스트를 생성하자.
package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
class DBConnectionUtilTest {
  @Test
  void connection() {
      Connection connection = DBConnectionUtil.getConnection(); //DB 접속 정보 가져오기
      assertThat(connection).isNotNull();
  }
}
  • 테스트를 돌려보면 정상적으로 접속 정보를 가져온 것을 알 수 있다.

DriverManager 커넥션 요청 흐름

  • JDBC가 제공하는 DriverManager 는 라이브러리에 등록된 DB 드라이버들을 관리하고, 커넥션을 획득하는 기능을 제공한다.
  1. 애플리케이션 로직에서 커넥션이 필요하면 DriverManager.getConnection()을 호출한다.
  2. DriverManager는 라이브러리에 등록된 드라이버 목록을 자동으로 인식한다.
    • 이 드라이버들에게 순서대로 다음 정보를 넘겨서 커넥션을 획득할 수 있는지 확인한다.
    • URL
      • 예시 : jdbc:h2:tcp://localhost/~/test
    • 이름, 비밀번호 등 접속에 필요한 추가 정보
  3. 이렇게 찾은 커넥션 구현체가 클라이언트에 반환된다

드라이버가 선정되는 방법

  • 각각의 드라이버는 URL 정보를 체크해서 본인이 처리할 수 있는 요청인지 확인한다.
    • 예시 : jdbc:h2:xxx
  • 만약 URL 정보에 jdbc:h2:xxx가 포함되어 있다면 DriverManager는 H2 DB의 드라이버를 찾아서 선정한다.
    • 드라이버가 여러 개 설정되어 있다면 해당하는 드라이버를 찾을 때까지 다음 드라이버로 반복해서 찾는다.

JDBC 개발 - 등록

도메인 만들기

  • 아까 만든 member 테이블에 데이터를 추가하기 위해 회원 클래스를 생성하자.
package hello.jdbc.domain;

import lombok.Data;

@Data
public class Member {
  private String memberId;
  private int money;

  public Member() {
  
  }
  public Member(String memberId, int money) {
      this.memberId = memberId;
      this.money = money;
  }
}

회원 등록 기능 만들기

  • 이번에는 실제로 DB에 데이터를 저장할 수 있는 기능을 만들자.
package hello.jdbc.repository;

import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import java.sql.*;

/**
 * JDBC - DriverManager 사용
 */
@Slf4j
public class MemberRepositoryV0 {
    //회원 정보 저장하기
    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";
        Connection con = null;
        PreparedStatement pstmt = null;
        
        try {
            con = getConnection(); //DB 접속 정보 가져오기
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }
    
    //DB 접속 종료시키기
    private void close(Connection con, Statement stmt, ResultSet rs) {
        if (rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
        if (con != null) {
            try {
                con.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
    }
    
    //DB 접속 정보 가져오기
    private Connection getConnection() {
        return DBConnectionUtil.getConnection();
    }
}
  • sql
    • 실행할 쿼리를 만든다.
    • 동적으로 값을 넣어야 할 부분은 물음표로 명시한다.
  • pstmt = con.prepareStatement(sql)
    • 데이터베이스에 전달할 쿼리와 전달할 데이터들을 준비한다.
  • pstmt.setXXX(순서, 값);
    • set + 자료형의 규칙으로 만들어져 있는 메소드를 통해서 전달할 데이터를 설정한다.
    • 순서는 1부터 시작한다.
    • 물음표의 개수만큼 선언해야 한다.
  • pstmt.executeUpdate()
    • PreparedStatement를 통해 준비된 쿼리를 커넥션을 통해 실제 DB에 전달한다.
  • close(xxx)
    • 리소스를 정리한다.
    • 리소스를 정리하지 않으면 커넥션이 끊기지 않고 계속 유지된다.
      • 이런 상황을 리소스 누수라고 부른다.
      • 또한 이런 상황이 유지되면 커넥션 부족으로 장애가 발생할 수 있다.
    • 리소르를 정리할 때는 항상 역순으로 정리해야 한다.

테스트

  • 테스트를 통해 실제 회원 정보를 등록해보자.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;

class MemberRepositoryV0Test {
  MemberRepositoryV0 repository = new MemberRepositoryV0();

  @Test
  void crud() throws SQLException {
      //save
      Member member = new Member("memberV0", 10000);
      repository.save(member);
  }
}

JDBC 개발 - 조회

  • 이번에는 MemberRepositoryV0에 데이터를 조회하는 기능을 추가해보자.
//회원 정보 조회하기
public Member findById(String memberId) throws SQLException {
    String sql = "select * from member where member_id = ?";
    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);
        rs = pstmt.executeQuery();
        if (rs.next()) {
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        } else {
            throw new NoSuchElementException("member not found memberId=" + memberId);
        }
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally {
        close(con, pstmt, rs);
    }
}
  • rs = pstmt.executeQuery()
    • 쿼리를 실행하여 조회 정보를 담는다.
  • rs.next()
    • ResultSet은 내부에 있는 커서를 이동해서 다음 데이터를 조회한다.
    • 커서 이동의 성공 여부에 따라 true나 false를 반환한다.
    • true일 경우에는 커서를 이동해서 조회한 데이터가 존재한다는 것을 가리킨다.
  • rs.getXXX(컬컬럼명)
    • get + 자료형의 규칙으로 만들어져 있는 메소드를 통해 데이터를 가져온다.
    • 파라미터로는 실제 DB에서 사용하는 컬럼명을 명시하면 된다. (alias를 지정했다면 alias 명시)

테스트

  • 아까 만든 MemberRepositoryV0Test에 조회 기능을 추가하자.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
class MemberRepositoryV0Test {
  MemberRepositoryV0 repository = new MemberRepositoryV0();

  @Test
  void crud() throws SQLException {
    //save
    Member member = new Member("memberV0", 10000);
    repository.save(member);

    //findById
    Member findMember = repository.findById(member.getMemberId());
    log.info("findMember={}", findMember);
    assertThat(findMember).isEqualTo(member);
  }
}

JDBC 개발 - 수정, 삭제

  • 수정과 삭제 기능도 사실상 등록과 동일한 방식으로 진행하면 된다.

수정 기능 추가

//회원 정보 수정하기
public void update(String memberId, int money) throws SQLException {
    String sql = "update member set money=? where member_id=?";
    Connection con = null;
    PreparedStatement pstmt = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setInt(1, money);
        pstmt.setString(2, memberId);
        int resultSize = pstmt.executeUpdate();
        log.info("resultSize={}", resultSize);
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally {
        close(con, pstmt, null);
    }
}

삭제 기능 추가

//회원 정보 삭제하기
public void delete(String memberId) throws SQLException {
    String sql = "delete from member where member_id=?";
    Connection con = null;
    PreparedStatement pstmt = null;
    
    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);
        pstmt.executeUpdate();
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally {
        close(con, pstmt, null);
    }
}

테스트

  • 아까 만든 MemberRepositoryV0Test에 수정 기능과 삭제 기능을 추가하자.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.util.NoSuchElementException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
class MemberRepositoryV0Test {
  MemberRepositoryV0 repository = new MemberRepositoryV0();

  @Test
  void crud() throws SQLException {
    //save
    Member member = new Member("memberV0", 10000);
    repository.save(member);

    //findById
    Member findMember = repository.findById(member.getMemberId());
    log.info("findMember={}", findMember);
    assertThat(findMember).isEqualTo(member);

    //update: money: 10000 -> 20000
    repository.update(member.getMemberId(), 20000);
    Member updatedMember = repository.findById(member.getMemberId());
    assertThat(updatedMember.getMoney()).isEqualTo(20000);

    //delete
    repository.delete(member.getMemberId());
    assertThatThrownBy(() -> repository.findById(member.getMemberId())).isInstanceOf(NoSuchElementException.class);
  }
}

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.