실무에서 대규모 레거시 시스템을 걷어내거나 새로운 아키텍처를 설계하다 보면, 많은 개발자가 프레임워크의 동작 방식을 마법처럼 여긴다는 것을 알게 된다.

 

코드를 작성하다가 객체 주입이 안 되면 습관적으로 @Autowired를 붙이고, 트랜잭션이 롤백되지 않으면 무지성으로 @Transsactional을 추가한다. 하지만 정작 이 어노테이션들이 내부적으로 어떻게 동작하는지 명확하게 대답할 수 있는 사람은 드물다.

 

이번 포스팅부터 당분간 프레임워크가 감춰둔 블랙박스를 열어, 순수 자바로 그 핵심을 직접 구현해 볼 것이다.

 

1. 어노테이션은 그저 포스트잇일 뿐이다

스프링을 처음 접할 때 가장 큰 착각은 "어노테이션 자체에 특별한 실행 로직이 들어있다"고 믿는 것이다. 결론부터 말하자면 어노테이션은 아무런 동작을 하지 않는다. 어노테이션(Annotation)의 사전적 의미는 주석이다. 컴파일러나 런타임 환경에게 "이 클래스는 이런 특성이 있어", "이 메서드는 이렇게 처리해 줘"라고 알려주는 메타데이터(Metadata), 즉 포스트잇에 불과하다. 포스트잇을 모니터에 붙였다고 해서 모니터가 저절로 켜지지 않듯, 클래스 위에 @Service를 붙였다고 해서 자바가 알아서 그 클래스를 특별하게 대우하지 않는다. 누군가는 반드시 그 포스트잇을 읽고, 내용에 따라 행동해야 한다.

 

2. 리플랙션(Reflection): 포스트잇을 읽어내는 돋보기

그렇다면 그 포스트잇을 읽어내는 주체는 누구일까? 바로 리플렉션 API(Reflection API)다. 

 

자바는 컴파일 시점에 타입이 결정되는 정적 언어다. 하지만 리플렉션을 사용하면 구체적인 클래스 타입을 컴파일 타임에 알지 못해도, 런타임에 클래스의 정보(이름, 메섣, 필드, 어노테이션 등)에 동적으로 접근하고 객체를 생성할 수 있다. 스프링 프레임워크는 시작될 때 지정된 패키지를 스캔하며 리플렉션을 십분 활용한다.

 

 a. Class.forName() 등을 통해 런타임에 클래스 정보를 가져온다.

 b. clazz.isAnnotationPresent(Service.class)로 @Service 포스트잇이 붙어있는지 확인한다.

 c. 붙어있다면 clazz.getDeclaredConstructor().netInstance()를 통해 메모리에 객체를 띄운다.

 

이것이 앞으로 만들 미니 DI 컨터이너가 수행하는 핵심 로직의 전부다. 마법이 아니라 자바의 기본 API를 극한으로 활용한 것이다.

 

3. [Bad vs Good Code] 런타임 성능을 고려한 리플렉션 스캐닝 아키텍처

리플렉션은 프레임워크를 지탱하는 강력한 기술이지만, 실무 비즈니스 로직에 무분별하게 도입하면 시스템 성능에 치명적인 병목을 유발한다. 클래스의 메타데이터를 확인하고 동적으로 객체를 생성하는 시스템으 설계한다고 가정해 보자.

 

[Bad Code] 매 요청마다 리플렉션을 수행하여 성능이 저하되는 구조

빠른 기능 구현에만 집중하다 보면, 특정 클래스의 메타데이터를 매 트래픽(Request)마다 동적으로 읽어오도록 코드를 작성하는 실수를 범하게 된다.

 

public void handleRequest() throws Exception {
    // Command: 클라이언트의 요청이 들어올 때마다 클래스를 로드하고 검사
    Class<?> clazz = Class.forName("com.seonsit.service.UserService");
    
    if (clazz.isAnnotationPresent(MyComponent.class)) {
        Object instance = clazz.getDeclaredConstructor().newInstance();
        // 비즈니스 로직 수행...
    }
}

 

문제점

리플렉션은 컴파일 타임의 최적화를 무시하고 런타임에 동적으로 타입을 분석하고 접근 제어를 검사한다. 트래픽이 몰리는 시간대에 매 요청마다 Class.forName()과 newInstance()가 실행된다면, 일반적인 객체 생성보다 훨씬 느린 처리 속도 때문에 CPU 사용률이 폭증하고 시스템이 마비된다.

 

결과

기능은 동작하지만, 트래픽을 조금만 받아도 시스템 성능이 기하급수적으로 떨어지는 전형적인 안티 패턴이 된다.

 

[Good Code] 애플리케이션 초기화 시점에 한 번만 스캔하여 캐싱하는 구조

프레임워크는 이러한 리플렉션의 성능 한계를 극복하기 위해, 서버가 기동되는 초기화(Init) 단계에서 메타데이터 스캔의 책임을 몰아서 처리하고 결과를 메모리에 캐싱한다.

 

public class ApplicationContext {
    // Read 전용 저장소 (캐시)
    private final Map<String, Object> beanCache = new ConcurrentHashMap<>();

    // 시스템 초기화 시 1회만 실행됨
    public void init() throws Exception {
        Class<?> clazz = Class.forName("com.seonsit.service.UserService");
        
        if (clazz.isAnnotationPresent(MyComponent.class)) {
            // 무거운 리플렉션 작업은 초기화 시점에 끝내고 캐시에 저장
            beanCache.put("userService", clazz.getDeclaredConstructor().newInstance());
        }
    }
    
    public Object getBean(String beanName) {
        // 실제 요청이 들어올 때는 리플렉션 없이 캐시된 객체만 즉시 반환 (O(1) 성능)
        return beanCache.get(beanName);
    }
}

 

결과

무거운 리플렉션 수행 책임(초기화)과 런타임 객체 반환 책임(조회)이 완벽하게 분리되었다. 실제 트래픽이 인입될 때는 리플렉션 연산이 전혀 발생하지 않으므로 압도적인 성능을 유지할 수 있다.

 

4. 실무 도입 시 주의점

원리를 알았다고 해서 실무 비즈니스 코드 내부에 리플렉션을 직접 사용하는 것은 극도로 지양해야 한다.

 

 - 캡슐화파괴 : setAccessible(true)와 같은 메서드를 사용하면 private으로 닫아둔 필드나 메서드에도 강제로 접근할 수 있다. 이는 객체 지향의 핵심인 정보 은닉을 무너뜨린다.

 

 - 런타임 에러의 공포 : 컴파일러가 타입 체킹을 할 수 없으므로, 오타가 나거나 클래스 구조가 변경되었을 때 런타임 시점에 가서야 ClassNotFoundException이나 NoSuchMethodException 등의 에러가 발생한다. 즉, 장애 추적이 매우 어려워진다.

 

5. 결론

스프링 프로젝트를 띄울 때 몇 초 이상의 시간이 걸리는 이유가 바로 여기에 있다. 스프링은 런타임 트래픽 처리 속도를 극한으로 끌어올리기 위해, 서버 기동 시간을 희생하더라도 한 번에 모든 클래스패스를 뒤져 Bean)을 스캔하고 싱글톤 레지스트리에 올려두는 엔지니어링적 결단을 내린 것이다.

 

도메인 로직 안에서 리플렉션 사용을 피하고, 왜 프레임워크가 초기화 시점에 무거운 작업을 몰아서 하는지 이해하는 것만으로도 시스템 아키텍처를 바라보는 시야가 달라진다.

 

다음편에서는 자바가 도대체 어떻게 디스크에 있는 .class 파일을 메모리로 끌어올려 리플렉션의 먹잇감으로 만드는지, JVM 클래스로더(ClassLoader)의 동작방식에 대해 깊이 파헤쳐 본다.

+ Recent posts