모종닷컴

스프링 프록시 못된 녀석 (with 코틀린, all-open) 본문

Programming/Spring

스프링 프록시 못된 녀석 (with 코틀린, all-open)

모종 2022. 7. 16. 00:03
반응형

코틀린 코드를 리팩토링하면서 만났던 에러를 공유합니다.

환경은 아래와 같습니다.

  • kotlin + Spring Boot (2.2.13.RELEASE)
  • kotlin allopen plugin 적용
  • spring-boot-starter-web 의존 추가

위에도 필요한 의존성이 있을 수 있습니다..


이슈

같은 로직을 가지고 있지만 때에 따라 조금씩 다른 부분들이었고 앞으로도 재사용 가능성이 있는 코드이기에 이를 추상화시켜 재사용하는 것이 저의 목표였습니다. 따라서 공통적인 부분들을 모아 추상클래스를 아래와 같은 형태로 리팩토링을 하였는데요.

private val log = KotlinLogging.logger {  }

abstract class EvaluationService {

    @Autowired
    private lateinit var calculationService: CalculationService

    @Logging
    fun evaluate() {
        log.info { "심사 시작" }
        calculationService.calculate()
    }
}
@Service
class EvaluationServiceImpl: EvaluationService()

@Service
class CalculationService {
    fun calculate() {
        log.info { "계산을 합니다.." }
    }
}

위의 코드 중 @Logging 에노테이션 같은 경우 제가 만든 에노테이션이며, Aspect를 정의하여 해당 에노테이션을 붙이면 메서드를 실행하기전 logging 이라는 로그를 출력하도록 아래와 같이 만들었습니다.

private val log = KotlinLogging.logger {  }

@Target(AnnotationTarget.FUNCTION)
annotation class Logging

@Aspect
@Component
class LoggingAspect {
    @Before("@annotation(com.example.practice.aop.Logging)")
    fun logging() {
        log.info { "logging.." }
    }
}

이렇게 aop를 이용하면 특징이 있는데요. Logging 에노테이션이 붙은 클래스에 대한 프록시가 만들어지는 것입니다. 따라서 저희가 위에서 만들었던 EvaluationService의 evaluate 메서드를 호출하게 되면 만들어진 프록시가 logging 로그를 찍은 후 원래 클래스의 메서드를 호출하는 것이죠.

Aspect가 어떻게 적용되는지 테스트해보기 위해 아래의 테스트 클래스를 하나 만들어볼게요. 

private val log = KotlinLogging.logger {  }

@Service
class TestService {

    // 생성자 주입으로 받으면 되지만.. 최대한 evaluationService와 비슷하게 만들기 위해 그냥 필드 주입으로 만들게요.
    @Autowired
    private lateinit var calculationService: CalculationService
    
    @Logging
    fun test() {
        log.info("TEST 출력")
        calculationService.calculate()
    }
}

이를 테스트하기 위해 저는 컨트롤러를 아래처럼 생성하였습니다.

@RestController
@RequestMapping("/test")
class TestController(
    private val testService: TestService
) {
    @PostMapping
    fun callTest(): ResponseEntity<String> {
        testService.test()
        return ResponseEntity.ok().body("ok")
    }
}

그리고는 아래와 같이 브레이크포인트를 걸고 디버그 모드로 애플리케이션을 실행시킨 뒤에 저희가 만든 엔드포인트를 호출해보도록 합시다.

호출을 하면 아래 노랑색 동그라미 부분이 보이시나요? 바로 이게 프록시로 만들어졌다는 것인데요. CGLIB가 무슨 의미인지 뭐하는 건지 잘모르겠다면 다음 글에서 설명을 보시면 됩니다. 일단은 프록시를 주입받아구나! 하고 넘어가도 좋습니다.

위처럼 프록시가 testService대신 주입되었다는 사실을 보고 나면 Resume Program 버튼을 눌러서 프로그램을 돌리세요. 그럼 출력이 아래처럼 된다는 것을 보실겁니다.

자 대충 어떤 동작인지 알았다면 이제 본격적으로 가장 위의 EvaluationService도 testService와 마찬가지로 컨트롤러에 추가해봅시다.

@RestController
@RequestMapping("/test")
class TestController(
    private val testService: TestService,
    private val evaluationService: EvaluationService
) {
    @PostMapping
    fun callTest(): ResponseEntity<String> {
        testService.test()
        return ResponseEntity.ok().body("ok")
    }

    @PostMapping("/evaluate")
    fun evaluateTest(): ResponseEntity<String> {
        evaluationService.evaluate()
        return ResponseEntity.ok().body("ok")
    }
}

자 아까 테스트와 같이 이번에는 evaluate()를 호출하는 라인에 브레이크 포인트를 걸고 똑같이 호출해보도록 할게요.

물론 TestService와 마찬가지로 프록시가 주입되어있는 상태입니다. 그리고 이제 프로그램을 다시 재개해보세요.

저와 같은 에러를 보고계신가요? logging 출력도 안찍혔으며 심지어 CalculationService가 초기화도 안되어있다고 나오네요.

일단 이 이슈를 해결하는 방법은 크게 3가지 정도가 있습니다.

첫번째 : 함수의 접근제어자를 open 으로 지정.

아래와 같이 EvaluationService.evaluate()의 접근제어를 open으로 만들면 해결할 수 있습니다.

@Logging
open fun evaluate() {
    log.info { "심사 시작" }
    calculationService.calculate()
}

위처럼 함수앞에 open 으로 바꾸고 다시 실행시키면 아래처럼 출력이 정상적으로 되는것을 확인할 수 있습니다.

 

두번째 : all-open 플러그인 이용하기

all-open 플러그인을 간략하게 설명하면 특정 에노테이션이 붙은 클래스의 필드 및 메서드 모두 open으로 만들어주는 플러그인입니다. 따라서 현재의 추상클래스에는 특정 에노테이션이 붙어있지 않았기에 open이 되지 않았던 것입니다. 위에서 open 붙여준 접근제어자를 지워버리고 추상클래스에 @Component 에노테이션을 추가하면 똑같이 정상동작이 됩니다.

세번째 : Evaluation 인터페이스추가와 프로퍼티 추가

@Component 붙였던 걸 지우고 EvaluationService를 아래처럼 인터페이스로 뽑아냅니다.

private val log = KotlinLogging.logger {  }

interface Evaluation {
    fun evaluate()
}

abstract class EvaluationService: Evaluation {

    @Autowired
    private lateinit var calculationService: CalculationService

    @Logging
    override fun evaluate() {
        log.info { "심사 시작" }
        calculationService.calculate()
    }
}
@Service
class EvaluationServiceImpl: EvaluationService()

그리고 application.yml or application.properties 파일에 아래의 프로퍼티를 셋합니다. 저는 yml 파일확장자로 만들었기에 계층적 구조 형태로 선언을 하였는데 properties 파일확장자를 이용하시는 분은 key-value 형태로 spring.aop.proxy-target-class=false 로 추가해주시면 될 것 같습니다.

자 다시 애플리케이션을 실행시키고 똑같이 엔드포인트를 호출할건데 컨트롤러의 주입받는 부분을 수정해야 해서 아래처럼 다시 컨트롤러를 정의해주세요. 그리고 evaluate() 호출하는 부분에 아까처럼 브레이크포인트 한번만 걸어주세요~

@RestController
@RequestMapping("/test")
class TestController(
    private val testService: TestService,
    private val evaluation: Evaluation
) {
    @PostMapping
    fun callTest(): ResponseEntity<String> {
        testService.test()
        return ResponseEntity.ok().body("ok")
    }

    @PostMapping("/evaluate")
    fun evaluateTest(): ResponseEntity<String> {
        evaluation.evaluate()
        return ResponseEntity.ok().body("ok")
    }
}

호출을 해보니 아까랑 조금 다른게 보입니다. 아까의 CGLIB가 사라지고 Proxy가 생겼는데요. 이 역시 프록시이지만 CGLIB와 달리 JDK Dynamic Proxy를 이용해 만들어진 프록시입니다. 

자 이제 프로그램을 다시 재개시켜보면..

정상적으로 동작이 되는 것이 보입니다. 

포스트의 요점

  • AOP를 이용하면 스프링은 해당 클래스에 프록시를 만든다.
  • 프로퍼티 설정에 따라 프록시를 만드는 방식이 달라진다.
  • all-open 플러그인은 특정 에노테이션이 붙은 클래스에만 적용이 된다.
  • 프록시를 만드는 방법은 CGLIB 방식과 JDK Dynamic Proxy 방식이 있다.
  • 다음글은 조금 늦게 올라올수도 있다. :) 

이번 글에서는 제가 겪은 프록시 관련 이슈와 그에 대한 몇가지 해결책을 보았습니다. 이어서 올릴 포스트는 프록시를 만드는 두 가지 방법의 차이점을 알아볼것입니다.  여기까지 읽어주셔서 감사합니다. 

 

반응형