토비의 스프링 5장

Sihwan Kim·2024년 7월 3일

토비의 스프링

목록 보기
5/5

🎇 서비스 추상화

사용자 수정 기능

Update 기능에 대한 테스트 확인

update 기능에 대해 테스트를 할 경우 수정한 row에 대해서는 보통 확인을 한다. 하지만, 수정되지 않아야할 row에 대해서 수정되지 않았는지에 관한 테스트도 해야한다.

row의 개수 확인하기

JdbcTemplate의 update()는 테이블의 내용에 영향을 주는 SQL을 실행하면 영향받은 로우의 개수를 돌려주기 때문에, 이를 통해 업데이트 할 row수와 실제 반영된 row수를 비교할 수 있다.

직접 확인하기

모든 사용자에 대해서 사용자를 비교하여 수정한 정보만 수정되었는지 확인하는 방법이다.



사용자 레벨 updgrade 메소드 수정

레벨이 업그레이드되는 다양한 조건이 있을 때, 어떻게 처리하는 것이 좋을까?

❌ 안좋은 예시

    public void upgradeLevels() {
        List<User> users = userDao.getAll();

        for (User user : users) {
            Boolean changed = null;

            if (user.getLevel() == Level.BASIC && user.getLoginCount() >= 50) {
                user.setLevel(Level.SILVER);
                changed = true;
            } else if (user.getLevel() == Level.SILVER && user.getRecommendCount() >= 30) {
                user.setLevel(Level.GOLD);
                changed = true;
            } else if (user.getLevel() == Level.GOLD) {
                changed = false;
            } else {
                changed = false;
            }

            if(changed) {
                userDao.update(user);
            }
        }
    }

위와 같이 구현하게 되면, if 조건 블록이 레벨 개수만큼 반복된다. 만약 새로운 Level이 추가된다면 if 조건과 블록이 추가되어야 한다. 이는 갈수록 이해하고 관리하기 힘든 코드가 된다.

리팩토링

추상적인 로직의 흐름과 구체적인 내용의 분리

upgradeLevels() 안에 흐름과 그에 관한 구체적인 코드가 같이 섞여 있는데, 이를 로직의 흐름과 구체적인 구현이 분리된 코드로 변경한다.

   public void upgradeLevels() {
        List<User> users = userDao.getAll();

        for (User user : users) {
            if(canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
    }

이렇게 구체적인 구현은 아직 몰라도 로직의 흐름은 보기 쉬운 코드로 작성할 수 있다. 이제 구체적인 구현 메소드를 작성해보자.

    private boolean canUpgradeLevel(User user) {
        Level currentLevel = user.getLevel();

        return switch(currentLevel) {
            case BASIC -> user.getLoginCount() >= 50;
            case SILVER -> user.getRecommendCount() >= 30;
            case GOLD -> false;
            default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
        };
    }
    private void upgradeLevel(User user) {
        Level currentLevel = user.getLevel();

        switch (currentLevel) {
            case BASIC -> user.setLevel(Level.SILVER);
            case SILVER -> user.setLevel(Level.GOLD);
            default -> throw new IllegalArgumentException("Can not upgrade this level: " + currentLevel);
        }

        userDao.update(user);
    }

업그레이드가 가능한지 여부를 확인하는 메소드와 사용자 레벨을 업그레이드하는 메소드 총 2개를 만들었다.

이렇게 작성하면 로직의 흐름을 변경할 때와, 구체적인 구현을 변경할 때 상황에 따라 역할과 책임이 명료하기 때문에 유지보수가 쉬운 코드가 되었다.




다음 레벨에 대한 관심사 변경

현재 코드에서는 여전히 switch문을 통해 업그레이드할 레벨을 지정해준다. 이렇게 되면 Level이 추가될 때마다 코드가 추가되는 문제가 여전히 남아있다.

다름 레벨을 알 수 있는 관심사를 upgrade가 아닌 레벨 자체에게 맡겨보자.

public enum Level {
    // 초기화 순서를 3, 2, 1 순서로 하지 않으면 `SILVER`의 다음 레벨에 `GOLD`를 넣는데 에러가 발생한다.
    GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);

    private final int value;
    private final Level next;

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

    public Level nextLevel() {
        return next;
    }

    public int intValue() {
        return value;
    }

    public static Level valueOf(int value) {
        return switch (value) {
            case 1 -> BASIC;
            case 2 -> SILVER;
            case 3 -> GOLD;
            default -> throw new AssertionError("Unknown value: " + value);
        };
    }
}

이렇게 구현하면 레벨이 추가되는 경우 레벨 클래스만 수정해주면 된다. 한 가지 변경 이유가 발생했을 때 여러 군데를 고치게 만든다면 중복이기 때문이다.




🎈 트랜잭션 서비스 추상화

사용자 레벨 조정 작업은 중간에 문제가 발생해서 작업이 중단된다면 그때까지 진행된 변경 작업도 모두 취소시키라는 요구사항이 있었다. 이를 구현해 보자.

일부러 예외를 만드는 건 어떻게 할까?

중간에 문제가 발생하는 상황을 테스트하기 위해서는 중간에 문제를 발생시켜야 한다. 기존에 잘 돌아가던 코드 중간에 테스트를 위해 예외를 넣어야 할까?? 기존에 잘되던 코드를 건드리는 것은 좋은 생각이 아니다.

이럴 때는 테스트용 서비스 코드를 만들자. 하지만, 코드를 복붙하지는 말고 상속을 통해 필요한 메소드만 오버라이드 해보자.

    static class TestUserService extends UserService {
        private final String targetUserId;

        public TestUserService(String targetUserId) {
            this.targetUserId = targetUserId;
        }

        @Override
        protected void upgradeLevel(User user) {
            if(user.getId().equals(targetUserId)) {
                throw new TestUserServiceException();
            }

            super.upgradeLevel(user);
        }

        static class TestUserServiceException extends RuntimeException {
        }
    }

이렇게 테스트용 서비스를 사용해서 중간에 예외가 발생하는 상황을 구현하였다.

트랜잭션 경계 설정

위와 같이 중간에 에러가 발생하는 경우에 요구사항을 만족할까? 아니다. 지금 구현된 상태로는 중간에 에러가 발생하면 업데이트된 row는 업데이트되고 아직 작업하지 않은 row는 업데이트 되지 않는다.

중간에 에러가 발생해서 모든 변경사항을 롤백하기 위해서는 트랜잭션처리를 해야한다. 트랜잭션의 경계를 설정하고 모든 작업이 성공하면 그때 commit을, 실패하면 rollback을 동작하도록 하는 것이다.

public void upgradeLevels() throws Exception {
  // (1) DB Connection 생성
  // (2) 트랜잭션 시작
  try {
    // (3) DAO 메소드 호출
    // (4) 트랜잭션 커밋
  }
  catch(Exception e) {
    // (5) 트랜잭션 롤백
    throw e;
  }
  finally {
    // (6) DB Connection 종료
  }
}


트랜잭션 경계 설정 문제점

  • try/catch/finally 블록은 이제 UserService 내에 존재하고 UserService의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수 밖에 없다.

  • UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다. upgardeLevels()에서 사용하는 메소드의 어딘가에서 DAO를 필요로 한다면, 그 사이의 모든 메소드에 걸쳐서 Connection 오브젝트가 계속 전달돼야 한다.

트랜잭션 동기화 사용

위와 같은 문제점을 해결할 수 있는 방법을 스프링이 제공한다. 바로 트랜잭션 동기화이다.

    public void upgradeLevels() throws SQLException{
        // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화
        TransactionSynchronizationManager.initSynchronization();
        // DB 커넥션을 생성하고 트랜잭션을 시작한다.
        // 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행된다.
        // 아래 두 줄이 DB 커넥션 생성과 동기화를 함께 해준다.
        Connection c = DataSourceUtils.getConnection(dataSource);
        c.setAutoCommit(false);

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

            c.commit();
        }catch(Exception e) {
            c.rollback();
            throw e;
        } finally {
            // 스프링 DataSourceUtils 유틸리티 메소드를 통해 커넥션을 안전하게 닫는다.
            DataSourceUtils.releaseConnection(c, dataSource);
            // 동기화 작업 종료 및 정리
            TransactionSynchronizationManager.unbindResource(this.dataSource);
            TransactionSynchronizationManager.clearSynchronization();
        }
    }

스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager이다.
DataSource에서 Connection을 직접 가져오지 않고 DataSourceUtils의 getConnection() 메소드를 사용하는데, 이때 Connection을 생성하고 동기화에 사용하도록 저장소에 바인딩해준다.



JdbcTemplate와 트랜잭션 동기화

JdbcTemplate에서는 트랜잭션 동기화를 시작해놓았다면, 직접 DB Connection을 만드는 대신 트랜잭션 동기화 저장소에 들어있는 DB Connection을 가져와 사용한다. 따라서 트랜잭션 적용을 해도 UserDAO에서는 수정할 필요가 없다.

트랜잭션 서비스 추상화

이제 처음에 생각했던 요구사항은 만족시켰다. 하지만 문제가 발생한다. 트랜잭션을 제공하면서 의도치 않게 JDBC에 종속적인 Connection을 이용하는 코드가 사용되면서 UserService가 UserDaoJdbc에 의존하는 코드가 되었다.

만일 JDBC가 아닌 데이터엑세스를 사용한다면 코드를 수정하여야 하기 때문이다.

스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공한다.

public class UserService {
    UserDao userDao;
    DataSource dataSource;
    PlatformTransactionManager transactionManager;
    

위와 같이 PlatformTransactionManager으로 선언하고 구체적인 트랜잭션 매니저를 나중에 주입해주는 방식을 사용하면 getTransaction(), commit(), rollback() 메소드를 모두 사용할 수 있기 때문에 코드를 수정하지 않아도 된다.

단일 책임의 원칙

단일 책임 원칙이란, 하나의 모듈은 한가지 책임을 가져야 한다는 의미다. 다른 말로 풀면 하나의 모듈이 바뀌는 이유는 한가지여야 한다고 설명할 수도 있다.
트랜잭션을 구현하기 위해 UserService에 JDBC 코드가 들어가있을 때는 UserService의 책임은 두가지였다.

  • 어떻게 사용자 레벨을 관리할 것인가
  • 어떻게 트랜잭션을 관리할 것인가

따라서 레벨 관리 로직이 바뀔 때도, 트랜잭션 기술이 바뀔 때도 UserService를 수정해야 한다.
이는 단일 책임 원칙은 깨지는 것이다.

0개의 댓글