스프링 공부를 하면서 데이터베이스 쪽이 아무래도 공부량이 적다고 생각해서 앞으로 이 쪽으로 공부를 집중적으로 해보려고 한다. 앞으로 공부할 키워드를 정리해 보자면 JDBC, ORM, Hibernate, MyBatis, JPA가 있다. 간단하게 알아보면
JDBC: 데이터베이스에 접속하여 자신이 작성한 쿼리를 실행할 수 있도록 해주는 API
ORM: Object-Relational Mapping의 준말로 자바의 Object와 Relational DB의 데이터를 매핑시켜주는 기술이다.
Hibernate: ORM 프레임워크 중 하나로, SQL문을 직접 입력하지 않아도되는 장점이 있다.
MyBatis: 자바 영속성 프레임워크중 하나로, 쿼리문을 작성하고 이를 XML이나 어노테이션을 이용하여 객체와 데이터 간 매핑을 한다. JDBC를 좀 더 편리하게 사용할 수 있도록 해준다.
JPA: Java Persistence API로, 자바에서 제공하는 ORM 표준 기술이다. 쿼리문을 작성하지 않아도 쉽게 자바 객체와 데이터베이스의 데이터를 매핑할 수 있다는 장점이 있다. JDBC, Hibernate와 연관이 있기 때문에 앞의 내용을 미리 공부해 두면 좋다. JPA의 구현체 중 하나가 Hibernate이다.
인터넷을 찾아보니 MyBatis와 JPA 중 어떤 것을 공부해야 하는지 여러 의견이 많았는데 결국 대부분 JPA를 사용하는 것 같아 JPA를 우선 공부하고 여유가 생기면 MyBatis를 공부하기로 했다.
JDBC
사실 JDBC를 배우지 않아도 JPA를 간단하게 다루는데에는 무리가 없다. 하지만 결국 동작하는 방식은 JDBC와 Hibernate가 연관되어 있으므로 나중에 가서는 무조건 배워야 한다. 그리고 직접 질의문을 작성하지 않는다고 해도 기본적인 동작 과정은 알아야 하기 때문에 질의문 작성도 할 줄 알아야 한다.
기본적인 JDBC의 동작 과정을 요약하자면 다음과 같다.
0. 드라이버 설치: 자신이 사용하려는 DB에 맞는 드라이버를 설치한다. 기본적으로 해야하는 내용이고 한번 해두면 이후에 작동할 때는 다시 설치하지 않지만 사전에 꼭 해두어야 하는 과정이어서 0번으로 넣었다. 한번 설치해 두면 1~3번만 반복한다.
1. DB와 연결 과정: DB나 사용자 등의 내용을 지정해서 DB와 연결한다.
2. 질의문 작성 및 실행: 질의문을 작성하고, DB로 전달하여 결과를 반환받는다.
3. 반환받은 결과 저장하고 사용: DB로 부터 얻은 데이터를 단순히 하나의 객체에 저장하거나 여러 객체를 담고 있는 리스트에 저장하여 사용한다.
이제 위의 과정을 자세히 알아보자.
0. 드라이버 설치
DB마다 정해져있는 질의문 문법(개인적으로 제일 싫어하는 부분이다. 사실 ANSI SQL이라는 DB 종류에 상관없이 사용할 수 있는 것이 있지만 어쨌든 싫다...)도 다르고 동작방식도 다르다. 그렇기 때문에 JDBC를 이용하기 위해서는 자신이 사용하려는 DB에 맞는 드라이버를 설치하여 해당 DB에 맞게 동작할 수 있도록 해야 하는 것이다. 사실 이 드라이버 덕분에 개발자가 DB마다 다르게 설정해야 하는 부분도 쉽게 이용할 수 있어서 큰 이득이다.
1. DB와 연결 과정
public Connection getConnection() {
Connection conn = null;
String dbName = "example_db";
String jdbcUrl = "jdbc:mysql://localhost:3306/" + dbName + "?characterEncoding=UTF8&serverTimezone=UTC&useSSL=false";
String dbUser = "megamaker";
String dbPass = "1234";
try {
Class.forName("com.mysql.cj.jdbc.Driver"); // 드라이버를 메모리에 로드
} catch (ClassNotFoundException e) {
System.out.println("Driver Load Errors");
}
try {
conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPass);
} catch(SQLException e) {
System.out.println("Connection Errors");
} finally {
return conn;
}
}
위 코드는 DB와 연결하는 과정을 메서드로 만들어서 사용하는 부분이다. 본격적으로 DB로부터 데이터를 가져오기 전에 연결하는 부분이라고 보면 된다. 자신이 사용할 DB와 해당 DB에 접근 권한이 있는 사용자와 비밀번호 등의 정보를 이용하여 DriverManager 클래스의 getConnection()이라는 메서드를 통해 Connection 객체를 얻는다. 이후에는 이 객체를 이용해서 DB로 접속하게 된다.
getConnection() 부분이 어떻게 구현되어 있나 살펴봤는데 코드는 약간 길이가 있기 때문에 일부만 캡처했다.
드라이버 목록을 순회하면서 맞는 드라이버를 찾는 과정을 거친다. 일단 연결을 시도하고 실패하면 다음 드라이버로 넘아가는 과정인 듯하다.
다음은 자주 사용될 중요한 Connection 인터페이스이다. 중요한 부분이긴 하지만 내용은 구현되지 않은 메서드밖에 없으니 생략하도록 하겠다. 그래도 궁금해서 미칠 것 같으면 직접 찾아서 보도록..!
사실 Connection을 반환하는 위의 getConnection() 코드에서 도대체 어디에서 connect 메서드를 구현한 것이고, 어떻게 사용할 수 있는 것인가 했는데 알고 보니 각 DBMS에 맞는 드라이버에 그 구현체가 있어서 이를 제공하는 것이었다!
어쨌든 이러한 과정을 거쳐서 생성된 Connection 객체를 이용해서 DB로 질의문을 보내면 된다.
2. 질의문 작성 및 실행
질의문은 간단하게 String으로 작성할 수 있다. 그렇기에 질의문을 작성하는 것은 크게 어렵지 않다. 하지만 이 질의문을 어떻게 처리할지는 크게 두 가지 방법으로 나뉜다. Statement와 PreparedStatement이다. 참고로 둘 다 인터페이스이며, PreparedStatement는 Statement를 상속받는다. 그렇기에 PreparedStatement가 어떤 기능을 더 지원한다는 것을 예측해 볼 수 있다.
일단 두 인터페이스 모두 구현체가 필요한데 이 구현체는 앞서 살펴봤던 Connection 객체를 통해 얻을 수 있다. DB와 연결하고 저장한 Connection 객체를 이용하여 Statement는 createStatement() 메서드를, PreparedStatement는 preparedStatement() 메서드로 구현체를 얻어 이것을 통해 질의문을 실행할 수 있다.
예시 코드를 통해 살펴보자.
먼저 Statement를 사용하는 예시이다. MVCS 모델을 적용하지 않고 그냥 한 번에 다 때려 박은 코드이다.
List<MemberDTO> memberDTOList = new ArrayList<>();
String orderBy = request.getParameter("orderby");
String direction = request.getParameter("direction");
String condition = "";
if (orderBy != null && direction != null)
condition = " order by " + orderBy + " " + direction;
// DB 관련
int cnt = -1;
String sql = "select * from " + TABLE_NAME + condition;
try (Connection conn = connectionManager.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);) {
// rs : result set, 질의 결과를 다루기 위한 객체, 레코드 또는 레코드들과 처리 메소드로 구성
while(rs.next()) {
MemberDTO member = setRsToDTO(rs); // setRsToDTO()는 따로 만들어둔 메서드
memberDTOList.add(member);
}
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
//close(conn, pstmt, rs); // 해제는 역순으로
if (!memberDTOList.isEmpty()) {
modelAndView.setView("../members/list.jsp");
modelAndView.addModel("dtoList", memberDTOList);
}
else {
renderErrorView("목록 조회 오류", request, response);
}
}
private MemberDTO setRsToDTO(ResultSet rs) throws SQLException {
MemberDTO member = MemberDTO.builder()
.mid(rs.getLong("mid"))
.fullName(rs.getString("full_name"))
.email(rs.getString("email"))
.pw(rs.getString("pw"))
.zipcode(rs.getString("zipcode"))
.regTimestamp(rs.getTimestamp("reg_timestamp"))
.build();
return member;
}
stmt에 conn으로부터 Statement를 반환받아 사용하는 것을 볼 수 있다. 질의문 실행은 executeQuery() 메서드를 통해서 실행하고 이를 ResultSet 변수에 저장한다. ResultSet은 질의문 실행 결과를 받는 인터페이스이다. 위의 예시에서는 rs 변수를 통해 실제 이용할 자바 객체로 변환한다. 다만 여기서 유의해야 할 점은 rs.next()를 한 번이라도 해야 레코드를 불러올 수 있다는 점이다. 이것이 일종의 커서 역할을 하며 다음 커서로 넘어가는데 첫 번째 레코드를 불러오려면 무조건 한 번은 next() 메서드를 호출해야 한다. 그렇기 때문에 하나의 레코드만 가져오고 싶으면 if문을 사용해도 무방하다.
그리고 가장 중요한 점은 안전하게 모든 작업을 마치려면 사용한 자원들을 반환해줘야 한다는 점이다. 앞서 사용했던 Connection, Statement, ResultSet을 닫아줘야 한다! 그리고 여기서 한 가지 또 중요한 점이 있는데 사용한 역순으로 닫아주어야 한다는 점이다. 보통은 다른 곳에서도 닫는 코드를 반복해서 사용하기 때문에 따로 메서드로 빼서 사용하곤 하는데 위의 예시에서처럼 close()라는 메서드를 만들어서 사용하였다. (직접 conn.close() 이렇게 사용하는 메서드와는 다르다. 다른 클래스의 close() 메서드를 static import 해서 사용하였기 때문에 보이는 것만 저렇게 보이는 것이다.)
// 사용한 conn, pstmt, rs를 해제함
// 해제는 사용한 역순으로
public static void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
System.out.println("rs close error");
}
}
if (pstmt != null) {
try {
pstmt.close();
} catch (SQLException e) {
System.out.println("pstmt close error");
}
}
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
System.out.println("conn close error");
}
}
}
이것이 직접 따로 작성한 close() 메서드이다. 이 부분은 김영한 님의 DB 강의를 듣고 작성하였다. 각 변수에서 close() 메서드를 직접 호출해서 사용한다. 그런데 예외처리를 각각 해준 이유는 하나의 객체를 close() 할 때 예외가 발생하면 이후의 try 블록의 코드가 실행되지 않기 때문에 제대로 닫아주지 못하는 현상이 발생해서이다. 어쨌든 이렇게 하면 안전하게 사용한 자원을 반환할 수 있다. 그런데 뭔가 이상하다. 다시 이 코드 위에 있는 코드를 봐보자.
그렇다. 위 close() 메서드 부분을 주석 처리해 놓았다. 그럼 어떻게 해제를 한다는 것일까?
해당 객체들을 선언한 부분을 보면 try의 () 안에 작성을 해놓은 것을 볼 수 있다. 이렇게 작성하면 나중에 직접 close() 해주지 않아도 해당 자원들을 사용한 이후에 자동으로 해제해 준다!
이는 try-with-resources라는 문법으로 자바 7 이후부터 사용가능하다.
다음은 PreparedStatement에 대해서 알아보자.
ProjectDTO projectDTO = getProjectDTO(request);
// DB 관련
int cnt = -1;
String sql = "update " + TABLE_NAME + " set project_name = ?, project_description = ?, status = ?" +
", client_company = ?, project_leader = ?, project_image = ?" +
", estimated_budget = ?, total_amount_spent = ?, estimated_project_duration = ?" +
", rev_timestamp = ? where pid = ?;";
try (Connection conn = connectionManager.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);) {
pstmt.setString(1, projectDTO.getProjectName());
pstmt.setString(2, projectDTO.getProjectDescription());
pstmt.setString(3, projectDTO.getStatus());
pstmt.setString(4, projectDTO.getClientCompany());
pstmt.setString(5, projectDTO.getProjectLeader());
pstmt.setString(6, projectDTO.getProjectImage());
pstmt.setLong(7, projectDTO.getEstimatedBudget());
pstmt.setLong(8, projectDTO.getTotalAmountSpent());
pstmt.setLong(9, projectDTO.getEstimatedProjectDuration());
pstmt.setTimestamp(10, projectDTO.getRevTimestamp());
pstmt.setLong(11, projectDTO.getPid());
cnt = pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
//close(conn, pstmt, rs); // 해제는 역순으로
if (cnt >= 1) {
response.sendRedirect("../projects/list");
}
else {
renderErrorView("프로젝트 업데이트 실패", request, response);
}
}
PreparedStatement는 앞서 살펴보았던 Statement와 사용법이 비슷하지만 Statement에 비교했을 때 크게 두 가지 장점이 있다. 첫 번째는 ?를 이용해서 알아보기 쉽게 질의문을 작성할 수 있다는 점이다. ?에 해당하는 부분을 이후에 일종의 인덱스 같은 역할을 하는 int값을 통해서 그 내용을 설정해 줄 수 있다. 이렇게 사용하지 않으면 + + 사이에 직접 자바 객체 getter를 넣어야 하기 때문에 상당히 지저분해 보일 것이다.
그리고 여담이지만 String을 연결 연산자로 합치게 되면 원래 문자열에 실제로 합쳐지는 것이 아니라 두 문자열을 이은 새로운 문자열을 만들어서 사용한다는 것은 다들 알고 있을 것이다. 근데 이 연결 연산자를 계속해서 사용해도 성능상 괜찮은 것일까 찾아봤는데 결론은 반복문 안에만 넣지 않으면 괜찮다. 한 줄 문자열 연결이면 자바에서 알아서 내부적으로 StringBuilder로 바꾸어서 처리한다고 한다.
두 번째 장점은 SQL Injection 공격을 막을 수 있다는 점이다. 만약 사용자가 진짜 아이디, 비밀번호가 아닌 질의문을 입력했다면 어떻게 될까? 질의문 내에 다른 질의문이 들어가는 부속 질의문처럼 처리가 될 수 있기 때문에 의도한 바와 다른 결과가 나올 수도 있다. 이를 통해서 민감한 정보가 유출될 수도 있고 여러모로 조심해야 한다. PreparedStatement를 사용하면 ?로 처리하는 부분을 단순 문자열로 처리하기 때문에 이런 걱정 없이 안전하게 사용할 수 있다.
질의문을 실행하는 부분은 executeUpdate()인데 이건 위의 코드가 정보를 업데이트할 때 사용해서 반환할 것이 실행 결과 정도밖에 없기 때문에 그렇고 Statement에서 살펴보았던 것처럼 ResultSet을 반환받고 싶으면 pstmt.executeQuery() 이런 식으로 사용하면 된다. 이후 과정은 Statement와 똑같이 자바 객체로 바꿔주는 과정을 거치면 된다.
3. 반환받은 결과 저장하고 사용
사실 별거 없다. 위에서 설명한 것에 포함되기 때문에 간단히 알아보면 아래의 코드가 이에 해당한다고 볼 수 있다. ResultSet의 내용을 자바 객체로 변경하는 부분이다. 각 타입에 맞게 get어쩌구() 메서드를 호출해서 각각을 변경해서 사용하면 된다. builder 패턴은 lombok을 이용해서 객체를 쉽게 생성하기 위함이므로 그냥 간단하게 생성자를 이용한다고 생각하면 된다.
private MemberDTO setRsToDTO(ResultSet rs) throws SQLException {
MemberDTO member = MemberDTO.builder()
.mid(rs.getLong("mid"))
.fullName(rs.getString("full_name"))
.email(rs.getString("email"))
.pw(rs.getString("pw"))
.zipcode(rs.getString("zipcode"))
.regTimestamp(rs.getTimestamp("reg_timestamp"))
.build();
return member;
}
대충 이러한 과정을 거쳐서 jdbc를 사용한다는 것을 알아보았다. 요즘은 JPA를 많이 사용한다고 하는데 아직 꽤 여러 회사에서 레거시 기술들을 사용한다고 한다. 어차피 JPA를 사용하려면 내부적으로 어떻게 동작하는지 정도는 알아두는 것이 좋기 때문에 이렇게 공부해 보는 것도 나쁘지 않다고 생각한다.
'공부 > Java' 카테고리의 다른 글
[Java] 람다식 (Lambda expression) (0) | 2024.02.23 |
---|---|
[Java] 생성자 (Constructor) (0) | 2024.02.22 |
[Java] 날짜 및 시간 관련 클래스 (Date, Calendar, Timestamp, LocalDate 등) (0) | 2023.12.25 |
[Java] 예외처리(Exception handling) (0) | 2023.06.23 |
[Java] 컬렉션 프레임워크(Collections Framework) (0) | 2023.05.21 |