[토비의 스프링] Week6(5.1~5.2)

21 분 소요

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

지금까지 다루었던 DAO는 단순 DB에 저장하고 불러오는 기능만을 담당했다. 간단한 비즈니스 로직을 추가하는 것이 이 장의 목표다.

  1. 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나이다.
  2. 사용자의 초기 레벨은 BASIC, 활동에 따라 업그레이드된다.
  3. 가입 후 50회 이상 로그인시 BASIC 레벨에서 SILVER 레벨이 된다.
  4. SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD 레벨이 된다.
  5. 사용자 레벨의 변경작업은 일정한 주기를 가지고 일괄적으로 진행된다. 실시간으로 조건을 충족하더라도 즉각적으로 등급의 변경이 일어나지 않는다.

ENUM

User 클래스에 사용자의 레벨을 어떻게 담을 수 있을까? 등급의 “BASIC”과 같이 문자열 그대로를 삽입하는 것은 좋은 아이디어가 아닐 것이다. (DB의 저장공간을 비효율적으로 사용하기도 하고, 나중에 등급명의 변경이 일어난다면? 직접 문자열 그대로를 저장하는 대신 관계형 DB를 고려하는 것이 좋지만 이 책에서 다루는 범위는 아니기에 설명을 하지 않는다.)

각 등급에 해당하는 상수 값을 매핑해서 사용하는 방법이 있다.

class User{
		private static final int BASIC = 1;
		private static final int SILVER = 2;
		private static final int GOLD = 3;

		public void setLevel(int level){
				this.level = level;
		}
}

문제는, 1,2,3이 User 클래스에서는 유저의 레벨 정보를 의미하지만, 만약 숫자를 유저의 접근 권한으로 쓰는 클래스가 있다고 해보자. 그리고 프로그래머는 다음과 같은 엉뚱한 실수를 저지른다면, 타입 체킹에선 정수형이라 아무 문제가 일어나지 않지만 의미상으로는 굉장히 위험한 정보의 설정과 교환이 이루어 질 것이다.

혹은 3을 넘는 범위를 집어넣게 되면 예측할 수 없는 문제들이 생겨날 것이다.

user1.setLevel(otherClass.getAuthLevel());
user1.setLevel(20131201);

각 숫자에 의미를 부여하고 프로그래머가 조심하도록 사용하여 실수를 유발하는 정수타입 대신에, Enum을 사용하자.

public enum Level {
    BASIC(1), SILVER(2), GOLD(3); // ENUM 클래스의 인스턴스라 생각하기.

    private final int value;

    Level(int value) {
        this.value = value;
    }
		// Level.BASIC의 경우 value 필드가 1로 설정이 된다.

    public int intValue() {
        return value;
    }
		// 각 등급 Enum 인스턴스의 정수 값 반환

    public static Level valueOf(int value) {
        switch (value) {
            case 1: return BASIC;
            case 2: return SILVER;
            case 3: return GOLD;
            default: throw new AssertionError("Unknown value" + value);
        }
    }
		// 각 등급의 매핑된 정수를 입력하면 Enum 인스턴스 반환
}

user1.setLevel(Level.SILVER);

주의할 점은 JDBC에서 Enum 객체를 DB에 직접 집어넣을 수 없다. 대신 Level.intValue()를 정의했으니 각 Enum 인스턴스를 매핑된 정수로 변환하여 DB에 넣어야 한다.

MyBatis나 다른 JPA의 경우에는 Enum 객체를 매핑하면 각 Enum 인스턴스의 이름을 스트링으로 변환해 알아서 집어넣지만, 사용을 권장하지는 않는다.

사용자 수정기능 추가

먼저 테스트를 작성한다.

@Test
public void update() {
    dao.deleteAll();

    dao.add(user1);

    user1.setName("고전파");
    user1.setPassword("1557");
    user1.setLevel(Level.GOLD);
    user1.setLogin(1557);
    user1.setRecommend(36);
    dao.update(user1);

    User user1update = dao.get(user1.getId());
    checkSameUser(user1, user1update);
}

테스트부터 작성했으니 당연히 dao.update() 메서드는 존재하지 않는다.

@Override
public void update(User user) {
    this.jdbcTemplate.update(
            "update users set name = ?, password = ?, level = ?, login = ?, " +
            "recommend = ? where id = ? ", user.getName(), user.getPassword(),
            user.getLevel().intValue(), user.getLogin(), user.getRecommend(),
						user.getId());
}
// SQL에 문제가 있는 코드이다.

위 update() 메서드를 구현하면 테스트는 문제없이 통과가 되지만, 뭔가 찝찝한 기분이 들 것이다. WHERE는 update 구문에서 빠져도 문제가 없지만, 대신 모든 row가 update의 대상이 될 것이다. 즉 테이블의 전체 row가 변경될 것이라는 의미이다.

테스트를 좀 더 고도화하면 이러한 참사(?)를 막을 수 있다.

  1. SQL의 UPDATE, DELETE, INSERT등은 정수 값을 리턴하는데, 영향을 받은 row의 갯수를 반환한다. UPDATE의 경우, 변경된 row의 갯수를 반환할 것이다. 이를 확인하는 코드를 테스트에 삽입한다.
  2. 원하는 사용자 외의 정보는 변경되지 않음을 확인한다. 사용자를 다수 등록하고, 단 하나의 사용자만 수정한 다음, 영향받지 않아야할 다른 사용자의 정보가 그대로 있는지를 확인하는 방법이 있다.

사용자 관리 로직은 어디에 두어야 할까?

등급을 관리하기 위한 DB 기능은 모두 구현했다. 그러면 실질적으로 누구를 어떤 등급으로 설정할 지에 해당하는 사용자 관리 로직은 어디에 두어야 할까? DAO는 DB와 관련된 로직만을 담당하도록 두는 것이 좋다. 그 대신 UserService 클래스를 추가하자.

UserService 구현하기

UserDao를 인터페이스 타입으로 Bean을 DI받아서 사용한다. UserDao 구체 클래스의 구현이 바뀌어도 UserService는 영향을 받지 않아야한다. UserDao를 DI받기 위해선 당연히 UserService도 Spring Bean으로 등록되어야 한다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/954f66ee-7a88-436f-87b6-d8f6b3e45ec9/Untitled.png

source : https://leejaedoo.github.io/service_abstracting/

public class UserService {
    UserDao userDao;
		// UserDao를 주입받아서 저장하는 공간

    public void setUserDao(UserDao userDao) {
				// UserDao를 주입받는 Setter
        this.userDao = userDao;
    }
}

유저 레벨 조작 구현하기

DAO도 완성됐고, 비즈니스 로직을 다룰 UserService 클래스도 준비가 됐다/

public class UserService {
    //...
    public void upgradeLevels() {
        List<User> users = userDao.getAll();
        for (User user : users) {
						boolean isChanged = false;
            if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
                user.setLevel(Level.SILVER);
                isChanged = true; // 조건에 부합하여 유저 등급이 바뀌어야 함
            } else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
                user.setLevel(Level.GOLD);
                isChanged = true;
            }
						if(isChanged){ userDao.update(user); } // 등급 바뀔 시 업데이트 적용
        }
    }
}

처음 가입한 회원은 BASIC 등급이어야 한다.

UserDao는 시키는 DB 트랜젝션 수행에만 관심이 있어야 한다. 그렇기에 UserDao는 적절치 않고, UserClass의 초기값을 BASIC으로 설정하기에도 좀 이상하다.

UserService에 회원가입을 담당하는 add() 메서드를 만들고, 그 안에서 초기 레벨을 BASIC으로 두도록 하자. 만약 어떤 유저는 회원가입할 때 부터 GOLD 등급으로 시작할 수도 있다. 이 경우에는 BASIC으로 초기화하지 않아야 할 것이다.

public class UserService {
    //...
		public void add(User user){
			if(user.getLevel() == null) user.setLevel(Level.BASIC);
			userDao.add(user);
		}
}

코드 리팩토링

코드를 돌아보며 다음 사항들을 체크하자.

  • 코드에 중복된 부분이 있는가?
  • 코드가 어떤 역할을 하는지 이해하기 불편한가?
  • 코드가 있어야 할 위치에 있는가?
  • 앞으로 코드에 어떤 변화가 있을 수 있고, 그 변화에 쉽게 대응이 가능한가?
for (User user : users) {
    if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
				userSetLevel(...);
				isChanged = true;
		}
}

분기문의 조건이 너무 복잡하다.

  1. 현재 레벨이 무엇인지 파악하는 로직
  2. 업그레이드 조건을 담은 로직
  3. 어떤 등급으로 업그레이드 할 것인지 담는 로직
  4. 미래의 업데이트 여부를 판독하기 위한 플래그
  5. 실제 등급변화를 반영하는 로직

문제는 이런 조건블록이 레벨이 많아지면 많아질수록 반복되어 나타난다. Level Enum도 수정해야 한다. upgradeLevels()는 점점 복잡해지고 비대해질것이며 버그가 생기기도 쉬울 것이고 디버그 하기는 점점 어려울 것이다.

이 요소들을 작은 단위로 분리해보자.

업그레이드 가능한지 여부를 확인하는 메서드

	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);
							// 현재 로직에서 다룰 수 없는 레벨의 경우 예외를 발생한다.
							// 추후 레벨을 추가하였는데 이 로직을 수정하지 않는다면 예외를 띄울 것이다.
    }
}

여기서부터는 책의 내용과 달라집니다. 저는 책에서 제안한 내용에 더해, Effective Java에서 다루었던 내용을 섞어 새롭게 리팩토링할 예정입니다.

책에서 제안한 코드는 위와 같지만, Enum 인스턴스에 직접 승급 조건을 판단하는 로직을 넣는 것이 관심사의 분리 측면에서 올바르다고 생각한다.

public enum Level {
    BASIC(1){
  		public boolean isPossibleUpgrade(User user) { // 각 Enum 인스턴스에 업그레이드 조건 작성
  			return (user.getLogin() >= 50);
  		}
    },
    SILVER(2){
    	public boolean isPossibleUpgrade(User user) {
				return (user.getRecommend() >= 30);
			}
    },
    GOLD(3){
    	public boolean isPossibleUpgrade(User user) {
				return false; // 마지막 등급의 업그레이드는 불가능하기 때문에 항상 false
			}
    };

    private final int value;
		public abstract boolean isPossibleUpgrade(User user);
    Level(int value) {
        this.value = value;
    }
		// Level.BASIC의 경우 value 필드가 1로 설정이 된다.

    public int intValue() {
        return value;
    }
		// 각 등급 Enum 인스턴스의 정수 값 반환

    public static Level valueOf(int value) {
        switch (value) {
            case 1: return BASIC;
            case 2: return SILVER;
            case 3: return GOLD;
            default: throw new AssertionError("Unknown value" + value);
        }
    }
		// 각 등급의 매핑된 정수를 입력하면 Enum 인스턴스 반환
}
// class UserService
private boolean canUpgradeLevel(User user) {
    Level currentLevel = user.getLevel();
    if(currentLevel == null){
				throw new IllegalStateException("No level for this User.");
		}
		return currentLevel.isPossibleUpgrade(user);

}

실질적 레벨 업그레이드를 수행하는 메서드

// class User
private void upgradeLevel() {
      Level currentLevel = this.getLevel();
			if(currentLevel == null)
					throw new IllegalStateException("유저의 레벨이 존재하지 않습니다.");
      try {
					Level nextLevel = Level.valueOf(++currentLevel.intValue());
					this.setLevel(nextLevel);
			}
			catch(AssertionError e){
					// 승급할 레벨을 valueOf로 못 찾는 경우
					throw new IllegalStateException(this.level + " 업그레이드 불가. 이미 최고레벨입니다.");
			}
  }
// class UserService
private void upgradeLevel(User user){
		user.upgradeLevel();
		userDao.update(user);
}

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

무식하게 if문을 때려박고 여러 책임이 혼재되어 있던 코드가 깔끔하게 분리가 됐다. 객체지향에선 다른 객체의 데이터를 직접 받아 처리하는 것 보다는, 각 객체의 상태 변경은 각 객체에게 요청을 통해 해당 객체의 책임 하에 이루어 져야 한다는 점이다.

각자 자기 책임에 충실하니 디버그도 쉽고 코드 이해가 쉬워진다.

승급 조건을 숫자로 명시하기보단 상수로 두어 코드 가독성을 높이고 반복적인 조건의 변경을 쉽게 하는 것도 소소한 리팩토링이다.

5.2 트랜잭션 서비스 추상화

레벨 관리 작업을 수행하던 중에 네트워크나 DB의 문제로 작업을 완료하기 어렵다면 사용자의 레벨은 그대로 두어야 하는가?

고객간 차별 논란이 있을 수 있기에 여지를 주지 않기 위해 롤백하는 편이 좋을 것이다. 이러한 기능을 테스트하려면 실제로 DB나 네트워크에 장애를 절묘한 타이밍에 만들어 내야할까? 그럴 필요까지는 없이 오류가 발생하면 발생하는 예외를 던지도록 작성하면 될 것이다.

원본 코드를 수정하는 것은 좋은 생각이 아니다. 대신 UserService를 상속받고, upgradeLevel을 override하여 구현하면 장애상황에서의 대응을 테스트하는 좋은 UserService가 된다.

트랜잭션

트랜잭션은 나눌수 없는 atomic한 작업의 단위다. 몇 개의 작업이 하나의 트랜잭션에 묶이든 상관없이 하나의 작업처럼 취급이 된다. 트랜잭션은 모두 성공하던지 모두 실패하여야 한다. 만약 트랜잭션을 완료할 수 없다면, 아예 작업이 시작조차 안한 것 처럼 감쪽같이 돌려놓아야 한다.

롤백

하나의 트랜잭션이 완전히 실행되지 못했다면 앞서 실행된 일부분의 SQL 작업을 취소시켜야 하는데, 이를 Transaction Rollback이라고 한다.

커밋

하나의 트랜잭션은 여러 작업으로 이루어질 수도 있다. 하나의 트랜잭션을 가르는 기준이 커밋이다. 커밋은 게임의 세이브 포인트와 같다고 생각하면 된다. 커밋 이후 실행되는 rollback은 가장 최근의 commit을 한 상태로 돌아오게 된다. (물론 commit에 이름을 붙이고 가장 최근이 아닌 commit으로도 rollback할 수는 있다.)

JDBC의 트랜잭션

JDBC는 하나의 Connection을 열고 닫는 사이에서 일어난다.

autocommit 옵션은 기본이 True인데, 매번 변화가 생길 때 마다 DB에 반영이 되니 트랜잭션을 원하는 대로 지정하고 싶다면 우선 false로 설정한다. setAutoCommit()이 트랜잭션의 시작이 된다.

commit(), rollback()으로 트랜잭션의 종료하는 방법을 Transaction Demarcation(트랜잭션 경계설정)이라고 부른다.

Connection c = dataSource.getConnection();

c.setAutoCommit(false); // 트랜잭션 시작
try {
		PreparedStatement st1 = c.prepareStatement("(update sql)");
		st1.executeUpdate();
		PreparedStatement st2 = c.prepareStatement("(delete sql)");
		st2.executeUpdate();
		c.commit(); // 트랜잭션 커밋
} catch (Exception e) {
		c.rollback(); // 트랜잭션 롤백
}

c.close();

UserDao를 작성하면서 JdbcTemplate를 구현하여 이용하면서 Connection을 직접 조작할 기회가 없었다. 그렇기에 트랜잭션 설정을 하지 못하는 상황이었다.

트랜잭션은 커넥션의 존재 범위가 짧은데, 템플릿 메서드가 호출될 때마다 트랜잭션이 생성되고 메서드를 빠져나오기 전에 종료가 된다.

userDao.update()를 수행할 때 마다 커넥션이 열고 닫히며 메서드 호출 한 번당 하나의 트랜잭션이 생겨나는 셈이지만 이걸 원하는 것이 아니기에 트랜잭션 설정을 위해 손을 좀 볼 필요가 있다.

첫 번째 아이디어 : 커넥션 관리를 UserService에 맡기기

UserService와 UserDao를 그대로 둔 채 트랜잭션을 적용하려면 경계설정 작업을 Service로 가져와야 한다. SQL이나 JDBC API를 이용한 데이트 코드를 그대로 두고 트랜잭션 부분만 가져오면 관심사 분리를 유지하면서 해결할 수 있다. upgradeLevels() 메서드 안에 커넥션의 종료와 시작을 수행해야 한다.

  1. DB 커넥션의 깔끔한 처리를 위해 JdbcTemplate를 작성했는데, 이 깔끔한 관심사의 분리는 더이상 유지가 되지 않는다.
  2. 비즈니스 로직만을 담고 있는 UserService의 메서드에 Connection 파라미터가 추가되고, 이 Connection은 모든 메서드에 파라미터를 통해서 전달되어야 한다. 코드가 지저분해진다.
  3. Connection 파라미터가 UserDao에 추가되면 데이터엑세스 기술에 독립적일 수가 없다. JDBC에 코드가 과하게 의존을 하게 된다.

두 번째 아이디어 : 독립적인 트랜잭션 동기화

UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를다른 곳에 저장해두고, DAO 메서드가 이 Connection을 사용하도록 한다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/65c3c9ec-22d5-4039-a835-3625f0f3a7db/Untitled.png

source : https://vvshinevv.tistory.com/71

update()를 3번 수행하는 동안 Connection은 죽지 않으며, autocommit=false, 그리고 각 dao.update()마다 commit()과 rollback()이 로직에 따라 수행될 것이다.

멀티쓰레드 환경에서도 안전하기 트랜잭션 구현하기

스프링에서는 멀티쓰레드 환경에서도 안전한 트랜젝션을 구성할 수 있도록 유틸리티메서드를 제공한다.

public void upgradeLevels() throws Exception {
    TransactionSynchronizationManager.initSynchronization();
		// 동기화 작업 시작.
    Connection c = DataSourceUtils.getConnection(dataSource);
		// DataSource에서 직접 가져오는 대신 DataSourceUtils에서
		// Connection을 가져오는 이유는, ThreadSafe한 저장소에 바인딩하기 위함이다.
    c.setAutoCommit(false);
    try {
        List<User> users = userDao.findAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user); // 하나의 커넥션으로 반복적인 쿼리 실행
            }
        }
        c.commit();
    } catch (Exception e) {
        c.rollback();
    } finally {
        DataSourceUtils.releaseConnection(c, dataSource);
        TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization();
				// 동기화 작업 종료 및 정리
    }
}

물론 UserService에 dataSource를 DI 해야 한다.

JdbcTemplate와 트랜잭션 동기화

이미 작성한 JdbcTemplate는 영리하게 동작한다.

만약 트랜잭션 동기화 저장소에 별도로 생성된 커넥션/트랜젝션이 없는 경우 원래 하던대로 커넥션을 생성한다. DataSourceUtils 및 TransactionSynchronizationManager로 커넥션을 생성했다면 새로 생성된 커넥션을 쓰도록 되어있다.

트랜젝션 서비스 추상화

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

시나리오 1

어떤 회사에서는 다수의 DB를 백업 목적으로 쓰고 있다. 여러 DB를 하나의 트랜젝션에 하길 원하지만, JDBC Connetion을 이용한 로컬 트랜젝션은 하나의 DB에 종속되기 때문에, 별도의 트랜젝션 관리자를 이용한 글로벌 트랜젝션을 사용해야 한다. 여러 개의 DB를 하나의 트랜잭션으로 관리할 수 있다. JDBC 이외의 글로벌 트랜젝션을 제공하는 Java Transaction API가 있다.

트랜젝션 매니저는 DB와 각 서버를 제어하고 관리하는 리소스 매니저와 XA 프로토콜을 이용해 연결된다. 어플리케이션은 JTA를 통해 다수 DB와 서버를 관리할 수 있다.

public void upgradeLevels() throws Exception {
    InitialContext ctx = new InitialContext();
    UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);
    tx.begin();
    Connection c = dataSource.getConnection();
    try {
        // 데이터 액세스 코드
        tx.commit();
    } catch (Exception e) {
        tx.rollback();
        throw e;
    } finally {
        c.close();
    }
}

생소한 클래스와 메서드가 보이지만 전체적인 흐름은 유사하다. 문제는 JDBC 로컬 트랜젝션을 JTA로 바꾸는 변화가 필요하단 점이다. UserService는 비즈니스 로직을 관장하는데 이게 변하지 않았음에도 기술의 변화에 따라 코드가 바뀌는 것이 문제다.

JTA 뿐 아니라 하이버네이트 등 JPA 등의 기술을 사용하더라도 비즈니스 로직이 변하지 않는다면 코드는 변하지 않도록 하고 싶다. 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는다.

JDBC, JTA, 하이버네이트, JPA, JDO, JMS 등 다양한 기술들에 트랜잭션 개념이 있으니, 추상화로 개선할 수 있을 것이다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f9f3bae3-715c-49f5-ad72-d8136755a3da/Untitled.png

source : https://xlffm3.github.io/spring & spring boot/toby-spring-chapter5/

@Autowired
private PlatformTransactionManager transactionManager // 변수 이름이 transactionManager인 것이 컨벤션이다.
			= new DataSourceTransactionManager(dataSource);
			// 사용할 DB의 DataSource를 DataSourceTransactionManager 생성자 파라미터로 넘겨 객체를 만든다.
			// JTA로 바꾸고 싶다면 JTATransactionManager 객체를 생성하면 된다.

public void upgradeLevels() {
    TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
		//getTransaction() 메서드를 호출하기만 하면 트렌젝션 시작 뿐 아니라 커넥션까지 가져올 수 있다
		// 트랜젝션은 TransactionStatus 타입의 변수에 저장된다.
    try {
        List<User> users = userDao.getAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
        this.transactionManager.commit(status);
    } catch (RuntimeException e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

코드가 어떤 구현 클래스를 쓸 지 아는 것은 것은 DI 원칙에 위반되므로, 주입식으로 이용하는 것이 좋다. 그렇다면 DataSourceTransactionManager는 스프링 빈으로 등록해야 하는데, 싱글턴으로 사용이 가능하므로 싱글턴으로 만들어 사용한다.

댓글남기기