Spring Event로 강결합 서비스 분리하기

Kyle·2023년 10월 5일
0
post-thumbnail

문제 상황


  • 제가 운영하는 시스템에서 몇 가지 핵심적인 데이터들이 있습니다.
  • 배치 중에 사용하는 설정값들이나, 조직에 대한 정보들이 바뀌면 각 업무 담당자들이 모두 영향을 받습니다.
  • 이 값들은 업무 담당자라면 누구나 수정할 수 있는 값입니다. 그렇다고 변경이 제대로 공유되지는 않습니다.

강결합된 서비스


class Fund(  
    val code: String,  
    val name: String,  
    var team: Team,  
) {  
    fun changeTeam(newTeam: Team) {  
        this.team = newTeam  
    }  
}

class Team(  
    val code: String,  
    val name: String  
)
  • 다음과 같은 도메인 객체가 있습니다.
@Service  
@Transactional  
class FundUpdateService(  
    private val messengerService: SendMessengerService  
) {  
    private val log = LoggerFactory.getLogger(this::class.java)  
  
    fun updateFund() {  
        log.info("펀드 변경 시작~")  
  
        val fund = getFundById()  
        fund.changeTeam(Team("A02", "IB2팀"))  
        messengerService.sendAlarm(fund)  
  
        log.info("펀드 변경 완료!")  
    }  
  
    private fun getFundById(): Fund {  
        return Fund("F01", "IB1팀-운용", Team("A01", "IB1팀"))  
    }  
}
  • 서비스 레이어에선 펀드의 매핑된 팀을 변경합니다.
  • 그리고 그 정보를 알려주기 위해 메신저에 알림 전송을 요청합니다.
@Component  
class SendMessengerService {  
  
    private val log = LoggerFactory.getLogger(this::class.java)  
  
    fun sendAlarm(target: Any) {  
        log.info("메신저 서버 호출 = {}", target)  
  
        for (second in 1..3) {  
            Thread.sleep(1000)  
            log.info("{}초 기다림...", second)  
        }  
  
        log.info("메신저 전송 완료 = {}", target)  
    }  
}
class FundUpdateServiceTest {  
  
    @Test  
    @DisplayName("강결합 양상 테스트")  
    fun `tightly coupled`() {  
        val fundUpdateService = FundUpdateService(SendMessengerService())  
  
        fundUpdateService.updateFund()  
    }  
}

예외가 발생한다면?

@Component  
class SendMessengerService {  
  
    private val log = LoggerFactory.getLogger(this::class.java)  
  
    fun sendAlarm(target: Any) {  
        log.info("메신저 서버 호출 = {}", target)  
  
        for (second in 1..3) {  
            Thread.sleep(1000)  
            log.info("{}초 기다림...", second)  
            throw RuntimeException("메신저 서버 오류 발생!!!!!!!!!!!!!") 
        }  
  
        log.info("메신저 전송 완료 = {}", target)  
    }  
}
  • 만약에 여기서 메신저 서버의 오류가 발생하면 어떻게 될까요?

메신저 서비스에서 발생한 오류로 인해 펀드 정보도 변경할 수 없는 일이 발생합니다. 
즉, 세부적인 사항 때문에 핵심적인 비즈니스가 이뤄지지 못하게 됩니다.

이벤트로 분리하기


  • [[ApplicationEventPublisher]]를 주입받아 줍니다.
  • 그리고 이벤트를 발행하여 줍니다.
@Service  
@Transactional  
class FundUpdateServiceWithEvent(  
    private val eventPublisher: ApplicationEventPublisher  
) {  
    private val log = LoggerFactory.getLogger(this::class.java)  
  
    fun updateFund() {  
        log.info("펀드 변경 시작~")  
  
        val fund = getFundById()  
        fund.changeTeam(Team("A02", "IB2팀"))  
        eventPublisher.publishEvent(UpdateEvent(fund))  
  
        log.info("펀드 변경 완료!")  
    }  
  
    private fun getFundById(): Fund {  
        return Fund("F01", "IB1팀-운용", Team("A01", "IB1팀"))  
    }  
}
@Component  
class UpdateEventListener(  
    private val sendMessengerService: SendMessengerService  
) {  
  
    @TransactionalEventListener  
    fun listenEvent(updateEvent: UpdateEvent) {  
        sendMessengerService.sendAlarm(updateEvent.target)  
    }  
}
  • 이벤트 리스너를 작성합니다. 애너테이션을 통해 어떤 메서드가 이벤트를 처리할 지 지정할 수 있습니다.
  • 트랜잭션이 관련 있을 경우에는 @EventListener 가 아니라 @TransactionalEventListener를 사용해아 합니다.
  • @EventListener 는 트랜잭션 범위 내에서 동기적으로 실행되고, @TransactionalEventListener 는 커밋 전/후 등을 자유롭게 지정할 수 있습니다.
@SpringBootTest  
class FundUpdateServiceWithEventTest {  
  
    @Autowired  
    private lateinit var fundUpdateServiceWithEvent: FundUpdateServiceWithEvent  
  
    @Test  
    @DisplayName("이벤트 분리 테스트")  
    fun `event isolation test`() {  
        fundUpdateServiceWithEvent.updateFund()  
    }  
}

이벤트를 분리하였지만 역시 오류가 발생합니다.
이벤트를 분리함으로써 별도의 제어흐름을 가져가는 것처럼 착각할 수 있지만, 실제로는 같은 쓰레드에서 동작함을 확인할 수 있습니다.

해결방법

  • 이벤트에 대한 처리를 별도의 쓰레드에서 실행하도록 해야합니다.
  • @Async 어노테이션은 메서드를 별도의 쓰레드에서 실행하도록 합니다.
  • 따라서 @Async 메서드에서 발생하는 예외는 해당 스레드에서 처리되며, 원래의 트랜잭션에 영향을 주지 않습니다.
  • 이를 통해 RuntimeException이 발생하더라도 펀드의 변경은 그대로 유지될 수 있습니다.

주의사항

  • 다만, @Async 어노테이션을 사용할 때는 다음과 같은 주의사항이 있습니다:
  1. @Async 메서드는 항상 public이어야 하며, 같은 클래스 내에서 직접 호출하면 비동기로 동작하지 않습니다.
  2. @EnableAsync 어노테이션을 Spring Boot 애플리케이션의 설정 클래스에 추가해야 @Async가 동작합니다.
@EnableAsync  
@Configuration  
class AsyncConfig {  
}
  • 추가로 별도로 쓰레드풀을 생성하지 않으면 애플리케이션의 기본 쓰레드풀을 사용하게 됩니다.
  • 비동기로 많은 요청을 보내게 될 경우, 메인 비즈니스 로직을 처리해야 할 쓰레드가 없는 상황이 발생하게 됩니다.
@SpringBootApplication  
class SpringEventApplication {  
  
    @Autowired  
    private lateinit var fundUpdateServiceWithEvent: FundUpdateServiceWithEvent  
  
    @Bean  
    fun init(): CommandLineRunner {  
        return CommandLineRunner {  
            for (i in 1..100) {  
                fundUpdateServiceWithEvent.asyncUpdateFund()  
            }  
        }  
    }  
}  
  
fun main(args: Array<String>) {  
  
    runApplication<SpringEventApplication>()  
}

  • 따라서 별도의 비동기용 쓰레드풀을 생성해주는 것이 좋습니다.
@EnableAsync  
@Configuration  
class AsyncConfig {  
  
    @Bean(name = ["customAsyncExecutor"])  
    fun taskExecutor(): Executor {  
        val executor = ThreadPoolTaskExecutor()  
        executor.corePoolSize = 10  
        executor.maxPoolSize = 20  
        executor.queueCapacity = 500  
        executor.setThreadNamePrefix("Async-")  
        executor.initialize()  
        return executor  
    }  
  
}

변경된 코드

@Service  
@Transactional  
class FundUpdateServiceWithEvent(  
    private val eventPublisher: ApplicationEventPublisher  
) {  
    private val log = LoggerFactory.getLogger(this::class.java)  
  
    fun asyncUpdateFund() {  
        log.info("펀드 변경 시작~")  
  
        val fund = getFundById()  
        fund.changeTeam(Team("A02", "IB2팀"))  
        eventPublisher.publishEvent(AsyncUpdateEvent(fund))  
  
        log.info("펀드 변경 완료!")  
    }  
  

    private fun getFundById(): Fund {  
        return Fund("F01", "IB1팀-운용", Team("A01", "IB1팀"))  
    }  
}
@Component  
class UpdateEventListener(  
    private val sendMessengerService: SendMessengerService  
) {  

    @TransactionalEventListener()  
    @Async("customAsyncExecutor")
    fun listenEventWithAsync(updateEvent: AsyncUpdateEvent) {  
        sendMessengerService.sendAlarm(updateEvent.target)  
    }  
}
  • 비동기로 별도의 쓰레드에서 메서드를 처리하게 됩니다.
@Test  
@DisplayName("이벤트 분리와 비동기")  
fun `async event isolation test`() {  
    fundUpdateServiceWithEvent.asyncUpdateFund()  
}

업로드중..

  • 메신저 서버를 호출하기도 전에 펀드 변경 작업이 완료됩니다.
  • 쓰레드의 이름은 위에서 설정한 Async-로 시작합니다. 호출하는 쓰레드와 명백히 다른 쓰레드입니다.
  • 이후에 메신저 서버 호출에서 오류가 발생하여도 펀드 변경 작업에는 영향을 주지 못합니다.

0개의 댓글