상품 공급 시스템 - transaction 관리 & 리팩토링

Not eligible for auto-proxying

Spring declarative transaction management를 사용하기 위해 아래와 같이 xml 설정 파일을 resource 디렉토리에 위치시키고 애플리케이션을 실행했지만 트랜잭션 어드바이스가 실행되지 않았다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/tx
                           http://www.springframework.org/schema/tx/spring-tx.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop.xsd">

    <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close">
        <property name="driverClassName" value="com.mysql.cj.jdbc.Driver" />
        <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/tranche?serverTime=UTC" />
        <property name="username" value="root" />
    </bean>

    <!-- the PlatformTransactionManager -->
    <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource" />
    </bean>

    <!-- the transactional advice-->
    <tx:advice id="txAdvice" transaction-manager="txManager">
        <!-- the transactional semantics -->
        <tx:attributes>
            <!-- default transaction settings -->
            <tx:method name="signup" rollback-for="com.example.tranche.domain.member.exception.RecordInsertionFailedException"/>
        </tx:attributes>
    </tx:advice>

    <!-- ensure that the above transactional advice runs for signup method -->
    <aop:config>
        <aop:pointcut id="signupOperation" expression="execution(* com.example.tranche.domain.member.service.SignupService.signup(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="signupOperation" />
    </aop:config>

</beans>

애플리케이션의 로그 중에 이런 메세지가 눈에 띄었다.

auto-proxying

DefaultBeanFactoryPointcutAdvisor 타입의 인스턴스 DefaultBeanFactoryPointcutAdvisor#0이라는 Bean이 모든 BeanPostProcessor들에 의해 처리될 수 없습니다. (예를 들면 auto-proxying이 될 수 없습니다.) 라는 뜻이다.

Spring BeanPostProcessor

BeanPostProcessor 인터페이스는 두 개의 콜백 메서드를 가진다.
postProcessBeforeInitialization()postProcessAfterInitialization()이다.
스프링 컨테이너에 의해 각각의 bean 인스턴스가 생성될때마다, 컨테이너는 post-processor의 콜백 메서드를 호출한다. container initialization 메서드들이 호출되기 전과 bean initialization 콜백들이 호출된 후이다.

Bean post-processor는 일반적으로 콜백 인터페이스를 확인(빈이 특정 인터페이스를 구현했는지 확인)하고 해당 인터페이스의 메서드를 호출한다. 또는 빈을 프록시로 감싸는 작업을 한다.

BeanPostProcessor 생성&등록&자세한 설명

solution

Auto-proxying을 활성화 해줘야 한다. Auto-proxying이 진행되지 않으면 AspectJ 어노테이션이 붙어있더라도 해당 객체는 그냥 Bean일 뿐이다. Auto-proxying을 활성화 하는 방법은 두 가지이다.

  • JavaConfig - Configuration 클래스에 @EnableAsepctJAutoProxy 어노테이션 추가
  • xml - 스프링의 aop 네임스페이스에서 〈aop:aspectj-autoproxy〉 요소 사용

Spring boot 프로젝트의 경우 auto-commit을 false로 설정해줘야 한다. @Transactional 어노테이션을 사용한다면 새로운 트랜잭션이 시작될 때 자동으로 autoCommit을 false로 설정한다. autoCommit이 true인 경우 모든 SQL statement는 실행된 직후에 자동으로 commit된다.

이번에 알게 되었는데 롤백되더라도 AUTO_INCREMENT 값은 증가한 채로 남는다.

회원가입 구현

트랜잭션 관리 코드를 서비스 코드와 완전히 분리하여 회원가입 기능을 구현하는데 성공했다. 스프링 부트의 @Transactional 어노테이션은 사용하지 않고 Spring의 선언적 트랜잭션 관리 기능과 JDBC를 사용했다. 추상화된 기술을 최대한 피해서 Spring을 깊게 공부하는 것이 이번 프로젝트의 목적이기 때문이다.

조만간 Spring Boot에서 Spring으로 이식해야 할 것 같다.

Controller

이전의 API들은 Service 객체에서 바로 BaseResponse 객체(Controller가 클라이언트로 반환하는 객체)를 반환했는데 이번에는 그렇게 하지 않았다. Service 객체에서는 트랜잭션을 관리하기 때문에 롤백을 위해 예외를 던져야 하기 때문이다. 컨트롤러가 예외를 처리하고 최종적으로 BaseResponse 객체를 만들어 반환하도록 구현했다. 일관성을 위해 다른 API도 컨트롤러가 Service객체의 예외를 처리하고 BaseResponse 객체를 만들어 반환하도록 리팩토링해야겠다.

@PostMapping("/signup")
public BaseResponse signupNewMember(@RequestBody SignupRequestBody requestBody) {
    return handleSignupService(requestBody);
}

private BaseResponse handleSignupService(SignupRequestBody requestBody) {
    try {
        signupService.signup(requestBody);
        return new BaseResponse(SUCCESS);
    } catch (RecordInsertionFailedException signupFailure) {
        return new BaseResponse(DATABASE_EXCEPTION);
    }
}

Service

signup 메서드는 스프링의 선언적 트랜잭션 관리 기능에 의해 트랜잭션 프록시로 감싸져 실행된다. AOP를 통해 트랜잭션을 구현하였기 때문에 트랜잭션 관리 코드가 서비스 코드와 완전히 분리되었다.

public void signup(SignupRequestBody requestBody) throws RecordInsertionFailedException {
    NewMemberDto newMemberDto = new NewMemberDto(requestBody);
    registerMemberInfo(newMemberDto);
}

public void registerMemberInfo(NewMemberDto newMemberDto) throws RecordInsertionFailedException{
    Long newMemberId = (long) memberDao.insertNewMember(newMemberDto);
    Role newMemberRole = new Role(newMemberId, newMemberDto);
    memberDao.insertNewMembersRole(newMemberRole);
}

DAO

JDBC를 사용해 구현한 MemberDaoJDBC 클래스이다. MemberDao 인터페이스를 구현하여 서비스 객체는 DAO 객체의 구체적인 구현 방식을 모른 채 작동한다.

@Override
public int insertNewMember(NewMemberDto newMember) throws RecordInsertionFailedException {
    String query = "INSERT INTO member " +
            "(email, password, nickname, last_password_update_date, " +
            "last_signin_date, account_creation_date, enabled, degree) " +
            "VALUES(?, ?, ?, ?, ?, ?, ?, ?)";
    try (Connection conn = dataSource.getConnection();
            PreparedStatement stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);) {
        stmt.setString(1, newMember.getEmail());
        stmt.setString(2, newMember.getPassword());
        stmt.setString(3, newMember.getNickname());
        stmt.setDate(4, Date.valueOf(newMember.getLastPasswordUpdateDate()));
        stmt.setDate(5, Date.valueOf(newMember.getLastSigninDate()));
        stmt.setDate(6, Date.valueOf(newMember.getAccountCreationDate()));
        stmt.setBoolean(7, newMember.isEnabled());
        stmt.setInt(8, newMember.getDegree());
        executeStatement(stmt);
        return getLastGeneratedKey(stmt);
    } catch ( SQLTimeoutException statementTimeoutException ) {
        throw new RecordInsertionFailedException(statementTimeoutException);
    } catch ( SQLException connectionOrStatementException ) {
        throw new RecordInsertionFailedException(connectionOrStatementException);
    }
}

@Override
public int insertNewMembersRole(Role role) throws RecordInsertionFailedException {
    String query = "INSERT INTO role " +
            "(member_id, authority) " +
            "VALUES (?, ?)";
    int lastGeneratedKey;
    try (Connection conn = dataSource.getConnection();
            PreparedStatement stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);) {
        Iterator<Authority> iter = role.getAuthorities().iterator();
        while (iter.hasNext()) {
            stmt.setInt(1, role.getMemberId().intValue());
            stmt.setString(2, iter.next().toString());
            executeStatement(stmt);
        }
        lastGeneratedKey = getLastGeneratedKey(stmt);
        return lastGeneratedKey;
    } catch (SQLTimeoutException statementTimeoutException) {
        throw new RecordInsertionFailedException(statementTimeoutException);
    } catch (SQLException connectionOrStatementException) {
        throw new RecordInsertionFailedException(connectionOrStatementException);
    }
}

private void executeStatement(PreparedStatement stmt) throws SQLTimeoutException, SQLException,
        RecordInsertionFailedException {
    if (stmt.executeUpdate() != 1)
        throw new RecordInsertionFailedException();
}

private int getLastGeneratedKey(PreparedStatement stmt) throws SQLException {
    ResultSet rs = stmt.getGeneratedKeys();
    // TODO rs.next() 값이 false인 경우가 있을까?
    rs.next();
    return rs.getInt(1);
}

트랜잭션 관리

스프링의 선언적 트랜잭션 관리 기능을 사용하였다

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/tx
                           http://www.springframework.org/schema/tx/spring-tx.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop.xsd">

    <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close">
        <property name="driverClassName" value="com.mysql.cj.jdbc.Driver" />
        <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/tranche?serverTime=UTC" />
        <property name="username" value="root" />
        <property name="autoCommit" value="false" />
    </bean>

    <!-- the PlatformTransactionManager -->
    <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource" />
    </bean>

    <!-- the transactional advice-->
    <tx:advice id="txAdvice" transaction-manager="txManager">
        <!-- the transactional semantics -->
        <tx:attributes>
            <!-- default transaction settings -->
            <tx:method name="signup"
                       rollback-for="com.example.tranche.domain.member.exception.RecordInsertionFailedException" />
        </tx:attributes>
    </tx:advice>

    <!-- <aop:aspectj-autoproxy /> -->

    <!-- ensure that the above transactional advice runs for signup method -->
    <aop:config>
        <aop:pointcut id="signupOperation" expression="execution(* com.example.tranche.domain.member.service.SignupService.signup(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="signupOperation" />
    </aop:config>

</beans>

Spring Boot가 자동으로 설정해주는 것을 내가 덮어쓰게 되는 것이지 헷갈리는 것들이 생겨 아예 스프링으로 옮기는게 나을 것 같다.

Test

회원가입은 서비스 객체의 signup 메서드로 추상화 하였는데 해당 메서드는 회원 정보를 member 테이블과 role 테이블에 나누어 저장한다. 이 두 가지 작업은 signup 메서드에서 하나의 트랜잭션으로 실행된다. 여기서 세 가지 경우의 수가 존재한다.

  1. role 테이블에 저장하는 과정에서 오류 발생
  2. member 테이블에 저장하는 과정에서 오류 발생
  3. 정상적으로 저장 성공

이 세 가지 경우에 대한 테스트를 작성했다.

트랜잭션이 롤백되는 경우에 대해서는 서비스 객체가 무조건 오류를 던지도록 구현한 뒤 postman으로 요청을 날리고 데이터베이스에 데이터가 저장되었는지 확인했다. 😗

@Test
void 회원가입_Role_삽입_오류() {
    // given
    int generatedMemberId = 123;
    Mockito.when(memberDao.insertNewMember(any(NewMemberDto.class)))
            .thenReturn(generatedMemberId);
    Mockito.when(memberDao.insertNewMembersRole(any(Role.class)))
            .thenThrow(RecordInsertionFailedException.class);
    SignupRequestBody requestBody = setupSignupRequestBody();
    // when & then
    assertThatCode(() -> {
        signupService.signup(requestBody);
    }).isInstanceOf(RecordInsertionFailedException.class);
}

@Test
void 회원가입_Member_삽입_오류() {
    // given
    int lastGeneratedRoleId = 123;
    Mockito.when(memberDao.insertNewMember(any(NewMemberDto.class)))
            .thenThrow(RecordInsertionFailedException.class);
    Mockito.when(memberDao.insertNewMembersRole(any(Role.class)))
            .thenReturn(lastGeneratedRoleId);
    SignupRequestBody requestBody = setupSignupRequestBody();
    // when & then
    assertThatCode(()->{
        signupService.signup(requestBody);
    }).isInstanceOf(RecordInsertionFailedException.class);
}


private SignupRequestBody setupSignupRequestBody() {
    return new SignupRequestBody("hello@world.com",
            "1234qwer", "Foo",
            "ADMIN", 0);
}

리팩토링(1cfb5fa)

기존에는 서비스 객체에서 컨트롤러가 반환할 객체를 만들어 컨트롤러에게 전달하였다. 리팩토링을 통해 서비스 객체는 서비스 로직만 수행하도록 제한하였다. 컨트롤러는 서비스 로직 수행 결과를 토대로 반환할 객체를 만들어 반환한다. 서비스 객체는 예외를 던지기까지만 하고 서비스 로직에 대한 예외 처리는 컨트롤러가 담당한다.

닉네임 중복검사

before

SignupController

@GetMapping("/nickname/{nickname}/availability")
public BaseResponse checkNicknameAvailability(@PathVariable("nickname") String nickname) {
    return new BaseResponse(signupService.checkNicknameAlreadyExists(nickname));
}

SignupService

public ResponseStatus checkNicknameAlreadyExists(String nickname) {
    Optional<Long> memberId;
    try {
        memberId = memberDao.findEnabledMemberIdByNickname(nickname);
    } catch (SQLException e) {
        return DATABASE_EXCEPTION;
    }
    if (memberId.isPresent())
        return NICKNAME_ALREADY_EXISTS;
    else
        return USABLE_NICKNAME;
}

after

SignupController

if-else 문을 동반하는 결과 코드 대신 예외를 사용하였다. 서비스 객체가 true 또는 false를 반환했다면 handleNicknameAvailabilityCheck 메서드에 if-else 문이 추가되어 depth가 추가되었을 것이다.

개인적으로는 굳이 try catch 블럭을 handleNicknameAvailabilityCheck 메서드로 뽑아내야 하나 싶지만 일단 고수들의 조언대로 하기로 했다.

@GetMapping("/nickname/{nickname}/availability")
public BaseResponse checkNicknameAvailability(@PathVariable("nickname") String nickname) {
    return new BaseResponse(handleNicknameAvailabilityCheck(nickname));
}

private ResponseStatus handleNicknameAvailabilityCheck(String nickname) {
    try {
        signupService.checkNicknameAvailability(nickname);
        return USABLE_NICKNAME;
    } catch (RecordRetrievalFailedException dbException) {
        return DATABASE_EXCEPTION;
    } catch (UnavailableNickname unavailable) {
        return NICKNAME_ALREADY_IN_USE;
    }
}
}

SignupService

public void checkNicknameAvailability(String nickname)
        throws RecordRetrievalFailedException, UnavailableNickname {
    Optional<Long> memberId = memberDao.findEnabledMemberIdByNickname(nickname);
    memberId.ifPresent((Long nicknameUser) -> {
        throw new UnavailableNickname(); 
    });

이메일 중복검사

before

SignupController

서비스 객체의 checkEmailAvailability 메서드가 전달받은 이메일이 사용중인지 확인하고 적절한 메세지를 선택하여 컨트롤러에 반환한다. 확인과 선택이라는 두 가지 작업을 하고 있다.

@GetMapping("/email/{email}/availability")
public BaseResponse checkEmailAvailability(@PathVariable("email") String email) {
    return new BaseResponse(signupService.checkEmailAvailability(email));
}

SignupService

public ResponseStatus checkEmailAlreadyExists(String email) {
        Optional<Long> memberId;
        try {
            memberId = memberDao.findEnabledMemberIdByEmail(email);
        } catch (SQLException e) {
            return DATABASE_EXCEPTION;
        }
        if (memberId.isPresent())
            return EMAIL_ALREADY_EXISTS;
        else
            return USABLE_EMAIL;
    }

after

SignupController

@GetMapping("/email/{email}/availability")
public BaseResponse checkEmailAvailability(@PathVariable("email") String email) {
    return new BaseResponse(handleEmailAvailabiltyCheck(email));
}

SignupService

리팩토링을 통해 전달받은 메일이 사용중이면 예외를 던진다라는 한 가지 작업으로 제한하였다.

public void checkEmailAvailability(String email)
        throws RecordRetrievalFailedException, UnavailableEmail {
    Optional<Long> memberId = memberDao.findEnabledMemberIdByEmail(email);
    memberId.ifPresent((Long emailUser) -> {
        throw new UnavailableEmail();
    });
}

Test

테스트도 모두 리팩토링했다.


@Test
void 사용가능한_닉네임_중복검사() throws SQLException {
    // given
    Optional<Long> retValOfMockedMemberDao = Optional.empty();
    // when
    Mockito.when(memberDao.findEnabledMemberIdByNickname(any(String.class)))
            .thenReturn(retValOfMockedMemberDao);
    // then
    assertThatCode(() -> {
        signupService.checkNicknameAvailability("foo");
    }).doesNotThrowAnyException();
}

@Test
void 이미_사용중인_닉네임_중복검사() throws SQLException {
    // given
    Optional<Long> retValOfMockedMemberDao = Optional.of(0l);
    // when
    Mockito.when(memberDao.findEnabledMemberIdByNickname(any(String.class)))
            .thenReturn(retValOfMockedMemberDao);
    // then
    assertThatCode(() -> {
        signupService.checkNicknameAvailability("foo");
    }).isInstanceOf(UnavailableNickname.class);
}

@Test
void 이미_가입된_이메일_가입_시도() throws SQLException {
    // given
    Optional<Long> retValOfMockedMemberDao = Optional.of(0l);
    // when
    Mockito.when(memberDao.findEnabledMemberIdByEmail(any(String.class)))
            .thenReturn(retValOfMockedMemberDao);
    // then
    assertThatCode(() -> {
        signupService.checkEmailAvailability("foo@bar.com");
    }).isInstanceOf(UnavailableEmail.class);
}

@Test
void 사용_가능한_이메일_가입_시도() throws SQLException {
    // given
    Optional<Long> retValOfMockedMemberDao = Optional.empty();
    // when
    Mockito.when(memberDao.findEnabledMemberIdByEmail(any(String.class)))
            .thenReturn(retValOfMockedMemberDao);
    // then
    assertThatCode(() -> {
        signupService.checkEmailAvailability("foo@bar.com");
    }).doesNotThrowAnyException();
}

테스트 통과

reference

Comments