4장에서 데이터베이스에 접근하는 기술이 다양함을 볼 수 있었듯이, 여러 환경이나 상황에서 기술이 바뀌고 다른 API를 사용하기 때문에 성격이 비슷한 여러 기술을 추상화하고 이를 일관되게 사용할 수 있도록 하는 것은 중요하다.
데이터를 가져오고 입력하는 역할을 하는 UserDao에 사용자의 레벨을 관리하는 기능을 추가하고 싶다.
그러나 레벨 관리와 관련한 복잡한 비즈니스 로직은 UserDao가 아닌 비즈니스 로직 서비스를 제공한다는 의미에서 UserService에서 관리한다.
- UserService는 UserDao의 빈을 DI 받아 사용한다.
- UserDao의 구현 클래스가 바뀌어도 UserService는 영향을 받지 않는다
public class UserService {
UserDao userDao;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
}
UserService
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);
}
}
}
본격적으로 로직을 추가해보자.
모든 유저에 대한 레벨을 검증하고 일일이 DB에 값을 주입하는 것으로 구현하였다.
@BeforeEach
public void setUp() {
users = Arrays.asList(
new User("bumjin", "박범진", "p1", Level.BASIC, 49, 0)
, new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0)
, new User("erwins", "신승한", "p3", Level.SILVER, 60, 29)
, new User("madnite1", "이상호", "p4", Level.SILVER, 60, 30)
, new User("green", "오민규", "p5", Level.GOLD, 100, 100)
);
}
@Test
@DisplayName("사용자 레벨 업그레이드 테스트")
public void upgradeLevels() {
for (User user : users) {
userDao.add(user);
}
userService.upgradeLevels();
checkLevel(users.get(0), Level.BASIC);
checkLevel(users.get(1), Level.SILVER);
checkLevel(users.get(2), Level.SILVER);
checkLevel(users.get(3), Level.GOLD);
checkLevel(users.get(4), Level.GOLD);
}
private void checkLevel(User user, Level expectedLevel) {
User userUpdate = userDao.get(user.getId());
Assertions.assertEquals(userUpdate.getLevel(), expectedLevel);
}
다양한 레벨의 사용자 리스트를 테스트 픽스처로 생성하고, 로그인 횟수와 추천 횟수를 기준으로 레벨이 업그레이드 될 수 있는 경계값으로 내부 필드를 설정하였다. 그리고 레벨을 업그레이드하는 테스트를 작성하면 테스트가 잘 통과한다.
upgradeLevels는 현재 변화에 취약하고 다루기 힘든 코드이다.
분기문이 레벨 개수만큼 반복되고 있고, 업그레이드 조건이나 과정이 복잡해지면 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);
}
업그레이드가 가능한지 체크하는 메서드를 생성하고, switch를 사용해서 업그레이드의 조건을 만족하는 지 확인해주었다. 그리고 조건이 만족하여 if 문 안에 진입하였다면, 레벨을 다음 단계로 변경해주거나 변경사항을 DB에 업데이트해주는 코드를 작성하였다.
그러나 여전히 Level이 늘어남에 따라 if문이 늘어나는 현상이 해결되지 않았고, 레벨 변경 시 오브젝트에서 다른 필드의 값도 같이 변경하는 요구사항이 생긴다면 함수가 너무 복잡해질 것이다. 따라서 Level Enum class에게 이 작업을 맡기는 것으로 해결해보자.
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);
};
}
}
Level 클래스에 다음 단계의 레벨 정보를 스스로 결정할 수 있도록 변수를 추가하였다.
public void upgradeLevel() {
Level nextLevel = this.level.nextLevel();
if (nextLevel == null) {
throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");
} else {
this.level = nextLevel;
}
}
그럼 이렇게 Level 객체에서 가져온 정보를 바로 대입하는 것으로 코드를 개선할 수 있다.
트랜잭션 서비스 추상화
트랜잭션 경계 설정
사용자 레벨 조정과 같은 작업을 처리하다가 네트워크나 시스템 상의 문제로 오류가 발생했다고 하자. 그럼 업그레이드 작업은 중단되고 이전 상태로 되돌아갈까? 이러한 환경을 테스트하는 것은 쉽지 않으므로, 의도적으로 애플리케이션 코드 중간에 시스템이 중단되는 코드를 작성한다.
UserService에서 upgradeLevels() 메서드의 접근 제한자를 잠시 protected로 변경하고, 이를 상속하는 임시 UserService 클래스를 생성한다
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 {
}
}
여기에서 오버라이드 된 메소드는 미리 지정된 아이디를 가진 사용자가 발견되면 강제로 예외를 던지도록 구현하였다. 이 테스트용 클래스에 대한 테스트를 진행해보자.
@Test
public void upgradeAllOrNothing() {
for (User user : users) {
userDao.add(user);
}
User joytouch = users.get(1);
User madnite1 = users.get(3);
TestUserService testUserService = new TestUserService(madnite1.getId());
testUserService.setUserDao(userDao);
testUserService.setUserLevelUpgradePolicy(userService.getUserLevelUpgradePolicy());
Assertions.assertThrows(TestUserService.TestUserServiceException.class, () -> {
testUserService.upgradeLevels();
});
User joytouchDb = userDao.get(joytouch.getId());
User madnite1Db = userDao.get(madnite1.getId());
System.out.println("joytouch = " + joytouch.getLevel());
System.out.println("joytouchDb = " + joytouchDb.getLevel());
System.out.println("madnite1 = " + madnite1.getLevel());
System.out.println("madnite1Db = " + madnite1Db.getLevel());
Assertions.assertEquals(joytouch.getLevel(), joytouchDb.getLevel());
Assertions.assertEquals(madnite1.getLevel(), madnite1Db.getLevel());
}
픽스쳐에 등록된 유저를 추가하고 업그레이드를 시도한다. 이 때 첫 번째 유저 업그레이드 되고 두번째 유저를 업그레이드 시도하는 과정에서 오류가 발생하면, 첫번째 유저의 업그레이드 정보는 유지된다.
즉 트랜잭션은 각각 이루어진다는 것을 알 수 있고, 이는 UserDao에 일일이 update 요청을 넘길 때 메서드 내에서 트랜잭션이 새로 시작되고 새로 끝나기 때문이다.
이런 경우 트랜잭션의 경계 설정을 직접 수행해야하는데, 서비스와 Dao 로직을 분리한 이상 트랜잭션의 경계를 동일하게 가져가는 것은 힘들다. 하나의 해결책으로는 서비스 로직 내에 Connection 객체를 생성하고, UserDao 레이어까지 이 Connection 객체를 전달하여 하나의 트랙잭션으로 로직이 작동하도록 하는 것이다.
이렇게 구현하는 경우, 아래와 같은 문제점이 있다.
- JdbcTemplate를 활용할 수 없다.
- DAO의 메서드와 비즈니스 로직의 모든 메서드에 Connection 객체가 파라미터로 추가되어야 한다.
- Connection 대신 EntityManager이나 Session 등의 오브젝트를 사용하기를 원하는 경우, 변경하는 것이 자유롭지 않다.
트랜잭션 동기화
우선, UserService의 upgradeLevels() 메서드가 트랜잭션 경계 설정을 해야한다는 사실은 여전하다. 그러나 Connection 객체를 일일이 전달하지 않기 위해 스프링에서는 트랜잭션 동기화라는 기능을 제공한다.
메서드가 시작했을 때 생성된 Connection 오브젝트를 보관하고, 이후 호출되는 DAO에서 이를 그대로 가져다쓰는 방식이다.
저장소에 저장된 Connection 오브젝트는 별도의 commit() 요청이 있기 전까지 계속 열려있으며 트랜잭션을 진행 중인 상태로 유지한다. commit()을 호출하게 되면 저장소에서 이 Connection 객체를 삭제한다. 이 저장소는 스레드마다 독립적으로 저장하기 때문에 충돌 위험이 없다.
JdbcTemplate는 이 트랜잭션 동기화를 간단하게 사용할 수 있도록 지원하고 있는데,
public class UserService {
UserDao userDao;
DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public void upgradeLevels() throws SQLException{
// 트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화
TransactionSynchronizationManager.initSynchronization();
// 커넥션 객체 생성 및 트랜잭션 시작
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.releaseConnection(c, dataSource);
// DB 커넥션을 안전하게 닫는 작업
TransactionSynchronizationManager.unbindResource(this.dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
트랜잭션 동기화 관리자에게 동기화를 시작하겠습니다! 라고 초기화 명령을 내려주면, DataSourceUtils에서 Connection을 생성하고 setAutoCommit(false) 메서드를 실행해주기만 하면 자동으로 DB 커넥션 생성과 트랜잭션 동기화에 사용될 수 있도록 저장소에 이동해준다.
트랜잭션 서비스 추상화
위에서 설명한 것처럼 하나의 DB Connection에 종속하여 작업을 트랜잭션 단위로 처리하는 것을 로컬 트랜잭션(Local Transaction)이라고 한다. 반면에, 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 방법을 글로벌 트랜잭션 (Global Transaction)이라고 한다. JDBC는 로컬 트랜잭션을 지원하지만, 요구사항에 의해 여러가지 DB Connection 오브젝트를 하나의 트랜잭션 안에서 사용해야하는 경우 JTA(Java Transaction API)에 의해 글로벌 트랜잭션을 사용할 수 있다.
JTA란?
JTA는 DB 관리는 JDBC, 메시징 서버는 JMS(Java Message Service) 와 같은 API를 사용해서 관리한다. 단, 트랜잭션은 JTA 내의 트랜잭션 매니저가 관리하는 방식이다.
위와 같은 구조로 트랜잭션 매니저가 DB와 메시징 서버의 트랜잭션을 종합적으로 제어하여 여러 개의 Connection을 동시에 사용할 수 있게 된다.
JTA를 사용하여 트랜잭션을 처리하는 코드는 아래와 같다.
// JNDI를 이용해 서버의 Transaction 오브젝트를 가져온다.
InitialContext ctx = new InitialContext();
UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);
tx.begin();
// JNDI(Java Naming and Directory Interface)로 가져온 dataSource를 사용해야 한다.
Connection c = dataSource.getConnection();
try {
// 데이터 액세스 코드
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
} finally {
c.close();
}
여기서도 여러가지 문제가 있는데, 정리해보면
- JDBC에서 JTA로 기술이 바뀐 탓에 UserService의 코드를 수정해야한다는 점
- Connection이 아닌 Session이나 기타 오브젝트를 생하는 UserDao가 있다면 어떤 식으로 처리해야 하나?
즉, UserService가 UserDao에 의존하면서도 JDBC라는 특정 기술을 통해 트랜잭션을 관리하고 있기 때문에 인터페이스가 아닌 구현체에 종속되어 있다는 문제가 생긴다.
이 때에는 많은 기술들에 대한 공통점을 찾아야하고, 이를 추상화하여 상위 계층의 무언가를 만들어야 한다. 트랜잭션 처리 코드에는 트랜잭션의 경계를 설정하는 작업을 한다는 공통점이 있다.
스프링에서는 이 공통점을 활용한 트랜잭션 추상화 계층 구조를 이미 가지고 있는데,
이 추상화 계층을 활용해 upgradeLevels()를 PlatformTransactionManager 인터페이스를 사용해 추상화하여 작성할 수 있다.
public void upgradeLevels() {
// JDBC 트랜잭션 추상 오브젝트 생성
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(datasource);
// 트랜잭션 시작
TransactionStatus status =
transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
transactionManager.commit(status);
}catch(Exception e) {
transactionManager.rollback(status);
throw e;
}
}
getTransaction() 메서드를 호출하여 트랜잭션을 시작하고, 트랜잭션 동기화 저장소에 이 트랜잭션을 저장한다. 작업을 모두 수행하면, TransactionStatus 오브젝트를 파라미터로 하여 트랜잭션 커밋을 수행하고 예외가 발생하면 롤백한다.
현재 코드에서는 JDBC를 이용한 트랜잭션 추상화 API를 구현하였다. 그런데 JTA나 하이버네이트 등을 사용한다면 JTATransactionManager, HibernateTransactionManager등 트랜잭션 매니저 구현 클래스를 서로 다르게 사용해야한다. 따라서 UserService에서 PlatformTransactionManager라는 추상화 계층의 상위 클래스를 사용해 코드를 개선해본다.
public class UserService {
UserDao userDao;
DataSource dataSource;
PlatformTransactionManager transactionManager;
public void upgradeLevels() {
// 트랜잭션 시작
TransactionStatus status =
transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
transactionManager.commit(status);
}catch(Exception e) {
transactionManager.rollback(status);
throw e;
}
}
transactionManager를 만들어서 빈을 주입해주면 UserService에서 PlatformTransactionManager 인터페이스에서 실제로 내가 사용할 트랜잭션 매니저 구현 클래스가 담기는 것이다.
UserDao와 UserService는 담당하는 코드의 기능적인 관심에 따라 분리되었다.
서비스 추상화와 단일 책임 원칙
서비스 추상화 기법을 적용하면 특정 기술 환경에 종속되지 않고, 확장 가능한 코드를 만들 수 있다.
트랜잭션의 추상화는 이와 다르게 비즈니스 로직과 별개로 그 하위의 로우 레벨의 트랜잭션 기술이라는 계층의 특성을 갖는 코드를 분리한 것이다.
애플리케이션 계층만 보았을 때 UserService는 비즈니스 로직을 담당하고, UserDao는 데이터를 어떻게 가져오고 등록할 것인가에 대한 데이터 관련 로직을 담당하고 있다. 이렇게 서로 다른 역할을 하는 것이 하나의 계층에서 DI를 통해 연결되어있다. 마찬가지로 UserDao는 데이터베이스를 연결하기 위해 어떤 라이브러리를 사용하던 구체적인 트랜잭션 기술에 얽매이지 않는 특성을 띈다. 이 역시 DI로 구현한 덕분이다.
'DEV book > 토비의 스프링 3.1' 카테고리의 다른 글
[토비의 스프링 3.1] 6장 - AOP (1) 직접 구현하는 고립된 단위 테스트 (0) | 2024.01.20 |
---|---|
[토비의 스프링 3.1] 4장 예외 - 예외 처리 전략과 DataAccessException을 사용한 JDBC 한계 극복 (0) | 2024.01.16 |
[토비의 스프링 3.1] 3장 템플릿 (3) - 템플릿과 콜백 (0) | 2023.09.24 |
[토비의 스프링 3.1] 3장 템플릿 (2) - JdbcContext를 UserDao에서 사용하는 두 가지 방법 (0) | 2023.09.24 |
[토비의 스프링 3.1] 3장 템플릿 (1) - 디자인 패턴과 DI를 이용한 DAO 최적화 (0) | 2023.09.23 |