5장에선 지금까지 만든 DAO에 트랜잭션을 적용해보면서 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지 살펴볼 것이다.
현재까지의 DAO
구현해야 할 비즈니스 로직
Level
이늄(enum)User
클래스에 사용자의 레벨을 저장할 필드를 추가하자.
USER
테이블에서의 타입, 이에 매핑되는 자바의 User
클래스에서의 타입은?varchar
타입으로 선언하고 문자열을 넣는다.Level
타입이 int
면 다른 종류의 정보를 넣는 실수를 해도 컴파일러가 체크해주지 못한다.int
타입의 값을 갖고 있지만, 겉으로는 Level
타입의 오브젝트이기 때문에 안전하게 사용할 수 있다. UserDao
필드 추가로그인 횟수와 추천수를 추가하자.
int
타입으로 만들어도 좋다.DB의 USER
테이블에도 필드를 추가한다.
UserDaoTest
테스트 수정UserDaoJdbc
와 테스트에도 필드를 추가하자.
UserDaoJdbc
는 테스트까지 갖추고 있는 안정된 코드이기 때문에,
기존 코드에 새로운 기능을 추가하려면 테스트를 먼저 만드는 것이 안전하다.
User
클래스 생성자의 파라미터도 추가해준다. User
오브젝트 필드 값이 모두 같은지 비교하는 checkSameUser()
메서드를 수정한다. 검증용 필드를 추가해서 기존 DAO 테스트 기능을 보완하는 것이다.
필드도 늘어났고, 앞으로 추가되거나 변경되도 User
오브젝트를 비교하는 assertThat
검증 로직을 일정하게 유지할 수 있도록 따로 메서드로 분리하는 것이다.
UserDaoJdbc
수정미리 준비된 테스트가 성공하도록 UserDaoJdbc
클래스를 수정하자.
INSERT
문장이 들어 있는 add()
메서드의 SQL과 각종 조회 작업에 사용되는 User
오브젝트 매핑용 콜백인 userMapper
에 추가된 필드를 넣는다. 📌
Level
타입의 level 필드를 사용할 때
- 저장할 때
Level
enum은 오브젝트이므로 DB에 저장될 수 있는 SQL 타입이 아니다. 따라서 DB에 저장 가능한 정수형 값으로 변환해줘야 한다. 각Level
이늄의 DB 저장용 값을 얻기 위해서Level
에 미리 만들어둔intValue()
메서드를 사용한다.
- 조회할 때
ResultSet
에서는 DB의 타입인int
로level
정보를 가져온다. 이 값을User
의setLevel()
메서드에 전달하면 타입이 일치하지 않는다는 에러가 발생할 것이다. 이때는Level
의 스태틱 메서드인valueOf()
를 이용해int
타입의 값을Level
타입의 enum 오브젝트로 만들어서setLevel()
메서드에 넣어줘야 한다.
id
를 제외한 나머지 필드는 수정될 가능성이 있다. User
오브젝트를 전달하면 id
를 참고해서 사용자를 찾아 필드 정보를 UPDATE
문을 이용해 모두 변경해주는 메서드를 하나 만들자. id
를 제외한 필드의 내용을 바꾼 뒤 update()
를 호출한다. id
로 조회해서 가져온 User
오브젝트와 수정한 픽스처 오브젝트를 비교한다. 📌 user1이라는 텍스트 픽스처는 인스턴스 변수인데 직접 변경해도 될까?
상관없다. 테스트 메서드가 실행될 때마다UserDaoTest
오브젝트는 새로 만들어지고,setUp()
메서드도 다시 불러와 초기화되기 때문이다.
UserDao
와 UserDaoJdbc
수정UserDao
인터페이스에 update()
메서드를 추가한다.UserDaoJdbc
에서 update()
메서드를 add()
와 비슷한 방식으로 구현한다.JDBC 개발에서 리소스 반환과 같은 기본 작업을 제외하면 가장 많은 실수가 일어나는 곳은 바로 SQL 문장이다. 필드 이름이나 SQL 키워드를 잘못 넣은 거라면 테스트에서 에러가 나니 쉽게 확인할 수 있지만, UPDATE
문장에서 WHERE
절은 빼먹는 경우는 테스트로는 검증하지 못하는 오류가 있을 수 있다. UPDATE
는 WHERE
가 없어도 아무런 경고 없이 정상적으로 동작하는 것처럼 보인다. 현재 update()
테스트는 수정할 로우의 내용이 바뀐 것만 확인할 뿐이지, 수정하지 않아야 할 로우의 내용이 그대로 남아 있는지는 확인해주지 못한다는 문제가 있다.
JdbcTemplate
의 update()
가 돌려주는 리턴 값을 확인한다.
JdbcTemplate
의 update()
는 UPDATE
나 DELETE
같이 테이블의 내용에 영향을 주는 SQL을 실행하면 영향받은 로우의 개수를 돌려준다. update()
메서드의 SQL에 문제가 있다는 사실을 알 수 있다. (선택) 테스트를 보강해서 원하는 사용자 외의 정보는 변경되지 않았음을 직접 확인한다.
UserDao
update()
메서드의 SQL문장에서WHERE
부분을 빼보자.
그래도 기존 update()
테스트는 성공할 것이다. 테스트에 결함이 있다는 증거다. 테스트를 수정해서 테스트가 실패하도록 만들어야 한다.
UserService.updgradeLevels()
레벨 관리 기능을 구현해보자.
UserDao
의 getAll()
메서드로 사용자를 다 가져와서 사용자별로 레벨 업그레이드 작업을 진행하면서 UserDao
의 update()
를 호출해 DB에 결과를 넣어주면 된다.UserService
라는 클래스를 추가하자.UserService
는 UserDao
인터페이스 타입으로 userDao
빈을 DI 받아 사용하게 만들자.UserService
는 UserDao
의 구현 클래스가 바뀌어도 영향받지 않도록 해야한다. 즉, 데이터 액세스 로직이 바뀌었다고 비즈니스 로직 코드를 수정하는 일이 있어서는 안 된다. UserService
도 당연히 스프링의 빈으로 등록돼야 한다.UserService
를 위한 테스트 클래스인 UserServiceTest
를 추가한다.UserService
의 클래스 레벨 의존관계UserService
클래스와 빈 등록UserService
클래스를 만들고 사용할 UserDao
오브젝트를 저장해둘 인스턴스 변수를 선언한다.UserDao
오브젝트의 DI가 가능하도록 수정자 메서드도 추가한다.userService
아이디로 빈을 추가한다. 그리고 userDao
빈을 DI 받도록 프로퍼티를 추가해준다.UserServiceTest
테스트 클래스UserServiceTest
클래스를 추가하고 테스트 대상인 UserService
빈을 제공받을 수 있도록 @Autowired
가 붙은 인스턴스를 선언해준다.UserService
는 컨테이너가 관리하는 스프링 빈이므로 스프링 테스트 컨텍스트를 통해 주입받을 수 있다.userService
빈이 생성되서 userService
변수에 주입되는지만 확인하는 테스트 메서드를 추가한다. 이후에 삭제하자.upgradeLevels()
메서드사용자 레벨 관리 기능을 먼저 만들고 테스트를 만들자.
upgradeLevels()
테스트1. 가능한 모든 조건을 하나씩 확인해보자.
upgradeLevels()
메서드를 실행한다. UserService.add()
처음 가입하는 사용자는 기본적으로 BASIC 레벨이어야 한다는 로직을 추가하자.
1.1 UserDaoJdbc
의 add()
는 적합하지 않다.
UserDaoJdbc
는 주어진 User
오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심을 가져야지, 비즈니스적인 의미를 지닌 정보를 설정하는 책임을 지는 것은 바람직하지 않다.
1.2 User
클래스에서 level
필드를 Level.BASIC
으로 초기화하는 것
처음 가입할 때를 제외하면 무의미한 정보인데 단지 이 로직을 담기 위해 클래스에서 직접 초기화하는 것은 좀 문제가 있다.
1.3 사용자 관리에 대한 비즈니스 로직을 담고 있는 UserService
UserDao
의 add()
메서드는 사용자 정보를 담은 User
오브젝트를 받아서 DB에 넣어주는 역할을 한다면, UserService
에 add()
를 만들고 사용자가 등록될 때 적용할 만한 비즈니스 로직을 담당하게 하면 된다.
2.1 검증할 기능 : UserService
의 add()
를 호출하면 레벨이 BASIC으로 설정되는 것
❓
UserService
의add()
에 전달되는User
오브젝트에level
필드에 값이 미리 설정되어 있는 경우에는 어떻게 할까?
- 이건 정하기 나름이다.
- 여기서는
add()
를 호출할 때level
값이 비어 있으면 로직을 따라 BASIC을 부여해주고, 만약 특별한 이유가 있어 미리 설정된 레벨을 가진User
오브젝트인 경우에는 그대로 두자.
2.2 테스트 케이스는 두 종류를 만든다.
레벨이 미리 정해진 경우, 레벨이 비어 있는 두 가지 경우에 각각 add()
메서드를 호출하고 결과를 확인하도록 만들자.
User
오브젝트의 레벨이 변경됐는지 확인하는 두 가지 방법
UserService
의 add()
메서드를 호출할 때 파라미터로 넘긴 User
오브젝트에 level
필드를 확인해보는 것UserDao
의 get()
메서드를 이용해 DB에 저장된 User
정보를 가져와 확인하는 것UserService
가 UserDao
를 제대로 사용하는지 함께 검증할 수 있고, 디폴트 레벨 설정 후에 UserDao
를 호출하는지도 검증된다.null
인 사용자 오브젝트 두 개를 준비한다.UserService
의 add()
메서드를 통해 초기화한 뒤에 DB에 저장되도록 만든다. 📌 작성된 코드를 살펴볼 때 체크해야 될 것
- 코드에 중복된 부분을 없는가?
- 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
- 코드가 자신이 있어야 할 자리에 있는가?
- 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?
upgradeLevels()
메서드 코드의 문제점for
루프에 있는 if/elseif/else
블록들이 읽기 불편하다.레벨의 변화 단계와 업그레이드 조건, 조건이 충족됐을 때 해야 할 작업같이 성격이 다른 여러 가지 로직이 한데 섞여 있어 로직을 이해하기 쉽지 않다.
if
블록 하나를 보면
if
조건 블록이 레벨 개수만큼 반복된다.만약 새로운 레벨이 추가된다면 Level
이늄도 수정해야 하고, upgradeLevels()
의 레벨 업그레이드 로직을 담은 코드에 if
조건식과 블록을 추가해줘야 되서 코드가 지저분해진다.
upgradeLevels()
리팩토링1. 추상적인 레벨에서 로직을 작성해보자.
레벨을 업그레이드하는 작업의 기본 흐름만 먼저 만들어보자. 구체적인 구현에서 외부에 노출할 인터페이스를 분리하는 것과 마찬가지 작업이라고 생각하면 된다.
모든 사용자 정보를 가져와 한 명씩 업그레이드가 가능한지 확인하고, 가능하면 업그레이드를 한다.
2. 업그레이드가 가능한지를 알려주는 메서드인 canUpgradeLevel()
메서드를 만들자.
user
에 대해 업그레이드가 가능하면 true
, 가능하지 않으면 false
를 리턴한다.false
를 반환한다.3. 업그레이드 조건을 만족했을 경우 구체적으로 무엇을 할 것인가를 담고 있는 upgradeLevel()
메서드를 만들자.
upgradeLevel()
메서드를 수정하자.upgradeLevel()
의 문제점level
필드를 변경해준다는 로직이 함께 있는데다, 너무 노골적으로 드러나 있다. if
문이 점점 늘어날 것이다. 또한 레벨 변경 시 변경할 작업이 추가된다면 if
조건 뒤에 붙는 내용도 점점 길어질 것이다.레벨의 순서와 다음 단계 레벨이 무엇인지를 결정하는 일 즉, 레벨의 업그레이드 순서는 Level
에게 맡기자.
Level
enum에 next
라는 다음 단계 레벨 정보를 담을 수 있도록 필드를 추가한다.Level
enum을 정의할 때 DB에 저장될 값과 다음 레벨이 무엇인지를 함께 넣어줄 수 있다.nextLevel()
메서드를 호출한다.사용자 정보가 바뀌는 부분을 UserService
메서드에서 User
로 옮기자.
User
의 내부 정보가 변경되는 것은 UserService
보다 User
가 스스로 다루는 게 적절하다. User
는 사용자 정보를 담고 있는 단순한 자바빈이긴 하지만 User
도 엄연히 자바오브젝트이고 내부 정보를 다루는 기능이 있을 수 있다. User
에 업그레이드 작업을 담당하는 독립적인 메서드를 두고 사용할 경우, 업그레이드 시 기타 정보도 변경이 필요해질 때 유용하다.
User
는 Level
의 nextLevel()
기능을 이용해 Level
타입의 level
필드에게 다음 레벨이 무엇인지 확인한 후 현재 레벨을 변경해준다. UserService
의 canUpgradeLevel()
메서드에서 업그레이드 가능 여부를 미리 판단해주긴 하지만, User
오브젝트를 UserService
만 사용하는 건 아니므로 스스로 예외상황에 대한 검증 기능을 갖고 있는 편이 안전한다.nextLevel()
에서 다음 레벨이 없는 경우엔 null
을 리턴하는데, 이 경우에는 User
의 레벨 업그레이드 작업이 진행돼서는 안되므로 예외를 던져야 한다. 지금 개선한 코드를 살펴보면 UserService
, User
, Level
각 오브젝트와 메서드가 각각 자기 몫의 책임을 맡아 일을 하고, 필요가 생기면 이런 작업을 수행해달라고 서로 요청하는 구조다. 잘못된 요청이나 작업을 시도했을 때 이를 확인하고 예외를 던져줄 준비도 다 되어 있다.
객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 하다.
User
테스트User
에 추가한 upgradeLevel()
메서드에 대한 테스트를 만들자.
User
오브젝트는 스프링이 IoC로 관리해주는 오브젝트가 아니기 때문에, 클래스의 테스트는 굳이 스프링의 테스트 컨텍스트를 사용하지 않아도 된다. @Autowired
로 가져오는 대신 생성자를 호출해서 테스트할 User
오브젝트를 만들면 된다. upgradeLevel()
테스트는 Level
enum에 정의된 모든 레벨을 가져와서 User
에 설정해두고 User
의 upgradeLevel()
을 실행해 다음 레벨로 바뀌는지 확인하는 것이다. 단, 다음 단계가 null
인 경우는 제외한다.nextLevel()
이 null
인 경우를 제외하고 업그레이드를 진행한다. nextLevel()
이 null
인 경우에 강제로 upgradeLevel()
을 호출한다.assertThrows
를 사용해 설정한 예외가 발생하면 테스트가 성공이다. UserServiceTest
개선checkLevel()
메서드의 중복 개선Level
이 갖고 있어야 할 다음 레벨이 무엇인가 하는 정보를 테스트에 직접 넣어둘 이유가 없다.
upgradeLevels()
테스트는 각 사용자에 대해 업그레이드를 확인하려는 것인지 아닌지 좀 더 이해하기 쉽게 true
, false
로 나타나 있어서 보기 좋다. UserService
와 UserServiceTest
중복 제거업그레이드 조건인 로그인 횟수와 추천 횟수가 애플리케이션 코드와 테스트 코드에 중복돼서 타나난다.
case BASIC: return (user.getLogin() >= 50); // UserService
new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0) // UserServiceTest
❓ 테스트와 애플리케이션 코드에 나타난 이런 숫자의 중복도 제거해줘야 할까?
당연하다. 한 가지 변경 이유가 발생했을 때 여러 군데를 고치게 만든다면 중복이기 때문이다. 이런 상수 값을 중복하는 건 바람직하지 못하다.
여기서 가장 좋은 방법은 정수형 상수로 변경하는 것이다.
연말 이벤트나 새로운 서비스 홍보기간 중에는 레벨 업그레이드 정책을 다르게 적용할 필요가 있을 수도 있다. 그럴 때마다 중요한 사용자 관리 로직을 담은 UserService
의 코드를 직접 수정했다가 이벤트 기간이 끝나면 다시 이전 코드로 수정한다는 것은 상당히 번거롭고 위험한 방법이다.
이런 경우 사용자 업그레이드 정책을 UserService
에서 분리하는 방법을 고려해볼 수 있다. 업그레이드 정책을 담은 인터페이스를 만들어두고 분리된 업그레이드 정책을 담은 오브젝트는 DI를 통해 UserService
에 주입한다. 스프링 설정을 통해 평상시 정책을 구현한 클래스를 UserService
에서 사용하게 하다가, 이벤트 때는 새로운 업그레이드 정책을 구현한 클래스를 따로 만들어서 DI 해주면 된다.
정기 사용자 레벨 관리 작업을 수행하는 도중에 네트워크가 끊기거나 서버에 장애가 생겨서 작업을 완료할 수 없다면, 그때까지 변경된 사용자의 레벨은 그대로 둬야할까 아니면 모두 초기 상태로 되돌여 놓아야 할까?
이 시스템에선 중간에 문제가 발생해서 작업이 중단된다면 그때까지 진행된 변경 작업도 모두 취소시키도록 결정했다고 하자.
지금까지 만든 사용자 레벨 업그레이드 코드는 모든 사용자에 대해 업그레이드 작업을 진행하다가 중간에 예외가 발생해서 작업이 중단된다면 어떻게 될까?
사용자 레벨 업그레이드를 시도하다가 중간에 예외가 발생했을 경우, 그 전에 업그레이드했던 사용자도 다시 원래 상태로 돌아가는지 확인하는 것이다.
UserService
대역UserService
의 대역을 사용하는 것UserService
를 대신해 테스트의 목적에 맞게 동작하는 클래스를 만들어 사용하는 것이다.UserService
확장 클래스를 만드는 방법UserService
코드를 복사해서 사용하는 것보다, 간단히 UserService
를 상속해서 테스트에 필요한 기능을 추가하도록 일부 메서드를 오버라이딩하는 방법이 좋다. 현재 5개의 테스트용 사용자 정보 중 두 번째와 네 번째가 업그레이드 대상이다. 네 번째 사용자를 처리하는 중 예외를 발생시키고, 그 전에 처리한 두 번째 사용자의 정보가 취소됐는지 여부를 확인하자.
테스트용으로 UserService
를 상속한 클래스를 만든다.
테스트에서만 사용할 클래스라면 테스트 클래스 내부에 스태틱 클래스로 만드는 것이 간편하다. 즉, UserServiceTest
안에 추가한다.
테스트용 UserService
의 서브클래스를 UserSerive
기능의 일부를 오버라이딩해서 특정 시점에서 강제로 예외가 발생하도록 만든다.
UserService
의 메서드 대부분은 현재 private
접근제한이 걸려있어 오버라이딩이 불가능하다.
테스트 코드는 테스트 대상 클래스의 내부의 구현 내용을 고려해 밀접하게 접근해야 하지만, private
은 제약이 강한 접근제한자이기 때문에 테스트를 위해 애플리케이션 코드를 수정하자. 하지만 테스트를 위해 애플리케이션 코드를 직접 수정하는 일은 가능한 한 피하자.
UserService
서브클래스에서 upgradeLevel()
메서드를 오버라이딩할 것이다.User
오브젝트를 확인해서 네 번째 User
오브젝트가 전달됐을 경우에 강제로 예외를 발생시킨다. UserService
의 upgradeLevel()
메서드 접근권한을 protected
로 수정해서 상속을 통해 오버라이딩이 가능하게 하자.upgradeLevel()
메서드가 UserService
메서드의 기능을 그대로 수행하지만 미리 지정된 id
를 가진 사용자가 발견되면 강제로 예외를 던지도록 만든다. TestUserService
와 마찬가지로 최상위 클래스로 정의하는 대신 테스트 클래스 내에 스태틱 멤버 클래스로 만들어도 된다. 이제 테스트를 만들어보자.
테스트용으로 만들어둔 TestUserService
의 오브젝트를 만든다.
id
를 넣어준다.userDao
를 수동으로 DI 해준다. TestUserService
는 upgradeAllOrNothing
테스트 메서드에서만 특별한 목적으로 사용되는 것이라, 번거롭게 스프링 빈으로 등록할 필요는 없다. 5개의 사용자 정보를 등록해준다. 이후 testUserService
의 업그레이드 메서드를 실행한다.
upgradeLevels()
에서는 DB에서 5개의 User
를 가져와 차례로 업그레이드 하다가 지정해둔 4번째 사용자 오브젝트 차례가 되면 TestUserServiceException
을 발생시킬 것이다. upgradeLevels()
가 정상적으로 종료되면 fail()
메서드 때문에 테스트가 실패할 것이다. fail()
은 테스트가 의도한 대로 동작하는지를 확인하기 위해 넣은 것이다. TestUserServiceException
을 잡은 후에는 checkLevelUpgraded()
메서드를 이용해 두 번째 사용자 레벨이 변경됐는지 확인한다.
테스트는 실패한다. 네 번째 사용자 처리 중 예외가 발생했지만 두 번째 사용자의 변경된 레벨이 그대로 유지되고 있다.
모든 사용자의 레벨을 업그레이드하는 작업인 upgradeLevels()
메서드가 하나의 트랜잭션 안에서 동작하지 않았기 때문이다.
트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 즉, 모든 사용자에 대한 레벨 업그레이드 작업은 전체가 다 성공하든지 아니면 전체가 다 실패하든지 해야 한다. 따라서 중간에 예외가 발생해서 작업을 완료할 수 없다면 아예 작업이 시작되지 않은 것처럼 초기 상태로 돌려놔야 한다.
DB는 그 자체로 완벽한 트랜잭션을 지원한다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.
하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 문제는 첫 번째 SQL을 성공적으로 실행했지만 두 번째 SQL이 성공하기 전에 장애가 생겨서 작업이 중단되는 경우다. 이때 두 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 SQL이 성공적으로 DB에서 수행되기 전에 문제가 발생한 경우에는 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백이라고 한다.
반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 트랜잭션 커밋이라고 한다.
모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확정하는 커밋이다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다.
Connection
, PreparedStatement
를 처리하는 일부분은 생략되어 있다. false
로 만들어주면 된다. 그러면 새로운 트랜잭션이 시작되게 만들 수 있다. commit()
또는 rollback()
메서드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다. commit()
또는 rollback()
이 호출되면 그에 따라 작업 결과가 DB에 반영되거나 취소되고 트랜잭션이 종료된다. setAutoCommit(false)
로 트랜잭션의 시작을 선언하고 commit()
또는 rollback()
으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정이라고 한다. Connection
이 만들어지고 닫히는 범위 안에 존재한다. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션이라고도 한다. Connection
을 가져와 사용하다가 닫는 사이에서 일어난다. 트랜잭션의 시작과 종료는 Connection
오브젝트를 통해 이뤄지기 때문이다.UserService
와 UserDao
의 트랜잭션 문제UserService
의 upgradeLevels()
에는 트랜잭션이 적용되지 않았을까?만든 코드 어디에도 트랜잭션을 시작하고, 커밋하고, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않는다.
JdbcTemplate
을 사용하기 때문에 Connection
오브젝트가 코드에서 없어졌다.
JdbcTemplate
은 하나의 템플릿 메서드 안에서 DataSource
의 getCommection()
메서드를 호출해서 Connection
오브젝트를 가져오고, 작업을 마치면 Connection
을 확실하게 닫아주고 템플릿 메서드를 빠져나온다. 결국 템플릿 메서드 호출 한 번에 한 개의 DB 커넥션이 만들어지고 닫히는 것이다. 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧다. 따라서 템플릿 메서드가 호출될 때마다 트랜잭션이 새로 만들어지고 메서드를 빠져나오기 전에 종료된다. 결국 JdbcTemplate
의 메서드를 사용하는 UserDao
는 각 메서드마다 하나씩의 독립적인 트랜잭션으로 실행될 수밖에 없다.
upgradeAllOrNothing()
테스트 메서드에서 2번째 사용자 오브젝트에서 UserDao
의 update()
메서드를 호출한다. JdbcTemplate
을 사용하는 update()
메서드는 자동으로 UPDATE
작업의 트랜잭션을 종료시킬 것이고, 수정 결과는 영구적으로 DB에 반영된다. 트랜잭션 작업은 내구성을 보장받기 때문에 일단 커밋되고 나면 DB 서버가 다운되더라도 그 결과는 DB에 그대로 남는다.
UserService
와 UserDao
를 통해 트랜잭션이 일어나는 과정UserDao
는 JdbcTemplate
을 통해 매번 새로운 DB 커넥션과 트랜잭션을 만들어 사용한다. update()
를 호출할 때 작업이 성공했다면 그 결과는 이미 트랜잭션이 종료되면서 커밋됐기 때문에 두 번째 update()
를 호출하는 시점에서 오류가 발생해서 작업이 중단된다고 해도 첫 번째 커밋한 트랜잭션의 결과는 DB에 그대로 남는다. 데이터 액세스 코드를 DAO로 만들어서 분리해놓았을 경우엔 이처럼 DAO 메서드를 호출할 때마다 하나의 새로운 트랜잭션이 만들어지는 구조가 될 수밖에 없다. DAO 메서드에서 DB 커넥션을 매번 만들기 때문이다. 결국 DAO를 사용하면 비즈니스 로직을 담고 있는 UserService
내에서 진행되는 여러 가지 작업을 하나의 트랜잭션으로 묶는 일이 불가능해진다.
❗️ 일련의 작업이 하나의 트랜잭션으로 묶이려면 그 작업이 진행되는 동안 DB 커넥션도 하나만 사용돼야 한다. 트랜잭션은 Connection
오브젝트 안에서 만들어지기 때문이다. 즉, 같은 Connection
을 사용해야 같은 트랜잭션 안에서 동작한다.
(비효율) DAO 메서드 안으로 upgradeLevels()
메서드의 내용을 옮기자.
UserDao
가 가진 SQL이나 JDBC API를 이용한 데이터 액세스 코드는 최대한 그대로 남겨둔 채로, UserService
에는 트랜잭션 시작과 종료를 담당하는 최소한의 코드만 가져오게 하자.
UserService
와 UserDao
를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService
쪽으로 가져와야 한다. 프로그램의 흐름을 볼 때 upgradeLevels()
메서드의 시작과 함께 트랜잭션이 시작하고 메서드를 빠져나올 때 트랜잭션이 종료돼야 하기 때문이다.
트랜잭션 경계를 upgradeLevels()
메서드 안에 두려면 DB 커넥션도 이 메서드 안에서 만들고, 종료시킬 필요가 있다.
Connection
오브젝트를 가지고 데이터 액세스 작업을 진행하는 코드는 UserDao
의 update()
메서드 안에 있어야 한다. 순수한 데이터 액세스 로직은 UserDao
에 둬야 하기 때문이다. UserDao
의 update()
메서드는 반드시 upgradeLevels()
메서드에서 만든 Connection
을 사용해야 한다. 그럴려면 DAO 메서드를 호출할 때마다 Connection
오브젝트를 파라미터로 전달해줘야 한다.UserService
에서 UserDao
의 update()
를 직접 호출하는 건 upgradeLevels()
가 아닌 upgradeLevel()
메서드이다. 결국 UserService
의 메서드 사이에도 같은 Connection
오브젝트를 사용하도록 파라미터로 전달해줘야 한다.upgradeLevels()
메서드 안에서 트랜잭션의 경계설정 작업이 일어나야 하고, 그 트랜잭션을 갖고 있는 DB 커넥션을 이용하도록 해야만 별도의 클래스에 만들어둔 DAO 내의 코드도 트랜잭션이 적용될 테니 이 방법을 사용할 수밖에 없다.
UserService
트랜잭션 경계설정의 문제점UserService
와 UserDao
를 이런 식으로 수정하면 트랜잭션 문제는 해결할 수 있지만 다른 문제가 생긴다.
DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate
을 더 이상 활용할 수 없다.
결국 JDBC API를 직접 사용해야 한다. try/catch/finally
블록은 이제 UserService
내에 존재하고, UserService
의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수밖에 없다.
DAO의 메서드와 비즈니스 로직을 담고 있는 UserService
의 메서드에 Connection
파라미터가 추가돼야 한다.
UserService
는 스프링 빈으로 선언해서 싱글톤으로 되어 있으니 Connection
을 인스턴스 변수에 저장해뒀다 다른 메서드에서 사용하게 할 수도 없다. 멀티스레드 환경에서는 공유하는 인스턴스 변수에 스레드별로 생성하는 정보를 저장하다가는 서로 덮어쓰는 일이 발생하기 때문이다.
Connection
파라미터가 UserDao
인터페이스 메서드에 추가되면 UserDao
는 더 이상 데이터 액세스 기술에 독립적일 수가 없다.
JPA나 Hibernate로 UserDao
의 구현 방식을 변경하려고 하면 Connection
대신 EntityManager
나 Session
오브젝트를 UserDao
메서드가 전달받도록 해야 한다. 결국 UserDao
인터페이스는 바뀔 것이고, 그에 따라 UserService
코드도 함께 수정돼야 한다. 인터페이스를 사용해 DAO를 분리라고 DI를 적용했던 게 소용없어진다.
DAO 메서드에 Connection
파라미터를 받게 하면 테스트 코드에도 영향을 미친다.
테스트 코드에서 직접 Connection
오브젝트를 일일이 만들어서 DAO 메서드를 호출하도록 모두 변경해야 한다. 테스트에서 UserDao
를 사용할 때 DB 커넥션까지 신경써야 한다.
비즈니스 로직을 담고 있는 UserService
메서드 안에서 트랜잭션의 경계를 설정해 관리하기 위해서, 스프링은 위 문제들을 해결할 수 있는 방법을 제공한다.
Connection
파라미터 제거upgradeLevels()
메서드가 트랜잭션 경계설정을 해야 한다. 하지만 여기서 생성된 Connection
오브젝트를 계속 메서드의 파라미터로 전달하다가 DAO를 호출할 때 사용하게 하는 것을 피하고 싶다.
이를 위해 스프링은 독립적인 트랜잭션 동기화 방식을 제안한다. 트랜잭션 동기화란 UserService
에서 트랜잭션을 시작하기 위해 만든 Connection
오브젝트를 특별한 저장소에 보관해두고, DAO의 메서드에서는 저장된 Connection
을 가져다가 사용하게 하는 것이다. 정확히는 DAO가 사용하는 JdbcTemplate
이 트랜잭션 동기화 방식을 이용하도록 하는 것이다. 그리고 트랜잭션이 모두 종료되면, 그때는 동기화를 마치면 된다.
(1) UserService
는 Connection
을 생성하고 (2) 이를 트랜잭션 동기화 저장소에 저장해두고 Connection
의 setAutoCommit(false)
를 호출해 트랜잭션을 시작시킨 후에 본격적으로 DAO의 기능을 이용하기 시작한다.
(3) 첫 번째 update()
메서드가 호출되고, update()
메서드 내부에서 이용하는 JdbcTemplate
메서드에서는 가장 먼저 (4) 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection
오브젝트가 존재하는지 확인한다.
(2) upgradeLevels()
메서드 시작 부분에서 저장해둔 Connection
을 발견하고 이를 가져온다. 가져온 (5) Connection
을 이용해 PreparedStatement
를 만들어 수정 SQL을 실행한다. 트랜잭션 동기화 저장소에서 DB 커넥션을 가져왔을 때는 JdbcTemplate
은 Connection
을 닫지 않은 채로 작업을 마친다. 이렇게 해서 트랜잭션 안에서 첫 번째 DB 작업을 마쳤다. 여전히 Connection
은 열려 있고 트랜잭션은 진행 중인 채로 트랜잭션 동기화 저장소에 저장되어 있다.
(6) 두 번째 update()
가 호출되면 이때도 마찬가지로 (7) 트랜잭션 동기화 저장소에서 Connection
을 가져와 (8) 사용한다.
(9) 마지막 update()
도 (10) 같은 트랜잭션을 가진 Connection
을 가져와 (11) 사용한다.
트랜잭션 내의 모든 작업이 정상적으로 끝났으면 UserService
는 이제 (12) Connection
의 commit()
을 호출해서 트랜잭션을 완료시킨다.
마지막으로 (13) 트랜잭션 저장소가 더 이상 Connection
오브젝트를 저장해두지 않도록 이를 제거한다. 어느 작업 중에라도 예외상황이 발생하면 UserService
는 즉시 Conneciton
의 rollback()
을 호출하고 트랜잭션을 종료할 수 있다. 물론 이때도 트랜잭션 저장소에 저장된 동기화된 Connection
오브젝트는 제거해줘야 한다.
이렇게 트랜잭션 동기화 기법을 사용하면 트랜잭션의 경계설정이 필요한 upgradeLevels()
에서만 Connection
을 다루게 하고, 여기서 생성된 Connection
과 트랜잭션을 DAO의 JdbcTemplate
이 사용할 수 있도록 별도의 저장소에 동기화하는 방법을 적용하기만 하면 된다.
문제는 멀티스레드 환경에서도 안전한 트랜잭션 동기화 방법을 구현하는 일이 기술적으로 간단하지 않다.
스프링은 JdbcTemplate
과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메서드를 제공하고 있다.
UserService
에서 DB 커넥션을 직접 다룰 때 DataSource
가 필요하므로 DataSource
빈에 대한 DI 설정을 해둬야 한다.
TransactionSynchronizationManager
: 스프링이 제공하는 트랜잭션 동기화 관리 클래스이다.
트랜잭션 동기화 적용 과정
TransactionSynchronizationManager
클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다. DataSourceUtils
에서 제공하는 getConnection()
메서드를 통해 DB 커넥션을 생성한다.DataSource
에서 Connection
을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메서드를 쓰는 이유는 이 DataSourceUtils
의 getConnection()
메서드는 Connection
오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다. JdbcTemplate
을 사용하면 JdbcTemplate
의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다. 결국 UserDao
를 통해 진행되는 모든 JDBC 작업은 upgradeLevels()
메서드에서 만든 Connection
오브젝트를 사용하고 같은 트랜잭션에 참여하게 된다. JDBC의 트랜잭션 경계설정 메서드를 사용해 트랜잭션을 이용하는 전형적인 코드에 간단한 트랜잭션 동기화 작업을 붙여 Connection
파라미터 문제를 해결했다.
트랜잭션이 적용됐는지 테스트를 해보자.
UserServiceTest
에 upgradeAllOrNothing()
테스트 메서드에 dataSource
빈을 가져와 주입해주는 코드를 추가한다.TestUserService
는 UserService
의 서브클래스이므로 UserService
와 마찬가지로 트랜잭션 동기화에 필요한 DataSource
를 DI 해줘야 하기 때문이다. UserService
의 dataSource
프로퍼티 설정을 설정파일에 추가해줘야 한다.TestUserService
를 직접 구성하는 upgradeAllOrNothing()
테스트와는 달리, upgradeLevels()
테스트는 스프링 컨테이너가 초기화한 userService
를 사용해야 하기 때문이다. JdbcTemplate
은 update()
나 query()
같은 JDBC 작업의 템플릿 메서드를 호출하면 직접 Connection
을 생성하고 종료하는 일을 모두 담당한다. 테스트에서 특별한 준비 없이 DAO의 메서드를 직접 사용했을 때도 제대로 동작하는 것을 보면 스스로 Connection
을 생성해서 사용한다는 사실을 알 수 있다.
만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate
이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다. 반면에 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate
의 메서드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다. 이를 통해 이미 시작된 트랜잭션에 참여하는 것이다.
따라서 DAO를 사용할 때 트랜잭션이 굳이 필요 없다면 바로 호출해서 사용해도 되고, DAO 외부에서 트랜잭션을 만들고 이를 관리할 필요가 있다면 미리 DB 커넥션을 생성한 다음 트랜잭션 동기화를 해주고 사용하면 된다. 트랜잭션 동기화를 해주고 나면 DAO에서 사용하는 JdbcTemplate
은 자동으로 트랜잭션 안에서 동작할 것이다.
지금까지 만든 코드는 DB 연결 방법이 바뀌어도 UserDao
나 UserService
코드는 수정하지 않아도 된다. DataSource
인터페이스와 DI를 적용한 덕분이다.
Connection
을 이용한 트랜잭션 방식인 로컬 트랜잭션으로는 불가능하다. 왜냐하면 로컬 트랜잭션은 하나의 DB Connection
에 종속되기 때문이다.👉 JTA(Java Transaction API) : 자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위해 제공하는 API이다.
애플리케이션은 기존의 방법대로 DB는 JDBC, 메시징 서버라면 JMS 같은 API를 사용해서 필요한 작업을 수행한다.
단, 트랜잭션은 JDBC나 JMS API를 사용해서 직접 제어하지 않고 JTA를 통해 트랜잭션 매니저가 관리하도록 위임한다.
트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다.
이를 통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 되는 것이다.
이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.
JTA를 이용한 트랜잭션 처리 코드의 전형적인 구조
트랜잭션 경계설정을 위한 코드의 구조는 JDBC를 사용했을 때와 비슷하다.
JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService
의 코드를 수정해야 한다. 로컬 트랜잭션과 글로벌 트랜잭션 중에 필요로 하는 것에 따라 트랜잭션 관리 코드를 적용해야 한다. UserService
는 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀐다.
Y사에서 Hibernate를 이용해 UserDao
를 직접 구현했다고 알려왔다. 그런데 문제는 Hiberenate를 이용한 트랜잭션 관리 코드는 JDBC나 JTA의 코드와는 또 다르다는 것이다. Hibernate는 Connection
을 직접 사용하지 않고 Session
을 사용하고, 독자적인 트랜잭션 관리 API를 사용한다. 그렇다면 이번엔 UserService
를 Hibernate의 Session
과 Transaction
오브젝트를 사용하는 트랜잭션 경계설정 코드로 변경할 수 밖에 없다.
UserDao
가 DAO 패턴을 사용해 구현 데이터 액세스 기술을 유연하게 바꿔서 사용할 수 있게 했지만 UserService
에서 트랜잭션의 경계설정을 해야 할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 되고 말았다.
UserService
에 트랜잭션 경계설정 코드를 도입한 후에 클래스의 의존관계UserService
는 UserDao
인터페이스에만 의존하는 구조였다. 하지만 JDBC에 종속적인 Connection
을 이용한 트랜잭션 코드가 UserService
에 등장하면서부터 UserDaoJdbc
에 간접적으로 의존하는 코드가 됐다. UserService
이 코드가 특정 트랜잭션 방법에 의존적이지 않고 독립적일 수 있게 할 방법은?
UserService
의 메서드 안에서 트랜잭션 경계설정 코드를 제거할 수는 없다. 하지만 특정 기술에 의존적인 Connection
, UserTransaction
, Session
/Transaction
API 등에 종속되지 않게 할 수 있는 방법은 있다.
트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조라 추상화를 생각해볼 수 있다.
JDBC, JTA, Hibernate, JPA, JDO, JMS 모두 트랜잭션 개념을 갖고 있으니 모두 트랜잭션 경계설정 방법에서 공통점이 있을 것이다. 이 공통적인 특징을 모아 추상화된 트랜잭션 관리 계층을 만들 수 있다.
그러면 애플리케이션 코드에서는 트랜잭션 추상 계층이 제공하는 API를 이용해 트랜잭션을 이용하게 만들어준다면 특정 기술에 종속되지 않는 트랜잭션 경계설정 코드를 만들 수 있다.
📌 추상화 : 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다.
- 추상화하면 하위 시스템이 어떤 것인지 알지 못해도, 또는 하위 시스템이 바뀌더라도 일관된 방법으로 접근할 수 있다.
- DB에서 제공하는 DB 클라이언트 라이브러리와 API는 서로 전혀 호환이 되지 않는 독자적인 방식으로 만들어져 있다. 하지만 모두 SQL을 이용하는 방식이라는 공통점이 있다. 이 공통점을 뽑아내 추상화한 것이 JDBC다. JDBC라는 추상화 기술이 있기 때문에 자바의 DB 프로그램 개발자는 DB의 종류에 상관없이 일관된 방법으로 데이터 액세스 코드를 작성할 수가 있다.
스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다.
PlatformTransactionManager
을 생성한다.
PlatformTransactionManager
: 스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스 PlatformTransactionManager
를 구현한 DataSourceTransactionManager
를 사용하면 된다. 사용할 DB의 DataSource
를 생성자 파라미터로 넣으면서 DataSourceTransactionManager
의 오브젝트를 만든다. 트랜잭션을 시작한다.
Connection
을 생성하고 나서 트랜잭션을 시작했지만, DataSourceTransactionManager
은 트랜잭션을 가져오는 요청인 getTransaction()
메서드를 호출하기만 하면 된다. DefaultTransactionDefinition
오브젝트를 트랜잭션에 대한 속성을 담고 있다. TransactionStatus
타입의 변수에 저장된다. TransactionStatus
는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager
메서드의 파라미터로 전달해주면 된다. 트랜잭션이 시작됐으니 이제 JdbcTemplate
을 사용하는 DAO를 이용하는 작업을 진행한다.
PlatformTransactionManager
로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장된다. PlatformTransactionManager
를 구현한 DataSourceTransactionManager
오브젝트는 JdbcTemplate
에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다. 따라서 PlatformTransactionManager
를 통해 시작한 트랜잭션은 UserDao
의 JdbcTemplate
안에서 사용된다. 트랜잭션 작업을 모두 수행한 후엔 트랜잭션을 만들 때 돌려받은 TransactionStatus
오브젝트를 파라미터로 해서 PlatformTransactionManager
의 commit()
메서드를 호출하면 된다. 예외가 발생하면 rollback()
메서드를 부른다.
트랜잭션 추상화 API를 적용한 UserService
코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면,
PlatformTransactionManager
구현 클래스를 JTATransactionManager
로 바꿔주기만 하면 된다.
Hibernate로 UserDao
를 구현했다면 HibernateTransactionManager
를 사용하면 된다.
JTATransactionManager
는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 갖고 있다. 따라서 별다른 설정 없이 JTATransactionManager
를 사용하기만 해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작한다.
UserService
에 PlatformTransactionManager
인터페이스 타입의 인스턴스 변수와 수정자 메서드를 추가해서 DI 가능하게 해준다.UserService
코드가 알고 있는 것은 DI 원칙에 위배된다. 컨테이너를 통해 외부에서 제공받게 하는 스프링 DI의 방식으로 바꾸자.어떤 클래스든 스프링 빈으로 등록할 때 검토해야 할 것은 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은가 하는 점이다. 스프링이 제공하는 모든 PlatformTransactionManager
의 구현 클래스는 싱글톤으로 사용이 가능하기 때문에 스프링의 싱글톤 빈으로 등록해도 좋다.
수정자 메서드를 추가해서 DI가 가능하게 해준다. 일반적으로는 인터페이스 이름과 변수 이름, 수정자 메서드 이름을 모두 같은 것으로 통일하지만, PlatformTransactionManager
의 경우는 관례적으로 transactionManager
라는 이름을 사용한다.
UserService
에 DataSource
변수와 수정자 메서드는 제거한다.
UserService
는 이제 PlatformTransactionManager
만으로도 Connection
생성과 트랜잭션 경계설정 기능을 모두 이용할 수 있기 때문이다.
JDBC 기반의 단일 DB를 사용하는 트랜잭션을 사용하기 위해 DataSourceTransactionManager
클래스를 사용하면 된다. 스프링 설정파일을 수정한다.
DataSourceTransactionManager
는 dataSource
빈으로부터 Connection
을 가져와 트랜잭션 처리를 해야 하기 때문에 dataSource
프로퍼티를 갖는다. userService
빈도 기존의 dataSource
프로퍼티를 없애고 새롭게 추가한 transactionManager
빈을 DI 받도록 프로퍼티를 설정한다.테스트도 수정한다.
트랜잭션 예외상황을 테스트하기 위해 수동 DI를 하는 upgradeAllOrNothing()
메서드에서 스프링 컨테이너로부터 transactionManager
빈을 @Autowired
로 주입받게 하고 이를 직접 DI 해주도록 변경한다.
이제 UserService
는 트랜잭션 기술에서 완전히 독립적인 코드가 됐다. 트랜잭션을 JTA를 이용하고 싶다면 설정파일의 transationManager
빈의 설정만 수정하면 된다. 또한 DAO를 Hibernate나 JPA, JPO 등을 사용하도록 수정했대도 그에 맞게 transactionManager
의 클래스만 변경해주면 된다.
📌
JtaTransactionManager
는 애플리케이션 서버의 트랜잭션 서비스를 이용하기 때문에 직접DataSource
와 연동할 필요는 없다. 대신 JTA를 사용하는 경우는DataSource
도 서버가 제공해주는 것을 사용해야 한다.
이제 스프링의 트랜잭션 서비스 추상화 기법을 이용한 다양한 트랜잭션 기술을 일관된 방식으로 제어할 수 있게 됐다. 사용자 관리의 핵심 코드 소스는 공개하지 않고, 설정을 고치는 것만으로 DB 연결 기술, 데이터 액세스 기술, 트랜잭션 기술을 자유롭게 바꿔서 사용할 수 있다.
UserDao
와 UserService
는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 독자적으로 확장이 가능하도록 만든 것이다. 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리했다. 같은 계층에서 수평적인 분리라고 볼 수 있다.
트랜잭션의 추상화는 수직적인 분리다. 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.
UserService
, UserDao
: 애플리케이션의 로직을 담고 있는 애플리케이션 계층UserService
: 순수하게 사용자 관리의 업무의 비즈니스 로직UserDao
: 데이터를 어떻게 가져오고 등록할 것인가에 대한 데이터 액세스 로직UserDao
와 UserService
는 인터페이스와 DI를 통해 연결됨으로써 결합도가 낮다.UserDao
와 DB 연결 기술도 결합도가 낮다.DataSource
인터페이스와 DI를 통해 추상화된 방식으로 로우레벨의 DB 연결 기술을 사용하기 때문이다. 그래서 UserDao
는 DB 연결을 생성하는 방법에 대해 독립적이다. UserService
와 트랜잭션 기술과도 독립적이다.PlatformTransactionManager
인터페이스를 통한 추상화 계층을 사이에 두고 사용하게 했기 때문에, 구체적인 트랜잭션 기술에 독립적인 코드가 됐다. UserDao
와 DB 연결 기술, UserService
와 트랜잭션 기술의 결합도가 낮은 분리는 애플리케이션 코드를 로우레벨의 기술 서비스와 환경에서 독립시켜준다. 📌 결합도가 낮다는 건 서로 영향을 주지 않고 자유롭게 독립적으로 확장될 수 있는 구조라는 뜻이다.
👉 단일 책임 원칙
하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다는 것이기도 하다.
이런 적절한 분리가 가져오는 특징은 단일 책임 원칙으로 설명할 수 있다.
(이전) UserService
에 JDBC Connection
의 메서드를 직접 사용하는 트랜잭션 코드가 들어있었을 때 UserService
는 어떻게 사용자 레벨을 관리할 것인가와 어떻게 트랜잭션을 관리할 것인가라는 두 가지 책임을 갖고 있었다. 두 가지 책임을 갖고 있다는 건 UserService
코드가 변경되는 이유가 두 가지라는 뜻이다. 사용자 관리 로직이 바뀌든 트랜잭션 기술이 바뀌든 UserService
코드를 수정해야 한다.
-> 결국 단일 책임 원칙을 지키지 못하는 것이다.
(이후) 트랜잭션 서비스의 추상화 방식을 도입하고, 이를 DI를 통해 외부에서 제어하도록 수정했을 때 UserService
가 바뀔 이유는 한 가지뿐이다. 사용자 관리 로직이 바뀌거나 추가되지 않는 한 코드를 수정할 일이 없다.
-> 따라서 단일 책임 원칙을 지키고 있다.
서비스 하나가 여러 개의 DAO를 사용하는 경우가 많아지면 의존관계가 매우 복잡해진다. 이 때 DAO를 하나 수정할 경우 그에 의존하고 있는 서비스 클래스도 같이 수정해야 하는 구조면 수백 개의 클래스를 다 같이 수정해줘야 한다.
기술적인 수정사항도 마찬가지다. 애플리케이션 계층의 코드가 특정 기술에 종속돼서 기술이 바뀔 때마다 코드의 수정이 필요하면 아마 그에 따라 엄청난 코드를 수정해야 할 것이다. 많은 코드를 수정하면 그만큼 실수가 일어날 확률이 놓고, 치명적인 버그도 도입될 가능성이 있다.
그래서 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업이 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI이다.
객체지향 설계와 프로그래밍의 원칙은 서로 긴밀하게 관련이 있다. 단일 책임 원칙을 잘 지키는 코드를 만들려면 인터페이스를 도입하고 이를 DI로 연결해야 하며, 그 결과로 단일 책임 원칙뿐 아니라 개방 폐쇄 원칙도 잘 지키고, 모듈 간에 결합도가 낮아서 서로의 변경이 영향을 주지 않고, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드가 나오기 때문이다. 또한 이런 과정에서 전략 패턴, 어댑터 패턴, 브리지 패턴 등 많은 디자인 패턴이 자연스럽게 적용되기도 한다. 객체지향 설계 원칙을 잘 지켜서 만든 코드는 테스트하기도 편하다. 스프링이 지원하는 DI와 싱글톤 레지스트리 덕분에 더욱 편리하게 자동화된 테스트를 만들 수 있다.
User
에 email
필드를 추가하면 된다. UserService
의 upgradeLevel()
메서드에 메일 발송 기능을 추가한다.JavaMail
을 이용한 메일 발송 기능사용자 정보에 이메일을 추가하는 일은 레벨을 추가했을 때와 동일하게 진행하면 된다.
USER
테이블에 email
필드를 추가한다.User
클래스에 email
프로퍼티를 추가한다. UserDao
의 userMapper
와 insert()
, update()
에 email
필드 처리 코드를 추가한다. UserDaoTest
도 수정한다. JavaMail
메일 발송JavaMail
을 사용하면 된다. javax.mail
패키지에서 제공하는 자바의 이메일 클래스를 사용한다. upgradeLevel()
에서 메일 발송 메서드를 호출한다. JavaMail
API를 사용하는 메서드를 추가한다. JavaMail
을 이용해 메일을 발송하는 가장 전형적인 코드이다. JavaMail
이 포함된 코드의 테스트테스트를 실행했는데 만약 메일 서버가 준비되어 있지 않았다면, MessagingException
예외가 발생하면서 실패한다. 메일 서버가 현재 연결 가능하도록 준비되어 있지 않기 때문이다.
그런데 테스트가 수행될 때 매번 메일이 발송되는 건 바람직하지 않다. 메일 발송은 매우 부하가 큰 작업이기 때문이다. 그것도 실제 운영 중인 메일 서버를 통해 테스트를 실행할 때마다 메일을 보내면 메일 서버에 상당한 부담을 줄 수 있다. 게다가 메일이 실제로 발송돼버린다는 문제도 있다.
테스트 때는 메일 서버 설정을 다르게 해서 테스트용으로 메일 서버를 이용하는 방법이 있다.
운영 중인 메일 서버에 부하를 주진 않게 된다. 하지만 메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과하다. 즉, 중요하지 않다.
실제 메일 전송을 수행하는 JavaMail
대신에 테스트에서 사용할, JavaMail
과 같은 인터페이스를 갖는 오브젝트를 만들어서 사용한다. 즉, JavaMail
API를 사용하지 않는 테스트용 오브젝트로 대체한다.
메일 서버 테스트란 엄밀히 말해서 불가능하다. 기껏해야 메일 발송용 서버에 전달됐음을 확인할 뿐이지, 메일이 정말 잘 도착했는지 확인이 힘들기 때문이다. 하지만 메일 서버는 충분히 테스트된 시스템이라 메일 서버까지만 잘 전달됐으면, 메일이 잘 전송됐다고 믿어도 된다. 따라서 메일 테스트를 한다고 매번 메일 수신 여부를 일일이 확인할 필요는 없고, 테스트 가능한 메일 서버까지만 잘 전송되는지 확인하면 된다. 그리고 테스트용 메일 서버는 메일 전송 요청은 받지만 실제로 메일이 발송되지 않도록 설정해주면 된다.
실제 메일 서버를 사용하지 않고 테스트 메일 서버를 이용해 테스트 하는 방법을 보여준다. 점선 안이 테스트가 동작하는 범위다. 테스트 메일 서버는 외부로 메일을 발송하지 않고, 단지 JavaMail
과 연동해서 메일 전송 요청을 받는 것까지만 담당한다. 결국 이 테스트용으로 준비한 메일 서버는 업그레이드 작업 시 테스트에서 메일 전송 관련 예외가 발생하지 않고 테스트를 마치게 해주는 역할을 맡을 뿐이다.
JavaMail
은 자바의 표준 기술이고 이미 검증된 안정적인 모듈이다. 따라서 JavaMail
API를 통해 요청이 들어간다는 보장만 있다면 굳이 테스트할 때마다 JavaMail
을 직접 구동시킬 필요가 없다.
운영 시에는 JavaMail
을 직접 이용해서 동작하도록 해야겠지만, 개발 중이거나 테스트를 수행할 때는 JavaMail
을 대신할 수 있는, 그러나 JavaMail
을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 만들어도 될 것이다.
JavaMail
을 이용한 테스트의 문제점이 방법의 문제는 JavaMail
의 API는 이 방법을 적용할 수 없다는 점이다.
JavaMail
의 핵심 API는 DataSource
처럼 인터페이스로 만들어져서 구현을 바꿀 수 있는 게 없다.JavaMail
에선 Session
오브젝트를 만들어야만 메일 메시지를 생성할 수 있고 메일을 전송할 수 있다. 그런데 Session
은 final
클래스여서 상속이 불가능하고, 생성자가 모두 private
으로 되어 있어 직접 생성도 불가능하다.결국 JavaMail
의 구현을 테스트용으로 바꿔치기하는 건 불가능하다고 볼 수 있다.
서비스 추상화를 적용해 JavaMail
처럼 테스트하기 힘든 구조인 API를 테스트하자.
스프링은 JavaMail
을 사용해 만든 코드의 테스트를 쉽게 하도록 하기 위해서 JavaMail
에 대한 추상화 기능을 제공하고 있다.
MailSender
: 스프링이 제공하는 메일 서비스 추상화의 핵심 인터페이스 SimpleMailMessage
라는 인터페이스를 구현한 클래스에 담긴 메일 메시지를 전송하는 메서드로만 구성되어 있다. JavaMail
을 사용해 메일 발송 기능을 제공하는 JavaMailSenderImpl
클래스를 이용하면 된다. JavaMailSenderImpl
은 내부적으로 JavaMail
API를 이용해 메일을 전송해준다.JavaMailSender
구현 클래스를 사용해서 만든 메일 발송용 코드이다.SimpleMailMessage
를 사용했고, 메일 전송 오브젝트는 JavaMailSenderImpl
의 오브젝트를 만들어 사용했다. JavaMail
을 처리하는 중에 발생한 각종 예외를 MailException
이라는 런타임 예외로 포장해서 던져주기 때문에 try/cath
블록을 만들지 않아도 된다.MailMessage
타입의 SimpleMailMessage
오브젝트를 만들어서 메시지를 넣은 뒤에 JavaMailSender
타입 오브젝트의 send()
메서드에 전달해주면 된다. JavaMail
API를 사용하는 JavaMailSenderImpl
클래스의 오브젝트를 코드에서 직접 사용한다. sendUpgradeEmail()
메서드에 JavaMailSenderImpl
클래스가 구현한 MailSender
인터페이스만 남기고, 구체적인 메일 전송 구현을 담은 클래스의 정보는 코드에서 모두 제거한다. UserService
에 MailSender
인터페이스 타입의 변수를 만들고 수정자 메서드를 추가해 DI가 가능하도록 만든다. JavaMailSenderImpl
클래스로 빈을 만들고 UserService
에 DI 해준다. MailSender
의 구현 클래스들은 싱글톤으로 사용 가능해야 한다.mailSender
빈의 host
프로퍼티에는 메일 서버를 지정해준다.이제 테스트를 실행하면 JavaMail
API를 직접 사용했을 때와 동일하게 지정된 메일 서버로 메일이 발송된다.
JavaMail
을 사용하지 않고, 메일 발송 기능이 포함된 코드를 테스트하게 됐다. 이를 위해 메일 전송 기능을 추상화해서 인터페이스를 적용하고 DI를 통해 빈으로 분리해놨다.
MailSender
인터페이스가 있으니 이를 구현해서 테스트용 메일 전송 클래스를 만든다.JavaMail
을 사용해서 메일을 전송할 필요가 없으니, 그냥 아무것도 하지 않는 MailSender
구현 빈 클래스인 DummyMailSender
를 만들면 된다.mailSender
빈 클래스를 JavaMail
을 사용하는 JavaMailSenderImpl
대신 DummyMailSender
로 변경한다. UserService
에 새로운 DI용 프로퍼티가 추가됐으니 수동 DI 방식을 사용한 upgradeAllOrNothing()
메서드에도 mailSender
를 추가해준다. 스프링이 제공하는 MailSender
인터페이스를 핵심으로 하는 메일 전송 서비스 추상화의 구조다.
일반적으로 서비스 추상화라고 하면 트랜잭션과 같이 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 접근 방법을 제공해주는 것을 말한다. 반면에 JavaMail
의 경우처럼 테스트를 어렵게 만드는 건전하지 않은 방식으로 설계된 API를 사용할 때도 유용하게 쓰일 수 있다.
JavaMailSenderImpl
의 장점스프링이 직접 제공하는 MailSender
를 구현한 추상화 클래스는 JavaMailServiceImpl
하나뿐이다. 하나뿐이지만 이 추상화된 메일 전송 기능을 사용해 애플리케이션을 작성함으로써 얻을 수 있는 장점은 크다.
JavaMail
이 아닌 다른 메시징 서버의 API를 이용해 메일을 전송해야 하는 경우가 생겨도, 해당 기술의 API를 이용하는 MailSender
구현 클래스를 만들어서 DI 해주면 된다. MailSender
인터페이스를 구현한, 메일 발송 큐의 구현을 하나 만들어 두고 다시 DI를 통해 JavaMailServiceImpl
같은 실제 메일 발송용 오브젝트를 연결해서 사용할 수 있다. MailSender
를 확장해서 메일 전송에 트랜잭션 개념을 적용하는 것이다.MailSender
를 구현한 트랜잭션 기능이 있는 메일 전송용 클래스를 만든다. 이 오브젝트에 업그레이드 작업 이전에 새로운 메일 전송 작업 시작을 알려주고, 그 때부터는 mailSender.send()
메서드를 호출해도 실제로 메일을 발송하지 않고 저장해둔다. 그리고 업그레이드 작업이 끝나면 트랜잭션 기능을 가진 MailSender
에 지금까지 저장된 메일을 모두 발송하고, 예외가 발생하면 모두 취소하게 할 수 있다. MailSender
의 구현 클래스를 이용하는 방법은 서로 다른 종류의 작업을 분리해 처리한다는 면에서 장점이 있다.📌 서비스 추상화의 도입
기술이나 환경이 바뀔 가능성이 있음에도,JavaMail
처럼 확장이 불가능하게 설계해놓은 API를 사용해야 하는 경우라면 추상화 계층의 도입을 적극 고려해볼 필요가 있다. 특별히 외부의 리소스와 연동하는 대부분 작업은 추상화의 대상이 될 수 있다.
DummyMailSender
클래스는 MailSender
인터페이스를 구현해놨을 뿐 아무것도 하는 일이 없다. 하지만 이 클래스를 이용해 JavaMail
로 메일을 직접 발송하는 클래스를 대치했기 때문에 테스트가 원활하게 된 것이다.
스프링의 XML 설정파일을 테스트용으로 따로 만든 이유는 개발자 환경에서 손쉽게 이용할 수 있는 테스트용 DB를 사용하도록 만들기 위해서다.
이처럼 테스트 환경에서 유용하게 사용하는 기법이 있다. 대부분 테스트할 대상이 의존하고 있는 오브젝트를 DI를 통해 바꿔치기 하는 것이다.
UserDaoTest
원래 UserDao
는 운영 시스템에서 사용하는 DB와 연결돼서 동작한다. 대용량의 DB 연결 기능에 최적화된 WAS에서 동작하는 DB 풀링 서비스를 사용하고, 이에 최적화된 복잡한 DataSource
의 구현 클래스를 이용하도록 되어 있다.
하지만 UserDaoTest
의 관심은 UserDao
가 어떻게 동작하는지에 있지, 그 뒤에 존재하는 DB 커넥션 풀이나 DB 자체에 있지 않다. 하지만 UserDao
가 제 기능을 수행하려면 반드시 DB를 사용해야 하기에 무시할 수는 없다. 그래서 이를 대신할 수 있도록 테스트환경에서도 잘 동작하고, 준비 과정도 간단한 DataSource
를 사용하고, DB도 개발자 PC에 설치해서 사용해도 무방한 가벼운 버전을 이용하게 한 것이다.
UserServiceTest
중 메일 전송 기능실제로 이 UserService
가 운영 시스템에서 사용될 때는 당연히 JavaMailSenderImpl
과 JavaMail
을 통해 메일 서버로 이어지는 구성이 필요하다.
하지만 UserServiceTest
의 관심사는 UserService
에서 구현해놓은 사용자 정보를 가공하는 비즈니스 로직이지, 메일이 어떻게 전송이 될 것인지가 아니다. 그렇다고 메일 전송 기능을 아예 뺄 수도 없다. 테스트 대상이 되는 코드를 수정하지 않고, 메일 발송 작업 때문에 UserService
자체에 대한 테스트에 지장을 주지 않기 위해 도입한 것이 결국 DummyMailSender
다. 이 클래스가 UserService
가 반드시 이용해야 하는 의존 오브젝트의 역할을 해주면서 원할하게 테스트 중에 UserService
의 코드가 실행되게 해준다.
이렇게 테스트 대상인 오브젝트가 의존 오브젝트를 갖고 있기 때문에 여러 가지 테스트상의 문제점이 있다.
UserDao
의 경우처럼 테스트를 위해 간단한 환경으로 만들어주던가, 아니면 UserService
의 메일 발송 기능의 경우처럼 아예 아무런 일도 하지 않는 빈 오브젝트로 대치해주는 방법이 있다. 스프링의 DI를 이용하는 것이다.단지 테스트만을 위해서도 DI는 유용하다. DataSource
의 경우에서도, 운영 중인 시스템에서는 특정 클래스 외에는 절대로 다른 것을 사용하지 않는다고 확신하더라도 테스트 때는 바꿀 수밖에 없기 때문이다. 그래서 인터페이스를 사용하고, 어떤 클래스의 오브젝트를 사용할지 외부에서 주입해주도록 스프링의 DI를 적용해야 한다.
테스트용으로 사용되는 특별한 오브젝트들이 있다. 대부분 테스트 대상인 오브젝트의 의존 오브젝트가 되는 것들이다. UserDao
의 DataSource
이거나, UserService
의 MailSender
인터페이스를 구현한 것들이다. 이렇게 테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 통틀어서 테스트 대역이라고 부른다. 테스트가 원활하게 돌아가도록 의존 오브젝트로서 간접적인 도움을 준다는 개념과 달리, 어떤 테스트 대역은 테스트 과정에 매우 적극적으로 참여할 수 있다.
대표적인 테스트 대역은 테스트 스텁이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.
일반적으로 테스트 스텁은 메서드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용된다. 따라서 DI 등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야 한다. DummyMailSender
는 가장 단순하고 심플한 테스트 스텁의 예다.
많은 경우 테스트 스텁이 결과를 돌려줘야 할 때도 있다. MailSender
처럼 호출만 하면 그만인 것도 있지만, 리턴 값이 있는 메서드를 이용하는 경우에는 결과가 필요하다. 이럴 땐 스텁에 미리 테스트 중에 필요한 정보를 리턴해주도록 만들 수 있다.
어떤 스텁은 메서드를 호출하면 강제로 예외를 발생시키게 해서 테스트 대상 오브젝트가 예외상황에서 어떻게 반응할지를 테스트할 때 적용할 수도 있다.
스텁을 이용하면 간접적인 입력 값을 지정해줄 수도 있다. 마찬가지로 어떤 스텁은 간접적인 출력 값을 받게 할 수 있다.
테스트 대상 오브젝트의 메서드가 돌려주는 결과뿐 아니라 테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위 자체에 대해서도 검증하고 싶다면 어떻게 해야 할까? 이 경우 단순하게 메서드의 리턴 값을 assertThat()
으로 검증하는 것으로는 불가능하다.
이런 경우에는 테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 목 오브젝트를 사용해야 한다. 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
테스트 대상 오브젝트는 테스트로부터만 입력을 받는 것이 아니다. 테스트가 수행되는 동안 실행되는 코드는 테스트 대상이 의존하고 있는 다른 의존 오브젝트와도 커뮤니케이션하기도 한다. 테스트 대상은 의존 오브젝트에게 값을 출력하기도, 입력받기도 한다. 간접적으로 테스트 대상이 받아야 할 입력 값이 필요할 때는, 별도로 준비해둔 스텁 오브젝트가 메서드 호출 시 특정 값을 리턴하도록 만들어두면 된다.
때론 테스트 대상 오브젝트가 의존 오브젝트에게 출력한 값에 관심이 있을 경우가 있다. 또는 의존 오브젝트를 얼마나 사용했는가 하는 커뮤니케이션 행위 자체에 관심이 있을 수가 있다. 이때는 테스트 대상과 의존 오브젝트 사이에 주고받는 정보를 보존해두는 기능을 가진 테스트용 의존 오브젝트인 목 오브젝트를 만들어서 사용해야 한다. 테스트 대상 오브젝트의 메서드 호출이 끝나고 나면 테스트는 목 오브젝트에게 테스트 대상과 목 오브젝트 사이에서 일어났던 일에 대해 확인을 요청해서, 그것을 테스트 검증 자료로 삼을 수 있다.
UserServiceTest
에 목 오브젝트를 적용해보자.
트랜잭션 기능을 테스트하려고 만든 upgradeAllOrNothing()
의 경우는 테스트가 수행되는 동안에 메일이 전송됐는지 여부는 관심의 대상이 아니기 때문에 DummyMailSender
가 잘 어울린다.
반면에 정상적인 사용자 레벨 업그레이드 결과를 확인하는 upgradeLevels()
테스트에서는 메일 전송 자체에 대해서도 검증할 필요가 있다. 조건을 만족하는 사용자의 레벨을 수정했다면, 메일도 발송했어야 하기 때문이다.
만약 JavaMail
을 직접 사용하는 방식이었다면, 메일 발송 테스트는 메일 주소로 실제 메일이 들어왔는지 직접 확인하거나, 아니면 아주 복잡한 방법을 사용해 메일 서버의 로그를 뒤져서 메일 발송 로그가 그 시점에 남았는지를 확인해야 할 것이다. 하지만 스프링의 JavaMail
서비스 추상화를 적용했기 때문에 목 오브젝트를 만들어서 메일 발송 여부를 확인할 수 있다.
DummyMailSender
대신 새로운 MailSender
를 대체할 클래스를 하나 만든다. UserServiceTest
안에서만 사용될 것이므로 스태틱 멤버 클래스로 정의한다.UserService
가 send()
메서드를 통해 자신을 불러서 메일 전송 요청을 보냈을 때 관련 정보를 저장해두는 기능이다.SimpleMailMessage
오브젝트에서 첫 번째 수신자 메일 주소를 꺼내온다. MockMailSender
를 이용해 테스트 upgradeLevels()
테스트 코드를 수정해서 목 오브젝트를 통해 메일 발송 여부를 검증하도록 만든다.
1. 스프링 설정을 통해 사용할 메일 전송 검증용 목 오브젝트를 준비한다.
2. MockMailSender
의 오브젝트를 만들고 UserService
오브젝트의 수정자를 이용해 수동 DI 해준다.
3. 생성된 MockMailSender
오브젝트는 뒤에서 검증정보를 얻을 때 사용할 것이므로 변수에 저장해둔다.
4. 테스트 대상의 메서드를 호출하고 먼저 업그레이드 결과에 대한 검증 과정을 거친다.
5. MockMailSender
오브젝트로부터 UserService
사이에서 일어난 일에 대한 결과를 검증한다.
5.1 userService
에 DI 해줬던 목 오브젝트로부터 getRequests()
를 호출해서 메일 주소가 저장된 리스트를 가져온다.
5.2 먼저 리스트의 크기를 확인한다.
5.3 리스트의 첫 번째 메일 주소와 두 번째 사용자의 메일 주소를 비교한다.
5.4 두 번째 메일 주소와 네 번째 사용자의 메일 주소가 같은지 검증한다.
테스트 조건에 따르면 업그레이드 대상은 두 번째와 네 번째 사용자 두 명이기 때문이다.
이제 레벨 업그레이드가 일어날 때 DB의 내용이 변경되는 것은 물론이고, 메일도 정상적으로 발송된다는 사실도 확인할 수 있다. 테스트가 수행될 수 있도록 의존 오브젝트에 간접적으로 입력 값을 제공해주는 스텁 오브젝트와 간접적인 출력 값까지 확인이 가능한 목 오브젝트, 이 두 가지는 테스트 대역의 가장 대표적인 방법이며 효과적인 테스트 코드를 작성하기 위한 중요한 도구다.
5장에서는 비즈니스 로직을 담은 UserService
클래스를 만들고 트랜잭션을 적용하면서 스프링의 서비스 추상화에 대해 살펴보았다.
JavaMail
같은 기술에도 적용할 수 있다. 테스트를 편리하게 작성하도록 도와주는 것만으로도 서비스 추상화는 가치가 있다.