개발자 노트

코틀린에서 자기 참조를 우회하는 방법 (feat. 확장함수) 본문

컴퓨터 언어/Kotlin

코틀린에서 자기 참조를 우회하는 방법 (feat. 확장함수)

jurogrammer 2024. 10. 11. 08:21

스프링 AOP 문제

Spring에서 self-invocation은 클래스의 메서드가 같은 클래스 내의 다른 메서드를 호출하는 방식으로, 특히 @Transactional과 같은 AOP(Aspect-Oriented Programming) 관련 애노테이션을 사용할 때 문제를 일으킬 수 있습니다. Spring에서는 프록시 기반 AOP를 사용하기 때문에, 프록시 객체가 아닌 클래스의 실제 메서드가 호출될 경우 AOP 애노테이션이 적용되지 않습니다.

예를 들어, Spring에서 @Transactional 애노테이션을 적용한 메서드 A가 같은 클래스의 다른 메서드 B에서 호출될 때, B가 프록시를 통해 호출되지 않으면 A의 트랜잭션이 제대로 관리되지 않을 수 있습니다. 이는 프록시가 생성한 객체 외부에서 메서드를 호출해야 AOP 애노테이션이 제대로 적용되기 때문입니다.

 

@Service
public class MyService {

    // 문제 발생 코드
    public void methodB() {
        // 같은 클래스의 메서드를 직접 호출 (self-invocation)
        methodA();
    }

    @Transactional
    public void methodA() {
        // 트랜잭션이 제대로 작동하지 않을 수 있음
        System.out.println("methodA 트랜잭션 시작");
        // 데이터베이스 작업 등...
        System.out.println("methodA 트랜잭션 종료");
    }
}

 

코틀린스러운 해결 방법

확장함수를 이용하면 됩니다.
확장함수를 사용할 경우
* 클래스명Kt.class 로 확장함수가 컴파일되며, static method로 스프링이 주입해준 객체를 호출합니다.

확장함수 디컴파일 결과

 

확장 함수를 활용한 예제

목표

유저 목록을 반환하는 캐시는 사용하되, 편의 메서드로 Map을 반환하는 함수를 만들자.

import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service

data class User(val id: Long)

@Service
class UserService {
    @Cacheable("users")
    fun getUsers(): List<User> {
        return listOf(
            User(1L),
            User(2L),
            User(3L),
            User(4L),
        )
    }

    fun userMapV1(): Map<Long, User> {
        // self-invocation
        return getUsers().associateBy { it.id }
    }
}

fun UserService.userMapV2(): Map<Long, User> {
    return getUsers().associateBy { it.id }
}

 

  • getUsers()
    • 유저를 조회하는 메서드이며, 결과인 유저 목록을 캐싱합니다.
  • userMapV1()
    • self-invocation 했기 때문에 캐시를 사용하지 않고 매번 실제 값으로 Map을 반환합니다.
  • userMapV2()
    • 확장 함수로 선언했기 때문에 정상적으로 캐시된 유저 목록으로 Map으로 반환합니다.

 

간단한 테스트

import com.github.benmanes.caffeine.cache.stats.CacheStats
import jurogrammer.self.user.UserService
import jurogrammer.self.user.userMapV2
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cache.caffeine.CaffeineCache
import org.springframework.cache.caffeine.CaffeineCacheManager

@SpringBootTest
class CacheTest {
    @Autowired
    private lateinit var userService: UserService

    @Autowired
    private lateinit var cacheManager: CaffeineCacheManager

    @Test
    fun `getUsers 호출시 캐시가 사용된다`() {
        // given
        val rep = 5

        // when
        repeat(rep) { userService.getUsers() }

        // then
        val stats = cacheManager.getCacheStats("users")
        assertThat(stats.missCount()).isEqualTo(1)
        assertThat(stats.hitCount()).isEqualTo(4)
    }

    @Test
    fun `객체 내 함수에서 getUsers를 호출한 경우 캐시가 사용되지 않는다`() {
        // given
        val rep = 5

        // when
        repeat(rep) { userService.userMapV1() }

        // then
        val stats = cacheManager.getCacheStats("users")
        assertThat(stats.missCount()).isEqualTo(0)
        assertThat(stats.hitCount()).isEqualTo(0)
    }

    @Test
    fun `확장 함수를 사용하여 getUsers를 호출한 경우 캐시가 사용된다`() {
        // given
        val rep = 5

        // when
        repeat(rep) { userService.userMapV2() }

        // then
        val stats = cacheManager.getCacheStats("users")
        assertThat(stats.missCount()).isEqualTo(1)
        assertThat(stats.hitCount()).isEqualTo(4)
    }

    private fun CaffeineCacheManager.getCacheStats(cacheName: String): CacheStats {
        val cache = this.getCache(cacheName) as? CaffeineCache
        return cache?.nativeCache?.stats() ?: throw RuntimeException("stats cannot be null")
    }
}

 

 

반응형
Comments