Course Introduction
Course Introduction
단순하게 생각하면 웹 애플리케이션은 크게 3가지 기능을 수행한다. 컨트롤러, 서비스, 리포지토리
Understanding JDBC
H2 Database Config
H2는 최신 버전을 받으면 안 된다. 우리가 쓰는 버전이랑 맞춰야 한다.
build.gradle에 가 보면
runtimeOnly 'com.h2database:h2'여기 있는 게 H2 데이터베이스에 접속하기 위한 클라이언트이다. 스프링 부트가 기본으로 잡아 준 거다. 버전을 봐야 한다.
External Libraries를 보면

h2:2.2.224
2.2.224 이거를 받아야 한다. 그래야 여기서 접근하는 클라이언트 라이브러리랑 서버랑 버전을 같은 걸 해야 문제가 안 생긴다. 버전이 좀 안 맞아도 될 때도 있지만 안 될 수도 있기 때문에 맞추는 게 좋다.
https://www.h2database.com/html/main.html
여기서 All Downloads 누르면 Archive Downloads라는 게 있다. 거기에 들어가면 내 버전 찾을 수 있다.
최초에 한 번은
JDBC URL에 jdbc:h2:~/test를 해야 한다.
파일로 바로 접근하는 방식인 듯
연결 누르면 창이 뜬다.
이렇게 접근을 최초로 하면 test.mv.db가 생긴다.
생겼다는 것을 확인해야 한다.
이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접근해야 한다.
jdbc:h2:~/test 이건 파일에 내가 직접 접근하는 것이다. 그래서 이제 이렇게 하면 안 된다.
파일이 생성되고 나면
jdbc:h2:tcp://localhost/~/test로 연결해야 한다.
이렇게 해야 문제가 안 생기고 잘 작동한다.
이제 준비는 됐다.
이후에 애플리케이션에서 데이터베이스 member에 접근해서 이 데이터를 넣고 빼는 예제들을 할 것이다.
Understanding JDBC
옛날엔 각각의 데이터베이스마다 사용 방법이 많이 달랐다. 옛날 이야기이다.

애플리케이션 로직은 과거엔 MySQL이나 오라클이 제공하는(?) 거기에 직접 사용했었다면, 이제 그렇게 하지 않고, 지금은 JDBC가 이런 걸 표준으로 정해 놨다.
java.sql.Connection - 연결하는 것
java.sql.Statement - SQL을 전달하는 것
java.sql.ResultSet - SQL 결과에 대해 어떻게 응답하는지
이게 옛날엔 DB마다 방법이 다 달랐다.
그런데 JDBC 표준 인터페이스가 나오게 되고 위 세 가지를 표준 인터페이스로 정했다. 자바는 이렇게 표준 인터페이스를 정의해 두었다.
JDBC 드라이버는 JDBC 표준 인터페이스를 구현해서 만든 드라이버이다.
개발자 입장에선 애플리케이션 로직을 개발할 때 JDBC 표준 인터페이스에만 맞춰서 개발하면 된다. 그러면 내가 MySQL 드라이버를 쓸 거면 MySQL 드라이버를 인터페이스에 꽂아서 쓰면 된다.
오라클로 바꿀 거면, 애플리케이션 로직에선 JDBC 표준 인터페이스는 그대로 사용하고, 구현체로 드라이버만 바꾸면 된다.
JDBC and Latest Data Access Technologies
JDBC를 직접 사용해도 되기는 한다.
JDBC를 직접 사용하면 사용 방법이 복잡하다. Low 레벨로 제공하다 보니 기능이 다 쪼개져 있다.
JDBC는 SQL 응답 결과를 객체로 바꿀 때 되게 복잡하다.
그런데 SQL Mapper를 사용하면 개발자가 SQL을 작성하면 나머지... SQL의 응답 결과를 객체로 바꿔 주거나 이런 것들을 좀 자동화해 준다. 그리고 JDBC의 반복 코드도 제거해 준다.
실무에서 SQL을 직접 쓸 때 JDBC를 직접 쓰는 경우는 없고, 최소한 JdbcTemplate이나 MyBatis 둘 중 하나를 선택해서 사용한다.
SQL Mapper의 단점은 개발자가 SQL을 직접 작성해야 한다는 것이다. ORM 기술과 비교했을 때의 단점이다.
ORM의 경우 애플리케이션 로직에선, 회원을 저장한다고 할 때 insert into 같은 SQL 쿼리를 전달하는 게 아니라, 회원 객체를 JPA에 전달한다. 그럼 여기서(구현체가 있기는 하다.) 객체의 매핑 정보가 있는데 그런 걸 보고 insert 쿼리를 직접 만든다.
개발자가 SQL을 작성하는 게 아니고, 개발자는 마치 자바의 컬렉션에 객체 넣을 때처럼 JPA에 넣어 주면 된다. 그러면 JPA가 알아서 SQL 다 만들어서 JDBC를 통해서 데이터베이스에 전달한다.
ORM은 객체를 관계형 데이터베이스 테이블과 매핑해 주는 기술이다. 객체랑 테이블이랑 어떻게 매핑되어 있는지 설정 정보를 주면 된다.
ORM은 각각의 데이터베이스마다 다른 SQL을 사용하는 문제도 중간에서 해결해 준다. 예를 들어 오라클이랑 MySQL은 페이징 쿼리가 다른데, 이런 부분들도 JPA가 제공하는 표준 페이징 인터페이스를 쓰면 오라클을 쓰면 오라클 DB에 맞춰서 오라클 페이징, MySQL을 쓰면 MySQL에 맞춰서 페이징 쿼리가 나간다.
JPA는 자바 진영의 ORM 표준 인터페이스이고, 이것을 구현한 것으로 하이버네이트와 이클립스 링크 등의 구현 기술이 있다.
실무에선 거의 하이버네이트를 쓰는 듯
Database Connection
java.sql.Connection이 JDBC 표준 인터페이스가 제공하는 커넥션이다.
F2 누르면 오류인 곳으로 이동한다.
@Slf4j
public class DBConnectionUtil {
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
log.info("connection = {}, class = {}", connection, connection.getClass());
return connection;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}예외는 뒤에서 더 배운다.
지금은 체크 예외를 실행 예외로 바꿔서 던진다고만 알고 있으면 된다.
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);데이터베이스에 연결하려면 JDBC가 제공하는 DriverManager.getConnection(..)을 사용하면 된다. 이렇게 하면 라이브러리에 있는 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션을 반환해 준다.
우리는 H2 클라이언트가 필요하다.

이걸(Driver) 통해 DB에 들어간다.
DriverManager가 자기들의 규칙에 따라서 이걸 찾는다.
즉 H2에 있는 이 드라이버(구현체)를 통해서 실제 커넥션을 가져온다.
DriverManager.getConnection()은 JDBC가 제공하는 거다.
@Slf4j
class DBConnectionUtilTest {
}테스트엔 앞에 public 없어도 된다.
@Slf4j
class DBConnectionUtilTest {
@Test
void connection() {
Connection connection = DBConnectionUtil.getConnection();
assertThat(connection).isNotNull();
}
}이 테스트를 실행할 때

h2.bat을 실행한 후, 이렇게 연결 버튼을 누르지 않고 이 창을 켜 놓기만 한 상태(혹은 왼쪽의 창은 끄고 오른쪽 검은 창만 켜 놓아도)에서 테스트를 해도

이렇게 테스트 성공한다.
그런데 h2.bat을 아예 실행하지 않고(혹은 실행한 다음 웹 화면은 그대로 두고, 검은 창만 꺼도) 테스트를 진행하면

테스트 실패한다.

테스트할 땐 이걸 조심해야 한다.
위 사진에서 connection()을 클릭하면
@Test
void connection() {
Connection connection = DBConnectionUtil.getConnection();
assertThat(connection).isNotNull();
}이 테스트를 실행할 때 나오는 로그만 나오고,
상위에 있는 Test Results를 클릭하면 실제 애플리케이션이 실행되는 전체 로그가 나온다.
테스트 로그를 보면
23:45:22.277 [Test worker] INFO hello.jdbc.connection.DBConnectionUtil -- connection = conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
이렇게 된다.
@Test
void connection() {
Connection connection = DBConnectionUtil.getConnection();
assertThat(connection).isNotNull();
}이 테스트 실행할 때
Connection connection = DBConnectionUtil.getConnection();여기서 getConnection()을 호출하면
@Slf4j
public class DBConnectionUtil {
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
log.info("connection = {}, class = {}", connection, connection.getClass());
return connection;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}여기서
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);DriverManager.getConnection()을 통해 커넥션을 가져왔다. 가져온 커넥션을 보면

인터페이스이다. 구현체가 있을 것이다. 이 구현체가 바로

MySQL 드라이버는 MySQLConnection이라는 구현체를 가지고 있고,
오라클 드라이버는 OracleConnection을 제공한다.(이름은 다를 것이다.)
H2는 org.h2.jdbc.JdbcConnection을 제공한다.
External Libraries에 가 보면

이게 있다. 이 JdbcConnection을 반환해 준다.
왜냐하면 우리는 MySQL 드라이버나 Oracle 드라이버가 아니고 H2 드라이버를 쓰기 때문이다.
그래서 Connection 인터페이스의 구현체인, H2 데이터베이스가 구현한 그 구현체를 제공해 준다.
우리는 그냥
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);이 Connection 인터페이스 기반으로만 코딩하면 된다. DB가 바뀌어도 이걸(커넥션을 획득하는 방법) 바꿀 필요가 없다.
org.h2.jdbc.JdbcConnection 이게 있어야 H2 데이터베이스랑 통신이 된다.
External Libraries에 H2 드라이버뿐만 아니라 MySQL 드라이버까지 같이 들어왔다고 가정하자.
그러면 DriverManager가 호출할 수 있도록 이것들이 다 드라이버 목록에서 관리된다.
예를 들어
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);jdbc:h2:tcp://localhost/~/test 이 URL로 요청했다고 하자
이름, 비밀번호 같은 추가 정보도 보낸다.
그러면 DriverManager에선 라이브러리에 등록된 드라이버 목록에 다 던진다. 우선 H2 드라이버에 던진다고 하자.(순서는 바뀔 수 있다.)
여기서 각각의 드라이버는 URL 정보를 체크해서 본인이 처리할 수 있는 요청인지 확인한다. 예를 들어서 URL이 jdbc:h2로 시작하면 이것은 h2 데이터베이스에 접근하기 위한 규칙이다.
따라서 H2 드라이버는 본인이 처리할 수 있으므로 실제 데이터베이스에 연결해서 커넥션을 획득하고 이 H2 커넥션을 클라이언트에 반환한다. 물론 인터페이스는 java.sql.Connection이고, 구현체는 H2 드라이버가 제공하는 H2 Connection이다.
반면에 URL이 jdbc:h2로 시작했는데 MySQL 드라이버가 먼저 실행되면 이 경우 본인이 처리할 수 없다는 결과를 반환하게 되고, 다음 드라이버에게 순서가 넘어간다. 만약 그래도 처리가 안 되면 커넥션을 획득할 수 없다고 오류가 날 것이다.
사실 우리가 H2 드라이버를 쓰는지, MySQL 드라이버를 쓰는지를 직접 어디에 등록 안 해도 된다. 라이브러리에 들어만 있으면, DriverManager에서 자동으로 다 인식할 수 있다.
JDBC Development - Registration
@Data
public class Member {
private String memberId;
private int money;
public Member() {
}
public Member(String memberId, int money) {
this.memberId = memberId;
this.money = money;
}
}
이전에 H2 데이터베이스에 만든

MEMBER_ID, MONEY와 매핑된다.
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV0 {
}JDBC의 DriverManager를 사용해서 저장할 것이다.
매우 Low 레벨로 하는 것을 한 번쯤은 하는 게 좋다.
나중엔 이런 JDBC 코드를 사용할 일은 없다.
Connection이 있어야 연결을 한다.
PreparedStatement를 가지고 데이터베이스에 쿼리를 날린다.
pstmt = con.prepareStatement(sql);
이건 체크 예외가 올라와서 try ~ catch 구문으로 잡거나 밖으로 던져야 한다.
} catch (SQLException e) {
log.error("db error", e);
throw e;
}throw e;를 통해 예외를 그냥 밖으로 던지겠다.
} finally {
pstmt.close();
con.close();
}닫아야 한다.
외부 리소스를 쓰고 있다. 실제 TCP/IP 커넥션이 걸려서 쓰고 있는 거다. 이 커넥션을 안 닫으면 잘못하면 이게 연결이 안 끊어지고 유지될 수 있다. 그러므로 닫아야 한다.
그런데 여기서 끝이 아니다.
만약 pstmt.close()에서 예외가 터지면 밖으로 나가면서 con.close()가 호출이 안 되는 문제가 발생할 수 있다.
그러니 다음과 같이 수정해야 한다.
private void close(Connection con, Statement stmt, ResultSet rs) {
if(rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.error("error", e);
}
}
if(stmt != null) {
try {
stmt.close();
} catch (SQLException e) {
log.error("error", e);
}
}
if(con != null) {
try {
con.close();
} catch (SQLException e) {
log.error("error", e);
}
}
}ResultSet은 뒤에 나온다. 지금은 ResultSet이 없으니 null로 하겠다.
Statement는 SQL을 그대로 넣는 거고, PreparedStatement는 파라미터를 바인딩 할 수 있다. 기능이 더 많다. Statement를 상속받았다. 그래서 이렇게 넘길 수 있다.
if(stmt != null) {
try {
stmt.close();
} catch (SQLException e) {
log.error("error", e);
}
}문제가 생겨도 여기서 크게 할 수 있는 건 없어서 그냥 로그로 에러만 남기겠다. 왜냐하면 이건 어떻게 처리할 수 있는 게 없다.
if(con != null) {
try {
con.close();
} catch (SQLException e) {
log.error("error", e);
}
}여기도 로그만 남기겠다. 이미 닫을 때 예외가 터진 거라서 특별히 할 수 있는 게 없다.
이런 식으로 하면 stmt.close()에서 SQLException 예외가 터져도 catch로 잡는다. 그래서 여기서 끝이 나기 때문에 뒤에 있는 if(con != null) {...}에 영향을 주진 않는다. 그래서 위에 SQLException이 터져도 con을 닫을 수 있다.
사용한 자원들을 다 닫아야 한다. 그래서 JDBC 코드가 지저분하다.
pstmt.executeUpdate();이걸 하면 준비된 쿼리가 실제 데이터베이스에 실행된다.
리소스 정리는 finally에서 정리해야 한다. 왜냐하면 close()를 try에서 한다고 하면, 위의 다른 코드에서 예외가 터지면 바로 catch로 가 버려서 close()가 호출되지 않는다. 그러므로 항상 호출되는 게 보장되도록 finally에서 해야 한다.
이런 순서는 옛날엔 중요했지만 지금은 SQL Mapper나 JPA가 다 해 주기 때문에 이제 우리가 크게 고민 안 해도 된다.
con = getConnection();
pstmt = con.prepareStatement(sql);Connection을 먼저 획득하고 Connection을 통해 PreparedStatement를 만들었기 때문에 리소스를 반환할 때는 PreparedStatement를 먼저 종료하고, 그다음에 Connection을 종료하면 된다.
PreparedStatement는 Statement의 자식 타입인데, ?를 통한 파라미터 바인딩을 가능하게 해 준다.
참고로 SQL Injection 공격을 예방하려면 PreparedStatement를 통한 파라미터 바인딩 방식을 사용해야 한다.
이걸 만약 안 쓰면 어떻게 되냐 하면(이 강의 범위를 넘어서는 거라 대략적으로만 설명)
String sql = "insert into member(member_id, money) values(?, ?)";이건 이전에 작성한 코드이다. ?라고 하면 파라미터 바인딩이라는 게 명확하다. 그런데 이렇게 할 수도 있다.
String memberId = "select * from ...";
String money = "...";
String sql = "insert into member(member_id, money) values("+ memberId +", "+ money +")";SQL Injection이라는 건 회원 가입할 때, 회원 가입 창에 이런 식으로 적는 거다.
String memberId = "select * from ..." 같은 쿼리가 들어오는 거다.
그럼 memberId 부분이 SQL로 치환된다. 지금 쓴 쿼리에선 문제가 없을 수도 있는데 select 쿼리 같은 경우 이게 프로그램 로직에 들어올 수 있다. 즉, 문제가 되는 다른 걸 호출할 수도 있다. 그러면서 DB가 털린다.
그런데 이걸 파라미터 바인딩 방식을 쓰면
String sql = "insert into member(member_id, money) values(?, ?)";이 안엔 단순히 데이터로만 취급이 되기 때문에 그럴 염려가 없다.
즉, 단순히 문자 더하기를 하면
insert into member(member_id, money) values(insert into ..., ...)
SQL의 일부가 된다. insert into가 추가로 들어올 수 있다.
그런데 파라미터 바인딩을 하면 단순하게 데이터로 취급된다. 그래서 SQL Injection 공격이 안 된다.
결론은 PreparedStatement를 쓰면 된다.
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
Member member = new Member("memberV0", 10000);
repository.save(member);
}
}throws SQLException을 하는 이유는 MemberRepositoryV0의 save() 코드를 보면
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {체크 예외 SQLException이 올라오기 때문이다. 그래서 예외를 던졌다.
테스트를 실행하고 성공했다면 이제 H2 데이터베이스에서 확인해 보겠다.

select * from member;를 실행하면
내가 저장했던 데이터가 들어 있다.
애플리케이션을 통해서 Member 데이터를 save()에 집어넣고 호출하면, save() 코드에서 insert 쿼리에 데이터들이 다 바인딩이 되어서, executeUpdate()로 실제 insert SQL이 데이터베이스에 전달되고, 결과가 반영된다.
그런데
@Test
void crud() throws SQLException {
Member member = new Member("memberV0", 10000);
repository.save(member);
}이걸 한 번 더 실행하면 예외가 터진다.
org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: "PUBLIC.PRIMARY_KEY_8 ON PUBLIC.MEMBER(MEMBER_ID) VALUES ( /* 1 */ 'memberV0' )"; SQL statement:
insert into member(member_id, money) values(?, ?) [23505-224]
.
.
.
왜냐하면 primary key가 MEMBER_ID인데 같은 값이 들어와서 문제가 된다. 다른 값으로 바꾸고 실행하면 제대로 된다.
반복해서 테스트할 수 있는 기법들은 뒤에서 배운다.
추가적으로 테스트)
h2.bat 실행해서

이 상태로만 두고, 연결 버튼은 안 누른 상태에서
@Test
void crud() throws SQLException {
Member member = new Member("memberV3", 10000);
repository.save(member);
}이 테스트 실행해도 정상적으로 등록된다.
JDBC Development - Query
회원 하나를 조회하는 기능을 개발해 보자.
Connection con = null;
PreparedStatement pstmt = null;이런 걸 밖에 선언하는 이유가 try ~ catch 이후에 finally에서 이걸 호출해야 하기 때문에 밖에 선언해야 한다.
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);
}데이터가 없어서 rs.next()가 false를 반환하면 else로 가서 예외를 던지겠다.
예외를 던질 땐 메시지를 잘 정의하는 게 좋다. 만약 memberId를 안 넣어 놓는다면 서비스 운영할 때 어떤 멤버에서 문제가 생겼는지 찾기 힘들 수 있다.
테스트 실행하기 전에 기존에 등록했던 데이터들은 H2 데이터베이스에서 다 삭제해 두겠다.
@Test
void crud() throws SQLException {
// save
Member member = new Member("memberV3", 10000);
repository.save(member);
// findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember = {}", findMember);
log.info("member == findMember {}", member == findMember);
assertThat(findMember).isEqualTo(member);
}member와 findMember는 다른 객체이다.
member는 위 코드에서 생성해 주고,
findMember는
findById() 내부 코드를 보면
if(rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));여기서 new Member()로 생성한 거다.
즉 다른 객체이다.
log.info("member == findMember {}", member == findMember);이 로그의 결과도
23:38:29.204 [Test worker] INFO hello.jdbc.repository.MemberRepositoryV0Test -- member == findMember false
이렇게 false가 나온다.
하지만
assertThat(findMember).isEqualTo(member);이건 테스트 성공한다.
@Test
void crud() throws SQLException {
// save
Member member = new Member("memberV4", 10000);
repository.save(member);
// findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember = {}", findMember);
log.info("member == findMember {}", member == findMember);
log.info("member equals findMember {}", member.equals(findMember));
assertThat(findMember).isEqualTo(member);
}이렇게 실행해 보면

equals는 true이다.
@Data를 쓰면 equals()와 hashCode()를 자동으로 만들어 준다.
assertThat(findMember).isEqualTo(member);이것도 내부에서 equals()를 써서 비교한다. 그래서 참이다.

ResultSet도 이런 식으로 생겼다.
findById()에서는 회원 하나를 조회하는 것이 목적이다. 따라서 조회 결과가 항상 1건이므로 while 대신에 if를 사용한다. 다음 SQL을 보면 PK인 member_id를 항상 지정하는 것을 확인할 수 있다.
-> 우리는 PK를 찍어서 데이터를 하나만 조회했으므로, 0개 아니면 1개이기 때문에 if를 사용했다.
JDBC Development - Update, Delete
int resultSize = pstmt.executeUpdate();이건 0이나 1이 나와야 한다.
왜냐하면
String sql = "update member set money = ? where member_id = ?";where 문에서 PK로 찍었다.
executeUpdate()는 쿼리를 실행하고 영향받은 row 수를 반환한다. 여기서는 하나의 데이터만 변경하기 때문에 결과로 1이 반환된다. 물론 없으면 0이 반환된다.
지금까지 작성한 save(), findById(), update(), delete()를 보면 비슷한 코드들이 많다. 그래서 SQL Mapper 이런 게 나왔다.
쿼리도 비슷하다. 그래서 JPA도 나왔다.
뒤에서 다 해 보겠다.
delete()는 검증을 어떻게 해야 할까?
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
// save
Member member = new Member("memberV100", 10000);
repository.save(member);
// findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember = {}", findMember);
log.info("member == findMember {}", member == findMember);
log.info("member equals findMember {}", member.equals(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());
Member deletedMember = repository.findById(member.getMemberId());
}
}이걸 테스트하면 테스트 실패한다.

NoSuchElementException 예외가 터진다. 이건
} else {
throw new NoSuchElementException("member not found memberId = " + memberId);
}우리가 만든 이거다.
그렇다면 검증은 어떻게 해야 할까? 삭제를 하면 데이터가 사라진다. 그러면 NoSuchElementException을 던지도록 해 놨다. 이 예외가 터지면, 이건 데이터가 없으니 삭제가 성공했다고 보면 된다.
// delete
repository.delete(member.getMemberId());
assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);findById()를 호출하면 NoSuchElementException 예외가 터져야 한다.
마지막에 회원을 삭제하기 때문에 테스트가 정상 수행되면, 이제부터는 같은 테스트를 반복해서 실행할 수 있다.
물론 테스트 중간에 오류가 발생해서 삭제 로직을 수행할 수 없다면 테스트를 반복해서 실행할 수 없다.
예를 들어
// update: money: 10000 -> 20000
repository.update(member.getMemberId(), 20000);
Member updatedMember = repository.findById(member.getMemberId());
assertThat(updatedMember.getMoney()).isEqualTo(20000);
if(true) {
throw new IllegalStateException("...");
}
// delete
repository.delete(member.getMemberId());
assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);이런 식으로 delete() 전에 예외가 터지면 delete()가 호출되지 않는다.
그러면 DB에도

memberV100이 저장되어 있다.
그러면 다음에 위 코드를 수정해서 예외를 발생하지 않도록 하더라도, 테스트 다시 실행하면 그래도 안 된다. 왜냐하면 memberV100은 이미 DB에 들어가 있기 때문이다.
그렇기 때문에 이 경우엔 어쩔 수 없이 DB에서 delete를 따로 해 줘야 한다. 그다음에 다시 테스트 실행하면 성공한다.
아무튼 이와 같은 이유로 지금처럼 테스트 마지막에 delete()를 넣어서 삭제하는 게 궁극적인 방법은 아니다.
트랜잭션을 활용하면 이 문제를 깔끔하게 해결할 수 있는데, 자세한 내용은 뒤에서 설명한다.
Summary
자바 진영에서 JDBC라는 표준 인터페이스를 만들었다. 그리고 데이터베이스 업체들이 이걸 구현해서 우리에게 제공해 달라는 식으로 했다.
Understanding Connection Pools and Data Sources
Understanding Connection Pool
DB 드라이버는 TCP/IP 커넥션이 연결되면 ID, PW와 기타 부가 정보를 DB에 전달한다.
DB는 ID, PW를 통해 내부 인증을 완료하고, 내부에 DB 세션을 생성한다.
이 사용자에 대해 DB 작업을 하기 위해 DB 내부에 세션을 생성한다.
DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환한다.
DB 드라이버 입장에선 애플리케이션 로직이 클라이언트다.
데이터베이스마다 커넥션을 생성하는 시간은 다르다.
TCP/IP 연결하는 것 외에도 데이터베이스 내부에서 ID, PASSWORD로 인증하고, 이 커넥션에서 온 정보를 기반으로 쿼리를 수행하기 위한 DB 세션도 생성하는 등 여러 과정이 있다.
시스템 상황마다 다르지만 MySQL 계열은 수 ms 정도로 매우 빨리 커넥션을 확보할 수 있다. 반면에 수십 ms 이상 걸리는 데이터베이스들도 있다.
애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다. 보통 얼마나 보관할지는 서비스의 특징과 서버 스펙에 따라 다르지만 기본값은 보통 10개이다.
상황에 따라 20, 30, 100개 등으로 늘어날 수 있다.
커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 다 연결되어 있는 상태이다. DB에도 세션이 10개 만들어져 있다.
애플리케이션 로직은 커넥션 풀에서 받은 커넥션을 사용해서 SQL을 데이터베이스에 전달하고 그 결과를 받아서 처리한다.
커넥션을 맺는 시간 자체가 사라진다. SQL을 수행하는 시간, SQL을 네트워크로 던지고 결과를 받아서 수행하는 시간만 필요하다.
커넥션을 모두 사용하고 나면 이제는 커넥션을 종료하는 것이 아니라, 다음에 다시 사용할 수 있도록 해당 커넥션을 그대로 커넥션 풀에 반환하면 된다. 커넥션을 닫으면 TCP/IP 연결이 끊어진다.
커넥션 풀은 서버당 최대 커넥션 수를 제한할 수 있다.
고객 입장에선 장애가 난다. 스레드들이 대기하므로.
그렇지만 DB 스펙을 무한정 올릴 수도 없다. DB에 무한정 연결이 생성되는 것을 막아주어서 DB를 보호하는 효과도 있다.
스프링 부트를 사용하면 hikariCP가 기본으로 제공된다.
Understanding DataSource
커넥션 풀도 내부적으로 DriverManager 같은 것들을 활용해서 커넥션을 생성한다. 단지 우리가 어디에 접근하는지의 문제다.
우리가 직접 DriverManager를 통해 항상 새로운 커넥션을 생성할지, 아니면 커넥션 풀에 있는 커넥션 풀이 대신 만들어 준 걸 꺼낼지의 차이다.
애플리케이션 로직에서 DriverManager를 사용해서 커넥션을 획득하다가 HikariCP 같은 커넥션 풀을 사용하도록 변경하면 커넥션을 획득하는 애플리케이션 코드도 함께 변경해야 한다.
DBCP2에서 HikariCP로 바꿀 때도 마찬가지다.
javax.sql.DataSource는 자바에서 기본으로 제공한다.
DriverManagerDataSource를 호출하면 내부에서 DriverManager를 사용해서 커넥션을 항상 새로 반환해 준다.
보통 어떤 커넥션 풀을 쓰다가 다른 커넥션 풀로 바꿀 때 효과를 발휘한다.
DataSource Example 1 - DriverManager
Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD);이렇게 하면 DB랑 연결해서 커넥션 하나를 얻게 된다.
Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);이것도 호출하면 2개를 얻는 거다.
@Slf4j
public class ConnectionTest {
@Test
void driverManager() throws SQLException {
Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
log.info("connection = {}, class = {}", con1, con1.getClass());
log.info("connection = {}, class = {}", con2, con2.getClass());
}
}실행하면

서로 다른 커넥션이다. 실제 DB에서 커넥션을 가져온다.
H2를 쓰므로 클래스는 org.h2.jdbc.JdbcConnection이다
이번엔 스프링이 제공하는 DriverManagerDataSource를 써 보겠다.
DriverManagerDataSource도 내부에서 DriverManager를 쓰기 때문에 항상 새로운 커넥션을 획득한다.
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);이걸
DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);이렇게 할 수도 있다. 왜냐하면 부모를 따라가 보면 DataSource를 구현하고 있다. 지금은 그냥 DriverManagerDataSource 타입으로 하겠다.
@Test
void dataSourceDriverManager() throws SQLException {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
useDataSource(dataSource);
}
private void useDataSource(DataSource dataSource) throws SQLException {
Connection con1 = dataSource.getConnection();
Connection con2 = dataSource.getConnection();
log.info("connection = {}, class = {}", con1, con1.getClass());
log.info("connection = {}, class = {}", con2, con2.getClass());
}이걸 실행하면

DriverManagerDataSource에서 뭔가 로그를 남겨 준다.
Creating new JDBC DriverManager Connection
새로운 JDBC DriverManager 커넥션을 만들었다.
그래서 conn0, conn1이 만들어졌다.
객체를 설정하는 부분과, 사용하는 부분을 좀 더 명확하게 분리할 수 있는 게 DataSource의 장점 중 하나다.
물론
DriverManager.getConnection(URL, USERNAME, PASSWORD);이렇게 하더라도 Util 만든 것처럼 한 곳에서 잘 모아 놔야 한다.
DataSource Example 2 - Connection Pool
HikariDataSource dataSource = new HikariDataSource();이건 어디서 오는 걸까?
package com.zaxxer.hikari;여기 것이다. 이건 스프링에서 JDBC를 쓰면 자동으로 import가 된다.
HikariDataSource dataSource = new HikariDataSource();이것도
DataSource dataSource = new HikariDataSource();이렇게 쓸 수 있지만, 아직 세팅해야 할 것들이 있어서 일단
설정할 땐 HikariDataSource 타입으로 해야 한다.
dataSource.setMaximumPoolSize(10);디폴트가 10개이지만 지금은 눈으로 보기 위해 넣었다.
주석 처리해도 되는 듯
dataSource.setPoolName("MyPool");풀의 이름을 지정할 수 있다. 지정 안 하면 기본 풀이 나오고 로그에서 확인할 수 있다.
@Test
void dataSourceConnectionPool() throws SQLException {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
dataSource.setMaximumPoolSize(10);
dataSource.setPoolName("MyPool");
useDataSource(dataSource);
}이렇게 그대로 실행하면 안 된다. 그래도 실행하면

타이밍이 좋아서인지 잘 된 것 같다. 여러 번 실행을 반복하다 보면 로그가 다르게 보일 때도 있다.
Thread.sleep(1000);을 해 줘야 한다.
@Test
void dataSourceConnectionPool() throws SQLException, InterruptedException {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
dataSource.setMaximumPoolSize(10);
dataSource.setPoolName("MyPool");
useDataSource(dataSource);
Thread.sleep(1000);
}이걸 실행하면

로그가 훨씬 많이 나온다.
커넥션 풀에서 커넥션을 생성하는 작업은 애플리케이션 실행 속도에 영향을 주지 않기 위해 MyPool connection adder(풀 이름을 MyPool이라고 내가 지정해서 그렇다. 풀 이름을 지정하지 않으면 HikariPool-1 connection adder라고 나온다.)라는 별도의 쓰레드에서 작동한다. 로그에서도 확인할 수 있다.
그런데 Thread.sleep(1000);을 안 하면, 테스트 코드가 useDataSource(dataSource);로
private void useDataSource(DataSource dataSource) throws SQLException {
Connection con1 = dataSource.getConnection();
Connection con2 = dataSource.getConnection();
log.info("connection = {}, class = {}", con1, con1.getClass());
log.info("connection = {}, class = {}", con2, con2.getClass());
}이거 찍고 바로 끝난다. 그래서 로그를 다 확인할 수 없다. 풀에 추가하는 게 제대로 안 보인다. 풀에 추가하는 것까지 보고 싶으면 약 1초 정도 sleep을 주면 된다.
로그들을 확인해 보면

Hikari가 로그를 남겨 준다.
dataSource.setMaximumPoolSize(10);이 코드를 그대로 두든, 주석 처리하든
풀 사이즈는 10이라고 뜬다.
After adding stats (total=10, active=2, idle=8, waiting=0)
활성화된 게 2개이다. 우리가 2개 꺼냈기 때문에 활성화된 게 2개이다. 우린 close를 안 했지만 꼭 해 줘야 한다.
대기 상태인 게 8개이다.
커넥션 풀에 커넥션을 채우는 것은 상대적으로 오래 걸리는 일이다. 왜냐하면 외부랑 연결을 해야 한다.
커넥션 풀에서 커넥션을 획득하고 그 결과를 출력했다.
23:38:27.745 [Test worker] INFO h.jdbc.connection.ConnectionTest -- connection = HikariProxyConnection@1347016882 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
23:38:27.753 [Test worker] INFO h.jdbc.connection.ConnectionTest -- connection = HikariProxyConnection@321772459 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
이 두 줄이다.
HikariProxyConnection은 히카리가 만들어 준 객체이다. 내부에 래핑해서 conn0과 conn1을 가지고 있다.
클래스를 보면 HikariProxyConnection이 보이는데 이건 히카리가 커넥션 풀에서 관리하는 그 커넥션이다.
HikariProxyConnection 안에 JDBC 커넥션 0과 1이 들어 있다고 보면 된다.
여기서는 커넥션 풀에서 커넥션을 2개 획득하고 반환하지는 않았다. 따라서 풀에 있는 10개의 커넥션 중에 2개를 애플리케이션이 가지고 있는 상태이다
그렇다면 커넥션 풀에 커넥션이 차기 전에 커넥션을 획득하면 어떻게 되나? 그땐
Connection con1 = dataSource.getConnection();
Connection con2 = dataSource.getConnection();여기서 약간 기다려서 내부적으로 커넥션을 획득한다.
예를 들어 풀에 현재 1개가 있다고 치자. 1개는
Connection con1 = dataSource.getConnection();여기서 바로 반환된다. 그런데
Connection con2 = dataSource.getConnection();여기서 획득하려면 없다. 그러면 풀에서 내부적으로 1개를 획득할 때까지 기다리게 된다. 풀이 1개를 획득하고 나서
Connection con2 = dataSource.getConnection();여기서 반환한다. 이때는 약간 대기 시간이 걸릴 수 있다.
만약 10개를 넘으면 어떻게 될까?
private void useDataSource(DataSource dataSource) throws SQLException {
Connection con1 = dataSource.getConnection();
Connection con2 = dataSource.getConnection();
Connection con3 = dataSource.getConnection();
Connection con4 = dataSource.getConnection();
Connection con5 = dataSource.getConnection();
Connection con6 = dataSource.getConnection();
Connection con7 = dataSource.getConnection();
Connection con8 = dataSource.getConnection();
Connection con9 = dataSource.getConnection();
Connection con10 = dataSource.getConnection();
Connection con11 = dataSource.getConnection();
log.info("connection = {}, class = {}", con1, con1.getClass());
log.info("connection = {}, class = {}", con2, con2.getClass());
}이렇게 하고 실행하자

1개가 waiting이다. 초과가 되었다. 그러면 풀이 확보될 때까지 block이 된다.
사진에선 글자가 잘렸지만
MyPool - Connection is not available, request timed out after 30017ms (total=10, active=10, idle=0, waiting=0)
이런 내용이 있다. 30초 후 커넥션이 끊겼다. 히카리에 들어가면 이런 설정이 있다. 풀이 가득찼을 때 얼마나 기다리고 나서 예외를 터뜨릴지 같은 설정이 있다.
풀 쓸 때 주의해야 할 건, 풀이 다 차면 얼마나 기다릴지를 세팅하는 게 중요하다. 보통 이런 시간을 짧게 하는 게 좋다. 고객은 30초나 기다리고 싶어 하지 않는다.
Apply DataSource
DataSource를 사용하려면 우선 의존 관계 주입을 받아야 한다.
@Slf4j
public class MemberRepositoryV1 {
private final DataSource dataSource;
public MemberRepositoryV1(DataSource dataSource) {
this.dataSource = dataSource;
}
.
.
.
getConnection()도 바꾸겠다.
private Connection getConnection() throws SQLException {
Connection con = dataSource.getConnection();
log.info("get connection = {}, class = {}", con, con.getClass());
return con;
}close()도 다음처럼 바꾸겠다.
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}이게 더 안정적으로 코드가 짜여 있다.
closeResultSet()의 내부 코드를 보면
public static void closeResultSet(@Nullable ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException var2) {
SQLException ex = var2;
logger.trace("Could not close JDBC ResultSet", ex);
} catch (Throwable var3) {
Throwable ex = var3;
logger.trace("Unexpected exception on closing JDBC ResultSet", ex);
}
}
}우리가 짰을 땐 SQLException만 했었는데, 이건 다른 예외까지 고려했다.
DataSource는 자바에서 제공하는 표준 인터페이스이기 때문에 DriverManagerDataSource에서 HikariDataSource로 변경되어도 해당 코드를 변경하지 않아도 된다.
MemberRepositoryV1Test를 실행해 보자.

get connection 로그를 보면 conn0, conn1, conn2, conn3, conn4, conn5 하면서 계속 새로운 커넥션을 맺는다.
Creating new JDBC DriverManager Connection 이 로그를 통해서도 알 수 있다.
쿼리를 실행할 때마다 DB에 계속 새로운 커넥션을 맺어서 수행하고 있는 거다. 왜냐하면 우리는 DriverManagerDataSource를 사용했기 때문이다. 이러면 성능이 안 좋다. 성능 테스트를 해 보면 느릴 것이다.
그렇기 때문에 커넥션 풀을 쓰면 된다.
커넥션 풀을 사용하기 위해 다음처럼 바꿨다.
@BeforeEach
void beforeEach() {
// 기본 DriverManager - 항상 새로운 커넥션을 획득
// DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// 커넥션 풀링:
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
repository = new MemberRepositoryV1(dataSource);
}나머지는 설정 안 해도 된다. 최대 풀 사이즈는 기본값인 10으로 되고 나머지도 기본값으로 된다.
여기선
DataSource dataSource = new HikariDataSource();이렇게 바꾸면 안 된다. DataSource 인터페이스에는 set~~ 이런 게 없다. 그래서 여기선 구체적인 타입을 써야 한다.
하지만 의존 관계 주입을 할 땐
@Slf4j
public class MemberRepositoryV1 {
private final DataSource dataSource;
public MemberRepositoryV1(DataSource dataSource) {
this.dataSource = dataSource;
}
.
.
.DataSource로 주입한다.
히카리로 바꾸고 다시 테스트 실행하면

sleep()을 걸면 다른 결과가 나왔을 텐데, 지금은 sleep()을 안 걸었기 때문에 그냥 하나로 끝났다.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}이걸 추가하고 다시 실행하면

풀이 10개로 가득 차는 걸 볼 수 있다.
get connection = HikariProxyConnection@1139609587 wrapping conn0
로그를 잘 보면 get connection 다음에 wrapping conn0라는 게 있다. 전부 0다. 왜 전부 0일까?
@Test
void crud() throws SQLException {
// save
Member member = new Member("memberV100", 10000);
repository.save(member);
// findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember = {}", findMember);
log.info("member == findMember {}", member == findMember);
log.info("member equals findMember {}", member.equals(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);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}repository.save(member);를 하고 끝난다.
그리고 repository.findById(member.getMemberId());를 한다. 끝난다.
MemberRepositoryV1의 save()를 보자.
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();
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);
}
}finally에서 close()를 하면
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}이게 호출되는데, 커넥션 풀인 경우엔 커넥션을 닫는 게 아니라, 커넥션 풀에 커넥션을 close() 시점에 반환한다.
그러면 conn0을 썼다가 반환하고, 썼다가 반환한다. 지금 순차적으로 로직이 돈다. 꺼낼 땐 처음에 있는 걸 꺼낸다. 그래서 0번을 꺼냈다가 반환하고, 또 꺼내면 첫 번째에 있는 0번이 반환된다.
웹 애플리케이션이랑(?) 동시에 여러 멀티 스레드에서 요청하면 다른 커넥션을 가져다 쓸 것이다.

아까의 히카리 로그를 다시 보면
사실 객체 인스턴스 주소는 다 다르다. 히카리 객체는 새로 생성하는데, 그 안에 래핑하고 있는...

히카리에서 커넥션 풀에서 조회하면, 리턴할 땐 HikariProxyConnection이란 객체를 생성하고 거기에 실제 커넥션을 래핑해서 반환한다.
그래서 객체 인스턴스 주소는 다르다. 그렇지만 그 안에 있는 실제 커넥션은 똑같다.
처음에 조회를 하면 1번을 반환해 주는데, 반환해 줄 때 HikariProxyConnection이라는 객체를 하나 새로 생성하고 거기에 실제 TCP/IP 연결된 커넥션을 넣어서 반환한다. 그걸 다 쓰면 이 객체는 끝나는 거고, 커넥션은 그대로 들어온다. 또 조회하면 HikariProxyConnection라는 객체를 생성해서(객체 생성은 비용이 크지 않다. 커넥션 풀이 문제다.) 그 안에 다시 0번을 담아서 반환한다.
이 내용이 핵심은 아니고, 핵심은 커넥션 풀을 사용하면 같은 커넥션을 재사용할 수 있다는 점이다.
Summary
커넥션 풀에서 꺼내서 쓰고, 다시 반환하면 커넥션을 close()를 하게 된다.
JdbcUtils.closeConnection(con);이 커넥션을 close() 하더라도, 풀에서 꺼내면 히카리 커넥션이 감싸고 있어서 close() 요청이 오면, 커넥션 풀에 커넥션을 반환하는 로직이 그 안에 들어 있다. 이건 참고로만 알면 된다.
DataSource는 커넥션을 획득하는 방법을 추상화하는 인터페이스라는 게 중요하다.
설정과 사용을 분리하는 게 좋은 설계다.
설정하는 부분을 한군데로 몰고(단일 책임 원칙)...
설정을 뭔가 해야 할 때, 여러 군데 설정을 하게 되면 그건 단일 책임 원칙을 어긴 거다.
URL이나 USERNAME 같은 걸 바꾸려면 한곳에서만 바꿔야 한다. 그게 단일 책임 원칙을 지키는 거다.
Understanding Transactions
Transaction - Concept Understanding
Database Connection Structure and DB Session
DB 세션에서 실제 DB 서버 안에 있는 어떤 동작을 한다.
사용자는 웹 애플리케이션 서버(WAS)나 DB 접근 툴 같은 클라이언트(DB 입장에선 이게 클라이언트이다.)를 사용해서 데이터베이스 서버에 접근할 수 있다.
클라이언트는 요청하는 것, 서버는 그 요청을 받아서 처리하는 것이다.
세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다
세션이 살아 있으면 트랜잭션 시작하고 SQL 실행하고 커밋하고 또 트랜잭션 시작하고 SQL 실행하고 커밋할 수 있다.
커넥션을 연결하면 내부에서 DB 세션이 만들어진다.
이게 실제 구현 레벨에 따라서 그림이 좀 다를 수는 있지만, 기본적으로는 이 그림으로 보면 된다.(pdf 참고)
Transaction - DB Example 1 - Understanding Concepts
insert 쿼리만 넣고 커밋 안 했는데 들어갈 수 있다. 자동 커밋 모드라는 게 있다. 지금은 이걸 수동 커밋 모드로 바꾸고 나서 시작하는 걸 기준으로 얘기하는 거다.

상태에 있는 완료, 임시는 우리가 눈으로 볼 수 있는 건 아니고 DB 내부에서 관리하는 거다.
임시라는 건 DB마다 구현이 다를 수 있다.
커밋 전의 데이터는 다른 세션에서 보이지 않는다. 하지만 트랜잭션 격리 수준 1단계인 READ UNCOMMITED라는 게 있다.
데이터베이스나 커넥션을 할 때 트랜잭션 격리 수준을 설정할 수 있다. READ UNCOMMITED로 설정하면 보인다. 대신 READ UNCOMMITED는 기능이 단순하기 때문에 성능 면에서는 좀 더 유리하다.
커넥션 통해서 commit을 호출하면 세션에서 내부적으로 commit을 처리한다.
Transaction - DB Example 2 - Auto Commit, Manual Commit
H2에서
set autocommit true;
하면 자동 커밋 모드로 설정한다.
기본이 자동 커밋 모드이다.
자동 커밋 모드에서 계좌 이체를 생각해 보면 A의 돈 5000원을 빼고, B의 돈 5000원을 더해야 하는데,
A의 돈 5000원을 빼고 바로 커밋이 된다. 그리고 B에게 5000원을 더하려고 하는데 실패하면 A의 돈만 5000원 빠지는 셈이다.
set autocommit true;
insert into member(member_id, money) values ('data1',10000);
insert into member(member_id, money) values ('data2',10000);
자동 커밋 모드를 하면 줄마다 커밋이 된다.
이렇게 하나씩 해도 트랜잭션은 내부에서 일어난다.
데이터베이스 입장에선 SQL 하나하나 실행할 때마다 트랜잭션이라는 건 내부에서 일어난다. 그런데 한 줄 단위로 굉장히 짧은 트랜잭션이 일어난다.
insert 하기 직전에 트랜잭션이 시작하고, insert 하고, 트랜잭션이 끝난다. 자동 커밋 모드는 이게 자동으로 된다.
그런데 수동 커밋으로 설정하면 내가 원하는 범위만큼, 커밋하기 전까진 데이터베이스에 반영이 안 된다.
사실 자동 커밋 모드에서도 한 줄마다 내부에서 트랜잭션은 발생한다. 우리가 눈으로 보지 못할 뿐이다.
commit, rollback 둘 다 안 하면 어떻게 될까?
데이터베이스엔 타임아웃이라는 게 있다. 트랜잭션 수행 시간 타임아웃이 보통 설정되어 있다. DB마다 좀 다르다. 설정한 시간을 넘어가게 되면 자동으로 rollback이 된다.
애플리케이션이랑 DB랑 커넥션을 맺으면 그때도... 소위 트랜잭션을 시작한다고 표현하는... 그렇게 하면 그게 커밋 모드를 수동 커밋 모드로 바꾸는 것이다.

참고로 수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 해당 세션에서는 계속 유지된다. 중간에 변경하는 것은 가능하다
다른 pdf에
H2 데이터베이스 웹 콘솔 창을 2개 열 때 기존 URL을 복사하면 안 된다. 꼭 http://localhost:8082를 직접 입력해서 완전히 새로운 세션에서 연결하도록 하자. URL을 복사하면 같은 세션(jsessionId)에서 실행되어서 원하는 결과가 나오지 않을 수 있다.
이런 내용이 있는 걸 보니 H2 창 하나는 같은 세션인 듯? 확실하지 않다.
수동 커밋 모드로 변경하는 것을 관례상 트랜잭션을 시작한다고 표현한다.
강의 외적으로 테스트)
세션1에서
set autocommit false;를 한 다음에
insert 쿼리를 여러 개 실행해도 임시 데이터이기 때문에 세션2에서 select 해도 안 보인다.
그런데 세션1에서 commit;을 안 했는데도
set autocommit true;를 하고 나면
세션2에서 위에서 insert 했던 데이터들을 select로 해도 보인다.
set autocommit true;
insert into member(member_id, money) values ('data1',10000);
insert into member(member_id, money) values ('data2',10000);
이렇게 하면 할 때마다 커밋이 일어난다.
그래서 이 이후에 rollback;을 호출해 봐야 아무 변화 없다.
다음 시간엔 앞에서 봤던 예제를 가지고 DB 트랜잭션을 실습해 보겠다. 우선 데이터베이스 안에서 해 볼 거고, 이후엔 애플리케이션에 옮겨서 해 보겠다.
Transaction - DB Example 3 - Hands-on Transaction
세션1에서
set autocommit false;
insert into member(member_id, money) values ('newId1',10000);
insert into member(member_id, money) values ('newId2',10000);
이렇게 실행했다.
커밋 전이지만 어쨌든 세션1이 넣은 거다. 세션1은 볼 수 있어야 한다. 그래서 임시로 newId1, newId2가 들어간다. 그래서 세션1에선 볼 수 있어야 한다.

세션1에서 커밋을 하면

세션1과 세션2 모두에서 데이터가 보인다.

세션1이 커밋을 하면 임시 데이터가 '완료'가 된다. 완료가 됐다는 것은 실제 DB에 반영됐다는 뜻이다.
업데이트 쿼리를 날리면, 예를 들어 원래 10000원인데 20000원으로 바꿨다. 그리고 롤백을 한다. 그러면 다시 10000원으로 돌아간다.
Transaction - DB Example 4 - Account Transfer

두 번째 쿼리를 일부러 틀렸더니 이런 오류 메시지가 나온다.
첫 번째 쿼리는 일단 임시 상태로 반영된다.
memberB는 쿼리가 실패했기 때문에 아예 건들지를 못 했다.
이때 세션1이 조회하면 8000원, 10000원으로 보이고
세션2가 조회하면 아직 커밋이 안 됐기 때문에 10000원, 10000원으로 보인다.
DB Lock - Understanding the Concept
DB Lock - Change
SET LOCK_TIMEOUT 60000;
이걸 안 해도 된다. 그러면 데이터베이스에 설정된 기본값을 쓴다.
세션1에서
set autocommit false;
update member set money=500 where member_id = 'memberA';
이걸 먼저 실행하고,
세션2에서
SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';
이걸 실행하면

위에 두 개는 성공하지만 update 쿼리는 아직 기다리면서 돌고 있다. 로그에 안 나왔다.
강의 외적으로 확인)
시간이 조금 지나니

이렇게 뜬다.
SET LOCK_TIMEOUT 60000: 락 획득 시간을 60초로 설정한다. 60초 안에 락을 얻지 못하면 예외가 발생한다.
참고로 DB마다 조금 다르지만 H2 데이터베이스는 내부에 시간 체크하는 타이머가 있는 듯. 아마 타이머가 정확하진 않아서 H2 데이터베이스에서는 딱 60초에 예외가 발생하지는 않고, 시간이 조금 더 걸릴 수 있다.

세션2에서
SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';
이걸 다시 실행하면 오른쪽처럼 뜨고(세션1의 commit은 아직 안 한 상태)
세션1에서 commit;을 하면

세션1에서 commit; 하는 순간 세션2에서 update 관련 메시지가 뜬다. 즉 update 쿼리가 수행된 거다. 그런데 세션2도 아직 commit;을 한 건 아니다.
그래서
세션1과 세션2에서 select * from member;를 해 보면

세션2는 1000원으로 임시 상태로 바꿔 놓았다. DB엔 500원으로 반영되어 있다.
참고로 김영한 님은 82875 ms가 떠서 60초가 훨씬 지났는데도... H2 데이터베이스의 락 타임아웃이 좀 오래 걸린다. 체크하는 것이.
그런데 내 PC에선 설정한 만큼 비슷하게 나온 것 같다.
DB Lock - Query
데이터베이스마다 다르지만, 보통 데이터를 조회할 때는 락을 획득하지 않고 바로 데이터를 조회할 수 있다. 예를 들어서 세션1이 락을 획득하고 데이터를 변경하고 있어도, 세션2에서 트랜잭션 반영되기 전의 데이터를 그대로 조회할 수 있다. 물론 임시 데이터 말고 커밋되기 전 상태의 데이터이다.
옛날 데이터베이스는 조건에 따라 다르지만 현대의 데이터베이스는 대부분 락을 획득하지 않고 다른 세션에서 다 조회할 수 있다.
set autocommit false;
select * from member where member_id='memberA' for update;
트랜잭션을 시작해야 한다. 자동 커밋 모드에선 의미가 없다.
세션1이 select for update 구문으로 락을 가져가도 세션2에서 select는 가능하다.
세션1이
set autocommit false;
select * from member where member_id='memberA' for update;
이걸 수행한 상태에서
세션2가
set autocommit false;
update member set money=500 where member_id = 'memberA';
이걸 수행하면

set autocommit false;만 수행되고 update 구문은 수행이 안 된다. 락이 없기 때문에 기다리게 된다.
세션1이 commit;을 한다고 해도 select이기 때문에 DB의 데이터가 바뀌진 않는다.
대신 세션2가 락을 획득하게 되어 update 구문을 실행한다.
이때 세션1이 select * from member;를 해도 세션2가 커밋을 안 해서 기존 10000원이 보인다.
트랜잭션과 락은 데이터베이스마다 실제 동작하는 방식이 조금씩 다르기 때문에, 해당 데이터베이스 매뉴얼을 확인해 보고, 의도한 대로 동작하는지 테스트한 이후에 사용하자.
그런데 현대의 데이터베이스들은 대부분 지금 방식으로 동작한다.
select for update 구문을 해도 락이 없다면 대기해야 한다.
지금까지는 데이터베이스만 가지고 트랜잭션과 락에 대한 개념 이해를 해 봤다.
실제론 애플리케이션에서 코드를 짜서 한다. 다음 시간엔 이런 상황을 애플리케이션에 녹여서 트랜잭션이 어떻게 동작하는지 배운다.
Transaction - Application 1
먼저 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직만 구현해 보고, 트랜잭션은 다음 시간에 적용하겠다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
.
.
.
}findById()를 쓰면 밑에 빨간 줄이 뜬다. findById() 코드를 보면
public Member findById(String memberId) throws SQLException {
.
.
.
}이렇게 SQLException을 던지기 때문이다. 그래서 accountTransfer()도 throws SQLException을 하겠다.
원래 이렇게 막 던지지는 않는데 지금 예제에선 그냥 이렇게 하겠다. Exception에 대해선 뒤에서 따로 배운다.
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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}검증에 성공하면 두 번째 update()로 넘어가는데, 실패하면 두 번째로 못 넘어간다.
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";처음부터 이렇게 상수 정하지 않아도, 코딩하면서 나중에 상수로 뽑아도 된다.
지금 테스트할 땐 간단하게 DriverManagerDataSource로 하겠다. dataSource가 필요한 이유는 지금 Repository가 dataSource를 필요로 하기 때문이다.
테스트를 처음 하면 given, when, then을 하는 게 좋지만 꼭 여기에 얽매일 필요는 없다.
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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}지금은 트랜잭션 이런 게 없다.
그러면 기본적으로 자동 커밋 모드로 돈다.
update() 하나 실행하고 자동 커밋이 계속된다. 그리고 예외가 발생해서 밖으로 튕겨져 나간다. 그러면 두 번째 로직이 수행되지 않는다. 결과적으로 memberA의 돈만 2000원 깎이고 toMember의 돈은 변경 사항이 없다.
테스트 코드 전체를 호출하면
before() -> 메서드1() -> after() -> before() -> 메서드2() -> after()
이런 식으로 호출되는 듯
테스트에서 사용한 데이터를 제거하는 더 나은 방법으로는 트랜잭션을 활용하면 된다. 테스트 전에 트랜잭션을 시작하고, 테스트 이후에 트랜잭션을 롤백해 버리면 데이터가 처음 상태로 돌아온다. 이 방법은 좀 나중에 배운다.
우리는
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money); 이렇게 예외를 발생시켰다.
물론 toMember 쪽에서 SQL 쿼리에 문제가 생겨도 동일한 문제가 발생한다.
다음 시간엔 트랜잭션을 활용해서 이 문제를 해결해 보겠다.
Transaction - Application2
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}이 부분이 전부 원자적으로 커밋이 되거나, 롤백이 되어야 한다.
보통 비즈니스 로직이 하나의 단위로 수행된다.
보통 비즈니스 로직 단위로 트랜잭션을 걸게 된다.
트랜잭션을 건다는 건 여기서 트랜잭션을 시작하고, 이게 끝날 때 트랜잭션을 커밋하거나 롤백한다는 거다.
커밋하거나 롤백하면 트랜잭션이 종료된다.
트랜잭션을 시작하려면 커넥션이 필요하다.
set autocommit false;를 하는 게 트랜잭션을 시작하는 거다.
커넥션이 있어야 거기에 명령을 날리고 그러면 DB가 그걸 받아서 트랜잭션 시작할 거다. set autocommit false 명령어가 수행되는 거다.
두 가지가 있다.
트랜잭션을 시작하려면 커넥션이 필요하다.
트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 그래야 같은 세션을 사용할 수 있다.
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money); 이 두 개를 같은 커넥션에서 써야 한다. 다른 커넥션이면 다른 세션이 동작한다. 그러면 세션1, 세션2로 완전 다른 데에서 동작하는 거다.
우리가 원하는 건 하나의 세션에서 트랜잭션 시작하고 데이터 쭉 업데이트하고 커밋하거나 롤백하거나 이런 결정을 하고 싶은 거다. 그러려면 그동안 같은 커넥션을 계속 유지해 줘야 한다.

사용자가 웹 애플리케이션 서버에 들어오면 여기서 커넥션을 맺는다. 이 커넥션을 통해서 세션이 맺어져 있고, 세션에서 트랜잭션을 결과적으로 시작하게 된다. 그런데 다른 커넥션을 맺으면 다른 세션이 맺어지고 트랜잭션도 다른 곳에서 시작한다. 그럼 트랜잭션이 하나로 안 묶인다.
MemberRepositoryV2를 만들 것이다.
MemberServiceV1의 accountTransfer()를 보면 findById()랑 update() 두 가지 메서드를 쓰고 있다. 그러므로 이 두 군데에 커넥션을 파라미터로 받을 수 있게 세팅할 거다.
MemberServiceV2에서 커넥션을 얻어야 하므로 이제 DataSource가 필요해진다.
con.setAutoCommit(false);
이렇게 false로 해야 트랜잭션이 시작된다.
이렇게 하면 커넥션을 통해서 데이터베이스에 set autocommit false; 명령어를 DB에 날려 준다. 그러면서 트랜잭션이 시작된다.
con.commit;을 하면 커밋 명령어가 커넥션을 통해 DB 세션에 전달되고 그 세션이 이 커밋을 실행한다.
} catch(Exception e) {
con.rollback(); // 실패 시 롤백
throw new IllegalStateException(e);
} 실패 시 롤백을 해야 한다. 여기선 롤백을 하고, 그냥 기존 예외를 감싸서 던지겠다.
예외를 언제 던질지는 뒤의 예외 파트에서 다시 배운다.
} finally {
if(con != null) {
try {
con.setAutoCommit(true); // 커넥션 풀 고려
con.close();
} catch(Exception e) {
log.info("error", e);
}
}
}지금은 이 긴 코드들이 나중에 다 줄어든다.
finally에서 커넥션을 릴리즈해야 한다. JdbcUtils를 써도 되는데 지금은 한 가지 고려해야 할 게 있어서 수동으로 하겠다.
커밋이든 롤백이든 다 끝났으니, 이 커넥션을 내가 시작했기 때문에 내가 정리해 줘야 한다.
참고로 Exception을 로그로 남길 땐 {}를 쓰지 않는다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); // 트랜잭션 시작
//비즈니스 로직
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(con, toId, toMember.getMoney() + money);
con.commit(); // 성공 시 커밋
} catch(Exception e) {
con.rollback(); // 실패 시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}개발할 땐 레이어가 다르다거나.. 이런 건 분리해 주는 게 좋다.
위 코드에서
//비즈니스 로직
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(con, toId, toMember.getMoney() + money);이거 제외하고는 다 트랜잭션을 처리하기 위한 코드이다. 그런데 위 코드는 순수한 비즈니스 로직이다. 섞여 있으면 보기 힘들다. 그래서 메서드 추출하는 게 좋다.

Ctrl + F6을 누르면 위 창에서 파라미터 순서 바꿀 수 있다.
지금은 서비스에서 커넥션을 조회했다.
항상 무슨 리소스를 시작하면, 그 리소스를 시작한 곳에서 리소스를 릴리즈하는 작업까지 해야 한다.
커넥션 풀을 사용 안 할 땐 상관없다. 풀을 사용 안 하면 con.close()를 하면 커넥션이 그냥 종료된다. 그러면 다시 커넥션을 생성하면 다시 자동 커밋 모드로 돌아간다. DB에서 그렇게 세팅이 되어 있다.
그런데 커넥션 풀을 사용하면 커넥션이 계속 살아 있다. 세션도 계속 유지가 되고 있다. 바로 con.close()를 하면 커넥션이 종료되는 게 아니라 풀에 반납이 된다. 그렇기 때문에 현재 수동 커밋 모드로 동작하기 때문에 자동 커밋 모드로 변경해야 한다.
다른 곳에서는 받았을 때 기본적으로 자동 커밋 모드라고 가정하고 코딩한다.
근데 사실 우리가 직접 이렇게 쓰지 않고 대부분 프레임워크가 이런 걸 대신 해 주기 때문에 일반적으론 문제가 발생하지 않는다.
MemberServiceV2Test를 만들겠다.
히카리로 바꿔도 되지만 그냥 DriverManagerDataSource를 하겠다.
/**
* 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
*/
class MemberServiceV2Test {
.
.
.
}파라미터를 전달해서 커넥션을 동기화하는 거다.
동기화는 같은 걸 쓰는 거다.
MemberServiceV2Test의
@Test
@DisplayName("정상 이체")
void accountTransfer() throws SQLException {
// given
Member memberA = new Member(MEMBER_A, 10000);
Member memberB = new Member(MEMBER_B, 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
// when
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(12000);
}이 테스트를 실행하면

conn3, conn4, conn5 등이 보인다. 같은 커넥션을 써야 하는 거 아닌가? 이건 위 코드에서
memberRepository.save(memberA);
memberRepository.save(memberB);이거나
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());이거다. 이런 건 다른 커넥션을 쓰고,
// when
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);여기 안에선 같은 커넥션을 쓴다.
// when
log.info("START TX");
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
log.info("END TX");이렇게 로그를 추가하고 다시 실행해 보자.

START TX 이후로 처음엔 커넥션을 만든다.
왜냐하면
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
.
.
.
}dataSource에서 커넥션을 받아 오기 때문이다. 그런데 이후엔 커넥션을 받아 오는 로그가 없다. 왜냐하면 받은 걸 파라미터로 계속 넘겨서 같은 커넥션을 내부에서 재사용하기 때문이다. 그래야 트랜잭션이 유지된다.
지금 보면 서비스 계층인데 비즈니스 로직 코드보다 트랜잭션 처리 코드가 더 많고 복잡하다. 옛날엔 이렇게 짰었다.
추가로 커넥션을 유지하도록 코드를 변경하는 것도 문제다.
MemberRepositoryV2를 보면 어떤 경우엔
public void update(String memberId, int money) throws SQLException {
.
.
.
}이게 필요하고
어떤 경우엔
public void update(Connection con, String memberId, int money) throws SQLException {
.
.
.
}이게 필요할 수 있다.
이런 식으로 메서드가 늘어난다.
다음 시간엔 스프링을 활용해서 이런 문제들을 하나씩 해결해 보겠다.
지금은 서비스 계층에 트랜잭션과 비즈니스 로직이 복잡하게 섞여 있다. 이걸 스프링에서 어떻게 해결하는지 다음 시간부터 하나하나씩 배운다.
Spring and Problem Solving - Transaction
Problems
Transaction Abstraction
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(); //실패시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}JDBC에선 이렇게 했었다. 커넥션에 대고 직접 했었다.
public static void main(String[] args) {
//엔티티 매니저 팩토리 생성
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("jpabook");
EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성
EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득
try {
tx.begin(); //트랜잭션 시작
logic(em); //비즈니스 로직
tx.commit();//트랜잭션 커밋
} catch (Exception e) {
tx.rollback(); //트랜잭션 롤백
} finally {
em.close(); //엔티티 매니저 종료
}
emf.close(); //엔티티 매니저 팩토리 종료
}JPA에선 EntityTransaction을 통해 begin, commit, rollback을 한다.
즉 JDBC랑 JPA랑 완전 다르다. 트랜잭션을 사용하는 코드는 데이터 접근 기술마다 다르다. 같은 것도 있지만 대부분 다르다.
JDBC 기술을 사용하다가 JPA 기술로 변경하게 되면 서비스 계층의 코드도 JPA 기술을 사용하도록 함께 수정해야 한다.
이게 단일 책임 원칙에 안 맞는 거다. 변경 포인트가 하나일 때 여러 군데에서 수정해야 한다.
이걸 어떻게 해결해야 할까? 트랜잭션 기능을 추상화하면 된다.
스프링은 OOP의 장점을 극대화하였다. 스프링 프레임워크가 여러 기능들을 제공하는데 잘 보면 그게 다 OCP, DI를 위한... OOP에서 제공하는 그 기능을 극대화해서 써서 변경에 굉장히 유연하게 기능을 제공한다. 스프링 기본 편 강의 내용이 중요하다.
getTransaction(): 트랜잭션을 시작한다.
이름이 getTransaction()인 이유는 기존에 이미 진행 중인 트랜잭션이 있는 경우, 해당 트랜잭션을 가져와서 쓸 수 있다.
트랜잭션 참여, 전파는 뒤에서 배운다.
지금은 단순히 트랜잭션을 획득해서 트랜잭션을 시작하는 것으로 이해하면 된다.
앞으로 강의에서 PlatformTransactionManager 인터페이스와 구현체들를 포함해서, 이렇게 트랜잭션을 스프링이 관리해 주는 걸 트랜잭션 매니저로 줄여서 언급될 것이다.
Transaction synchronization
동작 방식을 간단하게 설명하면 다음과 같다.
1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 내부에서 만들고 트랜잭션을 시작한다.
트랜잭션 매니저에서 커넥션 조회하고 setAutoCommit(false) 이런 걸 다 한다.
2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
이전엔 커넥션을 내가 들고 있었다. 그래서 할 때마다 파라미터로 넘겼었다.
이전에 봤듯이 update() 하거나 하려면 Repository에서 커넥션이 필요하다.
Repository에서 커넥션을 어떻게 조회하냐 하면, 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이건 트랜잭션이 이미 시작된 그 커넥션이다. 따라서 이제 파라미터로 커넥션을 전달하지 않아도 된다.
4. 트랜잭션이 종료되면, 즉 커밋이나 롤백 호출하게 되면, 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 커밋이나 롤백을 하고, 그다음에 커넥션도 닫고 동기화 매니저에 저장된 것도 같이 제거해 준다.
스레드 로컬을 사용하면 각각의 스레드마다 별도의 저장소가 부여된다. 따라서 해당 스레드만 해당 데이터에 접근할 수 있기 때문에, 동시에 여러 스레드가 같은 커넥션을 사용한다거나 이런 문제가 발생하지 않는다.
Transaction Problem Solving - Transaction Manager 1
DataSourceUtils.getConnection()
DataSourceUtils.releaseConnection()
이 코드들이 트랜잭션 동기화 매니저에 접근해서 커넥션 가져오고, 닫고 하는 코드들이다.
Connection con = DataSourceUtils.getConnection(dataSource);내가 직접 DataSource에서 꺼내는 게 아니라 DataSourceUtils라는 곳에서 꺼낸다. 코드를 따라가 보면 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼낸다.

만약 서비스 계층에서 트랜잭션 매니저를 안 쓴다면? 트랜잭션 없이 한다고 하면 Repository에서 트랜잭션 동기화 매니저에서 커넥션을 조회하면, 커넥션이 없을 것이다.
이런 경우엔 새로운 커넥션을 생성해서 반환한다.
어떤 경우엔 트랜잭션이 있어야 하고, 어떤 경우엔 트랜잭션이 없어도 되고.. 둘 다 동작하게 된다.
DataSourceUtils.releaseConnection()을 사용하면 커넥션을 바로 닫지 않고 다음을 체크한다.
트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해 준다.
트랜잭션 동기화 매니저에서 썼다면 닫지 않고 남겨 둔다. 왜냐하면 트랜잭션 동기화 매니저에 누가 넣어 줬을까? 서비스 계층이다. 트랜잭션 매니저가 넣어 준 거다. 이 경우엔 Repository에서 안 닫고 놔둔다. 왜냐하면 트랜잭션을 시작한 서비스 계층에서 트랜잭션 매니저를 통해서 닫아야 한다.
트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다. 트랜잭션 동기화 매니저가 관리하는 커넥션이 아니면 여기서 시작했다는 뜻이니 커넥션을 닫는다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 트랜잭션 시작
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 할 필요 없다. 트랜잭션이 커밋되거나 롤백될 때 release를 알아서 하면 된다. 트랜잭션 매니저가 이걸 해 준다.
왜냐하면 트랜잭션이 커밋되거나 롤백되면 다 끝난 거라 커넥션 정리해서 닫으면 된다. 트랜잭션이 종료가 되기 때문에 더 이상 커넥션을 쓸 일이 없다. 그래서 트랜잭션 매니저 내부에서 커넥션 다 정리해 준다. 닫아 준다.
JPA 같은 기술로 변경되면 JpaTransactionManager를 주입받으면 된다. 지금은 인텔리제이에서 JpaTransactionManager가 안 뜨는데 그 이유는 스프링 모듈 중에 JPA와 관련된 모듈을 build.gradle에서 안 받아서 그렇다.
TransactionStatus 상태 정보에 대한 건 나중에 스프링 전파 이런 거 배울 때 배운다.
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);파라미터로 dataSource를 넘겨야 한다. 왜냐하면 트랜잭션 매니저가 데이터 소스를 통해 커넥션을 생성한다. 이 일도 트랜잭션 매니저가 한다. 그런데 데이터 소스가 없다면 여기서 커넥션을 못 만든다.
트랜잭션 매니저에서 커넥션을 만들어야 setAutoCommit(false)로 바꾸고, 트랜잭션 동기화 매니저에 만든 커넥션을 집어넣어 주고 할 것이다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); // 성공 시 커밋
} catch(Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
}TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
트랜잭션 매니저(지금은 DataSourceTransactionManager)를 통해 트랜잭션 획득을 한다.
그러면 어떤 일이 발생할까?
트랜잭션 매니저는 데이터 소스를 알고 있다. 거기서 커넥션을 얻는다. 그리고 setAutoCommit(false);도 한다. 그리고 나서 트랜잭션 동기화 매니저에 보관한다.
그 후에 비즈니스 로직을 수행한다.
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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}findById() 호출하면
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);
}
}커넥션을 획득한다. 그런데 getConnection()을 보면
private Connection getConnection() throws SQLException {
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection = {}, class = {}", con, con.getClass());
return con;
}Connection con = DataSourceUtils.getConnection(dataSource);을 하면
Repository에서 트랜잭션 동기화 매니저에 들어가 있는, 트랜잭션이 시작한 그 커넥션을 가져와서 수행한다. 나머지 로직도 마찬가지다.
그리고 나서 끝나면 close()를 하는데
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}DataSourceUtils.releaseConnection(con, dataSource); 이걸 할 땐, 트랜잭션 동기화 매니저에서 꺼낸 거라면 내가 커넥션을 안 닫는다. 그냥 넘긴다.
비즈니스 로직이 다 돌고 성공하면
transactionManager.commit(status);이걸 호출한다. 그러면 트랜잭션 매니저가 실제 커넥션을 커밋하고, 리소스를 다 release 하고 트랜잭션 동기화 매니저에 있는 것도 다 정리한다.
Transaction Problem Solving - Transaction Manager 2
Tx troubleshooting - Tx template
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new
DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}비즈니스 로직 부분 제외하고 나머지는 서비스마다 똑같다.
이런 문제는 단순히 메서드 추출로 해결할 수 있지 않다. 트랜잭션 코드 중간에 비즈니스 로직이 나오기 때문이다.
이럴 땐 템플릿 콜백 패턴을 활용하면 이런 반복 문제를 깔끔하게 해결할 수 있다.
템플릿 콜백 패턴을 사용하면, 트랜잭션 시작하는 부분이나 성공하면 커밋, 실패하면 롤백하는 코드들을 어딘가에 몰아 두고 쓸 수 있다.
TransactionTemplate이라는 걸 쓰면 우리는 비즈니스 로직만 작성하고, TransactionTemplate 코드 안에서 트랜잭션 시작하고, 커밋하거나 롤백하는 코드를 대신 처리해 준다.
TransactionTemplate은 템플릿 콜백 패턴으로 구현되어 있다.
TransactionTemplate 안에 트랜잭션 관련된 코드가 다 들어가 있고, 우리는 비즈니스 로직만 따로 수행할 수 있게 로직을 작성해 주면 된다.
생성자에 로직을 넣을 거기 때문에 @RequiredArgsConstructor는 빼겠다.
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}TransactionTemplate을 밖에서 설정해서 주입받아도 되지만, 그냥 PlatformTransactionManager를 주입받겠다.
이 패턴을 많이 사용한다.
TransactionTemplate을 쓰려면 트랜잭션 매니저가 필요하다.
참고로 TransactionTemplate을 밖에서 빈으로 등록하고 이걸 주입받아도 된다. 그런데 위에 것처럼 쓰는 이유는 여러 이유가 있는데, 관례로 굳어진 이유도 있고, TransactionTemplate은 그냥 클래스이다. 유연성이 없다. 그런데 PlatformTransactionManager를 두면 유연성이 생긴다. 이걸 다른 거로 바꿀 수도 있다. 크게 중요한 건 아닌 듯
TransactionTemplate이 내부에 transactionManager를 가지고 있다. 감싸고 있어서 트랜잭션 매니저와 관련된 로직을 여기서 대신 다 수행해 준다고 보면 된다.
accountTransfer()는 반환하는 값이 없으므로 executeWithoutResult()를 쓰면 된다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult((status) -> {
// 비즈니스 로직
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}executeWithoutResult() 이 코드를 시작하면, 이 코드 안에서 트랜잭션을 시작하고, 그다음에
// 비즈니스 로직
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}이 비즈니스 로직을 수행한다. 체크 예외가 안 터지면 훨씬 깔끔했을 것이다.
그리고 이 코드가 끝났을 때 이 로직이 성공적으로 반환되면, executeWithoutResult() 코드 안에서 커밋, 예외가 터지면 롤백한다.
지금은 잘 이해가 안 되어도, 고급 편 강의 들으면 개념적으로 잘 이해될 듯
스프링에 기본 룰이 있다. 런타임(언체크) 예외는 롤백하고, 체크 예외들은 커밋한다는 기본 룰이 있다.
이 룰들을 나중에 바꿀 수 있는데, 이 룰들이 생긴 배경 같은 건 뒤에서 배운다.
반복하는 건 해결했다. 그런데 이곳은 서비스 로직인데 비즈니스 로직뿐만 아니라 트랜잭션을 처리하는 기술 로직이 여전히 함께 포함되어 있다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult(status -> {
// 비즈니스 로직
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}코드가 줄기는 했는데, 그래도 TransactionTemplate이라는 걸 쓰면서 트랜잭션을 처리하는 코드가 들어가 있다.
트랜잭션을 쓰다가 트랜잭션을 안 쓰게 되면 코드를 다시 고쳐야 한다.
서비스 로직은 가급적 핵심 비즈니스 로직만 있어야 한다. 하지만 트랜잭션 기술을 사용하려면 어쩔 수 없이 트랜잭션 코드가 나와야 한다. 어떻게 하면 이 문제를 해결할 수 있을까?
다음 시간엔 트랜잭션 템플릿을 쓰는 걸 넘어서, 트랜잭션 AOP, 프록시로 이 문제를 해결하는 방법에 대해 배운다.
Troubleshooting Transactions - Understanding Transaction AOP

프록시를 도입하기 전엔 트랜잭션 템플릿을 쓰든 쓰지 않든, 클라이언트가 서비스 로직을 호출하면 여기서 트랜잭션 로직을 시작했다. 그리고 비즈니스 로직 수행하고 트랜잭션 종료했다.
프록시는 대신 뭔가를 처리해 주는 거라고 생각하면 될 듯
클라이언트가 서비스를 직접 호출하는 게 아니라, 트랜잭션 프록시라는 코드를 호출하면 여기서 트랜잭션 시작하고, 여기서 실제 서비스 로직을 호출해 주고, 응답이 오면 여기서 트랜잭션 종료해 준다.
프록시는 스프링이 다 만들어 준다.
쉽게 얘기해서
public class TransactionProxy {
private MemberService target;
public void logic() {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}이런 코드를 스프링이 다 자동으로 만들어 주고 스프링 빈으로 다 등록해 준다. 대략적으로 이렇게 생겼다는 거지 실제로 이렇게 똑같이 생기진 않았다.
트랜잭션 프록시가 호출해야 할 실제 비즈니스 로직을 내부에 가지고 있다. 이걸 target이라고 한다.
클라이언트가 트랜잭션 프록시를 호출하면 여기서는 로직이 이런 식으로 돈다.
public class TransactionProxy {
private MemberService target;
public void logic() {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}예시에선 그냥 Exception이라고 했는데, 런타임 예외의 경우에 롤백한다.
여기선 그냥 쉽게 얘기해서 트랜잭션 프록시라고 지칭하는 듯. 트랜잭션을 처리해 주는 프록시.
@Transactional을 해 두면 스프링이 프록시를 앞에 만들어서 넣어 주고, 여기서 대신 트랜잭션을 처리하는구나라고 일단 이해해도 된다. 자세한 내용은 고급 편에서 배운다.
Transaction Troubleshooting - Applying Transaction AOP
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 비즈니스 로직
bizLogic(fromId, toId, money);
}이렇게 하면 이 메서드 호출될 때 트랜잭션을 걸고 시작한다. 이 메서드 호출이 끝나면, 성공하면 커밋, 런타임 예외가 터지면 롤백한다.
이 애노테이션 하나로 끝난다.
스프링이 제공하는 트랜잭션 AOP를 적용하기 위해 @Transactional 애노테이션을 추가했다.
코드 밑에 추가한 게 아니라, 메타데이터로서 애노테이션만 추가했다.
@Transactional 쓰고 AOP 적용하려고 하려면 스프링 컨테이너에 스프링 빈을 다 등록해야 할 수 있다.
그래야 스프링이 빈 등록된 걸 보고 뭔가 자기가 원하는 걸 그 안에서 만들어 내고 할 수 있다.
@SpringBootTest가 있으면 테스트를 돌릴 때 스프링을 하나 띄운다. 필요한 스프링 빈을 다 등록하고, 스프링 빈에 대한 의존 관계 주입도 다 받는다.
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
.
.
.
}우린 아직 위 두 개를 스프링 빈으로 등록을 안 했었다. 그럼 의존 관계 주입이 안 된다. 빈으로 등록해야 한다.
@Configuration vs @TestConfiguration 관련 질문 확인하기
https://www.inflearn.com/community/questions/1370868/configuration-vs-testconfiguration
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig {
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
.
.
.
}프록시에서 트랜잭션이 시작하려면 결국 프록시 코드도 트랜잭션 매니저 불러서 써야 한다.
그래서 transactionManager()가 필요하다. 이걸 주입받아서 쓴다.
DataSourceTransactionManager 트랜잭션 매니저를 스프링 빈으로 등록한다. 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해 두어야 한다.
-> DataSourceTransactionManager 이거 생략해도 되나? 이건 뒤에서 설명하는 듯. 원칙적으론 등록이 되어 있어야 한다.
@Test
void AopCheck() {
log.info("memberService class = {}", memberService.getClass());
log.info("memberRepository class = {}", memberRepository.getClass());
}이 테스트를 실행하면
memberService class = class hello.jdbc.service.MemberServiceV3_3$$SpringCGLIB$$0
memberRepository class = class hello.jdbc.repository.MemberRepositoryV3
클래스 이름 뒤에 SpringCGLIB가 붙는다.(나는 EnhancerBy가 안 붙는데 이유는 모르겠다.)

@Transactional이 붙어 있으면 서비스 로직을 상속받아서
public class TransactionProxy {
private MemberService target;
public void logic() {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}이런 트랜잭션 코드를 만든다. 이게 트랜잭션 프록시이다.
그래서 눈에 보이는 memberService는 실제 MemberService가 아니고 트랜잭션 프록시 코드이다. 그리고 트랜잭션 프록시 코드는 내부에 트랜잭션을 처리하는 로직을 가지고 있다. 그리고 실제 서비스 target을 호출하는 그런 코드들도 내부에 포함하고 있다.
고급 편 강의에선 더 자세히 배운다.
스프링 빈에 프록시가 들어가 있는 거다.
@Autowired
private MemberServiceV3_3 memberService;의존 관계 주입으로 서비스를 받는 게 아니라 트랜잭션 프록시를 대신 받는다.
@Test
void AopCheck() {
log.info("memberService class = {}", memberService.getClass());
log.info("memberRepository class = {}", memberRepository.getClass());
Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
}스프링이 MemberServiceV3_3 코드를 다 본다. 클래스나 메서드에 @Transactional이 있으면 AOP 적용 대상이라는 걸 알아서 프록시를 만들어서 적용해 준다.
Troubleshooting Transaction Problems - Transaction AOP Summary
테스트 같은 곳에서
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);이걸 호출하면 1. AOP 프록시가 호출된다.
그다음에 프록시 내부 코드에서 2. 스프링 컨테이너를 통해 스프링 빈으로 등록된 트랜잭션 매니저를 찾아서 갖다 쓰면서 3. transactionManager.getTransaction()을 하면서 트랜잭션을 시작한다. 여기선 4. 데이터 소스를 가지고 커넥션을 만든다. 그리고 5. con.setAutoCommit(false)를 수행해서 트랜잭션을 시작한다. 그리고 6. 이 커넥션을 트랜잭션 동기화 매니저에 보관한다. 이렇게 시작하고, 그다음에 AOP 프록시에서 8. 실제 서비스 로직을 호출한다. 실제 서비스 로직이 수행된다. 여기서도 Repository 호출한다. 그러면 이 Repository에서는 9. 트랜잭션 동기화 매니저에 있는 커넥션을 획득한다. 다 끝나면 리턴, 리턴된다. 그러면 프록시의 트랜잭션 처리 로직에선 성공이면 커밋, 런타임 예외가 발생하면 롤백하는 로직을 수행하고 그다음에 반환된다.
이 부분을 애노테이션 하나로 우리 대신 스프링이 다 해 준다.
MemberServiceV3_3를 보면 @Transactional 하나로 순수한 비즈니스 로직을 만들었다. 물론 @Transactional 애노테이션 정도는 넣어야 한다.
그리고 throws SQLException이 남았는데 이건 예외 처리 배우고 해결하겠다.
Resource auto-registration
스프링 부트의 자동 등록 메커니즘은 기본적으로 개발자가 뭔가 같은 걸 등록하면 자기는 자동 등록을 안 한다.
어떤 트랜잭션 매니저 구현체를 스프링 빈으로 등록할지(PlatformTransactionManager는 인터페이스다. 이건 껍데기고 실제론 구현체를 등록한다.)는 현재 등록된 라이브러리를 보고 판단한다.
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
.
.
.
}@Autowired 필드 주입으로 해도 되는 듯(이땐 final 빼야하는 듯)
Understanding Java Exceptions
Basic Exception Rule
Basic understanding of checked exceptions
catch에 MyCheckedException의 상위 타입인 Exception을 적어 주어도 MyCheckedException을 잡을 수 있다.
Exception으로 적으면 모든 예외를 다 잡는데, 이런 것보단 좀 더 자세하게 예외를 잡는 게 보통 더 낫다. 물론 특별한 경우엔 Exception으로 해야 할 수도 있다.
throws에 MyCheckedException의 상위 타입인 Exception을 적어 주어도 MyCheckedException을 던질 수 있다
근데 사실 throws Exception을 쓰는 건 굉장히 안 좋은 코드이다. 왜냐하면 모든 예외를 다 던진다. 그래서 자세하게 내가 던질 예외만 명시적으로 선언하는 게 좋다.
가장 좋은 오류는 컴파일 오류이다.
체크 예외는 추가로 의존 관계에 따른 단점도 있는데 이 부분은 뒤에서 설명하겠다.
Basic understanding of unchecked exceptions
체크 예외든 언체크 예외든 모든 예외는 잡거나 던지거나 둘 중 하나를 한다. 그걸 컴파일러가 체크를 하냐 안 하냐의 차이가 있을 뿐이다.
@Test
void unchecked_throw() {
Service service = new Service();
service.callThrow();
}이렇게 해 버리면 예외가 테스트 밖으로 나간다.

메시지랑 스택 트레이스를 자동으로 찍고 테스트가 종료된다. 테스트 실패다.
언체크 예외의 장점은 신경 쓰고 싶지 않은 예외의 의존 관계를 참조하지 않아도 된다는 점이다.
예를 들어
public void callThrow() {
repository.call();
}서비스의 이 코드에서 MyUncheckedException에 대해 아예 몰라도 된다.
체크 예외와 언체크 예외의 차이는 사실 예외를 처리할 수 없을 때 예외를 밖으로 던지는 부분에 있다. 이 부분을 필수로 선언해야 하는가 생략할 수 있는가의 차이다.
하지만 이 두 개를 실무에서 활용할 땐 많은 차이가 생긴다. 다음 시간에 배운다.
Checked Exception Usage
가급적 런타임 예외를 사용하는 게 요즘 트렌드다.
비즈니스상 만드는 예외 중에서도 체크 예외로 안 만들어도 될 것 같으면 그냥 런타임 예외로 만들고 매뉴얼에 문서화만 잘 해도 된다.
물론 읽는 사람이 이런 문서를 잘 읽어야 한다.
SQLException이나 ConnectException 같은 체크 예외를 컨트롤러에서 처리하려면 모든 컨트롤러, 예를 들면 컨트롤러 100개가 다 이걸 캐치해서 처리해야 한다. 그러므로 컨트롤러에서 하면 안 된다.
보통 웹 애플리케이션은 서블릿의 오류 페이지나, 또는 스프링 MVC가 제공하는 ControllerAdvice 또는 필터 같은 곳에서 예외를 공통으로 처리한다.
@Test
void checked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}여기서도 Exception.class라고 하면 Exception의 자식이어도 테스트에 성공한다.
추가적으로 테스트)
SQLException.class라고 해도 테스트 성공하지만, ConnectException.class라고 하면 테스트 실패한다.
왜냐하면
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
public void logic() throws SQLException, ConnectException {
repository.call();
networkClient.call();
}
}SQLException이 먼저 터지기 때문이다.
사실 JPAException이 아니라 다른 예외가 올라온다.
체크 예외의 또 다른 심각한 문제는 예외에 대한 의존 관계 문제이다. 향후 Repository를 JDBC 기술이 아닌 다른 기술로 변경한다면, 그래서 SQLException이 아니라 예를 들어서 JPAException으로 예외가 변경된다면 어떻게 될까? SQLException에 의존하던 모든 서비스, 컨트롤러의 코드를 JPAException에 의존하도록 고쳐야 한다
의존한다는 건 throws SQLException, ConnectException 이렇게 하거나, try ~ catch로 잡는 것 등
SQLException, ConnectException 같은 시스템 예외는 컨트롤러나 서비스에서는 대부분 복구가 불가능하고 처리할 수 없는 체크 예외이다. 따라서 다음과 같이 처리해 주어야 한다.
java void method() throws SQLException, ConnectException {..}
그런데 다음과 같이 최상위 예외인 Exception을 던져도 문제를 해결할 수 있다.
java void method() throws Exception {..}
이렇게 하면 의존 관계 문제가 해결된다.
다만 Exception은 최상위 타입이므로 모든 체크 예외를 다 밖으로 던지는 문제가 발생한다.
결과적으로 체크 예외의 최상위 타입인 Exception을 던지게 되면 다른 체크 예외를 체크할 수 있는 기능이 무효화되고, 중요한 체크 예외를 다 놓치게 된다. 중간에 중요한 체크 예외가 발생해도 컴파일러는 Exception을 던지기 때문에 문법에 맞다고 판단해서 컴파일 오류가 발생하지 않는다. 이렇게 하면 모든 예외를 다 던지기 때문에 체크 예외를 의도한 대로 사용하는 것이 아니다. 따라서 꼭 필요한 경우가 아니면 이렇게 Exception 자체를 밖으로 던지는 것은 좋지 않은 방법이다. 안티 패턴이다.
그렇다면 뭐가 대안일까? 바로 언체크 예외를 활용하는 것이 대안이다.
다음 시간엔 이런 부분들을 런타임 예외로 바꿔서 문제를 해결해 보겠다.
Unchecked exception utilization
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}생성자 중 이걸 사용하면 이게 왜 발생했는지 이전 예외를 같이 넣을 수 있다.
static class Repository {
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
public void runSQL() throws SQLException {
throw new SQLException("ex");
}
}throw new RuntimeSQLException(e); 이렇게 e를 넣을 수 있다. 즉 RuntimeSQLException 예외가 이거에 대한 이전 예외를 포함할 수 있다.(예외를 던질 때 항상 기존 예외를 넣어 줘야 한다. 그래야 이전 예외에서 발생한 스택 트레이스도 같이 확인할 수 있다.)
그러면 스택 트레이스 출력할 때 RuntimeSQLException이랑 SQLException 둘 다 출력이 된다. 이건 뒤에서 좀 더 자세히 배운다.
@Test
void unchecked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}테스트 성공한다.
Exception.class 대신 RuntimeSQLException.class 해도 테스트 성공한다. RuntimeConnectException.class로 하면 테스트 실패한다.
런타임 예외는 서비스 계층에서라도, 내가 이 런타임 예외는 잡고 싶다고 한다면 잡으면 되고, 신경 쓰고 싶지 않다면 그냥 신경 안 써도 된다. 그러면 공통으로 처리하는 곳에서 처리된다.
다음 강의인 예외 포함과 스택 트레이스를 넘어가면(그 이후인 듯), 스프링에선 예외들을 어떻게 하는지 실제 활용해 볼 것이다. 스프링을 가지고 문제들을 해결할 것이다.
Exception Inclusion and Stack Trace
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
// e.printStackTrace();
log.info("ex", e);
}
}e.printStackTrace()를 호출해도 되지만, 이렇게 하는 건 안 좋은 거다. 로그를 남기는 게 좋다.
Caused By가 여러 개 나올 수도 있다.
지금 예에선 SQLException을 직접 만들었지만, 실제로 DB와 연결하면, 이 SQLException에 실제 DB의 쿼리가 잘못됐는지, 뭐 때문에 잘못됐는지... 그 원인이 다 들어 있다. 그런데 기존 예외를 포함하지 않으면 그 정보가 다 날아간다.
무슨 SQL 때문에 터졌는지 알 수 없다.
Caused By가 여러 계층으로 쌓일 수 있다. 제일 밑에 있는, 원래의 문제를 Root Cause라고 한다.
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException() {
}
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}예외를 만들 때
public RuntimeSQLException(Throwable cause) {
super(cause);
}이걸 넣어 줘야 한다. 기존 예외를 담을 수 있다.
Cleanup
런타임 예외를 쓰면서 throws에서 벗어날 수 있게 되었다. 물론 필요하면 잡아도 된다. 구현 기술에 의존하는 단점이 생기기는 하겠지만.
Spring and Problem Solving - Exception Handling, Repetition
Checked Exception and Interface
Applying Runtime Exception
인터페이스 사용할 땐 @Override 사용하는 게 좋다. 안 해도 되지만 해 주면, 뭔가 안 맞으면 컴파일러가 오류를 내 준다.
이 코드에서 핵심은 SQLException이라는 체크 예외를 MyDbException이라는 런타임 예외로 변환해서 던지는 부분이다.
MyDbException 이건 필요한 데에서 잡으면 된다.
아직 끝난 게 아니다.
Repository에서 넘어오는 특정한 예외의 경우 복구를 시도할 수도 있다. 꼭 복구를 해야 하는 건 아닌데, 복구를 하고 싶을 수도 있다. 예를 들어 DB에서 같은 ID가 중복이 된다거나, 이런 예외가 올라왔을 때 서비스 계층에서 잡아서 새로운 ID를 만들어서 다시 시도한다거나... 이런 식으로 복구하는 메커니즘이 발생할 수도 있다.
그런데 지금 방식은 무조건 MyDbException이라는 예외만 넘어오기 때문에 예외를 구분할 수 없다. SQL 문법 오류인지, DB에 키가 중복된 건지, 락이 걸린 건지 등등을 구분할 수 있는 방법이 없다.
특별한 상황에는 예외를 잡아서 복구하고 싶을 때가 있다. 그러면 이렇게 되면 안 되고, 예외가 상황별로 구분이 되어 있어야 한다.
이런 경우엔 어떻게 예외를 구분해서 처리하면서, 특정 기술에 종속적이지 않게 할 수 있는지, 스프링이 어떤 메커니즘으로 그런 문제들을 해결하는지 다음 시간부터 배운다.
Make Data Access Exceptions Directly

만약 hello라는 이름으로 가입이 되어 있고 이게 유니크 제약 조건일 때, 내가 또 이거로 가입하면 데이터베이스는, DB 내부에서 정한 오류 코드를 우리 쪽으로 반환해 준다.
우리가 Repository에서 JDBC에 insert 쿼리를 날리면, 그러면 JDBC 드라이버가 결과적으로 DB에 insert 쿼리를 전달한다. 그러면 DB 입장에선 유니크 제약 조건 충돌이 났으므로, DB 내부에 가지고 있는 오류 코드를 반환해 준다. 예를 들어 Primary Key가 중복이면 H2 데이터베이스의 경우 23505를 반환한다. DB마다 오류 코드는 다 다르다. 이걸 받으면 JDBC 드라이버가 SQLException을 만드는데 그 안에 오류 코드를 넣는다. 그리고 이 SQLException이 Repository에 넘어온다. 그러면 여기서 이 오류 코드를 꺼내서 확인할 수 있다.
회원 가입 시 DB에 이미 같은 ID가 있으면 ID 뒤에 숫자를 붙여서 새로운 ID를 만들어야 한다고 가정해 보자.
이것도 어쨌든 비즈니스 로직이다.
새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문에 서비스 계층에선 이게 키 중복 오류인지 아닌지를 알아야 한다.
MyDuplicateKeyException은 RuntimeException을 상속받아도 되지만 지금은 MyDbException을 받겠다. 이렇게 하면 DB에서 발생한 오류라는 걸 카테고리로 묶을 수 있다.
MyDuplicateKeyException 예외는 우리가 직접 만든 것이기 때문에, JDBC나 JPA 같은 특정 기술에 종속적이지 않다. 따라서 이 예외를 사용하더라도 서비스 계층의 순수성을 유지할 수 있다.
향후 JDBC에서 다른 기술로 바꾸어도 이 예외는 그대로 유지할 수 있다. 대신 JPA Repository나 JDBC Repository에서 키 중복인 경우엔 이 예외로 둘 다 변환을 해서 서비스에 넘겨 줘야 한다.
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
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);
} finally {
JdbcUtils.closeStatement(pstmt);
JdbcUtils.closeConnection(con);
}
}여기서 throw new MyDbException(e); 이거 주석 처리하면 에러인 듯
메서드 리턴 타입이 void면 상관없겠지만, 리턴 타입이 Member이기 때문에 Member를 반환하거나 예외를 던져야 하는 듯?
} catch (MyDbException e) {
log.info("데이터 접근 계층 예외", e);
throw e;
}이건 사실 없어도 된다. 왜냐하면 런타임 예외이기 때문이다.
그리고 굳이 로그로 남기지 않아도 된다. 어차피 복구할 수 없는 예외는 예외를 공통으로 처리하는 부분까지 전달되기 때문이다. 따라서 이렇게 복구할 수 없는 예외는 공통으로 예외를 처리하는 곳에서 예외 로그를 남기는 것이 좋다.
Understanding Spring Exception Abstraction
스프링 예외 추상화 덕분에 특정 기술에 종속적이지 않게 되었다. 이제 JDBC에서 JPA 같은 기술로 변경되어도 예외로 인한 변경을 최소화할 수 있다. 향후 JDBC에서 JPA로 구현 기술을 변경하더라도, 스프링은 JPA 예외를 적절한 스프링 데이터 접근 예외로 변환해 준다.
DB 구현 기술에 따라 안 되는 것도 있긴 하기 때문에 이건 확인해 봐야 한다. 그래도 가급적 최대한 맞춰서 해 준다.
물론 스프링이 제공하는 예외를 사용하기 때문에 스프링에 대한 기술 종속성은 발생한다
스프링에 대해선 의존한다. 대신 나머지에 대해선 굉장히 유연하다.
스프링에도 종속하지 않는 것이 불가능한 것은 아니지만, 예외의 경우엔 그러려면 내가 직접 다 정의해야 한다. 그건 너무 힘들다. 그래서 트레이드오프를 가져가는 거다. 스프링에 의존하는 대신, 스프링이 추상화해 준 계층은 내가 쓰겠다는 거다.
스프링에서 다른 기술로 바꾸게 되면 이런 고민이 필요할 수 있다.
Applying Spring Exception Abstraction
@Slf4j
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);
}
.
.
.SQLErrorCodeSQLExceptionTranslator에 dataSource를 넣어 주는 이유는, 여기서 어떤 DB를 쓰는지 같은 정보들을 자기가 꺼내서 쓰기 위해서다.
SQLExceptionTranslator는 인터페이스이고, SQLErrorCodeSQLExceptionTranslator가 구현체다.
SQLErrorCodeSQLExceptionTranslator는 에러 코드 기반으로 찾는 거고, 이거 말고 다른 구현체들도 있다.
throw exTranslator.translate("update", sql, e);이 한 줄로 해결했다.
이제 MyDbException 같은 거 내가 직접 만들 필요 없다.
JDBC Boilerplate Resolution - JdbcTemplate
JDBC 반복 문제)
커넥션 조회, 커넥션 동기화
PreparedStatement 생성 및 파라미터 바인딩
쿼리 실행
결과 바인딩
예외 발생 시 스프링 예외 변환기 실행
리소스 종료
결과 바인딩은
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);
}이거인 듯?
이전에 했던 트랜잭션 템플릿이랑 비슷하다. 반복을 템플릿 코드 안에서 해결해 준다.
템플릿이라는 건 뭔가 껍데기가 있고, 거기에 내가 바꾸고 싶은 일부만 바꿀 수 있는 게 템플릿이다. 기본적인 틀을 다 제공해 준다.
int update = template.update(sql, member.getMemberId(), member.getMoney());이것도 반환하는 값이 있다. 업데이트된 숫자가 몇 개인지이다.
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}이거를
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}이렇게 바꾸었다.
여기서 이전에 했던 코드들을 다 해 준다.
한 건만 조회하는 것은 queryForObject()를 쓴다.
template.queryForObject(sql, memberRowMapper(), memberId);쿼리 결과를 어떻게... Member를 만들 거냐 하는 매핑 정보를 넣어 줘야 한다.
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
};
}rs는 ResultSet이고 rowNum은 몇 번째 로우인지이다.
지금은 자세히 이해할 필요는 없고, 대략만 이해하면 된다.
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
return template.queryForObject(sql, memberRowMapper(), memberId);
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
};
}sql 결과가... ResultSet이 나오면 그걸 던져서,
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;그 결과를 받아서
Member member = template.queryForObject(sql, memberRowMapper(), memberId);member가 반환된다.
@Override
public void update(String memberId, int money) {
String sql = "update member set money = ? where member_id = ?";
template.update(sql, money, memberId);
}money와 memberId 순서를 조심하자.
지금까지 있었던
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 {
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection = {}, class = {}", con, con.getClass());
return con;
}이 코드들 지워도 된다.
커넥션 닫는 거나, 커넥션 동기화하는 것도 안 해도 된다.
이것들을 다 해 준다.
JdbcTemplate은 JDBC로 개발할 때 발생하는 반복을 대부분 해결해 준다. 그뿐만 아니라 지금까지 학습했던, 트랜잭션을 위한 커넥션 동기화는 물론이고, 예외 발생 시 스프링 예외 변환기도 자동으로 실행해 준다. JdbcTemplate 코드 안에 다 들어 있다.
sql이나 memberRowMapper() 같은 것만 변한다. 기본적인 틀은 다 있고, 변하는 부분만 바꿔서 넣어 주면, 그걸 실행해 줘서 다 처리해 준다.
Summary
JDBC를 직접 쓰는 것보단 JdbcTemplate, MyBatis, JPA 이런 기술들을 사용하는 게 이런 코드들을 극적으로 줄일 수 있다.
각각의 DB마다 에러 코드 뒤질 필요 없이 스프링이 제공하는 데이터 접근 계층의 예외를 보고 쓰면 되고, 필요하다면 서비스 계층에서 이 예외를 잡아서 쓰면 된다. 사실 쓸 일이 거의 없긴 한데 꼭 필요하다면 쓰자.
JDBC를 직접 사용할 땐 SQLErrorCodeSQLExceptionTranslator 이걸 쓰면 되고, JPA를 사용하면 또 다른 게 제공된다. 그리고 스프링이 이미 그걸 다 내장하고 있기 때문에 그냥 쓰면 된다. 그러면 예외 계층을 추상화해서 우리에게 제공해 준다.
MemberRepositoryV4_2 코드는 JDBC를 그냥 그대로 사용하면서 할 수 있는 끝판왕 코드다.
그런데 그다음으로 넘어가서 템플릿 반복 문제를 JdbcTemplate이 템플릿화해서 다 제공해 주고, 우리는 변하는 부분만 파라미터로 전달해 주면 된다.
지금까지 데이터 접근 기술의 기본기를 배웠다. 모든 건 이 기본기 안에서 동작한다.
다음부턴 각각의 데이터 접근 기술들을 하나씩 학습한다.
JdbcTemplate도 하고, MyBatis, JPA 등을 하나씩 예제를 통해 배운다.






