728x90
반응형
SMALL

이번에는 TestContainer라는 기술을 사용해서 Spring Boot에서 테스트 하는 방법을 알아보고자 한다. 

이 포스팅은 아래 포스팅 이후의 포스팅이다. 그래서 아래 포스팅을 보지 않았다면 이 포스팅을 먼저 보고와야한다.

2024.10.02 - [Spring Advanced] - Redis를 사용해서 캐싱하기

 

Redis를 사용해서 캐싱하기

Spring Boot + Redis는 환상의 조합이다. 정말 많은 개발자들이 사용하는 조합이고, Redis는 대규모 트래픽을 처리하는 서비스에서는 거의 필수요소라고 볼 수 있다. 왜냐? 아주 빠르고 캐싱이 가능하

cwchoiit.tistory.com

 

TestContainer 써야 할까?

우선, 이 TestContainer가 유용한 경우는 이럴때다.

  • DB 연동을 해야 하는 테스트를 해야할 때
  • 테스트 환경에서 DB 연동은 해야겠는데 귀찮을 때
  • DB를 새로 만들거나 구축하기는 정말 싫어서 테스트 후 바로 없애버리고 싶을 때
  • Docker는 깔려있을 때

이런 경우 이 TestContainer가 장점을 발휘할 수 있다. 혹자는 그냥 H2로 테스트할때만 하면 되는거 아닌가? 싶을수 있다. MySQLH2는 같은 데이터베이스가 아니다. 즉, 동일한 작업을 해도 둘 중 하나는 에러가 발생하는데 둘 중 하나는 발생하지 않을 수 있다. 그래서 정확한 테스트라고 할 수는 없다.

 

또 다른 혹자는 그럼 그냥 Mocking하면 되는거 아닌가? 싶을수 있다. 이 또한, 사실 Mocking은 데이터베이스 연동 테스트가 전혀 아니다. 비즈니스 로직의 결함을 찾는것에 더 가까운 테스트이지 데이터베이스까지 연동한 테스트는 전혀 아니다.

 

TestContainer Dependencies

build.gradle

dependencies {
    ...
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    ...
}

 

TestContainer Test Code

package cwchoiit.dmaker;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import cwchoiit.dmaker.dto.CreateDeveloperRequestDTO;
import cwchoiit.dmaker.dto.DeveloperDetailDTO;
import cwchoiit.dmaker.service.DMakerService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.io.IOException;

import static cwchoiit.dmaker.type.DeveloperLevel.SENIOR;
import static cwchoiit.dmaker.type.DeveloperType.BACK_END;
import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@Testcontainers
@SpringBootTest
class DMakerApplicationTests {

    @Container
    private static final GenericContainer<?> redisContainer =
            new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379);
    @Autowired
    DMakerService dMakerService;

    @BeforeAll
    static void setUp() {
        redisContainer.followOutput(new Slf4jLogConsumer(log));
    }

    @AfterAll
    static void tearDown() {
        redisContainer.close();
    }

    @DynamicPropertySource
    private static void registerRedisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString());
    }

    @Test
    void contextLoads() throws IOException, InterruptedException {
        CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
                .developerLevel(SENIOR)
                .developerType(BACK_END)
                .experienceYears(15)
                .name("Xayah")
                .age(33)
                .memberId("xayah")
                .build();
        dMakerService.createDeveloper(xayah);

        dMakerService.getDeveloperByMemberId("xayah");
        dMakerService.getDeveloperByMemberId("xayah");

        GenericContainer.ExecResult execResult = redisContainer.execInContainer("redis-cli", "get", "developer:xayah");

        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        DeveloperDetailDTO developerDetailDTO = mapper.readValue(execResult.getStdout(), DeveloperDetailDTO.class);

        assertThat(redisContainer.isRunning()).isTrue();
        assertThat(developerDetailDTO.getName()).isEqualTo("Xayah");
        assertThat(developerDetailDTO.getAge()).isEqualTo(33);
        assertThat(developerDetailDTO.getMemberId()).isEqualTo("xayah");
    }
}
  • 우선 클래스 레벨에 @Testcontainers 애노테이션을 붙여준다.
  • 그리고 실제로 컨테이너를 생성해서 데이터베이스를 만들어야 하는데 나는 Redis를 사용해서 테스트해보기로 한다. 그래서 다음과 같이 필드 하나를 추가한다.
@Container
private static final GenericContainer<?> redisContainer =
        new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379);
  • @Container 애노테이션을 붙여서 이 필드가 컨테이너가 될 것임을 알려준다.
  • 가장 최근의 이미지인 "redis:latest"를 사용하고, Redis의 기본 포트인 6379를 Expose한다.
@BeforeAll
static void setUp() {
    redisContainer.followOutput(new Slf4jLogConsumer(log));
}

@AfterAll
static void tearDown() {
    redisContainer.close();
}
  • 이 부분은 둘 다 옵셔널하다. 나는 저 컨테이너에서 출력하는 로그 내용을 테스트 하면서 출력하고 싶어서 setUp()을 구현했고, tearDown()은 필요가 사실 없다. 왜냐하면 테스트가 끝나면 자동으로 컨테이너가 삭제된다.
@DynamicPropertySource
private static void registerRedisProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString());
}
  • 중요한 부분은 이 부분이다. 컨테이너를 띄울때 6379와 매핑되는 포트는 임의로 지정된다. 같은 6379:6379면 좋갰지만 그게 아니다. 그래서 컨테이너가 띄워진 후 6379와 매핑된 포트를 스프링 부트의 설정값 중 spring.data.redis.port에 지정해줘야 한다. 그래야 정상적으로 통신이 가능해질테니.
  • 그래서, 동적으로 속성값을 설정할 수 있는 방법을 이렇게 @DynamicPropertySource 애노테이션으로 제공한다.

 

@Test
void contextLoads() throws IOException, InterruptedException {
    CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
            .developerLevel(SENIOR)
            .developerType(BACK_END)
            .experienceYears(15)
            .name("Xayah")
            .age(33)
            .memberId("xayah")
            .build();
    dMakerService.createDeveloper(xayah);

    dMakerService.getDeveloperByMemberId("xayah");
    dMakerService.getDeveloperByMemberId("xayah");

    GenericContainer.ExecResult execResult = redisContainer.execInContainer("redis-cli", "get", "developer:xayah");

    ObjectMapper mapper = new ObjectMapper();
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    DeveloperDetailDTO developerDetailDTO = mapper.readValue(execResult.getStdout(), DeveloperDetailDTO.class);

    assertThat(redisContainer.isRunning()).isTrue();
    assertThat(developerDetailDTO.getName()).isEqualTo("Xayah");
    assertThat(developerDetailDTO.getAge()).isEqualTo(33);
    assertThat(developerDetailDTO.getMemberId()).isEqualTo("xayah");
}
  • 이 부분은 실제 테스트 코드다. 사실 이 부분은 별 게 없다. 나의 경우 getDeveloperByMemberId()Redis로 캐시할 수 있게 구현했다. 그래서 저 메서드를 두번 호출헀을 땐, 첫번째는 redis에 저장된 값이 없기 때문에 실제 데이터베이스에서 값을 가져올 것이고, 두번째 호출했을 땐, redis에 저장된 값이 있기 때문에 바로 캐싱이 가능해진다.
  • 그리고, redis에 저장됐기 때문에, redisContainer.execInContainer("redis-cli", "get", "developer:xayah")를 호출하면 저장된 캐시의 value가 반환될 것이다.
  • 그 반환된 값을 JSON으로 역직렬화를 하여 객체가 가진 값들을 비교한다.
어떻게 JSON으로 직렬화해서 저장이 바로 됐나요? → 이전 포스팅을 참고해야 한다. RedisConfig 클래스로 설정을 했다. 그리고 @SpringBootTest이므로 스프링 컨테이너가 온전히 띄워지기 때문에 설정값이 적용된 상태에서 이 테스트가 진행되는 것이다.

 

테스트 결과는 다음과 같이 성공이다.

 

 

참고. 컨테이너가 띄워지고 삭제되는 것을 확인

저 테스트가 실행되면, 컨테이너가 실제로 띄워진다. 그리고 테스트가 끝나면 컨테이너가 자동으로 삭제된다. 확인해보면 좋을것이다.

테스트가 진행중
테스트 종료 후

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Redis를 사용해서 캐싱하기  (0) 2024.10.02
Mockito를 사용한 스프링 프로젝트 단위 테스트  (4) 2024.09.29
AOP (Part. 3) - 포인트컷  (0) 2024.01.02
AOP (Part.2)  (0) 2024.01.02
AOP(Aspect Oriented Programming)  (0) 2023.12.29
728x90
반응형
SMALL

Spring Boot + Redis는 환상의 조합이다. 정말 많은 개발자들이 사용하는 조합이고, Redis는 대규모 트래픽을 처리하는 서비스에서는 거의 필수요소라고 볼 수 있다. 왜냐? 아주 빠르고 캐싱이 가능하기 때문이다.

 

간단하게 캐싱 테스트를 해 볼 목적으로 Redis를 로컬에 설치해보자. (모든 설명은 macOS 기준으로 되어 있다)

 

Redis 설치

다음 링크에 아주 간단하고 자세하게 설치 방법이 나와있다.

 

Install Redis on macOS

Use Homebrew to install and start Redis on macOS

redis.io

 

먼저 다음 명령어로 redis를 설치한다.

brew install redis

 

설치가 다 됐으면, 다음 명령어로 redis를 서비스로 등록한다.

brew services start redis

 

그럼 앞으로 백그라운드로 이 redis가 실행될 것이다. 만약 이 서비스를 종료하고 싶으면 다음 명령어를 실행하면 된다.

brew services stop redis

 

이 서비스의 정보를 확인하는 방법은 다음과 같다.

brew services info redis

이 명령어를 치면 다음과 같이 나온다.

redis (homebrew.mxcl.redis)
Running: ✔
Loaded: ✔
Schedulable: ✘
User: choichiwon
PID: 3404

 

Spring Boot에서 redis 의존성 내려받기

build.gradle

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    ...
}
  • 이 한줄만 추가해주면 끝난다.

Spring Boot에서 redis 설정하기

우선, 여러 방법으로 설정을 할 수 있는데, application.yaml 파일에서도 redis 설정을 할 수 있다. 근데 이렇게 하지 않을 것이다. 왜냐하면, 우선 첫번째 이유로는, redis가 로컬에 설치된 게 아니라 다른 외부에 있는게 아니라면 추가적인 설정이 필요가 없다.

 

왜냐하면 redis 의존성을 추가해주면 기본 설정이 다음과 같기 때문이다.

application.yaml

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

 

두번째로는, 이후에 알아보겠지만 관련 설정을 이 외부 설정 파일 말고 자바 코드로 풀 것이다.

그래서, 자바 코드로 하나씩 풀어보자. 나는 일단, RedisConfig라는 클래스 하나를 만들것이다.

 

RedisConfig

package cwchoiit.dmaker.config.redis;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class RedisConfig {}
  • 빈 껍데기의 클래스이다. 우선은 @Configuration, @EnableCaching 이 두개의 애노테이션만 있어도 충분하다.

 

이제 캐시를 적용할 것이다. 실제로 캐시가 적용되길 원하는 메서드에 다음과 같이 애노테이션을 붙여준다.

@Cacheable("developer")
public DeveloperDetailDTO getDeveloperByMemberId(@NonNull String memberId) {
    log.info("[getDeveloperByMemberId] memberId = {}", memberId);
    return developerRepository.findByMemberId(memberId)
            .map(DeveloperDetailDTO::fromEntity)
            .orElseThrow(() -> new DMakerException(NO_DEVELOPER));
}
  • @Cacheable() 애노테이션을 붙여준다. 그러면, 이 메서드가 호출되면 캐시된 값이 있으면 그 값을 가져오게 된다. 저기서 "developer"key를 의미한다.
  • 이대로 끝나면 안된다. 왜냐하면, redis는 스프링 부트 외부에 있는 서비스이다. 그렇다는 것은 서비스와 서비스간 통신을 하려면 규약이 필요하다. 데이터가 전송될 때, 전송할 때 같은 포맷, 형식으로 데이터를 주고 받아야 한다. 그래서 가장 간단한 방법은 캐시하려는 데이터(여기서는 DeveloperDetailDTO가 된다) 객체가 Serializable을 구현하면 된다. 다음 코드처럼.

DeveloperDetailDTO

package cwchoiit.dmaker.dto;

import cwchoiit.dmaker.entity.Developer;
import cwchoiit.dmaker.type.DeveloperLevel;
import cwchoiit.dmaker.type.DeveloperType;
import cwchoiit.dmaker.type.StatusCode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeveloperDetailDTO implements Serializable {
    private DeveloperLevel developerLevel;
    private DeveloperType developerType;
    private String memberId;
    private Integer experienceYears;
    private String name;
    private Integer age;
    private StatusCode statusCode;

    public static DeveloperDetailDTO fromEntity(Developer developer) {
        return DeveloperDetailDTO.builder()
                .developerLevel(developer.getDeveloperLevel())
                .developerType(developer.getDeveloperType())
                .memberId(developer.getMemberId())
                .experienceYears(developer.getExperienceYears())
                .name(developer.getName())
                .age(developer.getAge())
                .statusCode(developer.getStatusCode())
                .build();
    }
}
  • implements Serializable을 선언한다. 이럼 끝이다.

테스트 해보기

실제로 API를 날려보자. 다음 사진을 보자.

  • 최초에는 캐시가 없기 때문에, 직접 데이터베이스에서 값을 가져오는 모습이 보인다. SELECT문이 실행됐다.

저 이후에 다시 한번 API를 날려보면, 다음과 같이 캐시데이터를 가져온다.

  • 아예 서비스의 memberId를 보여주는 로그조차 찍히지 않았다. 즉, 캐시 데이터를 그대로 반환한 것이다.

 

그리고 실제로 redis-cli로 확인을 해보면 잘 저장되어 있다.

 

그럼 실제로 저 데이터가 어떻게 저장되어 있나 확인해보자.

우리가 알아볼 수 없는 유니코드로 보여진다. 직렬화를 하기 위해 Serializable을 구현했는데, 이게 자바 Serialization이기 때문에 사람이 알아보기가 힘들다. 그래서 사람이 알아보기 좋은 포맷이 뭘까? 바로 JSON이다. JSON으로 직렬화할 수 있겠지? 당연히 있다! 해보자!

 

JSON으로 직렬화 방법 바꾸기

이제 자바의 Serialization이 아닌 JSON 형태로 직렬화하기 위해 아까 빈 껍데기로 만들어 두었던 RedisConfig를 사용할 차례다.

 

RedisConfig

package cwchoiit.dmaker.config.redis;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

import static org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair.fromSerializer;

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}
  • RedisCacheConfiguration을 반환하는 빈을 등록한다.
  • serializeValuesWith() 메서드를 사용해서, key 말고 value에 대한 직렬화를 JSON으로 하도록 설정한다. (어차피 key는 문자열로 잘 직렬화 된 것을 이미 위에서 확인했기 때문에)

 

그리고 이렇게 직렬화 방식을 변경했으면, 다시 아까 DeveloperDetailDTO가 Serializable을 구현한 것을 지워줘야 한다. 이제는 자바 방식이 아니라 JSON 방식으로 수정했으니 당연히 지워줘야 한다.

DeveloperDetailDTO

package cwchoiit.dmaker.dto;

import cwchoiit.dmaker.entity.Developer;
import cwchoiit.dmaker.type.DeveloperLevel;
import cwchoiit.dmaker.type.DeveloperType;
import cwchoiit.dmaker.type.StatusCode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeveloperDetailDTO {
    private DeveloperLevel developerLevel;
    private DeveloperType developerType;
    private String memberId;
    private Integer experienceYears;
    private String name;
    private Integer age;
    private StatusCode statusCode;

    public static DeveloperDetailDTO fromEntity(Developer developer) {
        return DeveloperDetailDTO.builder()
                .developerLevel(developer.getDeveloperLevel())
                .developerType(developer.getDeveloperType())
                .memberId(developer.getMemberId())
                .experienceYears(developer.getExperienceYears())
                .name(developer.getName())
                .age(developer.getAge())
                .statusCode(developer.getStatusCode())
                .build();
    }
}
  • implements Serializable을 제거했다.

 

그리고, 아까 테스트 하면서 생긴 캐시는 다시 삭제하자. redis-cli에서 `flushall` 명령어를 실행하면 된다.

 

 

이제 다시 테스트 해보자.

  • 실행 결과를 보면, 첫번째 요청은 캐시가 없기 때문에 데이터베이스에서 조회해왔다.
  • 두번째 요청은 서비스의 로그조차 호출되지 않고 바로 캐시 데이터를 반환했다.

 

그리고 redis-cli로 확인해봐도 아주 잘 나오고 이제는 value값도 아주 잘 보인다.

 

 

RedisConfig 추가 설정하기

Prefix 설정

다만, 한가지 아쉬운 점이 있다. 캐시를 저장한 모습을 보면 이렇게 보인다.

  • :: 이게 두번 나온다. 그리고 Redis는 컨벤션을 : 하나를 사용하는 것을 말하고 있다. 그런데 스프링은 왜 두개를 붙이는지는 모른다. 그래도 이걸 바꿀순 있다.

이땐, 아까 RedisConfig 클래스에서 설정 정보를 추가해준다 다음처럼.

@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .computePrefixWith(name -> name + ":")
            .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
  • computePrefixWith(name -> name + ":") → 이렇게 prefix를 설정한다.
  • 이 상태에서 다시 캐시를 만들어보면 다음과 같이 `:`이 하나만 나온다.

 

TTL 설정

이번엔 Time to Live 값 설정이다. 지금 상태로는 서버가 내려가기전 까진 캐시가 무한정 살아있기 때문에 대규모 트래픽이나 사용량이 높은 서비스라면 이 경우 메모리가 부족한 현상이 일어나게 될 것이다. 그래서 캐시의 유효시간을 설정해줘야 한다.

 

RedisConfig

@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .computePrefixWith(name -> name + ":")
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
  • entryTtl(Duration.ofMinutes(10)) → 간단하게 10분정도로 설정했다. 

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

Spring 프로젝트에서 테스트를 작성할 때 단위 테스트를 위한 Mockito를 사용해보자.

Mockito를 사용하면 좋은 점은 다음과 같다.

  • 빠르고 독립적이다.
  • 외부 서비스나 데이터베이스와의 상호작용을 실제로 하고 싶지 않을 때 유용하다.

왜 빠르냐, 스프링이 띄워질 때 처리되는 여러 자동화된 작업들이 필요없으므로 @SpringBootTest 애노테이션을 사용한 스프링을 띄운 테스트보다 훨씬 빠르다. 

 

그래서 일단 간단하게 테스트 코드를 만들기 위한 필요 코드들을 살펴보자.

 

DMakerService

@Slf4j
@Service
@RequiredArgsConstructor
public class DMakerService {

    /**
     * The repository for accessing and manipulating {@link Developer} data.
     */
    private final DeveloperRepository developerRepository;
    private final RetiredDeveloperRepository retiredDeveloperRepository;

    /**
     * Creates a new developer based on the provided {@link CreateDeveloperRequestDTO} payload.
     * It first validates the payload and then saves the developer to the database.
     *
     * @param createDeveloperRequestDTO The payload containing the developer's information.
     * @throws DMakerException If the payload validation fails or if a developer with the same memberId already exists.
     */
    @Transactional
    public CreateDeveloperResponseDTO createDeveloper(CreateDeveloperRequestDTO createDeveloperRequestDTO) {
        validationCreatePayload(createDeveloperRequestDTO);

        Developer developer = Developer.builder()
                .developerLevel(createDeveloperRequestDTO.getDeveloperLevel())
                .developerType(createDeveloperRequestDTO.getDeveloperType())
                .name(createDeveloperRequestDTO.getName())
                .age(createDeveloperRequestDTO.getAge())
                .experienceYears(createDeveloperRequestDTO.getExperienceYears())
                .memberId(createDeveloperRequestDTO.getMemberId())
                .statusCode(EMPLOYED)
                .build();

        developerRepository.save(developer);

        return CreateDeveloperResponseDTO.fromEntity(developer);
    }

    public List<DeveloperDTO> getAllEmployedDevelopers() {
        return developerRepository.findDevelopersByStatusCodeEquals(EMPLOYED).stream()
                .map(DeveloperDTO::fromEntity)
                .collect(Collectors.toList());
    }

    public DeveloperDetailDTO getDeveloperByMemberId(String memberId) {
        return developerRepository.findByMemberId(memberId)
                .map(DeveloperDetailDTO::fromEntity)
                .orElseThrow(() -> new DMakerException(NO_DEVELOPER));
    }

    public DeveloperDetailDTO updateDeveloperByMemberId(String memberId, UpdateDeveloperRequestDTO updateDeveloperRequestDTO) {
        Developer findDeveloper = developerRepository.findByMemberId(memberId)
                .orElseThrow(() -> new DMakerException(NO_DEVELOPER));

        validationUpdatePayload(updateDeveloperRequestDTO, findDeveloper);
        findDeveloper.changeDeveloperData(updateDeveloperRequestDTO);
        return DeveloperDetailDTO.fromEntity(findDeveloper);
    }

    @Transactional
    public DeveloperDetailDTO deleteDeveloperByMemberId(String memberId) {
        Developer findDeveloper = developerRepository.findByMemberId(memberId)
                .orElseThrow(() -> new DMakerException(NO_DEVELOPER));

        findDeveloper.changeStatusCode(RETIRED);

        RetiredDeveloper retiredDeveloper = RetiredDeveloper.builder()
                .memberId(findDeveloper.getMemberId())
                .age(findDeveloper.getAge())
                .name(findDeveloper.getName())
                .build();

        retiredDeveloperRepository.save(retiredDeveloper);
        return DeveloperDetailDTO.fromEntity(findDeveloper);
    }

    private void validationUpdatePayload(UpdateDeveloperRequestDTO updateDeveloperRequestDTO, Developer updateDeveloper) {
        DeveloperLevel developerLevel = updateDeveloperRequestDTO.getDeveloperLevel() != null ? updateDeveloperRequestDTO.getDeveloperLevel() : updateDeveloper.getDeveloperLevel();
        Integer experienceYears = updateDeveloperRequestDTO.getExperienceYears() != null ? updateDeveloperRequestDTO.getExperienceYears() : updateDeveloper.getExperienceYears();

        compareLevelWithExperienceYears(developerLevel, experienceYears);
    }

    /**
     * Validates the provided {@link CreateDeveloperRequestDTO} payload.
     * It checks if the developer's experience years match the developer level.
     * It also checks if a developer with the same memberId already exists.
     *
     * @param createDeveloperRequestDTO The payload containing the developer's information.
     * @throws DMakerException If the payload validation fails.
     */
    private void validationCreatePayload(CreateDeveloperRequestDTO createDeveloperRequestDTO) {
        DeveloperLevel developerLevel = createDeveloperRequestDTO.getDeveloperLevel();

        compareLevelWithExperienceYears(developerLevel, createDeveloperRequestDTO.getExperienceYears());

        Optional<Developer> findDeveloper = developerRepository.findByMemberId(createDeveloperRequestDTO.getMemberId());
        if (findDeveloper.isPresent()) {
            throw new DMakerException(DMakerErrorCode.DUPLICATE_MEMBER_ID);
        }
    }

    private static void compareLevelWithExperienceYears(DeveloperLevel developerLevel, Integer developerExperienceYears) {
        if (developerLevel.equals(SENIOR) && developerExperienceYears < 10) {
            throw new DMakerException(LEVEL_EXPERIENCE_YEARS_NOT_MATCHED);
        }
        if (developerLevel.equals(MIDDLE) &&
                (developerExperienceYears < 4 || developerExperienceYears > 10)) {
            throw new DMakerException(LEVEL_EXPERIENCE_YEARS_NOT_MATCHED);
        }
        if (developerLevel.equals(JUNIOR) && developerExperienceYears > 4) {
            throw new DMakerException(LEVEL_EXPERIENCE_YEARS_NOT_MATCHED);
        }
    }
}
  • 서비스 클래스 하나가 있다. 이 서비스 클래스는 두가지 의존성을 주입받는다.
    • DeveloperRepository
    • RetiredDeveloperRepository

DeveloperRepository

@Repository
public interface DeveloperRepository extends JpaRepository<Developer, Long> {

    Optional<Developer> findByMemberId(String memberId);

    List<Developer> findDevelopersByStatusCodeEquals(StatusCode statusCode);
}

 

RetiredDeveloperRepository

@Repository
public interface RetiredDeveloperRepository extends JpaRepository<RetiredDeveloper, Long> {
}

 

이런 관계를 가지고 있을 때, 테스트 코드를 작성해보자.

MockitoDMakerServiceTest

package cwchoiit.dmaker.service;

import cwchoiit.dmaker.dto.DeveloperDetailDTO;
import cwchoiit.dmaker.entity.Developer;
import cwchoiit.dmaker.repository.DeveloperRepository;
import cwchoiit.dmaker.repository.RetiredDeveloperRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static cwchoiit.dmaker.type.DeveloperLevel.SENIOR;
import static cwchoiit.dmaker.type.DeveloperType.BACK_END;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
class MockitoDMakerServiceTest {

    @Mock
    private DeveloperRepository developerRepository;
    @Mock
    private RetiredDeveloperRepository retiredDeveloperRepository;

    @InjectMocks
    private DMakerService dMakerService;

    @BeforeEach
    void setUp() {
        given(developerRepository.findByMemberId(anyString()))
                .willReturn(Optional.of(Developer.builder()
                        .developerLevel(SENIOR)
                        .developerType(BACK_END)
                        .experienceYears(15)
                        .name("Samira")
                        .age(30)
                        .memberId("samira")
                        .build()));
    }

    @Test
    void getAllEmployedDevelopers() {

        DeveloperDetailDTO samira = dMakerService.getDeveloperByMemberId("samira");

        assertThat(samira.getDeveloperLevel()).isEqualTo(SENIOR);
        assertThat(samira.getDeveloperType()).isEqualTo(BACK_END);
        assertThat(samira.getName()).isEqualTo("Samira");
        assertThat(samira.getAge()).isEqualTo(30);
        assertThat(samira.getMemberId()).isEqualTo("samira");
    }
}
  • 보통이라면 해당 서비스는 빈으로 등록된 서비스이기 때문에 주입을 받아야 하지만 Mockito를 사용해서 가짜로 주입받는다.
  • Mockito를 사용하려면 먼저 다음 애노테이션이 필요하다.
@ExtendWith(MockitoExtension.class)
class MockitoDMakerServiceTest {...}
  • @ExtendWith(MockitoExtension.class) 애노테이션을 클래스 레벨에 붙여준다.

그리고 아까 위에서 봤지만, DMakerService는 두가지 의존성을 주입 받는다. 그래서 이 DMakerServiceMock으로 주입받기 위해선다음 작업이 필요하다.

@Mock
private DeveloperRepository developerRepository;
@Mock
private RetiredDeveloperRepository retiredDeveloperRepository;

@InjectMocks
private DMakerService dMakerService;
  • 주입받을 의존성을 @Mock 애노테이션이 달린 상태로 선언한다. 
  • 실제로 이 테스트 클래스에 주입 받을 DMakerServiceMock으로 주입하는데 이때 사용하는 애노테이션은 @InjectMocks이다.
  • 그런데 만약, 저 두개의 의존성 중 이 테스트에서는 사용하지 않는 의존관계가 있다면 제거해도 무방하다. 다음 코드처럼.
@Mock
private DeveloperRepository developerRepository;

@InjectMocks
private DMakerService dMakerService;

 

 

이렇게 하면 가짜로 주입을 받을 수 있다. 이제 테스트를 위한 데이터가 필요하다. 어떻게 데이터를 가짜로 만들 수 있냐면 다음 코드를 보자.

@BeforeEach
void setUp() {
    given(developerRepository.findByMemberId(anyString()))
            .willReturn(Optional.of(Developer.builder()
                    .developerLevel(SENIOR)
                    .developerType(BACK_END)
                    .experienceYears(15)
                    .name("Samira")
                    .age(30)
                    .memberId("samira")
                    .build()));
}
  • given() 메서드는 `org.mockito.BDDMockito.given`에서 제공하는 메서드이고, 사전 조건을 정의할 수 있다. 이때 developerRepository가 가지고 있는 findByMemberId() 메서드가 어떤 값을 받을 때 반환하는 가짜 데이터를 임의로 생성할 수 있다. 저기서는 anyString() 이라는 `org.mockito.ArgumentMatchers.anyString`에서 제공하는 메서드를 사용해서 어떤 문자열이 들어와도 동일한 반환을 할 수 있게 정의했다.
  • findByMemberId는 반환값이 Optional이다. 그렇게 때문에 willReturn(Optional.of(...))을 사용한다.
  • 이제 적절한 데이터를 만들어서 이 메서드가 호출되면 어떤 문자열이 들어와도 항상 코드에서 정의한 객체가 반환되도록 하였다.

이제 실제 테스트는 아래와 같이 작성하면 된다.

@Test
void getAllEmployedDevelopers() {

    DeveloperDetailDTO samira = dMakerService.getDeveloperByMemberId("samira");

    assertThat(samira.getDeveloperLevel()).isEqualTo(SENIOR);
    assertThat(samira.getDeveloperType()).isEqualTo(BACK_END);
    assertThat(samira.getName()).isEqualTo("Samira");
    assertThat(samira.getAge()).isEqualTo(30);
    assertThat(samira.getMemberId()).isEqualTo("samira");
}
  • dMakerService.getDeveloperByMemberId() 메서드는 내부적으로 findByMemberId를 사용해서 데이터를 가져온다. 그렇게 되면 위 setUp() 메서드에서 정의한 가짜 데이터가 튀어나오게 될 것이다.

실행 결과

 

생성 관련 서비스 테스트

이번에는 좀 더 복잡한, 생성 관련 테스트를 진행해보자.

@Test
void createDeveloper_pass() {
    CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
            .developerLevel(SENIOR)
            .developerType(BACK_END)
            .experienceYears(15)
            .name("Xayah")
            .age(33)
            .memberId("xayah")
            .build();

    // DMakerService.createDeveloper()를 호출할 때 검증 단계에서 실행되는 findByMemberId()
    given(developerRepository.findByMemberId(anyString())).willReturn(Optional.empty());

    // DMakerService.createDeveloper()를 호출할 때 DeveloperRepository.save()가 실행되는데, 그때 save()가 받는 Developer 객체를 Captor에 capture
    ArgumentCaptor<Developer> captor = ArgumentCaptor.forClass(Developer.class);

    // 서비스 호출
    dMakerService.createDeveloper(xayah);

    // DeveloperRepository.save()가 1번 호출되었는지 확인, save()가 받은 Developer 객체를 Captor에 저장
    verify(developerRepository, times(1)).save(captor.capture());

    // Captor에 저장된 값을 Retrieved
    Developer newDeveloper = captor.getValue();

    // 검증
    assertThat(newDeveloper.getDeveloperLevel()).isEqualTo(SENIOR);
    assertThat(newDeveloper.getDeveloperType()).isEqualTo(BACK_END);
    assertThat(newDeveloper.getExperienceYears()).isEqualTo(15);
    assertThat(newDeveloper.getName()).isEqualTo("Xayah");
    assertThat(newDeveloper.getAge()).isEqualTo(33);
    assertThat(newDeveloper.getMemberId()).isEqualTo("xayah");
}
  • 먼저 생성할 CreateDeveloperRequestDTO로 저장할 객체를 만든다.
  • DMakerServicecreateDeveloper() 메서드는 다음과 같이 검증 로직이 존재한다.
private void validationCreatePayload(CreateDeveloperRequestDTO createDeveloperRequestDTO) {
    DeveloperLevel developerLevel = createDeveloperRequestDTO.getDeveloperLevel();

    compareLevelWithExperienceYears(developerLevel, createDeveloperRequestDTO.getExperienceYears());

    Optional<Developer> findDeveloper = developerRepository.findByMemberId(createDeveloperRequestDTO.getMemberId());
    if (findDeveloper.isPresent()) {
        throw new DMakerException(DMakerErrorCode.DUPLICATE_MEMBER_ID);
    }
}
  • 이 검증 로직에는 DeveloperRepositoryfindByMemberId()를 호출해서 생성하려는 DevelopermemberId가 이미 있는지 체크하는 부분이 있다. 그렇다는 것은 findByMemberId()에 대한 Mocking이 필요하다는 얘기이다.
  • 그렇기 때문에 위 테스트 코드에서 다음 코드가 존재한다.
given(developerRepository.findByMemberId(anyString())).willReturn(Optional.empty());
  • findByMemberId()가 호출되면, Optional.empty()를 반환하게 Mocking하여, 검증에 통과하도록 한다.

여기서 끝나면 안된다. 왜냐하면 생성 관련 테스트는 실제로 전달한 생성 데이터가 제대로 생성이 됐는지 확인을 해줘야 하기 때문이다. 그리고 제대로 생성된 데이터를 확인하기 위해 그 데이터를 보관할 객체를 Mockito는 제공한다.

// DMakerService.createDeveloper()를 호출할 때 DeveloperRepository.save()가 실행되는데, 그때 save()가 받는 Developer 객체를 Captor에 capture
ArgumentCaptor<Developer> captor = ArgumentCaptor.forClass(Developer.class);
  • 이렇게 ArgumentCaptor<T>로 생성 객체를 저장할 수 있다. 우리가 확인하길 원하는 생성 객체의 타입은 Developer이므로, ArgumentCaptor<Developer> 타입으로 지정한다.
// 서비스 호출
dMakerService.createDeveloper(xayah);
  • 서비스를 호출한다.
// DeveloperRepository.save()가 1번 호출되었는지 확인, save()가 받은 Developer 객체를 Captor에 저장
verify(developerRepository, times(1)).save(captor.capture());
  • 호출한 서비스에서 DeveloperRepositorysave() 메서드가 1번 실행됐음을 확인한다.
  • 그리고 해당 save()에 던져진 파라미터를 위에서 선언한 Captor에 저장한다.
// Captor에 저장된 값을 Retrieved
Developer newDeveloper = captor.getValue();
  • Captor에 저장한 객체를 꺼내온다.
// 검증
assertThat(newDeveloper.getDeveloperLevel()).isEqualTo(SENIOR);
assertThat(newDeveloper.getDeveloperType()).isEqualTo(BACK_END);
assertThat(newDeveloper.getExperienceYears()).isEqualTo(15);
assertThat(newDeveloper.getName()).isEqualTo("Xayah");
assertThat(newDeveloper.getAge()).isEqualTo(33);
assertThat(newDeveloper.getMemberId()).isEqualTo("xayah");
  • 해당 객체는 우리가 생성 테스트를 위해 만든 객체와 동일한 값들을 가지는지 확인한다.

생성 관련 테스트 (실패 케이스)

이번엔 생성 테스트 중 실패 케이스를 보자.

@Test
void createDeveloper_fail() {
    // DMakerService.createDeveloper()를 호출할 때 검증 단계에서 실행되는 findByMemberId()
    given(developerRepository.findByMemberId(anyString()))
            .willReturn(Optional.of(Developer.builder()
                    .developerLevel(SENIOR)
                    .developerType(BACK_END)
                    .experienceYears(15)
                    .name("Xayah")
                    .age(33)
                    .memberId("xayah")
                    .build()));

    CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
            .developerLevel(SENIOR)
            .developerType(BACK_END)
            .experienceYears(15)
            .name("Xayah")
            .age(33)
            .memberId("xayah")
            .build();

    // 서비스 호출 시 중복 memberId로 Developer 생성 시, DMakerException 발생
    assertThatThrownBy(() -> dMakerService.createDeveloper(xayah)).isInstanceOf(DMakerException.class);
}
  • 한 라인씩 알아보자.
// DMakerService.createDeveloper()를 호출할 때 검증 단계에서 실행되는 findByMemberId()
given(developerRepository.findByMemberId(anyString()))
        .willReturn(Optional.of(Developer.builder()
                .developerLevel(SENIOR)
                .developerType(BACK_END)
                .experienceYears(15)
                .name("Xayah")
                .age(33)
                .memberId("xayah")
                .build()));
  • 이번엔 DeveloperRepository.findByMemberId()Mocking을 우리가 생성하고자 하는 데이터와 동일하게 해서 반환하도록 설정한다.
CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
                .developerLevel(SENIOR)
                .developerType(BACK_END)
                .experienceYears(15)
                .name("Xayah")
                .age(33)
                .memberId("xayah")
                .build();

// 서비스 호출 시 중복 memberId로 Developer 생성 시, DMakerException 발생
assertThatThrownBy(() -> dMakerService.createDeveloper(xayah)).isInstanceOf(DMakerException.class);
  • 그리고 생성 테스트를 위해 서비스의 createDeveloper()를 호출하면, DMakerException 예외를 터트릴 것을 예상한다.
  • 왜냐하면, 검증 단계에서 같은 memberId가 있는 경우 다음과 같이 예외를 터트린다.
Optional<Developer> findDeveloper = developerRepository.findByMemberId(createDeveloperRequestDTO.getMemberId());
if (findDeveloper.isPresent()) {
    throw new DMakerException(DMakerErrorCode.DUPLICATE_MEMBER_ID);
}

컨트롤러 테스트

이번엔 아래와 같은 컨트롤러에 대한 테스트도 진행해보자.

DMakerController

package cwchoiit.dmaker.controller;

import cwchoiit.dmaker.dto.*;
import cwchoiit.dmaker.service.DMakerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * This RESTful controller provides endpoints for retrieving developer names.
 */
@Slf4j
@RestController
@RequiredArgsConstructor
public class DMakerController {

    private final DMakerService dMakerService;

    /**
     * Retrieves a list of developer names.
     *
     * @return A list of developer names.
     */
    @GetMapping("/developers")
    public List<DeveloperDTO> getDevelopers() {
        log.info("GET /developers");
        return dMakerService.getAllEmployedDevelopers();
    }

    @GetMapping("/developers/{memberId}")
    public DeveloperDetailDTO getDeveloperByMemberId(@PathVariable String memberId) {
        log.info("GET /developers/{}", memberId);
        return dMakerService.getDeveloperByMemberId(memberId);
    }


    /**
     * Creates a new developer.
     *
     * @param createDeveloperRequestDTO The request object containing the details of the developer to be created.
     * @return A message indicating that the developer was created successfully.
     */
    @PostMapping("/developers")
    public CreateDeveloperResponseDTO createDeveloper(@Validated @RequestBody CreateDeveloperRequestDTO createDeveloperRequestDTO) {
        log.info("POST /developers");

        return dMakerService.createDeveloper(createDeveloperRequestDTO);
    }

    @PutMapping("/developers/{memberId}")
    public DeveloperDetailDTO updateDeveloperByMemberId(@PathVariable String memberId,
                                                        @Validated @RequestBody UpdateDeveloperRequestDTO updateDeveloperRequestDTO) {
        log.info("PUT /developers/{}", memberId);
        return dMakerService.updateDeveloperByMemberId(memberId, updateDeveloperRequestDTO);
    }

    @DeleteMapping("/developers/{memberId}")
    public DeveloperDetailDTO deleteDeveloperByMemberId(@PathVariable String memberId) {
        log.info("DELETE /developers/{}", memberId);
        return dMakerService.deleteDeveloperByMemberId(memberId);
    }
}

 

테스트 코드를 작성하자.

DMakerControllerTest

package cwchoiit.dmaker.controller;

import cwchoiit.dmaker.dto.DeveloperDTO;
import cwchoiit.dmaker.service.DMakerService;
import cwchoiit.dmaker.type.DeveloperLevel;
import cwchoiit.dmaker.type.DeveloperType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.nio.charset.StandardCharsets;
import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(DMakerController.class)
class DMakerControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private DMakerService dMakerService;

    private final MediaType contentType =
            new MediaType(MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(), StandardCharsets.UTF_8);

    @BeforeEach
    void setUp() {
        DeveloperDTO samira = DeveloperDTO.builder()
                .developerLevel(DeveloperLevel.SENIOR)
                .developerType(DeveloperType.BACK_END)
                .memberId("samira")
                .build();

        DeveloperDTO jin = DeveloperDTO.builder()
                .developerLevel(DeveloperLevel.MIDDLE)
                .developerType(DeveloperType.BACK_END)
                .memberId("Jin")
                .build();

        given(dMakerService.getAllEmployedDevelopers()).willReturn(List.of(samira, jin));
    }

    @Test
    void getAllDevelopers() throws Exception {
        mockMvc.perform(get("/developers").contentType(contentType))
                .andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("$.[0].memberId").value("samira"))
                .andExpect(jsonPath("$.[0].developerLevel").value("SENIOR"))
                .andExpect(jsonPath("$.[1].memberId").value("Jin"))
                .andExpect(jsonPath("$.[1].developerLevel").value("MIDDLE"));
    }
}
  • 컨트롤러 테스트만을 할건데, 모든 스프링 빈을 등록할 필요는 없을 것이다. 그래서 딱 원하는 빈만 스프링 컨테이너에 등록해서 테스트를 할 수 있게 해주는 기능이 있는데 바로 다음 애노테이션이다.
@WebMvcTest(DMakerController.class)
class DMakerControllerTest {...}
  • 이렇게 @WebMvcTest(DMakerController.class) 라는 애노테이션을 등록하면, 이 테스트를 실행할 때 저 컨트롤러만 빈으로 등록되어 사용할 수 있다.
@Autowired
private MockMvc mockMvc;

@MockBean
private DMakerService dMakerService;
  • HTTP REST 요청을 하기 위해 필요한 MockMvc
  • DMakerController가 주입 받아야 하는 DMakerServiceMock으로 빈 등록을 한다. 
  • 위에서 서비스 관련 테스트를 할때는 필요한 주입을 @Mock 애노테이션으로 사용했는데 여기서는 @MockBean으로 하는 이유는 이 테스트를 실행해보면 알겠지만 스프링이 띄워진다. @WebMvcTest는 스프링을 띄우긴 띄우는데 선언한 빈들만 등록하게 해서 띄우는 간단한 스프링이라고 생각하면 좋다. 그래서 스프링 Bean으로 등록될 Mock을 선언하는 @MockBean 애노테이션을 선언한다.
@BeforeEach
void setUp() {
    DeveloperDTO samira = DeveloperDTO.builder()
            .developerLevel(DeveloperLevel.SENIOR)
            .developerType(DeveloperType.BACK_END)
            .memberId("samira")
            .build();

    DeveloperDTO jin = DeveloperDTO.builder()
            .developerLevel(DeveloperLevel.MIDDLE)
            .developerType(DeveloperType.BACK_END)
            .memberId("Jin")
            .build();

    given(dMakerService.getAllEmployedDevelopers()).willReturn(List.of(samira, jin));
}
  • 테스트를 실행하기 앞서, 테스트 데이터가 필요하므로 테스트 데이터를 생성한다. 그래서 DMakerServicegetAllEmployedDevelopers()가 호출되면 반환되는 리스트를 가짜로 만든다.
@Test
void getAllDevelopers() throws Exception {
    mockMvc.perform(get("/developers").contentType(contentType))
            .andExpect(status().isOk())
            .andDo(print())
            .andExpect(jsonPath("$.[0].memberId").value("samira"))
            .andExpect(jsonPath("$.[0].developerLevel").value("SENIOR"))
            .andExpect(jsonPath("$.[1].memberId").value("Jin"))
            .andExpect(jsonPath("$.[1].developerLevel").value("MIDDLE"));
}
  • 실제 테스트 코드는 다음과 같다. 주입 받은 MockMvc를 사용해서 HTTP 요청을 날린다. 이때 Content-Type은 JSON으로 지정하기 위해 필드로 선언한 `contentType`을 사용한다. 그리고 그 응답 결과를 우리가 만든 가짜 데이터가 나올 것으로 예상한 테스트 코드를 작성한다.
  • andDo(print())는 이 요청에 대한 요청-응답 정보를 출력하는 메서드이다.

실행 결과

정리를 하자면

이렇게 간단하게 데이터베이스가 필요한 서비스지만, 데이터베이스를 사용하고 싶지 않을 때 Mockito를 사용해서 가짜로 데이터를 만들어 내는 방법을 배워봤다. 간단한 단위 테스트를 작성할 때 Mockito를 사용하면 코드 자체적인 문제를 잘 찾아낼 수 있을 것 같다.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

TestContainer를 이용한 Spring Boot 테스트  (2) 2024.10.03
Redis를 사용해서 캐싱하기  (0) 2024.10.02
AOP (Part. 3) - 포인트컷  (0) 2024.01.02
AOP (Part.2)  (0) 2024.01.02
AOP(Aspect Oriented Programming)  (0) 2023.12.29
728x90
반응형
SMALL
SMALL

포인트컷 지시자에 대해서 자세히 알아보자. 에스팩트J는 포인트컷을 편리하게 표현하기 위한 특별한 표현식을 제공한다. 예를 들면 이렇다.

@Pointcut("execution(* hello.aop.order..*(..))")

 

포인트컷 표현식은 'execution'과 같은 포인트컷 지시자(Pointcut Designator)로 시작한다. 줄여서 PCD라고도 한다.

 

포인트컷 지시자 종류

포인트컷 지시자의 종류는 다음과 같다.

  • execution: 메서드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용한다.
  • within: 특정 타입(클래스, 인터페이스) 내의 조인 포인트를 매칭한다.
  • args: 인자가 주어진 타입의 인스턴스인 조인 포인트
  • this: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트
  • target: Target 객체(스프링 AOP 프록시가 가리키는 실제 대상)를 대상으로 하는 조인 포인트
  • @target: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트
  • @within: 주어진 애노테이션이 있는 타입 내 조인 포인트
  • @annotation: 주어진 애노테이션을 가지고 있는 메서드를 조인 포인트로 매칭
  • @args: 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트
  • bean: 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.

 

말로만 보면 이해하기가 정말 난해하다. 그래서 코드로 하나씩 뜯어보자. execution이 가장 많이 사용되고 나머지는 거의 사용하지 않는다. 따라서 execution을 중점적으로 이해해보자.

 

 

필요 데이터 만들기

ClassAop.java

package com.example.aop.member.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE) // Class, Interface 에다가 붙이는 애노테이션인 경우 ElementType.TYPE 을 사용한다.
// RetentionPolicy.RUNTIME 은 실제 RUNTIME 일 때에도 이 애노테이션이 살아있는 경우를 말한다.
// RUNTIME 말고 SOURCE 란 것도 있는데 이건 컴파일하면 컴파일된 파일은 이 애노테이션이 사라져버린다. 그래서 동적으로 이 애노테이션을 읽을 수 없다. 우리가 원하는게 아니다.
@Retention(RetentionPolicy.RUNTIME)
public @interface ClassAop {

}

 

MethodAop.java

package com.example.aop.member.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodAop {
    String value();
}

 

우선, 두 개의 애노테이션을 만들었다. 하나는 클래스 레벨에 달 애노테이션이고 하나는 메서드 레벨에 달 애노테이션이다.

애노테이션을 만드려면 기본적으로 두 개의 애노테이션이 필요하다. @Target, @Retention.

 

@Target은 이 애노테이션이 어디에 달릴지를 설명하는 애노테이션이다. ElementType.TYPE으로 설정하면 클래스 또는 인터페이스에 레벨에 적용할 애노테이션이고 ElementType.METHOD는 메서드 레벨에 적용할 애노테이션이다. 

 

@Retention은 이 애노테이션이 살아있는 레벨을 말한다고 보면 된다. RetentionPolicy.RUNTIME으로 설정하면 런타임에도 해당 애노테이션은 살아 있는 상태로 남아있다. 그래서, 동적으로 애노테이션을 읽을 수 있다. RUNTIME말고 SOURCE도 있는데 이는 컴파일하면 컴파일된 파일에서 애노테이션이 보이지 않고 사라진다. 그래서 동적으로 이 애노테이션을 읽을 수 없다. 

 

그리고 MethodAop 애노테이션은 value() 라는 값을 가질 수 있다. 값의 형태는 문자열이다.

 

MemberService.java

package com.example.aop.member;

public interface MemberService {
    String hello(String param);
}

 

MemberServiceImpl.java

package com.example.aop.member;

import com.example.aop.member.annotation.ClassAop;
import com.example.aop.member.annotation.MethodAop;
import org.springframework.stereotype.Component;

@ClassAop
@Component
public class MemberServiceImpl implements MemberService {

    @Override
    @MethodAop(value = "test value")
    public String hello(String param) {
        return "ok";
    }

    public String internal(String param) {
        return "ok";
    }

    public String twoParams(String param1, String param2) {
        return "ok";
    }
}

 

이번엔 인터페이스와 그 인터페이스를 구현한 구체 클래스를 만들었다. 간단하게 하나의 메서드를 가지는 인터페이스(MemberService)와 그를 구현한 MemberServiceImpl이 있고, 이 구체 클래스는 @ClassAop 애노테이션을 달았다. 그리고 이 구체 클래스 내부에 hello(String param)은 @MethodAop 애노테이션이 달려있다. 

 

ExecutionTest.java

package com.example.aop.pointcut;

import com.example.aop.member.MemberServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;

import java.lang.reflect.Method;

import static org.assertj.core.api.Assertions.*;

@Slf4j
public class ExecutionTest {

    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    Method helloMethod;

    @BeforeEach
    public void init() throws NoSuchMethodException {
        helloMethod = MemberServiceImpl.class.getMethod("hello", String.class); // method 이름이 hello, 파라미터의 타입이 String
    }
}

 

테스트 코드다. 리플렉션을 이용해서 구현한 MemberServiceImpl의 hello 메서드를 가져온다. 각 테스트의 실행마다 그 바로 직전에 리플렉션을 활용해서 메서드를 가져오기 위해 @BeforeEach를 사용했다. 

 

AspectJExpressionPointcut은 포인트컷 표현식을 처리해주는 클래스다. 여기에 포인트컷 표현식을 지정하면 된다. 이 클래스는 상위에 Pointcut 인터페이스를 가진다. 

 

 

execution

가장 중요한 포인트컷 지시자이다. 이 execution은 다음과 같이 사용한다.

execution(접근제어자? 반환타입 선언타입?메서드이름(파라미터) 예외?)

 

execution은 메서드 실행 조인 포인트를 매칭한다. 그래서 결국 모든 메서드들 중 이 표현식에 일치하는 메서드들이 AOP로 적용된다. 위 표현 방식에서 '?'가 있는 것은 생략이 가능하다는 뜻이다.

 

그럼 하나씩 천천히 알아보자. 가장 정확한(자세한) 포인트 컷으로 표현해보자. 

 

가장 정확한(자세한) 포인트 컷

execution(public String com.example.aop.member.MemberServiceImpl.hello(String))
  • 접근제어자?: public
  • 반환 타입: String
  • 선언 타입?: com.example.aop.member.MemberServiceImpl
  • 메서드이름: hello
  • 파라미터: (String)
  • 예외?: 생략

 

이렇게 예외를 제외하고 모든 키워드를 작성했다. hello 메서드에 예외는 없기 때문에 제외했다.

이렇게 포인트컷을 지정하고 해당 포인트컷과 hello 메서드가 매치하는지 확인하는 테스트 코드를 작성해보자.

@Test
void exactMatch() {
    pointcut.setExpression("execution(public String com.example.aop.member.MemberServiceImpl.hello(String))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

 

결과는 당연히 테스트가 통과한다. 

 

가장 많이 생략한 포인트 컷

execution(* *(..))

 

  • 접근제어자?: 생략
  • 반환 타입: *
  • 선언 타입?: 생략
  • 메서드이름: *
  • 파라미터: (..)
  • 예외?: 생략

'*'은 와일드카드로 모든것을 허용한다는 의미로 받아들이면 될 것 같다. 여기서 생략을 할 수 없는 필수 키워드인 반환 타입, 메서드명, 파라미터만을 작성했다. 반환 타입은 전체(*)이며 메서드명 또한 어떠한 것도 상관 없다는 의미의 '*'이고 파라미터는 어떤 파라미터라도 상관없다는 의미의 (..)를 사용했다. (..)는 파라미터가 없거나 여러개거나 한개거나 어떠한 상태여도 상관이 없다는 의미이다.

 

이런 포인트컷을 사용해서 메서드가 일치하는지 확인해보자.

@Test
void allMatch() {
    pointcut.setExpression("execution(* *(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

 

결과는 당연히 테스트 통과한다.

 

메서드 이름 매칭 관련 포인트 컷

메서드 이름과 관련된 포인트 컷을 여러개 확인해보자. 메서드 이름에도 '*'를 사용할 수 있다.

@Test
void nameMatch() {
    pointcut.setExpression("execution(* hello(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

@Test
void nameWildcardMatch() {
    pointcut.setExpression("execution(* hel*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

@Test
void nameWildcardMatch2() {
    pointcut.setExpression("execution(* *el*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

@Test
void notNameMatch() {
    pointcut.setExpression("execution(* notMatched(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}

 

 

패키지 매칭 관련 포인트 컷

패키지 이름과 관련된 포인트 컷도 여러개 확인해보자. 주의할 점은 하위 패키지 전부를 허용하고 싶을 땐 '..'을 사용해야 한다. (점 두개)

@Test
void packageExactMatch() {
    pointcut.setExpression("execution(* com.example.aop.member.MemberServiceImpl.hello(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

@Test
void packageExactMatch2() {
    pointcut.setExpression("execution(* com.example.aop.member.*.*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

@Test
void packageNotMatch() {
    // 패키지에서 (.)은 정확히 그 위치. 즉, 아래같은 경우 com.example.aop 딱 그 위치를 말한다.
    pointcut.setExpression("execution(* com.example.aop.*.*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}

@Test
void subPackageMatch() {
    // 패키지에서 하위 패키지까지 몽땅 포함하려면 (..)이어야 한다.
    pointcut.setExpression("execution(* com.example.aop..*.*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

@Test
void subPackageMatch2() {
    pointcut.setExpression("execution(* com.example.aop.member..*.*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

 

packageNotMatch()를 확인해보면 com.example.aop.*.*(..)로 되어 있는데 이는 하위 패키지도 포함하는게 아니다. 즉, 정확히 com.example.aop의 모든 타입(인터페이스, 클래스)의 모든 메서드를 지정하는 포인트 컷이다. 하위 패키지도 포함하려면 subPackageMatch()처럼 com.example.aop..*.*(..)로 작성해야 한다. 

 

타입 매칭 포인트 컷 

타입 정보에 대한 매치 조건이다.

@Test
void typeExactMatch() {
    pointcut.setExpression("execution(* com.example.aop.member.MemberServiceImpl.*(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

 

이처럼 정확히 패키지 + 타입(클래스)가 일치하게 포인트컷을 지정할 수 있다. 근데 한가지 조심할 게 있다. 부모 타입은 어떻게 될까?

그러니까 MemberServiceImpl은 상위에 MemberService 인터페이스가 있다. 그럼 포인트컷 표현식에 부모 타입을 작성했을 때 저 hello 메서드는 포인트컷 조건에 만족할까? 결론부터 말하면 만족한다.

@Test
void typeMatchSuperType() {
    // 상위 타입으로 expression 을 설정
    pointcut.setExpression("execution(* com.example.aop.member.MemberService.*(..))");

    // pointcut 은 상위 타입이고 상위 타입이 가지고 있는 메서드면 자식 메서드도 역시 가능하다. 이유는 자식은 부모에 들어갈 수 있으니까.
    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

자식은 부모에 들어가는 게 가능하기 때문에, 포인트컷 표현식을 부모로 설정하면 자식 클래스들은 포인트컷을 만족한다. 단, 인터페이스에서 선언된 메서드에 한하여. 이 말은 무슨말이냐면 부모일지언정 부모에 선언된 메서드가 아니라 자식 내부적으로만 가지고 있는 메서드는 포인트컷을 만족하지 못한다는 말이다.

 

위에서 MemberService와 MemberServiceImpl을 보면 부모인 인터페이스에는 hello 메서드만 있고 internal은 없다. 자식인 구체 클래스에는 internal 이라는 내부 메서드가 있다. 이 땐 부모 타입으로 포인트컷을 지정하면 자식 내부적으로만 가지고 있는 메서드에는 포인트 컷 조건이 만족하지 않는다.

@Test
void typeMatchInternal() throws NoSuchMethodException {
    // 상위 타입으로 expression 을 설정
    pointcut.setExpression("execution(* com.example.aop.member.MemberService.*(..))");

    Method internalMethod = MemberServiceImpl.class.getMethod("internal", String.class);

    // 상위 타입으로 pointcut 의 expression 을 설정한 경우, 상위 타입이 가지고 있는 메서드만 가능하다.
    assertThat(pointcut.matches(internalMethod, MemberServiceImpl.class)).isFalse();
}

 

 

파라미터 매칭 포인트 컷

이 파라미터 매칭 조건은 다음 예시를 보면 하나하나 다 이해가 가능할 것이다.

/**
 * 모든 메서드 중 파라미터가 String 타입 하나 인 것들을 매치
 * */
@Test
void argsMatch() {
    pointcut.setExpression("execution(* *(String))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

/**
 * 모든 메서드 중 파라미터가 없는 것들을 매치
 * */
@Test
void argsMatchNoArgs() {
    pointcut.setExpression("execution(* *())");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}

/**
 * 모든 메서드 중 모든 타입을 허용하지만 딱 한 개의 파라미터만 허용
 * */
@Test
void argsMatchWildCard() {
    pointcut.setExpression("execution(* *(*))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

/**
 * 모든 메서드 중 모든 타입, 모든 개수의 파라미터를 허용
 * */
@Test
void argsMatchAll() {
    pointcut.setExpression("execution(* *(..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

/**
 * 모든 메서드 중 파라미터가 String 타입으로 시작하고 그 이후는 모든 타입, 모든 개수의 파라미터를 허용 또는 없어도 된다.
 * */
@Test
void argsMatchComplex() {
    pointcut.setExpression("execution(* *(String, ..))");

    assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}

/**
 * 모든 메서드 중 파라미터가 딱 두개이면서 둘 다 String 타입인 것
 * */
@Test
void argsMatchComplexExactly() throws NoSuchMethodException {
    pointcut.setExpression("execution(* *(String, String))");

    Method twoParamsMethod = MemberServiceImpl.class.getMethod("twoParams", String.class, String.class);

    assertThat(pointcut.matches(twoParamsMethod, MemberServiceImpl.class)).isTrue();
}

/**
 * 모든 메서드 중 파라미터가 딱 두개이면서 첫번째는 String, 두번째는 모든 타입
 * */
@Test
void argsMatchComplexExactly2() throws NoSuchMethodException {
    pointcut.setExpression("execution(* *(String, *))");

    Method twoParamsMethod = MemberServiceImpl.class.getMethod("twoParams", String.class, String.class);

    assertThat(pointcut.matches(twoParamsMethod, MemberServiceImpl.class)).isTrue();
}

 

 

within

within 지시자는 특정 타입 내의 조인 포인트들로 매칭을 제한한다. 이 말만 보면 무슨말인지 잘 모르겠다. 쉽게 말하면 작성한 타입이 매칭되면 그 안의 메서드들이 자동으로 매치된다.

 

WithinTest.java

package com.example.aop.pointcut;

import com.example.aop.member.MemberServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;

import java.lang.reflect.Method;

import static org.assertj.core.api.Assertions.*;

/**
 * Within은 타입(클래스, 인터페이스)을 지정하면 그 안에 메서드는 모두 매치가 되게 하는 방법
 * */
public class WithinTest {

    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    Method helloMethod;

    @BeforeEach
    public void init() throws NoSuchMethodException {
        helloMethod = MemberServiceImpl.class.getMethod("hello", String.class); // method 이름이 hello, 파라미터의 타입이 String
    }

    @Test
    void withinExactly() throws NoSuchMethodException {
        pointcut.setExpression("within(com.example.aop.member.MemberServiceImpl)");

        Method internal = MemberServiceImpl.class.getMethod("internal", String.class);

        assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut.matches(internal, MemberServiceImpl.class)).isTrue();
    }

    @Test
    void withinWildCard() {
        pointcut.setExpression("within(com.example.aop.member.*Service*)");

        assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
    }

    @Test
    void withinSubPackage() {
        pointcut.setExpression("within(com.example.aop..*)");

        assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
    }

    @Test
    @DisplayName("타겟의 정확하게 타입에만 직접 적용해야 한다")
    void withinSuperTypeFalse() {
        // 상위 타입으로 설정하면 within 은 안된다. 정확히 그 타입으로 지정해야 한다. execution 은 이게 가능했는데 within 은 아니다.
        pointcut.setExpression("within(com.example.aop.member.MemberService)");

        assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
    }
}

 

withinExactly()를 보면 within(com.example.aop.member.MemberServiceImpl)이라고 되어 있다. 이렇게 하면 MemberServiceImpl 클래스 내 메서드들이 이 포인트컷에 매칭된다.

 

주의

그러나, 주의할 부분이 있다. 표현식에 부모 타입을 지정하면 안된다. 정확하게 타입이 맞아야 한다. 이 점이 execution과 다른 점이다.

 

 

args

인자가 주어진 타입의 인스턴스인 조인 포인트로 매칭. 말이 또 어려운데 쉽게 말해 파라미터가 매치되는 녀석들이 다 조인 포인트가 된다고 보면 된다. 아래 코드를 보면 바로 이해가 될 것이다.

 

ArgsTest.java

package com.example.aop.pointcut;

import com.example.aop.member.MemberServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;

import java.lang.reflect.Method;

import static org.assertj.core.api.Assertions.assertThat;

public class ArgsTest {

    Method helloMethod;

    @BeforeEach
    public void init() throws NoSuchMethodException {
        helloMethod = MemberServiceImpl.class.getMethod("hello", String.class); // method 이름이 hello, 파라미터의 타입이 String
    }

    private AspectJExpressionPointcut pointcut(String expression) {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(expression);

        return pointcut;
    }

    @Test
    void args() {
        // hello(String)과 매칭
        assertThat(pointcut("args(String)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("args(Object)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("args()").matches(helloMethod, MemberServiceImpl.class)).isFalse();
        assertThat(pointcut("args(..)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("args(*)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("args(String, ..)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
    }

    @Test
    void argsVsExecution() {
        // Args (Args 는 상위 타입을 허용한다)
        assertThat(pointcut("args(String)")
                .matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("args(java.io.Serializable)")
                .matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("args(Object)")
                .matches(helloMethod, MemberServiceImpl.class)).isTrue();

        // Execution (Execution 은 상위 타입을 허용하지 않고 딱 정확하게 선언해야 한다)
        assertThat(pointcut("execution(* *(String))")
                .matches(helloMethod, MemberServiceImpl.class)).isTrue();
        assertThat(pointcut("execution(* *(java.io.Serializable))")
                .matches(helloMethod, MemberServiceImpl.class)).isFalse();
        assertThat(pointcut("execution(* *(Object))")
                .matches(helloMethod, MemberServiceImpl.class)).isFalse();
    }
}

 

args()를 보면, pointcut으로 args(String), args(Object),... 이렇게 되어 있다. 즉, 이 파라미터와 일치하는 메서드를 매치시키는 방법. 근데 이 args는 execution과 다르게 부모 타입도 허용한다. 즉, 파라미터의 타입이 String인 메서드라면 args(Object)로 해도 매치가 된다는 뜻이다. 

 

참고로 args 지시자는 단독으로 사용되기 보다는 파라미터 바인딩에서 주로 사용된다. 

 

 

@target, @within

정의

@target: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트

@within: 주어진 애노테이션이 있는 타입 내 조안 포인트

 

사실 그렇게 중요하지도 않고 정의만 보고서는 뭔 말인지 감이 잘 안오지만 코드로 보면 간단하다. 우선 둘 모두 타입에 있는 애노테이션으로 AOP 적용 여부를 판단한다.

@target(hello.aop.member.annotation.ClassAop)
@within(hello.aop.member.annotation.ClassAop)

 

@ClassAop
class Target {

}

 

여기서 두 개의 차이는 다음과 같다. 

@target은 애노테이션이 달린 클래스의 부모 클래스의 메서드까지 어드바이스를 전부 적용하고, @within은 자기 자신의 클래스에 정의된 메서드만 어드바이스를 적용한다. 

 

 

그래서 한 문장으로 정리를 하자면 @target, @within 둘 모두 애노테이션으로 AOP를 적용하는데 @target의 경우 애노테이션이 달린 클래스와 그 상위 클래스의 메서드 모두에게 어드바이스를 적용하고 @within의 경우 애노테이션이 달린 클래스의 메서드에만 어드바이스를 적용한다.

 

AtTargetAtWithinTest.java

package com.example.aop.pointcut.annotation;

import com.example.aop.member.annotation.ClassAop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;


/**
 * 클래스에 붙이는 애노테이션을 기반으로 포인트컷을 만들 땐 @target, @within 을 사용할 수 있다.
 * */
@Slf4j
@Import(AtTargetAtWithinTest.Config.class)
@SpringBootTest
public class AtTargetAtWithinTest {

    @Autowired Child child;

    @Test
    void success() {
        log.info("child proxy = {}", child.getClass());

        child.childMethod();
        child.parentMethod();
    }

    static class Config {

        @Bean
        public Parent parent() { return new Parent(); }

        @Bean
        public Child child() { return new Child(); }

        @Bean
        public AtTargetAtWithinAspect atTargetAtWithinAspect() { return new AtTargetAtWithinAspect(); }
    }

    static class Parent {
        public void parentMethod() {
            log.info("[parentMethod] Start");
        }
    }

    @ClassAop
    static class Child extends Parent {
        public void childMethod() {
            log.info("[childMethod] Start");
        }
    }

    @Aspect
    static class AtTargetAtWithinAspect {

        // @target: 인스턴스 기준으로 모든 메서드의 조인 포인트를 선정 = 부모 타입의 메서드도 적용
        @Around("execution(* com.example.aop..*(..)) && @target(com.example.aop.member.annotation.ClassAop)")
        public Object atTarget(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[@target] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        // @within: 선택된 클래스 내부에 있는 메서드만 조인 포인트로 선정 = 부모 타입의 메서드는 적용되지 않음
        @Around("execution(* com.example.aop..*(..)) && @within(com.example.aop.member.annotation.ClassAop)")
        public Object atWithin(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[@within] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        // 참고로 @target, @args, args 이런 포인트컷 지시자는 단독으로 사용하면 안된다. 위 에제에서도 execution 과 같이 사용했는데
        // 그 이유는 스프링이 이런 포인트컷 지시자가 있으면 모든 스프링 빈에 AOP 를 적용하려고 시도하는데 스프링이 내부에서 사용하는 빈 중에는 final 로
        // 지정된 빈들도 있기 때문에 오류가 발생할 수 있다.
    }
}

 

 

우선 체크포인트는 다음과 같다.

 

  • Child, Parent 클래스가 있다. Child 클래스는 상위 클래스로 Parent 클래스가 있다. 
  • 두 클래스를 모두 스프링 빈으로 등록한다.
  • 에스팩트가 있고 두 개의 어드바이저가 있다. 하나는 @target 하나는 @within으로 만들어진 포인트컷이다.
  • @target@within 모두 같은 애노테이션인 ClassAop 애노테이션이 달린 클래스를 찾아 AOP로 적용한다.
  • 이 에스팩트 역시 스프링 빈으로 등록한다.
  • 스프링 빈으로 등록한 Child 클래스를 테스트 코드에서는 주입받는다.
  • 주입받은 Child 클래스의 childMethod(), parentMethod()를 각각 호출한다.
  • 결과는 childMethod() 호출 시, @target과 @within 모두 적용된다. parentMethod() 호출 시 @target만 적용되고 @within은 적용되지 않는다.

 

주의

args, @args, @target 이 포인트컷 지시자는 단독으로 사용할 수 없다. 그 이유는 이런 포인트컷이 있으면 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도한다. 문제는 모든 스프링 빈에 AOP를 적용하려고 하면 스프링이 내부에서 사용하는 빈 중에는 final로 지정된 빈들도 있기 때문에 오류가 발생한다. 따라서 이런 포인트컷 지시자는 단독으로 사용하면 안되고 최대한 적용 대상을 축소하는 표현식과 함께 사용해야 한다.

 

 

@annotation, @args

@annotation: 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭

@args: 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트

 

@annotation은 종종 사용되니 이것을 집중해서 보자.

@annotation(hello.aop.member.annotation.MethodAop)

 

쉽게 말해 메서드에 지정한 애노테이션이 있으면 매칭한다. 다음 코드처럼.

public class MemberServiceImpl {
     @MethodAop("test value")
     public String hello(String param) {
         return "ok";
     }
}

 

AtAnnotationTest.java

package com.example.aop.pointcut.annotation;

import com.example.aop.member.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;


/**
 * 메서드에 붙이는 애노테이션을 기반으로 포인트컷을 만들 때 사용되는 @annotation
 *
 * ⭐이거는 좀 자주 사용된다.
 * */
@Slf4j
@Import(AtAnnotationTest.AtAnnotationAspect.class)
@SpringBootTest
public class AtAnnotationTest {

    @Autowired
    MemberService memberService;

    @Test
    void success() {
        log.info("memberService proxy = {}", memberService.getClass());
        memberService.hello("hello");
    }

    @Aspect
    static class AtAnnotationAspect {

        @Around("@annotation(com.example.aop.member.annotation.MethodAop)")
        public Object doAtAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[@annotation] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }
}

 

위 코드에서 에스팩트를 보면 @Around의 포인트컷 지시자로 @annotation을 사용한다. MethodAop 라는 애노테이션이 달린 메서드에 이 AOP가 적용된다. 그리고 만든 MemberServiceImpl의 hello()는 @MethodAop 애노테이션이 있다. 따라서 테스트 success()는 AOP가 적용된 hello()가 호출된다.

 

 

bean

스프링 전용 포인트컷 지시자. 빈의 이름으로 지정한다.

bean(orderService) || bean(*Repository)

 

바로 예시 코드로 확인해보자. 그리고 이 지시자 역시 자주 사용되는 지시자는 아니다.

 

BeanTest.java

package com.example.aop.pointcut;

import com.example.aop.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Slf4j
@Import(BeanTest.BeanAspect.class)
@SpringBootTest
public class BeanTest {

    @Autowired
    OrderService orderService;

    @Test
    void success() {
        orderService.orderItem("item");
    }

    @Aspect
    static class BeanAspect {

        @Around("bean(orderService) || bean(*Repository)")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[bean] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }
}

 

BeanAspect를 보면 orderService라는 bean 또는 *Repository라는 bean을 포인트컷의 조건으로 어드바이스를 만든 모습을 확인할 수 있다. 그 후 테스트 success()는 orderService의 orderItem()을 호출한다. 그러므로 저 어드바이스가 적용된다.

 

 

매개변수 전달 (중요⭐️)

어드바이스 쪽에서 메서드의 파라미터를 전달받고 싶을 땐 어떻게 해야 할까? 예를 들어 다음 코드를 보자.

orderService.orderItem("item");

 

이런 코드가 있을 때, 어드바이스가 저 파라미터 'item'을 어떻게 받을 수 있을까? 이를 알아보자.

 

우선 이 경우 joinPoint를 사용하거나 다음 포인트컷 지시자를 활용한다.

  • args

가장 원시적인 방법을 먼저 확인해보자.

 

포인트컷

@Pointcut("execution(* hello.aop.member..*.*(..))")
private void allMember() {}

 

JoinPoint를 활용하기

 

@Around("allMember()")
public Object logArgs1(ProceedingJoinPoint joinPoint) throws Throwable {
    Object arg1 = joinPoint.getArgs()[0];
    log.info("[logArgs1]{}, arg={}", joinPoint.getSignature(), arg1);
    return joinPoint.proceed();
}

 

joinPoint를 활용하면 getArgs() 메서드를 사용할 수 있다. 허나, 이 방법은 배열에서 꺼내는 방식인데 그렇게 좋은 방식은 아닌것 같다.

 

args

@Around("allMember() && args(arg, ..)")
public Object logArgs2(ProceedingJoinPoint joinPoint, Object arg) throws Throwable {
    log.info("[logArgs2]{}, arg = {}", joinPoint.getSignature(), arg);
    return joinPoint.proceed();
}

 

포인트컷 지시자 'args'를 사용한다. args(arg, ..)은 첫번째 파라미터를 받고 그 이후에 파라미터는 있거나 없거나 신경쓰지 않는다는 뜻이다. 그리고 이 arg를 어드바이스의 파라미터로 이름 그대로(arg) 동일하게 받아야 한다. 

 

실제 파라미터의 타입은 String인데 그 상위 타입인 Object로 받아도 무방하다. 

 

위에서 @Around를 사용했는데 @Around는 ProceedingJoinPoint를 반드시 첫번째 파라미터로 받아야 하는 불편함이 있다. 굳이 코드 내에서 실제 객체를 호출하는 코드를 직접 호출해야 하는 경우가 아니라면 다음처럼 더 간략하게 사용할 수 있다.

@Before("allMember() && args(arg, ..)")
public void logArgs3(String arg) {
    log.info("[logArgs3] arg = {}", arg);
}

 

이번에는 상위 타입이 아닌 정확히 String으로 받아주었다. 물론 상위 타입도 상관없다.

 

 

@annotation으로 애노테이션이 가지고 있는 값들 꺼내오기

애노테이션 중에는 특정 값을 가지는 애노테이션이 있다. 다음이 그 예시다.

package com.example.aop.member.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodAop {
    String value();
}

 

 

value() 라는 값을 가지는 애노테이션이다.

 

package com.example.aop.member;

import com.example.aop.member.annotation.ClassAop;
import com.example.aop.member.annotation.MethodAop;
import org.springframework.stereotype.Component;

@ClassAop
@Component
public class MemberServiceImpl implements MemberService {

    @Override
    @MethodAop(value = "test value")
    public String hello(String param) {
        return "ok";
    }

    public String internal(String param) {
        return "ok";
    }

    public String twoParams(String param1, String param2) {
        return "ok";
    }
}

 

그 애노테이션의 value 값으로 'test value'라는 값을 가지는 hello()가 있을 때 이 값은 어떻게 가져올까?

 

다음처럼 @annotation을 활용해서 애노테이션을 파라미터로 받으면 된다.

@Before("allMember() && @annotation(annotation)")
public void atAnnotationAcceptedArgs(JoinPoint joinPoint, MethodAop annotation) {
    log.info("[@annotation Accepted]{}, annotationValue = {}", joinPoint.getSignature(), annotation.value());
}

여기서, @annotation(annotation)이라고 썼으면 파라미터에서도 'annotation'이라는 이름으로 받아야 한다. 만약 @annotation(methodAop)로 썼으면 파라미터도 'methodAop'라는 이름으로 받으면 된다. 

 

그리고 한가지 더, 원래는 @annotation 지시자를 사용할 때 패키지명부터 쭉 써줘야 한다. 예를 들면 이렇게.

@annotation(com.example.aop.member.annotation.MethodAop)

 

근데 위에서처럼 저렇게 파라미터로 애노테이션 타입을 명시하면 이름으로 치환할 수 있다.

 

 

this, target

솔직히 이게 중요한지는 모르겠다. 근데 내용이 은근히 어렵다. 그래서 굳이라는 생각이 들지만 한번 정리해보겠다.

 

  • this: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트
  • target: Target 객체(스프링 AOP 프록시가 가리키는 실제 대상)를 대상으로 하는 조인 포인트

 

설명

  • this, target은 다음과 같이 적용 타입 하나를 정확하게 지정해야 한다.
  • '*' 같은 패턴을 사용할 수 없다.
  • 부모 타입을 허용한다.
this(hello.aop.member.MemberService)
target(hello.aop.member.MemberService)

 

똑같이 생겨가지고 무슨 차이가 있을까?

 

스프링에서 AOP를 적용하면 실제 target 객체 대신에 프록시 객체가 스프링 빈으로 등록된다. 여기서,

  • this는 스프링 빈으로 등록되어 있는 프록시 객체를 대상으로 포인트컷을 매칭한다.
  • target은 실제 target 객체를 대상으로 포인트컷을 매칭한다.

그러니까 다음 코드 예시를 보면,

this(hello.aop.member.MemberService)
target(hello.aop.member.MemberService)

 

똑같이 MemberService를 조건으로 입력해도 this는 스프링 빈으로 등록된 프록시를, target은 스프링 빈으로 등록된 프록시가 참조하는 실제 객체를 바라본다는 뜻인데 이게 뭐 큰 의미가 있고 달라지나 싶을 수 있다. 그러나, JDK 동적 프록시와 CGLIB의 프록시 생성 방식이 다르기 때문에 차이점이 발생할 수 있다.

 

JDK 동적 프록시

이 방식은 인터페이스가 필수이고 인터페이스를 구현한 프록시 객체를 생성한다. 다음이 그 그림이다.

 

 

그럼 이 방식으로 프록시를 만들 때 this와 target 지시자가 어떻게 다른지 보자.

 

MemberService 인터페이스 지정

  • this(hello.aop.member.MemberService)
    • proxy 객체를 보고 판단한다. this는 부모 타입을 허용한다. 프록시는 인터페이스인 MemberService를 참조하므로 AOP가 적용된다.
  • target(hello.aop.member.MemberService)
    • target 객체를 보고 판단한다. target은 부모 타입을 허용한다. target이 상속받는 MemberService가 있으므로 AOP가 적용된다.

MemberServiceImpl 구체 클래스 지정

  • this(hello.aop.member.MemberServiceImpl)
    • proxy 객체를 보고 판단한다. 프록시 객체의 부모는 MemberService 인터페이스이다. 인터페이스 위에 있는 것은 없다. MemberServiceImpl에 대한 정보를 아예 알 수 없으므로 AOP 적용 대상이 아니다.
  • target(hello.aop.member.MemberServiceImpl)
    • target 객체를 보고 판단한다. target은 바로 MemberServiceImpl 구체 클래스이므로 AOP 적용 대상이다.

 

결론은 JDK 동적 프록시는 this로 구체 클래스를 받으면 AOP 적용 대상이 아니게 된다. 반면 CGLIB는 어떨까?

 

 

CGLIB 프록시

 

MemberService 인터페이스 지정

  • this(hello.aop.member.MemberService)
    • this는 proxy 객체를 바라본다. 프록시 객체는 구체 클래스인 MemberServiceImpl을 상속받는다. 그리고 이 구체 클래스의 부모인 MemberService 인터페이스가 있다. this는 부모 타입을 허용하므로 AOP 적용 대상이다.
  • target(hello.aop.member.MemberService)
    • target은 실제 target 객체를 바라본다. target 객체인 MemberServiceImpl의 부모인 MemberService가 있다. target은 부모 타입을 허용하므로 AOP 적용 대상이다.

MemberServiceImpl 구체 클래스 지정

  • this(hello.aop.member.MemberServiceImpl)
    • this는 proxy 객체를 바라본다. 프록시 객체는 구체 클래스인 MemberServiceImpl을 상속받는다. this는 부모 타입을 허용하므로 AOP 적용 대상이다.
  • target(hello.aop.member.MemberServiceImpl)
    • target은 실제 target 객체를 바라본다. target 객체가 MemberServiceImpl이므로 AOP 적용 대상이다.

 

결론은 CGLIB 프록시는 모든 경우에 AOP 적용 대상이 된다. 그리고 스프링은 기본으로 CGLIB로 프록시를 만들어낸다. 

 

 

실제로 AOP 적용을 위 설명처럼 하는지 확인해보자.

 

ThisTargetTest.java

package com.example.aop.pointcut;

import com.example.aop.member.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Slf4j
@Import(ThisTargetTest.ThisTargetAspect.class)
@SpringBootTest
public class ThisTargetTest {

    @Autowired
    MemberService memberService;

    @Test
    void success() {
        log.info("memberService Proxy = {}", memberService.getClass());
        memberService.hello("helloA");
    }

    @Aspect
    static class ThisTargetAspect {

        @Around("this(com.example.aop.member.MemberService)")
        public Object doThisInterface(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[this-interface] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        @Around("target(com.example.aop.member.MemberService)")
        public Object doTargetInterface(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[target-interface] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        @Around("this(com.example.aop.member.MemberServiceImpl)")
        public Object doThisConcrete(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[this-impl] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        @Around("target(com.example.aop.member.MemberServiceImpl)")
        public Object doTargetConcrete(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[target-impl] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }
}

 

에스팩트에 4개의 어드바이저가 있다. 위 설명 대로 this에 인터페이스, 구체 클래스를 target에 인터페이스, 구체 클래스를 적용했을 때 AOP가 적용되는지에 대한 내용이다. 스프링은 기본으로 CGLIB 프록시로 프록시를 만들어내는데 그 설정 값은 다음과 같다.

spring:
  aop:
    proxy-target-class: true # true = CGLIB 를 기본으로 / false = JDK 동적 프록시를 기본으로

 

이 상태로 success() 테스트를 실행하면 모든 어드바이저가 적용된다.

 

이제 JDK 동적 프록시를 스프링 기본 프록시로 설정해보자. 

spring:
  aop:
    proxy-target-class: false

 

 

this-impl 로그가 찍히지 않았음을 확인할 수 있다.

 

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Redis를 사용해서 캐싱하기  (0) 2024.10.02
Mockito를 사용한 스프링 프로젝트 단위 테스트  (4) 2024.09.29
AOP (Part.2)  (0) 2024.01.02
AOP(Aspect Oriented Programming)  (0) 2023.12.29
AOP와 @Aspect, @Around  (0) 2023.12.29
728x90
반응형
SMALL

이번 포스팅에서는 저번 포스팅인 https://cwchoiit.tistory.com/85 이 Part.1 에 이어 스프링 AOP를 직접 만들어보자.

 

AOP(Aspect Oriented Programming)

이제 드디어 AOP에 대해 진지하게 알아보는 시간을 가져보자. 우선 AOP란 번역 하면 관점 지향 프로그래밍이다. 여기서 관점이란 무엇일까? 관점은 애플리케이션의 핵심적인 관점과 부가적인 관

cwchoiit.tistory.com

 

SMALL

 

라이브러리 추가

우선 스프링 AOP를 사용하려면 다음과 같은 라이브러리가 필요하다.

 

build.gradle

 implementation 'org.springframework.boot:spring-boot-starter-aop'

 

이 라이브러리를 추가한 후에 다운된 외부 라이브러리 목록을 보면 다음 라이브러리가 있어야 한다.

 

 

 

핵심적인 관점(기능)

애플리케이션의 핵심적인 기능을 담당하는 비즈니스 로직이다. 상품에 대한 주문 관련 코드가 있다고 가정하자.

 

OrderRepository.java

package com.example.aop.order;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

@Slf4j
@Repository
public class OrderRepository {

    public void save(String itemId) {

        log.info("[save] Execute.");

        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }

        sleep(1000);
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

OrderService.java

package com.example.aop.order;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void orderItem(String itemId) {
        log.info("[orderItem] Execute.");
        orderRepository.save(itemId);
    }
}

 

이렇게 두 개의 클래스가 있고 Service에서 Repository에 호출이 있다. 이 두 개의 클래스가 모두 공통적으로 관심있는 사항이 있다면 이 두개의 클래스 각각의 같은 코드를 하나하나 넣는게 아니라 부가적인 관점(기능)을 담당하는 모듈을 만들어서 관리하는 AOP를 적용하자.

 

 

부가적인 관점(기능)

예제 코드니까 어떤 로그를 찍는 부가 기능을 가진다고 가정해보자.

 

AspectV1.java

package com.example.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

/**
 * 스프링은 프록시 방식의 AOP 를 사용한다. 즉, 프록시를 통하는 메서드만 적용 대상이 된다.
 * 그래서 아래 @Around() 에 정규 표현식도 특정 패키지의 특정 메서드명의 특정 파라미터를 정규식으로 작성하는 것.
 *
 * 스프링 AOP 는 AspectJ의 문법을 차용하고 프록시 방식의 AOP 를 제공한다. AspectJ를 직접 사용하는 것이 아니다.
 * 스프링 AOP 를 사용할 땐 @Aspect 애노테이션을 주로 사용하는데 이 애노테이션도 AspectJ가 제공하는 애노테이션이다.
 * */
@Slf4j
@Aspect
public class AspectV1 {

    @Around("execution(* com.example.aop.order..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[doLog] {}", joinPoint.getSignature()); // JoinPoint Signature
        return joinPoint.proceed(); // 실제 Target 호출
    }
}

 

이렇게 부가 기능을 에스팩트로 만들려면 스프링에서는 @Aspect 애노테이션을 달면 된다. 그리고 그 안에 @Around 애노테이션을 달아서 포인트컷을 지정하고 그 안의 로직이 어드바이스가 된다. 포인트컷의 정규 표현식은 com.example.aop.order 패키지와 그 하위 패키지 전부에 해당하는 어떠한 파라미터를 가져도 상관없는 모든 메서드에 적용한다. 

 

적용할 부가 기능은 로그를 하나 출력하는 것인데 로그의 내용은 호출된 메서드의 패키지와 메서드 명을 출력해주는 joinPoint.getSignature()를 호출한다. 그 후 프록시는 실제 객체를 호출하여 실제 객체의 핵심 기능에 대한 코드를 수행해야 하므로 실제 객체 호출 코드인 joinPoint.proceed()를 호출한다.

 

이렇게 애스팩트를 만들면 이 녀석안의 @Around 애노테이션이 달린 메서드가 하나의 어드바이저가 된다. 어드바이저는 포인트컷 + 어드바이스이고 이 어드바이저의 포인트컷은 위에서 설명한 경로와 같고 어드바이스는 위에서 설명한 로그 출력 기능과 같다.

 

이렇게만 만들었다고 해서 AOP가 바로 적용되는 것은 아니다. 이 애스팩트를 스프링 빈으로 등록해줘야 스프링이 AOP 프록시로 만들어준다. 등록해보자.

 

등록하는 방법은 다양하게 있다. @Bean, @Component, @Import,... 여기서는 테스트 코드에만 적용해볼거니까 간단하게 @Import를 사용해보자.

 

AopTest.java

package com.example.aop;

import com.example.aop.order.OrderRepository;
import com.example.aop.order.OrderService;
import com.example.aop.order.aop.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.*;

@Import(AspectV1.class) //@Import 만으로도 빈으로 등록하는것과 동일하다. 주로 @Configuration 에서 추가할 때 자주 사용됐지만, @Import 로도 그 안에 클래스들을 빈으로 등록한다.
@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        assertThatThrownBy(() -> orderService.orderItem("ex"))
                .isInstanceOf(IllegalStateException.class);
    }
}

 

@Import 애노테이션으로 애스팩트를 스프링 빈으로 등록한다. 그 후 테스트 코드로 success() 메서드를 실행해보면, AOP가 적용된 모습을 확인할 수 있다.

애스팩트에 작성한 로그가 출력된 모습이다. 이렇게 간단하게 AOP를 구현했다. 

 

포인트컷 분리

포인트컷과 어드바이스를 분리할 수도 있다. 

 

AspectV2.java

package com.example.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

/**
 * 포인트컷과 어드바이스를 분리하는 법.
 * */
@Slf4j
@Aspect
public class AspectV2 {

    /**
     * 반환 타입은 'void' 여야 한다.
     * 다른 곳에서 이 포인트컷을 사용하려면 public 이어야 하고 이 내부 안에서 사용하는 건 private 이어도 된다.
     * */
    @Pointcut("execution(* com.example.aop.order..*(..))")
    private void allOrder() {

    } // Pointcut Signature

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[doLog] {}", joinPoint.getSignature()); // JoinPoint Signature
        return joinPoint.proceed(); // 실제 Target 호출
    }
}

 

위 코드처럼 @Pointcut 애노테이션을 사용해서 포인트컷을 작성하고 이 포인트컷을 사용하는 어드바이스에서는 @Around에 해당 메서드를 입력하면 된다. @Around("allOrder()").

 

이렇게 포인트컷을 분리하여 얻는 이점은 다음과 같다.

  • 포인트컷에 의미를 부여할 수 있다. (모든 주문에 대해: allOrder)
  • 여러 어드바이스에서 해당 포인트컷을 가져다가 사용할 수 있다. (쉽게 말해 모듈화가 된다는 것)

한가지 더 알아야 할 내용은 같은 클래스 내에서 포인트컷을 분리할 땐 접근 제어자가 private, public 상관없이 가져다 사용할 수 있지만 외부에서 포인트컷을 사용하려면 public이어야 한다.

 

이 애스팩트(AspectV2)를 임포트해서 테스트 코드를 돌려도 동일한 결과를 얻는다.

 

AopTest.java

package com.example.aop;

import com.example.aop.order.OrderRepository;
import com.example.aop.order.OrderService;
import com.example.aop.order.aop.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.*;

@Import(AspectV2.class)
@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        assertThatThrownBy(() -> orderService.orderItem("ex"))
                .isInstanceOf(IllegalStateException.class);
    }
}

 

 

어드바이스 추가

하나의 프록시는 여러개의 어드바이저를 가질 수 있다고 했다. 그러니까 에스팩트에 여러 어드바이저를 만들어서 그런 상황을 만들어보자. 

 

AspectV3.java

package com.example.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV3 {

    /**
     * 반환 타입은 'void' 여야 한다.
     * 다른 곳에서 이 포인트컷을 사용하려면 public 이어야 하고 이 내부 안에서 사용하는 건 private 이어도 된다.
     * */
    @Pointcut("execution(* com.example.aop.order..*(..))")
    private void allOrder() {} // Pointcut Signature

    @Pointcut("execution(* *..*Service.*(..))")
    private void allService() {}

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[doLog] {}", joinPoint.getSignature()); // JoinPoint Signature
        return joinPoint.proceed(); // 실제 Target 호출
    }

    /**
     * com.example.aop.order 의 모든 하위 패키지 이면서 타입(클래스, 인터페이스) 이름 패턴이 *Service
     * '&&'라서 두 조건 모두를 만족해야한다.
     * */
    @Around("allOrder() && allService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            log.info("[doTransaction] Start {}", joinPoint.getSignature());
            Object proceed = joinPoint.proceed();
            log.info("[doTransaction] Commit {}", joinPoint.getSignature());

            return proceed;
        } catch (Exception e) {
            log.info("[doTransaction] Rollback {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[doTransaction] Release {}", joinPoint.getSignature());
        }
    }
}

 

이 에스팩트(AspectV3)는 두 개의 포인트컷이 존재한다. 나는 기존 에스팩트(AspectV2)에서 하나의 어드바이스를 추가했다. 이 어드바이스는 포인트컷 두개를 동시에 만족해야 적용되는 어드바이스이다. 

 

포인트컷 두 개

  • allOrder(): com.example.aop.order 패키지와 그 하위 모든 패키지의 어떠한 파라미터가 들어와도 상관없고 모든 메서드에 대해
  • allService(): 모든 패키지의 타입(클래스, 인터페이스)이름의 패턴이 *Service로 된 내부의 어떠한 파라미터가 들어와도 상관없고 모든 메서드에 대해

저 두개의 조건을 모두 만족하는 조인 포인트만이 적용되는 어드바이스이다. 이 에스팩트를 테스트 코드에 적용하고 어떤 결과가 도출되는지 확인해보자. 

 

AopTest.java

package com.example.aop;

import com.example.aop.order.OrderRepository;
import com.example.aop.order.OrderService;
import com.example.aop.order.aop.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.*;


@Import(AspectV3.class)
@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        assertThatThrownBy(() -> orderService.orderItem("ex"))
                .isInstanceOf(IllegalStateException.class);
    }
}

 

success() 테스트의 로그 결과를 보면 다음과 같다.

 

결과를 보면 doLog 어드바이스와 doTransaction 어드바이스가 모두 실행된 후 orderItem() 메서드가 실행됐다. 이 말은 orderItem()은 doLog, doTransaction 어드바이스 모두 적용 대상이라는 의미다. 이 후 orderItem()은 내부에서 OrderRepository의 save() 메서드를 호출하는데 잘 보면 doLog 어드바이스만 적용됐고 doTransaction 어드바이스는 호출되지 않았다. 이 의미는 OrderRepository.save() 메서드는 두 번째 어드바이스인 doTransaction에는 적용되지 않는다는 의미다. 그도 그럴것이 포인트컷 중 allService()는 타입의 패턴이 "*Service"여야 하기 때문에 OrderRepository는 적용되지 않는다.

 

이렇게 여러개의 어드바이스를 적용할 수 있다. 

 

 

포인트컷 참조

위에서 포인트컷을 분리하면서 외부에서 가져다가 사용하는 경우에 대해 잠깐 말을 했는데 그것을 코드로 구현해보자.

 

Pointcuts.java

package com.example.aop.order.aop;

import org.aspectj.lang.annotation.Pointcut;

public class Pointcuts {

    @Pointcut("execution(* com.example.aop.order..*(..))")
    public void allOrder() {} // Pointcut Signature

    @Pointcut("execution(* *..*Service.*(..))")
    public void allService() {}

    /**
     * Pointcut에 '&&' 로 되어 있으면 둘 다 만족해야한다.
     * 그러니까 이 예제에서 이 포인트컷은 OrderRepository에는 대상이 아닌 것.
     * */
    @Pointcut("allOrder() && allService()")
    public void orderAndService() {}
}

 

이렇게 포인트컷만 따로 모아놓는 클래스를 만들 수도 있다. 그리고 이렇게 외부로 빼면 접근 제어자는 'public'이어야 한다.

 

AspectV4Pointcut.java

package com.example.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV4Pointcut {

    @Around("com.example.aop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[doLog] {}", joinPoint.getSignature()); // JoinPoint Signature
        return joinPoint.proceed(); // 실제 Target 호출
    }

    /**
     * com.example.aop.order 의 모든 하위 패키지 이면서 타입(클래스, 인터페이스) 이름 패턴이 *Service
     * '&&'라서 두 조건 모두를 만족해야한다.
     * */
    @Around("com.example.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            log.info("[doTransaction] Start {}", joinPoint.getSignature());
            Object proceed = joinPoint.proceed();
            log.info("[doTransaction] Commit {}", joinPoint.getSignature());

            return proceed;
        } catch (Exception e) {
            log.info("[doTransaction] Rollback {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[doTransaction] Release {}", joinPoint.getSignature());
        }
    }
}

 

이 클래스가 실제로 외부의 포인트컷을 가져다가 사용하는 방식이다. @Around 애노테이션은 외부 포인트컷을 참조하면 된다. 살짝 불편한 부분은 패키지명까지 작성해줘야 한다는 것인데 이는 어쩔 수 없다. 문자로 입력해야 하기 때문에.

 

이러한 에스팩트를 가지고 위에서 사용한 테스트 코드를 수행해도 여전히 동일하게 동작한다.

 

 

어드바이스 순서

어드바이스의 적용 순서를 지정하고 싶을 땐 @Order 애노테이션을 사용하면 되는데 이 애노테이션은 클래스 단위로 사용된다. 그래서 다음처럼 이너 클래스를 사용해야 한다.

 

AspectV5Order.java

package com.example.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;

/**
 * 프록시에 어드바이저 두 개가 있을 때 어드바이스의 순서를 변경 또는 지정할 수 있는데 그 방법은 이렇게
 * 클래스로 어드바이저 순서를 지정해줘야 한다. 근데 순서가 다르다고 어드바이저 별로 클래스를 만들기는 귀찮으니까
 * 이렇게 이너 클래스로 만들어서 정리하면 된다. 그리고 그 이너 클래스가 빈으로 등록되면 된다.
 * */
@Slf4j
public class AspectV5Order {

    @Aspect
    @Order(2)
    public static class LogAspect {
        @Around("com.example.aop.order.aop.Pointcuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[doLog] {}", joinPoint.getSignature()); // JoinPoint Signature
            return joinPoint.proceed(); // 실제 Target 호출
        }
    }

    @Aspect
    @Order(1)
    public static class TxAspect {

        /**
         * com.example.aop.order 의 모든 하위 패키지 이면서 타입(클래스, 인터페이스) 이름 패턴이 *Service
         * '&&'라서 두 조건 모두를 만족해야한다.
         * */
        @Around("com.example.aop.order.aop.Pointcuts.orderAndService()")
        public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[doTransaction] Start {}", joinPoint.getSignature());
                Object proceed = joinPoint.proceed();
                log.info("[doTransaction] Commit {}", joinPoint.getSignature());

                return proceed;
            } catch (Exception e) {
                log.info("[doTransaction] Rollback {}", joinPoint.getSignature());
                throw e;
            } finally {
                log.info("[doTransaction] Release {}", joinPoint.getSignature());
            }
        }
    }
}

 

이렇게 각각의 이너클래스가 에스팩트로 되고 클래스 단위로 @Order 애노테이션을 사용해야 정상적으로 어드바이스 순서가 적용된다.

이렇게 변경한 상태에서 테스트 코드를 돌려보자. 트랜잭션 관련 에스팩트가 먼저 실행되어야 한다.

 

AopTest.java

package com.example.aop;

import com.example.aop.order.OrderRepository;
import com.example.aop.order.OrderService;
import com.example.aop.order.aop.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.*;


@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})
@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        assertThatThrownBy(() -> orderService.orderItem("ex"))
                .isInstanceOf(IllegalStateException.class);
    }
}

 

@Import를 두 클래스 모두 해줘야한다. 각각이 에스팩트니까. 이 상태에서 실행해보면 트랜잭션이 먼저 실행된다.

결과를 보면 트랜잭션이 먼저 실행된 모습이다. 이렇게 어드바이스 적용 순서도 지정할 수 있다. 반대로 트랜잭션을 두번째로 하고 로그 어드바이스를 먼저 수행하면 로그 어드바이스가 먼저 진행된다.

 

 

어드바이스 종류

지금까지는 @Around만을 사용했었는데, @Around가 가장 강력한 어드바이스이고 그 안에서 세부적으로 나뉘어질 수 있다.

 

나뉘어지는 각 부분은 4가지이다. 

  • @Before: 조인 포인트 실행 이전에 실행
  • @AfterReturning: 조인 포인트가 정상 완료 후 실행
  • @AfterThrowing: 메서드가 예외를 던지는 경우 실행
  • @After: 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

코드로 보면 확실히 이해가 된다. 코드를 보자.

 

AspectV6Advice.java

package com.example.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;

/**
 * 스프링 AOP 에서 @Around를 조각으로 분리할 수가 있다.
 * 그러니까 어드바이스 로직 흐름에 따라 @Around를 @Before, @AfterReturning, @AfterThrowing, @After로 분리할 수 있다.
 * {@code @Before}는 실제 타겟을 호출하기 전까지의 과정만을 작성하고 @AfterReturning은 실제 타겟을 호출한 후 과정을,
 * {@code @AfterThrowing}은 예외가 발생한 후 과정을, @After는 모든 로직이 다 끝난 마지막 부분을 담당한다.
 * 솔직히 @Around만 알면 나머지는 굳이 알 필요가 없다고 생각하는데 그럼에도 한가지는 알아야 한다. @Around는 파라미터로 ProceedingJoinPoint를 받는다.
 * 그러나 나머지는 그것을 받을수가 없다. JoinPoint로 받아야 한다. 왜냐하면 ProceedingJoinPoint는 JoinPoint를 상속받는데 둘 차이는 ProceedingJoinPoint는
 * 실제 타겟을 호출할 수가 있다. 'proceed()'가 존재한다. 근데 나머지 @Before, @AfterReturning, @AfterThrowing, @After가 담당하는 부분은
 * 실제 타켓을 호출하는 부분을 담당하지 않으니 당연히 받지 않는 것이다. 이 정도 차이만? 알고 있으면 좋을 것 같다.
 * 그리고 @Around를 사용할 때 발생할 수 있는 예외 상황(proceed()를 호출하지 않는 경우)를 방지할 수 있는 장점이 있다.
 *
 * 그리고 저 말은 @Around는 반드시 proceed()를 호출해야만 정상 흐름으로 진행할 수 있다. 아니면 다음 타겟으로 진행되지가 않는다.
 * */
@Slf4j
@Aspect
public class AspectV6Advice {

    /*@Around("com.example.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            // @Before
            log.info("[doTransaction] Start {}", joinPoint.getSignature());

            Object proceed = joinPoint.proceed();

            // @AfterReturning
            log.info("[doTransaction] Commit {}", joinPoint.getSignature());

            return proceed;
        } catch (Exception e) {
            // @AfterThrowing
            log.info("[doTransaction] Rollback {}", joinPoint.getSignature());

            throw e;
        } finally {
            // @After
            log.info("[doTransaction] Release {}", joinPoint.getSignature());
        }
    }*/

    /**
     * {@code @Before}는 한가지 더 알아야 할 게 이 @Before가 호출된 후 자동으로 실제 타겟을 호출한다.
     * 실제 타겟을 호출한다는 건 @Around에서 joinPoint.proceed() 실행하는 것을 말한다.
     * */
    @Before("com.example.aop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
        log.info("[doBefore] {}", joinPoint.getSignature());
    }

    /**
     * {@code @AfterReturning}도 하나 알아야 할 것이 있다. 뭐냐면 result 의 타입이다.
     * 실제 타겟이 반환하는 타입이 일치하거나 그보다 상위 타입으로 받아야 정상적으로 호출할 수 있다.
     * 즉, 만약 서비스가 반환 하는 타입이 String 이면 이 @AfterReturning도 String 또는 그 상위인 Object여야 정상 호출이 된다.
     * */
    @AfterReturning(value = "com.example.aop.order.aop.Pointcuts.orderAndService()", returning = "result")
    public void doReturning(JoinPoint joinPoint, Object result) {
        log.info("[doReturning] {}, return = {}", joinPoint.getSignature(), result);
    }

    /**
     * {@code @AfterThrowing}은 위 @AfterReturning과 마찬가지로 예외의 타입이 실제 타겟이 던지는 예외 타입과 일치하거나 그 상위 예외여야 한다.
     * */
    @AfterThrowing(value = "com.example.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
        log.info("[doThrowing] {}, message = {}", joinPoint.getSignature(), ex.getMessage());
    }

    @After(value = "com.example.aop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[doAfter] {}", joinPoint.getSignature());
    }
}

 

알고 넘어가야 할 부분이 있다.

  • 첫번째, 우선 모든 어드바이스는 JoinPoint를 첫번째 파라미터로 받을 수 있다. 생략도 가능하다. 그러나, @Around는 반드시 ProceedingJoinPoint를 받아야 한다. 
    • 그 이유는 @Around 같은 경우 개발자가 직접 타겟을 호출하는 코드를 작성해야 한다. joinPoint.proceed() 이 코드. 그 외 나머지 어드바이스는 개발자가 직접 타겟을 호출하지 않는다. 그래서 @Around는 ProceedingJoinPoint를 파라미터로 받아야 하고 그 외 나머지 어드바이스는 JoinPoint를 받는다. 
  • 두번째, @Before는 실제 타겟을 호출하는 코드를 작성안하지만 @Before의 모든 코드가 다 수행되면 자동으로 호출한다.
  • 세번째, @AfterThrowing 역시 실제 에러에 대한 코드가 없어도 이미 에러가 터진 후 이후의 상황이다. (try - catch의 catch 안 코드라고 생각)
  • 네번째, @AfterReturning, @AfterThrowing은 각각 실제 타겟 호출의 결과와 에러를 파라미터로 받고 그 파라미터의 이름은 애노테이션에서 작성한 이름과 동일해야 한다.
  • 다섯번째, @AfterReturning, @AfterThrowing에서 파라미터로 받는 실제 타겟 호출 반환값과 에러의 타입은 해당 타입과 일치하거나 그 상위 타입이어야 한다.
  • 여섯번째, @AfterReturning에서는 @Around와 다르게 실제 타겟 호출 반환값에 대한 변경(조작)이 불가능하다. 
    • 이는 단순하게 생각해보면 된다. @Around는 개발자가 직접 실제 타겟을 호출하여 돌려받는 결과를 리턴하는데 그렇기 때문에 리턴값에 조작이 가능한것이고 @AfterReturning은 그렇지 않기 때문에 불가능한 것.
  • 일곱번째, @Around는 joinPoint.proceed()를 여러번 호출할 수도 있다.
  • 여덟번째, @Around는 joinPoint.proceed()를 반드시 호출해야 한다. 즉, 실제 객체를 반드시 호출해서 리턴해야 한다.

 

이 여러 어드바이스의 호출 순서는 다음과 같다.

@Around -> @Before -> @After -> @AfterReturning -> @AfterThrowing

물론, @Aspect 안에 동일한 종류의 어드바이스가 2개 이상이면 순서가 보장되지 않는다. 이 경우에 보장된 순서를 원한다면 @Aspect를 분리해서 @Order를 적용해서 순서를 적용해야 한다.

 

그럼 왜 @Around만 사용하더라도 모든게 가능한데 이렇게 부분적으로 나뉘어진 어드바이스가 있을까?

 

이 부분에 대한 답은 이런것들이다. 다음 코드엔 심각한 문제가 있다.

@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
 public void doBefore(ProceedingJoinPoint joinPoint) {
     log.info("[before] {}", joinPoint.getSignature());
 }

 

어떤 문제가 있을까? 바로 @Around 어드바이스인데도 실제 객체를 호출하지 않는다. 이 코드를 작성한 개발자의 의도는 실제 객체를 호출하기 전에 무언가를 로그로 출력하고 싶었던 것 뿐인데 @Around이기 때문에 실제 객체를 반드시 호출해야 한다. 

 

그럼 이 코드를 보자. 이 코드에는 문제가 전혀 없다

@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
 public void doBefore(JoinPoint joinPoint) {
     log.info("[before] {}", joinPoint.getSignature());
 }

 

@Before이기 때문에 실제 객체를 호출하는 고민을 전혀 할 필요가 없다.

 

이 말은, @Around는 가장 넓은 기능을 제공하나 실수할 가능성이 있다. 반면 @Before, @After 같은 어드바이스는 기능은 적더라도 실수할 가능성이 적으며 코드가 단순해진다. 그리고 가장 중요한 부분은 이 코드를 작성한 의도가 분명해진다는 것이다. @Before 애노테이션을 본 순간 "아, 이 코드는 실제 객체를 호출하기 전에 무언가를 하기 위해 만들어진 어드바이스구나" 라고 자연스레 생각할 수 있다.

 

즉, 좋은 설계는 제약이 있는 것이다. 제약은 실수의 가능성을 줄여준다. 애시당초 @Around가 아니라 @Before를 사용하면 실제 객체를 호출할 고민조차 할 필요가 없기 때문에 그 부분을 고려하지 않아도 되는것이다. 

 

728x90
반응형
LIST
728x90
반응형
SMALL

 

SMALL

이제 드디어 AOP에 대해 진지하게 알아보는 시간을 가져보자. 우선 AOP란 번역 하면 관점 지향 프로그래밍이다. 여기서 관점이란 무엇일까? 관점은 애플리케이션의 핵심적인 관점과 부가적인 관점으로 분류할 수 있다. 애플리케이션의 핵심적인 관점이라고 하면 비즈니스 로직이다. 그리고 부가적인 관점은 애플리케이션의 비즈니스 로직을 수행하면서 있으면 도움이 되는 로직이다. 예를 들면 비즈니스 로직을 처리하는 시간에 대한 로그 출력이나, 데이터베이스와의 트랜잭션 기능.

 

이러한 부가 기능은 단독으로 사용되지 않고 핵심 기능과 함께 사용된다. 즉, 이름 그대로 핵심 기능을 보조하기 위해 존재한다고 볼 수 있다.

 

핵심 로직을 수행하기 직전 부가 로직이 수행되어야 하면 핵심 기능 로직과 부가 기능 로직이 하나의 객체 안에 섞여 들어가게 된다. 부가 기능이 필요하면 이렇게 둘을 합해서 하나의 로직을 완성한다.

 

그러나, 보통 부가 기능은 여러 클래스에 걸쳐서 함께 사용된다. 예를 들어, 모든 애플리케이션 호출을 로깅해야 하는 요구사항이 있으면 하나의 객체에서 끝나지 않고 여러 객체를 거쳐야 할 것이다.

 

이렇게 여러 객체에 걸쳐 공통의 관심사를 횡단 관심사라고 하는데 이러한 횡단 관심사를 모든 객체에 적용하려면 모든 객체의 로직이 수정되어야 한다. 즉, 할 수 없는 것이다. 또는 너무너무 비효율적이거나. 예를 들어 부가 기능을 적용해야 하는 클래스가 100개면 100개 모두 로직을 수정해야 하고 동일한 코드를 추가해야 한다.

 

코드의 중복이라도 줄이고자 부가 기능을 별도의 유틸리티 클래스로 만든다고 해도 해당 유틸리티 클래스를 호출하는 코드가 결국 필요하다. 그리고 부가 기능이 구조적으로 단순 호출이 아니고 try - catch - finally 같은 구조가 필요하다면 더욱 복잡해진다. 그리고 부가 기능에 변경 사항이 발생하면? 클래스 100개 모두 변경해줘야 하며 적용 대상이 변경된다고 해도 마찬가지 문제점이 발생한다. 

 

요약하자면, 다음과 같은 문제점이 발생한다.

  • 부가 기능을 적용할 때 아주 많은 반복이 필요하다.
  • 부가 기능이 여러 곳에 퍼져서 중복 코드를 만들어낸다.
  • 부가 기능을 변경할 때 중복 때문에 많은 수정이 필요하다.
  • 부가 기능의 적용 대상을 변경할 때 많은 수정이 필요하다.
소프트웨어 개발에서 변경 지점은 하나가 될 수 있도록 잘 모듈화를 해야 한다. 

 


AOP

그럼 이러한 부가 기능 도입의 문제점들을 어떻게 해결할 수 있을까? 부가 기능을 핵심 기능에서 분리하여 한 곳에서 관리하는 것이다. 그리고 해당 부가 기능을 어디에 적용할지 선택하는 기능을 만드는 것이다. 이렇게 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만들었는데 이것이 바로 애스펙트(Aspect)이다. 애스펙트는 부가 기능과, 해당 부가 기능을 어디에 적용할지 정의한 것이다. 예를 들어 로깅 기능(부가 기능)을 모든 서비스에 적용해라(어디에) 라는 것이 정의되어 있는 것이다.

 

저번 시간에 살펴본 @Aspect가 바로 이것이다. 그리고 스프링이 제공하는 어드바이저도 어드바이스(부가 기능) + 포인트컷(대상)을 가지고 있어서 개념상 하나의 애스팩트이다.

 

그리고 참고로 AOP는 OOP를 대체하기 위함이 아니다. 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.

 

AspectJ 프레임워크

AOP의 대표적인 구현으로 AspectJ 프레임워크가 있다. 스프링은 AOP를 지원하고 있으나 대부분 AspectJ의 문법을 차용하고 AspectJ가 제공하는 기능의 일부만 제공한다.

그러나, 결론은 스프링 AOP를 사용하면 실무에서 어지간한 모든 기능을 다 사용할 수 있다. 이는 이후에 차차 알아보도록 하자.

 

AOP 적용 방식

그럼 이 부가적인 관점에 해당하는 로직과 핵심 관점에 해당하는 로직은 AOP를 사용할 때 코드상 완전히 분리되어서 관리되는데 어떻게 AOP를 사용할 때 부가 기능 로직은 어떤 방식으로 실제 로직에 추가될 수 있을까?

 

크게 3가지 방법이 있다.

  • 컴파일 시점
  • 클래스 로딩 시점
  • 런타임 시점(프록시)

 

컴파일 시점

AspectJ가 제공하는 특별한 컴파일러를 통해 이루어진다. 다음 그림을 보자.

.java 소스 코드를 컴파일러를 사용해서 .class를 만드는 시점에 부가 기능 로직을 추가할 수 있다. 이때는 AspectJ가 제공하는 컴파일러를 통해 이루어진다. .class를 디컴파일 해보면 에스팩트 관련 호출 코드가 들어간다. 쉽게 말해서 부가 기능 코드가 핵심 기능이 있는 컴파일된 코드 주변에 실제로 붙어 버린다고 생각하면 된다. AspectJ 컴파일러는 Aspect를 확인해서 해당 클래스가 적용 대상인지 먼저 확인한 후 적용 대상인 경우에 부가 기능 로직을 적용한다. 이렇게 원본 로직에 부가 기능 로직이 추가되는 것을 위빙(Weaving)이라고 한다.

 

이 방법의 단점은 특별한 컴파일러가 필요하다는 것 그 자체이고 사용하기에 굉장히 복잡하다.

 

클래스 로딩 시점

 

자바를 실행하면 자바 언어는 .class 파일을 JVM 내부의 클래스 로더에 보관한다. 이때 중간에서 .class 파일을 조작한 다음 JVM에 올릴 수 있다. 자바 언어는 .class를 JVM에 저장하기 전에 조작할 수 있는 기능을 제공한다. 자세한 내용을 알려면 java instrumentation을 검색해 보면 된다. 참고로 수많은 모니터링 툴들이 이 방식을 사용한다. 이 시점에 에스팩트를 적용하는 것을 로드 타임 위빙이라고 한다.

 

이 방법의 단점은 자바를 실행할 때 특별한 옵션을 통해 클래스 로더 조작기를 지정해야 하는데 이 부분이 번거롭고 운영하기에 단점이 있다.

 

 

런타임 시점

런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음을 말한다. 자바의 메인(main) 메서드가 이미 실행된 다음이다. 따라서 자바 언어가 제공하는 범위 안에서 부가 기능을 적용해야 한다. 스프링과 같은 컨테이너의 도움을 받고 프록시와 DI, 빈 포스트 프로세서 같은 개념들을 총 동원해야 한다. 이렇게 하면 최종적으로 프록시를 통해 스프링 빈에 부가 기능을 적용할 수 있다. 

 

 

부가 기능이 적용되는 차이를 정리하면 다음과 같다.

  • 컴파일 시점: 실제 대상 코드에 애스팩트를 통한 부가 기능 호출 코드가 포함된다. AspectJ를 직접 사용해야 한다.
  • 클래스 로딩 시점: 실제 대상 코드에 애스팩트를 통한 부가 기능 호출 코드가 포함된다. AspectJ를 직접 사용해야 한다.
  • 런타임 시점: 실제 대상 코드는 그대로 유지된다. 대신에 프록시를 통해 부가 기능이 적용된다. 따라서 항상 프록시를 통해야 부가 기능을 사용할 수 있다. 스프링 AOP는 이 방식을 사용한다.

 

AOP 적용 위치

AOP는 지금까지 학습한 메서드 실행 위치뿐만 아니라 다음과 같은 다양한 위치에 적용할 수 있다.

  • 생성자
  • 필드 값 접근
  • static 메서드 접근
  • 메서드 실행

생성자, 필드 값 접근, static 메서드 접근 같은 경우는 AspectJ를 직접 사용해서 실제 코드에 부가 로직 코드를 합치는 경우에 가능하다. 당연히 가능하겠지 생성자안에 부가 로직을 추가하면 되니까. 그리고 이런 AOP를 적용할 수 있는 지점을 조인 포인트(JoinPoint)라고 한다. 그러나, 프록시를 사용하는 방식은 메서드 실행 지점에만 AOP를 적용할 수 있다. 프록시는 메서드 오버라이딩 개념으로 동작한다. 따라서 생성자나 static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없다. 

 

그러면, 스프링 AOP 방식인 프록시보다 그냥 AspectJ를 사용하면 더 좋은 거 아니야?

라고 생각할 수 있다. 그러나, AspectJ 프레임워크를 사용하기 위해 공부해야 할 내용이 어마어마하게 많고 설정 방법도 굉장히 복잡하다고 알려져 있다. 반면 스프링 AOP는 별도의 추가 자바 설정 없이 스프링만 있으면 편리하게 AOP를 사용할 수 있고 실무에서는 스프링이 제공하는 AOP 기능만 사용해도 대부분의 문제를 해결할 수 있다. 그러니 스프링 AOP가 제공하는 기능을 학습하는 것에 집중해 보자.

 

AOP 용어 정리

 

 

  • 조인 포인트(Join Point)
    • 어드바이스가 적용될 수 있는 위치. 메서드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근 같은 프로그램 실행 중 지점
    • 조인 포인트는 추상적인 개념이다. AOP를 적용할 수 있는 모든 지점이라 생각하면 된다.
    • 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메서드 실행 지점으로 제한된다.
  • 포인트컷(Pointcut)
    • 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능
    • 주로 AspectJ 표현식을 사용해서 지정
    • 프록시를 사용하는 스프링 AOP는 메서드 실행 지점만 포인트컷으로 선별 가능
  • 타겟(Target)
    • 어드바이스를 받는 객체(프록시가 참조하는 실제 객체를 말한다). 포인트컷으로 결정된다.
  • 어드바이스(Advice)
    • 부가 기능
    • Around, Before, After와 같은 다양한 종류의 어드바이스가 있다.
  • 에스팩트(Aspect)
    • 어드바이스 + 포인트컷을 모듈화 한 것
    • @Aspect를 생각하면 된다.
    • 여러 어드바이스와 포인트 컷이 함께 존재
  • 어드바이저(Advisor)
    • 하나의 어드바이스와 하나의 포인트 컷으로 구성
    • 스프링 AOP에서만 사용되는 특별한 용어
  • 위빙(Weaving)
    • 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것
    • 위빙을 통해 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가할 수 있음
    • AOP 적용을 위해 에스팩트를 객체에 연결한 상태
      • 컴파일 타임
      • 로드 타임
      • 런타임(스프링 AOP는 런타임이고 프록시 방식이다)
  • AOP 프록시
    • AOP 기능을 구현하기 위해 만든 프록시 객체. 스프링에서 AOP 프록시는 JDK 동적 프록시 또는 CGLIB 프록시이다.

 

 

정리를 하자면

지금까지가 AOP, 스프링 AOP의 개념이었다. 그러니까 결론은 AOP는 여러 컴포넌트 단위에서 공통적으로 가지는 공통의 관심사를 처리하기 위한 방법으로 고안된 개념이다. 공통의 관심사에 대한 코드를 작성하기 위해 모든 컴포넌트(객체)에 같은 코드를 작성하는 것은 비효율적이고 중복 코드가 발생하며 유지보수에 적합하지 않기 때문에 모듈화 하여 모듈 하나를 관리하는 방식이 AOP라고 생각하면 될 것 같다. 스프링 AOP는 프록시 방식을 사용한다고 했고 그렇기에 조인 포인트는 메서드 실행 지점으로 제한된다. 그러나, 그렇다한들 대부분의 문제를 해결할 수 있기 때문에 스프링 AOP를 사용하는 것만으로 충분하다. 이제 실제로 AOP를 구현해보자.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

AOP (Part. 3) - 포인트컷  (0) 2024.01.02
AOP (Part.2)  (0) 2024.01.02
AOP와 @Aspect, @Around  (0) 2023.12.29
빈 후처리기(BeanPostProcessor)  (2) 2023.12.27
Advisor, Advice, Pointcut  (0) 2023.12.15
728x90
반응형
SMALL
SMALL

AOP(Aspect Oriented Programming)는 번역하면 관점 지향 프로그래밍으로 해석할 수 있고, 이 관점 지향 프로그래밍이란 애플리케이션에서 핵심적인 관점과 부가적인 관점을 분리하여 각각을 모듈화하여 개발하는 방법이다. 그리고 이런 관점 지향 프로그래밍을 위해 지금까지 프록시를 만들고 프록시를 어떻게 쓰는지 어떤 의도를 가지고 프록시 패턴과 데코레이터 패턴으로 나뉘어지는지 등 공부를 했다. 그에 대한 내용은 이전 포스팅을 참고하자. 

 

스프링에서는 어드바이저를 스프링 빈으로 등록만 하면 자동으로 스프링이 해당 어드바이저의 포인트컷을 가져다가 프록시가 적용되어야 할 스프링 빈에 알아서 적용을 해주는데 이 어드바이저를 스프링 빈으로 등록하는 과정보다 더 간단하고 더 깔끔하게 프록시를 적용할 수 있는 방법이 있다. 이는 AspectJ 프로젝트에서 제공하는 @Aspect 애노테이션을 사용하는 것이다.

 

AOP에 대해서는 이후 포스팅에서 더 자세히 알아보도록 하고 @Aspect를 이용해서 스프링 빈에 프록시를 적용하는 방법을 먼저 알아보자. 

 

@Aspect를 이용해서 프록시 적용하기

아주 간단하다. 클래스에 @Aspect 애노테이션을 달고, 어드바이스가 될 메서드에 @Around 애노테이션을 달면 된다.

LogTraceAspect.java

package com.example.advanced.app.proxy.config.v6_aop.aspect;

import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;


/**
 * @Aspect 가 붙어있는 녀석이 빈으로 등록되어 있으면 자동 프록시 생성기인 AnnotationAwareAspectJAutoProxyCreator 이 녀석이
 * @Aspect 가 붙어있는 이 녀석을 Advisor 로 만들어 준다. 이 때 Pointcut 은 @Around 가 달려있는 녀석이 포인트컷이 되고
 * 그 안 로직이 Advice 가 된다. 그리고 그 하나가 Advisor 가 된다. 만약 이 @Aspect 가 붙은 녀석이 @Around 가 여러개 있으면 Advisor 도 여러개가 되는 것.
 * 왜냐하면 @Around = 포인트컷, @Around 내부 로직 = 어드바이스, 포인트컷 + 어드바이스 = 어드바이저 이기 때문에 이게 여러개 있으면 어드바이저도 여러개가 생기는 것.
 * */
@Slf4j
@Aspect
public class LogTraceAspect {

    private final LogTrace logTrace;

    public LogTraceAspect(LogTrace logTrace) {
        this.logTrace = logTrace;
    }

    @Around("execution(* com.example.advanced.app.proxy.version..*(..))") // Pointcut
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        // Advice 로직
        TraceStatus status = null;

        try {
            // Ex) "OrderController.request()"
            String message = joinPoint.getSignature().toShortString();

            status = logTrace.begin(message);

            Object result = joinPoint.proceed();

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

이렇게 Aspect 클래스를 하나 만들면 되는데, 한가지 더 알아야 할 내용은 @Around 애노테이션에 작성하는 값이다. 이 값은 포인트컷 표현식인데, 지금은 execution의 괄호'()' 안에 있는 표현식에 해당하는 메서드들만 프록시 적용 대상이다라고 생각하면 된다. 괄호 안의 값을 설명하자면,

  • *: 모든 반환 타입을 의미. (원래 맨 앞에 접근 제어자가 가장 먼저인데 생략이 가능하다. 그래서 한 개만 있는 경우 반환 타입을 나타낸다)
  • com.example.advanced.app.proxy.version..: com.example.advanced.app.proxy.version 패키지와 그 하위 모든 패키지(..)를 나타낸다.
  • *(..): 모든 메서드(*)의 모든 파라미터(..)를 의미한다. 

그래서 결론적으로 * com.example.advanced.app.proxy.version..*(..) 은 해당 패키지부터 그 하위 모든 패키지의 모든 반환 타입의 모든 메서드의 어떠한 파라미터도 상관없이 해당되는 메서드들을 가리킨다.

 

그리고 해당 메서드의 파라미터를 보면 'ProceedingJoinPoint joinpoint'를 받는다. 이건 예전 포스팅에서 어드바이스를 만들 때 'MethodInvocation invocation'과 유사하다. 내부에 실제 호출 대상, 전달 인자, 그리고 어떤 객체와 어떤 메서드가 호출되었는지 정보가 포함되어 있다.

 

그래서 joinpoint.proceed()를 호출하는 게 실제 호출 대상(target)을 호출하는 것이다. 여기서 실제 호출 대상을 이해하지 못한다면 이전 포스팅에서 프록시 관련 내용을 먼저 이해해야 한다. 모든 프록시는 실제 호출 대상을 가지고 있어야 한다.

 

 

그러면 어떻게 스프링이 @Aspect 애노테이션이 붙은 클래스를 가져와서 어드바이저를 만들고 프록시를 입혀줄까?

정답은 스프링의 '자동 프록시 생성기(AnnotationAwareAspectJAutoProxyCreator)'에 있다.

 

@Aspect를 어드바이저로 변환하는 과정

우선 스프링의 자동 프록시 생성기는 2가지 일을 한다.

  • 스프링 빈으로 등록된 어드바이저를 가져와서 프록시 적용 대상 판단 후 적용
  • @Aspect를 보고 어드바이저로 변환하고 프록시 적용 대상 판단 후 적용

그래서 전체적인 흐름을 보면 다음과 같다.

 

 

1. 생성: 스프링이 빈으로 등록될 객체를 생성한다.(@Bean, 컴포넌트 스캔 모두 포함)

2. 전달: 생성된 객체를 빈 저장소에 등록하기 전 빈 후처리기에 전달한다.

3. 모든 Advisor 빈 조회: 스프링 컨테이너에서 Advisor 빈을 모두 조회한다.

3-1. 모든 @Aspect 빈 조회: @Aspect 어드바이저 빌더 내부에 저장된 Advisor를 모두 조회한다. (사실 이 과정 전에 먼저 @Aspect 애노테이션이 달려있는 모든 클래스를 찾고 어드바이저로 만든 후 @Aspect 어드바이저 빌더에 저장하는 과정이 생략되어 있다)

4. 프록시 적용 대상 체크: 앞서 3, 3-1 에서 조회한 Advisor에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다. 이 때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭해본다. 그래서 조건이 하나라도 만족하면 프록시 적용 대상이 된다. 예를 들어, 메서드 하나만 포인트컷 조건에 만족해도 프록시 적용 대상이 된다.

5. 프록시 생성: 프록시 적용 대상이면 프록시를 생성하고 프록시를 반환한다. 그래서 프록시를 스프링 빈으로 등록한다. 만약 프록시 적용 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록한다.

6. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.

 

 

정리를 하자면

@Aspect를 사용해서 이전 작업과는 비교도 안되게 편리하게 프록시를 적용할 수 있다. @Aspect로 등록한 클래스를 스프링이 자동으로 만들어주는 자동 프록시 생성기 빈 후처리기를 통해 알아서 포인트컷의 조건을 기반으로 프록시를 만들어준다. 이제 AOP에 대한 깊은 이해를 가져보는 시간이 필요하다.

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

AOP (Part.2)  (0) 2024.01.02
AOP(Aspect Oriented Programming)  (0) 2023.12.29
빈 후처리기(BeanPostProcessor)  (2) 2023.12.27
Advisor, Advice, Pointcut  (0) 2023.12.15
스프링이 지원하는 ProxyFactory  (0) 2023.12.14
728x90
반응형
SMALL
SMALL

스프링에서 '@Bean'이나 컴포넌트 스캔으로 스프링 빈을 등록하면 스프링은 대상 객체를 생성하고 스프링 컨테이너 내부의 빈 저장소에 등록한다. 그리고 스프링 컨테이너를 통해 등록한 스프링 빈을 조회해서 사용한다.

 

스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사용한다.  

BeanPostProcessor는 번역하면 빈 후처리기로, 이름 그대로 빈을 생성한 후에 무언가를 처리하는 용도로 사용한다.

 

빈 후처리기는 강력하다. 객체를 조작하는게 가능하고 완전히 다른 객체로 바꿔치기 하는 것도 가능하다. 빈 후처리기 과정을 자세히 살펴보자.

 

빈 후처리기 과정

스프링 빈 등록 과정 -> 빈 후처리기

 

1. 생성: 스프링 빈 대상이 되는 객체를 생성한다 (@Bean, 컴포넌트 스캔 모두 포함)

2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.

3. 후 처리 작업: 빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바꿔치기 할 수 있다.

4. 등록: 빈 후처리기는 빈을 반환한다. 전달된 빈을 그대로 반환하면 해당 빈이 등록되고, 바꿔치기 하면 다른 객체가 빈 저장소에 등록된다.

 

여기서 '3. 후 처리 작업'을 보면 스프링 빈 객체를 조작 또는 바꿔치기 한다고 되어 있는데 이 말은 무슨 말일까? 다음 그림을 보자.

 

빈 후처리기에서 객체 A를 객체 B로 바꿔버린 모습을 볼 수 있다. 그리고 그 바꾼 객체 B를 스프링 빈 저장소에 전달하면 최초 객체 A가 객체 B로 최종 등록된다. 이것을 객체를 조작 또는 바꿔치기한다 말한다.

 

빈 후처리기를 사용하려면 BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.

이 BeanPostProcessor 인터페이스는 두 개의 메서드를 제공한다. 

  • postProcessBeforeInitialization: 객체 생성 이후에 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서
  • postProcessAfterInitialization: 객체 생성 이후에 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서

 

이 빈후처리기를 통해 특정 객체를 다른 객체로 변경해버리는 예시 코드를 작성해보자.

 

빈 후처리기 테스트 코드

BeanPostProcessorTest.java

package com.example.advanced.postprocessor;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
public class BeanPostProcessorTest {

    @Test
    void basicConfig() {
        // AnnotationConfigApplicationContext 자체가 스프링 컨테이너
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(beanPostProcessorConfig.class);

        // A를 Bean 으로 등록했지만 빈 후처리기에서 A를 B로 바꿨다.
        B b = applicationContext.getBean("beanA", B.class);
        b.helloB();

        // 그러므로 A는 Bean 으로 등록되지 않는다.
        assertThatThrownBy(() -> applicationContext.getBean(A.class))
                .isInstanceOf(NoSuchBeanDefinitionException.class);
    }

    @Configuration
    static class beanPostProcessorConfig {
        @Bean(name = "beanA")
        public A a() {
            return new A();
        }

        @Bean
        public AtoBPostProcessor postProcessor() {
            return new AtoBPostProcessor();
        }
    }

    static class A {
        public void helloA() {
            log.info("hello A");
        }
    }

    static class B {
        public void helloB() {
            log.info("hello B");
        }
    }

    static class AtoBPostProcessor implements BeanPostProcessor {
        

        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            log.info("beanName = {}, bean = {}", beanName, bean);
            if (bean instanceof A) {
                return new B();
            }
            return bean;
        }
    }
}

 

위 코드를 보면 클래스 A, 클래스 B가 있고 AtoBPostProcessor라는 클래스가 BeanPostProcessor를 구현한다. 이 AtoBPostProcessor 클래스에서는 BeanPostProcessor의 메서드 중 하나인 postProcessAfterInitialization을 구현한다. 이 메서드에서 넘겨받는 빈의 타입이 'A'인 경우 'B'타입으로 변경하여 반환한다.

 

그리고 실제로 A를 빈으로 등록하는 Configuration 클래스로 빈을 등록한 후 스프링 컨테이너에서 A객체를 빈으로 등록할 때 사용한 이름인 'beanA'를 가져오면 B 객체를 반환 받는것을 확인할 수 있다.

 

 

정리를 하자면..

이렇게 빈 후처리기를 통해 스프링이 빈 저장소(스프링 컨테이너)에 등록할 객체를 강력한 방식으로 조작하고 변경할 수 있다. 여기서 조작이란 메서드를 호출함을 의미한다. 예를 들어, @PostConstruct 애노테이션이 붙은 메서드도 스프링이 자체적으로 등록하는 빈 후처리기인 'CommonAnnotationBeanPostProcessor'에서 해당 애노테이션이 붙은 객체를 다 찾아내서 해당 메서드를 호출하는 것이다. 

 

이 말은, 컴포넌트 스캔으로 등록한 빈도 개발자는 조작 및 바꿔치기가 가능하다는 얘기다. 그리고 실제로 스프링은 AOP를 구현할 때 빈 후처리기를 통해 컴포넌트 스캔으로 등록되는 빈 중 프록시로 만들어져야 하는 객체를 포인트컷을 통해 찾아 프록시로 변경하여 등록해준다. 

 

그렇다면 스프링이 제공하는 빈 후처리기는 어떤것들이 있을까?

 

스프링이 제공하는 빈 후처리기

우선, 스프링이 제공하는 빈 후처리기를 사용하기 위해서는 다음 라이브러리를 추가해야한다.

implementation 'org.springframework.boot:spring-boot-starter-aop'

 

이 라이브러리를 추가하면 'aspectjweaver' 라는 aspectJ 관련 라이브러리를 등록하고, 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다. 스프링 부트가 없던 시절에는 @EnableAspectJAutoProxy를 직접 사용해야 했는데, 이 부분을 스프링 부트가 자동으로 처리해준다. 스프링 부트가 활성화하는 빈은 AopAutoConfiguration인데 이 빈을 활성화하면 자동 프록시 생성기라는 빈 후처리기가 스프링 빈에 자동으로 등록된다.

 

 

AnnotationAwareAspectJAutoProxyCreator

이 녀석이 스프링 부트가 자동으로 스프링 빈으로 등록해주는 빈 후처리기다. 이름 그대로 자동으로 프록시를 생성해주는 빈 후처리기. 이 빈 후처리기는 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다. Advisor안에는 Pointcut과 Advice가 이미 모두 포함되어 있다. 따라서 Advisor만 알고 있으면 그 안에 있는 Pointcut으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다. 그리고 Advice로 부가 기능을 적용하면 된다.

 

그리고 @Aspect도 자동으로 인식해서 프록시를 만들고 AOP를 적용해준다.

 

 

자동 프록시 생성기의 작동 과정을 자세히 살펴보자.

 

1. 생성: 스프링이 스프링 빈 대상이 되는 객체를 생성한다. (@Bean, 컴포넌트 스캔 모두 포함)

2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.

3. 모든 Advisor 빈 조회: 자동 프록시 생성기 - 빈 후처리기는 스프링 컨테이너에서 모든 Advisor를 조회한다.

4. 프록시 적용 대상 체크: 앞서 조회한 Advisor에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다. 이 때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭해본다. 하나라도 조건이 만족하면 프록시 적용 대상이 된다. 예를 들어 10개의 메서드 중에 하나만 포인트컷 조건에 만족해도 프록시 적용 대상이 된다.

5. 프록시 생성: 프록시 적용 대상이면 프록시를 생성하고 반환해서 프록시를 스프링 빈으로 등록한다. 만약 프록시 적용 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록한다.

6. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.

 

 

이를 통해 한가지 더 알 수 있는 중요한 사실이 있다.

포인트컷은 2가지에 사용된다.

1. 프록시 적용 여부 판단 - 프록시를 생성하는 단계

  • 자동 프록시 생성기는 포인트컷을 사용해서 해당 빈이 프록시를 생성할 필요가 있는지 없는지 체크한다.
  • 클래스 + 메서드 조건을 모두 비교한다. 이 때 모든 메서드를 체크하는데, 포인트컷 조건에 하나하나 매칭해본다. 만약 조건에 맞는 것이 하나라도 있으면 프록시를 생성하고, 조건에 맞는 것이 하나도 없을 땐 프록시를 생성하지 않는다.

2. 어드바이스 적용 여부 판단 - 프록시를 사용하는 단계

  • 프록시가 호출되었을 때 부가 기능인 어드바이스를 적용할지 말지 포인트컷을 보고 판단한다. 즉, 특정 메서드가 호출됐을 때 그 메서드가 포인트컷 조건에 만족하는 메서드인지 확인 한다는 뜻이다.

프록시를 모든 곳에 생성하는 것은 비용 낭비이기 때문에 자동 프록시 생성기는 모든 스프링 빈에 프록시를 적용하는 게 아니고 포인트컷으로 한번 필터링해서 어드바이스가 사용될 가능성이 있는 곳에만 프록시를 생성한다.

 

프록시를 모든 곳에 생성하는 것은 비용 낭비라고 했고 자동 프록시 생성기는 포인트컷으로 한번 필터링해서 어드바이스가 사용될 가능성이 있는 곳에만 프록시를 생성한다고 했다. 그럼 만약 여러개의 포인트컷 조건을 만족한다고 하면 프록시는 여러개가 생길까? 아니다. 프록시는 딱 하나만 생기고 여러 어드바이저가 생긴다.

 

프록시 자동 생성기 상황별 정리

  • advisor1의 포인트컷만 만족: 프록시 1개 생성, 프록시에 advisor1만 포함
  • advisor1, advisor2의 포인트컷을 모두 만족: 프록시 1개 생성, 프록시에 advisor1, advisor2 모두 포함
  • advisor1, advisor2의 포인트컷을 모두 만족하지 않음: 프록시가 생성되지 않음

 

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

AOP(Aspect Oriented Programming)  (0) 2023.12.29
AOP와 @Aspect, @Around  (0) 2023.12.29
Advisor, Advice, Pointcut  (0) 2023.12.15
스프링이 지원하는 ProxyFactory  (0) 2023.12.14
Proxy/Decorator Pattern 2 (동적 프록시)  (0) 2023.12.14
728x90
반응형
SMALL

스프링에서 프록시를 만들고 AOP 관련 개념을 접하면 한번은 듣는 단어인 Advisor, Advice, Pointcut에 대해 정리하고자 한다.

 

우선 그 전에 다음 포스팅을 참고하면 좋을 것 같다. https://cwchoiit.tistory.com/80

 

스프링이 지원하는 프록시

https://cwchoiit.tistory.com/79 Proxy/Decorator Pattern 2 (동적 프록시) https://cwchoiit.tistory.com/78 Proxy/Decorator Pattern 이제 스프링에서 굉장히 자주 사용되는 프록시와 데코레이터 패턴을 정리해 보자. 이 프록시

cwchoiit.tistory.com

위 포스팅에서 스프링이 ProxyFactory를 제공해주고 이 프록시 팩토리로 동적 프록시를 만드는 내용을 얘기하면서 Advice라는 개념을 살짝 맛봤다. ProxyFactory에 Advice를 추가해서 프록시가 제공하는 추가 기능 로직을 담당하는 녀석이 Advice라고. 

 

SMALL

Advisor, Advice, Pointcut

  • Advice: 프록시가 제공하는 추가 기능에 대한 로직을 가지고 있는 곳을 말한다. (조언)
  • Pointcut: 프록시가 제공하는 추가 기능을 어디에 어떤 기준으로 적용할것인가?을 가지고 있는 곳을 말한다. (누구에게? / 어디에?)
  • Advisor: Advice와 Pointcut을 한 개씩 가지고 있는 곳을 말한다. (조언자)

쉽게 풀어 얘기하면 프록시는 Pointcut(어디에?) Advice(조언 = 추가기능)을 할 것인가? 그리고 그 프록시가 어디에 어떤 조언을 할 지 알려줄 Advisor(조언자)를 가지고 있다.

 

그리고 ProxyFactory는 Advisor가 필수이다. 근데 저 위에 포스팅에서는 Advisor를 안 사용했고 addAdvice()만 호출해서 Advice만 넘겼는데 이렇게 하면 기본 Advisor에 모든 곳에 적용하는 Pointcut으로 할당된다. 단순 편의 메서드인 것 뿐이다.

 

한번 Advisor, Advice, Pointcut을 적용해 보는 코드를 작성해보자.

 

모든 대상에 대해 허용하는 Pointcut으로 Advisor를 만들기

어떤 요청이어도 Advice(프록시가 제공하는 추가 기능)를 모두 적용하는 Pointcut으로 Advisor를 만든다.

ServiceInterface.java

package com.example.advanced.common.service;

public interface ServiceInterface {
    void save();

    void find();
}

ServiceImpl.java

package com.example.advanced.common.service;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ServiceImpl implements ServiceInterface {
    @Override
    public void save() {
        log.info("save calling");
    }

    @Override
    public void find() {
        log.info("find calling");
    }
}

TimeAdvice.java

package com.example.advanced.common.advice;

import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

@Slf4j
public class TimeAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("TimeProxy Start");

        long startTime = System.currentTimeMillis();

        Object result = invocation.proceed();

        long endTime = System.currentTimeMillis();

        log.info("TimeProxy End. ResultTime = {}ms", endTime - startTime);
        return result;
    }
}

 

Advice를 만들었다. Advice를 만들기 위해 MethodInterceptor를 구현한다. 왜 Advice를 만든다고 해놓고 MethodInterceptor일까? MethodInterceptor가 상속받는 Interceptor가 있고 그 Interceptor가 상속받는 인터페이스가 'Advice'이기 때문이다. 이 MethodInterceptor를 구현하려면 'invoke()'를 구현해야한다. 이 메서드는 프록시가 추가로 제공해주는 기능에 대한 내용을 넣는 곳이다. 그리고 프록시는 항상 실제 객체가 있어야 하는데 기존에는 실제 객체를 주입받아서 프록시를 만들었는데 이 Advice에는 없다. 왜 그러냐면 ProxyFactory에 Advice가 적용될텐데 ProcyFactory가 실제 객체를 들고 있기 때문에 Advice에서는 필요가 없다. 그저 MethodInvocation 타입의 invocation.proceed()를 호출하면 ProxyFactory가 가지고 있는 실제 객체의 호출한 메서드가 호출된다.

 

AdvisorTest.java

package com.example.advanced.advisor;

import com.example.advanced.common.advice.TimeAdvice;
import com.example.advanced.common.service.ServiceImpl;
import com.example.advanced.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.ClassFilter;
import org.springframework.aop.MethodMatcher;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;

import java.lang.reflect.Method;

@Slf4j
public class AdvisorTest {

    @Test
    void advisorTest1() {
        // 실제 객체가 될 서비스 객체
        ServiceInterface target = new ServiceImpl();

        // ProxyFactory 객체를 생성
        ProxyFactory proxyFactory = new ProxyFactory(target);

        // DefaultPointcutAdvisor 객체를 생성.
        // 여기서 Pointcut.TRUE 는 모든 요청에 대해 내가 지금 전달한 TimeAdvice 로직을 적용하겠다는 의미다.
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());

        // ProxyFactory에 Advisor를 추가한다.
        proxyFactory.addAdvisor(advisor);

        // ProxyFactory로부터 프록시를 꺼낸다.
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();
    }
}

 

이제 Advisor, Advice, Pointcut을 만들어보자. 우선 첫번째로 해볼 것은 아무런 필터링도 하지 않는 'Pointcut'을 만드는 것이다.

그 부분이 아래 코드이다. Advisor를 만들기 위해 DefaultPointcutAdvisor 객체를 생성하는데 이 때 생성자에 두가지가 넘어간다. Pointcut과 Advice. 모든 대상에 대해 Advice를 적용하는 Pointcut.TRUE를 넘기면 위에서 말한것처럼 아무런 필터링도 하지 않는다.

DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());

 

 

두번째 파라미터는 'TimeAdvice()'를 넘겨주면 된다. 이렇게 만든 advisor를 ProxyFactory에 추가해준다.

proxyFactory.addAdvisor(advisor);

 

이제 프록시 팩토리에서 프록시를 꺼내서 프록시가 가진(실제 객체가 가진) 메서드를 호출할 수 있다. 이렇게 프록시를 만들고 프록시의 메서드를 호출해보자. 실제 객체가 가진 모든 메서드에 대해서 프록시가 주는 추가 기능(Advice)이 적용되어야 한다.

결과

결과 로그를 보면 save(), find() 모두 프록시가 제공하는 TimeProxy Start, End, ResultTime = Xms 로그가 찍히는 것을 볼 수 있다.

그러나, 이러면 사실상 Pointcut이 의미가 없기 때문에 Pointcut으로 필터링도 해보자. 딱 한번만 Pointcut을 직접 만들어 보자. 그 이후에는 스프링이 제공해주는 여러가지 Pointcut으로 편하게 사용하면 된다. 

 

 

일부 대상에 대해 허용하는 Pointcut으로 Advisor를 만들기

Pointcut을 만들어보자. Pointcut을 구현하면 되는데 두 가지 메서드가 있다. getClassFilter(), getMethodMatcher().

메서드 명만 봐도 어떤것을 하는 메서드인지 알 수 있을것 같다. 하나는 클래스로 필터링을 하는것이고 하나는 메서드로 필터링을 하는것.

    /**
     * 직접 만들일은 없음, 스프링이 만들어주는 Pointcut을 사용하면 되지만 한번 만들어보자.
     * 클래스와 메서드 둘 다 'true' 를 리턴해야만 Pointcut에 적합한 요청이라고 판단하여 Advice를 적용한다.
     * */
    static class MyPointcut implements Pointcut {
        /**
         * 클래스를 기준으로 필터링
         * ClassFilter.TRUE 를 반환하면 모든 클래스에 대해 Advice 적용을 허용
         * */
        @Override
        public ClassFilter getClassFilter() {
            return ClassFilter.TRUE;
        }

        /**
         * 메서드를 기준으로 필터링
         * MethodMatcher를 구현해야 한다.
         * */
        @Override
        public MethodMatcher getMethodMatcher() {
            return new MyMethodMatcher();
        }
    }

    static class MyMethodMatcher implements MethodMatcher {

        private String matchName = "save";

        @Override
        public boolean matches(Method method, Class<?> targetClass) {
            boolean result = method.getName().equals(matchName);

            log.info("포인트컷 호출 method = {} targetClass= {}", method.getName(), targetClass);
            log.info("포인트컷 결과 result = {}", result);

            return result;
        }

        @Override
        public boolean isRuntime() {
            return false;
        }

        @Override
        public boolean matches(Method method, Class<?> targetClass, Object... args) {
            return false;
        }
    }

 

getMethodMatcher()는 MethodMatcher 타입을 반환하는데 이는 우리가 직접 구현하면 된다. 위 MyMethodMatcher 클래스가 그 예시이다. 이 MethodMatcher를 구현하면 세가지 메서드를 구현해야한다. 

 

우선, 간단하게 예시를 작성할거니까 'save()' 메서드만 Advice를 적용시켜보자. 이름으로 비교를 하기 위해 변수로 저장한다.

private String matchName = "save";

 

그 다음, 지금 전달받은 Method의 이름이 'save'와 일치하는지 판단한다.

boolean result = method.getName().equals(matchName);

 

맞다면, true를 반환해서 Advice가 적용되게끔 작성했다. 이제 우리가 직접만든 Pointcut으로 Advisor를 만들어보자.

@Test
@DisplayName("직접 만든 포인트컷")
void advisorTest2() {
    // 실제 객체 서비스
    ServiceInterface target = new ServiceImpl();

    // ProxyFactory 객체 생성 후 실제 객체를 전달
    ProxyFactory proxyFactory = new ProxyFactory(target);

    // ProxyFactory가 만들 Advisor
    // Pointcut을 직접 만들어서 넣었고, Advice도 만들어서 넣었음.
    DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());

    // ProxyFactory에 advisor 추가
    proxyFactory.addAdvisor(advisor);

    // ProxyFactory 로부터 proxy 를 꺼내온다.
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

    // Pointcut에 의하여 Advice 적용된다.
    proxy.save();
    // Pointcut에 의하여 Advice 적용되지 않는다.
    proxy.find();
}

 

위에서 모든 대상에 대해 Advice를 적용했던 예시 코드에 비교해서 바뀌는 부분은 딱 Pointcut이 달라지는것 말고 없다. 우리가 만든 Pointcut을 전달한다. 대신 실행 결과가 달라질 것이다. 'save()'가 아닌 'find()'에는 Advice는 적용되지 않는다.

결과 로그를 보면, save calling이 찍히기 전 후에 TimeProxy Start, TimeProxy End, ResultTime = Xms가 찍히지만, find calling은 없다. Pointcut이 잘 동작하는 것을 확인할 수 있다. 이제 두번 다시 Pointcut을 직접 만들지는 않을거다. 스프링의 도움을 받자.

 

 

스프링이 제공하는 Pointcut으로 Advisor를 만들기

이제 Spring이 제공해주는 여러가지 Pointcut이 있는데 그 중 하나로 Advisor를 만들어 보자. 

@Test
@DisplayName("스프링이 제공하는 포인트컷")
void advisorTest3() {
    // 실제 객체 서비스
    ServiceInterface target = new ServiceImpl();

    // ProxyFactory 객체 생성 후 실제 객체를 전달
    ProxyFactory proxyFactory = new ProxyFactory(target);

    // 스프링이 제공하는 포인트 컷 중 하나인 NameMatchMethodPointcut
    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    // Method 명이 save인 애들에게 Advice를 적용해주는 Pointcut을 만든다.
    pointcut.setMappedName("save");

    DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());

    // ProxyFactory에 advisor 추가
    proxyFactory.addAdvisor(advisor);

    // ProxyFactory 로부터 proxy 를 꺼내온다.
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

    // Pointcut에 의하여 Advice 적용된다.
    proxy.save();
    // Pointcut에 의하여 Advice 적용되지 않는다.
    proxy.find();
}

 

스프링이 제공하는 Pointcut인 NameMatchMethodPointcut을 사용해보자. 

NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedName("save");

 

말 그대로 메서드의 이름으로 Pointcut을 설정하는 Pointcut이다. 위 코드처럼 'save'라는 값을 setMappedName()에 넘겨주면 이 Pointcut은 'save'라는 메서드명을 가진 요청에 한하여 Advice를 적용한다. 나머지는 동일하다. 결과를 보자.

save가 호출됐을 땐 TimeProxy가 동작하고 그렇지 않은 find가 호출됐을 땐 Advice가 적용되지 않았다. 이렇게 스프링이 제공해주는 Pointcut으로 편리하게 Pointcut을 만들 수 있다.

 

더 나아가서, 여러개의 Advisor를 사용할 수도 있다. 프록시가 여러개로 사용될 수 있는 것처럼 Advisor도 마찬가지로 여러개가 사용될 수 있다. 프록시 팩토리로 프록시를 만들 때 Advisor가 반드시 있어야 한다는 것은 프록시가 여러개면 Advisor도 여러개란 뜻이다. 그러나 프록시를 하나만 가지고 있고 Advisor가 여러개일수도 있다. 그리고 이게 더 일반적으로 많이 사용되는 방법이다.

 

Multi Advisor

 

이제 여러개의 Advisor를 사용해서 클라이언트 요청을 처리해보자. 우선 두 가지 경우를 다룰 것이다. 

 

1. Proxy가 2개 = Advisor가 2개

2. Proxy 1개가 가지고 있는 2개의 Advisor

 

먼저 공통으로 사용될 Advice1, 2를 먼저 살펴보자.

static class Advice1 implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("Advice1 Call");
        return invocation.proceed();
    }
}

 

static class Advice2 implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("Advice2 Call");
        return invocation.proceed();
    }
}

 

Proxy가 2개 = Advisor가 2개

@Test
@DisplayName("여러 프록시")
void multiAdvisorTest1() {
    // Client -> Proxy2(Advisor2) -> Proxy1(Advisor1) -> target

    // Proxy1 생성
    ServiceInterface target = new ServiceImpl();

    ProxyFactory proxyFactory1 = new ProxyFactory(target);
    DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
    proxyFactory1.addAdvisor(advisor);
    ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();

    // Proxy2 생성
    ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
    DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
    proxyFactory2.addAdvisor(advisor2);
    ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();

    proxy2.save();
}

 

뭐 새로운 개념은 없다 ProxyFactory를 두개 생성해서 하나는 실제 객체를 target으로 받고 하나는 target을 실제 객체로 받은 프록시를 받으면 된다. 그래서 프록시를 target으로 받은 proxy를 실행하면 끝이다. 결과를 보자.

 

차례대로 Advice2, Advice1이 실행된다. 그리고 실제 객체인 target의 로직까지 실행됐다. 그러나 이건 프록시를 계속 만들어내야 하니까 프록시 하나에 여러개의 Advisor를 생성해보자. 

 

Proxy 1개가 가지고 있는 2개의 Advisor

@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
void multiAdvisorTest2() {
    // Client -> Proxy -> Advisor2 -> Advisor1 -> target

    DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
    DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());

    // Proxy 생성
    ServiceInterface target = new ServiceImpl();

    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvisor(advisor2);
    proxyFactory.addAdvisor(advisor);

    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

    proxy.save();
}

 

Advisor2개를 먼저 생성한다. 그리고 ProxyFactory에서 addAdvisor()를 두번하면 끝난다. 그럼 위 코드상 먼저 등록된 advisor2가 먼저 실행되고 그다음 advisor1이 실행될 것이다. 결과를 보자.

 

 

결론

이제 동적 프록시를 만들 때 스프링의 도움을 받아 ProxyFactory로 간단하게 만들 수 있었다. 또한 인터페이스를 제공하느냐 구체 클래스를 제공하느냐에 따라 동적 프록시를 만들어내는 방법이 달라지는 문제 또한 스프링의 ProxyFactory가 해결해 주었다. 이렇게 스프링은 유사한 여러 구현 기술이 있을 때 추상화를 통해 편리하게 사용할 수 있게 해준다. 그리고 ProxyFactory를 통해 프록시를 만들 때 항상 Advisor가 생성되어야 한다는 사실도 알았다. Advisor는 하나의 프록시에 여러개가 들어갈 수 있고 Pointcut으로 어떤 요청에 대해서는 Advice를 적용하고 적용하지 않을지도 필터링이 가능하다는 사실도 알았다. 

 

참고로, 이후에 AOP를 배우겠지만 하나의 target에 여러 AOP를 적용을 한다고 해도 프록시는 하나만 만들어진다. 위 내용이 그 근거이다.

 

Advisor, Advice, Pointcut을 실제 프로젝트 코드에 도입하기

이제 Advisor, Advice, Pointcut이 뭔지도 배웠고 ProxyFactory를 통해서 동적 프록시를 쉽게 만들 수 있게 됐으니 실제 프로젝트 코드에 도입해보자. 두 가지 케이스를 해볼것이다.

 

  • 인터페이스로 프록시를 만드는 경우
  • 구체 클래스로 프록시를 만드는 경우

 

우선, 인터페이스건 구체 클래스건을 떠나 프록시의 추가 기능을 담당하는 Advice를 만들어야 한다. 

 

Advice 만들기

LogTraceAdvice.java

package com.example.advanced.app.proxy.config.v3_proxyfactory.advice;

import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

import java.lang.reflect.Method;

public class LogTraceAdvice implements MethodInterceptor {

    private final LogTrace logTrace;

    public LogTraceAdvice(LogTrace logTrace) {
        this.logTrace = logTrace;
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TraceStatus status = null;

        try {
            Method method = invocation.getMethod();

            // Ex) "OrderController.request()"
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";

            status = logTrace.begin(message);

            Object result = invocation.proceed();

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

Advice를 만들기 위해 MethodInterceptor를 구현하는 LogTraceAdvice 클래스를 만들었다. MethodInterceptor가 구현해야 하는 Invoke()를 기존에 계속 사용했던 LogTrace 기능으로 채워넣었다. Advice는 실제 객체를 주입받지 않아도 되기 때문에 편리함을 준다. 

 

이제 Advice를 만들었으니까 ProxyFactory를 통해서 만든 프록시를 스프링 빈으로 등록해보자.

 

인터페이스로 프록시를 만드는 경우

ProxyFactoryConfigV1.java

package com.example.advanced.app.proxy.config.v3_proxyfactory;

import com.example.advanced.app.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import com.example.advanced.app.proxy.v1.OrderRepositoryV1;
import com.example.advanced.app.proxy.v1.OrderRepositoryV1Impl;
import com.example.advanced.app.proxy.v1.OrderServiceV1;
import com.example.advanced.app.proxy.v1.OrderServiceV1Impl;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1Impl;
import com.example.advanced.trace.logtrace.LogTrace;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ProxyFactoryConfigV1 {

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1Impl orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));

        ProxyFactory proxyFactory = new ProxyFactory(orderController);
        proxyFactory.addAdvisor(getAdvisor(logTrace));

        return (OrderControllerV1) proxyFactory.getProxy();
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1Impl orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));

        ProxyFactory proxyFactory = new ProxyFactory(orderService);
        proxyFactory.addAdvisor(getAdvisor(logTrace));

        return (OrderServiceV1) proxyFactory.getProxy();
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();

        ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
        proxyFactory.addAdvisor(getAdvisor(logTrace));

        return (OrderRepositoryV1) proxyFactory.getProxy();
    }

    private Advisor getAdvisor(LogTrace logTrace) {

        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*");

        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

 

컨트롤러, 서비스, 레포지토리를 프록시로 스프링 컨테이너에 등록해야 한다. 그렇기 때문에 Configuration 파일이 필요하고 여기서 각각을 프록시로 등록하기 위해 ProxyFactory로 프록시를 만든다. ProxyFactory로 프록시를 만들려면 Advisor가 필요하기 때문에 Advisor를 만들어야 한다. Advisor는 Advice와 Pointcut이 필요하기 때문에 우리가 이 위에서 만든 Advice를 전달해야 하고 그 Advice가 적용될 Pointcut을 스프링이 제공하는 NameMatchMethodPointcut을 사용해서 만든다. 메서드 명이 'request'로 시작하는 것과 'order'로 시작하는 것과 'save'로 시작하는 것들은 이 Advice를 적용한다. 이제 Pointcut과 Advice를 Advisor에게 전달한다. 

 

 

이렇게 Config 파일 하나를 만들면 끝난다. 이게 인터페이스를 이용해서 ProxyFactory로 동적 프록시를 만드는 방법이다. 

테스트 해보자. '/v1/request'로 요청하면 LogTrace 정보가 남아야한다. '/v1/no-log'로 요청하면 LogTrace 정보가 남지 않아야 한다.

 

 

구체 클래스로 프록시를 만드는 경우

구체 클래스로 프록시를 만든다고 달라지는 건 없다. 반환 타입만 달라질 뿐이다. 왜냐하면 ProxyFactory를 사용하기 때문이다. ProxyFactory는 알아서 구체 클래스면 CGLIB로 프록시를, 인터페이스면 JDK Dynamic Proxy를 사용해서 프록시를 만들어 준다.

 

ProxyFactoryConfigV2.java

package com.example.advanced.app.proxy.config.v3_proxyfactory;

import com.example.advanced.app.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import com.example.advanced.app.proxy.v2.OrderControllerV2;
import com.example.advanced.app.proxy.v2.OrderRepositoryV2;
import com.example.advanced.app.proxy.v2.OrderServiceV2;
import com.example.advanced.trace.logtrace.LogTrace;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ProxyFactoryConfigV2 {

    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
        OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));

        ProxyFactory proxyFactory = new ProxyFactory(orderController);
        proxyFactory.addAdvisor(getAdvisor(logTrace));

        return (OrderControllerV2) proxyFactory.getProxy();
    }

    @Bean
    public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
        OrderServiceV2 orderService = new OrderServiceV2(orderRepositoryV2(logTrace));

        ProxyFactory proxyFactory = new ProxyFactory(orderService);
        proxyFactory.addAdvisor(getAdvisor(logTrace));

        return (OrderServiceV2) proxyFactory.getProxy();
    }

    @Bean
    public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
        OrderRepositoryV2 orderRepository = new OrderRepositoryV2();

        ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
        proxyFactory.addAdvisor(getAdvisor(logTrace));

        return (OrderRepositoryV2) proxyFactory.getProxy();
    }

    private Advisor getAdvisor(LogTrace logTrace) {

        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*");

        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

 

끝이다. Advice도 Pointcut도 이미 만들어 놓은거니까 가져다가 사용만 하면 된다. 아니면 이 코드처럼 복붙으로 처리해 버리기. 

'/v2/request' 로 요청하면 마찬가지로 LogTrace의 정보를 출력한다.

 

 

결론

확실히 프록시로 사용될 코드도 Advice 하나만 만들면 되고, 동적 프록시를 만들기 때문에 프록시를 일일이 만들어 줄 필요도 없으며 구체클래스냐 인터페이스냐에 따라 나뉘어지는 동적 프록시 생성 방법을 스프링의 도움을 받아 고민하지 않게됐다. 훨씬 개선되었지만 여전히 불편함은 남아있다. 어떤 게 불편하냐면 일단 프록시로 만들기 원하는 것들은 전부 이렇게 빈으로 등록해야 한다는 것이다. 만약 100개면 100개의 빈을 이렇게 일일이 등록해야 한다. 또 한가지는 컴포넌트 스캔 대상은 프록시로 만들수가 없다는 사실이다. 왜냐하면 스프링이 이미 스프링 컨테이너에 컴포넌트 스캔 대상 클래스를 등록해버렸기 때문에. 이도 역시 더 좋은 코드로 개선될 수 있지 않을까? '빈 후처리기'이다.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

AOP와 @Aspect, @Around  (0) 2023.12.29
빈 후처리기(BeanPostProcessor)  (2) 2023.12.27
스프링이 지원하는 ProxyFactory  (0) 2023.12.14
Proxy/Decorator Pattern 2 (동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
728x90
반응형
SMALL

https://cwchoiit.tistory.com/79

 

Proxy/Decorator Pattern 2 (동적 프록시)

https://cwchoiit.tistory.com/78 Proxy/Decorator Pattern 이제 스프링에서 굉장히 자주 사용되는 프록시와 데코레이터 패턴을 정리해 보자. 이 프록시 패턴을 이해하니 스프링이 어떻게 내가 만들어서 컴포넌

cwchoiit.tistory.com

https://cwchoiit.tistory.com/78

 

Proxy/Decorator Pattern

이제 스프링에서 굉장히 자주 사용되는 프록시와 데코레이터 패턴을 정리해 보자. 이 프록시 패턴을 이해하니 스프링이 어떻게 내가 만들어서 컴포넌트 스캔 대상에 넣은 클래스를 프록시로 주

cwchoiit.tistory.com

 

SMALL

위 두개의 포스팅에서 프록시 패턴을 자세히 배워봤다. 최초에는 프록시를 직접 만들어서 하나하나 빈으로 등록하여 사용했고 이후에는 이 프록시를 하나하나 만들어내는 것이 비효율적이라 동적 프록시를 사용해서 한 개의 프록시로 여러 클래스에 프록시를 입힐 수 있게 됐다. 그러나 아직 동적 프록시를 사용할 때 문제가 되는 부분을 해결하지 못했다.

 

동적 프록시를 사용할 때 JDK 동적 프록시는 인터페이스만을 취급하고 CGLIB는 구체 클래스만을 취급한다는 것. 그래서 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용해야 하는데 어떻게 해야할까?

 

스프링이 제공하는 'ProxyFactory'를 사용하면 된다.

 

ProxyFactory

프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용한다. 

말보단 코드를 작성해보자. 우선은 Advice를 만들어야 한다.

Advice는 프록시가 제공해주는 부가 기능을 정의한 로직이라고 생각하면 된다. 예를 들어, 실제 객체의 메서드 A()를 호출한다고 하면 그 A()메서드에 추가적으로 부가할 기능과 실제 A()메서드 호출 로직이 하나의 Advice라고 생각하면 될 것 같다.

TimeAdvice.java

package com.example.advanced.common.advice;

import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

@Slf4j
public class TimeAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("TimeProxy Start");

        long startTime = System.currentTimeMillis();

        Object result = invocation.proceed();

        long endTime = System.currentTimeMillis();

        log.info("TimeProxy End. ResultTime = {}ms", endTime - startTime);
        return result;
    }
}

 

Advice라고 했는데 CGLIB를 만들어낼 때 구현하는 MethodInterceptor를 구현했다. 왜 그럴까? MethodInterceptor가 상속받는 Interceptor가 있는데 이 Interceptor가 상속받는 인터페이스가 Advice이기 때문이다.

 

그리고 invoke()에서 invocation.proceed()를 호출하는데 이게 실제 객체의 메서드를 호출하는 부분이라고 보면 된다. 원래 기존에 프록시는 항상 실제 객체를 주입받아야 한다고 했는데 여기에는 그런 코드가 없다. 이는 ProxyFactory를 만들 때 전달해주기 때문이다. 말보단 코드. ProxyFactory를 만들어내는 코드를 보자. 

 

ProxyFactoryTest.java

package com.example.advanced.proxyfactory;

import com.example.advanced.common.advice.TimeAdvice;
import com.example.advanced.common.service.ServiceImpl;
import com.example.advanced.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;

import static org.assertj.core.api.Assertions.*;

@Slf4j
public class ProxyFactoryTest {

    @Test
    @DisplayName("인터페이스가 있으면 JDK 동적 프록시를 사용")
    void interfaceFactory() {
        // 프록시로 만들어 낼 실제 객체
        ServiceInterface target = new ServiceImpl();

        // ProxyFactory를 사용해서 동적 프록시를 만든다.
        ProxyFactory proxyFactory = new ProxyFactory(target);

        // ProxyFactory를 이용하기 위해선 스프링에서 제공하는 Advice 인터페이스 구현한 클래스가 필요하다.
        // 그 Advice를 상속받는 Interceptor를 상속받는 MethodInterceptor를 구현한 클래스를 만들었다. (TimeAdvice)
        proxyFactory.addAdvice(new TimeAdvice());

        // proxyFactory에서 proxy를 꺼내온다. 이 proxy는 인터페이스를 제공했으면 JDK Dynamic Proxy로 만들어지고
        // 구체 클래스를 제공했으면 CGLIB 프록시로 만들어진다.
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        log.info("targetClass = {}", target.getClass());
        log.info("proxyClass = {}", proxy.getClass());

        // 만든 프록시로 실제 객체(ServiceImpl)의 메서드를 실행
        proxy.save();

        // AopUtils.isAopProxy()는 스프링에서 제공해주는 기능인데 Proxy 인지를 알려준다.
        // ProxyFactory를 사용해서 만든 Proxy여야만 사용할 수 있다.
        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
    }
}

 

프록시로 만들어 낼 실제 객체를 생성한다. 그 부분이 위에서 target을 만드는 코드이다.

그리고 ProxyFactory 객체를 만들어낸다. 이 때 target이 넘어간다.

이 ProxyFactory 객체에서 addAdvice()로 위에서 만든 TimeAdvice()를 전달한다.

 

이렇게 ProxyFactory를 만들기 때문에 Advice 클래스에서는 실제 객체를 주입받을 필요가 없는 것.

그리고 getProxy()를 통해 프록시를 받아온다. 이러면 끝난다.

 

확인을 위해 ApoUtils를 이용한다. 이 isAopProxy는 ProxyFactory를 통해 만들어진 프록시인지를 확인한다.

isJdkDynamicProxy()는 JDK 동적 프록시로 만들어졌는지를 확인하고, isCglibProxy()는 CGLIB로 만든 동적 프록시인지 확인한다.

 

우리는 인터페이스를 넘겨줬기 때문에 JDK 동적 프록시로 만들어졌음을 확인할 수 있다.

 

 

이제 인터페이스 말고 구체 클래스를 넘겨줘보자.

@Test
@DisplayName("구체 클래스만 있으면 CGLIB 사용")
void concreteFactory() {
    ConcreteService target = new ConcreteService();

    ProxyFactory proxyFactory = new ProxyFactory(target);

    proxyFactory.addAdvice(new TimeAdvice());

    ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();

    log.info("targetClass = {}", target.getClass());
    log.info("proxyClass = {}", proxy.getClass());

    proxy.call();

    assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}

 

ConcreteService는 구체 클래스다. 이 객체를 'target'으로 넘기면 ProxyFactory는 CGLIB로 동적 프록시를 만든다.

나머지 내용은 위 인터페이스를 넘긴것과 동일하다.

 

 

ProxyFactory한테 인터페이스를 넘겨도 CGLIB로 만들어달라고 할 수 있다.

ProxyFactory가 가지고 있는 setProxyTargetClass() 메서드를 사용하면 된다. 이 메서드는 이름 그대로 Proxy를 Target의 클래스로 설정하겠다는 의미다. 파라미터로 'true'를 넘기면 된다.

@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB 를 사용하고 클래스 기반 프록시 사용")
void proxyTargetClass() {
    ServiceInterface target = new ServiceImpl();

    ProxyFactory proxyFactory = new ProxyFactory(target);

    // Proxy를 만드는데 Target의 클래스를 기반으로 (즉, 구체 클래스로) 만들것인지에 대한 옵션. 중요하다⭐️
    proxyFactory.setProxyTargetClass(true);

    proxyFactory.addAdvice(new TimeAdvice());

    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

    log.info("targetClass = {}", target.getClass());
    log.info("proxyClass = {}", proxy.getClass());

    proxy.save();

    assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}

 

궁금한 점

근데, JDK 동적 프록시는 InvocationHandler 라는 인터페이스를 상속받은 클래스를 직접 구현해서 공통 로직과 실제 객체 호출을 하는 메서드를 만들고, CGLIB는 MethodInterceptor라는 인터페이스를 구현해서 공통 로직과 실제 객체 호출을 하는 메서드를 만드는데 어떻게 이 ProxyFactory는 그냥 Advice라는것 하나만 만들면 이 두개를 분개할까? ProxyFactory는 내부적으로 만들어진 프록시가 JDK 동적 프록시라면 InvocationHandler가 Advice를 호출하도록 개발하고 CGLIB 프록시라면 MethodInterceptor가 Advice를 호출하도록 개발해두었다. 그래서 상관없이 결국 Advice만 만들면 되는것이다.

 

결론

ProxyFactory와 Advice로 이젠 동적 프록시를 만들 때 JDK 동적 프록시를 사용해야하나 CGLIB를 사용해야하나 번거로움을 해소할 수 있었다. 그런데 Advice는 갑자기 어디서 튀어나온 개념일까? 다음 포스팅에서 Advice, Advisor, Pointcut 개념을 정리하고자 한다.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

빈 후처리기(BeanPostProcessor)  (2) 2023.12.27
Advisor, Advice, Pointcut  (0) 2023.12.15
Proxy/Decorator Pattern 2 (동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Strategy Pattern  (2) 2023.12.12

+ Recent posts