WEB Application은 크게 3가지 기능 수행
-
웹이나 앱같은 클라이언트 요청을 받아서 처리하는 기능
-
중간에서 비즈니스 로직을 수행하는 기능
-
수행 결과를 DB에 보관하고 관리하는 기능
Application에서 DB의 데이터를 관리하기 위해서는 다양한 데이터 접근 기술 학습 필요
어려운 이유는 크게 3가지
- DB기반 지식이 약하기 때문
모든 데이터 접근 기술은 DB에 데이터를 보관하고 관리하기 위한 것 (기본 이해 필요)
- 데이터 접근 기술이 너무 다양
ex - JDBC, JdbcTemplate, MyBatis, JPA, QueryDSL …
- DB접근 기술의 역사가 너무 오래되었다.
20년 동안 DB접근 기술은 물론이고, 스프링은 DB접근 기술을 편리하게 사용하도록 수많은 기능을 발전시키고 개선
너무 많은게 자동화되고, 추상화 되었다.
강의는 20년전으로 돌아가 데이터 접근 기술의 시작인 JDBC를 시작으로
커넥션, 커넥션 풀, 데이터 소스, 트랜잭션 개념, 데이터 접근 계층을 위한 Java 예외처리, Spring이 지원하는 데이터 접근 기술까지 단계적으로 진행
백엔드 개발자는
웹 MVC, DB와 관련된 DB접근 기술을 꼭 알아야 한다.
dependencies에 lombok설정 추가
//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok'
설정을 추가해야 @Slfj4
같은 롬복 어노테이션 사용 가능
1. JDBC 이해
JDBC(Java Database Connectivity)는 자바에서 데이터베이스를 접속할 수 있도록 하는 자바 API다. JDBC는 DB에서 자료를 쿼리하거나 업데이트 하는 방법을 제공한다.
대표적으로 3가지 기능을 표준 인터페이스로 정의하여 제공
- java.sql.Connection : 연결
- java.sql.Satatement : SQL에 담은 내용 (전달)
- java.sql.ResultSet : SQL요청 응답 (결과)
정리
JDBC의 등장으로 2가지 문제 해결
- DB변경 시 관련된 코드도 함께 변경해야 하는 문제
- Application 로직은 JDBC표준 인터페이스에만 의존
- DB변경 시 JDBC 구현라이브러리만 변경
- DB마다 커넥션 연결, SQL전달, 결과 응답 방법을 새로 학습하는 문제
- JDBC 표준 인터페이스 사용법만 학습
- 한번 배우면 다른 DB모두 동일하게 적용 가능
한계
DB변경 시 JDBC코드는 변경하지 않아도 되지만 SQL은 해당 데이터 베이스에 맞게 변경 필요
JDBC기술은 대표적으로 SQL Mapper와 ORM으로 나뉜다.
- SQL응답 결과를 객체로 편리하게 변환
- JDBC의 반복코드 제거
- 개발자가 SQL 직접 작성
- ex - JdbcTemplate, MyBatis
- 객체를 DB Table과 매핑하는 기술
- JPA는 ORM표준 인터페이스 이고 이것을 구현한 것으로 하이버네이트 등이 있다.
2가지 기술 모두 내부에서는 JDBC사용
- JDBC는
java.sql.Connection
표준 커넥션 인터페이스를 정의 - H2 DB Driver는 JDBC Connection 인터페이스를 구현한
org.h2.jdbc.JdbcConnection
구현체를 제공
JDBC가 제공하는 DriverManager
는 라이브러리에 등록된 DB Driver관리, 커넥션 획득 기능 제공
삽입
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
Statement - SQL 그대로 넣는 것
prepareStatement - 파라미터를 binding
조회
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;
}
...
- executeQuery - 데이터 조회 시 사용
- executeQuery() - 결과는 ResultSet에 담아 반환
ResultSet
은 보통 select 쿼리의 결과가 순서대로 들어가 있음
2. 커넥션 풀과 데이터 소스 이해
2.1 커넥션 풀
DriverManager
는 항상 신규 커넥션 생성- 매번 커넥션을 새로 생성할 경우 자원 낭비
- 해결책 -> 커넥션을 미리 생성해두고 사용하는 커넥션 풀
- Application 시작 시점에 필요한 만큼 커넥션 확보해 풀에 보관 (보통 10개, 상황 별 다름)
- 적절한 커넥션 풀 숫자는 성능 테스트를 통해 정함
- 커넥션 풀은 서버당 최대 커넥션 수를 제한 가능, 따라서 DB에 무한정 연결이 생성되는 것을 막아 DB를 보호하는 효과도 있다.
- 커넥션 풀은 얻는 이점이 크기 떄문에 실무에서 항상 기본으로 사용
- 커넥션 풀 오픈소스 ex - HikariCP
- 성능과 사용의 편리함 측면에서 최근에는
hikariCP
주로 사용 - 스프링 부트 2.0부터는 기본 커넥션 풀로
hikariCP
제공 (성능, 사용의 편리함, 안전성 검증이 되었다.)
2.2 DataSource
커넥션을 획득하는 방법을 추상화
public interface DataSource {
Connection getConnection() throws SQLException;
}
- 대부분의 커넥션 풀은
DataSource
인터페이스를 이미 구현되어 있어DBCP2 커넥션 풀
,HikariCP 커넥션 풀
의 코드를 직접 의존하는 것이 아니라DataSource
인터페이스에만 의존하도록 애플리케이션 로직을 작성 - 커넥션 풀 구현 기술을 변경하고 싶으면 해당 구현체로 갈아끼우기만 하면 된다.
DriverManager
는DataSource
인터페이스를 사용하지 않는다.
->DriverManager
도DataSource
를 통해서 사용할 수 있도록DriverManagerDataSource
라는DataSource
구현한 클래스 제공
DriverManager
는 커넥션 획득할 때 마다 URL, USERNAME, PASSWORD같은 파라미터 계속 전달- 반면에
DataSource
를 사용하는 방식은 처음 객체 생성시에만 파라미터 넘겨주고, 커넥션을 획득할 때dataSource.getConnection()
만 호출
=> 설정과 사용 분리
=> 쉽게 이야기해서 리포지토리(Repository)는 DataSource
만 의존하고, 이런 속성을 몰라도 된다.
DataSource
를 통해 커넥션 풀 예제를 실행시키면 별도의 쓰레드를 사용해서 커넥션 풀에 커넥션 채우는 것을 확인가능
-> 상당히 오래 걸리는 작업, 별도 쓰레드를 사용해야 어플리케이션 실행 시간에 영향을 주지 않는다.
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
JdbcUtils
편의 메서드
- 스프링은 JDBC를 편리하게 다룰 수 있는
JdbcUtils
라는 편의 메서드를 제공 JdbcUtils
을 사용하면 커넥션을 좀 더 편리하게 닫을 수 있다.
3. 트랜잭션과 DB Lock 이해
DB에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다.
3.1 트랜잭션
- 모든 작업이 성공해서 DB에 정상 반영 => 커밋
- 작업 중 하나라도 실패해서 거래 이전으로 되돌리기 => 롤백
트랜잭션은 ACID
를 보장해야 한다.
종류 | 내용 |
---|---|
원자성(Automicity) | 트랜잭션 내에서 실행한 작업들은 하나의 작업인 것처럼 모두 성공하거나 실패해야 한다 |
일관성(Consistency) | 일관성 있는 DB상태 유지, DB에서 정한 무결성 제약조건을 항상 만족해야 한다 |
격리성(Isolation) | 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리, 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준을 선택할 수 있다 |
지속성(Durability) | 트랜잭션을 성공적으로 끝내면 결과를 항상 기록, 시스템에 문제가 발생해도 DB로그 등을 사용해 성공한 트랜잭션 내용 복구 |
문제는 격리성인데 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 한다.
이렇게 동시 처리하면 성능이 매우 나빠진다.
ANSI 표준은 트랜잭션을 4단계로 나누어 정의
- READ UNCOMMITED(커밋되지 않은 읽기)
- READ COMMITTED(커밋된 읽기)
- REPEATABLE READ(반복 가능한 읽기)
- SERIALIZABLE(직렬화 가능)
궁금하면 더 찾아보고 강의에서는 READ COMMITTED(커맷된 읽기)수준을 기준으로 설명
자동 커밋은 쿼리 실행 직후에 자동으로 커밋 호출
트랜잭션을 제대로 수행하려면 자동 커밋은 끄고, 수동 커밋을 사용해야 한다.
에러 상황 가정 - 오토 커밋 모드로 동작할 때 2가지 동작이 중간에 실패하면 -> 1개 성공, 1개 실패
=> 에러 발생 -> 이런종류의 작업은 꼭 수동 커밋 모드를 사용해 수동으로 커밋, 롤백이 가능해야 한다.
보통 이렇게 자동 커밋 모드에서 수동 커밋 모드로 전환하는 것을 트랜잭션을 시작한다고 표현한다.
# 자동 커밋 사용
set autocommit true;
# 수동 커밋 사용
set autocommit false;
3.2 DB Lock
세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막는 것
일반적으로 조회 시 락 사용x
-> 사용하고 싶을 땐 select for update
구문 사용
예제
set autocommit false;
select * from member where member_id='memberA' for update;
락을 걸 때 무한정 기다릴 수 없기 때문에 timeout
이 발생한다.
트랜잭션 적용
- 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 수행
- 트랜잭션이 시작하려면 커넥션이 필요하다. 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 후에 종료
- 트랜잭션을 사용하는 동안 같은 커넥션 유지
- 같은 트랜잭션을 유지하는 가장 단순한 방법은 파라미터로 전달해서 같은 커넥션이 사용되도록 유지 (추후 개선된다.)
4. 스프링과 문제 해결 - 트랜잭션
보통 역할에 따라 3가지 계층으로 나눈다.
프레젠테이션 계층
- UI와 관련된 처리 담당
- 웹 요청과 응답
- 사용자 요청을 검증
- 주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC
서비스 계층
- 비즈니스 로직을 담당
- 주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
데이터 접근 계층
- 실제 데이터베이스에 접근하는 코드
- 주 사용 기술: JDBC, JPA, File, Redis, Mongo
- 서비스 계층을 특정 기술에 종속적이지 않게 개발해야 한다.
- 기술에 종속적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가져간다.
- ex - HTTP API사용하다 GRPC같은 기술로 변경해도 프레젠테이션 계층만 변경
- ex - JDBC를 사용하다 JPA로 변경해도 서비스 계층은 변경x
=> 비즈니스 로직만 구현하고 특정 구현 기술에 의존x
=> 구현 기술이 변경되어도 변경의 영향 범위 최소화
JDBC로 작성한 코드의 문제점은 현재 3가지가 있다.
- 트랜잭션 문제
- JDBC구현 기술이 서비스 계층에 누수
- 서비스는 특정 기술에 종속되지 않아야 한다. -> 구현 기술을 변경해도 최대한 유지 가능해야 한다.
- 트랜잭션을 적용하면서 결국 서비스 계층에 JDBC구현 기술의 누수 발생
- 트랜잭션 유지를 위해 커넥션을 파라미터로 넘겨야 한다.
- 이에 파생되는 문제, 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리 필요
- JDBC구현 기술이 서비스 계층에 누수
- 예외 누수 문제
- JDBC의 예외가 서비스 계층으로 전파
SQLException
발생 -> JDBC예외, JPA나 다른 데이터 접근 기술 사용 시 그에 맞는 예외 필요
- JDBC반복 문제
- 유사한 반복 코드가 많다. (try, catch, finally)
- 커넥션을 열고, PreparedStatement사용, 결과 매핑 등
트랜잭션 문제, 예외 누수 문제, JDBC반복 문제 하나씩 해결해 나간다.
트랜잭션 추상화
구현 기술마다 트랜잭션을 사용하는 방법이 다르다.
- JDBC :
con.setAutoCommit(false)
- JPA :
transaction.begin()
JDBC에서 JPA로 기술을 변경 시 서비스 계층의 코드도 JPA기술을 사용하도록 수정해야 한다.
문제를 해결하기 위해 트랜잭션 기능을 추상화
트랜잭션 추상화 인터페이스를 구현
public interface TxManager {
begin();
commit();
rollback();
}
TxManager
인터페이스를 기반으로 각각의 기술에 맞는 구현체 생성하면 된다.
JdbcTxManager
- JDBC트랜잭션 기능 구현체
JpaTxManager
- JPA트랜잭션 기능 제공 구현체
Interface를 구현했으면 구현체를 DI를 통해 주입하면 된다.
인터페이스에 의존하고 DI를 사용한 덕분에 OCP원칙을 지키게 된다.
스프링은 이런 고민 해결해 놨다.
스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager
인터페이스 이다.
Tip!!
- 스프링 5.3부터는 JDBC트랜잭션을 관리할 때
DataSourceTransactionManager
를 상속받아서 기능을 확장한JdbcTransactionManager
를 제공 - 기능차이가 크지 않으므로 같은것으로 이해하면 된다.
PlatformTransactionManager 인터페이스
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
getTransaction()
- 이름이
getTransaction()
인 이유는 이미 진행중인 트랜잭션이 있는 경우 해당 트랜잭션에 참여할 수 있기 때문 (시작이라고 이해하면 된다.)
- 이름이
commit
: 트랜잭션 커밋rollback
: 트랜잭션을 롤백
앞으로 PlatformTransactionManager
인터페이스와 구현체를 포함해서 트랜잭션 매니저로 줄여서 부른다.
트랜잭션 동기화
스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.
- 트랜잭션 추상화
- 리소스 동기화
이전까지 예제에서는 같은 커넥션을 사용하기 위해서 파라미터로 커넥션을 전달해서 사용
-> 스프링에선 트랜잭션 동기화 매니저 제공
- 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용해 커넥션을 동기화 한다.
- 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.
- 쓰레드 로컬을 사용하기 때문에 멀티 쓰레드 상황에 안전하게 커넥션을 동기화 할 수 있다.
- 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저 사용
동작 방식 설명
- 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 만들고 트랜잭션 시작
- 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관
- Repository는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용
- 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션 종료 후 커넥션을 닫는다.
쓰레드 로컬 관련된 내용은 추후 스프링 고급편에서 설명
여기서 핵심은 트랜잭션 매니저가 트랜잭션을 실행하면 트랜잭션 동기화 매니저라는 곳에다 트랜잭션을 보관한다.
Repository에서 필요할 때 가져다 쓴다.
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;
}
DataSourceUtils.getConnection()
은 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환하고, 없는 경우 새로운 커넥션을 생성해 반환
DataSourceUtils.releaseConnection()
을 사용하면 커넥션을 바로 닫는것이 아니라 트랜잭션을 사용하기 위해 동기화된 커넥션은 유지하고, 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
/**
* 트랜잭션 - 트랜잭션 매니저 사용해 파라미터로 전달하지 않아도 된다.
*/
@Slf4j
@RequiredArgsConstructor // 생성자를 만들어 주는 어노테이션
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
// public MemberServiceV1(MemberRepositoryV1 memberRepositoryV1){
// this.memberRepositoryV1 = memberRepositoryV1;
// }
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);
}
}
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);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); //커넥션 풀 고려 con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
private final PlatformTransactionManager transactionManager
- 트랜잭션 매니저를 주입받는다. JDBC를 사용하기 때문에
DataSourceTransactionManager
주입 - JPA로 변경 시
JpaTransactionManager
를 주입 받으면 된다.
transactionManager.getTransaction()
- 트랜잭션을 시작
TransactionStatus status
를 반환, 현재 트랜잭션의 상태 정보가 포함되어 있고, 트랜잭 션을 커밋, 롤백할 때 필요하다.
transactionManager.commit(status)
- 트랜잭션 커밋
transactionManager.rollback(status)
- 트랜잭션 롤백
트랜잭션 매니저의 전체 동작 흐름
- 서비스 계층에서
transactionManager.getTransaction()
을 호출해서 트랜잭션을 시작 - 트랜잭션 매니저 내부에서 데이터 소스를 생성해 커넥션 생성
- 커넥션을 수동 커밋모드로 변경해 실제 DB트랜잭션 시작
- 커넥션을 트랜잭션 동기화 매니저에 보관
- 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션 보관 (멀티 쓰레드 환경에서 안전하게 커넥션 보관)
- 서비스의 비즈니스 로직이 실행되면서 Repository 메서드 호출 (커넥션으로 파라미터 전달x)
- Repository메서드들은 트랜잭션이 시작된 커넥션 필요
DataSourceUtils.getConnection()
을 사용해 보관된 커넥션을 꺼내서 사용- 자연스럽게 같은 커넥션 사용, 트랜잭션도 유지
- 획득한 커넥션을 사용해 SQL을 DB에 전달해 실행
- 비즈니스 로직이 끝나고 트랜잭션 종료, 트랜잭션은 커밋하거나 롤백하면 종료
- 트랜잭션을 종료하면 동기화된 커넥션 필요, 트랜잭션 동기화 매니저를 통해 동기화된 커넥션 획득
- 획득한 커넥션을 통해 DB에 트랜잭션을 커밋하거나 롤백
- 전체 리소스 정리
- 트랜잭션 동기화 매니저 정리 (쓰레드 로컬은 사용 후 꼭 정리)
con.setAutoCommit(true)
로 되돌린다. 커넥션 풀 고려con.close()
호출 해 커넥션 종료. 커넥션 풀 사용시엔con.close()
호출하면 커넥션 풀에 반환
트랜잭션 추상화 덕분에 서비스 코드는 JDBC기술에 의존 x
- 기술 변경 시 의존관계 주입만
DataSourceTransactionManager
에서JpaTransactionManager
로 변경해주면 된다. java.sql.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);
}
- 트랜잭션을 시작하고, 비즈니스 로직 실행, 성공 시 커밋, 예외 시 롤백
- try, catch를 포함해 반복된다.
- 이럴 경우 템플릿 콜백 패턴을 활용하면 반복 문제를 깔끔하게 해결할 수 있다.
- 템플릿 콜백 패턴 이해를 못해도 괜찮다. 추후에 고급편 강의에서 자세히 다룬다.
- 지금은 스프링이
TransactionTemplate
라는 편리한 기능을 제공하는 구나 라고 이해
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
execute()
: 응답 값이 있을 때 사용한다.
executeWithoutResult()
: 응답 값이 없을 때 사용한다.
여기서 잠깐 정리하고 가면
V0 -> JDBC DriverManager사용
V1 -> DataSource 사용, JdbcUtils 사용
V2 -> 트랜잭션 - 파라미터 연동, 풀을 고려한 종료 (파라미터로 커넥션 전달)
V3_1 -> DataSourceUtils 사용 (파라미터로 전달하지 않아도 된다.)
V3_2 -> try, catch등 반복문제 제거
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) throws SQLException {
txTemplate.executeWithoutResult((status) -> {
//비즈니스 로직
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
PlatformTransactionManager
을 주입받아 TransactionTemplate
로 감쌌는데 여러 이유가 있는데
- 관례적
TransactionTemplate
은 클래스이다. 유연성이 별로 없음
executeWithoutResult
이 코드가 성공적으로 반환 -> Commit, 언체크 예외 -> Rollback (체크 예외의 경우엔 커밋하는데 이부분은 뒤에서 설명 / 소스에선 언체크 예외로 던진다.)
완벽한 이해는 고급편 이후로 미뤄두자
정리
- 트랜잭션 템플릿 덕분에 트랜잭션 사용 시 반복 코드 제거 (commit, rollback)
- 서비스 로직인데 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 포함되어 있다.
- 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 한 곳에 있으면 두 관심사를 하나의 클래스에서 처리하게 되어 코드를 유지보수하기 어려워진다.
- 서비스 로직에 핵심 비즈니스 로직만 있기 위한 해결책 필요
트랜잭션 AOP
아직 서비스에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여있다.
- 트랜잭션 프록시를 도입하면 트랜잭션 처리 로직을 모두 가져간다.
- 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다.
@Transactional
- 개발자는 트랜잭션 처리가 필요한 곳에
@Transactional
애노테이션만 붙여주면 된다. - 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
- 스프링 AOP를 적용하려면 어드바이저, 포인트컷, 어드바이스가 필요하다.
- 스프링은 트랜잭션 AOP 처리를 위해 아래와 같은 클래스를 제공한다.
- 스프링 부트를 사용하면 해당 빈들은 스프링 컨테이너에 자동으로 등록된다.
어드바이저: BeanFactoryTransactionAttributeSourceAdvisor
포인트컷: TransactionAttributeSourcePointcut
어드바이스: TransactionInterceptor
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
@Transactional
애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다.- 클래스에 붙이면 외부에서 호출 가능한
public
메서드가 AOP 적용 대상이 된다.
@Transaction Test 코드를 짜다보면 스프링 컨테이너를 쓰고 있지 않아서 @Transaction 적용이 되지 않는다.
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {
...
@Autowired
MemberRepositoryV3 memberRepository;
@Autowired
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());
}
}
}
@SpringBootTest
스프링 AOP를 적용하려면 스프링 컨테이너 필요- 해당 어노테이션 있으면 테스트 시 스프링 부트를 통해 스프링 컨테이너 생성
- 테스트에서
@Autowired
등 스프링 컨테이너가 관리하는 빈들을 사용 가능
TestConfiguration
테스트 안에서 내부 설정 클래스를 만들어 사용 시 추가로 필요한 스프링 빈들을 등록하고 테스트 수행 가능TestConfig
DataSourceTransactionManager
트랜잭션 매니저를 스프링 빈으로 등록- 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아 사용
-> 트랜잭션 매니저를 스프링 빈으로 등록해야 한다.
- 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아 사용
프록시 도입(@Transaction 사용) 하면 서비스 로직을 상속 받는다.
-> 트랜잭션 코드를 만들어 낸다 (이전 V2처럼)
-> 즉, 실제로 실행되는 service는 transaction proxy 코드다
트랜잭션 AOP 정리
@Transaction
이 있으면 AOP Proxy생성- 스프링 컨테이너를 통해 트랜잭션 매니저 획득
- 트랜잭션 시작
- 데이터 소스로 커넥션 생성
- 수동 커밋 모드로 만듬
- 트랜잭션 동기화 매니저에 보관
- 실제 서비스 호출
- 트랜잭션 동기화 커넥션 획득
- 다 끝나면 성공이면 commit, 예외 발생 시 rollback
트랜잭션을 공부하다보면 선언적 vs 프로그래밍 방식이 나온다.
선언적 트랜잭션 관리
@Transactional
애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것- 과거 XML설정하기도 했다.
- 이름 그대로 해당 로직에 트랜잭션을 적용하겠다. 라고 어딘가에 선언하기만 하면 트랜잭션 적용되는 방식
프로그래밍 방식
- 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해 트랜잭션 관련 코드를 직접 코드로 짜는 것
정리
@Transactional
를 통해 어노테이션만 붙여주면 트랜잭션 관련 코드를 비즈니스 로직에서 제거 가능
스프링 부트의 자동 리소스 등록
스프링 부트가 등장하기 이전에는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링 빈으로 등록해서 사용
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
데이터소스 - 자동 등록
- 스프링 부트는 데이터소스(
DataSource
)를 스프링 빈에 자동으로 등록한다. - 자동으로 등록되는 스프링 빈 이름:
dataSource
- 참고로 개발자가 직접 데이터소스를 빈으로 등록하면 스프링 부트는 데이터소스를 자동으로 등록하지 않는다.
application.properties
의 속성을 사용해 DataSource
생성
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
- 스프링 부트가 기본적으로 생성하는 데이터 소스는 커넥션 풀을 제공하는
HikariDataSource
이다. - 커넥션풀과 관련된 설정도 application.properties 를 통해서 지정할 수 있다.
spring.datasource.url
속성이 없으면 내장 DB생성 시도
트랜잭션 매니저 - 자동 등록
- 트랜잭션 매니저(
PlatformTransactionManager
)자동으로 스프링 빈에 등록 - 자동 등록되는 빈 이름 :
transactionManager
- 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단
-> JDBC를 기술을 사용하면
DataSourceTransactionManager
를 빈으로 등록
-> JPA를 사용하면JpaTransactionManager
를 빈으로 등록
(참고로 JpaTransactionManager 는 DataSourceTransactionManager 제공 기능 대부분 지원)
정리
=> 데이터 소스와 트랜잭션 매니저는 스프링 부트가 제공하는 자동 빈 등록 기능을 사용하는게 편리하다
=> application.properties
를 통해 설정도 편리하게 가능
5. 자바 예외 이해
자바 예외의 이해부터 시작
자바 예외는 기본적으로 Throwable부터 시작
에러도 객체기 때문에 최상위 Object를 가지고 있다.
error와 exception으로 나뉘는데
Error
는 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다.
그렇기에 애플리케이션 개발자는 Error
를 잡으려고 해서는 안된다.
상위 예외를 catch
로 잡으면 그 하위 예외까지 함께 잡는다.
따라서 애플리케이션 로직에서는 Throwable
예외도 잡으면 안된다. (error가 함께 잡히기 때문)
Exception과 그 하위 예외는 모두 컴파일러가 체크하는 예외 (RuntimeExcption은 예외)
RuntimeException
언체크 예외, 런타임 예외로 컴파일러가 체크하지 않는 언체크 예외이다.
RuntimeException
의 이름을 따라서 RuntimeException
과 그 하위 언체크 예외를 런타임 예외라 고 많이 부른다
예외는 기본적으로 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야 한다.
예외를 처리하면 정상 흐름으로 동작하고, 처리하지 못하면 호출한 곳으로 예외를 계속 던진다.
2가지 기본 규칙을 기억하자
- 예외는 잡아서 처리하거나 던져야 한다.
- 예외를 잡거나 던질 때 지정한 예외 뿐만 아니라 그 예외의 자식들도 함께 처리한다.
- 예를 들어서 Exception 을 catch 로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
- 예를 들어서 Exception 을 throws 로 던지면 그 하위 예외들도 모두 던질 수 있다
체크 예외
체크 예외는 잡아서 처리하거나, 또는 밖으로 던지도록 선언해야한다. 그렇지 않으면 컴파일 오류가 발생한다.
체크 예외는 컴파일러가 체크한다.
static class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
MyCheckedException
는Exception
을 상속받았다.Exception
을 상속받으면 체크 예외가 된다.- 참고로
RuntimeException
을 상속받으면 언체크 예외가 된다. 이런 규칙은 자바 언어에서 문법으로 정한 것 이다. - 예외가 제공하는 여러가지 기본 기능이 있는데, 그 중에 오류 메시지를 보관하는 기능도 있다. 예제에서 보는 것 처럼 생성자를 통해서 해당 기능을 그대로 사용하면 편리하다.
체크예외 장단점
체크예외는 예외를 잡아서 처리할 수 없을 떄, 예외를 밖으로 던지는 throw
예외를 필수로 선언해야 한다. 이것 때문에 장, 단점이 동시에 존재
장점
- 개발자가 실수로 예외를 누락하지 않게 컴파일러를 통해 문제를 잡아주는 안전장치
단점
- 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던진다.
- 신경쓰고싶지 않은 예외를 모두 챙기고, 의존관계에 따른 단점도 존재(상세한건 뒤에서)
언체크(런타입) 예외
RuntimeException
과 그 하위 예외는 언체크 예외로 분류- 컴파일러가 예외를 체크하지 않음
- throws를 선언하지 않고, 생략 가능 -> 자동으로 예외를 던진다.
체크예외 vs 언체크(런타입) 예외
- 체크 예외: 예외를 잡아서 처리하지 않으면 항상
throws
에 던지는 예외를 선언 - 언체크 예외: 예외를 잡아서 처리하지 않아도
throws
를 생략 가능
언체크(런타입) 예외의 장단점
예외를 생략할 수 있어서 장, 단점 존재
장점
- 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다
- 신경 쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 된다는 장점
단점
- 개발자가 실수로 예외를 누락할 수 있다.
체크 예외 활용
기본적으로 원칙 2가지 생각
- 기본적으로 언체크(런타임) 예외 사용
- 체크 예외는 비즈니스 로직 상 의도적으로 던지는 예외에서만 사용
- ex - 계좌 이체 실패 / 결제 시 포인트 부족 / 로그인 ID, PW불일치 등
체크 예외가 더 좋아보이지만 사용하지 않는 이유는 문제점 떄문이다.
DB가 죽었을 때 생기는 SQLException이나 ConnectException은 Service나 Controller로직에서 처리하기가 힘듬
보통 공통적으로 처리하는 ControllerAdvice에서 이런 예외를 공통적으로 처리
지금까지 설명한 예제와 코드를 보면 2가지 문제가 있다.
- 복구가 불가능한 예외
- 대부분의 예외는 복구가 불가능
- 대부분의 서비스나 컨트롤러는 이런 문제를 해결할 수는 없다
- 따라서 이런 문제들은 일관성 있게 공통으로 처리
- 서블릿 필터, 스프링 인터셉터, 스프링의 ControllerAdvice 를 사용하면 이런 부분을 깔끔하게 공통으로 해결할 수 있다.
- 의존 관계에 대한 문제
- 체크 예외이기 때문에 컨트롤러나 서비스 입장에서는 본인이 처리할 수 없어도 어쩔 수 없이
throws
를 통해 던지는 예외를 선언 - 컨트롤러에서
java.sql.SQLException
을 의존하기 때문에 문제가 된다. - 향후 리포지토리를 JDBC 기술이 아닌 다른 기술로 변경 시 문제 (ex - JPAException <-> SQLException 변경 필요)
문제가 있어 최상위 예외인 Exception
을 던져 해결할 수 있지만 던지는 것은 좋지 않은 패턴
Exception
을 던지게 되면 다른 체크 예외를 체크할 수 있는 기능이 무효화 되고, 중요한 체크 예외를 다 놓치게 된다.
꼭 필요한 예외를 그냥 넘어갈 수 있다.
언체크(런타임) 예외 사용
그림과 같이 runtime exception 시 controller와 service에서 넘어가는게 가능하다.
예제로 SQLException
을 RuntimeSQLException
로 바꿔 던짐
기존 예외를 포함시켜 주어야 예외 출력 시 스택 트레이스에서 기존 예외도 함께 확인 가능
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
- 런타임 예외를 사용하면 기술을 바꿔도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서는 코드 변경하지 않아도 된다.
- 구현 기술이 변경되는 경우, 예외를 공통으로 처리하는 곳에서는 예외에 따른 다른 처리가 필요할 수 있다
-> 하지만 공통 처리하는 한곳만 변경하면 되기 때문에 변경의 영향 범위는 최소화 된다.
정리
- 체크 예외를 사용한다면 잡을 건 잡고 던질 예외는 명확하게 던지도록 선언
- 체크 예외의 이런 문제점 때문에 최근 라이브러리들은 대부분 런타임 예외를 기본으로 제공
- 런타임 예외도 필요하면 잡을 수 있다.
- 추가로 런타임 예외는 놓칠 수 있기 때문에 문서화가 중요
예외 포함과 스택 트레이스
- 예외를 전환할 때는 꼭 기존 예외를 포함해야 한다.
- 그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제 발생
- 실무에서는 항상 로그를 사용해야 한다는 점을 기억
- 아래 코드와 같이 e를 넘겨주지 않으면 예외를 확인할 수 없음
- 예외를 전환할 때는 꼭! 기존 예외를 포함하자
public void call() {
try {
runSQL();
} catch (SQLException e) {
// throw new RuntimeSQLException(); // e를 넘겨줘야 함
throw new RuntimeSQLException(e);
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException() {
}
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
6. 스프링과 문제 해결 - 예외 처리, 반복
repository를 추상화를 해 인터페이스로 작성
인터페이스로 분리하더라도 아래와 같이 인터페이스에도 동일하게 SQLException
이 필요하기에 기존에는 작성하지 않았다.
public interface MemberRepositoryEx {
Member save(Member member) throws SQLException;
Member findById(String memberId) throws SQLException;
void update(String memberId, int money) throws SQLException;
void delete(String memberId) throws SQLException;
}
서비스가 처리할 수 없는 SQLException
에 대한 의존을 제거하기 위해선
레포지토리에서 던지는 SQLException
체크 예외를 런타임 예외로 전환한다.
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);
}
}
//
@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 new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
SQLException
시 catch로 잡아 MyDBException(RuntimeException)으로 던져준다.
예외를 변환할 때는 기존 예외를 꼭! 포함
장애가 발생하고 로그에서 진짜 원인이 남지 않는 심각한 문제가 발생할 수 있다
데이터 접근 예외 직접 만들기
데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 수 있다.
SQLException
에는 errorCode
라는 것이 들어있는데 이 오류 코드를 통해 어떤 문제가 발생했는지 확인할 수 있다.
} catch (SQLException e) {
//h2 db
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
}
ex) 키 중복 오류 코드
H2 DB: 23505
MySQL: 1062
스프링은 이러한 문제를 미리 해결
정리
- SQL ErrorCode로 데이터베이스에 어떤 오류가 있는지 확인할 수 있었다.
- 예외 변환을 통해 SQLException 을 특정 기술에 의존하지 않는 직접 만든 예외인 MyDuplicateKeyException 로 변환 할 수 있었다.
- 리포지토리 계층이 예외를 변환해준 덕분에 서비스 계층은 특정 기술에 의존하지 않는 MyDuplicateKeyException 을 사용해서 문제를 복구하고, 서비스 계층의 순수성도 유지할 수 있었다.
스프링 예외 추상화
- 스프링은 데이터 접근 계층에 대한 수십가지 예외를 정리해서 일관된 예외 계층 제공
- 각각의 예외는 특정 기술에 종속적이지 않게 설계
- 서비스 계층에서도 스프링이 제공하는 예외 사용
- 예를 들어서 JDBC 기술을 사용하든, JPA 기술을 사용하든 스프링이 제공하는 예외를 사용하면 된다
- JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환도 스프링이 제공
- 최고 상위는
org.springframework.dao.DataAccessException
런타임 예외- 런타임 예외를 상속 받았기 때문에 스프링에서 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외
DataAccessException
은 크게 2가지로 구분하는데NonTransient
예외와Transient
예외Transient
는 일시적이라는 뜻이다.Transient
하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다.- ex - 쿼리 타임 아웃, 락 등
NonTransient
는 일시적이지 않다는 뜻이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다.- SQL문법 오류, 데이터베이스 제약조건 위배 등
- 오류 코드를 확인하고 스프링의 예외 체계에 맞추어 예외를 변환하는 것은 현실성이 없다.
- 그래서 스프링은 예외 변환기를 제공한다.
SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
다음과 같이 사용한다.
translate()
메서드의 첫번째 파라미터는 읽을 수 있는 설명이고, 두번째는 실행한 sql, 마지막은 발생된 SQLException
을 전달하면 된다. 이렇게 하면 적절한 스프링 데이터 접근 계층의 예외로 변환해서 반환해준다.
org.springframework.jdbc.support.sql-error-codes.xml
파일을 확인해보면 DB마다 에러코드 시 어떤 Exception을 발생할 지 작성되어 있다.
<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
</property>
<property name="duplicateKeyCodes">
<value>23001,23505</value>
</property>
</bean>
<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>1054,1064,1146</value>
</property>
<property name="duplicateKeyCodes">
<value>1062</value>
</property>
</bean>
정리
- 스프링은 데이터 접근 계층에 대한 일관된 예외 추상화를 제공
- 스프링은 예외 변환기를 통해
SQLException
의ErrorCode
에 맞는 적절한 스프링 데이터 접근 예외로 변경해준다. - 만약 컨트롤러, 서비스 계층에서 예외 처리가 필요하면
SQLException
같은 스프링이 제공하는 데이터 접근예외를 사용 - 추상화 덕분에 특정 기술에 종속적이지 않고, 기술이 변경 되어도 예외로 인한 변경 최소화
->향후 JDBC에서 JPA로 구현기술을 변경하더라도, 스프링은 JPA예외를 적절한 스프링 데이터 접근 예외로 변환한다.
@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);
}
}
위의 catch문과 같이 exTranslator.translate("save", sql, e);
로 넘겨주기만 하면된다.
- 스프링이 예외를 추상화해 서비스 계층에서 예외에 종속되지 않는다.
- 서비스 계층에서 특정 구현 기술이 변경되더라도 그대로 유지 가능 (DI를 제대로 활용할 수 있게 되었다)
- 추가로 서비스 계층에서 예외를 잡아서 복구해야 하는 경우, 예외가 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요 시 예외를 잡아 복구한다.
JDBC 반복 문제 해결
리포지토리에서 JDBC를 사용하기 때문에 발생하는 반복 문제를 해결
JDBC 반복 문제
- 커넥션 조회, 동기화
PreparedStatement
생성 및 파라미터 바인딩- 쿼리 실행
- 결과 바인딩
- 예외 발생 시 스프링 예외 변환기 실행
- 리소스 종료
이러한 반복을 효과적으로 처리하는 방법 => 템플릿 콜백 패턴
JDBC반복 문제를 해결하기 위해 JDBCTemplate
제공
JdbcTemplate
는 반복을 제거한다.
트랜잭션을 위한 커넥션 동기화는 물론이고, 예외 발생 시 스프링 예외 변환기도 자동으로 실행
JDBC템플릿 사용방법에 대한 자세한 설명은 다음 강의
JDBCTemplate
은 JDBC로 개발할 때 발생하는 반복 대부분 해결
트랜잭션을 위한 커넥션 동기화는 물론이고, 예외 발생 시 스프링 예외 변환기도 자동으로 실행
정리
- 서비스 계층의 순수성
- 트랜잭션 추상화 + 트랜잭션 AOP 덕분에 서비스 계층의 순수성을 유지하며 트랜잭션 사용이 가능
- 스프링이 제공하는 예외 추상화와 예외 변환기 덕분에 데이터 접근 기술이 변경되어도 서비스 계층의 순수성 유지하며 예외 사용
- 서비스 계층이 리포지토리 인터페이스를 의존한 덕분에 다른 데이터 접근 기술로 변경되어도 서비스 계층 순수하게 유지 가능
- JDBC를 반복하는 코드가
JDBCTemplate
으로 대부분 제거