과정을 즐기자

스프링 DI의 원리, JPA Entity의 기본 생성자가 필요한 이유 (feat. Java Reflection) 본문

Java

스프링 DI의 원리, JPA Entity의 기본 생성자가 필요한 이유 (feat. Java Reflection)

320Hwany 2023. 9. 19. 18:02

스프링 프레임워크를 이용해서 개발을 하다보면 DI라는 말을 자주 듣게 됩니다. 

제어의 역전인 IoC는 제어의 권한을 제 3자에게 넘겼다는 말입니다.

DI는 IoC의 일종으로 의존관계 주입을 개발자가 직접하는 것이 아니라 프레임워크에게 넘겼다는 말입니다.

이러한 사실을 알고 사용하고 있었지만 스프링이 어떻게 의존 관계 주입을 해주는 지 그 내부 동작 방식을 알아보고 싶었습니다.

 

또한 JPA를 사용하다보면 기본 생성자를 필수로 생성해야 한다는 것도 알고 있었지만 어떤 원리로 동작하는지는

정확히 알지 못했습니다. 이 2가지의 공통점이 있는데 바로 Java Reflection을 사용한다는 사실입니다.

이번 글에서는 Java Reflection에 대해 알아보고 지금까지 궁금증을 가지고 있었던 내부 동작 방식에 대해 알아보겠습니다.

Java Relection 이란

Java Reflection이란 구체적인 클래스 타입을 알지 못하더라도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는

자바 API입니다. 컴파일 시점이 아닌 실행 시간에 동적으로 특정 클래스의 정보를 추출할 수 있는 프로그래밍 기법입니다.

 

개발자가 만든 .java 파일은 자바 컴파일러에 의해 바이트 코드인 .class 파일로 바뀌고 클래스 로더에 의해

JVM 메모리로 올라갑니다.Java Reflection은 JVM 메모리에 저장된 클래스의 정보를 꺼내와서 생성자, 필드, 메소드와 같은

필요한 정보를 사용합니다.

 

즉 Reflection을 사용하여 객체 생성, 필드 정보 가져오기, 필드 수정, 메소드 정보 가져오기, 메소드 호출 등을 할 수 있습니다.

각각의 예시를 살펴보겠습니다.

Member

public class Member {

    private String name;

    private int age;

    private Member() {
    }

    private Member(final String name, final int age) {
        this.name = name;
        this.age = age;
    }

    private void helloWorld(final String name) {
        System.out.println("hello world");
        System.out.println(name);
    }
}

리플렉션을 이용하여 객체 생성

@Test
@DisplayName("리플렉션을 이용하여 객체를 생성")
void test1() throws Exception {
    // given
    Class<?> clazz = Class.forName("com.example.javatest.reflection.Member");

    Constructor<?> constructor1 = clazz.getDeclaredConstructor();
    Constructor<?> constructor2 = clazz.getDeclaredConstructor(String.class, int.class);
    constructor1.setAccessible(true);
    constructor2.setAccessible(true);

    // when
    Object o1 = constructor1.newInstance();
    Object o2 = constructor2.newInstance("hello", 20);

    // then
    assertThat(o1).isNotNull();
    assertThat(o2).isNotNull();
}

Class.forName()으로 클래스 정보를 가져옵니다. 그리고 생성자 2개를 이용해 객체를 생성하였습니다.

이때 주목할 점은 모두 private 생성자이지만 setAccessible(true)를 이용해 객체를 생성할 수 있다는 것입니다.

리플렉션을 이용하여 필드 정보 가져오기

@Test
@DisplayName("리플렉션을 이용하여 필드 정보를 가져옵니다")
void test2() throws Exception {
    // given
    Class<?> clazz = Class.forName("com.example.javatest.reflection.Member");
    Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
    constructor.setAccessible(true);

    // when
    Field[] fields = clazz.getDeclaredFields();

    // then
    for (Field field : fields) {
        System.out.println(field);
    }
}

접근 제어자, 리턴 타입, 패키지 및 필드 이름 정보를 가져올 수 있습니다.

리플렉션을 이용하여 필드의 값 변경하기 

@Test
@DisplayName("리플렉션을 이용하여 private 필드의 값을 변경할 수 있습니다")
void test3() throws Exception {
    // given
    Class<?> clazz = Class.forName("com.example.javatest.reflection.Member");
    Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
    constructor.setAccessible(true);
    Member member = (Member) constructor.newInstance("hello name", 20);

    // when
    Field field = clazz.getDeclaredField("age");
    field.setAccessible(true);
    field.set(member, 30);

    // then
    assertThat(member.getAge()).isEqualTo(30);
}

age는 private 필드이지만 field.setAccessible(true) 설정으로 값을 변경할 수 있습니다. 

리플렉션을 이용하여 메소드 정보 가져오기

@Test
@DisplayName("리플렉션을 이용하여 메소드 정보를 가져올 수 있습니다")
void test4() throws Exception{
    // given
    Class<?> clazz = Class.forName("com.example.javatest.reflection.Member");

    // when
    Method[] methods = clazz.getDeclaredMethods();

    // then
    for (Method method : methods) {
        System.out.println(method);
    }
}

접근 제어자, 리턴 타입, 패키지 및 메소드 이름, 파라미터 정보를 가져올 수 있습니다.

리플렉션을 이용하여 메소드 호출 하기

@Test
@DisplayName("리플렉션을 이용하여 메소드를 호출할 수 있습니다")
void test5() throws Exception{
    // given
    Class<?> clazz = Class.forName("com.example.javatest.reflection.Member");
    Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
    constructor.setAccessible(true);
    Object member = constructor.newInstance("hello name", 20);

    // when
    Method method = clazz.getDeclaredMethod("helloWorld", String.class);
    method.setAccessible(true);

    // then
    method.invoke(member, "name");
}

private 메소드도 method.setAccessible(true) 설정으로 호출할 수 있습니다.

 

이렇게 Java Reflection을 이용해서 JVM의 메모리에 저장된 클래스 정보를 꺼내와서 생성자로 생성을 하고

필드 정보를 수정하고 메소드를 호출할 수 있습니다.

중요한 것은 이 모든 동작이 컴파일 시점이 아니라 구체적인 클래스 타입을 알지 못하는 런타임 시점이라는 것입니다.

스프링 DI의 원리

그렇다면 지금까지 자주 사용하고 익숙한 스프링의 의존 관계 주입의 원리에 대해 알아보겠습니다.

위에서 설명한 것을 토대로 생각해보면 컴파일 시점에 의존 관계를 알고 있는 것이 아니라 런타임 시점에 의존 관계를 주입해줍니다.

 

간단한 DI 프레임워크를 만들어보겠습니다.

MyComponent, MyAutowired 어노테이션을 만들어줍니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyComponent {
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAutowired {
}

MemberRepository, MemberService을 @MyComponent를 붙여주고 @MyAutowired를 이용하여 

MemberService에 MemberRepository를 주입시켜 주겠습니다.

@MyComponent
public class MemberRepository {
}
@MyComponent
public class MemberService {

    @MyAutowired
    private MemberRepository memberRepository;

    public void printAutowiredInfo() {
        System.out.println(memberRepository);
    }
}

이제 Java Reflection을 이용해 런타임 시점에 의존 관계를 주입시켜보겠습니다.

public class MyDIContainer {

    public static <T> T getMySpringBean(Class<T> clazz) throws Exception{
        T mySpringBean = clazz.getConstructor().newInstance();
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            if (clazz.getAnnotation(MyComponent.class) != null &&
                    field.getAnnotation(MyAutowired.class) != null) {
                Object fieldInstance = field.getType().getConstructor().newInstance();
                field.setAccessible(true);
                field.set(mySpringBean, fieldInstance);
            }
        }
        return mySpringBean;
    }

    public static void main(String[] args) throws Exception {
        MemberService mySpringBean = getMySpringBean(MemberService.class);
        System.out.println(mySpringBean);
        mySpringBean.printAutowiredInfo();
    }
}

getMySpringBean() 메소드를 살펴보겠습니다. 먼저 매개변수로 들어온 클래스(A)를 기본 생성자로 생성합니다.

그 다음으로 필드에 @MyAutowired가 있고 그 필드가 @MyComponent가 붙어 있다면 (여기서는 MemberRepository)

객체(B)를 생성하고 클래스 A에 주입해줍니다.

위와 같이 MemberService, MemberRepository가 생성되고 MemberService에 주입된 것을 확인할 수 있습니다.

 

이때 MemberRepository에 @MyAutowired 어노테이션을 제거해보겠습니다.

그 결과 MemberService만 생성되고 MemberRepository에는 주입되지 않은 것을 확인할 수 있습니다.

 

Java Reflection으로 런타임 시점에 의존 관계를 주입해주는 간단한 DI 프레임워크를 만들어보았습니다.

물론 실제 스프링 프레임워크는 생성자 주입, 컴포넌트 스캔, 싱글톤 등 훨씬 많은 기능을 지원합니다.

기본 생성자 

Java Reflection은 스프링 DI 이외에도 여러 곳에서 사용됩니다. JPA를 사용하면 Entity에 기본 생성자를 만들어줘야 합니다.

또한 Jackson 라이브러리를 이용해서 Json 데이터로부터 객체를 생성하는 경우에도 기본 생성자를 만들어줘야 합니다.

여기에도 Java Reflection이 사용되는데 기본 생성자를 통해서 우선 객체를 생성한 후에 필요한 필드들을 이후에 주입해줍니다.

 

참고로 JPA Entity의 경우에는 protected 이상의 기본 생성자를 만들어줘야 하는데 이는 프록시, Lazy Loading이라는 개념이

추가적으로 들어가기 때문입니다. 기본적으로 엔티티를 생성하는 경우에는 Java Reflection으로 private 생성자로도 객체를

생성할 수 있습니다. 

Reflection의 단점

하지만 Reflection은 컴파일 시점이 아닌 런타임 시점에 클래스를 분석하여 JVM을 최적화 할 수 없어 성능 저하를 유발합니다.

또한 컴파일 시점에 타입 체크를 하지 못하고 런타임 시점에 가서야 타입 체크를 할 수 있다는 단점이 있으며

위에서 보았던 코드 예시처럼 리플렉션의 코드는 가독성이 떨어집니다.

그리고 Java Reflection은 접근 제어자를 private으로 설정 하여도 접근을 할 수 있기 때문에 캡슐화를 깨뜨릴 수 있습니다. 

정리

Java Reflection는 구체적인 클래스 타입을 알지 못하더라도 그 클래스의 생성자, 필드, 메소드에 접근할 수 있는 API이며

이를 통해 컴파일 타임이 아닌 실행 시간에 동적으로 객체 생성, 필드 수정, 메소드 호출 등을 할 수 있다고 했습니다.

이를 통해 IoC의 일종인 DI의 원리에 대해 이해할 수 있었고 지금까지 당연한 것처럼 받아들인 JPA 사용시 Entity의 기본생성자,

Jackson 라이브러리를 사용하여 Json 데이터로부터 객체를 생성하기 위해 기본 생성자가 필요한 이유에 대해서

이해할 수 있었습니다.

 

Java Reflection 덕분에 유연한 프로그래밍이 가능해졌지만 성능 저하, 컴파일 시점 타입 체크 불가능, 가독성 저하, 캡슐화 깨뜨림이라는 단점이 있기 때문에 꼭 필요한 경우에만 주의해서 사용해야 합니다.

 

참고한 자료

 

[Java] 리플렉션 (Reflection)이란 무엇일까? (개념/ 예시)

서론 이번 포스팅에서 다룰 내용은 '리플렉션'이다. 최근 "리플렉션이 무엇인가요?" 라는 질문을 받았는데, 제대로 된 답변을 못한 것 같다. C# 개발을 할 때 분명 사용은 해보았지만 개념적으로

jeongkyun-it.tistory.com

 

리플렉션: 스프링의 DI는 어떻게 동작하는걸까?

이제까지 자바와 스프링으로 개발을 해왔지만, 한번도 의존성 주입이 어떻게 이루어지는지 궁금해하지 않고 당연한 것처럼 써왔다.이번 기회를 통해, 스프링 내부 동작 방식에 대해 공부해보려

velog.io