티스토리 뷰
서론
JDBC는 연결하는 과정에 있어서 표준화된 인터페이스이다.
JDBC가 없을땐 각각의 DB(ORACLE, MySql 등..)마다 연결하는 과정이 달라 어려움이 있었다고 한다.
요즘에 누가 JDBC를 사용하냐고 하는데 사실 나는 요즘에 누가를 경험하고 있는 중이다.
그 이유로는 Data Engineering을 하는데 있어서 저장하는 DB의 경우에는 Oracle DB로 통합되어있기 때문에 JPA를 사용하고 있다.하지만, 문제점은 각각의 인프라들에서 파싱해가야하는 DB들이 하나가 아니라는 점에 있었다.
(몇가지 생각나는 DB들만 해도 MS-SQL, MySQL, Orcale 등등...)
또한, 몇몇 DB들은 쿼리 권한도 없어 프로시져를 통해서 가져가야 했다.
따라서 데이터를 파싱하는 점에 있어서는 JPA를 사용하는 것이 옳지 않다고 생각했다.
또한, MyBatis를 사용할 수도 있었지만 굳이 전체적인 back-end의 DB접근 기술을 JPA로 하고 있는데 불필요하다고 생각했다.
서론은 이쯤하면 됐고, JDBC를 어떻게 하면 더 나은 방법으로 쓸 수 있는지 정리해보겠다.
Connection
JDBC로 DB를 connection하는 과정은 각각의 DB 드라이버를 지정해주어야 한다.
기본적으로 JDBC라는것 자체가 서로 다른 DB 드라이버를 공통의 인터페이스로 묶은 것이기 때문에 그에 맞는 드라이버를 설정해줘야 한다.
package hello.jdbc.connection; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import lombok.extern.slf4j.Slf4j; import static hello.jdbc.connection.ConnectionConst.*; @Slf4j public class DBConnectionUtil { @Value("${jdbc.connection.url}") private static URL; @Value("${jdbc.connection.username}") private static USERNAME; @Value("${jdbc.connection.password}") private static PASSWORD; 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()이라는 메서드에 url, username, password 정보를 넣어서 connection 정보를 리턴해주면 된다.
JDBC
우선 간단한 저장할 정보를 간단한 객체로 만들어 주었다.
Member.java
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에 CRUD할 수 있도록 하는 객체 또한 만들어 줘야한다.
우선 하나하나 살펴보기 전에 Connection과 PreparedStatement, ResultSet 이렇게 세가지가 공통적으로 많이 들어간다.
Connection은 말 그대로 DB 드라이버를 JDBC 인터페이스를 이용해서 연결하는 것이고,
PreparedStatement는 sql을 통해서 쿼리를 날리는 것이다.
ResultSet은 쿼리를 날려 반환받는 결과 값이다.
대표적으로 save와 findById를 살펴보겠다. 다른 것은 비슷한 구조로 되어있어 소스코드를 참고해주길 바란다.
save
public Member save(Member member) throws SQLException{ String sql = "insert into member(member_id, money) value (?, ?)"; Connection con = null; PreparedStatement pstmt = null; try{ con = getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); return member; } catch (SQLException e){ log.error("db error", e); throw e; } finally { close(con, pstmt, null); } }
findByIdpublic 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); } }
save에서는 pstmt에 변수를 설정할 때, sql문에 ?의 순서대로 1base indexing 을 순서대로 자료형에 맞게 값을 넣어주면 된다.
findById는 select 쿼리임으로 반환값으로 ResultSet을 받아줄 수 있다.
ResultSet은 next()로 하나하나 row를 읽어오며, DB의 테이블의 컬럼명으로 데이터를 가져올 수 있다.(rs.getString("컬럼명"))
Connection Pool
connection을 자꾸 연결하게 되면 연결하는 데에 리소스를 낭비할 수 있다.
한 두번의 connection이라면 괜찮지만 굉장히 많은 커넥션을 요구한다면 새로운 커넥션을 생성할때마다 시간을 잡아먹게 될 것이다.
이를 해결하기 위해 Connection Pool을 생성해두어 미리 Connection을 여러개 만들어두는 것이다.
JDBC에서는 DataSource라는 것을 통해 Connection Pool을 제공하고 있다.
위의 MemberRepositoryV0.java에서 getConnection부분만 소스코드 수정을 해주면 된다.
private void close(Connection con, Statement stmt, ResultSet rs){ JdbcUtils.closeConnection(con); JdbcUtils.closeResultSet(rs); JdbcUtils.closeStatement(stmt); } private Connection getConnection() throws SQLException{ Connection con = dataSource.getConnection(); log.info("get connection={}, class={}", con, con.getClass()); return con; }
다음처럼 Connection을 DriverManager를 통해서 가져오는 것이 아니라 DataSource에서 가져오도록 하면
미리 설정해놓은 Connection Pool에서 가져오게 된다.
또한, Connection이 끝났을때 연결을 끊는 개념이 아니라 다시 connection pool에 돌려주는 개념이라서
JdbcUtil.close~~를 통해서 끝내주어야 한다.
트랜잭션
트랜잭션의 개념적인 부분들은 생략하기로 하겠다.
트랜잭션을 소스코드내에 포함해주지 않는다면 ACID에 문제가 생기게 된다.
void accountTransferEx() throws SQLException{ Member memberA = new Member(MEMBER_A, 10000); Member memberEx = new Member(MEMBER_EX, 20000); memberRepository.save(memberA); memberRepository.save(memberEx); assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000)) .isInstanceOf(IllegalStateException.class); Member findMemberA = memberRepository.findById(memberA.getMemberId()); Member findMemberB = memberRepository.findById(memberEx.getMemberId()); assertThat(findMemberA.getMoney()).isEqualTo(8000); assertThat(findMemberB.getMoney()).isEqualTo(22000); }
다음과 같은 상황에서 accountTransfer()을 실행할때 예외가 발생해서 불완전하게 끝난다면 큰 오류에 빠질 것이다.
이를 해결하기 위해서 트랜잭션은 꼭 필요하다고 볼 수 있다.
트랜잭션은 비즈니스 로직이 시작될때 항상 시작해줘야 한다.
해당 비즈니스 로직이 잘못될 경우 그 시작까지 롤백 해줘야 문제가 생기지 않기 때문이다.
또한, 트랜잭션이 정확히 롤백되기 위해서는 같은 커넥션을 항상 유지해줘야 한다.
AS-IS
@RequiredArgsConstructor public class MemberServiceV1 { private final MemberRepositoryV1 memberRepository; public void accountTransfer(String fromId, String toId, int money) throws SQLException{ Member fromMember = memberRepository.findById(fromId); Member toMember = memberRepository.findById(toId); memberRepository.update(fromId, fromMember.getMoney() - money); memberRepository.update(toId, toMember.getMoney() + money); } }
TO-BE
@Slf4j @RequiredArgsConstructor public class MemberServiceV2 { private final DataSource dataSource; private final MemberRepositoryV2 memberRepository; public void accountTransfer(String fromId, String toId, int money) throws SQLException{ Connection con = dataSource.getConnection(); try{ con.setAutoCommit(false); // 트랜잭션 시작 bizLogic(con, fromId, toId, money); con.commit(); // 성공시 커밋 }catch(Exception e){ con.rollback(); // 실패시 롤백 }finally{ release(con); } } private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException{ Member fromMember = memberRepository.findById(con, fromId); Member toMember = memberRepository.findById(con, toId); memberRepository.update(con, fromId, fromMember.getMoney() - money); memberRepository.update(con, toId, toMember.getMoney() + money); } private void release(Connection con){ if(con != null){ try{ con.setAutoCommit(true); // 커넥션 풀 고려 con.close(); }catch(Exception e){ log.info("error", e); } } } }
위에서 AS-IS에서 TO-BE를 보면 비즈니스 로직을 트랜잭션으로 감싸주었다.
하지만 소스코드에서 보다시피 서비스 계층임에도 불구하고 비즈니스 로직 코드보다 트랜잭션 코드가 많고,
서비스계층의 코드가 트랜잭션에 종속적인 코드가 되었다.
SQLException이나 connection을 반복해서 넘겨주는 것 등 JDBC의 소스코드에 너무 의존하게 된다.
이것은 트랜잭션의 코드 또한 추상화 시켜주는 것으로 해결이 가능하다.
스프링에서는 트랜잭션을 추상화한은 것을 PlatformTransactionManager를 통해서 해결해주고 있다.
PlatformTransactionManager 로직
트랜잭션을 시작하려면 데이터베이스 커넥션이 필요하다.
PlatformTransactionManager는 내부적으로 DataSource를 생성자를 통해 주입받는다.
커넥션은 트랜잭션 동기화 매니저에 보관한다.
트랜잭션 동기화 매니저는 스레드 로컨에 커넥션을 보관하여 멀티 스레드 환경에 안전하게 커넥션 보관하게 된다.
커넥션 호출 시에 데이터 접근 로직에서 앞선 트랜잭션 동기화 매니저에서 저장된 커넥션을 가져와 사용하게 된다.
위에 설명된 로직을 소스코드를 통해서 살펴보자
MemberService3_1.java
@Slf4j @RequiredArgsConstructor public class MemberServiceV3_1 { private final PlatformTransactionManager transactionManager; private final MemberRepositoryV3 memberRepository; public void accountTransfer(String fromId, String toId, int money){ TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try{ bizLogic(fromId, toId, money); transactionManager.commit(status); // 성공시 커밋 }catch(Exception e){ transactionManager.rollback(status); // 실패시 롤백 throw new IllegalStateException(e); }finally{ // release takes care of it from transaction manager // The repository automatically returns the connection to the <data synchronization manager>, so release is not required. } } private void bizLogic(String fromId, String toId, int money) throws SQLException{ Member fromMember = memberRepository.findById(fromId); Member toMember = memberRepository.findById( toId); memberRepository.update(fromId, fromMember.getMoney() - money); memberRepository.update(toId, toMember.getMoney() + money); } }
MemberRepositoryV3.java
@Slf4j public class MemberRepositoryV3{ // CRUD code... private void close(Connection con, Statement stmt, ResultSet rs){ JdbcUtils.closeResultSet(rs); JdbcUtils.closeStatement(stmt); // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다. // 트랜잭션을 사용하기 위해 동기화된 커넥션은 닫지않고 그대로 유지해준다. DataSourceUtils.releaseConnection(con, dataSource); } private Connection getConnection() throws SQLException{ // 만약 커넥션이 없다면 새로 만들어준다. // 이 작업을 통해서 같은 커넥션을 사용하게 된다. Connection con = DataSourceUtils.getConnection(dataSource); log.info("get connection={}, class={}", con, con.getClass()); return con; } }
MemerService3_1을 보면 트랜잭션 코드가 한결 간결해진 것을 볼 수 있다.
transactionManager에서 getTransaction을 호출하면 커넥션도 자동으로 생성된다.
또한, release도 트랜잭션 종료시에 커넥션을 반납해주기 때문에 따로 필요가 없다.
MemberRepositoryV3에서는 트랜잭션 매니저를 사용한다면 커넥션을 DataSourceUtils를 이용해서 생성 해줘야한다는 것을 주의해야 한다.
마지막으로 TransactionTemplate을 통해서 PlatformTransactionManager를 더욱 간단하게 사용이 가능하다.
@Slf4j @RequiredArgsConstructor public class MemberServiceV3_2 { private final TransactionTemplate txTemplate; private final MemberRepositoryV3 memberRepository; public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) { this.txTemplate = new TransactionTemplate(transactionManager); this.memberRepository = memberRepository; } public void accountTransfer(String fromId, String toId, int money){ // 많이 간단해졌지만 아직 트랜잭션 코드가 비즈니스 로직에 포함되어 있기는 하다. // 서비스 로직은 가급적 핵심 비즈니스 로직만 존재해야 좋다. txTemplate.executeWithoutResult((status) -> { // business logic try { bizLogic(fromId, toId, money); } catch (SQLException e) { e.printStackTrace(); } }); } }
TransactionTemplate을 적용하니 정말 간단해졌다.
여기서 좀 더 생각해보면 AOP를 이용하면 비즈니스 로직을 제외한 어떠한 코드도 필요하지 않을 것 같다.
이것을 스프링에서는 @Transactional을 통해서 제공한다.
@Transactional public void accountTransfer(String fromId, String toId, int money) throws SQLException { bizLogic(fromId, toId, money); }
이전에 코드에 비해서 완벽하게 깔끔해졌고,
서비스 계층에 필요한 코드만 있어서 순수하게 만들어줄 수 있다.
마지막으로 해결해야할 부분으로 보이는 것은 SQLException이라는 예외를 제거해준다면 완벽할 것 같다.
이를 해결할 수 있는 방법으로는 RuntimeException을 활용하는 방법이 있다.
RuntimeException의 경우에는 호출하는 상위 객체로 예외를 던지지 않아도 되는 언체크 예외이다.
체크예외
개발자가 예외를 알게하여 실수를 놓치지 않게 하지만 신경쓰지 않고 싶은 예외도 너무 많이 신경쓰게 한다.
언체크예외
신경쓰고 싶지 않은 것을 던질수 있지만, 실수를 하게 되는 경우가 더 생길 수 있다.
여기서 대부분의 체크예외의 경우,
예를 들어 DB연결이 불량한 것은 소스코드내에서 자체적으로 해결할 수 없는 것이 대부분이다.
따라서 예외를 최상위까지 올려봤자 큰 상관이 없다.
이러한 경우에 개발자가 더이상 신경 쓰지 않게 언체크 예외로 처리해주는 것이 좋다.
이로써 얻게 되는 이점은 interface상에 예외를 적지 않아되 된다는 점이다.
만약 위에서 만든 MemberRepository시리즈를 interface를 통해서 상속 하고 싶어도
public interface MemberRepository{ void save() throws SQLException{} // ~~ }
다음과 같이 예외를 포함해주어야하는 단점을 해결할 수 있다.
이럴땐 개발자가 의도한 예외를 따로 클래스로 만들어주고(꼭 RuntimeException이나 그 자식들을 상속받아야 한다.)
public class MyDbException extends RuntimeException{ public MyDbException() { } public MyDbException(String message) { super(message); } public MyDbException(String message, Throwable cause) { super(message, cause); } public MyDbException(Throwable cause) { super(cause); } }
그리고 나서 기존의 체크 예외들을 내가 만든 예외로 감싸주는 것이다.
public Member save(Member member) { String sql = "insert into member(member_id, money) value (?, ?)"; Connection con = null; PreparedStatement pstmt = null; try{ con = getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); return member; } catch (SQLException e){ throw new MyDbException(e); } finally { close(con, pstmt, null); } }
이렇게 하면 언체크 예외가 되기 때문에 함수 호출부에 예외를 던질 필요가 없어진다.
그리고 각각의 DB마다는 에러 코드가 있다.
해당 에러코드를 통해서 소스코드내에서 해결할 수 있게도 가능하다.
만약 key값 중복이 있어서 DB예외가 나는 경우를 생각해보자.
그렇다면 사용자에게 다시 입력하라고 요청할 수도 있지만 조금 더 간단하게 생각해서 임의의 해시코드를 생성해 저장한다고 하자.
그렇다면 key값 중복에 대한 예외를 만들어주자.
public class MyDuplicateKeyException extends MyDbException { public MyDuplicateKeyException() { } public MyDuplicateKeyException(String message) { super(message); } public MyDuplicateKeyException(String message, Throwable cause) { super(message, cause); } public MyDuplicateKeyException(Throwable cause) { super(cause); } }
그리고 나서 key코드를 잡아내서 해결해주자.
try { con = dataSource.getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); pstmt.executeUpdate(); return member; } catch (SQLException e) { //h2 db if (e.getErrorCode() == 23505) { throw new MyDuplicateKeyException(e); } throw new MyDbException(e); }
catch (MyDuplicateKeyException e){ log.info("duplicate keys, attempting to recover"); String retryId = generateNewId(memberId); log.info("retry id = {}", retryId); repository.save(new Member(retryId, 0)); }
다음처럼 재시도를 하는 소스코드를 만드는 것이 가능하다.
하지만 여기서 조금 불편한 점이라는 것은 각각 DB회사들의 문서를 보고 내가 필요한 에러 코드를 직접 찾아야 하는 것이다.
이것을 도와주는 번역기가 spring에서 제공한다.
아래 소스코드의 exTranslator
public class MemberRepositoryV4_2 implements MemberRepository { private final DataSource dataSource; private final SQLExceptionTranslator exTranslator; public MemberRepositoryV4_2(DataSource dataSource){ this.dataSource = dataSource; this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); } public Member save(Member member) { String sql = "insert into member(member_id, money) value (?, ?)"; Connection con = null; PreparedStatement pstmt = null; try{ con = getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); return member; } catch (SQLException e){ throw exTranslator.translate("save", sql, e); } finally { close(con, pstmt, null); } } // and so on... }
- Total
- Today
- Yesterday
- Container
- logback
- OS
- NextJS
- Firebase
- rhel
- React
- Linux
- spring boot
- Data Engineering
- broker
- frontend
- cs
- Java
- KAFKA
- K8S
- JPA
- apache kafka
- API
- 리액트
- Front
- 프론트엔드
- spring
- feign client
- consumer
- docker
- Producer
- apache
- centos
- zookeeper
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |