본문 바로가기
JAVA/오류기록

Spring boot 순환 참조 회피하기(구조 파악, 중재자 패턴, 지연 초기화)

by 진짠 2025. 1. 25.
728x90
순환 참조

 

사이드 프로젝트를 진행하며 순환 참조 오류가 발생하였습니다.

 

 

런타임에 로그가 정말 친절하게 어떤 부분에서 순환 참조 오류가 발생하였는지 설명해주었습니다. 순환 참조는 paragraphExtractorHandler 클래스가 controlAdditionalTextExtractor 클래스를 의존하는데 controlAdditionalTextExtractor 클래스도 paragraphExtractorHandler 클래스를 의존하고 있을때, 다시말해 두 클래스가 서로를 의존하고 있을 때 발생합니다.

 

프로젝트를 실행하면 스프링 컨테이너는 어노테이션으로 판단하여 빈을 생성합니다. 그리고 빈을 생성할 때 참조하는 클래스를 판단하여 의존성을 주입합니다. 이 때, 의존성을 주입하는 방법은 생성자 주입, setter 주입, 필드 주입, 일반 메서드 주입 총 4개가 존재하는데요. 방법은 달라도 빈을 생성하는 단계에서 의존성을 주입한 후 의존하는 메서드를 사용한다는 목적은 같습니다.

 

위 경우, 스프링 컨테이너가 paragraphExtractorHandler 클래스의 빈을 생성합니다. 그런데 해당 클래스가 controlAdditionalTextExtractor 를 의존하고 있습니다. 스프링 컨테이너는 의존성 주입을 위해 controlAdditionalTextExtractor 클래스의 빈도 생성합니다. 그런데 controlAdditionalTextExtractor 클래스의 빈은 paragraphExtractorHandler을 의존하고 있습니다. 스프링 컨테이너는 두 클래스 모두 빈을 생성할 수 없는 상태가 됩니다.

 

그럼 이제 제가 이 순환 참조를 끊기 위해 어떤 노력을 했는지 간단하게 소개해 드리고자 합니다.

 

구조 점검

 

제일 좋은 것은 전체적인 클래스 설계 구조를 파악하고 좋은 솔루션을 찾아내 순환 구조를 끊어내는 것입니다. 원초적으로 서로를 의존하는 상황을 만들지 않는 것이 가장 좋습니다. 그래서 제가 설계한 클래스 구조, 해당 클래스에서 사용하는 라이브러리의 클래스 구조를 파악했습니다.

 

클래스명은 약간 간소화했습니다.

 

제가 설계한 클래스 구조는 다음과 같습니다. 한글 파일의 텍스트를 추출하는 기능인데요, hwpFileService가 Handler 클래스를 통해 어떤 하위 클래스를 구현체로 가질 것인지 선택합니다. Text라면 그냥 추출하면 되고 Control이면 추가 작업이 필요하기 때문입니다.

 

ControlExtractor 는 추상 클래스로 되어 있으며 extends를 통해 하위 컨트롤들을 정의하고 있습니다.

 

 

제가 사용중인 라이브러리 구조는 다음과 같습니다.

 

 

막상 그리고 나니 이해가 어려울 것 같습니다(...)

문단(Paragraph)을 기준으로 text와 control list로 나뉘는데요. Text는 아까 말씀드린 대로 그냥 추출 메서드를 사용하면 되지만 Control의 경우는 얘기가 다릅니다.추상 클래스인 이 녀석은 수많은 구체 클래스를 거느리고 다니고 있었습니다. 문제는 control 하위로 n개의 control이 계속해서 존재가 가능하다는 것이었습니다.

 

때문에 control 분류 작업을 하는 handler 클래스와 control 구현 클래스 간의 순환 참조를 끊을 방법이 없었습니다. 구현 클래스에서 하위 control이 존재한다면 다시 handler 클래스를 통해 어떤 control인지 확인해야 하니까요. 그래서 다른 방법을 찾아보자고 생각했습니다.

 

중재자(Mediator) 패턴 적용

 

순환 참조를 끊어내는 방법으로 중재자 패턴을 사용할 수 있습니다. 서로 의존하는 두 객체 대상의 사이에 중재자 객체를 둠으로써 객체 간의 직접적인 통신은 피하고 중재자 객체를 통해 통신하는 패턴입니다.

 

애초에 저는 전략 패턴을 사용하여 처음에 구조를 잡았었는데 이 방법을 적용하면 클래스의 구조 자체를 변경해야 하고 그 비용이 뒤에 소개할 방법보다 훨씬 크다고 생각했습니다. 물론 구조를 바꾸며 얻을 수 있는 메리트를 발견한다면 바꿔야겠지만 지금은 그럴 필요성을 느끼지 못했습니다.

 

(혹시나 바꾸게 된다면 따로 포스팅을...)

 

지연 초기화(Lazy Initialization) 적용

 

말그대로 스프링 빈 생성을 프록시를 통해 지연시켜주는 방식입니다. 이 방법을 사용하면 실제 애플리케이션이 시작될 때 초기화를 하는 것이 아닌, 해당 빈이 실제로 필요할 때 까지 지연시킬 수 있습니다.

 

다음과 같이 서로가 서로를 의존하는 경우, 지연 초기화를 이용하면 빈을 초기화할 때 프록시 객체로 저장합니다. 프록시 객체는 대리자 역할이 되어 해당 빈이 필요하기 전까지 그 역할을 수행합니다. 

 

프록시 객체에 대해 간단히 설명드리면, 실제 객체에 접근할때 중간에 가로채어 필요한 작업을 수행하기 위해 만들어진 객체입니다. 스프링의 AOP나 JPA에서도 데이터 지연 조회 시 사용하고 있습니다. 

 

프록시 객체를 사용하면 의존성을 주입할 때 실제 클래스가 아닌 프록시를 저장하기 때문에 순환 구조를 끊어낼 수 있습니다. 빈을 초기화할때 의존하는 객체의 기능을 전혀 사용하지 않기 때문입니다.

 

@RequiredArgsConstructor
public abstract class ParagraphControlExtractor implements ParagraphExtractor<HWPChar> {
    private final ParagraphExtractorHandler paragraphExtractorHandler;
...

 

다음과 같이 생성자 주입을 사용하는 방법에서,

 

@NoArgsConstructor
public abstract class ParagraphControlExtractor implements ParagraphExtractor<HWPChar> {
    private ParagraphExtractorHandler paragraphExtractorHandler;

    @Autowired
    public void setParagraphExtractorHandler(@Lazy ParagraphExtractorHandler paragraphExtractorHandler) {
        this.paragraphExtractorHandler = paragraphExtractorHandler;
    }
...

 

@Lazy 어노테이션을 사용한 setter 주입으로 변경함으로써 지연 초기화를 사용할 수 있습니다. (생성자 주입은 빈을 초기화하는 시점에서 의존성을 주입하기 때문에 프록시 객체와는 사용할 수 없어 setter 주입으로 바꿔주었습니다.)

 

정말 간편하게 순환 구조를 끊었습니다. 하지만 이 방법에는 몇가지 주의사항이 있습니다. 

 

먼저 setter 주입은 스프링이 빈을 생성한 후 의존성을 주입합니다. 이때, 지연 초기화된 빈은 실제로 필요할때까지 초기화되지 않습니다. 다른 빈이 해당 빈을 필요로 하는 시점에 초기화가 수행되기 때문에, 다른 부분에서 초기화가 되지 않은 빈을 의존하게 될 경우 NullPointerException이 발생할 수 있습니다.

 

ParagraphControlExtractor가 의존하는 ParagraphExtractorHandler 클래스는 현재 지연 초기화를 사용중인데요. 이 때는 실제로 빈이 초기화 되지 않았다는 의미입니다. 근데 이 상황에서 ParagraphExtractorHandler 의 메서드를 사용할 경우 문제가 발생합니다. 실제 사용 전에는 지연 초기화 된 빈을 초기화 시켜주어야 합니다.

 

하지만 스프링은 스프링 컨테이너가 빈을 관리하기 때문에 위 상황이 발생할 가능성은 적습니다.(스프링 만세) 실제로 빈을 사용할때 컨테이너가 초기화 기능을 수행하기 때문입니다. 대신 이를 위해서는 꼭 스프링이 제공하는 의존성 주입을 사용하여야만 합니다.

 

728x90

댓글