안녕하세요😎 백엔드 개발자 제임스입니다 :)
서론
어제 '백기선 개발자님'의 Youtube 라이브 방송을 보게 되었습니다. 방송은 신입 또는 취업을 준비하는 개발자들의 프로젝트를 '리팩토링' 하는 내용이었습니다. 많은 프로젝트들이 방송에 나왔는데요. 출연한 프로젝트들의 공통점은 대부분 클래스 순환 참조 문제에 대한 지적을 받은 것입니다. 초보 개발자들이 대부분 빈(Bean)들 간의 의존성을 무시해서 발생하게 된 문제였습니다. 만약 순환 참조를 고려하지 않는다면, 추후 서비스 런칭 후 문제가 발생할 수 있다고 합니다. 저 또한 순환 참조란 용어가 굉장히 생소했습니다. 최근에 백엔드 개발자들과 협업하며, 많은 도메인을 개발하고 있기 때문에, 순환참조 문제에 대해서 제대로 알아야 할 필요를 느꼈습니다.
추가로 우리 백엔드 팀이 설계 시 클래스 다이어그램을 문서화해야 할 큰 이유입니다.
순환 참조(Circular Reference)
1) 순환 참조 문제란?
먼저 순환 참조는 여러 개의 빈들이 서로 연결되어 의존하게 되는 경우를 말합니다. 이때 빈 간의 관계를 양방향 관계라고 말합니다. 즉, A라는 클래스가 B 클래스의 Bean을 주입받고, B 클래스가 A클래스의 Bean을 주입받는 상황입니다. 이러한 상황에서 스프링은 어떠한 Bean부터 생성해야 할지 결정하지 못하게 됩니다.
기존 스프링의 의존성 주입방법은 이렇습니다. Bean A, Bean B, Bean C가 있을 때, A는 B를 의존하고, B는 C를 의존한다고 가정하겠습니다. 즉, 아래 그림과 같은 상황입니다.
Bean A를 생성할 때 Bean B의 주입이 필요합니다. 이어서 Bean B는 Bean C의 주입이 필요합니다. 따라서 스프링은 Bean C를 가장 먼저 생성하게 됩니다. 이어서 Bean C를 B에 주입해서 Bean B를 만들고, Bean B를 A클래스에 주입해서 Bean A를 만들 수 있게 됩니다. 이처럼 단방향 경우에는 어디서부터 어떠한 방향으로 Bean을 생성할지 결정할 수 있습니다.
다시 요약하자면, 순환 참조 문제는 클래스 또는 빈들 간의 양방향 의존으로 발생하는 문제입니다. 이렇게 양방향 설계 방식은 잘못된 설계라고 합니다.
2) 순환 참조 발생 이유
그렇다면 순환 참조는 왜 발생할까요? 저는 그 이유에 대해서 두 가지를 생각할 수 있었습니다. 첫 번째는 복잡하고, 규칙적이지 못한 클래스 구현 또는 메서드 선언 위치에 문제라고 생각합니다. 처음 순환 참조를 알아보게 된 계기로 초보 개발자들의 실수를 언급했습니다. 초보 개발자들은 대부분 클래스의 역할이 명확하지 않거나, 메서드의 선언 위치가 규칙적이지 않았습니다. 가령 Controller 클래스에 비즈니스 로직이 있다던지, 한 Service 클래스에 정체불명의 클래스들을 대량으로 주입시킵니다.
두 번째 이유는 주입 방식으로 인한 원인입니다. 의존관계 주입 방법은 다양하게 있습니다.
* 의존관계 주입 방법
1) 수정자(Setter) 주입
2) 필드 주입
3) 일반 메서드 주입
4) 생성자 주입
3) 순환 참조로 발생할 수 있는 문제
순환 참조는 개발할 당시에는 문제로 나타나지 않습니다. 물론 어떠한 경우에는 애플리케이션 로딩 시점에서 예외가 발생할 때도 있습니다.
이렇게 예외가 발생하지 않을 경우에는 본인 프로젝트가 순환 참조 중이란 것을 인지할 수 없습니다. 따라서 통합 테스트를 진행하거나 배포 이후에 문제가 발생합니다. 만약 의존하는 컴포넌트가 적거나, 의존관계를 알고 있다면 해결은 어렵지 않습니다. 반면, 대규모 프로젝트가 된다면 해결은 쉽지 않을 것입니다. 추후 유지보수뿐만 아니라, 서비스 확장 시에도 어려움을 겪을 수 있습니다. 의존성이 복잡하게 연결되었다면, 테스트 코드를 작성하기도 쉽지 않습니다.
순환 참조 문제 해결하기
엉클밥 아저씨(로버트 C 마틴, 클린 코드 저자)는 컴포넌트 간에 순환 참조가 있어서는 안 된다고 말하며 ADP (Acyclic Dependencies Principle)라는 한 가지 설계 원칙도 만들었습니다. 의존성 구조에 순환이 발생하는지 항상 관찰하며 순환이 발생한다면 어떤 식으로든 끊어야 한다고 강조합니다.
위 글처럼 항상 모든 클래스는 순환이 발생하지 않도록 관계를 최대한 끊어야 합니다. 의존이 필요한 경우에는 단방향으로 만들어야 합니다. 이 외에 방법도 아래에서 알아보도록 하겠습니다.
1) 의존 방향 변경하기
DIP에 따라서 의존관계를 역전시킵니다. 다시 말하면, 인터페이스를 통해 의존 방향을 우회(변경)시킵니다. 그림으로 나타내면 아래와 같습니다. 첫 번째 그림은 A 클래스와 B클래스가 순환 참조를 이룰 때, C라는 인터페이스로 해결한 것입니다. 그럼에도 A에서 B를 참조해야 하는 경우가 종종 있을 것입니다. 이를 해결한 것이 두 번째 그림처럼 하나의 클래스(D)를 추가로 만들어서 A와 B를 의존하는 것입니다.
2) 생성자 주입을 통해 의존관계 주입
스프링에서 권장하는 의존관계 주입 방법입니다.
@Service
public class UserService {
private UserRepository userRepository;
@Autowired // 생성자가 한 개일 경우 생략 가능
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
의존관계 주입은 애플리케이션이 시작할 때 이루어져야 하며, 종료할 때까지 변경이 되면 안 됩니다. 이 규칙을 지켜주는 것이 생성자 주입 방식입니다. 생성자 주입은 다른 주입 방식과는 다르게 생성 시점에 주입 관계를 맺습니다. 추가로 생성사 주입은 누락을 방지하고 빈을 불변하게 합니다. 바로 이러한 점 때문에 순환 참조를 방지할 수 있는 것입니다.
생성자 주입을 통해 의존관계를 맺는데, 순환 참조가 있다고 가정하겠습니다. 이 경우가 되면, 스프링 애플리케이션은 실행되지 않습니다. 그 이유는 컨테이너가 빈을 생성하는 시점에서 사이클 문제가 발생하기 때문입니다. 다시 말하면, 주입된 객체가 없어서 빈을 생성할 수가 없을뿐더러, 즉각적인 에러 발생으로 우리가 순환 참조되었다는 것을 인지할 수 있게 됩니다. (다른 의존관계 주입은 주입 시점이 다르기 때문에 순환 참조가 일어나는지 알 방법이 없습니다.) 이는 NullPointerException까지 방지할 수 있습니다.
3) 의존 시점 변경하기
스프링에서 권장하는 방법은 아닙니다.
해당 방법은 @Lazy 애너테이션을 활용하는 것입니다. @Lazy 애너테이션은 지연시키는 것으로, 빈이 필요한 시점에 생성하는 것입니다. 하지만 스프링에서는 권장하지 않는데요.
그 이유는 Bean 생성을 지연시키면서, 해당 빈으로 발생할 수 있는 문제 또한 늦게 발견되기 때문입니다. 두 번째는 JVM 메모리에 드는 비용이 크기 때문입니다.
4) 클래스의 역할과 메서드 선언 위치 확실하게 하기 (가장 좋은 방법)
처음부터 클래스(패키지) 구조 자체를 순환 참조가 발생하지 않도록 설계합니다. 사실 이 방법이 가장 중요한 해결 방법입니다. 여기서 포인트는 클래스의 역할이 명확해야 한다는 것입니다. 그렇다고 너무 세분화를 하라는 것은 아닙니다. 클래스의 역할이 명확해진다면, 메서드 또한 클래스에 맞게 위치하게 될 것입니다.
추가로 조사하다 보니, 꼭 클래스 간 참조한다고 모두 순환 참조라 하는 것은 아니라고 합니다. 즉, 하나의 패키지 안에서는 클래스 간 순환 참조를 허용한다는 내용입니다. 이 말은 패키지 구조와 계층구조를 잘 나눈다면 문제가 되지 않는다는 것 같은데. 이 부분은 더 자세하게 알아볼 필요가 있습니다.
마지막으로
'설계란 코드를 어디에 위치시킬 것인가에 대한 의사 결정이다. 이에 대한 결정은 변경에 초점을 맞추는 것이며 변경되는 코드들을 함께 넣어야 한다. 변경에 핵심은 의존성이다.'
조영호 님이 '우아한 객체지향' 세미나에서 언급했던 말입니다. 평소에 기획과 설계에 중요성을 크게 느끼고 있어서 와닿는 말입니다.
앞으로 프로젝트를 진행할 때, 패키지와 클래스 간의 계층구조를 명확하게 알 필요도 느꼈습니다. 또한 설계 시 사전에 순환 참조에 대해서 정확하게 인지하고 있으면, 문제를 줄일 수 있음을 알았습니다.
'개발 > Spring&JPA' 카테고리의 다른 글
[Spring MVC] DTO 분석, DTO를 Service 계층에서 처리해도 될까? (2) | 2022.11.01 |
---|---|
[JPA] Spring Data JPA의 DB 초기화 (2) | 2022.10.02 |
[JPA] JPA에서 Spring Data의 Audit 기능 적용하기 (2) | 2022.09.07 |
[보안/인증] CSRF 로 인해서, 403에러가 발생했을 때 (6) | 2022.08.20 |
[스프링 코어] 스프링을 알기 전에! 프레임워크(Framework)는 무엇일까요? (2) | 2022.06.15 |