서비스 추상화
사용자 레벨 관리 기능 추가
스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 일관된 방법으로 사용할 수 있도록 지원하는지 확인해보자
사용자 레벨 관리 기능 추가
지금까지 짯던 코드들은 DAO 를 이용한 간단한 CRUD 였는데,
지금부터는 비즈니스 로직을 조금 추가 해보자
사용자의 활동내역을 참고해 레벨을 조정해주는 기능이다.
스케줄링으로 정해진 시간에 사용자가 정해진 조건을 만족시켰는지 확인 후 레벨을 변경시킨다.
필드 추가
public class User {
private static final int BASIC = 1;
private static final int SILVER = 2;
private static final int GOLD = 3;
private int level;
public void setLevel(int level){
this.level = level;
}
}
만약 이렇게 상수로 관리하면 문제들이 생긴다.
예를들어 의도한 값이 아닌 다른 인트값이 들어가도 컴파일러단에서는 에러가 나지 않는다는점 등등..
이런식으로 의도와 다른 값이 넣어질 수 있다.
user1.setLevel(other.getSum())
user1.setLevel(1000);
이런 문제는 컴파일러단에서 막을 방법이 있다.
enum 타입을 사용하는 것
public enum Level {
BASIC(1),
SILVER(2),
GOLD(3)
;
private final int value;
Level(int value){
this.value = value;
}
public int value(){
return value;
}
}
이렇게 enum 을 만들면 enum 객체에 해당하는 값만 넣을 수 있으니 컴파일러단에서 에러를 방지 할 수 있게 된다.
이런식으로 User 객체를 만들었다.
@Getter
@Setter
@NoArgsConstructor
public class User {
private String id;
private String name;
private String password;
private Level level;
private int login;
private int recommend;
@Builder
public User(String id, String name, String password, Level level, int login, int recommend) {
this.id = id;
this.name = name;
this.password = password;
this.level = level;
this.login = login;
this.recommend = recommend;
}
}
사용자 수정 기능 추가
사용자 정보는 여러번 변경이 될 수 있다.
또 사용자 기능은 다른 클래스들이 호출 할 수 있으니 UserService 로 생성해둔다.
public class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
....
}
서비스에 유저 레벨업을 할 수 있는 로직을 넣어뒀다.
public List<User> upgradeLevels(){
List<User> users = (List<User>) userRepository.findAll();
for(User user : users){
boolean changed = false;
if(user.getLevel() == Level.BASIC &&user.getLogin() >= 50){
user.updateLevel(Level.SILVER);
changed = true;
}
else if(user.getLevel() == Level.SILVER &&user.getLogin() >= 30){
user.updateLevel(Level.GOLD);
changed = true;
}else if(user.getLevel() == Level.GOLD){
changed = false;
}else{
changed false;
}
if(changed){
userRepository.save(users)
}
}
return users;
}
코드 개선
여기까지 코드를 개선해보자
리팩토링을 위해 다음 사항을 체크해봐야한다.
1. 코드에 중복된 부분은 없는가
2. 코드가 무엇을 하는 코든지 이해가 쉬운지
3. 코드가 자신이 있어야 할 곳에 있는지
4. 앞으로 변경이 되면 어떤 변경들이 있을 수 있고 변화에 쉽게 대응 가능한가
upgradeLevels 문제점
먼저 for문 안에 if/else if/else 블록들이 읽기 불편하다.
레벨의 변화 단계,
업그레이드 조건,
조건이 충족 됐을때 할 작업 들이 섞여 있어서 이해하기 힘들다.
성격이 다른 로직이 한데 섞여 있기 때문이다.
upgradeLevels 리팩토링
로직을 조금 분리해보자.
1. 유저 레벨을 업그레이드
2. 모든 유저를 조회
3. 유저는 업그레이 할 수 있는지
4. 업그레이드
public void upgradeLevels(){
List<User> users = (List<User>) userRepository.findAll();
for(User user : users){
if(canUpgradeLevel(user))
upgradeLevel(user);
}
}
private boolean canUpgradeLevel(User user){
Level currentLevel = user.getLevel();
switch (currentLevel){
case BASIC: return (user.getLogin() >= 50);
case SILVER: return (user.getLogin() >= 30);
case GOLD: return false;
default: throw new IllegalArgumentException("Unknown Level");
}
}
뭐 이런식으로 리팩토리들이 가능하다.
트랜잭션 서비스 추상화
시스템 수행 도중 문제가 생겼을때 어떻게 처리할지 다루어 보자
만명의 유저가 있고 전체 업그레이드를 해야하는데 천명 정도 되었을때 예상치못한 에러가 발생했다고 가정해보면
전체를 롤백시켜여한다.
테스트 케이스를 만들어보자
UserService 를 바로 바꾸지말고 테스트를 위해 기능들 모두를 상속받는 서브클래스를 만들어서
거기서 테스트를 해본다.
public class TestUserService extends UserService{
private String id;
public TestUserService(UserRepository userRepository, String id) {
super(userRepository);
this.id = id;
}
protected void upgradeLevel(User user){
if(user.getId().equals(this.id)) throw new TestUserServiceException();
super.upgradeLevel(user);
}
}
@Test
public void upgradeAllOrNothing(){
UserService testUserService = new TestUserService(userRepository, users.get(3).getId());
for(User user :users) userRepository.save(user);
try {
testUserService.upgradeLevels();
fail("TestUserServiceException expected");
}catch (TestUserServiceException e){
}
checkLevel(users.get(1), false);
}
대충 요약해보면, 특정 id 가 들어오면 예외를 내뱉게 해두고 테스트를 한 상황,
3번 유저가 에러를 발생시킨다 했을때 1번, 2번 유저가 롤백 되었는지를 확인 하는 테스트다.
java.lang.AssertionError:
Expected: is <BASIC>
but: was <SILVER>
Expected :is <BASIC>
하지만, 돌려보면 롤백이 안된걸 볼 수 있다.
이게 바로 트랜잭션의 문제다.
트랙잭션 경계 설정
DB는 완벽한 트랜잭션을 지원한다.
하나의 SQL 명령에서는 DB가 트랜잭션을 보장해준다.
하지만 여러개의 SQL 이 사용되는 작업을 하나의 트랜잭션으로 취급해야하는 경우가 있다.
Connection c = dataSource.getConnection();
c.setAutoCommit(false);
try {
PreparedStatement st1 = c.prepareStatement("update users ...");
st1.executeUpdate();
PreparedStatement st2 = c.prepareStatement("delete users ...");
st2.executeUpdate();
c.commit();
}catch (Exception e){
c.rollback();
}
c.close();
JDBC 트랜잭션은 하나의 커넥션에서 일어난다.
자동 커밋 옵션은 디폴트가 true인데 false로 변경해야 트랜잭션을 제어 할 수 있다.
커밋이나 롤백이 호출되는 지점까지가 트랜잭션으로 묶인다.
이런 설정이 트랜잭션 경계 설정이라고 할 수 있다.
JdbcTemplate 에서의 트랜잭션
jdbcTemplate 메소드를 사용하면 메소드가 호출될때마다 트랜잭션이 새로 만들어지고 메소드를 빠져나오기전에 종료된다. 즉, 하나의 독립적인 트랜잭션으로 실행 될 수 밖에 없다.
결국 트랜잭션 경계를 설정하기 위해선 구조를 이런식으로 만들어야 하는데,
public void upgradeLevel () throw Exception{
(1) DB Connection 생성
(2) 트랜잭션 시작
try {
(3) DAO 메소드 호출
(4) 트랜잭션 커밋
}catch(Exception e){
(5)트랜잭션 롤백
throw e;
}finally{
(6)db Connection 종료
}
}
public void upgradeLevels() throws Exception{
Connection c = ...;
...
try{
...
upgradeLevel(c, user);
...
}
....
protected void upgradeLevel(Connection c, User user){
user.upgradeLevel();
userDao.update(c, user);
}
}
이렇게 두면 문제점이
DB커넥션을 비롯해 여러 리소스를 깔끔하게 처리 가능했던 JdbcTemplate 을 더이상 쓸 수 없게 되고
JDBC API 를 직접 사용하는 방법을 써야 하게된다.
또, upgradeLevel 의 파라미터에 Connection이 붙게 되는데
UserDao 인터페이스에 Connection을 사용해야 되게 되면서, 데이터 엑세스 기술에 의존적이게 된다.
JPA 를 쓰려면 Connection 을 EntityManager 로 바꿔줘야 되기 떄문에
또 테스트 코드에도 영향을 끼치게 된다.
트랜잭션 동기화
커넥션을 파라미터로 넘겨야 되는 문제부터 봐보자
upgradeLevels 에서 트랜잭션 경계 설정을 해야되는건 피할 수 없다.
다만 Connection 객체를 계속 파라미터로 전달해서 사용하는건 피하고 싶다.
이걸 위해 스프링에서 제안하는 방법은 독립적인 트랜잭션 동기화 방식이다.
트랜잭션 동기화란? 커넥션 객체를 특별한 저장소에 보관해뒀다가 호출되는 DAO 메소드에서 커넥션을 가져다가 사용하는것.
트랜잭션 동기화 적용
public void upgradeLevels() throws SQLException {
TransactionSynchronizationManager.initSynchronization();
Connection connection = DataSourceUtils.getConnection(dataSource);
connection.setAutoCommit(false);
try {
final List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
connection.commit();
} catch (SQLException e) {
connection.rollback();
throw e;
} finally {
DataSourceUtils.releaseConnection(connection, dataSource);
TransactionSynchronizationManager.unbindResource(this.dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
스프링이 제공하는 트랜잭션 동기화 관리 클래스 TransactionSynchronizationManager다.
먼저 동기화 작업을 초기화 하게 요청하고
DataSourceUtils 로 트랜잭션 동기화 저장소화 바인딩을 하고 커넥션을 얻는다.
그 후 커밋해주거나 롤백해주면 된다.
JdbcTemplate 과 트랜잭션 동기화
JdbcTemplate은 혼자 커넥션을 열고 닫는 역할을 하는데,
만약 트랜잭션 동기화 저장소에 커넥션이 있으면 JdbcTemplate은 새로 커넥션을 생성하지 않는다.