5장 서비스 추상화

Jiwon An·2021년 12월 14일
0

백엔드/스프링

목록 보기
5/6

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

1. 사용자 레벨 관리 기능 추가

  • 현재까지의 DAO

    • User 오브젝트에 담겨 있는 사용자 정보를 CRUD 하는 가장 기초적인 작업만 가능하다.
    • 인터넷 서비스의 사용자 관리 기능의 로직으로 변경한다.
  • 구현해야 할 비즈니스 로직

    • 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나다.
    • 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라서 한 단계씩 업그레이드될 수 있다.
    • 가입 후 50회 이상 로그인을 하면 BASIC에서 SILVER 레벨이 된다.
    • SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD 레벨이 된다.
    • 사용자 레벨의 변경 작업은 일정한 주기를 가지고 일괄적으로 진행된다. 변경 작업 전에는 조건을 충족하더라도 레벨의 변경이 일어나지 않는다.

1.1 필드 추가

1.1.1 Level 이늄(enum)

User 클래스에 사용자의 레벨을 저장할 필드를 추가하자.

고려 사항

  • DB의 USER 테이블에서의 타입, 이에 매핑되는 자바의 User 클래스에서의 타입은?
    • (비효율) DB에 varchar 타입으로 선언하고 문자열을 넣는다.
    • DB에서 각 레벨을 코드화해서 숫자로 넣는다.
    • 그럼 자바에서 추가할 프로퍼티 타입도 숫자로 하면 될까? No
      • 의미 없는 숫자를 프로퍼티에 사용하면 타입이 안전하지 않아서 위험하다.
      • 만약 Level 타입이 int면 다른 종류의 정보를 넣는 실수를 해도 컴파일러가 체크해주지 못한다.
    • 숫자 타입을 직접 사용하는 것보단 enum을 이용하는 게 안전하고 편리하다.
      이렇게 하면, DB에 저장할 int 타입의 값을 갖고 있지만, 겉으로는 Level 타입의 오브젝트이기 때문에 안전하게 사용할 수 있다.

1.1.2 UserDao 필드 추가

로그인 횟수와 추천수를 추가하자.

고려 사항

  • 이 두 가지는 단순한 int 타입으로 만들어도 좋다.
  • 각각 접근자와 수정자 메서드도 추가해두자.

DB의 USER 테이블에도 필드를 추가한다.

1.1.3 UserDaoTest 테스트 수정

UserDaoJdbc와 테스트에도 필드를 추가하자.

고려 사항

  • UserDaoJdbc는 테스트까지 갖추고 있는 안정된 코드이기 때문에,
    기존 코드에 새로운 기능을 추가하려면 테스트를 먼저 만드는 것이 안전하다.

    1. (테스트) 먼저 테스트 픽스처로 만든 user1, user2, user3에 새로 추가된 세 필드의 값을 넣는다.
    2. (User) 이에 맞게 User 클래스 생성자의 파라미터도 추가해준다.
    3. (테스트) 두 개의 User 오브젝트 필드 값이 모두 같은지 비교하는 checkSameUser() 메서드를 수정한다.
  • 검증용 필드를 추가해서 기존 DAO 테스트 기능을 보완하는 것이다.

  • 필드도 늘어났고, 앞으로 추가되거나 변경되도 User 오브젝트를 비교하는 assertThat 검증 로직을 일정하게 유지할 수 있도록 따로 메서드로 분리하는 것이다.

1.1.4 UserDaoJdbc 수정

미리 준비된 테스트가 성공하도록 UserDaoJdbc 클래스를 수정하자.

고려 사항

  • 등록을 위한 INSERT 문장이 들어 있는 add() 메서드의 SQL과 각종 조회 작업에 사용되는 User 오브젝트 매핑용 콜백인 userMapper에 추가된 필드를 넣는다.

📌 Level 타입의 level 필드를 사용할 때

  • 저장할 때
    Level enum은 오브젝트이므로 DB에 저장될 수 있는 SQL 타입이 아니다. 따라서 DB에 저장 가능한 정수형 값으로 변환해줘야 한다. 각 Level 이늄의 DB 저장용 값을 얻기 위해서 Level에 미리 만들어둔 intValue() 메서드를 사용한다.

  • 조회할 때
    ResultSet에서는 DB의 타입인 intlevel 정보를 가져온다. 이 값을 UsersetLevel() 메서드에 전달하면 타입이 일치하지 않는다는 에러가 발생할 것이다. 이때는 Level의 스태틱 메서드인 valueOf()를 이용해 int 타입의 값을 Level 타입의 enum 오브젝트로 만들어서 setLevel() 메서드에 넣어줘야 한다.

1.2 사용자 수정 기능 추가

고려 사항

  • 사용자 관리 비즈니스 로직에 따르면 사용자 정보 중 기본키 id를 제외한 나머지 필드는 수정될 가능성이 있다.
  • 성능을 극대화 하기 위해, 수정되는 필드의 종류에 따라서 각각 여러 개의 수정용 DAO 메서드를 만들어야 할 때도 있다. 아직은 사용자 정보가 간단하고 자주 변경되는 것도 아니므로 간단히 접근하자.
  • 수정할 정보가 담긴 User 오브젝트를 전달하면 id를 참고해서 사용자를 찾아 필드 정보를 UPDATE 문을 이용해 모두 변경해주는 메서드를 하나 만들자.

1.2.1 수정 기능 테스트 추가

고려 사항

  • 먼저 픽스처 오브젝트를 하나 등록한다. 그리고 id를 제외한 필드의 내용을 바꾼 뒤 update()를 호출한다.
  • 다시 id로 조회해서 가져온 User 오브젝트와 수정한 픽스처 오브젝트를 비교한다.

    📌 user1이라는 텍스트 픽스처는 인스턴스 변수인데 직접 변경해도 될까?
    상관없다. 테스트 메서드가 실행될 때마다 UserDaoTest 오브젝트는 새로 만들어지고, setUp() 메서드도 다시 불러와 초기화되기 때문이다.

1.2.2 UserDaoUserDaoJdbc 수정

고려 사항

  • UserDao 인터페이스에 update() 메서드를 추가한다.
  • UserDaoJdbc에서 update() 메서드를 add()와 비슷한 방식으로 구현한다.

1.2.3 수정 테스트 보완

문제

JDBC 개발에서 리소스 반환과 같은 기본 작업을 제외하면 가장 많은 실수가 일어나는 곳은 바로 SQL 문장이다. 필드 이름이나 SQL 키워드를 잘못 넣은 거라면 테스트에서 에러가 나니 쉽게 확인할 수 있지만, UPDATE 문장에서 WHERE 절은 빼먹는 경우는 테스트로는 검증하지 못하는 오류가 있을 수 있다. UPDATEWHERE가 없어도 아무런 경고 없이 정상적으로 동작하는 것처럼 보인다. 현재 update() 테스트는 수정할 로우의 내용이 바뀐 것만 확인할 뿐이지, 수정하지 않아야 할 로우의 내용이 그대로 남아 있는지는 확인해주지 못한다는 문제가 있다.

해결방법

  1. JdbcTemplateupdate()가 돌려주는 리턴 값을 확인한다.

    • JdbcTemplateupdate()UPDATEDELETE 같이 테이블의 내용에 영향을 주는 SQL을 실행하면 영향받은 로우의 개수를 돌려준다.
    • 비록 에러가 발생하지 않고, 원하는 사용자의 정보는 변경됐음을 확인했더라도 영향받은 로우의 개수가 1 이상이라면 update() 메서드의 SQL에 문제가 있다는 사실을 알 수 있다.
  2. (선택) 테스트를 보강해서 원하는 사용자 외의 정보는 변경되지 않았음을 직접 확인한다.

    • 사용자를 두 명 등록해놓고, 그중 하나만 수정한 뒤에 수정된 사용자와 수정하지 않은 사용자의 정보를 모두 확인하면 된다.

두 번째 방법으로 테스트 보완

UserDao update() 메서드의 SQL문장에서WHERE 부분을 빼보자.
그래도 기존 update() 테스트는 성공할 것이다. 테스트에 결함이 있다는 증거다. 테스트를 수정해서 테스트가 실패하도록 만들어야 한다.

1.3 UserService.updgradeLevels()

레벨 관리 기능을 구현해보자.

고려 사항

  • UserDaogetAll() 메서드로 사용자를 다 가져와서 사용자별로 레벨 업그레이드 작업을 진행하면서 UserDaoupdate()를 호출해 DB에 결과를 넣어주면 된다.
  • 사용자 관리 로직은 어디다 둘까?
    • DAO는 데이터를 어떻게 가져오고 조작할지를 다루는 곳이지 비즈니스 로직을 두는 곳이 아니다.
    • 사용자 관리 비즈니스 로직을 담을 UserService라는 클래스를 추가하자.
    • UserServiceUserDao 인터페이스 타입으로 userDao 빈을 DI 받아 사용하게 만들자.
      • UserServiceUserDao의 구현 클래스가 바뀌어도 영향받지 않도록 해야한다. 즉, 데이터 액세스 로직이 바뀌었다고 비즈니스 로직 코드를 수정하는 일이 있어서는 안 된다.
      • 따라서 DAO의 인터페이스를 사용하고 DI를 적용해야 하기 때문에 UserService도 당연히 스프링의 빈으로 등록돼야 한다.
  • UserService를 위한 테스트 클래스인 UserServiceTest를 추가한다.
    • UserService의 클래스 레벨 의존관계

1.3.1 UserService 클래스와 빈 등록

고려 사항

  • UserService 클래스를 만들고 사용할 UserDao 오브젝트를 저장해둘 인스턴스 변수를 선언한다.
  • UserDao 오브젝트의 DI가 가능하도록 수정자 메서드도 추가한다.
  • 스프링 설정파일에 userService 아이디로 빈을 추가한다. 그리고 userDao 빈을 DI 받도록 프로퍼티를 추가해준다.

1.3.2 UserServiceTest 테스트 클래스

  • UserServiceTest 클래스를 추가하고 테스트 대상인 UserService 빈을 제공받을 수 있도록 @Autowired가 붙은 인스턴스를 선언해준다.
    • UserService는 컨테이너가 관리하는 스프링 빈이므로 스프링 테스트 컨텍스트를 통해 주입받을 수 있다.
  • 테스트 메서드가 하나도 없으면 에러가 나므로, 간단히 userService 빈이 생성되서 userService 변수에 주입되는지만 확인하는 테스트 메서드를 추가한다. 이후에 삭제하자.

1.3.3 upgradeLevels() 메서드

사용자 레벨 관리 기능을 먼저 만들고 테스트를 만들자.

1.3.4 upgradeLevels() 테스트

고려 사항

1. 가능한 모든 조건을 하나씩 확인해보자.

  • 사용자 레벨은 BASIC, SILVER, GOLD 세 가지가 있고, 변경이 일어나지 않는 GOLD를 제외한 나머지는 업그레이드가 되는 경우와 아닌 경우가 있을 수 있으므로 최소한 다섯 가지 경우를 살펴봐야 한다.
  • 다섯 종류의 사용자 정보를 등록해두고 업그레이드를 진행한 후에 예상한 대로 결과가 나오는지 확인한다.
  • BASIC과 SILVER 레벨의 사용자는 각각 두 개씩 등록한다.
  • 로직을 보면 로그인 횟수와 추천 횟수가 각각 기준 값인 50, 30 이상이 되면 SILVER와 GOLD로 업그레이드된다.
    • 이럴 땐 테스트에 사용할 데이터를 경계가 되는 값의 전후로 선택하는 것이 좋다.
    • 첫 번째 테스트 사용자는 SILVER 업그레이드 경계인 50에서 하나 모자란 49를, 두 번째 테스트 사용자는 업그레이드가 되는 가장 작은 login 값인 50으로 설정한다.
  • 테스트 픽스처의 개수가 UserDaoTest에서보다 많아졌으니 이번에는 각각 변수로 등록하는 리스트를 사용한다.
  1. 먼저 준비한 다섯 가지 종류의 사용자 정보를 저장한 뒤에 upgradeLevels() 메서드를 실행한다.
  2. 업그레이드 작업이 끝나면 사용자 정보를 하나씩 가져와 레벨의 변경 여부를 확인한다.

1.4 UserService.add()

처음 가입하는 사용자는 기본적으로 BASIC 레벨이어야 한다는 로직을 추가하자.

고려 사항

1. 이 로직을 어디에 담을까?

1.1 UserDaoJdbcadd()는 적합하지 않다.

UserDaoJdbc는 주어진 User 오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심을 가져야지, 비즈니스적인 의미를 지닌 정보를 설정하는 책임을 지는 것은 바람직하지 않다.

1.2 User 클래스에서 level 필드를 Level.BASIC으로 초기화하는 것

처음 가입할 때를 제외하면 무의미한 정보인데 단지 이 로직을 담기 위해 클래스에서 직접 초기화하는 것은 좀 문제가 있다.

1.3 사용자 관리에 대한 비즈니스 로직을 담고 있는 UserService

UserDaoadd() 메서드는 사용자 정보를 담은 User 오브젝트를 받아서 DB에 넣어주는 역할을 한다면, UserServiceadd()를 만들고 사용자가 등록될 때 적용할 만한 비즈니스 로직을 담당하게 하면 된다.

2. 테스트를 만들자.

2.1 검증할 기능 : UserServiceadd()를 호출하면 레벨이 BASIC으로 설정되는 것

UserServiceadd()에 전달되는 User 오브젝트에 level 필드에 값이 미리 설정되어 있는 경우에는 어떻게 할까?

  • 이건 정하기 나름이다.
  • 여기서는 add()를 호출할 때 level 값이 비어 있으면 로직을 따라 BASIC을 부여해주고, 만약 특별한 이유가 있어 미리 설정된 레벨을 가진 User 오브젝트인 경우에는 그대로 두자.

2.2 테스트 케이스는 두 종류를 만든다.

  • 레벨이 미리 정해진 경우, 레벨이 비어 있는 두 가지 경우에 각각 add() 메서드를 호출하고 결과를 확인하도록 만들자.

  • User 오브젝트의 레벨이 변경됐는지 확인하는 두 가지 방법

    1. UserServiceadd() 메서드를 호출할 때 파라미터로 넘긴 User 오브젝트에 level 필드를 확인해보는 것
    2. UserDaoget() 메서드를 이용해 DB에 저장된 User 정보를 가져와 확인하는 것
      • 이 방법은 UserServiceUserDao를 제대로 사용하는지 함께 검증할 수 있고, 디폴트 레벨 설정 후에 UserDao를 호출하는지도 검증된다.

  1. 이미 레벨이 GOLD로 설정된 사용자와 레벨이 null인 사용자 오브젝트 두 개를 준비한다.
  2. 준비한 두 개의 사용자 오브젝트를 UserServiceadd() 메서드를 통해 초기화한 뒤에 DB에 저장되도록 만든다.
  3. 확인을 위해 DB에서 사용자 정보를 읽어온다.
    • 레벨이 이미 설정됐던 것은 그대로 유지되어 있어야 하고, 레벨이 없던 것은 디폴트인 BASIC으로 설정됐는지 확인한다.

1.5 코드 개선

📌 작성된 코드를 살펴볼 때 체크해야 될 것

  • 코드에 중복된 부분을 없는가?
  • 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
  • 코드가 자신이 있어야 할 자리에 있는가?
  • 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

1.5.1 upgradeLevels() 메서드 코드의 문제점

1. for 루프에 있는 if/elseif/else 블록들이 읽기 불편하다.

레벨의 변화 단계와 업그레이드 조건, 조건이 충족됐을 때 해야 할 작업같이 성격이 다른 여러 가지 로직이 한데 섞여 있어 로직을 이해하기 쉽지 않다.

if 블록 하나를 보면

  1. 현재 레벨이 무엇인지 파악하는 로직
  2. 업그레이드 조건을 담은 로직
  3. 다음 단계의 레벨이 무엇이며 업그레이드를 위한 작업은 어떤 것인지가 함께 존재
  4. 그 자체로는 의미가 없고, 단지 멀리 떨어져 있는 5의 작업이 필요한지를 알려주기 위해 임시 플래그를 설정해주는 것

2. if 조건 블록이 레벨 개수만큼 반복된다.

만약 새로운 레벨이 추가된다면 Level 이늄도 수정해야 하고, upgradeLevels()의 레벨 업그레이드 로직을 담은 코드에 if 조건식과 블록을 추가해줘야 되서 코드가 지저분해진다.

변경 사항

  • 조건을 두 단계에 걸쳐서 비교해야 한다.
    • 현재 레벨과 업그레이드 조건을 동시에 비교하는 부분을 분리한다.
    • 첫 단계에서는 레벨을 확인하고 각 레벨별로 다시 조건을 판단하는 조건식을 넣어야 한다.

1.5.2 upgradeLevels() 리팩토링

1. 추상적인 레벨에서 로직을 작성해보자.

  • 레벨을 업그레이드하는 작업의 기본 흐름만 먼저 만들어보자. 구체적인 구현에서 외부에 노출할 인터페이스를 분리하는 것과 마찬가지 작업이라고 생각하면 된다.

  • 모든 사용자 정보를 가져와 한 명씩 업그레이드가 가능한지 확인하고, 가능하면 업그레이드를 한다.

2. 업그레이드가 가능한지를 알려주는 메서드인 canUpgradeLevel() 메서드를 만들자.

  • 주어진 user에 대해 업그레이드가 가능하면 true, 가능하지 않으면 false를 리턴한다.
  • 상태에 따라서 업그레이드 조건만 비교하므로, 역할과 책임이 명료해진다.
  • 업그레이드 가능한지 확인
    • 각 레벨에 대한 업그레이드 조건을 만족하는지 확인한다.
    • GOLD의 경우는 항상 업그레이드가 불가능하니 false를 반환한다.
    • 로직에서 처리할 수 없는 레벨인 경우 예외를 던진다.

3. 업그레이드 조건을 만족했을 경우 구체적으로 무엇을 할 것인가를 담고 있는 upgradeLevel() 메서드를 만들자.

  • 업그레이드 작업용 메서드를 따로 분리해두면 나중에 작업 내용이 추가되더라도 어느 곳을 수정해야 할지가 명확해진다는 장점이 있다.
  • 레벨 업그레이드를 위한 작업은 사용자의 레벨을 다음 단계로 변경하는 것과 변경된 오브젝트를 DB에 업데이트하는 것이다.
  • 나중에 업그레이드 안내 메일을 보낸다거나 로그를 남기거나 관리자에게 통보를 해주거나, 통계를 내는 작업이 추가될 수도 있다.

  1. upgradeLevel() 메서드를 수정하자.

upgradeLevel() 의 문제점

  1. 다음 단계가 무엇인가 하는 로직과 그때 사용자 오브젝트의 level 필드를 변경해준다는 로직이 함께 있는데다, 너무 노골적으로 드러나 있다.
  2. 레벨이 늘어나면 if문이 점점 늘어날 것이다. 또한 레벨 변경 시 변경할 작업이 추가된다면 if 조건 뒤에 붙는 내용도 점점 길어질 것이다.
  3. 예외상황에 대한 처리가 없다.

해결방법

  1. 레벨의 순서와 다음 단계 레벨이 무엇인지를 결정하는 일 즉, 레벨의 업그레이드 순서는 Level에게 맡기자.

    • Level enum에 next라는 다음 단계 레벨 정보를 담을 수 있도록 필드를 추가한다.
    • 생성자 파라미터를 추가해서 다음 단계 레벨 정보를 지정할 수 있게 해준다. Level enum을 정의할 때 DB에 저장될 값과 다음 레벨이 무엇인지를 함께 넣어줄 수 있다.
    • 다음 레벨이 무엇인지 알고 싶다면 nextLevel() 메서드를 호출한다.
  2. 사용자 정보가 바뀌는 부분을 UserService 메서드에서 User로 옮기자.
    User의 내부 정보가 변경되는 것은 UserService보다 User가 스스로 다루는 게 적절하다. User는 사용자 정보를 담고 있는 단순한 자바빈이긴 하지만 User도 엄연히 자바오브젝트이고 내부 정보를 다루는 기능이 있을 수 있다. User에 업그레이드 작업을 담당하는 독립적인 메서드를 두고 사용할 경우, 업그레이드 시 기타 정보도 변경이 필요해질 때 유용하다.

    • UserLevelnextLevel() 기능을 이용해 Level 타입의 level 필드에게 다음 레벨이 무엇인지 확인한 후 현재 레벨을 변경해준다.
    • 업그레이드가 불가능한 경우엔 예외를 던져야 한다.
      • UserServicecanUpgradeLevel() 메서드에서 업그레이드 가능 여부를 미리 판단해주긴 하지만, User 오브젝트를 UserService만 사용하는 건 아니므로 스스로 예외상황에 대한 검증 기능을 갖고 있는 편이 안전한다.
      • nextLevel()에서 다음 레벨이 없는 경우엔 null을 리턴하는데, 이 경우에는 User의 레벨 업그레이드 작업이 진행돼서는 안되므로 예외를 던져야 한다.

지금 개선한 코드를 살펴보면 UserService, User, Level 각 오브젝트와 메서드가 각각 자기 몫의 책임을 맡아 일을 하고, 필요가 생기면 이런 작업을 수행해달라고 서로 요청하는 구조다. 잘못된 요청이나 작업을 시도했을 때 이를 확인하고 예외를 던져줄 준비도 다 되어 있다.

객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 하다.

1.5.3 User 테스트

User에 추가한 upgradeLevel() 메서드에 대한 테스트를 만들자.

고려 사항

  • User 오브젝트는 스프링이 IoC로 관리해주는 오브젝트가 아니기 때문에, 클래스의 테스트는 굳이 스프링의 테스트 컨텍스트를 사용하지 않아도 된다.
    • 컨테이너가 생성한 오브젝트를 @Autowired로 가져오는 대신 생성자를 호출해서 테스트할 User 오브젝트를 만들면 된다.
  • 두 가지 경우를 테스트 한다.
    • upgradeLevel() 테스트는 Level enum에 정의된 모든 레벨을 가져와서 User에 설정해두고 UserupgradeLevel()을 실행해 다음 레벨로 바뀌는지 확인하는 것이다. 단, 다음 단계가 null인 경우는 제외한다.
    • 업그레이드할 레벨이 있는 경우
      • nextLevel()null인 경우를 제외하고 업그레이드를 진행한다.
      • 다음 단계로 업그레이드가 가능한 정상적인 상태만 확인하면 되기 때문이다.
    • 업그레이드할 레벨이 없는 경우
      • nextLevel()null인 경우에 강제로 upgradeLevel()을 호출한다.
      • assertThrows를 사용해 설정한 예외가 발생하면 테스트가 성공이다.

1.5.4 UserServiceTest 개선

1. checkLevel() 메서드의 중복 개선

Level이 갖고 있어야 할 다음 레벨이 무엇인가 하는 정보를 테스트에 직접 넣어둘 이유가 없다.

  • 개선한 upgradeLevels() 테스트는 각 사용자에 대해 업그레이드를 확인하려는 것인지 아닌지 좀 더 이해하기 쉽게 true, false로 나타나 있어서 보기 좋다.

2. UserServiceUserServiceTest 중복 제거

업그레이드 조건인 로그인 횟수와 추천 횟수가 애플리케이션 코드와 테스트 코드에 중복돼서 타나난다.

case BASIC: return (user.getLogin() >= 50); // UserService

new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0) // UserServiceTest

테스트와 애플리케이션 코드에 나타난 이런 숫자의 중복도 제거해줘야 할까?
당연하다. 한 가지 변경 이유가 발생했을 때 여러 군데를 고치게 만든다면 중복이기 때문이다. 이런 상수 값을 중복하는 건 바람직하지 못하다.

여기서 가장 좋은 방법은 정수형 상수로 변경하는 것이다.

3. 레벨 업그레이드 정책을 유연하게 변경

연말 이벤트나 새로운 서비스 홍보기간 중에는 레벨 업그레이드 정책을 다르게 적용할 필요가 있을 수도 있다. 그럴 때마다 중요한 사용자 관리 로직을 담은 UserService의 코드를 직접 수정했다가 이벤트 기간이 끝나면 다시 이전 코드로 수정한다는 것은 상당히 번거롭고 위험한 방법이다.

이런 경우 사용자 업그레이드 정책을 UserService에서 분리하는 방법을 고려해볼 수 있다. 업그레이드 정책을 담은 인터페이스를 만들어두고 분리된 업그레이드 정책을 담은 오브젝트는 DI를 통해 UserService에 주입한다. 스프링 설정을 통해 평상시 정책을 구현한 클래스를 UserService에서 사용하게 하다가, 이벤트 때는 새로운 업그레이드 정책을 구현한 클래스를 따로 만들어서 DI 해주면 된다.

2. 트랜잭션 서비스 추상화

정기 사용자 레벨 관리 작업을 수행하는 도중에 네트워크가 끊기거나 서버에 장애가 생겨서 작업을 완료할 수 없다면, 그때까지 변경된 사용자의 레벨은 그대로 둬야할까 아니면 모두 초기 상태로 되돌여 놓아야 할까?

이 시스템에선 중간에 문제가 발생해서 작업이 중단된다면 그때까지 진행된 변경 작업도 모두 취소시키도록 결정했다고 하자.

2.1 모 아니면 도

지금까지 만든 사용자 레벨 업그레이드 코드는 모든 사용자에 대해 업그레이드 작업을 진행하다가 중간에 예외가 발생해서 작업이 중단된다면 어떻게 될까?

테스트의 목적

사용자 레벨 업그레이드를 시도하다가 중간에 예외가 발생했을 경우, 그 전에 업그레이드했던 사용자도 다시 원래 상태로 돌아가는지 확인하는 것이다.

테스트 고려 사항

  • 예외적인 상황을 작업 중간에 강제로 발생시켜야 한다.
  • 테스트 시나리오
    1. 5명의 사용자 정보를 DB에 넣는다.
    2. 업그레이드 작업을 수행하다가 중간에서 예외가 발생되게 만든다.
  • 시스템 예외상황을 연출하는 건 불가능하고, 테스트의 기본은 자동화된 테스트여야 하니 사람이 간섭하는 것은 좋은 생각이 아니다.
  • 이보다는 장애가 발생했을 때 일어나는 현상 중 하나인 예외가 던져지는 상황을 의도적으로 만드는 게 나을 것이다.

2.1.1 테스트용 UserService 대역

작업 중간에 예외를 강제로 만들 수 있는 방법

  • 예외를 강제로 발생시키도록 애플리케이션 코드를 수정하는 것
    • 테스트를 위해 코드를 함부로 건드리는 것은 좋은 생각이 아니다.
  • 테스트용으로 특별히 만든 UserService의 대역을 사용하는 것
    • UserService를 대신해 테스트의 목적에 맞게 동작하는 클래스를 만들어 사용하는 것이다.
    • 테스트용 UserService 확장 클래스를 만드는 방법
      • UserService 코드를 복사해서 사용하는 것보다, 간단히 UserService를 상속해서 테스트에 필요한 기능을 추가하도록 일부 메서드를 오버라이딩하는 방법이 좋다.

UserService의 대역 구현

현재 5개의 테스트용 사용자 정보 중 두 번째와 네 번째가 업그레이드 대상이다. 네 번째 사용자를 처리하는 중 예외를 발생시키고, 그 전에 처리한 두 번째 사용자의 정보가 취소됐는지 여부를 확인하자.

  1. 테스트용으로 UserService를 상속한 클래스를 만든다.
    테스트에서만 사용할 클래스라면 테스트 클래스 내부에 스태틱 클래스로 만드는 것이 간편하다. 즉, UserServiceTest 안에 추가한다.

  2. 테스트용 UserService의 서브클래스를 UserSerive 기능의 일부를 오버라이딩해서 특정 시점에서 강제로 예외가 발생하도록 만든다.
    UserService의 메서드 대부분은 현재 private 접근제한이 걸려있어 오버라이딩이 불가능하다.
    테스트 코드는 테스트 대상 클래스의 내부의 구현 내용을 고려해 밀접하게 접근해야 하지만, private은 제약이 강한 접근제한자이기 때문에 테스트를 위해 애플리케이션 코드를 수정하자. 하지만 테스트를 위해 애플리케이션 코드를 직접 수정하는 일은 가능한 한 피하자.

    • 테스트용 UserService 서브클래스에서 upgradeLevel() 메서드를 오버라이딩할 것이다.
      • 이 메서드에 전달되는 User 오브젝트를 확인해서 네 번째 User 오브젝트가 전달됐을 경우에 강제로 예외를 발생시킨다.
      • UserServiceupgradeLevel() 메서드 접근권한을 protected로 수정해서 상속을 통해 오버라이딩이 가능하게 하자.
    • 오버라이드된 upgradeLevel() 메서드가 UserService 메서드의 기능을 그대로 수행하지만 미리 지정된 id를 가진 사용자가 발견되면 강제로 예외를 던지도록 만든다.

  1. 다른 예외가 발생했을 경우와 구분하기 위해 테스트 목적을 띤 예외를 같이 정의해둔다.
  • TestUserService와 마찬가지로 최상위 클래스로 정의하는 대신 테스트 클래스 내에 스태틱 멤버 클래스로 만들어도 된다.

2.1.2 강제 예외 발생을 통한 테스트

이제 테스트를 만들어보자.

  1. 테스트용으로 만들어둔 TestUserService의 오브젝트를 만든다.

    • 생성자 파라미터로는 예외를 발생시킬 사용자의 id를 넣어준다.
    • 스프링 컨텍스트로부터 가져온 userDao를 수동으로 DI 해준다.
      • TestUserServiceupgradeAllOrNothing 테스트 메서드에서만 특별한 목적으로 사용되는 것이라, 번거롭게 스프링 빈으로 등록할 필요는 없다.
  2. 5개의 사용자 정보를 등록해준다. 이후 testUserService의 업그레이드 메서드를 실행한다.

    • 테스트용으로 개조한 upgradeLevels()에서는 DB에서 5개의 User를 가져와 차례로 업그레이드 하다가 지정해둔 4번째 사용자 오브젝트 차례가 되면 TestUserServiceException을 발생시킬 것이다.
    • 테스트 코드를 잘못 작성해서 예외 발생 없이 upgradeLevels()가 정상적으로 종료되면 fail() 메서드 때문에 테스트가 실패할 것이다.
      • fail()은 테스트가 의도한 대로 동작하는지를 확인하기 위해 넣은 것이다.
  3. TestUserServiceException을 잡은 후에는 checkLevelUpgraded() 메서드를 이용해 두 번째 사용자 레벨이 변경됐는지 확인한다.

    • 기대하는 것은 네 번째 사용자를 처리하다가 예외가 발생해서 작업이 중단됐으니 이미 레벨을 수정했던 두 번째 사용자도 원래 상태로 돌아가는 것이다.

테스트는 실패한다. 네 번째 사용자 처리 중 예외가 발생했지만 두 번째 사용자의 변경된 레벨이 그대로 유지되고 있다.

2.1.3 테스트 실패의 원인

모든 사용자의 레벨을 업그레이드하는 작업인 upgradeLevels() 메서드가 하나의 트랜잭션 안에서 동작하지 않았기 때문이다.

트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 즉, 모든 사용자에 대한 레벨 업그레이드 작업은 전체가 다 성공하든지 아니면 전체가 다 실패하든지 해야 한다. 따라서 중간에 예외가 발생해서 작업을 완료할 수 없다면 아예 작업이 시작되지 않은 것처럼 초기 상태로 돌려놔야 한다.

2.2 트랜잭션 경계설정

DB는 그 자체로 완벽한 트랜잭션을 지원한다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.

하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 문제는 첫 번째 SQL을 성공적으로 실행했지만 두 번째 SQL이 성공하기 전에 장애가 생겨서 작업이 중단되는 경우다. 이때 두 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 SQL이 성공적으로 DB에서 수행되기 전에 문제가 발생한 경우에는 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백이라고 한다.

반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 트랜잭션 커밋이라고 한다.

2.2.1 JDBC 트랜잭션의 트랜잭션 경계설정

모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확정하는 커밋이다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다.

JDB를 트랜잭션을 적용하는 간단한 예

  • 예시는 트랜잭션 처리에 초점을 맞춰서 Connection, PreparedStatement를 처리하는 일부분은 생략되어 있다.

  • JDBC에서 트랜잭션을 시작하려면 자동커밋 옵션을 false로 만들어주면 된다. 그러면 새로운 트랜잭션이 시작되게 만들 수 있다.
    • JDBC의 기본 설정은 DB 작업을 수행한 직후에 자동으로 커밋이 되도록 되어 있다.
      작업마다 커밋해서 트랜잭션을 끝내버리므로 여러 개의 DB 작업을 모아서 트랜잭션을 만드는 기능이 꺼져 있는 것이다.
  • 트랜잭션이 한 번 시작되면 commit() 또는 rollback() 메서드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다.
    • commit() 또는 rollback()이 호출되면 그에 따라 작업 결과가 DB에 반영되거나 취소되고 트랜잭션이 종료된다.
    • 일반적으로 작업 중 예외가 발생하면 트랜잭션을 롤백한다.
  • 이렇게 setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정이라고 한다.
    • 트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션이라고도 한다.
    • 즉, JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에서 일어난다. 트랜잭션의 시작과 종료는 Connection 오브젝트를 통해 이뤄지기 때문이다.

2.2.2 UserServiceUserDao의 트랜잭션 문제

❓ 왜 UserServiceupgradeLevels()에는 트랜잭션이 적용되지 않았을까?

만든 코드 어디에도 트랜잭션을 시작하고, 커밋하고, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않는다.
JdbcTemplate을 사용하기 때문에 Connection 오브젝트가 코드에서 없어졌다.
JdbcTemplate 하나의 템플릿 메서드 안에서 DataSourcegetCommection() 메서드를 호출해서 Connection 오브젝트를 가져오고, 작업을 마치면 Connection을 확실하게 닫아주고 템플릿 메서드를 빠져나온다. 결국 템플릿 메서드 호출 한 번에 한 개의 DB 커넥션이 만들어지고 닫히는 것이다. 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧다. 따라서 템플릿 메서드가 호출될 때마다 트랜잭션이 새로 만들어지고 메서드를 빠져나오기 전에 종료된다. 결국 JdbcTemplate의 메서드를 사용하는 UserDao는 각 메서드마다 하나씩의 독립적인 트랜잭션으로 실행될 수밖에 없다.

upgradeAllOrNothing() 테스트 메서드에서 2번째 사용자 오브젝트에서 UserDaoupdate() 메서드를 호출한다. JdbcTemplate을 사용하는 update() 메서드는 자동으로 UPDATE 작업의 트랜잭션을 종료시킬 것이고, 수정 결과는 영구적으로 DB에 반영된다. 트랜잭션 작업은 내구성을 보장받기 때문에 일단 커밋되고 나면 DB 서버가 다운되더라도 그 결과는 DB에 그대로 남는다.

  • UserServiceUserDao를 통해 트랜잭션이 일어나는 과정
    • UserDaoJdbcTemplate을 통해 매번 새로운 DB 커넥션과 트랜잭션을 만들어 사용한다.
    • 첫 번째 update()를 호출할 때 작업이 성공했다면 그 결과는 이미 트랜잭션이 종료되면서 커밋됐기 때문에 두 번째 update()를 호출하는 시점에서 오류가 발생해서 작업이 중단된다고 해도 첫 번째 커밋한 트랜잭션의 결과는 DB에 그대로 남는다.

데이터 액세스 코드를 DAO로 만들어서 분리해놓았을 경우엔 이처럼 DAO 메서드를 호출할 때마다 하나의 새로운 트랜잭션이 만들어지는 구조가 될 수밖에 없다. DAO 메서드에서 DB 커넥션을 매번 만들기 때문이다. 결국 DAO를 사용하면 비즈니스 로직을 담고 있는 UserService 내에서 진행되는 여러 가지 작업을 하나의 트랜잭션으로 묶는 일이 불가능해진다.

❗️ 일련의 작업이 하나의 트랜잭션으로 묶이려면 그 작업이 진행되는 동안 DB 커넥션도 하나만 사용돼야 한다. 트랜잭션은 Connection 오브젝트 안에서 만들어지기 때문이다. 즉, 같은 Connection을 사용해야 같은 트랜잭션 안에서 동작한다.

2.2.3 비즈니스 로직 내의 트랜잭션 경계설정

두 가지 해결 방법

  1. (비효율) DAO 메서드 안으로 upgradeLevels() 메서드의 내용을 옮기자.

    • DAO 안에서 JDBC API를 직접 이용한다면 하나의 DB 커넥션과 트랜잭션을 만들어놓고 여러 명의 사용자에 대한 정보를 업데이트 할 수 있다.
    • 하지만 이 방식은 비즈니스 로직과 데이터 로직을 한데 묶어버리게 된다.
  2. UserDao가 가진 SQL이나 JDBC API를 이용한 데이터 액세스 코드는 최대한 그대로 남겨둔 채로, UserService에는 트랜잭션 시작과 종료를 담당하는 최소한의 코드만 가져오게 하자.

    • UserServiceUserDao를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다. 프로그램의 흐름을 볼 때 upgradeLevels() 메서드의 시작과 함께 트랜잭션이 시작하고 메서드를 빠져나올 때 트랜잭션이 종료돼야 하기 때문이다.

    • 트랜잭션 경계를 upgradeLevels() 메서드 안에 두려면 DB 커넥션도 이 메서드 안에서 만들고, 종료시킬 필요가 있다.

      • 트랜잭션을 사용하는 전형적인 JDBC 코드의 구조다.
      • 여기서 생성된 Connection 오브젝트를 가지고 데이터 액세스 작업을 진행하는 코드는 UserDaoupdate() 메서드 안에 있어야 한다. 순수한 데이터 액세스 로직은 UserDao에 둬야 하기 때문이다.
      • UserDaoupdate() 메서드는 반드시 upgradeLevels() 메서드에서 만든 Connection을 사용해야 한다. 그럴려면 DAO 메서드를 호출할 때마다 Connection 오브젝트를 파라미터로 전달해줘야 한다.
      • UserService에서 UserDaoupdate()를 직접 호출하는 건 upgradeLevels()가 아닌 upgradeLevel() 메서드이다. 결국 UserService의 메서드 사이에도 같은 Connection 오브젝트를 사용하도록 파라미터로 전달해줘야 한다.

upgradeLevels() 메서드 안에서 트랜잭션의 경계설정 작업이 일어나야 하고, 그 트랜잭션을 갖고 있는 DB 커넥션을 이용하도록 해야만 별도의 클래스에 만들어둔 DAO 내의 코드도 트랜잭션이 적용될 테니 이 방법을 사용할 수밖에 없다.

2.2.4 UserService 트랜잭션 경계설정의 문제점

UserServiceUserDao를 이런 식으로 수정하면 트랜잭션 문제는 해결할 수 있지만 다른 문제가 생긴다.

  1. DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다.

    결국 JDBC API를 직접 사용해야 한다. try/catch/finally 블록은 이제 UserService 내에 존재하고, UserService의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수밖에 없다.

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

    UserService는 스프링 빈으로 선언해서 싱글톤으로 되어 있으니 Connection을 인스턴스 변수에 저장해뒀다 다른 메서드에서 사용하게 할 수도 없다. 멀티스레드 환경에서는 공유하는 인스턴스 변수에 스레드별로 생성하는 정보를 저장하다가는 서로 덮어쓰는 일이 발생하기 때문이다.

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

    JPA나 Hibernate로 UserDao의 구현 방식을 변경하려고 하면 Connection 대신 EntityManagerSession 오브젝트를 UserDao 메서드가 전달받도록 해야 한다. 결국 UserDao 인터페이스는 바뀔 것이고, 그에 따라 UserService 코드도 함께 수정돼야 한다. 인터페이스를 사용해 DAO를 분리라고 DI를 적용했던 게 소용없어진다.

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

    테스트 코드에서 직접 Connection 오브젝트를 일일이 만들어서 DAO 메서드를 호출하도록 모두 변경해야 한다. 테스트에서 UserDao를 사용할 때 DB 커넥션까지 신경써야 한다.

2.3 트랜잭션 동기화

비즈니스 로직을 담고 있는 UserService 메서드 안에서 트랜잭션의 경계를 설정해 관리하기 위해서, 스프링은 위 문제들을 해결할 수 있는 방법을 제공한다.

2.3.1 Connection 파라미터 제거

upgradeLevels() 메서드가 트랜잭션 경계설정을 해야 한다. 하지만 여기서 생성된 Connection 오브젝트를 계속 메서드의 파라미터로 전달하다가 DAO를 호출할 때 사용하게 하는 것을 피하고 싶다.

이를 위해 스프링은 독립적인 트랜잭션 동기화 방식을 제안한다. 트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, DAO의 메서드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다. 정확히는 DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다. 그리고 트랜잭션이 모두 종료되면, 그때는 동기화를 마치면 된다.

트랜잭션 동기화를 사용한 경우의 작업 흐름

(1) UserServiceConnection을 생성하고 (2) 이를 트랜잭션 동기화 저장소에 저장해두고 ConnectionsetAutoCommit(false)를 호출해 트랜잭션을 시작시킨 후에 본격적으로 DAO의 기능을 이용하기 시작한다.
(3) 첫 번째 update() 메서드가 호출되고, update() 메서드 내부에서 이용하는 JdbcTemplate 메서드에서는 가장 먼저 (4) 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인한다.
(2) upgradeLevels() 메서드 시작 부분에서 저장해둔 Connection을 발견하고 이를 가져온다. 가져온 (5) Connection을 이용해 PreparedStatement를 만들어 수정 SQL을 실행한다. 트랜잭션 동기화 저장소에서 DB 커넥션을 가져왔을 때는 JdbcTemplateConnection을 닫지 않은 채로 작업을 마친다. 이렇게 해서 트랜잭션 안에서 첫 번째 DB 작업을 마쳤다. 여전히 Connection은 열려 있고 트랜잭션은 진행 중인 채로 트랜잭션 동기화 저장소에 저장되어 있다.
(6) 두 번째 update()가 호출되면 이때도 마찬가지로 (7) 트랜잭션 동기화 저장소에서 Connection을 가져와 (8) 사용한다.
(9) 마지막 update()도 (10) 같은 트랜잭션을 가진 Connection을 가져와 (11) 사용한다.

트랜잭션 내의 모든 작업이 정상적으로 끝났으면 UserService는 이제 (12) Connectioncommit()을 호출해서 트랜잭션을 완료시킨다.
마지막으로 (13) 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 이를 제거한다. 어느 작업 중에라도 예외상황이 발생하면 UserService는 즉시 Connecitonrollback()을 호출하고 트랜잭션을 종료할 수 있다. 물론 이때도 트랜잭션 저장소에 저장된 동기화된 Connection 오브젝트는 제거해줘야 한다.

이렇게 트랜잭션 동기화 기법을 사용하면 트랜잭션의 경계설정이 필요한 upgradeLevels()에서만 Connection을 다루게 하고, 여기서 생성된 Connection과 트랜잭션을 DAO의 JdbcTemplate이 사용할 수 있도록 별도의 저장소에 동기화하는 방법을 적용하기만 하면 된다.

2.3.2 트랜잭션 동기화 적용

문제는 멀티스레드 환경에서도 안전한 트랜잭션 동기화 방법을 구현하는 일이 기술적으로 간단하지 않다.
스프링은 JdbcTemplate과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메서드를 제공하고 있다.

  • UserService에서 DB 커넥션을 직접 다룰 때 DataSource가 필요하므로 DataSource 빈에 대한 DI 설정을 해둬야 한다.

  • TransactionSynchronizationManager : 스프링이 제공하는 트랜잭션 동기화 관리 클래스이다.

  • 트랜잭션 동기화 적용 과정

    1. TransactionSynchronizationManager 클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다.
    2. DataSourceUtils에서 제공하는 getConnection() 메서드를 통해 DB 커넥션을 생성한다.
      DataSource에서 Connection을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메서드를 쓰는 이유는 이 DataSourceUtilsgetConnection() 메서드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.
    3. 동기화 준비가 됐으면 트랜잭션을 시작하고 DAO의 메서드를 사용하는 트랜잭션 내의 작업을 진행한다.
      트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다. 결국 UserDao를 통해 진행되는 모든 JDBC 작업은 upgradeLevels() 메서드에서 만든 Connection 오브젝트를 사용하고 같은 트랜잭션에 참여하게 된다.
    4. 작업을 정상적으로 마치면 트랜잭션을 커밋해준다.
    5. 스프링 유틸리티 메서드의 도움을 받아 커넥션을 닫고 트랜잭션 동기화를 마치도록 요청한다. 만약 예외가 발생하면 트랜잭션을 롤백해준다.
      이때도 DB 커넥션을 닫는 것과 동기화 작업 중단은 동일하게 진행해야 한다.

JDBC의 트랜잭션 경계설정 메서드를 사용해 트랜잭션을 이용하는 전형적인 코드에 간단한 트랜잭션 동기화 작업을 붙여 Connection 파라미터 문제를 해결했다.

2.3.3 트랜잭션 테스트 보완

트랜잭션이 적용됐는지 테스트를 해보자.

고려 사항

  • UserServiceTestupgradeAllOrNothing() 테스트 메서드에 dataSource 빈을 가져와 주입해주는 코드를 추가한다.
    테스트용으로 확장해서 만든 TestUserServiceUserService의 서브클래스이므로 UserService와 마찬가지로 트랜잭션 동기화에 필요한 DataSource를 DI 해줘야 하기 때문이다.

  • 나머지 테스트의 정상 작동을 위해 UserServicedataSource 프로퍼티 설정을 설정파일에 추가해줘야 한다.
    TestUserService를 직접 구성하는 upgradeAllOrNothing() 테스트와는 달리, upgradeLevels() 테스트는 스프링 컨테이너가 초기화한 userService를 사용해야 하기 때문이다.

2.3.4 JdbcTemplate과 트랜잭션 동기화

JdbcTemplateupdate()query() 같은 JDBC 작업의 템플릿 메서드를 호출하면 직접 Connection을 생성하고 종료하는 일을 모두 담당한다. 테스트에서 특별한 준비 없이 DAO의 메서드를 직접 사용했을 때도 제대로 동작하는 것을 보면 스스로 Connection을 생성해서 사용한다는 사실을 알 수 있다.

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

따라서 DAO를 사용할 때 트랜잭션이 굳이 필요 없다면 바로 호출해서 사용해도 되고, DAO 외부에서 트랜잭션을 만들고 이를 관리할 필요가 있다면 미리 DB 커넥션을 생성한 다음 트랜잭션 동기화를 해주고 사용하면 된다. 트랜잭션 동기화를 해주고 나면 DAO에서 사용하는 JdbcTemplate은 자동으로 트랜잭션 안에서 동작할 것이다.

2.4 트랜잭션 서비스 추상화

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

지금까지 만든 코드는 DB 연결 방법이 바뀌어도 UserDaoUserService 코드는 수정하지 않아도 된다. DataSource 인터페이스와 DI를 적용한 덕분이다.

요구 사항

  • G 사는 여러 개의 DB를 사용하고 있다. 그래서 하나의 트랜잭션 안에서 여러 개의 DB에 데이터를 넣는 작업을 해야한다.
    한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션으로는 불가능하다. 왜냐하면 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문이다.

고려 사항

  • 여기선 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다.
    글로벌 트랜잭션을 적용하면 트랜잭션 매니저를 통해 하나 이상의 DB가 참여하는 작업을 하나의 트랜잭션으로 만들 수 있다. 또 JMS와 같은 트랜잭션 기능을 지원하는 서비스도 트랜잭션에 참여시킬 수 있다.

    👉 JTA(Java Transaction API) : 자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위해 제공하는 API이다.

  • G 사의 요청은 서버가 제공하는 트랜잭션 매니저와 트랜잭션 서비스를 사용할 테니 JTA를 사용해 트랜잭션을 관리하게 해달라는 것이다.

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

  • 애플리케이션은 기존의 방법대로 DB는 JDBC, 메시징 서버라면 JMS 같은 API를 사용해서 필요한 작업을 수행한다.

  • 단, 트랜잭션은 JDBC나 JMS API를 사용해서 직접 제어하지 않고 JTA를 통해 트랜잭션 매니저가 관리하도록 위임한다.

  • 트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다.
    이를 통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 되는 것이다.

  • 이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.

  • JTA를 이용한 트랜잭션 처리 코드의 전형적인 구조

트랜잭션 경계설정을 위한 코드의 구조는 JDBC를 사용했을 때와 비슷하다.

두 가지 문제

  1. JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야 한다. 로컬 트랜잭션과 글로벌 트랜잭션 중에 필요로 하는 것에 따라 트랜잭션 관리 코드를 적용해야 한다. UserService는 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀐다.

  2. Y사에서 Hibernate를 이용해 UserDao를 직접 구현했다고 알려왔다. 그런데 문제는 Hiberenate를 이용한 트랜잭션 관리 코드는 JDBC나 JTA의 코드와는 또 다르다는 것이다. Hibernate는 Connection을 직접 사용하지 않고 Session을 사용하고, 독자적인 트랜잭션 관리 API를 사용한다. 그렇다면 이번엔 UserService를 Hibernate의 SessionTransaction 오브젝트를 사용하는 트랜잭션 경계설정 코드로 변경할 수 밖에 없다.

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

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

문제

  • UserService에 트랜잭션 경계설정 코드를 도입한 후에 클래스의 의존관계
    • 원래 UserServiceUserDao 인터페이스에만 의존하는 구조였다. 하지만 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의 종류에 상관없이 일관된 방법으로 데이터 액세스 코드를 작성할 수가 있다.

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

스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다.

  1. PlatformTransactionManager을 생성한다.

    • PlatformTransactionManager : 스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스
    • JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 된다. 사용할 DB의 DataSource를 생성자 파라미터로 넣으면서 DataSourceTransactionManager의 오브젝트를 만든다.
  2. 트랜잭션을 시작한다.

    • JDBC를 이용하는 경우엔 먼저 Connection을 생성하고 나서 트랜잭션을 시작했지만, DataSourceTransactionManager은 트랜잭션을 가져오는 요청인 getTransaction() 메서드를 호출하기만 하면 된다.
      • 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 같이 수행해주기 때문이다. 여기서 트랜잭션을 가져온다는 것은 일단 트랜잭션을 시작한다는 의미라고 생각하자.
      • 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트를 트랜잭션에 대한 속성을 담고 있다.
    • 이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다. TransactionStatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager 메서드의 파라미터로 전달해주면 된다.
  3. 트랜잭션이 시작됐으니 이제 JdbcTemplate을 사용하는 DAO를 이용하는 작업을 진행한다.

    • 스프링의 트랜잭션 추상화 기술은 앞에서 적용해봤던 트랜잭션 동기화 저장소에 저장된다.
    • PlatformTransactionManager로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장된다.
    • PlatformTransactionManager를 구현한 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다. 따라서 PlatformTransactionManager를 통해 시작한 트랜잭션은 UserDaoJdbcTemplate 안에서 사용된다.
  4. 트랜잭션 작업을 모두 수행한 후엔 트랜잭션을 만들 때 돌려받은 TransactionStatus 오브젝트를 파라미터로 해서 PlatformTransactionManagercommit() 메서드를 호출하면 된다. 예외가 발생하면 rollback() 메서드를 부른다.

2.4.4 트랜잭션 기술 설정의 분리

트랜잭션 추상화 API를 적용한 UserService 코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면,
PlatformTransactionManager 구현 클래스를 JTATransactionManager로 바꿔주기만 하면 된다.
Hibernate로 UserDao를 구현했다면 HibernateTransactionManager를 사용하면 된다.

JTATransactionManager는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 갖고 있다. 따라서 별다른 설정 없이 JTATransactionManager를 사용하기만 해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작한다.

변경 사항

  • UserServicePlatformTransactionManager 인터페이스 타입의 인스턴스 변수와 수정자 메서드를 추가해서 DI 가능하게 해준다.
    어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI 원칙에 위배된다. 컨테이너를 통해 외부에서 제공받게 하는 스프링 DI의 방식으로 바꾸자.

어떤 클래스든 스프링 빈으로 등록할 때 검토해야 할 것은 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은가 하는 점이다. 스프링이 제공하는 모든 PlatformTransactionManager의 구현 클래스는 싱글톤으로 사용이 가능하기 때문에 스프링의 싱글톤 빈으로 등록해도 좋다.

수정자 메서드를 추가해서 DI가 가능하게 해준다. 일반적으로는 인터페이스 이름과 변수 이름, 수정자 메서드 이름을 모두 같은 것으로 통일하지만, PlatformTransactionManager의 경우는 관례적으로 transactionManager라는 이름을 사용한다.

  • UserServiceDataSource 변수와 수정자 메서드는 제거한다.

    UserService는 이제 PlatformTransactionManager만으로도 Connection 생성과 트랜잭션 경계설정 기능을 모두 이용할 수 있기 때문이다.

  • JDBC 기반의 단일 DB를 사용하는 트랜잭션을 사용하기 위해 DataSourceTransactionManager 클래스를 사용하면 된다. 스프링 설정파일을 수정한다.

    • DataSourceTransactionManagerdataSource 빈으로부터 Connection을 가져와 트랜잭션 처리를 해야 하기 때문에 dataSource 프로퍼티를 갖는다.
    • userService 빈도 기존의 dataSource 프로퍼티를 없애고 새롭게 추가한 transactionManager 빈을 DI 받도록 프로퍼티를 설정한다.
  • 테스트도 수정한다.
    트랜잭션 예외상황을 테스트하기 위해 수동 DI를 하는 upgradeAllOrNothing() 메서드에서 스프링 컨테이너로부터 transactionManager 빈을 @Autowired로 주입받게 하고 이를 직접 DI 해주도록 변경한다.

이제 UserService는 트랜잭션 기술에서 완전히 독립적인 코드가 됐다. 트랜잭션을 JTA를 이용하고 싶다면 설정파일의 transationManager 빈의 설정만 수정하면 된다. 또한 DAO를 Hibernate나 JPA, JPO 등을 사용하도록 수정했대도 그에 맞게 transactionManager의 클래스만 변경해주면 된다.

📌 JtaTransactionManager는 애플리케이션 서버의 트랜잭션 서비스를 이용하기 때문에 직접 DataSource와 연동할 필요는 없다. 대신 JTA를 사용하는 경우는 DataSource도 서버가 제공해주는 것을 사용해야 한다.

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

이제 스프링의 트랜잭션 서비스 추상화 기법을 이용한 다양한 트랜잭션 기술을 일관된 방식으로 제어할 수 있게 됐다. 사용자 관리의 핵심 코드 소스는 공개하지 않고, 설정을 고치는 것만으로 DB 연결 기술, 데이터 액세스 기술, 트랜잭션 기술을 자유롭게 바꿔서 사용할 수 있다.

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

UserDaoUserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 독자적으로 확장이 가능하도록 만든 것이다. 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리했다. 같은 계층에서 수평적인 분리라고 볼 수 있다.

트랜잭션의 추상화는 수직적인 분리다. 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.

지금까지의 사용자 관리 모듈의 의존관계

  • UserService, UserDao : 애플리케이션의 로직을 담고 있는 애플리케이션 계층
    • UserService : 순수하게 사용자 관리의 업무의 비즈니스 로직
    • UserDao : 데이터를 어떻게 가져오고 등록할 것인가에 대한 데이터 액세스 로직
    • UserDaoUserService는 인터페이스와 DI를 통해 연결됨으로써 결합도가 낮다.
    • UserDao와 DB 연결 기술도 결합도가 낮다.
      DataSource 인터페이스와 DI를 통해 추상화된 방식으로 로우레벨의 DB 연결 기술을 사용하기 때문이다. 그래서 UserDao는 DB 연결을 생성하는 방법에 대해 독립적이다.
    • UserService와 트랜잭션 기술과도 독립적이다.
      스프링이 제공하는 PlatformTransactionManager 인터페이스를 통한 추상화 계층을 사이에 두고 사용하게 했기 때문에, 구체적인 트랜잭션 기술에 독립적인 코드가 됐다.
    • UserDao와 DB 연결 기술, UserService와 트랜잭션 기술의 결합도가 낮은 분리는 애플리케이션 코드를 로우레벨의 기술 서비스와 환경에서 독립시켜준다.
  • 애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮은 데는 스프링의 DI가 중요한 역할을 하고 있다. DI의 가치는 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.

📌 결합도가 낮다는 건 서로 영향을 주지 않고 자유롭게 독립적으로 확장될 수 있는 구조라는 뜻이다.

3.2 단일 책임 원칙

👉 단일 책임 원칙
하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다는 것이기도 하다.

이런 적절한 분리가 가져오는 특징은 단일 책임 원칙으로 설명할 수 있다.

  • (이전) UserService에 JDBC Connection의 메서드를 직접 사용하는 트랜잭션 코드가 들어있었을 때 UserService는 어떻게 사용자 레벨을 관리할 것인가와 어떻게 트랜잭션을 관리할 것인가라는 두 가지 책임을 갖고 있었다. 두 가지 책임을 갖고 있다는 건 UserService 코드가 변경되는 이유가 두 가지라는 뜻이다. 사용자 관리 로직이 바뀌든 트랜잭션 기술이 바뀌든 UserService 코드를 수정해야 한다.

    -> 결국 단일 책임 원칙을 지키지 못하는 것이다.

  • (이후) 트랜잭션 서비스의 추상화 방식을 도입하고, 이를 DI를 통해 외부에서 제어하도록 수정했을 때 UserService가 바뀔 이유는 한 가지뿐이다. 사용자 관리 로직이 바뀌거나 추가되지 않는 한 코드를 수정할 일이 없다.

    -> 따라서 단일 책임 원칙을 지키고 있다.

3.3 단일 책임 원칙의 장점

어떤 변경이 필요할 때 수정 대상이 명확해진다.

서비스 하나가 여러 개의 DAO를 사용하는 경우가 많아지면 의존관계가 매우 복잡해진다. 이 때 DAO를 하나 수정할 경우 그에 의존하고 있는 서비스 클래스도 같이 수정해야 하는 구조면 수백 개의 클래스를 다 같이 수정해줘야 한다.

기술적인 수정사항도 마찬가지다. 애플리케이션 계층의 코드가 특정 기술에 종속돼서 기술이 바뀔 때마다 코드의 수정이 필요하면 아마 그에 따라 엄청난 코드를 수정해야 할 것이다. 많은 코드를 수정하면 그만큼 실수가 일어날 확률이 놓고, 치명적인 버그도 도입될 가능성이 있다.

그래서 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업이 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI이다.

객체지향 설계와 프로그래밍의 원칙은 서로 긴밀하게 관련이 있다. 단일 책임 원칙을 잘 지키는 코드를 만들려면 인터페이스를 도입하고 이를 DI로 연결해야 하며, 그 결과로 단일 책임 원칙뿐 아니라 개방 폐쇄 원칙도 잘 지키고, 모듈 간에 결합도가 낮아서 서로의 변경이 영향을 주지 않고, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드가 나오기 때문이다. 또한 이런 과정에서 전략 패턴, 어댑터 패턴, 브리지 패턴 등 많은 디자인 패턴이 자연스럽게 적용되기도 한다. 객체지향 설계 원칙을 잘 지켜서 만든 코드는 테스트하기도 편하다. 스프링이 지원하는 DI와 싱글톤 레지스트리 덕분에 더욱 편리하게 자동화된 테스트를 만들 수 있다.

4. 메일 서비스 추상화

추가 사항

  • 레벨이 업그레이드 되는 사용자에게는 안내 메일을 발송한다.

고려 사항

  • 안내 메일을 발송하기 위해 해야할 일
    • 사용자의 이메일 정보를 관리해야 한다. Useremail 필드를 추가하면 된다.
    • 업그레이드 작업을 담은 UserServiceupgradeLevel() 메서드에 메일 발송 기능을 추가한다.

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

사용자 정보에 이메일을 추가하는 일은 레벨을 추가했을 때와 동일하게 진행하면 된다.

고려 사항

  • DB의 USER 테이블에 email 필드를 추가한다.
  • User 클래스에 email 프로퍼티를 추가한다.
    • 그에 따라 UserDaouserMapperinsert(), update()email 필드 처리 코드를 추가한다.
    • UserDaoTest도 수정한다.

4.1.1 JavaMail 메일 발송

고려 사항

  • 자바에서 메일을 발송할 때는 표준 기술인 JavaMail을 사용하면 된다. javax.mail 패키지에서 제공하는 자바의 이메일 클래스를 사용한다.
  • upgradeLevel()에서 메일 발송 메서드를 호출한다.
  • JavaMail API를 사용하는 메서드를 추가한다.

  • JavaMail을 이용해 메일을 발송하는 가장 전형적인 코드이다.
  • SMTP 프로토콜을 지원하는 메일 전송 서버가 준비되어 있다면, 코드는 정상적으로 동작할 것이다.

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

문제

테스트를 실행했는데 만약 메일 서버가 준비되어 있지 않았다면, MessagingException 예외가 발생하면서 실패한다. 메일 서버가 현재 연결 가능하도록 준비되어 있지 않기 때문이다.

그런데 테스트가 수행될 때 매번 메일이 발송되는 건 바람직하지 않다. 메일 발송은 매우 부하가 큰 작업이기 때문이다. 그것도 실제 운영 중인 메일 서버를 통해 테스트를 실행할 때마다 메일을 보내면 메일 서버에 상당한 부담을 줄 수 있다. 게다가 메일이 실제로 발송돼버린다는 문제도 있다.

해결

  1. 테스트 때는 메일 서버 설정을 다르게 해서 테스트용으로 메일 서버를 이용하는 방법이 있다.
    운영 중인 메일 서버에 부하를 주진 않게 된다. 하지만 메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과하다. 즉, 중요하지 않다.

  2. 실제 메일 전송을 수행하는 JavaMail 대신에 테스트에서 사용할, JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들어서 사용한다. 즉, JavaMail API를 사용하지 않는 테스트용 오브젝트로 대체한다.

메일 서버 테스트란 엄밀히 말해서 불가능하다. 기껏해야 메일 발송용 서버에 전달됐음을 확인할 뿐이지, 메일이 정말 잘 도착했는지 확인이 힘들기 때문이다. 하지만 메일 서버는 충분히 테스트된 시스템이라 메일 서버까지만 잘 전달됐으면, 메일이 잘 전송됐다고 믿어도 된다. 따라서 메일 테스트를 한다고 매번 메일 수신 여부를 일일이 확인할 필요는 없고, 테스트 가능한 메일 서버까지만 잘 전송되는지 확인하면 된다. 그리고 테스트용 메일 서버는 메일 전송 요청은 받지만 실제로 메일이 발송되지 않도록 설정해주면 된다.

실제 메일 서버를 사용하지 않고 테스트 메일 서버를 이용해 테스트 하는 방법을 보여준다. 점선 안이 테스트가 동작하는 범위다. 테스트 메일 서버는 외부로 메일을 발송하지 않고, 단지 JavaMail과 연동해서 메일 전송 요청을 받는 것까지만 담당한다. 결국 이 테스트용으로 준비한 메일 서버는 업그레이드 작업 시 테스트에서 메일 전송 관련 예외가 발생하지 않고 테스트를 마치게 해주는 역할을 맡을 뿐이다.

JavaMail은 자바의 표준 기술이고 이미 검증된 안정적인 모듈이다. 따라서 JavaMail API를 통해 요청이 들어간다는 보장만 있다면 굳이 테스트할 때마다 JavaMail을 직접 구동시킬 필요가 없다.

운영 시에는 JavaMail을 직접 이용해서 동작하도록 해야겠지만, 개발 중이거나 테스트를 수행할 때는 JavaMail을 대신할 수 있는, 그러나 JavaMail을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 만들어도 될 것이다.

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

4.3.1 JavaMail을 이용한 테스트의 문제점

  1. 이 방법의 문제는 JavaMail의 API는 이 방법을 적용할 수 없다는 점이다.

    • JavaMail의 핵심 API는 DataSource처럼 인터페이스로 만들어져서 구현을 바꿀 수 있는 게 없다.
    • JavaMail에선 Session 오브젝트를 만들어야만 메일 메시지를 생성할 수 있고 메일을 전송할 수 있다. 그런데 Sessionfinal 클래스여서 상속이 불가능하고, 생성자가 모두 private으로 되어 있어 직접 생성도 불가능하다.

    결국 JavaMail의 구현을 테스트용으로 바꿔치기하는 건 불가능하다고 볼 수 있다.

  2. 서비스 추상화를 적용해 JavaMail처럼 테스트하기 힘든 구조인 API를 테스트하자.

    스프링은 JavaMail을 사용해 만든 코드의 테스트를 쉽게 하도록 하기 위해서 JavaMail에 대한 추상화 기능을 제공하고 있다.

4.3.2 메일 발송 기능 추상화

  • MailSender : 스프링이 제공하는 메일 서비스 추상화의 핵심 인터페이스
    • 이 인터페이스는 SimpleMailMessage라는 인터페이스를 구현한 클래스에 담긴 메일 메시지를 전송하는 메서드로만 구성되어 있다.
  • 기본적으로는 JavaMail을 사용해 메일 발송 기능을 제공하는 JavaMailSenderImpl 클래스를 이용하면 된다.
    • JavaMailSenderImpl은 내부적으로 JavaMail API를 이용해 메일을 전송해준다.
  • 스프링이 제공하는 JavaMailSender 구현 클래스를 사용해서 만든 메일 발송용 코드이다.
    • 메시지는 SimpleMailMessage를 사용했고, 메일 전송 오브젝트는 JavaMailSenderImpl의 오브젝트를 만들어 사용했다.
    • 스프링의 예외처리 원칙에 따라서 JavaMail을 처리하는 중에 발생한 각종 예외를 MailException이라는 런타임 예외로 포장해서 던져주기 때문에 try/cath 블록을 만들지 않아도 된다.
    • MailMessage 타입의 SimpleMailMessage 오브젝트를 만들어서 메시지를 넣은 뒤에 JavaMailSender 타입 오브젝트의 send() 메서드에 전달해주면 된다.

개선 사항

  • JavaMail API를 사용하는 JavaMailSenderImpl 클래스의 오브젝트를 코드에서 직접 사용한다.
    • 스프링의 DI를 적용한다. sendUpgradeEmail() 메서드에 JavaMailSenderImpl 클래스가 구현한 MailSender 인터페이스만 남기고, 구체적인 메일 전송 구현을 담은 클래스의 정보는 코드에서 모두 제거한다.
    • 메일 발송 호스트를 설정하는 코드 또한 제거한다.
    • UserServiceMailSender 인터페이스 타입의 변수를 만들고 수정자 메서드를 추가해 DI가 가능하도록 만든다.
    • 설정파일 안에 JavaMailSenderImpl 클래스로 빈을 만들고 UserService에 DI 해준다.
      • 스프링 빈으로 등록되는 MailSender의 구현 클래스들은 싱글톤으로 사용 가능해야 한다.
      • mailSender 빈의 host 프로퍼티에는 메일 서버를 지정해준다.

이제 테스트를 실행하면 JavaMail API를 직접 사용했을 때와 동일하게 지정된 메일 서버로 메일이 발송된다.
JavaMail을 사용하지 않고, 메일 발송 기능이 포함된 코드를 테스트하게 됐다. 이를 위해 메일 전송 기능을 추상화해서 인터페이스를 적용하고 DI를 통해 빈으로 분리해놨다.

4.3.3 테스트용 메일 발송 오브젝트

고려 사항

  • 스프링이 제공한 메일 전송 기능에 대한 MailSender 인터페이스가 있으니 이를 구현해서 테스트용 메일 전송 클래스를 만든다.
    • 테스트가 수행될 때는 JavaMail을 사용해서 메일을 전송할 필요가 없으니, 그냥 아무것도 하지 않는 MailSender 구현 빈 클래스인 DummyMailSender를 만들면 된다.
  • 테스트 설정파일의 mailSender 빈 클래스를 JavaMail을 사용하는 JavaMailSenderImpl 대신 DummyMailSender로 변경한다.
  • UserService에 새로운 DI용 프로퍼티가 추가됐으니 수동 DI 방식을 사용한 upgradeAllOrNothing() 메서드에도 mailSender를 추가해준다.

4.3.4 테스트와 서비스 추상화

스프링이 제공하는 MailSender 인터페이스를 핵심으로 하는 메일 전송 서비스 추상화의 구조다.
일반적으로 서비스 추상화라고 하면 트랜잭션과 같이 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 접근 방법을 제공해주는 것을 말한다. 반면에 JavaMail의 경우처럼 테스트를 어렵게 만드는 건전하지 않은 방식으로 설계된 API를 사용할 때도 유용하게 쓰일 수 있다.

JavaMailSenderImpl의 장점

스프링이 직접 제공하는 MailSender를 구현한 추상화 클래스는 JavaMailServiceImpl 하나뿐이다. 하나뿐이지만 이 추상화된 메일 전송 기능을 사용해 애플리케이션을 작성함으로써 얻을 수 있는 장점은 크다.

  • JavaMail이 아닌 다른 메시징 서버의 API를 이용해 메일을 전송해야 하는 경우가 생겨도, 해당 기술의 API를 이용하는 MailSender 구현 클래스를 만들어서 DI 해주면 된다.
  • 메일을 바로 전송하지 않고 작업 큐에 담아뒀다가 정해진 시간에 메일을 발송하는 기능을 만들기도 쉽다.
    MailSender 인터페이스를 구현한, 메일 발송 큐의 구현을 하나 만들어 두고 다시 DI를 통해 JavaMailServiceImpl 같은 실제 메일 발송용 오브젝트를 연결해서 사용할 수 있다.

현재 코드의 문제점과 해결방안

  • 메일 발송 작업에 트랜잭션 개념을 적용해야 한다.
    레벨 업그레이드 작업 중간에 예외가 발생해서 DB에 반영했던 레벨 업그레이드가 모두 롤백됐다고 하자. 그런데 메일은 사용자별로 업그레이드 처리를 할 때 이미 발송해버렸다면 어떻게 취소할 것인가?
  • 두 가지 해결방안
    • 메일을 업그레이드할 사용자를 발견했을 때마다 발송하지 않고 발송 대상을 별도의 목록에 저장해두는 것이다. 그리고 업그레이드 작업이 모두 성공적으로 끝났을 때 한 번에 메일을 전송하면 된다.
      • 단점은 메일 저장용 리스트 등을 파라미터로 계속 갖고 다녀야 한다는 점이다.
    • MailSender를 확장해서 메일 전송에 트랜잭션 개념을 적용하는 것이다.
      MailSender를 구현한 트랜잭션 기능이 있는 메일 전송용 클래스를 만든다. 이 오브젝트에 업그레이드 작업 이전에 새로운 메일 전송 작업 시작을 알려주고, 그 때부터는 mailSender.send() 메서드를 호출해도 실제로 메일을 발송하지 않고 저장해둔다. 그리고 업그레이드 작업이 끝나면 트랜잭션 기능을 가진 MailSender에 지금까지 저장된 메일을 모두 발송하고, 예외가 발생하면 모두 취소하게 할 수 있다.
    • 전자는 사용자 관리 비즈니스 로직과 메일 발송에 트랜잭션 개념을 적용하는 기술적인 부분이 한데 섞이게 한다면, MailSender의 구현 클래스를 이용하는 방법은 서로 다른 종류의 작업을 분리해 처리한다는 면에서 장점이 있다.

📌 서비스 추상화의 도입
기술이나 환경이 바뀔 가능성이 있음에도, JavaMail처럼 확장이 불가능하게 설계해놓은 API를 사용해야 하는 경우라면 추상화 계층의 도입을 적극 고려해볼 필요가 있다. 특별히 외부의 리소스와 연동하는 대부분 작업은 추상화의 대상이 될 수 있다.

4.4 테스트 대역

DummyMailSender 클래스는 MailSender 인터페이스를 구현해놨을 뿐 아무것도 하는 일이 없다. 하지만 이 클래스를 이용해 JavaMail로 메일을 직접 발송하는 클래스를 대치했기 때문에 테스트가 원활하게 된 것이다.

스프링의 XML 설정파일을 테스트용으로 따로 만든 이유는 개발자 환경에서 손쉽게 이용할 수 있는 테스트용 DB를 사용하도록 만들기 위해서다.

이처럼 테스트 환경에서 유용하게 사용하는 기법이 있다. 대부분 테스트할 대상이 의존하고 있는 오브젝트를 DI를 통해 바꿔치기 하는 것이다.

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

UserDaoTest

원래 UserDao는 운영 시스템에서 사용하는 DB와 연결돼서 동작한다. 대용량의 DB 연결 기능에 최적화된 WAS에서 동작하는 DB 풀링 서비스를 사용하고, 이에 최적화된 복잡한 DataSource의 구현 클래스를 이용하도록 되어 있다.

하지만 UserDaoTest의 관심은 UserDao가 어떻게 동작하는지에 있지, 그 뒤에 존재하는 DB 커넥션 풀이나 DB 자체에 있지 않다. 하지만 UserDao가 제 기능을 수행하려면 반드시 DB를 사용해야 하기에 무시할 수는 없다. 그래서 이를 대신할 수 있도록 테스트환경에서도 잘 동작하고, 준비 과정도 간단한 DataSource를 사용하고, DB도 개발자 PC에 설치해서 사용해도 무방한 가벼운 버전을 이용하게 한 것이다.

UserServiceTest 중 메일 전송 기능

실제로 UserService가 운영 시스템에서 사용될 때는 당연히 JavaMailSenderImplJavaMail을 통해 메일 서버로 이어지는 구성이 필요하다.

하지만 UserServiceTest의 관심사는 UserService에서 구현해놓은 사용자 정보를 가공하는 비즈니스 로직이지, 메일이 어떻게 전송이 될 것인지가 아니다. 그렇다고 메일 전송 기능을 아예 뺄 수도 없다. 테스트 대상이 되는 코드를 수정하지 않고, 메일 발송 작업 때문에 UserService 자체에 대한 테스트에 지장을 주지 않기 위해 도입한 것이 결국 DummyMailSender다. 이 클래스가 UserService가 반드시 이용해야 하는 의존 오브젝트의 역할을 해주면서 원할하게 테스트 중에 UserService의 코드가 실행되게 해준다.

정리

이렇게 테스트 대상인 오브젝트가 의존 오브젝트를 갖고 있기 때문에 여러 가지 테스트상의 문제점이 있다.

  • 대표적으로 간단한 오브젝트의 코드를 테스트하는 데 너무 거창한 작업이 뒤따른다. 이럴 땐 UserDao의 경우처럼 테스트를 위해 간단한 환경으로 만들어주던가, 아니면 UserService의 메일 발송 기능의 경우처럼 아예 아무런 일도 하지 않는 빈 오브젝트로 대치해주는 방법이 있다. 스프링의 DI를 이용하는 것이다.

단지 테스트만을 위해서도 DI는 유용하다. DataSource의 경우에서도, 운영 중인 시스템에서는 특정 클래스 외에는 절대로 다른 것을 사용하지 않는다고 확신하더라도 테스트 때는 바꿀 수밖에 없기 때문이다. 그래서 인터페이스를 사용하고, 어떤 클래스의 오브젝트를 사용할지 외부에서 주입해주도록 스프링의 DI를 적용해야 한다.

4.4.2 테스트 대역의 종류와 특징

테스트 대역의 정의

테스트용으로 사용되는 특별한 오브젝트들이 있다. 대부분 테스트 대상인 오브젝트의 의존 오브젝트가 되는 것들이다. UserDaoDataSource이거나, UserServiceMailSender 인터페이스를 구현한 것들이다. 이렇게 테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 통틀어서 테스트 대역이라고 부른다. 테스트가 원활하게 돌아가도록 의존 오브젝트로서 간접적인 도움을 준다는 개념과 달리, 어떤 테스트 대역은 테스트 과정에 매우 적극적으로 참여할 수 있다.

1. 테스트 스텁

대표적인 테스트 대역은 테스트 스텁이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.

일반적으로 테스트 스텁은 메서드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용된다. 따라서 DI 등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야 한다. DummyMailSender는 가장 단순하고 심플한 테스트 스텁의 예다.

많은 경우 테스트 스텁이 결과를 돌려줘야 할 때도 있다. MailSender처럼 호출만 하면 그만인 것도 있지만, 리턴 값이 있는 메서드를 이용하는 경우에는 결과가 필요하다. 이럴 땐 스텁에 미리 테스트 중에 필요한 정보를 리턴해주도록 만들 수 있다.

어떤 스텁은 메서드를 호출하면 강제로 예외를 발생시키게 해서 테스트 대상 오브젝트가 예외상황에서 어떻게 반응할지를 테스트할 때 적용할 수도 있다.

스텁을 이용하면 간접적인 입력 값을 지정해줄 수도 있다. 마찬가지로 어떤 스텁은 간접적인 출력 값을 받게 할 수 있다.

2. 목 오브젝트

테스트 대상 오브젝트의 메서드가 돌려주는 결과뿐 아니라 테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위 자체에 대해서도 검증하고 싶다면 어떻게 해야 할까? 이 경우 단순하게 메서드의 리턴 값을 assertThat()으로 검증하는 것으로는 불가능하다.

이런 경우에는 테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 목 오브젝트를 사용해야 한다. 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.

  • (5)을 제외하면 스텁이라고 봐도 된다.

테스트 대상 오브젝트는 테스트로부터만 입력을 받는 것이 아니다. 테스트가 수행되는 동안 실행되는 코드는 테스트 대상이 의존하고 있는 다른 의존 오브젝트와도 커뮤니케이션하기도 한다. 테스트 대상은 의존 오브젝트에게 값을 출력하기도, 입력받기도 한다. 간접적으로 테스트 대상이 받아야 할 입력 값이 필요할 때는, 별도로 준비해둔 스텁 오브젝트가 메서드 호출 시 특정 값을 리턴하도록 만들어두면 된다.

때론 테스트 대상 오브젝트가 의존 오브젝트에게 출력한 값에 관심이 있을 경우가 있다. 또는 의존 오브젝트를 얼마나 사용했는가 하는 커뮤니케이션 행위 자체에 관심이 있을 수가 있다. 이때는 테스트 대상과 의존 오브젝트 사이에 주고받는 정보를 보존해두는 기능을 가진 테스트용 의존 오브젝트인 목 오브젝트를 만들어서 사용해야 한다. 테스트 대상 오브젝트의 메서드 호출이 끝나고 나면 테스트는 목 오브젝트에게 테스트 대상과 목 오브젝트 사이에서 일어났던 일에 대해 확인을 요청해서, 그것을 테스트 검증 자료로 삼을 수 있다.

4.4.3 목 오브젝트를 이용한 테스트

UserServiceTest에 목 오브젝트를 적용해보자.

트랜잭션 기능을 테스트하려고 만든 upgradeAllOrNothing()의 경우는 테스트가 수행되는 동안에 메일이 전송됐는지 여부는 관심의 대상이 아니기 때문에 DummyMailSender가 잘 어울린다.

반면에 정상적인 사용자 레벨 업그레이드 결과를 확인하는 upgradeLevels() 테스트에서는 메일 전송 자체에 대해서도 검증할 필요가 있다. 조건을 만족하는 사용자의 레벨을 수정했다면, 메일도 발송했어야 하기 때문이다.

만약 JavaMail을 직접 사용하는 방식이었다면, 메일 발송 테스트는 메일 주소로 실제 메일이 들어왔는지 직접 확인하거나, 아니면 아주 복잡한 방법을 사용해 메일 서버의 로그를 뒤져서 메일 발송 로그가 그 시점에 남았는지를 확인해야 할 것이다. 하지만 스프링의 JavaMail 서비스 추상화를 적용했기 때문에 목 오브젝트를 만들어서 메일 발송 여부를 확인할 수 있다.

고려 사항

  • DummyMailSender 대신 새로운 MailSender를 대체할 클래스를 하나 만든다.
    • 메일을 발송하는 기능은 없지만 테스트 대상이 넘겨주는 출력 값을 보관해두는 기능을 추가한다.
      • UserService 코드의 정상적으로 수행되도록 돕는 역할이 우선이기 때문이다.
    • UserServiceTest 안에서만 사용될 것이므로 스태틱 멤버 클래스로 정의한다.

  • 테스트 대상인 UserServicesend() 메서드를 통해 자신을 불러서 메일 전송 요청을 보냈을 때 관련 정보를 저장해두는 기능이다.
  • 동작 과정
    1. 전달받은 SimpleMailMessage 오브젝트에서 첫 번째 수신자 메일 주소를 꺼내온다.
    2. 수신자 메일 주소를 미리 준비해둔 리스트에 저장해둔다.
    3. 이 리스트를 테스트에서 읽어갈 수 있도록 간단한 접근자 메서드를 만들어 둔다. 테스트 대상 오브젝트가 목 오브젝트에게 전달하는 출력정보를 저장해두는 것이다.

테스트

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. 정리

5장에서는 비즈니스 로직을 담은 UserService 클래스를 만들고 트랜잭션을 적용하면서 스프링의 서비스 추상화에 대해 살펴보았다.

  • 비즈니스 로직을 담은 코드는 데이터 액세스 로직을 담은 코드와 깔끔하게 분리되는 것이 바람직하다. 비즈니스 로직 코드 또한 내부적으로 책임과 역할에 따라서 깔끔하게 메서드로 정리돼야 한다.
  • 이를 위해서는 DAO의 기술 변화에 서비스 계층의 코드가 영향을 받지 않도록 인터페이스와 DI를 잘 활용해서 결합도를 낮춰줘야 한다.
  • DAO를 사용하는 비즈니스 로직에는 단위 작업을 보장해주는 트랜잭션이 필요하다.
  • 트랜잭션의 시작과 종료를 지정하는 일은 트랜잭션 경계설정이라고 한다. 트랜잭션 경계설정은 주로 비즈니스 로직 안에서 일어나는 경우가 많다.
  • 시작된 트랜잭션 정보를 담은 오브젝트를 파라미터로 DAO에 전달하는 방법은 매우 비효율적이기 때문에 스프링이 제공하는 트랜잭션 동기화 기법을 활용하는 것이 편리하다.
  • 자바에서 사용되는 트랜잭션 API의 종류와 방법은 다양하다. 환경과 서버에 따라서 트랜잭션 방법이 변경되면 경계설정 코드도 함께 변경돼야 한다.
  • 트랜잭션 방법에 따라 비즈니스 로직을 담은 코드가 함께 변경되면 단일 책임 원칙에 위배되며, DAO가 사용하는 특정 기술에 대해 강한 결합을 만들어낸다.
  • 트랜잭션 경계설정 코드가 비즈니스 로직 코드에 영향을 주지 않게 하려면 스프링이 제공하는 트랜잭션 서비스 추상화를 이용하면 된다.
  • 서비스 추상화는 로우레벨의 트랜잭션 기술과 API의 변화에 상관없이 일관된 API를 가진 추상화 계층을 도입한다.
  • 서비스 추상화는 테스트하기 어려운 JavaMail 같은 기술에도 적용할 수 있다. 테스트를 편리하게 작성하도록 도와주는 것만으로도 서비스 추상화는 가치가 있다.
  • 테스트 대상이 사용하는 의존 오브젝트를 대체할 수 있도록 만든 오브젝트를 테스트 대역이라고 한다.
  • 테스트 대역은 테스트 대상 오브젝트가 원활하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.
  • 테스트 대역 중에서 테스트 대상으로부터 전달받은 정보를 검증할 수 있도록 설계된 것을 목 오브젝트라고 한다.
profile
🚀 백엔드 2년차 개발자입니다 🚀 성장의 즐거움으로 아자자자!🤣

0개의 댓글