[GGB] Java Reflection 성능 비교해보기

Kim Hyen Su·2024년 9월 24일

GGB

목록 보기
13/13

개요

Member Business Layer 테스트 코드 작성 중 Member 엔티티의 id 값을 수정하여 동일성 여부를 확인해봐야 할 상황이 발생했다.

현재 Member 엔티티 내부에 Builder는 생성자 레벨에 정의되어 있으며, id 수정을 위해서는 setter를 사용해줘야만 했다.

하지만, Member 엔티티에 setter를 정의하게 되면, 캡슐화가 깨지게 될 확률이 높아져 컨벤션으로 로직 내에 setter 사용을 지양하고 있다.

해당 상황에 대해서 팀원들과 논의하던 중 Reflection 이라는 개념에 대해서 알게 되었다. Reflection은 런타임 시점에 클래스 정보를 확인하고 조작할 수 있는 기능을 말한다.

즉, Reflection을 활용하여 테스트 실행 중에 Member 엔티티 객체의 id 필드의 값을 설정해줌으로써, 테스트를 적용해볼 수 있다.

Reflection 적용

Spring Boot 테스트 코드 내 Reflection을 활용하기 위해서는 org.springframework.test.util.ReflectionTestUtils 라는 Springframework에서 지원해주는 라이브러리를 활용할 수 있다.

다음은 ReflectionTestUtils를 활용한 코드이다.

  @Test
  void findById_성공() {
    Long id = 1L;
    Member mockMember = Member.builder().build();
    ReflectionTestUtils.setField(mockMember, "id", id);
    when(memberRepository.findById(any(Long.class))).thenReturn(Optional.of(mockMember));

    Member member = memberReader.findById(id);

    assertNotNull(member);
    assertEquals(mockMember.getId(), member.getId());
    verify(memberRepository, times(1)).findById(any(Long.class));
  }

위 코드에 대해서 간단하게 설명하면, Member id로 회원을 조회하는 로직을 검증하기 위한 테스트 코드이다.

Member 객체를 생성한 뒤 ReflectionTestUtils의 static 메서드인 setField()를 호출하여 id를 1L로 설정해준다.

테스트 결과, 정상적으로 id 값을 1L로 가져오는 것을 확인할 수 있다.

output :

TestMember 사용

하지만, 고려해야 할 내용은 Reflection을 사용하게 될 경우, 성능 저하 문제가 동반된다는 점이다. 검증 과정은 최대한 신속하게 처리되어야 좋은 테스트 코드라고 생각한다. 하지만, 실제 테스트 로직을 수행하기 전 준비 단계에서 지연이 발생하는 것은 더욱이 안된다라는 생각이 들었다.

이를 해결할 수 있는 방법에 대해서 생각하던 중, 테스트 목적의 TestMember를 정의하여 Mebmer를 상속한 뒤 TestMember 내부에만 Setter를 정의하면 어떨까라는 생각에서 다음과 같은 방법을 고안했다.

  1. TestMember 정의

    
    @Getter
    @Setter
    public class TestMember extends Member {}
  2. findById 검증 코드에 적용

    
      @Test
      void findById_성공() {
        Long id = 1L;
        TestMember mockMember = new TestMember();
        mockMember.setId(id);
        when(memberRepository.findById(any(Long.class))).thenReturn(Optional.of(mockMember));
    
        Member member = memberReader.findById(id);
    
        assertNotNull(member);
        assertEquals(mockMember.getId(), member.getId());
        verify(memberRepository, times(1)).findById(any(Long.class));
    }

테스트 결과 동일하게 정상적으로 검증이 성공한 것을 확인할 수 있다.

output :

성능 비교

다음으로 실제로 Reflection을 사용할 경우, 성능이 차이가 많이 날까라는 생각에서 두 개의 기능의 성능을 간단하게 테스트 해보려고 한다.

테스트 코드는 다음과 같다.

public class ReflectionTestUtilsTest {

  private static final int TOTAL_RUN = 100_000_000;

  @Test
  void test_reflection() {
    long startTime = System.currentTimeMillis();
    Member member = Member.builder().build();
    for (int i = 0; i < TOTAL_RUN; i++) {
      ReflectionTestUtils.setField(member, "id", 1L);
    }
    long endTime = System.currentTimeMillis();
    System.out.println(
        "test_reflection ::: id = " + member.getId() + ", time = " + (endTime - startTime));
  }

  @Test
  void test_setId() {
    long startTime = System.currentTimeMillis();
    TestMember member = new TestMember();
    for (int i = 0; i < TOTAL_RUN; i++) {
      member.setId(1L);
    }
    long endTime = System.currentTimeMillis();
    System.out.println(
        "test_reflection ::: id = " + member.getId() + ", time = " + (endTime - startTime));
  }
}

output :

대략 1억회 정도 테스트 해봤을 때, Reflection이 상대적으로 오래걸리는 것을 확인할 수 있다.

그렇다면, 이는 왜 이렇게 차이가 나는걸까? 왜 Reflection을 사용하면 성능이 느려지는 걸까?

Reflection 성능 오버헤드 발생 원인

관련 내용들에 대해서는 Chat GPT에게 물어봤다.

자바 리플렉션을 사용하면 성능이 느려지는 이유는 여러 가지가 있습니다. 

## 동적 해석의 오버헤드

리플렉션은 런타임에 동적으로 클래스, 메서드, 필드 등의 정보를 분석합니다.
이 과정에서 다음과 같은 오버헤드가 발생합니다:

1. 메타데이터 검색 : 클래스의 메타데이터를 검색하고 파싱하는 과정이 필요합니다.
2. 타입 확인 : 메서드 호출이나 필드 접근 시 타입 안전성을 보장하기 위한 추가적인 검사가 필요합니다.

## JIT 컴파일러 최적화 제한

Just-In-Time (JIT) 컴파일러는 자주 실행되는 코드를 최적화하여 성능을 향상시킵니다. 
하지만 리플렉션을 사용하면:

1. 인라이닝 불가 : 동적으로 결정되는 메서드 호출은 인라이닝될 수 없습니다.
2. 최적화 기회 감소 : 컴파일 시점에 알 수 없는 정보로 인해 다양한 최적화 기법을 적용하기 어렵습니다.

## 접근 제어 검사

리플렉션을 통한 메서드 호출이나 필드 접근 시, 매번 접근 권한을 검사해야 합니다:

1. 보안 검사 : private, protected 멤버에 접근할 때 추가적인 보안 검사가 필요합니다.
2. setAccessible(true) : 이 메서드를 사용하여 접근 제어를 우회할 수 있지만, 이 역시 오버헤드를
발생시킵니다.

## 박싱/언박싱 오버헤드

리플렉션 API는 대부분 Object 타입을 사용하므로:

1. 기본 타입 변환 : int, long 등의 기본 타입을 사용할 때 박싱/언박싱 과정이 필요합니다.
2. 타입 캐스팅 : 반환값을 적절한 타입으로 캐스팅하는 과정이 필요합니다.

## 캐시 미스

일반적인 메서드 호출은 JVM에 의해 최적화되고 캐시될 수 있지만, 리플렉션을 통한 호출은:

1. 캐시 효율 저하 : 동적 특성으로 인해 CPU 캐시 효율이 떨어집니다.
2. 메서드 테이블 룩업 : 가상 메서드 테이블을 통한 빠른 룩업이 불가능합니다.


결론적으로, 리플렉션은 강력한 기능을 제공하지만 성능 오버헤드가 있습니다.
따라서 꼭 필요한 경우에만 신중하게 사용하고, 가능한 경우 대안을 고려해야 합니다.

Citations:
[1] https://blog.frankel.ch/performance-cost-of-reflection/
[2] https://blogs.oracle.com/javamagazine/post/java-reflection-performance
[3] https://www.jmix.io/cuba-blog/think-twice-before-using-reflection/
[4] https://stackoverflow.com/questions/435553/java-reflection-performance
[5] https://dev.to/fromfullstack/java-performance-optimization-mastering-techniques-to-boost-your-applications-85j
[6] https://hudi.blog/java-reflection/

여러 내용들이 있지만, 지금 내가 작성한 테스트 코드에서 성능저하의 원인이 되는 부분은 다음과 같을 것이다.

  • 동적 해석의 오버헤드
  • 접근 제어 검사

이와 같은 이유들로 현재 테스트 코드 내에서 Reflection을 사용 시 성능 차이가 발생할 수 있다.

따라서, 성능 저하를 염려하여 ReflectionTestUtils 대신에 TestMember를 사용하는 방식으로 테스트 코드를 구현했다.

profile
백엔드 서버 엔지니어

0개의 댓글