춤추는 개발자

[Spring] 기술의 공통점을 담은 서비스 추상화(PSA) 본문

Developer's_til/스프링 프레임워크

[Spring] 기술의 공통점을 담은 서비스 추상화(PSA)

Heon_9u 2021. 8. 21. 18:24
728x90
반응형

 

 

✅ PSA (Portable Service Abstraction)

 이전 포스팅에서 UserDao와 인터페이스인 ConnectionMaker를 통해 IoC/DI를 구현하였습니다. 여기서 인터페이스란, 추상화 기술을 활용한 것으로 각 기술의 공통점을 담아 확장성을 용이하게 해준다는 특징이 있습니다.

 

 이번에 학습할 PSA란, 직역하자면 '일관성있는 서비스 추상화' 입니다. 환경과 세부 기술의 변화에 관계없이 일관된 방식으로 기술에 접근할 수 있게 해주는 것을 의미합니다. PSA를 설명하기 좋은 예시 중 하나인 트랜잭션을 기반으로 상황 설정과 코드를 통해 알아보겠습니다.

 

[ 트랜잭션이란? ]

 PSA에 대해 학습하기 전, 트랜잭션이란 간단하게 알고 넘어가겠습니다.

트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 의미합니다. 중간에 예외가 발생해서 작업을 완료할 수 없는 경우, 트랜잭션을 시작하기 전, 상태로 돌아가야 합니다. 이렇게 트랜잭션의 시작과 종료를 관리하는 작업은 트랜잭션의 경계설정이라고 합니다. 트랜잭션의 경계란, 하나의 Connection이 생성되고 종료되는 범위를 의미합니다.

 트랜잭션은 아래 4가지 성질을 갖고 있습니다.

 

  1. 원자성: 트랜잭션의 연산이 DB에 모두 반영되거나, 모두 반영되지 않아야 합니다.
  2. 일관성: 트랜잭션이 완료되면 항상 일관성있는 DB상태를 유지해야 합니다.
  3. 독립성: 이미 수행 중인 트랜잭션이 완료되기 전에 다른 트랜잭션에서 수행 결과를 참조할 수 없습니다.
  4. 영속성: 완료된 트랜잭션은 시스템이 고장나도 DB에는 영구적으로 반영되야 합니다.

트랜잭션 처리 과정

📢 트랜잭션의 예시
 치킨값 2만원을 결제하는데 현금 1만원카드 1만원으로 나눠서 결제하려고 합니다. 이때 현금 결제 + 카드 결제를 하나의 트랜잭션으로 볼 수 있습니다.

상황1) 미리 현금을 내고, 카드 결제를 통해 2만원을 계산한다 (commit)
상황2) 미리 현금을 냈지만, 계좌에 잔고가 없어서... 결제에 실패했다 (rollback)
만약, 상황2)의 경우, 미리 결제했던 현금을 돌려받는건 당연하겠죠? 이렇게 결제하기 전 상태로 돌아가는 것을 rollback이라고 합니다.

 

[ 스프링의 트랜잭션 ]

 일반적으로 스프링에서 트랜잭션은 기능은 유사하나 사용 방법이 다릅니다.

 

  1. JDBC/Connection
  2. JTA/UserTransaction
  3. Hibernate/Transaction

 트랜잭션은 경우에 따라 commit() 또는 rollback()을 통해 원자성을 유지해야합니다. 그러기 위해선 트랜잭션의 경계설정 코드를 적용하여 작업의 시작과 종료를 관리해야 합니다.

 하지만, 위처럼 트랜잭션의 3가지 사용 방법이 존재합니다. 만약, 상황에 따라 매번 코드를 수정하게되면 트랜잭션 처리 코드를 담은 UserService와 트랜잭션 관리 코드 사이에 의존관계가 발생하게 됩니다.

 

 트랜잭션 도입으로 인해 발생한 새로운 의존관계입니다. UserService에서 트랜잭션의 경계설정 코드를 적용하며 UserDaoJdbc에 종속되는 구조가 되었습니다.

 

✅ 스프링의 트랜잭션 서비스 추상화

 스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있습니다. 이를 통해 각 기술의 트랜잭션 API를 이용하지 않아도, 일관된 방식으로 트랜잭션을 제어하는 경계설정 코드를 작업할 수 있습니다. 아래 그림은 스프링이 제공하는 트랜잭션 추상화 계층 구조입니다.

 

 추상화 계층의 인터페이스인 PlatformTransactionManager와 이를 DI 받아서 UserService를 개선합니다. 이를 통해 완전히 관심사가 분리된 UserService를 구현할 수 있게됩니다.

 

 User의 등급을 올리는 코드를 아래처럼 작성해보겠습니다.

public class UserService {

    ...
    
    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/Connection 방법에 따라 트랜잭션의 경계설정이 적용되어 있습니다. 같은 메서드에서 다른 방법의 트랜잭션을 적용하기위해 PlatformTransactionManager와 DI를 적용해보겠습니다.

public class UserService {

    @Autowired
    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    public void upgradeLevels() {
        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);
        } catch (RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}

 

 

 이를 통해 확장이 용이하고, 트랜잭션 기술에 독립적인 UserService가 되었습니다. 추가적으로 UserService의 DI가 될 transactionManager 빈을 XML 설정파일을 통해 아래와 같이 등록하겠습니다.

<bean id="userService" class="spring.user.service.UserService">
    <property name="userDao" ref="userDao"/>
    <property name="transactionManager" ref="transactionManager"/>
</bean>

<bean id="transactionManager"
    class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>

 

만약, 2개 이상의 DB가 참여하는 트랜잭션을 만들려면 jdbc로 적용된 transactionManager 빈을 JTA로 변경하면 끝납니다.

 

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

 이렇게 기술과 서비스에 대한 추상화 기법으로 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있었습니다. UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 영향을 주지 않으면서 확장이 가능한 구조가 되었습니다. 같은 어플리케이션 로직을 담았지만, 내용에 따라 분리하여 수평적인 분리라고 볼 수 있습니다.

 

 반면에, 트랜잭션의 추상화는 어플리케이션의 로직과 그 하위에서 동작하는 로우 레벨의 트랜잭션 기술이라는 다른 계층의 특성을 갖는 코드를 분리했습니다.

 

 특히, UserDao를 다음과 같이 정리할 수 있습니다.

  • 데이터를 어떻게 가져오고 등록할 것인가에 대한 데이터 엑세스 로직
  • DB연결을 생성하는 방법에 독립적(DataSource 인터페이스와 DI를 통해 추상화된 방식 사용)
  • UserService와 트랜잭션 기술과는 스프링이 제공하는 PlatformTransactionManager 인터페이스를 통한 추상화 계층을 사이에 두고 사용.

결과적으로 서로 영향을 주지 않고, 자유롭게 확장할 수 있는 구조를 만들 수 있었던 것은 스프링의 DI가 중요한 역할을 하고 있습니다.

 

[ 단일 책임 원칙 ]

 단일 책임 원칙이란 SRP라고도 불리며 객체지향 SOLID 원칙 중 하나입니다. 하나의 모듈은 한 가지 책임을 가져야 한다는 의미로, 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명합니다.

 

 즉, SRP를 잘 지키고 있는 코드는 어떤 변경이 필요할 때 수정 대상이 명확합니다. 기술이 바뀐다면 기술 계층과의 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 됩니다. 또는 데이터를 가져오는 테이블의 이름이 바뀌었다면 데이터 엑세스 로직을 담고 있는 UserDao만 변경하면 됩니다.

 

🙋‍♂️ 느낀점

 이제 웬만한 코드를 보면 어떻게 개선하는게 좋을지 대충 보이기 시작했다. 트랜잭션의 경계설정 코드만 봤을 때는 무슨 문제가 있지? 싶었다. 하지만, 트랜잭션 경계설정의 다양한 방법이 있음을 확인하고, 이를 일관된 방식으로 개선하는 작업이 필요함을 느꼈다.

 

 SRP를 보며 Clean Code라는 책을 보면서 '하나의 메서드는 하나의 기능만 담당한다.' 라는 구절이 생각났다. 알고리즘 문제를 풀 때마다 모듈화시키는게 습관이 되서 그런지 단일 책임 원칙은 더 크게 와닿았다. 또한, 각 기능들을 관심사에 따라 철저하게 분리하려면 로직의 전체 구현 과정들을 정확하게 인지해야함을 느꼈다.

 

 예를 들어 DB에 접근하는 UserDao에서도 DB 커넥션 설정, 내부 메서드 호출, 쿼리문 처리 등의 로직이 있다. 이러한 호출 순서를 정확히 파악하고 있다면, 관심사에 따라 오브젝트를 철저하게 분리하며 유연하고 확장이 쉬운 코드를 작성하는데 기반이 될 것이다.

 

 

 

728x90
반응형