Java, Spring

[토비의 스프링] 서비스 추상화 - 트랜잭션

집사킴 2025. 4. 20. 23:20
728x90

작성된 코드에 대해 질문해보기

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

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

기존 코드의 문제점

  • 일단 for 루프 속에 들어 있는 if/elseif/else 블록들이 읽기 불편하다.
  • 게다가 이런 if 조건 블록이 레벨 개수만큼 반복된다. 만약 새로운 레벨이 추가된다면 Level 이늄도 수정해야 하고 upgradeLevels() 의 레벨 업그레이드 로직을 담은 코드에 if 조건식과 블록을 추가해줘야 한다.
  • 현재 레벨과 업그레이드 조건을 동시에 비교하는 부분도 문제가 될 수 있다.

 

추상화를 사용한 리팩토링

// 간결해진 upgradeLevel()
private void upgradeLevel(User user) {
	user.upgradeLevel();
	userDao.update(user);
}
public void upgradeLevel() {
	Level nextLevel = this.level.nextLevel();
	if (nextLevel == null) {
		throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다");
	}
	else {
		this.level = nextLevel;
	}
}
// 레벨의 순서와 디음 단계 레벨이 무엇인지를 결정하는 일은 Level 에게 맡기자.
public enum Level {
	GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);
	
	private final int value;
	private final Level next;
	
	public Level nextLevel() {
		return this.next;
	}
}
  • 지금 개선한 모드를 살펴보면 각 오브젝트와 메소드가 각각 자기 몫의 책임을 맡아 일을 히는 구조로 만들졌음을 알 수 있을 것이다.
  • 객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구 하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기 도 하다.

 

UserServiceTest 개선

@Test
public void upgradeLevels() {
	userDao.deleteAll();
	for(User user : users) userDao.add(user);
	
	userService.upgradeLevels();
	
	// 개선한 upgradeLevels() 테스트는 각 사용자 에 대해 업그레이드를 확인하려는 것인지 아닌지가 
	// 좀 더 이해하기 쉽게 true , false 로 나타나 있어서 보기 좋다.
	checkLevelUpgraded(users.get(0), false);
	checkLevelUpgraded(users.get(1), true);
	checkLevelUpgraded(users.get(2), false);
	checkLevelUpgraded(users.get(3), true);
	checkLevelUpgraded(users.get(4), false);
}

private void checkLevelUpgraded(User user, boolean upgraded) {
	User userUpdate = userDao.get(user.getId());
	if (upgraded) {
		assertThat(userUpdate.getLevel(), is(user.getLevel()).nextLevel());
	}
	else {
		assertThat(userUpdate.getLevel(), is(user.getLevel()));
	}
}

 

상수 도입하기

case BASIC: return (user.getLogin() >= 50); // userService
...
new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0); // userServiceTes

이런 상수 값을 중복하는 건 바람직하지 못하다. 기준이 되는 최소 로그인 횟수가 변경될 때도 한 번만 수정할 수 있도록 만들자. 가장 좋은 방법은 정수형 상수로 변경하는 것이다.

// userService
public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_FOR_GOLD = 30;
...
case BASIC: return (user.getLogin() >= MIN_LOGCOUNT_FOR_SILVER); 
case SILVER: return (user.getRecommend() >= MIN_RECCOMEND_FOR_GOLD);

// userServiceTest
import static springbook.user.service.UserService.MIN_LOGCOUNT_FOR_SILVER;
import static springbook.user.service.UserService.MIN_RECCOMEND_FOR_GOLD;

@Before
public void setUp() (
	users = Arrays.asList(
		new User("bumjin", "박범진", "p1", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER-1, 0),
		new User("joytouch", "강명성", "p2", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER, 0),
		new User("erwins", "신승한", "p3", Level.SLIVER, 60, MIN_RECCOMEND_FOR_GOLD-1),
		new User("madnite1", "이상호", "p4", Level.SLIVER, 60, MIN_RECCOMEND_FOR_GOLD),
		new User("green", "오민규", "p5", Level.GOLD, 100, Integer.MAX_VALUE)
	);
}

 

정책 인터페이스 정의

좀 더 욕심을 내자면 레벨을 업그레이드하는 정책을 유연하게 변경할 수 있도록 개선하는 것도 생각해볼 수 있다.

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

 

트랜잭션 서비스 추상화

트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다. (= 모 아니면 도)

DB는 그 자체로 완벽한 트랜잭션을 지원한다. 하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 히는 경우도 있다.

  • 롤백 : 처리 중이던 작업 전체 취소
  • 커밋 : 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시키는 것

 

트랜잭션 경계설정

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

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class JdbcTransactionExample {

    public void executeTransaction(DataSource dataSource) {
        Connection c = null;
        try {
            c = dataSource.getConnection();
            c.setAutoCommit(false); // 트랜잭션 시작

            // 첫 번째 쿼리 실행
            PreparedStatement st1 = c.prepareStatement("update users ...");
            st1.executeUpdate();

            // 두 번째 쿼리 실행
            PreparedStatement st2 = c.prepareStatement("delete users ...");
            st2.executeUpdate();

            // 트랜잭션 커밋
            c.commit();

        } catch (SQLException e) {
            // 예외 발생 시 롤백
            if (c != null) {
                try {
                    c.rollback(); // 트랜잭션 롤백
                } catch (SQLException ex) {
                    ex.printStackTrace();
                }
            }
        } finally {
            // 연결 닫기
            if (c != null) {
                try {
                    c.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에서 일어난다.

로컬 트랜잭션 : 하나의 DB 커넥션 안에서 만들어지는 트랜잭션

 

비즈니스 로직 내의 트랜잭션 경계 설정하는 여러 방법 시도

  • DAO 메소드 안으로 upgradeLevels() 메소드의 내용을 옮기는 방법
  • → 비즈니스 로직과 데이터 로직을 한데 묶어버리는 한심한 결과를 초래한다
  • UserService와 UserDao를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다.
  • UserService에서 만든 Connection 오브젝트를 UserDao에서 사용하려면 DAO 메소드를 호출할 때마다 Connection 오브젝트를 파라미터로 전달해줘야 한다.
public interface UserDao {
	public void add(Connection c, User user);
	public User get(Connection c, String id);
	...
	public void update(Connection c, User user);
}

문제

  • DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다
  • DAO의 메소드와 비즈니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다. 그 사이의 모든 메소드에 걸쳐서 Connection 오브젝트가 계속 전달돼야 한다.
  • Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더 이상 데이터 액세스 기술에 독립적일 수가 없다
  • 테스트 코드에도 영향을 미친다. 지금까지 DB 커넥션은 전혀 신경 쓰지 않고 테스트에서 UserDao를 사용 할 수 있었는데, 이제는 테스트 코드에서 직접 Connection 오브젝트를 일일이 만들어서 DAO 메소드를 호출하도록 모두 변경해야 한다.

 

트랜잭션 동기화 (Transaction synchronization )

  • 트랜잭션의 시작부터 끝까지의 흐름에 맞춰 특정 작업을 함께 수행할 수 있게 해주는 메커니즘
  • 주로 Spring 프레임워크와 함께 사용할 때 의미가 큼
  • Spring에서는 트랜잭션이 시작되면 스레드 로컬(ThreadLocal)을 통해 트랜잭션 컨텍스트를 관리
  • 트랜잭션이 커밋되거나 롤백될 때 콜백을 등록해서 동기화 작업을 할 수 있도록 해줌
  • 트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌 날 염려 없음

→ UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다가 사용

 

트랜잭션 동기화 방식을 적용

 

트랜잭션 동기화 방식을 적용한 UserService

private DataSource dataSource;

public void setDataSource(DataSource dataSource) {
    this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {
    // 트랜잭션 동기화 관리
    TransactionSynchronizationManager.initSynchronization();

    // 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 {
        // 트랜잭션 작업 종료 후 DB 연결 정리
        DataSourceUtils.releaseConnection(c, dataSource);
        TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization();
    }
}

 

 

트랜잭션 서비스 추상화

이렇게 여러 기술의 사용방법에 공통점이 있다면 추상화를 생각해볼수 있다. 추상화란 하위 시스템의 공통점 뽑아내서 분리시키는 것을 말한다.

PlatformTransactionManager라는 인터페이스로 트랜잭션을 추상화.

JDBC Connection, Hibernate, JTA Global Transaction 등 니즈에 맞게 구현해서 사용.

 

public class UserService {
  // ...
	private PlatformTransactionManager transactionManager;
	
	// 트랜잭션매니저 인터페이스 빈으로 정의하여 setter 주입
	public void setTransactionManager(PlatformTransactionManager transactionManager) {
		this.transactionManager = transactionManager;
	}
	
	public void upgradeLevels() {
	
		// get transaction
		TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
		
		try {
			List<User> users = userDao.getAll();
			for (User user : users) {
				if (canUpgradeLevel(user)) {
					upgradeLevel(user);
				}
			}
			this.transactionManager.commit(status); // commit
		
		} catch (Exception e) {
			this.transactionManager.rollback(status); // rollback
			throw e;
		}
	}
	// ...
}

어떤 클래스든 스프링의 빈으로 등록할 때 먼저 검토해야 할 것은 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은가 하는 점이다. 상태를 갖고 있고, 멀티스레드 환경에서 안전하지 않은 클래스를 빈으로 무작정 등록하면 심각한 문제가 발생하기 때문이다.

 

서비스 추상화

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

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

수직 계층 구조 (Vertical layering): 도메인별로 나눈 구조

/user
  - UserController
  - UserService
  - UserRepository
/order
  - OrderController
  - OrderService
  - OrderRepository

수평 계층 구조 (Horizontal layering): 전통적인 3계층 구조

controller
└── service
    └── repository

애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며, 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 스프링의 DI가 중요한 역할을 하고 있다. DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.

728x90