1. JDBC 이해
애플리케이션 개발시 데이터는 주로 데이터베이스에 보관하며, 보통 애플리케이션 서버의 구조는 다음과 같다.
이때, 클라이언트가 데이터의 저장, 조회 등을 요청하면, 서버는 해당 요청을 처리하기 위해 DB에 접근해야 한다. 이를 위해, 어플리케이션 서버는 아래 과정을 통해 데이터베이스에 접근한다.
- 커넥션 연결은 주로 TCP/IP를 사용한다.
하지만, (옛날에는) 각각의 데이터베이스마다 사용 방법이 달랐다. 커넥션 연결, SQL 전달 방법, 응답 받는 방법 모두 달라 아래와 같은 문제가 생겼다.
- 사용하던 데이터베이스를 변경하면, 데이터베이스 사용 코드도 변경해야 한다.
- 개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 전달, 응답 받는 방법을 숙지해야 한다.
이러한 문제 때문에 JDBC가 등장한다. JDBC 표준 인터페이스는 Java Database Connectivity의 약자로, 자바에서 데이터베이스 접속을 할 수 있게하는 자바 API이다. JDBC는 데이터베이스에서 자료를 쿼리하거나, 업데이트하는 방법을 제공한다. 형태는 다음과 같다.
- JDBC는 위와 같이, 연결하는 것, SQL을 전달하는 것, 결과 응답 방법 등을 통합했다.
- java.sql.Connection - 연결
- java.sql.Statement - SQL을 담은 내용
- java.sql.ResultSet - SQL 요청 응답
하지만 JDBC와 같은 표준 인터페이스만 있다고 기능이 동작하진 않는다. 이를 위해 각각의 DB 회사에서 자신의 DB에 맞게 구현하여 라이브러리를 제공하고, 이를 JDBC Driver라고 한다. 즉, MySQL DB에 접근할 수 있는 건 MySQL JDBC Driver, Oracle DB의 경우 Oracle JDBC Driver라고 부른다. JDBC 표준 인터페이스 덕분에 개발자는 DB가 변경되어도 다음과 같이 작업을 할 수 있게 된다. (단지, Driver만 변경하면 된다.)
JDBC 덕분에 코드의 변경도 줄었고, 각 데이터베이스의 커넥션 연결, SQL 전달, 응답 받는 방법을 숙지하는 것 역시 수고가 줄었다. 하지만, 각각의 데이터베이스마다 SQL, 데이터 타입 등 일부 사용법이 다르다. ANSI SQL이라는 표준도 있지만, 일반적이 부분만 공통화해서 한계가 있다. 예를 들어, 페이징 SQL의 경우 각각의 데이터 베이스마다 사용법이 다르다.
결국 데이터베이스를 변경하면 JDBC 코드는 변경하지 않아도 되지만, SQL은 변경해야 한다. JPA(Java Persistence API)를 사용하면 이렇게 다른 SQL을 정의해야 하는 문제도 많이 해결할 수 있다.
2. JDBC와 최신 데이터 접근 기술
JDBC는 굉장히 오래된 기술이고, 사용 방법도 복잡하다. 따라서 최근에는 JDBC를 직접 사용하기 보다는 JDBC를 편리하게 사용하는 다양한 기술이 존재한다. 대표적으로 SQL Mapper와 ORM 기술로 나눌 수 있다. SQL Mapper는 다음과 같이 작동한다고 볼 수 있다.
- SQL Mapper는 JDBC를 편리하게 사용하도록 도와준다. SQL 응답 결과를 객체로 편리하게 변환해주며, JDBC의 반복 코드를 제거해준다.
- 하지만 개발자가 SQL을 직접 작성해야 한다는 단점이 있다. (ORM 기술과 비교했을 때의 단점이다.)
ORM의 경우 다음과 같이 작동한다고 볼 수 있다.
- Application에서 회원을 저장한다고 하자. 이때 insert 쿼리를 직접 전달하는 것이 아니라 회원 객체를 JPA에 전달한다. 이때 객체의 매핑 정보들이 있는데, 이를 통해 insert 쿼리를 직접 만들어낸다. 개발자가 SQL을 작성하는 것이 아니다. 덕분에 개발자는 반복적인 SQL을 작성하지 않을 수 있다.
- ORM은 객체를 관계형 데이터베이스 테이블과 매핑해주는 기술이다. 이 덕분에 ORM 기술이 개발자 대신 SQL을 동적으로 만들어 실행하며 데이터베이스마다 다른 SQL을 사용하는 문제도 해결할 수 있다. JPA는 자바 진영의 표준 인터페이스이고, 이것을 구현한 것으로 Hybernate와 Eclipse Link같은 것들이 있다.
SQL Mapper는 SQL만 직접 작성하면 나머지 번거로운 작업은 SQL Mapper가 대신해준다. 즉, SQL만 작성하면 금방 배워 사용할 수 있다. 하지만 ORM 기술은 SQL 자체를 작성하지 않아도 되므로, 개발 생산성이 매우 높지만, 쉬운 기술이 아니라 실무에서 사용하기 위해서는 깊이있게 학습해야 한다.
3. 데이터베이스 연결
데이터베이스 연결을 위해 일단, DB 정보를 담는 Connection 상수를 만들 것이다. DB의 정보인 url, username, password 등을 저장하는 추상 클래스를 아래와 같이 선언하자.
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 = "";
}
- 클래스를 abstract로 선언한 이유: 해당 클래스는 상수만 모아서 쓴 것이기 때문에 객체를 생성할 필요가 없기 때문이다.
그리고 데이터베이스에 연결하는 코드를 만들어보자.
@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);
}
}
}
- 위와 같이 DriverManager.getConnection에 DB 정보를 가지는 이전에 선언한 상수들을 넣어주면 java.util의 Connection 객체를 얻을 수 있다.
데이터베이스에 연결하기 위해서는 JDBC가 제공하는 "DriverManager.getConnection()"을 사용하면 된다. 이렇게 하면 라이브러리에 있는 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션을 반환해준다. 우리의 경우 H2 데이터베이스를 사용하브로 H2 데이터베이스 드라이버가 작동하여 실제 데이터베이스과 커넥션을 맺고, 그 결과를 반환해준다. 아래의 테스트 코드를 통해 잘 작동하는 것을 확인할 수 있다.
@Slf4j
public class DBConnectionUtilTest {
@Test
@DisplayName("DB에 올바르게 연결된다.")
void connection() {
Connection connection = DBConnectionUtil.getConnection();
Assertions.assertThat(connection).isNotNull();
}
}
이때 실행결과에서 log를 보면 로그 내용은 다음과 같다.
- 16:29:40.567 [main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
class를 보면 Connection 인터페이스의 구현체인 "org.h2.jdbc.JdbcConnection"을 사용한 것을 확인할 수 있다. H2는 Connection을 위해 이 친구를 제공한다. 즉, 해당 클래스는 H2 드라이버가 제공하는 H2 전용 커넥션으로 java.sql.Connection을 구현한 구현체라는 것이다. 이때 DriverManager의 작동 원리는 다음과 같다.
- 먼저, application logic에서는 커넥션이 필요하면 DriverManager.getConnection()을 호출한다.
- DriverManager는 라이브러리에 등록된 드라이버 목록을 자동으로 인식한다. 이후 드라이버들에게 DB 정보를 넘겨, 커넥션을 얻을 수 있는지 확인한다.
- 각 드라이버들은 URL 정보를 체크해서 본인이 처리할 수 있는 요청인지 확인한다. URL이 "jdbc::h2"로 시작하면, H2 데이터베이스를에 접근하기 위한 규칙이므로 H2 드라이버는 본인이 처리할 수 있으므로 데이터베이스에 연결해 커넥션을 획득하고, 해당 커넥션을 반환해준다. 반면 MySQL Driver는 본인이 처리할 수 없다는 결과를 반환하고, 다음 드라이버에게 순서가 넘어간다.
- 결과적으로 이 과정을 통해 얻은 커넥션 구현체가 클라이언트에게 반환된다.
4. JDBC 개발 - 등록
JDBC를 이용해 어플리케이션을 개발해보자. JDBC를 이용해 회원 데이터를 데이터베이스에서 관리하는 기능을 개발할 것이다. 먼저, H2 데이터베이스에 다음과 같이 테이블을 생성한다.
DROP TABLE member_jdbc IF EXISTS CASCADE;
CREATE TABLE member_jdbc (
member_id varchar(10),
money integer not null default 0,
primary key (member_id)
);
그리고 domain 패키지를 한 개 만들고, 위와 같은 member의 정보를 담을 수 있는 객체를 생성하기 위한 클래스를 생성한다.
@Data
public class Member {
private String memberId;
private int money;
public Member() {
}
public Member(String memberId, int money) {
this.memberId = memberId;
this.money = money;
}
}
이제 JDBC를 통해 회원 객체를 DB에 저장하는 코드를 짜야 한다. repository 패키지를 만들고, 해당 패키지 안에 Member 클래스를 처리하는 MemberRepositoryV0 클래스를 만들자.
@Slf4j
public class MemberRepositoryV0 {
public Member save(Member member) throws SQLException {
String sql = "INSERT INTO member_jdbc(member_id, money) VALUES (?, ?)";
Connection con = null;
PreparedStatement ps = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setString(1, member.getMemberId());
ps.setInt(2, member.getMoney());
ps.executeUpdate();
return member;
}
catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, ps, null);
}
}
private void close(Connection con, Statement st, ResultSet rs) throws SQLException {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (st != null) {
try {
st.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (con != null) {
try {
con.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
public Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
- Connection을 획득하기 위해 이전에 만든 DBConnectionUtil을 사용했다.
- con.prepareStatement(sql): Connection을 얻으면 이를 통해 PrepareStatement 객체를 얻을 수 있다. 우리는 이를 통해 데이터베이스에 전달할 SQL과 파라미터로 전달할 데이터들을 준비한다.
- ps.setString(1, member.getMemberId()): SQL의 첫 번째 ?값에 String 데이터를 전달한다.
- ps.setInt(2, member.getMoney()): SQL의 두 번째 ?값에 Int 데이터를 전달한다.
- ps.executeUpdate(): Statement를 통해 준비된 SQL을 커넥션을 통해 데이터베이스에 전다한다. executeUpdate의 경우 영향받은 DB의 행 개수를 반환한다. 이 경우 단 하나의 행을 추가했으므로 1을 반환한다.
- 쿼리를 실행하면, 리소스를 정리해야 한다. 우리의 경우 Connection과 PrepareStatement를 사용했다. 단, 리소스 정리 시에는 역순으로 해야한다. 즉, Connection을 통해 얻은 PreparedStatement를 먼저 종료하고, Connection을 종료해야 한다.
- 리소스는 무조건 정리해야 하기 때문에 반드시 실행됨을 보장하기 위해 finally에 사용한 것이다.
- 만약 Resultset이나, PreparedStatement 종료시 문제가 발생하면, try, catch문을 사용하지 않은 경우 이후 리소스 정리에 문제가 생길 수 있다. 따라서 각각 try, catch를 사용해 따로따로 리소스 정리를 수행한 것이다.
- PrepareStatement는 Statement의 자식 타입으로, "?"를 통해 파라미터 바인딩을 가능하게 해준다. SQL Injection 공격을 예방하기 위해서는 PreparedStatement를 통한 바인딩 방식을 사용해야 한다.
아래와 같이 테스트 코드를 통해 올바르게 동작하는 것을 확인할 수 있다.
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void save() throws SQLException {
Member member = new Member("member1", 10000);
repository.save(member);
}
}
5. JDBC 개발 - 조회
JDBC를 통해 위에서 저장된 데이터를 조회해보자. 먼저 아래와 같이 select문을 JDBC를 통해 DB로 전달하여 결과를 받아오는 코드를 작성한다.
public Member findById(String memberId) throws SQLException {
String sql = "SELECT * FROM member_jdbc WHERE member_id = ?";
// try, catch 구문 때문에 밖에 선언한다.
Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setString(1, memberId);
// ps.executeUpdate(): insert 등 데이터를 변경할 때 사용.
// ps.executeQuery(): select로 데이터 조회시 사용.
rs = ps.executeQuery();
// rs.next()를 해줘야 실제 데이터부터 시작함.
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, ps, rs);
}
}
- ps.executeUpdate() VS ps.executeQuery()
- ps.executeUpdate(): 데이터 변경시 사용한다.
- ps.executeQuery(): 데이터 조회시 사용한다. executeQuery는 결과를 ResultSet에 담아 반환한다.
- ResultSet: select 쿼리의 결과가 순서대로 들어가 저장된다.
- ResultSet 내부에는 커서가 존재하여 rs.next()를 호출하면, 커서가 다음으로 이동한다. 이때, 최초의 커서는 데이터를 가리키고 있지 않기 때문에 rs.next()를 한 번은 호출해야 데이터를 조회할 수 있다.
- 만약 rs.next()의 결과가 true라면 커서의 이동 결과 데이터가 있다는 의미다. false의 경우 커서가 가리키는 데이터가 없다는 의미다.
- rs.getString("member_id"): 현재 커서가 가리키고 위치의 "member_id" 데이터를 String 타입으로 변환한다.
- rs.getInt("money"): 현재 커서가 가리키고 있는 위치의 "money"데이터를 int 타입으로 변환한다.
다음과 같이 테스트코드를 작성하면 올바르게 동작하는 것을 확인할 수 있다.
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void findById() throws SQLException {
Member member = new Member("member1", 10000);
repository.save(member);
Member findMember = repository.findById(member.getMemberId());
Assertions.assertThat(findMember).isEqualTo(member);
}
}
6. JDBC 개발 - 수정
회원의 money데이터를 수정하기 위해서 update 쿼리문을 JDBC를 통해 DB로 전달하여 회원의 데이터를 변경해보자. 이를 위한 코드는 다음과 같다.
public void update(String memberId, int money) throws SQLException {
String sql = "UPDATE member_jdbc SET money=? WHERE member_id=?";
Connection con = null;
PreparedStatement ps = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setInt(1, money);
ps.setString(2, memberId);
int resultSize = ps.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, ps, null);
}
}
이를 위한 테스트 코드는 다음과 같다.
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void update() throws SQLException {
Member member = new Member("member1", 10000);
repository.save(member);
repository.update(member.getMemberId(), 20000);
Member updatedMember = repository.findById(member.getMemberId());
Assertions.assertThat(updatedMember.getMoney()).isEqualTo(20000);
}
}
6. JDBC 개발 - 삭제
마지막으로 원하는 멤버 데이터를 삭제하는 메서드를 만들어보자. 코드는 아래와 같다. DELETE 쿼리를 보내는 방법 역시 이전 방법들과 동일하다.
public void delete(String memberId) throws SQLException {
String sql = "DELETE FROM member_jdbc WHERE member_id=?";
Connection con = null;
PreparedStatement ps = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setString(1, memberId);
ps.executeUpdate();
} catch (SQLException e) {
log.info("db error", e);
throw e;
} finally {
close(con, ps, null);
}
}
테스트는 다음과 같이 에러가 발생하는지를 확인함으로써 검증한다.
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void delete() throws SQLException {
Member member = new Member("member1", 10000);
repository.save(member);
repository.delete("member1");
Assertions.assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);
}
}
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring DB-1] 트랜잭션 이해 (0) | 2024.05.05 |
---|---|
[Spring DB-1] Connection Pool과 DataSource (0) | 2023.11.24 |
[Spring MVC-2] 메시지, 국제화 (0) | 2023.11.10 |
[Spring MVC-2] 타임리프 - 스프링 통합과 폼 (0) | 2023.09.27 |
[Spring MVC-2] 타임리프 - 기본 기능 (0) | 2023.09.25 |