티스토리 뷰
트랜잭션 적용으로 알아보는 서비스 추상화
5장에서는 트랜잭션을 적용해보면서 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지를 살펴볼 것이다.
모 아니면 도
서비스에서 필요한 사용자의 레벨 관리 기능에 대한 요구사항을 추가로 구현하고, 테스트도 잘 만들어서 검증도 마쳤다. 이 때 다음과 같은 질문이 나왔다. 정기 사용자 레벨 관리 작업을 수행하는 도중에 네트워크가 끊기거나 서버에 장애가 생겨서 작업을 완료할 수 없다면, 그때까지 변경된 사용자의 레벨은 그대로 둘까요? 아니면 모두 초기 상태로 되돌려 놓아야 할까요?
열띤 토론 끝에 사용자 레벨 조정 작업은 중간에 문제가 발생해서 작업이 중단된다면 그때까지 진행된 변경 작업도 모두 취소시키도록 결정했다.
그래서 변경 도중에 문제가 생긴다면 다시 되돌리는 기능을 추가해야 하는데, 테스트부터 만만치가 않다. 강제로 예외를 발생시켜야 하기 때문이다. 현재 테스트하고 싶은 UserService를 상속받는 TestUserService를 만들고, 특정 사용자 id의 레벨 업데이트 로직이 실행되면 강제로 예외가 던져지도록 만든다. 그리고 이전에 업데이트를 실행한 사용자의 정보가 업데이트 전으로 되돌려졌는지를 검증하는 테스트를 작성한다. 지금은 당연하게도 테스트가 실패한다. 바로 트랜잭션
문제다. 모든 사용자의 레벨을 업그레이드하는 작업의 메소드가 하나의 트랜잭션 안에서 동작하지 않았기 때문이다.
트랜잭션 경계설정
DB는 그 자체로 완벽한 트랜잭션을 지원한다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.
하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 계좌이체의 과정이라든가 방금 이야기한 전체 사용자에 대한 정보 수정 등이 그렇다.
문제는 첫 번째 SQL을 성공적으로 실행했지만, 두 번째 SQL이 성공하기 전에 장애가 생겨서 작업이 중단되는 경우다. 이때 두 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 SQL이 성공적으로 DB에서 수행되기 전에 문제가 발생할 경우에는 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백(transaction rollback)
이라고 한다. 반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 트랜잭션 커밋(transaction commit)
이라고 한다.
모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확정하는 커밋이다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계
라고 부른다. 복잡한 로직의 흐름 사이에서 정확하게 트랜잭션 경계를 설정하는 일은 매우 중요한 작업이다.
Connection c = dataSource.getConnection();
c.setAutoCommit(false); // 트랜잭션 시작
try {
PreparedStatement st1 = c.prepareStatement("UPDATE users ...");
st1.executeUpdate();
PreparedStatement st2 = c.prepareStatement("DELETE users ...");
st2.executeUpdate();
c.commit(); // 트랜잭션 커밋
} catch (Exception e) {
c.rollback(); // 트랜잭션 롤백
}
c.close();
JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에서 일어난다. 트랜잭션의 시작과 종료는 Connection 오브젝트를 통해 이뤄지기 때문이다. JDBC에서 트랜잭션을 시작하려면 자동커밋 옵션을 false로 만들어주면 된다. 트랜잭션은 한 번 시작되면 commit()
또는 rollback()
메소드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다. 이렇게 setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정(transaction demarcation)
이라고 한다. 트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다는 점도 기억해두자. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(local transaction)
이라고 한다.
비즈니스 로직 내의 트랜잭션 경계설정과 그 문제점
지금까지의 비즈니스 코드에서는 그 어디에서도 트랜잭션을 시작하고, 커밋하고, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않았다. 데이터 액세스 코드를 DAO로 만들어서 분리해놓았을 경우에는 이처럼 DAO 메소드를 호출할 때마다 하나의 새로운 트랜잭션이 만들어지는 구조가 될 수밖에 없다. DAO 메소드에서 DB 커넥션을 매번 만들기 때문에 어쩔 수 없이 나타나는 결과다. 결국 DAO를 사용하면 비즈니스 로직을 담고 있는 UserService 내에서 진행되는 여러 가지 작업을 하나의 트랜잭션으로 묶는 일이 불가능해진다.
기존의 로직을 그대로 유지하면서 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다. 하나의 트랜잭션으로 묶이려면, 하나의 Connection을 공용으로 사용해야 하기 때문에 다음과 같이 파라미터로 Connection을 주고 받도록 할 수 밖에 없다.
class UserService {
public void upgradeLevels() throws Exception {
Connection c = ...; // 여기서 Connection을 만들어서
...
try {
...
upgradeLevel(c, user); // 다른 메소드에 전달하고
...
}
}
protected void upgradeLevel(Connection c, User user) {
user.upgradeLevel();
userDao.update(c, user); // DAO까지 전달해주어야 한다.
}
}
interface UserDao {
public update(Connection c, User user);
}
위와 같이 사용하면 당장에 문제는 해결할 수 있겠지만, 또 다른 문제들이 발생한다.
첫 번째는 DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다는 점이다. try/catch/finally 블록은 UserService 내에 존재하고, 결국 JDBC API를 직접 사용하는 초기 방식으로 돌아가야 한다.
두 번째는 DAO의 메소드와 비즈니스 로직을 담고 잇는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다. 트랜잭션이 필요한 작업에 참여하는 UserService의 메소드는 Connection 파라미터로 지저분해질 것이다.
세 번째는 Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더 이상 액세스 기술에 독립적일 수가 없다는 점이다. JPA나 하이버네이트를 사용한다면 Connection 대신 EntityManager나 Session 오브젝트를 메소드가 전달받도록 해야하는데, 지금까지 적용한 DAO 분리 작업과 DI를 적용한 수고가 물거품이 되고 만다.
마지막으로 DAO 메소드에 Connection 파라미터를 받게 하면 테스트 코드에도 영향을 미친다는 점이다.
트랜잭션 동기화
스프링이 제안하는 방법은 독립적인 트랜잭션 동기화(transaction synchronization)
방식이다. 트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다. 정확히는 DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다. 그리고 트랜잭션이 모두 종료되면, 그때는 동기화를 마치면 된다.
트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 날 염려는 없다.
public void upgradeLevels() throws Exception {
TransactionSynchronizationManager.initStnchronization(); // 트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화
Connection c = DataSourceUtils.getConnection(dataSource);
c.setAutoCommit(false); // 트랜잭션 시작. 이후 DAO 작업은 모두 이 트랜잭션 안에서 진행된다.
try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
c.commit(); // 트랜잭션 커밋
} catch (Exception e) {
c.rollback(); // 트랜잭션 롤백
throw e;
} finally {
DataSourceUtils.releaseConnection(c, dataSource); // 스프링 유틸리티 메소드를 이용해 DB 커넥션을 안전하게 닫는다.
TransactionSynchronizationManager.unbindResource(this.dataSource); // 동기화 작업 종료 및 정리
TransactionSynchronizationManager.clearSynchronization();;
}
}
스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager다. 이 클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다. 그리고 DataSourceUtils에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다. 작업 후에는 스프링 유틸리티 메소드의 도움을 받아 커넥션을 닫고 트랜잭션 동기화를 마치도록 요청하면 된다.
한 가지 궁금한 것이 있다. JdbcTemplate의 동작방식이다. 지금까지 별다른 Connection 설정 없이 JdbcTemplate이 동작하는 것을 보면 스스로 Connection을 생성해서 사용한다는 사실을 알 수 있다.
JdbcTemplate은 영리하게 동작하도록 설계되어 있다. 만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다. 반면에 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다. 이를 통해 이미 시작된 트랜잭션에 참여하는 것이다.
따라서 DAO를 사용할 때 트랜잭션이 굳이 필요 없다면 바로 호출해서 사용해도 되고, DAO 외부에서 트랜잭션을 만들고 이를 관리할 필요가 있다면 미리 DB 커넥션을 생성한 다음 트랜잭션 동기화를 해주고 사용하면 된다. 트랜잭션 동기화를 해주고 나면 DAO에서 사용하는 JdbcTemplate은 자동으로 트랜잭션 안에서 동작할 것이다. JDBC 코드의 try/catch/finally 작업 흐름 지원, SQLException의 예외 변환과 함께 JdbcTemplate이 제공해주는 세 가지 유용한 기능 중 하나다.
트랜잭션 서비스 추상화
지금까지 만들어온 로직은 JDBC API를 사용하고 트랜잭션을 적용했으면서도, 책임에 따라 데이터 액세스 부분과 비즈니스 로직을 잘 분리하고 유지할 수 있게 만든 뛰어난 코드다. 그러나 새로운 문제가 또 발생했다.
이번에는 하나의 트랜잭션 안에서 여러 개의 DB에 데이터를 넣는 작업을 해야 할 필요가 발생했다. 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(global transaction)
방식을 사용해야 한다.
자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API인 JTA(Java Transaction API)
를 제공하고 있다. JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.
InitialContext ctx = new InitialContext();;
UserTransaction tx = (UserTransaction) ctx.lookup(USER_TX_JNDI_NAME); // JNDI를 이용해 서버의 UserTransaction 오브젝트를 가져온다.
tx.begin();
Connection c = dataSource.getConnection(); // JNDI로 가져온 dataSource를 사용해야 한다.
try {
// 데이터 액세스 코드
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
} finally {
c.close();
}
JTA를 사용하는 방법으로 바뀌긴 했지만 여전히 문제는 남아있다. JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야 한다는 점이다. 로컬 DB로 충분한 클라이언트에게는 JDBC 코드를, 여러 DB를 사용하는 클라이언트에게는 JTA 코드를 제공해야 할텐데, 기술 환경에 따라 로직이 변경되어야 하는 일이 벌어지고 말았다. 마찬가지로 하이버네이트를 사용하기 원하는 클라이언트에게도 또 독자적인 트랜잭션 관리 방법에 맞추어서 로직을 변경해 주어야만 한다.
다행히도 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다. 이렇게 여러 기술의 사용 방법에 공통점이 있다면 추상화
를 생각해볼 수 있다. 추상화
란 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다. 그렇게 하면 하위 시스템이 어떤 것인지 알지 못해도, 또는 하위 시스템이 바뀌더라도 일관된 방법으로 접근할 수가 있다.
트랜잭션 처리 코드에도 추상화를 도입해볼 수 있지 않을까? 모든 기술이 트랜잭션 개념을 갖고 있으니 트랜잭션 경계설정 방법에서 공통점이 있을 것이다. 이 공통적인 특징들을 모아서 추상화된 트랜잭션 관리 계층을 만들 수 있다. 그리고 애플리케이션 코드에서는 트랜잭션 추상 계층이 제공하는 API를 이용해 트랜잭션을 이용하게 만들어준다면 특정 기술에 종속되지 않는 트랜잭션 경계설정 코드를 만들 수 있을 것이다.
스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다.
class UserService {
...
private PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
// DI로 외부에서 구현체를 받는다.
// JdbcTemplate을 사용하고 싶으면 DataSourceTransactionManager
// JTA를 사용하고 싶으면 JTATransactionManager
// JPA를 사용하고 싶으면 JPATransactionManager
}
public void upgradeLevels() {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
this.transactionManager.commit(status);
} catch (RuntimeException e) {
this.transactionManager.rollback(status);
throw e;
}
}
}
스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager이다. 스프링의 트랜잭션 추상화 기술은 앞에서 적용해봤던 트랜잭션 동기화를 사용한다. 이제 UserService는 트랜잭션 기술에서 완전히 독립적인 코드가 됐다.
서비스 추상화와 단일 책임 원칙
이렇게 기술과 서비스에 대한 추상화 기법을 적용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다. UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다. 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리했다. 같은 계층에서 수평적인 분리
라고 볼 수 있다.
트랜잭션의 추상화는 이와는 좀 다르다. 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리
한 것이다.
애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며, 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 스프링의 DI
가 중요한 역할을 하고 있다. DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.
단일 책임 원칙(Single Responsibility Principle)
단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.
단일 책임 원칙을 지키는 코드가 되면 어떤 장점이 있을까? 단일 책임 원칙을 잘 지키고 있다면, 어떤 변경이 필요할 때 수정 대상이 명확해진다. 이 원칙이 잘 지켜지지 않은 상태라면, 하나의 변경 사항이 생길 때마다 수 백개의 클래스들과 테스트들을 수정해주어야 할지도 모른다. 그래서 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI다.
객체지향 설계와 프로그래밍의 원칙은 서로 긴밀하게 관련이 있다. 단일 책임 원칙을 잘 지키는 코드를 만들려면 인터페이스를 도입하고 이를 DI로 연결
해야 하며, 그 결과로 단일 책임 원칙뿐 아니라 개방 폐쇄 원칙도 잘 지키고
, 모듈 간에 결합도가 낮아서 서로의 변경이 영향을 주지 않고
, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드
가 나오니까 말이다. 심지어 이런 과정에서 전략 패턴, 어댑터 패턴, 브리지 패턴, 미디에이터 패턴 등 많은 디자인 패턴이 자연스럽게 적용되기도 한다. 객체 지향 설계 원칙을 잘 지켜서 만든 코드는 테스트하기도 편하다. 스프링이 지원하는 DI와 싱글톤 레지스트리 덕분에 더욱 편리하게 자동화된 테스트를 만들 수 있다.
좋은 코드를 설계하고 만들려면 꾸준한 노력이 필요하다. 기능이 동작한다고 해서 쉽게 만족하지 말고 계속 다듬고 개선하려는 자세도 필요하다. 좋은 코드를 만들기 위한 개발자 스스로의 노력과 고민이 있을 때 도움을 주기 때문이다. 멋진 이름을 달고 있는 패턴이나 원칙은 사실은 많은 선배 개발자가 좋은 코드를 만들려고 고민했던 시간을 통해 만들어진 유산일 뿐이다. DI도 마찬가지다.
지금까지 코드를 개선하고 발전시켜온 과정을 생각해보면 단 한 번도 DI가 빠진 적이 없었다
. 스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다. 스프링을 DI 프레임워크
라고 부르는 이유는 스프링이 DI에 담긴 원칙과 이를 응용하는 프로그래밍 모델을 자바 엔터프라이즈 기술의 많은 문제를 해결하는 데 적극적으로 활용하고 있기 때문이다. 또한 스프링을 사용하는 개발자가 만드는 애플리케이션 코드 또한 이런 DI를 활용해서 깔끔하고 유연한 코드와 설계를 만들어낼 수 있도록 지원하고 지지해주기 때문이다.
테스트 스텁과 목 오브젝트
테스트에 대해서 마지막으로 이야기해보자. 테스트 대상이 되는 오브젝트가 또 다른 오브젝트들에 의존하는 경우는 흔한 일이다. 의존한다는 말은 종속되거나 기능을 사용한다는 의미다. 이렇게 테스트 대상인 오브젝트가 의존 오브젝트를 갖고 있기 때문에 발생하는 여러 가지 테스트상의 문제점이 있다. 이런 오브젝트를 테스트하려면 아무리 간단한 로직을 테스트하려고 해도, 해당 오브젝트의 의존 오브젝트들을 미리 전부 세팅해주어야 테스트를 진행할 수 있다. 이런 상황에서 사용할 수 있는 테스트를 위한 특별한 오브젝트들이 있다.
테스트 대역(test double)
테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 오브젝트를 통틀어서 테스트 대역이라고 부른다.
테스트 스텁(test stub)
대표적인 테스트 대역이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.
테스트 대상 오브젝트의 메소드가 돌려주는 결과뿐 아니라 테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위 자체에 대해서도 검증하고 싶다면 어떻게 해야 할까? 이런 경우에는 목 오브젝트를 사용해야 한다.
목 오브젝트(mock object)
테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 오브젝트이다. 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
목 오브젝트를 이용한 테스트라는 게, 작성하기는 간단하면서도 기능은 상당히 막강하다는 사실을 알 수 있을 것이다. 보통의 테스트 방법으로는 검증하기가 매우 까다로운 테스트 대상 오브젝트의 내부에서 일어나는 일이나 다른 오브젝트 사이에서 주고받는 정보까지 검증하는 일이 손쉽기 때문이다.
테스트가 수행될 수 있도록 의존 오브젝트에 간접적으로 입력 값을 제공해주는 스텁 오브젝트와 간접적인 출력 값까지 확인이 가능한 목 오브젝트, 이 두가지는 테스트 대역의 가장 대표적인 방법이며 효과적인 테스트 코드를 작성하는 데 빠질 수 없는 중요한 도구다.
참고
'Spring > 토비의 스프링 3.1' 카테고리의 다른 글
[토비의 스프링] 6. AOP (2) (1) | 2019.11.02 |
---|---|
[토비의 스프링] 6. AOP (1) (2) | 2019.10.27 |
[토비의 스프링] 4. 예외 (0) | 2019.10.10 |
[토비의 스프링] 3. 템플릿 (0) | 2019.10.03 |
[토비의 스프링] 2. 테스트 (0) | 2019.10.03 |