지금까지 만든 DAO에 트랜잭션을 적용해보면서 스프링이 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법에 사용하는 방법

사용자 레벨 관리 기능 추가

UserDao를 다수의 회원이 가입할 수 있는 인터넷 서비스의 사용자 관리 모듈에 적용한다고 생각해보자. 사용자 관리 기능에는 단지 정보를 넣고 검색하는 것 외에도 정기적으로 사용자의 활동내역을 참고해서 레벨을 조정해주는 기능이 필요하다.

  • 사용자의 레벨은 BASIC, SILVER, GOLD
  • 처음 가입시 BASIC
  • 가입 후 로그인 50회 이상 : BASIC → SILVER
  • SILVER 레벨이면서 30번 이상 추천 : GOLD
  • 사용자 레벨의 변경 작업은 일정한 주기를 가지고 일괄적으로 진행. 변경 작업 전에는 레벨의 변경이 일어나지 않는다.

구현

필드 추가

  1. LEVEL 구현 하기
    • 3가지 레벨은 코드화해서 숫자로 DB에 넣는다. (Tiny int)
    • 정수형 상수값으로 레벨을 정의하기 위해 이늄(enum)을 사용한다. (다른 엉뚱한 정보를 넣는 실수를 해도 컴파일러가 체크해준다.)
    • 이늄에는 정수값(int)을 레벨이늄로 바꿔주거나, 레벨을 정수값(int)으로 바꿔주는 메소드를 작성한다.
  2. User 클래스의 필드에 level, login, recommend 멤버 필드를 추가한다.
  3. DB에 LEVEL, LOGIN, RECOMMEND 필드를 USER 테이블에 추가한다.
  4. UserDaoTest 테스트 클래스의 테스트 픽스처에 추가된 필드값을 넣어 수정한다.
  5. User 클래스의 생성자도 추가된 필드를 받도록 수정한다.
  6. UserDaoTest의 checkSameUser 멤버 메소드도 수정한다.
  7. UserDaoJdbc 클래스의 RowMapper를 수정한다. LEVEL 이늄이 오브젝트이므로 DB에 저장되지 않음에 유의한다.

사용자 수정 기능 추가

비즈니스 로직에 따르면 사용자 정보는 여러 번 수정될 수 있다. 성능 극대화를 위해 수정되는 필드의 종류에 따라서 각각 여러 개의 수정용 DAO 메소드를 만들어야 할 때도 있다. 하지만 아직은 단순하고 필드도 몇 개가 되지 않으며 자주 변경되지 않으므로 간단히 구현한다.

  1. 만들어야 할 코드의 기능 테스트 코드를 먼저 작성한다.
    1. 픽스처 오브젝트를 먼저 두 개를 등록
    2. 등록된 오브젝트 중 하나를 선택해서 수정한다
    3. 두 오브젝트의 수정 여부를 비교한다.
  2. 사용자 정보를 모든 필드 정보를 수정하는 Update 쿼리를 전송하는 DAO 메소드를 작성한다.

UserService.upgradeLevels() 구현

사용자 관리 로직을 추가해야 한다. UserDaoJdbc는 적합하지 않다. DAO는 데이터를 어떻게 가져오고 조작할지를 다루는 곳이지 비즈니스 로직을 두는 곳이 아니다. 따라서 사용자 관리 비즈니스 로직을 담을 클래스를 하나 추가한다. 비즈니스 로직 서비스를 제공한다는 의미로 UserService로 한다. 인터페이스로 userDao 빈을 DI 받아 사용한다. 코드의 구현은 다음과 같다.

public void upgradeLevels() {
    List<User> users = userDao.getAll();
    for(User user : users) {
        Boolean changed = null;
        if(user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
            changed = true;
        }
        else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
            changed = true;
        }
        else if(user.getLevel() == Level.GOLD) {
            changed = false;
        }
        else {
            changed = false;
        }
        if(changed) {
            user.setLevel(Level.valueOf(user.getLevel().intValue() + 1));
            userDao.update(user);
        }
    }
}

upgradeLevels() 테스트

로직을 보면 30, 50의 경계를 기준으로 레벨이 변하므로 테스트 케이스로는 경계 조건 값의 전후로 선택하는 것이 좋다. 픽스처는 리스트 컬렉션을 활용해 만들도록 한다.

UserService.add() 구현

처음 가입시 BASIC 조건을 만족하는 함수를 제공해야 한다. upgradaeLevels와 마찬가지로 UserDaoJdbc는 적합하지 않다. DAO에 비즈니스적인 의미를 지는 정보를 설정하는 책임을 지는 것은 바람직하지 않다. User 클래스의 생성자로 Level 필드를 Basic으로 초기화 하는 것도 좋지만 단지 이 로직을 담기 위해 클래스에서 직접 초기화하는 것은 문제가 있다.

  • add() 메소드 테스트

    레벨이 값이 있는 픽스처와 레벨 값이 null인 두 픽스처를 add() 이용해 넣고 dao의 get으로 초기 레벨값을 확인하면 된다.

코드 개선

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

upgradeLevels의 개선

for 루프 속에 들어 있는 if elseif else 블록이 가독성이 떨어진다. 레벨의 변화 단계와 업그레이드 조건, 조건이 충족시 할 작업이 한데 섞여 있어서 이해하기 어렵다. 플래그를 활용한 업그레이드 방법도 깔끔하지 못하다. 플래그 구조는 성격이 조금씩 다른 것들이 섞여 있거나 분리돼서 나타나는 구조다.

만약 새 레벨이 추가된다면 Level 이늄도 수정하고, if 조건식과 블록을 추가해야 한다. 업그레이드 조건이 복잡해지거나 작업에서 level 필드를 변경하는 수준 이상(ex 권한부여?)이라면 메소드가 길어지고 복잡해지는 문제가 생긴다. 새로운 레벨이 추가돼도 기존 if 조건들에 맞지 않을 테니 else 블록으로 이동한다. 성격이 다른 두가지 경우가 모두 한 곳에서 처리되는 것은 옳지않다. 제대로 만들려면 조건을 두 단계에 걸쳐서 비교해야 한다. 첫 단계에서는 레벨을 확인하고 각 레벨별로 다시 조건을 판단하는 조건식을 넣어야 한다.

ugradeLevels() 리팩토링

자주 변경될 가능성이 있는 구체적인 내용이 추상적인 로직의 흐름과 함께 섞여 있다. 구체적인 구현에서 외부에 노출할 인터페이스를 분리하는 것과 마찬가지 작업이다.

public void upgradeLevels() {
    List<User> users = userDao.getAll();
    for(User user : users) {
        if(canUpgradeLevel(user)) {
            upgradeLevel(user);
        }
    }
}

canUpgradeLevel(User user) : 업그레이드 확인 메소드

private boolean canUpgradeLevel(User user) {
    Level currentLevel = user.getLevel();
    switch (currentLevel) {
        case BASIC: return (user.getLogin() >= 50);
        case SILVER: return (user.getRecommend() >= 30);
        case GOLD: return false;
        default: throw new IllegalArgumentException("Unknown Level : " + currentLevel);
    }
}

upgradeLevel(User user) : 레벨 업그레이드 작업 메소드

private void upgradeLevel(User user) {
    user.upgradeLevel();
    userDao.update(user);
}

Level : Enum이 업그레이드 순서를 담고 있도록 수정

Level(int value, Level next) {
    this.value = value;
    this.next = next;
}

public Level nextLevel() {
    return this.next;
}

User : 레벨 업그레이드 기능 추가

단순 자바빈이긴 하지만 User도 엄연히 자바 오브젝트이고 내부 정보를 다루는 기능이 있을 수 있다.

public void upgradeLevel() {
    Level nextLevel = this.level.nextLevel();
    if(nextLevel == null) {
        throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다");
    }
    else {
        this.level = nextLevel;
    }
}

개선 결론

각 오브젝트와 메소드가 각각 자기 몫의 책임을 맡아 일을 하는 구조

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

상수의 도입

애플리케이션 코드와 테스트 코드의 업그레이드 조건 상수가 중복되어 나타난다. 한 가지 변경이 일어날 때 여러 군데를 고치게 만든다면 중복이다.

// In UserSerevice class
public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_FOR_GOLD = 30;

// In UserTestService class
users = Arrays.asList(
        new User("bumjin", "박범진", "p1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER - 1, 0),
        new User("joytouch", "강명성", "p2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0),
        new User("erwins", "신승한", "p3", Level.SILVER, 60, MIN_RECOMMEND_FOR_GOLD - 1),
        new User("madnite1", "이상호", "p3", Level.SILVER, 60, MIN_RECOMMEND_FOR_GOLD),
        new User("green", "오민규", "p5", Level.GOLD, 100, Integer.MAX_VALUE)
);

User 테스트 작성

User에 추가한 upgradeLevel에 대한 테스트를 작성할 필요가 있다. 모든 레벨에 대한 업그레이드를 시도하면서 기대한 레벨과 같은지 아니면 레벨 업그레이드가 불가능하면 어떤 예외가 발생하는지 테스트 해야한다.

@Test
public void upgradeLevelTest() {
    Level[] levels = Level.values();
    for(Level level : levels) {
        if(level.nextLevel() == null) continue;
        user.setLevel(level);
        user.upgradeLevel();
        assertEquals(level.nextLevel(), user.getLevel());
    }
}

@Test
public void cannotUpgradeLevel() {
    Level[] levels = Level.values();
    for(Level level : levels) {
        if(level.nextLevel() != null) continue;
        user.setLevel(level);
        assertThrows(IllegalStateException.class, () -> user.upgradeLevel());
    }
}

업그레이드 정책의 분리

업그레이드 정책을 UserService에서 분리하는 방법을 고려할 수 있다. 분리된 업그레이드 정책을 담은 오브젝트는 DI를 통해 UserService에 주입된다.

public interface UserLevelUpgradePolicy {
    boolean canUpgradeLevel(User user);
    void upgradeLevel(User user);
}

트랜잭션

트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다. 더 이상 쪼개서 이뤄질 수 없는 원자와 같은 성질을 띤다. 따라서 중간에 예외가 발생해서 작업을 완료할 수 없다면 아예 작업이 시작되지 않은 것처럼 초기 상태로 돌려놔야 한다.

트랜잭션 테스트 작성

UserService에서 예외를 강제로 발생시키기 위해 UserService를 상속하는 TestUserService라는 이너 클래스를 테스트 클래스 내부에 만든다. TestUserService는 예외를 발생시킬 지정된 User오브젝트의 id를 생성자로 받는다. 그리고 오버라이드한upgradeLevel메소드는 그대로 UserService메소드의 기능을 수행하지만 미리 지정된 id를 가진 사용자 발견되면 강제로 예외를 던지도록 한다.

트랜잭션 경계 설정

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

앞서 실행된 SQL은 성공적으로 실행했지만 다음의 SQL이 장애가 생겨서 작업이 중단된 경우, 트랜잭션의 원자성에 의해 성공한 SQL문을 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백이라고 한다. 반대로 모든 작업을 다 성공적으로 마무리하면 DB에 알려줘서 작업을 확정시켜야 한다. 이를 트랜잭션 커밋이라고 한다.

모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두가다. 모든 작업을 무효화하는 트랜잭션 롤백과 모든 작업을 다 확정하는 커밋이다.

애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다. 복잡한 로직의 흐름 사이에서 정확하게 트랜잭션 경계를 설정하는 일은 매우 중요하다.

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

Connection c = dataSource.getConnection();

c.setAutoCommit(false); // 트랜잭션 시작
try {
    PreparedStatement st1 = c.preparedStatement("update users ...");
    st1.excuteUpdate();

    PreparedStatement st2 = c.preparedStatement("update users ...");
    st2.excuteUpdate();
    c.commit(); // 트랜잭션 커밋
}
catch(Exception e) {
    c.rollback(); // 트랜잭션 롤백
}

c.close();

이렇게 setAutoCommit(false) 로 트랜잭션의 시작을 선언하고 commit() 또는 rollback() 으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정 이라고 한다. 트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다는 점도 기억하자.

이와 같이 하나의 DB 커넥션 안에서 만들어 지는 트랜잭션을 로컬 트랜잭션이라고도 한다.

트랜잭션 동기화

트랜잭션 동기화란? 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다. JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것

public void upgradeLevels() throws Exception{
    // 동기화 작업을 초기화한다.
    TransactionSynchronizationManager.initSynchronization();
    // DataSourceUtils 스프링 유틸리티 메소드 제공
    // 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주는 스프링 DataSourceUtils getConnection
    Connection c = DataSourceUtils.getConnection(dataSource);
    c.setAutoCommit(false); // 트랜잭션 시작, 트랜잭션의 경계 설정

    try {
        List<User> users = userDao.getAll();
        for(User user : users) {
            if(userLevelUpgradePolicy.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();
    }
}

JdbcTemplate의 저장된 Connection 가져오기

JdbcTemplate은 Connection을 스프링 DataSourceUtils를 이용해 가져온다. DataSourceUtils는 다시 TransactionSynchronizationManager(TSM)에서 Connection을 가져오는데 TSM은 트랜잭션의 스레드 마다 생성되는 오브젝트로 한 스레드에 묶여 공통적으로 사용되는 오브젝트를 저장 및 반환이 주된 목적인 것같다. 다시 TSM은 DataSource를 Key로 이용해 ThreadLocal<Map<Object, Object>> resources 로 부터 Connection을 받아와 멀티스레드에서 환경에서도 안전한 트랜잭션 동기화를 이용할 수 있다. ThreadLocal을 통해 트랜잭션 컨텍스트를 전파할 수 있는 것이다.

트랜잭션 서비스 추상화

UserService의 트랜잭션 처리 코드의 특정 API와 의존관계 문제를 Spring PlatformTransactionManager를 이용해 해결해 보자.

토막상식 : JTA(Java Transaction API)란?

여러 개의 DB에 데이터를 넣는 작업은 글로벌 트랜잭션 방식을 사용해야 한다. 자바는 JDBC 외에 글로벌 트랜잭션 매니저를 지원하는 JTA를 제공한다. JTA의 트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다. 이를 통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어한다. JTA를 이용해 트랜재션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다. (하나 이상의 DB가 참여하는 트랜잭션 → JTA 사용하면 된다!)

트랜잭션 API의 의존관계 문제와 해결책 : 스프링 트랜잭션 서비스 추상화

UserService의 메소드 안에서 트랜잭션 경계설정 코드를 제거할 수는 없다. 하지만 특정 기술에 종속되지 않게 할 수 있는 방법은 있다 → 스프링 트랜잭션 서비스 추상화

<interface> PlatformTransactionManager

public void upgradeLevels() {
    // JDBC 트랜잭션 추상 오브잭트 생성
    PlatformTransactionManager transactionManager = 
            new DataSourceTransactionManager(datasource)
    // 트랜잭션 시작
    // DefaultTransactionDefinition 트랜잭션 속성
    // TransactionStatus : 시작된 트랜잭션 저장
    TransactionStatus status = transactionManager
            .getTransaction(new DefaultTransactionDefinition());

    try {
        List<User> users = userDao.getAll();
        for(User user : users) {
            if(userLevelUpgradePolicy.canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

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

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

기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다.

  • UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으면서 독자적인 확장이 가능하다. 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리했다. 이를 같은 계층에서 수평적인 분리라고 볼 수 있다.
  • 트랜잭션 추상화는 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션이라는 아예 다른 계층의 특성을 갖는 코드를 분리했다. 수직적인 분리
  • 애플리케이션 로직의 종류에 따른 수평적인 구분, 로직과 기술이라는 수직적인 구분
  • 결합도를 낮추고 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만드는 데 스프링 DI가 중요한 역할을 한다.
  • DI는 관심, 책임, 성격이 다른 코드를 깔끔하게 분리한다.

단일 책임 원칙(Single Responsibility Principle)

하나의 모듈은 한 가지 책임을 가져야 한다는 의미. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수 있다. 사용하는 기술이나 서버환경, 테이블 등이 바뀌어도 수정할 이유가 없어야 한다.

단일 책임 원칙의 장점

  • 어떤 변경이 필요할 때 수정 대상이 명확해진다.
  • 단일 책임 원칙을 위한 핵심적인 도구는 DI
  • 객체지향 설계와 프로그래밍의 원칙은 서로 긴밀하게 관련되어 있다. 단일 책임 원칙을 잘 지키는 코드를 만드려면 인터페이스를 도입하고 이를 DI로 연결해야 하며, 그 결과로 단일 책임 원칙 뿐만 아니라 개방 폐쇄 원칙도 잘 지키고, 모듈 간에 결합도가 낮아서 서로의 영향을 주지 않고, 변경이 단일 책임에 집중되는 응집도 높은 코드가 나온다.
  • 스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리
  • 변경사유가 생겼을 때 코드의 어디를 어떻게 수정해야 하는지 주의 깊게 살펴보면 좋은 코드의 특징과 가치를 알 수 있다.

메일 서비스 추상화

테스트와 서비스 추상화

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

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

의존 오브젝트를 협력오브젝트(collaborator)라고 부르기도 한다. 함께 협력해서 일을 처리하는 대상이기 때문이다.

테스트 대역의 종류와 특징

테스트용으로 사용되는 특별한 오브젝트는 대부부 테스트 대상인 오브젝트의 의존 오브젝트가 되는 것들이다.

테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 테스트 대역(test double)이라고 부른다.

대표적인 테스트 대역은 테스트 스텁(test stub)이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다. 일반적으로 테스트 스텁은 메소드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용된다. 따라서 DI 등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야 한다.

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

목 오브젝트는 보통의 테스트 방법으로는 검증하기가 매우 까다로운 테스트 대상 오브젝트의 내부에서 일어나는 일이나 다른 오브젝트 사이에서 주고받는 정보까지 검증할 수 있다.

출처

토비의 스프링(책)

태그:

카테고리:

업데이트:

댓글남기기