5장 서비스 추상화

토비의 스프링 1권 5장을 요약한 내용 입니다.

사용자 수정 기능 추가

기존 코드를 수정하여 테스트하기에 앞서 우리는 다음과 같은 질문을 해볼 필요가 있다.

  • 코드에 중복된 부분은 없는가?

  • 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?

  • 코드가 자신이 있어야할 자리에 있는가?

  • 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

updradeLevel() 메소드 코드의 문제점

public void upgradeLevels() {
    List<User> users = userDao.getAllO; 
    for(User user : users) {
        Boolean changed = null; // 레벨의 변화가 있는지를 확인하는 플래그 

        if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
            user.setLevel(Level.SILVER); // basic 레벨 업그레이드 작업
            changed = true; 
        } else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
            user.setLevel(Level. GOLD) // silver 레벨 업그레이드 작업 
            changed = true; // 레벨 변경 플래그 설정
        } else if (user.getLevel() == Level.GOLD) { 
            changed = false; // G0LD 레벨은 변경이 일어나지 않는다 
        } else { 
            changed = false; // 일치하는 조건이 없으면 변경 없음 
        } 
        
        if (changed) { userDao.update(user); }
    }   
}

일단 for 루프 속에 들어 있는 if/elseif/else 블록들이 읽기 불편하다. 레벨의 변화 단계와 업그레이드 조건, 조건이 충족됐을 때 해야 할 작업이 한데 섞여 있어서 로직을 이해하기가 쉽지 않다.

코드가 깔끔해 보이지 않는 이유는 이렇게 성격이 다른 여러 가지 로직이 한데 섞여 있기 때문이다. 게다가 이런 if 조건 블록이 레벨 개수만큼 반복된다.

upgradeLevels() 리팩토링

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

모든 사용자 정보를 가져와 한 명씩 업그레이드가 가능한지 확인하고, 가능하면 업그레이드를 한다.

객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 하다.

UserService는 User에게 "레벨 업그레이드 작업을 해달라"고 요청하고, 또 User는 Level에게 "다음 레벨이 무엇인지 알려달라"고 요청하는 방식으로 동작하게 하는 것이 바람직하다

트랜잭션 서비스 추상화

모든 사용자에 대해 업그레이드 작업을 진행하다가 중간에 예외가 발생해서 작업이 중단된다면 어떻게 될까?

  • 이미 변경된 사용자의 레벨은 작업 이전 상태로 돌아갈까?

  • 아니면 바뀐 채로 남아 있을까?

테스트를 만들어서 확인해보자. 이를 확인하기 위해서는 예외적인 상황을 작업 중간에 강제로 발생시켜야 한다.

모 아니면 도

간단히 UserService를 상속해서 테스트에 필요한 기능을 추가하도록 일부 메소드를 오버라이딩 해보자

강제 예외 발생을 통한 테스트

static class TestUserService extends UserService {

		...

		@Test
		public void upgradeAllOrNothing() {
		    UserService testUserService = new TestUserService(users.get(3).getId());    //  예외 발생 시, 4번째 사용자의 id를 넣어 테스트용 UserService 대역 오브젝트를 생성한다.
		    testUserService.setUserDao(this.userDao);   //  userDao를 수동 DI 해준다.
		
		    userDao.deleteAll();
		    for (User user : users) userDao.add(user);
		
		    try {
		        testUserService.upgradeLevels();
		        fail("TestUserServiceException expected");  //  TestUserService는 업그레이드 작업 중에 예외가 발생해야 한다.
		                                                    //  정상 종료라면 문제가 있으니 실패.
		    } catch (TestUserServiceException e) {      //  TestUserService가 던져주는 예외를 잡아서 계속 진행되도록 한다. 그외의 예외라면 테스트 실패
		    }
		
		    checkLevelUpgraded(users.get(1), false);    //  예외가 발생하기 전에 레벨 변경이 있었던 사용자의 레벨이 처음 상태로 바뀌었나 확인
		}
}

하지만 이는 중간에 예외가 발생하였지만 변경 내용은 그대로 유지될 것이다. 이유는 모든 사용자의 레벨을 업그레이드하는 작업인 updatedLevels() 메소드가 하나의 트랜잭션 안에서 동작하지 않았기 때문이다.

모든 사용자에 대한 레벨 업그레이드 작업은 새로 추가된 기술 요구사항대로 전체가 다 성공하든지 아니면 전체가 다 실패해야 한다.

UserServuce와 UserDao의 트랜잭션 문제

지금까지 만든 코드 어디에도 트랜잭션을 시작하고, 커밋하고, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않는다.

DAO 안에서 JDBC API를 직접 이용한다면 하나의 DB 커넥션과 트랜잭션을 만들어놓고 여러 명의 사용자에 대한 정보를 업데이트할 수 있다. 하지만 이 방식은 비즈니스 로직과 데이터 로직을 한데 묶어버리는 한심한 결과를 초래한다.

UserService에는 트랜잭션 기작과 종료를 담당하는 최소한의 코드만 가져오게 만들면 어느 정도 책임이 다른 코드를 분리해둔 채로 트랜잭션 문제를 해결할 수 있다.

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

트랜잭션 동기화 적용

private DataSource dataSource;

public void setDataSource(DataSource dataSource) {  //  Connection을 생성할 때 사용할 DataSource를 DI받는다.
    this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {  //  
    TransactionSymchronizationManager.initSynchronization();    //  트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화
    Connection c = DataSourceUtils.getConnection(dataSource);   //  DB 커넥션을 생성하고 트랜잭션을 시작. 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행됨.
    c.setAutoCommit(false);     //  트랜잭션의 시작 선언
    //  DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드

    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();           //  동기화 작업 정리
    }
}

스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager다. 그리고 DataSourceUtils에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.

작업을 정상적으로 마치면 트랜잭션을 커밋해주고 스프링 유틸리티 메소드의 도움을 받아 커넥션을 닫고 트랜잭션 동기화를 마치도록 요청하면 된다. 만약 예외가 발생하면 트랜잭션을 롤백해준다. 이때도 DB 커넥션을 닫는 것과 동기화 작업 중단은 동일하게 진행해야 한다.

서비스 추상화와 단일 책임 원칙

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

UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다.

트랜잭션의 추상화는 이와는 다르다. 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.

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

단일 책임 원칙

단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.

이전 UserService는 어떻게 사용자 레벨을 관리할 것인가와 어떻게 트랜잭션을 관리할 것인가라는 두 가지 책임을 갖고 있었다. 두 가지 책임을 갖고 있다는 건 UserService 코드가 수정되는 이유가 두 가지라는 뜻이다.

단일 책임 원칙의 장점

단일 책임 원칙을 적용하면 어떤 변경이 필요할 때 수정 대상이 명확해진다.

애플리케이션 계층의 코드가 특정 기술에 종속돼서 기술이 바뀔 때마다 코드의 수정이 필요하다면 어떨지 상상해보자. 적용한 기술에 이슈가 있거나 변경하고 싶을 경우 운영중에 이런 수정은 아마도 엄청난 부담을 안고 시작하거나 아니면 겁나서 도저히 못 하겠다고 저항해야 할지도 모르겠다.

그래서 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다.

단일 책임 원칙을 잘 지키는 코드를 만들려면 인터페이스를 도입하고 이를 DI로 연경해야 하며, 그 결과로 단일 책임 원칙뿐 아니라 개방 폐쇄 원칙도 잘 지키고, 모듈 간에 결합도가 낮아서 서로의 변경이 영향을 주지 않고, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드가 나오게 된다.

스프링을 DI 프레임워크라고 부르는 이유는 외부 설정정보를 통한 런타임 오브젝트 DI라는 단순한 기능을 제공하기 때문이 아니다. 오히려 스프링이 DI에 담긴 원칙과 이를 응용하는 프로그래밍 모델을 자바 엔터프라이즈 기술의 많은 문제를 해결하는 데 적극적으로 활용하고 있기 때문이다.

Last updated