Study with book/토비의 스프링 3.1

5장. 서비스 추상화


DAO에 트랜잭션을 적용해보면서 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지 살펴보자.

5.1 사용자 레벨 관리 기능 추가

5.1.1 필드 추가

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.1.1

 

5.1.2 사용자 수정 기능 추가

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.1.2

 

5.1.3 UserService.upgradeLevels()

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.1.3

로직을 보면 로그인 횟수와 추천 횟수가 각각 기준 값인 50, 30 이상이 되면 SILVER와 GOLD로 업그레이드 된다. 이럴 땐 테스트에 사용할 테이터를 경계가 되는 값의 전후로 선택하는 것이 좋다.

 

5.1.4 UserService.add()

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.1.4

처음 가입한 사용자는 기본적으로 BASIC 레벨이어야 한다. 어디에 담는 것이 좋을까? UserDaoJdbc는 주어진 User 오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심을 가져야지 비지니스적인 의미를 지닌 정보를 설정하는 책임을 지는 것은 바람직하지 않다. 그렇다면 아예 level필드를 Level.BASIC으로 초기화하는 것은 어떨까? 처음 가입할 때를 제외하면 무의미한 정보인데 단지 이 로직을 담기 위해서 클래스에 직접 초기화하는 것은 문제가 있어보인다. UserService에 이 로직을 넣으면 어떨까? UserDao의 add() 메소드는 사용자 정보를 담은 User 오브젝트를 받아서 DB에 넣어주는 데 충실한 역할을 한다면, UserService에서도 add()를 만들어두고 사용자가 등록될 때 적용할 만한 비지니스 로직을 담당하게 하면 될 것이다. 테스트 케이스에서는 add() 메소드를 호출할 때 레벨이 BASIC이 되면 된다.

 

5.1.5 코드 개선

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.1.5

작성된 코드를 살펴볼 때는 다음과 같은 질문을 해볼 필요가 있다.

 1. 코드에 중복된 부분은 없는가?

 2. 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?

 3. 코드가 자신이 있어야 할 자리에 있는가?

 4. 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

 

객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이다. 처음 구현했던 UserService의 upgradeLevels() 메소드는 User 오브젝트에서 데이터를 가져와서 그것을 가지고 User 오브젝트나 Level 이늄이 해야 할 작업을 대신 수행하고 직접 User 오브젝트의 데이터를 변경해버린다. 이보다는 UserService는 User에게 "레벨 업그레이드 작업을 해달라"고 요청하고, 또 User는 Level에게 "다음 레벨이 무엇인지 알려달라"고 요청하는 방식으로 동작하는 것이 바람직하다.

5.2 트랜잭션 서비스 추상화

5.2.1 모 아니면 도

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.2.1

예외를 발생하는 상황을 테스트 해보자. 어떻게 중간에 예외를 강제로 만들 수 있을까? 예외를 강제로 발생시키도록 애플리케이션 코드를 수정할 수 있겠지만, 테스트를 위해서 코드를 함부로 수정하는 것은 좋은 방법이 아니다. 그래서 테스트용으로 특별히 만든 UserService 대역을 사용하는 방법이 좋다. UserService를 대신해서 테스트의 목적에 맞게 동작하는 클래스를 만들어 사용하자는 것이다.

 

모든 사용자의 레벨을 업그레이드하는 작업인 upgradeLevels() 메소드가 하나의 트랜잭션 안에서 동작하지 않았기 때문에 두 번째 사용자의 레벨이 BASIC에서 SILVER로 바뀐 상태로 유지되었다. 트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다.

 

5.2.2 트랜잭션 경계설정

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.2.2

SQL을 이용해 다중 로우의 수정이나 삭제를 위한 요청을 했을 때 일부 로우만 삭제되고 나머지는 안 된다거나, 일부 필드는 수정했는데 나머지 필드는 수정이 안 되고 실패로 끝나는 경우는 없다. 하나의 SQL 명령을 처리하는 경우 DB가 트랜잭션을 보장해준다고 믿을 수 있다. 하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 그 예로 계좌이체를 들 수 있다. 

트랜잭션에서 취소 작업을 트랜잭션 롤백이라고 하고 반대로 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확장시켜야 한다. 이것을 트랜잭션 커밋이라고 한다.

 

모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확장하는 커밋이다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다. 트랜잭션이 한 번 시작되면 commit() 또는 rollback() 메소드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다. 일반적으로 작업 중에 예외가 발생하면 트랜잭션을 롤백한다. commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정이라고 한다. 그렇다면 upgradeLevels() 와 같이 여러 번 DB에 업데이트를 해야 하는 작업을 하나의 트랜잭션으로 만들려면 어떻게 해야 할까? 어떤 일련의 작업이 하나의 트랜잭션으로 묶이려면 그 작업이 진행되는 동안 DB 커넥션도 하나만 사용돼야 한다.

 

이 문제를 해결하기 위해서 DAO메소드 안으로 upgradeLevels() 메소드의 내용을 옮기는 방법을 생각해볼 수 있다. 하지만 이 방식은 비지니스 로직과 데이터 로직을 한데 묶어버리는 한심한 결과를 초래한다. UserService와 UserDao를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야한다. 트랜잭션 경계를 upgradeLevels() 메소드 안에 두려면 DB 커넥션도 이 메소드 안에서 만들고, 종료시킬 필요가 있다. 

이렇게 되면 아래와 같은 문제가 발생한다.

 1) DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다는 점이다. 결국 Jdbc api를 직접 사용하는 초기 방식으로 되돌아가야 한다.

 2) DAO의 메소드와 비지니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다.

 3) Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더 이상 데이터 엑세스 기술에 독립적일 수가 없다.

 4) DAO 메소드에 Connection 파라미터를 받게 하면 테스트 코드에도 영향을 미친다.

 

5.2.3 트랜잭션 동기화

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.2.3

Connection 파라미터 제거

upgradeLevels() 메소드가 트랜잭션 경계설정을 해야 한다는 사실은 피할 수 없다. 따라서 그 안에서 Connection을 생성하고 트랜잭션 시작과 종료를 관리하게 한다. 그러나 DAO를 호출할 때 Connection 오브젝트를 전달하는 것은 피하고 싶다. 이를 위해서 독립적인 트랜잭션 동기화 방식을 스프링은 제공한다. 트랜잭션 동기란 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을 닫지 않은 채로 작업을 마친다.

(6) 두 번째 update()가 호출되면 이때도 마찬가지로

(7) 트랜잭션 동기화 저장소에서 Connection을 가져와서 

(8) 사용한다.

(9) 마지막 update()도 

(10) 같은 트랜잭션을 가진 Connection을 가져와

(11) 사용한다. 트랜잭션 내의 모든 작업이 끝났으면 UserService는 이제

(12) Connection의 commit()을 호출해서 트랜잭션을 완료시킨다.

(13) 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 이를 제거한다. 어느 작업 중에라도 예외가 발생하면 UserService는 즉시 Connection의 rollback()을 호출하고 트랜잭션을 종료한다.

 

트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 날 염려는 없다. 이렇게 트랜잭션 동기화 기법을ㄹ 사용하면 파라미터를 통해 일일이 Connection 오브젝트를 전달할 필요가 없어진다.

 

트랜잭션 동기화 적용

멀티스레드 환경에서도 안전한 트랜잭션 동기화 방법을 구현하는 일이 기술적을로 간단하지 않다는 점인데, 다행히도 스프링은 JdbcTemplate 과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메소드를 제공하고 있다. 바로 TransactionSyncronizationManager이다. 이 클래스를 이용해서 트랜잭션 동기화 작업을 초기화하도로 요청한다. 그리고 DataSource에서 커넥션을 직접 가져오지 않고 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.

 

트랜잭션 테스트 보안

책 참고

 

JdbcTemplate과 트랜잭션 동기화

JdbcTemplate은 영리하게 동작하도록 설계되어 있다. 만약 밀리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다. 반면에 upgradeLevels() 메소드에서처럼 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다. 

 

5.2.4 트랜잭션 서비스 추상화

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.2.4

기술과 환경에 종속되는 트랜잭션 경계설정 코드

트랜잭션 처리 코드를 담은 UserService에서 문제가 발생했다. 하나의 트랜잭션 안에서 여러 개의 DB에 데이터를 넣는 작업을 해야 할 필요가 발생했다. 한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 Jdbc의  Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션으로는 불가능하다. 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 관리자를 통해서 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다. 글로벌 트랜잭션을 적용해야 트랜잭션 매니저를 통해 여러 개의 DB가 참여하는 작업을 하나의 트랜잭션으로 만들 수 있다.

자바는 Jdbc 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API인 JTA(java transaction api)를 제공한다. 

여러 개의 DB 또는 메시징 서버에 대한 트랜잭션을 관리하는 방법

애플리케이션은 기존 방법대로 DB는 Jdbc, 메시징 서버라면 JMS 같은 api를 사용해서 필요한 작업을 수행한다. 단, 트랜잭션은 Jdbc나 JMS api를 사용해서 직접 제어하지 않고 JTA를 통해 트랜잭션 매니저가 관리하도록 위임한다. 트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다. 이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.

 

그러나 문제는 Jdbc 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야 한다. UserService는 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀌는 코드가 돼버리고 만다. 또한 하이버네이트를 이용한 트랜잭션 관리 코드는 Jdbc나 JTA의 코드와는 또 다른 것이다. 하이버네이트는 Connection을 직접 사용하지 않고 Session이라는 것을 사용하고, 독립적인 틀랜잭션 관리 API를 사용한다.

 

트랜잭션 API의 의존관계 문제와 해결책

UserDao가 DAO 패턴을 사용해 구현 데이터 액세스 기술을 유연하게 바꿔서 사용할 수 있게 했지만 UserService에서 트랜잭션의 경계 설정을 해야 할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 되고 말았다.

트랜잭션 도입으로 인한 새로운 의존관계

어떻게 UserService의 코드가 트랜잭션 방법에 의존적이지 않고 독립적일 수 있게 만들려면 어떻게 해야할까? UserService의 메소드 안에서 트랜잭션 경계설정 코드를 제거할 수는 없다. 하지만 특정 기술에 의존적인 Connection, UserTransaction, Session/Transaction API 등에 종속되지 않게 할 수 있는 방법은 있다. 바로 여러 기술의 공통점을 뽑아내는 기술인 추상화를 생각해볼 수 있다.

DB에서 제공하는 DB클라이언트 라이브러리와 API는 서로 전혀 호환이 되지 않는 독자적인 방식으로 만들어져 있다. 하지만 모두 SQL을 이용하는 방식이라는 공통점이 있다. 이 공통점을 뽑아내 추상화한 것이 JDBC이다. JDBC라는 추상화 기술이 있기 때문에 자바의 DB 프로그램 개발자는 DB의 종류에 상관없이 일관된 방법으로 데이터 액세스 코드를 작성할 수가 있다. 그렇다면 트랜잭션 처리 코드에도 추상화를 도입해볼 수 있지 않을까?

 

스프링의 트랜잭션 서비스 추상화

스프링의 트랜잭션 추상화 계층

스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager이다. 사용할 DB의 DataSourceTransactionMnager를 사용하면 된다. 

 

트랜잭션 기술 설정의 분리

트랜잭션 추상화 API를 적용한 UserService 코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면 어떻게 해야 할까? PlatformTransactionManager 구현 클래스를 DataSourceTransactionManager에서 JTATransactionManager로 바꿔주기만 하면 된다. 하지만 어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI 원칙에 위배된다. 그렇다면 DataSourceTransactionManager는 스프링 빈으로 등록하고 UserService가 DI 방식으로 사용하게 해야 한다. 어떤 클래스든 스프링의 빈으로 등록할 때 먼저 검토해야 할 것은 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은가 하는 점이다. 상태를 갖고 있고, 멀티스레드 환경에서 안전하지 않는 클래스를 빈으로 무작정 등록하면 심각한 문제가 발생하기 때문이다.

5.3 서비스 추상화와 단일 책임 원칙

수직, 수평 계층구조와 의존관계

기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다. 트랜잭션 추상화는 서비스 추상화 기법과 그 성격이 다르다. 애플리케이션의 비지니스 로직과 그 하위에서 동작하는 로우레벨 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다. 

계층과 책임의 분리

 

단일 책임의 원칙

UserService가 바뀔 이유는 한 가지뿐이다. 사용자 관리 로직이 바뀌거나 추가되지 않는 한 UserService의 코드에는 손댈 이유가 없다. 설령 트랜잭션 기술이 바뀌고 서버 환경이 바뀌더라도 UserService 코드는 단 한 줄도 수정할 이유가 없다.

5.4 메일 서비스 추상화

5.4.1 JavaMail을 이용한 메일 발송 기능

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.4.1

 

5.4.2 JavaMail이 포함된 코드의 테스트

메일 발송이란 매우 부하가 큰 작업이다. 게다가 메일이 실제로 발송돼버린다는 문제도 있다. 그렇다면 테스트 때는 메일 서버 설정을 다르게 해서 테스트용으로 따로 준비된 메일 서버를 이용하는 방법은 어떨까? 메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과하다. 이 것이 DB에 잘 반영되는지를 확인하는 일만큼 중요하지 않다.

테스트 메일 서버를 이용한 테스트 구조

5.4.3 테스트를 위한 서비스 추상화

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.4.3

테스트와 서비스 추상화

스프링의 메일 전송 서비스 추상화 구조

메일 발송에도 트랜잭션 개념을 적용해야 한다. 

한 가지 방법은 메일을 업그레이드할 사용자를 발견했을 때마다 발송하지 않고 발송 대상을 별도의 목록에 저장해두는 것이다. 그리고 업그레이드 작업이 모두 성공적으로 끝났을 때 한 번에 메일을 전송하면 된다. 다른 방법은 MailSender를 확장해서 메일 전송에 트랜잭션 개념을 적용하는 것이다. MailSender를 구현한 트랜잭션 기능이 있는 메일 전송용 클래스를 만든다. 이 오브젝트에 업그레이드 작업 이전에 새로운 메일 전송 작업 시작을 알려주고, 그때부터는 mailSender.send() 메소드를 호출해도 실제로 메일을 발송하지 않고 저장해둔다. 그리고 업그레이드 작업이 끝나면 트랜잭션 기능을 가진 MailSender에 지금까지 저장된 메일을 모두 발송하고 예외가 발생하면 모두 취소하게 할 수 있다. 

 

5.4.4 테스트 대역

실습 코드 링크 : https://github.com/vvshinevv/toby-spring/tree/feature/5.4.4

의존 오브젝트의 변경을 통한 테스트 방법

UserService 테스트 구조

 

테스트 대역의 종류와 특징

목 오브젝트를 이용한 테스트 동작 방식


[참고자료]

토비 스프링 vol.1 - 이일민

'Study with book > 토비의 스프링 3.1' 카테고리의 다른 글

6장. AOP2  (1) 2020.04.19
6장. AOP1  (0) 2020.04.12
4장. 예외  (0) 2020.03.29
3장. 템플릿2  (0) 2020.03.22
3장. 템플릿1  (0) 2020.03.22