// 테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
rookie@192 bin % chmod 755 h2.sh
rookie@192 bin % ./h2.sh
등장 이유?
애플리케이션 서버와 DB - 일반적 사용법
JDBC는 1997년에 출시된 오래된 기술, 사용도 복잡.
최근에는 JDBC를 직접 사용하기 보다는 JDBC를 편리하게 사용하는 다양한 기술이 존재.
ex) SQL Mapper, ORM
SQL Mapper
ORM 기술
JDBC connection
jdbc가 제공하는 drivermanager는 라이브러리에 등록된 DB 드라이버들을 관리하고, jdbcconnection 구현체를 제공한다.
String sql = "insert into member (member_id, money) values (?, ?)";
위 와 같이 직접적으로 sql 을 작성해주는 것은 sql injection 공격을 당하게 되는 원인이 된다.
value를 ?로 파라미터 바인딩을 하면 단순히 데이터로 취급되기 때문에 sql injection 공격이 안되는 것이다.
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
Member memberV0 = new Member("memberV0", 10000);
repository.save(memberV0);
}
}
// findById
Member findMember = repository.findById(memberV0.getMemberId());
log.info("findMember: {}", findMember);
assertThat(findMember).isEqualTo(memberV0);
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);
}
}
// update: money 10000 -> 20000
repository.update(memberV0.getMemberId(), 20000);
Member updatedMember = repository.findById(memberV0.getMemberId());
assertThat(updatedMember.getMoney()).isEqualTo(20000);
//delete
repository.delete(memberV0.getMemberId());
assertThatThrownBy(() -> repository.findById(memberV0.getMemberId()))
.isInstanceOf(NoSuchElementException.class);
마지막에 회원을 삭제 하기 때문에 중간엔 오류가 발생하면 삭제 로직을 수행하지 않는다. 트랜잭션을 활용하면 이 문제를 깔끔하게 해결할 수 있다.
데이터베이스 커넥션을 새로 만드는 것은 과정도 복잡하고 시간도 많이 소모되는 일이다.
치명적인 문제는 고객이 애플리케이션을 사용할 때, SQL을 실행하는 시간 뿐만 아니라 커넥션을 새로 만드는 시간이 추가되기 때문에 결과적으로 응답 속도에 영향을 준다. (유저 경험이 나빠짐)
이러한 문제를 해결하는 아이디어가 바로 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법이다.
커넥션 풀은 이름 그대로 커넥션을 관리하는 풀(수영장 풀)이다.
애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다. 기본 값은 보통 10개
커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다.
커넥션을 얻는 방법은 앞서 학습한 JDBC DriverManager 를 직접 사용하거나, 커넥션 풀을 사용하는 등 다양한 방법이 존재한다.
💫커넥션을 획득하는 방법을 추상화
datasource 핵심 기능만 축약
public interface DataSource {
Connection getConnection() throws SQLException;
}
스프링이 제공하는 DataSource가 적용된 DriverManager인 DriverManagerDataSource를 사용해보자.
package hello.jdbc.connection;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
@Slf4j
public class ConnectionTest {
// 기존의 방식 jdbc 로 커넥션을 얻는 방법
@Test
void driverManager() throws SQLException {
//DriverManagerDataSource - 항상 새로운 커넥션 획득
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());
}
@Test
void dataSourceDriverManager() throws SQLException {
// DriverManagerDataSource 항상 새로운 커넥션 획득
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());
}
}
→ 설정과 사용의 분리: 설정: 드라이버매니저데이터소스 객체 생성, 사용: 데이터소스 커넥션 생성
⇒ 객체를 설정하는 부분과, 사용하는 부분을 좀 더 명확하게 분리할 수 있다.
@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); // 커넥션 에서 커넥션 생성 시간 대기
}
데이터를 파일에 저장해도 되는데 굳이 데이터베이스에 저장하는 이유가 무엇일까?
→ 여러 이유가 있지만, 가장 대표적인 이유는 데이터베이스가 트랜잭션이라는 개념을 지원하기 때문이다.
데이터베이스에서 트랜잭션은 하나의 거래를 안저낳게 처리하도록 보장해주는 것을 뜻한다.
데이터베이스가 제공하는 트랜잭션 기능을 사용하면 모든 작업이 성공해야 저장하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있다.
트랜잭션은 원자성, 일관성, 지속성을 보장한다. 문제는 격리성인데 트랜잭션 간에 격리성을 보장하려면 트랜잭션을 거의 순서대로 실행해야 한다. 이러면 성능이 매우 나빠진다.
트랜잭션 격리 수준 - Isolation level
트랜잭션 사용법
세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야한다
참고로, 세션2가 락을 무한정 대기하는 것이 아니다. 일정 시간을 넘어가면 타임아웃이 발생. 락 대기시간은 설정할 수 있다.
일반적인 조회는 락을 사용하지 않는다.
비즈니스 로직과 트랜잭션
애플리케이션에서 db 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 복잡한 코드를 요구한다.
커넥션을 유지하도록 코드를 변경하는 것도 쉬운 일은 아니다.
→ 스프링으로 해결
가장 단순하면서 많이 사용하는 방법은 역할에 따라 3가지 계층으로 나누는 것
@Controller(UI 관련 처리)
@Service 비즈니스 로직
@Repository DB 접근 처리
DB 서버
프레젠테이션 계층
서비스 계층
데이터 접근 계층
순수한 서비스 계층
스프링은 서비스 계층을 순수하게 유지하면서, 지금까지 이야기한 문제들을 해결할 수 있는 다양한 방법과 기술들을 제공한다.
현재 서비스 계층은 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하고 있다. 향후 JDBC에서 JPA 같은 다른 데이터 접근 기술로 변경하면, 서비스 계층의 트랜잭션 관련 코드도 모두 함께 수정해야 한다.
구현 기술에 따른 트랜잭션 사용법
public interface TxManager {
begin();
commit();
rollback();
}
트랜잭션은 사실 단순하다. 트랜잭션을 시작하고, 비즈니스 로직의 수행이 끝나면 커밋하거나 롤백하면 된다.
그리고 다음과 같이 TxManager
인터페이스를 기반으로 각각의 기술에 맞는 구현체를 만들면 된다.
JdbcTxManager
: JDBC 트랜잭션 기능을 제공하는 구현체
JpaTxManager
: JPA 트랜잭션 기능을 제공하는 구현체
⇒ 인테페이스에 의존하고 DI를 사용한 덕분에 OCP 원칙을 지키게 되었다.
→ 서비스 코드를 수정하지 않고 트랜잭션 기술을 마음껏 변경할 수 있다.
스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager
인터페이스이다. org.springframework.transaction.PlatformTransactionManager
스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.
트랜잭션 매니저와 트랜잭션 동기화 매니저
동작 방식
트랜잭션 사용 코드
try {
// 비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); // 성공 시 커밋
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new ~;
}
달라지는 부분은 비즈니스 로직 뿐이다.
이럴 때 템플릿 콜백 패턴을 활용하면 이런 반복 문제를 깔끔하게 해결할 수 있다.
템플릿 콜백 패턴을 적용하려면 템플릿 클래스를 작성해야 하는데, 스프링은 TransactionTemplate라는 템플릿 클래스를 제공한다.
TransactionTemplate
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action) {}
void executeWithoutResult(Consumer<TransactionStatus> action){}
}
스프링 AOP와 프록시에 대해서 지금은 자세히 이해하지 못해도 괜찮다. 지금은 @Transactional을 사용하면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다 정도로 이해해도 된다.
프록시 도입 전
클라이언트 → 서비스(트랜잭션시작~트랜잭션종료)-비즈니스 로직 → 리포지토리-데이터 접근 로직
서비스 계층의 트랜잭션 사용 코드 예시
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); // 성공시 커밋
} catch(Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
프록시 도입 후
프록시를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
트랜잭션 프록시 코드 예시
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);
}
}
}
트랜잭션 프록시 적용 후 서비스 코드 예시
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
@Transactional
org.springframework.transaction.annotation.Transactional
트랜잭션 AOP를 사용하는 새로운 서비스 클래스를 만들자.
선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리
선언적 트랜잭션 관리
프로그래밍 방식 트랜잭션 관리
선언적 트랜잭션 관리가 프로그래밍 방식에 비해 훨씬 간단하고 실욪적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다
프로그래밍 방식의 트랜잭션 관리는 스프링 컨테이너나 AOP 기술 없이 간단히 사용할 수 있지만 실무에서는 대부분 스프링 컨테이너와 스프링 AOP를 사용하기 때문에 거의 사용되지 않는다.
그나마 프로그래밍 방식 트랜잭션 관리는 테스트 시에 가끔 사용될 때는 있다.
기존에는 데이터소스와 트랜잭션 매니저를 직접 스프링 빈으로 등록해야 했다. 그런데 스프링 부트가 나오면서 많은 부분이 자동화되었다.
스프링 부트는 데이터소스(DataSource)를 스프링 빈에 자동으로 등록한다.
참고로 개발자가 직접 데이터소스를 빈으로 등록하면 스프링 부트는 데이터소스를 자동으로 등록하지 않는다.
스프링부트가 기본으로 생성하는 데이터소스는 커넥션풀을 제공하는 HikariDataSource이다. 커넥션풀과 관련된 설정도 설정파일에서 지정할 수 있다.
spring.database.url 속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도한다.
트랜잭션 매니저 - 자동 등록
예외는 폭탄돌리기와 같다. 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야 한다.
예외에 대한 2가지 기본 규칙을 기억하자
예외를 처리하지 못하고 계속 던지면 어떻게 될까?
체크 예외의 장단점
체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 이것 때문에 장점과 단점이 동시에 존재한다.
장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려
면 항상 throws 예외
를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다. 이후에 설명하겠지만, 신경
쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 되는 장점이 있다.
단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면에 체크 예외는 컴파일러를 통해 예외 누락을
잡아준다.
기본 원칙은 다음 2가지를 기억
체크 예외의 문제점
체크 예외는 컴파일러가 예외 누락을 체크하기 때문에 개발자가 실수로 놓친 예외를 잡아준다. 그래서 항상 명시적으로 예외를 잡아서 처리하거나, 처리할 수 없을 때는 예외를 던지도록 throws 예외를 선언한다.
SQLException
을 런타임 예외인 RuntimeSQLException
으로 변환했다.
ConnectException
대신에 RuntimeConnectException
을 사용하도록 바꾸었다.
런타임 예외이기 때문에 서비스, 컨트롤러는 해당 예외들을 처리할 수 없다면 별도의 선언 없이 그냥 두면 된다.
JPA 기술도 런타임 예외를 사용한다. 스프링도 대부분 런타임 예외를 제공한다.
런타임 예외도 필요하면 잡을 수 있기 때문에 필요한 경우에는 잡아서 처리하고, 그렇지 않으면 자연스럽게 던지도록 둔 다. 그리고 예외를 공통으로 처리하는 부분을 앞에 만들어서 처리하면 된다.
추가로 런타임 예외는 놓칠 수 있기 때문에 문서화가 중요하다.
throws 런타임예외
을 남겨서 중요한 예외를 인지할 수 있게 해준다.스프링과 문제 해결 - 예외 처리, 반복
todo..