[Java] 어노테이션, 리플랙션(reflection)에 대해서 알아보자. - Spring, JPA에도 적용된다 😮

devdo·2022년 1월 17일

Java

목록 보기
33/63
post-thumbnail

자바 리플랙션에 대해서 알아보자.

리플렉션 (Reflection)이란?

  • 힙 영역에 로드돼 있는 클래스 타입의 객체를 통해 필드/메소드/생성자를 접근 제어자와 상관 없이 사용할 수 있도록 지원하는 API
  • 컴파일 시점이 아닌 런타임 시점에 동적으로 특정 클래스의 정보를 추출해낼 수 있는 프로그래밍 기법
  • 주로 프레임워크 또는 라이브러리 개발 시 사용됨
  • ex. Spring 프레임워크 (ex. DI), Test 프레임워크 (ex. JUnit), JSON Serialization/Deserialization 라이브러리 (ex. Jackson)
    참고) https://www.baeldung.com/reflections-library

정리하면,

리플렉션은 구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드와 타입 그리고 변수들을 접근할 수 있도록 해주는 자바 API 이다.

클래스로더를 통해 읽어온 클래스 정보(거울에 반사된 정보)를 사용하는 기술

리플랙션을 사용해 클래스를 읽어오거나, 인스턴스를 만들거나, 메서드를 실행하거나, 필드의 값을 가져오거나 변경하는 것이 가능하다는 것이다.

위 설명에 의하면 구체적인 클래스 타입을 모를때 사용하는 방법을 리플렉션이라는 것이다.

특정 어노테이션이 붙어있는 필드 또는 메소드 읽어올 때(JUnit, Spring)
특정 이름 패턴에 해당하는 메서드 목록 가져와 호출할 때(getter, setter)

여기서 의문점이 든다.

내가 짠 코든데 내가 만든 클래스의 이름도 모르는게 말이 되나?

하지만 가끔 어떤 타입의 클래스나 변수 혹은 메소드를 사용할지 모르는 경우가 생길 수 있다.

예를 들어 변수의 값을 조건에 따라서 다르게 사용해야하는 경우라던가.

어플리케이션이 실행되고 나서 생성되는 클래스라던가.

이럴경우에 리플렉션을 사용할 수 있는 것이다.


사용방법

사용방법은 간단하다.

Class 객체의 forName() 메소드를 사용하여 클래스이름에 해당하는 클래스를 찾아온다.

하지만 없으면, ClassNotFound 에러가 날 수 있다.

Class c = Class.forName("클래스이름");

// 메소드
Method[] m = c.getMethods();                     

// 필드
Field[] f = c.getFields();

// 구조체
Constructor[] cs = c.getConstructors();
Class[] inter = c.getInterfaces();
Class superClass = c.getSuperclass();

getClass() vs forName()

여기서 클래스 Class 객체를 얻는데 getClass() 메서드와도 헷갈릴 수 있는데,

getClass()는 Object의 메서드로써 해당클래스가 객체를 생성했을 때만 사용가능한 것이다!

forName()은 객체를 생성하기 전에 직접 클래스를 얻을 수 있는 것이 차이이다!


jdk 프록시, CGLib에도 적용돼

네, JDK 동적 프록시와 CGLib은 모두 런타임에 리플렉션을 사용하여 프록시 객체를 생성합니다.

jdk 동적 프록시

인터페이스를 기반으로 동작, 인터페이스의 구현 클래스를 자동으로 생성하여 프록시 객체를 생성, 해당 프록시에서 메서드 호출 등 런타임시 실제 객체의 메서드를 호출해서 확인할 수 있다.

jdk 동적 프록시는 아래 클래스를 사용하여 프록시를 생성한다.
java.lang.reflect.Proxy

참고로, 활용 예시는,

  • 테스트 코드에서 가짜 객체(Mock)를 만들 때
  • JPA Repository 인터페이스만 선언해도 DB 쿼리가 나가게 할때

CGLib

클래스를 기반으로 동작, 런타임에 클래스의 서브클래스를 생성하여 프록시 객체를 생성, CGLib은 바이트 코드 생성 라이브러리를 사용하여 클래스의 서브 클래스를 만들고, 해당 서브 클래스에서 메서드 호출을 가로채서 실제 객체의 메서드를 호출하거나 필요한 작업을 수행한다.

CGLIB의 동적으로 생성하는 클래스 이름은 다음과 같은 규칙으로 생성한다.
대상클래스$$EnhancerByCGLIB$$임의코드

참고) https://velog.io/@mooh2jj/Spring-Proxy

스프링 프레임워크는 JDK 동적 프록시(JDK Dynamic Proxy)와 CGLIB 두 가지 기술을 모두 사용하며, 상황에 따라 둘 중 하나를 선택하여 적용합니다. 대체로 CGLIB를 기본으로 사용한다.

특히 Spring Boot 2.x 이후부터 현재(Spring Boot 3.x 이상)까지는 특별한 설정이 없다면 기본적으로 CGLIB를 우선적으로 사용하도록 전략이 바뀌었다.

1. 두 프록시 기술의 핵심 차이점

스프링이 리플렉션과 바이트코드 조작을 통해 프록시(가짜 객체)를 만드는 방식은 대상 클래스의 구조에 따라 다르다.

① JDK 동적 프록시 (자바 순정 기술)

  • 적용 대상: 구체 클래스가 인터페이스를 구현하고 있을 때 사용 가능합니다.
  • 동작 원리: 자바 리플렉션의 java.lang.reflect.Proxy 기술을 사용하여, 인터페이스를 구현하는 새로운 프록시 객체를 런타임에 동적으로 생성합니다.
  • 한계: 인터페이스가 없는 순수 구체 클래스(Concrete Class)에는 적용할 수 없습니다.

② CGLIB (Code Generation Library)

  • 적용 대상: 인터페이스 유무와 상관없이 구체 클래스 자체를 상속받아 프록시를 만듭니다.
  • 동작 원리: 클래스의 바이트코드를 조작하여 대상 클래스를 상속받는 하위 클래스를 런타임에 동적으로 생성합니다. (과거엔 외부 라이브러리였으나 현재는 스프링 코어에 내장됨)
  • 한계: 클래스나 메서드에 final 키워드가 붙어있으면 상속 및 오버라이딩이 불가능하므로 프록시를 생성할 수 없습니다.

2. 스프링 부트(Spring Boot)의 기본 선택 전략 변화

과거와 현재의 스프링 부트 정책을 보면 기술의 발전 흐름을 볼 수 있습니다.

  • 과거 (Spring Boot 1.x 이하 또는 순수 Spring):
  • 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 인터페이스가 없는 경우에만 CGLIB를 사용했습니다.
  • 현재 (Spring Boot 2.x ~ 3.x):
  • 인터페이스 유무와 상관없이 기본적으로 CGLIB를 사용하여 프록시를 생성합니다. (spring.aop.proxy-target-class=true가 기본값)

✅ 왜 CGLIB를 기본값으로 바꿨을까?

과거 CGLIB는 다음과 같은 세 가지 치명적인 문제가 있었습니다.

  1. net.sf.cglib 인터페이스를 위한 별도의 의존성 필요
  2. 대상 클래스의 기본 생성자(Default Constructor)를 반드시 호출해야 하는 문제
  3. 프록시 생성 시 대상 클래스의 메서드가 2번 호출되는 문제

하지만 스프링 4.0 이후 CGLIB를 스프링 코어 패키지(org.springframework.cglib)에 내장시켰고, Objenesis라는 라이브러리를 도입하여 생성자 호출 없이 객체를 런타임에 생성하는 기술을 적용하면서 위의 단점들을 완벽하게 극복했습니다.

또한, 런타임에 매번 리플렉션 검사를 수행하는 JDK 동적 프록시보다, 바이트코드를 직접 생성하여 타겟 메서드를 직접 호출하는 CGLIB 방식이 성능적으로도 더 우수하기 때문에 현재는 CGLIB가 대세가 되었다.

💡 요약

  • 스프링은 JDK 동적 프록시CGLIB 둘 다 품고 있습니다.
  • 모던 스프링 부트 환경에서는 리플렉션 오버헤드가 적고 상속 기반으로 안정적인 CGLIB를 디폴트(기본값)로 사용합니다.
  • 단, 클래스에 final이 붙어 상속이 불가능하거나 개발자가 설정을 강제(proxy-target-class=false)한 경우에는 JDK 동적 프록시로 전환되어 인터페이스 기반으로 동작합니다.

스프링에도 사용돼

Reflection을 사용하는 기술을 나열하자면, jdk 동적 프록시, CGLib 프록시, 우리가 잘 아는 스프링 프레임워크, 대표적 ORM 기술인 하이버네이트, jackson라이브러리 등에 사용된다.

Reflection을 사용해서 스프링에서는 런타임 시에 개발자가 등록한 빈을 애플리케이션에서 가져와 사용할 수 있게 되는 것이다.
(일종의 reflection 기술이었음!)

자세한 내용은 이 블로그에서 잘 설명해 놓았다.
https://medium.com/msolo021015/%EC%9E%90%EB%B0%94-reflection%EC%9D%B4%EB%9E%80-ee71caf7eec5

예시를 들자면,

1) 컴포넌트 스캔(Component Scan)

  • 스프링에서는 @Component 어노테이션을 사용하여 클래스를 컴포넌트(Component)로 등록합니다.
  • 컴포넌트 스캔은 스프링이 애플리케이션을 시작할 때 지정된 패키지에서
  • @Component 어노테이션이 붙은 클래스를 찾아서 객체를 생성합니다.
  • 이때 스프링은 리플렉션을 사용하여 클래스의 정보를 조회하고 객체를 생성합니다.

2) 의존성 주입(Dependency Injection)

  • 스프링에서는 의존성 주입을 사용하여 객체 간의 의존 관계를 느슨하게 결합합니다.
  • 의존성 주입을 사용하면 객체 간의 의존 관계를 설정하기 위해 직접 코드를 작성할 필요가 없습니다.
  • 스프링은 리플렉션을 사용하여 의존 관계가 필요한 클래스의 생성자를 호출하고 필요한 객체를 주입합니다.

3) AOP(Aspect Oriented Programming)

  • 스프링에서는 AOP를 사용하여 애플리케이션의 여러 부분에 공통으로 적용되는 기능을 모듈화합니다.
  • AOP는 프록시 객체를 생성하여 핵심 로직을 실행하기 전과 후에 공통으로 수행할 코드를 추가합니다.
  • 이때 스프링은 리플렉션을 사용하여 프록시 객체를 생성하고 필요한 메소드를 호출합니다.

코드예시

// 스프링 리플렉션 사용 코드
public static Field findField(Class<?> clazz, String name, Class<?> type) {
	// Assert.notNull(clazz, "Class must not be null");
	// Assert.isTrue(name != null || type != null, "Either name or type of the field must be specified");
	
    Class<?> searchType = clazz;
	while (!Object.class.equals(searchType) && searchType != null) {
		Field[] fields = searchType.getDeclaredFields();
		for (Field field : fields) {
			if ((name == null || name.equals(field.getName())) && (type == null || type.equals(field.getType()))) {
				return field;
			}
		}
		searchType = searchType.getSuperclass();
	}
	return null;
}

@GetMapping 으로 스프링 리플렉션 기술을 확인해보자

⭐ 결론부터 말씀드리면, 어노테이션(@GetMapping 등) 내부에는 실행할 수 있는 "실제 비즈니스 로직 코드"가 단 한 줄도 들어있지 않습니다!

어노테이션은 그저 "힌트가 적혀 있는 이름표(메타데이터)"일 뿐입니다.

그렇다면 어떻게 @GetMapping("/user")을 붙였다고 브라우저 요청이 내 메서드로 연결되는지, 그 비밀을 리플렉션과 스프링 내부 메커니즘을 통해 풀어드리겠습니다.


@GetMapping 예를 들어보자.

실제 자바 소스코드에서 @GetMapping을 열어보면 딱 이렇게 생겼습니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface GetMapping {

    @AliasFor(annotation = RequestMapping.class)
    String[] value() default {}; // 여기에 우리가 적는 주소(예: "/user")가 저장됨

    @AliasFor(annotation = RequestMapping.class)
    String[] path() default {};
    
    // ... 몇 가지 설정 파라미터들
}

보시다시피 인터페이스(Interface) 형태(@interface)로 되어 있고, 내부에는 value() 같은 설정 값을 담는 변수(메서드 형태의 속성)만 존재할 뿐, 어떠한 실행 코드도 없습니다. 즉, @GetMapping("/user")를 메서드 위에 붙이는 것은 자바 파일에 다음과 같이 메모를 남겨두는 것과 같습니다.

"이 메서드는 /user라는 URL의 GET 요청과 관련된 녀석이야!"


2. 코드가 없다면, 주소 매핑은 누가 실행할까? (스프링의 비밀)

어노테이션은 스스로 움직이지 못합니다. 이 어노테이션을 런타임에 읽어서 실제 '동작(코드)'으로 전환해 주는 관찰자(Processor)가 프레임워크 내부에 존재합니다. 스프링에서는 이를 RequestMappingHandlerMapping이라는 클래스가 담당합니다.

스프링 애플리케이션이 처음 켜질 때(구동 시점), 스프링은 다음과 같은 대대적인 리플렉션 대탐사를 시작합니다.

스프링의 구동 시점 (Application Startup)

  1. 클래스 전체 스캔: 스프링은 컴포넌트 스캔을 통해 @Controller@RestController가 붙은 모든 자바 클래스를 리플렉션 기술로 파고듭니다.
  2. 메서드 뒤지기 (리플렉션): 클래스 내부의 모든 메서드를 하나씩 순회하면서 method.getAnnotation(GetMapping.class) 코드를 호출합니다.
  3. 정보 추출 및 지도(Map) 작성: * 만약 @GetMapping 어노테이션이 발견되면, 리플렉션을 통해 어노테이션에 적힌 주소 값(예: "/user")을 읽어옵니다.
  • 그리고 스프링 내부의 거대한 지도(HashMap)에 이 정보를 기록합니다.
  • Key: GET /user
  • Value: UserController 객체 + 해당 메서드 정보(Method 객체)

이 과정이 끝나면 스프링 내부에 하나의 거대한 라우팅 테이블이 완성됩니다.


3. 실제 요청이 들어오는 순간의 동작 메커니즘

애플리케이션 구동이 완료된 후, 실제 사용자가 브라우저에 GET /user를 요청하면 다음과 같이 동작합니다.

  1. 요청 접수: 톰캣을 거쳐 스프링의 앞단 문지기인 DispatcherServlet이 요청을 받습니다.
  2. 지도 검색: 앞서 리플렉션으로 만들어 둔 지도에서 GET /user라는 Key를 찾습니다.
  3. 리플렉션으로 메서드 실행 (Invoke): 지도에서 매칭되는 UserController 인스턴스와 Method 객체를 꺼내어, 최종적으로 리플렉션 코드를 실행합니다.
// 스프링 내부에서 대략 이런 식으로 동적 호출이 일어납니다.
targetMethod.invoke(userControllerInstance, ...파라미터들);

💡 요약

  • 어노테이션(@GetMapping)은 실행 코드를 가지지 않는 순수한 메모지(메타데이터)입니다.
  • 스프링 프레임워크가 구동될 때, 리플렉션(Reflection)이라는 돋보기를 들고 이 메모지를 싹 훑어서 {"GET /user" : 해당 메서드} 형태의 지도를 만듭니다.
  • 실제 요청이 오면 그 지도를 기반으로 리플렉션을 통해 우리가 작성한 컨트롤러 코드를 동적으로 호출해 주는 것입니다.

결국 어노테이션은 데이터를 제공하고, 실제 일(연산/코드 실행)은 스프링 프레임워크 엔진이 다 하는 구조인 셈이죠.


JPA 엔티티 생성시 기본 생성자가 필요한 이유!

✅ JPA Entity 생성시, 리플렉션을 활용하여 생성한다. 이때 기본 생성자가 필요하다. 리플렉션은 생성자의 매개변수를 읽을 수가 없어서
반드시 기본 생성자를 정의해 줘야 하기 때문이다!

그 이유는 다음과 같다.
JPA 내 엔티티는 hibernate가 내부적으로 JPA 엔티티를 만들때 Class.newInstance()라는 리플렉션을 이용해 해당 "Entity의 기본 생성자"를 호출해서 객체를 생성한다.

리플렉션은 생성자의 매개변수를 읽을 수가 없어서 반드시 기본 생성자를 정의해 줘야 한다.
java Reflection이 가져올 수 없는 정보 중 하나가 바로 생성자의 매개변수 정보이다.

따라서 기본 생성자 없이 파라미터가 있는 생성자만 존재한다면
java Reflection이 객체를 생성할 수 없게 되는 것이다.

다음 블로그 - https://velog.io/@mooh2jj/자바-어노테이션커스텀-어노테이션



참고

https://kingname.tistory.com/164

profile
자바 스프링 백엔드 개발자입니다. 배운 것을 기록합니다.

0개의 댓글